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