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