editor: Add setting for snippet sorting behavior for code completion (#29429)

Smit Barmase created

Added `snippet_sort_order`, which determines how snippets are sorted
relative to other completion items. It can have the values `top`,
`bottom`, or `inline`, with `inline` being the default.

This mimics VS Code’s setting:
https://code.visualstudio.com/docs/editing/intellisense#_snippets-in-suggestions

Release Notes:

- Added support for `snippet_sort_order` to control snippet sorting
behavior in code completion menus.

Change summary

assets/settings/default.json               | 16 ++++++++
crates/editor/src/code_completion_tests.rs | 33 +++++++++--------
crates/editor/src/code_context_menus.rs    | 20 +++++++++-
crates/editor/src/editor.rs                | 13 ++++++
crates/editor/src/editor_settings.rs       | 20 ++++++++++
docs/src/configuring-zed.md                | 44 +++++++++++++++++++++++
6 files changed, 126 insertions(+), 20 deletions(-)

Detailed changes

assets/settings/default.json πŸ”—

@@ -167,7 +167,23 @@
   // Default: not set, defaults to "bar"
   "cursor_shape": null,
   // Determines when the mouse cursor should be hidden in an editor or input box.
+  //
+  // 1. Never hide the mouse cursor:
+  //    "never"
+  // 2. Hide only when typing:
+  //    "on_typing"
+  // 3. Hide on both typing and cursor movement:
+  //    "on_typing_and_movement"
   "hide_mouse": "on_typing_and_movement",
+  // Determines how snippets are sorted relative to other completion items.
+  //
+  // 1. Place snippets at the top of the completion list:
+  //    "top"
+  // 2. Place snippets normally without any preference:
+  //    "inline"
+  // 3. Place snippets at the bottom of the completion list:
+  //    "bottom"
+  "snippet_sort_order": "inline",
   // How to highlight the current line in the editor.
   //
   // 1. Don't highlight the current line:

crates/editor/src/code_completion_tests.rs πŸ”—

@@ -1,4 +1,7 @@
-use crate::code_context_menus::{CompletionsMenu, SortableMatch};
+use crate::{
+    code_context_menus::{CompletionsMenu, SortableMatch},
+    editor_settings::SnippetSortOrder,
+};
 use fuzzy::StringMatch;
 use gpui::TestAppContext;
 
@@ -74,7 +77,7 @@ fn test_sort_matches_local_variable_over_global_variable(_cx: &mut TestAppContex
             sort_key: (2, "floorf128"),
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
     assert_eq!(
         matches[0].string_match.string.as_str(),
         "foo_bar_qux",
@@ -122,7 +125,7 @@ fn test_sort_matches_local_variable_over_global_variable(_cx: &mut TestAppContex
             sort_key: (1, "foo_bar_qux"),
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
     assert_eq!(
         matches[0].string_match.string.as_str(),
         "foo_bar_qux",
@@ -185,7 +188,7 @@ fn test_sort_matches_local_variable_over_global_enum(_cx: &mut TestAppContext) {
             sort_key: (0, "while let"),
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
     assert_eq!(
         matches[0].string_match.string.as_str(),
         "element_type",
@@ -234,7 +237,7 @@ fn test_sort_matches_local_variable_over_global_enum(_cx: &mut TestAppContext) {
             sort_key: (2, "REPLACEMENT_CHARACTER"),
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
     assert_eq!(
         matches[0].string_match.string.as_str(),
         "element_type",
@@ -272,7 +275,7 @@ fn test_sort_matches_local_variable_over_global_enum(_cx: &mut TestAppContext) {
             sort_key: (1, "element_type"),
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
     assert_eq!(
         matches[0].string_match.string.as_str(),
         "ElementType",
@@ -335,7 +338,7 @@ fn test_sort_matches_for_unreachable(_cx: &mut TestAppContext) {
             sort_key: (2, "unreachable_unchecked"),
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
     assert_eq!(
         matches[0].string_match.string.as_str(),
         "unreachable!(…)",
@@ -379,7 +382,7 @@ fn test_sort_matches_for_unreachable(_cx: &mut TestAppContext) {
             sort_key: (3, "unreachable_unchecked"),
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
     assert_eq!(
         matches[0].string_match.string.as_str(),
         "unreachable!(…)",
@@ -423,7 +426,7 @@ fn test_sort_matches_for_unreachable(_cx: &mut TestAppContext) {
             sort_key: (2, "unreachable_unchecked"),
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
     assert_eq!(
         matches[0].string_match.string.as_str(),
         "unreachable!(…)",
@@ -467,7 +470,7 @@ fn test_sort_matches_for_unreachable(_cx: &mut TestAppContext) {
             sort_key: (2, "unreachable_unchecked"),
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
     assert_eq!(
         matches[0].string_match.string.as_str(),
         "unreachable!(…)",
@@ -503,7 +506,7 @@ fn test_sort_matches_variable_and_constants_over_function(_cx: &mut TestAppConte
             sort_key: (1, "var"), // variable
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
     assert_eq!(
         matches[0].string_match.candidate_id, 1,
         "Match order not expected"
@@ -539,7 +542,7 @@ fn test_sort_matches_variable_and_constants_over_function(_cx: &mut TestAppConte
             sort_key: (2, "var"), // constant
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
     assert_eq!(
         matches[0].string_match.candidate_id, 1,
         "Match order not expected"
@@ -622,7 +625,7 @@ fn test_sort_matches_jsx_event_handler(_cx: &mut TestAppContext) {
             sort_key: (3, "className?"),
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
     assert_eq!(
         matches[0].string_match.string, "onCut?",
         "Match order not expected"
@@ -944,7 +947,7 @@ fn test_sort_matches_jsx_event_handler(_cx: &mut TestAppContext) {
             sort_key: (3, "onLoadedData?"),
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
     assert_eq!(
         matches
             .iter()
@@ -996,7 +999,7 @@ fn test_sort_matches_for_snippets(_cx: &mut TestAppContext) {
             sort_key: (2, "println!(…)"),
         },
     ];
-    CompletionsMenu::sort_matches(&mut matches, query);
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::Top);
     assert_eq!(
         matches[0].string_match.string.as_str(),
         "println!(…)",

crates/editor/src/code_context_menus.rs πŸ”—

@@ -25,6 +25,7 @@ use task::ResolvedTask;
 use ui::{Color, IntoElement, ListItem, Pixels, Popover, Styled, prelude::*};
 use util::ResultExt;
 
+use crate::editor_settings::SnippetSortOrder;
 use crate::hover_popover::{hover_markdown_style, open_markdown_url};
 use crate::{
     CodeActionProvider, CompletionId, CompletionItemKind, CompletionProvider, DisplayRow, Editor,
@@ -184,6 +185,7 @@ pub struct CompletionsMenu {
     pub(super) ignore_completion_provider: bool,
     last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
     markdown_element: Option<Entity<Markdown>>,
+    snippet_sort_order: SnippetSortOrder,
 }
 
 impl CompletionsMenu {
@@ -195,6 +197,7 @@ impl CompletionsMenu {
         initial_position: Anchor,
         buffer: Entity<Buffer>,
         completions: Box<[Completion]>,
+        snippet_sort_order: SnippetSortOrder,
     ) -> Self {
         let match_candidates = completions
             .iter()
@@ -217,6 +220,7 @@ impl CompletionsMenu {
             resolve_completions: true,
             last_rendered_range: RefCell::new(None).into(),
             markdown_element: None,
+            snippet_sort_order,
         }
     }
 
@@ -226,6 +230,7 @@ impl CompletionsMenu {
         choices: &Vec<String>,
         selection: Range<Anchor>,
         buffer: Entity<Buffer>,
+        snippet_sort_order: SnippetSortOrder,
     ) -> Self {
         let completions = choices
             .iter()
@@ -275,6 +280,7 @@ impl CompletionsMenu {
             ignore_completion_provider: false,
             last_rendered_range: RefCell::new(None).into(),
             markdown_element: None,
+            snippet_sort_order,
         }
     }
 
@@ -657,7 +663,11 @@ impl CompletionsMenu {
         )
     }
 
-    pub fn sort_matches(matches: &mut Vec<SortableMatch<'_>>, query: Option<&str>) {
+    pub fn sort_matches(
+        matches: &mut Vec<SortableMatch<'_>>,
+        query: Option<&str>,
+        snippet_sort_order: SnippetSortOrder,
+    ) {
         #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
         enum MatchTier<'a> {
             WordStartMatch {
@@ -703,7 +713,11 @@ impl CompletionsMenu {
                 MatchTier::OtherMatch { sort_score }
             } else {
                 let sort_score_int = Reverse(if score >= FUZZY_THRESHOLD { 1 } else { 0 });
-                let sort_snippet = Reverse(if mat.is_snippet { 1 } else { 0 });
+                let sort_snippet = match snippet_sort_order {
+                    SnippetSortOrder::Top => Reverse(if mat.is_snippet { 1 } else { 0 }),
+                    SnippetSortOrder::Bottom => Reverse(if mat.is_snippet { 0 } else { 1 }),
+                    SnippetSortOrder::Inline => Reverse(0),
+                };
                 MatchTier::WordStartMatch {
                     sort_score_int,
                     sort_snippet,
@@ -770,7 +784,7 @@ impl CompletionsMenu {
                 })
                 .collect();
 
-            Self::sort_matches(&mut sortable_items, query);
+            Self::sort_matches(&mut sortable_items, query, self.snippet_sort_order);
 
             matches = sortable_items
                 .into_iter()

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

@@ -4733,6 +4733,8 @@ impl Editor {
             .as_ref()
             .map_or(true, |provider| provider.filter_completions());
 
+        let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order;
+
         let id = post_inc(&mut self.next_completion_id);
         let task = cx.spawn_in(window, async move |editor, cx| {
             async move {
@@ -4780,6 +4782,7 @@ impl Editor {
                         position,
                         buffer.clone(),
                         completions.into(),
+                        snippet_sort_order,
                     );
 
                     menu.filter(
@@ -8229,10 +8232,18 @@ impl Editor {
         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);
+        let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order;
 
         if let Some(buffer) = buffer {
             *self.context_menu.borrow_mut() = Some(CodeContextMenu::Completions(
-                CompletionsMenu::new_snippet_choices(id, true, choices, selection, buffer),
+                CompletionsMenu::new_snippet_choices(
+                    id,
+                    true,
+                    choices,
+                    selection,
+                    buffer,
+                    snippet_sort_order,
+                ),
             ));
         }
     }

crates/editor/src/editor_settings.rs πŸ”—

@@ -39,6 +39,7 @@ pub struct EditorSettings {
     pub go_to_definition_fallback: GoToDefinitionFallback,
     pub jupyter: Jupyter,
     pub hide_mouse: Option<HideMouseMode>,
+    pub snippet_sort_order: SnippetSortOrder,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -239,6 +240,21 @@ pub enum HideMouseMode {
     OnTypingAndMovement,
 }
 
+/// Determines how snippets are sorted relative to other completion items.
+///
+/// Default: inline
+#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum SnippetSortOrder {
+    /// Place snippets at the top of the completion list
+    Top,
+    /// Sort snippets normally using the default comparison logic
+    #[default]
+    Inline,
+    /// Place snippets at the bottom of the completion list
+    Bottom,
+}
+
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 pub struct EditorSettingsContent {
     /// Whether the cursor blinks in the editor.
@@ -254,6 +270,10 @@ pub struct EditorSettingsContent {
     ///
     /// Default: on_typing_and_movement
     pub hide_mouse: Option<HideMouseMode>,
+    /// Determines how snippets are sorted relative to other completion items.
+    ///
+    /// Default: inline
+    pub snippet_sort_order: Option<SnippetSortOrder>,
     /// How to highlight the current line in the editor.
     ///
     /// Default: all

docs/src/configuring-zed.md πŸ”—

@@ -592,7 +592,49 @@ List of `string` values
 
 **Options**
 
-`boolean` values
+1. Never hide the mouse cursor:
+
+```json
+"hide_mouse": "never"
+```
+
+2. Hide only when typing:
+
+```json
+"hide_mouse": "on_typing"
+```
+
+3. Hide on both typing and cursor movement:
+
+```json
+"hide_mouse": "on_typing_and_movement"
+```
+
+## Snippet Sort Order
+
+- Description: Determines how snippets are sorted relative to other completion items.
+- Setting: `snippet_sort_order`
+- Default: `inline`
+
+**Options**
+
+1. Place snippets at the top of the completion list:
+
+```json
+"snippet_sort_order": "top"
+```
+
+2. Place snippets normally without any preference:
+
+```json
+"snippet_sort_order": "inline"
+```
+
+3. Place snippets at the bottom of the completion list:
+
+```json
+"snippet_sort_order": "bottom"
+```
 
 ## Editor Scrollbar