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

@ -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::request::ResolveCompletionItem>(*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<CoreCompletion> {
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::<String>()
{
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<LspStoreEvent> for LspStore {}
fn remove_empty_hover_blocks(mut hover: Hover) -> Option<Hover> {

View file

@ -388,6 +388,10 @@ pub enum CompletionSource {
resolved: bool,
},
Custom,
BufferWord {
word_range: Range<Anchor>,
resolved: bool,
},
}
impl CompletionSource {