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
<!--
Don't forget to add tests that cover your changes.

Make sure you've run and fixed any issues with these commands:

- `cargo fmt --all -- --check` to check standard code formatting (`cargo
fmt --all` applies these changes)
- `cargo clippy --workspace -- -D warnings -D clippy::unwrap_used` to
check that you're using the standard code style
- `cargo test --workspace` to check that all tests pass (on Windows make
sure to [enable developer
mode](https://learn.microsoft.com/en-us/windows/apps/get-started/developer-mode-features-and-debugging))
- `cargo run -- -c "use std testing; testing run-tests --path
crates/nu-std"` to run the tests for the standard library

> **Note**
> from `nushell` you can also use the `toolkit` as follows
> ```bash
> use toolkit.nu # or use an `env_change` hook to activate it
automatically
> toolkit check pr
> ```
-->

# After Submitting
<!-- If your PR had any user-facing changes, update [the
documentation](https://github.com/nushell/nushell.github.io) after the
PR is merged, if necessary. This will help us keep the docs up to date.
-->
This commit is contained in:
Marc Schreiber 2023-11-02 16:18:57 +01:00 committed by GitHub
parent a46048f362
commit 0ca8fcf58c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 1102 additions and 3 deletions

75
Cargo.lock generated
View file

@ -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]]

View file

@ -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" }

29
crates/nu-lsp/Cargo.toml Normal file
View file

@ -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"

28
crates/nu-lsp/LICENSE Normal file
View file

@ -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.

933
crates/nu-lsp/src/lib.rs Normal file
View file

@ -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<IoThreads>,
}
impl LanguageServer {
pub fn initialize_stdio_connection() -> Result<Self> {
let (connection, io_threads) = Connection::stdio();
Self::initialize_connection(connection, Some(io_threads))
}
fn initialize_connection(
connection: Connection,
io_threads: Option<IoThreads>,
) -> Result<Self> {
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<P, H, R>(
&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>,
R: serde::ser::Serialize,
{
let resp = {
match serde_json::from_value::<P>(req.params) {
Ok(params) => Response {
id: req.id,
result: param_handler(engine_state, &params)
.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<u8>, 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<GotoDefinitionResponse> {
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(
&params.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<Hover> {
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(
&params.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<CompletionResponse> {
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(&params.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<Result<()>>) {
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"
}
}
])
);
}
}

View file

@ -37,7 +37,7 @@ pub(crate) fn gather_commandline_args() -> (Vec<String>, String, Vec<String>) {
#[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<Expression> = call.get_flag_expr("include-path");
let ide_goto_def: Option<Value> =
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<Spanned<String>>,
pub(crate) table_mode: Option<Value>,
pub(crate) include_path: Option<Spanned<String>>,
pub(crate) lsp: bool,
pub(crate) ide_goto_def: Option<Value>,
pub(crate) ide_hover: Option<Value>,
pub(crate) ide_complete: Option<Value>,
@ -298,6 +301,11 @@ impl Command for Nu {
"start with an alternate environment config file",
None,
)
.switch(
"lsp",
"start nu's language server protocol",
None,
)
.named(
"ide-goto-def",
SyntaxShape::Int,

View file

@ -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);

View file

@ -0,0 +1 @@
config n

3
tests/fixtures/lsp/completion/var.nu vendored Normal file
View file

@ -0,0 +1,3 @@
let greeting = "Hello"
echo $gre

5
tests/fixtures/lsp/goto/command.nu vendored Normal file
View file

@ -0,0 +1,5 @@
def greet [name] {
$"hello ($name)"
}
greet nushell

3
tests/fixtures/lsp/goto/var.nu vendored Normal file
View file

@ -0,0 +1,3 @@
let greeting = "hello"
print $"($greeting) world!"

4
tests/fixtures/lsp/hover/command.nu vendored Normal file
View file

@ -0,0 +1,4 @@
# Renders some greeting message
def hello [] {}
hello

3
tests/fixtures/lsp/hover/var.nu vendored Normal file
View file

@ -0,0 +1,3 @@
let my_var = (ls)
$my_var