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