mirror of
https://github.com/nushell/nushell.git
synced 2025-05-17 16:30:47 +02:00
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:
parent
210c6f1c43
commit
a72f94f452
@ -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 ",
|
||||||
|
Loading…
Reference in New Issue
Block a user