Skip to main content

bock_lsp/
inlay_hint.rs

1//! `textDocument/inlayHint` support — inferred type hints for `let` bindings.
2//!
3//! For every `let` (or `let mut`) binding *without* an explicit type
4//! annotation, the LSP renders an inline `: T` hint immediately after the
5//! binding name, where `T` is the type the checker inferred for the binding.
6//! `for`-loop binders are collected through the same machinery (the binder
7//! pattern survives the checker's iterator desugaring with its `NodeId`
8//! intact), so they produce hints whenever the element type resolves.
9//!
10//! Flow:
11//! 1. Run the hover pipeline (lex → parse → resolve → lower → check).
12//! 2. *Before* checking, walk the freshly lowered AIR module and record every
13//!    bind-pattern site under an unannotated `let` or a `for` binder. Doing
14//!    this pre-check matters: the checker synthesizes its own helper
15//!    `let __bock_iter_N = …` bindings while desugaring `for` loops, and
16//!    collecting first keeps those out of the hint set.
17//! 3. After checking, look each site's inferred type up by the pattern's
18//!    `NodeId`, drop any that are unresolved or poisoned by type errors, and
19//!    render the rest via [`format_type`](crate::type_display::format_type).
20//!
21//! Hints whose insertion point falls outside the requested range are
22//! filtered out, and pathologically long type renders are truncated to
23//! [`TYPE_RENDER_BUDGET`] characters.
24
25use std::path::PathBuf;
26
27use bock_air::{
28    lower_module, resolve_names_with_registry, visitor::walk_node, visitor::Visitor, AIRNode,
29    ModuleRegistry, NodeId, NodeIdGen, NodeKind, SymbolTable,
30};
31use bock_errors::{FileId, Span};
32use bock_lexer::Lexer;
33use bock_parser::Parser;
34use bock_source::SourceMap;
35use bock_types::{seed_imports, seed_prelude, Type, TypeChecker};
36use tower_lsp::lsp_types::Range;
37
38use crate::goto_definition::position_to_offset;
39use crate::pipeline::register_builtins;
40use crate::type_display::format_type;
41
42/// Maximum number of characters of a rendered type shown in a hint label.
43/// Longer renders are cut at the budget and terminated with `…`.
44pub const TYPE_RENDER_BUDGET: usize = 60;
45
46/// One inferred-type hint, ready for conversion to an LSP `InlayHint`.
47pub struct TypeHint {
48    /// Zero-width span at the insertion point — immediately after the
49    /// binding name. Convert with [`span_to_range`](crate::span_to_range)
50    /// and use the range's `start` as the hint position.
51    pub span: Span,
52    /// Hint label, including the leading `: ` separator.
53    pub label: String,
54}
55
56/// Result of computing inlay hints for a document.
57pub struct InlayHintsResult {
58    /// Owned source map containing the document (keeps `SourceFile`
59    /// borrows valid for the lifetime of the result).
60    pub source_map: SourceMap,
61    /// Id of the added file inside [`InlayHintsResult::source_map`].
62    pub file_id: FileId,
63    /// Hints inside the requested range, sorted by insertion offset.
64    pub hints: Vec<TypeHint>,
65}
66
67/// Compute inferred-type inlay hints for the unannotated `let` bindings
68/// (and `for` binders) whose names end inside `range`.
69#[must_use]
70pub fn inlay_hints(path: PathBuf, content: String, range: Range) -> InlayHintsResult {
71    let mut source_map = SourceMap::new();
72    let file_id = source_map.add_file(path, content);
73    let source_file = source_map.get_file(file_id);
74
75    // Clamp the requested range to byte offsets. A position past the end of
76    // the document clamps to the document end.
77    let eof = source_file.content.len();
78    let range_start = position_to_offset(
79        &source_file.content,
80        range.start.line,
81        range.start.character,
82    )
83    .unwrap_or(eof);
84    let range_end = position_to_offset(&source_file.content, range.end.line, range.end.character)
85        .unwrap_or(eof);
86
87    // Lex + parse. As in hover, we tolerate diagnostics — a partial AST
88    // still yields useful hints for the parts that did check.
89    let mut lexer = Lexer::new(source_file);
90    let tokens = lexer.tokenize();
91    let mut parser = Parser::new(tokens, source_file);
92    let module = parser.parse_module();
93
94    // Resolve (single-file — no cross-file registry).
95    let registry = ModuleRegistry::new();
96    let mut symbols = SymbolTable::new();
97    let _ = resolve_names_with_registry(&module, &mut symbols, &registry);
98
99    // Lower to AIR.
100    let id_gen = NodeIdGen::new();
101    let mut air_module = lower_module(&module, &id_gen, &symbols);
102
103    // Collect hint sites BEFORE type checking: the checker rewrites `for`
104    // loops in place and synthesizes helper `let` bindings while doing so;
105    // collecting first guarantees only user-written binders produce hints.
106    let mut sites = Vec::new();
107    SiteCollector { sites: &mut sites }.visit_node(&air_module);
108
109    // Type-check (records inferred pattern types keyed by `NodeId`).
110    let mut checker = TypeChecker::new();
111    register_builtins(&mut checker);
112    seed_prelude(&mut checker, &registry);
113    seed_imports(&mut checker, &module.imports, &registry);
114    checker.check_module(&mut air_module);
115
116    let mut hints = Vec::new();
117    for site in sites {
118        let offset = site.name_span.end;
119        if offset < range_start || offset > range_end {
120            continue;
121        }
122        let Some(ty) = checker.type_of(site.pattern_id) else {
123            continue;
124        };
125        // Re-apply the substitution so lingering inference variables get
126        // their final answer (mirrors the hover path).
127        let ty = checker.subst.apply(ty);
128        if !is_renderable(&ty) {
129            continue;
130        }
131        let label = format!(": {}", truncate_render(format_type(&ty)));
132        hints.push(TypeHint {
133            span: Span {
134                file: file_id,
135                start: offset,
136                end: offset,
137            },
138            label,
139        });
140    }
141    hints.sort_unstable_by_key(|h| h.span.start);
142
143    InlayHintsResult {
144        source_map,
145        file_id,
146        hints,
147    }
148}
149
150/// `true` if `ty` contains no error poison, unresolved inference variable,
151/// or sketch-mode placeholder anywhere — i.e. rendering it produces a
152/// clean, fully resolved type string rather than `<error>`/`?N` noise.
153fn is_renderable(ty: &Type) -> bool {
154    match ty {
155        Type::Primitive(_) | Type::Named(_) => true,
156        Type::Generic(g) => g.args.iter().all(is_renderable),
157        Type::Tuple(elems) => elems.iter().all(is_renderable),
158        Type::Function(f) => f.params.iter().all(is_renderable) && is_renderable(&f.ret),
159        Type::Optional(inner) => is_renderable(inner),
160        Type::Result(ok, err) => is_renderable(ok) && is_renderable(err),
161        Type::Refined(base, _) => is_renderable(base),
162        Type::TypeVar(_) | Type::Flexible(_) | Type::Error => false,
163    }
164}
165
166/// Cut a rendered type down to [`TYPE_RENDER_BUDGET`] characters, replacing
167/// the overflow with a single `…`.
168fn truncate_render(rendered: String) -> String {
169    if rendered.chars().count() <= TYPE_RENDER_BUDGET {
170        return rendered;
171    }
172    let mut out: String = rendered.chars().take(TYPE_RENDER_BUDGET - 1).collect();
173    out.push('…');
174    out
175}
176
177// ─── AIR walker: collect unannotated binder sites ────────────────────────────
178
179/// One candidate hint site recorded before type checking.
180struct HintSite {
181    /// AIR `NodeId` of the `BindPat` whose inferred type becomes the hint.
182    pattern_id: NodeId,
183    /// Span of the binding *name*; the hint is inserted at `span.end`.
184    name_span: Span,
185}
186
187/// Visitor that records every bind-pattern under an unannotated `let`
188/// binding or a `for`-loop binder. Destructuring patterns (tuples, lists,
189/// constructors) contribute one site per bound name.
190struct SiteCollector<'a> {
191    sites: &'a mut Vec<HintSite>,
192}
193
194impl Visitor for SiteCollector<'_> {
195    fn visit_node(&mut self, node: &AIRNode) {
196        match &node.kind {
197            NodeKind::LetBinding {
198                ty: None, pattern, ..
199            } => collect_bind_pats(pattern, self.sites),
200            NodeKind::For { pattern, .. } => collect_bind_pats(pattern, self.sites),
201            _ => {}
202        }
203        walk_node(self, node);
204    }
205}
206
207/// Recursively collect every `BindPat` under `pattern` (handles
208/// destructuring: tuple, list, constructor and record patterns).
209fn collect_bind_pats(pattern: &AIRNode, sites: &mut Vec<HintSite>) {
210    struct BindPatCollector<'a> {
211        sites: &'a mut Vec<HintSite>,
212    }
213
214    impl Visitor for BindPatCollector<'_> {
215        fn visit_node(&mut self, node: &AIRNode) {
216            if let NodeKind::BindPat { name, .. } = &node.kind {
217                self.sites.push(HintSite {
218                    pattern_id: node.id,
219                    name_span: name.span,
220                });
221            }
222            walk_node(self, node);
223        }
224    }
225
226    BindPatCollector { sites }.visit_node(pattern);
227}
228
229// ─── Tests ───────────────────────────────────────────────────────────────────
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use tower_lsp::lsp_types::Position;
235
236    /// A range that covers any document (end clamps to EOF).
237    fn full_range() -> Range {
238        Range::new(Position::new(0, 0), Position::new(u32::MAX, 0))
239    }
240
241    fn run(src: &str) -> InlayHintsResult {
242        inlay_hints(PathBuf::from("test.bock"), src.to_string(), full_range())
243    }
244
245    /// Assert that `hint` sits immediately after `name` in `src`.
246    fn assert_after_name(src: &str, hint: &TypeHint, name: &str) {
247        assert_eq!(
248            hint.span.start, hint.span.end,
249            "hint span must be zero-width"
250        );
251        assert!(
252            src[..hint.span.start].ends_with(name),
253            "hint at offset {} should sit immediately after `{name}`; text before it: {:?}",
254            hint.span.start,
255            &src[..hint.span.start],
256        );
257    }
258
259    #[test]
260    fn unannotated_let_gets_int_hint_after_name() {
261        let src = "\
262module m
263
264fn main() {
265    let answer = 42
266}
267";
268        let result = run(src);
269        assert_eq!(result.hints.len(), 1, "expected exactly one hint");
270        assert_eq!(result.hints[0].label, ": Int");
271        assert_after_name(src, &result.hints[0], "answer");
272    }
273
274    #[test]
275    fn annotated_let_gets_no_hint() {
276        let src = "\
277module m
278
279fn main() {
280    let answer: Int = 42
281}
282";
283        let result = run(src);
284        assert!(
285            result.hints.is_empty(),
286            "annotated binding must not produce a hint, got: {:?}",
287            result.hints.iter().map(|h| &h.label).collect::<Vec<_>>(),
288        );
289    }
290
291    #[test]
292    fn let_mut_gets_hint_after_name() {
293        let src = "\
294module m
295
296fn main() {
297    let mut count = 1
298}
299";
300        let result = run(src);
301        assert_eq!(result.hints.len(), 1);
302        assert_eq!(result.hints[0].label, ": Int");
303        assert_after_name(src, &result.hints[0], "count");
304    }
305
306    #[test]
307    fn inferred_generic_list_type() {
308        let src = "\
309module m
310
311fn main() {
312    let xs = [1, 2, 3]
313}
314";
315        let result = run(src);
316        assert_eq!(result.hints.len(), 1);
317        assert_eq!(result.hints[0].label, ": List[Int]");
318        assert_after_name(src, &result.hints[0], "xs");
319    }
320
321    #[test]
322    fn inferred_optional_from_fn_return() {
323        let src = "\
324module m
325
326fn find() -> Int? {
327    42
328}
329
330fn main() {
331    let v = find()
332}
333";
334        let result = run(src);
335        assert_eq!(result.hints.len(), 1, "expected one hint for `v`");
336        assert_eq!(result.hints[0].label, ": Int?");
337        assert_after_name(src, &result.hints[0], "v");
338    }
339
340    #[test]
341    fn error_typed_binding_produces_no_hint() {
342        let src = "\
343module m
344
345fn main() {
346    let x = nonexistent_fn(1)
347}
348";
349        let result = run(src);
350        assert!(
351            result.hints.is_empty(),
352            "error-typed binding must not produce a hint, got: {:?}",
353            result.hints.iter().map(|h| &h.label).collect::<Vec<_>>(),
354        );
355    }
356
357    #[test]
358    fn unresolved_lambda_binding_produces_no_hint() {
359        // With no call site, the lambda's parameter type stays an
360        // inference variable — the hint must be suppressed rather than
361        // rendering `Fn(?0) -> ?0`.
362        let src = "\
363module m
364
365fn main() {
366    let f = (x) => x
367}
368";
369        let result = run(src);
370        for hint in &result.hints {
371            assert!(
372                !hint.label.contains('?'),
373                "hint must not leak inference variables: {}",
374                hint.label,
375            );
376        }
377    }
378
379    #[test]
380    fn range_filters_hints_outside_it() {
381        let src = "\
382module m
383
384fn main() {
385    let first = 1
386    let second = \"two\"
387}
388";
389        // Full range sees both.
390        let all = run(src);
391        assert_eq!(all.hints.len(), 2, "full range should see both hints");
392        assert_eq!(all.hints[0].label, ": Int");
393        assert_eq!(all.hints[1].label, ": String");
394
395        // A range covering only line 4 (`let second = …`) sees one.
396        let line4 = Range::new(Position::new(4, 0), Position::new(4, 99));
397        let result = inlay_hints(PathBuf::from("test.bock"), src.to_string(), line4);
398        assert_eq!(result.hints.len(), 1, "line-4 range should see one hint");
399        assert_eq!(result.hints[0].label, ": String");
400        assert_after_name(src, &result.hints[0], "second");
401    }
402
403    #[test]
404    fn tuple_destructuring_hints_each_name() {
405        let src = "\
406module m
407
408fn main() {
409    let (a, b) = (1, \"hi\")
410}
411";
412        let result = run(src);
413        assert_eq!(result.hints.len(), 2, "expected a hint per bound name");
414        assert_eq!(result.hints[0].label, ": Int");
415        assert_after_name(src, &result.hints[0], "a");
416        assert_eq!(result.hints[1].label, ": String");
417        assert_after_name(src, &result.hints[1], "b");
418    }
419
420    #[test]
421    fn long_type_render_is_truncated() {
422        // 12-element tuple renders to well over the 60-char budget.
423        let src = "\
424module m
425
426fn main() {
427    let t = (1, \"a\", 1, \"a\", 1, \"a\", 1, \"a\", 1, \"a\", 1, \"a\", 1, \"a\")
428}
429";
430        let result = run(src);
431        assert_eq!(result.hints.len(), 1);
432        let label = &result.hints[0].label;
433        assert!(
434            label.ends_with('…'),
435            "truncated label must end in …: {label}"
436        );
437        // `: ` prefix plus exactly the budget.
438        assert_eq!(
439            label.chars().count(),
440            2 + TYPE_RENDER_BUDGET,
441            "label: {label}"
442        );
443    }
444
445    #[test]
446    fn truncate_render_leaves_short_strings_alone() {
447        assert_eq!(truncate_render("Int".to_string()), "Int");
448        let exactly = "A".repeat(TYPE_RENDER_BUDGET);
449        assert_eq!(truncate_render(exactly.clone()), exactly);
450    }
451
452    #[test]
453    fn truncate_render_cuts_at_budget() {
454        let long = "A".repeat(TYPE_RENDER_BUDGET + 40);
455        let out = truncate_render(long);
456        assert_eq!(out.chars().count(), TYPE_RENDER_BUDGET);
457        assert!(out.ends_with('…'));
458    }
459
460    #[test]
461    fn for_loop_binder_gets_element_type_hint() {
462        let src = "\
463module m
464
465fn main() {
466    for x in [1, 2, 3] {
467        println(\"hi\")
468    }
469}
470";
471        let result = run(src);
472        assert_eq!(result.hints.len(), 1, "expected one hint for the binder");
473        assert_eq!(result.hints[0].label, ": Int");
474        assert_after_name(src, &result.hints[0], "x");
475    }
476
477    #[test]
478    fn range_past_eof_yields_no_hints() {
479        let src = "\
480module m
481
482fn main() {
483    let x = 1
484}
485";
486        let past_eof = Range::new(Position::new(90, 0), Position::new(99, 0));
487        let result = inlay_hints(PathBuf::from("test.bock"), src.to_string(), past_eof);
488        assert!(result.hints.is_empty());
489    }
490}