Skip to main content

harn_parser/typechecker/
mod.rs

1use std::collections::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    /// Maps function name -> deprecation metadata `(since, use_hint)`. Populated
117    /// when an `@deprecated` attribute is encountered on a top-level fn decl
118    /// during the `check_inner` pre-pass; consulted at every `FunctionCall`
119    /// site to emit a warning + help line.
120    deprecated_fns: std::collections::HashMap<String, (Option<String>, Option<String>)>,
121    /// Names statically known to be introduced by cross-module imports
122    /// (resolved via `harn-modules`). `Some(set)` switches the checker into
123    /// strict cross-module mode: an unresolved callable name is reported as
124    /// an error instead of silently passing through. `None` preserves the
125    /// conservative pre-v0.7.12 behavior (no cross-module undefined-name
126    /// diagnostics).
127    imported_names: Option<HashSet<String>>,
128    /// Type-like declarations imported from other modules. These are registered
129    /// into the scope before local checking so imported type aliases and tagged
130    /// unions participate in normal field access and narrowing.
131    imported_type_decls: Vec<SNode>,
132    /// Callable declarations imported from other modules. Only their
133    /// signatures are registered; bodies stay owned by the defining module.
134    imported_callable_decls: Vec<SNode>,
135    /// Compile-time environment populated by every successfully folded
136    /// `const` binding. Later const initializers see earlier values so
137    /// expressions like `const Y = X + 1` work.
138    const_env: crate::const_eval::ConstEnv,
139}
140
141impl TypeChecker {
142    pub(in crate::typechecker) fn wildcard_type() -> TypeExpr {
143        TypeExpr::Named("_".into())
144    }
145
146    pub(in crate::typechecker) fn is_wildcard_type(ty: &TypeExpr) -> bool {
147        matches!(ty, TypeExpr::Named(name) if name == "_")
148    }
149
150    pub(in crate::typechecker) fn base_type_name(ty: &TypeExpr) -> Option<&str> {
151        match ty {
152            TypeExpr::Named(name) => Some(name.as_str()),
153            TypeExpr::Applied { name, .. } => Some(name.as_str()),
154            _ => None,
155        }
156    }
157
158    pub fn new() -> Self {
159        Self {
160            diagnostics: Vec::new(),
161            scope: Rc::new(TypeScope::new()),
162            source: None,
163            hints: Vec::new(),
164            strict_types: false,
165            fn_depth: 0,
166            stream_fn_depth: 0,
167            stream_emit_types: Vec::new(),
168            deprecated_fns: std::collections::HashMap::new(),
169            imported_names: None,
170            imported_type_decls: Vec::new(),
171            imported_callable_decls: Vec::new(),
172            const_env: crate::const_eval::ConstEnv::new(),
173        }
174    }
175
176    /// Create a type checker with strict types mode.
177    /// When enabled, flags unvalidated boundary-API values used in field access.
178    pub fn with_strict_types(strict: bool) -> Self {
179        Self {
180            diagnostics: Vec::new(),
181            scope: Rc::new(TypeScope::new()),
182            source: None,
183            hints: Vec::new(),
184            strict_types: strict,
185            fn_depth: 0,
186            stream_fn_depth: 0,
187            stream_emit_types: Vec::new(),
188            deprecated_fns: std::collections::HashMap::new(),
189            imported_names: None,
190            imported_type_decls: Vec::new(),
191            imported_callable_decls: Vec::new(),
192            const_env: crate::const_eval::ConstEnv::new(),
193        }
194    }
195
196    /// Attach the set of names statically introduced by cross-module imports.
197    ///
198    /// Enables strict cross-module undefined-call errors: call sites that are
199    /// not builtins, not local declarations, not struct constructors, not
200    /// callable variables, and not in `imported` will produce a type error.
201    ///
202    /// Passing `None` (the default) preserves pre-v0.7.12 behavior where
203    /// unresolved call names only surface via lint diagnostics. Callers
204    /// should only pass `Some(set)` when every import in the file resolved
205    /// — see `harn_modules::ModuleGraph::imported_names_for_file`.
206    pub fn with_imported_names(mut self, imported: HashSet<String>) -> Self {
207        self.imported_names = Some(imported);
208        self
209    }
210
211    /// Attach imported type / struct / enum / interface declarations. The
212    /// caller is responsible for resolving module imports and filtering the
213    /// visible declarations before passing them in.
214    pub fn with_imported_type_decls(mut self, imported: Vec<SNode>) -> Self {
215        self.imported_type_decls = imported;
216        self
217    }
218
219    /// Attach imported function / pipeline / tool declarations. The checker
220    /// registers only call signatures so imported pure-Harn functions enforce
221    /// their parameter annotations at the caller without checking the imported
222    /// body in the caller's scope.
223    pub fn with_imported_callable_decls(mut self, imported: Vec<SNode>) -> Self {
224        self.imported_callable_decls = imported;
225        self
226    }
227
228    /// Check a program with source text for autofix generation.
229    pub fn check_with_source(mut self, program: &[SNode], source: &str) -> Vec<TypeDiagnostic> {
230        self.source = Some(source.to_string());
231        self.check_inner(program).0
232    }
233
234    /// Check a program with strict types mode and source text.
235    pub fn check_strict_with_source(
236        mut self,
237        program: &[SNode],
238        source: &str,
239    ) -> Vec<TypeDiagnostic> {
240        self.source = Some(source.to_string());
241        self.check_inner(program).0
242    }
243
244    /// Check a program and return diagnostics.
245    pub fn check(self, program: &[SNode]) -> Vec<TypeDiagnostic> {
246        self.check_inner(program).0
247    }
248
249    /// Check whether a function call value is a boundary source that produces
250    /// unvalidated data.  Returns `None` if the value is type-safe
251    /// (e.g. llm_call with a schema option, or a non-boundary function).
252    pub(in crate::typechecker) fn detect_boundary_source(
253        value: &SNode,
254        scope: &TypeScope,
255    ) -> Option<String> {
256        match &value.node {
257            Node::FunctionCall { name, args, .. } => {
258                if !builtin_signatures::is_untyped_boundary_source(name) {
259                    return None;
260                }
261                // llm_call/llm_completion with a schema option are type-safe
262                if (name == "llm_call" || name == "llm_completion")
263                    && Self::llm_call_has_typed_schema_option(args, scope)
264                {
265                    return None;
266                }
267                Some(name.clone())
268            }
269            Node::Identifier(name) => scope.is_untyped_source(name).map(|s| s.to_string()),
270            _ => None,
271        }
272    }
273
274    /// True if an `llm_call` / `llm_completion` options dict names a
275    /// resolvable output schema. Used by the strict-types boundary checks
276    /// to suppress "unvalidated" warnings when the call site is typed.
277    /// Actual return-type narrowing is driven by the generic-builtin
278    /// dispatch path in `infer_type`, not this helper.
279    pub(in crate::typechecker) fn llm_call_has_typed_schema_option(
280        args: &[SNode],
281        scope: &TypeScope,
282    ) -> bool {
283        let Some(opts) = args.get(2) else {
284            return false;
285        };
286        let Node::DictLiteral(entries) = &opts.node else {
287            return false;
288        };
289        entries.iter().any(|entry| {
290            let key = match &entry.key.node {
291                Node::StringLiteral(k) | Node::Identifier(k) => k.as_str(),
292                _ => return false,
293            };
294            (key == "schema" || key == "output_schema")
295                && schema_type_expr_from_node(&entry.value, scope).is_some()
296        })
297    }
298
299    /// Check whether a type annotation is a concrete shape/struct type
300    /// (as opposed to bare `dict` or no annotation).
301    pub(in crate::typechecker) fn is_concrete_type(ty: &TypeExpr) -> bool {
302        matches!(
303            ty,
304            TypeExpr::Shape(_)
305                | TypeExpr::Applied { .. }
306                | TypeExpr::FnType { .. }
307                | TypeExpr::List(_)
308                | TypeExpr::Iter(_)
309                | TypeExpr::Generator(_)
310                | TypeExpr::Stream(_)
311                | TypeExpr::DictType(_, _)
312        ) || matches!(ty, TypeExpr::Named(n) if n != "dict" && n != "any" && n != "_")
313    }
314
315    /// Check a program and return both diagnostics and inlay hints.
316    pub fn check_with_hints(
317        mut self,
318        program: &[SNode],
319        source: &str,
320    ) -> (Vec<TypeDiagnostic>, Vec<InlayHintInfo>) {
321        self.source = Some(source.to_string());
322        self.check_inner(program)
323    }
324
325    pub(in crate::typechecker) fn error_at(&mut self, code: Code, message: String, span: Span) {
326        self.diagnostics.push(TypeDiagnostic {
327            code,
328            message,
329            severity: DiagnosticSeverity::Error,
330            span: Some(span),
331            help: None,
332            related: Vec::new(),
333            fix: None,
334            details: None,
335            repair: default_repair(code),
336        });
337    }
338
339    #[allow(dead_code)]
340    pub(in crate::typechecker) fn error_at_with_help(
341        &mut self,
342        code: Code,
343        message: String,
344        span: Span,
345        help: String,
346    ) {
347        self.diagnostics.push(TypeDiagnostic {
348            code,
349            message,
350            severity: DiagnosticSeverity::Error,
351            span: Some(span),
352            help: Some(help),
353            related: Vec::new(),
354            fix: None,
355            details: None,
356            repair: default_repair(code),
357        });
358    }
359
360    pub(in crate::typechecker) fn type_mismatch_at(
361        &mut self,
362        code: Code,
363        context: impl Into<String>,
364        expected: &TypeExpr,
365        actual: &TypeExpr,
366        span: Span,
367        evidence: TypeMismatchEvidence,
368        scope: &TypeScope,
369    ) {
370        let (expected_origin, value_span) = evidence;
371        let nested_mismatch = first_nested_mismatch(expected, actual, scope);
372        let mut message = format!(
373            "{}: expected {}, found {}",
374            context.into(),
375            format_type(expected),
376            format_type(actual)
377        );
378        if let Some(detail) = shape_mismatch_detail(expected, actual)
379            .or_else(|| nested_mismatch.as_ref().map(|note| note.message.clone()))
380        {
381            message.push_str(&format!(" ({detail})"));
382        }
383
384        let mut related = Vec::new();
385        if let Some((span, message)) = expected_origin {
386            related.push(RelatedDiagnostic { span, message });
387        }
388        if let Some(note) = nested_mismatch {
389            related.push(RelatedDiagnostic {
390                span,
391                message: format!("nested mismatch: {}", note.message),
392            });
393        }
394
395        self.diagnostics.push(TypeDiagnostic {
396            code,
397            message,
398            severity: DiagnosticSeverity::Error,
399            span: Some(span),
400            help: coercion_suggestion(expected, actual, value_span, self.source.as_deref()),
401            related,
402            fix: None,
403            details: Some(DiagnosticDetails::TypeMismatch),
404            repair: default_repair(code),
405        });
406    }
407
408    pub(in crate::typechecker) fn error_at_with_fix(
409        &mut self,
410        code: Code,
411        message: String,
412        span: Span,
413        fix: Vec<FixEdit>,
414    ) {
415        self.diagnostics.push(TypeDiagnostic {
416            code,
417            message,
418            severity: DiagnosticSeverity::Error,
419            span: Some(span),
420            help: None,
421            related: Vec::new(),
422            fix: Some(fix),
423            details: None,
424            repair: default_repair(code),
425        });
426    }
427
428    /// Diagnostic site for non-exhaustive `match` arms. Match arms must be
429    /// exhaustive — a missing-variant `match` is a hard error. Authors who
430    /// genuinely want partial coverage opt out with a wildcard `_` arm.
431    /// Partial `if/elif/else` chains are intentionally allowed and are
432    /// instead handled by `check_unknown_exhaustiveness`, which stays a
433    /// warning so the `unreachable()` opt-in pattern continues to work.
434    pub(in crate::typechecker) fn exhaustiveness_error_at(
435        &mut self,
436        code: Code,
437        message: String,
438        span: Span,
439    ) {
440        self.diagnostics.push(TypeDiagnostic {
441            code,
442            message,
443            severity: DiagnosticSeverity::Error,
444            span: Some(span),
445            help: None,
446            related: Vec::new(),
447            fix: None,
448            details: None,
449            repair: default_repair(code),
450        });
451    }
452
453    /// Like `exhaustiveness_error_at` but additionally attaches the
454    /// missing-variant list as structured details. LSP code-actions
455    /// read this to synthesise an "Add missing match arms" quick-fix
456    /// without string-parsing the message.
457    pub(in crate::typechecker) fn exhaustiveness_error_with_missing(
458        &mut self,
459        code: Code,
460        message: String,
461        span: Span,
462        missing: Vec<String>,
463    ) {
464        self.diagnostics.push(TypeDiagnostic {
465            code,
466            message,
467            severity: DiagnosticSeverity::Error,
468            span: Some(span),
469            help: None,
470            related: Vec::new(),
471            fix: None,
472            details: Some(DiagnosticDetails::NonExhaustiveMatch { missing }),
473            repair: default_repair(code),
474        });
475    }
476
477    pub(in crate::typechecker) fn warning_at(&mut self, code: Code, message: String, span: Span) {
478        self.diagnostics.push(TypeDiagnostic {
479            code,
480            message,
481            severity: DiagnosticSeverity::Warning,
482            span: Some(span),
483            help: None,
484            related: Vec::new(),
485            fix: None,
486            details: None,
487            repair: default_repair(code),
488        });
489    }
490
491    #[allow(dead_code)]
492    pub(in crate::typechecker) fn warning_at_with_help(
493        &mut self,
494        code: Code,
495        message: String,
496        span: Span,
497        help: String,
498    ) {
499        self.diagnostics.push(TypeDiagnostic {
500            code,
501            message,
502            severity: DiagnosticSeverity::Warning,
503            span: Some(span),
504            help: Some(help),
505            related: Vec::new(),
506            fix: None,
507            details: None,
508            repair: default_repair(code),
509        });
510    }
511
512    pub(in crate::typechecker) fn lint_warning_at_with_fix(
513        &mut self,
514        code: Code,
515        rule: &'static str,
516        message: String,
517        span: Span,
518        help: String,
519        fix: Vec<FixEdit>,
520    ) {
521        self.diagnostics.push(TypeDiagnostic {
522            code,
523            message,
524            severity: DiagnosticSeverity::Warning,
525            span: Some(span),
526            help: Some(help),
527            related: Vec::new(),
528            fix: Some(fix),
529            details: Some(DiagnosticDetails::LintRule { rule }),
530            repair: default_repair(code),
531        });
532    }
533}
534
535/// Materialize the default [`Repair`] for a diagnostic code, or `None`
536/// if no static repair shape is registered. Cheap (one pointer
537/// dereference plus an allocation for the summary string); call sites
538/// pay nothing when the code has no repair template.
539pub(crate) fn default_repair(code: Code) -> Option<Repair> {
540    code.repair_template().map(Repair::from_template)
541}
542
543#[derive(Debug)]
544struct MismatchNote {
545    message: String,
546}
547
548fn first_nested_mismatch(
549    expected: &TypeExpr,
550    actual: &TypeExpr,
551    scope: &TypeScope,
552) -> Option<MismatchNote> {
553    let expected = resolve_type_for_diagnostic(expected, scope);
554    let actual = resolve_type_for_diagnostic(actual, scope);
555    match (&expected, &actual) {
556        (TypeExpr::Shape(expected_fields), TypeExpr::Shape(actual_fields)) => {
557            for expected_field in expected_fields {
558                if expected_field.optional {
559                    continue;
560                }
561                let Some(actual_field) = actual_fields
562                    .iter()
563                    .find(|actual_field| actual_field.name == expected_field.name)
564                else {
565                    return Some(MismatchNote {
566                        message: format!(
567                            "field `{}` is missing; expected {}",
568                            expected_field.name,
569                            format_type(&expected_field.type_expr)
570                        ),
571                    });
572                };
573                if !types_compatible_for_diagnostic(
574                    &expected_field.type_expr,
575                    &actual_field.type_expr,
576                    scope,
577                ) {
578                    return Some(MismatchNote {
579                        message: format!(
580                            "field `{}` expected {}, found {}",
581                            expected_field.name,
582                            format_type(&expected_field.type_expr),
583                            format_type(&actual_field.type_expr)
584                        ),
585                    });
586                }
587            }
588            None
589        }
590        (TypeExpr::List(expected_inner), TypeExpr::List(actual_inner)) => {
591            if !types_compatible_for_diagnostic(expected_inner, actual_inner, scope)
592                || !types_compatible_for_diagnostic(actual_inner, expected_inner, scope)
593            {
594                Some(MismatchNote {
595                    message: format!(
596                        "list element expected {}, found {}",
597                        format_type(expected_inner),
598                        format_type(actual_inner)
599                    ),
600                })
601            } else {
602                None
603            }
604        }
605        (
606            TypeExpr::DictType(expected_key, expected_value),
607            TypeExpr::DictType(actual_key, actual_value),
608        ) => {
609            if !types_compatible_for_diagnostic(expected_key, actual_key, scope)
610                || !types_compatible_for_diagnostic(actual_key, expected_key, scope)
611            {
612                Some(MismatchNote {
613                    message: format!(
614                        "dict key expected {}, found {}",
615                        format_type(expected_key),
616                        format_type(actual_key)
617                    ),
618                })
619            } else if !types_compatible_for_diagnostic(expected_value, actual_value, scope)
620                || !types_compatible_for_diagnostic(actual_value, expected_value, scope)
621            {
622                Some(MismatchNote {
623                    message: format!(
624                        "dict value expected {}, found {}",
625                        format_type(expected_value),
626                        format_type(actual_value)
627                    ),
628                })
629            } else {
630                None
631            }
632        }
633        (
634            TypeExpr::Applied {
635                name: expected_name,
636                args: expected_args,
637            },
638            TypeExpr::Applied {
639                name: actual_name,
640                args: actual_args,
641            },
642        ) if expected_name == actual_name => expected_args
643            .iter()
644            .zip(actual_args.iter())
645            .enumerate()
646            .find_map(|(idx, (expected_arg, actual_arg))| {
647                if types_compatible_for_diagnostic(expected_arg, actual_arg, scope)
648                    && types_compatible_for_diagnostic(actual_arg, expected_arg, scope)
649                {
650                    None
651                } else {
652                    Some(MismatchNote {
653                        message: format!(
654                            "{} type argument {} expected {}, found {}",
655                            expected_name,
656                            idx + 1,
657                            format_type(expected_arg),
658                            format_type(actual_arg)
659                        ),
660                    })
661                }
662            }),
663        (
664            TypeExpr::FnType {
665                params: expected_params,
666                return_type: expected_return,
667            },
668            TypeExpr::FnType {
669                params: actual_params,
670                return_type: actual_return,
671            },
672        ) => {
673            for (idx, (expected_param, actual_param)) in
674                expected_params.iter().zip(actual_params.iter()).enumerate()
675            {
676                if !types_compatible_for_diagnostic(actual_param, expected_param, scope) {
677                    return Some(MismatchNote {
678                        message: format!(
679                            "function parameter {} expected {}, found {}",
680                            idx + 1,
681                            format_type(expected_param),
682                            format_type(actual_param)
683                        ),
684                    });
685                }
686            }
687            if !types_compatible_for_diagnostic(expected_return, actual_return, scope) {
688                Some(MismatchNote {
689                    message: format!(
690                        "function return expected {}, found {}",
691                        format_type(expected_return),
692                        format_type(actual_return)
693                    ),
694                })
695            } else {
696                None
697            }
698        }
699        _ => None,
700    }
701}
702
703fn types_compatible_for_diagnostic(
704    expected: &TypeExpr,
705    actual: &TypeExpr,
706    scope: &TypeScope,
707) -> bool {
708    TypeChecker::new().types_compatible(expected, actual, scope)
709}
710
711fn resolve_type_for_diagnostic(ty: &TypeExpr, scope: &TypeScope) -> TypeExpr {
712    TypeChecker::new().resolve_alias(ty, scope)
713}
714
715fn coercion_suggestion(
716    expected: &TypeExpr,
717    actual: &TypeExpr,
718    value_span: Option<Span>,
719    source: Option<&str>,
720) -> Option<String> {
721    let expr = value_span
722        .and_then(|span| source.and_then(|source| source.get(span.start..span.end)))
723        .map(str::trim)
724        .filter(|expr| !expr.is_empty());
725    if is_nilable(actual) {
726        return Some("handle `nil` first or provide a default with `??`".to_string());
727    }
728    let expected_ty = expected;
729    let expected = simple_type_name(expected)?;
730    let actual_name = simple_type_name(actual)?;
731    let with_expr = |template: &str| {
732        expr.map(|expr| template.replace("{}", expr))
733            .unwrap_or_else(|| template.replace("{}", "value"))
734    };
735
736    match (expected, actual_name) {
737        ("string", "int" | "float" | "bool" | "nil" | "duration") => {
738            Some(format!("did you mean `{}`?", with_expr("to_string({})")))
739        }
740        ("int", "string") => Some(format!("did you mean `{}`?", with_expr("to_int({})"))),
741        ("float", "string" | "int") => {
742            Some(format!("did you mean `{}`?", with_expr("to_float({})")))
743        }
744        (_, "nil") => Some("handle `nil` first or provide a default with `??`".to_string()),
745        _ if actual_is_result_of(expected_ty, actual) => Some(format!(
746            "did you mean `{}` or `{}`?",
747            with_expr("{}?"),
748            with_expr("unwrap_or({}, default)")
749        )),
750        _ => None,
751    }
752}
753
754fn simple_type_name(ty: &TypeExpr) -> Option<&str> {
755    match ty {
756        TypeExpr::Named(name) => Some(name.as_str()),
757        TypeExpr::LitString(_) => Some("string"),
758        TypeExpr::LitInt(_) => Some("int"),
759        _ => None,
760    }
761}
762
763fn is_nilable(ty: &TypeExpr) -> bool {
764    match ty {
765        TypeExpr::Union(members) if members.len() == 2 => members
766            .iter()
767            .any(|member| matches!(member, TypeExpr::Named(name) if name == "nil")),
768        _ => false,
769    }
770}
771
772fn actual_is_result_of(expected: &TypeExpr, actual: &TypeExpr) -> bool {
773    matches!(
774        actual,
775        TypeExpr::Applied { name, args }
776            if name == "Result" && args.first().is_some_and(|ok| ok == expected)
777    )
778}
779
780impl Default for TypeChecker {
781    fn default() -> Self {
782        Self::new()
783    }
784}
785
786#[cfg(test)]
787mod tests;