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