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