fix(lsp): completion of commands defined after the cursor (#15188)

# Description

Completion feature in LSP can't deal with commands defined after the
cursor before this PR.
This PR adds an alternative completion route where text is not truncated
and no extra `a` appended.

This will also ease future implementation of [signature
help](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_signatureHelp).

# User-Facing Changes

# Tests + Formatting

+6

# After Submitting
This commit is contained in:
zc he
2025-03-01 20:21:53 +08:00
committed by GitHub
parent 93612974e0
commit 7555743ccc
17 changed files with 901 additions and 397 deletions

View File

@@ -0,0 +1,529 @@
use std::sync::Arc;
use crate::{uri_to_path, LanguageServer};
use lsp_types::{
CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionParams,
CompletionResponse, CompletionTextEdit, Documentation, MarkupContent, MarkupKind, Range,
TextEdit,
};
use nu_cli::{NuCompleter, SuggestionKind};
use nu_protocol::engine::Stack;
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));
let (results, engine_state) = if need_fallback {
let engine_state = Arc::new(self.initial_engine_state.clone());
let completer = NuCompleter::new(engine_state.clone(), Arc::new(Stack::new()));
(
completer.fetch_completions_at(&file_text[..location], location),
engine_state,
)
} else {
let engine_state = Arc::new(self.new_engine_state());
let completer = NuCompleter::new(engine_state.clone(), Arc::new(Stack::new()));
let file_path = uri_to_path(&path_uri);
let filename = file_path.to_str()?;
(
completer.fetch_completions_within_file(filename, location, &file_text),
engine_state,
)
};
(!results.is_empty()).then_some(CompletionResponse::Array(
results
.into_iter()
.map(|r| {
let mut start = params.text_document_position.position;
start.character -= (r.suggestion.span.end - r.suggestion.span.start) as u32;
let decl_id = r.kind.clone().and_then(|kind| {
matches!(kind, SuggestionKind::Command(_))
.then_some(engine_state.find_decl(r.suggestion.value.as_bytes(), &[])?)
});
CompletionItem {
label: r.suggestion.value.clone(),
label_details: r
.kind
.clone()
.map(|kind| match kind {
SuggestionKind::Type(t) => t.to_string(),
SuggestionKind::Command(cmd) => cmd.to_string(),
SuggestionKind::Module => "module".to_string(),
SuggestionKind::Operator => "operator".to_string(),
})
.map(|s| CompletionItemLabelDetails {
detail: None,
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,
})
}),
kind: Self::lsp_completion_item_kind(r.kind),
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
range: Range {
start,
end: params.text_document_position.position,
},
new_text: r.suggestion.value,
})),
..Default::default()
}
})
.collect(),
))
}
fn lsp_completion_item_kind(
suggestion_kind: Option<SuggestionKind>,
) -> Option<CompletionItemKind> {
suggestion_kind.and_then(|suggestion_kind| match suggestion_kind {
SuggestionKind::Type(t) => match t {
nu_protocol::Type::String => Some(CompletionItemKind::VARIABLE),
_ => None,
},
SuggestionKind::Command(c) => match c {
nu_protocol::engine::CommandType::Keyword => Some(CompletionItemKind::KEYWORD),
nu_protocol::engine::CommandType::Builtin => Some(CompletionItemKind::FUNCTION),
nu_protocol::engine::CommandType::External => Some(CompletionItemKind::INTERFACE),
_ => None,
},
SuggestionKind::Module => Some(CompletionItemKind::MODULE),
SuggestionKind::Operator => Some(CompletionItemKind::OPERATOR),
})
}
}
#[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": "string" },
"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, 8);
#[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,
"textEdit": { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 8 }, },
"newText": "config n foo bar"
},
},
{
"label": "config nu",
"detail": "Edit nu configurations.",
"textEdit": { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 8 }, },
"newText": "config nu"
},
"kind": 3
},
])
);
// 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",
"textEdit": { "range": { "start": { "line": 1, "character": 17 }, "end": { "line": 1, "character": 18 }, },
"newText": "-s"
},
})
));
// 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",
"textEdit": { "range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 22 }, },
"newText": "--long"
},
})
));
// 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": "LICENSE",
"textEdit": { "range": { "start": { "line": 2, "character": 17 }, "end": { "line": 2, "character": 18 }, },
"newText": "LICENSE"
},
})
));
// 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)",
"textEdit": { "range": { "start": { "line": 10, "character": 33 }, "end": { "line": 10, "character": 34 }, },
"newText": "-g"
},
})
));
}
#[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"
},
"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"
},
"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": "LICENSE",
"textEdit": { "range": { "start": { "line": 5, "character": 3 }, "end": { "line": 5, "character": 4 }, },
"newText": "LICENSE"
},
})
));
}
#[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"
},
"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",
"labelDetails": { "description": "record<1: string, 🤔🐘: table<baz: int>>" },
"textEdit": {
"newText": "1",
"range": { "start": { "line": 1, "character": 5 }, "end": { "line": 1, "character": 5 } }
},
},
])
);
let resp = send_complete_request(&client_connection, script, 1, 10);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "baz",
"labelDetails": { "description": "table<baz: int>" },
"textEdit": {
"newText": "baz",
"range": { "start": { "line": 1, "character": 10 }, "end": { "line": 1, "character": 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"
},
"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
}
])
);
}
}

View File

@@ -56,7 +56,7 @@ mod tests {
#[test]
fn publish_diagnostics_variable_does_not_exists() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -87,7 +87,7 @@ mod tests {
#[test]
fn publish_diagnostics_fixed_unknown_variable() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -126,7 +126,7 @@ mod tests {
#[test]
fn publish_diagnostics_none() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");

View File

@@ -1,9 +1,47 @@
use crate::{Id, LanguageServer};
use lsp_types::{GotoDefinitionParams, GotoDefinitionResponse};
use nu_protocol::engine::StateWorkingSet;
use std::path::Path;
use crate::{path_to_uri, span_to_range, Id, LanguageServer};
use lsp_textdocument::FullTextDocument;
use lsp_types::{GotoDefinitionParams, GotoDefinitionResponse, Location};
use nu_protocol::engine::{CachedFile, StateWorkingSet};
use nu_protocol::Span;
impl LanguageServer {
fn get_location_by_span<'a>(
&self,
files: impl Iterator<Item = &'a CachedFile>,
span: &Span,
) -> Option<Location> {
for cached_file in files.into_iter() {
if cached_file.covered_span.contains(span.start) {
let path = Path::new(&*cached_file.name);
if !path.is_file() {
return None;
}
let target_uri = path_to_uri(path);
if let Some(file) = self.docs.lock().ok()?.get_document(&target_uri) {
return Some(Location {
uri: target_uri,
range: span_to_range(span, file, cached_file.covered_span.start),
});
} else {
// in case where the document is not opened yet,
// typically included by the `use/source` command
let temp_doc = FullTextDocument::new(
"nu".to_string(),
0,
String::from_utf8_lossy(cached_file.content.as_ref()).to_string(),
);
return Some(Location {
uri: target_uri,
range: span_to_range(span, &temp_doc, cached_file.covered_span.start),
});
}
}
}
None
}
pub(crate) fn find_definition_span_by_id(
working_set: &StateWorkingSet,
id: &Id,
@@ -105,7 +143,7 @@ mod tests {
#[test]
fn goto_definition_for_none_existing_file() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut none_existent_path = root();
none_existent_path.push("none-existent.nu");
@@ -117,7 +155,7 @@ mod tests {
#[test]
fn goto_definition_of_variable() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -142,7 +180,7 @@ mod tests {
#[test]
fn goto_definition_of_cell_path() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -167,7 +205,7 @@ mod tests {
#[test]
fn goto_definition_of_command() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -192,7 +230,7 @@ mod tests {
#[test]
fn goto_definition_of_command_unicode() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -217,7 +255,7 @@ mod tests {
#[test]
fn goto_definition_of_command_parameter() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -242,7 +280,7 @@ mod tests {
#[test]
fn goto_definition_of_variable_in_else_block() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -267,7 +305,7 @@ mod tests {
#[test]
fn goto_definition_of_variable_in_match_guard() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -292,7 +330,7 @@ mod tests {
#[test]
fn goto_definition_of_variable_in_each() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -317,7 +355,7 @@ mod tests {
#[test]
fn goto_definition_of_module() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -342,7 +380,7 @@ mod tests {
#[test]
fn goto_definition_of_module_in_another_file() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -367,7 +405,7 @@ mod tests {
#[test]
fn goto_definition_of_module_in_hide() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -392,7 +430,7 @@ mod tests {
#[test]
fn goto_definition_of_module_in_overlay() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");

View File

@@ -160,7 +160,6 @@ impl LanguageServer {
}
}
/// TODO: test for files loaded as user config
#[cfg(test)]
mod tests {
use crate::path_to_uri;
@@ -205,7 +204,7 @@ mod tests {
#[test]
fn inlay_hint_variable_type() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -232,7 +231,7 @@ mod tests {
#[test]
fn inlay_hint_assignment_type() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -260,7 +259,7 @@ mod tests {
#[test]
fn inlay_hint_parameter_names() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -319,4 +318,34 @@ mod tests {
])
);
}
#[test]
/// https://github.com/nushell/nushell/pull/15071
fn inlay_hint_for_nu_script_loaded_on_init() {
let mut script = fixtures();
script.push("lsp");
script.push("hints");
script.push("type.nu");
let script_path_str = script.to_str();
let script = path_to_uri(&script);
let config = format!("source {}", script_path_str.unwrap());
let (client_connection, _recv) = initialize_language_server(Some(&config), None);
open_unchecked(&client_connection, script.clone());
let resp = send_inlay_hint_request(&client_connection, script.clone());
assert_json_eq!(
result_from_message(resp),
serde_json::json!([
{ "position": { "line": 0, "character": 9 }, "label": ": int", "kind": 1 },
{ "position": { "line": 1, "character": 7 }, "label": ": string", "kind": 1 },
{ "position": { "line": 2, "character": 8 }, "label": ": bool", "kind": 1 },
{ "position": { "line": 3, "character": 9 }, "label": ": float", "kind": 1 },
{ "position": { "line": 4, "character": 8 }, "label": ": list", "kind": 1 },
{ "position": { "line": 5, "character": 10 }, "label": ": record", "kind": 1 },
{ "position": { "line": 6, "character": 11 }, "label": ": closure", "kind": 1 }
])
);
}
}

View File

@@ -3,19 +3,16 @@ use lsp_server::{Connection, IoThreads, Message, Response, ResponseError};
use lsp_textdocument::{FullTextDocument, TextDocuments};
use lsp_types::{
request::{self, Request},
CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionParams,
CompletionResponse, CompletionTextEdit, Documentation, Hover, HoverContents, HoverParams,
InlayHint, Location, MarkupContent, MarkupKind, OneOf, Position, Range, ReferencesOptions,
RenameOptions, SemanticToken, SemanticTokenType, SemanticTokensLegend, SemanticTokensOptions,
SemanticTokensServerCapabilities, ServerCapabilities, TextDocumentSyncKind, TextEdit, Uri,
WorkDoneProgressOptions, WorkspaceFolder, WorkspaceFoldersServerCapabilities,
WorkspaceServerCapabilities,
Hover, HoverContents, HoverParams, InlayHint, MarkupContent, MarkupKind, OneOf, Position,
Range, ReferencesOptions, RenameOptions, SemanticToken, SemanticTokenType,
SemanticTokensLegend, SemanticTokensOptions, SemanticTokensServerCapabilities,
ServerCapabilities, TextDocumentSyncKind, Uri, WorkDoneProgressOptions, WorkspaceFolder,
WorkspaceFoldersServerCapabilities, WorkspaceServerCapabilities,
};
use miette::{miette, IntoDiagnostic, Result};
use nu_cli::{NuCompleter, SuggestionKind};
use nu_protocol::{
ast::{Block, PathMember},
engine::{CachedFile, Command, EngineState, Stack, StateDelta, StateWorkingSet},
engine::{Command, EngineState, StateDelta, StateWorkingSet},
DeclId, ModuleId, Span, Type, VarId,
};
use std::{
@@ -30,6 +27,7 @@ use symbols::SymbolCache;
use workspace::{InternalMessage, RangePerDoc};
mod ast;
mod completion;
mod diagnostics;
mod goto;
mod hints;
@@ -329,6 +327,16 @@ impl LanguageServer {
engine_state
}
fn cache_parsed_block(&mut self, working_set: &mut StateWorkingSet, block: Arc<Block>) {
if self.need_parse {
// TODO: incremental parsing
// add block to working_set for later references
working_set.add_block(block.clone());
self.cached_state_delta = Some(working_set.delta.clone());
self.need_parse = false;
}
}
pub(crate) fn parse_and_find<'a>(
&mut self,
engine_state: &'a mut EngineState,
@@ -376,50 +384,11 @@ impl LanguageServer {
self.semantic_tokens
.insert(uri.clone(), file_semantic_tokens);
}
if self.need_parse {
// TODO: incremental parsing
// add block to working_set for later references
working_set.add_block(block.clone());
self.cached_state_delta = Some(working_set.delta.clone());
self.need_parse = false;
}
drop(docs);
self.cache_parsed_block(&mut working_set, block.clone());
Some((block, span, working_set))
}
fn get_location_by_span<'a>(
&self,
files: impl Iterator<Item = &'a CachedFile>,
span: &Span,
) -> Option<Location> {
for cached_file in files.into_iter() {
if cached_file.covered_span.contains(span.start) {
let path = Path::new(&*cached_file.name);
if !path.is_file() {
return None;
}
let target_uri = path_to_uri(path);
if let Some(file) = self.docs.lock().ok()?.get_document(&target_uri) {
return Some(Location {
uri: target_uri,
range: span_to_range(span, file, cached_file.covered_span.start),
});
} else {
// in case where the document is not opened yet, typically included by `nu -I`
let temp_doc = FullTextDocument::new(
"nu".to_string(),
0,
String::from_utf8_lossy(cached_file.content.as_ref()).to_string(),
);
return Some(Location {
uri: target_uri,
range: span_to_range(span, &temp_doc, cached_file.covered_span.start),
});
}
}
}
None
}
fn handle_lsp_request<P, H, R>(req: lsp_server::Request, mut param_handler: H) -> Response
where
P: serde::de::DeserializeOwned,
@@ -675,117 +644,48 @@ impl LanguageServer {
}
}
}
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 engine_state = Arc::new(self.initial_engine_state.clone());
let mut completer = NuCompleter::new(engine_state.clone(), Arc::new(Stack::new()));
let location = file.offset_at(params.text_document_position.position) as usize;
let results = completer.fetch_completions_at(&file.get_content(None)[..location], location);
(!results.is_empty()).then_some(CompletionResponse::Array(
results
.into_iter()
.map(|r| {
let mut start = params.text_document_position.position;
start.character -= (r.suggestion.span.end - r.suggestion.span.start) as u32;
let decl_id = r.kind.clone().and_then(|kind| {
matches!(kind, SuggestionKind::Command(_))
.then_some(engine_state.find_decl(r.suggestion.value.as_bytes(), &[])?)
});
CompletionItem {
label: r.suggestion.value.clone(),
label_details: r
.kind
.clone()
.map(|kind| match kind {
SuggestionKind::Type(t) => t.to_string(),
SuggestionKind::Command(cmd) => cmd.to_string(),
SuggestionKind::Module => "module".to_string(),
SuggestionKind::Operator => "operator".to_string(),
})
.map(|s| CompletionItemLabelDetails {
detail: None,
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,
})
}),
kind: Self::lsp_completion_item_kind(r.kind),
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
range: Range {
start,
end: params.text_document_position.position,
},
new_text: r.suggestion.value,
})),
..Default::default()
}
})
.collect(),
))
}
fn lsp_completion_item_kind(
suggestion_kind: Option<SuggestionKind>,
) -> Option<CompletionItemKind> {
suggestion_kind.and_then(|suggestion_kind| match suggestion_kind {
SuggestionKind::Type(t) => match t {
nu_protocol::Type::String => Some(CompletionItemKind::VARIABLE),
_ => None,
},
SuggestionKind::Command(c) => match c {
nu_protocol::engine::CommandType::Keyword => Some(CompletionItemKind::KEYWORD),
nu_protocol::engine::CommandType::Builtin => Some(CompletionItemKind::FUNCTION),
nu_protocol::engine::CommandType::External => Some(CompletionItemKind::INTERFACE),
_ => None,
},
SuggestionKind::Operator => Some(CompletionItemKind::OPERATOR),
SuggestionKind::Module => Some(CompletionItemKind::MODULE),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use assert_json_diff::{assert_json_eq, assert_json_include};
use assert_json_diff::assert_json_eq;
use lsp_types::{
notification::{
DidChangeTextDocument, DidOpenTextDocument, Exit, Initialized, Notification,
},
request::{Completion, HoverRequest, Initialize, Request, Shutdown},
CompletionParams, DidChangeTextDocumentParams, DidOpenTextDocumentParams,
InitializedParams, PartialResultParams, Position, TextDocumentContentChangeEvent,
TextDocumentIdentifier, TextDocumentItem, TextDocumentPositionParams,
WorkDoneProgressParams,
request::{HoverRequest, Initialize, Request, Shutdown},
DidChangeTextDocumentParams, DidOpenTextDocumentParams, InitializedParams, Position,
TextDocumentContentChangeEvent, TextDocumentIdentifier, TextDocumentItem,
TextDocumentPositionParams, WorkDoneProgressParams,
};
use nu_protocol::{debugger::WithoutDebug, engine::Stack, PipelineData, ShellError, Value};
use nu_test_support::fs::fixtures;
use std::sync::mpsc::Receiver;
use std::sync::mpsc::{self, Receiver};
use std::time::Duration;
/// Initialize the language server for test purposes
///
/// # Arguments
/// - `nu_config_code`: Optional user defined `config.nu` that is loaded on start
/// - `params`: Optional client side capability parameters
pub(crate) fn initialize_language_server(
nu_config_code: Option<&str>,
params: Option<serde_json::Value>,
) -> (Connection, Receiver<Result<()>>) {
use std::sync::mpsc;
let (client_connection, server_connection) = Connection::memory();
let engine_state = nu_cmd_lang::create_default_context();
let engine_state = nu_command::add_shell_command_context(engine_state);
let mut engine_state = nu_command::add_shell_command_context(engine_state);
engine_state.generate_nu_constant();
let cwd = std::env::current_dir().expect("Could not get current working directory.");
engine_state.add_env_var(
"PWD".into(),
nu_protocol::Value::test_string(cwd.to_string_lossy()),
);
if let Some(code) = nu_config_code {
assert!(merge_input(code.as_bytes(), &mut engine_state, &mut Stack::new()).is_ok());
}
let (client_connection, server_connection) = Connection::memory();
let lsp_server =
LanguageServer::initialize_connection(server_connection, None, engine_state).unwrap();
@@ -816,9 +716,40 @@ mod tests {
(client_connection, recv)
}
/// merge_input executes the given input into the engine
/// and merges the state
fn merge_input(
input: &[u8],
engine_state: &mut EngineState,
stack: &mut Stack,
) -> Result<(), ShellError> {
let (block, delta) = {
let mut working_set = StateWorkingSet::new(engine_state);
let block = nu_parser::parse(&mut working_set, None, input, false);
assert!(working_set.parse_errors.is_empty());
(block, working_set.render())
};
engine_state.merge_delta(delta)?;
assert!(nu_engine::eval_block::<WithoutDebug>(
engine_state,
stack,
&block,
PipelineData::Value(Value::nothing(Span::unknown()), None),
)
.is_ok());
// Merge environment into the permanent state
engine_state.merge_env(stack)
}
#[test]
fn shutdown_on_request() {
let (client_connection, recv) = initialize_language_server(None);
let (client_connection, recv) = initialize_language_server(None, None);
client_connection
.sender
@@ -956,7 +887,7 @@ mod tests {
#[test]
fn hover_on_variable() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -975,7 +906,7 @@ mod tests {
#[test]
fn hover_on_cell_path() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -1009,7 +940,7 @@ mod tests {
#[test]
fn hover_on_custom_command() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -1033,7 +964,7 @@ mod tests {
#[test]
fn hover_on_external_command() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -1059,7 +990,7 @@ mod tests {
#[test]
fn hover_on_str_join() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -1083,7 +1014,7 @@ mod tests {
#[test]
fn hover_on_module() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -1104,150 +1035,4 @@ mod tests {
"\"# module doc\""
);
}
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(Duration::from_secs(2))
.unwrap()
}
#[test]
fn complete_on_variable() {
let (client_connection, _recv) = initialize_language_server(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": "string" },
"textEdit": {
"newText": "$greeting",
"range": { "start": { "character": 5, "line": 2 }, "end": { "character": 9, "line": 2 } }
},
"kind": 6
}
])
);
}
#[test]
fn complete_command_with_space() {
let (client_connection, _recv) = initialize_language_server(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, 0, 8);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!([
{
"label": "config nu",
"detail": "Edit nu configurations.",
"textEdit": { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 8 }, },
"newText": "config nu"
},
"kind": 3
}
])
);
}
#[test]
fn complete_command_with_line() {
let (client_connection, _recv) = initialize_language_server(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"
},
"kind": 3
}
])
);
}
#[test]
fn complete_keyword() {
let (client_connection, _recv) = initialize_language_server(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
},
])
);
}
}

View File

@@ -146,7 +146,7 @@ mod tests {
#[test]
fn hover_correct_documentation_on_let() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -170,7 +170,7 @@ mod tests {
#[test]
fn hover_on_command_after_full_content_change() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -205,7 +205,7 @@ hello"#,
#[test]
fn hover_on_command_after_partial_content_change() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -244,7 +244,7 @@ hello"#,
#[test]
fn open_document_with_utf_char() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");

View File

@@ -10,7 +10,7 @@ use nu_protocol::{
use crate::{span_to_range, LanguageServer};
/// Important for keep spans in increasing order,
/// Important to keep spans in increasing order,
/// since `SemanticToken`s are created by relative positions
/// to one's previous token
///
@@ -141,7 +141,7 @@ mod tests {
#[test]
fn semantic_token_internals() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");

View File

@@ -352,7 +352,7 @@ mod tests {
#[test]
// for variable `$in/$it`, should not appear in symbols
fn document_symbol_special_variables() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -368,7 +368,7 @@ mod tests {
#[test]
fn document_symbol_basic() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -410,7 +410,7 @@ mod tests {
#[test]
fn document_symbol_update() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -456,7 +456,7 @@ mod tests {
#[test]
fn workspace_symbol_current() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
@@ -516,7 +516,7 @@ mod tests {
#[test]
fn workspace_symbol_other() {
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");

View File

@@ -573,9 +573,10 @@ mod tests {
let mut script = fixtures();
script.push("lsp");
script.push("workspace");
let (client_connection, _recv) = initialize_language_server(Some(
serde_json::json!({ "workspaceFolders": serde_json::Value::Null }),
));
let (client_connection, _recv) = initialize_language_server(
None,
Some(serde_json::json!({ "workspaceFolders": serde_json::Value::Null })),
);
script.push("foo.nu");
let script = path_to_uri(&script);
@@ -588,6 +589,7 @@ mod tests {
script.push("lsp");
script.push("workspace");
let (client_connection, _recv) = initialize_language_server(
None,
serde_json::to_value(InitializeParams {
workspace_folders: Some(vec![WorkspaceFolder {
uri: path_to_uri(&script),
@@ -638,6 +640,7 @@ mod tests {
script.push("lsp");
script.push("workspace");
let (client_connection, _recv) = initialize_language_server(
None,
serde_json::to_value(InitializeParams {
workspace_folders: Some(vec![WorkspaceFolder {
uri: path_to_uri(&script),
@@ -688,6 +691,7 @@ mod tests {
script.push("lsp");
script.push("workspace");
let (client_connection, _recv) = initialize_language_server(
None,
serde_json::to_value(InitializeParams {
workspace_folders: Some(vec![WorkspaceFolder {
uri: path_to_uri(&script),
@@ -766,6 +770,7 @@ mod tests {
script.push("lsp");
script.push("workspace");
let (client_connection, _recv) = initialize_language_server(
None,
serde_json::to_value(InitializeParams {
workspace_folders: Some(vec![WorkspaceFolder {
uri: path_to_uri(&script),
@@ -834,6 +839,7 @@ mod tests {
script.push("lsp");
script.push("workspace");
let (client_connection, _recv) = initialize_language_server(
None,
serde_json::to_value(InitializeParams {
workspace_folders: Some(vec![WorkspaceFolder {
uri: path_to_uri(&script),
@@ -927,7 +933,7 @@ mod tests {
script.push("foo.nu");
let script = path_to_uri(&script);
let (client_connection, _recv) = initialize_language_server(None);
let (client_connection, _recv) = initialize_language_server(None, None);
open_unchecked(&client_connection, script.clone());
let message = send_document_highlight_request(&client_connection, script.clone(), 3, 5);