Skip to main content

harn_parser/typechecker/
mod.rs

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