rust: Nicer snippet completions (#43891)

At some point, rust-analyzer started including the snippet expression
for some completions in the description field, but that can look like a
bug:

<img width="1570" height="578" alt="CleanShot 2025-11-27 at 20 39 49@2x"
src="https://github.com/user-attachments/assets/5a87c9fe-c0a8-472f-8d83-3bc9e9e00bbc"></img>


In these cases, we will now inline the tab stops as an ellipsis
character:

<img width="1544" height="428" alt="CleanShot 2025-12-01 at 10 01 18@2x"
src="https://github.com/user-attachments/assets/4c550891-4545-47cd-a295-a5eb07e78e92"></img>

You may also notice that we now syntax highlight the pattern closer to
what it looks like after accepted.

Alternatively, when the tab stop isn't just one position, it gets
highlighted as a selection since that's what it would do when accepted:

<img width="1558" height="314" alt="CleanShot 2025-12-01 at 10 04 37@2x"
src="https://github.com/user-attachments/assets/ce630ab2-da22-4072-a996-7b71ba21637d"
/>


Release Notes:

- rust: Display completion tab stops inline rather than as a raw LSP
snippet expression
This commit is contained in:
Agus Zubiaga
2025-12-01 12:19:00 -03:00
committed by GitHub
parent a996fa4320
commit 8561943095
7 changed files with 252 additions and 55 deletions
Generated
+2
View File
@@ -9043,7 +9043,9 @@ dependencies = [
"serde_json",
"serde_json_lenient",
"settings",
"smallvec",
"smol",
"snippet",
"task",
"terminal",
"text",
+30 -28
View File
@@ -835,36 +835,38 @@ impl CompletionsMenu {
FontWeight::BOLD.into(),
)
}),
styled_runs_for_code_label(&completion.label, &style.syntax).map(
|(range, mut highlight)| {
// Ignore font weight for syntax highlighting, as we'll use it
// for fuzzy matches.
highlight.font_weight = None;
if completion
.source
.lsp_completion(false)
.and_then(|lsp_completion| {
match (lsp_completion.deprecated, &lsp_completion.tags)
{
(Some(true), _) => Some(true),
(_, Some(tags)) => Some(
tags.contains(&CompletionItemTag::DEPRECATED),
),
_ => None,
styled_runs_for_code_label(
&completion.label,
&style.syntax,
&style.local_player,
)
.map(|(range, mut highlight)| {
// Ignore font weight for syntax highlighting, as we'll use it
// for fuzzy matches.
highlight.font_weight = None;
if completion
.source
.lsp_completion(false)
.and_then(|lsp_completion| {
match (lsp_completion.deprecated, &lsp_completion.tags) {
(Some(true), _) => Some(true),
(_, Some(tags)) => {
Some(tags.contains(&CompletionItemTag::DEPRECATED))
}
})
.unwrap_or(false)
{
highlight.strikethrough = Some(StrikethroughStyle {
thickness: 1.0.into(),
..Default::default()
});
highlight.color = Some(cx.theme().colors().text_muted);
}
_ => None,
}
})
.unwrap_or(false)
{
highlight.strikethrough = Some(StrikethroughStyle {
thickness: 1.0.into(),
..Default::default()
});
highlight.color = Some(cx.theme().colors().text_muted);
}
(range, highlight)
},
),
(range, highlight)
}),
);
let completion_label = StyledText::new(completion.label.text.clone())
+12 -1
View File
@@ -24922,6 +24922,7 @@ pub fn diagnostic_style(severity: lsp::DiagnosticSeverity, colors: &StatusColors
pub fn styled_runs_for_code_label<'a>(
label: &'a CodeLabel,
syntax_theme: &'a theme::SyntaxTheme,
local_player: &'a theme::PlayerColor,
) -> impl 'a + Iterator<Item = (Range<usize>, HighlightStyle)> {
let fade_out = HighlightStyle {
fade_out: Some(0.35),
@@ -24934,7 +24935,17 @@ pub fn styled_runs_for_code_label<'a>(
.iter()
.enumerate()
.flat_map(move |(ix, (range, highlight_id))| {
let style = if let Some(style) = highlight_id.style(syntax_theme) {
let style = if *highlight_id == language::HighlightId::TABSTOP_INSERT_ID {
HighlightStyle {
color: Some(local_player.cursor),
..Default::default()
}
} else if *highlight_id == language::HighlightId::TABSTOP_REPLACE_ID {
HighlightStyle {
background_color: Some(local_player.selection),
..Default::default()
}
} else if let Some(style) = highlight_id.style(syntax_theme) {
style
} else {
return Default::default();
+3
View File
@@ -51,6 +51,9 @@ impl HighlightMap {
}
impl HighlightId {
pub const TABSTOP_INSERT_ID: HighlightId = HighlightId(u32::MAX - 1);
pub const TABSTOP_REPLACE_ID: HighlightId = HighlightId(u32::MAX - 2);
pub(crate) fn is_default(&self) -> bool {
*self == DEFAULT_SYNTAX_HIGHLIGHT_ID
}
+4 -2
View File
@@ -45,8 +45,8 @@ futures.workspace = true
globset.workspace = true
gpui.workspace = true
http_client.workspace = true
json_schema_store.workspace = true
itertools.workspace = true
json_schema_store.workspace = true
language.workspace = true
log.workspace = true
lsp.workspace = true
@@ -67,8 +67,9 @@ serde.workspace = true
serde_json.workspace = true
serde_json_lenient.workspace = true
settings.workspace = true
smallvec.workspace = true
smol.workspace = true
url.workspace = true
snippet.workspace = true
task.workspace = true
terminal.workspace = true
theme.workspace = true
@@ -91,6 +92,7 @@ tree-sitter-regex = { workspace = true, optional = true }
tree-sitter-rust = { workspace = true, optional = true }
tree-sitter-typescript = { workspace = true, optional = true }
tree-sitter-yaml = { workspace = true, optional = true }
url.workspace = true
util.workspace = true
[dev-dependencies]
+198 -23
View File
@@ -13,7 +13,9 @@ use project::project_settings::ProjectSettings;
use regex::Regex;
use serde_json::json;
use settings::Settings as _;
use smallvec::SmallVec;
use smol::fs::{self};
use std::cmp::Reverse;
use std::fmt::Display;
use std::ops::Range;
use std::{
@@ -235,7 +237,7 @@ impl LspAdapter for RustLspAdapter {
.or(completion.detail.as_ref())
.map(|detail| detail.trim());
// this tends to contain alias and import information
let detail_left = completion
let mut detail_left = completion
.label_details
.as_ref()
.and_then(|detail| detail.detail.as_deref());
@@ -341,31 +343,88 @@ impl LspAdapter for RustLspAdapter {
}
}
(_, kind) => {
let highlight_name = kind.and_then(|kind| match kind {
lsp::CompletionItemKind::STRUCT
| lsp::CompletionItemKind::INTERFACE
| lsp::CompletionItemKind::ENUM => Some("type"),
lsp::CompletionItemKind::ENUM_MEMBER => Some("variant"),
lsp::CompletionItemKind::KEYWORD => Some("keyword"),
lsp::CompletionItemKind::VALUE | lsp::CompletionItemKind::CONSTANT => {
Some("constant")
}
_ => None,
});
let label = completion.label.clone();
let mut label;
let mut runs = vec![];
if let Some(highlight_name) = highlight_name {
let highlight_id = language.grammar()?.highlight_id_for_name(highlight_name)?;
runs.push((
0..label.rfind('(').unwrap_or(completion.label.len()),
highlight_id,
));
} else if detail_left.is_none() {
return None;
if completion.insert_text_format == Some(lsp::InsertTextFormat::SNIPPET)
&& let Some(
lsp::CompletionTextEdit::InsertAndReplace(lsp::InsertReplaceEdit {
new_text,
..
})
| lsp::CompletionTextEdit::Edit(lsp::TextEdit { new_text, .. }),
) = completion.text_edit.as_ref()
&& let Ok(mut snippet) = snippet::Snippet::parse(new_text)
&& !snippet.tabstops.is_empty()
{
label = String::new();
// we never display the final tabstop
snippet.tabstops.remove(snippet.tabstops.len() - 1);
let mut text_pos = 0;
let mut all_stop_ranges = snippet
.tabstops
.into_iter()
.flat_map(|stop| stop.ranges)
.collect::<SmallVec<[_; 8]>>();
all_stop_ranges.sort_unstable_by_key(|a| (a.start, Reverse(a.end)));
for range in &all_stop_ranges {
let start_pos = range.start as usize;
let end_pos = range.end as usize;
label.push_str(&snippet.text[text_pos..end_pos]);
text_pos = end_pos;
if start_pos == end_pos {
let caret_start = label.len();
label.push('…');
runs.push((caret_start..label.len(), HighlightId::TABSTOP_INSERT_ID));
} else {
runs.push((start_pos..end_pos, HighlightId::TABSTOP_REPLACE_ID));
}
}
label.push_str(&snippet.text[text_pos..]);
if detail_left.is_some_and(|detail_left| detail_left == new_text) {
// We only include the left detail if it isn't the snippet again
detail_left.take();
}
runs.extend(language.highlight_text(&Rope::from(&label), 0..label.len()));
} else {
let highlight_name = kind.and_then(|kind| match kind {
lsp::CompletionItemKind::STRUCT
| lsp::CompletionItemKind::INTERFACE
| lsp::CompletionItemKind::ENUM => Some("type"),
lsp::CompletionItemKind::ENUM_MEMBER => Some("variant"),
lsp::CompletionItemKind::KEYWORD => Some("keyword"),
lsp::CompletionItemKind::VALUE | lsp::CompletionItemKind::CONSTANT => {
Some("constant")
}
_ => None,
});
label = completion.label.clone();
if let Some(highlight_name) = highlight_name {
let highlight_id =
language.grammar()?.highlight_id_for_name(highlight_name)?;
runs.push((
0..label.rfind('(').unwrap_or(completion.label.len()),
highlight_id,
));
} else if detail_left.is_none() {
return None;
}
}
mk_label(label, &|| 0..completion.label.len(), runs)
let label_len = label.len();
mk_label(label, &|| 0..label_len, runs)
}
};
@@ -379,6 +438,7 @@ impl LspAdapter for RustLspAdapter {
label.text.push(')');
}
}
Some(label)
}
@@ -1371,6 +1431,121 @@ mod tests {
vec![(0..11, HighlightId(3)), (13..19, HighlightId(0))],
))
);
// Snippet with insert tabstop (empty placeholder)
assert_eq!(
adapter
.label_for_completion(
&lsp::CompletionItem {
kind: Some(lsp::CompletionItemKind::SNIPPET),
label: "println!".to_string(),
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: lsp::Range::default(),
new_text: "println!(\"$1\", $2)$0".to_string(),
})),
..Default::default()
},
&language,
)
.await,
Some(CodeLabel::new(
"println!(\"\", …)".to_string(),
0..8,
vec![
(10..13, HighlightId::TABSTOP_INSERT_ID),
(16..19, HighlightId::TABSTOP_INSERT_ID),
(0..7, HighlightId(2)),
(7..8, HighlightId(2)),
],
))
);
// Snippet with replace tabstop (placeholder with default text)
assert_eq!(
adapter
.label_for_completion(
&lsp::CompletionItem {
kind: Some(lsp::CompletionItemKind::SNIPPET),
label: "vec!".to_string(),
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: lsp::Range::default(),
new_text: "vec![${1:elem}]$0".to_string(),
})),
..Default::default()
},
&language,
)
.await,
Some(CodeLabel::new(
"vec![elem]".to_string(),
0..4,
vec![
(5..9, HighlightId::TABSTOP_REPLACE_ID),
(0..3, HighlightId(2)),
(3..4, HighlightId(2)),
],
))
);
// Snippet with tabstop appearing more than once
assert_eq!(
adapter
.label_for_completion(
&lsp::CompletionItem {
kind: Some(lsp::CompletionItemKind::SNIPPET),
label: "if let".to_string(),
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: lsp::Range::default(),
new_text: "if let ${1:pat} = $1 {\n $0\n}".to_string(),
})),
..Default::default()
},
&language,
)
.await,
Some(CodeLabel::new(
"if let pat = … {\n \n}".to_string(),
0..6,
vec![
(7..10, HighlightId::TABSTOP_REPLACE_ID),
(13..16, HighlightId::TABSTOP_INSERT_ID),
(0..2, HighlightId(1)),
(3..6, HighlightId(1)),
],
))
);
// Snippet with tabstops not in left-to-right order
assert_eq!(
adapter
.label_for_completion(
&lsp::CompletionItem {
kind: Some(lsp::CompletionItemKind::SNIPPET),
label: "for".to_string(),
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: lsp::Range::default(),
new_text: "for ${2:item} in ${1:iter} {\n $0\n}".to_string(),
})),
..Default::default()
},
&language,
)
.await,
Some(CodeLabel::new(
"for item in iter {\n \n}".to_string(),
0..3,
vec![
(4..8, HighlightId::TABSTOP_REPLACE_ID),
(12..16, HighlightId::TABSTOP_REPLACE_ID),
(0..3, HighlightId(1)),
(9..11, HighlightId(1)),
],
))
);
}
#[gpui::test]
@@ -224,7 +224,9 @@ impl PickerDelegate for ProjectSymbolsDelegate {
let path_style = self.project.read(cx).path_style(cx);
let string_match = &self.matches.get(ix)?;
let symbol = &self.symbols.get(string_match.candidate_id)?;
let syntax_runs = styled_runs_for_code_label(&symbol.label, cx.theme().syntax());
let theme = cx.theme();
let local_player = theme.players().local();
let syntax_runs = styled_runs_for_code_label(&symbol.label, theme.syntax(), &local_player);
let path = match &symbol.path {
SymbolLocation::InProject(project_path) => {