Infer completions old ranges based on the syntax tree

Max Brunsfeld created

Change summary

Cargo.lock                    |   1 
crates/language/Cargo.toml    |   4 
crates/language/src/buffer.rs |  30 +++++++
crates/project/src/project.rs | 143 +++++++++++++++++++++++++++++-------
4 files changed, 148 insertions(+), 30 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2651,6 +2651,7 @@ dependencies = [
  "tree-sitter",
  "tree-sitter-json 0.19.0",
  "tree-sitter-rust",
+ "tree-sitter-typescript",
  "unindent",
  "util",
 ]

crates/language/Cargo.toml 🔗

@@ -15,6 +15,7 @@ test-support = [
     "lsp/test-support",
     "text/test-support",
     "tree-sitter-rust",
+    "tree-sitter-typescript",
     "util/test-support",
 ]
 
@@ -45,7 +46,8 @@ similar = "1.3"
 smallvec = { version = "1.6", features = ["union"] }
 smol = "1.2"
 tree-sitter = "0.20"
-tree-sitter-rust = { version = "0.20.0", optional = true }
+tree-sitter-rust = { version = "*", optional = true }
+tree-sitter-typescript = { version = "*", optional = true }
 
 [dev-dependencies]
 client = { path = "../client", features = ["test-support"] }

crates/language/src/buffer.rs 🔗

@@ -38,7 +38,7 @@ use tree_sitter::{InputEdit, QueryCursor, Tree};
 use util::TryFutureExt as _;
 
 #[cfg(any(test, feature = "test-support"))]
-pub use tree_sitter_rust;
+pub use {tree_sitter_rust, tree_sitter_typescript};
 
 pub use lsp::DiagnosticSeverity;
 
@@ -1638,6 +1638,34 @@ impl BufferSnapshot {
             .and_then(|language| language.grammar.as_ref())
     }
 
+    pub fn range_for_word_token_at<T: ToOffset + ToPoint>(
+        &self,
+        position: T,
+    ) -> Option<Range<usize>> {
+        let offset = position.to_offset(self);
+
+        // Find the first leaf node that touches the position.
+        let tree = self.tree.as_ref()?;
+        let mut cursor = tree.root_node().walk();
+        while cursor.goto_first_child_for_byte(offset).is_some() {}
+        let node = cursor.node();
+        if node.child_count() > 0 {
+            return None;
+        }
+
+        // Check that the leaf node contains word characters.
+        let range = node.byte_range();
+        if self
+            .text_for_range(range.clone())
+            .flat_map(str::chars)
+            .any(|c| c.is_alphanumeric())
+        {
+            return Some(range);
+        } else {
+            None
+        }
+    }
+
     pub fn range_for_syntax_ancestor<T: ToOffset>(&self, range: Range<T>) -> Option<Range<usize>> {
         let tree = self.tree.as_ref()?;
         let range = range.start.to_offset(self)..range.end.to_offset(self);

crates/project/src/project.rs 🔗

@@ -2488,26 +2488,52 @@ impl Project {
                 };
 
                 source_buffer_handle.read_with(&cx, |this, _| {
+                    let snapshot = this.snapshot();
+                    let clipped_position = this.clip_point_utf16(position, Bias::Left);
+                    let mut range_for_token = None;
                     Ok(completions
                         .into_iter()
                         .filter_map(|lsp_completion| {
                             let (old_range, new_text) = match lsp_completion.text_edit.as_ref() {
+                                // If the language server provides a range to overwrite, then
+                                // check that the range is valid.
                                 Some(lsp::CompletionTextEdit::Edit(edit)) => {
-                                    (range_from_lsp(edit.range), edit.new_text.clone())
+                                    let range = range_from_lsp(edit.range);
+                                    let start = snapshot.clip_point_utf16(range.start, Bias::Left);
+                                    let end = snapshot.clip_point_utf16(range.end, Bias::Left);
+                                    if start != range.start || end != range.end {
+                                        log::info!("completion out of expected range");
+                                        return None;
+                                    }
+                                    (
+                                        snapshot.anchor_before(start)..snapshot.anchor_after(end),
+                                        edit.new_text.clone(),
+                                    )
                                 }
+                                // If the language server does not provide a range, then infer
+                                // the range based on the syntax tree.
                                 None => {
-                                    let clipped_position =
-                                        this.clip_point_utf16(position, Bias::Left);
                                     if position != clipped_position {
                                         log::info!("completion out of expected range");
                                         return None;
                                     }
+                                    let Range { start, end } = range_for_token
+                                        .get_or_insert_with(|| {
+                                            let offset = position.to_offset(&snapshot);
+                                            snapshot
+                                                .range_for_word_token_at(offset)
+                                                .unwrap_or_else(|| offset..offset)
+                                        })
+                                        .clone();
                                     let text = lsp_completion
                                         .insert_text
                                         .as_ref()
                                         .unwrap_or(&lsp_completion.label)
                                         .clone();
-                                    (this.common_prefix_at(clipped_position, &text), text.clone())
+                                    (
+                                        snapshot.anchor_before(start)..snapshot.anchor_after(end),
+                                        text.clone(),
+                                    )
                                 }
                                 Some(lsp::CompletionTextEdit::InsertAndReplace(_)) => {
                                     log::info!("unsupported insert/replace completion");
@@ -2515,28 +2541,20 @@ impl Project {
                                 }
                             };
 
-                            let clipped_start = this.clip_point_utf16(old_range.start, Bias::Left);
-                            let clipped_end = this.clip_point_utf16(old_range.end, Bias::Left);
-                            if clipped_start == old_range.start && clipped_end == old_range.end {
-                                Some(Completion {
-                                    old_range: this.anchor_before(old_range.start)
-                                        ..this.anchor_after(old_range.end),
-                                    new_text,
-                                    label: language
-                                        .as_ref()
-                                        .and_then(|l| l.label_for_completion(&lsp_completion))
-                                        .unwrap_or_else(|| {
-                                            CodeLabel::plain(
-                                                lsp_completion.label.clone(),
-                                                lsp_completion.filter_text.as_deref(),
-                                            )
-                                        }),
-                                    lsp_completion,
-                                })
-                            } else {
-                                log::info!("completion out of expected range");
-                                None
-                            }
+                            Some(Completion {
+                                old_range,
+                                new_text,
+                                label: language
+                                    .as_ref()
+                                    .and_then(|l| l.label_for_completion(&lsp_completion))
+                                    .unwrap_or_else(|| {
+                                        CodeLabel::plain(
+                                            lsp_completion.label.clone(),
+                                            lsp_completion.filter_text.as_deref(),
+                                        )
+                                    }),
+                                lsp_completion,
+                            })
                         })
                         .collect())
                 })
@@ -4888,8 +4906,8 @@ mod tests {
     use futures::{future, StreamExt};
     use gpui::test::subscribe;
     use language::{
-        tree_sitter_rust, Diagnostic, FakeLspAdapter, LanguageConfig, OffsetRangeExt, Point,
-        ToPoint,
+        tree_sitter_rust, tree_sitter_typescript, Diagnostic, FakeLspAdapter, LanguageConfig,
+        OffsetRangeExt, Point, ToPoint,
     };
     use lsp::Url;
     use serde_json::json;
@@ -6506,6 +6524,75 @@ mod tests {
         }
     }
 
+    #[gpui::test]
+    async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) {
+        let mut language = Language::new(
+            LanguageConfig {
+                name: "TypeScript".into(),
+                path_suffixes: vec!["ts".to_string()],
+                ..Default::default()
+            },
+            Some(tree_sitter_typescript::language_typescript()),
+        );
+        let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default());
+
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/dir",
+            json!({
+                "a.ts": "",
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, cx);
+        project.update(cx, |project, _| project.languages.add(Arc::new(language)));
+
+        let (tree, _) = project
+            .update(cx, |project, cx| {
+                project.find_or_create_local_worktree("/dir", true, cx)
+            })
+            .await
+            .unwrap();
+        let worktree_id = tree.read_with(cx, |tree, _| tree.id());
+        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+            .await;
+
+        let buffer = project
+            .update(cx, |p, cx| p.open_buffer((worktree_id, "a.ts"), cx))
+            .await
+            .unwrap();
+
+        let fake_server = fake_language_servers.next().await.unwrap();
+
+        let text = "let a = b.fqn";
+        buffer.update(cx, |buffer, cx| buffer.set_text(text, cx));
+        let completions = project.update(cx, |project, cx| {
+            project.completions(&buffer, text.len(), cx)
+        });
+
+        fake_server
+            .handle_request::<lsp::request::Completion, _, _>(|_, _| async move {
+                Ok(Some(lsp::CompletionResponse::Array(vec![
+                    lsp::CompletionItem {
+                        label: "fullyQualifiedName?".into(),
+                        insert_text: Some("fullyQualifiedName".into()),
+                        ..Default::default()
+                    },
+                ])))
+            })
+            .next()
+            .await;
+        let completions = completions.await.unwrap();
+        let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
+        assert_eq!(completions.len(), 1);
+        assert_eq!(completions[0].new_text, "fullyQualifiedName");
+        assert_eq!(
+            completions[0].old_range.to_offset(&snapshot),
+            text.len() - 3..text.len()
+        );
+    }
+
     #[gpui::test(iterations = 10)]
     async fn test_apply_code_actions_with_commands(cx: &mut gpui::TestAppContext) {
         let mut language = Language::new(