nushell/crates/nu-lsp/src/signature.rs
zc he 41f4d0dcbc
refactor(lsp): align markdown doc string with output of --help (#15508)
#15499 reminds me of the discrepancies between lsp hover docs and
`--help` outputs.

# Description

# User-Facing Changes

Before:

<img width="610" alt="image"
src="https://github.com/user-attachments/assets/f73f7ace-5c1b-4380-9921-fb4783bdb187"
/>

After:

<img width="610" alt="image"
src="https://github.com/user-attachments/assets/96de3ffe-e37b-41b1-88bb-123eeb72ced2"
/>

Output of `if -h` as a reference:

```
Usage:
  > if <cond> <then_block> (else <else_expression>)

Flags:
  -h, --help: Display the help message for this command

Parameters:
  cond <variable>: Condition to check.
  then_block <block>: Block to run if check succeeds.
  "else" + <one_of(block, expression)>: Expression or block to run when the condition is false. (optional)

```

# Tests + Formatting

Refined

# After Submitting
2025-04-06 08:37:59 -05:00

408 lines
14 KiB
Rust

use lsp_types::{
Documentation, MarkupContent, MarkupKind, ParameterInformation, SignatureHelp,
SignatureHelpParams, SignatureInformation,
};
use nu_protocol::{
ast::{Argument, Call, Expr, Expression, FindMapResult, Traverse},
engine::StateWorkingSet,
Flag, PositionalArg, Signature, SyntaxShape, Value,
};
use crate::{uri_to_path, LanguageServer};
fn find_active_internal_call<'a>(
expr: &'a Expression,
working_set: &'a StateWorkingSet,
pos: usize,
) -> FindMapResult<&'a Call> {
if !expr.span.contains(pos) {
return FindMapResult::Stop;
}
let closure = |e| find_active_internal_call(e, working_set, pos);
match &expr.expr {
Expr::Call(call) => {
if call.head.contains(pos) {
return FindMapResult::Stop;
}
call.arguments
.iter()
.find_map(|arg| arg.expr().and_then(|e| e.find_map(working_set, &closure)))
.or(Some(call.as_ref()))
.map(FindMapResult::Found)
.unwrap_or_default()
}
_ => FindMapResult::Continue,
}
}
pub(crate) fn display_flag(flag: &Flag, verbitam: bool) -> String {
let md_backtick = if verbitam { "`" } else { "" };
let mut text = String::new();
if let Some(short_flag) = flag.short {
text.push_str(&format!("{md_backtick}-{short_flag}{md_backtick}"));
}
if !flag.long.is_empty() {
if flag.short.is_some() {
text.push_str(", ");
}
text.push_str(&format!("{md_backtick}--{}{md_backtick}", flag.long));
}
text
}
pub(crate) fn doc_for_arg(
syntax_shape: Option<SyntaxShape>,
desc: String,
default_value: Option<Value>,
optional: bool,
) -> String {
let mut text = String::new();
if let Some(mut shape) = syntax_shape {
if let SyntaxShape::Keyword(_, inner_shape) = shape {
shape = *inner_shape;
}
text.push_str(&format!(": `<{}>`", shape));
}
if !(desc.is_empty() && default_value.is_none()) || optional {
text.push_str(" -")
};
if !desc.is_empty() {
text.push_str(&format!(" {}", desc));
};
if let Some(value) = default_value.as_ref().and_then(|v| v.coerce_str().ok()) {
text.push_str(&format!(
" ({}default: `{value}`)",
if optional { "optional, " } else { "" }
));
} else if optional {
text.push_str(" (optional)");
}
text
}
pub(crate) fn get_signature_label(signature: &Signature, indent: bool) -> String {
let expand_keyword = |arg: &PositionalArg, optional: bool| match &arg.shape {
SyntaxShape::Keyword(kwd, _) => {
format!("{} <{}>", String::from_utf8_lossy(kwd), arg.name)
}
_ => {
if optional {
arg.name.clone()
} else {
format!("<{}>", arg.name)
}
}
};
let mut label = String::new();
if indent {
label.push_str(" ");
}
label.push_str(&signature.name);
if !signature.named.is_empty() {
label.push_str(" {flags}");
}
for required_arg in &signature.required_positional {
label.push_str(&format!(" {}", expand_keyword(required_arg, false)));
}
for optional_arg in &signature.optional_positional {
label.push_str(&format!(" ({})", expand_keyword(optional_arg, true)));
}
if let Some(arg) = &signature.rest_positional {
label.push_str(&format!(" ...({})", arg.name));
}
label
}
impl LanguageServer {
pub(crate) fn get_signature_help(
&mut self,
params: &SignatureHelpParams,
) -> Option<SignatureHelp> {
let path_uri = params
.text_document_position_params
.text_document
.uri
.to_owned();
let docs = self.docs.lock().ok()?;
let file = docs.get_document(&path_uri)?;
let location = file.offset_at(params.text_document_position_params.position) as usize;
let file_text = file.get_content(None).to_owned();
drop(docs);
let engine_state = self.new_engine_state(Some(&path_uri));
let mut working_set = StateWorkingSet::new(&engine_state);
// NOTE: in case the cursor is at the end of the call expression
let need_placeholder = location == 0
|| file_text
.get(location - 1..location)
.is_some_and(|s| s.chars().all(|c| c.is_whitespace()));
let file_path = uri_to_path(&path_uri);
let filename = if need_placeholder {
"lsp_signature_helper_temp_file"
} else {
file_path.to_str()?
};
let block = if need_placeholder {
nu_parser::parse(
&mut working_set,
Some(filename),
format!(
"{}a{}",
file_text.get(..location).unwrap_or_default(),
file_text.get(location..).unwrap_or_default()
)
.as_bytes(),
false,
)
} else {
nu_parser::parse(
&mut working_set,
Some(filename),
file_text.as_bytes(),
false,
)
};
let span = working_set.get_span_for_filename(filename)?;
let pos_to_search = location.saturating_add(span.start).saturating_sub(1);
let active_call = block.find_map(&working_set, &|expr: &Expression| {
find_active_internal_call(expr, &working_set, pos_to_search)
})?;
let active_signature = working_set.get_decl(active_call.decl_id).signature();
let label = get_signature_label(&active_signature, false);
let mut param_num_before_pos = 0;
for arg in active_call.arguments.iter() {
// skip flags
if matches!(arg, Argument::Named(_)) {
continue;
}
if arg.span().end <= pos_to_search {
param_num_before_pos += 1;
} else {
break;
}
}
let str_to_doc = |s: String| {
Some(Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value: s,
}))
};
let arg_to_param_info = |arg: PositionalArg, optional: bool| ParameterInformation {
label: lsp_types::ParameterLabel::Simple(arg.name),
documentation: str_to_doc(doc_for_arg(
Some(arg.shape),
arg.desc,
arg.default_value,
optional,
)),
};
let flag_to_param_info = |flag: Flag| ParameterInformation {
label: lsp_types::ParameterLabel::Simple(display_flag(&flag, false)),
documentation: str_to_doc(doc_for_arg(flag.arg, flag.desc, flag.default_value, false)),
};
// positional args
let mut parameters: Vec<ParameterInformation> = active_signature
.required_positional
.into_iter()
.map(|arg| arg_to_param_info(arg, false))
.chain(
active_signature
.optional_positional
.into_iter()
.map(|arg| arg_to_param_info(arg, true)),
)
.collect();
if let Some(rest_arg) = active_signature.rest_positional {
parameters.push(arg_to_param_info(rest_arg, false));
}
let max_idx = parameters.len().saturating_sub(1) as u32;
let active_parameter = Some(param_num_before_pos.min(max_idx));
// also include flags in the end, just for documentation
parameters.extend(active_signature.named.into_iter().map(flag_to_param_info));
Some(SignatureHelp {
signatures: vec![SignatureInformation {
label,
documentation: str_to_doc(active_signature.description),
parameters: Some(parameters),
active_parameter,
}],
active_signature: Some(0),
active_parameter,
})
}
}
#[cfg(test)]
mod tests {
use crate::path_to_uri;
use crate::tests::{initialize_language_server, open_unchecked, result_from_message};
use assert_json_diff::assert_json_include;
use lsp_server::{Connection, Message};
use lsp_types::{
request::{Request, SignatureHelpRequest},
TextDocumentIdentifier, Uri, WorkDoneProgressParams,
};
use lsp_types::{Position, SignatureHelpParams, TextDocumentPositionParams};
use nu_test_support::fs::fixtures;
fn send_signature_help_request(
client_connection: &Connection,
uri: Uri,
line: u32,
character: u32,
) -> Message {
client_connection
.sender
.send(Message::Request(lsp_server::Request {
id: 1.into(),
method: SignatureHelpRequest::METHOD.to_string(),
params: serde_json::to_value(SignatureHelpParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position { line, character },
},
work_done_progress_params: WorkDoneProgressParams::default(),
context: None,
})
.unwrap(),
}))
.unwrap();
client_connection
.receiver
.recv_timeout(std::time::Duration::from_secs(2))
.unwrap()
}
#[test]
fn signature_help_on_builtins() {
let (client_connection, _recv) = initialize_language_server(None, None);
let mut script = fixtures();
script.push("lsp");
script.push("hints");
script.push("signature.nu");
let script = path_to_uri(&script);
open_unchecked(&client_connection, script.clone());
let resp = send_signature_help_request(&client_connection, script.clone(), 0, 15);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!({
"signatures": [{
"label": "str substring {flags} <range> ...(rest)",
"parameters": [ ],
"activeParameter": 0
}],
"activeSignature": 0
})
);
let resp = send_signature_help_request(&client_connection, script.clone(), 0, 17);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!({ "signatures": [{ "activeParameter": 0 }], "activeSignature": 0 })
);
let resp = send_signature_help_request(&client_connection, script.clone(), 0, 18);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!({ "signatures": [{ "activeParameter": 1 }], "activeSignature": 0 })
);
let resp = send_signature_help_request(&client_connection, script.clone(), 0, 22);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!({ "signatures": [{ "activeParameter": 1 }], "activeSignature": 0 })
);
let resp = send_signature_help_request(&client_connection, script.clone(), 7, 0);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!({ "signatures": [{
"label": "str substring {flags} <range> ...(rest)",
"activeParameter": 1
}]})
);
let resp = send_signature_help_request(&client_connection, script.clone(), 4, 0);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!({ "signatures": [{
"label": "str substring {flags} <range> ...(rest)",
"activeParameter": 0
}]})
);
let resp = send_signature_help_request(&client_connection, script, 16, 6);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!({ "signatures": [{
"label": "echo {flags} ...(rest)",
"activeParameter": 0
}]})
);
}
#[test]
fn signature_help_on_custom_commands() {
let config_str = r#"export def "foo bar" [
p1: int
p2: string, # doc
p3?: int = 1
] {}"#;
let (client_connection, _recv) = initialize_language_server(Some(config_str), None);
let mut script = fixtures();
script.push("lsp");
script.push("hints");
script.push("signature.nu");
let script = path_to_uri(&script);
open_unchecked(&client_connection, script.clone());
let resp = send_signature_help_request(&client_connection, script.clone(), 9, 11);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!({
"signatures": [{
"label": "foo bar {flags} <p1> <p2> (p3)",
"parameters": [
{"label": "p1", "documentation": {"value": ": `<int>`"}},
{"label": "p2", "documentation": {"value": ": `<string>` - doc"}},
{"label": "p3", "documentation": {"value": ": `<int>` - (optional, default: `1`)"}},
],
"activeParameter": 1
}],
"activeSignature": 0,
"activeParameter": 1
})
);
let resp = send_signature_help_request(&client_connection, script, 10, 15);
assert_json_include!(
actual: result_from_message(resp),
expected: serde_json::json!({
"signatures": [{
"label": "foo baz {flags} <p1> <p2> (p3)",
"parameters": [
{"label": "p1", "documentation": {"value": ": `<int>`"}},
{"label": "p2", "documentation": {"value": ": `<string>` - doc"}},
{"label": "p3", "documentation": {"value": ": `<int>` - (optional, default: `1`)"}},
{"label": "-h, --help", "documentation": {"value": " - Display the help message for this command"}},
],
"activeParameter": 2
}],
"activeSignature": 0,
"activeParameter": 2
})
);
}
}