From a72f94f452b2ef78fe33fb5c9ea76f27133810fe Mon Sep 17 00:00:00 2001 From: zc he Date: Sat, 5 Apr 2025 22:23:27 +0800 Subject: [PATCH] feat(lsp): snippet style completion for commands (#15494) # Description For example: here's what happens after selecting the `if` command from the completion menu: image image Missing arguments are inserted as placeholders in a snippet, just as function name completions in other lsp servers like rust-analyzer and clangd. # User-Facing Changes Press tab to navigate Flags still need to be added manually # Tests + Formatting Refined # After Submitting --- crates/nu-lsp/src/completion.rs | 118 ++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 38 deletions(-) diff --git a/crates/nu-lsp/src/completion.rs b/crates/nu-lsp/src/completion.rs index e7573857f9..a3650c5f32 100644 --- a/crates/nu-lsp/src/completion.rs +++ b/crates/nu-lsp/src/completion.rs @@ -3,12 +3,13 @@ use std::sync::Arc; use crate::{span_to_range, uri_to_path, LanguageServer}; use lsp_types::{ CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionParams, - CompletionResponse, CompletionTextEdit, Documentation, MarkupContent, MarkupKind, TextEdit, + CompletionResponse, CompletionTextEdit, Documentation, InsertTextFormat, MarkupContent, + MarkupKind, TextEdit, }; use nu_cli::{NuCompleter, SuggestionKind}; use nu_protocol::{ engine::{CommandType, Stack}, - Span, + PositionalArg, Span, SyntaxShape, }; impl LanguageServer { @@ -45,27 +46,71 @@ impl LanguageServer { results .into_iter() .map(|r| { - let decl_id = r.kind.clone().and_then(|kind| { + let decl_id = r.kind.as_ref().and_then(|kind| { matches!(kind, SuggestionKind::Command(_)) .then_some(engine_state.find_decl(r.suggestion.value.as_bytes(), &[])?) }); - let mut label_value = r.suggestion.value; - if r.suggestion.append_whitespace { - label_value.push(' '); + let mut snippet_text = r.suggestion.value.clone(); + let mut doc_string = r.suggestion.extra.map(|ex| ex.join("\n")); + let mut insert_text_format = None; + let mut idx = 1; + // use snippet as `insert_text_format` for command argument completion + if let Some(decl_id) = decl_id { + let cmd = engine_state.get_decl(decl_id); + doc_string = Some(Self::get_decl_description(cmd, true)); + insert_text_format = Some(InsertTextFormat::SNIPPET); + let signature = cmd.signature(); + // add curly brackets around block arguments + let block_wrapper = |arg: &PositionalArg, text: String| -> String { + if matches!(arg.shape, SyntaxShape::Block | SyntaxShape::MatchBlock) { + format!("{{ {text} }}") + } else { + text + } + }; + + for required in signature.required_positional { + snippet_text.push(' '); + snippet_text.push_str( + block_wrapper(&required, format!("${{{}:{}}}", idx, required.name)) + .as_str(), + ); + idx += 1; + } + for optional in signature.optional_positional { + snippet_text.push(' '); + snippet_text.push_str( + block_wrapper( + &optional, + format!("${{{}:{}?}}", idx, optional.name), + ) + .as_str(), + ); + idx += 1; + } + if let Some(rest) = signature.rest_positional { + snippet_text + .push_str(format!(" ${{{}:...{}}}", idx, rest.name).as_str()); + idx += 1; + } + } + // no extra space for a command with args expanded in the snippet + if idx == 1 && r.suggestion.append_whitespace { + snippet_text.push(' '); } let span = r.suggestion.span; let text_edit = Some(CompletionTextEdit::Edit(TextEdit { range: span_to_range(&Span::new(span.start, span.end), file, 0), - new_text: label_value.clone(), + new_text: snippet_text, })); CompletionItem { - label: label_value, + label: r.suggestion.value, label_details: r .kind - .clone() + .as_ref() .map(|kind| match kind { SuggestionKind::Value(t) => t.to_string(), SuggestionKind::Command(cmd) => cmd.to_string(), @@ -80,21 +125,15 @@ impl LanguageServer { description: Some(s), }), detail: r.suggestion.description, - documentation: r - .suggestion - .extra - .map(|ex| ex.join("\n")) - .or(decl_id.map(|decl_id| { - Self::get_decl_description(engine_state.get_decl(decl_id), true) - })) - .map(|value| { - Documentation::MarkupContent(MarkupContent { - kind: MarkupKind::Markdown, - value, - }) - }), + documentation: doc_string.map(|value| { + Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value, + }) + }), kind: Self::lsp_completion_item_kind(r.kind), text_edit, + insert_text_format, ..Default::default() } }) @@ -221,9 +260,9 @@ mod tests { actual: result_from_message(resp), expected: serde_json::json!([ // defined after the cursor - { "label": "config n foo bar ", "detail": detail_str, "kind": 2 }, + { "label": "config n foo bar", "detail": detail_str, "kind": 2 }, { - "label": "config nu ", + "label": "config nu", "detail": "Edit nu configurations.", "textEdit": { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 8 }, }, "newText": "config nu " @@ -236,7 +275,7 @@ mod tests { let resp = send_complete_request(&client_connection, script.clone(), 1, 18); assert!(result_from_message(resp).as_array().unwrap().contains( &serde_json::json!({ - "label": "-s ", + "label": "-s", "detail": "test flag", "labelDetails": { "description": "flag" }, "textEdit": { "range": { "start": { "line": 1, "character": 17 }, "end": { "line": 1, "character": 18 }, }, @@ -250,7 +289,7 @@ mod tests { let resp = send_complete_request(&client_connection, script.clone(), 2, 22); assert!(result_from_message(resp).as_array().unwrap().contains( &serde_json::json!({ - "label": "--long ", + "label": "--long", "detail": "test flag", "labelDetails": { "description": "flag" }, "textEdit": { "range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 22 }, }, @@ -277,7 +316,7 @@ mod tests { let resp = send_complete_request(&client_connection, script, 10, 34); assert!(result_from_message(resp).as_array().unwrap().contains( &serde_json::json!({ - "label": "-g ", + "label": "-g", "detail": "count indexes and split using grapheme clusters (all visible chars have length 1)", "labelDetails": { "description": "flag" }, "textEdit": { "range": { "start": { "line": 10, "character": 33 }, "end": { "line": 10, "character": 34 }, }, @@ -305,13 +344,14 @@ mod tests { actual: result_from_message(resp), expected: serde_json::json!([ { - "label": "alias ", + "label": "alias", "labelDetails": { "description": "keyword" }, "detail": "Alias a command (with optional flags) to a new name.", "textEdit": { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 }, }, - "newText": "alias " + "newText": "alias ${1:name} ${2:initial_value}" }, + "insertTextFormat": 2, "kind": 14 } ]) @@ -322,13 +362,14 @@ mod tests { actual: result_from_message(resp), expected: serde_json::json!([ { - "label": "alias ", + "label": "alias", "labelDetails": { "description": "keyword" }, "detail": "Alias a command (with optional flags) to a new name.", "textEdit": { "range": { "start": { "line": 3, "character": 2 }, "end": { "line": 3, "character": 2 }, }, - "newText": "alias " + "newText": "alias ${1:name} ${2:initial_value}" }, + "insertTextFormat": 2, "kind": 14 } ]) @@ -364,13 +405,14 @@ mod tests { actual: result_from_message(resp), expected: serde_json::json!([ { - "label": "str trim ", + "label": "str trim", "labelDetails": { "description": "built-in" }, "detail": "Trim whitespace or specific character.", "textEdit": { "range": { "start": { "line": 0, "character": 8 }, "end": { "line": 0, "character": 13 }, }, - "newText": "str trim " + "newText": "str trim ${1:...rest}" }, + "insertTextFormat": 2, "kind": 3 } ]) @@ -394,7 +436,7 @@ mod tests { actual: result_from_message(resp), expected: serde_json::json!([ { - "label": "overlay ", + "label": "overlay", "labelDetails": { "description": "keyword" }, "textEdit": { "newText": "overlay ", @@ -483,12 +525,12 @@ mod tests { actual: result_from_message(resp), expected: serde_json::json!([ { - "label": "alias ", + "label": "alias", "labelDetails": { "description": "keyword" }, "detail": "Alias a command (with optional flags) to a new name.", "textEdit": { "range": { "start": { "line": 0, "character": 5 }, "end": { "line": 0, "character": 5 }, }, - "newText": "alias " + "newText": "alias ${1:name} ${2:initial_value}" }, "kind": 14 }, @@ -513,7 +555,7 @@ mod tests { actual: result_from_message(resp), expected: serde_json::json!([ { - "label": "!= ", + "label": "!=", "labelDetails": { "description": "operator" }, "textEdit": { "newText": "!= ", @@ -529,7 +571,7 @@ mod tests { actual: result_from_message(resp), expected: serde_json::json!([ { - "label": "not-has ", + "label": "not-has", "labelDetails": { "description": "operator" }, "textEdit": { "newText": "not-has ",