Skip to main content

shape_lsp/
inlay_hints.rs

1//! Inlay hints provider for Shape
2//!
3//! Provides inline type hints for variables and parameter name hints for function calls.
4//! Uses the Visitor trait for exhaustive AST traversal.
5
6use std::collections::HashMap;
7
8use crate::type_inference::simplify_result_type;
9use crate::util::offset_to_position;
10use shape_ast::ast::expr_helpers::ComptimeForExpr;
11use shape_ast::ast::{
12    Expr, FunctionDef, Item, Program, Span, Spanned, Statement, TypeAnnotation, VariableDecl,
13};
14use shape_ast::parser::parse_program;
15use shape_runtime::visitor::{Visitor, walk_program};
16use tower_lsp_server::ls_types::{InlayHint, InlayHintKind, InlayHintLabel, Position, Range};
17
18use crate::type_inference::{
19    FunctionTypeInfo, ParamReferenceMode, infer_expr_type, infer_expr_type_via_engine,
20    infer_function_signatures, infer_program_types, infer_program_types_with_context,
21    infer_variable_type_for_display, unified_metadata,
22};
23
24/// Configuration for inlay hints
25pub struct InlayHintConfig {
26    pub show_type_hints: bool,
27    pub show_parameter_hints: bool,
28    /// Show `: type` hints after variable names in let/var/const without explicit annotations
29    pub show_variable_type_hints: bool,
30    /// Show `-> type` hints after function parameter lists without explicit return annotations
31    pub show_return_type_hints: bool,
32}
33
34impl Default for InlayHintConfig {
35    fn default() -> Self {
36        Self {
37            show_type_hints: true,
38            show_parameter_hints: true,
39            show_variable_type_hints: true,
40            show_return_type_hints: true,
41        }
42    }
43}
44
45/// Context for collecting inlay hints.
46/// Implements the Visitor trait for exhaustive AST traversal.
47struct HintContext<'a> {
48    text: &'a str,
49    program: &'a Program,
50    range: Range,
51    config: &'a InlayHintConfig,
52    hints: Vec<InlayHint>,
53    /// Program-level type map from TypeInferenceEngine (primary) + heuristic (fallback)
54    type_map: HashMap<String, String>,
55    /// Per-function inferred parameter and return types from TypeInferenceEngine
56    function_types: HashMap<String, FunctionTypeInfo>,
57}
58
59impl<'a> HintContext<'a> {
60    fn is_primitive_value_type_name(name: &str) -> bool {
61        let normalized = name.trim().trim_end_matches('?');
62        matches!(
63            normalized,
64            "int"
65                | "integer"
66                | "i64"
67                | "number"
68                | "float"
69                | "f64"
70                | "decimal"
71                | "bool"
72                | "boolean"
73                | "()"
74                | "void"
75                | "unit"
76                | "none"
77                | "null"
78                | "undefined"
79                | "never"
80        )
81    }
82
83    fn split_top_level_union(type_str: &str) -> Vec<String> {
84        let mut parts = Vec::new();
85        let mut start = 0usize;
86        let mut paren_depth = 0usize;
87        let mut bracket_depth = 0usize;
88        let mut brace_depth = 0usize;
89        let mut angle_depth = 0usize;
90
91        for (idx, ch) in type_str.char_indices() {
92            match ch {
93                '(' => paren_depth += 1,
94                ')' => paren_depth = paren_depth.saturating_sub(1),
95                '[' => bracket_depth += 1,
96                ']' => bracket_depth = bracket_depth.saturating_sub(1),
97                '{' => brace_depth += 1,
98                '}' => brace_depth = brace_depth.saturating_sub(1),
99                '<' => angle_depth += 1,
100                '>' => angle_depth = angle_depth.saturating_sub(1),
101                _ => {}
102            }
103            if ch == '|'
104                && paren_depth == 0
105                && bracket_depth == 0
106                && brace_depth == 0
107                && angle_depth == 0
108            {
109                parts.push(type_str[start..idx].trim().to_string());
110                start = idx + ch.len_utf8();
111            }
112        }
113
114        parts.push(type_str[start..].trim().to_string());
115        parts.into_iter().filter(|part| !part.is_empty()).collect()
116    }
117
118    fn apply_ref_prefix(type_str: &str, mode: &ParamReferenceMode) -> String {
119        let trimmed = type_str.trim();
120        if trimmed.starts_with('&') {
121            trimmed.to_string()
122        } else {
123            format!("{}{}", mode.prefix(), trimmed)
124        }
125    }
126
127    fn format_reference_aware_type(type_str: &str, mode: Option<&ParamReferenceMode>) -> String {
128        let Some(mode) = mode else {
129            return type_str.to_string();
130        };
131
132        let union_parts = Self::split_top_level_union(type_str);
133        if union_parts.len() <= 1 {
134            return Self::apply_ref_prefix(type_str, mode);
135        }
136
137        union_parts
138            .into_iter()
139            .map(|part| {
140                if Self::is_primitive_value_type_name(&part) {
141                    part
142                } else {
143                    Self::apply_ref_prefix(&part, mode)
144                }
145            })
146            .collect::<Vec<_>>()
147            .join(" | ")
148    }
149
150    fn new(
151        text: &'a str,
152        program: &'a Program,
153        range: Range,
154        config: &'a InlayHintConfig,
155        type_map: HashMap<String, String>,
156        function_types: HashMap<String, FunctionTypeInfo>,
157    ) -> Self {
158        Self {
159            text,
160            program,
161            range,
162            config,
163            hints: Vec::new(),
164            type_map,
165            function_types,
166        }
167    }
168
169    /// Collect type hint for a variable declaration without explicit type annotation.
170    /// Uses the program-level type_map (from TypeInferenceEngine) first,
171    /// falls back to heuristic infer_expr_type for unresolved variables.
172    fn collect_variable_type_hint(&mut self, decl: &VariableDecl) {
173        if !self.config.show_type_hints
174            || !self.config.show_variable_type_hints
175            || decl.type_annotation.is_some()
176        {
177            return;
178        }
179
180        // Try engine-inferred type from type_map first, fall back to heuristic
181        let var_name = decl.pattern.as_identifier();
182        let inferred_type = var_name
183            .and_then(|name| {
184                decl.pattern
185                    .as_identifier_span()
186                    .and_then(|span| {
187                        if span.is_dummy() {
188                            None
189                        } else {
190                            infer_variable_type_for_display(self.program, name, span.end)
191                        }
192                    })
193                    .or_else(|| self.type_map.get(name).cloned())
194            })
195            .or_else(|| {
196                decl.value
197                    .as_ref()
198                    .and_then(infer_expr_type_via_engine)
199                    .or_else(|| decl.value.as_ref().and_then(infer_expr_type))
200            });
201
202        if let Some(inferred_type) = inferred_type {
203            if let Some(span) = decl.pattern.as_identifier_span() {
204                if !span.is_dummy() {
205                    let position = offset_to_position(self.text, span.end);
206                    if is_in_range(position, self.range) {
207                        self.hints.push(InlayHint {
208                            position,
209                            label: InlayHintLabel::String(format!(": {}", inferred_type)),
210                            kind: Some(InlayHintKind::TYPE),
211                            text_edits: None,
212                            tooltip: None,
213                            padding_left: Some(false),
214                            padding_right: Some(true),
215                            data: None,
216                        });
217                    }
218                }
219            }
220        }
221    }
222
223    /// Collect type hints for function parameters and return type.
224    /// Uses types inferred by the TypeInferenceEngine — no manual AST walking.
225    fn collect_function_type_hints(&mut self, func_def: &FunctionDef) {
226        if !self.config.show_type_hints {
227            return;
228        }
229
230        let info = match self.function_types.get(&func_def.name) {
231            Some(info) => info.clone(),
232            None => return,
233        };
234
235        // Parameter type hints: show `: type` after each unannotated parameter name
236        for (param_name, type_str) in &info.param_types {
237            // Find the matching AST parameter to get its span
238            if let Some(ast_param) = func_def
239                .params
240                .iter()
241                .find(|p| p.simple_name() == Some(param_name.as_str()))
242            {
243                let span = ast_param.span();
244                if !span.is_dummy() {
245                    let display_type = Self::format_reference_aware_type(
246                        type_str,
247                        info.param_ref_modes.get(param_name),
248                    );
249                    let position = offset_to_position(self.text, span.end);
250                    if is_in_range(position, self.range) {
251                        self.hints.push(InlayHint {
252                            position,
253                            label: InlayHintLabel::String(format!(": {}", display_type)),
254                            kind: Some(InlayHintKind::TYPE),
255                            text_edits: None,
256                            tooltip: None,
257                            padding_left: Some(false),
258                            padding_right: Some(true),
259                            data: None,
260                        });
261                    }
262                }
263            }
264        }
265
266        // Return type hint: show `-> type` after the closing `)` of the parameter list
267        if let Some(return_type) = &info.return_type {
268            if !self.config.show_return_type_hints {
269                return;
270            }
271            if let Some(hint_offset) = self.return_hint_offset(func_def) {
272                let position = offset_to_position(self.text, hint_offset);
273                if is_in_range(position, self.range) {
274                    let display_type = simplify_result_type(return_type);
275                    self.hints.push(InlayHint {
276                        position,
277                        label: InlayHintLabel::String(format!("-> {}", display_type)),
278                        kind: Some(InlayHintKind::TYPE),
279                        text_edits: None,
280                        tooltip: None,
281                        padding_left: Some(true),
282                        padding_right: Some(true),
283                        data: None,
284                    });
285                }
286            }
287        }
288    }
289
290    /// Compute an AST-driven offset for function return inlay hints.
291    ///
292    /// We anchor after the closing `)` of the parameter list when possible.
293    /// Fallback to the end of the last parameter span if the header cannot be
294    /// recovered from source text.
295    fn return_hint_offset(&self, func_def: &FunctionDef) -> Option<usize> {
296        let text_len = self.text.len();
297        let header_start = func_def.name_span.end.min(text_len);
298        let header_tail = &self.text[header_start..];
299        if let Some(open_brace_rel) = header_tail.find('{') {
300            let header = &header_tail[..open_brace_rel];
301            if let Some(close_paren_rel) = header.rfind(')') {
302                return Some(header_start + close_paren_rel + 1);
303            }
304        }
305
306        let last_param_end = func_def
307            .params
308            .iter()
309            .filter_map(|param| {
310                let span = param.span();
311                if span.is_dummy() {
312                    None
313                } else {
314                    Some(span.end)
315                }
316            })
317            .max();
318
319        if let Some(end) = last_param_end {
320            return Some(end);
321        }
322
323        if !func_def.name_span.is_dummy() {
324            return Some(func_def.name_span.end);
325        }
326
327        None
328    }
329
330    /// Collect parameter name hints for a function call
331    /// Show an inlay hint for `comptime for` indicating unrolled iteration count.
332    ///
333    /// When the iterable is `target.fields` and we can resolve the struct type,
334    /// shows the number of fields that will be unrolled. Otherwise shows a generic
335    /// "comptime unrolled" indicator.
336    fn collect_comptime_for_hint(&mut self, comptime_for: &ComptimeForExpr, span: &Span) {
337        if span.is_dummy() {
338            return;
339        }
340
341        // Try to resolve iteration count from iterable
342        let hint_label =
343            if let Expr::PropertyAccess { property, .. } = comptime_for.iterable.as_ref() {
344                if property == "fields" {
345                    // The iterable is `something.fields` — we can try to count struct fields
346                    // In practice, this requires knowing what `target` refers to, which needs
347                    // annotation context. For now, show a generic hint.
348                    "comptime unrolled".to_string()
349                } else {
350                    "comptime unrolled".to_string()
351                }
352            } else {
353                "comptime unrolled".to_string()
354            };
355
356        let position = offset_to_position(self.text, span.end);
357        if is_in_range(position, self.range) {
358            self.hints.push(InlayHint {
359                position,
360                label: InlayHintLabel::String(hint_label),
361                kind: Some(InlayHintKind::TYPE),
362                text_edits: None,
363                tooltip: Some(tower_lsp_server::ls_types::InlayHintTooltip::String(
364                    "This loop is unrolled at compile time by the comptime system.".to_string(),
365                )),
366                padding_left: Some(true),
367                padding_right: Some(false),
368                data: None,
369            });
370        }
371    }
372
373    /// Collect parameter-style hints for table row literals.
374    /// Shows struct field names before each positional element in `[a, b, c], [d, e, f]`
375    /// when the variable has a `Table<T>` type annotation.
376    fn collect_table_row_hints(&mut self, decl: &VariableDecl) {
377        if !self.config.show_parameter_hints {
378            return;
379        }
380
381        // Check if the init expression is TableRows
382        let rows = match &decl.value {
383            Some(Expr::TableRows(rows, _)) => rows,
384            _ => return,
385        };
386
387        // Extract inner type name from Table<T> annotation
388        let inner_type = match &decl.type_annotation {
389            Some(TypeAnnotation::Generic { name, args }) if name == "Table" => args
390                .first()
391                .and_then(|a| a.as_simple_name())
392                .map(String::from),
393            _ => None,
394        };
395        let inner_type = match inner_type {
396            Some(t) => t,
397            None => return,
398        };
399
400        // Find struct field names from the program
401        let field_names: Vec<String> = self
402            .program
403            .items
404            .iter()
405            .find_map(|item| {
406                if let Item::StructType(struct_def, _) = item {
407                    if struct_def.name == inner_type {
408                        Some(
409                            struct_def
410                                .fields
411                                .iter()
412                                .filter(|f| !f.is_comptime)
413                                .map(|f| f.name.clone())
414                                .collect(),
415                        )
416                    } else {
417                        None
418                    }
419                } else {
420                    None
421                }
422            })
423            .unwrap_or_default();
424
425        if field_names.is_empty() {
426            return;
427        }
428
429        // Emit parameter hints for each element in each row
430        for row in rows {
431            for (i, elem) in row.iter().enumerate() {
432                if let Some(field_name) = field_names.get(i) {
433                    let elem_span = elem.span();
434                    if !elem_span.is_dummy() {
435                        let position = offset_to_position(self.text, elem_span.start);
436                        if is_in_range(position, self.range) {
437                            self.hints.push(InlayHint {
438                                position,
439                                label: InlayHintLabel::String(format!("{}:", field_name)),
440                                kind: Some(InlayHintKind::PARAMETER),
441                                text_edits: None,
442                                tooltip: None,
443                                padding_left: Some(false),
444                                padding_right: Some(true),
445                                data: None,
446                            });
447                        }
448                    }
449                }
450            }
451        }
452    }
453
454    fn collect_parameter_hints(&mut self, args: &[Expr], func_name: &str) {
455        if func_name == "print" {
456            return;
457        }
458
459        let func_info = unified_metadata().get_function(func_name);
460
461        if let Some(func) = func_info {
462            for (i, arg) in args.iter().enumerate() {
463                if let Some(param) = func.parameters.get(i) {
464                    // Don't show hints for single-letter parameter names
465                    if param.name.len() > 1 {
466                        // Use the argument's span to get the position before it
467                        let arg_span = arg.span();
468                        if !arg_span.is_dummy() {
469                            let position = offset_to_position(self.text, arg_span.start);
470                            if is_in_range(position, self.range) {
471                                self.hints.push(InlayHint {
472                                    position,
473                                    label: InlayHintLabel::String(format!("{}:", param.name)),
474                                    kind: Some(InlayHintKind::PARAMETER),
475                                    text_edits: None,
476                                    tooltip: Some(
477                                        tower_lsp_server::ls_types::InlayHintTooltip::String(
478                                            param.description.clone(),
479                                        ),
480                                    ),
481                                    padding_left: Some(false),
482                                    padding_right: Some(true),
483                                    data: None,
484                                });
485                            }
486                        }
487                    }
488                }
489            }
490        }
491    }
492}
493
494impl<'a> Visitor for HintContext<'a> {
495    fn visit_item(&mut self, item: &Item) -> bool {
496        match item {
497            Item::VariableDecl(decl, _) => {
498                self.collect_variable_type_hint(decl);
499                self.collect_table_row_hints(decl);
500            }
501            Item::Function(func_def, _) => self.collect_function_type_hints(func_def),
502            _ => {}
503        }
504        true // Continue visiting children
505    }
506
507    fn visit_stmt(&mut self, stmt: &Statement) -> bool {
508        // Handle variable declarations at the statement level
509        if let Statement::VariableDecl(decl, _) = stmt {
510            self.collect_variable_type_hint(decl);
511            self.collect_table_row_hints(decl);
512        }
513        true // Continue visiting children
514    }
515
516    fn visit_expr(&mut self, expr: &Expr) -> bool {
517        // Handle function calls for parameter hints
518        if let Expr::FunctionCall { name, args, .. } = expr {
519            if self.config.show_parameter_hints {
520                self.collect_parameter_hints(args, name);
521            }
522        }
523
524        // Handle comptime for — show unroll hint
525        if let Expr::ComptimeFor(comptime_for, span) = expr {
526            if self.config.show_type_hints {
527                self.collect_comptime_for_hint(comptime_for, span);
528            }
529        }
530
531        true // Continue visiting children
532    }
533}
534
535/// Get inlay hints for a document within a range.
536///
537/// Hint positions must always be derived from the current text buffer.
538/// On parse errors we use resilient parsing of the current text and return no
539/// hints only if nothing can be recovered.
540pub fn get_inlay_hints(
541    text: &str,
542    range: Range,
543    config: &InlayHintConfig,
544    _cached_program: Option<&Program>,
545) -> Vec<InlayHint> {
546    get_inlay_hints_with_context(text, range, config, _cached_program, None, None)
547}
548
549/// Get inlay hints with optional file/workspace context for extension-aware inference.
550pub fn get_inlay_hints_with_context(
551    text: &str,
552    range: Range,
553    config: &InlayHintConfig,
554    _cached_program: Option<&Program>,
555    current_file: Option<&std::path::Path>,
556    workspace_root: Option<&std::path::Path>,
557) -> Vec<InlayHint> {
558    // Parse the current document; never use cached AST spans for hint placement.
559    let program = match parse_program(text) {
560        Ok(p) => p,
561        Err(_) => {
562            let partial = shape_ast::parse_program_resilient(text);
563            if partial.items.is_empty() {
564                return Vec::new();
565            }
566            partial.into_program()
567        }
568    };
569
570    // Run type inference once for the whole program
571    let type_map = if current_file.is_none() && workspace_root.is_none() {
572        infer_program_types(&program)
573    } else {
574        infer_program_types_with_context(&program, current_file, workspace_root, Some(text))
575    };
576    let function_types = infer_function_signatures(&program);
577
578    let mut ctx = HintContext::new(text, &program, range, config, type_map, function_types);
579
580    // Use the Visitor trait for exhaustive AST traversal
581    walk_program(&mut ctx, &program);
582
583    // Collect comptime value hints for type aliases
584    if config.show_type_hints {
585        collect_comptime_alias_hints(text, &program, range, &mut ctx.hints);
586    }
587
588    ctx.hints
589}
590
591/// Collect inlay hints showing resolved comptime values on type alias definitions.
592///
593/// For `type EUR = Currency { symbol: "EUR" }`, shows the full resolved comptime values
594/// as an inlay hint after the alias name, including inherited defaults.
595fn collect_comptime_alias_hints(
596    text: &str,
597    program: &shape_ast::ast::Program,
598    range: Range,
599    hints: &mut Vec<InlayHint>,
600) {
601    use std::collections::HashMap;
602
603    // First pass: collect struct definitions with comptime fields
604    let mut struct_comptime: HashMap<String, Vec<(String, Option<String>)>> = HashMap::new();
605    for item in &program.items {
606        if let Item::StructType(struct_def, _) = item {
607            let comptime_fields: Vec<(String, Option<String>)> = struct_def
608                .fields
609                .iter()
610                .filter(|f| f.is_comptime)
611                .map(|f| {
612                    let default = f.default_value.as_ref().map(format_comptime_value);
613                    (f.name.clone(), default)
614                })
615                .collect();
616            if !comptime_fields.is_empty() {
617                struct_comptime.insert(struct_def.name.clone(), comptime_fields);
618            }
619        }
620    }
621
622    // Second pass: for each type alias with overrides, show resolved values
623    for item in &program.items {
624        if let Item::TypeAlias(alias_def, span) = item {
625            let base_type = match &alias_def.type_annotation {
626                shape_ast::ast::TypeAnnotation::Basic(name) => name.clone(),
627                _ => continue,
628            };
629
630            let comptime_fields = match struct_comptime.get(&base_type) {
631                Some(fields) => fields,
632                None => continue,
633            };
634
635            // Build the resolved values: override > default
636            let resolved: Vec<String> = comptime_fields
637                .iter()
638                .filter_map(|(name, default)| {
639                    let value = alias_def
640                        .meta_param_overrides
641                        .as_ref()
642                        .and_then(|o| o.get(name))
643                        .map(format_comptime_value)
644                        .or_else(|| default.clone());
645                    value.map(|v| format!("{} = {}", name, v))
646                })
647                .collect();
648
649            if resolved.is_empty() {
650                continue;
651            }
652
653            let hint_offset = span.end;
654            let position = offset_to_position(text, hint_offset);
655            if is_in_range(position, range) {
656                hints.push(InlayHint {
657                    position,
658                    label: InlayHintLabel::String(format!(" [{}]", resolved.join(", "))),
659                    kind: Some(InlayHintKind::TYPE),
660                    text_edits: None,
661                    tooltip: Some(tower_lsp_server::ls_types::InlayHintTooltip::String(
662                        format!("Resolved comptime values from {}", base_type),
663                    )),
664                    padding_left: Some(false),
665                    padding_right: Some(true),
666                    data: None,
667                });
668            }
669        }
670    }
671}
672
673/// Format a comptime expression value for display
674fn format_comptime_value(expr: &Expr) -> String {
675    match expr {
676        Expr::Literal(lit, _) => match lit {
677            shape_ast::ast::Literal::String(s) => format!("\"{}\"", s),
678            shape_ast::ast::Literal::Number(n) => format!("{}", n),
679            shape_ast::ast::Literal::Int(n) => format!("{}", n),
680            shape_ast::ast::Literal::Decimal(d) => format!("{}D", d),
681            shape_ast::ast::Literal::Bool(b) => format!("{}", b),
682            shape_ast::ast::Literal::None => "None".to_string(),
683            _ => "...".to_string(),
684        },
685        _ => "...".to_string(),
686    }
687}
688
689/// Check if a position is within a range
690fn is_in_range(pos: Position, range: Range) -> bool {
691    if pos.line < range.start.line || pos.line > range.end.line {
692        return false;
693    }
694    if pos.line == range.start.line && pos.character < range.start.character {
695        return false;
696    }
697    if pos.line == range.end.line && pos.character > range.end.character {
698        return false;
699    }
700    true
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use crate::type_inference::infer_literal_type;
707    use shape_ast::ast::Literal;
708
709    #[test]
710    fn test_infer_literal_type() {
711        assert_eq!(infer_literal_type(&Literal::Number(42.0)), "number");
712        assert_eq!(
713            infer_literal_type(&Literal::String("hello".to_string())),
714            "string"
715        );
716        assert_eq!(infer_literal_type(&Literal::Bool(true)), "bool");
717        assert_eq!(infer_literal_type(&Literal::None), "Option");
718    }
719
720    #[test]
721    fn test_infer_literal_type_int() {
722        assert_eq!(infer_literal_type(&Literal::Int(42)), "int");
723    }
724
725    #[test]
726    fn test_infer_literal_type_decimal() {
727        use rust_decimal::Decimal;
728        assert_eq!(
729            infer_literal_type(&Literal::Decimal(Decimal::new(1050, 2))),
730            "decimal"
731        );
732    }
733
734    #[test]
735    fn test_numeric_type_hints_int() {
736        let config = InlayHintConfig::default();
737        let range = Range {
738            start: Position {
739                line: 0,
740                character: 0,
741            },
742            end: Position {
743                line: 10,
744                character: 100,
745            },
746        };
747
748        let hints = get_inlay_hints("let i = 10", range, &config, None);
749        assert!(!hints.is_empty(), "Expected at least one hint for integer");
750        let label = match &hints[0].label {
751            InlayHintLabel::String(s) => s.clone(),
752            _ => panic!("Expected string label"),
753        };
754        assert!(
755            label.contains("int"),
756            "Expected 'int' in hint, got: {}",
757            label
758        );
759    }
760
761    #[test]
762    fn test_numeric_type_hints_decimal() {
763        let config = InlayHintConfig::default();
764        let range = Range {
765            start: Position {
766                line: 0,
767                character: 0,
768            },
769            end: Position {
770                line: 10,
771                character: 100,
772            },
773        };
774
775        let hints = get_inlay_hints("let d = 10D", range, &config, None);
776        assert!(!hints.is_empty(), "Expected at least one hint for decimal");
777        let label = match &hints[0].label {
778            InlayHintLabel::String(s) => s.clone(),
779            _ => panic!("Expected string label"),
780        };
781        assert!(
782            label.contains("decimal"),
783            "Expected 'decimal' in hint, got: {}",
784            label
785        );
786    }
787
788    #[test]
789    fn test_numeric_type_hints_number() {
790        let config = InlayHintConfig::default();
791        let range = Range {
792            start: Position {
793                line: 0,
794                character: 0,
795            },
796            end: Position {
797                line: 10,
798                character: 100,
799            },
800        };
801
802        let hints = get_inlay_hints("let f = 10.0", range, &config, None);
803        assert!(!hints.is_empty(), "Expected at least one hint for float");
804        let label = match &hints[0].label {
805            InlayHintLabel::String(s) => s.clone(),
806            _ => panic!("Expected string label"),
807        };
808        assert!(
809            label.contains("number"),
810            "Expected 'number' in hint, got: {}",
811            label
812        );
813    }
814
815    #[test]
816    fn test_offset_to_position() {
817        let text = "let x = 42;\nlet y = 10;";
818        let pos = offset_to_position(text, 0);
819        assert_eq!(pos.line, 0);
820        assert_eq!(pos.character, 0);
821
822        let pos = offset_to_position(text, 12);
823        assert_eq!(pos.line, 1);
824        assert_eq!(pos.character, 0);
825    }
826
827    #[test]
828    fn test_match_expression_type_hint() {
829        let config = InlayHintConfig::default();
830        let range = Range {
831            start: Position {
832                line: 0,
833                character: 0,
834            },
835            end: Position {
836                line: 10,
837                character: 100,
838            },
839        };
840
841        let code = "let test = match 2 {\n  0 => true,\n  _ => false,\n}";
842        let hints = get_inlay_hints(code, range, &config, None);
843        eprintln!(
844            "Hints for match: {:?}",
845            hints
846                .iter()
847                .map(|h| match &h.label {
848                    InlayHintLabel::String(s) => s.clone(),
849                    _ => "non-string".to_string(),
850                })
851                .collect::<Vec<_>>()
852        );
853        let type_hints: Vec<_> = hints
854            .iter()
855            .filter(|h| h.kind == Some(InlayHintKind::TYPE))
856            .collect();
857        assert!(
858            !type_hints.is_empty(),
859            "Expected a type hint for 'let test = match ...'"
860        );
861        let label = match &type_hints[0].label {
862            InlayHintLabel::String(s) => s.clone(),
863            _ => panic!("Expected string label"),
864        };
865        assert!(
866            label.contains("bool"),
867            "Expected 'bool' in hint, got: {}",
868            label
869        );
870    }
871
872    #[test]
873    fn test_infer_try_operator_type() {
874        use shape_ast::ast::Span;
875
876        let expr = Expr::TryOperator(
877            Box::new(Expr::FunctionCall {
878                name: "some_func".to_string(),
879                args: vec![],
880                named_args: vec![],
881                span: Span::DUMMY,
882            }),
883            Span::DUMMY,
884        );
885        let _ = infer_expr_type(&expr);
886    }
887
888    fn full_range() -> Range {
889        Range {
890            start: Position {
891                line: 0,
892                character: 0,
893            },
894            end: Position {
895                line: 100,
896                character: 100,
897            },
898        }
899    }
900
901    fn type_hint_labels(hints: &[InlayHint]) -> Vec<String> {
902        hints
903            .iter()
904            .filter(|h| h.kind == Some(InlayHintKind::TYPE))
905            .map(|h| match &h.label {
906                InlayHintLabel::String(s) => s.clone(),
907                _ => "non-string".to_string(),
908            })
909            .collect()
910    }
911
912    #[test]
913    fn test_function_return_type_hint() {
914        let code = "fn add(a: int, b: int) {\n  return a + b\n}";
915        let config = InlayHintConfig::default();
916        let hints = get_inlay_hints(code, full_range(), &config, None);
917        let labels = type_hint_labels(&hints);
918        eprintln!("Return type hints: {:?}", labels);
919        // Engine should infer a return type from the body
920        let has_return_hint = labels.iter().any(|l| l.starts_with("->"));
921        assert!(
922            has_return_hint,
923            "Expected a return type hint for fn without return annotation, got: {:?}",
924            labels
925        );
926    }
927
928    #[test]
929    fn test_function_return_hint_for_empty_params_anchors_after_close_paren() {
930        let code = "fn test() {\n}\n";
931        let config = InlayHintConfig::default();
932        let hints = get_inlay_hints(code, full_range(), &config, None);
933
934        let return_hint = hints
935            .iter()
936            .find(|hint| match &hint.label {
937                InlayHintLabel::String(label) => label.starts_with("->"),
938                _ => false,
939            })
940            .expect("expected return type hint");
941
942        let expected_col = code
943            .lines()
944            .next()
945            .and_then(|line| line.find(')'))
946            .map(|idx| idx as u32 + 1)
947            .expect("header should contain ')'");
948
949        assert_eq!(
950            return_hint.position,
951            Position {
952                line: 0,
953                character: expected_col
954            }
955        );
956        match &return_hint.label {
957            InlayHintLabel::String(label) => assert_eq!(label, "-> ()"),
958            _ => panic!("expected string inlay label"),
959        }
960    }
961
962    #[test]
963    fn test_print_parameter_hints_are_suppressed() {
964        let code = "print(\"hello\")\n";
965        let hints = get_inlay_hints(code, full_range(), &InlayHintConfig::default(), None);
966        let has_parameter_hints = hints
967            .iter()
968            .any(|h| h.kind == Some(InlayHintKind::PARAMETER));
969        assert!(
970            !has_parameter_hints,
971            "print() should not emit parameter hints, got: {:?}",
972            hints
973        );
974    }
975
976    #[test]
977    fn test_function_param_type_hint_not_shown_when_annotated() {
978        let code = "fn greet(name: string) {\n  return name\n}";
979        let config = InlayHintConfig::default();
980        let hints = get_inlay_hints(code, full_range(), &config, None);
981        let labels = type_hint_labels(&hints);
982        // Parameter already has type annotation — should NOT show a hint for it
983        let has_param_hint = labels
984            .iter()
985            .any(|l| l.contains("string") && l.starts_with(":"));
986        assert!(
987            !has_param_hint,
988            "Should not show param type hint when annotation exists, got: {:?}",
989            labels
990        );
991    }
992
993    #[test]
994    fn test_function_no_hint_when_return_annotated() {
995        let code = "fn double(x: int) -> int {\n  return x * 2\n}";
996        let config = InlayHintConfig::default();
997        let hints = get_inlay_hints(code, full_range(), &config, None);
998        let labels = type_hint_labels(&hints);
999        // Both param and return are annotated — no type hints expected
1000        let has_return_hint = labels.iter().any(|l| l.starts_with("->"));
1001        assert!(
1002            !has_return_hint,
1003            "Should not show return type hint when annotation exists, got: {:?}",
1004            labels
1005        );
1006    }
1007
1008    #[test]
1009    fn test_function_param_hint_shows_inferred_shared_reference() {
1010        let code = r#"
1011fn read_only(a) {
1012  return a.len()
1013}
1014let s = "abc"
1015read_only(s)
1016"#;
1017        let hints = get_inlay_hints(code, full_range(), &InlayHintConfig::default(), None);
1018        let labels = type_hint_labels(&hints);
1019        assert!(
1020            labels.iter().any(|l| l == ": &string"),
1021            "Expected inferred shared reference hint ': &string', got: {:?}",
1022            labels
1023        );
1024    }
1025
1026    #[test]
1027    fn test_function_param_hint_shows_inferred_exclusive_reference() {
1028        let code = r#"
1029fn write_ref(a) {
1030  a = a + "!"
1031  return a
1032}
1033let s = "abc"
1034write_ref(s)
1035"#;
1036        let hints = get_inlay_hints(code, full_range(), &InlayHintConfig::default(), None);
1037        let labels = type_hint_labels(&hints);
1038        assert!(
1039            labels.iter().any(|l| l == ": &mut string"),
1040            "Expected inferred exclusive reference hint ': &mut string', got: {:?}",
1041            labels
1042        );
1043    }
1044
1045    #[test]
1046    fn test_function_param_hint_union_is_memberwise_reference_aware() {
1047        let code = r#"
1048fn foo(a) { return a }
1049let i = foo(1)
1050let s = foo("hi")
1051"#;
1052        let hints = get_inlay_hints(code, full_range(), &InlayHintConfig::default(), None);
1053        let labels = type_hint_labels(&hints);
1054        let union_hint = labels
1055            .iter()
1056            .find(|l| l.starts_with(":") && l.contains("int") && l.contains("string"))
1057            .cloned()
1058            .unwrap_or_default();
1059        assert!(
1060            union_hint.contains("&string"),
1061            "Expected union hint to show reference-aware heap member, got: {:?}",
1062            labels
1063        );
1064        assert!(
1065            union_hint.contains("int"),
1066            "Expected union hint to keep primitive member by value, got: {:?}",
1067            labels
1068        );
1069    }
1070
1071    #[test]
1072    fn test_variable_type_hint_disabled() {
1073        let config = InlayHintConfig {
1074            show_type_hints: true,
1075            show_parameter_hints: true,
1076            show_variable_type_hints: false,
1077            show_return_type_hints: true,
1078        };
1079        let hints = get_inlay_hints("let x = 42", full_range(), &config, None);
1080        let type_labels = type_hint_labels(&hints);
1081        assert!(
1082            type_labels.is_empty(),
1083            "Should not show variable type hints when disabled, got: {:?}",
1084            type_labels
1085        );
1086    }
1087
1088    #[test]
1089    fn test_return_type_hint_disabled() {
1090        let config = InlayHintConfig {
1091            show_type_hints: true,
1092            show_parameter_hints: true,
1093            show_variable_type_hints: true,
1094            show_return_type_hints: false,
1095        };
1096        let code = "fn add(a: int, b: int) {\n  return a + b\n}";
1097        let hints = get_inlay_hints(code, full_range(), &config, None);
1098        let labels = type_hint_labels(&hints);
1099        let has_return_hint = labels.iter().any(|l| l.starts_with("->"));
1100        assert!(
1101            !has_return_hint,
1102            "Should not show return type hints when disabled, got: {:?}",
1103            labels
1104        );
1105    }
1106
1107    #[test]
1108    fn test_variable_inside_function_gets_hint() {
1109        let config = InlayHintConfig::default();
1110        let code = "fn foo() {\n  let x = 42\n  return x\n}";
1111        let hints = get_inlay_hints(code, full_range(), &config, None);
1112        let type_labels = type_hint_labels(&hints);
1113        let has_int_hint = type_labels.iter().any(|l| l.contains("int"));
1114        assert!(
1115            has_int_hint,
1116            "Should show type hint for variable inside function body, got: {:?}",
1117            type_labels
1118        );
1119    }
1120
1121    #[test]
1122    fn test_string_variable_hint() {
1123        let config = InlayHintConfig::default();
1124        let hints = get_inlay_hints("let name = \"hello\"", full_range(), &config, None);
1125        let labels = type_hint_labels(&hints);
1126        let has_string = labels.iter().any(|l| l.contains("string"));
1127        assert!(
1128            has_string,
1129            "Should show 'string' hint for string literal, got: {:?}",
1130            labels
1131        );
1132    }
1133
1134    #[test]
1135    fn test_bool_variable_hint() {
1136        let config = InlayHintConfig::default();
1137        let hints = get_inlay_hints("let flag = true", full_range(), &config, None);
1138        let labels = type_hint_labels(&hints);
1139        let has_bool = labels.iter().any(|l| l.contains("bool"));
1140        assert!(
1141            has_bool,
1142            "Should show 'bool' hint for bool literal, got: {:?}",
1143            labels
1144        );
1145    }
1146
1147    #[test]
1148    fn test_no_hint_when_type_annotated() {
1149        let config = InlayHintConfig::default();
1150        let hints = get_inlay_hints("let x: int = 42", full_range(), &config, None);
1151        let type_labels = type_hint_labels(&hints);
1152        // Should not produce a type hint since the variable already has an annotation
1153        let has_int = type_labels.iter().any(|l| l.contains("int"));
1154        assert!(
1155            !has_int,
1156            "Should not show type hint when annotation exists, got: {:?}",
1157            type_labels
1158        );
1159    }
1160
1161    #[test]
1162    fn test_table_row_literal_field_hints() {
1163        let code = r#"type FinRecord {
1164  month: int,
1165  revenue: number,
1166  profit: number,
1167  note: string
1168}
1169let t: Table<FinRecord> = [1, 100.0, 60.0, "jan"], [2, 120.0, 70.0, "feb"]
1170"#;
1171        let config = InlayHintConfig::default();
1172        let hints = get_inlay_hints(code, full_range(), &config, None);
1173        let param_hints: Vec<String> = hints
1174            .iter()
1175            .filter(|h| h.kind == Some(InlayHintKind::PARAMETER))
1176            .map(|h| match &h.label {
1177                InlayHintLabel::String(s) => s.clone(),
1178                _ => "non-string".to_string(),
1179            })
1180            .collect();
1181        // Should have 8 parameter hints (4 fields x 2 rows)
1182        assert_eq!(
1183            param_hints.len(),
1184            8,
1185            "Expected 8 parameter hints for 2 rows x 4 fields, got: {:?}",
1186            param_hints
1187        );
1188        assert_eq!(param_hints[0], "month:");
1189        assert_eq!(param_hints[1], "revenue:");
1190        assert_eq!(param_hints[2], "profit:");
1191        assert_eq!(param_hints[3], "note:");
1192        // Second row repeats
1193        assert_eq!(param_hints[4], "month:");
1194        assert_eq!(param_hints[7], "note:");
1195    }
1196
1197    #[test]
1198    fn test_parse_error_with_no_recoverable_items_emits_no_hints() {
1199        let code = r#"
1200from std.core.snapshot import { Snapshot }
1201
1202let x = {x: 1}
1203x.y = 1
1204let i = 10D
1205"#;
1206        let hints = get_inlay_hints(code, full_range(), &InlayHintConfig::default(), None);
1207
1208        assert!(
1209            hints.is_empty(),
1210            "invalid parse with no recoverable AST should not emit hints"
1211        );
1212    }
1213}