Refine word completions (#26779)

Follow-up of https://github.com/zed-industries/zed/pull/26410

* Extract word completions into their own, `editor::ShowWordCompletions`
action so those could be triggered independently of completions
* Assign `ctrl-shift-space` binding to this new action
* Still keep words returned along the completions as in the original PR,
but:
* Tone down regular completions' fallback logic, skip words when the
language server responds with empty list of completions, but keep on
adding words if nothing or an error were returned instead
    * Adjust the defaults to wait for LSP completions infinitely
* Skip "words" with digits such as `0_usize` or `2.f32` from completion
items, unless a completion query has digits in it

Release Notes:

- N/A
This commit is contained in:
Kirill Bulatov 2025-03-14 17:18:55 +02:00 committed by GitHub
parent 21057e3af7
commit 566c5f91a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 431 additions and 251 deletions

View file

@ -106,7 +106,7 @@ use language::{
point_from_lsp, text_diff_with_options, AutoindentMode, BracketMatch, BracketPair, Buffer,
Capability, CharKind, CodeLabel, CursorShape, Diagnostic, DiffOptions, EditPredictionsMode,
EditPreview, HighlightedText, IndentKind, IndentSize, Language, OffsetRangeExt, Point,
Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions,
Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery,
};
use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange};
use linked_editing_ranges::refresh_linked_ranges;
@ -3977,20 +3977,34 @@ impl Editor {
}))
}
pub fn show_word_completions(
&mut self,
_: &ShowWordCompletions,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_completions_menu(true, None, window, cx);
}
pub fn show_completions(
&mut self,
options: &ShowCompletions,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_completions_menu(false, options.trigger.as_deref(), window, cx);
}
fn open_completions_menu(
&mut self,
ignore_completion_provider: bool,
trigger: Option<&str>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.pending_rename.is_some() {
return;
}
let Some(provider) = self.completion_provider.as_ref() else {
return;
};
if !self.snippet_stack.is_empty() && self.context_menu.borrow().as_ref().is_some() {
return;
}
@ -4012,14 +4026,14 @@ impl Editor {
let query = Self::completion_query(&self.buffer.read(cx).read(cx), position);
let trigger_kind = match &options.trigger {
let trigger_kind = match trigger {
Some(trigger) if buffer.read(cx).completion_triggers().contains(trigger) => {
CompletionTriggerKind::TRIGGER_CHARACTER
}
_ => CompletionTriggerKind::INVOKED,
};
let completion_context = CompletionContext {
trigger_character: options.trigger.as_ref().and_then(|trigger| {
trigger_character: trigger.and_then(|trigger| {
if trigger_kind == CompletionTriggerKind::TRIGGER_CHARACTER {
Some(String::from(trigger))
} else {
@ -4028,8 +4042,7 @@ impl Editor {
}),
trigger_kind,
};
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
@ -4067,15 +4080,49 @@ impl Editor {
);
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 provider = self
.completion_provider
.as_ref()
.filter(|_| !ignore_completion_provider);
let skip_digits = query
.as_ref()
.map_or(true, |query| !query.chars().any(|c| c.is_digit(10)));
let (mut words, provided_completions) = match provider {
Some(provider) => {
let completions =
provider.completions(&buffer, buffer_position, completion_context, window, cx);
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(WordsQuery {
fuzzy_contents: None,
range: word_search_range,
skip_digits,
})
}),
};
(words, completions)
}
None => (
cx.background_spawn(async move {
buffer_snapshot.words_in_range(WordsQuery {
fuzzy_contents: None,
range: word_search_range,
skip_digits,
})
}),
Task::ready(Ok(None)),
),
};
let sort_completions = provider.sort_completions();
let sort_completions = provider
.as_ref()
.map_or(true, |provider| provider.sort_completions());
let id = post_inc(&mut self.next_completion_id);
let task = cx.spawn_in(window, |editor, mut cx| {
@ -4083,55 +4130,34 @@ impl Editor {
editor.update(&mut cx, |this, _| {
this.completion_tasks.retain(|(task_id, _)| *task_id >= id);
})?;
let mut completions = completions.await.log_err().unwrap_or_default();
match completion_settings.words {
WordsCompletionMode::Enabled => {
let mut words = words.await;
if let Some(word_to_exclude) = &word_to_exclude {
words.remove(word_to_exclude);
}
for lsp_completion in &completions {
words.remove(&lsp_completion.new_text);
}
completions.extend(words.into_iter().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,
}
}));
let mut completions = Vec::new();
if let Some(provided_completions) = provided_completions.await.log_err().flatten() {
completions.extend(provided_completions);
if completion_settings.words == WordsCompletionMode::Fallback {
words = Task::ready(HashMap::default());
}
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 mut words = words.await;
if let Some(word_to_exclude) = &word_to_exclude {
words.remove(word_to_exclude);
}
for lsp_completion in &completions {
words.remove(&lsp_completion.new_text);
}
completions.extend(words.into_iter().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,
}));
let menu = if completions.is_empty() {
None
} else {
@ -4188,7 +4214,7 @@ impl Editor {
}
})?;
Ok::<_, anyhow::Error>(())
anyhow::Ok(())
}
.log_err()
});
@ -16913,7 +16939,7 @@ pub trait CompletionProvider {
trigger: CompletionContext,
window: &mut Window,
cx: &mut Context<Editor>,
) -> Task<Result<Vec<Completion>>>;
) -> Task<Result<Option<Vec<Completion>>>>;
fn resolve_completions(
&self,
@ -17153,15 +17179,25 @@ impl CompletionProvider for Entity<Project> {
options: CompletionContext,
_window: &mut Window,
cx: &mut Context<Editor>,
) -> Task<Result<Vec<Completion>>> {
) -> Task<Result<Option<Vec<Completion>>>> {
self.update(cx, |project, cx| {
let snippets = snippet_completions(project, buffer, buffer_position, cx);
let project_completions = project.completions(buffer, buffer_position, options, cx);
cx.background_spawn(async move {
let mut completions = project_completions.await?;
let snippets_completions = snippets.await?;
completions.extend(snippets_completions);
Ok(completions)
match project_completions.await? {
Some(mut completions) => {
completions.extend(snippets_completions);
Ok(Some(completions))
}
None => {
if snippets_completions.is_empty() {
Ok(None)
} else {
Ok(Some(snippets_completions))
}
}
}
})
})
}