From 85619430957dc5d4aadf9ee45b7e3e09b011145c Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Mon, 1 Dec 2025 12:19:00 -0300 Subject: [PATCH] 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: CleanShot 2025-11-27 at 20 39 49@2x In these cases, we will now inline the tab stops as an ellipsis character: CleanShot 2025-12-01 at 10 01 18@2x 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: CleanShot 2025-12-01 at 10 04 37@2x 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 | 221 ++++++++++++++++-- crates/project_symbols/src/project_symbols.rs | 4 +- 7 files changed, 252 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3d1d76482..4b619bc4d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9043,7 +9043,9 @@ dependencies = [ "serde_json", "serde_json_lenient", "settings", + "smallvec", "smol", + "snippet", "task", "terminal", "text", diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index efecb70b59..dcd9667420 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -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()) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1f23f08b10..3569fb10a1 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -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, 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(); diff --git a/crates/language/src/highlight_map.rs b/crates/language/src/highlight_map.rs index 8829eb94ac..ed9eb5d11d 100644 --- a/crates/language/src/highlight_map.rs +++ b/crates/language/src/highlight_map.rs @@ -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 } diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index 235f7a94c7..c0aa9c39aa 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -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] diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 25b7bfab7d..ea56c1d203 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -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::>(); + 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] diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 95926116b7..61ed715ffd 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -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) => {