diff --git a/Cargo.lock b/Cargo.lock index 059abc3cc..8966286b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2160,6 +2160,7 @@ dependencies = [ "perl-position-tracking", "perl-semantic-analyzer", "perl-tdd-support", + "serde", "serde_json", ] diff --git a/crates/perl-lsp-inlay-hints/Cargo.toml b/crates/perl-lsp-inlay-hints/Cargo.toml index c6c15ee73..3c1e3fe8d 100644 --- a/crates/perl-lsp-inlay-hints/Cargo.toml +++ b/crates/perl-lsp-inlay-hints/Cargo.toml @@ -21,6 +21,7 @@ perl-semantic-analyzer = { workspace = true } perl-position-tracking = { workspace = true } perl-parser-core = { workspace = true } perl-builtins = { workspace = true } +serde = { version = "1", features = ["derive"] } serde_json = "1.0.149" [dev-dependencies] diff --git a/crates/perl-lsp-inlay-hints/src/inlay_hints.rs b/crates/perl-lsp-inlay-hints/src/inlay_hints.rs index 2098cf3ac..2c05a641b 100644 --- a/crates/perl-lsp-inlay-hints/src/inlay_hints.rs +++ b/crates/perl-lsp-inlay-hints/src/inlay_hints.rs @@ -47,6 +47,19 @@ pub struct InlayHint { pub padding_left: bool, /// Padding on the right pub padding_right: bool, + /// Optional tooltip (deferred to resolve) + pub tooltip: Option, + /// Optional source location for jump-to-definition from hint label + pub location: Option, +} + +/// Source location attached to a hint for label.location support (LSP 3.17). +#[derive(Debug, Clone)] +pub struct HintLocation { + /// Document URI + pub uri: String, + /// Byte range of the target symbol in the source document + pub range: (usize, usize), } /// Inlay hints provider. @@ -87,6 +100,7 @@ impl InlayHintsProvider { 2 => InlayHintKind::Parameter, _ => InlayHintKind::Type, }; + let tooltip = v.get("tooltip").and_then(|t| t.as_str()).map(|s| s.to_string()); Some(InlayHint { position: Position::new( pos["line"].as_u64()? as u32, @@ -96,6 +110,8 @@ impl InlayHintsProvider { kind, padding_left: v["paddingLeft"].as_bool().unwrap_or(false), padding_right: v["paddingRight"].as_bool().unwrap_or(false), + tooltip, + location: None, }) }) .collect() @@ -117,6 +133,7 @@ impl InlayHintsProvider { 2 => InlayHintKind::Parameter, _ => InlayHintKind::Type, }; + let tooltip = v.get("tooltip").and_then(|t| t.as_str()).map(|s| s.to_string()); Some(InlayHint { position: Position::new( pos["line"].as_u64()? as u32, @@ -126,6 +143,8 @@ impl InlayHintsProvider { kind, padding_left: v["paddingLeft"].as_bool().unwrap_or(false), padding_right: v["paddingRight"].as_bool().unwrap_or(false), + tooltip, + location: None, }) }) .collect() @@ -239,13 +258,27 @@ pub fn parameter_hints( } } - out.push(json!({ + // Phase 1: embed function name and param index in data for + // later label.location resolution via inlayHint/resolve. + let mut hint = json!({ "position": { "line": l, "character": c }, "label": format!("{}:", param_names[i]), "kind": 2, // parameter "paddingLeft": false, - "paddingRight": true - })); + "paddingRight": true, + "data": { + "functionName": name.as_str(), + "paramIndex": i, + } + }); + + // Phase 3: embed perldoc summary for tooltip resolution. + // The resolver will pick this up when the client requests it. + if let Some(doc) = builtin_doc_summary(name.as_str(), ¶m_names[i], i) { + hint["data"]["docSummary"] = json!(doc); + } + + out.push(hint); } } } @@ -276,16 +309,19 @@ pub fn trivial_type_hints( let mut out = Vec::new(); walk_ast(ast, &mut |node| { let type_hint = match &node.kind { - NodeKind::Number { .. } => Some("Num"), - NodeKind::String { .. } => Some("Str"), - NodeKind::HashLiteral { .. } => Some("Hash"), - NodeKind::ArrayLiteral { .. } => Some("Array"), - NodeKind::Regex { .. } => Some("Regex"), - NodeKind::Subroutine { name: None, .. } => Some("CodeRef"), - _ => None, + NodeKind::Number { .. } => Some(("Num".to_string(), Some("Numeric literal"))), + NodeKind::String { .. } => Some(("Str".to_string(), Some("String literal"))), + NodeKind::HashLiteral { .. } => Some(("Hash".to_string(), Some("Hash reference"))), + NodeKind::ArrayLiteral { .. } => Some(("Array".to_string(), Some("Array reference"))), + NodeKind::Regex { .. } => Some(("Regex".to_string(), Some("Regular expression"))), + NodeKind::Subroutine { name: None, .. } => { + Some(("CodeRef".to_string(), Some("Anonymous subroutine (code reference)"))) + } + // Fall through to semantic type inference for non-literal nodes + _ => infer_semantic_type(node).map(|t| (t, None)), }; - if let Some(hint) = type_hint { + if let Some((hint, tooltip)) = type_hint { let (l, c) = to_pos16(node.location.end); // Filter by range if specified @@ -296,19 +332,142 @@ pub fn trivial_type_hints( } } - out.push(json!({ + let mut val = json!({ "position": {"line": l, "character": c}, "label": format!(": {}", hint), "kind": 1, // type "paddingLeft": true, "paddingRight": false - })); + }); + + // Phase 3: embed tooltip text for deferred resolution + if let Some(tt) = tooltip { + val["data"] = json!({ "tooltip": tt }); + } + + out.push(val); } true }); out } +// --------------------------------------------------------------------------- +// Phase 2: Semantic type inference +// --------------------------------------------------------------------------- + +/// Infers a semantic type label for an expression node. +/// +/// Goes beyond trivial literal detection by examining context: +/// - Scalar variables assigned from known-return-type functions +/// - Array/hash from builtins like `keys`, `values`, `split` +/// - Blessed references from `new` / `bless` calls +/// - Filehandle operations +/// +/// Returns `None` when the type cannot be determined. +pub fn infer_semantic_type(node: &Node) -> Option { + match &node.kind { + NodeKind::FunctionCall { name, .. } => function_return_type(name), + NodeKind::MethodCall { method, .. } => method_return_type(method), + NodeKind::Variable { name, sigil } => { + // Infer from common naming conventions + match (sigil.as_str(), name.as_str()) { + ("$", _) if name.ends_with("_fh") || name.ends_with("_handle") => { + Some("FileHandle".to_string()) + } + ("$", _) if name.ends_with("_ref") => Some("Ref".to_string()), + ("@", _) if name.ends_with("_nums") => Some("@Nums".to_string()), + ("@", _) if name.ends_with("_strs") => Some("@Strs".to_string()), + ("@", _) if name.ends_with("_lines") => Some("@Lines".to_string()), + ("%", _) => Some("Hash".to_string()), + _ => None, + } + } + _ => None, + } +} + +/// Return type for known builtin functions. +fn function_return_type(name: &str) -> Option { + match name { + "open" => Some("Bool|FileHandle".to_string()), + "split" => Some("@Str".to_string()), + "join" => Some("Str".to_string()), + "keys" | "values" | "each" => Some("List".to_string()), + "map" | "grep" => Some("@List".to_string()), + "sort" => Some("@Sorted".to_string()), + "reverse" => Some("@List|Str".to_string()), + "scalar" => Some("Scalar".to_string()), + "ref" => Some("Str|Undef".to_string()), + "bless" => Some("Object".to_string()), + "stat" | "lstat" => Some("@Stat".to_string()), + "localtime" | "gmtime" => Some("@Time|Str".to_string()), + "caller" => Some("@Caller|Hash".to_string()), + "wantarray" => Some("Bool|Undef".to_string()), + "defined" => Some("Bool".to_string()), + "length" | "index" | "rindex" | "substr" => Some("Int".to_string()), + "abs" | "int" | "sqrt" | "exp" | "log" | "cos" | "sin" => Some("Num".to_string()), + "chr" => Some("Str".to_string()), + "ord" => Some("Int".to_string()), + "uc" | "lc" | "ucfirst" | "lcfirst" => Some("Str".to_string()), + "pack" => Some("Str".to_string()), + "unpack" => Some("@Mixed".to_string()), + _ => None, + } +} + +/// Return type for known method calls. +fn method_return_type(method: &str) -> Option { + match method { + "new" => Some("Object".to_string()), + "count" | "size" | "length" => Some("Int".to_string()), + "push" | "unshift" | "splice" => Some("Int".to_string()), + "pop" | "shift" => Some("Scalar".to_string()), + "keys" | "values" => Some("@List".to_string()), + "exists" | "defined" => Some("Bool".to_string()), + "delete" => Some("Scalar".to_string()), + "fetch" | "get" => Some("Scalar".to_string()), + "put" | "set" | "store" => Some("Undef".to_string()), + "find" | "search" => Some("@Results|Undef".to_string()), + "first" | "next" => Some("Scalar|Undef".to_string()), + "all" => Some("@All".to_string()), + "each" | "iterator" => Some("Iterator".to_string()), + "isa" => Some("Bool".to_string()), + "can" => Some("CodeRef|Undef".to_string()), + "clone" => Some("Object".to_string()), + "to_string" | "as_string" | "stringify" => Some("Str".to_string()), + "to_array" | "as_array" | "elements" => Some("@Array".to_string()), + "to_hash" | "as_hash" => Some("%Hash".to_string()), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Phase 3: Documentation integration +// --------------------------------------------------------------------------- + +/// Returns a short perldoc-style summary for a builtin function parameter. +/// +/// Looks up the builtin's documentation from `perl_builtins::builtin_signatures` +/// rather than maintaining a hardcoded list. Falls back to `None` for unknown +/// builtins or parameters. +fn builtin_doc_summary(function: &str, param: &str, _param_index: usize) -> Option { + let sigs = create_builtin_signatures(); + let builtin = sigs.get(function)?; + // Use the first signature variant to extract param names and match + // against the requested parameter. + if let Some(first_sig) = builtin.signatures.first() { + let param_names = extract_param_names(first_sig); + if param_names.contains(¶m.to_string()) { + // Return the builtin's documentation as the summary. + // The full doc covers the function; callers can truncate or + // format it as needed. + return Some(builtin.documentation.to_string()); + } + } + None +} + fn walk_ast(node: &Node, visitor: &mut F) -> bool where F: FnMut(&Node) -> bool, diff --git a/crates/perl-lsp/src/runtime/language/misc.rs b/crates/perl-lsp/src/runtime/language/misc.rs index e0807c317..a038a4980 100644 --- a/crates/perl-lsp/src/runtime/language/misc.rs +++ b/crates/perl-lsp/src/runtime/language/misc.rs @@ -34,6 +34,12 @@ impl LspServer { params: Option, ) -> Result, JsonRpcError> { use crate::protocol::req_range; + + // Return empty if client does not support inlay hints. + if !self.client_capabilities.lock().inlay_hint_support { + return Ok(Some(json!([]))); + } + let cap = inlay_hints_cap(); if let Some(p) = params { @@ -69,19 +75,18 @@ impl LspServer { range, )); - // Add data field to hints for later resolution - // This enables deferred tooltip computation + // Add URI to hint data for later resolution. + // Merge with any existing data (e.g. functionName/paramIndex from + // the hints provider) rather than overwriting it. let enriched_hints: Vec = hints .iter() .map(|hint| { let mut h = hint.clone(); if let Some(obj) = h.as_object_mut() { - obj.insert( - "data".to_string(), - json!({ - "uri": uri - }), - ); + let data = obj.entry("data".to_string()).or_insert_with(|| json!({})); + if let Some(data_obj) = data.as_object_mut() { + data_obj.insert("uri".to_string(), json!(uri)); + } } h }) @@ -122,33 +127,49 @@ impl LspServer { let label = hint.get("label").and_then(|l| l.as_str()).unwrap_or("").to_string(); let kind = hint.get("kind").and_then(|k| k.as_u64()).unwrap_or(0); - // Add tooltip if not already present + // Add tooltip if not already present. + // Prefer documentation summary from hint data (Phase 3); + // fall back to generic tooltip generation. if hint.get("tooltip").is_none() { - let tooltip = match kind { - 1 => { - // Type hint - if label.contains("Str") { - "String value".to_string() - } else if label.contains("Num") { - "Numeric value".to_string() - } else if label.contains("Array") || label.contains("ARRAY") { - "Array reference".to_string() - } else if label.contains("Hash") || label.contains("HASH") { - "Hash reference".to_string() - } else if label.contains("Regex") { - "Regular expression".to_string() - } else if label.contains("CodeRef") { - "Code reference (anonymous subroutine)".to_string() - } else { - "Type annotation".to_string() + let tooltip = hint + .pointer("/data/docSummary") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| { + // Check for deferred tooltip embedded in data + hint.pointer("/data/tooltip").and_then(|v| v.as_str()).map(String::from) + }) + .unwrap_or_else(|| match kind { + 1 => { + // Type hint + if label.contains("Str") { + "String value".to_string() + } else if label.contains("Num") { + "Numeric value".to_string() + } else if label.contains("Array") || label.contains("ARRAY") { + "Array reference".to_string() + } else if label.contains("Hash") || label.contains("HASH") { + "Hash reference".to_string() + } else if label.contains("Regex") { + "Regular expression".to_string() + } else if label.contains("CodeRef") { + "Code reference (anonymous subroutine)".to_string() + } else { + "Type annotation".to_string() + } } - } - 2 => { - let param_name = label.trim_end_matches(':').trim(); - format!("Parameter: {}", param_name) - } - _ => "Inlay hint".to_string(), - }; + 2 => { + let param_name = label.trim_end_matches(':').trim(); + // Include the function name in the tooltip when available + let func = hint + .pointer("/data/functionName") + .and_then(|v| v.as_str()) + .or_else(|| hint.pointer("/data/function").and_then(|v| v.as_str())) + .unwrap_or("unknown"); + format!("{}() — parameter: {}", func, param_name) + } + _ => "Inlay hint".to_string(), + }); if let Some(obj) = hint.as_object_mut() { obj.insert("tooltip".to_string(), json!(tooltip)); } @@ -192,13 +213,21 @@ impl LspServer { fn resolve_hint_label_location(&self, hint: &Value) -> Option { let data = hint.get("data")?; let uri = data.get("uri").and_then(|u| u.as_str())?; - let function_name = data.get("function").and_then(|f| f.as_str())?; + let function_name = data + .get("functionName") + .and_then(|f| f.as_str()) + .or_else(|| data.get("function").and_then(|f| f.as_str()))?; + let short_name = function_name.rsplit("::").next().unwrap_or(function_name); let documents = self.documents_guard(); let doc = self.get_document(&documents, uri)?; let ast = doc.ast.as_ref()?; - let sub_node = Self::find_subroutine_node(ast, function_name)?; + let sub_node = Self::find_subroutine_node(ast, function_name).or_else(|| { + (short_name != function_name) + .then(|| Self::find_subroutine_node(ast, short_name)) + .flatten() + })?; let (start_line, start_char) = self.offset_to_pos16(doc, sub_node.location.start); let (end_line, end_char) = self.offset_to_pos16(doc, sub_node.location.end); @@ -213,13 +242,18 @@ impl LspServer { /// Walk the AST to find a top-level subroutine node with the given name. fn find_subroutine_node<'a>(node: &'a Node, name: &str) -> Option<&'a Node> { - match &node.kind { - NodeKind::Subroutine { name: Some(sub_name), .. } if sub_name == name => Some(node), - NodeKind::Program { statements } | NodeKind::Block { statements } => { - statements.iter().find_map(|s| Self::find_subroutine_node(s, name)) - } - _ => None, + if matches!(&node.kind, NodeKind::Subroutine { name: Some(sub_name), .. } if sub_name == name) + { + return Some(node); } + + let mut found = None; + node.for_each_child(|child| { + if found.is_none() { + found = Self::find_subroutine_node(child, name); + } + }); + found } /// Handle textDocument/documentLink request @@ -1550,7 +1584,7 @@ mod tests { /// handle_inlay_hint_resolve must include labelDetails in the response for /// a parameter hint (kind=2) that has no function data to resolve. /// - /// In this test the hint has no `data.function` so `resolve_hint_label_location` + /// In this test the hint has no `data.functionName` so `resolve_hint_label_location` /// returns None — but the important thing is that the code path is entered /// (i.e. no labelDetails are injected when there is nothing to look up, and /// no panic occurs). diff --git a/crates/perl-lsp/src/runtime/lifecycle/capabilities.rs b/crates/perl-lsp/src/runtime/lifecycle/capabilities.rs index 9703ad452..39905bb7f 100644 --- a/crates/perl-lsp/src/runtime/lifecycle/capabilities.rs +++ b/crates/perl-lsp/src/runtime/lifecycle/capabilities.rs @@ -111,6 +111,12 @@ impl LspServer { .and_then(|v| v.as_bool()) .unwrap_or(false); + // textDocument/inlayHint + caps.inlay_hint_support = cap_val + .pointer("/textDocument/inlayHint/staticRegistration") + .or_else(|| cap_val.pointer("/textDocument/inlayHint")) + .is_some(); + // workspace/inlineValue/refresh caps.inline_value_refresh_support = cap_val .pointer("/workspace/inlineValue/refreshSupport") diff --git a/crates/perl-lsp/src/state/document.rs b/crates/perl-lsp/src/state/document.rs index 11d4cc00e..f4991a1eb 100644 --- a/crates/perl-lsp/src/state/document.rs +++ b/crates/perl-lsp/src/state/document.rs @@ -277,6 +277,8 @@ pub struct ClientCapabilities { pub semantic_tokens_refresh_support: bool, /// Supports workspace/inlayHint/refresh request pub inlay_hint_refresh_support: bool, + /// Client declared textDocument/inlayHint capability + pub inlay_hint_support: bool, /// Supports workspace/inlineValue/refresh request pub inline_value_refresh_support: bool, /// Supports workspace/diagnostic/refresh request