Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-lsp/src/snippet.rs')
| -rw-r--r-- | helix-lsp/src/snippet.rs | 1010 |
1 files changed, 1010 insertions, 0 deletions
diff --git a/helix-lsp/src/snippet.rs b/helix-lsp/src/snippet.rs new file mode 100644 index 00000000..ebf3da24 --- /dev/null +++ b/helix-lsp/src/snippet.rs @@ -0,0 +1,1010 @@ +use std::borrow::Cow; + +use anyhow::{anyhow, Result}; +use helix_core::{smallvec, SmallVec, Tendril}; + +#[derive(Debug, PartialEq, Eq)] +pub enum CaseChange { + Upcase, + Downcase, + Capitalize, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum FormatItem { + Text(Tendril), + Capture(usize), + CaseChange(usize, CaseChange), + Conditional(usize, Option<Tendril>, Option<Tendril>), +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Regex { + value: Tendril, + replacement: Vec<FormatItem>, + options: Tendril, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum SnippetElement<'a> { + Tabstop { + tabstop: usize, + }, + Placeholder { + tabstop: usize, + value: Vec<SnippetElement<'a>>, + }, + Choice { + tabstop: usize, + choices: Vec<Tendril>, + }, + Variable { + name: &'a str, + default: Option<Vec<SnippetElement<'a>>>, + regex: Option<Regex>, + }, + Text(Tendril), +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Snippet<'a> { + elements: Vec<SnippetElement<'a>>, +} + +pub fn parse(s: &str) -> Result<Snippet<'_>> { + parser::parse(s).map_err(|rest| anyhow!("Failed to parse snippet. Remaining input: {}", rest)) +} + +fn render_elements( + snippet_elements: &[SnippetElement<'_>], + insert: &mut Tendril, + offset: &mut usize, + tabstops: &mut Vec<(usize, (usize, usize))>, + newline_with_offset: &str, + include_placeholder: bool, +) { + use SnippetElement::*; + + for element in snippet_elements { + match element { + Text(text) => { + // small optimization to avoid calling replace when it's unnecessary + let text = if text.contains('\n') { + Cow::Owned(text.replace('\n', newline_with_offset)) + } else { + Cow::Borrowed(text.as_str()) + }; + *offset += text.chars().count(); + insert.push_str(&text); + } + Variable { + name: _, + regex: _, + r#default, + } => { + // TODO: variables. For now, fall back to the default, which defaults to "". + render_elements( + r#default.as_deref().unwrap_or_default(), + insert, + offset, + tabstops, + newline_with_offset, + include_placeholder, + ); + } + &Tabstop { tabstop } => { + tabstops.push((tabstop, (*offset, *offset))); + } + Placeholder { + tabstop, + value: inner_snippet_elements, + } => { + let start_offset = *offset; + if include_placeholder { + render_elements( + inner_snippet_elements, + insert, + offset, + tabstops, + newline_with_offset, + include_placeholder, + ); + } + tabstops.push((*tabstop, (start_offset, *offset))); + } + &Choice { + tabstop, + choices: _, + } => { + // TODO: choices + tabstops.push((tabstop, (*offset, *offset))); + } + } + } +} + +#[allow(clippy::type_complexity)] // only used one time +pub fn render( + snippet: &Snippet<'_>, + newline_with_offset: &str, + include_placeholder: bool, +) -> (Tendril, Vec<SmallVec<[(usize, usize); 1]>>) { + let mut insert = Tendril::new(); + let mut tabstops = Vec::new(); + let mut offset = 0; + + render_elements( + &snippet.elements, + &mut insert, + &mut offset, + &mut tabstops, + newline_with_offset, + include_placeholder, + ); + + // sort in ascending order (except for 0, which should always be the last one (per lsp doc)) + tabstops.sort_unstable_by_key(|(n, _)| if *n == 0 { usize::MAX } else { *n }); + + // merge tabstops with the same index (we take advantage of the fact that we just sorted them + // above to simply look backwards) + let mut ntabstops = Vec::<SmallVec<[(usize, usize); 1]>>::new(); + { + let mut prev = None; + for (tabstop, r) in tabstops { + if prev == Some(tabstop) { + let len_1 = ntabstops.len() - 1; + ntabstops[len_1].push(r); + } else { + prev = Some(tabstop); + ntabstops.push(smallvec![r]); + } + } + } + + (insert, ntabstops) +} + +mod parser { + use helix_core::Tendril; + use helix_parsec::*; + + use super::{CaseChange, FormatItem, Regex, Snippet, SnippetElement}; + + /* + https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#snippet_syntax + + 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 + */ + + 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), + ) + } + + 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, Some(seq.3), None) } + ), + // '${' int ':?' if ':' else '}' + map( + seq!( + "${", + digit(), + ":?", + text(TEXT_ESCAPE_CHARS, &[':']), + ":", + text(TEXT_ESCAPE_CHARS, &['}']), + "}" + ), + |seq| { Conditional(seq.1, Some(seq.3), Some(seq.5)) } + ), + // '${' int ':-' else '}' | '${' int ':' else '}' + map( + seq!( + "${", + digit(), + ":", + optional("-"), + text(TEXT_ESCAPE_CHARS, &['}']), + "}" + ), + |seq| { Conditional(seq.1, None, Some(seq.4)) } + ), + ) + } + + fn regex<'a>() -> impl Parser<'a, Output = Regex> { + 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)| Regex { + value, + replacement, + options, + }, + ) + } + + fn tabstop<'a>() -> impl Parser<'a, Output = SnippetElement<'a>> { + map( + or( + right("$", digit()), + map(seq!("${", digit(), "}"), |values| values.1), + ), + |digit| SnippetElement::Tabstop { tabstop: digit }, + ) + } + + fn placeholder<'a>() -> impl Parser<'a, Output = SnippetElement<'a>> { + 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<'a>> { + 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<'a>> { + choice!( + // $var + map(right("$", var()), |name| SnippetElement::Variable { + name, + default: None, + regex: None, + }), + // ${var} + map(seq!("${", var(), "}",), |values| SnippetElement::Variable { + name: values.1, + default: None, + regex: None, + }), + // ${var:default} + map( + seq!( + "${", + var(), + ":", + zero_or_more(anything(TEXT_ESCAPE_CHARS, true)), + "}", + ), + |values| SnippetElement::Variable { + name: values.1, + default: Some(values.3), + regex: None, + } + ), + // ${var/value/format/options} + map(seq!("${", var(), regex(), "}"), |values| { + SnippetElement::Variable { + name: values.1, + default: None, + regex: Some(values.2), + } + }), + ) + } + + fn anything<'a>( + escape_chars: &'static [char], + end_at_brace: bool, + ) -> impl Parser<'a, Output = SnippetElement<'a>> { + 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 = Snippet<'a>> { + map(one_or_more(anything(TEXT_ESCAPE_CHARS, false)), |parts| { + Snippet { elements: parts } + }) + } + + pub fn parse(s: &str) -> Result<Snippet, &str> { + snippet().parse(s).and_then(|(remainder, snippet)| { + if remainder.is_empty() { + Ok(snippet) + } else { + Err(remainder) + } + }) + } + + #[cfg(test)] + mod test { + 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(Snippet { + elements: 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(Snippet { + elements: vec![Text("match(".into()), Text("$".into()), Text("{1:)".into())] + }), + parse("match(${1:)") + ) + } + + #[test] + fn parse_empty_placeholder() { + assert_eq!( + Ok(Snippet { + elements: vec![ + Text("match(".into()), + Placeholder { + tabstop: 1, + value: vec![], + }, + Text(")".into()) + ] + }), + parse("match(${1:})") + ) + } + + #[test] + fn parse_placeholders_in_statement() { + assert_eq!( + Ok(Snippet { + elements: 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(Snippet { + elements: vec![Placeholder { + tabstop: 1, + value: vec!(Text("var, ".into()), Tabstop { tabstop: 2 },), + },] + }), + parse("${1:var, $2}") + ) + } + + #[test] + fn parse_placeholder_nested_in_placeholder() { + assert_eq!( + Ok(Snippet { + elements: 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(Snippet { + elements: vec![ + Text("hello ".into()), + Tabstop { tabstop: 1 }, + Tabstop { tabstop: 2 }, + Text(" ".into()), + Choice { + tabstop: 1, + choices: vec!["one".into(), "two".into(), "three".into()] + }, + Text(" ".into()), + Variable { + name: "name", + default: Some(vec![Text("foo".into())]), + regex: None + }, + Text(" ".into()), + Variable { + name: "var", + default: None, + regex: None + }, + Text(" ".into()), + Variable { + name: "TM", + default: None, + regex: None + }, + ] + }), + parse("hello $1${2} ${1|one,two,three|} ${name:foo} $var $TM") + ); + } + + #[test] + fn regex_capture_replace() { + assert_eq!( + Ok(Snippet { + elements: vec![Variable { + name: "TM_FILENAME", + default: None, + regex: Some(Regex { + value: "(.*).+$".into(), + replacement: vec![FormatItem::Capture(1), FormatItem::Text("$".into())], + options: Tendril::new(), + }), + }] + }), + parse("${TM_FILENAME/(.*).+$/$1$/}") + ); + } + + #[test] + fn rust_macro() { + assert_eq!( + Ok(Snippet { + elements: vec![ + Text("macro_rules! ".into()), + Tabstop { tabstop: 1 }, + Text(" {\n (".into()), + Tabstop { tabstop: 2 }, + Text(") => {\n ".into()), + Tabstop { tabstop: 0 }, + Text("\n };\n}".into()) + ] + }), + parse("macro_rules! $1 {\n ($2) => {\n $0\n };\n}") + ); + } + + fn assert_text(snippet: &str, parsed_text: &str) { + let res = parse(snippet).unwrap(); + let text = crate::snippet::render(&res, "\n", true).0; + assert_eq!(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 parsed_snippet = parse(snippet).unwrap(); + assert_eq!(parsed_snippet.elements, expect.to_owned()) + } + + #[test] + fn parse_variable() { + use SnippetElement::*; + assert_snippet( + "$far-boo", + &[ + Variable { + name: "far", + default: None, + regex: None, + }, + Text("-boo".into()), + ], + ); + assert_snippet( + "far$farboo", + &[ + Text("far".into()), + Variable { + name: "farboo", + regex: None, + default: None, + }, + ], + ); + assert_snippet( + "far${farboo}", + &[ + Text("far".into()), + Variable { + name: "farboo", + regex: None, + default: None, + }, + ], + ); + assert_snippet("$123", &[Tabstop { tabstop: 123 }]); + assert_snippet( + "$farboo", + &[Variable { + name: "farboo", + regex: None, + default: None, + }], + ); + assert_snippet( + "$far12boo", + &[Variable { + name: "far12boo", + regex: None, + default: None, + }], + ); + assert_snippet( + "000_${far}_000", + &[ + Text("000_".into()), + Variable { + name: "far", + regex: None, + default: None, + }, + Text("_000".into()), + ], + ); + } + + #[test] + fn parse_variable_transform() { + assert_snippet( + "${foo///}", + &[Variable { + name: "foo", + regex: Some(Regex { + value: Tendril::new(), + replacement: Vec::new(), + options: Tendril::new(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/regex/format/gmi}", + &[Variable { + name: "foo", + regex: Some(Regex { + value: "regex".into(), + replacement: vec![FormatItem::Text("format".into())], + options: "gmi".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/([A-Z][a-z])/format/}", + &[Variable { + name: "foo", + regex: Some(Regex { + value: "([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 flavour + // 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", + regex: Some(Regex { + value: "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", + regex: Some(Regex { + value: ".*".into(), + replacement: vec![FormatItem::Conditional(0, None, Some("fooo".into()))], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/${1}/i}", + &[Variable { + name: "foo", + regex: Some(Regex { + value: ".*".into(), + replacement: vec![FormatItem::Capture(1)], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/$1/i}", + &[Variable { + name: "foo", + regex: Some(Regex { + value: ".*".into(), + replacement: vec![FormatItem::Capture(1)], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/This-$1-encloses/i}", + &[Variable { + name: "foo", + regex: Some(Regex { + value: ".*".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", + regex: Some(Regex { + value: ".*".into(), + replacement: vec![ + FormatItem::Text("complex".into()), + FormatItem::Conditional(1, None, Some("else".into())), + ], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/complex${1:-else}/i}", + &[Variable { + name: "foo", + regex: Some(Regex { + value: ".*".into(), + replacement: vec![ + FormatItem::Text("complex".into()), + FormatItem::Conditional(1, None, Some("else".into())), + ], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/complex${1:+if}/i}", + &[Variable { + name: "foo", + regex: Some(Regex { + value: ".*".into(), + replacement: vec![ + FormatItem::Text("complex".into()), + FormatItem::Conditional(1, Some("if".into()), None), + ], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/complex${1:?if:else}/i}", + &[Variable { + name: "foo", + regex: Some(Regex { + value: ".*".into(), + replacement: vec![ + FormatItem::Text("complex".into()), + FormatItem::Conditional(1, Some("if".into()), Some("else".into())), + ], + options: "i".into(), + }), + default: None, + }], + ); + assert_snippet( + "${foo/.*/complex${1:/upcase}/i}", + &[Variable { + name: "foo", + regex: Some(Regex { + value: ".*".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", + regex: Some(Regex { + value: "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", + regex: Some(Regex { + value: "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", + regex: Some(Regex { + value: "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", + regex: Some(Regex { + value: "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 + } +} |