use std::path::PathBuf;
use bock_air::{
lower_module, resolve_names_with_registry, visitor::walk_node, visitor::Visitor, AIRNode,
ModuleRegistry, NodeId, NodeIdGen, NodeKind, SymbolTable,
};
use bock_errors::{FileId, Span};
use bock_lexer::Lexer;
use bock_parser::Parser;
use bock_source::SourceMap;
use bock_types::{seed_imports, seed_prelude, Type, TypeChecker};
use tower_lsp::lsp_types::Range;
use crate::goto_definition::position_to_offset;
use crate::pipeline::register_builtins;
use crate::type_display::format_type;
pub const TYPE_RENDER_BUDGET: usize = 60;
pub struct TypeHint {
pub span: Span,
pub label: String,
}
pub struct InlayHintsResult {
pub source_map: SourceMap,
pub file_id: FileId,
pub hints: Vec<TypeHint>,
}
#[must_use]
pub fn inlay_hints(path: PathBuf, content: String, range: Range) -> InlayHintsResult {
let mut source_map = SourceMap::new();
let file_id = source_map.add_file(path, content);
let source_file = source_map.get_file(file_id);
let eof = source_file.content.len();
let range_start = position_to_offset(
&source_file.content,
range.start.line,
range.start.character,
)
.unwrap_or(eof);
let range_end = position_to_offset(&source_file.content, range.end.line, range.end.character)
.unwrap_or(eof);
let mut lexer = Lexer::new(source_file);
let tokens = lexer.tokenize();
let mut parser = Parser::new(tokens, source_file);
let module = parser.parse_module();
let registry = ModuleRegistry::new();
let mut symbols = SymbolTable::new();
let _ = resolve_names_with_registry(&module, &mut symbols, ®istry);
let id_gen = NodeIdGen::new();
let mut air_module = lower_module(&module, &id_gen, &symbols);
let mut sites = Vec::new();
SiteCollector { sites: &mut sites }.visit_node(&air_module);
let mut checker = TypeChecker::new();
register_builtins(&mut checker);
seed_prelude(&mut checker, ®istry);
seed_imports(&mut checker, &module.imports, ®istry);
checker.check_module(&mut air_module);
let mut hints = Vec::new();
for site in sites {
let offset = site.name_span.end;
if offset < range_start || offset > range_end {
continue;
}
let Some(ty) = checker.type_of(site.pattern_id) else {
continue;
};
let ty = checker.subst.apply(ty);
if !is_renderable(&ty) {
continue;
}
let label = format!(": {}", truncate_render(format_type(&ty)));
hints.push(TypeHint {
span: Span {
file: file_id,
start: offset,
end: offset,
},
label,
});
}
hints.sort_unstable_by_key(|h| h.span.start);
InlayHintsResult {
source_map,
file_id,
hints,
}
}
fn is_renderable(ty: &Type) -> bool {
match ty {
Type::Primitive(_) | Type::Named(_) => true,
Type::Generic(g) => g.args.iter().all(is_renderable),
Type::Tuple(elems) => elems.iter().all(is_renderable),
Type::Function(f) => f.params.iter().all(is_renderable) && is_renderable(&f.ret),
Type::Optional(inner) => is_renderable(inner),
Type::Result(ok, err) => is_renderable(ok) && is_renderable(err),
Type::Refined(base, _) => is_renderable(base),
Type::TypeVar(_) | Type::Flexible(_) | Type::Error => false,
}
}
fn truncate_render(rendered: String) -> String {
if rendered.chars().count() <= TYPE_RENDER_BUDGET {
return rendered;
}
let mut out: String = rendered.chars().take(TYPE_RENDER_BUDGET - 1).collect();
out.push('…');
out
}
struct HintSite {
pattern_id: NodeId,
name_span: Span,
}
struct SiteCollector<'a> {
sites: &'a mut Vec<HintSite>,
}
impl Visitor for SiteCollector<'_> {
fn visit_node(&mut self, node: &AIRNode) {
match &node.kind {
NodeKind::LetBinding {
ty: None, pattern, ..
} => collect_bind_pats(pattern, self.sites),
NodeKind::For { pattern, .. } => collect_bind_pats(pattern, self.sites),
_ => {}
}
walk_node(self, node);
}
}
fn collect_bind_pats(pattern: &AIRNode, sites: &mut Vec<HintSite>) {
struct BindPatCollector<'a> {
sites: &'a mut Vec<HintSite>,
}
impl Visitor for BindPatCollector<'_> {
fn visit_node(&mut self, node: &AIRNode) {
if let NodeKind::BindPat { name, .. } = &node.kind {
self.sites.push(HintSite {
pattern_id: node.id,
name_span: name.span,
});
}
walk_node(self, node);
}
}
BindPatCollector { sites }.visit_node(pattern);
}
#[cfg(test)]
mod tests {
use super::*;
use tower_lsp::lsp_types::Position;
fn full_range() -> Range {
Range::new(Position::new(0, 0), Position::new(u32::MAX, 0))
}
fn run(src: &str) -> InlayHintsResult {
inlay_hints(PathBuf::from("test.bock"), src.to_string(), full_range())
}
fn assert_after_name(src: &str, hint: &TypeHint, name: &str) {
assert_eq!(
hint.span.start, hint.span.end,
"hint span must be zero-width"
);
assert!(
src[..hint.span.start].ends_with(name),
"hint at offset {} should sit immediately after `{name}`; text before it: {:?}",
hint.span.start,
&src[..hint.span.start],
);
}
#[test]
fn unannotated_let_gets_int_hint_after_name() {
let src = "\
module m
fn main() {
let answer = 42
}
";
let result = run(src);
assert_eq!(result.hints.len(), 1, "expected exactly one hint");
assert_eq!(result.hints[0].label, ": Int");
assert_after_name(src, &result.hints[0], "answer");
}
#[test]
fn annotated_let_gets_no_hint() {
let src = "\
module m
fn main() {
let answer: Int = 42
}
";
let result = run(src);
assert!(
result.hints.is_empty(),
"annotated binding must not produce a hint, got: {:?}",
result.hints.iter().map(|h| &h.label).collect::<Vec<_>>(),
);
}
#[test]
fn let_mut_gets_hint_after_name() {
let src = "\
module m
fn main() {
let mut count = 1
}
";
let result = run(src);
assert_eq!(result.hints.len(), 1);
assert_eq!(result.hints[0].label, ": Int");
assert_after_name(src, &result.hints[0], "count");
}
#[test]
fn inferred_generic_list_type() {
let src = "\
module m
fn main() {
let xs = [1, 2, 3]
}
";
let result = run(src);
assert_eq!(result.hints.len(), 1);
assert_eq!(result.hints[0].label, ": List[Int]");
assert_after_name(src, &result.hints[0], "xs");
}
#[test]
fn inferred_optional_from_fn_return() {
let src = "\
module m
fn find() -> Int? {
42
}
fn main() {
let v = find()
}
";
let result = run(src);
assert_eq!(result.hints.len(), 1, "expected one hint for `v`");
assert_eq!(result.hints[0].label, ": Int?");
assert_after_name(src, &result.hints[0], "v");
}
#[test]
fn error_typed_binding_produces_no_hint() {
let src = "\
module m
fn main() {
let x = nonexistent_fn(1)
}
";
let result = run(src);
assert!(
result.hints.is_empty(),
"error-typed binding must not produce a hint, got: {:?}",
result.hints.iter().map(|h| &h.label).collect::<Vec<_>>(),
);
}
#[test]
fn unresolved_lambda_binding_produces_no_hint() {
let src = "\
module m
fn main() {
let f = (x) => x
}
";
let result = run(src);
for hint in &result.hints {
assert!(
!hint.label.contains('?'),
"hint must not leak inference variables: {}",
hint.label,
);
}
}
#[test]
fn range_filters_hints_outside_it() {
let src = "\
module m
fn main() {
let first = 1
let second = \"two\"
}
";
let all = run(src);
assert_eq!(all.hints.len(), 2, "full range should see both hints");
assert_eq!(all.hints[0].label, ": Int");
assert_eq!(all.hints[1].label, ": String");
let line4 = Range::new(Position::new(4, 0), Position::new(4, 99));
let result = inlay_hints(PathBuf::from("test.bock"), src.to_string(), line4);
assert_eq!(result.hints.len(), 1, "line-4 range should see one hint");
assert_eq!(result.hints[0].label, ": String");
assert_after_name(src, &result.hints[0], "second");
}
#[test]
fn tuple_destructuring_hints_each_name() {
let src = "\
module m
fn main() {
let (a, b) = (1, \"hi\")
}
";
let result = run(src);
assert_eq!(result.hints.len(), 2, "expected a hint per bound name");
assert_eq!(result.hints[0].label, ": Int");
assert_after_name(src, &result.hints[0], "a");
assert_eq!(result.hints[1].label, ": String");
assert_after_name(src, &result.hints[1], "b");
}
#[test]
fn long_type_render_is_truncated() {
let src = "\
module m
fn main() {
let t = (1, \"a\", 1, \"a\", 1, \"a\", 1, \"a\", 1, \"a\", 1, \"a\", 1, \"a\")
}
";
let result = run(src);
assert_eq!(result.hints.len(), 1);
let label = &result.hints[0].label;
assert!(
label.ends_with('…'),
"truncated label must end in …: {label}"
);
assert_eq!(
label.chars().count(),
2 + TYPE_RENDER_BUDGET,
"label: {label}"
);
}
#[test]
fn truncate_render_leaves_short_strings_alone() {
assert_eq!(truncate_render("Int".to_string()), "Int");
let exactly = "A".repeat(TYPE_RENDER_BUDGET);
assert_eq!(truncate_render(exactly.clone()), exactly);
}
#[test]
fn truncate_render_cuts_at_budget() {
let long = "A".repeat(TYPE_RENDER_BUDGET + 40);
let out = truncate_render(long);
assert_eq!(out.chars().count(), TYPE_RENDER_BUDGET);
assert!(out.ends_with('…'));
}
#[test]
fn for_loop_binder_gets_element_type_hint() {
let src = "\
module m
fn main() {
for x in [1, 2, 3] {
println(\"hi\")
}
}
";
let result = run(src);
assert_eq!(result.hints.len(), 1, "expected one hint for the binder");
assert_eq!(result.hints[0].label, ": Int");
assert_after_name(src, &result.hints[0], "x");
}
#[test]
fn range_past_eof_yields_no_hints() {
let src = "\
module m
fn main() {
let x = 1
}
";
let past_eof = Range::new(Position::new(90, 0), Position::new(99, 0));
let result = inlay_hints(PathBuf::from("test.bock"), src.to_string(), past_eof);
assert!(result.hints.is_empty());
}
}