Fix completion labels becoming overly large due to LSP completion items with newlines (#23407)

Reworks https://github.com/zed-industries/zed/pull/23030 and
https://github.com/zed-industries/zed/pull/15087
Closes https://github.com/zed-industries/zed/issues/23352
Closes https://github.com/zed-industries/zed/issues/23310 

Zed's completion items use `label` from LSP completion items as a base
to show in the list:
d290da7dac/crates/project/src/lsp_store.rs (L4371-L4374)

Besides that, certain language plugins append `detail` or
`label_details.description` as a suffix:
d290da7dac/crates/languages/src/vtsls.rs (L178-L188)

Either of these 3 properties may return `\n` (or multiple) in it,
spoiling Zed's completion menu, which uses `UniformList` to render those
items: a uniform list uses common, minimum possible height for each
element, and `\n` bloats that overly.

Good approach would be to use something else:
https://github.com/zed-industries/zed/issues/21403 but that has its own
drawbacks and relatively hard to use instead (?).

We could follow VSCode's approach and move away all but `label` from
`CodeLabel.text` to the side, where the documentation is, but that does
not solve the issue with `details` having newlines.

So, for now, sanitize all labels and remove any newlines from them. If
newlines are found, also replace whitespace sequences if there's more
than 1 in a row.

Later, this approach can be improved similarly to how Helix and Zed's
inline completions do: rendering a "ghost" text, showing the
completion's edit applied to the editor.

Release Notes:

- Fixed completion labels becoming overly large due to LSP completion
items with newlines
This commit is contained in:
Kirill Bulatov 2025-01-21 17:55:41 +02:00 committed by GitHub
parent 94189e1784
commit 75c5344754
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 338 additions and 15 deletions

View file

@ -4357,7 +4357,7 @@ impl LspStore {
// NB: Zed does not have `details` inside the completion resolve capabilities, but certain language servers violate the spec and do not return `details` immediately, e.g. https://github.com/yioneko/vtsls/issues/213
// So we have to update the label here anyway...
let new_label = match snapshot.language() {
let mut new_label = match snapshot.language() {
Some(language) => {
adapter
.labels_for_completions(&[completion_item.clone()], language)
@ -4373,6 +4373,7 @@ impl LspStore {
completion_item.filter_text.as_deref(),
)
});
ensure_uniform_list_compatible_label(&mut new_label);
let mut completions = completions.borrow_mut();
let completion = &mut completions[completion_index];
@ -8014,15 +8015,18 @@ async fn populate_labels_for_completions(
None
};
let mut label = label.unwrap_or_else(|| {
CodeLabel::plain(
lsp_completion.label.clone(),
lsp_completion.filter_text.as_deref(),
)
});
ensure_uniform_list_compatible_label(&mut label);
completions.push(Completion {
old_range: completion.old_range,
new_text: completion.new_text,
label: label.unwrap_or_else(|| {
CodeLabel::plain(
lsp_completion.label.clone(),
lsp_completion.filter_text.as_deref(),
)
}),
label,
server_id: completion.server_id,
documentation,
lsp_completion,
@ -8733,6 +8737,70 @@ fn include_text(server: &lsp::LanguageServer) -> Option<bool> {
}
}
/// Completion items are displayed in a `UniformList`.
/// Usually, those items are single-line strings, but in LSP responses,
/// completion items `label`, `detail` and `label_details.description` may contain newlines or long spaces.
/// Many language plugins construct these items by joining these parts together, and we may fall back to `CodeLabel::plain` that uses `label`.
/// All that may lead to a newline being inserted into resulting `CodeLabel.text`, which will force `UniformList` to bloat each entry to occupy more space,
/// breaking the completions menu presentation.
///
/// Sanitize the text to ensure there are no newlines, or, if there are some, remove them and also remove long space sequences if there were newlines.
fn ensure_uniform_list_compatible_label(label: &mut CodeLabel) {
let mut new_text = String::with_capacity(label.text.len());
let mut offset_map = vec![0; label.text.len() + 1];
let mut last_char_was_space = false;
let mut new_idx = 0;
let mut chars = label.text.char_indices().fuse();
let mut newlines_removed = false;
while let Some((idx, c)) = chars.next() {
offset_map[idx] = new_idx;
match c {
'\n' if last_char_was_space => {
newlines_removed = true;
}
'\t' | ' ' if last_char_was_space => {}
'\n' if !last_char_was_space => {
new_text.push(' ');
new_idx += 1;
last_char_was_space = true;
newlines_removed = true;
}
' ' | '\t' => {
new_text.push(' ');
new_idx += 1;
last_char_was_space = true;
}
_ => {
new_text.push(c);
new_idx += 1;
last_char_was_space = false;
}
}
}
offset_map[label.text.len()] = new_idx;
// Only modify the label if newlines were removed.
if !newlines_removed {
return;
}
for (range, _) in &mut label.runs {
range.start = offset_map[range.start];
range.end = offset_map[range.end];
}
if label.filter_range == (0..label.text.len()) {
label.filter_range = 0..new_text.len();
} else {
label.filter_range.start = offset_map[label.filter_range.start];
label.filter_range.end = offset_map[label.filter_range.end];
}
label.text = new_text;
}
#[cfg(test)]
#[test]
fn test_glob_literal_prefix() {