Skip to main content

harn_parser/typechecker/
mod.rs

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