diff --git a/Cargo.toml b/Cargo.toml index f67e110..a44dc72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,5 +11,5 @@ lsp-textdocument = { git = "https://github.com/GiveMe-A-Name/lsp-textdocument.gi mktemp = "0.5" serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1.32.0", features = ["fs", "io-std", "macros", "process", "rt-multi-thread"] } +tokio = { version = "1.32.0", features = ["fs", "io-std", "macros", "process", "rt-multi-thread", "time"] } tower-lsp = "0.20.0" diff --git a/README.md b/README.md index 5fbaecd..59f9c63 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,10 @@ Language Server Protocol implementation for nushell - [x] [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) +- [x] [textDocument/inlayHint](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_inlayHint) -> `nu --ide-check` - [x] [textDocument/publishDiagnostics](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_publishDiagnostics) -> `nu --ide-check` - [x] [workspace/configuration](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_configuration) - [x] [workspace/didChangeConfiguration](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeConfiguration) -- [ ] [textDocument/inlayHint](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_inlayHint) -> `nu --ide-check` - [ ] raise a PR for `vscode-nushell-lang` to replace its wrapper/glue code with `nuls` ### stretch goals diff --git a/src/backend/language_server.rs b/src/backend/language_server.rs index 769ea57..d3b668a 100644 --- a/src/backend/language_server.rs +++ b/src/backend/language_server.rs @@ -105,12 +105,15 @@ impl LanguageServer for Backend { Ok(InitializeResult { capabilities: ServerCapabilities { - // `nu --ide-complete` completion_provider: Some(CompletionOptions::default()), - // `nu --ide-goto-def` definition_provider: Some(OneOf::Left(true)), - // `nu --ide-hover` hover_provider: Some(HoverProviderCapability::Simple(true)), + inlay_hint_provider: Some(OneOf::Right(InlayHintServerCapabilities::Options( + InlayHintOptions { + resolve_provider: Some(false), + ..Default::default() + }, + ))), // TODO: what do we do when the client doesn't support UTF-16 ? // lsp-textdocument crate requires UTF-16 position_encoding: Some(PositionEncodingKind::UTF16), @@ -286,4 +289,13 @@ impl LanguageServer for Backend { range, })) } + + async fn inlay_hint(&self, params: InlayHintParams) -> Result>> { + let document_inlay_hints = self.document_inlay_hints.read().map_err(|e| { + tower_lsp::jsonrpc::Error::invalid_params(format!( + "cannot read from inlay hints cache: {e:?}" + )) + })?; + Ok(document_inlay_hints.get(¶ms.text_document.uri).cloned()) + } } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 11e4ccc..deb7811 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -4,9 +4,10 @@ use std::time::{Duration, Instant}; use std::{ffi::OsStr, sync::RwLock}; pub(crate) mod language_server; +use crate::nu::{IdeCheckHint, IdeCheckResponse}; use crate::{ error::map_err_to_internal_error, - nu::{run_compiler, IdeCheck, IdeCheckDiagnostic, IdeSettings}, + nu::{run_compiler, IdeCheckDiagnostic, IdeSettings}, }; use lsp_textdocument::{FullTextDocument, TextDocuments}; @@ -25,6 +26,7 @@ pub(crate) struct Backend { can_publish_diagnostics: OnceLock, client: Client, documents: RwLock, + document_inlay_hints: RwLock>>, document_settings: RwLock>, global_settings: RwLock, last_validated: RwLock, @@ -112,6 +114,7 @@ impl Backend { can_publish_diagnostics: OnceLock::new(), client, documents: RwLock::new(TextDocuments::new()), + document_inlay_hints: RwLock::new(HashMap::new()), document_settings: RwLock::new(HashMap::new()), global_settings: RwLock::new(IdeSettings::default()), last_validated: RwLock::new(Instant::now()), @@ -227,23 +230,17 @@ impl Backend { let text = self.for_document(uri, &|doc| String::from(doc.get_content(None)))?; let ide_settings = self.get_document_settings(uri).await?; + let show_inferred_types = ide_settings.hints.show_inferred_types; let output = run_compiler(&text, vec![OsStr::new("--ide-check")], ide_settings, uri).await?; - let ide_checks: Vec = output - .stdout - .lines() - .filter_map(|l| serde_json::from_slice(l.as_bytes()).ok()) - .collect(); + let ide_checks = IdeCheckResponse::from_compiler_response(&output); let (diagnostics, version) = self.for_document(uri, &|doc| { ( ide_checks + .diagnostics .iter() - .filter_map(|c| match c { - IdeCheck::Diagnostic(d) => Some(d), - IdeCheck::Hint(_) => None, - }) .map(|d| IdeCheckDiagnostic::to_diagnostic(d, doc, uri)) .collect::>(), doc.version(), @@ -254,6 +251,21 @@ impl Backend { .publish_diagnostics(uri.clone(), diagnostics, Some(version)) .await; + if show_inferred_types { + let inlay_hints = self.for_document(uri, &|doc| { + ide_checks + .inlay_hints + .iter() + .map(|d| IdeCheckHint::to_inlay_hint(d, doc)) + .collect::>() + })?; + + let mut documents = self.document_inlay_hints.write().map_err(|e| { + map_err_to_internal_error(&e, format!("cannot write inlay hints cache: {e:?}")) + })?; + documents.insert(uri.clone(), inlay_hints); + } + Ok(()) } } diff --git a/src/nu.rs b/src/nu.rs index 248150e..ab703ac 100644 --- a/src/nu.rs +++ b/src/nu.rs @@ -2,9 +2,10 @@ use std::{ffi::OsStr, path::PathBuf, time::Duration}; use lsp_textdocument::FullTextDocument; use serde::Deserialize; -use tokio::fs; +use tokio::{fs, time::timeout}; use tower_lsp::lsp_types::{ - CompletionItem, CompletionItemKind, CompletionResponse, DiagnosticSeverity, Range, Url, + CompletionItem, CompletionItemKind, CompletionResponse, DiagnosticSeverity, InlayHint, + InlayHintKind, Range, Url, }; use tower_lsp::{jsonrpc::Result, lsp_types::Diagnostic}; @@ -17,7 +18,7 @@ pub(crate) enum IdeCheck { Hint(IdeCheckHint), } -#[derive(Debug, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq)] pub(crate) struct IdeCheckDiagnostic { pub message: String, pub severity: IdeDiagnosticSeverity, @@ -38,11 +39,63 @@ impl IdeCheckDiagnostic { } } -#[derive(Debug, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq)] pub(crate) struct IdeCheckHint { pub position: IdeSpan, pub typename: String, } +impl IdeCheckHint { + pub fn to_inlay_hint(&self, doc: &FullTextDocument) -> InlayHint { + InlayHint { + position: doc.position_at(self.position.end), + label: tower_lsp::lsp_types::InlayHintLabel::String(format!(": {}", &self.typename)), + kind: Some(InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + } + } +} + +#[derive(Debug, PartialEq)] +pub(crate) struct IdeCheckResponse { + pub diagnostics: Vec, + pub inlay_hints: Vec, +} +impl IdeCheckResponse { + pub fn from_compiler_response(value: &CompilerResponse) -> Self { + let ide_checks: Vec = value + .stdout + .lines() + .filter_map(|l| serde_json::from_slice(l.as_bytes()).ok()) + .collect(); + + let diagnostics = ide_checks + .iter() + .filter_map(|c| match c { + IdeCheck::Diagnostic(d) => Some(d), + IdeCheck::Hint(_) => None, + }) + .cloned() + .collect::>(); + + let inlay_hints = ide_checks + .iter() + .filter_map(|c| match c { + IdeCheck::Diagnostic(_) => None, + IdeCheck::Hint(h) => Some(h), + }) + .cloned() + .collect::>(); + + Self { + diagnostics, + inlay_hints, + } + } +} #[derive(Deserialize)] pub(crate) struct IdeComplete { @@ -82,7 +135,7 @@ impl From for CompletionResponse { } } -#[derive(Debug, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq)] pub(crate) enum IdeDiagnosticSeverity { Error, Warning, @@ -113,13 +166,12 @@ pub(crate) struct IdeHover { pub hover: String, pub span: Option, } -#[derive(Debug, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq)] pub(crate) struct IdeSpan { pub end: u32, pub start: u32, } -#[allow(dead_code)] #[derive(Clone, Debug, Deserialize)] #[serde(default, rename_all = "camelCase")] pub(crate) struct IdeSettings { @@ -141,7 +193,6 @@ impl Default for IdeSettings { } } -#[allow(dead_code)] #[derive(Clone, Debug, Deserialize)] #[serde(default, rename_all = "camelCase")] pub(crate) struct IdeSettingsHints { @@ -155,6 +206,7 @@ impl Default for IdeSettingsHints { } } +#[derive(Debug)] pub(crate) struct CompilerResponse { pub cmdline: String, pub stdout: String, @@ -167,8 +219,6 @@ pub(crate) async fn run_compiler( settings: IdeSettings, uri: &Url, ) -> Result { - // TODO: support allowErrors and label options like vscode-nushell-lang? - let max_number_of_problems = format!("{}", settings.max_number_of_problems); let max_number_of_problems_flag = OsStr::new(&max_number_of_problems); if flags.contains(&OsStr::new("--ide-check")) { @@ -210,16 +260,27 @@ pub(crate) async fn run_compiler( let cmdline = format!("nu {flags:?}"); - // TODO: honour max_nushell_invocation_time like vscode-nushell-lang - // TODO: call settings.nushell_executable_path - // TODO: call nushell Rust code directly instead of via separate process, // https://github.com/jokeyrhyme/nuls/issues/7 - let output = tokio::process::Command::new("nu") - .args(flags) - .output() - .await - .map_err(|e| map_err_to_internal_error(e, format!("`{cmdline}` failed")))?; + let output = timeout( + settings.max_nushell_invocation_time, + tokio::process::Command::new(settings.nushell_executable_path) + .args(flags) + .output(), + ) + .await + .map_err(|e| { + map_err_to_internal_error( + e, + format!( + "`{cmdline}` timeout, {:?} elapsed", + &settings.max_nushell_invocation_time + ), + ) + })? + .map_err(|e| map_err_to_internal_error(e, format!("`{cmdline}` failed")))?; + // intentionally skip checking the ExitStatus, we always want stdout regardless + let stdout = String::from_utf8(output.stdout).map_err(|e| { map_err_to_parse_error(e, format!("`{cmdline}` did not return valid UTF-8")) })?; @@ -285,7 +346,7 @@ mod tests { } #[tokio::test] - async fn completion_ok() { + async fn run_compiler_for_completion_ok() { let output = run_compiler( "wh", vec![OsStr::new("--ide-complete"), OsStr::new(&format!("{}", 2))], @@ -315,4 +376,44 @@ mod tests { unreachable!(); } } + + #[tokio::test] + async fn run_compiler_for_diagnostic_ok() { + let doc = FullTextDocument::new( + String::from("nushell"), + 1, + String::from( + " + let foo = ['one', 'two', 'three'] + ls || + ", + ), + ); + let uri = Url::parse("file:///foo.nu").expect("unable to parse test URL"); + let output = run_compiler( + doc.get_content(None), + vec![OsStr::new("--ide-check")], + IdeSettings::default(), + &uri, + ) + .await + .expect("unable to run `nu --ide-check ...`"); + + let got = IdeCheckResponse::from_compiler_response(&output); + + assert_eq!( + got, + IdeCheckResponse { + diagnostics: vec![IdeCheckDiagnostic { + message: String::from("The '||' operator is not supported in Nushell"), + severity: IdeDiagnosticSeverity::Error, + span: IdeSpan { end: 72, start: 70 } + }], + inlay_hints: vec![IdeCheckHint { + position: IdeSpan { end: 24, start: 21 }, + typename: String::from("list") + }], + } + ); + } }