Skip to main content

aver/types/checker/
mod.rs

1/// Aver static type checker.
2///
3/// Two-phase analysis:
4///   Phase 1 — build a signature table from all FnDef nodes and builtins.
5///   Phase 2 — check top-level statements, then each FnDef for call-site
6///              argument types, return type, BinOp compatibility, and effects.
7///
8/// The checker resolves named generic variables at call sites. Error recovery
9/// uses `Type::Invalid`, which matches anything to suppress cascading diagnostics
10/// (Iron — A4).
11use std::collections::{HashMap, HashSet};
12
13use super::{Type, parse_type_str_strict};
14use crate::ast::{
15    BinOp, Expr, FnDef, Literal, Module, Pattern, Spanned, Stmt, TailCallData, TopLevel, TypeDef,
16};
17
18mod builtins;
19pub mod effect_classification;
20pub mod effect_lifting;
21mod exhaustiveness;
22mod flow;
23pub mod hostile_effects;
24pub mod hostile_values;
25mod infer;
26mod memo;
27mod modules;
28pub mod oracle_subtypes;
29pub mod proof_trust_header;
30
31#[cfg(test)]
32mod tests;
33
34// ---------------------------------------------------------------------------
35// Public API
36// ---------------------------------------------------------------------------
37
38#[derive(Debug, Clone)]
39pub struct TypeError {
40    pub message: String,
41    pub line: usize,
42    pub col: usize,
43    /// Optional secondary span for multi-region diagnostics (e.g. declared type vs actual return).
44    pub secondary: Option<TypeErrorSpan>,
45}
46
47#[derive(Debug, Clone)]
48pub struct TypeErrorSpan {
49    pub line: usize,
50    pub col: usize,
51    pub label: String,
52}
53
54/// Result of type-checking that also carries memo-safety metadata.
55#[derive(Debug)]
56pub struct TypeCheckResult {
57    pub errors: Vec<TypeError>,
58    /// For each user-defined fn: (param_types, return_type, effects).
59    /// Used by the memo system to decide which fns qualify.
60    pub fn_sigs: HashMap<String, (Vec<Type>, Type, Vec<String>)>,
61    /// Set of type names whose values are memo-safe (hashable scalars / records of scalars).
62    pub memo_safe_types: HashSet<String>,
63    /// Unused binding warnings: (binding_name, fn_name, line).
64    pub unused_bindings: Vec<(String, String, usize)>,
65}
66
67pub fn run_type_check(items: &[TopLevel]) -> Vec<TypeError> {
68    run_type_check_with_base(items, None)
69}
70
71pub fn run_type_check_with_base(items: &[TopLevel], base_dir: Option<&str>) -> Vec<TypeError> {
72    run_type_check_full(items, base_dir).errors
73}
74
75pub fn run_type_check_full(items: &[TopLevel], base_dir: Option<&str>) -> TypeCheckResult {
76    let mut checker = TypeChecker::new();
77    checker.check(items, base_dir);
78    finalize_check_result(checker, items)
79}
80
81/// Variant of [`run_type_check_full`] that uses pre-loaded dependency
82/// modules instead of resolving them from disk. The playground feeds
83/// this from its in-memory virtual fs so multi-file projects type-
84/// check without any filesystem access.
85pub fn run_type_check_with_loaded(
86    items: &[TopLevel],
87    loaded: &[crate::source::LoadedModule],
88) -> TypeCheckResult {
89    let mut checker = TypeChecker::new();
90    checker.check_with_loaded(items, loaded);
91    finalize_check_result(checker, items)
92}
93
94/// Self-host variant of [`run_type_check_full`]: bypasses the
95/// opaque-type checks (construction, field access, pattern match).
96/// Used exclusively by `aver compile --with-self-host-support` so
97/// `self_hosted/domain/builtins.av` can round-trip opaque host
98/// types (e.g. `Tcp.Connection`) through the replay JSON contract.
99/// User code outside the self-host always goes through the regular
100/// [`run_type_check_full`] and stays bound by the opaque rules.
101pub fn run_type_check_full_self_host(
102    items: &[TopLevel],
103    base_dir: Option<&str>,
104) -> TypeCheckResult {
105    let mut checker = TypeChecker::new();
106    checker.self_host_mode = true;
107    checker.check(items, base_dir);
108    finalize_check_result(checker, items)
109}
110
111/// Self-host variant of [`run_type_check_with_loaded`]. See
112/// [`run_type_check_full_self_host`] for the opaque-bypass rationale.
113pub fn run_type_check_with_loaded_self_host(
114    items: &[TopLevel],
115    loaded: &[crate::source::LoadedModule],
116) -> TypeCheckResult {
117    let mut checker = TypeChecker::new();
118    checker.self_host_mode = true;
119    checker.check_with_loaded(items, loaded);
120    finalize_check_result(checker, items)
121}
122
123fn finalize_check_result(mut checker: TypeChecker, items: &[TopLevel]) -> TypeCheckResult {
124    // Internal `fn_sigs` is keyed by canonical "Module.name" (Iron — A3).
125    // The exported map preserves both forms so external consumers
126    // (`verify_effects`, Lean / Dafny codegen, the CLI summary) can
127    // continue to look entries up by the bare name the user wrote.
128    let mut fn_sigs: HashMap<String, (Vec<Type>, Type, Vec<String>)> = checker
129        .fn_sigs
130        .iter()
131        .map(|(k, v)| {
132            (
133                k.clone(),
134                (v.params.clone(), v.ret.clone(), v.effects.clone()),
135            )
136        })
137        .collect();
138    for (alias, canonical) in &checker.sig_aliases {
139        if !fn_sigs.contains_key(alias)
140            && let Some(sig) = checker.fn_sigs.get(canonical)
141        {
142            fn_sigs.insert(
143                alias.clone(),
144                (sig.params.clone(), sig.ret.clone(), sig.effects.clone()),
145            );
146        }
147    }
148
149    let memo_safe_types = checker.compute_memo_safe_types(items);
150
151    check_module_effect_boundary(items, &mut checker.errors);
152
153    TypeCheckResult {
154        errors: checker.errors,
155        fn_sigs,
156        memo_safe_types,
157        unused_bindings: checker.unused_warnings,
158    }
159}
160
161/// Enforce module-level `effects [...]` declaration against per-fn effect
162/// usage. The rule:
163///
164/// - Module without `effects [...]` → legacy/mixed, no enforcement (0.13
165///   migration shim; 0.14+ may upgrade to soft warning).
166/// - Module with `effects [...]` (including `effects []` for explicit pure)
167///   → every function's `! [...]` must be covered by the module's declared
168///   surface. A namespace-level entry like `Disk` admits any `Disk.*`
169///   method; a method-level entry like `Time.now` admits only that one.
170fn check_module_effect_boundary(items: &[TopLevel], errors: &mut Vec<TypeError>) {
171    let Some(allowed) = items.iter().find_map(|i| match i {
172        TopLevel::Module(m) => m.effects.as_ref().map(|e| (e, m)),
173        _ => None,
174    }) else {
175        return;
176    };
177    let (allowed_list, module) = allowed;
178
179    let allowed_namespaces: HashSet<&str> = allowed_list
180        .iter()
181        .filter(|e| !e.contains('.'))
182        .map(|e| e.as_str())
183        .collect();
184    let allowed_methods: HashSet<&str> = allowed_list.iter().map(|e| e.as_str()).collect();
185
186    for item in items {
187        let TopLevel::FnDef(fd) = item else { continue };
188        for eff in &fd.effects {
189            let method = eff.node.as_str();
190            if allowed_methods.contains(method) {
191                continue;
192            }
193            if let Some((ns, _)) = method.split_once('.')
194                && allowed_namespaces.contains(ns)
195            {
196                continue;
197            }
198            errors.push(TypeError {
199                message: format!(
200                    "module '{}' declared `effects [{}]` but '{}' uses '{}' which is not in the declared boundary",
201                    module.name,
202                    allowed_list.join(", "),
203                    fd.name,
204                    method
205                ),
206                line: eff.line,
207                col: 1,
208                secondary: module.effects_line.map(|l| TypeErrorSpan {
209                    line: l,
210                    col: 1,
211                    label: "module effects declared here".to_string(),
212                }),
213            });
214        }
215    }
216}
217
218// ---------------------------------------------------------------------------
219// Internal structures
220// ---------------------------------------------------------------------------
221
222#[derive(Debug, Clone)]
223struct FnSig {
224    params: Vec<Type>,
225    ret: Type,
226    effects: Vec<String>,
227}
228
229/// Iron — A5: typed key for `record_field_types`. Pre-A5 the map
230/// was keyed by `"TypeName.fieldName"` stringifications, which
231/// forced every reader to `strip_prefix(format!("{type}."))` and
232/// then re-check that the remainder didn't itself contain a dot
233/// (because the post-A3 dual-keying mirrored each entry under both
234/// the canonical `"Module.Type.field"` form and the bare alias
235/// `"Type.field"` — and the canonical form spuriously matched the
236/// `"Module."` prefix-strip when the read came from a module
237/// looking up its own fields). The struct key separates the two
238/// dimensions, so the canonical resolution happens once at
239/// insert/lookup (via `sig_aliases`) and iteration filters on
240/// `key.type_name == canonical` with no string-shape gymnastics.
241#[derive(Debug, Clone, PartialEq, Eq, Hash)]
242pub(crate) struct RecordFieldKey {
243    pub(crate) type_name: String,
244    pub(crate) field_name: String,
245}
246
247impl RecordFieldKey {
248    pub(crate) fn new(type_name: impl Into<String>, field_name: impl Into<String>) -> Self {
249        Self {
250            type_name: type_name.into(),
251            field_name: field_name.into(),
252        }
253    }
254}
255
256struct TypeChecker {
257    fn_sigs: HashMap<String, FnSig>,
258    value_members: HashMap<String, Type>,
259    /// Field types for record types, keyed by `(type_name, field_name)`.
260    /// Populated for both user-defined `record` types and built-in records
261    /// (HttpResponse, Header). Single entry per (canonical type name, field);
262    /// lookup canonicalises `type_name` through `sig_aliases` at read time.
263    /// Enables checked dot-access on Named types.
264    record_field_types: HashMap<RecordFieldKey, Type>,
265    /// Unqualified → qualified aliases for cross-module lookups.
266    /// E.g. "Shape.Circle" → "Data.Shape.Circle".
267    sig_aliases: HashMap<String, String>,
268    /// Variant names for sum types: "Shape" → ["Circle", "Rect", "Point"].
269    /// Pre-populated for Result and Option; extended by user-defined sum types.
270    type_variants: HashMap<String, Vec<String>>,
271    /// Top-level bindings visible from function bodies.
272    globals: HashMap<String, Type>,
273    /// Local bindings in the current function/scope.
274    locals: HashMap<String, Type>,
275    errors: Vec<TypeError>,
276    /// Return type of the function currently being checked; None at top level.
277    current_fn_ret: Option<Type>,
278    /// Line number of the function currently being checked; None at top level.
279    current_fn_line: Option<usize>,
280    /// Type names that are opaque in this module's context (imported via `exposes opaque`).
281    opaque_types: HashSet<String>,
282    /// When `true`, opaque-type construction + field-access + pattern-match
283    /// checks are bypassed. Used only by the self-host compile path
284    /// (`aver compile --with-self-host-support`) where
285    /// `self_hosted/domain/builtins.av` round-trips opaque host types
286    /// (e.g. `Tcp.Connection`) through the replay `Val` representation:
287    /// it serialises by reading `.id` / `.host` / `.port`, and
288    /// reconstructs by `Tcp.Connection(id = …, host = …, port = …)` on
289    /// replay deserialise. Both operations are forbidden in user code by
290    /// design (Phase 4.7+ fix #11), but the self-host has to read +
291    /// write the underlying record shape because that's the contract
292    /// with the replay JSON format. The flag is set by
293    /// [`run_type_check_full_self_host`] / [`run_type_check_with_loaded_self_host`]
294    /// and never user-toggleable from source.
295    self_host_mode: bool,
296    /// Names referenced during type checking of current function body (for unused detection).
297    used_names: HashSet<String>,
298    /// Bindings defined in the current function body: (name, line).
299    fn_bindings: Vec<(String, usize)>,
300    /// Unused binding warnings collected during checking: (binding_name, fn_name, line).
301    unused_warnings: Vec<(String, String, usize)>,
302    /// Oracle v1: `.result` / `.trace` / `.trace.*` projections are
303    /// only meaningful inside `verify <fn> trace` cases. This flag is
304    /// set true while checking such a case's LHS / RHS, false
305    /// otherwise. Outside verify-trace the projections are rejected at
306    /// check time — otherwise user code would type-check then crash
307    /// at runtime with "namespace has no member 'trace'".
308    in_verify_trace_context: bool,
309}
310
311impl TypeChecker {
312    fn new() -> Self {
313        let mut type_variants = HashMap::new();
314        type_variants.insert(
315            "Result".to_string(),
316            vec!["Ok".to_string(), "Err".to_string()],
317        );
318        type_variants.insert(
319            "Option".to_string(),
320            vec!["Some".to_string(), "None".to_string()],
321        );
322
323        let mut tc = TypeChecker {
324            fn_sigs: HashMap::new(),
325            value_members: HashMap::new(),
326            record_field_types: HashMap::new(),
327            sig_aliases: HashMap::new(),
328            type_variants,
329            globals: HashMap::new(),
330            locals: HashMap::new(),
331            errors: Vec::new(),
332            current_fn_ret: None,
333            current_fn_line: None,
334            opaque_types: HashSet::new(),
335            self_host_mode: false,
336            used_names: HashSet::new(),
337            fn_bindings: Vec::new(),
338            unused_warnings: Vec::new(),
339            in_verify_trace_context: false,
340        };
341        tc.register_builtins();
342        tc
343    }
344
345    // -- Alias-aware lookups ------------------------------------------------
346
347    fn find_fn_sig(&self, key: &str) -> Option<&FnSig> {
348        self.fn_sigs
349            .get(key)
350            .or_else(|| self.sig_aliases.get(key).and_then(|c| self.fn_sigs.get(c)))
351    }
352
353    fn find_value_member(&self, key: &str) -> Option<&Type> {
354        self.value_members.get(key).or_else(|| {
355            self.sig_aliases
356                .get(key)
357                .and_then(|c| self.value_members.get(c))
358        })
359    }
360
361    fn find_record_field_type(&self, type_name: &str, field_name: &str) -> Option<&Type> {
362        // Iron — A5: lookup canonicalises the type-name dimension via
363        // `sig_aliases` before hashing. We only need to do that for
364        // the type-name part — field names are intra-type and stay
365        // verbatim.
366        let direct = RecordFieldKey::new(type_name, field_name);
367        if let Some(ty) = self.record_field_types.get(&direct) {
368            return Some(ty);
369        }
370        if let Some(canonical_type) = self.sig_aliases.get(type_name) {
371            let canonical = RecordFieldKey::new(canonical_type, field_name);
372            return self.record_field_types.get(&canonical);
373        }
374        None
375    }
376
377    /// Iron — A5: list every `(field_name, field_type)` pair declared
378    /// for `type_name`. Resolves `type_name` through `sig_aliases`
379    /// so a bare reference in source matches a canonical entry; the
380    /// reverse direction (canonical reference hitting a bare entry)
381    /// is a no-op because A3 normalises stored keys to canonical
382    /// form whenever a module alias exists.
383    fn fields_for_type(&self, type_name: &str) -> Vec<(String, Type)> {
384        let canonical = self
385            .sig_aliases
386            .get(type_name)
387            .map(String::as_str)
388            .unwrap_or(type_name);
389        self.record_field_types
390            .iter()
391            .filter(|(k, _)| k.type_name == canonical || k.type_name == type_name)
392            .map(|(k, v)| (k.field_name.clone(), v.clone()))
393            .collect()
394    }
395
396    /// Iron — A5: `true` if any field has been registered for
397    /// `type_name`. Drops the pre-A5 `record_field_types.keys().any(|k|
398    /// k.starts_with(&format!("{}.", type_name)))` substring probe.
399    fn has_record_schema(&self, type_name: &str) -> bool {
400        let canonical = self
401            .sig_aliases
402            .get(type_name)
403            .map(String::as_str)
404            .unwrap_or(type_name);
405        self.record_field_types
406            .keys()
407            .any(|k| k.type_name == canonical || k.type_name == type_name)
408    }
409
410    // -- Helpers -----------------------------------------------------------
411
412    /// Check whether `required_effect` is satisfied by `caller_effects`.
413    fn caller_has_effect(&self, caller_effects: &[String], required_effect: &str) -> bool {
414        caller_effects
415            .iter()
416            .any(|declared| crate::effects::effect_satisfies(declared, required_effect))
417    }
418
419    fn error(&mut self, msg: impl Into<String>) {
420        let line = self.current_fn_line.unwrap_or(1);
421        self.errors.push(TypeError {
422            message: msg.into(),
423            line,
424            col: 0,
425            secondary: None,
426        });
427    }
428
429    fn error_at_line(&mut self, line: usize, msg: impl Into<String>) {
430        self.errors.push(TypeError {
431            message: msg.into(),
432            line,
433            col: 0,
434            secondary: None,
435        });
436    }
437
438    fn insert_sig(&mut self, name: &str, params: &[Type], ret: Type, effects: &[&str]) {
439        self.fn_sigs.insert(
440            name.to_string(),
441            FnSig {
442                params: params.to_vec(),
443                ret,
444                effects: effects.iter().map(|s| s.to_string()).collect(),
445            },
446        );
447    }
448
449    fn fn_type_from_sig(sig: &FnSig) -> Type {
450        Type::Fn(
451            sig.params.clone(),
452            Box::new(sig.ret.clone()),
453            sig.effects.clone(),
454        )
455    }
456
457    fn sig_from_callable_type(ty: &Type) -> Option<FnSig> {
458        match ty {
459            Type::Fn(params, ret, effects) => Some(FnSig {
460                params: params.clone(),
461                ret: *ret.clone(),
462                effects: effects.clone(),
463            }),
464            _ => None,
465        }
466    }
467
468    fn binding_type(&self, name: &str) -> Option<Type> {
469        self.locals
470            .get(name)
471            .or_else(|| self.globals.get(name))
472            .cloned()
473    }
474
475    /// Iron — A3: `&self`-bearing constraint check. Resolves bare
476    /// Named types through `sig_aliases` so source-faithful Spanned
477    /// stamps (often bare inside a module) match against
478    /// canonicalised fn signatures (always "Module.Type").
479    pub(super) fn compatible(&self, actual: &Type, expected: &Type) -> bool {
480        let mut subst = HashMap::new();
481        Self::match_expected_type_inner(actual, expected, &mut subst, &self.sig_aliases)
482    }
483
484    /// Static-form matcher (no alias resolution). Tests use this
485    /// directly; production code should reach for `compatible`
486    /// instead.
487    pub(super) fn match_expected_type(
488        actual: &Type,
489        expected: &Type,
490        subst: &mut HashMap<String, Type>,
491    ) -> bool {
492        Self::match_expected_type_inner(actual, expected, subst, &HashMap::new())
493    }
494
495    /// Iron — A3: `&self` matcher that lets the caller carry a
496    /// substitution (poly fn arg inference). The pure `compatible`
497    /// helper above hides `subst` for the common "no Type::Var
498    /// involved" callers; this method exposes it for the FnCall arg
499    /// loop in `infer/expr.rs`.
500    pub(super) fn match_with(
501        &self,
502        actual: &Type,
503        expected: &Type,
504        subst: &mut HashMap<String, Type>,
505    ) -> bool {
506        Self::match_expected_type_inner(actual, expected, subst, &self.sig_aliases)
507    }
508
509    fn match_expected_type_inner(
510        actual: &Type,
511        expected: &Type,
512        subst: &mut HashMap<String, Type>,
513        aliases: &HashMap<String, String>,
514    ) -> bool {
515        // Iron — A4: `Type::Invalid` is the checker's "we already
516        // reported an error here, don't compound it" sentinel.
517        // Returning `false` for it turned every downstream use site
518        // into a fresh `expected X, got Invalid` diagnostic — a single
519        // unknown-fn call could fan out to N + 1 errors (the unknown
520        // fn plus one per downstream consumer). Treat Invalid as a
521        // wildcard on either side so the original error stands alone.
522        // Per-callsite guards like `!matches!(ty, Type::Invalid)`
523        // around `self.compatible(...)` are now redundant but harmless;
524        // sweeping them is deliberately out of scope here.
525        if matches!(actual, Type::Invalid) || matches!(expected, Type::Invalid) {
526            return true;
527        }
528        match expected {
529            Type::Var(name) => Self::bind_expected_var(name, actual, subst),
530            Type::Invalid => unreachable!("Type::Invalid handled by the early guard above"),
531            Type::Int => matches!(actual, Type::Int),
532            Type::Float => matches!(actual, Type::Float),
533            Type::Str => matches!(actual, Type::Str),
534            Type::Bool => matches!(actual, Type::Bool),
535            Type::Unit => matches!(actual, Type::Unit),
536            Type::Named(expected_name) => match actual {
537                // Iron — A3: bare ↔ canonical resolves through
538                // `sig_aliases`. After A3, fn / record / variant
539                // signatures live under their "Module.Type" key and
540                // mirror a bare-name alias in `sig_aliases`; source
541                // expressions stamp Spanned.ty in whatever form the
542                // user wrote. Resolve both sides to the canonical
543                // form first, then compare strictly. Two distinct
544                // modules both exposing "Shape" still produce
545                // ambiguous aliases at registration time — that's
546                // surfaced upfront elsewhere; here we only need to
547                // know that whatever bare form survives in
548                // `sig_aliases` IS the unique canonical.
549                Type::Named(actual_name) => {
550                    let exp_canon = aliases
551                        .get(expected_name)
552                        .map(String::as_str)
553                        .unwrap_or(expected_name);
554                    let act_canon = aliases
555                        .get(actual_name)
556                        .map(String::as_str)
557                        .unwrap_or(actual_name);
558                    act_canon == exp_canon
559                }
560                _ => false,
561            },
562            Type::Option(expected_inner) => match actual {
563                Type::Option(actual_inner) => {
564                    Self::match_expected_type_inner(actual_inner, expected_inner, subst, aliases)
565                }
566                _ => false,
567            },
568            Type::List(expected_inner) => match actual {
569                Type::List(actual_inner) => {
570                    Self::match_expected_type_inner(actual_inner, expected_inner, subst, aliases)
571                }
572                _ => false,
573            },
574            Type::Vector(expected_inner) => match actual {
575                Type::Vector(actual_inner) => {
576                    Self::match_expected_type_inner(actual_inner, expected_inner, subst, aliases)
577                }
578                _ => false,
579            },
580            Type::Result(expected_ok, expected_err) => match actual {
581                Type::Result(actual_ok, actual_err) => {
582                    Self::match_expected_type_inner(actual_ok, expected_ok, subst, aliases)
583                        && Self::match_expected_type_inner(actual_err, expected_err, subst, aliases)
584                }
585                _ => false,
586            },
587            Type::Map(expected_k, expected_v) => match actual {
588                Type::Map(actual_k, actual_v) => {
589                    Self::match_expected_type_inner(actual_k, expected_k, subst, aliases)
590                        && Self::match_expected_type_inner(actual_v, expected_v, subst, aliases)
591                }
592                _ => false,
593            },
594            Type::Tuple(expected_items) => match actual {
595                Type::Tuple(actual_items) if actual_items.len() == expected_items.len() => {
596                    actual_items.iter().zip(expected_items.iter()).all(
597                        |(actual_item, expected_item)| {
598                            Self::match_expected_type_inner(
599                                actual_item,
600                                expected_item,
601                                subst,
602                                aliases,
603                            )
604                        },
605                    )
606                }
607                _ => false,
608            },
609            Type::Fn(expected_params, expected_ret, expected_effects) => match actual {
610                Type::Fn(actual_params, actual_ret, actual_effects)
611                    if actual_params.len() == expected_params.len() =>
612                {
613                    actual_params.iter().zip(expected_params.iter()).all(
614                        |(actual_param, expected_param)| {
615                            Self::match_expected_type_inner(
616                                actual_param,
617                                expected_param,
618                                subst,
619                                aliases,
620                            )
621                        },
622                    ) && Self::match_expected_type_inner(actual_ret, expected_ret, subst, aliases)
623                        && actual_effects.iter().all(|actual| {
624                            expected_effects
625                                .iter()
626                                .any(|expected| crate::effects::effect_satisfies(expected, actual))
627                        })
628                }
629                _ => false,
630            },
631        }
632    }
633
634    fn bind_expected_var(name: &str, actual: &Type, subst: &mut HashMap<String, Type>) -> bool {
635        match actual {
636            Type::Var(actual_name) => return actual_name == name,
637            // Iron — A4: matches the wildcard in `match_expected_type_inner`.
638            // An already-errored actual binds vacuously instead of
639            // refusing the unification and triggering a cascade.
640            Type::Invalid => return true,
641            _ => {}
642        }
643        if let Some(bound) = subst.get(name).cloned() {
644            return Self::match_expected_type(actual, &bound, subst)
645                && Self::match_expected_type(&bound, actual, subst);
646            // bind_expected_var is alias-agnostic — Var bindings
647            // never compare Named types against `sig_aliases` since
648            // the binding rule already accepts whatever concrete
649            // type the caller hands in.
650        }
651        // Occurs check — refuse `T := F<…T…>` style circular bindings.
652        // Without this, polymorphic recursion patterns like `fn nest(v:
653        // A) -> Unit; nest([v])` would insert `A → List<A>` into `subst`
654        // and rely on downstream structural mismatch to terminate
655        // matching. The HashMap entry itself is still a cycle that
656        // later `instantiate_type` walks would have to skip; rejecting
657        // the bind at source keeps the substitution map well-formed
658        // and surfaces the constraint failure to the caller as a
659        // normal type-incompatibility error.
660        if Self::type_contains_var(actual, name) {
661            return false;
662        }
663        subst.insert(name.to_string(), actual.clone());
664        true
665    }
666
667    /// Structural recursion over `ty` looking for any `Type::Var(name)`.
668    /// Used by the occurs check in [`bind_expected_var`]; not exposed
669    /// elsewhere because it's a one-step deep walk over a finite Type
670    /// AST (no shared subterms, no cycles in the AST itself — the cycle
671    /// would only exist in the substitution map, which the bind path
672    /// is what guards).
673    fn type_contains_var(ty: &Type, name: &str) -> bool {
674        match ty {
675            Type::Var(other) => other == name,
676            Type::Int
677            | Type::Float
678            | Type::Str
679            | Type::Bool
680            | Type::Unit
681            | Type::Invalid
682            | Type::Named(_) => false,
683            Type::Option(inner) | Type::List(inner) | Type::Vector(inner) => {
684                Self::type_contains_var(inner, name)
685            }
686            Type::Result(ok, err) => {
687                Self::type_contains_var(ok, name) || Self::type_contains_var(err, name)
688            }
689            Type::Map(k, v) => Self::type_contains_var(k, name) || Self::type_contains_var(v, name),
690            Type::Tuple(items) => items.iter().any(|t| Self::type_contains_var(t, name)),
691            Type::Fn(params, ret, _effects) => {
692                params.iter().any(|p| Self::type_contains_var(p, name))
693                    || Self::type_contains_var(ret, name)
694            }
695        }
696    }
697
698    pub(super) fn instantiate_type(ty: &Type, subst: &HashMap<String, Type>) -> Type {
699        match ty {
700            Type::Var(name) => subst.get(name).cloned().unwrap_or_else(|| ty.clone()),
701            Type::Result(ok, err) => Type::Result(
702                Box::new(Self::instantiate_type(ok, subst)),
703                Box::new(Self::instantiate_type(err, subst)),
704            ),
705            Type::Option(inner) => Type::Option(Box::new(Self::instantiate_type(inner, subst))),
706            Type::List(inner) => Type::List(Box::new(Self::instantiate_type(inner, subst))),
707            Type::Vector(inner) => Type::Vector(Box::new(Self::instantiate_type(inner, subst))),
708            Type::Map(k, v) => Type::Map(
709                Box::new(Self::instantiate_type(k, subst)),
710                Box::new(Self::instantiate_type(v, subst)),
711            ),
712            Type::Tuple(items) => Type::Tuple(
713                items
714                    .iter()
715                    .map(|item| Self::instantiate_type(item, subst))
716                    .collect(),
717            ),
718            Type::Fn(params, ret, effects) => Type::Fn(
719                params
720                    .iter()
721                    .map(|param| Self::instantiate_type(param, subst))
722                    .collect(),
723                Box::new(Self::instantiate_type(ret, subst)),
724                effects.clone(),
725            ),
726            Type::Int
727            | Type::Float
728            | Type::Str
729            | Type::Bool
730            | Type::Unit
731            | Type::Invalid
732            | Type::Named(_) => ty.clone(),
733        }
734    }
735}