diff --git a/Cargo.lock b/Cargo.lock index 15b9d1706f..c2d8fe62ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2651,6 +2651,7 @@ dependencies = [ "tree-sitter", "tree-sitter-json 0.19.0", "tree-sitter-rust", + "tree-sitter-typescript", "unindent", "util", ] diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index fd901fd769..ab64866419 100644 --- a/crates/language/Cargo.toml +++ b/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"] } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 30c428ce50..3a1c0f0667 100644 --- a/crates/language/src/buffer.rs +++ b/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( + &self, + position: T, + ) -> Option> { + 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(&self, range: Range) -> Option> { let tree = self.tree.as_ref()?; let range = range.start.to_offset(self)..range.end.to_offset(self); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 81d8301efb..9085490fca 100644 --- a/crates/project/src/project.rs +++ b/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::(|_, _| 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(