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