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