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.strict_types = true;
242        self.check_inner(program).0
243    }
244
245    /// Check a program and return diagnostics.
246    pub fn check(self, program: &[SNode]) -> Vec<TypeDiagnostic> {
247        self.check_inner(program).0
248    }
249
250    /// Check whether a function call value is a boundary source that produces
251    /// unvalidated data.  Returns `None` if the value is type-safe
252    /// (e.g. llm_call with a schema option, or a non-boundary function).
253    pub(in crate::typechecker) fn detect_boundary_source(
254        value: &SNode,
255        scope: &TypeScope,
256    ) -> Option<String> {
257        match &value.node {
258            Node::FunctionCall { name, args, .. } => {
259                if !builtin_signatures::is_untyped_boundary_source(name) {
260                    return None;
261                }
262                // llm_call/llm_completion with a schema option are type-safe
263                if (name == "llm_call" || name == "llm_completion")
264                    && Self::llm_call_has_typed_schema_option(args, scope)
265                {
266                    return None;
267                }
268                Some(name.clone())
269            }
270            Node::Identifier(name) => scope.is_untyped_source(name).map(|s| s.to_string()),
271            _ => None,
272        }
273    }
274
275    /// True if an `llm_call` / `llm_completion` options dict names a
276    /// resolvable output schema. Used by the strict-types boundary checks
277    /// to suppress "unvalidated" warnings when the call site is typed.
278    /// Actual return-type narrowing is driven by the generic-builtin
279    /// dispatch path in `infer_type`, not this helper.
280    pub(in crate::typechecker) fn llm_call_has_typed_schema_option(
281        args: &[SNode],
282        scope: &TypeScope,
283    ) -> bool {
284        let Some(opts) = args.get(2) else {
285            return false;
286        };
287        let Node::DictLiteral(entries) = &opts.node else {
288            return false;
289        };
290        entries.iter().any(|entry| {
291            let key = match &entry.key.node {
292                Node::StringLiteral(k) | Node::Identifier(k) => k.as_str(),
293                _ => return false,
294            };
295            (key == "schema" || key == "output_schema")
296                && schema_type_expr_from_node(&entry.value, scope).is_some()
297        })
298    }
299
300    /// Check whether a type annotation is a concrete shape/struct type
301    /// (as opposed to bare `dict` or no annotation).
302    pub(in crate::typechecker) fn is_concrete_type(ty: &TypeExpr) -> bool {
303        matches!(
304            ty,
305            TypeExpr::Shape(_)
306                | TypeExpr::Applied { .. }
307                | TypeExpr::FnType { .. }
308                | TypeExpr::List(_)
309                | TypeExpr::Iter(_)
310                | TypeExpr::Generator(_)
311                | TypeExpr::Stream(_)
312                | TypeExpr::DictType(_, _)
313        ) || matches!(ty, TypeExpr::Named(n) if n != "dict" && n != "any" && n != "_")
314    }
315
316    /// Check a program and return both diagnostics and inlay hints.
317    pub fn check_with_hints(
318        mut self,
319        program: &[SNode],
320        source: &str,
321    ) -> (Vec<TypeDiagnostic>, Vec<InlayHintInfo>) {
322        self.source = Some(source.to_string());
323        self.check_inner(program)
324    }
325
326    pub(in crate::typechecker) fn error_at(&mut self, code: Code, message: String, span: Span) {
327        self.diagnostics.push(TypeDiagnostic {
328            code,
329            message,
330            severity: DiagnosticSeverity::Error,
331            span: Some(span),
332            help: None,
333            related: Vec::new(),
334            fix: None,
335            details: None,
336            repair: default_repair(code),
337        });
338    }
339
340    #[allow(dead_code)]
341    pub(in crate::typechecker) fn error_at_with_help(
342        &mut self,
343        code: Code,
344        message: String,
345        span: Span,
346        help: String,
347    ) {
348        self.diagnostics.push(TypeDiagnostic {
349            code,
350            message,
351            severity: DiagnosticSeverity::Error,
352            span: Some(span),
353            help: Some(help),
354            related: Vec::new(),
355            fix: None,
356            details: None,
357            repair: default_repair(code),
358        });
359    }
360
361    pub(in crate::typechecker) fn type_mismatch_at(
362        &mut self,
363        code: Code,
364        context: impl Into<String>,
365        expected: &TypeExpr,
366        actual: &TypeExpr,
367        span: Span,
368        evidence: TypeMismatchEvidence,
369        scope: &TypeScope,
370    ) {
371        let (expected_origin, value_span) = evidence;
372        let nested_mismatch = first_nested_mismatch(expected, actual, scope);
373        let mut message = format!(
374            "{}: expected {}, found {}",
375            context.into(),
376            format_type(expected),
377            format_type(actual)
378        );
379        if let Some(detail) = shape_mismatch_detail(expected, actual)
380            .or_else(|| nested_mismatch.as_ref().map(|note| note.message.clone()))
381        {
382            message.push_str(&format!(" ({detail})"));
383        }
384
385        let mut related = Vec::new();
386        if let Some((span, message)) = expected_origin {
387            related.push(RelatedDiagnostic { span, message });
388        }
389        if let Some(note) = nested_mismatch {
390            related.push(RelatedDiagnostic {
391                span,
392                message: format!("nested mismatch: {}", note.message),
393            });
394        }
395
396        self.diagnostics.push(TypeDiagnostic {
397            code,
398            message,
399            severity: DiagnosticSeverity::Error,
400            span: Some(span),
401            help: coercion_suggestion(expected, actual, value_span, self.source.as_deref()),
402            related,
403            fix: None,
404            details: Some(DiagnosticDetails::TypeMismatch),
405            repair: default_repair(code),
406        });
407    }
408
409    pub(in crate::typechecker) fn error_at_with_fix(
410        &mut self,
411        code: Code,
412        message: String,
413        span: Span,
414        fix: Vec<FixEdit>,
415    ) {
416        self.diagnostics.push(TypeDiagnostic {
417            code,
418            message,
419            severity: DiagnosticSeverity::Error,
420            span: Some(span),
421            help: None,
422            related: Vec::new(),
423            fix: Some(fix),
424            details: None,
425            repair: default_repair(code),
426        });
427    }
428
429    /// Diagnostic site for non-exhaustive `match` arms. Match arms must be
430    /// exhaustive — a missing-variant `match` is a hard error. Authors who
431    /// genuinely want partial coverage opt out with a wildcard `_` arm.
432    /// Partial `if/elif/else` chains are intentionally allowed and are
433    /// instead handled by `check_unknown_exhaustiveness`, which stays a
434    /// warning so the `unreachable()` opt-in pattern continues to work.
435    pub(in crate::typechecker) fn exhaustiveness_error_at(
436        &mut self,
437        code: Code,
438        message: String,
439        span: Span,
440    ) {
441        self.diagnostics.push(TypeDiagnostic {
442            code,
443            message,
444            severity: DiagnosticSeverity::Error,
445            span: Some(span),
446            help: None,
447            related: Vec::new(),
448            fix: None,
449            details: None,
450            repair: default_repair(code),
451        });
452    }
453
454    /// Like `exhaustiveness_error_at` but additionally attaches the
455    /// missing-variant list as structured details. LSP code-actions
456    /// read this to synthesise an "Add missing match arms" quick-fix
457    /// without string-parsing the message.
458    pub(in crate::typechecker) fn exhaustiveness_error_with_missing(
459        &mut self,
460        code: Code,
461        message: String,
462        span: Span,
463        missing: Vec<String>,
464    ) {
465        self.diagnostics.push(TypeDiagnostic {
466            code,
467            message,
468            severity: DiagnosticSeverity::Error,
469            span: Some(span),
470            help: None,
471            related: Vec::new(),
472            fix: None,
473            details: Some(DiagnosticDetails::NonExhaustiveMatch { missing }),
474            repair: default_repair(code),
475        });
476    }
477
478    pub(in crate::typechecker) fn warning_at(&mut self, code: Code, message: String, span: Span) {
479        self.diagnostics.push(TypeDiagnostic {
480            code,
481            message,
482            severity: DiagnosticSeverity::Warning,
483            span: Some(span),
484            help: None,
485            related: Vec::new(),
486            fix: None,
487            details: None,
488            repair: default_repair(code),
489        });
490    }
491
492    #[allow(dead_code)]
493    pub(in crate::typechecker) fn warning_at_with_help(
494        &mut self,
495        code: Code,
496        message: String,
497        span: Span,
498        help: String,
499    ) {
500        self.diagnostics.push(TypeDiagnostic {
501            code,
502            message,
503            severity: DiagnosticSeverity::Warning,
504            span: Some(span),
505            help: Some(help),
506            related: Vec::new(),
507            fix: None,
508            details: None,
509            repair: default_repair(code),
510        });
511    }
512
513    pub(in crate::typechecker) fn lint_warning_at_with_fix(
514        &mut self,
515        code: Code,
516        rule: &'static str,
517        message: String,
518        span: Span,
519        help: String,
520        fix: Vec<FixEdit>,
521    ) {
522        self.diagnostics.push(TypeDiagnostic {
523            code,
524            message,
525            severity: DiagnosticSeverity::Warning,
526            span: Some(span),
527            help: Some(help),
528            related: Vec::new(),
529            fix: Some(fix),
530            details: Some(DiagnosticDetails::LintRule { rule }),
531            repair: default_repair(code),
532        });
533    }
534}
535
536/// Materialize the default [`Repair`] for a diagnostic code, or `None`
537/// if no static repair shape is registered. Cheap (one pointer
538/// dereference plus an allocation for the summary string); call sites
539/// pay nothing when the code has no repair template.
540pub(crate) fn default_repair(code: Code) -> Option<Repair> {
541    code.repair_template().map(Repair::from_template)
542}
543
544#[derive(Debug)]
545struct MismatchNote {
546    message: String,
547}
548
549fn first_nested_mismatch(
550    expected: &TypeExpr,
551    actual: &TypeExpr,
552    scope: &TypeScope,
553) -> Option<MismatchNote> {
554    let expected = resolve_type_for_diagnostic(expected, scope);
555    let actual = resolve_type_for_diagnostic(actual, scope);
556    match (&expected, &actual) {
557        (TypeExpr::Shape(expected_fields), TypeExpr::Shape(actual_fields)) => {
558            for expected_field in expected_fields {
559                if expected_field.optional {
560                    continue;
561                }
562                let Some(actual_field) = actual_fields
563                    .iter()
564                    .find(|actual_field| actual_field.name == expected_field.name)
565                else {
566                    return Some(MismatchNote {
567                        message: format!(
568                            "field `{}` is missing; expected {}",
569                            expected_field.name,
570                            format_type(&expected_field.type_expr)
571                        ),
572                    });
573                };
574                if !types_compatible_for_diagnostic(
575                    &expected_field.type_expr,
576                    &actual_field.type_expr,
577                    scope,
578                ) {
579                    return Some(MismatchNote {
580                        message: format!(
581                            "field `{}` expected {}, found {}",
582                            expected_field.name,
583                            format_type(&expected_field.type_expr),
584                            format_type(&actual_field.type_expr)
585                        ),
586                    });
587                }
588            }
589            None
590        }
591        (TypeExpr::List(expected_inner), TypeExpr::List(actual_inner)) => {
592            if !types_compatible_for_diagnostic(expected_inner, actual_inner, scope)
593                || !types_compatible_for_diagnostic(actual_inner, expected_inner, scope)
594            {
595                Some(MismatchNote {
596                    message: format!(
597                        "list element expected {}, found {}",
598                        format_type(expected_inner),
599                        format_type(actual_inner)
600                    ),
601                })
602            } else {
603                None
604            }
605        }
606        (
607            TypeExpr::DictType(expected_key, expected_value),
608            TypeExpr::DictType(actual_key, actual_value),
609        ) => {
610            if !types_compatible_for_diagnostic(expected_key, actual_key, scope)
611                || !types_compatible_for_diagnostic(actual_key, expected_key, scope)
612            {
613                Some(MismatchNote {
614                    message: format!(
615                        "dict key expected {}, found {}",
616                        format_type(expected_key),
617                        format_type(actual_key)
618                    ),
619                })
620            } else if !types_compatible_for_diagnostic(expected_value, actual_value, scope)
621                || !types_compatible_for_diagnostic(actual_value, expected_value, scope)
622            {
623                Some(MismatchNote {
624                    message: format!(
625                        "dict value expected {}, found {}",
626                        format_type(expected_value),
627                        format_type(actual_value)
628                    ),
629                })
630            } else {
631                None
632            }
633        }
634        (
635            TypeExpr::Applied {
636                name: expected_name,
637                args: expected_args,
638            },
639            TypeExpr::Applied {
640                name: actual_name,
641                args: actual_args,
642            },
643        ) if expected_name == actual_name => expected_args
644            .iter()
645            .zip(actual_args.iter())
646            .enumerate()
647            .find_map(|(idx, (expected_arg, actual_arg))| {
648                if types_compatible_for_diagnostic(expected_arg, actual_arg, scope)
649                    && types_compatible_for_diagnostic(actual_arg, expected_arg, scope)
650                {
651                    None
652                } else {
653                    Some(MismatchNote {
654                        message: format!(
655                            "{} type argument {} expected {}, found {}",
656                            expected_name,
657                            idx + 1,
658                            format_type(expected_arg),
659                            format_type(actual_arg)
660                        ),
661                    })
662                }
663            }),
664        (
665            TypeExpr::FnType {
666                params: expected_params,
667                return_type: expected_return,
668            },
669            TypeExpr::FnType {
670                params: actual_params,
671                return_type: actual_return,
672            },
673        ) => {
674            for (idx, (expected_param, actual_param)) in
675                expected_params.iter().zip(actual_params.iter()).enumerate()
676            {
677                if !types_compatible_for_diagnostic(actual_param, expected_param, scope) {
678                    return Some(MismatchNote {
679                        message: format!(
680                            "function parameter {} expected {}, found {}",
681                            idx + 1,
682                            format_type(expected_param),
683                            format_type(actual_param)
684                        ),
685                    });
686                }
687            }
688            if !types_compatible_for_diagnostic(expected_return, actual_return, scope) {
689                Some(MismatchNote {
690                    message: format!(
691                        "function return expected {}, found {}",
692                        format_type(expected_return),
693                        format_type(actual_return)
694                    ),
695                })
696            } else {
697                None
698            }
699        }
700        _ => None,
701    }
702}
703
704fn types_compatible_for_diagnostic(
705    expected: &TypeExpr,
706    actual: &TypeExpr,
707    scope: &TypeScope,
708) -> bool {
709    TypeChecker::new().types_compatible(expected, actual, scope)
710}
711
712fn resolve_type_for_diagnostic(ty: &TypeExpr, scope: &TypeScope) -> TypeExpr {
713    TypeChecker::new().resolve_alias(ty, scope)
714}
715
716fn coercion_suggestion(
717    expected: &TypeExpr,
718    actual: &TypeExpr,
719    value_span: Option<Span>,
720    source: Option<&str>,
721) -> Option<String> {
722    let expr = value_span
723        .and_then(|span| source.and_then(|source| source.get(span.start..span.end)))
724        .map(str::trim)
725        .filter(|expr| !expr.is_empty());
726    if is_nilable(actual) {
727        return Some("handle `nil` first or provide a default with `??`".to_string());
728    }
729    let expected_ty = expected;
730    let expected = simple_type_name(expected)?;
731    let actual_name = simple_type_name(actual)?;
732    let with_expr = |template: &str| {
733        expr.map(|expr| template.replace("{}", expr))
734            .unwrap_or_else(|| template.replace("{}", "value"))
735    };
736
737    match (expected, actual_name) {
738        ("string", "int" | "float" | "bool" | "nil" | "duration") => {
739            Some(format!("did you mean `{}`?", with_expr("to_string({})")))
740        }
741        ("int", "string") => Some(format!("did you mean `{}`?", with_expr("to_int({})"))),
742        ("float", "string" | "int") => {
743            Some(format!("did you mean `{}`?", with_expr("to_float({})")))
744        }
745        (_, "nil") => Some("handle `nil` first or provide a default with `??`".to_string()),
746        _ if actual_is_result_of(expected_ty, actual) => Some(format!(
747            "did you mean `{}` or `{}`?",
748            with_expr("{}?"),
749            with_expr("unwrap_or({}, default)")
750        )),
751        _ => None,
752    }
753}
754
755fn simple_type_name(ty: &TypeExpr) -> Option<&str> {
756    match ty {
757        TypeExpr::Named(name) => Some(name.as_str()),
758        TypeExpr::LitString(_) => Some("string"),
759        TypeExpr::LitInt(_) => Some("int"),
760        _ => None,
761    }
762}
763
764fn is_nilable(ty: &TypeExpr) -> bool {
765    match ty {
766        TypeExpr::Union(members) if members.len() == 2 => members
767            .iter()
768            .any(|member| matches!(member, TypeExpr::Named(name) if name == "nil")),
769        _ => false,
770    }
771}
772
773fn actual_is_result_of(expected: &TypeExpr, actual: &TypeExpr) -> bool {
774    matches!(
775        actual,
776        TypeExpr::Applied { name, args }
777            if name == "Result" && args.first().is_some_and(|ok| ok == expected)
778    )
779}
780
781impl Default for TypeChecker {
782    fn default() -> Self {
783        Self::new()
784    }
785}
786
787#[cfg(test)]
788mod tests;