feat(lsp): snippet style completion for commands (#15494)

# Description

For example: here's what happens after selecting the `if` command from
the completion menu:

<img width="318" alt="image"
src="https://github.com/user-attachments/assets/752a3bae-ce92-4473-bc96-01032d9295aa"
/>

<img width="319" alt="image"
src="https://github.com/user-attachments/assets/c4bf0c25-ec42-4416-b93e-4925a4650e73"
/>

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
This commit is contained in:
zc he 2025-04-05 22:23:27 +08:00 committed by GitHub
parent 210c6f1c43
commit a72f94f452
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -3,12 +3,13 @@ use std::sync::Arc;
use crate::{span_to_range, uri_to_path, LanguageServer}; use crate::{span_to_range, uri_to_path, LanguageServer};
use lsp_types::{ use lsp_types::{
CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionParams, CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionParams,
CompletionResponse, CompletionTextEdit, Documentation, MarkupContent, MarkupKind, TextEdit, CompletionResponse, CompletionTextEdit, Documentation, InsertTextFormat, MarkupContent,
MarkupKind, TextEdit,
}; };
use nu_cli::{NuCompleter, SuggestionKind}; use nu_cli::{NuCompleter, SuggestionKind};
use nu_protocol::{ use nu_protocol::{
engine::{CommandType, Stack}, engine::{CommandType, Stack},
Span, PositionalArg, Span, SyntaxShape,
}; };
impl LanguageServer { impl LanguageServer {
@ -45,27 +46,71 @@ impl LanguageServer {
results results
.into_iter() .into_iter()
.map(|r| { .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(_)) matches!(kind, SuggestionKind::Command(_))
.then_some(engine_state.find_decl(r.suggestion.value.as_bytes(), &[])?) .then_some(engine_state.find_decl(r.suggestion.value.as_bytes(), &[])?)
}); });
let mut label_value = r.suggestion.value; let mut snippet_text = r.suggestion.value.clone();
if r.suggestion.append_whitespace { let mut doc_string = r.suggestion.extra.map(|ex| ex.join("\n"));
label_value.push(' '); 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 span = r.suggestion.span;
let text_edit = Some(CompletionTextEdit::Edit(TextEdit { let text_edit = Some(CompletionTextEdit::Edit(TextEdit {
range: span_to_range(&Span::new(span.start, span.end), file, 0), range: span_to_range(&Span::new(span.start, span.end), file, 0),
new_text: label_value.clone(), new_text: snippet_text,
})); }));
CompletionItem { CompletionItem {
label: label_value, label: r.suggestion.value,
label_details: r label_details: r
.kind .kind
.clone() .as_ref()
.map(|kind| match kind { .map(|kind| match kind {
SuggestionKind::Value(t) => t.to_string(), SuggestionKind::Value(t) => t.to_string(),
SuggestionKind::Command(cmd) => cmd.to_string(), SuggestionKind::Command(cmd) => cmd.to_string(),
@ -80,21 +125,15 @@ impl LanguageServer {
description: Some(s), description: Some(s),
}), }),
detail: r.suggestion.description, detail: r.suggestion.description,
documentation: r documentation: doc_string.map(|value| {
.suggestion Documentation::MarkupContent(MarkupContent {
.extra kind: MarkupKind::Markdown,
.map(|ex| ex.join("\n")) value,
.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,
})
}),
kind: Self::lsp_completion_item_kind(r.kind), kind: Self::lsp_completion_item_kind(r.kind),
text_edit, text_edit,
insert_text_format,
..Default::default() ..Default::default()
} }
}) })
@ -221,9 +260,9 @@ mod tests {
actual: result_from_message(resp), actual: result_from_message(resp),
expected: serde_json::json!([ expected: serde_json::json!([
// defined after the cursor // 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.", "detail": "Edit nu configurations.",
"textEdit": { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 8 }, }, "textEdit": { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 8 }, },
"newText": "config nu " "newText": "config nu "
@ -236,7 +275,7 @@ mod tests {
let resp = send_complete_request(&client_connection, script.clone(), 1, 18); let resp = send_complete_request(&client_connection, script.clone(), 1, 18);
assert!(result_from_message(resp).as_array().unwrap().contains( assert!(result_from_message(resp).as_array().unwrap().contains(
&serde_json::json!({ &serde_json::json!({
"label": "-s ", "label": "-s",
"detail": "test flag", "detail": "test flag",
"labelDetails": { "description": "flag" }, "labelDetails": { "description": "flag" },
"textEdit": { "range": { "start": { "line": 1, "character": 17 }, "end": { "line": 1, "character": 18 }, }, "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); let resp = send_complete_request(&client_connection, script.clone(), 2, 22);
assert!(result_from_message(resp).as_array().unwrap().contains( assert!(result_from_message(resp).as_array().unwrap().contains(
&serde_json::json!({ &serde_json::json!({
"label": "--long ", "label": "--long",
"detail": "test flag", "detail": "test flag",
"labelDetails": { "description": "flag" }, "labelDetails": { "description": "flag" },
"textEdit": { "range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 22 }, }, "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); let resp = send_complete_request(&client_connection, script, 10, 34);
assert!(result_from_message(resp).as_array().unwrap().contains( assert!(result_from_message(resp).as_array().unwrap().contains(
&serde_json::json!({ &serde_json::json!({
"label": "-g ", "label": "-g",
"detail": "count indexes and split using grapheme clusters (all visible chars have length 1)", "detail": "count indexes and split using grapheme clusters (all visible chars have length 1)",
"labelDetails": { "description": "flag" }, "labelDetails": { "description": "flag" },
"textEdit": { "range": { "start": { "line": 10, "character": 33 }, "end": { "line": 10, "character": 34 }, }, "textEdit": { "range": { "start": { "line": 10, "character": 33 }, "end": { "line": 10, "character": 34 }, },
@ -305,13 +344,14 @@ mod tests {
actual: result_from_message(resp), actual: result_from_message(resp),
expected: serde_json::json!([ expected: serde_json::json!([
{ {
"label": "alias ", "label": "alias",
"labelDetails": { "description": "keyword" }, "labelDetails": { "description": "keyword" },
"detail": "Alias a command (with optional flags) to a new name.", "detail": "Alias a command (with optional flags) to a new name.",
"textEdit": { "textEdit": {
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 }, }, "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 }, },
"newText": "alias " "newText": "alias ${1:name} ${2:initial_value}"
}, },
"insertTextFormat": 2,
"kind": 14 "kind": 14
} }
]) ])
@ -322,13 +362,14 @@ mod tests {
actual: result_from_message(resp), actual: result_from_message(resp),
expected: serde_json::json!([ expected: serde_json::json!([
{ {
"label": "alias ", "label": "alias",
"labelDetails": { "description": "keyword" }, "labelDetails": { "description": "keyword" },
"detail": "Alias a command (with optional flags) to a new name.", "detail": "Alias a command (with optional flags) to a new name.",
"textEdit": { "textEdit": {
"range": { "start": { "line": 3, "character": 2 }, "end": { "line": 3, "character": 2 }, }, "range": { "start": { "line": 3, "character": 2 }, "end": { "line": 3, "character": 2 }, },
"newText": "alias " "newText": "alias ${1:name} ${2:initial_value}"
}, },
"insertTextFormat": 2,
"kind": 14 "kind": 14
} }
]) ])
@ -364,13 +405,14 @@ mod tests {
actual: result_from_message(resp), actual: result_from_message(resp),
expected: serde_json::json!([ expected: serde_json::json!([
{ {
"label": "str trim ", "label": "str trim",
"labelDetails": { "description": "built-in" }, "labelDetails": { "description": "built-in" },
"detail": "Trim whitespace or specific character.", "detail": "Trim whitespace or specific character.",
"textEdit": { "textEdit": {
"range": { "start": { "line": 0, "character": 8 }, "end": { "line": 0, "character": 13 }, }, "range": { "start": { "line": 0, "character": 8 }, "end": { "line": 0, "character": 13 }, },
"newText": "str trim " "newText": "str trim ${1:...rest}"
}, },
"insertTextFormat": 2,
"kind": 3 "kind": 3
} }
]) ])
@ -394,7 +436,7 @@ mod tests {
actual: result_from_message(resp), actual: result_from_message(resp),
expected: serde_json::json!([ expected: serde_json::json!([
{ {
"label": "overlay ", "label": "overlay",
"labelDetails": { "description": "keyword" }, "labelDetails": { "description": "keyword" },
"textEdit": { "textEdit": {
"newText": "overlay ", "newText": "overlay ",
@ -483,12 +525,12 @@ mod tests {
actual: result_from_message(resp), actual: result_from_message(resp),
expected: serde_json::json!([ expected: serde_json::json!([
{ {
"label": "alias ", "label": "alias",
"labelDetails": { "description": "keyword" }, "labelDetails": { "description": "keyword" },
"detail": "Alias a command (with optional flags) to a new name.", "detail": "Alias a command (with optional flags) to a new name.",
"textEdit": { "textEdit": {
"range": { "start": { "line": 0, "character": 5 }, "end": { "line": 0, "character": 5 }, }, "range": { "start": { "line": 0, "character": 5 }, "end": { "line": 0, "character": 5 }, },
"newText": "alias " "newText": "alias ${1:name} ${2:initial_value}"
}, },
"kind": 14 "kind": 14
}, },
@ -513,7 +555,7 @@ mod tests {
actual: result_from_message(resp), actual: result_from_message(resp),
expected: serde_json::json!([ expected: serde_json::json!([
{ {
"label": "!= ", "label": "!=",
"labelDetails": { "description": "operator" }, "labelDetails": { "description": "operator" },
"textEdit": { "textEdit": {
"newText": "!= ", "newText": "!= ",
@ -529,7 +571,7 @@ mod tests {
actual: result_from_message(resp), actual: result_from_message(resp),
expected: serde_json::json!([ expected: serde_json::json!([
{ {
"label": "not-has ", "label": "not-has",
"labelDetails": { "description": "operator" }, "labelDetails": { "description": "operator" },
"textEdit": { "textEdit": {
"newText": "not-has ", "newText": "not-has ",