#![doc = include_str!("../README.md")] 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, ServerCapabilities, TextDocumentSyncKind, TextEdit, 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}, DeclId, ModuleId, Span, Type, VarId, }; use std::{ collections::BTreeMap, path::{Path, PathBuf}, str::FromStr, sync::Arc, sync::Mutex, time::Duration, }; use symbols::SymbolCache; use workspace::{InternalMessage, RangePerDoc}; mod ast; mod diagnostics; mod goto; mod hints; mod notification; mod symbols; mod workspace; #[derive(Debug, Clone, PartialEq)] pub(crate) enum Id { Variable(VarId), Declaration(DeclId), Value(Type), Module(ModuleId), CellPath(VarId, Vec), } pub struct LanguageServer { connection: Connection, io_threads: Option, docs: Arc>, initial_engine_state: EngineState, symbol_cache: SymbolCache, inlay_hints: BTreeMap>, workspace_folders: BTreeMap, /// for workspace wide requests occurrences: BTreeMap>, channels: Option<( crossbeam_channel::Sender, Arc>, )>, /// set to true when text changes need_parse: bool, /// cache `StateDelta` to avoid repeated parsing cached_state_delta: Option, } pub(crate) fn path_to_uri(path: impl AsRef) -> Uri { Uri::from_str( url::Url::from_file_path(path) .expect("Failed to convert path to Url") .as_str(), ) .expect("Failed to convert Url to lsp_types::Uri.") } pub(crate) fn uri_to_path(uri: &Uri) -> PathBuf { url::Url::from_str(uri.as_str()) .expect("Failed to convert Uri to Url") .to_file_path() .expect("Failed to convert Url to path") } pub(crate) fn span_to_range(span: &Span, file: &FullTextDocument, offset: usize) -> Range { let start = file.position_at(span.start.saturating_sub(offset) as u32); let end = file.position_at(span.end.saturating_sub(offset) as u32); Range { start, end } } impl LanguageServer { pub fn initialize_stdio_connection(engine_state: EngineState) -> Result { let (connection, io_threads) = Connection::stdio(); Self::initialize_connection(connection, Some(io_threads), engine_state) } fn initialize_connection( connection: Connection, io_threads: Option, engine_state: EngineState, ) -> Result { Ok(Self { connection, io_threads, docs: Arc::new(Mutex::new(TextDocuments::new())), initial_engine_state: engine_state, symbol_cache: SymbolCache::new(), inlay_hints: BTreeMap::new(), workspace_folders: BTreeMap::new(), occurrences: BTreeMap::new(), channels: None, need_parse: true, cached_state_delta: None, }) } pub fn serve_requests(mut self) -> Result<()> { let work_done_progress_options = WorkDoneProgressOptions { work_done_progress: Some(true), }; let server_capabilities = serde_json::to_value(ServerCapabilities { completion_provider: Some(lsp_types::CompletionOptions::default()), definition_provider: Some(OneOf::Left(true)), document_highlight_provider: Some(OneOf::Left(true)), document_symbol_provider: Some(OneOf::Left(true)), hover_provider: Some(lsp_types::HoverProviderCapability::Simple(true)), inlay_hint_provider: Some(OneOf::Left(true)), references_provider: Some(OneOf::Right(ReferencesOptions { work_done_progress_options, })), rename_provider: Some(OneOf::Right(RenameOptions { prepare_provider: Some(true), work_done_progress_options, })), text_document_sync: Some(lsp_types::TextDocumentSyncCapability::Kind( TextDocumentSyncKind::INCREMENTAL, )), workspace: Some(WorkspaceServerCapabilities { workspace_folders: Some(WorkspaceFoldersServerCapabilities { supported: Some(true), change_notifications: Some(OneOf::Left(true)), }), ..Default::default() }), workspace_symbol_provider: Some(OneOf::Left(true)), ..Default::default() }) .expect("Must be serializable"); let init_params = self .connection .initialize_while(server_capabilities, || { !self.initial_engine_state.signals().interrupted() }) .into_diagnostic()?; self.initialize_workspace_folders(init_params); while !self.initial_engine_state.signals().interrupted() { // first check new messages from child thread self.handle_internal_messages()?; let msg = match self .connection .receiver .recv_timeout(Duration::from_secs(1)) { Ok(msg) => { // cancel execution if other messages received before job done self.cancel_background_thread(); msg } Err(crossbeam_channel::RecvTimeoutError::Timeout) => { continue; } Err(_) => break, }; match msg { Message::Request(request) => { if self .connection .handle_shutdown(&request) .into_diagnostic()? { return Ok(()); } let resp = match request.method.as_str() { request::Completion::METHOD => { Self::handle_lsp_request(request, |params| self.complete(params)) } request::DocumentHighlightRequest::METHOD => { Self::handle_lsp_request(request, |params| { self.document_highlight(params) }) } request::GotoDefinition::METHOD => { Self::handle_lsp_request(request, |params| self.goto_definition(params)) } request::HoverRequest::METHOD => { Self::handle_lsp_request(request, |params| self.hover(params)) } request::InlayHintRequest::METHOD => { Self::handle_lsp_request(request, |params| self.get_inlay_hints(params)) } request::PrepareRenameRequest::METHOD => { let id = request.id.clone(); if let Err(e) = self.prepare_rename(request) { self.send_error_message(id, 2, e.to_string())? } continue; } request::References::METHOD => { Self::handle_lsp_request(request, |params| { self.references(params, 5000) }) } request::Rename::METHOD => { Self::handle_lsp_request(request, |params| self.rename(params)) } request::DocumentSymbolRequest::METHOD => { Self::handle_lsp_request(request, |params| self.document_symbol(params)) } request::WorkspaceSymbolRequest::METHOD => { Self::handle_lsp_request(request, |params| { self.workspace_symbol(params) }) } _ => { continue; } }; self.connection .sender .send(Message::Response(resp)) .into_diagnostic()?; } Message::Response(_) => {} Message::Notification(notification) => { if let Some(updated_file) = self.handle_lsp_notification(notification) { self.need_parse = true; self.symbol_cache.mark_dirty(updated_file.clone(), true); self.publish_diagnostics_for_file(updated_file)?; } } } } if let Some(io_threads) = self.io_threads { io_threads.join().into_diagnostic()?; } Ok(()) } /// Send a cancel message to a running bg thread pub(crate) fn cancel_background_thread(&mut self) { if let Some((sender, _)) = &self.channels { sender.send(true).ok(); } } /// Check results from background thread pub(crate) fn handle_internal_messages(&mut self) -> Result { let mut reset = false; if let Some((_, receiver)) = &self.channels { for im in receiver.try_iter() { match im { InternalMessage::RangeMessage(RangePerDoc { uri, ranges }) => { self.occurrences.insert(uri, ranges); } InternalMessage::OnGoing(token, progress) => { self.send_progress_report(token, progress, None)?; } InternalMessage::Finished(token) => { reset = true; self.send_progress_end(token, Some("Finished.".to_string()))?; } InternalMessage::Cancelled(token) => { reset = true; self.send_progress_end(token, Some("interrupted.".to_string()))?; } } } } if reset { self.channels = None; } Ok(reset) } pub(crate) fn new_engine_state(&self) -> EngineState { let mut engine_state = self.initial_engine_state.clone(); 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()), ); // merge the cached `StateDelta` if text not changed if !self.need_parse { engine_state .merge_delta( self.cached_state_delta .to_owned() .expect("Tried to merge a non-existing state delta"), ) .expect("Failed to merge state delta"); } engine_state } pub(crate) fn parse_and_find<'a>( &mut self, engine_state: &'a mut EngineState, uri: &Uri, pos: Position, ) -> Result<(StateWorkingSet<'a>, Id, Span, usize)> { let (block, file_span, working_set) = self .parse_file(engine_state, uri, false) .ok_or_else(|| miette!("\nFailed to parse current file"))?; let docs = match self.docs.lock() { Ok(it) => it, Err(err) => return Err(miette!(err.to_string())), }; let file = docs .get_document(uri) .ok_or_else(|| miette!("\nFailed to get document"))?; let location = file.offset_at(pos) as usize + file_span.start; let (id, span) = ast::find_id(&block, &working_set, &location) .ok_or_else(|| miette!("\nFailed to find current name"))?; Ok((working_set, id, span, file_span.start)) } pub(crate) fn parse_file<'a>( &mut self, engine_state: &'a mut EngineState, uri: &Uri, need_hints: bool, ) -> Option<(Arc, Span, StateWorkingSet<'a>)> { let mut working_set = StateWorkingSet::new(engine_state); let docs = self.docs.lock().ok()?; let file = docs.get_document(uri)?; let file_path = uri_to_path(uri); let file_path_str = file_path.to_str()?; let contents = file.get_content(None).as_bytes(); let _ = working_set.files.push(file_path.clone(), Span::unknown()); let block = nu_parser::parse(&mut working_set, Some(file_path_str), contents, false); let span = working_set.get_span_for_filename(file_path_str)?; if need_hints { let file_inlay_hints = Self::extract_inlay_hints(&working_set, &block, span.start, file); self.inlay_hints.insert(uri.clone(), file_inlay_hints); } 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; } 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, H: FnMut(&P) -> Option, R: serde::ser::Serialize, { match serde_json::from_value::

(req.params) { Ok(params) => Response { id: req.id, result: Some( param_handler(¶ms) .and_then(|response| serde_json::to_value(response).ok()) .unwrap_or(serde_json::Value::Null), ), error: None, }, Err(err) => Response { id: req.id, result: None, error: Some(ResponseError { code: 1, message: err.to_string(), data: None, }), }, } } fn get_decl_description(decl: &dyn Command, skip_description: bool) -> String { let mut description = String::new(); if !skip_description { // First description description.push_str(&format!("{}\n", decl.description().replace('\r', ""))); // Additional description if !decl.extra_description().is_empty() { description.push_str(&format!("\n{}\n", decl.extra_description())); } } // Usage description.push_str("---\n### Usage \n```nu\n"); 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"); // Flags if !signature.named.is_empty() { description.push_str("\n### Flags\n\n"); let mut first = true; for named in &signature.named { if first { first = false; } else { description.push('\n'); } 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'); } description.push('\n'); } // Parameters 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 { first = false; } else { description.push('\n'); } 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 { first = false; } else { description.push('\n'); } 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('\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'); } // Input/output types if !signature.input_output_types.is_empty() { description.push_str("\n### Input/output types\n"); description.push_str("\n```nu\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"); } // Examples if !decl.examples().is_empty() { description.push_str("### Example(s)\n"); for example in decl.examples() { description.push_str(&format!( " {}\n```nu\n {}\n```\n", example.description, example.example )); } } description } fn hover(&mut self, params: &HoverParams) -> Option { let mut engine_state = self.new_engine_state(); let path_uri = params .text_document_position_params .text_document .uri .to_owned(); let (working_set, id, _, _) = self .parse_and_find( &mut engine_state, &path_uri, params.text_document_position_params.position, ) .ok()?; let markdown_hover = |content: String| { Some(Hover { contents: HoverContents::Markup(MarkupContent { kind: MarkupKind::Markdown, value: content, }), // TODO range: None, }) }; match id { Id::Variable(var_id) => { let var = working_set.get_variable(var_id); let value = var .const_val .clone() .and_then(|v| v.coerce_into_string().ok()) .map(|s| format!("\n---\n{}", s)) .unwrap_or_default(); let contents = format!( "{} ```\n{}\n``` {}", if var.mutable { "mutable " } else { "" }, var.ty, value ); markdown_hover(contents) } Id::CellPath(var_id, cell_path) => { let var = working_set.get_variable(var_id); markdown_hover( var.const_val .clone() .and_then(|val| val.follow_cell_path(&cell_path, false).ok()) .map(|val| { let ty = val.get_type().clone(); let value_string = val .coerce_into_string() .ok() .map(|s| format!("\n---\n{}", s)) .unwrap_or_default(); format!("```\n{}\n```{}", ty, value_string) }) .unwrap_or("`unknown`".into()), ) } Id::Declaration(decl_id) => markdown_hover(Self::get_decl_description( working_set.get_decl(decl_id), false, )), Id::Module(module_id) => { let description = working_set .get_module_comments(module_id)? .iter() .map(|sp| String::from_utf8_lossy(working_set.get_span_contents(*sp)).into()) .collect::>() .join("\n"); markdown_hover(description) } Id::Value(t) => markdown_hover(format!("`{}`", t)), } } 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 => "".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), _ => None, }, SuggestionKind::Module => Some(CompletionItemKind::MODULE), }) } } #[cfg(test)] mod tests { use super::*; use assert_json_diff::{assert_json_eq, assert_json_include}; 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, }; use nu_test_support::fs::fixtures; use std::sync::mpsc::Receiver; use std::time::Duration; pub(crate) fn initialize_language_server( 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 lsp_server = LanguageServer::initialize_connection(server_connection, None, engine_state).unwrap(); let (send, recv) = mpsc::channel(); std::thread::spawn(move || send.send(lsp_server.serve_requests())); client_connection .sender .send(Message::Request(lsp_server::Request { id: 1.into(), method: Initialize::METHOD.to_string(), params: params.unwrap_or(serde_json::Value::Null), })) .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(Duration::from_secs(2)) .unwrap(); (client_connection, recv) } #[test] fn shutdown_on_request() { let (client_connection, recv) = initialize_language_server(None); 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(Duration::from_secs(2)).unwrap().is_ok()); } pub(crate) fn open_unchecked( client_connection: &Connection, uri: Uri, ) -> lsp_server::Notification { open(client_connection, uri).unwrap() } pub(crate) fn open( client_connection: &Connection, uri: Uri, ) -> Result { let text = std::fs::read_to_string(uri_to_path(&uri)).map_err(|e| e.to_string())?; client_connection .sender .send(Message::Notification(lsp_server::Notification { method: DidOpenTextDocument::METHOD.to_string(), params: serde_json::to_value(DidOpenTextDocumentParams { text_document: TextDocumentItem { uri, language_id: String::from("nu"), version: 1, text, }, }) .unwrap(), })) .map_err(|e| e.to_string())?; let notification = client_connection .receiver .recv_timeout(Duration::from_secs(2)) .map_err(|e| e.to_string())?; if let Message::Notification(n) = notification { Ok(n) } else { Err(String::from("Did not receive a notification from server")) } } pub(crate) fn update( client_connection: &Connection, uri: Uri, text: String, range: Option, ) -> lsp_server::Notification { client_connection .sender .send(lsp_server::Message::Notification( lsp_server::Notification { method: DidChangeTextDocument::METHOD.to_string(), params: serde_json::to_value(DidChangeTextDocumentParams { text_document: lsp_types::VersionedTextDocumentIdentifier { uri: uri.clone(), version: 2, }, content_changes: vec![TextDocumentContentChangeEvent { range, range_length: None, text, }], }) .unwrap(), }, )) .unwrap(); let notification = client_connection .receiver .recv_timeout(Duration::from_secs(2)) .unwrap(); if let Message::Notification(n) = notification { n } else { panic!(); } } pub(crate) fn result_from_message(message: lsp_server::Message) -> serde_json::Value { match message { Message::Response(Response { result, .. }) => result.expect("Empty result!"), _ => panic!("Unexpected message type!"), } } pub(crate) fn send_hover_request( client_connection: &Connection, uri: Uri, line: u32, character: u32, ) -> Message { 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: Position { line, character }, }, work_done_progress_params: WorkDoneProgressParams::default(), }) .unwrap(), })) .unwrap(); client_connection .receiver .recv_timeout(Duration::from_secs(2)) .unwrap() } #[test] fn hover_on_variable() { let (client_connection, _recv) = initialize_language_server(None); let mut script = fixtures(); script.push("lsp"); script.push("hover"); script.push("var.nu"); let script = path_to_uri(&script); open_unchecked(&client_connection, script.clone()); let resp = send_hover_request(&client_connection, script.clone(), 2, 0); assert_json_eq!( result_from_message(resp), serde_json::json!({ "contents": { "kind": "markdown", "value": " ```\ntable\n``` " } }) ); } #[test] fn hover_on_cell_path() { let (client_connection, _recv) = initialize_language_server(None); let mut script = fixtures(); script.push("lsp"); script.push("hover"); script.push("cell_path.nu"); let script = path_to_uri(&script); open_unchecked(&client_connection, script.clone()); let resp = send_hover_request(&client_connection, script.clone(), 4, 3); let result = result_from_message(resp); assert_json_eq!( result.pointer("/contents/value").unwrap(), serde_json::json!("```\nlist\n```") ); let resp = send_hover_request(&client_connection, script.clone(), 4, 7); let result = result_from_message(resp); assert_json_eq!( result.pointer("/contents/value").unwrap(), serde_json::json!("```\nrecord\n```") ); let resp = send_hover_request(&client_connection, script.clone(), 4, 11); let result = result_from_message(resp); assert_json_eq!( result.pointer("/contents/value").unwrap(), serde_json::json!("```\nint\n```\n---\n2") ); } #[test] fn hover_on_custom_command() { let (client_connection, _recv) = initialize_language_server(None); let mut script = fixtures(); script.push("lsp"); script.push("hover"); script.push("command.nu"); let script = path_to_uri(&script); open_unchecked(&client_connection, script.clone()); let resp = send_hover_request(&client_connection, script.clone(), 3, 0); assert_json_eq!( result_from_message(resp), serde_json::json!({ "contents": { "kind": "markdown", "value": "Renders some greeting message\n---\n### Usage \n```nu\n hello {flags}\n```\n\n### Flags\n\n `-h`, `--help` - Display the help message for this command\n\n" } }) ); } #[test] fn hover_on_str_join() { let (client_connection, _recv) = initialize_language_server(None); let mut script = fixtures(); script.push("lsp"); script.push("hover"); script.push("command.nu"); let script = path_to_uri(&script); open_unchecked(&client_connection, script.clone()); let resp = send_hover_request(&client_connection, script.clone(), 5, 8); assert_json_eq!( result_from_message(resp), serde_json::json!({ "contents": { "kind": "markdown", "value": "Concatenate multiple strings into a single string, with an optional separator between each.\n---\n### Usage \n```nu\n str join {flags} \n```\n\n### Flags\n\n `-h`, `--help` - Display the help message for this command\n\n\n### Parameters\n\n `separator: string` - Optional separator to use when creating string.\n\n\n### Input/output types\n\n```nu\n list | string\n string | string\n\n```\n### Example(s)\n Create a string from input\n```nu\n ['nu', 'shell'] | str join\n```\n Create a string from input with a separator\n```nu\n ['nu', 'shell'] | str join '-'\n```\n" } }) ); } #[test] fn hover_on_module() { let (client_connection, _recv) = initialize_language_server(None); let mut script = fixtures(); script.push("lsp"); script.push("goto"); script.push("module.nu"); let script = path_to_uri(&script); open_unchecked(&client_connection, script.clone()); let resp = send_hover_request(&client_connection, script.clone(), 3, 12); let result = result_from_message(resp); assert_eq!( result .pointer("/contents/value") .unwrap() .to_string() .replace("\\r", ""), "\"# 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 }, ]) ); } }