mirror of
https://github.com/nushell/nushell.git
synced 2025-06-02 16:16:01 +02:00
# Description This PR fixes one reported bug of recent lsp changes. It exit unexpectedly with empty `root_dir` settings in neovim. # User-Facing Changes # Tests + Formatting +1 test case # After Submitting
1188 lines
44 KiB
Rust
1188 lines
44 KiB
Rust
#![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<PathMember>),
|
|
}
|
|
|
|
pub struct LanguageServer {
|
|
connection: Connection,
|
|
io_threads: Option<IoThreads>,
|
|
docs: Arc<Mutex<TextDocuments>>,
|
|
initial_engine_state: EngineState,
|
|
symbol_cache: SymbolCache,
|
|
inlay_hints: BTreeMap<Uri, Vec<InlayHint>>,
|
|
workspace_folders: BTreeMap<String, WorkspaceFolder>,
|
|
/// for workspace wide requests
|
|
occurrences: BTreeMap<Uri, Vec<Range>>,
|
|
channels: Option<(
|
|
crossbeam_channel::Sender<bool>,
|
|
Arc<crossbeam_channel::Receiver<InternalMessage>>,
|
|
)>,
|
|
/// set to true when text changes
|
|
need_parse: bool,
|
|
/// cache `StateDelta` to avoid repeated parsing
|
|
cached_state_delta: Option<StateDelta>,
|
|
}
|
|
|
|
pub(crate) fn path_to_uri(path: impl AsRef<Path>) -> 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<Self> {
|
|
let (connection, io_threads) = Connection::stdio();
|
|
Self::initialize_connection(connection, Some(io_threads), engine_state)
|
|
}
|
|
|
|
fn initialize_connection(
|
|
connection: Connection,
|
|
io_threads: Option<IoThreads>,
|
|
engine_state: EngineState,
|
|
) -> Result<Self> {
|
|
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<bool> {
|
|
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<Block>, 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<Item = &'a CachedFile>,
|
|
span: &Span,
|
|
) -> Option<Location> {
|
|
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<P, H, R>(req: lsp_server::Request, mut param_handler: H) -> Response
|
|
where
|
|
P: serde::de::DeserializeOwned,
|
|
H: FnMut(&P) -> Option<R>,
|
|
R: serde::ser::Serialize,
|
|
{
|
|
match serde_json::from_value::<P>(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<Hover> {
|
|
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::<Vec<String>>()
|
|
.join("\n");
|
|
markdown_hover(description)
|
|
}
|
|
Id::Value(t) => markdown_hover(format!("`{}`", t)),
|
|
}
|
|
}
|
|
|
|
fn complete(&mut self, params: &CompletionParams) -> Option<CompletionResponse> {
|
|
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<SuggestionKind>,
|
|
) -> Option<CompletionItemKind> {
|
|
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<serde_json::Value>,
|
|
) -> (Connection, Receiver<Result<()>>) {
|
|
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<lsp_server::Notification, String> {
|
|
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<Range>,
|
|
) -> 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<any>\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<bar: int>\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} <separator?>\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<any> | 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
|
|
},
|
|
])
|
|
);
|
|
}
|
|
}
|