Skip to main content

harn_parser/typechecker/
mod.rs

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