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 | 219 ++++++++++++++++-- crates/project_symbols/src/project_symbols.rs | 4 +- 7 files changed, 251 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3d1d764822596c0060f77e684852553b5b74b0a..4b619bc4d1d90f817bba19cc42a4b43df46cce16 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 efecb70b59746b51db713b4a13153033d0dd3b10..dcd96674207f02101b4066924b011d2b9ebd7a08 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 1f23f08b101c56cde25ac6cd2eaffbc2c7f32469..3569fb10a11506131b277b6b36245289f3ad716c 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 8829eb94ac576be4b1ad7bc6512fd4720cf81dcb..ed9eb5d11d7bc4b156dc9bd660fb10a485129c3d 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 235f7a94c738f4204421ac685d40c2a71ea980a5..c0aa9c39aacd86e45071bfe7f7289e50cb64b9b1 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 25b7bfab7d049d3ba3ccef648dbd273e071635f0..ea56c1d203d111cd27f7654a7d8ef54d12cae9c9 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") + 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::>(); + 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] diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 95926116b7b450e36e8c53e192f95dfec76f1f00..61ed715ffd639c532257319d2165d530ae5c0513 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) => {