nushell/crates/nu-lsp/src/completion.rs
zc he eb2a91ea7c
fix(lsp): keywords in completion snippets (#15499)
# Description

Fixes some leftover issues for keyword snippets of #15494

# Tests + Formatting

Adjusted
2025-04-06 08:36:59 -05:00

700 lines
27 KiB
Rust

use std::sync::Arc;
use crate::{span_to_range, uri_to_path, LanguageServer};
use lsp_types::{
CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionParams,
CompletionResponse, CompletionTextEdit, Documentation, InsertTextFormat, MarkupContent,
MarkupKind, Range, TextEdit,
};
use nu_cli::{NuCompleter, SemanticSuggestion, SuggestionKind};
use nu_protocol::{
engine::{CommandType, EngineState, Stack},
PositionalArg, Span, SyntaxShape,
};
impl LanguageServer {
pub(crate) fn complete(&mut self, params: &CompletionParams) -> Option<CompletionResponse> {
let path_uri = params.text_document_position.text_document.uri.to_owned();
let docs = self.docs.lock().ok()?;
let file = docs.get_document(&path_uri)?;
let location = file.offset_at(params.text_document_position.position) as usize;
let file_text = file.get_content(None).to_owned();
drop(docs);
// fallback to default completer where
// the text is truncated to `location` and
// an extra placeholder token is inserted for correct parsing
let need_fallback = location == 0
|| file_text
.get(location - 1..location)
.and_then(|s| s.chars().next())
.is_some_and(|c| c.is_whitespace() || "|(){}[]<>,:;".contains(c));
self.need_parse |= need_fallback;
let engine_state = Arc::new(self.new_engine_state(Some(&path_uri)));
let completer = NuCompleter::new(engine_state.clone(), Arc::new(Stack::new()));
let results = if need_fallback {
completer.fetch_completions_at(&file_text[..location], location)
} else {
let file_path = uri_to_path(&path_uri);
let filename = file_path.to_str()?;
completer.fetch_completions_within_file(filename, location, &file_text)
};
let docs = self.docs.lock().ok()?;
let file = docs.get_document(&path_uri)?;
(!results.is_empty()).then_some(CompletionResponse::Array(
results
.into_iter()
.map(|r| {
let reedline_span = r.suggestion.span;
Self::completion_item_from_suggestion(
&engine_state,
r,
span_to_range(&Span::new(reedline_span.start, reedline_span.end), file, 0),
)
})
.collect(),
))
}
fn completion_item_from_suggestion(
engine_state: &EngineState,
suggestion: SemanticSuggestion,
range: Range,
) -> CompletionItem {
let decl_id = suggestion.kind.as_ref().and_then(|kind| {
matches!(kind, SuggestionKind::Command(_))
.then_some(engine_state.find_decl(suggestion.suggestion.value.as_bytes(), &[])?)
});
let mut snippet_text = suggestion.suggestion.value.clone();
let mut doc_string = suggestion.suggestion.extra.map(|ex| ex.join("\n"));
let mut insert_text_format = None;
let mut idx = 0;
// 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
// and keywords, e.g. `=` in `alias foo = bar`
let mut arg_wrapper = |arg: &PositionalArg, text: String, optional: bool| -> String {
idx += 1;
match &arg.shape {
SyntaxShape::Block | SyntaxShape::MatchBlock => {
format!("{{ ${{{}:{}}} }}", idx, text)
}
SyntaxShape::Keyword(kwd, _) => {
// NOTE: If optional, the keyword should also be in a placeholder so that it can be removed easily.
// Here we choose to use nested placeholders. Note that some editors don't fully support this format,
// but usually they will simply ignore the inner ones, so it should be fine.
if optional {
idx += 1;
format!(
"${{{}:{} ${{{}:{}}}}}",
idx - 1,
String::from_utf8_lossy(kwd),
idx,
text
)
} else {
format!("{} ${{{}:{}}}", String::from_utf8_lossy(kwd), idx, text)
}
}
_ => format!("${{{}:{}}}", idx, text),
}
};
for required in signature.required_positional {
snippet_text.push(' ');
snippet_text
.push_str(arg_wrapper(&required, required.name.clone(), false).as_str());
}
for optional in signature.optional_positional {
snippet_text.push(' ');
snippet_text
.push_str(arg_wrapper(&optional, format!("{}?", optional.name), true).as_str());
}
if let Some(rest) = signature.rest_positional {
idx += 1;
snippet_text.push_str(format!(" ${{{}:...{}}}", idx, rest.name).as_str());
}
}
// no extra space for a command with args expanded in the snippet
if idx == 0 && suggestion.suggestion.append_whitespace {
snippet_text.push(' ');
}
let text_edit = Some(CompletionTextEdit::Edit(TextEdit {
range,
new_text: snippet_text,
}));
CompletionItem {
label: suggestion.suggestion.value,
label_details: suggestion
.kind
.as_ref()
.map(|kind| match kind {
SuggestionKind::Value(t) => t.to_string(),
SuggestionKind::Command(cmd) => cmd.to_string(),
SuggestionKind::Module => "module".to_string(),
SuggestionKind::Operator => "operator".to_string(),
SuggestionKind::Variable => "variable".to_string(),
SuggestionKind::Flag => "flag".to_string(),
_ => String::new(),
})
.map(|s| CompletionItemLabelDetails {
detail: None,
description: Some(s),
}),
detail: suggestion.suggestion.description,
documentation: doc_string.map(|value| {
Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value,
})
}),
kind: Self::lsp_completion_item_kind(suggestion.kind),
text_edit,
insert_text_format,
..Default::default()
}
}
fn lsp_completion_item_kind(
suggestion_kind: Option<SuggestionKind>,
) -> Option<CompletionItemKind> {
suggestion_kind.and_then(|suggestion_kind| match suggestion_kind {
SuggestionKind::Value(t) => match t {
nu_protocol::Type::String => Some(CompletionItemKind::VALUE),
_ => None,
},
SuggestionKind::CellPath => Some(CompletionItemKind::PROPERTY),
SuggestionKind::Command(c) => match c {
CommandType::Keyword => Some(CompletionItemKind::KEYWORD),
CommandType::Builtin => Some(CompletionItemKind::FUNCTION),
CommandType::Alias => Some(CompletionItemKind::REFERENCE),
CommandType::External => Some(CompletionItemKind::INTERFACE),
CommandType::Custom | CommandType::Plugin => Some(CompletionItemKind::METHOD),
},
SuggestionKind::Directory => Some(CompletionItemKind::FOLDER),
SuggestionKind::File => Some(CompletionItemKind::FILE),
SuggestionKind::Flag => Some(CompletionItemKind::FIELD),
SuggestionKind::Module => Some(CompletionItemKind::MODULE),
SuggestionKind::Operator => Some(CompletionItemKind::OPERATOR),
SuggestionKind::Variable => Some(CompletionItemKind::VARIABLE),
})
}
}
#[cfg(test)]
mod tests {
use crate::path_to_uri;
use crate::tests::{initialize_language_server, open_unchecked, result_from_message};
use assert_json_diff::assert_json_include;
use lsp_server::{Connection, Message};
use lsp_types::{
request::{Completion, Request},
CompletionParams, PartialResultParams, Position, TextDocumentIdentifier,
TextDocumentPositionParams, Uri, WorkDoneProgressParams,
};
use nu_test_support::fs::fixtures;
fn send_complete_request(
client_connection: &Connection,
uri: Uri,
line: u32,
character: u32,
) -> Message {
client_connection
.sender
.send(Message::Request(lsp_server::Request {
id: 2.into(),
method: Completion::METHOD.to_string(),
params: serde_json::to_value(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position { line, character },
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.unwrap(),
}))
.unwrap();
client_connection
.receiver
.recv_timeout(std::time::Duration::from_secs(2))
.unwrap()
}
#[test]
fn complete_on_variable() {
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
script.push("completion");
script.push("var.nu");
let script = path_to_uri(&script);
open_unchecked(&client_connection, script.clone());
let resp = send_complete_request(&client_connection, script, 2, 9);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "$greeting",
"labelDetails": { "description": "variable" },
"textEdit": {
"newText": "$greeting",
"range": { "start": { "character": 5, "line": 2 }, "end": { "character": 9, "line": 2 } }
},
"kind": 6
}
])
);
}
#[test]
fn complete_command() {
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
script.push("completion");
script.push("command.nu");
let script = path_to_uri(&script);
open_unchecked(&client_connection, script.clone());
let resp = send_complete_request(&client_connection, script.clone(), 0, 6);
#[cfg(not(windows))]
let detail_str = "detail";
#[cfg(windows)]
let detail_str = "detail\r";
assert_json_include!(
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 nu",
"detail": "Edit nu configurations.",
"textEdit": { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 8 }, },
"newText": "config nu "
},
},
])
);
// short flag
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",
"detail": "test flag",
"labelDetails": { "description": "flag" },
"textEdit": { "range": { "start": { "line": 1, "character": 17 }, "end": { "line": 1, "character": 18 }, },
"newText": "-s "
},
"kind": 5
})
));
// long flag
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",
"detail": "test flag",
"labelDetails": { "description": "flag" },
"textEdit": { "range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 22 }, },
"newText": "--long "
},
"kind": 5
})
));
// file path
let resp = send_complete_request(&client_connection, script.clone(), 2, 18);
assert!(result_from_message(resp).as_array().unwrap().contains(
&serde_json::json!({
"label": "command.nu",
"labelDetails": { "description": "" },
"textEdit": { "range": { "start": { "line": 2, "character": 17 }, "end": { "line": 2, "character": 18 }, },
"newText": "command.nu"
},
"kind": 17
})
));
// inside parenthesis
let resp = send_complete_request(&client_connection, script, 10, 34);
assert!(result_from_message(resp).as_array().unwrap().contains(
&serde_json::json!({
"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 }, },
"newText": "-g "
},
"kind": 5
})
));
}
#[test]
fn fallback_completion() {
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
script.push("completion");
script.push("fallback.nu");
let script = path_to_uri(&script);
open_unchecked(&client_connection, script.clone());
// at the very beginning of a file
let resp = send_complete_request(&client_connection, script.clone(), 0, 0);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"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 ${1:name} = ${2:initial_value}"
},
"insertTextFormat": 2,
"kind": 14
}
])
);
// after a white space character
let resp = send_complete_request(&client_connection, script.clone(), 3, 2);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"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 ${1:name} = ${2:initial_value}"
},
"insertTextFormat": 2,
"kind": 14
}
])
);
// fallback file path completion
let resp = send_complete_request(&client_connection, script, 5, 4);
assert!(result_from_message(resp).as_array().unwrap().contains(
&serde_json::json!({
"label": "cell_path.nu",
"labelDetails": { "description": "" },
"textEdit": { "range": { "start": { "line": 5, "character": 3 }, "end": { "line": 5, "character": 4 }, },
"newText": "cell_path.nu"
},
"kind": 17
})
));
}
#[test]
fn complete_command_with_line() {
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
script.push("completion");
script.push("utf_pipeline.nu");
let script = path_to_uri(&script);
open_unchecked(&client_connection, script.clone());
let resp = send_complete_request(&client_connection, script, 0, 13);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"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 ${1:...rest}"
},
"insertTextFormat": 2,
"kind": 3
}
])
);
}
#[test]
fn complete_keyword() {
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
script.push("completion");
script.push("keyword.nu");
let script = path_to_uri(&script);
open_unchecked(&client_connection, script.clone());
let resp = send_complete_request(&client_connection, script, 0, 2);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "overlay",
"labelDetails": { "description": "keyword" },
"textEdit": {
"newText": "overlay ",
"range": { "start": { "character": 0, "line": 0 }, "end": { "character": 2, "line": 0 } }
},
"kind": 14
},
])
);
}
#[test]
fn complete_cell_path() {
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
script.push("completion");
script.push("cell_path.nu");
let script = path_to_uri(&script);
open_unchecked(&client_connection, script.clone());
let resp = send_complete_request(&client_connection, script.clone(), 1, 5);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "1",
"detail": "string",
"textEdit": {
"newText": "1",
"range": { "start": { "line": 1, "character": 5 }, "end": { "line": 1, "character": 5 } }
},
"kind": 10
},
])
);
let resp = send_complete_request(&client_connection, script, 1, 10);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "baz",
"detail": "int",
"textEdit": {
"newText": "baz",
"range": { "start": { "line": 1, "character": 10 }, "end": { "line": 1, "character": 10 } }
},
"kind": 10
},
])
);
}
#[test]
fn complete_with_external_completer() {
let config = "$env.config.completions.external.completer = {|spans| ['--background']}";
let (client_connection, _recv) = initialize_language_server(Some(config), None);
let mut script = fixtures();
script.push("lsp");
script.push("completion");
script.push("external.nu");
let script = path_to_uri(&script);
open_unchecked(&client_connection, script.clone());
let resp = send_complete_request(&client_connection, script.clone(), 0, 11);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "--background",
"labelDetails": { "description": "string" },
"textEdit": {
"newText": "--background",
"range": { "start": { "line": 0, "character": 5 }, "end": { "line": 0, "character": 11 } }
},
},
])
);
// fallback completer, special argument treatment for `sudo`/`doas`
let resp = send_complete_request(&client_connection, script, 0, 5);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"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 ${1:name} = ${2:initial_value}"
},
"kind": 14
},
])
);
}
#[test]
fn complete_operators() {
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
script.push("completion");
script.push("fallback.nu");
let script = path_to_uri(&script);
open_unchecked(&client_connection, script.clone());
// fallback completer
let resp = send_complete_request(&client_connection, script.clone(), 7, 10);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "!=",
"labelDetails": { "description": "operator" },
"textEdit": {
"newText": "!= ",
"range": { "start": { "character": 10, "line": 7 }, "end": { "character": 10, "line": 7 } }
},
"kind": 24 // operator kind
}
])
);
let resp = send_complete_request(&client_connection, script.clone(), 7, 15);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "not-has",
"labelDetails": { "description": "operator" },
"textEdit": {
"newText": "not-has ",
"range": { "start": { "character": 10, "line": 7 }, "end": { "character": 15, "line": 7 } }
},
"kind": 24 // operator kind
}
])
);
}
#[test]
fn complete_use_arguments() {
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
script.push("completion");
script.push("use.nu");
let script = path_to_uri(&script);
open_unchecked(&client_connection, script.clone());
let resp = send_complete_request(&client_connection, script.clone(), 4, 17);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "std-rfc",
"labelDetails": { "description": "module" },
"textEdit": {
"newText": "std-rfc",
"range": { "start": { "character": 11, "line": 4 }, "end": { "character": 17, "line": 4 } }
},
"kind": 9 // module kind
}
])
);
let resp = send_complete_request(&client_connection, script.clone(), 5, 22);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "clip",
"labelDetails": { "description": "module" },
"textEdit": {
"newText": "clip",
"range": { "start": { "character": 19, "line": 5 }, "end": { "character": 23, "line": 5 } }
},
"kind": 9 // module kind
}
])
);
let resp = send_complete_request(&client_connection, script.clone(), 5, 35);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "paste",
"labelDetails": { "description": "custom" },
"textEdit": {
"newText": "paste",
"range": { "start": { "character": 32, "line": 5 }, "end": { "character": 37, "line": 5 } }
},
"kind": 2
}
])
);
let resp = send_complete_request(&client_connection, script.clone(), 6, 14);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "null_device",
"labelDetails": { "description": "variable" },
"textEdit": {
"newText": "null_device",
"range": { "start": { "character": 8, "line": 6 }, "end": { "character": 14, "line": 6 } }
},
"kind": 6 // variable kind
}
])
);
let resp = send_complete_request(&client_connection, script, 7, 13);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "foo",
"labelDetails": { "description": "variable" },
"textEdit": {
"newText": "foo",
"range": { "start": { "character": 11, "line": 7 }, "end": { "character": 14, "line": 7 } }
},
"kind": 6 // variable kind
}
])
);
}
}