//! Workspace-load pin tests, relocated from `ailang-core/src/workspace.rs` //! `#[cfg(test)] mod tests` to a `tests/*` integration crate in iter //! form-a.1 Task 5. The relocation switches each test from //! `ailang_core::load_workspace` (the JSON-only loader) to //! `ailang_surface::load_workspace` (the extension-dispatching superset), //! so the post-iter `.ail` corpus is loaded from Form A rather than the //! deleted `.ail.json` siblings. //! //! pd.2 extension: the second batch (10 tests at the end of this file) //! is relocated from `ailang-core/src/workspace.rs::tests` because pd.2 //! deletes the `load_workspace_with` shim those tests depended on. //! Their post-pd.2 successor is `ailang_surface::load_workspace`, which //! is unreachable from `ailang-core` lib-tests due to the dev-dep cycle //! (`ailang-core` dev-deps `ailang-surface` which depends on //! `ailang-core` — fine for integration tests, breaks lib-tests). The //! integration-test crate is the right home: it consumes both crates //! against the same `ailang-core` artefact, so the cycle never //! materialises. use ailang_core::workspace::{Registry, RegistryEntry, WorkspaceLoadError}; use ailang_surface::load_workspace; use ailang_test_support::examples_dir; use std::path::{Path, PathBuf}; // Adapter so this integration crate can call `load_modules_with` with // the same Io/Schema mapping the in-mod test helper used. fn load_one_adapter(path: &Path) -> Result { match ailang_core::load_module(path) { Ok(m) => Ok(m), Err(ailang_core::Error::Io(e)) => Err(WorkspaceLoadError::Io { path: path.to_path_buf(), source: e, }), Err(e) => Err(WorkspaceLoadError::Schema { path: path.to_path_buf(), source: e, }), } } #[test] fn loads_example_workspace_happy_path() { let entry = examples_dir().join("ws_main.ail"); let ws = load_workspace(&entry).expect("load workspace"); assert_eq!(ws.entry, "ws_main"); assert!(ws.modules.contains_key("ws_main")); assert!(ws.modules.contains_key("ws_lib")); // the loader auto-injects two built-in kernel-tier modules // (`prelude` and `raw_buf`, the first real-payload kernel // extension, raw-buf.4), so the count is the user's two modules // plus those two. assert_eq!(ws.modules.len(), 4); assert!(ws.modules.contains_key("prelude")); assert!(ws.modules.contains_key("raw_buf")); } #[test] fn loads_workspace_auto_injects_prelude() { // the prelude module is implicitly part of every // workspace, regardless of whether the user's modules import // it. Loading any well-formed workspace must result in // `ws.modules["prelude"]` being present with the Ordering // type def. let entry = examples_dir().join("ws_main.ail"); let ws = load_workspace(&entry).expect("load workspace"); assert!( ws.modules.contains_key("prelude"), "prelude module must be auto-injected; modules present: {:?}", ws.modules.keys().collect::>() ); let prelude = &ws.modules["prelude"]; assert_eq!(prelude.name, "prelude"); assert!( prelude.defs.iter().any(|d| matches!( d, ailang_core::ast::Def::Type(t) if t.name == "Ordering" )), "prelude must contain Ordering type def" ); // prelude also ships the `Eq` class plus three // primitive instances (Eq Int, Eq Bool, Eq Str). assert!( prelude.defs.iter().any(|d| matches!( d, ailang_core::ast::Def::Class(c) if c.name == "Eq" )), "prelude must contain Eq class def" ); let eq_instance_types: Vec<&str> = prelude .defs .iter() .filter_map(|d| match d { ailang_core::ast::Def::Instance(i) if i.class == "Eq" => { if let ailang_core::ast::Type::Con { name, .. } = &i.type_ { Some(name.as_str()) } else { None } } _ => None, }) .collect(); assert!( eq_instance_types.contains(&"Int"), "prelude must contain `instance Eq Int`; saw Eq instances on: {eq_instance_types:?}" ); assert!( eq_instance_types.contains(&"Bool"), "prelude must contain `instance Eq Bool`; saw Eq instances on: {eq_instance_types:?}" ); assert!( eq_instance_types.contains(&"Str"), "prelude must contain `instance Eq Str`; saw Eq instances on: {eq_instance_types:?}" ); // prelude also ships the `Ord` class plus three // primitive instances (Ord Int, Ord Bool, Ord Str). assert!( prelude.defs.iter().any(|d| matches!( d, ailang_core::ast::Def::Class(c) if c.name == "Ord" )), "prelude must contain Ord class def" ); let ord_instance_types: Vec<&str> = prelude .defs .iter() .filter_map(|d| match d { ailang_core::ast::Def::Instance(i) if i.class == "Ord" => { if let ailang_core::ast::Type::Con { name, .. } = &i.type_ { Some(name.as_str()) } else { None } } _ => None, }) .collect(); assert!( ord_instance_types.contains(&"Int"), "prelude must contain `instance Ord Int`; saw: {ord_instance_types:?}" ); assert!( ord_instance_types.contains(&"Bool"), "prelude must contain `instance Ord Bool`; saw: {ord_instance_types:?}" ); assert!( ord_instance_types.contains(&"Str"), "prelude must contain `instance Ord Str`; saw: {ord_instance_types:?}" ); } #[test] fn iter22b1_workspace_with_no_classes_has_empty_registry() { let entry = examples_dir().join("sum.ail"); let ws = load_workspace(&entry).expect("sum.ail loads"); assert!( ws.registry.entries.values().all(|e: &RegistryEntry| e.defining_module == "prelude"), "pre-22b fixture has no class/instance defs of its own; \ all registry entries must come from the auto-injected prelude. \ got non-prelude entries: {:?}", ws.registry.entries.values() .filter(|e| e.defining_module != "prelude") .map(|e| &e.defining_module) .collect::>() ); // Silence unused-import warning for `Registry`; the type is named // in the test only via the field-access path above. let _: Option<&Registry> = None; } #[test] fn iter22b1_instance_in_class_module_loads_clean() { let entry = examples_dir().join("test_22b1_orphan_class.ail"); let ws = load_workspace(&entry).expect("coherent instance loads"); let fixture_entries: Vec<_> = ws.registry.entries.iter() .filter(|(_, e)| e.defining_module != "prelude") .collect(); assert_eq!(fixture_entries.len(), 1); let (key, entry) = fixture_entries[0]; // registry key is keyed by the qualified class name. // 24.2: class renamed `Show` → `TShow` workspace-wide. assert_eq!(&key.0, "test_22b1_orphan_class.TShow"); assert_eq!(entry.defining_module, "test_22b1_orphan_class"); // `instance.class` carries the canonical-form on-disk value // (bare for same-module per the canonical-form rule). assert_eq!(entry.instance.class, "TShow"); } /// an instance declared in a module that is neither /// the class's module nor the type's module fires `OrphanInstance`. #[test] fn iter22b1_orphan_instance_fires_diagnostic() { let entry = examples_dir().join("test_22b1_orphan_third.ail"); let err = load_workspace(&entry).expect_err("must fire orphan"); match err { WorkspaceLoadError::OrphanInstance { class, type_repr, defining_module, .. } => { assert_eq!(class, "test_22b1_orphan_third_classmod.TShow"); assert_eq!(type_repr, "Int"); assert_eq!(defining_module, "test_22b1_orphan_third"); } other => panic!("expected OrphanInstance, got {other:?}"), } } /// two instances of the same `(class, type)` pair /// collide on the registry's uniqueness check. Post-canonical-class-form both /// instances must live in the class's or the type's module (see /// fixture). #[test] fn iter22b1_duplicate_instance_fires_diagnostic() { let entry = examples_dir().join("test_22b1_dup_same_module.ail"); let err = load_workspace(&entry).expect_err("must fire duplicate"); match err { WorkspaceLoadError::DuplicateInstance { class, type_repr, first_module, second_module, } => { assert_eq!(class, "TShow"); assert_eq!(type_repr, "Int"); assert_eq!(first_module, "test_22b1_dup_same_module"); assert_eq!(second_module, "test_22b1_dup_same_module"); } other => panic!("expected DuplicateInstance, got {other:?}"), } } /// an instance that omits a required (non-default) method /// of its class fires `MissingMethod`. #[test] fn iter22b1_missing_method_fires_diagnostic() { let entry = examples_dir().join("test_22b1_missing_method.ail"); let err = load_workspace(&entry).expect_err("must fire missing-method"); match err { WorkspaceLoadError::MissingMethod { class, type_repr, method, } => { assert_eq!(class, "TEq"); assert_eq!(type_repr, "Int"); assert_eq!(method, "teq"); } other => panic!("expected MissingMethod, got {other:?}"), } } /// an instance that specifies a body for a method name the /// class never declared must fire `OverridingNonExistentMethod`. /// (`test_22b2_overriding_nonexistent` is NOT a §C4 carve-out — it /// stays as a `.ail`-loadable fixture.) #[test] fn instance_overriding_nonexistent_method_fires() { let entry = examples_dir().join("test_22b2_overriding_nonexistent.ail"); let err = load_workspace(&entry) .expect_err("must fire overriding-non-existent-method"); match err { WorkspaceLoadError::OverridingNonExistentMethod { class, type_repr, method, } => { assert_eq!(class, "TEq"); assert_eq!(type_repr, "Int"); assert_eq!(method, "ne"); } other => panic!("expected OverridingNonExistentMethod, got {other:?}"), } } /// an instance `C T` whose class `C` declares a superclass /// `S` requires that `instance S T` also exist in the workspace. #[test] fn instance_without_superclass_instance_fires() { let entry = examples_dir().join("test_22b2_missing_superclass_instance.ail"); let err = load_workspace(&entry) .expect_err("must fire missing-superclass-instance"); match err { WorkspaceLoadError::MissingSuperclassInstance { class, superclass, type_repr, } => { assert_eq!(class, "test_22b2_missing_superclass_instance.TOrd"); assert_eq!(superclass, "TEq"); assert_eq!(type_repr, "Int"); } other => panic!("expected MissingSuperclassInstance, got {other:?}"), } } /// positive on-disk pair where `Constraint.class` references a /// class in an imported module via the qualified form loads cleanly. #[test] fn mq1_xmod_constraint_class_fixture_loads() { let entry = examples_dir().join("mq1_xmod_constraint_class.ail"); let ws = load_workspace(&entry) .expect("workspace must load with qualified Constraint.class"); assert!(ws.modules.contains_key("mq1_xmod_constraint_class")); assert!(ws.modules.contains_key("mq1_xmod_constraint_class_dep")); } // --------------------------------------------------------------------- // pd.2 relocations from `ailang-core/src/workspace.rs::tests` (the // post-shim-retirement batch). Each test below was a `load_workspace_with` // caller in the in-mod tests; post-pd.2 the shim is gone and these // tests now go through `ailang_surface::load_workspace` (which composes // `load_modules_with` + caller-side prelude inject + `build_workspace`). // // `tmp_dir` + `write_module` helpers are inlined per-test to keep each // test self-contained at integration-test scope. // --------------------------------------------------------------------- fn write_simple_module_json(dir: &Path, name: &str, imports: &[&str]) -> PathBuf { use std::fs; let imports_json: Vec = imports .iter() .map(|m| serde_json::json!({ "module": m })) .collect(); let module = serde_json::json!({ "schema": "ailang/v0", "name": name, "imports": imports_json, "defs": [], }); let path = dir.join(format!("{name}.ail.json")); fs::write(&path, serde_json::to_vec_pretty(&module).unwrap()).unwrap(); path } /// the loader auto-injects a module named /// "prelude", so a user module that also claims that name would collide /// silently. The collision is caught explicitly with `ReservedModuleName`. #[test] fn user_module_named_prelude_is_rejected() { let dir = tempfile::tempdir().unwrap(); let entry = write_simple_module_json(dir.path(), "prelude", &[]); let err = load_workspace(&entry).expect_err("must reject user prelude"); match err { WorkspaceLoadError::ReservedModuleName { name } => { assert_eq!(name, "prelude"); } other => panic!("expected ReservedModuleName, got: {other:?}"), } } /// pd.2 relocation: workspace import-cycle detection. #[test] fn detects_import_cycle() { let dir = tempfile::tempdir().unwrap(); write_simple_module_json(dir.path(), "a", &["b"]); write_simple_module_json(dir.path(), "b", &["a"]); let entry = dir.path().join("a.ail.json"); let err = load_workspace(&entry).expect_err("must error on cycle"); match err { WorkspaceLoadError::Cycle { path } => { assert!(path.contains(&"a".to_string())); assert!(path.contains(&"b".to_string())); } other => panic!("expected Cycle, got {other:?}"), } } /// pd.2 relocation: a missing imported module yields a structured /// `ModuleNotFound` error naming the absent name + the path that was /// searched. #[test] fn module_not_found_yields_structured_error() { let dir = tempfile::tempdir().unwrap(); write_simple_module_json(dir.path(), "main", &["does_not_exist"]); let entry = dir.path().join("main.ail.json"); let err = load_workspace(&entry).expect_err("must error on missing module"); match err { WorkspaceLoadError::ModuleNotFound { name, expected_path } => { assert_eq!(name, "does_not_exist"); assert!(expected_path.ends_with("does_not_exist.ail.json")); } other => panic!("expected ModuleNotFound, got {other:?}"), } } /// pd.2 relocation: §C4 (a) carve-out fixture /// `test_22b2_kind_mismatch.ail.json` — class param appearing in /// applied position fires the canonical-form rejection /// (`BareCrossModuleTypeRef`). #[test] fn class_param_in_applied_position_fires_canonical_form_rejection() { let entry = examples_dir().join("test_22b2_kind_mismatch.ail.json"); let err = load_workspace(&entry) .expect_err("must fire canonical-form rejection"); match err { WorkspaceLoadError::BareCrossModuleTypeRef { module, name, .. } => { assert_eq!(module, "test_22b2_kind_mismatch"); assert_eq!(name, "f"); } other => panic!( "expected BareCrossModuleTypeRef (validator now fires first), got {other:?}", ), } } /// pd.2 relocation: §C4 (a) carve-out fixture /// `test_22b2_invalid_superclass_param.ail.json` — `class Ord a extends /// Eq b` shape fires `InvalidSuperclassParam`. #[test] fn superclass_with_wrong_param_fires_invalid_superclass_param() { let entry = examples_dir().join("test_22b2_invalid_superclass_param.ail.json"); let err = load_workspace(&entry) .expect_err("must fire invalid-superclass-param"); match err { WorkspaceLoadError::InvalidSuperclassParam { class, superclass, expected_param, got_type, } => { assert_eq!(class, "Ord"); assert_eq!(superclass, "Eq"); assert_eq!(expected_param, "a"); assert_eq!(got_type, "b"); } other => panic!("expected InvalidSuperclassParam, got {other:?}"), } } /// pd.2 relocation: §C4 (a) carve-out fixture /// `test_22b2_unbound_constraint_var.ail.json` — class method /// `Type::Forall.constraints` referencing an unbound type var fires /// `UnboundConstraintTypeVar`. #[test] fn constraint_with_unbound_var_fires_unbound_constraint_type_var() { let entry = examples_dir().join("test_22b2_unbound_constraint_var.ail.json"); let err = load_workspace(&entry) .expect_err("must fire constraint-references-unbound-type-var"); match err { WorkspaceLoadError::UnboundConstraintTypeVar { class, method, var, .. } => { assert_eq!(class, "Foo"); assert_eq!(method, "foo"); assert_eq!(var, "z"); } other => panic!("expected UnboundConstraintTypeVar, got {other:?}"), } } /// pd.2 relocation, prep.1 fixture-flip: §C4 (a) carve-out fixture /// `test_ct1_bare_xmod_rejected.ail.json`. Pre-prep.1 the fixture used /// bare `Ordering` and the validator caught it via prelude-as-candidate. /// Post-prep.1 a bare in-scope type-name (`Ordering` from implicit /// prelude) is ACCEPTED, so the fixture was switched to a name no /// module declares (`Mystery_Type`); the rejection path still fires /// `BareCrossModuleTypeRef` but the candidates list is empty. #[test] fn ct1_fixture_bare_xmod_rejected() { let entry = examples_dir().join("test_ct1_bare_xmod_rejected.ail.json"); let err = load_workspace(&entry).expect_err("must reject bare Mystery_Type"); match err { WorkspaceLoadError::BareCrossModuleTypeRef { module, name, candidates } => { assert_eq!(module, "test_ct1_bare_xmod_rejected"); assert_eq!(name, "Mystery_Type"); assert!( candidates.is_empty(), "no module declares Mystery_Type => candidates empty; got {candidates:?}" ); } other => panic!("expected BareCrossModuleTypeRef, got {other:?}"), } } /// pd.2 relocation: §C4 (a) carve-out fixture /// `test_ct1_bad_qualifier.ail.json` — qualified `Mystery.Type` with /// `Mystery` not a known module fires `BadCrossModuleTypeRef`. #[test] fn ct1_fixture_bad_qualifier() { let entry = examples_dir().join("test_ct1_bad_qualifier.ail.json"); let err = load_workspace(&entry).expect_err("must reject Mystery.Type"); match err { WorkspaceLoadError::BadCrossModuleTypeRef { module, name } => { assert_eq!(module, "test_ct1_bad_qualifier"); assert_eq!(name, "Mystery.Type"); } other => panic!("expected BadCrossModuleTypeRef, got {other:?}"), } } /// pd.2 relocation: §C4 (a) carve-out fixture /// `test_ct1_qualified_class_rejected.ail.json` — declares /// `instance prelude.Eq Int` outside the prelude AND outside Int's /// defining module; post-canonical-class-form the qualified class-ref is schema-valid /// and the downstream coherence check rejects with `OrphanInstance`. #[test] fn ct1_fixture_qualified_class_orphan_post_mq1() { let entry = examples_dir().join("test_ct1_qualified_class_rejected.ail.json"); let err = load_workspace(&entry).expect_err("must reject (now as Orphan)"); match err { WorkspaceLoadError::OrphanInstance { class, type_repr, defining_module, .. } => { assert_eq!(class, "prelude.Eq"); assert_eq!(type_repr, "Int"); assert_eq!(defining_module, "test_ct1_qualified_class_rejected"); } other => panic!("expected OrphanInstance, got {other:?}"), } } /// pd.1 Task 2 + pd.3 Task 1 relocation: `build_workspace` accepts a /// pre-assembled modules map (with the caller having already injected /// any implicit modules) and runs the three-stage validation pipeline. /// The `implicit_imports` slice is what the diagnostic helpers consult /// for fallback candidate suggestions. Relocated from the in-mod /// `tests` of `ailang-core/src/workspace.rs` because the caller-side /// prelude inject now uses `ailang_surface::parse_prelude()`, which /// only typechecks across the dev-dep edge from the integration-test /// crate (the lib-test crate sees two distinct `ailang-core` /// compilations and fails to unify the `Module` types). #[test] fn build_workspace_accepts_assembled_modules_and_runs_validation() { let dir = tempfile::tempdir().unwrap(); write_simple_module_json(dir.path(), "main", &[]); let entry = dir.path().join("main.ail.json"); let (entry_name, root_dir, mut modules) = ailang_core::workspace::load_modules_with(&entry, load_one_adapter) .expect("load"); // Caller-side prelude inject — surface owns the production inject // path; this test consumes it via the dev-dep edge to demonstrate // the caller-injects pattern with the same source of truth. let prelude = ailang_surface::parse_prelude(); modules.insert("prelude".to_string(), prelude); let ws = ailang_core::workspace::build_workspace( entry_name, root_dir, modules, &["prelude"], ) .expect("build"); assert_eq!(ws.entry, "main"); assert!(ws.modules.contains_key("main")); assert!(ws.modules.contains_key("prelude")); }