Skip to main content

harn_parser/typechecker/
mod.rs

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