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