diff --git a/crates/ail/tests/mono_xmod_ctor_pattern.rs b/crates/ail/tests/mono_xmod_ctor_pattern.rs index 53549ce..e2c374e 100644 --- a/crates/ail/tests/mono_xmod_ctor_pattern.rs +++ b/crates/ail/tests/mono_xmod_ctor_pattern.rs @@ -1,55 +1,29 @@ -//! Bug regression: the workspace-monomorphisation pass must not mis-resolve +//! Regression: the workspace-monomorphisation pass must not mis-resolve //! a cross-module constructor pattern. The mono pass re-runs `synth` on //! every fn body to recover residual class constraints; that env is built //! by `mono::build_workspace_env`, which delegates to `crate::build_check_env` -//! and produces a **workspace-flat** `ctor_index` (every Def::Type ctor -//! across every module, keyed by bare ctor name → bare type name). +//! and produces a workspace-flat `ctor_index` and `types` map. //! -//! In contrast, `check_in_workspace` (lib.rs:1247-1258) explicitly clears -//! that flat index after `build_check_env` and rebuilds it per-module so -//! that `Pattern::Ctor`'s local-first / imports-fallback resolution -//! (lib.rs:2486-2521) can keep the qualified-type-name comparison at -//! lib.rs:2526 intact: the imports-fallback branch produces a qualified -//! `resolved_type_name` (`Mod.Type`), the local-hit branch produces a bare -//! one (`Type`). +//! Post-ct.2, `Pattern::Ctor` lookup is type-driven — it consults the +//! scrutinee's canonical `Type::Con.name` to find the TypeDef directly +//! in `env.module_types`, then validates the ctor name within it. The +//! mono pass's flat `ctor_index` is no longer consulted by this path; +//! the per-module overlay (lib.rs:1247-1258) is now decorative for the +//! pattern path and remains only for duplicate-type / duplicate-ctor +//! detection at the workspace-build prologue. //! -//! The mono pass never does the per-module overlay. So when a body in -//! module B pattern-matches a constructor `C` whose Def::Type lives in -//! imported module A, the workspace-flat ctor_index resolves `C` locally -//! (in fact: anywhere in the workspace) and yields the bare type name -//! `"Type"`. The scrutinee, however, was typed by the main pass against -//! the qualified name `"A.Type"` (per the imports-fallback path), so the -//! comparison -//! -//! ```ignore -//! Type::Con { name, args } if name == &resolved_type_name => args.clone(), -//! _ => return Err(CheckError::PatternTypeMismatch { ... }), -//! ``` -//! -//! at lib.rs:2526 fails with `cannot match constructor pattern C against -//! type A.Type<...>`. -//! -//! Sibling regressions in the same family: -//! * `mono_xmod_qualified_ref.rs` — `env.imports` not seeded. -//! * commit 13b36cc — `env.globals` not seeded for self-recursive fns. -//! * commit 5c5180f — `env.types` / `env.ctor_index` not seeded for -//! user ADTs (the original "flat ctor_index" decision that this -//! bug now exposes as wrong-by-construction for cross-module -//! pattern-match resolution). +//! This test pins the cross-module pattern shape against a minimal +//! 2-module fixture (`test_mono_ctor_main` + `test_mono_ctor_listmod`). +//! Pre-ct.2 the bug surfaced as `PatternTypeMismatch { ctor: "Cons", +//! ty: "test_mono_ctor_listmod.List" }` because the mono env +//! resolved `Cons` to bare `List` via the flat index. Post-ct.2 the +//! lookup is type-driven and `expected.name == "test_mono_ctor_listmod.List"` +//! directly indexes the right TypeDef. //! //! Surfaced by iter 23.2 Task 3, which adds `class Eq a` + Eq Int/Bool/Str -//! instances to `examples/prelude.ail.json`. Before Task 3 the prelude has -//! only `Def::Type Ordering`, so `workspace_has_typeclasses(ws) == false` -//! and the mono pass early-outs at `mono.rs:73`. Task 3 flips the gate; -//! every workspace now traverses bodies, which brings the latent bug to -//! the surface. -//! -//! Without the prelude change, two pre-existing E2E tests already -//! exercise the bug shape (`nested_ctor_pattern_first_two_sum`, -//! `std_either_list_demo`) and silently pass because the mono pass is a -//! no-op on a class-free workspace. This test pins the inner cause -//! against a minimal 2-module fixture so it stays RED regardless of the -//! prelude's typeclass content. +//! instances to `examples/prelude.ail.json`, flipping the +//! `workspace_has_typeclasses` gate so every workspace exercises the +//! mono pass. use std::path::PathBuf; diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 3e1365c..1eecfa6 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -2489,69 +2489,52 @@ fn type_check_pattern( } } } - // Iter 15a: try local ctor_index first; if the bare name - // doesn't resolve locally, fall back to scanning imported - // modules' type defs. The fallback lookup keys on the - // `module.Type` form so it lines up with what the typechecker - // produces for qualified `Term::Ctor`s. Multiple imported - // candidates → `ambiguous-ctor` (local always wins on - // conflict, hence the "imported only if local missing" order). - // - // Iter 15b: track whether the resolved ctor lives in an - // imported module. If so, the cdef's recursive self-references - // need `qualify_local_types` (symmetric to the term-ctor fix); - // otherwise their bare names will not unify against the - // qualified scrutinee args. - let resolved_type_name: String; - let resolved_td: TypeDef; - let resolved_owning_module: Option; - if let Some(cref) = env.ctor_index.get(ctor) { - resolved_type_name = cref.type_name.clone(); - resolved_td = env.types[&cref.type_name].clone(); - resolved_owning_module = None; - } else { - let mut hits: Vec<(String, String, TypeDef)> = Vec::new(); - for imp in env.imports.values() { - if let Some(tys) = env.module_types.get(imp) { - for (tname, td) in tys { - if td.ctors.iter().any(|c| &c.name == ctor) { - hits.push(( - format!("{imp}.{tname}"), - imp.clone(), - td.clone(), - )); - } + // ct.2 Task 2: Pattern::Ctor lookup is type-driven post-ct.1. + // The scrutinee's Type::Con name is canonical (bare = local + // TypeDef, qualified = explicit cross-module). Derive the + // TypeDef from `expected` and find the ctor by name within + // it; no env.ctor_index consult, no imports-walk. + let (resolved_type_name, resolved_td, resolved_owning_module) = + match expected { + Type::Con { name, args: _ } => { + if let Some((owner, suffix)) = name.split_once('.') { + let td = env + .module_types + .get(owner) + .and_then(|tys| tys.get(suffix)) + .cloned() + .ok_or_else(|| { + CheckError::PatternTypeMismatch { + ctor: ctor.clone(), + ty: ailang_core::pretty::type_to_string(expected), + } + })?; + (name.clone(), td, Some(owner.to_string())) + } else { + let td = env.types.get(name).cloned().ok_or_else(|| { + CheckError::PatternTypeMismatch { + ctor: ctor.clone(), + ty: ailang_core::pretty::type_to_string(expected), + } + })?; + (name.clone(), td, None) } } - } - match hits.len() { - 0 => return Err(CheckError::UnknownCtorInPattern(ctor.clone())), - 1 => { - let (qname, owner, td) = - hits.into_iter().next().expect("len == 1"); - resolved_type_name = qname; - resolved_td = td; - resolved_owning_module = Some(owner); - } _ => { - return Err(CheckError::AmbiguousCtor { + return Err(CheckError::PatternTypeMismatch { ctor: ctor.clone(), - candidates: hits.into_iter().map(|(q, _, _)| q).collect(), + ty: ailang_core::pretty::type_to_string(expected), }); } - } + }; + // Validate the ctor exists in the resolved TypeDef. + if !resolved_td.ctors.iter().any(|c| &c.name == ctor) { + return Err(CheckError::UnknownCtorInPattern(ctor.clone())); } - // expected must be this ADT. For parameterised ADTs, capture - // the type-args so we can substitute them into the cdef's - // field types when binding sub-patterns. + // expected is already validated as Type::Con above. let scrutinee_args: Vec = match expected { - Type::Con { name, args } if name == &resolved_type_name => args.clone(), - _ => { - return Err(CheckError::PatternTypeMismatch { - ctor: ctor.clone(), - ty: ailang_core::pretty::type_to_string(expected), - }); - } + Type::Con { args, .. } => args.clone(), + _ => unreachable!("matched above"), }; let td = &resolved_td; let cdef = td @@ -3599,11 +3582,13 @@ mod tests { assert!(diags.is_empty(), "expected green; got {diags:?}"); } - /// Iter 15a, path 3: a bare `Pattern::Ctor.ctor` falls back through - /// imports when the ctor isn't local. The scrutinee carries the - /// qualified type so the pattern check finds the right ADT. + /// Iter 15a (post-ct.2): a bare `Pattern::Ctor` (e.g. `MkBox x`) + /// against a scrutinee of type `lib.Box` resolves through + /// the type-driven lookup keyed on `expected.name`. Previously + /// this exercised an imports-fallback; post-ct.2 the lookup is + /// anchored to the scrutinee's qualified TypeDef directly. #[test] - fn cross_module_pat_ctor_fallback_resolves() { + fn cross_module_pat_ctor_typedriven_resolves() { let consumer = Module { schema: SCHEMA.into(), name: "use_lib".into(), @@ -3638,86 +3623,6 @@ mod tests { assert!(diags.is_empty(), "expected green; got {diags:?}"); } - /// Iter 15a, path 4: a bare ctor name that resolves in two - /// imported modules surfaces as `ambiguous-ctor` with both - /// candidates listed. - #[test] - fn cross_module_pat_ctor_ambiguous_errors() { - // Two different libs, each declaring a ctor `Mk` (intentional clash). - let lib_a = Module { - schema: SCHEMA.into(), - name: "lib_a".into(), - imports: vec![], - defs: vec![Def::Type(TypeDef { - name: "TA".into(), - vars: vec![], - ctors: vec![Ctor { name: "Mk".into(), fields: vec![] }], - doc: None, - drop_iterative: false, - })], - }; - let lib_b = Module { - schema: SCHEMA.into(), - name: "lib_b".into(), - imports: vec![], - defs: vec![Def::Type(TypeDef { - name: "TB".into(), - vars: vec![], - ctors: vec![Ctor { name: "Mk".into(), fields: vec![] }], - doc: None, - drop_iterative: false, - })], - }; - let consumer = Module { - schema: SCHEMA.into(), - name: "use_both".into(), - imports: vec![ - Import { module: "lib_a".into(), alias: None }, - Import { module: "lib_b".into(), alias: None }, - ], - defs: vec![fn_def( - "f", - Type::Fn { - params: vec![Type::Con { - name: "lib_a.TA".into(), - args: vec![], - }], - ret: Box::new(Type::int()), - effects: vec![], - param_modes: vec![], - ret_mode: ParamMode::Implicit, - }, - vec!["t"], - Term::Match { - scrutinee: Box::new(Term::Var { name: "t".into() }), - arms: vec![Arm { - // Bare `Mk` is ambiguous between lib_a and lib_b. - pat: Pattern::Ctor { - ctor: "Mk".into(), - fields: vec![], - }, - body: Term::Lit { lit: Literal::Int { value: 0 } }, - }], - }, - )], - }; - let mut modules = BTreeMap::new(); - modules.insert("lib_a".into(), lib_a); - modules.insert("lib_b".into(), lib_b); - modules.insert("use_both".into(), consumer); - let ws = Workspace { - entry: "use_both".into(), - modules, - root_dir: std::path::PathBuf::from("."), - registry: ailang_core::workspace::Registry::default(), - }; - let diags = check_workspace(&ws); - assert!( - diags.iter().any(|d| d.code == "ambiguous-ctor"), - "expected ambiguous-ctor diagnostic; got {diags:?}" - ); - } - /// Iter 15b: a recursive cross-module ADT (`std_list.List a` with a /// `Cons a (List a)` ctor) round-trips through both `Term::Ctor` synth /// and pattern-ctor binding without unqualified-field-name unification @@ -5046,4 +4951,120 @@ mod tests { "expected CheckError::FloatPatternNotAllowed, got {err:?}" ); } + + /// ct.2 Task 2: a `Pattern::Ctor` against a scrutinee of type + /// `Type::Con { name: "p.T", .. }` looks up the TypeDef in + /// `env.module_types["p"]["T"]` and finds the ctor by name within + /// it — no `env.ctor_index` consult, no imports-walk. This pins the + /// new lookup strategy. + #[test] + fn ct2_pattern_ctor_type_driven_lookup_cross_module() { + let lib = Module { + schema: SCHEMA.into(), + name: "lib".into(), + imports: vec![], + defs: vec![Def::Type(TypeDef { + name: "Box".into(), + vars: vec!["a".into()], + ctors: vec![Ctor { + name: "MkBox".into(), + fields: vec![Type::Var { name: "a".into() }], + }], + doc: None, + drop_iterative: false, + })], + }; + let consumer = Module { + schema: SCHEMA.into(), + name: "use_lib".into(), + imports: vec![Import { module: "lib".into(), alias: None }], + defs: vec![fn_def( + "open", + Type::Fn { + params: vec![Type::Con { + name: "lib.Box".into(), + args: vec![Type::int()], + }], + ret: Box::new(Type::int()), + effects: vec![], + param_modes: vec![], + ret_mode: ParamMode::Implicit, + }, + vec!["b"], + Term::Match { + scrutinee: Box::new(Term::Var { name: "b".into() }), + arms: vec![Arm { + pat: Pattern::Ctor { + ctor: "MkBox".into(), + fields: vec![Pattern::Var { name: "x".into() }], + }, + body: Term::Var { name: "x".into() }, + }], + }, + )], + }; + let mut modules = BTreeMap::new(); + modules.insert("lib".into(), lib); + modules.insert("use_lib".into(), consumer); + let ws = Workspace { + entry: "use_lib".into(), + modules, + root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), + }; + let diags = check_workspace(&ws); + assert!(diags.is_empty(), "expected green; got {diags:?}"); + } + + /// ct.2 Task 2: when the scrutinee carries a Type::Con name that + /// doesn't resolve to any TypeDef (workspace-wide), the pattern + /// lookup must fail closed. The post-ct.1 validator catches this + /// shape earlier for canonical inputs; this test pins the residual + /// runtime guard against constructed (non-loaded) AST. + #[test] + fn ct2_pattern_ctor_fails_closed_when_scrutinee_type_unknown() { + let consumer = Module { + schema: SCHEMA.into(), + name: "u".into(), + imports: vec![], + defs: vec![fn_def( + "f", + Type::Fn { + params: vec![Type::Con { + name: "Nonexistent".into(), + args: vec![], + }], + ret: Box::new(Type::int()), + effects: vec![], + param_modes: vec![], + ret_mode: ParamMode::Implicit, + }, + vec!["x"], + Term::Match { + scrutinee: Box::new(Term::Var { name: "x".into() }), + arms: vec![Arm { + pat: Pattern::Ctor { + ctor: "Whatever".into(), + fields: vec![], + }, + body: Term::Lit { lit: Literal::Int { value: 0 } }, + }], + }, + )], + }; + let mut modules = BTreeMap::new(); + modules.insert("u".into(), consumer); + let ws = Workspace { + entry: "u".into(), + modules, + root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), + }; + let diags = check_workspace(&ws); + assert!( + !diags.is_empty(), + "expected at least one diagnostic for unknown scrutinee \ + type, got none" + ); + } }