From 0ca8fcf58c02bef31f1eb65e001d71f5648b3b35 Mon Sep 17 00:00:00 2001 From: Marc Schreiber Date: Thu, 2 Nov 2023 16:18:57 +0100 Subject: [PATCH] Integrated Language Server (#10723) # Description This commit integrates a language server into nushell so that IDEs don't have to convert CLI option back and forth. - fixes https://github.com/nushell/vscode-nushell-lang/issues/117 - fixes https://github.com/jokeyrhyme/nuls/issues/8 Tracking tasks - [x] [textDocument/hover](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover) -> `nu --ide-hover` - [x] [textDocument/completion](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion) -> `nu --ide-complete` - [x] [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) -> `nu --ide-goto-def` - ~~[ ] [textDocument/didChange](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_didChange), [textDocument/didClose](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_didClose), and [textDocument/didOpen](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_didOpen)~~ (will be done in a follow-up PR) - ~~[ ] [textDocument/inlayHint](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_inlayHint) -> `nu --ide-check`~~ (will be done in a follow-up PR) - ~~[ ] [textDocument/publishDiagnostics](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_publishDiagnostics) -> `nu --ide-check`~~ (will be done in a follow-up PR) - ~~[ ] [workspace/configuration](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_configuration)~~ (will be done in a follow-up PR) - ~~[ ] [workspace/didChangeConfiguration](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeConfiguration)~~ (will be done in a follow-up PR) # User-Facing Changes The command line options `--lsp` will start a LSP server. # Tests + Formatting # After Submitting --- Cargo.lock | 75 ++ Cargo.toml | 2 + crates/nu-lsp/Cargo.toml | 29 + crates/nu-lsp/LICENSE | 28 + crates/nu-lsp/src/lib.rs | 933 +++++++++++++++++++++++ src/command.rs | 14 +- src/main.rs | 5 + tests/fixtures/lsp/completion/command.nu | 1 + tests/fixtures/lsp/completion/var.nu | 3 + tests/fixtures/lsp/goto/command.nu | 5 + tests/fixtures/lsp/goto/var.nu | 3 + tests/fixtures/lsp/hover/command.nu | 4 + tests/fixtures/lsp/hover/var.nu | 3 + 13 files changed, 1102 insertions(+), 3 deletions(-) create mode 100644 crates/nu-lsp/Cargo.toml create mode 100644 crates/nu-lsp/LICENSE create mode 100644 crates/nu-lsp/src/lib.rs create mode 100644 tests/fixtures/lsp/completion/command.nu create mode 100644 tests/fixtures/lsp/completion/var.nu create mode 100644 tests/fixtures/lsp/goto/command.nu create mode 100644 tests/fixtures/lsp/goto/var.nu create mode 100644 tests/fixtures/lsp/hover/command.nu create mode 100644 tests/fixtures/lsp/hover/var.nu diff --git a/Cargo.lock b/Cargo.lock index 691aa4be0e..280fcb4b44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2309,6 +2309,31 @@ dependencies = [ "nu-ansi-term", ] +[[package]] +name = "lsp-server" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52dccdf3302eefab8c8a1273047f0a3c3dca4b527c8458d00c09484c8371928" +dependencies = [ + "crossbeam-channel", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "lsp-types" +version = "0.94.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", +] + [[package]] name = "lz4" version = "1.24.0" @@ -2641,6 +2666,7 @@ dependencies = [ "nu-engine", "nu-explore", "nu-json", + "nu-lsp", "nu-parser", "nu-path", "nu-plugin", @@ -2940,6 +2966,27 @@ dependencies = [ "serde", ] +[[package]] +name = "nu-lsp" +version = "0.86.1" +dependencies = [ + "assert-json-diff", + "lsp-server", + "lsp-types", + "miette", + "nu-cli", + "nu-cmd-lang", + "nu-command", + "nu-parser", + "nu-protocol", + "nu-test-support", + "reedline", + "ropey", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "nu-parser" version = "0.86.1" @@ -4332,6 +4379,16 @@ dependencies = [ "serde", ] +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + [[package]] name = "roxmltree" version = "0.18.1" @@ -4636,6 +4693,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8725e1dfadb3a50f7e5ce0b1a540466f6ed3fe7a0fca2ac2b8b831d31316bd00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.37", +] + [[package]] name = "serde_spanned" version = "0.6.3" @@ -4903,6 +4971,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str_indices" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8eeaedde8e50d8a331578c9fa9a288df146ce5e16173ad26ce82f6e263e2be4" + [[package]] name = "streaming-decompression" version = "0.1.2" @@ -5586,6 +5660,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 22d1eef2e0..92a54243eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ members = [ "crates/nu-cmd-lang", "crates/nu-cmd-dataframe", "crates/nu-command", + "crates/nu-lsp", "crates/nu-protocol", "crates/nu-plugin", "crates/nu_plugin_inc", @@ -56,6 +57,7 @@ nu-command = { path = "./crates/nu-command", version = "0.86.1" } nu-engine = { path = "./crates/nu-engine", version = "0.86.1" } nu-explore = { path = "./crates/nu-explore", version = "0.86.1" } nu-json = { path = "./crates/nu-json", version = "0.86.1" } +nu-lsp = { path = "./crates/nu-lsp/", version = "0.86.1" } nu-parser = { path = "./crates/nu-parser", version = "0.86.1" } nu-path = { path = "./crates/nu-path", version = "0.86.1" } nu-plugin = { path = "./crates/nu-plugin", optional = true, version = "0.86.1" } diff --git a/crates/nu-lsp/Cargo.toml b/crates/nu-lsp/Cargo.toml new file mode 100644 index 0000000000..8530a69dc9 --- /dev/null +++ b/crates/nu-lsp/Cargo.toml @@ -0,0 +1,29 @@ +[package] +authors = ["The Nushell Project Developers"] +description = "Nushell's integrated LSP server" +repository = "https://github.com/nushell/nushell/tree/main/crates/nu-lsp" +name = "nu-lsp" +version = "0.86.1" +edition = "2021" +license = "MIT" + +[dependencies] +nu-cli = { path = "../nu-cli", version = "0.86.1" } +nu-parser = { path = "../nu-parser", version = "0.86.1" } +nu-protocol = { path = "../nu-protocol", version = "0.86.1" } +reedline = { version = "0.25" } + +lsp-types = "0.94.1" +lsp-server = "0.7.4" +miette = "5.10" +ropey = "1.6.1" +serde = "1.0" +serde_json = "1.0" + +[dev-dependencies] +nu-cmd-lang = { path = "../nu-cmd-lang", version = "0.86.1" } +nu-command = { path = "../nu-command", version = "0.86.1" } +nu-test-support = { path = "../nu-test-support", version = "0.86.1" } + +assert-json-diff = "2.0" +tempfile = "3.2" diff --git a/crates/nu-lsp/LICENSE b/crates/nu-lsp/LICENSE new file mode 100644 index 0000000000..8242915f88 --- /dev/null +++ b/crates/nu-lsp/LICENSE @@ -0,0 +1,28 @@ +The MIT License (MIT) + +Copyright (c) 2014 The Rust Project Developers +Copyright (c) 2020-2023 The Nushell Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/crates/nu-lsp/src/lib.rs b/crates/nu-lsp/src/lib.rs new file mode 100644 index 0000000000..9c94f44d24 --- /dev/null +++ b/crates/nu-lsp/src/lib.rs @@ -0,0 +1,933 @@ +use std::{fs::File, io::Cursor, sync::Arc}; + +use lsp_server::{Connection, IoThreads, Message, Response, ResponseError}; +use lsp_types::{ + request::{Completion, GotoDefinition, HoverRequest, Request}, + CompletionItem, CompletionParams, CompletionResponse, CompletionTextEdit, GotoDefinitionParams, + GotoDefinitionResponse, Hover, HoverContents, HoverParams, Location, MarkupContent, MarkupKind, + OneOf, Range, ServerCapabilities, TextEdit, Url, +}; +use miette::{IntoDiagnostic, Result}; +use nu_cli::NuCompleter; +use nu_parser::{flatten_block, parse, FlatShape}; +use nu_protocol::{ + engine::{EngineState, Stack, StateWorkingSet}, + DeclId, Span, Value, VarId, +}; +use reedline::Completer; +use ropey::Rope; + +#[derive(Debug)] +enum Id { + Variable(VarId), + Declaration(DeclId), + Value(FlatShape), +} + +pub struct LanguageServer { + connection: Connection, + io_threads: Option, +} + +impl LanguageServer { + pub fn initialize_stdio_connection() -> Result { + let (connection, io_threads) = Connection::stdio(); + Self::initialize_connection(connection, Some(io_threads)) + } + + fn initialize_connection( + connection: Connection, + io_threads: Option, + ) -> Result { + Ok(Self { + connection, + io_threads, + }) + } + + pub fn serve_requests(self, engine_state: EngineState) -> Result<()> { + let server_capabilities = serde_json::to_value(&ServerCapabilities { + definition_provider: Some(OneOf::Left(true)), + hover_provider: Some(lsp_types::HoverProviderCapability::Simple(true)), + completion_provider: Some(lsp_types::CompletionOptions::default()), + ..Default::default() + }) + .expect("Must be serializable"); + + let _initialization_params = self + .connection + .initialize(server_capabilities) + .into_diagnostic()?; + + for msg in &self.connection.receiver { + match msg { + Message::Request(request) => { + if self + .connection + .handle_shutdown(&request) + .into_diagnostic()? + { + return Ok(()); + } + + let mut engine_state = engine_state.clone(); + match request.method.as_str() { + GotoDefinition::METHOD => { + self.handle_lsp_request( + &mut engine_state, + request, + Self::goto_definition, + )?; + } + HoverRequest::METHOD => { + self.handle_lsp_request(&mut engine_state, request, Self::hover)?; + } + Completion::METHOD => { + self.handle_lsp_request(&mut engine_state, request, Self::complete)?; + } + _ => {} + } + } + Message::Response(_) => {} + Message::Notification(_) => {} + } + } + + if let Some(io_threads) = self.io_threads { + io_threads.join().into_diagnostic()?; + } + + Ok(()) + } + + fn handle_lsp_request( + &self, + engine_state: &mut EngineState, + req: lsp_server::Request, + param_handler: H, + ) -> Result<()> + where + P: serde::de::DeserializeOwned, + H: Fn(&mut EngineState, &P) -> Option, + R: serde::ser::Serialize, + { + let resp = { + match serde_json::from_value::

(req.params) { + Ok(params) => Response { + id: req.id, + result: param_handler(engine_state, ¶ms) + .and_then(|response| serde_json::to_value(response).ok()), + error: None, + }, + + Err(err) => Response { + id: req.id, + result: None, + error: Some(ResponseError { + code: 1, + message: err.to_string(), + data: None, + }), + }, + } + }; + + self.connection + .sender + .send(Message::Response(resp)) + .into_diagnostic() + } + + fn span_to_range(span: &Span, rope_of_file: &Rope, offset: usize) -> lsp_types::Range { + let line = rope_of_file.char_to_line(span.start - offset); + let character = span.start - offset - rope_of_file.line_to_char(line); + + let start = lsp_types::Position { + line: line as u32, + character: character as u32, + }; + + let line = rope_of_file.char_to_line(span.end - offset); + let character = span.end - offset - rope_of_file.line_to_char(line); + + let end = lsp_types::Position { + line: line as u32, + character: character as u32, + }; + + lsp_types::Range { start, end } + } + + fn lsp_position_to_location(position: &lsp_types::Position, rope_of_file: &Rope) -> usize { + let line_idx = rope_of_file.line_to_char(position.line as usize); + line_idx + position.character as usize + } + + fn find_id( + working_set: &mut StateWorkingSet, + file_path: &str, + file: &[u8], + location: usize, + ) -> Option<(Id, usize, Span)> { + let file_id = working_set.add_file(file_path.to_string(), file); + let offset = working_set.get_span_for_file(file_id).start; + let block = parse(working_set, Some(file_path), file, false); + let flattened = flatten_block(working_set, &block); + + let location = location + offset; + for item in flattened { + if location >= item.0.start && location < item.0.end { + match &item.1 { + FlatShape::Variable(var_id) | FlatShape::VarDecl(var_id) => { + return Some((Id::Variable(*var_id), offset, item.0)); + } + FlatShape::InternalCall(decl_id) => { + return Some((Id::Declaration(*decl_id), offset, item.0)); + } + _ => return Some((Id::Value(item.1), offset, item.0)), + } + } + } + None + } + + fn read_in_file<'a>( + engine_state: &'a mut EngineState, + file_path: &str, + ) -> Result<(Vec, StateWorkingSet<'a>)> { + let file = std::fs::read(file_path).into_diagnostic()?; + + engine_state.start_in_file(Some(file_path)); + + let working_set = StateWorkingSet::new(engine_state); + + Ok((file, working_set)) + } + + fn goto_definition( + engine_state: &mut EngineState, + params: &GotoDefinitionParams, + ) -> Option { + let cwd = std::env::current_dir().expect("Could not get current working directory."); + engine_state.add_env_var("PWD".into(), Value::test_string(cwd.to_string_lossy())); + + let file_path = params + .text_document_position_params + .text_document + .uri + .to_file_path() + .ok()?; + + let file_path = file_path.to_string_lossy(); + + let (file, mut working_set) = Self::read_in_file(engine_state, &file_path).ok()?; + let rope_of_file = Rope::from_reader(Cursor::new(&file)).ok()?; + + let (id, _, _) = Self::find_id( + &mut working_set, + &file_path, + &file, + Self::lsp_position_to_location( + ¶ms.text_document_position_params.position, + &rope_of_file, + ), + )?; + + match id { + Id::Declaration(decl_id) => { + if let Some(block_id) = working_set.get_decl(decl_id).get_block_id() { + let block = working_set.get_block(block_id); + if let Some(span) = &block.span { + for (file_path, file_start, file_end) in working_set.files() { + if span.start >= *file_start && span.start < *file_end { + return Some(GotoDefinitionResponse::Scalar(Location { + uri: Url::from_file_path(file_path).ok()?, + range: Self::span_to_range(span, &rope_of_file, *file_start), + })); + } + } + } + } + } + Id::Variable(var_id) => { + let var = working_set.get_variable(var_id); + for (_, file_start, file_end) in working_set.files() { + if var.declaration_span.start >= *file_start + && var.declaration_span.start < *file_end + { + return Some(GotoDefinitionResponse::Scalar(Location { + uri: params + .text_document_position_params + .text_document + .uri + .clone(), + range: Self::span_to_range( + &var.declaration_span, + &rope_of_file, + *file_start, + ), + })); + } + } + } + _ => {} + } + None + } + + fn hover(engine_state: &mut EngineState, params: &HoverParams) -> Option { + let cwd = std::env::current_dir().expect("Could not get current working directory."); + engine_state.add_env_var("PWD".into(), Value::test_string(cwd.to_string_lossy())); + + let file_path = params + .text_document_position_params + .text_document + .uri + .to_file_path() + .ok()?; + + let file_path = file_path.to_string_lossy(); + + let (file, mut working_set) = Self::read_in_file(engine_state, &file_path).ok()?; + let rope_of_file = Rope::from_reader(Cursor::new(&file)).ok()?; + + let (id, _, _) = Self::find_id( + &mut working_set, + &file_path, + &file, + Self::lsp_position_to_location( + ¶ms.text_document_position_params.position, + &rope_of_file, + ), + )?; + + match id { + Id::Variable(var_id) => { + let var = working_set.get_variable(var_id); + let contents = format!("{}{}", if var.mutable { "mutable " } else { "" }, var.ty); + + Some(Hover { + contents: HoverContents::Scalar(lsp_types::MarkedString::String(contents)), + // TODO + range: None, + }) + } + Id::Declaration(decl_id) => { + let decl = working_set.get_decl(decl_id); + + let mut description = "```\n### Signature\n```\n".to_string(); + let signature = decl.signature(); + description.push_str(&format!(" {}", signature.name)); + if !signature.named.is_empty() { + description.push_str(" {flags}") + } + for required_arg in &signature.required_positional { + description.push_str(&format!(" <{}>", required_arg.name)); + } + for optional_arg in &signature.optional_positional { + description.push_str(&format!(" <{}?>", optional_arg.name)); + } + if let Some(arg) = &signature.rest_positional { + description.push_str(&format!(" <...{}>", arg.name)); + } + description.push_str("\n```\n"); + if !signature.required_positional.is_empty() + || !signature.optional_positional.is_empty() + || signature.rest_positional.is_some() + { + description.push_str("\n### Parameters\n\n"); + let mut first = true; + for required_arg in &signature.required_positional { + if !first { + description.push_str("\\\n"); + } else { + first = false; + } + description.push_str(&format!( + " `{}: {}`", + required_arg.name, + required_arg.shape.to_type() + )); + if !required_arg.desc.is_empty() { + description.push_str(&format!(" - {}", required_arg.desc)); + } + description.push('\n'); + } + for optional_arg in &signature.optional_positional { + if !first { + description.push_str("\\\n"); + } else { + first = false; + } + description.push_str(&format!( + " `{}: {}`", + optional_arg.name, + optional_arg.shape.to_type() + )); + if !optional_arg.desc.is_empty() { + description.push_str(&format!(" - {}", optional_arg.desc)); + } + description.push('\n'); + } + if let Some(arg) = &signature.rest_positional { + if !first { + description.push_str("\\\n"); + } + description.push_str(&format!( + " `...{}: {}`", + arg.name, + arg.shape.to_type() + )); + if !arg.desc.is_empty() { + description.push_str(&format!(" - {}", arg.desc)); + } + description.push('\n'); + } + description.push('\n'); + } + if !signature.named.is_empty() { + description.push_str("\n### Flags\n\n"); + let mut first = true; + for named in &signature.named { + if !first { + description.push_str("\\\n"); + } else { + first = false; + } + description.push_str(" "); + if let Some(short_flag) = &named.short { + description.push_str(&format!("`-{}`", short_flag)); + } + if !named.long.is_empty() { + if named.short.is_some() { + description.push_str(", ") + } + description.push_str(&format!("`--{}`", named.long)); + } + if let Some(arg) = &named.arg { + description.push_str(&format!(" `<{}>`", arg.to_type())) + } + if !named.desc.is_empty() { + description.push_str(&format!(" - {}", named.desc)); + } + } + description.push('\n'); + } + if !signature.input_output_types.is_empty() { + description.push_str("\n### Input/output\n"); + description.push_str("\n```\n"); + for input_output in &signature.input_output_types { + description + .push_str(&format!(" {} | {}\n", input_output.0, input_output.1)); + } + description.push_str("\n```\n"); + } + description.push_str(&format!( + "### Usage\n {}\n", + decl.usage().replace('\r', "") + )); + if !decl.extra_usage().is_empty() { + description + .push_str(&format!("\n### Extra usage:\n {}\n", decl.extra_usage())); + } + if !decl.examples().is_empty() { + description.push_str("### Example(s)\n```\n"); + for example in decl.examples() { + description.push_str(&format!( + "```\n {}\n```\n {}\n\n", + example.description, example.example + )); + } + } + + Some(Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: description, + }), + // TODO + range: None, + }) + } + Id::Value(shape) => { + let hover = String::from(match shape { + FlatShape::And => "and", + FlatShape::Binary => "binary", + FlatShape::Block => "block", + FlatShape::Bool => "bool", + FlatShape::Closure => "closure", + FlatShape::DateTime => "datetime", + FlatShape::Directory => "directory", + FlatShape::External => "external", + FlatShape::ExternalArg => "external arg", + FlatShape::Filepath => "file path", + FlatShape::Flag => "flag", + FlatShape::Float => "float", + FlatShape::GlobPattern => "glob pattern", + FlatShape::Int => "int", + FlatShape::Keyword => "keyword", + FlatShape::List => "list", + FlatShape::MatchPattern => "match-pattern", + FlatShape::Nothing => "nothing", + FlatShape::Range => "range", + FlatShape::Record => "record", + FlatShape::String => "string", + FlatShape::StringInterpolation => "string interpolation", + FlatShape::Table => "table", + _ => { + return None; + } + }); + + Some(Hover { + contents: HoverContents::Scalar(lsp_types::MarkedString::String(hover)), + // TODO + range: None, + }) + } + } + } + + fn complete( + engine_state: &mut EngineState, + params: &CompletionParams, + ) -> Option { + let cwd = std::env::current_dir().expect("Could not get current working directory."); + engine_state.add_env_var("PWD".into(), Value::test_string(cwd.to_string_lossy())); + + let file_path = params + .text_document_position + .text_document + .uri + .to_file_path() + .ok()?; + + let file_path = file_path.to_string_lossy(); + let rope_of_file = Rope::from_reader(File::open(file_path.as_ref()).ok()?).ok()?; + + let stack = Stack::new(); + let mut completer = NuCompleter::new(Arc::new(engine_state.clone()), stack); + + let location = + Self::lsp_position_to_location(¶ms.text_document_position.position, &rope_of_file); + let results = completer.complete(&rope_of_file.to_string(), location); + if results.is_empty() { + None + } else { + Some(CompletionResponse::Array( + results + .into_iter() + .map(|r| { + let mut start = params.text_document_position.position; + start.character -= (r.span.end - r.span.start) as u32; + + CompletionItem { + label: r.value.clone(), + detail: r.description, + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: Range { + start, + end: params.text_document_position.position, + }, + new_text: r.value, + })), + ..Default::default() + } + }) + .collect(), + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_json_diff::assert_json_eq; + use lsp_types::{ + notification::{Exit, Initialized, Notification}, + request::{Completion, GotoDefinition, HoverRequest, Initialize, Request, Shutdown}, + CompletionParams, GotoDefinitionParams, InitializeParams, InitializedParams, + TextDocumentIdentifier, TextDocumentPositionParams, Url, + }; + use nu_test_support::fs::{fixtures, root}; + use std::sync::mpsc::Receiver; + + fn initialize_language_server() -> (Connection, Receiver>) { + use std::sync::mpsc; + let (client_connection, server_connection) = Connection::memory(); + let lsp_server = LanguageServer::initialize_connection(server_connection, None).unwrap(); + + let (send, recv) = mpsc::channel(); + std::thread::spawn(move || { + let engine_state = nu_cmd_lang::create_default_context(); + let engine_state = nu_command::add_shell_command_context(engine_state); + send.send(lsp_server.serve_requests(engine_state)) + }); + + client_connection + .sender + .send(Message::Request(lsp_server::Request { + id: 1.into(), + method: Initialize::METHOD.to_string(), + params: serde_json::to_value(InitializeParams { + ..Default::default() + }) + .unwrap(), + })) + .unwrap(); + client_connection + .sender + .send(Message::Notification(lsp_server::Notification { + method: Initialized::METHOD.to_string(), + params: serde_json::to_value(InitializedParams {}).unwrap(), + })) + .unwrap(); + + let _initialize_response = client_connection + .receiver + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + + (client_connection, recv) + } + + #[test] + fn shutdown_on_request() { + let (client_connection, recv) = initialize_language_server(); + + client_connection + .sender + .send(Message::Request(lsp_server::Request { + id: 2.into(), + method: Shutdown::METHOD.to_string(), + params: serde_json::Value::Null, + })) + .unwrap(); + client_connection + .sender + .send(Message::Notification(lsp_server::Notification { + method: Exit::METHOD.to_string(), + params: serde_json::Value::Null, + })) + .unwrap(); + + assert!(recv + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap() + .is_ok()); + } + + #[test] + fn goto_definition_for_none_existing_file() { + let (client_connection, _recv) = initialize_language_server(); + + let mut none_existent_path = root(); + none_existent_path.push("none-existent.nu"); + + client_connection + .sender + .send(Message::Request(lsp_server::Request { + id: 2.into(), + method: GotoDefinition::METHOD.to_string(), + params: serde_json::to_value(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: Url::from_file_path(none_existent_path).unwrap(), + }, + position: lsp_types::Position { + line: 0, + character: 0, + }, + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }) + .unwrap(), + })) + .unwrap(); + + let resp = client_connection + .receiver + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + + assert!(matches!( + resp, + Message::Response(response) if response.result.is_none() + )); + } + + fn goto_definition(uri: Url, line: u32, character: u32) -> Message { + let (client_connection, _recv) = initialize_language_server(); + + client_connection + .sender + .send(Message::Request(lsp_server::Request { + id: 2.into(), + method: GotoDefinition::METHOD.to_string(), + params: serde_json::to_value(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: lsp_types::Position { line, character }, + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }) + .unwrap(), + })) + .unwrap(); + + client_connection + .receiver + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap() + } + + #[test] + fn goto_definition_of_variable() { + let mut script = fixtures(); + script.push("lsp"); + script.push("goto"); + script.push("var.nu"); + let script = Url::from_file_path(script).unwrap(); + + let resp = goto_definition(script.clone(), 2, 12); + let result = if let Message::Response(response) = resp { + response.result + } else { + panic!() + }; + + assert_json_eq!( + result, + serde_json::json!({ + "uri": script, + "range": { + "start": { "line": 0, "character": 4 }, + "end": { "line": 0, "character": 12 } + } + }) + ); + } + + #[test] + fn goto_definition_of_command() { + let mut script = fixtures(); + script.push("lsp"); + script.push("goto"); + script.push("command.nu"); + let script = Url::from_file_path(script).unwrap(); + + let resp = goto_definition(script.clone(), 4, 1); + let result = if let Message::Response(response) = resp { + response.result + } else { + panic!() + }; + + assert_json_eq!( + result, + serde_json::json!({ + "uri": script, + "range": { + "start": { "line": 0, "character": 17 }, + "end": { "line": 2, "character": 1 } + } + }) + ); + } + + #[test] + fn goto_definition_of_command_parameter() { + let mut script = fixtures(); + script.push("lsp"); + script.push("goto"); + script.push("command.nu"); + let script = Url::from_file_path(script).unwrap(); + + let resp = goto_definition(script.clone(), 1, 14); + let result = if let Message::Response(response) = resp { + response.result + } else { + panic!() + }; + + assert_json_eq!( + result, + serde_json::json!({ + "uri": script, + "range": { + "start": { "line": 0, "character": 11 }, + "end": { "line": 0, "character": 15 } + } + }) + ); + } + + fn hover(uri: Url, line: u32, character: u32) -> Message { + let (client_connection, _recv) = initialize_language_server(); + + client_connection + .sender + .send(Message::Request(lsp_server::Request { + id: 2.into(), + method: HoverRequest::METHOD.to_string(), + params: serde_json::to_value(HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: lsp_types::Position { line, character }, + }, + work_done_progress_params: Default::default(), + }) + .unwrap(), + })) + .unwrap(); + + client_connection + .receiver + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap() + } + + #[test] + fn hover_on_variable() { + let mut script = fixtures(); + script.push("lsp"); + script.push("hover"); + script.push("var.nu"); + let script = Url::from_file_path(script).unwrap(); + + let resp = hover(script.clone(), 2, 0); + let result = if let Message::Response(response) = resp { + response.result + } else { + panic!() + }; + + assert_json_eq!( + result, + serde_json::json!({ + "contents": "table" + }) + ); + } + + #[test] + fn hover_on_command() { + let mut script = fixtures(); + script.push("lsp"); + script.push("hover"); + script.push("command.nu"); + let script = Url::from_file_path(script).unwrap(); + + let resp = hover(script.clone(), 3, 0); + let result = if let Message::Response(response) = resp { + response.result + } else { + panic!() + }; + + assert_json_eq!( + result, + serde_json::json!({ + "contents": { + "kind": "markdown", + "value": "```\n### Signature\n```\n hello {flags}\n```\n\n### Flags\n\n `-h`, `--help` - Display the help message for this command\n### Usage\n Renders some greeting message\n" + } + }) + ); + } + + fn complete(uri: Url, line: u32, character: u32) -> Message { + let (client_connection, _recv) = initialize_language_server(); + + 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: lsp_types::Position { line, character }, + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: None, + }) + .unwrap(), + })) + .unwrap(); + + client_connection + .receiver + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap() + } + + #[test] + fn complete_on_variable() { + let mut script = fixtures(); + script.push("lsp"); + script.push("completion"); + script.push("var.nu"); + let script = Url::from_file_path(script).unwrap(); + + let resp = complete(script, 2, 9); + let result = if let Message::Response(response) = resp { + response.result + } else { + panic!() + }; + + assert_json_eq!( + result, + serde_json::json!([ + { + "label": "$greeting", + "textEdit": { + "newText": "$greeting", + "range": { + "start": { "character": 5, "line": 2 }, + "end": { "character": 9, "line": 2 } + } + } + } + ]) + ); + } + + #[test] + fn complete_command_with_space() { + let mut script = fixtures(); + script.push("lsp"); + script.push("completion"); + script.push("command.nu"); + let script = Url::from_file_path(script).unwrap(); + + let resp = complete(script, 0, 8); + let result = if let Message::Response(response) = resp { + response.result + } else { + panic!() + }; + + assert_json_eq!( + result, + 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" + } + } + ]) + ); + } +} diff --git a/src/command.rs b/src/command.rs index eeacfd9150..4edf4376fd 100644 --- a/src/command.rs +++ b/src/command.rs @@ -37,7 +37,7 @@ pub(crate) fn gather_commandline_args() -> (Vec, String, Vec) { #[cfg(feature = "plugin")] "--plugin-config" => args.next().map(|a| escape_quote_string(&a)), "--log-level" | "--log-target" | "--testbin" | "--threads" | "-t" - | "--include-path" | "--ide-goto-def" | "--ide-hover" | "--ide-complete" + | "--include-path" | "--lsp" | "--ide-goto-def" | "--ide-hover" | "--ide-complete" | "--ide-check" => args.next(), _ => None, }; @@ -108,6 +108,7 @@ pub(crate) fn parse_commandline_args( call.get_flag(engine_state, &mut stack, "table-mode")?; // ide flags + let lsp = call.has_flag("lsp"); let include_path: Option = call.get_flag_expr("include-path"); let ide_goto_def: Option = call.get_flag(engine_state, &mut stack, "ide-goto-def")?; @@ -193,6 +194,7 @@ pub(crate) fn parse_commandline_args( ide_goto_def, ide_hover, ide_complete, + lsp, ide_check, ide_ast, table_mode, @@ -229,6 +231,7 @@ pub(crate) struct NushellCliArgs { pub(crate) execute: Option>, pub(crate) table_mode: Option, pub(crate) include_path: Option>, + pub(crate) lsp: bool, pub(crate) ide_goto_def: Option, pub(crate) ide_hover: Option, pub(crate) ide_complete: Option, @@ -298,7 +301,12 @@ impl Command for Nu { "start with an alternate environment config file", None, ) - .named( + .switch( + "lsp", + "start nu's language server protocol", + None, + ) + .named( "ide-goto-def", SyntaxShape::Int, "go to the definition of the item at the given position", @@ -309,7 +317,7 @@ impl Command for Nu { SyntaxShape::Int, "give information about the item at the given position", None, - ) + ) .named( "ide-complete", SyntaxShape::Int, diff --git a/src/main.rs b/src/main.rs index ae9a1f3d11..ce0b4c024e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ use log::Level; use miette::Result; use nu_cli::gather_parent_env_vars; use nu_cmd_base::util::get_init_cwd; +use nu_lsp::LanguageServer; use nu_protocol::{ engine::EngineState, eval_const::create_nu_constant, report_error_new, util::BufferedReader, PipelineData, RawStream, Span, Value, NU_VARIABLE_ID, @@ -191,6 +192,10 @@ fn main() -> Result<()> { load_standard_library(&mut engine_state)?; } + if parsed_nu_cli_args.lsp { + return LanguageServer::initialize_stdio_connection()?.serve_requests(engine_state); + } + // IDE commands if let Some(ide_goto_def) = parsed_nu_cli_args.ide_goto_def { ide::goto_def(&mut engine_state, &script_name, &ide_goto_def); diff --git a/tests/fixtures/lsp/completion/command.nu b/tests/fixtures/lsp/completion/command.nu new file mode 100644 index 0000000000..9ed5909acf --- /dev/null +++ b/tests/fixtures/lsp/completion/command.nu @@ -0,0 +1 @@ +config n diff --git a/tests/fixtures/lsp/completion/var.nu b/tests/fixtures/lsp/completion/var.nu new file mode 100644 index 0000000000..de8f657235 --- /dev/null +++ b/tests/fixtures/lsp/completion/var.nu @@ -0,0 +1,3 @@ +let greeting = "Hello" + +echo $gre diff --git a/tests/fixtures/lsp/goto/command.nu b/tests/fixtures/lsp/goto/command.nu new file mode 100644 index 0000000000..798ae00db3 --- /dev/null +++ b/tests/fixtures/lsp/goto/command.nu @@ -0,0 +1,5 @@ +def greet [name] { + $"hello ($name)" +} + +greet nushell diff --git a/tests/fixtures/lsp/goto/var.nu b/tests/fixtures/lsp/goto/var.nu new file mode 100644 index 0000000000..56352285f5 --- /dev/null +++ b/tests/fixtures/lsp/goto/var.nu @@ -0,0 +1,3 @@ +let greeting = "hello" + +print $"($greeting) world!" diff --git a/tests/fixtures/lsp/hover/command.nu b/tests/fixtures/lsp/hover/command.nu new file mode 100644 index 0000000000..14a1196d00 --- /dev/null +++ b/tests/fixtures/lsp/hover/command.nu @@ -0,0 +1,4 @@ +# Renders some greeting message +def hello [] {} + +hello diff --git a/tests/fixtures/lsp/hover/var.nu b/tests/fixtures/lsp/hover/var.nu new file mode 100644 index 0000000000..1634be8b47 --- /dev/null +++ b/tests/fixtures/lsp/hover/var.nu @@ -0,0 +1,3 @@ +let my_var = (ls) + +$my_var