Skip to main content

harn_parser/typechecker/
mod.rs

1use std::collections::{BTreeMap, BTreeSet, HashSet};
2use std::rc::Rc;
3
4use crate::ast::*;
5use crate::builtin_signatures;
6use crate::diagnostic_codes::{Code, Repair};
7use harn_lexer::{FixEdit, Span};
8
9type TypeMismatchEvidence = (Option<(Span, String)>, Option<Span>);
10
11mod binary_ops;
12mod exits;
13mod format;
14mod inference;
15pub mod method_registry;
16mod schema_inference;
17mod scope;
18mod union;
19
20pub use exits::{block_definitely_exits, stmt_definitely_exits};
21pub use format::{format_type, shape_mismatch_detail};
22
23/// Substitute generic bindings with the same open-row folding used by type
24/// inference. Schema compilation calls this instead of carrying a second type
25/// expression rewriter.
26pub fn substitute_type_expr(ty: &TypeExpr, bindings: &BTreeMap<String, TypeExpr>) -> TypeExpr {
27    TypeChecker::apply_type_bindings(ty, bindings)
28}
29
30use schema_inference::output_schema_type_expr_from_node;
31use scope::TypeScope;
32
33/// An inlay hint produced during type checking.
34#[derive(Debug, Clone)]
35pub struct InlayHintInfo {
36    /// Position (line, column) where the hint should be displayed (after the variable name).
37    pub line: usize,
38    pub column: usize,
39    /// The type label to display (e.g. ": string").
40    pub label: String,
41}
42
43/// A diagnostic produced by the type checker.
44#[derive(Debug, Clone)]
45pub struct TypeDiagnostic {
46    pub code: Code,
47    pub message: String,
48    pub severity: DiagnosticSeverity,
49    pub span: Option<Span>,
50    pub help: Option<String>,
51    pub related: Vec<RelatedDiagnostic>,
52    /// Machine-applicable fix edits.
53    pub fix: Option<Vec<FixEdit>>,
54    /// Optional structured payload that higher-level tooling (e.g. the
55    /// LSP code-action provider) can consume to synthesise fixes that
56    /// need more than a static `FixEdit`. Out-of-band from `fix` so the
57    /// string-based rendering pipeline doesn't have to care.
58    pub details: Option<DiagnosticDetails>,
59    /// Structured repair classifier — id, summary, and safety class.
60    /// Agents and IDEs dispatch on `repair.safety` to decide whether to
61    /// auto-apply, propose, or escalate. `None` when no repair shape is
62    /// registered for this code; populated automatically from
63    /// [`Code::repair_template`] by the builder helpers.
64    pub repair: Option<Repair>,
65}
66
67#[derive(Debug, Clone)]
68pub struct RelatedDiagnostic {
69    pub span: Span,
70    pub message: String,
71}
72
73/// Optional structured companion data on a `TypeDiagnostic`. The
74/// variants map one-to-one with diagnostics that have specific
75/// tooling-consumable state beyond the human-readable message; each
76/// variant is attached only by the sites that produce its
77/// corresponding diagnostic, so a consumer can pattern-match on the
78/// variant without parsing the error string.
79#[derive(Debug, Clone)]
80pub enum DiagnosticDetails {
81    /// A concrete expected/found mismatch. Renderers can use this to
82    /// provide stable labels without scraping human-readable text.
83    TypeMismatch,
84    /// A `match` expression with missing variant coverage. `missing`
85    /// holds the formatted literal values of each uncovered variant
86    /// (quoted for strings, bare for ints), ready to drop into a new
87    /// arm prefix. The diagnostic's `span` covers the whole `match`
88    /// expression, so a code-action can locate the closing `}` by
89    /// reading the source at `span.end`.
90    NonExhaustiveMatch { missing: Vec<String> },
91    /// A type-aware lint diagnostic. These diagnostics are produced by
92    /// the type checker because the rule depends on flow-sensitive type
93    /// information, but `harn lint` should surface and filter them like
94    /// ordinary lint rules.
95    LintRule { rule: &'static str },
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum DiagnosticSeverity {
100    Error,
101    Warning,
102}
103
104/// The static type checker.
105pub struct TypeChecker {
106    diagnostics: Vec<TypeDiagnostic>,
107    /// Root scope shared by every child scope created during the walk.
108    /// `Rc` lets fn/pipeline body entries take a refcount bump instead of
109    /// deep-cloning the entire scope chain. Mutations during the pre-pass
110    /// (and the top-level non-callable arm) go through `Rc::make_mut`,
111    /// which is O(1) while the refcount is 1.
112    scope: Rc<TypeScope>,
113    source: Option<String>,
114    hints: Vec<InlayHintInfo>,
115    /// When true, flag unvalidated boundary-API values used in field access.
116    strict_types: bool,
117    /// Lexical depth of enclosing function-like bodies (fn/tool/pipeline/closure).
118    /// `try*` requires `fn_depth > 0` so the rethrow has a body to live in.
119    fn_depth: usize,
120    /// Lexical depth of enclosing `gen fn` bodies. `emit` is only valid here.
121    stream_fn_depth: usize,
122    /// Expected emitted value type for each enclosing `gen fn`.
123    stream_emit_types: Vec<Option<TypeExpr>>,
124    /// Declared return type for the current function-like body. `None`
125    /// entries deliberately break propagation across untyped closures, where
126    /// an inner `return` belongs to the closure rather than the enclosing fn.
127    expected_return_types: Vec<Option<TypeExpr>>,
128    /// Maps function name -> deprecation metadata `(since, use_hint)`. Populated
129    /// when an `@deprecated` attribute is encountered on a top-level fn decl
130    /// during the `check_inner` pre-pass; consulted at every `FunctionCall`
131    /// site to emit a warning + help line.
132    deprecated_fns: std::collections::HashMap<String, (Option<String>, Option<String>)>,
133    /// Names statically known to be introduced by cross-module imports
134    /// (resolved via `harn-modules`). `Some(set)` switches the checker into
135    /// strict cross-module mode: an unresolved callable name is reported as
136    /// an error instead of silently passing through. `None` preserves the
137    /// conservative pre-v0.7.12 behavior (no cross-module undefined-name
138    /// diagnostics).
139    imported_names: Option<HashSet<String>>,
140    /// Type-like declarations imported from other modules. These are registered
141    /// into the scope before local checking so imported type aliases and tagged
142    /// unions participate in normal field access and narrowing.
143    imported_type_decls: Vec<SNode>,
144    /// Callable declarations imported from other modules. Only their
145    /// signatures are registered; bodies stay owned by the defining module.
146    imported_callable_decls: Vec<SNode>,
147    /// Compile-time environment populated by every successfully folded
148    /// `const` binding. Later const initializers see earlier values so
149    /// expressions like `const Y = X + 1` work.
150    const_env: crate::const_eval::ConstEnv,
151    /// Coinductive guard for recursive-type subtype checks. Holds the
152    /// pre-unfolding `(expected, actual)` pairs currently on the
153    /// `types_compatible_at` stack. Re-encountering a pair means the walk has
154    /// cycled through a recursive type alias (`type Tree = {children: [Tree]}`);
155    /// we then assume compatibility (greatest-fixpoint / equirecursive
156    /// subtyping) instead of recursing forever. Interior mutability because
157    /// `types_compatible_at` runs behind `&self`.
158    subtype_cycle_guard: std::cell::RefCell<Vec<(TypeExpr, TypeExpr)>>,
159}
160
161impl TypeChecker {
162    pub(in crate::typechecker) fn wildcard_type() -> TypeExpr {
163        TypeExpr::Named("_".into())
164    }
165
166    pub(in crate::typechecker) fn is_wildcard_type(ty: &TypeExpr) -> bool {
167        matches!(ty, TypeExpr::Named(name) if name == "_")
168    }
169
170    pub(in crate::typechecker) fn contains_wildcard_type(ty: &TypeExpr) -> bool {
171        match ty {
172            TypeExpr::Named(name) => name == "_",
173            TypeExpr::Union(members) | TypeExpr::Intersection(members) => {
174                members.iter().any(Self::contains_wildcard_type)
175            }
176            TypeExpr::Shape(fields) => fields
177                .iter()
178                .any(|field| Self::contains_wildcard_type(&field.type_expr)),
179            TypeExpr::OpenShape { fields, rests } => {
180                fields
181                    .iter()
182                    .any(|field| Self::contains_wildcard_type(&field.type_expr))
183                    || rests.iter().any(Self::contains_wildcard_type)
184            }
185            TypeExpr::List(inner)
186            | TypeExpr::Iter(inner)
187            | TypeExpr::Generator(inner)
188            | TypeExpr::Stream(inner)
189            | TypeExpr::Owned(inner) => Self::contains_wildcard_type(inner),
190            TypeExpr::DictType(key, value) => {
191                Self::contains_wildcard_type(key) || Self::contains_wildcard_type(value)
192            }
193            TypeExpr::Applied { args, .. } => args.iter().any(Self::contains_wildcard_type),
194            TypeExpr::FnType {
195                params,
196                return_type,
197            } => {
198                params.iter().any(Self::contains_wildcard_type)
199                    || Self::contains_wildcard_type(return_type)
200            }
201            TypeExpr::Never | TypeExpr::LitString(_) | TypeExpr::LitInt(_) => false,
202        }
203    }
204
205    pub(in crate::typechecker) fn contains_type_param(
206        ty: &TypeExpr,
207        type_params: &BTreeSet<String>,
208    ) -> bool {
209        match ty {
210            TypeExpr::Named(name) => type_params.contains(name),
211            TypeExpr::Union(members) | TypeExpr::Intersection(members) => members
212                .iter()
213                .any(|member| Self::contains_type_param(member, type_params)),
214            TypeExpr::Shape(fields) => fields
215                .iter()
216                .any(|field| Self::contains_type_param(&field.type_expr, type_params)),
217            TypeExpr::OpenShape { fields, rests } => {
218                fields
219                    .iter()
220                    .any(|field| Self::contains_type_param(&field.type_expr, type_params))
221                    || rests
222                        .iter()
223                        .any(|rest| Self::contains_type_param(rest, type_params))
224            }
225            TypeExpr::List(inner)
226            | TypeExpr::Iter(inner)
227            | TypeExpr::Generator(inner)
228            | TypeExpr::Stream(inner)
229            | TypeExpr::Owned(inner) => Self::contains_type_param(inner, type_params),
230            TypeExpr::DictType(key, value) => {
231                Self::contains_type_param(key, type_params)
232                    || Self::contains_type_param(value, type_params)
233            }
234            TypeExpr::Applied { args, .. } => args
235                .iter()
236                .any(|arg| Self::contains_type_param(arg, type_params)),
237            TypeExpr::FnType {
238                params,
239                return_type,
240            } => {
241                params
242                    .iter()
243                    .any(|param| Self::contains_type_param(param, type_params))
244                    || Self::contains_type_param(return_type, type_params)
245            }
246            TypeExpr::Never | TypeExpr::LitString(_) | TypeExpr::LitInt(_) => false,
247        }
248    }
249
250    pub(in crate::typechecker) fn contains_abstract_type(
251        &self,
252        ty: &TypeExpr,
253        scope: &TypeScope,
254    ) -> bool {
255        match ty {
256            TypeExpr::Named(name) => {
257                matches!(name.as_str(), "_" | "any" | "unknown")
258                    || scope.is_generic_type_param(name)
259            }
260            TypeExpr::Union(members) | TypeExpr::Intersection(members) => members
261                .iter()
262                .any(|member| self.contains_abstract_type(member, scope)),
263            TypeExpr::Shape(fields) => fields
264                .iter()
265                .any(|field| self.contains_abstract_type(&field.type_expr, scope)),
266            TypeExpr::OpenShape { fields, rests } => {
267                fields
268                    .iter()
269                    .any(|field| self.contains_abstract_type(&field.type_expr, scope))
270                    || rests
271                        .iter()
272                        .any(|rest| self.contains_abstract_type(rest, scope))
273            }
274            TypeExpr::List(inner)
275            | TypeExpr::Iter(inner)
276            | TypeExpr::Generator(inner)
277            | TypeExpr::Stream(inner)
278            | TypeExpr::Owned(inner) => self.contains_abstract_type(inner, scope),
279            TypeExpr::DictType(key, value) => {
280                self.contains_abstract_type(key, scope) || self.contains_abstract_type(value, scope)
281            }
282            TypeExpr::Applied { args, .. } => args
283                .iter()
284                .any(|arg| self.contains_abstract_type(arg, scope)),
285            TypeExpr::FnType {
286                params,
287                return_type,
288            } => {
289                params
290                    .iter()
291                    .any(|param| self.contains_abstract_type(param, scope))
292                    || self.contains_abstract_type(return_type, scope)
293            }
294            TypeExpr::Never | TypeExpr::LitString(_) | TypeExpr::LitInt(_) => false,
295        }
296    }
297
298    pub(in crate::typechecker) fn base_type_name(ty: &TypeExpr) -> Option<&str> {
299        match ty {
300            TypeExpr::Named(name) => Some(name.as_str()),
301            TypeExpr::Applied { name, .. } => Some(name.as_str()),
302            _ => None,
303        }
304    }
305
306    pub fn new() -> Self {
307        Self {
308            diagnostics: Vec::new(),
309            scope: Rc::new(TypeScope::new()),
310            source: None,
311            hints: Vec::new(),
312            strict_types: false,
313            fn_depth: 0,
314            stream_fn_depth: 0,
315            stream_emit_types: Vec::new(),
316            expected_return_types: Vec::new(),
317            deprecated_fns: std::collections::HashMap::new(),
318            imported_names: None,
319            imported_type_decls: Vec::new(),
320            imported_callable_decls: Vec::new(),
321            const_env: crate::const_eval::ConstEnv::new(),
322            subtype_cycle_guard: std::cell::RefCell::new(Vec::new()),
323        }
324    }
325
326    /// Create a type checker with strict types mode.
327    /// When enabled, flags unvalidated boundary-API values used in field access.
328    pub fn with_strict_types(strict: bool) -> Self {
329        Self {
330            diagnostics: Vec::new(),
331            scope: Rc::new(TypeScope::new()),
332            source: None,
333            hints: Vec::new(),
334            strict_types: strict,
335            fn_depth: 0,
336            stream_fn_depth: 0,
337            stream_emit_types: Vec::new(),
338            expected_return_types: Vec::new(),
339            deprecated_fns: std::collections::HashMap::new(),
340            imported_names: None,
341            imported_type_decls: Vec::new(),
342            imported_callable_decls: Vec::new(),
343            const_env: crate::const_eval::ConstEnv::new(),
344            subtype_cycle_guard: std::cell::RefCell::new(Vec::new()),
345        }
346    }
347
348    /// Attach the set of names statically introduced by cross-module imports.
349    ///
350    /// Enables strict cross-module undefined-call errors: call sites that are
351    /// not builtins, not local declarations, not struct constructors, not
352    /// callable variables, and not in `imported` will produce a type error.
353    ///
354    /// Passing `None` (the default) preserves pre-v0.7.12 behavior where
355    /// unresolved call names only surface via lint diagnostics. Callers
356    /// should only pass `Some(set)` when every import in the file resolved
357    /// — see `harn_modules::ModuleGraph::imported_names_for_file`.
358    pub fn with_imported_names(mut self, imported: HashSet<String>) -> Self {
359        self.imported_names = Some(imported);
360        self
361    }
362
363    /// Attach imported type / struct / enum / interface declarations. The
364    /// caller is responsible for resolving module imports and filtering the
365    /// visible declarations before passing them in.
366    pub fn with_imported_type_decls(mut self, imported: Vec<SNode>) -> Self {
367        self.imported_type_decls = imported;
368        self
369    }
370
371    /// Attach imported function / pipeline / tool declarations. The checker
372    /// registers only call signatures so imported pure-Harn functions enforce
373    /// their parameter annotations at the caller without checking the imported
374    /// body in the caller's scope.
375    pub fn with_imported_callable_decls(mut self, imported: Vec<SNode>) -> Self {
376        self.imported_callable_decls = imported;
377        self
378    }
379
380    /// Check a program with source text for autofix generation.
381    pub fn check_with_source(mut self, program: &[SNode], source: &str) -> Vec<TypeDiagnostic> {
382        self.source = Some(source.to_string());
383        self.check_inner(program).0
384    }
385
386    /// Check a program with strict types mode and source text.
387    pub fn check_strict_with_source(
388        mut self,
389        program: &[SNode],
390        source: &str,
391    ) -> Vec<TypeDiagnostic> {
392        self.source = Some(source.to_string());
393        self.strict_types = true;
394        self.check_inner(program).0
395    }
396
397    /// Check a program and return diagnostics.
398    pub fn check(self, program: &[SNode]) -> Vec<TypeDiagnostic> {
399        self.check_inner(program).0
400    }
401
402    /// Check whether a function call value is a boundary source that produces
403    /// unvalidated data.  Returns `None` if the value is type-safe
404    /// (e.g. llm_call with a schema option, or a non-boundary function).
405    pub(in crate::typechecker) fn detect_boundary_source(
406        value: &SNode,
407        scope: &TypeScope,
408    ) -> Option<String> {
409        match &value.node {
410            Node::FunctionCall { name, args, .. } => {
411                if !builtin_signatures::is_untyped_boundary_source(name) {
412                    return None;
413                }
414                // llm_call/llm_completion with a schema option are type-safe
415                if (name == "llm_call" || name == "llm_completion")
416                    && Self::llm_call_has_typed_schema_option(args, scope)
417                {
418                    return None;
419                }
420                Some(name.clone())
421            }
422            Node::Identifier(name) => scope.is_untyped_source(name).map(|s| s.to_string()),
423            _ => None,
424        }
425    }
426
427    /// True if an `llm_call` / `llm_completion` options dict names a
428    /// resolvable output schema. Used by the strict-types boundary checks
429    /// to suppress "unvalidated" warnings when the call site is typed.
430    /// Actual return-type narrowing is driven by the generic-builtin
431    /// dispatch path in `infer_type`, not this helper.
432    pub(in crate::typechecker) fn llm_call_has_typed_schema_option(
433        args: &[SNode],
434        scope: &TypeScope,
435    ) -> bool {
436        let Some(opts) = args.get(2) else {
437            return false;
438        };
439        let Node::DictLiteral(entries) = &opts.node else {
440            return false;
441        };
442        entries.iter().any(|entry| {
443            let key = match &entry.key.node {
444                Node::StringLiteral(k) | Node::Identifier(k) => k.as_str(),
445                _ => return false,
446            };
447            key == "output" && output_schema_type_expr_from_node(&entry.value, scope).is_some()
448        })
449    }
450
451    /// Check whether a type annotation is a concrete shape/struct type
452    /// (as opposed to bare `dict` or no annotation).
453    pub(in crate::typechecker) fn is_concrete_type(ty: &TypeExpr) -> bool {
454        matches!(
455            ty,
456            TypeExpr::Shape(_)
457                | TypeExpr::Applied { .. }
458                | TypeExpr::FnType { .. }
459                | TypeExpr::List(_)
460                | TypeExpr::Iter(_)
461                | TypeExpr::Generator(_)
462                | TypeExpr::Stream(_)
463                | TypeExpr::DictType(_, _)
464        ) || matches!(ty, TypeExpr::Named(n) if n != "dict" && n != "any" && n != "_")
465    }
466
467    /// Check a program and return both diagnostics and inlay hints.
468    pub fn check_with_hints(
469        mut self,
470        program: &[SNode],
471        source: &str,
472    ) -> (Vec<TypeDiagnostic>, Vec<InlayHintInfo>) {
473        self.source = Some(source.to_string());
474        self.check_inner(program)
475    }
476
477    pub(in crate::typechecker) fn error_at(&mut self, code: Code, message: String, span: Span) {
478        self.diagnostics.push(TypeDiagnostic {
479            code,
480            message,
481            severity: DiagnosticSeverity::Error,
482            span: Some(span),
483            help: None,
484            related: Vec::new(),
485            fix: None,
486            details: None,
487            repair: default_repair(code),
488        });
489    }
490
491    #[allow(dead_code)]
492    pub(in crate::typechecker) fn error_at_with_help(
493        &mut self,
494        code: Code,
495        message: String,
496        span: Span,
497        help: String,
498    ) {
499        self.diagnostics.push(TypeDiagnostic {
500            code,
501            message,
502            severity: DiagnosticSeverity::Error,
503            span: Some(span),
504            help: Some(help),
505            related: Vec::new(),
506            fix: None,
507            details: None,
508            repair: default_repair(code),
509        });
510    }
511
512    pub(in crate::typechecker) fn type_mismatch_at(
513        &mut self,
514        code: Code,
515        context: impl Into<String>,
516        expected: &TypeExpr,
517        actual: &TypeExpr,
518        span: Span,
519        evidence: TypeMismatchEvidence,
520        scope: &TypeScope,
521    ) {
522        let (expected_origin, value_span) = evidence;
523        let nested_mismatch = first_nested_mismatch(expected, actual, scope);
524        let mut message = format!(
525            "{}: expected {}, found {}",
526            context.into(),
527            format_type(expected),
528            format_type(actual)
529        );
530        if let Some(detail) = shape_mismatch_detail(expected, actual)
531            .or_else(|| nested_mismatch.as_ref().map(|note| note.message.clone()))
532        {
533            message.push_str(&format!(" ({detail})"));
534        }
535
536        let mut related = Vec::new();
537        if let Some((span, message)) = expected_origin {
538            related.push(RelatedDiagnostic { span, message });
539        }
540        if let Some(note) = nested_mismatch {
541            related.push(RelatedDiagnostic {
542                span,
543                message: format!("nested mismatch: {}", note.message),
544            });
545        }
546
547        self.diagnostics.push(TypeDiagnostic {
548            code,
549            message,
550            severity: DiagnosticSeverity::Error,
551            span: Some(span),
552            help: coercion_suggestion(expected, actual, value_span, self.source.as_deref()),
553            related,
554            fix: None,
555            details: Some(DiagnosticDetails::TypeMismatch),
556            repair: default_repair(code),
557        });
558    }
559
560    pub(in crate::typechecker) fn error_at_with_fix(
561        &mut self,
562        code: Code,
563        message: String,
564        span: Span,
565        fix: Vec<FixEdit>,
566    ) {
567        self.diagnostics.push(TypeDiagnostic {
568            code,
569            message,
570            severity: DiagnosticSeverity::Error,
571            span: Some(span),
572            help: None,
573            related: Vec::new(),
574            fix: Some(fix),
575            details: None,
576            repair: default_repair(code),
577        });
578    }
579
580    /// Diagnostic site for non-exhaustive `match` arms. Match arms must be
581    /// exhaustive — a missing-case `match` is a hard error. Authors who
582    /// genuinely want partial coverage opt out with a wildcard `_` arm.
583    /// The missing-case list is structured so LSP code-actions can synthesize
584    /// "Add missing match arms" fixes without string-parsing the message.
585    pub(in crate::typechecker) fn exhaustiveness_error_with_missing(
586        &mut self,
587        code: Code,
588        message: String,
589        span: Span,
590        missing: Vec<String>,
591    ) {
592        self.diagnostics.push(TypeDiagnostic {
593            code,
594            message,
595            severity: DiagnosticSeverity::Error,
596            span: Some(span),
597            help: None,
598            related: Vec::new(),
599            fix: None,
600            details: Some(DiagnosticDetails::NonExhaustiveMatch { missing }),
601            repair: default_repair(code),
602        });
603    }
604
605    pub(in crate::typechecker) fn warning_at(&mut self, code: Code, message: String, span: Span) {
606        self.diagnostics.push(TypeDiagnostic {
607            code,
608            message,
609            severity: DiagnosticSeverity::Warning,
610            span: Some(span),
611            help: None,
612            related: Vec::new(),
613            fix: None,
614            details: None,
615            repair: default_repair(code),
616        });
617    }
618
619    #[allow(dead_code)]
620    pub(in crate::typechecker) fn warning_at_with_help(
621        &mut self,
622        code: Code,
623        message: String,
624        span: Span,
625        help: String,
626    ) {
627        self.diagnostics.push(TypeDiagnostic {
628            code,
629            message,
630            severity: DiagnosticSeverity::Warning,
631            span: Some(span),
632            help: Some(help),
633            related: Vec::new(),
634            fix: None,
635            details: None,
636            repair: default_repair(code),
637        });
638    }
639
640    pub(in crate::typechecker) fn lint_warning_at_with_fix(
641        &mut self,
642        code: Code,
643        rule: &'static str,
644        message: String,
645        span: Span,
646        help: String,
647        fix: Vec<FixEdit>,
648    ) {
649        self.diagnostics.push(TypeDiagnostic {
650            code,
651            message,
652            severity: DiagnosticSeverity::Warning,
653            span: Some(span),
654            help: Some(help),
655            related: Vec::new(),
656            fix: Some(fix),
657            details: Some(DiagnosticDetails::LintRule { rule }),
658            repair: default_repair(code),
659        });
660    }
661}
662
663/// Materialize the default [`Repair`] for a diagnostic code, or `None`
664/// if no static repair shape is registered. Cheap (one pointer
665/// dereference plus an allocation for the summary string); call sites
666/// pay nothing when the code has no repair template.
667pub(crate) fn default_repair(code: Code) -> Option<Repair> {
668    code.repair_template().map(Repair::from_template)
669}
670
671#[derive(Debug)]
672struct MismatchNote {
673    message: String,
674}
675
676fn first_nested_mismatch(
677    expected: &TypeExpr,
678    actual: &TypeExpr,
679    scope: &TypeScope,
680) -> Option<MismatchNote> {
681    let expected = resolve_type_for_diagnostic(expected, scope);
682    let actual = resolve_type_for_diagnostic(actual, scope);
683    match (&expected, &actual) {
684        (TypeExpr::Shape(expected_fields), TypeExpr::Shape(actual_fields)) => {
685            for expected_field in expected_fields {
686                if expected_field.optional {
687                    continue;
688                }
689                let Some(actual_field) = actual_fields
690                    .iter()
691                    .find(|actual_field| actual_field.name == expected_field.name)
692                else {
693                    return Some(MismatchNote {
694                        message: format!(
695                            "field `{}` is missing; expected {}",
696                            expected_field.name,
697                            format_type(&expected_field.type_expr)
698                        ),
699                    });
700                };
701                if !types_compatible_for_diagnostic(
702                    &expected_field.type_expr,
703                    &actual_field.type_expr,
704                    scope,
705                ) {
706                    return Some(MismatchNote {
707                        message: format!(
708                            "field `{}` expected {}, found {}",
709                            expected_field.name,
710                            format_type(&expected_field.type_expr),
711                            format_type(&actual_field.type_expr)
712                        ),
713                    });
714                }
715            }
716            None
717        }
718        (TypeExpr::List(expected_inner), TypeExpr::List(actual_inner)) => {
719            if !types_compatible_for_diagnostic(expected_inner, actual_inner, scope)
720                || !types_compatible_for_diagnostic(actual_inner, expected_inner, scope)
721            {
722                Some(MismatchNote {
723                    message: format!(
724                        "list element expected {}, found {}",
725                        format_type(expected_inner),
726                        format_type(actual_inner)
727                    ),
728                })
729            } else {
730                None
731            }
732        }
733        (
734            TypeExpr::DictType(expected_key, expected_value),
735            TypeExpr::DictType(actual_key, actual_value),
736        ) => {
737            if !types_compatible_for_diagnostic(expected_key, actual_key, scope)
738                || !types_compatible_for_diagnostic(actual_key, expected_key, scope)
739            {
740                Some(MismatchNote {
741                    message: format!(
742                        "dict key expected {}, found {}",
743                        format_type(expected_key),
744                        format_type(actual_key)
745                    ),
746                })
747            } else if !types_compatible_for_diagnostic(expected_value, actual_value, scope)
748                || !types_compatible_for_diagnostic(actual_value, expected_value, scope)
749            {
750                Some(MismatchNote {
751                    message: format!(
752                        "dict value expected {}, found {}",
753                        format_type(expected_value),
754                        format_type(actual_value)
755                    ),
756                })
757            } else {
758                None
759            }
760        }
761        (
762            TypeExpr::Applied {
763                name: expected_name,
764                args: expected_args,
765            },
766            TypeExpr::Applied {
767                name: actual_name,
768                args: actual_args,
769            },
770        ) if expected_name == actual_name => expected_args
771            .iter()
772            .zip(actual_args.iter())
773            .enumerate()
774            .find_map(|(idx, (expected_arg, actual_arg))| {
775                if types_compatible_for_diagnostic(expected_arg, actual_arg, scope)
776                    && types_compatible_for_diagnostic(actual_arg, expected_arg, scope)
777                {
778                    None
779                } else {
780                    Some(MismatchNote {
781                        message: format!(
782                            "{} type argument {} expected {}, found {}",
783                            expected_name,
784                            idx + 1,
785                            format_type(expected_arg),
786                            format_type(actual_arg)
787                        ),
788                    })
789                }
790            }),
791        (
792            TypeExpr::FnType {
793                params: expected_params,
794                return_type: expected_return,
795            },
796            TypeExpr::FnType {
797                params: actual_params,
798                return_type: actual_return,
799            },
800        ) => {
801            for (idx, (expected_param, actual_param)) in
802                expected_params.iter().zip(actual_params.iter()).enumerate()
803            {
804                if !types_compatible_for_diagnostic(actual_param, expected_param, scope) {
805                    return Some(MismatchNote {
806                        message: format!(
807                            "function parameter {} expected {}, found {}",
808                            idx + 1,
809                            format_type(expected_param),
810                            format_type(actual_param)
811                        ),
812                    });
813                }
814            }
815            if !types_compatible_for_diagnostic(expected_return, actual_return, scope) {
816                Some(MismatchNote {
817                    message: format!(
818                        "function return expected {}, found {}",
819                        format_type(expected_return),
820                        format_type(actual_return)
821                    ),
822                })
823            } else {
824                None
825            }
826        }
827        _ => None,
828    }
829}
830
831fn types_compatible_for_diagnostic(
832    expected: &TypeExpr,
833    actual: &TypeExpr,
834    scope: &TypeScope,
835) -> bool {
836    TypeChecker::new().types_compatible(expected, actual, scope)
837}
838
839fn resolve_type_for_diagnostic(ty: &TypeExpr, scope: &TypeScope) -> TypeExpr {
840    TypeChecker::new().resolve_alias(ty, scope)
841}
842
843fn coercion_suggestion(
844    expected: &TypeExpr,
845    actual: &TypeExpr,
846    value_span: Option<Span>,
847    source: Option<&str>,
848) -> Option<String> {
849    let expr = value_span
850        .and_then(|span| source.and_then(|source| source.get(span.start..span.end)))
851        .map(str::trim)
852        .filter(|expr| !expr.is_empty());
853    if is_nilable(actual) {
854        return Some("handle `nil` first or provide a default with `??`".to_string());
855    }
856    let expected_ty = expected;
857    let expected = simple_type_name(expected)?;
858    let actual_name = simple_type_name(actual)?;
859    let with_expr = |template: &str| {
860        expr.map(|expr| template.replace("{}", expr))
861            .unwrap_or_else(|| template.replace("{}", "value"))
862    };
863
864    match (expected, actual_name) {
865        ("string", "int" | "float" | "bool" | "nil" | "duration") => {
866            Some(format!("did you mean `{}`?", with_expr("to_string({})")))
867        }
868        ("int", "string") => Some(format!("did you mean `{}`?", with_expr("to_int({})"))),
869        ("float", "string" | "int") => {
870            Some(format!("did you mean `{}`?", with_expr("to_float({})")))
871        }
872        (_, "nil") => Some("handle `nil` first or provide a default with `??`".to_string()),
873        _ if actual_is_result_of(expected_ty, actual) => Some(format!(
874            "did you mean `{}` or `{}`?",
875            with_expr("{}?"),
876            with_expr("unwrap_or({}, default)")
877        )),
878        _ => None,
879    }
880}
881
882fn simple_type_name(ty: &TypeExpr) -> Option<&str> {
883    match ty {
884        TypeExpr::Named(name) => Some(name.as_str()),
885        TypeExpr::LitString(_) => Some("string"),
886        TypeExpr::LitInt(_) => Some("int"),
887        _ => None,
888    }
889}
890
891fn is_nilable(ty: &TypeExpr) -> bool {
892    match ty {
893        TypeExpr::Union(members) if members.len() == 2 => members
894            .iter()
895            .any(|member| matches!(member, TypeExpr::Named(name) if name == "nil")),
896        _ => false,
897    }
898}
899
900fn actual_is_result_of(expected: &TypeExpr, actual: &TypeExpr) -> bool {
901    matches!(
902        actual,
903        TypeExpr::Applied { name, args }
904            if name == "Result" && args.first().is_some_and(|ok| ok == expected)
905    )
906}
907
908/// The names of the gradual *top* types — values whose static type is
909/// deliberately unknown (`any`/`unknown`) or a wildcard (`_`). A gradual type
910/// is assignment- and operator-compatible with everything; the real check is
911/// deferred to runtime. Centralized so every site that special-cases "we don't
912/// statically know this type" agrees on the same set. Note this is the
913/// non-`nil` gradual set: callers that also want to treat `nil` leniently must
914/// check for it separately.
915pub(in crate::typechecker) fn is_gradual_type_name(name: &str) -> bool {
916    matches!(name, "any" | "unknown" | "_")
917}
918
919impl Default for TypeChecker {
920    fn default() -> Self {
921        Self::new()
922    }
923}
924
925#[cfg(test)]
926mod tests;