1use 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
42pub const TYPE_RENDER_BUDGET: usize = 60;
45
46pub struct TypeHint {
48 pub span: Span,
52 pub label: String,
54}
55
56pub struct InlayHintsResult {
58 pub source_map: SourceMap,
61 pub file_id: FileId,
63 pub hints: Vec<TypeHint>,
65}
66
67#[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 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 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 let registry = ModuleRegistry::new();
96 let mut symbols = SymbolTable::new();
97 let _ = resolve_names_with_registry(&module, &mut symbols, ®istry);
98
99 let id_gen = NodeIdGen::new();
101 let mut air_module = lower_module(&module, &id_gen, &symbols);
102
103 let mut sites = Vec::new();
107 SiteCollector { sites: &mut sites }.visit_node(&air_module);
108
109 let mut checker = TypeChecker::new();
111 register_builtins(&mut checker);
112 seed_prelude(&mut checker, ®istry);
113 seed_imports(&mut checker, &module.imports, ®istry);
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 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
150fn 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
166fn 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
177struct HintSite {
181 pattern_id: NodeId,
183 name_span: Span,
185}
186
187struct 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
207fn 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#[cfg(test)]
232mod tests {
233 use super::*;
234 use tower_lsp::lsp_types::Position;
235
236 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 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 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 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 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 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 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}