Snippet choices (#13958)

Anthony Eid , Piotr Osiewicz , and Piotr Osiewicz created

Closes: #12739

Release Notes:

Solves #12739 by
- Enable snippet parsing to successfully parse snippets with choices
- Show completion menu when tabbing to a snippet variable with multiple
choices

Todo:
 - [x] Parse snippet choices
- [x] Open completion menu when tabbing to a snippet variable with
several choices (Thank you Piotr)
- [x] Get snippet choices to reappear when tabbing back to a previous
tabstop in a snippet
 - [x] add snippet unit tests
- [x] Add fuzzy search to snippet choice completion menu & update
completion menu based on choices
 - [x] add completion menu unit tests

Current State:

Using these custom snippets

```json
  "my snippet": {
      "prefix": "log",
      "body": ["type ${1|i32, u32|} = $2"],
      "description": "Expand `log` to `console.log()`"
  },
  "my snippet2": {
      "prefix": "snip",
      "body": [
        "type ${1|i,i8,i16,i64,i32|} ${2|test,test_again,test_final|} = $3"
      ],
      "description": "snippet choice tester"
    }
```

Using snippet choices:



https://github.com/user-attachments/assets/d29fb1a2-7632-4071-944f-daeaa243e3ac

---------

Co-authored-by: Piotr Osiewicz <piotr@zed.dev>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>

Change summary

crates/editor/src/debounced_delay.rs |   1 
crates/editor/src/editor.rs          | 201 ++++++++++++++++++++++++-----
crates/editor/src/editor_tests.rs    |  39 +++++
crates/snippet/src/snippet.rs        | 119 +++++++++++++++++
4 files changed, 321 insertions(+), 39 deletions(-)

Detailed changes

crates/editor/src/debounced_delay.rs ๐Ÿ”—

@@ -5,6 +5,7 @@ use gpui::{Task, ViewContext};
 
 use crate::Editor;
 
+#[derive(Debug)]
 pub struct DebouncedDelay {
     task: Option<Task<()>>,
     cancel_channel: Option<oneshot::Sender<()>>,

crates/editor/src/editor.rs ๐Ÿ”—

@@ -883,6 +883,7 @@ struct AutocloseRegion {
 struct SnippetState {
     ranges: Vec<Vec<Range<Anchor>>>,
     active_index: usize,
+    choices: Vec<Option<Vec<String>>>,
 }
 
 #[doc(hidden)]
@@ -1000,7 +1001,7 @@ enum ContextMenuOrigin {
     GutterIndicator(DisplayRow),
 }
 
-#[derive(Clone)]
+#[derive(Clone, Debug)]
 struct CompletionsMenu {
     id: CompletionId,
     sort_completions: bool,
@@ -1011,10 +1012,100 @@ struct CompletionsMenu {
     matches: Arc<[StringMatch]>,
     selected_item: usize,
     scroll_handle: UniformListScrollHandle,
-    selected_completion_documentation_resolve_debounce: Arc<Mutex<DebouncedDelay>>,
+    selected_completion_documentation_resolve_debounce: Option<Arc<Mutex<DebouncedDelay>>>,
 }
 
 impl CompletionsMenu {
+    fn new(
+        id: CompletionId,
+        sort_completions: bool,
+        initial_position: Anchor,
+        buffer: Model<Buffer>,
+        completions: Box<[Completion]>,
+    ) -> Self {
+        let match_candidates = completions
+            .iter()
+            .enumerate()
+            .map(|(id, completion)| StringMatchCandidate::new(id, completion.label.text.clone()))
+            .collect();
+
+        Self {
+            id,
+            sort_completions,
+            initial_position,
+            buffer,
+            completions: Arc::new(RwLock::new(completions)),
+            match_candidates,
+            matches: Vec::new().into(),
+            selected_item: 0,
+            scroll_handle: UniformListScrollHandle::new(),
+            selected_completion_documentation_resolve_debounce: Some(Arc::new(Mutex::new(
+                DebouncedDelay::new(),
+            ))),
+        }
+    }
+
+    fn new_snippet_choices(
+        id: CompletionId,
+        sort_completions: bool,
+        choices: &Vec<String>,
+        selection: Range<Anchor>,
+        buffer: Model<Buffer>,
+    ) -> Self {
+        let completions = choices
+            .iter()
+            .map(|choice| Completion {
+                old_range: selection.start.text_anchor..selection.end.text_anchor,
+                new_text: choice.to_string(),
+                label: CodeLabel {
+                    text: choice.to_string(),
+                    runs: Default::default(),
+                    filter_range: Default::default(),
+                },
+                server_id: LanguageServerId(usize::MAX),
+                documentation: None,
+                lsp_completion: Default::default(),
+                confirm: None,
+            })
+            .collect();
+
+        let match_candidates = choices
+            .iter()
+            .enumerate()
+            .map(|(id, completion)| StringMatchCandidate::new(id, completion.to_string()))
+            .collect();
+        let matches = choices
+            .iter()
+            .enumerate()
+            .map(|(id, completion)| StringMatch {
+                candidate_id: id,
+                score: 1.,
+                positions: vec![],
+                string: completion.clone(),
+            })
+            .collect();
+        Self {
+            id,
+            sort_completions,
+            initial_position: selection.start,
+            buffer,
+            completions: Arc::new(RwLock::new(completions)),
+            match_candidates,
+            matches,
+            selected_item: 0,
+            scroll_handle: UniformListScrollHandle::new(),
+            selected_completion_documentation_resolve_debounce: Some(Arc::new(Mutex::new(
+                DebouncedDelay::new(),
+            ))),
+        }
+    }
+
+    fn suppress_documentation_resolution(mut self) -> Self {
+        self.selected_completion_documentation_resolve_debounce
+            .take();
+        self
+    }
+
     fn select_first(
         &mut self,
         provider: Option<&dyn CompletionProvider>,
@@ -1115,6 +1206,12 @@ impl CompletionsMenu {
         let Some(provider) = provider else {
             return;
         };
+        let Some(documentation_resolve) = self
+            .selected_completion_documentation_resolve_debounce
+            .as_ref()
+        else {
+            return;
+        };
 
         let resolve_task = provider.resolve_completions(
             self.buffer.clone(),
@@ -1127,15 +1224,13 @@ impl CompletionsMenu {
             EditorSettings::get_global(cx).completion_documentation_secondary_query_debounce;
         let delay = Duration::from_millis(delay_ms);
 
-        self.selected_completion_documentation_resolve_debounce
-            .lock()
-            .fire_new(delay, cx, |_, cx| {
-                cx.spawn(move |this, mut cx| async move {
-                    if let Some(true) = resolve_task.await.log_err() {
-                        this.update(&mut cx, |_, cx| cx.notify()).ok();
-                    }
-                })
-            });
+        documentation_resolve.lock().fire_new(delay, cx, |_, cx| {
+            cx.spawn(move |this, mut cx| async move {
+                if let Some(true) = resolve_task.await.log_err() {
+                    this.update(&mut cx, |_, cx| cx.notify()).ok();
+                }
+            })
+        });
     }
 
     fn visible(&self) -> bool {
@@ -1418,6 +1513,7 @@ impl CompletionsMenu {
     }
 }
 
+#[derive(Clone)]
 struct AvailableCodeAction {
     excerpt_id: ExcerptId,
     action: CodeAction,
@@ -4386,6 +4482,10 @@ impl Editor {
             return;
         };
 
+        if !self.snippet_stack.is_empty() && self.context_menu.read().as_ref().is_some() {
+            return;
+        }
+
         let position = self.selections.newest_anchor().head();
         let (buffer, buffer_position) =
             if let Some(output) = self.buffer.read(cx).text_anchor_for_position(position, cx) {
@@ -4431,30 +4531,13 @@ impl Editor {
                 })?;
                 let completions = completions.await.log_err();
                 let menu = if let Some(completions) = completions {
-                    let mut menu = CompletionsMenu {
+                    let mut menu = CompletionsMenu::new(
                         id,
                         sort_completions,
-                        initial_position: position,
-                        match_candidates: completions
-                            .iter()
-                            .enumerate()
-                            .map(|(id, completion)| {
-                                StringMatchCandidate::new(
-                                    id,
-                                    completion.label.text[completion.label.filter_range.clone()]
-                                        .into(),
-                                )
-                            })
-                            .collect(),
-                        buffer: buffer.clone(),
-                        completions: Arc::new(RwLock::new(completions.into())),
-                        matches: Vec::new().into(),
-                        selected_item: 0,
-                        scroll_handle: UniformListScrollHandle::new(),
-                        selected_completion_documentation_resolve_debounce: Arc::new(Mutex::new(
-                            DebouncedDelay::new(),
-                        )),
-                    };
+                        position,
+                        buffer.clone(),
+                        completions.into(),
+                    );
                     menu.filter(query.as_deref(), cx.background_executor().clone())
                         .await;
 
@@ -4657,7 +4740,11 @@ impl Editor {
         self.transact(cx, |this, cx| {
             if let Some(mut snippet) = snippet {
                 snippet.text = text.to_string();
-                for tabstop in snippet.tabstops.iter_mut().flatten() {
+                for tabstop in snippet
+                    .tabstops
+                    .iter_mut()
+                    .flat_map(|tabstop| tabstop.ranges.iter_mut())
+                {
                     tabstop.start -= common_prefix_len as isize;
                     tabstop.end -= common_prefix_len as isize;
                 }
@@ -5693,6 +5780,27 @@ impl Editor {
         context_menu
     }
 
+    fn show_snippet_choices(
+        &mut self,
+        choices: &Vec<String>,
+        selection: Range<Anchor>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if selection.start.buffer_id.is_none() {
+            return;
+        }
+        let buffer_id = selection.start.buffer_id.unwrap();
+        let buffer = self.buffer().read(cx).buffer(buffer_id);
+        let id = post_inc(&mut self.next_completion_id);
+
+        if let Some(buffer) = buffer {
+            *self.context_menu.write() = Some(ContextMenu::Completions(
+                CompletionsMenu::new_snippet_choices(id, true, choices, selection, buffer)
+                    .suppress_documentation_resolution(),
+            ));
+        }
+    }
+
     pub fn insert_snippet(
         &mut self,
         insertion_ranges: &[Range<usize>],
@@ -5702,6 +5810,7 @@ impl Editor {
         struct Tabstop<T> {
             is_end_tabstop: bool,
             ranges: Vec<Range<T>>,
+            choices: Option<Vec<String>>,
         }
 
         let tabstops = self.buffer.update(cx, |buffer, cx| {
@@ -5721,10 +5830,11 @@ impl Editor {
                 .tabstops
                 .iter()
                 .map(|tabstop| {
-                    let is_end_tabstop = tabstop.first().map_or(false, |tabstop| {
+                    let is_end_tabstop = tabstop.ranges.first().map_or(false, |tabstop| {
                         tabstop.is_empty() && tabstop.start == snippet.text.len() as isize
                     });
                     let mut tabstop_ranges = tabstop
+                        .ranges
                         .iter()
                         .flat_map(|tabstop_range| {
                             let mut delta = 0_isize;
@@ -5746,6 +5856,7 @@ impl Editor {
                     Tabstop {
                         is_end_tabstop,
                         ranges: tabstop_ranges,
+                        choices: tabstop.choices.clone(),
                     }
                 })
                 .collect::<Vec<_>>()
@@ -5755,16 +5866,29 @@ impl Editor {
                 s.select_ranges(tabstop.ranges.iter().cloned());
             });
 
+            if let Some(choices) = &tabstop.choices {
+                if let Some(selection) = tabstop.ranges.first() {
+                    self.show_snippet_choices(choices, selection.clone(), cx)
+                }
+            }
+
             // If we're already at the last tabstop and it's at the end of the snippet,
             // we're done, we don't need to keep the state around.
             if !tabstop.is_end_tabstop {
+                let choices = tabstops
+                    .iter()
+                    .map(|tabstop| tabstop.choices.clone())
+                    .collect();
+
                 let ranges = tabstops
                     .into_iter()
                     .map(|tabstop| tabstop.ranges)
                     .collect::<Vec<_>>();
+
                 self.snippet_stack.push(SnippetState {
                     active_index: 0,
                     ranges,
+                    choices,
                 });
             }
 
@@ -5839,6 +5963,13 @@ impl Editor {
                 self.change_selections(Some(Autoscroll::fit()), cx, |s| {
                     s.select_anchor_ranges(current_ranges.iter().cloned())
                 });
+
+                if let Some(choices) = &snippet.choices[snippet.active_index] {
+                    if let Some(selection) = current_ranges.first() {
+                        self.show_snippet_choices(&choices, selection.clone(), cx);
+                    }
+                }
+
                 // If snippet state is not at the last tabstop, push it back on the stack
                 if snippet.active_index + 1 < snippet.ranges.len() {
                     self.snippet_stack.push(snippet);

crates/editor/src/editor_tests.rs ๐Ÿ”—

@@ -6551,6 +6551,45 @@ async fn test_auto_replace_emoji_shortcode(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_snippet_placeholder_choices(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
+    let (text, insertion_ranges) = marked_text_ranges(
+        indoc! {"
+            ห‡
+        "},
+        false,
+    );
+
+    let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx));
+    let (editor, cx) = cx.add_window_view(|cx| build_editor(buffer, cx));
+
+    _ = editor.update(cx, |editor, cx| {
+        let snippet = Snippet::parse("type ${1|,i32,u32|} = $2").unwrap();
+
+        editor
+            .insert_snippet(&insertion_ranges, snippet, cx)
+            .unwrap();
+
+        fn assert(editor: &mut Editor, cx: &mut ViewContext<Editor>, marked_text: &str) {
+            let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false);
+            assert_eq!(editor.text(cx), expected_text);
+            assert_eq!(editor.selections.ranges::<usize>(cx), selection_ranges);
+        }
+
+        assert(
+            editor,
+            cx,
+            indoc! {"
+            type ยซยป =โ€ข
+            "},
+        );
+
+        assert!(editor.context_menu_visible(), "There should be a matches");
+    });
+}
+
 #[gpui::test]
 async fn test_snippets(cx: &mut gpui::TestAppContext) {
     init_test(cx, |_| {});

crates/snippet/src/snippet.rs ๐Ÿ”—

@@ -8,7 +8,11 @@ pub struct Snippet {
     pub tabstops: Vec<TabStop>,
 }
 
-type TabStop = SmallVec<[Range<isize>; 2]>;
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct TabStop {
+    pub ranges: SmallVec<[Range<isize>; 2]>,
+    pub choices: Option<Vec<String>>,
+}
 
 impl Snippet {
     pub fn parse(source: &str) -> Result<Self> {
@@ -24,7 +28,11 @@ impl Snippet {
         if let Some(final_tabstop) = final_tabstop {
             tabstops.push(final_tabstop);
         } else {
-            let end_tabstop = [len..len].into_iter().collect();
+            let end_tabstop = TabStop {
+                ranges: [len..len].into_iter().collect(),
+                choices: None,
+            };
+
             if !tabstops.last().map_or(false, |t| *t == end_tabstop) {
                 tabstops.push(end_tabstop);
             }
@@ -88,11 +96,17 @@ fn parse_tabstop<'a>(
 ) -> Result<&'a str> {
     let tabstop_start = text.len();
     let tabstop_index;
+    let mut choices = None;
+
     if source.starts_with('{') {
         let (index, rest) = parse_int(&source[1..])?;
         tabstop_index = index;
         source = rest;
 
+        if source.starts_with("|") {
+            (source, choices) = parse_choices(&source[1..], text)?;
+        }
+
         if source.starts_with(':') {
             source = parse_snippet(&source[1..], true, text, tabstops)?;
         }
@@ -110,7 +124,11 @@ fn parse_tabstop<'a>(
 
     tabstops
         .entry(tabstop_index)
-        .or_default()
+        .or_insert_with(|| TabStop {
+            ranges: Default::default(),
+            choices,
+        })
+        .ranges
         .push(tabstop_start as isize..text.len() as isize);
     Ok(source)
 }
@@ -126,6 +144,61 @@ fn parse_int(source: &str) -> Result<(usize, &str)> {
     Ok((prefix.parse()?, suffix))
 }
 
+fn parse_choices<'a>(
+    mut source: &'a str,
+    text: &mut String,
+) -> Result<(&'a str, Option<Vec<String>>)> {
+    let mut found_default_choice = false;
+    let mut current_choice = String::new();
+    let mut choices = Vec::new();
+
+    loop {
+        match source.chars().next() {
+            None => return Ok(("", Some(choices))),
+            Some('\\') => {
+                source = &source[1..];
+
+                if let Some(c) = source.chars().next() {
+                    if !found_default_choice {
+                        current_choice.push(c);
+                        text.push(c);
+                    }
+                    source = &source[c.len_utf8()..];
+                }
+            }
+            Some(',') => {
+                found_default_choice = true;
+                source = &source[1..];
+                choices.push(current_choice);
+                current_choice = String::new();
+            }
+            Some('|') => {
+                source = &source[1..];
+                choices.push(current_choice);
+                return Ok((source, Some(choices)));
+            }
+            Some(_) => {
+                let chunk_end = source.find([',', '|', '\\']);
+
+                if chunk_end.is_none() {
+                    return Err(anyhow!(
+                        "Placeholder choice doesn't contain closing pipe-character '|'"
+                    ));
+                }
+
+                let (chunk, rest) = source.split_at(chunk_end.unwrap());
+
+                if !found_default_choice {
+                    text.push_str(chunk);
+                }
+
+                current_choice.push_str(chunk);
+                source = rest;
+            }
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -142,11 +215,13 @@ mod tests {
         let snippet = Snippet::parse("one$1two").unwrap();
         assert_eq!(snippet.text, "onetwo");
         assert_eq!(tabstops(&snippet), &[vec![3..3], vec![6..6]]);
+        assert_eq!(tabstop_choices(&snippet), &[&None, &None]);
 
         // Multi-digit numbers
         let snippet = Snippet::parse("one$123-$99-two").unwrap();
         assert_eq!(snippet.text, "one--two");
         assert_eq!(tabstops(&snippet), &[vec![4..4], vec![3..3], vec![8..8]]);
+        assert_eq!(tabstop_choices(&snippet), &[&None, &None, &None]);
     }
 
     #[test]
@@ -157,6 +232,7 @@ mod tests {
         // an additional tabstop at the end.
         assert_eq!(snippet.text, r#"foo."#);
         assert_eq!(tabstops(&snippet), &[vec![4..4]]);
+        assert_eq!(tabstop_choices(&snippet), &[&None]);
     }
 
     #[test]
@@ -167,6 +243,7 @@ mod tests {
         // don't insert an additional tabstop at the end.
         assert_eq!(snippet.text, r#"<div class=""></div>"#);
         assert_eq!(tabstops(&snippet), &[vec![12..12], vec![14..14]]);
+        assert_eq!(tabstop_choices(&snippet), &[&None, &None]);
     }
 
     #[test]
@@ -177,6 +254,30 @@ mod tests {
             tabstops(&snippet),
             &[vec![3..6], vec![11..15], vec![15..15]]
         );
+        assert_eq!(tabstop_choices(&snippet), &[&None, &None, &None]);
+    }
+
+    #[test]
+    fn test_snippet_with_choice_placeholders() {
+        let snippet = Snippet::parse("type ${1|i32, u32|} = $2")
+            .expect("Should be able to unpack choice placeholders");
+
+        assert_eq!(snippet.text, "type i32 = ");
+        assert_eq!(tabstops(&snippet), &[vec![5..8], vec![11..11],]);
+        assert_eq!(
+            tabstop_choices(&snippet),
+            &[&Some(vec!["i32".to_string(), " u32".to_string()]), &None]
+        );
+
+        let snippet = Snippet::parse(r"${1|\$\{1\|one\,two\,tree\|\}|}")
+            .expect("Should be able to parse choice with escape characters");
+
+        assert_eq!(snippet.text, "${1|one,two,tree|}");
+        assert_eq!(tabstops(&snippet), &[vec![0..18], vec![18..18]]);
+        assert_eq!(
+            tabstop_choices(&snippet),
+            &[&Some(vec!["${1|one,two,tree|}".to_string(),]), &None]
+        );
     }
 
     #[test]
@@ -196,6 +297,10 @@ mod tests {
                 vec![40..40],
             ]
         );
+        assert_eq!(
+            tabstop_choices(&snippet),
+            &[&None, &None, &None, &None, &None]
+        );
     }
 
     #[test]
@@ -203,10 +308,12 @@ mod tests {
         let snippet = Snippet::parse("\"\\$schema\": $1").unwrap();
         assert_eq!(snippet.text, "\"$schema\": ");
         assert_eq!(tabstops(&snippet), &[vec![11..11]]);
+        assert_eq!(tabstop_choices(&snippet), &[&None]);
 
         let snippet = Snippet::parse("{a\\}").unwrap();
         assert_eq!(snippet.text, "{a}");
         assert_eq!(tabstops(&snippet), &[vec![3..3]]);
+        assert_eq!(tabstop_choices(&snippet), &[&None]);
 
         // backslash not functioning as an escape
         let snippet = Snippet::parse("a\\b").unwrap();
@@ -221,6 +328,10 @@ mod tests {
     }
 
     fn tabstops(snippet: &Snippet) -> Vec<Vec<Range<isize>>> {
-        snippet.tabstops.iter().map(|t| t.to_vec()).collect()
+        snippet.tabstops.iter().map(|t| t.ranges.to_vec()).collect()
+    }
+
+    fn tabstop_choices(snippet: &Snippet) -> Vec<&Option<Vec<String>>> {
+        snippet.tabstops.iter().map(|t| &t.choices).collect()
     }
 }