Support word-based completions (#26410)

Closes https://github.com/zed-industries/zed/issues/4957


https://github.com/user-attachments/assets/ff491378-376d-48ec-b552-6cc80f74200b

Adds `"completions"` language settings section, to configure LSP and
word completions per language.
Word-based completions may be turned on never, always (returned along
with the LSP ones), and as a fallback if no LSP completion items were
returned.

Future work:

* words are matched with the same fuzzy matching code that the rest of
the completions are

This might worsen the completion menu's usability even more, and will
require work on better completion sorting.

* completion entries currently have no icons or other ways to indicate
those are coming from LSP or from word search, or from something else

* we may work with language scopes more intelligently, group words by
them and distinguish during completions

Release Notes:

- Supported word-based completions

---------

Co-authored-by: Max Brunsfeld <max@zed.dev>
This commit is contained in:
Kirill Bulatov 2025-03-12 21:27:10 +02:00 committed by GitHub
parent 74c29f1818
commit 91c209900b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 632 additions and 102 deletions

View file

@ -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::<String>();
(
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| {

View file

@ -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::<lsp::request::Completion, _, _>(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, |_| {});