editor: Fix package version completion partial accept and improve sorting (#43473)

Smit Barmase created

Closes #41723

This PR fixes an issue with accepting partial semver completions by
including `.` in the completion query. This makes the editor treat the
entire version string as the query, instead of breaking segment at last
`.` .

This PR also adds a test for sorting semver completions. The actual
sorting fix is handled in the `package-version-server` by having it
provide `sort_text`. More:
https://github.com/zed-industries/package-version-server/pull/10

<img width="600" alt="image"
src="https://github.com/user-attachments/assets/7657912f-c6da-4e05-956b-1c044918304f"
/>

Release Notes:

- Fixed an issue where accepting a completion for a semver version in
package.json would append the suggestion to the existing text instead of
replacing it.
- Improved the sorting of semver completions in package.json so the
latest versions appear at the top.

Change summary

crates/editor/src/code_completion_tests.rs | 122 ++++++++++++++++++++++-
crates/languages/src/json/config.toml      |   3 
2 files changed, 117 insertions(+), 8 deletions(-)

Detailed changes

crates/editor/src/code_completion_tests.rs 🔗

@@ -239,6 +239,89 @@ async fn test_fuzzy_over_sort_positions(cx: &mut TestAppContext) {
     assert_eq!(matches[2].string, "fetch_code_lens");
 }
 
+#[gpui::test]
+async fn test_semver_label_sort_by_latest_version(cx: &mut TestAppContext) {
+    let mut versions = [
+        "10.4.112",
+        "10.4.22",
+        "10.4.2",
+        "10.4.20",
+        "10.4.21",
+        "10.4.12",
+        // Pre-release versions
+        "10.4.22-alpha",
+        "10.4.22-beta.1",
+        "10.4.22-rc.1",
+        // Build metadata versions
+        "10.4.21+build.123",
+        "10.4.20+20210327",
+    ];
+    versions.sort_by(|a, b| {
+        match (
+            semver::Version::parse(a).ok(),
+            semver::Version::parse(b).ok(),
+        ) {
+            (Some(a_ver), Some(b_ver)) => b_ver.cmp(&a_ver),
+            _ => std::cmp::Ordering::Equal,
+        }
+    });
+    let completions: Vec<_> = versions
+        .iter()
+        .enumerate()
+        .map(|(i, version)| {
+            // This sort text would come from the LSP
+            let sort_text = format!("{:08}", i);
+            CompletionBuilder::new(version, None, &sort_text, None)
+        })
+        .collect();
+
+    // Case 1: User types just the major and minor version
+    let matches =
+        filter_and_sort_matches("10.4.", &completions, SnippetSortOrder::default(), cx).await;
+    // Versions are ordered by recency (latest first)
+    let expected_versions = [
+        "10.4.112",
+        "10.4.22",
+        "10.4.22-rc.1",
+        "10.4.22-beta.1",
+        "10.4.22-alpha",
+        "10.4.21+build.123",
+        "10.4.21",
+        "10.4.20+20210327",
+        "10.4.20",
+        "10.4.12",
+        "10.4.2",
+    ];
+    for (match_item, expected) in matches.iter().zip(expected_versions.iter()) {
+        assert_eq!(match_item.string.as_ref() as &str, *expected);
+    }
+
+    // Case 2: User types the major, minor, and patch version
+    let matches =
+        filter_and_sort_matches("10.4.2", &completions, SnippetSortOrder::default(), cx).await;
+    let expected_versions = [
+        // Exact match comes first
+        "10.4.2",
+        // Ordered by recency with exact major, minor, and patch versions
+        "10.4.22",
+        "10.4.22-rc.1",
+        "10.4.22-beta.1",
+        "10.4.22-alpha",
+        "10.4.21+build.123",
+        "10.4.21",
+        "10.4.20+20210327",
+        "10.4.20",
+        // Versions with non-exact patch versions are ordered by fuzzy score
+        // Higher fuzzy score than 112 patch version since "2" appears before "1"
+        // in "12", making it rank higher than "112"
+        "10.4.12",
+        "10.4.112",
+    ];
+    for (match_item, expected) in matches.iter().zip(expected_versions.iter()) {
+        assert_eq!(match_item.string.as_ref() as &str, *expected);
+    }
+}
+
 async fn test_for_each_prefix<F>(
     target: &str,
     completions: &Vec<Completion>,
@@ -259,30 +342,55 @@ struct CompletionBuilder;
 
 impl CompletionBuilder {
     fn constant(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion {
-        Self::new(label, filter_text, sort_text, CompletionItemKind::CONSTANT)
+        Self::new(
+            label,
+            filter_text,
+            sort_text,
+            Some(CompletionItemKind::CONSTANT),
+        )
     }
 
     fn function(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion {
-        Self::new(label, filter_text, sort_text, CompletionItemKind::FUNCTION)
+        Self::new(
+            label,
+            filter_text,
+            sort_text,
+            Some(CompletionItemKind::FUNCTION),
+        )
     }
 
     fn method(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion {
-        Self::new(label, filter_text, sort_text, CompletionItemKind::METHOD)
+        Self::new(
+            label,
+            filter_text,
+            sort_text,
+            Some(CompletionItemKind::METHOD),
+        )
     }
 
     fn variable(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion {
-        Self::new(label, filter_text, sort_text, CompletionItemKind::VARIABLE)
+        Self::new(
+            label,
+            filter_text,
+            sort_text,
+            Some(CompletionItemKind::VARIABLE),
+        )
     }
 
     fn snippet(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion {
-        Self::new(label, filter_text, sort_text, CompletionItemKind::SNIPPET)
+        Self::new(
+            label,
+            filter_text,
+            sort_text,
+            Some(CompletionItemKind::SNIPPET),
+        )
     }
 
     fn new(
         label: &str,
         filter_text: Option<&str>,
         sort_text: &str,
-        kind: CompletionItemKind,
+        kind: Option<CompletionItemKind>,
     ) -> Completion {
         Completion {
             replace_range: Anchor::MIN..Anchor::MAX,
@@ -294,7 +402,7 @@ impl CompletionBuilder {
                 server_id: LanguageServerId(0),
                 lsp_completion: Box::new(CompletionItem {
                     label: label.to_string(),
-                    kind: Some(kind),
+                    kind: kind,
                     sort_text: Some(sort_text.to_string()),
                     filter_text: filter_text.map(|text| text.to_string()),
                     ..Default::default()

crates/languages/src/json/config.toml 🔗

@@ -11,5 +11,6 @@ brackets = [
 tab_size = 2
 prettier_parser_name = "json"
 debuggers = ["JavaScript"]
+
 [overrides.string]
-completion_query_characters = [":", " "]
+completion_query_characters = [":", " ", "."]