diff --git a/assets/settings/default.json b/assets/settings/default.json index 534d564441..490a9ae874 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -336,14 +336,14 @@ "active_line_width": 1, // Determines how indent guides are colored. // This setting can take the following three values: - /// + // // 1. "disabled" // 2. "fixed" // 3. "indent_aware" "coloring": "fixed", // Determines how indent guide backgrounds are colored. // This setting can take the following two values: - /// + // // 1. "disabled" // 2. "indent_aware" "background_coloring": "disabled" @@ -402,8 +402,8 @@ // Time to wait after scrolling the buffer, before requesting the hints, // set to 0 to disable debouncing. "scroll_debounce_ms": 50, - /// A set of modifiers which, when pressed, will toggle the visibility of inlay hints. - /// If the set if empty or not all the modifiers specified are pressed, inlay hints will not be toggled. + // A set of modifiers which, when pressed, will toggle the visibility of inlay hints. + // If the set if empty or not all the modifiers specified are pressed, inlay hints will not be toggled. "toggle_on_modifiers_press": { "control": false, "shift": false, @@ -440,7 +440,7 @@ "scrollbar": { // When to show the scrollbar in the project panel. // This setting can take five values: - /// + // // 1. null (default): Inherit editor settings // 2. Show the scrollbar if there's important information or // follow the system's configured behavior (default): @@ -455,7 +455,7 @@ }, // Which files containing diagnostic errors/warnings to mark in the project panel. // This setting can take the following three values: - /// + // // 1. Do not mark any files: // "off" // 2. Only mark files with errors: @@ -512,7 +512,7 @@ "scrollbar": { // When to show the scrollbar in the project panel. // This setting can take five values: - /// + // // 1. null (default): Inherit editor settings // 2. Show the scrollbar if there's important information or // follow the system's configured behavior (default): @@ -686,7 +686,7 @@ // Which files containing diagnostic errors/warnings to mark in the tabs. // Diagnostics are only shown when file icons are also active. // This setting only works when can take the following three values: - /// + // // 1. Do not mark any files: // "off" // 2. Only mark files with errors: @@ -1014,7 +1014,7 @@ "scrollbar": { // When to show the scrollbar in the terminal. // This setting can take five values: - /// + // // 1. null (default): Inherit editor settings // 2. Show the scrollbar if there's important information or // follow the system's configured behavior (default): @@ -1085,6 +1085,31 @@ "auto_install_extensions": { "html": true }, + // Controls how completions are processed for this language. + "completions": { + // Controls how words are completed. + // For large documents, not all words may be fetched for completion. + // + // May take 3 values: + // 1. "enabled" + // Always fetch document's words for completions. + // 2. "fallback" + // Only if LSP response errors/times out/is empty, use document's words to show completions. + // 3. "disabled" + // Never fetch or complete document's words for completions. + // + // Default: fallback + "words": "fallback", + // Whether to fetch LSP completions or not. + // + // Default: true + "lsp": true, + // When fetching LSP completions, determines how long to wait for a response of a particular server. + // When set to 0, waits indefinitely. + // + // Default: 500 + "lsp_fetch_timeout_ms": 500 + }, // Different settings for specific languages. "languages": { "Astro": { diff --git a/crates/copilot/src/copilot_completion_provider.rs b/crates/copilot/src/copilot_completion_provider.rs index b6e854eacf..00e00f7670 100644 --- a/crates/copilot/src/copilot_completion_provider.rs +++ b/crates/copilot/src/copilot_completion_provider.rs @@ -271,7 +271,10 @@ mod tests { use gpui::{AppContext as _, BackgroundExecutor, TestAppContext, UpdateGlobal}; use indoc::indoc; use language::{ - language_settings::{AllLanguageSettings, AllLanguageSettingsContent}, + language_settings::{ + AllLanguageSettings, AllLanguageSettingsContent, CompletionSettings, + WordsCompletionMode, + }, Point, }; use project::Project; @@ -286,7 +289,13 @@ mod tests { #[gpui::test(iterations = 10)] async fn test_copilot(executor: BackgroundExecutor, cx: &mut TestAppContext) { // flaky - init_test(cx, |_| {}); + init_test(cx, |settings| { + settings.defaults.completions = Some(CompletionSettings { + words: WordsCompletionMode::Disabled, + lsp: true, + lsp_fetch_timeout_ms: 0, + }); + }); let (copilot, copilot_lsp) = Copilot::fake(cx); let mut cx = EditorLspTestContext::new_rust( @@ -511,7 +520,13 @@ mod tests { cx: &mut TestAppContext, ) { // flaky - init_test(cx, |_| {}); + init_test(cx, |settings| { + settings.defaults.completions = Some(CompletionSettings { + words: WordsCompletionMode::Disabled, + lsp: true, + lsp_fetch_timeout_ms: 0, + }); + }); let (copilot, copilot_lsp) = Copilot::fake(cx); let mut cx = EditorLspTestContext::new_rust( diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 60a0f7746c..5da098a660 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -101,6 +101,7 @@ use itertools::Itertools; use language::{ language_settings::{ self, all_language_settings, language_settings, InlayHintSettings, RewrapBehavior, + WordsCompletionMode, }, point_from_lsp, text_diff_with_options, AutoindentMode, BracketMatch, BracketPair, Buffer, Capability, CharKind, CodeLabel, CursorShape, Diagnostic, DiffOptions, EditPredictionsMode, @@ -4012,9 +4013,8 @@ impl Editor { } else { return; }; - let show_completion_documentation = buffer - .read(cx) - .snapshot() + let buffer_snapshot = buffer.read(cx).snapshot(); + let show_completion_documentation = buffer_snapshot .settings_at(buffer_position, cx) .show_completion_documentation; @@ -4038,6 +4038,51 @@ impl Editor { }; let completions = provider.completions(&buffer, buffer_position, completion_context, window, cx); + let (old_range, word_kind) = buffer_snapshot.surrounding_word(buffer_position); + let (old_range, word_to_exclude) = if word_kind == Some(CharKind::Word) { + let word_to_exclude = buffer_snapshot + .text_for_range(old_range.clone()) + .collect::(); + ( + buffer_snapshot.anchor_before(old_range.start) + ..buffer_snapshot.anchor_after(old_range.end), + Some(word_to_exclude), + ) + } else { + (buffer_position..buffer_position, None) + }; + + let completion_settings = language_settings( + buffer_snapshot + .language_at(buffer_position) + .map(|language| language.name()), + buffer_snapshot.file(), + cx, + ) + .completions; + + // The document can be large, so stay in reasonable bounds when searching for words, + // otherwise completion pop-up might be slow to appear. + const WORD_LOOKUP_ROWS: u32 = 5_000; + let buffer_row = text::ToPoint::to_point(&buffer_position, &buffer_snapshot).row; + let min_word_search = buffer_snapshot.clip_point( + Point::new(buffer_row.saturating_sub(WORD_LOOKUP_ROWS), 0), + Bias::Left, + ); + let max_word_search = buffer_snapshot.clip_point( + Point::new(buffer_row + WORD_LOOKUP_ROWS, 0).min(buffer_snapshot.max_point()), + Bias::Right, + ); + let word_search_range = buffer_snapshot.point_to_offset(min_word_search) + ..buffer_snapshot.point_to_offset(max_word_search); + let words = match completion_settings.words { + WordsCompletionMode::Disabled => Task::ready(HashMap::default()), + WordsCompletionMode::Enabled | WordsCompletionMode::Fallback => { + cx.background_spawn(async move { + buffer_snapshot.words_in_range(None, word_search_range) + }) + } + }; let sort_completions = provider.sort_completions(); let id = post_inc(&mut self.next_completion_id); @@ -4046,8 +4091,55 @@ impl Editor { editor.update(&mut cx, |this, _| { this.completion_tasks.retain(|(task_id, _)| *task_id >= id); })?; - let completions = completions.await.log_err(); - let menu = if let Some(completions) = completions { + let mut completions = completions.await.log_err().unwrap_or_default(); + + match completion_settings.words { + WordsCompletionMode::Enabled => { + completions.extend( + words + .await + .into_iter() + .filter(|(word, _)| word_to_exclude.as_ref() != Some(word)) + .map(|(word, word_range)| Completion { + old_range: old_range.clone(), + new_text: word.clone(), + label: CodeLabel::plain(word, None), + documentation: None, + source: CompletionSource::BufferWord { + word_range, + resolved: false, + }, + confirm: None, + }), + ); + } + WordsCompletionMode::Fallback => { + if completions.is_empty() { + completions.extend( + words + .await + .into_iter() + .filter(|(word, _)| word_to_exclude.as_ref() != Some(word)) + .map(|(word, word_range)| Completion { + old_range: old_range.clone(), + new_text: word.clone(), + label: CodeLabel::plain(word, None), + documentation: None, + source: CompletionSource::BufferWord { + word_range, + resolved: false, + }, + confirm: None, + }), + ); + } + } + WordsCompletionMode::Disabled => {} + } + + let menu = if completions.is_empty() { + None + } else { let mut menu = CompletionsMenu::new( id, sort_completions, @@ -4061,8 +4153,6 @@ impl Editor { .await; menu.visible().then_some(menu) - } else { - None }; editor.update_in(&mut cx, |editor, window, cx| { diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 8c05279443..3c4c9622a4 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -16,7 +16,8 @@ use gpui::{ use indoc::indoc; use language::{ language_settings::{ - AllLanguageSettings, AllLanguageSettingsContent, LanguageSettingsContent, PrettierSettings, + AllLanguageSettings, AllLanguageSettingsContent, CompletionSettings, + LanguageSettingsContent, PrettierSettings, }, BracketPairConfig, Capability::ReadWrite, @@ -30,7 +31,7 @@ use pretty_assertions::{assert_eq, assert_ne}; use project::project_settings::{LspSettings, ProjectSettings}; use project::FakeFs; use serde_json::{self, json}; -use std::{cell::RefCell, future::Future, rc::Rc, time::Instant}; +use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant}; use std::{ iter, sync::atomic::{self, AtomicUsize}, @@ -9169,6 +9170,101 @@ async fn test_completion(cx: &mut TestAppContext) { apply_additional_edits.await.unwrap(); } +#[gpui::test] +async fn test_words_completion(cx: &mut TestAppContext) { + let lsp_fetch_timeout_ms = 10; + init_test(cx, |language_settings| { + language_settings.defaults.completions = Some(CompletionSettings { + words: WordsCompletionMode::Fallback, + lsp: true, + lsp_fetch_timeout_ms: 10, + }); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..lsp::CompletionOptions::default() + }), + signature_help_provider: Some(lsp::SignatureHelpOptions::default()), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let throttle_completions = Arc::new(AtomicBool::new(false)); + + let lsp_throttle_completions = throttle_completions.clone(); + let _completion_requests_handler = + cx.lsp + .server + .on_request::(move |_, cx| { + let lsp_throttle_completions = lsp_throttle_completions.clone(); + async move { + if lsp_throttle_completions.load(atomic::Ordering::Acquire) { + cx.background_executor() + .timer(Duration::from_millis(lsp_fetch_timeout_ms * 10)) + .await; + } + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "first".into(), + ..Default::default() + }, + lsp::CompletionItem { + label: "last".into(), + ..Default::default() + }, + ]))) + } + }); + + cx.set_state(indoc! {" + oneˇ + two + three + "}); + cx.simulate_keystroke("."); + cx.executor().run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, window, cx| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!( + completion_menu_entries(&menu), + &["first", "last"], + "When LSP server is fast to reply, no fallback word completions are used" + ); + } else { + panic!("expected completion menu to be open"); + } + editor.cancel(&Cancel, window, cx); + }); + cx.executor().run_until_parked(); + cx.condition(|editor, _| !editor.context_menu_visible()) + .await; + + throttle_completions.store(true, atomic::Ordering::Release); + cx.simulate_keystroke("."); + cx.executor() + .advance_clock(Duration::from_millis(lsp_fetch_timeout_ms * 2)); + cx.executor().run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!(completion_menu_entries(&menu), &["one", "three", "two"], + "When LSP server is slow, document words can be shown instead, if configured accordingly"); + } else { + panic!("expected completion menu to be open"); + } + }); +} + #[gpui::test] async fn test_multiline_completion(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 10b519b18c..b08fe045f1 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -4145,6 +4145,63 @@ impl BufferSnapshot { None } } + + pub fn words_in_range( + &self, + query: Option<&str>, + range: Range, + ) -> HashMap> { + if query.map_or(false, |query| query.is_empty()) { + return HashMap::default(); + } + + let classifier = CharClassifier::new(self.language.clone().map(|language| LanguageScope { + language, + override_id: None, + })); + + let mut query_ix = 0; + let query = query.map(|query| query.chars().collect::>()); + let query_len = query.as_ref().map_or(0, |query| query.len()); + + let mut words = HashMap::default(); + let mut current_word_start_ix = None; + let mut chunk_ix = range.start; + for chunk in self.chunks(range, false) { + for (i, c) in chunk.text.char_indices() { + let ix = chunk_ix + i; + if classifier.is_word(c) { + if current_word_start_ix.is_none() { + current_word_start_ix = Some(ix); + } + + if let Some(query) = &query { + if query_ix < query_len { + let query_c = query.get(query_ix).expect( + "query_ix is a vec of chars, which we access only if before the end", + ); + if c.to_lowercase().eq(query_c.to_lowercase()) { + query_ix += 1; + } + } + } + continue; + } else if let Some(word_start) = current_word_start_ix.take() { + if query_ix == query_len { + let word_range = self.anchor_before(word_start)..self.anchor_after(ix); + words.insert( + self.text_for_range(word_start..ix).collect::(), + word_range, + ); + } + } + query_ix = 0; + } + chunk_ix += chunk.text.len(); + } + + words + } } fn indent_size_for_line(text: &text::BufferSnapshot, row: u32) -> IndentSize { diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index eef74b4cee..78f10640c9 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -13,6 +13,7 @@ use proto::deserialize_operation; use rand::prelude::*; use regex::RegexBuilder; use settings::SettingsStore; +use std::collections::BTreeSet; use std::{ env, ops::Range, @@ -3140,6 +3141,93 @@ fn test_trailing_whitespace_ranges(mut rng: StdRng) { ); } +#[gpui::test] +fn test_words_in_range(cx: &mut gpui::App) { + init_settings(cx, |_| {}); + + let contents = r#"let word=öäpple.bar你 Öäpple word2-öÄpPlE-Pizza-word ÖÄPPLE word"#; + + let buffer = cx.new(|cx| { + let buffer = Buffer::local(contents, cx).with_language(Arc::new(rust_lang()), cx); + assert_eq!(buffer.text(), contents); + buffer.check_invariants(); + buffer + }); + + buffer.update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + assert_eq!( + BTreeSet::from_iter(["Pizza".to_string()]), + snapshot + .words_in_range(Some("piz"), 0..snapshot.len()) + .into_keys() + .collect::>() + ); + assert_eq!( + BTreeSet::from_iter([ + "öäpple".to_string(), + "Öäpple".to_string(), + "öÄpPlE".to_string(), + "ÖÄPPLE".to_string(), + ]), + snapshot + .words_in_range(Some("öp"), 0..snapshot.len()) + .into_keys() + .collect::>() + ); + assert_eq!( + BTreeSet::from_iter([ + "öÄpPlE".to_string(), + "Öäpple".to_string(), + "ÖÄPPLE".to_string(), + "öäpple".to_string(), + ]), + snapshot + .words_in_range(Some("öÄ"), 0..snapshot.len()) + .into_keys() + .collect::>() + ); + assert_eq!( + BTreeSet::default(), + snapshot + .words_in_range(Some("öÄ好"), 0..snapshot.len()) + .into_keys() + .collect::>() + ); + assert_eq!( + BTreeSet::from_iter(["bar你".to_string(),]), + snapshot + .words_in_range(Some("你"), 0..snapshot.len()) + .into_keys() + .collect::>() + ); + assert_eq!( + BTreeSet::default(), + snapshot + .words_in_range(Some(""), 0..snapshot.len()) + .into_keys() + .collect::>() + ); + assert_eq!( + BTreeSet::from_iter([ + "bar你".to_string(), + "öÄpPlE".to_string(), + "Öäpple".to_string(), + "ÖÄPPLE".to_string(), + "öäpple".to_string(), + "let".to_string(), + "Pizza".to_string(), + "word".to_string(), + "word2".to_string(), + ]), + snapshot + .words_in_range(None, 0..snapshot.len()) + .into_keys() + .collect::>() + ); + }); +} + fn ruby_lang() -> Language { Language::new( LanguageConfig { diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index be5b2ae6a6..4e373f6b0d 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -79,10 +79,10 @@ pub struct LanguageSettings { /// The column at which to soft-wrap lines, for buffers where soft-wrap /// is enabled. pub preferred_line_length: u32, - // Whether to show wrap guides (vertical rulers) in the editor. - // Setting this to true will show a guide at the 'preferred_line_length' value - // if softwrap is set to 'preferred_line_length', and will show any - // additional guides as specified by the 'wrap_guides' setting. + /// Whether to show wrap guides (vertical rulers) in the editor. + /// Setting this to true will show a guide at the 'preferred_line_length' value + /// if softwrap is set to 'preferred_line_length', and will show any + /// additional guides as specified by the 'wrap_guides' setting. pub show_wrap_guides: bool, /// Character counts at which to show wrap guides (vertical rulers) in the editor. pub wrap_guides: Vec, @@ -137,7 +137,7 @@ pub struct LanguageSettings { pub use_on_type_format: bool, /// Whether indentation of pasted content should be adjusted based on the context. pub auto_indent_on_paste: bool, - // Controls how the editor handles the autoclosed characters. + /// Controls how the editor handles the autoclosed characters. pub always_treat_brackets_as_autoclosed: bool, /// Which code actions to run on save pub code_actions_on_format: HashMap, @@ -151,6 +151,8 @@ pub struct LanguageSettings { /// Whether to display inline and alongside documentation for items in the /// completions menu. pub show_completion_documentation: bool, + /// Completion settings for this language. + pub completions: CompletionSettings, } impl LanguageSettings { @@ -306,6 +308,50 @@ pub struct AllLanguageSettingsContent { pub file_types: HashMap, Vec>, } +/// Controls how completions are processed for this language. +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct CompletionSettings { + /// Controls how words are completed. + /// For large documents, not all words may be fetched for completion. + /// + /// Default: `fallback` + #[serde(default = "default_words_completion_mode")] + pub words: WordsCompletionMode, + /// Whether to fetch LSP completions or not. + /// + /// Default: true + #[serde(default = "default_true")] + pub lsp: bool, + /// When fetching LSP completions, determines how long to wait for a response of a particular server. + /// When set to 0, waits indefinitely. + /// + /// Default: 500 + #[serde(default = "lsp_fetch_timeout_ms")] + pub lsp_fetch_timeout_ms: u64, +} + +/// Controls how document's words are completed. +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WordsCompletionMode { + /// Always fetch document's words for completions. + Enabled, + /// Only if LSP response errors/times out/is empty, + /// use document's words to show completions. + Fallback, + /// Never fetch or complete document's words for completions. + Disabled, +} + +fn default_words_completion_mode() -> WordsCompletionMode { + WordsCompletionMode::Fallback +} + +fn lsp_fetch_timeout_ms() -> u64 { + 500 +} + /// The settings for a particular language. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct LanguageSettingsContent { @@ -478,6 +524,8 @@ pub struct LanguageSettingsContent { /// /// Default: true pub show_completion_documentation: Option, + /// Controls how completions are processed for this language. + pub completions: Option, } /// The behavior of `editor::Rewrap`. @@ -1381,6 +1429,7 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent &mut settings.show_completion_documentation, src.show_completion_documentation, ); + merge(&mut settings.completions, src.completions); } /// Allows to enable/disable formatting with Prettier diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 5b2602601f..6ceabc8308 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -23,13 +23,13 @@ use client::{proto, TypedEnvelope}; use collections::{btree_map, BTreeMap, BTreeSet, HashMap, HashSet}; use futures::{ future::{join_all, Shared}, - select, + select, select_biased, stream::FuturesUnordered, AsyncWriteExt, Future, FutureExt, StreamExt, }; use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder}; use gpui::{ - App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, PromptLevel, SharedString, Task, + App, AppContext, AsyncApp, Context, Entity, EventEmitter, PromptLevel, SharedString, Task, WeakEntity, }; use http_client::HttpClient; @@ -4325,6 +4325,15 @@ impl LspStore { let offset = position.to_offset(&snapshot); let scope = snapshot.language_scope_at(offset); let language = snapshot.language().cloned(); + let completion_settings = language_settings( + language.as_ref().map(|language| language.name()), + buffer.read(cx).file(), + cx, + ) + .completions; + if !completion_settings.lsp { + return Task::ready(Ok(Vec::new())); + } let server_ids: Vec<_> = buffer.update(cx, |buffer, cx| { local @@ -4341,23 +4350,51 @@ impl LspStore { }); let buffer = buffer.clone(); + let lsp_timeout = completion_settings.lsp_fetch_timeout_ms; + let lsp_timeout = if lsp_timeout > 0 { + Some(Duration::from_millis(lsp_timeout)) + } else { + None + }; cx.spawn(move |this, mut cx| async move { let mut tasks = Vec::with_capacity(server_ids.len()); - this.update(&mut cx, |this, cx| { + this.update(&mut cx, |lsp_store, cx| { for server_id in server_ids { - let lsp_adapter = this.language_server_adapter_for_id(server_id); - tasks.push(( - lsp_adapter, - this.request_lsp( - buffer.clone(), - LanguageServerToQuery::Other(server_id), - GetCompletions { - position, - context: context.clone(), + let lsp_adapter = lsp_store.language_server_adapter_for_id(server_id); + let lsp_timeout = lsp_timeout + .map(|lsp_timeout| cx.background_executor().timer(lsp_timeout)); + let mut timeout = cx.background_spawn(async move { + match lsp_timeout { + Some(lsp_timeout) => { + lsp_timeout.await; + true }, - cx, - ), - )); + None => false, + } + }).fuse(); + let mut lsp_request = lsp_store.request_lsp( + buffer.clone(), + LanguageServerToQuery::Other(server_id), + GetCompletions { + position, + context: context.clone(), + }, + cx, + ).fuse(); + let new_task = cx.background_spawn(async move { + select_biased! { + response = lsp_request => response, + timeout_happened = timeout => { + if timeout_happened { + log::warn!("Fetching completions from server {server_id} timed out, timeout ms: {}", completion_settings.lsp_fetch_timeout_ms); + return anyhow::Ok(Vec::new()) + } else { + lsp_request.await + } + }, + } + }); + tasks.push((lsp_adapter, new_task)); } })?; @@ -4416,47 +4453,58 @@ impl LspStore { { did_resolve = true; } + } else { + resolve_word_completion( + &buffer_snapshot, + &mut completions.borrow_mut()[completion_index], + ); } } } else { for completion_index in completion_indices { - let Some(server_id) = completions.borrow()[completion_index].source.server_id() - else { - continue; + let server_id = { + let completion = &completions.borrow()[completion_index]; + completion.source.server_id() }; + if let Some(server_id) = server_id { + let server_and_adapter = this + .read_with(&cx, |lsp_store, _| { + let server = lsp_store.language_server_for_id(server_id)?; + let adapter = + lsp_store.language_server_adapter_for_id(server.server_id())?; + Some((server, adapter)) + }) + .ok() + .flatten(); + let Some((server, adapter)) = server_and_adapter else { + continue; + }; - let server_and_adapter = this - .read_with(&cx, |lsp_store, _| { - let server = lsp_store.language_server_for_id(server_id)?; - let adapter = - lsp_store.language_server_adapter_for_id(server.server_id())?; - Some((server, adapter)) - }) - .ok() - .flatten(); - let Some((server, adapter)) = server_and_adapter else { - continue; - }; - - let resolved = Self::resolve_completion_local( - server, - &buffer_snapshot, - completions.clone(), - completion_index, - ) - .await - .log_err() - .is_some(); - if resolved { - Self::regenerate_completion_labels( - adapter, + let resolved = Self::resolve_completion_local( + server, &buffer_snapshot, completions.clone(), completion_index, ) .await - .log_err(); - did_resolve = true; + .log_err() + .is_some(); + if resolved { + Self::regenerate_completion_labels( + adapter, + &buffer_snapshot, + completions.clone(), + completion_index, + ) + .await + .log_err(); + did_resolve = true; + } + } else { + resolve_word_completion( + &buffer_snapshot, + &mut completions.borrow_mut()[completion_index], + ); } } } @@ -4500,7 +4548,9 @@ impl LspStore { ); server.request::(*lsp_completion.clone()) } - CompletionSource::Custom => return Ok(()), + CompletionSource::BufferWord { .. } | CompletionSource::Custom => { + return Ok(()); + } } }; let resolved_completion = request.await?; @@ -4641,7 +4691,9 @@ impl LspStore { } serde_json::to_string(lsp_completion).unwrap().into_bytes() } - CompletionSource::Custom => return Ok(()), + CompletionSource::Custom | CompletionSource::BufferWord { .. } => { + return Ok(()); + } } }; let request = proto::ResolveCompletionDocumentation { @@ -8172,51 +8224,54 @@ impl LspStore { } pub(crate) fn serialize_completion(completion: &CoreCompletion) -> proto::Completion { - let (source, server_id, lsp_completion, lsp_defaults, resolved) = match &completion.source { + let mut serialized_completion = proto::Completion { + old_start: Some(serialize_anchor(&completion.old_range.start)), + old_end: Some(serialize_anchor(&completion.old_range.end)), + new_text: completion.new_text.clone(), + ..proto::Completion::default() + }; + match &completion.source { CompletionSource::Lsp { server_id, lsp_completion, lsp_defaults, resolved, - } => ( - proto::completion::Source::Lsp as i32, - server_id.0 as u64, - serde_json::to_vec(lsp_completion).unwrap(), - lsp_defaults + } => { + serialized_completion.source = proto::completion::Source::Lsp as i32; + serialized_completion.server_id = server_id.0 as u64; + serialized_completion.lsp_completion = serde_json::to_vec(lsp_completion).unwrap(); + serialized_completion.lsp_defaults = lsp_defaults .as_deref() - .map(|lsp_defaults| serde_json::to_vec(lsp_defaults).unwrap()), - *resolved, - ), - CompletionSource::Custom => ( - proto::completion::Source::Custom as i32, - 0, - Vec::new(), - None, - true, - ), - }; - - proto::Completion { - old_start: Some(serialize_anchor(&completion.old_range.start)), - old_end: Some(serialize_anchor(&completion.old_range.end)), - new_text: completion.new_text.clone(), - server_id, - lsp_completion, - lsp_defaults, - resolved, - source, + .map(|lsp_defaults| serde_json::to_vec(lsp_defaults).unwrap()); + serialized_completion.resolved = *resolved; + } + CompletionSource::BufferWord { + word_range, + resolved, + } => { + serialized_completion.source = proto::completion::Source::BufferWord as i32; + serialized_completion.buffer_word_start = Some(serialize_anchor(&word_range.start)); + serialized_completion.buffer_word_end = Some(serialize_anchor(&word_range.end)); + serialized_completion.resolved = *resolved; + } + CompletionSource::Custom => { + serialized_completion.source = proto::completion::Source::Custom as i32; + serialized_completion.resolved = true; + } } + + serialized_completion } pub(crate) fn deserialize_completion(completion: proto::Completion) -> Result { let old_start = completion .old_start .and_then(deserialize_anchor) - .ok_or_else(|| anyhow!("invalid old start"))?; + .context("invalid old start")?; let old_end = completion .old_end .and_then(deserialize_anchor) - .ok_or_else(|| anyhow!("invalid old end"))?; + .context("invalid old end")?; Ok(CoreCompletion { old_range: old_start..old_end, new_text: completion.new_text, @@ -8232,6 +8287,20 @@ impl LspStore { .transpose()?, resolved: completion.resolved, }, + Some(proto::completion::Source::BufferWord) => { + let word_range = completion + .buffer_word_start + .and_then(deserialize_anchor) + .context("invalid buffer word start")? + ..completion + .buffer_word_end + .and_then(deserialize_anchor) + .context("invalid buffer word end")?; + CompletionSource::BufferWord { + word_range, + resolved: completion.resolved, + } + } _ => anyhow::bail!("Unexpected completion source {}", completion.source), }, }) @@ -8296,6 +8365,40 @@ impl LspStore { } } +fn resolve_word_completion(snapshot: &BufferSnapshot, completion: &mut Completion) { + let CompletionSource::BufferWord { + word_range, + resolved, + } = &mut completion.source + else { + return; + }; + if *resolved { + return; + } + + if completion.new_text + != snapshot + .text_for_range(word_range.clone()) + .collect::() + { + return; + } + + let mut offset = 0; + for chunk in snapshot.chunks(word_range.clone(), true) { + let end_offset = offset + chunk.text.len(); + if let Some(highlight_id) = chunk.syntax_highlight_id { + completion + .label + .runs + .push((offset..end_offset, highlight_id)); + } + offset = end_offset; + } + *resolved = true; +} + impl EventEmitter for LspStore {} fn remove_empty_hover_blocks(mut hover: Hover) -> Option { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index c5e1a27ddb..61ccfe27ae 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -388,6 +388,10 @@ pub enum CompletionSource { resolved: bool, }, Custom, + BufferWord { + word_range: Range, + resolved: bool, + }, } impl CompletionSource { diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 5493b87017..c8a1507a9a 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -1002,10 +1002,13 @@ message Completion { bool resolved = 6; Source source = 7; optional bytes lsp_defaults = 8; + optional Anchor buffer_word_start = 9; + optional Anchor buffer_word_end = 10; enum Source { Lsp = 0; Custom = 1; + BufferWord = 2; } }