diff --git a/Cargo.lock b/Cargo.lock index 2b2ccf56e5..0aa56f288a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3814,6 +3814,7 @@ dependencies = [ "nu-cli", "nu-cmd-lang", "nu-command", + "nu-engine", "nu-glob", "nu-parser", "nu-protocol", diff --git a/crates/nu-cli/src/completions/cell_path_completions.rs b/crates/nu-cli/src/completions/cell_path_completions.rs index dc6465baa9..0ee079ab93 100644 --- a/crates/nu-cli/src/completions/cell_path_completions.rs +++ b/crates/nu-cli/src/completions/cell_path_completions.rs @@ -12,6 +12,19 @@ use super::completion_options::NuMatcher; pub struct CellPathCompletion<'a> { pub full_cell_path: &'a FullCellPath, + pub position: usize, +} + +fn prefix_from_path_member(member: &PathMember, pos: usize) -> (String, Span) { + let (prefix_str, start) = match member { + PathMember::String { val, span, .. } => (val.clone(), span.start), + PathMember::Int { val, span, .. } => (val.to_string(), span.start), + }; + let prefix_str = prefix_str + .get(..pos + 1 - start) + .map(str::to_string) + .unwrap_or(prefix_str); + (prefix_str, Span::new(start, pos + 1)) } impl Completer for CellPathCompletion<'_> { @@ -24,24 +37,30 @@ impl Completer for CellPathCompletion<'_> { offset: usize, options: &CompletionOptions, ) -> Vec { - // empty tail is already handled as variable names completion - let Some((prefix_member, path_members)) = self.full_cell_path.tail.split_last() else { - return vec![]; - }; - let (mut prefix_str, span) = match prefix_member { - PathMember::String { val, span, .. } => (val.clone(), span), - PathMember::Int { val, span, .. } => (val.to_string(), span), - }; - // strip the placeholder - prefix_str.pop(); - let true_end = std::cmp::max(span.start, span.end - 1); - let span = Span::new(span.start, true_end); + let mut prefix_str = String::new(); + // position at dots, e.g. `$env.config.` + let mut span = Span::new(self.position + 1, self.position + 1); + let mut path_member_num_before_pos = 0; + for member in self.full_cell_path.tail.iter() { + if member.span().end <= self.position { + path_member_num_before_pos += 1; + } else if member.span().contains(self.position) { + (prefix_str, span) = prefix_from_path_member(member, self.position); + break; + } + } + let current_span = reedline::Span { start: span.start - offset, - end: true_end - offset, + end: span.end - offset, }; let mut matcher = NuMatcher::new(prefix_str, options); + let path_members = self + .full_cell_path + .tail + .get(0..path_member_num_before_pos) + .unwrap_or_default(); let value = eval_cell_path( working_set, stack, diff --git a/crates/nu-cli/src/completions/completer.rs b/crates/nu-cli/src/completions/completer.rs index 3a8fc07298..75407921a5 100644 --- a/crates/nu-cli/src/completions/completer.rs +++ b/crates/nu-cli/src/completions/completer.rs @@ -7,7 +7,7 @@ use nu_color_config::{color_record_to_nustyle, lookup_ansi_color_style}; use nu_engine::eval_block; use nu_parser::{flatten_expression, parse}; use nu_protocol::{ - ast::{Argument, Expr, Expression, FindMapResult, Traverse}, + ast::{Argument, Block, Expr, Expression, FindMapResult, Traverse}, debugger::WithoutDebug, engine::{Closure, EngineState, Stack, StateWorkingSet}, PipelineData, Span, Value, @@ -21,7 +21,7 @@ use super::base::{SemanticSuggestion, SuggestionKind}; /// /// returns the inner-most pipeline_element of interest /// i.e. the one that contains given position and needs completion -pub fn find_pipeline_element_by_position<'a>( +fn find_pipeline_element_by_position<'a>( expr: &'a Expression, working_set: &'a StateWorkingSet, pos: usize, @@ -76,9 +76,17 @@ pub fn find_pipeline_element_by_position<'a>( /// Before completion, an additional character `a` is added to the source as a placeholder for correct parsing results. /// This function helps to strip it -fn strip_placeholder<'a>(working_set: &'a StateWorkingSet, span: &Span) -> (Span, &'a [u8]) { - let new_end = std::cmp::max(span.end - 1, span.start); - let new_span = Span::new(span.start, new_end); +fn strip_placeholder_if_any<'a>( + working_set: &'a StateWorkingSet, + span: &Span, + strip: bool, +) -> (Span, &'a [u8]) { + let new_span = if strip { + let new_end = std::cmp::max(span.end - 1, span.start); + Span::new(span.start, new_end) + } else { + span.to_owned() + }; let prefix = working_set.get_span_contents(new_span); (new_span, prefix) } @@ -90,6 +98,7 @@ fn strip_placeholder_with_rsplit<'a>( working_set: &'a StateWorkingSet, span: &Span, predicate: impl FnMut(&u8) -> bool, + strip: bool, ) -> (Span, &'a [u8]) { let span_content = working_set.get_span_contents(*span); let mut prefix = span_content @@ -97,7 +106,7 @@ fn strip_placeholder_with_rsplit<'a>( .next() .unwrap_or(span_content); let start = span.end.saturating_sub(prefix.len()); - if !prefix.is_empty() { + if strip && !prefix.is_empty() { prefix = &prefix[..prefix.len() - 1]; } let end = start + prefix.len(); @@ -142,15 +151,11 @@ impl NuCompleter { } } - pub fn fetch_completions_at(&mut self, line: &str, pos: usize) -> Vec { + pub fn fetch_completions_at(&self, line: &str, pos: usize) -> Vec { let mut working_set = StateWorkingSet::new(&self.engine_state); let offset = working_set.next_span_start(); // TODO: Callers should be trimming the line themselves let line = if line.len() > pos { &line[..pos] } else { line }; - // Adjust offset so that the spans of the suggestions will start at the right - // place even with `only_buffer_difference: true` - let pos = offset + pos; - let block = parse( &mut working_set, Some("completer"), @@ -158,19 +163,64 @@ impl NuCompleter { format!("{}a", line).as_bytes(), false, ); - let Some(element_expression) = block.find_map(&working_set, &|expr: &Expression| { - find_pipeline_element_by_position(expr, &working_set, pos) + self.fetch_completions_by_block(block, &working_set, pos, offset, line, true) + } + + /// For completion in LSP server. + /// We don't truncate the contents in order + /// to complete the definitions after the cursor. + /// + /// And we avoid the placeholder to reuse the parsed blocks + /// cached while handling other LSP requests, e.g. diagnostics + pub fn fetch_completions_within_file( + &self, + filename: &str, + pos: usize, + contents: &str, + ) -> Vec { + let mut working_set = StateWorkingSet::new(&self.engine_state); + let block = parse(&mut working_set, Some(filename), contents.as_bytes(), false); + let Some(file_span) = working_set.get_span_for_filename(filename) else { + return vec![]; + }; + let offset = file_span.start; + self.fetch_completions_by_block(block.clone(), &working_set, pos, offset, contents, false) + } + + fn fetch_completions_by_block( + &self, + block: Arc, + working_set: &StateWorkingSet, + pos: usize, + offset: usize, + contents: &str, + extra_placeholder: bool, + ) -> Vec { + // Adjust offset so that the spans of the suggestions will start at the right + // place even with `only_buffer_difference: true` + let mut pos_to_search = pos + offset; + if !extra_placeholder { + pos_to_search = pos_to_search.saturating_sub(1); + } + let Some(element_expression) = block.find_map(working_set, &|expr: &Expression| { + find_pipeline_element_by_position(expr, working_set, pos_to_search) }) else { return vec![]; }; - // line of element_expression + // text of element_expression let start_offset = element_expression.span.start - offset; - if let Some(line) = line.get(start_offset..) { - self.complete_by_expression(&working_set, element_expression, offset, pos, line) - } else { - vec![] - } + let Some(text) = contents.get(start_offset..pos) else { + return vec![]; + }; + self.complete_by_expression( + working_set, + element_expression, + offset, + pos_to_search, + text, + extra_placeholder, + ) } /// Complete given the expression of interest @@ -180,13 +230,15 @@ impl NuCompleter { /// * `offset` - start offset of current working_set span /// * `pos` - cursor position, should be > offset /// * `prefix_str` - all the text before the cursor, within the `element_expression` - pub fn complete_by_expression( + /// * `strip` - whether to strip the extra placeholder from a span + fn complete_by_expression( &self, working_set: &StateWorkingSet, element_expression: &Expression, offset: usize, pos: usize, prefix_str: &str, + strip: bool, ) -> Vec { let mut suggestions: Vec = vec![]; @@ -196,18 +248,24 @@ impl NuCompleter { working_set, element_expression.span, offset, + strip, ); } Expr::FullCellPath(full_cell_path) => { // e.g. `$e` parsed as FullCellPath - if full_cell_path.tail.is_empty() { + // but `$e.` without placeholder should be taken as cell_path + if full_cell_path.tail.is_empty() && !prefix_str.ends_with('.') { return self.variable_names_completion_helper( working_set, element_expression.span, offset, + strip, ); } else { - let mut cell_path_completer = CellPathCompletion { full_cell_path }; + let mut cell_path_completer = CellPathCompletion { + full_cell_path, + position: if strip { pos - 1 } else { pos }, + }; let ctx = Context::new(working_set, Span::unknown(), &[], offset); return self.process_completion(&mut cell_path_completer, &ctx); } @@ -217,7 +275,7 @@ impl NuCompleter { let mut operator_completions = OperatorCompletion { left_hand_side: lhs.as_ref(), }; - let (new_span, prefix) = strip_placeholder(working_set, &op.span); + let (new_span, prefix) = strip_placeholder_if_any(working_set, &op.span, strip); let ctx = Context::new(working_set, new_span, prefix, offset); let results = self.process_completion(&mut operator_completions, &ctx); if !results.is_empty() { @@ -230,13 +288,13 @@ impl NuCompleter { let span = attr.expr.span; span.contains(pos).then_some(span) }) { - let (new_span, prefix) = strip_placeholder(working_set, &span); + let (new_span, prefix) = strip_placeholder_if_any(working_set, &span, strip); let ctx = Context::new(working_set, new_span, prefix, offset); return self.process_completion(&mut AttributeCompletion, &ctx); }; let span = ab.item.span; if span.contains(pos) { - let (new_span, prefix) = strip_placeholder(working_set, &span); + let (new_span, prefix) = strip_placeholder_if_any(working_set, &span, strip); let ctx = Context::new(working_set, new_span, prefix, offset); return self.process_completion(&mut AttributableCompletion, &ctx); } @@ -258,6 +316,7 @@ impl NuCompleter { offset, need_internals, need_externals, + strip, )) } _ => (), @@ -276,11 +335,14 @@ impl NuCompleter { if let Some(decl_id) = arg.expr().and_then(|e| e.custom_completion) { // for `--foo ` and `--foo=`, the arg span should be trimmed let (new_span, prefix) = if matches!(arg, Argument::Named(_)) { - strip_placeholder_with_rsplit(working_set, &span, |b| { - *b == b'=' || *b == b' ' - }) + strip_placeholder_with_rsplit( + working_set, + &span, + |b| *b == b'=' || *b == b' ', + strip, + ) } else { - strip_placeholder(working_set, &span) + strip_placeholder_if_any(working_set, &span, strip) }; let ctx = Context::new(working_set, new_span, prefix, offset); @@ -296,18 +358,24 @@ impl NuCompleter { } // normal arguments completion - let (new_span, prefix) = strip_placeholder(working_set, &span); + let (new_span, prefix) = + strip_placeholder_if_any(working_set, &span, strip); let ctx = Context::new(working_set, new_span, prefix, offset); + let flag_completion_helper = || { + let mut flag_completions = FlagCompletion { + decl_id: call.decl_id, + }; + self.process_completion(&mut flag_completions, &ctx) + }; suggestions.extend(match arg { // flags Argument::Named(_) | Argument::Unknown(_) if prefix.starts_with(b"-") => { - let mut flag_completions = FlagCompletion { - decl_id: call.decl_id, - }; - self.process_completion(&mut flag_completions, &ctx) + flag_completion_helper() } + // only when `strip` == false + Argument::Positional(_) if prefix == b"-" => flag_completion_helper(), // complete according to expression type and command head Argument::Positional(expr) => { let command_head = working_set.get_span_contents(call.head); @@ -339,6 +407,7 @@ impl NuCompleter { offset, true, true, + strip, ); // flags of sudo/doas can still be completed by external completer if !commands.is_empty() { @@ -357,16 +426,17 @@ impl NuCompleter { String::from_utf8_lossy(bytes).to_string() }) .collect(); + let mut new_span = span; // strip the placeholder - if let Some(last) = text_spans.last_mut() { - last.pop(); + if strip { + if let Some(last) = text_spans.last_mut() { + last.pop(); + new_span = Span::new(span.start, span.end.saturating_sub(1)); + } } - if let Some(external_result) = self.external_completion( - closure, - &text_spans, - offset, - Span::new(span.start, span.end.saturating_sub(1)), - ) { + if let Some(external_result) = + self.external_completion(closure, &text_spans, offset, new_span) + { suggestions.extend(external_result); return suggestions; } @@ -380,10 +450,12 @@ impl NuCompleter { // if no suggestions yet, fallback to file completion if suggestions.is_empty() { - let (new_span, prefix) = - strip_placeholder_with_rsplit(working_set, &element_expression.span, |c| { - *c == b' ' - }); + let (new_span, prefix) = strip_placeholder_with_rsplit( + working_set, + &element_expression.span, + |c| *c == b' ', + strip, + ); let ctx = Context::new(working_set, new_span, prefix, offset); suggestions.extend(self.process_completion(&mut FileCompletion, &ctx)); } @@ -395,8 +467,9 @@ impl NuCompleter { working_set: &StateWorkingSet, span: Span, offset: usize, + strip: bool, ) -> Vec { - let (new_span, prefix) = strip_placeholder(working_set, &span); + let (new_span, prefix) = strip_placeholder_if_any(working_set, &span, strip); if !prefix.starts_with(b"$") { return vec![]; } @@ -411,12 +484,13 @@ impl NuCompleter { offset: usize, internals: bool, externals: bool, + strip: bool, ) -> Vec { let mut command_completions = CommandCompletion { internals, externals, }; - let (new_span, prefix) = strip_placeholder(working_set, &span); + let (new_span, prefix) = strip_placeholder_if_any(working_set, &span, strip); let ctx = Context::new(working_set, new_span, prefix, offset); self.process_completion(&mut command_completions, &ctx) } @@ -644,7 +718,7 @@ mod completer_tests { result.err().unwrap() ); - let mut completer = NuCompleter::new(engine_state.into(), Arc::new(Stack::new())); + let completer = NuCompleter::new(engine_state.into(), Arc::new(Stack::new())); let dataset = [ ("1 bit-sh", true, "b", vec!["bit-shl", "bit-shr"]), ("1.0 bit-sh", false, "b", vec![]), diff --git a/crates/nu-lsp/Cargo.toml b/crates/nu-lsp/Cargo.toml index e235527056..d169ed15e7 100644 --- a/crates/nu-lsp/Cargo.toml +++ b/crates/nu-lsp/Cargo.toml @@ -26,6 +26,7 @@ url = { workspace = true } [dev-dependencies] nu-cmd-lang = { path = "../nu-cmd-lang", version = "0.102.1" } nu-command = { path = "../nu-command", version = "0.102.1" } +nu-engine = { path = "../nu-engine", version = "0.102.1" } nu-test-support = { path = "../nu-test-support", version = "0.102.1" } assert-json-diff = "2.0" diff --git a/crates/nu-lsp/src/completion.rs b/crates/nu-lsp/src/completion.rs new file mode 100644 index 0000000000..64846b0f88 --- /dev/null +++ b/crates/nu-lsp/src/completion.rs @@ -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 { + 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, + ) -> Option { + 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>" }, + "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" }, + "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 + } + ]) + ); + } +} diff --git a/crates/nu-lsp/src/diagnostics.rs b/crates/nu-lsp/src/diagnostics.rs index 70e94a7da2..92ea2073ff 100644 --- a/crates/nu-lsp/src/diagnostics.rs +++ b/crates/nu-lsp/src/diagnostics.rs @@ -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"); diff --git a/crates/nu-lsp/src/goto.rs b/crates/nu-lsp/src/goto.rs index 1a384dbce9..c61d2f1485 100644 --- a/crates/nu-lsp/src/goto.rs +++ b/crates/nu-lsp/src/goto.rs @@ -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, + span: &Span, + ) -> Option { + 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"); diff --git a/crates/nu-lsp/src/hints.rs b/crates/nu-lsp/src/hints.rs index 807206a9f5..c01966e382 100644 --- a/crates/nu-lsp/src/hints.rs +++ b/crates/nu-lsp/src/hints.rs @@ -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 } + ]) + ); + } } diff --git a/crates/nu-lsp/src/lib.rs b/crates/nu-lsp/src/lib.rs index 28ac6aa31d..d72e625b33 100644 --- a/crates/nu-lsp/src/lib.rs +++ b/crates/nu-lsp/src/lib.rs @@ -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) { + 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, - span: &Span, - ) -> Option { - 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(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 { - 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, - ) -> Option { - 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, ) -> (Connection, Receiver>) { - 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::( + 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 - }, - ]) - ); - } } diff --git a/crates/nu-lsp/src/notification.rs b/crates/nu-lsp/src/notification.rs index 123fae7728..d3c4c8ac3e 100644 --- a/crates/nu-lsp/src/notification.rs +++ b/crates/nu-lsp/src/notification.rs @@ -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"); diff --git a/crates/nu-lsp/src/semantic_tokens.rs b/crates/nu-lsp/src/semantic_tokens.rs index 2d6e6e939d..6a963f40b4 100644 --- a/crates/nu-lsp/src/semantic_tokens.rs +++ b/crates/nu-lsp/src/semantic_tokens.rs @@ -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"); diff --git a/crates/nu-lsp/src/symbols.rs b/crates/nu-lsp/src/symbols.rs index 13f48b51c4..4d2cf67dc6 100644 --- a/crates/nu-lsp/src/symbols.rs +++ b/crates/nu-lsp/src/symbols.rs @@ -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"); diff --git a/crates/nu-lsp/src/workspace.rs b/crates/nu-lsp/src/workspace.rs index f7d99c4c41..66b70f58ea 100644 --- a/crates/nu-lsp/src/workspace.rs +++ b/crates/nu-lsp/src/workspace.rs @@ -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); diff --git a/tests/fixtures/lsp/completion/cell_path.nu b/tests/fixtures/lsp/completion/cell_path.nu new file mode 100644 index 0000000000..0a8d8cd94b --- /dev/null +++ b/tests/fixtures/lsp/completion/cell_path.nu @@ -0,0 +1,2 @@ +const foo = {"1": "bar" "🤔🐘": [{"baz": 1}]} +$foo.🤔🐘.0.b diff --git a/tests/fixtures/lsp/completion/command.nu b/tests/fixtures/lsp/completion/command.nu index 9ed5909acf..cf30603ccf 100644 --- a/tests/fixtures/lsp/completion/command.nu +++ b/tests/fixtures/lsp/completion/command.nu @@ -1 +1,12 @@ config n +config n foo bar - +config n foo bar l --l + +# detail +def "config n foo bar" [ + f: path + --long (-s): int # test flag +] { + echo "🤔🐘" + | str substring (str substring -) +} diff --git a/tests/fixtures/lsp/completion/external.nu b/tests/fixtures/lsp/completion/external.nu new file mode 100644 index 0000000000..54561d1865 --- /dev/null +++ b/tests/fixtures/lsp/completion/external.nu @@ -0,0 +1 @@ +sudo --back diff --git a/tests/fixtures/lsp/completion/fallback.nu b/tests/fixtures/lsp/completion/fallback.nu new file mode 100644 index 0000000000..4e454cd552 --- /dev/null +++ b/tests/fixtures/lsp/completion/fallback.nu @@ -0,0 +1,8 @@ +let greeting = "Hello" + +echo $gre +| st + +ls l + +$greeting not-h