Cargo.lock 🔗
@@ -9043,7 +9043,9 @@ dependencies = [
"serde_json",
"serde_json_lenient",
"settings",
+ "smallvec",
"smol",
+ "snippet",
"task",
"terminal",
"text",
Agus Zubiaga created
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
Cargo.lock | 2
crates/editor/src/code_context_menus.rs | 58 ++--
crates/editor/src/editor.rs | 13 +
crates/language/src/highlight_map.rs | 3
crates/languages/Cargo.toml | 6
crates/languages/src/rust.rs | 219 ++++++++++++++++++--
crates/project_symbols/src/project_symbols.rs | 4
7 files changed, 251 insertions(+), 54 deletions(-)
@@ -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]
@@ -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")
+ let mut label;
+ let mut runs = vec![];
+
+ 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));
+ }
}
- _ => None,
- });
- let label = completion.label.clone();
- 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;
+ 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) => {