Unnamed repository; edit this file 'description' to name the repository.
add snippet system to helix core
Pascal Kuthe 2024-12-18
parent db95927 · commit c8c0d04
-rw-r--r--Cargo.lock3
-rw-r--r--helix-core/Cargo.toml3
-rw-r--r--helix-core/src/case_conversion.rs69
-rw-r--r--helix-core/src/indent.rs34
-rw-r--r--helix-core/src/lib.rs2
-rw-r--r--helix-core/src/snippets.rs13
-rw-r--r--helix-core/src/snippets/active.rs255
-rw-r--r--helix-core/src/snippets/elaborate.rs378
-rw-r--r--helix-core/src/snippets/parser.rs922
-rw-r--r--helix-core/src/snippets/render.rs355
10 files changed, 2032 insertions, 2 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 954e2858..559e9eb8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1219,6 +1219,7 @@ name = "helix-core"
version = "24.7.0"
dependencies = [
"ahash",
+ "anyhow",
"arc-swap",
"bitflags",
"chrono",
@@ -1228,6 +1229,7 @@ dependencies = [
"globset",
"hashbrown",
"helix-loader",
+ "helix-parsec",
"helix-stdx",
"imara-diff",
"indoc",
@@ -1237,6 +1239,7 @@ dependencies = [
"parking_lot",
"quickcheck",
"regex",
+ "regex-cursor",
"ropey",
"serde",
"serde_json",
diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml
index c86fbea7..d245ec13 100644
--- a/helix-core/Cargo.toml
+++ b/helix-core/Cargo.toml
@@ -18,6 +18,7 @@ integration = []
[dependencies]
helix-stdx = { path = "../helix-stdx" }
helix-loader = { path = "../helix-loader" }
+helix-parsec = { path = "../helix-parsec" }
ropey = { version = "1.6.1", default-features = false, features = ["simd"] }
smallvec = "1.13"
@@ -42,6 +43,7 @@ dunce = "1.0"
url = "2.5.4"
log = "0.4"
+anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
@@ -58,6 +60,7 @@ textwrap = "0.16.1"
nucleo.workspace = true
parking_lot = "0.12"
globset = "0.4.15"
+regex-cursor = "0.1.4"
[dev-dependencies]
quickcheck = { version = "1", default-features = false }
diff --git a/helix-core/src/case_conversion.rs b/helix-core/src/case_conversion.rs
new file mode 100644
index 00000000..2054a2bb
--- /dev/null
+++ b/helix-core/src/case_conversion.rs
@@ -0,0 +1,69 @@
+use crate::Tendril;
+
+// todo: should this be grapheme aware?
+
+pub fn to_pascal_case(text: impl Iterator<Item = char>) -> Tendril {
+ let mut res = Tendril::new();
+ to_pascal_case_with(text, &mut res);
+ res
+}
+
+pub fn to_pascal_case_with(text: impl Iterator<Item = char>, buf: &mut Tendril) {
+ let mut at_word_start = true;
+ for c in text {
+ // we don't count _ as a word char here so case conversions work well
+ if !c.is_alphanumeric() {
+ at_word_start = true;
+ continue;
+ }
+ if at_word_start {
+ at_word_start = false;
+ buf.extend(c.to_uppercase());
+ } else {
+ buf.push(c)
+ }
+ }
+}
+
+pub fn to_upper_case_with(text: impl Iterator<Item = char>, buf: &mut Tendril) {
+ for c in text {
+ for c in c.to_uppercase() {
+ buf.push(c)
+ }
+ }
+}
+
+pub fn to_lower_case_with(text: impl Iterator<Item = char>, buf: &mut Tendril) {
+ for c in text {
+ for c in c.to_lowercase() {
+ buf.push(c)
+ }
+ }
+}
+
+pub fn to_camel_case(text: impl Iterator<Item = char>) -> Tendril {
+ let mut res = Tendril::new();
+ to_camel_case_with(text, &mut res);
+ res
+}
+pub fn to_camel_case_with(mut text: impl Iterator<Item = char>, buf: &mut Tendril) {
+ for c in &mut text {
+ if c.is_alphanumeric() {
+ buf.extend(c.to_lowercase())
+ }
+ }
+ let mut at_word_start = false;
+ for c in text {
+ // we don't count _ as a word char here so case conversions work well
+ if !c.is_alphanumeric() {
+ at_word_start = true;
+ continue;
+ }
+ if at_word_start {
+ at_word_start = false;
+ buf.extend(c.to_uppercase());
+ } else {
+ buf.push(c)
+ }
+ }
+}
diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs
index 3faae53e..108c18d0 100644
--- a/helix-core/src/indent.rs
+++ b/helix-core/src/indent.rs
@@ -1,4 +1,4 @@
-use std::{borrow::Cow, collections::HashMap};
+use std::{borrow::Cow, collections::HashMap, iter};
use helix_stdx::rope::RopeSliceExt;
use tree_sitter::{Query, QueryCursor, QueryPredicateArg};
@@ -8,7 +8,7 @@ use crate::{
graphemes::{grapheme_width, tab_width_at},
syntax::{IndentationHeuristic, LanguageConfiguration, RopeProvider, Syntax},
tree_sitter::Node,
- Position, Rope, RopeGraphemes, RopeSlice,
+ Position, Rope, RopeGraphemes, RopeSlice, Tendril,
};
/// Enum representing indentation style.
@@ -210,6 +210,36 @@ fn whitespace_with_same_width(text: RopeSlice) -> String {
s
}
+/// normalizes indentation to tabs/spaces based on user configuration
+/// This function does not change the actual indentation width, just the character
+/// composition.
+pub fn normalize_indentation(
+ prefix: RopeSlice<'_>,
+ line: RopeSlice<'_>,
+ dst: &mut Tendril,
+ indent_style: IndentStyle,
+ tab_width: usize,
+) -> usize {
+ #[allow(deprecated)]
+ let off = crate::visual_coords_at_pos(prefix, prefix.len_chars(), tab_width).col;
+ let mut len = 0;
+ let mut original_len = 0;
+ for ch in line.chars() {
+ match ch {
+ '\t' => len += tab_width_at(len + off, tab_width as u16),
+ ' ' => len += 1,
+ _ => break,
+ }
+ original_len += 1;
+ }
+ if indent_style == IndentStyle::Tabs {
+ dst.extend(iter::repeat('\t').take(len / tab_width));
+ len %= tab_width;
+ }
+ dst.extend(iter::repeat(' ').take(len));
+ original_len
+}
+
fn add_indent_level(
mut base_indent: String,
added_indent_level: isize,
diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs
index 413c2da7..2bf75f69 100644
--- a/helix-core/src/lib.rs
+++ b/helix-core/src/lib.rs
@@ -1,6 +1,7 @@
pub use encoding_rs as encoding;
pub mod auto_pairs;
+pub mod case_conversion;
pub mod chars;
pub mod comment;
pub mod completion;
@@ -22,6 +23,7 @@ mod position;
pub mod search;
pub mod selection;
pub mod shellwords;
+pub mod snippets;
pub mod surround;
pub mod syntax;
pub mod test;
diff --git a/helix-core/src/snippets.rs b/helix-core/src/snippets.rs
new file mode 100644
index 00000000..3dd3b9c3
--- /dev/null
+++ b/helix-core/src/snippets.rs
@@ -0,0 +1,13 @@
+mod active;
+mod elaborate;
+mod parser;
+mod render;
+
+#[derive(PartialEq, Eq, Hash, Debug, PartialOrd, Ord, Clone, Copy)]
+pub struct TabstopIdx(usize);
+pub const LAST_TABSTOP_IDX: TabstopIdx = TabstopIdx(usize::MAX);
+
+pub use active::ActiveSnippet;
+pub use elaborate::{Snippet, SnippetElement, Transform};
+pub use render::RenderedSnippet;
+pub use render::SnippetRenderCtx;
diff --git a/helix-core/src/snippets/active.rs b/helix-core/src/snippets/active.rs
new file mode 100644
index 00000000..c5c743cd
--- /dev/null
+++ b/helix-core/src/snippets/active.rs
@@ -0,0 +1,255 @@
+use std::ops::{Index, IndexMut};
+
+use hashbrown::HashSet;
+use helix_stdx::range::{is_exact_subset, is_subset};
+use helix_stdx::Range;
+use ropey::Rope;
+
+use crate::movement::Direction;
+use crate::snippets::render::{RenderedSnippet, Tabstop};
+use crate::snippets::TabstopIdx;
+use crate::{Assoc, ChangeSet, Selection, Transaction};
+
+pub struct ActiveSnippet {
+ ranges: Vec<Range>,
+ active_tabstops: HashSet<TabstopIdx>,
+ current_tabstop: TabstopIdx,
+ tabstops: Vec<Tabstop>,
+}
+
+impl Index<TabstopIdx> for ActiveSnippet {
+ type Output = Tabstop;
+ fn index(&self, index: TabstopIdx) -> &Tabstop {
+ &self.tabstops[index.0]
+ }
+}
+
+impl IndexMut<TabstopIdx> for ActiveSnippet {
+ fn index_mut(&mut self, index: TabstopIdx) -> &mut Tabstop {
+ &mut self.tabstops[index.0]
+ }
+}
+
+impl ActiveSnippet {
+ pub fn new(snippet: RenderedSnippet) -> Option<Self> {
+ let snippet = Self {
+ ranges: snippet.ranges,
+ tabstops: snippet.tabstops,
+ active_tabstops: HashSet::new(),
+ current_tabstop: TabstopIdx(0),
+ };
+ (snippet.tabstops.len() != 1).then_some(snippet)
+ }
+
+ pub fn is_valid(&self, new_selection: &Selection) -> bool {
+ is_subset::<false>(self.ranges.iter().copied(), new_selection.range_bounds())
+ }
+
+ pub fn tabstops(&self) -> impl Iterator<Item = &Tabstop> {
+ self.tabstops.iter()
+ }
+
+ pub fn delete_placeholder(&self, doc: &Rope) -> Transaction {
+ Transaction::delete(
+ doc,
+ self[self.current_tabstop]
+ .ranges
+ .iter()
+ .map(|range| (range.start, range.end)),
+ )
+ }
+
+ /// maps the active snippets through a `ChangeSet` updating all tabstop ranges
+ pub fn map(&mut self, changes: &ChangeSet) -> bool {
+ let positions_to_map = self.ranges.iter_mut().flat_map(|range| {
+ [
+ (&mut range.start, Assoc::After),
+ (&mut range.end, Assoc::Before),
+ ]
+ });
+ changes.update_positions(positions_to_map);
+
+ for (i, tabstop) in self.tabstops.iter_mut().enumerate() {
+ if self.active_tabstops.contains(&TabstopIdx(i)) {
+ let positions_to_map = tabstop.ranges.iter_mut().flat_map(|range| {
+ let end_assoc = if range.start == range.end {
+ Assoc::Before
+ } else {
+ Assoc::After
+ };
+ [
+ (&mut range.start, Assoc::Before),
+ (&mut range.end, end_assoc),
+ ]
+ });
+ changes.update_positions(positions_to_map);
+ } else {
+ let positions_to_map = tabstop.ranges.iter_mut().flat_map(|range| {
+ let end_assoc = if range.start == range.end {
+ Assoc::After
+ } else {
+ Assoc::Before
+ };
+ [
+ (&mut range.start, Assoc::After),
+ (&mut range.end, end_assoc),
+ ]
+ });
+ changes.update_positions(positions_to_map);
+ }
+ let mut snippet_ranges = self.ranges.iter();
+ let mut snippet_range = snippet_ranges.next().unwrap();
+ let mut tabstop_i = 0;
+ let mut prev = Range { start: 0, end: 0 };
+ let num_ranges = tabstop.ranges.len() / self.ranges.len();
+ tabstop.ranges.retain_mut(|range| {
+ if tabstop_i == num_ranges {
+ snippet_range = snippet_ranges.next().unwrap();
+ tabstop_i = 0;
+ }
+ tabstop_i += 1;
+ let retain = snippet_range.start <= snippet_range.end;
+ if retain {
+ range.start = range.start.max(snippet_range.start);
+ range.end = range.end.max(range.start).min(snippet_range.end);
+ // guaranteed by assoc
+ debug_assert!(prev.start <= range.start);
+ debug_assert!(range.start <= range.end);
+ if prev.end > range.start {
+ // not really sure what to do in this case. It shouldn't
+ // really occur in practice, the below just ensures
+ // our invariants hold
+ range.start = prev.end;
+ range.end = range.end.max(range.start)
+ }
+ prev = *range;
+ }
+ retain
+ });
+ }
+ self.ranges.iter().all(|range| range.end <= range.start)
+ }
+
+ pub fn next_tabstop(&mut self, current_selection: &Selection) -> (Selection, bool) {
+ let primary_idx = self.primary_idx(current_selection);
+ while self.current_tabstop.0 + 1 < self.tabstops.len() {
+ self.current_tabstop.0 += 1;
+ if self.activate_tabstop() {
+ let selection = self.tabstop_selection(primary_idx, Direction::Forward);
+ return (selection, self.current_tabstop.0 + 1 == self.tabstops.len());
+ }
+ }
+
+ (
+ self.tabstop_selection(primary_idx, Direction::Forward),
+ true,
+ )
+ }
+
+ pub fn prev_tabstop(&mut self, current_selection: &Selection) -> Option<Selection> {
+ let primary_idx = self.primary_idx(current_selection);
+ while self.current_tabstop.0 != 0 {
+ self.current_tabstop.0 -= 1;
+ if self.activate_tabstop() {
+ return Some(self.tabstop_selection(primary_idx, Direction::Forward));
+ }
+ }
+ None
+ }
+ // computes the primary idx adjusted for the number of cursors in the current tabstop
+ fn primary_idx(&self, current_selection: &Selection) -> usize {
+ let primary: Range = current_selection.primary().into();
+ let res = self
+ .ranges
+ .iter()
+ .position(|&range| range.contains(primary));
+ res.unwrap_or_else(|| {
+ unreachable!(
+ "active snippet must be valid {current_selection:?} {:?}",
+ self.ranges
+ )
+ })
+ }
+
+ fn activate_tabstop(&mut self) -> bool {
+ let tabstop = &self[self.current_tabstop];
+ if tabstop.has_placeholder() && tabstop.ranges.iter().all(|range| range.is_empty()) {
+ return false;
+ }
+ self.active_tabstops.clear();
+ self.active_tabstops.insert(self.current_tabstop);
+ let mut parent = self[self.current_tabstop].parent;
+ while let Some(tabstop) = parent {
+ self.active_tabstops.insert(tabstop);
+ parent = self[tabstop].parent;
+ }
+ true
+ // TODO: if the user removes the selection(s) in one snippet (but
+ // there are still other cursors in other snippets) and jumps to the
+ // next tabstop the selection in that tabstop is restored (at the
+ // next tabstop). This could be annoying since its not possible to
+ // remove a snippet cursor until the snippet is complete. On the other
+ // hand it may be useful since the user may just have meant to edit
+ // a subselection (like with s) of the tabstops and so the selection
+ // removal was just temporary. Potentially this could have some sort of
+ // separate keymap
+ }
+
+ pub fn tabstop_selection(&self, primary_idx: usize, direction: Direction) -> Selection {
+ let tabstop = &self[self.current_tabstop];
+ tabstop.selection(direction, primary_idx, self.ranges.len())
+ }
+
+ pub fn insert_subsnippet(mut self, snippet: RenderedSnippet) -> Option<Self> {
+ if snippet.ranges.len() % self.ranges.len() != 0
+ || !is_exact_subset(self.ranges.iter().copied(), snippet.ranges.iter().copied())
+ {
+ log::warn!("number of subsnippets did not match, discarding outer snippet");
+ return ActiveSnippet::new(snippet);
+ }
+ let mut cnt = 0;
+ let parent = self[self.current_tabstop].parent;
+ let tabstops = snippet.tabstops.into_iter().map(|mut tabstop| {
+ cnt += 1;
+ if let Some(parent) = &mut tabstop.parent {
+ parent.0 += self.current_tabstop.0;
+ } else {
+ tabstop.parent = parent;
+ }
+ tabstop
+ });
+ self.tabstops
+ .splice(self.current_tabstop.0..=self.current_tabstop.0, tabstops);
+ self.activate_tabstop();
+ Some(self)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::iter::{self};
+
+ use ropey::Rope;
+
+ use crate::snippets::{ActiveSnippet, Snippet, SnippetRenderCtx};
+ use crate::{Selection, Transaction};
+
+ #[test]
+ fn fully_remove() {
+ let snippet = Snippet::parse("foo(${1:bar})$0").unwrap();
+ let mut doc = Rope::from("bar.\n");
+ let (transaction, _, snippet) = snippet.render(
+ &doc,
+ &Selection::point(4),
+ |_| (4, 4),
+ &mut SnippetRenderCtx::test_ctx(),
+ );
+ assert!(transaction.apply(&mut doc));
+ assert_eq!(doc, "bar.foo(bar)\n");
+ let mut snippet = ActiveSnippet::new(snippet).unwrap();
+ let edit = Transaction::change(&doc, iter::once((4, 12, None)));
+ assert!(edit.apply(&mut doc));
+ snippet.map(edit.changes());
+ assert!(!snippet.is_valid(&Selection::point(4)))
+ }
+}
diff --git a/helix-core/src/snippets/elaborate.rs b/helix-core/src/snippets/elaborate.rs
new file mode 100644
index 00000000..0fb5fb7b
--- /dev/null
+++ b/helix-core/src/snippets/elaborate.rs
@@ -0,0 +1,378 @@
+use std::mem::swap;
+use std::ops::Index;
+use std::sync::Arc;
+
+use anyhow::{anyhow, Result};
+use helix_stdx::rope::RopeSliceExt;
+use helix_stdx::Range;
+use regex_cursor::engines::meta::Builder as RegexBuilder;
+use regex_cursor::engines::meta::Regex;
+use regex_cursor::regex_automata::util::syntax::Config as RegexConfig;
+use ropey::RopeSlice;
+
+use crate::case_conversion::to_lower_case_with;
+use crate::case_conversion::to_upper_case_with;
+use crate::case_conversion::{to_camel_case_with, to_pascal_case_with};
+use crate::snippets::parser::{self, CaseChange, FormatItem};
+use crate::snippets::{TabstopIdx, LAST_TABSTOP_IDX};
+use crate::Tendril;
+
+#[derive(Debug)]
+pub struct Snippet {
+ elements: Vec<SnippetElement>,
+ tabstops: Vec<Tabstop>,
+}
+
+impl Snippet {
+ pub fn parse(snippet: &str) -> Result<Self> {
+ let parsed_snippet = parser::parse(snippet)
+ .map_err(|rest| anyhow!("Failed to parse snippet. Remaining input: {}", rest))?;
+ Ok(Snippet::new(parsed_snippet))
+ }
+
+ pub fn new(elements: Vec<parser::SnippetElement>) -> Snippet {
+ let mut res = Snippet {
+ elements: Vec::new(),
+ tabstops: Vec::new(),
+ };
+ res.elements = res.elaborate(elements, None).into();
+ res.fixup_tabstops();
+ res.ensure_last_tabstop();
+ res.renumber_tabstops();
+ res
+ }
+
+ pub fn elements(&self) -> &[SnippetElement] {
+ &self.elements
+ }
+
+ pub fn tabstops(&self) -> impl Iterator<Item = &Tabstop> {
+ self.tabstops.iter()
+ }
+
+ fn renumber_tabstops(&mut self) {
+ Self::renumber_tabstops_in(&self.tabstops, &mut self.elements);
+ for i in 0..self.tabstops.len() {
+ if let Some(parent) = self.tabstops[i].parent {
+ let parent = self
+ .tabstops
+ .binary_search_by_key(&parent, |tabstop| tabstop.idx)
+ .expect("all tabstops have been resolved");
+ self.tabstops[i].parent = Some(TabstopIdx(parent));
+ }
+ let tabstop = &mut self.tabstops[i];
+ if let TabstopKind::Placeholder { default } = &tabstop.kind {
+ let mut default = default.clone();
+ tabstop.kind = TabstopKind::Empty;
+ Self::renumber_tabstops_in(&self.tabstops, Arc::get_mut(&mut default).unwrap());
+ self.tabstops[i].kind = TabstopKind::Placeholder { default };
+ }
+ }
+ }
+
+ fn renumber_tabstops_in(tabstops: &[Tabstop], elements: &mut [SnippetElement]) {
+ for elem in elements {
+ match elem {
+ SnippetElement::Tabstop { idx } => {
+ idx.0 = tabstops
+ .binary_search_by_key(&*idx, |tabstop| tabstop.idx)
+ .expect("all tabstops have been resolved")
+ }
+ SnippetElement::Variable { default, .. } => {
+ if let Some(default) = default {
+ Self::renumber_tabstops_in(tabstops, default);
+ }
+ }
+ SnippetElement::Text(_) => (),
+ }
+ }
+ }
+
+ fn fixup_tabstops(&mut self) {
+ self.tabstops.sort_by_key(|tabstop| tabstop.idx);
+ self.tabstops.dedup_by(|tabstop1, tabstop2| {
+ if tabstop1.idx != tabstop2.idx {
+ return false;
+ }
+ // use the first non empty tabstop for all multicursor tabstops
+ if tabstop2.kind.is_empty() {
+ swap(tabstop2, tabstop1)
+ }
+ true
+ })
+ }
+
+ fn ensure_last_tabstop(&mut self) {
+ if matches!(self.tabstops.last(), Some(tabstop) if tabstop.idx == LAST_TABSTOP_IDX) {
+ return;
+ }
+ self.tabstops.push(Tabstop {
+ idx: LAST_TABSTOP_IDX,
+ parent: None,
+ kind: TabstopKind::Empty,
+ });
+ self.elements.push(SnippetElement::Tabstop {
+ idx: LAST_TABSTOP_IDX,
+ })
+ }
+
+ fn elaborate(
+ &mut self,
+ default: Vec<parser::SnippetElement>,
+ parent: Option<TabstopIdx>,
+ ) -> Box<[SnippetElement]> {
+ default
+ .into_iter()
+ .map(|val| match val {
+ parser::SnippetElement::Tabstop {
+ tabstop,
+ transform: None,
+ } => SnippetElement::Tabstop {
+ idx: self.elaborate_placeholder(tabstop, parent, Vec::new()),
+ },
+ parser::SnippetElement::Tabstop {
+ tabstop,
+ transform: Some(transform),
+ } => SnippetElement::Tabstop {
+ idx: self.elaborate_transform(tabstop, parent, transform),
+ },
+ parser::SnippetElement::Placeholder { tabstop, value } => SnippetElement::Tabstop {
+ idx: self.elaborate_placeholder(tabstop, parent, value),
+ },
+ parser::SnippetElement::Choice { tabstop, choices } => SnippetElement::Tabstop {
+ idx: self.elaborate_choice(tabstop, parent, choices),
+ },
+ parser::SnippetElement::Variable {
+ name,
+ default,
+ transform,
+ } => SnippetElement::Variable {
+ name,
+ default: default.map(|default| self.elaborate(default, parent)),
+ // TODO: error for invalid transforms
+ transform: transform.and_then(Transform::new).map(Box::new),
+ },
+ parser::SnippetElement::Text(text) => SnippetElement::Text(text),
+ })
+ .collect()
+ }
+
+ fn elaborate_choice(
+ &mut self,
+ idx: usize,
+ parent: Option<TabstopIdx>,
+ choices: Vec<Tendril>,
+ ) -> TabstopIdx {
+ let idx = TabstopIdx::elaborate(idx);
+ self.tabstops.push(Tabstop {
+ idx,
+ parent,
+ kind: TabstopKind::Choice {
+ choices: choices.into(),
+ },
+ });
+ idx
+ }
+
+ fn elaborate_placeholder(
+ &mut self,
+ idx: usize,
+ parent: Option<TabstopIdx>,
+ default: Vec<parser::SnippetElement>,
+ ) -> TabstopIdx {
+ let idx = TabstopIdx::elaborate(idx);
+ let default = self.elaborate(default, Some(idx));
+ self.tabstops.push(Tabstop {
+ idx,
+ parent,
+ kind: TabstopKind::Placeholder {
+ default: default.into(),
+ },
+ });
+ idx
+ }
+
+ fn elaborate_transform(
+ &mut self,
+ idx: usize,
+ parent: Option<TabstopIdx>,
+ transform: parser::Transform,
+ ) -> TabstopIdx {
+ let idx = TabstopIdx::elaborate(idx);
+ if let Some(transform) = Transform::new(transform) {
+ self.tabstops.push(Tabstop {
+ idx,
+ parent,
+ kind: TabstopKind::Transform(Arc::new(transform)),
+ })
+ } else {
+ // TODO: proper error
+ self.tabstops.push(Tabstop {
+ idx,
+ parent,
+ kind: TabstopKind::Empty,
+ })
+ }
+ idx
+ }
+}
+
+impl Index<TabstopIdx> for Snippet {
+ type Output = Tabstop;
+ fn index(&self, index: TabstopIdx) -> &Tabstop {
+ &self.tabstops[index.0]
+ }
+}
+
+#[derive(Debug)]
+pub enum SnippetElement {
+ Tabstop {
+ idx: TabstopIdx,
+ },
+ Variable {
+ name: Tendril,
+ default: Option<Box<[SnippetElement]>>,
+ transform: Option<Box<Transform>>,
+ },
+ Text(Tendril),
+}
+
+#[derive(Debug)]
+pub struct Tabstop {
+ idx: TabstopIdx,
+ pub parent: Option<TabstopIdx>,
+ pub kind: TabstopKind,
+}
+
+#[derive(Debug)]
+pub enum TabstopKind {
+ Choice { choices: Arc<[Tendril]> },
+ Placeholder { default: Arc<[SnippetElement]> },
+ Empty,
+ Transform(Arc<Transform>),
+}
+
+impl TabstopKind {
+ pub fn is_empty(&self) -> bool {
+ matches!(self, TabstopKind::Empty)
+ }
+}
+
+#[derive(Debug)]
+pub struct Transform {
+ regex: Regex,
+ regex_str: Box<str>,
+ global: bool,
+ replacement: Box<[FormatItem]>,
+}
+
+impl PartialEq for Transform {
+ fn eq(&self, other: &Self) -> bool {
+ self.replacement == other.replacement
+ && self.global == other.global
+ // doens't compare m and i setting but close enough
+ && self.regex_str == other.regex_str
+ }
+}
+
+impl Transform {
+ fn new(transform: parser::Transform) -> Option<Transform> {
+ let mut config = RegexConfig::new();
+ let mut global = false;
+ let mut invalid_config = false;
+ for c in transform.options.chars() {
+ match c {
+ 'i' => {
+ config = config.case_insensitive(true);
+ }
+ 'm' => {
+ config = config.multi_line(true);
+ }
+ 'g' => {
+ global = true;
+ }
+ // we ignore 'u' since we always want to
+ // do unicode aware matching
+ _ => invalid_config = true,
+ }
+ }
+ if invalid_config {
+ log::error!("invalid transform configuration characters {transform:?}");
+ }
+ let regex = match RegexBuilder::new().syntax(config).build(&transform.regex) {
+ Ok(regex) => regex,
+ Err(err) => {
+ log::error!("invalid transform {err} {transform:?}");
+ return None;
+ }
+ };
+ Some(Transform {
+ regex,
+ regex_str: transform.regex.as_str().into(),
+ global,
+ replacement: transform.replacement.into(),
+ })
+ }
+
+ pub fn apply(&self, mut doc: RopeSlice<'_>, range: Range) -> Tendril {
+ let mut buf = Tendril::new();
+ let it = self
+ .regex
+ .captures_iter(doc.regex_input_at(range))
+ .enumerate();
+ doc = doc.slice(range);
+ let mut last_match = 0;
+ for (_, cap) in it {
+ // unwrap on 0 is OK because captures only reports matches
+ let m = cap.get_group(0).unwrap();
+ buf.extend(doc.byte_slice(last_match..m.start).chunks());
+ last_match = m.end;
+ for fmt in &*self.replacement {
+ match *fmt {
+ FormatItem::Text(ref text) => {
+ buf.push_str(text);
+ }
+ FormatItem::Capture(i) => {
+ if let Some(cap) = cap.get_group(i) {
+ buf.extend(doc.byte_slice(cap.range()).chunks());
+ }
+ }
+ FormatItem::CaseChange(i, change) => {
+ if let Some(cap) = cap.get_group(i).filter(|i| !i.is_empty()) {
+ let mut chars = doc.byte_slice(cap.range()).chars();
+ match change {
+ CaseChange::Upcase => to_upper_case_with(chars, &mut buf),
+ CaseChange::Downcase => to_lower_case_with(chars, &mut buf),
+ CaseChange::Capitalize => {
+ let first_char = chars.next().unwrap();
+ buf.extend(first_char.to_uppercase());
+ buf.extend(chars);
+ }
+ CaseChange::PascalCase => to_pascal_case_with(chars, &mut buf),
+ CaseChange::CamelCase => to_camel_case_with(chars, &mut buf),
+ }
+ }
+ }
+ FormatItem::Conditional(i, ref if_, ref else_) => {
+ if cap.get_group(i).map_or(true, |mat| mat.is_empty()) {
+ buf.push_str(else_)
+ } else {
+ buf.push_str(if_)
+ }
+ }
+ }
+ }
+ if !self.global {
+ break;
+ }
+ }
+ buf.extend(doc.byte_slice(last_match..).chunks());
+ buf
+ }
+}
+
+impl TabstopIdx {
+ fn elaborate(idx: usize) -> Self {
+ TabstopIdx(idx.wrapping_sub(1))
+ }
+}
diff --git a/helix-core/src/snippets/parser.rs b/helix-core/src/snippets/parser.rs
new file mode 100644
index 00000000..3d06e417
--- /dev/null
+++ b/helix-core/src/snippets/parser.rs
@@ -0,0 +1,922 @@
+/*!
+A parser for LSP/VSCode style snippet syntax
+See <https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#snippet_syntax>.
+
+``` text
+any ::= tabstop | placeholder | choice | variable | text
+tabstop ::= '$' int | '${' int '}'
+placeholder ::= '${' int ':' any '}'
+choice ::= '${' int '|' text (',' text)* '|}'
+variable ::= '$' var | '${' var }'
+ | '${' var ':' any '}'
+ | '${' var '/' regex '/' (format | text)+ '/' options '}'
+format ::= '$' int | '${' int '}'
+ | '${' int ':' '/upcase' | '/downcase' | '/capitalize' '}'
+ | '${' int ':+' if '}'
+ | '${' int ':?' if ':' else '}'
+ | '${' int ':-' else '}' | '${' int ':' else '}'
+regex ::= Regular Expression value (ctor-string)
+options ::= Regular Expression option (ctor-options)
+var ::= [_a-zA-Z] [_a-zA-Z0-9]*
+int ::= [0-9]+
+text ::= .*
+if ::= text
+else ::= text
+```
+*/
+
+use crate::Tendril;
+use helix_parsec::*;
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum CaseChange {
+ Upcase,
+ Downcase,
+ Capitalize,
+ PascalCase,
+ CamelCase,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum FormatItem {
+ Text(Tendril),
+ Capture(usize),
+ CaseChange(usize, CaseChange),
+ Conditional(usize, Tendril, Tendril),
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct Transform {
+ pub regex: Tendril,
+ pub replacement: Vec<FormatItem>,
+ pub options: Tendril,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum SnippetElement {
+ Tabstop {
+ tabstop: usize,
+ transform: Option<Transform>,
+ },
+ Placeholder {
+ tabstop: usize,
+ value: Vec<SnippetElement>,
+ },
+ Choice {
+ tabstop: usize,
+ choices: Vec<Tendril>,
+ },
+ Variable {
+ name: Tendril,
+ default: Option<Vec<SnippetElement>>,
+ transform: Option<Transform>,
+ },
+ Text(Tendril),
+}
+
+pub fn parse(s: &str) -> Result<Vec<SnippetElement>, &str> {
+ snippet().parse(s).and_then(|(remainder, snippet)| {
+ if remainder.is_empty() {
+ Ok(snippet)
+ } else {
+ Err(remainder)
+ }
+ })
+}
+
+fn var<'a>() -> impl Parser<'a, Output = &'a str> {
+ // var = [_a-zA-Z][_a-zA-Z0-9]*
+ move |input: &'a str| {
+ input
+ .char_indices()
+ .take_while(|(p, c)| {
+ *c == '_'
+ || if *p == 0 {
+ c.is_ascii_alphabetic()
+ } else {
+ c.is_ascii_alphanumeric()
+ }
+ })
+ .last()
+ .map(|(index, c)| {
+ let index = index + c.len_utf8();
+ (&input[index..], &input[0..index])
+ })
+ .ok_or(input)
+ }
+}
+
+const TEXT_ESCAPE_CHARS: &[char] = &['\\', '}', '$'];
+const CHOICE_TEXT_ESCAPE_CHARS: &[char] = &['\\', '|', ','];
+
+fn text<'a>(
+ escape_chars: &'static [char],
+ term_chars: &'static [char],
+) -> impl Parser<'a, Output = Tendril> {
+ move |input: &'a str| {
+ let mut chars = input.char_indices().peekable();
+ let mut res = Tendril::new();
+ while let Some((i, c)) = chars.next() {
+ match c {
+ '\\' => {
+ if let Some(&(_, c)) = chars.peek() {
+ if escape_chars.contains(&c) {
+ chars.next();
+ res.push(c);
+ continue;
+ }
+ }
+ res.push('\\');
+ }
+ c if term_chars.contains(&c) => return Ok((&input[i..], res)),
+ c => res.push(c),
+ }
+ }
+
+ Ok(("", res))
+ }
+}
+
+fn digit<'a>() -> impl Parser<'a, Output = usize> {
+ filter_map(take_while(|c| c.is_ascii_digit()), |s| s.parse().ok())
+}
+
+fn case_change<'a>() -> impl Parser<'a, Output = CaseChange> {
+ use CaseChange::*;
+
+ choice!(
+ map("upcase", |_| Upcase),
+ map("downcase", |_| Downcase),
+ map("capitalize", |_| Capitalize),
+ map("pascalcase", |_| PascalCase),
+ map("camelcase", |_| CamelCase),
+ )
+}
+
+fn format<'a>() -> impl Parser<'a, Output = FormatItem> {
+ use FormatItem::*;
+
+ choice!(
+ // '$' int
+ map(right("$", digit()), Capture),
+ // '${' int '}'
+ map(seq!("${", digit(), "}"), |seq| Capture(seq.1)),
+ // '${' int ':' '/upcase' | '/downcase' | '/capitalize' '}'
+ map(seq!("${", digit(), ":/", case_change(), "}"), |seq| {
+ CaseChange(seq.1, seq.3)
+ }),
+ // '${' int ':+' if '}'
+ map(
+ seq!("${", digit(), ":+", text(TEXT_ESCAPE_CHARS, &['}']), "}"),
+ |seq| { Conditional(seq.1, seq.3, Tendril::new()) }
+ ),
+ // '${' int ':?' if ':' else '}'
+ map(
+ seq!(
+ "${",
+ digit(),
+ ":?",
+ text(TEXT_ESCAPE_CHARS, &[':']),
+ ":",
+ text(TEXT_ESCAPE_CHARS, &['}']),
+ "}"
+ ),
+ |seq| { Conditional(seq.1, seq.3, seq.5) }
+ ),
+ // '${' int ':-' else '}' | '${' int ':' else '}'
+ map(
+ seq!(
+ "${",
+ digit(),
+ ":",
+ optional("-"),
+ text(TEXT_ESCAPE_CHARS, &['}']),
+ "}"
+ ),
+ |seq| { Conditional(seq.1, Tendril::new(), seq.4) }
+ ),
+ )
+}
+
+fn regex<'a>() -> impl Parser<'a, Output = Transform> {
+ map(
+ seq!(
+ "/",
+ // TODO parse as ECMAScript and convert to rust regex
+ text(&['/'], &['/']),
+ "/",
+ zero_or_more(choice!(
+ format(),
+ // text doesn't parse $, if format fails we just accept the $ as text
+ map("$", |_| FormatItem::Text("$".into())),
+ map(text(&['\\', '/'], &['/', '$']), FormatItem::Text),
+ )),
+ "/",
+ // vscode really doesn't allow escaping } here
+ // so it's impossible to write a regex escape containing a }
+ // we can consider deviating here and allowing the escape
+ text(&[], &['}']),
+ ),
+ |(_, value, _, replacement, _, options)| Transform {
+ regex: value,
+ replacement,
+ options,
+ },
+ )
+}
+
+fn tabstop<'a>() -> impl Parser<'a, Output = SnippetElement> {
+ map(
+ or(
+ map(right("$", digit()), |i| (i, None)),
+ map(
+ seq!("${", digit(), optional(regex()), "}"),
+ |(_, i, transform, _)| (i, transform),
+ ),
+ ),
+ |(tabstop, transform)| SnippetElement::Tabstop { tabstop, transform },
+ )
+}
+
+fn placeholder<'a>() -> impl Parser<'a, Output = SnippetElement> {
+ map(
+ seq!(
+ "${",
+ digit(),
+ ":",
+ // according to the grammar there is just a single anything here.
+ // However in the prose it is explained that placeholders can be nested.
+ // The example there contains both a placeholder text and a nested placeholder
+ // which indicates a list. Looking at the VSCode sourcecode, the placeholder
+ // is indeed parsed as zero_or_more so the grammar is simply incorrect here
+ zero_or_more(anything(TEXT_ESCAPE_CHARS, true)),
+ "}"
+ ),
+ |seq| SnippetElement::Placeholder {
+ tabstop: seq.1,
+ value: seq.3,
+ },
+ )
+}
+
+fn choice<'a>() -> impl Parser<'a, Output = SnippetElement> {
+ map(
+ seq!(
+ "${",
+ digit(),
+ "|",
+ sep(text(CHOICE_TEXT_ESCAPE_CHARS, &['|', ',']), ","),
+ "|}",
+ ),
+ |seq| SnippetElement::Choice {
+ tabstop: seq.1,
+ choices: seq.3,
+ },
+ )
+}
+
+fn variable<'a>() -> impl Parser<'a, Output = SnippetElement> {
+ choice!(
+ // $var
+ map(right("$", var()), |name| SnippetElement::Variable {
+ name: name.into(),
+ default: None,
+ transform: None,
+ }),
+ // ${var}
+ map(seq!("${", var(), "}",), |values| SnippetElement::Variable {
+ name: values.1.into(),
+ default: None,
+ transform: None,
+ }),
+ // ${var:default}
+ map(
+ seq!(
+ "${",
+ var(),
+ ":",
+ zero_or_more(anything(TEXT_ESCAPE_CHARS, true)),
+ "}",
+ ),
+ |values| SnippetElement::Variable {
+ name: values.1.into(),
+ default: Some(values.3),
+ transform: None,
+ }
+ ),
+ // ${var/value/format/options}
+ map(seq!("${", var(), regex(), "}"), |values| {
+ SnippetElement::Variable {
+ name: values.1.into(),
+ default: None,
+ transform: Some(values.2),
+ }
+ }),
+ )
+}
+
+fn anything<'a>(
+ escape_chars: &'static [char],
+ end_at_brace: bool,
+) -> impl Parser<'a, Output = SnippetElement> {
+ let term_chars: &[_] = if end_at_brace { &['$', '}'] } else { &['$'] };
+ move |input: &'a str| {
+ let parser = choice!(
+ tabstop(),
+ placeholder(),
+ choice(),
+ variable(),
+ map("$", |_| SnippetElement::Text("$".into())),
+ map(text(escape_chars, term_chars), SnippetElement::Text),
+ );
+ parser.parse(input)
+ }
+}
+
+fn snippet<'a>() -> impl Parser<'a, Output = Vec<SnippetElement>> {
+ one_or_more(anything(TEXT_ESCAPE_CHARS, false))
+}
+
+#[cfg(test)]
+mod test {
+ use crate::snippets::{Snippet, SnippetRenderCtx};
+
+ use super::SnippetElement::*;
+ use super::*;
+
+ #[test]
+ fn empty_string_is_error() {
+ assert_eq!(Err(""), parse(""));
+ }
+
+ #[test]
+ fn parse_placeholders_in_function_call() {
+ assert_eq!(
+ Ok(vec![
+ Text("match(".into()),
+ Placeholder {
+ tabstop: 1,
+ value: vec![Text("Arg1".into())],
+ },
+ Text(")".into()),
+ ]),
+ parse("match(${1:Arg1})")
+ )
+ }
+
+ #[test]
+ fn unterminated_placeholder() {
+ assert_eq!(
+ Ok(vec![
+ Text("match(".into()),
+ Text("$".into()),
+ Text("{1:)".into())
+ ]),
+ parse("match(${1:)")
+ )
+ }
+
+ #[test]
+ fn parse_empty_placeholder() {
+ assert_eq!(
+ Ok(vec![
+ Text("match(".into()),
+ Placeholder {
+ tabstop: 1,
+ value: vec![],
+ },
+ Text(")".into()),
+ ]),
+ parse("match(${1:})")
+ )
+ }
+
+ #[test]
+ fn parse_placeholders_in_statement() {
+ assert_eq!(
+ Ok(vec![
+ Text("local ".into()),
+ Placeholder {
+ tabstop: 1,
+ value: vec![Text("var".into())],
+ },
+ Text(" = ".into()),
+ Placeholder {
+ tabstop: 1,
+ value: vec![Text("value".into())],
+ },
+ ]),
+ parse("local ${1:var} = ${1:value}")
+ )
+ }
+
+ #[test]
+ fn parse_tabstop_nested_in_placeholder() {
+ assert_eq!(
+ Ok(vec![Placeholder {
+ tabstop: 1,
+ value: vec![
+ Text("var, ".into()),
+ Tabstop {
+ tabstop: 2,
+ transform: None
+ }
+ ],
+ }]),
+ parse("${1:var, $2}")
+ )
+ }
+
+ #[test]
+ fn parse_placeholder_nested_in_placeholder() {
+ assert_eq!(
+ Ok({
+ vec![Placeholder {
+ tabstop: 1,
+ value: vec![
+ Text("foo ".into()),
+ Placeholder {
+ tabstop: 2,
+ value: vec![Text("bar".into())],
+ },
+ ],
+ }]
+ }),
+ parse("${1:foo ${2:bar}}")
+ )
+ }
+
+ #[test]
+ fn parse_all() {
+ assert_eq!(
+ Ok(vec![
+ Text("hello ".into()),
+ Tabstop {
+ tabstop: 1,
+ transform: None
+ },
+ Tabstop {
+ tabstop: 2,
+ transform: None
+ },
+ Text(" ".into()),
+ Choice {
+ tabstop: 1,
+ choices: vec!["one".into(), "two".into(), "three".into()],
+ },
+ Text(" ".into()),
+ Variable {
+ name: "name".into(),
+ default: Some(vec![Text("foo".into())]),
+ transform: None,
+ },
+ Text(" ".into()),
+ Variable {
+ name: "var".into(),
+ default: None,
+ transform: None,
+ },
+ Text(" ".into()),
+ Variable {
+ name: "TM".into(),
+ default: None,
+ transform: None,
+ },
+ ]),
+ parse("hello $1${2} ${1|one,two,three|} ${name:foo} $var $TM")
+ );
+ }
+
+ #[test]
+ fn regex_capture_replace() {
+ assert_eq!(
+ Ok({
+ vec![Variable {
+ name: "TM_FILENAME".into(),
+ default: None,
+ transform: Some(Transform {
+ regex: "(.*).+$".into(),
+ replacement: vec![FormatItem::Capture(1), FormatItem::Text("$".into())],
+ options: Tendril::new(),
+ }),
+ }]
+ }),
+ parse("${TM_FILENAME/(.*).+$/$1$/}")
+ );
+ }
+
+ #[test]
+ fn rust_macro() {
+ assert_eq!(
+ Ok({
+ vec![
+ Text("macro_rules! ".into()),
+ Tabstop {
+ tabstop: 1,
+ transform: None,
+ },
+ Text(" {\n (".into()),
+ Tabstop {
+ tabstop: 2,
+ transform: None,
+ },
+ Text(") => {\n ".into()),
+ Tabstop {
+ tabstop: 0,
+ transform: None,
+ },
+ Text("\n };\n}".into()),
+ ]
+ }),
+ parse("macro_rules! $1 {\n ($2) => {\n $0\n };\n}")
+ );
+ }
+
+ fn assert_text(snippet: &str, parsed_text: &str) {
+ let snippet = Snippet::parse(snippet).unwrap();
+ let mut rendered_snippet = snippet.prepare_render();
+ let rendered_text = snippet
+ .render_at(
+ &mut rendered_snippet,
+ "".into(),
+ false,
+ &mut SnippetRenderCtx::test_ctx(),
+ 0,
+ )
+ .0;
+ assert_eq!(rendered_text, parsed_text)
+ }
+
+ #[test]
+ fn robust_parsing() {
+ assert_text("$", "$");
+ assert_text("\\\\$", "\\$");
+ assert_text("{", "{");
+ assert_text("\\}", "}");
+ assert_text("\\abc", "\\abc");
+ assert_text("foo${f:\\}}bar", "foo}bar");
+ assert_text("\\{", "\\{");
+ assert_text("I need \\\\\\$", "I need \\$");
+ assert_text("\\", "\\");
+ assert_text("\\{{", "\\{{");
+ assert_text("{{", "{{");
+ assert_text("{{dd", "{{dd");
+ assert_text("}}", "}}");
+ assert_text("ff}}", "ff}}");
+ assert_text("farboo", "farboo");
+ assert_text("far{{}}boo", "far{{}}boo");
+ assert_text("far{{123}}boo", "far{{123}}boo");
+ assert_text("far\\{{123}}boo", "far\\{{123}}boo");
+ assert_text("far{{id:bern}}boo", "far{{id:bern}}boo");
+ assert_text("far{{id:bern {{basel}}}}boo", "far{{id:bern {{basel}}}}boo");
+ assert_text(
+ "far{{id:bern {{id:basel}}}}boo",
+ "far{{id:bern {{id:basel}}}}boo",
+ );
+ assert_text(
+ "far{{id:bern {{id2:basel}}}}boo",
+ "far{{id:bern {{id2:basel}}}}boo",
+ );
+ assert_text("${}$\\a\\$\\}\\\\", "${}$\\a$}\\");
+ assert_text("farboo", "farboo");
+ assert_text("far{{}}boo", "far{{}}boo");
+ assert_text("far{{123}}boo", "far{{123}}boo");
+ assert_text("far\\{{123}}boo", "far\\{{123}}boo");
+ assert_text("far`123`boo", "far`123`boo");
+ assert_text("far\\`123\\`boo", "far\\`123\\`boo");
+ assert_text("\\$far-boo", "$far-boo");
+ }
+
+ fn assert_snippet(snippet: &str, expect: &[SnippetElement]) {
+ let elements = parse(snippet).unwrap();
+ assert_eq!(elements, expect.to_owned())
+ }
+
+ #[test]
+ fn parse_variable() {
+ use SnippetElement::*;
+ assert_snippet(
+ "$far-boo",
+ &[
+ Variable {
+ name: "far".into(),
+ default: None,
+ transform: None,
+ },
+ Text("-boo".into()),
+ ],
+ );
+ assert_snippet(
+ "far$farboo",
+ &[
+ Text("far".into()),
+ Variable {
+ name: "farboo".into(),
+ transform: None,
+ default: None,
+ },
+ ],
+ );
+ assert_snippet(
+ "far${farboo}",
+ &[
+ Text("far".into()),
+ Variable {
+ name: "farboo".into(),
+ transform: None,
+ default: None,
+ },
+ ],
+ );
+ assert_snippet(
+ "$123",
+ &[Tabstop {
+ tabstop: 123,
+ transform: None,
+ }],
+ );
+ assert_snippet(
+ "$farboo",
+ &[Variable {
+ name: "farboo".into(),
+ transform: None,
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "$far12boo",
+ &[Variable {
+ name: "far12boo".into(),
+ transform: None,
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "000_${far}_000",
+ &[
+ Text("000_".into()),
+ Variable {
+ name: "far".into(),
+ transform: None,
+ default: None,
+ },
+ Text("_000".into()),
+ ],
+ );
+ }
+
+ #[test]
+ fn parse_variable_transform() {
+ assert_snippet(
+ "${foo///}",
+ &[Variable {
+ name: "foo".into(),
+ transform: Some(Transform {
+ regex: Tendril::new(),
+ replacement: Vec::new(),
+ options: Tendril::new(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${foo/regex/format/gmi}",
+ &[Variable {
+ name: "foo".into(),
+ transform: Some(Transform {
+ regex: "regex".into(),
+ replacement: vec![FormatItem::Text("format".into())],
+ options: "gmi".into(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${foo/([A-Z][a-z])/format/}",
+ &[Variable {
+ name: "foo".into(),
+ transform: Some(Transform {
+ regex: "([A-Z][a-z])".into(),
+ replacement: vec![FormatItem::Text("format".into())],
+ options: Tendril::new(),
+ }),
+ default: None,
+ }],
+ );
+
+ // invalid regex TODO: reneable tests once we actually parse this regex flavor
+ // assert_text(
+ // "${foo/([A-Z][a-z])/format/GMI}",
+ // "${foo/([A-Z][a-z])/format/GMI}",
+ // );
+ // assert_text(
+ // "${foo/([A-Z][a-z])/format/funky}",
+ // "${foo/([A-Z][a-z])/format/funky}",
+ // );
+ // assert_text("${foo/([A-Z][a-z]/format/}", "${foo/([A-Z][a-z]/format/}");
+ assert_text(
+ "${foo/regex\\/format/options}",
+ "${foo/regex\\/format/options}",
+ );
+
+ // tricky regex
+ assert_snippet(
+ "${foo/m\\/atch/$1/i}",
+ &[Variable {
+ name: "foo".into(),
+ transform: Some(Transform {
+ regex: "m/atch".into(),
+ replacement: vec![FormatItem::Capture(1)],
+ options: "i".into(),
+ }),
+ default: None,
+ }],
+ );
+
+ // incomplete
+ assert_text("${foo///", "${foo///");
+ assert_text("${foo/regex/format/options", "${foo/regex/format/options");
+
+ // format string
+ assert_snippet(
+ "${foo/.*/${0:fooo}/i}",
+ &[Variable {
+ name: "foo".into(),
+ transform: Some(Transform {
+ regex: ".*".into(),
+ replacement: vec![FormatItem::Conditional(0, Tendril::new(), "fooo".into())],
+ options: "i".into(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${foo/.*/${1}/i}",
+ &[Variable {
+ name: "foo".into(),
+ transform: Some(Transform {
+ regex: ".*".into(),
+ replacement: vec![FormatItem::Capture(1)],
+ options: "i".into(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${foo/.*/$1/i}",
+ &[Variable {
+ name: "foo".into(),
+ transform: Some(Transform {
+ regex: ".*".into(),
+ replacement: vec![FormatItem::Capture(1)],
+ options: "i".into(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${foo/.*/This-$1-encloses/i}",
+ &[Variable {
+ name: "foo".into(),
+ transform: Some(Transform {
+ regex: ".*".into(),
+ replacement: vec![
+ FormatItem::Text("This-".into()),
+ FormatItem::Capture(1),
+ FormatItem::Text("-encloses".into()),
+ ],
+ options: "i".into(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${foo/.*/complex${1:else}/i}",
+ &[Variable {
+ name: "foo".into(),
+ transform: Some(Transform {
+ regex: ".*".into(),
+ replacement: vec![
+ FormatItem::Text("complex".into()),
+ FormatItem::Conditional(1, Tendril::new(), "else".into()),
+ ],
+ options: "i".into(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${foo/.*/complex${1:-else}/i}",
+ &[Variable {
+ name: "foo".into(),
+ transform: Some(Transform {
+ regex: ".*".into(),
+ replacement: vec![
+ FormatItem::Text("complex".into()),
+ FormatItem::Conditional(1, Tendril::new(), "else".into()),
+ ],
+ options: "i".into(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${foo/.*/complex${1:+if}/i}",
+ &[Variable {
+ name: "foo".into(),
+ transform: Some(Transform {
+ regex: ".*".into(),
+ replacement: vec![
+ FormatItem::Text("complex".into()),
+ FormatItem::Conditional(1, "if".into(), Tendril::new()),
+ ],
+ options: "i".into(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${foo/.*/complex${1:?if:else}/i}",
+ &[Variable {
+ name: "foo".into(),
+ transform: Some(Transform {
+ regex: ".*".into(),
+ replacement: vec![
+ FormatItem::Text("complex".into()),
+ FormatItem::Conditional(1, "if".into(), "else".into()),
+ ],
+ options: "i".into(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${foo/.*/complex${1:/upcase}/i}",
+ &[Variable {
+ name: "foo".into(),
+ transform: Some(Transform {
+ regex: ".*".into(),
+ replacement: vec![
+ FormatItem::Text("complex".into()),
+ FormatItem::CaseChange(1, CaseChange::Upcase),
+ ],
+ options: "i".into(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${TM_DIRECTORY/src\\//$1/}",
+ &[Variable {
+ name: "TM_DIRECTORY".into(),
+ transform: Some(Transform {
+ regex: "src/".into(),
+ replacement: vec![FormatItem::Capture(1)],
+ options: Tendril::new(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${TM_SELECTED_TEXT/a/\\/$1/g}",
+ &[Variable {
+ name: "TM_SELECTED_TEXT".into(),
+ transform: Some(Transform {
+ regex: "a".into(),
+ replacement: vec![FormatItem::Text("/".into()), FormatItem::Capture(1)],
+ options: "g".into(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${TM_SELECTED_TEXT/a/in\\/$1ner/g}",
+ &[Variable {
+ name: "TM_SELECTED_TEXT".into(),
+ transform: Some(Transform {
+ regex: "a".into(),
+ replacement: vec![
+ FormatItem::Text("in/".into()),
+ FormatItem::Capture(1),
+ FormatItem::Text("ner".into()),
+ ],
+ options: "g".into(),
+ }),
+ default: None,
+ }],
+ );
+ assert_snippet(
+ "${TM_SELECTED_TEXT/a/end\\//g}",
+ &[Variable {
+ name: "TM_SELECTED_TEXT".into(),
+ transform: Some(Transform {
+ regex: "a".into(),
+ replacement: vec![FormatItem::Text("end/".into())],
+ options: "g".into(),
+ }),
+ default: None,
+ }],
+ );
+ }
+ // TODO port more tests from https://github.com/microsoft/vscode/blob/dce493cb6e36346ef2714e82c42ce14fc461b15c/src/vs/editor/contrib/snippet/test/browser/snippetParser.test.ts
+}
diff --git a/helix-core/src/snippets/render.rs b/helix-core/src/snippets/render.rs
new file mode 100644
index 00000000..e5a7d9bb
--- /dev/null
+++ b/helix-core/src/snippets/render.rs
@@ -0,0 +1,355 @@
+use std::borrow::Cow;
+use std::ops::{Index, IndexMut};
+use std::sync::Arc;
+
+use helix_stdx::Range;
+use ropey::{Rope, RopeSlice};
+use smallvec::SmallVec;
+
+use crate::indent::{normalize_indentation, IndentStyle};
+use crate::movement::Direction;
+use crate::snippets::elaborate;
+use crate::snippets::TabstopIdx;
+use crate::snippets::{Snippet, SnippetElement, Transform};
+use crate::{selection, Selection, Tendril, Transaction};
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum TabstopKind {
+ Choice { choices: Arc<[Tendril]> },
+ Placeholder,
+ Empty,
+ Transform(Arc<Transform>),
+}
+
+#[derive(Debug, PartialEq)]
+pub struct Tabstop {
+ pub ranges: SmallVec<[Range; 1]>,
+ pub parent: Option<TabstopIdx>,
+ pub kind: TabstopKind,
+}
+
+impl Tabstop {
+ pub fn has_placeholder(&self) -> bool {
+ matches!(
+ self.kind,
+ TabstopKind::Choice { .. } | TabstopKind::Placeholder
+ )
+ }
+
+ pub fn selection(
+ &self,
+ direction: Direction,
+ primary_idx: usize,
+ snippet_ranges: usize,
+ ) -> Selection {
+ Selection::new(
+ self.ranges
+ .iter()
+ .map(|&range| {
+ let mut range = selection::Range::new(range.start, range.end);
+ if direction == Direction::Backward {
+ range = range.flip()
+ }
+ range
+ })
+ .collect(),
+ primary_idx * (self.ranges.len() / snippet_ranges),
+ )
+ }
+}
+
+#[derive(Debug, Default, PartialEq)]
+pub struct RenderedSnippet {
+ pub tabstops: Vec<Tabstop>,
+ pub ranges: Vec<Range>,
+}
+
+impl RenderedSnippet {
+ pub fn first_selection(&self, direction: Direction, primary_idx: usize) -> Selection {
+ self.tabstops[0].selection(direction, primary_idx, self.ranges.len())
+ }
+}
+
+impl Index<TabstopIdx> for RenderedSnippet {
+ type Output = Tabstop;
+ fn index(&self, index: TabstopIdx) -> &Tabstop {
+ &self.tabstops[index.0]
+ }
+}
+
+impl IndexMut<TabstopIdx> for RenderedSnippet {
+ fn index_mut(&mut self, index: TabstopIdx) -> &mut Tabstop {
+ &mut self.tabstops[index.0]
+ }
+}
+
+impl Snippet {
+ pub fn prepare_render(&self) -> RenderedSnippet {
+ let tabstops =
+ self.tabstops()
+ .map(|tabstop| Tabstop {
+ ranges: SmallVec::new(),
+ parent: tabstop.parent,
+ kind: match &tabstop.kind {
+ elaborate::TabstopKind::Choice { choices } => TabstopKind::Choice {
+ choices: choices.clone(),
+ },
+ // start out as empty: the first non-empty placeholder will change this to
+ // a placeholder automatically
+ elaborate::TabstopKind::Empty
+ | elaborate::TabstopKind::Placeholder { .. } => TabstopKind::Empty,
+ elaborate::TabstopKind::Transform(transform) => {
+ TabstopKind::Transform(transform.clone())
+ }
+ },
+ })
+ .collect();
+ RenderedSnippet {
+ tabstops,
+ ranges: Vec::new(),
+ }
+ }
+
+ pub fn render_at(
+ &self,
+ snippet: &mut RenderedSnippet,
+ indent: RopeSlice<'_>,
+ at_newline: bool,
+ ctx: &mut SnippetRenderCtx,
+ pos: usize,
+ ) -> (Tendril, usize) {
+ let mut ctx = SnippetRender {
+ dst: snippet,
+ src: self,
+ indent,
+ text: Tendril::new(),
+ off: pos,
+ ctx,
+ at_newline,
+ };
+ ctx.render_elements(self.elements());
+ let end = ctx.off;
+ let text = ctx.text;
+ snippet.ranges.push(Range { start: pos, end });
+ (text, end - pos)
+ }
+
+ pub fn render(
+ &self,
+ doc: &Rope,
+ selection: &Selection,
+ change_range: impl FnMut(&selection::Range) -> (usize, usize),
+ ctx: &mut SnippetRenderCtx,
+ ) -> (Transaction, Selection, RenderedSnippet) {
+ let mut snippet = self.prepare_render();
+ let mut off = 0;
+ let (transaction, selection) = Transaction::change_by_selection_ignore_overlapping(
+ doc,
+ selection,
+ change_range,
+ |replacement_start, replacement_end| {
+ let line_idx = doc.char_to_line(replacement_start);
+ let line_start = doc.line_to_char(line_idx);
+ let prefix = doc.slice(line_start..replacement_start);
+ let indent_len = prefix.chars().take_while(|c| c.is_whitespace()).count();
+ let indent = prefix.slice(..indent_len);
+ let at_newline = indent_len == replacement_start - line_start;
+
+ let (replacement, replacement_len) = self.render_at(
+ &mut snippet,
+ indent,
+ at_newline,
+ ctx,
+ (replacement_start as i128 + off) as usize,
+ );
+ off +=
+ replacement_start as i128 - replacement_end as i128 + replacement_len as i128;
+
+ Some(replacement)
+ },
+ );
+ (transaction, selection, snippet)
+ }
+}
+
+pub type VariableResolver = dyn FnMut(&str) -> Option<Cow<str>>;
+pub struct SnippetRenderCtx {
+ pub resolve_var: Box<VariableResolver>,
+ pub tab_width: usize,
+ pub indent_style: IndentStyle,
+ pub line_ending: &'static str,
+}
+
+impl SnippetRenderCtx {
+ #[cfg(test)]
+ pub(super) fn test_ctx() -> SnippetRenderCtx {
+ SnippetRenderCtx {
+ resolve_var: Box::new(|_| None),
+ tab_width: 4,
+ indent_style: IndentStyle::Spaces(4),
+ line_ending: "\n",
+ }
+ }
+}
+
+struct SnippetRender<'a> {
+ ctx: &'a mut SnippetRenderCtx,
+ dst: &'a mut RenderedSnippet,
+ src: &'a Snippet,
+ indent: RopeSlice<'a>,
+ text: Tendril,
+ off: usize,
+ at_newline: bool,
+}
+
+impl SnippetRender<'_> {
+ fn render_elements(&mut self, elements: &[SnippetElement]) {
+ for element in elements {
+ self.render_element(element)
+ }
+ }
+
+ fn render_element(&mut self, element: &SnippetElement) {
+ match *element {
+ SnippetElement::Tabstop { idx } => self.render_tabstop(idx),
+ SnippetElement::Variable {
+ ref name,
+ ref default,
+ ref transform,
+ } => {
+ // TODO: allow resolve_var access to the doc and make it return rope slice
+ // so we can access selections and other document content without allocating
+ if let Some(val) = (self.ctx.resolve_var)(name) {
+ if let Some(transform) = transform {
+ self.push_multiline_str(&transform.apply(
+ (&*val).into(),
+ Range {
+ start: 0,
+ end: val.chars().count(),
+ },
+ ));
+ } else {
+ self.push_multiline_str(&val)
+ }
+ } else if let Some(default) = default {
+ self.render_elements(default)
+ }
+ }
+ SnippetElement::Text(ref text) => self.push_multiline_str(text),
+ }
+ }
+
+ fn push_multiline_str(&mut self, text: &str) {
+ let mut lines = text
+ .split('\n')
+ .map(|line| line.strip_suffix('\r').unwrap_or(line));
+ let first_line = lines.next().unwrap();
+ self.push_str(first_line, self.at_newline);
+ for line in lines {
+ self.push_newline();
+ self.push_str(line, true);
+ }
+ }
+
+ fn push_str(&mut self, mut text: &str, at_newline: bool) {
+ if at_newline {
+ let old_len = self.text.len();
+ let old_indent_len = normalize_indentation(
+ self.indent,
+ text.into(),
+ &mut self.text,
+ self.ctx.indent_style,
+ self.ctx.tab_width,
+ );
+ // this is ok because indentation can only be ascii chars (' ' and '\t')
+ self.off += self.text.len() - old_len;
+ text = &text[old_indent_len..];
+ if text.is_empty() {
+ self.at_newline = true;
+ return;
+ }
+ }
+ self.text.push_str(text);
+ self.off += text.chars().count();
+ }
+
+ fn push_newline(&mut self) {
+ self.off += self.ctx.line_ending.chars().count() + self.indent.len_chars();
+ self.text.push_str(self.ctx.line_ending);
+ self.text.extend(self.indent.chunks());
+ }
+
+ fn render_tabstop(&mut self, tabstop: TabstopIdx) {
+ let start = self.off;
+ let end = match &self.src[tabstop].kind {
+ elaborate::TabstopKind::Placeholder { default } if !default.is_empty() => {
+ self.render_elements(default);
+ self.dst[tabstop].kind = TabstopKind::Placeholder;
+ self.off
+ }
+ _ => start,
+ };
+ self.dst[tabstop].ranges.push(Range { start, end });
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use helix_stdx::Range;
+
+ use crate::snippets::render::Tabstop;
+ use crate::snippets::{Snippet, SnippetRenderCtx};
+
+ use super::TabstopKind;
+
+ fn assert_snippet(snippet: &str, expect: &str, tabstops: &[Tabstop]) {
+ let snippet = Snippet::parse(snippet).unwrap();
+ let mut rendered_snippet = snippet.prepare_render();
+ let rendered_text = snippet
+ .render_at(
+ &mut rendered_snippet,
+ "\t".into(),
+ false,
+ &mut SnippetRenderCtx::test_ctx(),
+ 0,
+ )
+ .0;
+ assert_eq!(rendered_text, expect);
+ assert_eq!(&rendered_snippet.tabstops, tabstops);
+ assert_eq!(
+ rendered_snippet.ranges.last().unwrap().end,
+ rendered_text.chars().count()
+ );
+ assert_eq!(rendered_snippet.ranges.last().unwrap().start, 0)
+ }
+
+ #[test]
+ fn rust_macro() {
+ assert_snippet(
+ "macro_rules! ${1:name} {\n\t($3) => {\n\t\t$2\n\t};\n}",
+ "macro_rules! name {\n\t () => {\n\t \n\t };\n\t}",
+ &[
+ Tabstop {
+ ranges: vec![Range { start: 13, end: 17 }].into(),
+ parent: None,
+ kind: TabstopKind::Placeholder,
+ },
+ Tabstop {
+ ranges: vec![Range { start: 42, end: 42 }].into(),
+ parent: None,
+ kind: TabstopKind::Empty,
+ },
+ Tabstop {
+ ranges: vec![Range { start: 26, end: 26 }].into(),
+ parent: None,
+ kind: TabstopKind::Empty,
+ },
+ Tabstop {
+ ranges: vec![Range { start: 53, end: 53 }].into(),
+ parent: None,
+ kind: TabstopKind::Empty,
+ },
+ ],
+ );
+ }
+}