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

@ -8437,6 +8437,247 @@ async fn test_completion(cx: &mut gpui::TestAppContext) {
apply_additional_edits.await.unwrap();
}
#[gpui::test]
async fn test_multiline_completion(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/a",
json!({
"main.ts": "a",
}),
)
.await;
let project = Project::test(fs, ["/a".as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
let typescript_language = Arc::new(Language::new(
LanguageConfig {
name: "TypeScript".into(),
matcher: LanguageMatcher {
path_suffixes: vec!["ts".to_string()],
..LanguageMatcher::default()
},
line_comments: vec!["// ".into()],
..LanguageConfig::default()
},
Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
));
language_registry.add(typescript_language.clone());
let mut fake_servers = language_registry.register_fake_lsp(
"TypeScript",
FakeLspAdapter {
capabilities: 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()
},
// Emulate vtsls label generation
label_for_completion: Some(Box::new(|item, _| {
let text = if let Some(description) = item
.label_details
.as_ref()
.and_then(|label_details| label_details.description.as_ref())
{
format!("{} {}", item.label, description)
} else if let Some(detail) = &item.detail {
format!("{} {}", item.label, detail)
} else {
item.label.clone()
};
let len = text.len();
Some(language::CodeLabel {
text,
runs: Vec::new(),
filter_range: 0..len,
})
})),
..FakeLspAdapter::default()
},
);
let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let worktree_id = workspace
.update(cx, |workspace, cx| {
workspace.project().update(cx, |project, cx| {
project.worktrees(cx).next().unwrap().read(cx).id()
})
})
.unwrap();
let _buffer = project
.update(cx, |project, cx| {
project.open_local_buffer_with_lsp("/a/main.ts", cx)
})
.await
.unwrap();
let editor = workspace
.update(cx, |workspace, cx| {
workspace.open_path((worktree_id, "main.ts"), None, true, cx)
})
.unwrap()
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let fake_server = fake_servers.next().await.unwrap();
let multiline_label = "StickyHeaderExcerpt {\n excerpt,\n next_excerpt_controls_present,\n next_buffer_row,\n }: StickyHeaderExcerpt<'_>,";
let multiline_label_2 = "a\nb\nc\n";
let multiline_detail = "[]struct {\n\tSignerId\tstruct {\n\t\tIssuer\t\t\tstring\t`json:\"issuer\"`\n\t\tSubjectSerialNumber\"`\n}}";
let multiline_description = "d\ne\nf\n";
let multiline_detail_2 = "g\nh\ni\n";
let mut completion_handle =
fake_server.handle_request::<lsp::request::Completion, _, _>(move |params, _| async move {
Ok(Some(lsp::CompletionResponse::Array(vec![
lsp::CompletionItem {
label: multiline_label.to_string(),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: lsp::Range {
start: lsp::Position {
line: params.text_document_position.position.line,
character: params.text_document_position.position.character,
},
end: lsp::Position {
line: params.text_document_position.position.line,
character: params.text_document_position.position.character,
},
},
new_text: "new_text_1".to_string(),
})),
..lsp::CompletionItem::default()
},
lsp::CompletionItem {
label: "single line label 1".to_string(),
detail: Some(multiline_detail.to_string()),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: lsp::Range {
start: lsp::Position {
line: params.text_document_position.position.line,
character: params.text_document_position.position.character,
},
end: lsp::Position {
line: params.text_document_position.position.line,
character: params.text_document_position.position.character,
},
},
new_text: "new_text_2".to_string(),
})),
..lsp::CompletionItem::default()
},
lsp::CompletionItem {
label: "single line label 2".to_string(),
label_details: Some(lsp::CompletionItemLabelDetails {
description: Some(multiline_description.to_string()),
detail: None,
}),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: lsp::Range {
start: lsp::Position {
line: params.text_document_position.position.line,
character: params.text_document_position.position.character,
},
end: lsp::Position {
line: params.text_document_position.position.line,
character: params.text_document_position.position.character,
},
},
new_text: "new_text_2".to_string(),
})),
..lsp::CompletionItem::default()
},
lsp::CompletionItem {
label: multiline_label_2.to_string(),
detail: Some(multiline_detail_2.to_string()),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: lsp::Range {
start: lsp::Position {
line: params.text_document_position.position.line,
character: params.text_document_position.position.character,
},
end: lsp::Position {
line: params.text_document_position.position.line,
character: params.text_document_position.position.character,
},
},
new_text: "new_text_3".to_string(),
})),
..lsp::CompletionItem::default()
},
lsp::CompletionItem {
label: "Label with many spaces and \t but without newlines".to_string(),
detail: Some(
"Details with many spaces and \t but without newlines".to_string(),
),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: lsp::Range {
start: lsp::Position {
line: params.text_document_position.position.line,
character: params.text_document_position.position.character,
},
end: lsp::Position {
line: params.text_document_position.position.line,
character: params.text_document_position.position.character,
},
},
new_text: "new_text_4".to_string(),
})),
..lsp::CompletionItem::default()
},
])))
});
editor.update(cx, |editor, cx| {
editor.focus(cx);
editor.move_to_end(&MoveToEnd, cx);
editor.handle_input(".", cx);
});
cx.run_until_parked();
completion_handle.next().await.unwrap();
editor.update(cx, |editor, _| {
assert!(editor.context_menu_visible());
if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref()
{
let completion_labels = menu
.completions
.borrow()
.iter()
.map(|c| c.label.text.clone())
.collect::<Vec<_>>();
assert_eq!(
completion_labels,
&[
"StickyHeaderExcerpt { excerpt, next_excerpt_controls_present, next_buffer_row, }: StickyHeaderExcerpt<'_>,",
"single line label 1 []struct { SignerId struct { Issuer string `json:\"issuer\"` SubjectSerialNumber\"` }}",
"single line label 2 d e f ",
"a b c g h i ",
"Label with many spaces and \t but without newlines Details with many spaces and \t but without newlines",
],
"Completion items should have their labels without newlines, also replacing excessive whitespaces. Completion items without newlines should not be altered.",
);
for completion in menu
.completions
.borrow()
.iter() {
assert_eq!(
completion.label.filter_range,
0..completion.label.text.len(),
"Adjusted completion items should still keep their filter ranges for the entire label. Item: {completion:?}"
);
}
} else {
panic!("expected completion menu to be open");
}
});
}
#[gpui::test]
async fn test_completion_page_up_down_keys(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});

View file

@ -763,6 +763,14 @@ pub struct FakeLspAdapter {
pub capabilities: lsp::ServerCapabilities,
pub initializer: Option<Box<dyn 'static + Send + Sync + Fn(&mut lsp::FakeLanguageServer)>>,
pub label_for_completion: Option<
Box<
dyn 'static
+ Send
+ Sync
+ Fn(&lsp::CompletionItem, &Arc<Language>) -> Option<CodeLabel>,
>,
>,
}
/// Configuration of handling bracket pairs for a given language.
@ -1778,6 +1786,7 @@ impl Default for FakeLspAdapter {
arguments: vec![],
env: Default::default(),
},
label_for_completion: None,
}
}
}
@ -1849,6 +1858,15 @@ impl LspAdapter for FakeLspAdapter {
) -> Result<Option<Value>> {
Ok(self.initialization_options.clone())
}
async fn label_for_completion(
&self,
item: &lsp::CompletionItem,
language: &Arc<Language>,
) -> Option<CodeLabel> {
let label_for_completion = self.label_for_completion.as_ref()?;
label_for_completion(item, language)
}
}
fn get_capture_indices(query: &Query, captures: &mut [(&str, &mut Option<u32>)]) {

View file

@ -212,16 +212,14 @@ impl LspAdapter for TypeScriptLspAdapter {
_ => None,
}?;
let one_line = |s: &str| s.replace(" ", "").replace('\n', " ");
let text = if let Some(description) = item
.label_details
.as_ref()
.and_then(|label_details| label_details.description.as_ref())
{
format!("{} {}", item.label, one_line(description))
format!("{} {}", item.label, description)
} else if let Some(detail) = &item.detail {
format!("{} {}", item.label, one_line(detail))
format!("{} {}", item.label, detail)
} else {
item.label.clone()
};

View file

@ -175,16 +175,14 @@ impl LspAdapter for VtslsLspAdapter {
_ => None,
}?;
let one_line = |s: &str| s.replace(" ", "").replace('\n', " ");
let text = if let Some(description) = item
.label_details
.as_ref()
.and_then(|label_details| label_details.description.as_ref())
{
format!("{} {}", item.label, one_line(description))
format!("{} {}", item.label, description)
} else if let Some(detail) = &item.detail {
format!("{} {}", item.label, one_line(detail))
format!("{} {}", item.label, detail)
} else {
item.label.clone()
};

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
};
completions.push(Completion {
old_range: completion.old_range,
new_text: completion.new_text,
label: label.unwrap_or_else(|| {
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,
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() {