diff --git a/Cargo.lock b/Cargo.lock index d1058c02c72..9ad34f71605 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,6 +633,7 @@ dependencies = [ "once_cell", "profile", "rustc-hash", + "smallvec", "sourcegen", "stdx", "syntax", diff --git a/crates/ide/src/lib.rs b/crates/ide/src/lib.rs index 5c472279907..35b6a758d41 100644 --- a/crates/ide/src/lib.rs +++ b/crates/ide/src/lib.rs @@ -98,7 +98,8 @@ pub use ide_assists::{ Assist, AssistConfig, AssistId, AssistKind, AssistResolveStrategy, SingleResolve, }; pub use ide_completion::{ - CompletionConfig, CompletionItem, CompletionItemKind, CompletionRelevance, ImportEdit, + CompletionConfig, CompletionItem, CompletionItemKind, CompletionRelevance, ImportEdit, Snippet, + SnippetScope, }; pub use ide_db::{ base_db::{ @@ -532,19 +533,10 @@ impl Analysis { &self, config: &CompletionConfig, position: FilePosition, - full_import_path: &str, - imported_name: String, + imports: impl IntoIterator + std::panic::UnwindSafe, ) -> Cancellable> { Ok(self - .with_db(|db| { - ide_completion::resolve_completion_edits( - db, - config, - position, - full_import_path, - imported_name, - ) - })? + .with_db(|db| ide_completion::resolve_completion_edits(db, config, position, imports))? .unwrap_or_default()) } diff --git a/crates/ide_completion/Cargo.toml b/crates/ide_completion/Cargo.toml index 8d910015602..0d4413978d4 100644 --- a/crates/ide_completion/Cargo.toml +++ b/crates/ide_completion/Cargo.toml @@ -14,6 +14,7 @@ itertools = "0.10.0" rustc-hash = "1.1.0" either = "1.6.1" once_cell = "1.7" +smallvec = "1.4" stdx = { path = "../stdx", version = "0.0.0" } syntax = { path = "../syntax", version = "0.0.0" } diff --git a/crates/ide_completion/src/completions/postfix.rs b/crates/ide_completion/src/completions/postfix.rs index 55c49c23228..b74030c1735 100644 --- a/crates/ide_completion/src/completions/postfix.rs +++ b/crates/ide_completion/src/completions/postfix.rs @@ -2,8 +2,9 @@ mod format_like; +use hir::Documentation; use ide_db::{ - helpers::{FamousDefs, SnippetCap}, + helpers::{insert_use::ImportScope, FamousDefs, SnippetCap}, ty_filter::TryEnum, }; use syntax::{ @@ -56,6 +57,10 @@ pub(crate) fn complete_postfix(acc: &mut Completions, ctx: &CompletionContext) { let postfix_snippet = build_postfix_snippet_builder(ctx, cap, &dot_receiver); + if !ctx.config.snippets.is_empty() { + add_custom_postfix_completions(acc, ctx, &postfix_snippet, &receiver_text); + } + let try_enum = TryEnum::from_ty(&ctx.sema, &receiver_ty.strip_references()); if let Some(try_enum) = &try_enum { match try_enum { @@ -218,13 +223,40 @@ fn build_postfix_snippet_builder<'a>( } } +fn add_custom_postfix_completions( + acc: &mut Completions, + ctx: &CompletionContext, + postfix_snippet: impl Fn(&str, &str, &str) -> Builder, + receiver_text: &str, +) -> Option<()> { + let import_scope = + ImportScope::find_insert_use_container_with_macros(&ctx.token.parent()?, &ctx.sema)?; + ctx.config.postfix_snippets().filter(|(_, snip)| snip.is_expr()).for_each( + |(trigger, snippet)| { + let imports = match snippet.imports(ctx, &import_scope) { + Some(imports) => imports, + None => return, + }; + let body = snippet.postfix_snippet(&receiver_text); + let mut builder = + postfix_snippet(trigger, snippet.description.as_deref().unwrap_or_default(), &body); + builder.documentation(Documentation::new(format!("```rust\n{}\n```", body))); + for import in imports.into_iter() { + builder.add_import(import); + } + builder.add_to(acc); + }, + ); + None +} + #[cfg(test)] mod tests { use expect_test::{expect, Expect}; use crate::{ - tests::{check_edit, filtered_completion_list}, - CompletionKind, + tests::{check_edit, check_edit_with_config, filtered_completion_list, TEST_CONFIG}, + CompletionConfig, CompletionKind, Snippet, }; fn check(ra_fixture: &str, expect: Expect) { @@ -442,6 +474,34 @@ fn main() { ) } + #[test] + fn custom_postfix_completion() { + check_edit_with_config( + CompletionConfig { + snippets: vec![Snippet::new( + &[], + &["break".into()], + &["ControlFlow::Break(${receiver})".into()], + "", + &["core::ops::ControlFlow".into()], + crate::SnippetScope::Expr, + ) + .unwrap()], + ..TEST_CONFIG + }, + "break", + r#" +//- minicore: try +fn main() { 42.$0 } +"#, + r#" +use core::ops::ControlFlow; + +fn main() { ControlFlow::Break(42) } +"#, + ); + } + #[test] fn postfix_completion_for_format_like_strings() { check_edit( diff --git a/crates/ide_completion/src/completions/snippet.rs b/crates/ide_completion/src/completions/snippet.rs index a896a759abf..a0e5f56129e 100644 --- a/crates/ide_completion/src/completions/snippet.rs +++ b/crates/ide_completion/src/completions/snippet.rs @@ -1,11 +1,12 @@ //! This file provides snippet completions, like `pd` => `eprintln!(...)`. -use ide_db::helpers::SnippetCap; +use hir::Documentation; +use ide_db::helpers::{insert_use::ImportScope, SnippetCap}; use syntax::T; use crate::{ context::PathCompletionContext, item::Builder, CompletionContext, CompletionItem, - CompletionItemKind, CompletionKind, Completions, + CompletionItemKind, CompletionKind, Completions, SnippetScope, }; fn snippet(ctx: &CompletionContext, cap: SnippetCap, label: &str, snippet: &str) -> Builder { @@ -29,6 +30,10 @@ pub(crate) fn complete_expr_snippet(acc: &mut Completions, ctx: &CompletionConte None => return, }; + if !ctx.config.snippets.is_empty() { + add_custom_completions(acc, ctx, cap, SnippetScope::Expr); + } + if can_be_stmt { snippet(ctx, cap, "pd", "eprintln!(\"$0 = {:?}\", $0);").add_to(acc); snippet(ctx, cap, "ppd", "eprintln!(\"$0 = {:#?}\", $0);").add_to(acc); @@ -52,6 +57,10 @@ pub(crate) fn complete_item_snippet(acc: &mut Completions, ctx: &CompletionConte None => return, }; + if !ctx.config.snippets.is_empty() { + add_custom_completions(acc, ctx, cap, SnippetScope::Item); + } + let mut item = snippet( ctx, cap, @@ -86,3 +95,66 @@ fn ${1:feature}() { let item = snippet(ctx, cap, "macro_rules", "macro_rules! $1 {\n\t($2) => {\n\t\t$0\n\t};\n}"); item.add_to(acc); } + +fn add_custom_completions( + acc: &mut Completions, + ctx: &CompletionContext, + cap: SnippetCap, + scope: SnippetScope, +) -> Option<()> { + let import_scope = + ImportScope::find_insert_use_container_with_macros(&ctx.token.parent()?, &ctx.sema)?; + ctx.config.prefix_snippets().filter(|(_, snip)| snip.scope == scope).for_each( + |(trigger, snip)| { + let imports = match snip.imports(ctx, &import_scope) { + Some(imports) => imports, + None => return, + }; + let body = snip.snippet(); + let mut builder = snippet(ctx, cap, &trigger, &body); + builder.documentation(Documentation::new(format!("```rust\n{}\n```", body))); + for import in imports.into_iter() { + builder.add_import(import); + } + builder.detail(snip.description.as_deref().unwrap_or_default()); + builder.add_to(acc); + }, + ); + None +} + +#[cfg(test)] +mod tests { + use crate::{ + tests::{check_edit_with_config, TEST_CONFIG}, + CompletionConfig, Snippet, + }; + + #[test] + fn custom_snippet_completion() { + check_edit_with_config( + CompletionConfig { + snippets: vec![Snippet::new( + &["break".into()], + &[], + &["ControlFlow::Break(())".into()], + "", + &["core::ops::ControlFlow".into()], + crate::SnippetScope::Expr, + ) + .unwrap()], + ..TEST_CONFIG + }, + "break", + r#" +//- minicore: try +fn main() { $0 } +"#, + r#" +use core::ops::ControlFlow; + +fn main() { ControlFlow::Break(()) } +"#, + ); + } +} diff --git a/crates/ide_completion/src/config.rs b/crates/ide_completion/src/config.rs index c300ce887be..c659b4455a9 100644 --- a/crates/ide_completion/src/config.rs +++ b/crates/ide_completion/src/config.rs @@ -6,6 +6,8 @@ use ide_db::helpers::{insert_use::InsertUseConfig, SnippetCap}; +use crate::snippet::Snippet; + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CompletionConfig { pub enable_postfix_completions: bool, @@ -15,4 +17,18 @@ pub struct CompletionConfig { pub add_call_argument_snippets: bool, pub snippet_cap: Option, pub insert_use: InsertUseConfig, + pub snippets: Vec, +} + +impl CompletionConfig { + pub fn postfix_snippets(&self) -> impl Iterator { + self.snippets.iter().flat_map(|snip| { + snip.postfix_triggers.iter().map(move |trigger| (trigger.as_str(), snip)) + }) + } + pub fn prefix_snippets(&self) -> impl Iterator { + self.snippets.iter().flat_map(|snip| { + snip.prefix_triggers.iter().map(move |trigger| (trigger.as_str(), snip)) + }) + } } diff --git a/crates/ide_completion/src/context.rs b/crates/ide_completion/src/context.rs index d6e15e6af7b..a34e529ea58 100644 --- a/crates/ide_completion/src/context.rs +++ b/crates/ide_completion/src/context.rs @@ -868,7 +868,8 @@ mod tests { fn check_expected_type_and_name(ra_fixture: &str, expect: Expect) { let (db, pos) = position(ra_fixture); - let completion_context = CompletionContext::new(&db, pos, &TEST_CONFIG).unwrap(); + let config = TEST_CONFIG; + let completion_context = CompletionContext::new(&db, pos, &config).unwrap(); let ty = completion_context .expected_type diff --git a/crates/ide_completion/src/item.rs b/crates/ide_completion/src/item.rs index 2bc69d5657e..4c75bd69000 100644 --- a/crates/ide_completion/src/item.rs +++ b/crates/ide_completion/src/item.rs @@ -11,6 +11,7 @@ use ide_db::{ }, SymbolKind, }; +use smallvec::SmallVec; use stdx::{format_to, impl_from, never}; use syntax::{algo, TextRange}; use text_edit::TextEdit; @@ -76,7 +77,7 @@ pub struct CompletionItem { ref_match: Option, /// The import data to add to completion's edits. - import_to_add: Option, + import_to_add: SmallVec<[ImportEdit; 1]>, } // We use custom debug for CompletionItem to make snapshot tests more readable. @@ -305,7 +306,7 @@ impl CompletionItem { trigger_call_info: None, relevance: CompletionRelevance::default(), ref_match: None, - import_to_add: None, + imports_to_add: Default::default(), } } @@ -364,8 +365,8 @@ impl CompletionItem { self.ref_match.map(|mutability| (mutability, relevance)) } - pub fn import_to_add(&self) -> Option<&ImportEdit> { - self.import_to_add.as_ref() + pub fn imports_to_add(&self) -> &[ImportEdit] { + &self.import_to_add } } @@ -398,7 +399,7 @@ impl ImportEdit { pub(crate) struct Builder { source_range: TextRange, completion_kind: CompletionKind, - import_to_add: Option, + imports_to_add: SmallVec<[ImportEdit; 1]>, trait_name: Option, label: String, insert_text: Option, @@ -422,14 +423,13 @@ impl Builder { let mut lookup = self.lookup; let mut insert_text = self.insert_text; - if let Some(original_path) = self - .import_to_add - .as_ref() - .and_then(|import_edit| import_edit.import.original_path.as_ref()) - { - lookup = lookup.or_else(|| Some(label.clone())); - insert_text = insert_text.or_else(|| Some(label.clone())); - format_to!(label, " (use {})", original_path) + if let [import_edit] = &*self.imports_to_add { + // snippets can have multiple imports, but normal completions only have up to one + if let Some(original_path) = import_edit.import.original_path.as_ref() { + lookup = lookup.or_else(|| Some(label.clone())); + insert_text = insert_text.or_else(|| Some(label.clone())); + format_to!(label, " (use {})", original_path) + } } else if let Some(trait_name) = self.trait_name { insert_text = insert_text.or_else(|| Some(label.clone())); format_to!(label, " (as {})", trait_name) @@ -456,7 +456,7 @@ impl Builder { trigger_call_info: self.trigger_call_info.unwrap_or(false), relevance: self.relevance, ref_match: self.ref_match, - import_to_add: self.import_to_add, + import_to_add: self.imports_to_add, } } pub(crate) fn lookup_by(&mut self, lookup: impl Into) -> &mut Builder { @@ -527,8 +527,8 @@ impl Builder { self.trigger_call_info = Some(true); self } - pub(crate) fn add_import(&mut self, import_to_add: Option) -> &mut Builder { - self.import_to_add = import_to_add; + pub(crate) fn add_import(&mut self, import_to_add: ImportEdit) -> &mut Builder { + self.imports_to_add.push(import_to_add); self } pub(crate) fn ref_match(&mut self, mutability: Mutability) -> &mut Builder { diff --git a/crates/ide_completion/src/lib.rs b/crates/ide_completion/src/lib.rs index f10f3772b19..251ddfa2fc0 100644 --- a/crates/ide_completion/src/lib.rs +++ b/crates/ide_completion/src/lib.rs @@ -9,16 +9,19 @@ mod render; #[cfg(test)] mod tests; +mod snippet; use completions::flyimport::position_for_import; use ide_db::{ base_db::FilePosition, helpers::{ - import_assets::{LocatedImport, NameToImport}, - insert_use::ImportScope, + import_assets::NameToImport, + insert_use::{self, ImportScope}, + mod_path_to_ast, }, items_locator, RootDatabase, }; +use syntax::algo; use text_edit::TextEdit; use crate::{completions::Completions, context::CompletionContext, item::CompletionKind}; @@ -26,6 +29,7 @@ use crate::{completions::Completions, context::CompletionContext, item::Completi pub use crate::{ config::CompletionConfig, item::{CompletionItem, CompletionItemKind, CompletionRelevance, ImportEdit}, + snippet::{Snippet, SnippetScope}, }; //FIXME: split the following feature into fine-grained features. @@ -173,31 +177,37 @@ pub fn resolve_completion_edits( db: &RootDatabase, config: &CompletionConfig, position: FilePosition, - full_import_path: &str, - imported_name: String, + imports: impl IntoIterator, ) -> Option> { + let _p = profile::span("resolve_completion_edits"); let ctx = CompletionContext::new(db, position, config)?; let position_for_import = position_for_import(&ctx, None)?; let scope = ImportScope::find_insert_use_container_with_macros(position_for_import, &ctx.sema)?; let current_module = ctx.sema.scope(position_for_import).module()?; let current_crate = current_module.krate(); + let new_ast = scope.clone_for_update(); + let mut import_insert = TextEdit::builder(); - let (import_path, item_to_import) = items_locator::items_with_name( - &ctx.sema, - current_crate, - NameToImport::Exact(imported_name), - items_locator::AssocItemSearch::Include, - Some(items_locator::DEFAULT_QUERY_SEARCH_LIMIT.inner()), - ) - .filter_map(|candidate| { - current_module - .find_use_path_prefixed(db, candidate, config.insert_use.prefix_kind) - .zip(Some(candidate)) - }) - .find(|(mod_path, _)| mod_path.to_string() == full_import_path)?; - let import = - LocatedImport::new(import_path.clone(), item_to_import, item_to_import, Some(import_path)); + // FIXME: lift out and make some tests here, this is ImportEdit::to_text_edit but changed to work with multiple edits + imports.into_iter().for_each(|(full_import_path, imported_name)| { + let items_with_name = items_locator::items_with_name( + &ctx.sema, + current_crate, + NameToImport::Exact(imported_name), + items_locator::AssocItemSearch::Include, + Some(items_locator::DEFAULT_QUERY_SEARCH_LIMIT.inner()), + ); + let import = items_with_name + .filter_map(|candidate| { + current_module.find_use_path_prefixed(db, candidate, config.insert_use.prefix_kind) + }) + .find(|mod_path| mod_path.to_string() == full_import_path); + if let Some(import_path) = import { + insert_use::insert_use(&new_ast, mod_path_to_ast(&import_path), &config.insert_use); + } + }); - ImportEdit { import, scope }.to_text_edit(config.insert_use).map(|edit| vec![edit]) + algo::diff(scope.as_syntax_node(), new_ast.as_syntax_node()).into_text_edit(&mut import_insert); + Some(vec![import_insert.finish()]) } diff --git a/crates/ide_completion/src/render.rs b/crates/ide_completion/src/render.rs index 62a5fac5349..58443f566ef 100644 --- a/crates/ide_completion/src/render.rs +++ b/crates/ide_completion/src/render.rs @@ -212,7 +212,10 @@ fn render_resolution_( ctx.source_range(), local_name.to_string(), ); - item.kind(CompletionItemKind::UnresolvedReference).add_import(import_to_add); + item.kind(CompletionItemKind::UnresolvedReference); + if let Some(import_to_add) = import_to_add { + item.add_import(import_to_add); + } return Some(item.build()); } }; @@ -258,9 +261,12 @@ fn render_resolution_( } } item.kind(kind) - .add_import(import_to_add) .set_documentation(scope_def_docs(ctx.db(), resolution)) .set_deprecated(scope_def_is_deprecated(&ctx, resolution)); + + if let Some(import_to_add) = import_to_add { + item.add_import(import_to_add); + } Some(item.build()) } diff --git a/crates/ide_completion/src/render/enum_variant.rs b/crates/ide_completion/src/render/enum_variant.rs index d5cfd8bba46..2ba86eaa0af 100644 --- a/crates/ide_completion/src/render/enum_variant.rs +++ b/crates/ide_completion/src/render/enum_variant.rs @@ -68,9 +68,12 @@ impl<'a> EnumRender<'a> { item.kind(SymbolKind::Variant) .set_documentation(self.variant.docs(self.ctx.db())) .set_deprecated(self.ctx.is_deprecated(self.variant)) - .add_import(import_to_add) .detail(self.detail()); + if let Some(import_to_add) = import_to_add { + item.add_import(import_to_add); + } + if self.variant_kind == hir::StructKind::Tuple { cov_mark::hit!(inserts_parens_for_tuple_enums); let params = Params::Anonymous(self.variant.fields(self.ctx.db()).len()); diff --git a/crates/ide_completion/src/render/function.rs b/crates/ide_completion/src/render/function.rs index 904624f9ab0..cc95bd53690 100644 --- a/crates/ide_completion/src/render/function.rs +++ b/crates/ide_completion/src/render/function.rs @@ -97,7 +97,10 @@ impl<'a> FunctionRender<'a> { } } - item.add_import(import_to_add).lookup_by(self.name); + if let Some(import_to_add) = import_to_add { + item.add_import(import_to_add); + } + item.lookup_by(self.name); let ret_type = self.func.ret_type(self.ctx.db()); item.set_relevance(CompletionRelevance { diff --git a/crates/ide_completion/src/render/macro_.rs b/crates/ide_completion/src/render/macro_.rs index d1b549df1bb..196b667baac 100644 --- a/crates/ide_completion/src/render/macro_.rs +++ b/crates/ide_completion/src/render/macro_.rs @@ -51,9 +51,12 @@ impl<'a> MacroRender<'a> { item.kind(SymbolKind::Macro) .set_documentation(self.docs.clone()) .set_deprecated(self.ctx.is_deprecated(self.macro_)) - .add_import(import_to_add) .set_detail(self.detail()); + if let Some(import_to_add) = import_to_add { + item.add_import(import_to_add); + } + let needs_bang = !(self.ctx.completion.in_use_tree() || matches!(self.ctx.completion.path_call_kind(), Some(CallKind::Mac))); let has_parens = self.ctx.completion.path_call_kind().is_some(); diff --git a/crates/ide_completion/src/snippet.rs b/crates/ide_completion/src/snippet.rs new file mode 100644 index 00000000000..d527f3aef6f --- /dev/null +++ b/crates/ide_completion/src/snippet.rs @@ -0,0 +1,176 @@ +//! User (postfix)-snippet definitions. +//! +//! Actual logic is implemented in [`crate::completions::postfix`] and [`crate::completions::snippet`]. + +// Feature: User Snippet Completions +// +// rust-analyzer allows the user to define custom (postfix)-snippets that may depend on items to be accessible for the current scope to be applicable. +// +// A custom snippet can be defined by adding it to the `rust-analyzer.completion.snippets` object respectively. +// +// [source,json] +// ---- +// { +// "rust-analyzer.completion.snippets": { +// "thread spawn": { +// "prefix": ["spawn", "tspawn"], +// "body": [ +// "thread::spawn(move || {", +// "\t$0", +// ")};", +// ], +// "description": "Insert a thread::spawn call", +// "requires": "std::thread", +// "scope": "expr", +// } +// } +// } +// ---- +// +// In the example above: +// +// * `"thread spawn"` is the name of the snippet. +// +// * `prefix` defines one or more trigger words that will trigger the snippets completion. +// Using `postfix` will instead create a postfix snippet. +// +// * `body` is one or more lines of content joined via newlines for the final output. +// +// * `description` is an optional description of the snippet, if unset the snippet name will be used. +// +// * `requires` is an optional list of item paths that have to be resolvable in the current crate where the completion is rendered. +// On failure of resolution the snippet won't be applicable, otherwise the snippet will insert an import for the items on insertion if +// the items aren't yet in scope. +// +// * `scope` is an optional filter for when the snippet should be applicable. Possible values are: +// ** for Snippet-Scopes: `expr`, `item` (default: `item`) +// ** for Postfix-Snippet-Scopes: `expr`, `type` (default: `expr`) +// +// The `body` field also has access to placeholders as visible in the example as `$0`. +// These placeholders take the form of `$number` or `${number:placeholder_text}` which can be traversed as tabstop in ascending order starting from 1, +// with `$0` being a special case that always comes last. +// +// There is also a special placeholder, `${receiver}`, which will be replaced by the receiver expression for postfix snippets, or nothing in case of normal snippets. +// It does not act as a tabstop. +use ide_db::helpers::{import_assets::LocatedImport, insert_use::ImportScope}; +use itertools::Itertools; +use syntax::ast; + +use crate::{context::CompletionContext, ImportEdit}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SnippetScope { + Item, + Expr, + Type, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Snippet { + pub postfix_triggers: Box<[String]>, + pub prefix_triggers: Box<[String]>, + pub scope: SnippetScope, + snippet: String, + pub description: Option, + pub requires: Box<[String]>, +} + +impl Snippet { + pub fn new( + prefix_triggers: &[String], + postfix_triggers: &[String], + snippet: &[String], + description: &str, + requires: &[String], + scope: SnippetScope, + ) -> Option { + let (snippet, description) = validate_snippet(snippet, description, requires)?; + Some(Snippet { + // Box::into doesn't work as that has a Copy bound 😒 + postfix_triggers: postfix_triggers.iter().cloned().collect(), + prefix_triggers: prefix_triggers.iter().cloned().collect(), + scope, + snippet, + description, + requires: requires.iter().cloned().collect(), + }) + } + + /// Returns None if the required items do not resolve. + pub(crate) fn imports( + &self, + ctx: &CompletionContext, + import_scope: &ImportScope, + ) -> Option> { + import_edits(ctx, import_scope, &self.requires) + } + + pub fn snippet(&self) -> String { + self.snippet.replace("${receiver}", "") + } + + pub fn postfix_snippet(&self, receiver: &str) -> String { + self.snippet.replace("${receiver}", receiver) + } + + pub fn is_item(&self) -> bool { + self.scope == SnippetScope::Item + } + + pub fn is_expr(&self) -> bool { + self.scope == SnippetScope::Expr + } +} + +fn import_edits( + ctx: &CompletionContext, + import_scope: &ImportScope, + requires: &[String], +) -> Option> { + let resolve = |import| { + let path = ast::Path::parse(import).ok()?; + let item = match ctx.scope.speculative_resolve(&path)? { + hir::PathResolution::Macro(mac) => mac.into(), + hir::PathResolution::Def(def) => def.into(), + _ => return None, + }; + let path = ctx.scope.module()?.find_use_path_prefixed( + ctx.db, + item, + ctx.config.insert_use.prefix_kind, + )?; + Some((path.len() > 1).then(|| ImportEdit { + import: LocatedImport::new(path.clone(), item, item, None), + scope: import_scope.clone(), + })) + }; + let mut res = Vec::with_capacity(requires.len()); + for import in requires { + match resolve(import) { + Some(first) => res.extend(first), + None => return None, + } + } + Some(res) +} + +fn validate_snippet( + snippet: &[String], + description: &str, + requires: &[String], +) -> Option<(String, Option)> { + // validate that these are indeed simple paths + // we can't save the paths unfortunately due to them not being Send+Sync + if requires.iter().any(|path| match ast::Path::parse(path) { + Ok(path) => path.segments().any(|seg| { + !matches!(seg.kind(), Some(ast::PathSegmentKind::Name(_))) + || seg.generic_arg_list().is_some() + }), + Err(_) => true, + }) { + return None; + } + let snippet = snippet.iter().join("\n"); + let description = if description.is_empty() { None } else { Some(description.to_owned()) }; + Some((snippet, description)) +} diff --git a/crates/ide_completion/src/tests.rs b/crates/ide_completion/src/tests.rs index 6872e3b8dc1..9168956235d 100644 --- a/crates/ide_completion/src/tests.rs +++ b/crates/ide_completion/src/tests.rs @@ -74,6 +74,7 @@ pub(crate) const TEST_CONFIG: CompletionConfig = CompletionConfig { group: true, skip_glob_imports: true, }, + snippets: Vec::new(), }; pub(crate) fn completion_list(ra_fixture: &str) -> String { @@ -181,13 +182,15 @@ pub(crate) fn check_edit_with_config( let mut actual = db.file_text(position.file_id).to_string(); let mut combined_edit = completion.text_edit().to_owned(); - if let Some(import_text_edit) = - completion.import_to_add().and_then(|edit| edit.to_text_edit(config.insert_use)) - { - combined_edit.union(import_text_edit).expect( - "Failed to apply completion resolve changes: change ranges overlap, but should not", - ) - } + completion + .imports_to_add() + .iter() + .filter_map(|edit| edit.to_text_edit(config.insert_use)) + .for_each(|text_edit| { + combined_edit.union(text_edit).expect( + "Failed to apply completion resolve changes: change ranges overlap, but should not", + ) + }); combined_edit.apply(&mut actual); assert_eq_text!(&ra_fixture_after, &actual) diff --git a/crates/rust-analyzer/src/config.rs b/crates/rust-analyzer/src/config.rs index 6c098333683..a032c2b653a 100644 --- a/crates/rust-analyzer/src/config.rs +++ b/crates/rust-analyzer/src/config.rs @@ -12,7 +12,7 @@ use std::{ffi::OsString, iter, path::PathBuf}; use flycheck::FlycheckConfig; use ide::{ AssistConfig, CompletionConfig, DiagnosticsConfig, HighlightRelatedConfig, HoverConfig, - HoverDocFormat, InlayHintsConfig, JoinLinesConfig, + HoverDocFormat, InlayHintsConfig, JoinLinesConfig, Snippet, SnippetScope, }; use ide_db::helpers::{ insert_use::{ImportGranularity, InsertUseConfig, PrefixKind}, @@ -112,6 +112,8 @@ config_data! { completion_addCallArgumentSnippets: bool = "true", /// Whether to add parenthesis when completing functions. completion_addCallParenthesis: bool = "true", + /// Custom completion snippets. + completion_snippets: FxHashMap = "{}", /// Whether to show postfix snippets like `dbg`, `if`, `not`, etc. completion_postfix_enable: bool = "true", /// Toggles the additional completions that automatically add imports when completed. @@ -277,9 +279,9 @@ config_data! { rustfmt_enableRangeFormatting: bool = "false", /// Workspace symbol search scope. - workspace_symbol_search_scope: WorskpaceSymbolSearchScopeDef = "\"workspace\"", + workspace_symbol_search_scope: WorkspaceSymbolSearchScopeDef = "\"workspace\"", /// Workspace symbol search kind. - workspace_symbol_search_kind: WorskpaceSymbolSearchKindDef = "\"only_types\"", + workspace_symbol_search_kind: WorkspaceSymbolSearchKindDef = "\"only_types\"", } } @@ -296,6 +298,7 @@ pub struct Config { detached_files: Vec, pub discovered_projects: Option>, pub root_path: AbsPathBuf, + snippets: Vec, } #[derive(Debug, Clone, Eq, PartialEq)] @@ -431,6 +434,7 @@ impl Config { detached_files: Vec::new(), discovered_projects: None, root_path, + snippets: Default::default(), } } pub fn update(&mut self, mut json: serde_json::Value) { @@ -443,6 +447,28 @@ impl Config { .map(AbsPathBuf::assert) .collect(); self.data = ConfigData::from_json(json); + self.snippets.clear(); + for (name, def) in self.data.completion_snippets.iter() { + if def.prefix.is_empty() && def.postfix.is_empty() { + continue; + } + let scope = match def.scope { + SnippetScopeDef::Expr => SnippetScope::Expr, + SnippetScopeDef::Type => SnippetScope::Type, + SnippetScopeDef::Item => SnippetScope::Item, + }; + match Snippet::new( + &def.prefix, + &def.postfix, + &def.body, + def.description.as_ref().unwrap_or(name), + &def.requires, + scope, + ) { + Some(snippet) => self.snippets.push(snippet), + None => tracing::info!("Invalid snippet {}", name), + } + } } pub fn json_schema() -> serde_json::Value { @@ -778,6 +804,7 @@ impl Config { .snippet_support?, false )), + snippets: self.snippets.clone(), } } pub fn assist(&self) -> AssistConfig { @@ -848,14 +875,14 @@ impl Config { pub fn workspace_symbol(&self) -> WorkspaceSymbolConfig { WorkspaceSymbolConfig { search_scope: match self.data.workspace_symbol_search_scope { - WorskpaceSymbolSearchScopeDef::Workspace => WorkspaceSymbolSearchScope::Workspace, - WorskpaceSymbolSearchScopeDef::WorkspaceAndDependencies => { + WorkspaceSymbolSearchScopeDef::Workspace => WorkspaceSymbolSearchScope::Workspace, + WorkspaceSymbolSearchScopeDef::WorkspaceAndDependencies => { WorkspaceSymbolSearchScope::WorkspaceAndDependencies } }, search_kind: match self.data.workspace_symbol_search_kind { - WorskpaceSymbolSearchKindDef::OnlyTypes => WorkspaceSymbolSearchKind::OnlyTypes, - WorskpaceSymbolSearchKindDef::AllSymbols => WorkspaceSymbolSearchKind::AllSymbols, + WorkspaceSymbolSearchKindDef::OnlyTypes => WorkspaceSymbolSearchKind::OnlyTypes, + WorkspaceSymbolSearchKindDef::AllSymbols => WorkspaceSymbolSearchKind::AllSymbols, }, } } @@ -908,6 +935,66 @@ impl Config { } } +#[derive(Deserialize, Debug, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum SnippetScopeDef { + Expr, + Item, + Type, +} + +impl Default for SnippetScopeDef { + fn default() -> Self { + SnippetScopeDef::Expr + } +} + +#[derive(Deserialize, Debug, Clone, Default)] +#[serde(default)] +struct SnippetDef { + #[serde(deserialize_with = "single_or_array")] + prefix: Vec, + #[serde(deserialize_with = "single_or_array")] + postfix: Vec, + description: Option, + #[serde(deserialize_with = "single_or_array")] + body: Vec, + #[serde(deserialize_with = "single_or_array")] + requires: Vec, + scope: SnippetScopeDef, +} + +fn single_or_array<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct SingleOrVec; + + impl<'de> serde::de::Visitor<'de> for SingleOrVec { + type Value = Vec; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("string or array of strings") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + Ok(vec![value.to_owned()]) + } + + fn visit_seq(self, seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(seq)) + } + } + + deserializer.deserialize_any(SingleOrVec) +} + #[derive(Deserialize, Debug, Clone)] #[serde(untagged)] enum ManifestOrProjectJson { @@ -939,14 +1026,14 @@ enum ImportPrefixDef { #[derive(Deserialize, Debug, Clone)] #[serde(rename_all = "snake_case")] -enum WorskpaceSymbolSearchScopeDef { +enum WorkspaceSymbolSearchScopeDef { Workspace, WorkspaceAndDependencies, } #[derive(Deserialize, Debug, Clone)] #[serde(rename_all = "snake_case")] -enum WorskpaceSymbolSearchKindDef { +enum WorkspaceSymbolSearchKindDef { OnlyTypes, AllSymbols, } @@ -1077,6 +1164,9 @@ fn field_props(field: &str, ty: &str, doc: &[&str], default: &str) -> serde_json "items": { "type": "string" }, "uniqueItems": true, }, + "FxHashMap" => set! { + "type": "object", + }, "FxHashMap" => set! { "type": "object", }, @@ -1133,7 +1223,7 @@ fn field_props(field: &str, ty: &str, doc: &[&str], default: &str) -> serde_json "type": "array", "items": { "type": ["string", "object"] }, }, - "WorskpaceSymbolSearchScopeDef" => set! { + "WorkspaceSymbolSearchScopeDef" => set! { "type": "string", "enum": ["workspace", "workspace_and_dependencies"], "enumDescriptions": [ @@ -1141,7 +1231,7 @@ fn field_props(field: &str, ty: &str, doc: &[&str], default: &str) -> serde_json "Search in current workspace and dependencies" ], }, - "WorskpaceSymbolSearchKindDef" => set! { + "WorkspaceSymbolSearchKindDef" => set! { "type": "string", "enum": ["only_types", "all_symbols"], "enumDescriptions": [ diff --git a/crates/rust-analyzer/src/handlers.rs b/crates/rust-analyzer/src/handlers.rs index 6cb6b0a8d02..ca286b7de9c 100644 --- a/crates/rust-analyzer/src/handlers.rs +++ b/crates/rust-analyzer/src/handlers.rs @@ -785,8 +785,10 @@ pub(crate) fn handle_completion_resolve( .resolve_completion_edits( &snap.config.completion(), FilePosition { file_id, offset }, - &resolve_data.full_import_path, - resolve_data.imported_name, + resolve_data + .imports + .into_iter() + .map(|import| (import.full_import_path, import.imported_name)), )? .into_iter() .flat_map(|edit| edit.into_iter().map(|indel| to_proto::text_edit(&line_index, indel))) diff --git a/crates/rust-analyzer/src/integrated_benchmarks.rs b/crates/rust-analyzer/src/integrated_benchmarks.rs index 036cfe157b4..b10eb3d6e92 100644 --- a/crates/rust-analyzer/src/integrated_benchmarks.rs +++ b/crates/rust-analyzer/src/integrated_benchmarks.rs @@ -144,6 +144,7 @@ fn integrated_completion_benchmark() { group: true, skip_glob_imports: true, }, + snippets: Vec::new(), }; let position = FilePosition { file_id, offset: TextSize::try_from(completion_offset).unwrap() }; @@ -180,6 +181,7 @@ fn integrated_completion_benchmark() { group: true, skip_glob_imports: true, }, + snippets: Vec::new(), }; let position = FilePosition { file_id, offset: TextSize::try_from(completion_offset).unwrap() }; diff --git a/crates/rust-analyzer/src/lsp_ext.rs b/crates/rust-analyzer/src/lsp_ext.rs index 521691d5ec9..19137b942eb 100644 --- a/crates/rust-analyzer/src/lsp_ext.rs +++ b/crates/rust-analyzer/src/lsp_ext.rs @@ -520,6 +520,11 @@ pub enum WorkspaceSymbolSearchKind { #[derive(Debug, Serialize, Deserialize)] pub struct CompletionResolveData { pub position: lsp_types::TextDocumentPositionParams, + pub imports: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CompletionImport { pub full_import_path: String, pub imported_name: String, } diff --git a/crates/rust-analyzer/src/to_proto.rs b/crates/rust-analyzer/src/to_proto.rs index 59a768397fe..fc3e25064f3 100644 --- a/crates/rust-analyzer/src/to_proto.rs +++ b/crates/rust-analyzer/src/to_proto.rs @@ -270,14 +270,20 @@ fn completion_item( lsp_item.insert_text_format = Some(lsp_types::InsertTextFormat::Snippet); } if config.completion().enable_imports_on_the_fly { - if let Some(import_edit) = item.import_to_add() { - let import_path = &import_edit.import.import_path; - if let Some(import_name) = import_path.segments().last() { - let data = lsp_ext::CompletionResolveData { - position: tdpp.clone(), - full_import_path: import_path.to_string(), - imported_name: import_name.to_string(), - }; + if let imports @ [_, ..] = item.imports_to_add() { + let imports: Vec<_> = imports + .iter() + .filter_map(|import_edit| { + let import_path = &import_edit.import.import_path; + let import_name = import_path.segments().last()?; + Some(lsp_ext::CompletionImport { + full_import_path: import_path.to_string(), + imported_name: import_name.to_string(), + }) + }) + .collect(); + if !imports.is_empty() { + let data = lsp_ext::CompletionResolveData { position: tdpp.clone(), imports }; lsp_item.data = Some(to_value(data).unwrap()); } } diff --git a/docs/dev/lsp-extensions.md b/docs/dev/lsp-extensions.md index a6a1c73111b..9076b93d35f 100644 --- a/docs/dev/lsp-extensions.md +++ b/docs/dev/lsp-extensions.md @@ -1,5 +1,5 @@