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

@ -4146,12 +4146,9 @@ impl BufferSnapshot {
}
}
pub fn words_in_range(
&self,
query: Option<&str>,
range: Range<usize>,
) -> HashMap<String, Range<Anchor>> {
if query.map_or(false, |query| query.is_empty()) {
pub fn words_in_range(&self, query: WordsQuery) -> HashMap<String, Range<Anchor>> {
let query_str = query.fuzzy_contents;
if query_str.map_or(false, |query| query.is_empty()) {
return HashMap::default();
}
@ -4161,13 +4158,13 @@ impl BufferSnapshot {
}));
let mut query_ix = 0;
let query = query.map(|query| query.chars().collect::<Vec<_>>());
let query_len = query.as_ref().map_or(0, |query| query.len());
let query_chars = query_str.map(|query| query.chars().collect::<Vec<_>>());
let query_len = query_chars.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) {
let mut chunk_ix = query.range.start;
for chunk in self.chunks(query.range, false) {
for (i, c) in chunk.text.char_indices() {
let ix = chunk_ix + i;
if classifier.is_word(c) {
@ -4175,12 +4172,9 @@ impl BufferSnapshot {
current_word_start_ix = Some(ix);
}
if let Some(query) = &query {
if let Some(query_chars) = &query_chars {
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()) {
if c.to_lowercase().eq(query_chars[query_ix].to_lowercase()) {
query_ix += 1;
}
}
@ -4189,10 +4183,16 @@ impl BufferSnapshot {
} 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::<String>(),
word_range,
);
let mut word_text = self.text_for_range(word_start..ix).peekable();
let first_char = word_text
.peek()
.and_then(|first_chunk| first_chunk.chars().next());
// Skip empty and "words" starting with digits as a heuristic to reduce useless completions
if !query.skip_digits
|| first_char.map_or(true, |first_char| !first_char.is_digit(10))
{
words.insert(word_text.collect(), word_range);
}
}
}
query_ix = 0;
@ -4204,6 +4204,15 @@ impl BufferSnapshot {
}
}
pub struct WordsQuery<'a> {
/// Only returns words with all chars from the fuzzy string in them.
pub fuzzy_contents: Option<&'a str>,
/// Skips words that start with a digit.
pub skip_digits: bool,
/// Buffer offset range, to look for words.
pub range: Range<usize>,
}
fn indent_size_for_line(text: &text::BufferSnapshot, row: u32) -> IndentSize {
indent_size_for_text(text.chars_at(Point::new(row, 0)))
}

View file

@ -3145,7 +3145,11 @@ fn test_trailing_whitespace_ranges(mut rng: StdRng) {
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"#;
// The first line are words excluded from the results with heuristics, we do not expect them in the test assertions.
let contents = r#"
0_isize 123 3.4 4  
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);
@ -3159,7 +3163,11 @@ fn test_words_in_range(cx: &mut gpui::App) {
assert_eq!(
BTreeSet::from_iter(["Pizza".to_string()]),
snapshot
.words_in_range(Some("piz"), 0..snapshot.len())
.words_in_range(WordsQuery {
fuzzy_contents: Some("piz"),
skip_digits: true,
range: 0..snapshot.len(),
})
.into_keys()
.collect::<BTreeSet<_>>()
);
@ -3171,7 +3179,11 @@ fn test_words_in_range(cx: &mut gpui::App) {
"ÖÄPPLE".to_string(),
]),
snapshot
.words_in_range(Some("öp"), 0..snapshot.len())
.words_in_range(WordsQuery {
fuzzy_contents: Some("öp"),
skip_digits: true,
range: 0..snapshot.len(),
})
.into_keys()
.collect::<BTreeSet<_>>()
);
@ -3183,28 +3195,44 @@ fn test_words_in_range(cx: &mut gpui::App) {
"öäpple".to_string(),
]),
snapshot
.words_in_range(Some("öÄ"), 0..snapshot.len())
.words_in_range(WordsQuery {
fuzzy_contents: Some("öÄ"),
skip_digits: true,
range: 0..snapshot.len(),
})
.into_keys()
.collect::<BTreeSet<_>>()
);
assert_eq!(
BTreeSet::default(),
snapshot
.words_in_range(Some("öÄ好"), 0..snapshot.len())
.words_in_range(WordsQuery {
fuzzy_contents: Some("öÄ好"),
skip_digits: true,
range: 0..snapshot.len(),
})
.into_keys()
.collect::<BTreeSet<_>>()
);
assert_eq!(
BTreeSet::from_iter(["bar你".to_string(),]),
snapshot
.words_in_range(Some(""), 0..snapshot.len())
.words_in_range(WordsQuery {
fuzzy_contents: Some(""),
skip_digits: true,
range: 0..snapshot.len(),
})
.into_keys()
.collect::<BTreeSet<_>>()
);
assert_eq!(
BTreeSet::default(),
snapshot
.words_in_range(Some(""), 0..snapshot.len())
.words_in_range(WordsQuery {
fuzzy_contents: Some(""),
skip_digits: true,
range: 0..snapshot.len(),
},)
.into_keys()
.collect::<BTreeSet<_>>()
);
@ -3221,7 +3249,36 @@ fn test_words_in_range(cx: &mut gpui::App) {
"word2".to_string(),
]),
snapshot
.words_in_range(None, 0..snapshot.len())
.words_in_range(WordsQuery {
fuzzy_contents: None,
skip_digits: true,
range: 0..snapshot.len(),
})
.into_keys()
.collect::<BTreeSet<_>>()
);
assert_eq!(
BTreeSet::from_iter([
"0_isize".to_string(),
"123".to_string(),
"3".to_string(),
"4".to_string(),
"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(WordsQuery {
fuzzy_contents: None,
skip_digits: false,
range: 0..snapshot.len(),
})
.into_keys()
.collect::<BTreeSet<_>>()
);

View file

@ -326,8 +326,8 @@ pub struct CompletionSettings {
/// 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")]
/// Default: 0
#[serde(default = "default_lsp_fetch_timeout_ms")]
pub lsp_fetch_timeout_ms: u64,
}
@ -335,12 +335,13 @@ pub struct CompletionSettings {
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum WordsCompletionMode {
/// Always fetch document's words for completions.
/// Always fetch document's words for completions along with LSP completions.
Enabled,
/// Only if LSP response errors/times out/is empty,
/// Only if LSP response errors or times out,
/// use document's words to show completions.
Fallback,
/// Never fetch or complete document's words for completions.
/// (Word-based completions can still be queried via a separate action)
Disabled,
}
@ -348,8 +349,8 @@ fn default_words_completion_mode() -> WordsCompletionMode {
WordsCompletionMode::Fallback
}
fn lsp_fetch_timeout_ms() -> u64 {
500
fn default_lsp_fetch_timeout_ms() -> u64 {
0
}
/// The settings for a particular language.