Skip to main content

perl_lsp_inlay_hints/
inlay_hints.rs

1//! Inlay hints provider for Perl code.
2//!
3//! Provides inlay hints for function parameters and type annotations to improve
4//! code readability without modifying the source.
5//!
6//! # LSP Context
7//!
8//! Implements `textDocument/inlayHint` for the Parse → Analyze stages to surface
9//! inline annotations during language server rendering.
10//!
11//! # Client capability requirements
12//!
13//! Clients must advertise the inlay hint capability (`textDocument/inlayHint`)
14//! to receive hint payloads.
15//!
16//! # Protocol compliance
17//!
18//! Follows the inlay hint protocol for range-scoped responses and stable hint
19//! ordering per the LSP specification.
20
21use perl_builtins::builtin_signatures::create_builtin_signatures;
22use perl_parser_core::ast::{Node, NodeKind};
23use perl_position_tracking::{WirePosition as Position, WireRange as Range};
24use perl_semantic_analyzer::declaration::get_node_children;
25use serde_json::Value;
26use serde_json::json;
27
28/// Inlay hint kind.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum InlayHintKind {
31    /// Type hint
32    Type = 1,
33    /// Parameter hint
34    Parameter = 2,
35}
36
37/// Inlay hint.
38#[derive(Debug, Clone)]
39pub struct InlayHint {
40    /// Position of the hint
41    pub position: Position,
42    /// Label text
43    pub label: String,
44    /// Kind of hint
45    pub kind: InlayHintKind,
46    /// Padding on the left
47    pub padding_left: bool,
48    /// Padding on the right
49    pub padding_right: bool,
50    /// Optional tooltip (deferred to resolve)
51    pub tooltip: Option<String>,
52    /// Optional source location for jump-to-definition from hint label
53    pub location: Option<HintLocation>,
54}
55
56/// Source location attached to a hint for label.location support (LSP 3.17).
57#[derive(Debug, Clone)]
58pub struct HintLocation {
59    /// Document URI
60    pub uri: String,
61    /// Byte range of the target symbol in the source document
62    pub range: (usize, usize),
63}
64
65/// Inlay hints provider.
66pub struct InlayHintsProvider;
67
68impl InlayHintsProvider {
69    /// Create a new inlay hints provider.
70    pub fn new() -> Self {
71        Self
72    }
73
74    /// Generate inlay hints for the given AST.
75    pub fn generate_hints(
76        &self,
77        ast: &Node,
78        to_pos16: &impl Fn(usize) -> (u32, u32),
79        range: Option<Range>,
80    ) -> Vec<InlayHint> {
81        let mut hints = Vec::new();
82        hints.extend(self.parameter_hints(ast, to_pos16, range));
83        hints.extend(self.trivial_type_hints(ast, to_pos16, range));
84        hints
85    }
86
87    /// Generate parameter hints.
88    pub fn parameter_hints(
89        &self,
90        ast: &Node,
91        to_pos16: &impl Fn(usize) -> (u32, u32),
92        range: Option<Range>,
93    ) -> Vec<InlayHint> {
94        parameter_hints(ast, to_pos16, range)
95            .into_iter()
96            .filter_map(|v| {
97                let pos = v["position"].clone();
98                let label = v["label"].as_str()?.to_string();
99                let kind = match v["kind"].as_u64().unwrap_or(1) {
100                    2 => InlayHintKind::Parameter,
101                    _ => InlayHintKind::Type,
102                };
103                let tooltip = v.get("tooltip").and_then(|t| t.as_str()).map(|s| s.to_string());
104                Some(InlayHint {
105                    position: Position::new(
106                        pos["line"].as_u64()? as u32,
107                        pos["character"].as_u64()? as u32,
108                    ),
109                    label,
110                    kind,
111                    padding_left: v["paddingLeft"].as_bool().unwrap_or(false),
112                    padding_right: v["paddingRight"].as_bool().unwrap_or(false),
113                    tooltip,
114                    location: None,
115                })
116            })
117            .collect()
118    }
119
120    /// Generate trivial type hints.
121    pub fn trivial_type_hints(
122        &self,
123        ast: &Node,
124        to_pos16: &impl Fn(usize) -> (u32, u32),
125        range: Option<Range>,
126    ) -> Vec<InlayHint> {
127        trivial_type_hints(ast, to_pos16, range)
128            .into_iter()
129            .filter_map(|v| {
130                let pos = v["position"].clone();
131                let label = v["label"].as_str()?.to_string();
132                let kind = match v["kind"].as_u64().unwrap_or(1) {
133                    2 => InlayHintKind::Parameter,
134                    _ => InlayHintKind::Type,
135                };
136                let tooltip = v.get("tooltip").and_then(|t| t.as_str()).map(|s| s.to_string());
137                Some(InlayHint {
138                    position: Position::new(
139                        pos["line"].as_u64()? as u32,
140                        pos["character"].as_u64()? as u32,
141                    ),
142                    label,
143                    kind,
144                    padding_left: v["paddingLeft"].as_bool().unwrap_or(false),
145                    padding_right: v["paddingRight"].as_bool().unwrap_or(false),
146                    tooltip,
147                    location: None,
148                })
149            })
150            .collect()
151    }
152}
153
154impl Default for InlayHintsProvider {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160fn pos_in_range(pos: Position, range: Range) -> bool {
161    if pos.line < range.start.line || pos.line > range.end.line {
162        return false;
163    }
164    if pos.line == range.start.line && pos.character < range.start.character {
165        return false;
166    }
167    if pos.line == range.end.line && pos.character >= range.end.character {
168        return false;
169    }
170    true
171}
172
173/// Extracts parameter names from a builtin signature string.
174///
175/// Signature strings follow the Perl perldoc convention, e.g.:
176/// - `"open FILEHANDLE, MODE, FILENAME"` → `["filehandle", "mode", "filename"]`
177/// - `"push ARRAY, LIST"` → `["array", "list"]`
178/// - `"split /PATTERN/, EXPR, LIMIT"` → `["pattern", "expr", "limit"]`
179/// - `"map BLOCK LIST"` → `["block", "list"]`
180///
181/// The function name prefix is stripped, comma-separated groups are split,
182/// and within each group space-separated tokens are treated as individual
183/// parameters. Slash delimiters (e.g. `/PATTERN/`) are removed and all names
184/// are lowercased.
185pub fn extract_param_names(signature: &str) -> Vec<String> {
186    // Strip function name prefix (first word)
187    let rest = match signature.find(' ') {
188        Some(idx) => &signature[idx + 1..],
189        None => return Vec::new(),
190    };
191
192    let mut params = Vec::new();
193    // Split on ", " to get comma-separated groups
194    for group in rest.split(", ") {
195        // Within each group, split on space for space-separated params
196        for token in group.split(' ') {
197            if token.is_empty() {
198                continue;
199            }
200            // Strip slash delimiters from patterns like /PATTERN/
201            let cleaned = token.trim_matches('/');
202            params.push(cleaned.to_lowercase());
203        }
204    }
205    params
206}
207
208/// Generates inlay hints for function and method parameters.
209///
210/// This function traverses the AST and identifies function calls, adding inlay
211/// hints for parameter names based on the builtin signatures database from the
212/// `perl-builtins` crate. Any builtin with a known signature will produce
213/// parameter name hints for its arguments.
214///
215/// # Arguments
216///
217/// * `ast` - The root node of the AST to traverse.
218/// * `to_pos16` - A function that converts a byte offset to a (line, character) tuple.
219/// * `range` - An optional range to filter the inlay hints.
220///
221/// # Returns
222///
223/// A vector of `serde_json::Value` objects, each representing an inlay hint.
224pub fn parameter_hints(
225    ast: &Node,
226    to_pos16: &impl Fn(usize) -> (u32, u32),
227    range: Option<Range>,
228) -> Vec<Value> {
229    let sigs = create_builtin_signatures();
230    let mut out = Vec::new();
231    walk_ast(ast, &mut |node| {
232        if let NodeKind::FunctionCall { name, args } = &node.kind
233            && let Some(builtin) = sigs.get(name.as_str())
234        {
235            // Use the first (most complete) signature variant to extract
236            // parameter names, since it lists all possible parameters.
237            if let Some(first_sig) = builtin.signatures.first() {
238                let param_names = extract_param_names(first_sig);
239
240                // Skip functions with only a single parameter -- hints
241                // for e.g. `chomp($x)` showing `variable:` add noise
242                // rather than clarity.
243                if param_names.len() <= 1 {
244                    return true;
245                }
246
247                for (i, arg) in args.iter().enumerate() {
248                    if i >= param_names.len() {
249                        break;
250                    }
251                    let (l, c) = to_pos16(arg.location.start);
252
253                    // Filter by range if specified
254                    if let Some(filter_range) = range {
255                        let hint_pos = Position::new(l, c);
256                        if !pos_in_range(hint_pos, filter_range) {
257                            continue;
258                        }
259                    }
260
261                    // Phase 1: embed function name and param index in data for
262                    // later label.location resolution via inlayHint/resolve.
263                    let mut hint = json!({
264                        "position": { "line": l, "character": c },
265                        "label": format!("{}:", param_names[i]),
266                        "kind": 2, // parameter
267                        "paddingLeft": false,
268                        "paddingRight": true,
269                        "data": {
270                            "functionName": name.as_str(),
271                            "paramIndex": i,
272                        }
273                    });
274
275                    // Phase 3: embed perldoc summary for tooltip resolution.
276                    // The resolver will pick this up when the client requests it.
277                    if let Some(doc) = builtin_doc_summary(name.as_str(), &param_names[i], i) {
278                        hint["data"]["docSummary"] = json!(doc);
279                    }
280
281                    out.push(hint);
282                }
283            }
284        }
285        true
286    });
287    out
288}
289
290/// Generates inlay hints for trivial types.
291///
292/// This function traverses AST and adds inlay hints for literals such as
293/// numbers, strings, and code references.
294///
295/// # Arguments
296///
297/// * `ast` - The root node of the AST to traverse.
298/// * `to_pos16` - A function that converts a byte offset to a (line, character) tuple.
299/// * `range` - An optional range to filter the inlay hints.
300///
301/// # Returns
302///
303/// A vector of `serde_json::Value` objects, each representing an inlay hint.
304pub fn trivial_type_hints(
305    ast: &Node,
306    to_pos16: &impl Fn(usize) -> (u32, u32),
307    range: Option<Range>,
308) -> Vec<Value> {
309    let mut out = Vec::new();
310    walk_ast(ast, &mut |node| {
311        let type_hint = match &node.kind {
312            NodeKind::Number { .. } => Some(("Num".to_string(), Some("Numeric literal"))),
313            NodeKind::String { .. } => Some(("Str".to_string(), Some("String literal"))),
314            NodeKind::HashLiteral { .. } => Some(("Hash".to_string(), Some("Hash reference"))),
315            NodeKind::ArrayLiteral { .. } => Some(("Array".to_string(), Some("Array reference"))),
316            NodeKind::Regex { .. } => Some(("Regex".to_string(), Some("Regular expression"))),
317            NodeKind::Subroutine { name: None, .. } => {
318                Some(("CodeRef".to_string(), Some("Anonymous subroutine (code reference)")))
319            }
320            // Fall through to semantic type inference for non-literal nodes
321            _ => infer_semantic_type(node).map(|t| (t, None)),
322        };
323
324        if let Some((hint, tooltip)) = type_hint {
325            let (l, c) = to_pos16(node.location.end);
326
327            // Filter by range if specified
328            if let Some(filter_range) = range {
329                let hint_pos = Position::new(l, c);
330                if !pos_in_range(hint_pos, filter_range) {
331                    return true;
332                }
333            }
334
335            let mut val = json!({
336                "position": {"line": l, "character": c},
337                "label": format!(": {}", hint),
338                "kind": 1, // type
339                "paddingLeft": true,
340                "paddingRight": false
341            });
342
343            // Phase 3: embed tooltip text for deferred resolution
344            if let Some(tt) = tooltip {
345                val["data"] = json!({ "tooltip": tt });
346            }
347
348            out.push(val);
349        }
350        true
351    });
352    out
353}
354
355// ---------------------------------------------------------------------------
356// Phase 2: Semantic type inference
357// ---------------------------------------------------------------------------
358
359/// Infers a semantic type label for an expression node.
360///
361/// Goes beyond trivial literal detection by examining context:
362/// - Scalar variables assigned from known-return-type functions
363/// - Array/hash from builtins like `keys`, `values`, `split`
364/// - Blessed references from `new` / `bless` calls
365/// - Filehandle operations
366///
367/// Returns `None` when the type cannot be determined.
368pub fn infer_semantic_type(node: &Node) -> Option<String> {
369    match &node.kind {
370        NodeKind::FunctionCall { name, .. } => function_return_type(name),
371        NodeKind::MethodCall { method, .. } => method_return_type(method),
372        NodeKind::Variable { name, sigil } => {
373            // Infer from common naming conventions
374            match (sigil.as_str(), name.as_str()) {
375                ("$", _) if name.ends_with("_fh") || name.ends_with("_handle") => {
376                    Some("FileHandle".to_string())
377                }
378                ("$", _) if name.ends_with("_ref") => Some("Ref".to_string()),
379                ("@", _) if name.ends_with("_nums") => Some("@Nums".to_string()),
380                ("@", _) if name.ends_with("_strs") => Some("@Strs".to_string()),
381                ("@", _) if name.ends_with("_lines") => Some("@Lines".to_string()),
382                ("%", _) => Some("Hash".to_string()),
383                _ => None,
384            }
385        }
386        _ => None,
387    }
388}
389
390/// Return type for known builtin functions.
391fn function_return_type(name: &str) -> Option<String> {
392    match name {
393        "open" => Some("Bool|FileHandle".to_string()),
394        "split" => Some("@Str".to_string()),
395        "join" => Some("Str".to_string()),
396        "keys" | "values" | "each" => Some("List".to_string()),
397        "map" | "grep" => Some("@List".to_string()),
398        "sort" => Some("@Sorted".to_string()),
399        "reverse" => Some("@List|Str".to_string()),
400        "scalar" => Some("Scalar".to_string()),
401        "ref" => Some("Str|Undef".to_string()),
402        "bless" => Some("Object".to_string()),
403        "stat" | "lstat" => Some("@Stat".to_string()),
404        "localtime" | "gmtime" => Some("@Time|Str".to_string()),
405        "caller" => Some("@Caller|Hash".to_string()),
406        "wantarray" => Some("Bool|Undef".to_string()),
407        "defined" => Some("Bool".to_string()),
408        "length" | "index" | "rindex" | "substr" => Some("Int".to_string()),
409        "abs" | "int" | "sqrt" | "exp" | "log" | "cos" | "sin" => Some("Num".to_string()),
410        "chr" => Some("Str".to_string()),
411        "ord" => Some("Int".to_string()),
412        "uc" | "lc" | "ucfirst" | "lcfirst" => Some("Str".to_string()),
413        "pack" => Some("Str".to_string()),
414        "unpack" => Some("@Mixed".to_string()),
415        _ => None,
416    }
417}
418
419/// Return type for known method calls.
420fn method_return_type(method: &str) -> Option<String> {
421    match method {
422        "new" => Some("Object".to_string()),
423        "count" | "size" | "length" => Some("Int".to_string()),
424        "push" | "unshift" | "splice" => Some("Int".to_string()),
425        "pop" | "shift" => Some("Scalar".to_string()),
426        "keys" | "values" => Some("@List".to_string()),
427        "exists" | "defined" => Some("Bool".to_string()),
428        "delete" => Some("Scalar".to_string()),
429        "fetch" | "get" => Some("Scalar".to_string()),
430        "put" | "set" | "store" => Some("Undef".to_string()),
431        "find" | "search" => Some("@Results|Undef".to_string()),
432        "first" | "next" => Some("Scalar|Undef".to_string()),
433        "all" => Some("@All".to_string()),
434        "each" | "iterator" => Some("Iterator".to_string()),
435        "isa" => Some("Bool".to_string()),
436        "can" => Some("CodeRef|Undef".to_string()),
437        "clone" => Some("Object".to_string()),
438        "to_string" | "as_string" | "stringify" => Some("Str".to_string()),
439        "to_array" | "as_array" | "elements" => Some("@Array".to_string()),
440        "to_hash" | "as_hash" => Some("%Hash".to_string()),
441        _ => None,
442    }
443}
444
445// ---------------------------------------------------------------------------
446// Phase 3: Documentation integration
447// ---------------------------------------------------------------------------
448
449/// Returns a short perldoc-style summary for a builtin function parameter.
450///
451/// Looks up the builtin's documentation from `perl_builtins::builtin_signatures`
452/// rather than maintaining a hardcoded list. Falls back to `None` for unknown
453/// builtins or parameters.
454fn builtin_doc_summary(function: &str, param: &str, _param_index: usize) -> Option<String> {
455    let sigs = create_builtin_signatures();
456    let builtin = sigs.get(function)?;
457    // Use the first signature variant to extract param names and match
458    // against the requested parameter.
459    if let Some(first_sig) = builtin.signatures.first() {
460        let param_names = extract_param_names(first_sig);
461        if param_names.contains(&param.to_string()) {
462            // Return the builtin's documentation as the summary.
463            // The full doc covers the function; callers can truncate or
464            // format it as needed.
465            return Some(builtin.documentation.to_string());
466        }
467    }
468    None
469}
470
471fn walk_ast<F>(node: &Node, visitor: &mut F) -> bool
472where
473    F: FnMut(&Node) -> bool,
474{
475    if !visitor(node) {
476        return false;
477    }
478
479    for child in get_node_children(node) {
480        if !walk_ast(child, visitor) {
481            return false;
482        }
483    }
484
485    true
486}