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