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