Unnamed repository; edit this file 'description' to name the repository.
implement snippet tabstop support
Pascal Kuthe 2024-12-18
parent 66fb1e6 · commit 1badd9e
-rw-r--r--book/src/generated/static-cmd.md2
-rw-r--r--helix-lsp/src/lib.rs132
-rw-r--r--helix-lsp/src/snippet.rs1010
-rw-r--r--helix-term/src/commands.rs49
-rw-r--r--helix-term/src/handlers.rs2
-rw-r--r--helix-term/src/handlers/snippet.rs28
-rw-r--r--helix-term/src/ui/completion.rs216
-rw-r--r--helix-term/src/ui/editor.rs55
-rw-r--r--helix-view/src/document.rs13
-rw-r--r--helix-view/src/editor.rs1
-rw-r--r--theme.toml1
11 files changed, 268 insertions, 1241 deletions
diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md
index 4d838a10..08c8e702 100644
--- a/book/src/generated/static-cmd.md
+++ b/book/src/generated/static-cmd.md
@@ -292,3 +292,5 @@
| `command_palette` | Open command palette | normal: `` <space>? ``, select: `` <space>? `` |
| `goto_word` | Jump to a two-character label | normal: `` gw `` |
| `extend_to_word` | Extend to a two-character label | select: `` gw `` |
+| `goto_next_tabstop` | goto next snippet placeholder | |
+| `goto_prev_tabstop` | goto next snippet placeholder | |
diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs
index 47f38bcf..134cb74f 100644
--- a/helix-lsp/src/lib.rs
+++ b/helix-lsp/src/lib.rs
@@ -2,7 +2,6 @@ mod client;
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 mod util {
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 mod util {
)
.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 mod util {
.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 ebf3da24..00000000
--- 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<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
- }
-}
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 788d10f0..76cad67b 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -585,6 +585,8 @@ impl MappableCommand {
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",
);
}
@@ -3948,7 +3950,11 @@ pub mod insert {
});
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;
}
}
@@ -6187,6 +6193,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 b27e34e2..31e15330 100644
--- a/helix-term/src/handlers.rs
+++ b/helix-term/src/handlers.rs
@@ -16,6 +16,7 @@ mod auto_save;
pub mod completion;
mod diagnostics;
mod signature_help;
+mod snippet;
pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
events::register();
@@ -34,5 +35,6 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> 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 00000000..3860d3f7
--- /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 cb0af6fc..c50832af 100644
--- a/helix-term/src/ui/completion.rs
+++ b/helix-term/src/ui/completion.rs
@@ -16,7 +16,11 @@ use tui::{buffer::Buffer as Surface, text::Span};
use std::{borrow::Cow, sync::Arc};
-use helix_core::{self as core, chars, Change, Transaction};
+use helix_core::{
+ self as core, chars,
+ snippets::{ActiveSnippet, RenderedSnippet, Snippet},
+ Change, Transaction,
+};
use helix_view::{graphics::Rect, Document, Editor};
use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent};
@@ -133,101 +137,6 @@ impl Completion {
// Then create the menu
let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| {
- fn lsp_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<Change> {
- transaction
- .changes_iter()
- .filter(|(start, end, _)| (*start..=*end).contains(&trigger_offset))
- .collect()
- }
-
let (view, doc) = current!(editor);
macro_rules! language_server {
@@ -272,18 +181,17 @@ impl Completion {
let item = item.unwrap();
match item {
- CompletionItem::Lsp(item) => doc.apply_temporary(
- &lsp_item_to_transaction(
+ CompletionItem::Lsp(item) => {
+ let (transaction, _) = lsp_item_to_transaction(
doc,
view.id,
&item.item,
language_server!(item).offset_encoding(),
trigger_offset,
- true,
replace_mode,
- ),
- view.id,
- ),
+ );
+ doc.apply_temporary(&transaction, view.id)
+ }
CompletionItem::Other(core::CompletionItem { transaction, .. }) => {
doc.apply_temporary(transaction, view.id)
}
@@ -303,7 +211,7 @@ impl Completion {
doc.append_changes_to_history(view);
// item always present here
- let (transaction, additional_edits) = match item.unwrap().clone() {
+ let (transaction, additional_edits, snippet) = match item.unwrap().clone() {
CompletionItem::Lsp(mut item) => {
let language_server = language_server!(item);
@@ -318,29 +226,40 @@ impl Completion {
};
let encoding = language_server.offset_encoding();
- let transaction = lsp_item_to_transaction(
+ let (transaction, snippet) = lsp_item_to_transaction(
doc,
view.id,
&item.item,
encoding,
trigger_offset,
- false,
replace_mode,
);
let add_edits = item.item.additional_text_edits;
- (transaction, add_edits.map(|edits| (edits, encoding)))
+ (
+ transaction,
+ add_edits.map(|edits| (edits, encoding)),
+ snippet,
+ )
}
CompletionItem::Other(core::CompletionItem { transaction, .. }) => {
- (transaction, None)
+ (transaction, None, None)
}
};
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?
@@ -581,3 +500,86 @@ impl Component for Completion {
markdown_doc.render(doc_area, surface, cx);
}
}
+fn lsp_item_to_transaction(
+ doc: &Document,
+ view_id: ViewId,
+ item: &lsp::CompletionItem,
+ offset_encoding: OffsetEncoding,
+ trigger_offset: usize,
+ replace_mode: bool,
+) -> (Transaction, Option<RenderedSnippet>) {
+ 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<Change> {
+ 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 530dd8b5..5ac401bf 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -147,6 +147,9 @@ impl EditorView {
}
if is_focused {
+ if let Some(tabstops) = Self::tabstop_highlights(doc, theme) {
+ overlay_highlights = Box::new(syntax::merge(overlay_highlights, tabstops));
+ }
let highlights = syntax::merge(
overlay_highlights,
Self::doc_selection_highlights(
@@ -592,6 +595,24 @@ impl EditorView {
Vec::new()
}
+ pub fn tabstop_highlights(
+ doc: &Document,
+ theme: &Theme,
+ ) -> Option<Vec<(usize, std::ops::Range<usize>)>> {
+ 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
@@ -1055,24 +1076,38 @@ impl EditorView {
Some(area)
}
- pub fn clear_completion(&mut self, editor: &mut Editor) {
+ pub fn clear_completion(&mut self, editor: &mut Editor) -> Option<OnKeyCallback> {
self.completion = None;
+ let mut on_next_key: Option<OnKeyCallback> = 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 {
@@ -1419,7 +1454,15 @@ impl Component for EditorView {
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 310f2715..dcdc8dc2 100644
--- a/helix-view/src/document.rs
+++ b/helix-view/src/document.rs
@@ -7,6 +7,7 @@ use helix_core::auto_pairs::AutoPairs;
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;
@@ -135,6 +136,7 @@ pub struct Document {
text: Rope,
selections: HashMap<ViewId, Selection>,
view_data: HashMap<ViewId, ViewData>,
+ pub active_snippet: Option<ActiveSnippet>,
/// Inlay hints annotations for the document, by view.
///
@@ -655,6 +657,7 @@ impl Document {
Self {
id: DocumentId::default(),
+ active_snippet: None,
path: None,
encoding,
has_bom,
@@ -2053,6 +2056,16 @@ impl Document {
}
}
+ 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 0b07f6df..6c585a8a 100644
--- a/helix-view/src/editor.rs
+++ b/helix-view/src/editor.rs
@@ -1137,6 +1137,7 @@ pub enum CompleteAction {
Applied {
trigger_offset: usize,
changes: Vec<Change>,
+ placeholder: bool,
},
}
diff --git a/theme.toml b/theme.toml
index c1e5883d..d6307c5f 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"] }