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