From a6511ff9a826aa85f9080da424183527418fb0ad Mon Sep 17 00:00:00 2001 From: Pascal Kuthe Date: Sun, 3 Mar 2024 23:23:34 +0100 Subject: [PATCH] implement snippet tabstop support --- helix-lsp/src/lib.rs | 132 +--- helix-lsp/src/snippet.rs | 1010 ---------------------------- helix-term/src/commands.rs | 49 +- helix-term/src/handlers.rs | 2 + helix-term/src/handlers/snippet.rs | 28 + helix-term/src/ui/completion.rs | 198 +++--- helix-term/src/ui/editor.rs | 57 +- helix-view/src/document.rs | 13 + helix-view/src/editor.rs | 1 + theme.toml | 1 + 10 files changed, 256 insertions(+), 1235 deletions(-) delete mode 100644 helix-lsp/src/snippet.rs create mode 100644 helix-term/src/handlers/snippet.rs diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 4a27802d3..3bb7700e2 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -2,7 +2,6 @@ pub mod file_event; mod file_operations; pub mod jsonrpc; -pub mod snippet; mod transport; use arc_swap::ArcSwap; @@ -67,7 +66,8 @@ pub enum OffsetEncoding { pub mod util { use super::*; use helix_core::line_ending::{line_end_byte_index, line_end_char_index}; - use helix_core::{chars, RopeSlice, SmallVec}; + use helix_core::snippets::{RenderedSnippet, Snippet, SnippetRenderCtx}; + use helix_core::{chars, RopeSlice}; use helix_core::{diagnostic::NumberOrString, Range, Rope, Selection, Tendril, Transaction}; /// Converts a diagnostic in the document to [`lsp::Diagnostic`]. @@ -355,25 +355,17 @@ pub fn generate_transaction_from_completion_edit( transaction.with_selection(selection) } - /// Creates a [Transaction] from the [snippet::Snippet] in a completion response. + /// Creates a [Transaction] from the [Snippet] in a completion response. /// The transaction applies the edit to all cursors. - #[allow(clippy::too_many_arguments)] pub fn generate_transaction_from_snippet( doc: &Rope, selection: &Selection, edit_offset: Option<(i128, i128)>, replace_mode: bool, - snippet: snippet::Snippet, - line_ending: &str, - include_placeholder: bool, - tab_width: usize, - indent_width: usize, - ) -> Transaction { + snippet: Snippet, + cx: &mut SnippetRenderCtx, + ) -> (Transaction, RenderedSnippet) { let text = doc.slice(..); - - let mut off = 0i128; - let mut mapped_doc = doc.clone(); - let mut selection_tabstops: SmallVec<[_; 1]> = SmallVec::new(); let (removed_start, removed_end) = completion_range( text, edit_offset, @@ -382,8 +374,7 @@ pub fn generate_transaction_from_snippet( ) .expect("transaction must be valid for primary selection"); let removed_text = text.slice(removed_start..removed_end); - - let (transaction, mut selection) = Transaction::change_by_selection_ignore_overlapping( + let (transaction, mapped_selection, snippet) = snippet.render( doc, selection, |range| { @@ -392,108 +383,15 @@ pub fn generate_transaction_from_snippet( .filter(|(start, end)| text.slice(start..end) == removed_text) .unwrap_or_else(|| find_completion_range(text, replace_mode, cursor)) }, - |replacement_start, replacement_end| { - let mapped_replacement_start = (replacement_start as i128 + off) as usize; - let mapped_replacement_end = (replacement_end as i128 + off) as usize; - - let line_idx = mapped_doc.char_to_line(mapped_replacement_start); - let indent_level = helix_core::indent::indent_level_for_line( - mapped_doc.line(line_idx), - tab_width, - indent_width, - ) * indent_width; - - let newline_with_offset = format!( - "{line_ending}{blank:indent_level$}", - line_ending = line_ending, - blank = "" - ); - - let (replacement, tabstops) = - snippet::render(&snippet, &newline_with_offset, include_placeholder); - selection_tabstops.push((mapped_replacement_start, tabstops)); - mapped_doc.remove(mapped_replacement_start..mapped_replacement_end); - mapped_doc.insert(mapped_replacement_start, &replacement); - off += - replacement_start as i128 - replacement_end as i128 + replacement.len() as i128; - - Some(replacement) - }, + cx, ); - - let changes = transaction.changes(); - if changes.is_empty() { - return transaction; - } - - // Don't normalize to avoid merging/reording selections which would - // break the association between tabstops and selections. Most ranges - // will be replaced by tabstops anyways and the final selection will be - // normalized anyways - selection = selection.map_no_normalize(changes); - let mut mapped_selection = SmallVec::with_capacity(selection.len()); - let mut mapped_primary_idx = 0; - let primary_range = selection.primary(); - for (range, (tabstop_anchor, tabstops)) in selection.into_iter().zip(selection_tabstops) { - if range == primary_range { - mapped_primary_idx = mapped_selection.len() - } - - let tabstops = tabstops.first().filter(|tabstops| !tabstops.is_empty()); - let Some(tabstops) = tabstops else { - // no tabstop normal mapping - mapped_selection.push(range); - continue; - }; - - // expand the selection to cover the tabstop to retain the helix selection semantic - // the tabstop closest to the range simply replaces `head` while anchor remains in place - // the remaining tabstops receive their own single-width cursor - if range.head < range.anchor { - let last_idx = tabstops.len() - 1; - let last_tabstop = tabstop_anchor + tabstops[last_idx].0; - - // if selection is forward but was moved to the right it is - // contained entirely in the replacement text, just do a point - // selection (fallback below) - if range.anchor > last_tabstop { - let range = Range::new(range.anchor, last_tabstop); - mapped_selection.push(range); - let rem_tabstops = tabstops[..last_idx] - .iter() - .map(|tabstop| Range::point(tabstop_anchor + tabstop.0)); - mapped_selection.extend(rem_tabstops); - continue; - } - } else { - let first_tabstop = tabstop_anchor + tabstops[0].0; - - // if selection is forward but was moved to the right it is - // contained entirely in the replacement text, just do a point - // selection (fallback below) - if range.anchor < first_tabstop { - // we can't properly compute the the next grapheme - // here because the transaction hasn't been applied yet - // that is not a problem because the range gets grapheme aligned anyway - // tough so just adding one will always cause head to be grapheme - // aligned correctly when applied to the document - let range = Range::new(range.anchor, first_tabstop + 1); - mapped_selection.push(range); - let rem_tabstops = tabstops[1..] - .iter() - .map(|tabstop| Range::point(tabstop_anchor + tabstop.0)); - mapped_selection.extend(rem_tabstops); - continue; - } - }; - - let tabstops = tabstops - .iter() - .map(|tabstop| Range::point(tabstop_anchor + tabstop.0)); - mapped_selection.extend(tabstops); - } - - transaction.with_selection(Selection::new(mapped_selection, mapped_primary_idx)) + let transaction = transaction.with_selection(snippet.first_selection( + // we keep the direction of the old primary selection in case it changed during mapping + // but use the primary idx from the mapped selection in case ranges had to be merged + selection.primary().direction(), + mapped_selection.primary_index(), + )); + (transaction, snippet) } pub fn generate_transaction_from_edits( diff --git a/helix-lsp/src/snippet.rs b/helix-lsp/src/snippet.rs deleted file mode 100644 index ebf3da240..000000000 --- a/helix-lsp/src/snippet.rs +++ /dev/null @@ -1,1010 +0,0 @@ -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, Option), -} - -#[derive(Debug, PartialEq, Eq)] -pub struct Regex { - value: Tendril, - replacement: Vec, - options: Tendril, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum SnippetElement<'a> { - Tabstop { - tabstop: usize, - }, - Placeholder { - tabstop: usize, - value: Vec>, - }, - Choice { - tabstop: usize, - choices: Vec, - }, - Variable { - name: &'a str, - default: Option>>, - regex: Option, - }, - Text(Tendril), -} - -#[derive(Debug, PartialEq, Eq)] -pub struct Snippet<'a> { - elements: Vec>, -} - -pub fn parse(s: &str) -> Result> { - 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>) { - 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::>::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().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 - } -} diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 4d4792675..d95a58be0 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -546,6 +546,8 @@ pub fn doc(&self) -> &str { command_palette, "Open command palette", goto_word, "Jump to a two-character label", extend_to_word, "Extend to a two-character label", + goto_next_tabstop, "goto next snippet placeholder", + goto_prev_tabstop, "goto next snippet placeholder", ); } @@ -3810,7 +3812,11 @@ pub fn smart_tab(cx: &mut Context) { }); if !cursors_after_whitespace { - move_parent_node_end(cx); + if doc.active_snippet.is_some() { + goto_next_tabstop(cx); + } else { + move_parent_node_end(cx); + } return; } } @@ -5966,6 +5972,47 @@ fn increment_impl(cx: &mut Context, increment_direction: IncrementDirection) { } } +fn goto_next_tabstop(cx: &mut Context) { + goto_next_tabstop_impl(cx, Direction::Forward) +} + +fn goto_prev_tabstop(cx: &mut Context) { + goto_next_tabstop_impl(cx, Direction::Backward) +} + +fn goto_next_tabstop_impl(cx: &mut Context, direction: Direction) { + let (view, doc) = current!(cx.editor); + let view_id = view.id; + let Some(mut snippet) = doc.active_snippet.take() else { + cx.editor.set_error("no snippet is currently active"); + return; + }; + let tabstop = match direction { + Direction::Forward => Some(snippet.next_tabstop(doc.selection(view_id))), + Direction::Backward => snippet + .prev_tabstop(doc.selection(view_id)) + .map(|selection| (selection, false)), + }; + let Some((selection, last_tabstop)) = tabstop else { + return; + }; + doc.set_selection(view_id, selection); + if !last_tabstop { + doc.active_snippet = Some(snippet) + } + if cx.editor.mode() == Mode::Insert { + cx.on_next_key_fallback(|cx, key| { + if let Some(c) = key.char() { + let (view, doc) = current!(cx.editor); + if let Some(snippet) = &doc.active_snippet { + doc.apply(&snippet.delete_placeholder(doc.text()), view.id); + } + insert_char(cx, c); + } + }) + } +} + fn record_macro(cx: &mut Context) { if let Some((reg, mut keys)) = cx.editor.macro_recording.take() { // Remove the keypress which ends the recording diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs index b27e34e29..31e15330e 100644 --- a/helix-term/src/handlers.rs +++ b/helix-term/src/handlers.rs @@ -16,6 +16,7 @@ pub mod completion; mod diagnostics; mod signature_help; +mod snippet; pub fn setup(config: Arc>) -> Handlers { events::register(); @@ -34,5 +35,6 @@ pub fn setup(config: Arc>) -> Handlers { signature_help::register_hooks(&handlers); auto_save::register_hooks(&handlers); diagnostics::register_hooks(&handlers); + snippet::register_hooks(&handlers); handlers } diff --git a/helix-term/src/handlers/snippet.rs b/helix-term/src/handlers/snippet.rs new file mode 100644 index 000000000..3860d3f72 --- /dev/null +++ b/helix-term/src/handlers/snippet.rs @@ -0,0 +1,28 @@ +use helix_event::register_hook; +use helix_view::events::{DocumentDidChange, DocumentFocusLost, SelectionDidChange}; +use helix_view::handlers::Handlers; + +pub(super) fn register_hooks(_handlers: &Handlers) { + register_hook!(move |event: &mut SelectionDidChange<'_>| { + if let Some(snippet) = &event.doc.active_snippet { + if !snippet.is_valid(event.doc.selection(event.view)) { + event.doc.active_snippet = None; + } + } + Ok(()) + }); + register_hook!(move |event: &mut DocumentDidChange<'_>| { + if let Some(snippet) = &mut event.doc.active_snippet { + let invalid = snippet.map(event.changes); + if invalid { + event.doc.active_snippet = None; + } + } + Ok(()) + }); + register_hook!(move |event: &mut DocumentFocusLost<'_>| { + let editor = &mut event.editor; + doc_mut!(editor).active_snippet = None; + Ok(()) + }); +} diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 14397bb5c..c33af4e45 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -13,7 +13,11 @@ use std::{borrow::Cow, sync::Arc}; -use helix_core::{chars, Change, Transaction}; +use helix_core::{ + chars, + snippets::{ActiveSnippet, RenderedSnippet, Snippet}, + Change, Transaction, +}; use helix_view::{graphics::Rect, Document, Editor}; use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent}; @@ -119,101 +123,6 @@ pub fn new( // Then create the menu let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| { - fn item_to_transaction( - doc: &Document, - view_id: ViewId, - item: &lsp::CompletionItem, - offset_encoding: OffsetEncoding, - trigger_offset: usize, - include_placeholder: bool, - replace_mode: bool, - ) -> Transaction { - use helix_lsp::snippet; - let selection = doc.selection(view_id); - let text = doc.text().slice(..); - let primary_cursor = selection.primary().cursor(text); - - let (edit_offset, new_text) = if let Some(edit) = &item.text_edit { - let edit = match edit { - lsp::CompletionTextEdit::Edit(edit) => edit.clone(), - lsp::CompletionTextEdit::InsertAndReplace(item) => { - let range = if replace_mode { - item.replace - } else { - item.insert - }; - lsp::TextEdit::new(range, item.new_text.clone()) - } - }; - - let Some(range) = - util::lsp_range_to_range(doc.text(), edit.range, offset_encoding) - else { - return Transaction::new(doc.text()); - }; - - let start_offset = range.anchor as i128 - primary_cursor as i128; - let end_offset = range.head as i128 - primary_cursor as i128; - - (Some((start_offset, end_offset)), edit.new_text) - } else { - let new_text = item - .insert_text - .clone() - .unwrap_or_else(|| item.label.clone()); - // check that we are still at the correct savepoint - // we can still generate a transaction regardless but if the - // document changed (and not just the selection) then we will - // likely delete the wrong text (same if we applied an edit sent by the LS) - debug_assert!(primary_cursor == trigger_offset); - (None, new_text) - }; - - if matches!(item.kind, Some(lsp::CompletionItemKind::SNIPPET)) - || matches!( - item.insert_text_format, - Some(lsp::InsertTextFormat::SNIPPET) - ) - { - match snippet::parse(&new_text) { - Ok(snippet) => util::generate_transaction_from_snippet( - doc.text(), - selection, - edit_offset, - replace_mode, - snippet, - doc.line_ending.as_str(), - include_placeholder, - doc.tab_width(), - doc.indent_width(), - ), - Err(err) => { - log::error!( - "Failed to parse snippet: {:?}, remaining output: {}", - &new_text, - err - ); - Transaction::new(doc.text()) - } - } - } else { - util::generate_transaction_from_completion_edit( - doc.text(), - selection, - edit_offset, - replace_mode, - new_text, - ) - } - } - - fn completion_changes(transaction: &Transaction, trigger_offset: usize) -> Vec { - transaction - .changes_iter() - .filter(|(start, end, _)| (*start..=*end).contains(&trigger_offset)) - .collect() - } - let (view, doc) = current!(editor); macro_rules! language_server { @@ -257,13 +166,12 @@ macro_rules! language_server { // always present here let item = item.unwrap(); - let transaction = item_to_transaction( + let (transaction, _) = item_to_transaction( doc, view.id, &item.item, language_server!(item).offset_encoding(), trigger_offset, - true, replace_mode, ); doc.apply_temporary(&transaction, view.id); @@ -292,20 +200,27 @@ macro_rules! language_server { doc.restore(view, &savepoint, true); // save an undo checkpoint before the completion doc.append_changes_to_history(view); - let transaction = item_to_transaction( + let (transaction, snippet) = item_to_transaction( doc, view.id, &item.item, offset_encoding, trigger_offset, - false, replace_mode, ); doc.apply(&transaction, view.id); + let placeholder = snippet.is_some(); + if let Some(snippet) = snippet { + doc.active_snippet = match doc.active_snippet.take() { + Some(active) => active.insert_subsnippet(snippet), + None => ActiveSnippet::new(snippet), + }; + } editor.last_completion = Some(CompleteAction::Applied { trigger_offset, changes: completion_changes(&transaction, trigger_offset), + placeholder, }); // TODO: add additional _edits to completion_changes? @@ -537,3 +452,86 @@ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { markdown_doc.render(doc_area, surface, cx); } } +fn item_to_transaction( + doc: &Document, + view_id: ViewId, + item: &lsp::CompletionItem, + offset_encoding: OffsetEncoding, + trigger_offset: usize, + replace_mode: bool, +) -> (Transaction, Option) { + let selection = doc.selection(view_id); + let text = doc.text().slice(..); + let primary_cursor = selection.primary().cursor(text); + + let (edit_offset, new_text) = if let Some(edit) = &item.text_edit { + let edit = match edit { + lsp::CompletionTextEdit::Edit(edit) => edit.clone(), + lsp::CompletionTextEdit::InsertAndReplace(item) => { + let range = if replace_mode { + item.replace + } else { + item.insert + }; + lsp::TextEdit::new(range, item.new_text.clone()) + } + }; + + let Some(range) = util::lsp_range_to_range(doc.text(), edit.range, offset_encoding) else { + return (Transaction::new(doc.text()), None); + }; + + let start_offset = range.anchor as i128 - primary_cursor as i128; + let end_offset = range.head as i128 - primary_cursor as i128; + + (Some((start_offset, end_offset)), edit.new_text) + } else { + let new_text = item + .insert_text + .clone() + .unwrap_or_else(|| item.label.clone()); + // check that we are still at the correct savepoint + // we can still generate a transaction regardless but if the + // document changed (and not just the selection) then we will + // likely delete the wrong text (same if we applied an edit sent by the LS) + debug_assert!(primary_cursor == trigger_offset); + (None, new_text) + }; + + if matches!(item.kind, Some(lsp::CompletionItemKind::SNIPPET)) + || matches!( + item.insert_text_format, + Some(lsp::InsertTextFormat::SNIPPET) + ) + { + let Ok(snippet) = Snippet::parse(&new_text) else { + log::error!("Failed to parse snippet: {new_text:?}",); + return (Transaction::new(doc.text()), None); + }; + let (transaction, snippet) = util::generate_transaction_from_snippet( + doc.text(), + selection, + edit_offset, + replace_mode, + snippet, + &mut doc.snippet_ctx(), + ); + (transaction, Some(snippet)) + } else { + let transaction = util::generate_transaction_from_completion_edit( + doc.text(), + selection, + edit_offset, + replace_mode, + new_text, + ); + (transaction, None) + } +} + +fn completion_changes(transaction: &Transaction, trigger_offset: usize) -> Vec { + transaction + .changes_iter() + .filter(|(start, end, _)| (*start..=*end).contains(&trigger_offset)) + .collect() +} diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index c84f9d0da..3c99f8583 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -146,7 +146,7 @@ pub fn render_view( } if is_focused { - let highlights = syntax::merge( + let mut highlights = syntax::merge( overlay_highlights, Self::doc_selection_highlights( editor.mode(), @@ -161,6 +161,9 @@ pub fn render_view( if focused_view_elements.is_empty() { overlay_highlights = Box::new(highlights) } else { + if let Some(tabstops) = Self::tabstop_highlights(doc, theme) { + highlights = syntax::merge(Box::new(highlights), tabstops); + } overlay_highlights = Box::new(syntax::merge(highlights, focused_view_elements)) } } @@ -591,6 +594,24 @@ pub fn highlight_focused_view_elements( Vec::new() } + pub fn tabstop_highlights( + doc: &Document, + theme: &Theme, + ) -> Option)>> { + let snippet = doc.active_snippet.as_ref()?; + let highlight = theme.find_scope_index_exact("tabstop")?; + let mut highlights = Vec::new(); + for tabstop in snippet.tabstops() { + highlights.extend( + tabstop + .ranges + .iter() + .map(|range| (highlight, range.start..range.end)), + ); + } + (!highlights.is_empty()).then_some(highlights) + } + /// Render bufferline at the top pub fn render_bufferline(editor: &Editor, viewport: Rect, surface: &mut Surface) { let scratch = PathBuf::from(SCRATCH_BUFFER_NAME); // default filename to use for scratch buffer @@ -1054,24 +1075,38 @@ pub fn set_completion( Some(area) } - pub fn clear_completion(&mut self, editor: &mut Editor) { + pub fn clear_completion(&mut self, editor: &mut Editor) -> Option { self.completion = None; + let mut on_next_key: Option = None; if let Some(last_completion) = editor.last_completion.take() { match last_completion { CompleteAction::Triggered => (), CompleteAction::Applied { trigger_offset, changes, - } => self.last_insert.1.push(InsertEvent::CompletionApply { - trigger_offset, - changes, - }), + placeholder, + } => { + self.last_insert.1.push(InsertEvent::CompletionApply { + trigger_offset, + changes, + }); + on_next_key = placeholder.then_some(Box::new(|cx, key| { + if let Some(c) = key.char() { + let (view, doc) = current!(cx.editor); + if let Some(snippet) = &doc.active_snippet { + doc.apply(&snippet.delete_placeholder(doc.text()), view.id); + } + commands::insert::insert_char(cx, c); + } + })) + } CompleteAction::Selected { savepoint } => { let (view, doc) = current!(editor); doc.restore(view, &savepoint, false); } } } + on_next_key } pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult { @@ -1418,7 +1453,15 @@ fn handle_event( if let Some(callback) = res { if callback.is_some() { // assume close_fn - self.clear_completion(cx.editor); + if let Some(cb) = self.clear_completion(cx.editor) { + if consumed { + cx.on_next_key_callback = + Some((cb, OnKeyCallbackKind::Fallback)) + } else { + self.on_next_key = + Some((cb, OnKeyCallbackKind::Fallback)); + } + } } } } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 2b2c8ace5..4f8425194 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -7,6 +7,7 @@ use helix_core::chars::char_is_word; use helix_core::doc_formatter::TextFormat; use helix_core::encoding::Encoding; +use helix_core::snippets::{ActiveSnippet, SnippetRenderCtx}; use helix_core::syntax::{Highlight, LanguageServerFeature}; use helix_core::text_annotations::{InlineAnnotation, Overlay}; use helix_lsp::util::lsp_pos_to_pos; @@ -134,6 +135,7 @@ pub struct Document { text: Rope, selections: HashMap, view_data: HashMap, + pub active_snippet: Option, /// Inlay hints annotations for the document, by view. /// @@ -654,6 +656,7 @@ pub fn from( Self { id: DocumentId::default(), + active_snippet: None, path: None, encoding, has_bom, @@ -1992,6 +1995,16 @@ pub fn auto_pairs<'a>(&'a self, editor: &'a Editor) -> Option<&'a AutoPairs> { } } + pub fn snippet_ctx(&self) -> SnippetRenderCtx { + SnippetRenderCtx { + // TODO snippet variable resolution + resolve_var: Box::new(|_| None), + tab_width: self.tab_width(), + indent_style: self.indent_style, + line_ending: self.line_ending.as_str(), + } + } + pub fn text_format(&self, mut viewport_width: u16, theme: Option<&Theme>) -> TextFormat { let config = self.config.load(); let text_width = self diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 7b9c9aff8..dc5b8a154 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1116,6 +1116,7 @@ pub enum CompleteAction { Applied { trigger_offset: usize, changes: Vec, + placeholder: bool, }, } diff --git a/theme.toml b/theme.toml index c1e5883d0..d6307c5f0 100644 --- a/theme.toml +++ b/theme.toml @@ -27,6 +27,7 @@ string = "silver" "constant.character.escape" = "honey" # used for lifetimes label = "honey" +tabstop = { modifiers = ["italic"], bg = "bossanova" } "markup.heading" = "lilac" "markup.bold" = { modifiers = ["bold"] }