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}