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:
Generated
+2
@@ -9043,7 +9043,9 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_json_lenient",
|
||||
"settings",
|
||||
"smallvec",
|
||||
"smol",
|
||||
"snippet",
|
||||
"task",
|
||||
"terminal",
|
||||
"text",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user