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