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-case `match` is a hard error. Authors who
565    /// genuinely want partial coverage opt out with a wildcard `_` arm.
566    /// The missing-case list is structured so LSP code-actions can synthesize
567    /// "Add missing match arms" fixes without string-parsing the message.
568    pub(in crate::typechecker) fn exhaustiveness_error_with_missing(
569        &mut self,
570        code: Code,
571        message: String,
572        span: Span,
573        missing: Vec<String>,
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: Some(DiagnosticDetails::NonExhaustiveMatch { missing }),
584            repair: default_repair(code),
585        });
586    }
587
588    pub(in crate::typechecker) fn warning_at(&mut self, code: Code, message: String, span: Span) {
589        self.diagnostics.push(TypeDiagnostic {
590            code,
591            message,
592            severity: DiagnosticSeverity::Warning,
593            span: Some(span),
594            help: None,
595            related: Vec::new(),
596            fix: None,
597            details: None,
598            repair: default_repair(code),
599        });
600    }
601
602    #[allow(dead_code)]
603    pub(in crate::typechecker) fn warning_at_with_help(
604        &mut self,
605        code: Code,
606        message: String,
607        span: Span,
608        help: String,
609    ) {
610        self.diagnostics.push(TypeDiagnostic {
611            code,
612            message,
613            severity: DiagnosticSeverity::Warning,
614            span: Some(span),
615            help: Some(help),
616            related: Vec::new(),
617            fix: None,
618            details: None,
619            repair: default_repair(code),
620        });
621    }
622
623    pub(in crate::typechecker) fn lint_warning_at_with_fix(
624        &mut self,
625        code: Code,
626        rule: &'static str,
627        message: String,
628        span: Span,
629        help: String,
630        fix: Vec<FixEdit>,
631    ) {
632        self.diagnostics.push(TypeDiagnostic {
633            code,
634            message,
635            severity: DiagnosticSeverity::Warning,
636            span: Some(span),
637            help: Some(help),
638            related: Vec::new(),
639            fix: Some(fix),
640            details: Some(DiagnosticDetails::LintRule { rule }),
641            repair: default_repair(code),
642        });
643    }
644}
645
646/// Materialize the default [`Repair`] for a diagnostic code, or `None`
647/// if no static repair shape is registered. Cheap (one pointer
648/// dereference plus an allocation for the summary string); call sites
649/// pay nothing when the code has no repair template.
650pub(crate) fn default_repair(code: Code) -> Option<Repair> {
651    code.repair_template().map(Repair::from_template)
652}
653
654#[derive(Debug)]
655struct MismatchNote {
656    message: String,
657}
658
659fn first_nested_mismatch(
660    expected: &TypeExpr,
661    actual: &TypeExpr,
662    scope: &TypeScope,
663) -> Option<MismatchNote> {
664    let expected = resolve_type_for_diagnostic(expected, scope);
665    let actual = resolve_type_for_diagnostic(actual, scope);
666    match (&expected, &actual) {
667        (TypeExpr::Shape(expected_fields), TypeExpr::Shape(actual_fields)) => {
668            for expected_field in expected_fields {
669                if expected_field.optional {
670                    continue;
671                }
672                let Some(actual_field) = actual_fields
673                    .iter()
674                    .find(|actual_field| actual_field.name == expected_field.name)
675                else {
676                    return Some(MismatchNote {
677                        message: format!(
678                            "field `{}` is missing; expected {}",
679                            expected_field.name,
680                            format_type(&expected_field.type_expr)
681                        ),
682                    });
683                };
684                if !types_compatible_for_diagnostic(
685                    &expected_field.type_expr,
686                    &actual_field.type_expr,
687                    scope,
688                ) {
689                    return Some(MismatchNote {
690                        message: format!(
691                            "field `{}` expected {}, found {}",
692                            expected_field.name,
693                            format_type(&expected_field.type_expr),
694                            format_type(&actual_field.type_expr)
695                        ),
696                    });
697                }
698            }
699            None
700        }
701        (TypeExpr::List(expected_inner), TypeExpr::List(actual_inner)) => {
702            if !types_compatible_for_diagnostic(expected_inner, actual_inner, scope)
703                || !types_compatible_for_diagnostic(actual_inner, expected_inner, scope)
704            {
705                Some(MismatchNote {
706                    message: format!(
707                        "list element expected {}, found {}",
708                        format_type(expected_inner),
709                        format_type(actual_inner)
710                    ),
711                })
712            } else {
713                None
714            }
715        }
716        (
717            TypeExpr::DictType(expected_key, expected_value),
718            TypeExpr::DictType(actual_key, actual_value),
719        ) => {
720            if !types_compatible_for_diagnostic(expected_key, actual_key, scope)
721                || !types_compatible_for_diagnostic(actual_key, expected_key, scope)
722            {
723                Some(MismatchNote {
724                    message: format!(
725                        "dict key expected {}, found {}",
726                        format_type(expected_key),
727                        format_type(actual_key)
728                    ),
729                })
730            } else if !types_compatible_for_diagnostic(expected_value, actual_value, scope)
731                || !types_compatible_for_diagnostic(actual_value, expected_value, scope)
732            {
733                Some(MismatchNote {
734                    message: format!(
735                        "dict value expected {}, found {}",
736                        format_type(expected_value),
737                        format_type(actual_value)
738                    ),
739                })
740            } else {
741                None
742            }
743        }
744        (
745            TypeExpr::Applied {
746                name: expected_name,
747                args: expected_args,
748            },
749            TypeExpr::Applied {
750                name: actual_name,
751                args: actual_args,
752            },
753        ) if expected_name == actual_name => expected_args
754            .iter()
755            .zip(actual_args.iter())
756            .enumerate()
757            .find_map(|(idx, (expected_arg, actual_arg))| {
758                if types_compatible_for_diagnostic(expected_arg, actual_arg, scope)
759                    && types_compatible_for_diagnostic(actual_arg, expected_arg, scope)
760                {
761                    None
762                } else {
763                    Some(MismatchNote {
764                        message: format!(
765                            "{} type argument {} expected {}, found {}",
766                            expected_name,
767                            idx + 1,
768                            format_type(expected_arg),
769                            format_type(actual_arg)
770                        ),
771                    })
772                }
773            }),
774        (
775            TypeExpr::FnType {
776                params: expected_params,
777                return_type: expected_return,
778            },
779            TypeExpr::FnType {
780                params: actual_params,
781                return_type: actual_return,
782            },
783        ) => {
784            for (idx, (expected_param, actual_param)) in
785                expected_params.iter().zip(actual_params.iter()).enumerate()
786            {
787                if !types_compatible_for_diagnostic(actual_param, expected_param, scope) {
788                    return Some(MismatchNote {
789                        message: format!(
790                            "function parameter {} expected {}, found {}",
791                            idx + 1,
792                            format_type(expected_param),
793                            format_type(actual_param)
794                        ),
795                    });
796                }
797            }
798            if !types_compatible_for_diagnostic(expected_return, actual_return, scope) {
799                Some(MismatchNote {
800                    message: format!(
801                        "function return expected {}, found {}",
802                        format_type(expected_return),
803                        format_type(actual_return)
804                    ),
805                })
806            } else {
807                None
808            }
809        }
810        _ => None,
811    }
812}
813
814fn types_compatible_for_diagnostic(
815    expected: &TypeExpr,
816    actual: &TypeExpr,
817    scope: &TypeScope,
818) -> bool {
819    TypeChecker::new().types_compatible(expected, actual, scope)
820}
821
822fn resolve_type_for_diagnostic(ty: &TypeExpr, scope: &TypeScope) -> TypeExpr {
823    TypeChecker::new().resolve_alias(ty, scope)
824}
825
826fn coercion_suggestion(
827    expected: &TypeExpr,
828    actual: &TypeExpr,
829    value_span: Option<Span>,
830    source: Option<&str>,
831) -> Option<String> {
832    let expr = value_span
833        .and_then(|span| source.and_then(|source| source.get(span.start..span.end)))
834        .map(str::trim)
835        .filter(|expr| !expr.is_empty());
836    if is_nilable(actual) {
837        return Some("handle `nil` first or provide a default with `??`".to_string());
838    }
839    let expected_ty = expected;
840    let expected = simple_type_name(expected)?;
841    let actual_name = simple_type_name(actual)?;
842    let with_expr = |template: &str| {
843        expr.map(|expr| template.replace("{}", expr))
844            .unwrap_or_else(|| template.replace("{}", "value"))
845    };
846
847    match (expected, actual_name) {
848        ("string", "int" | "float" | "bool" | "nil" | "duration") => {
849            Some(format!("did you mean `{}`?", with_expr("to_string({})")))
850        }
851        ("int", "string") => Some(format!("did you mean `{}`?", with_expr("to_int({})"))),
852        ("float", "string" | "int") => {
853            Some(format!("did you mean `{}`?", with_expr("to_float({})")))
854        }
855        (_, "nil") => Some("handle `nil` first or provide a default with `??`".to_string()),
856        _ if actual_is_result_of(expected_ty, actual) => Some(format!(
857            "did you mean `{}` or `{}`?",
858            with_expr("{}?"),
859            with_expr("unwrap_or({}, default)")
860        )),
861        _ => None,
862    }
863}
864
865fn simple_type_name(ty: &TypeExpr) -> Option<&str> {
866    match ty {
867        TypeExpr::Named(name) => Some(name.as_str()),
868        TypeExpr::LitString(_) => Some("string"),
869        TypeExpr::LitInt(_) => Some("int"),
870        _ => None,
871    }
872}
873
874fn is_nilable(ty: &TypeExpr) -> bool {
875    match ty {
876        TypeExpr::Union(members) if members.len() == 2 => members
877            .iter()
878            .any(|member| matches!(member, TypeExpr::Named(name) if name == "nil")),
879        _ => false,
880    }
881}
882
883fn actual_is_result_of(expected: &TypeExpr, actual: &TypeExpr) -> bool {
884    matches!(
885        actual,
886        TypeExpr::Applied { name, args }
887            if name == "Result" && args.first().is_some_and(|ok| ok == expected)
888    )
889}
890
891/// The names of the gradual *top* types — values whose static type is
892/// deliberately unknown (`any`/`unknown`) or a wildcard (`_`). A gradual type
893/// is assignment- and operator-compatible with everything; the real check is
894/// deferred to runtime. Centralized so every site that special-cases "we don't
895/// statically know this type" agrees on the same set. Note this is the
896/// non-`nil` gradual set: callers that also want to treat `nil` leniently must
897/// check for it separately.
898pub(in crate::typechecker) fn is_gradual_type_name(name: &str) -> bool {
899    matches!(name, "any" | "unknown" | "_")
900}
901
902impl Default for TypeChecker {
903    fn default() -> Self {
904        Self::new()
905    }
906}
907
908#[cfg(test)]
909mod tests;