From d45977f9bb017b694b031ad066a070d3ed06cffd Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 11 May 2026 01:16:55 +0200 Subject: [PATCH] iter ct.1.4: dev-only ail migrate-canonical-types subcommand --- crates/ail/src/main.rs | 514 ++++++++++++++++++++ crates/ail/tests/migrate_canonical_types.rs | 83 ++++ 2 files changed, 597 insertions(+) create mode 100644 crates/ail/tests/migrate_canonical_types.rs diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 8695c8d..5852131 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -225,6 +225,18 @@ enum Cmd { /// Edited `.prose.txt` (carries the human's intent). edited: PathBuf, }, + /// ct.1 (dev-only): rewrite every `*.ail.json` under `` so + /// that bare cross-module `Type::Con` and `Term::Ctor.type_name` + /// references gain their owning module's qualifier. Idempotent. + /// Uses the first-match-in-imports-order disambiguation rule + /// (matches iter 23.1.3's imports-fallback) so the rewrite never + /// silently changes which type a fixture resolves to. Prelude is + /// treated as an implicit last-resort fallback. + MigrateCanonicalTypes { + /// Directory containing `*.ail.json` to migrate (typically + /// `examples/`). + dir: PathBuf, + }, } /// Composes the prose-round-trip prompt described in @@ -432,6 +444,85 @@ fn main() -> Result<()> { .with_context(|| format!("reading {}", edited.display()))?; print!("{}", compose_merge_prose_prompt(&original_form_a, &prose)); } + Cmd::MigrateCanonicalTypes { dir } => { + use ailang_core::ast::{Def, Module}; + use std::collections::{BTreeMap, BTreeSet}; + use std::fs; + + // Pass 1: load every *.ail.json in `dir` into a map by + // module name (NOT via load_workspace — these fixtures + // may currently fail validation). + let mut modules: BTreeMap = BTreeMap::new(); + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let bytes = fs::read(&path)?; + let m: Module = match serde_json::from_slice(&bytes) { + Ok(m) => m, + Err(_) => continue, // not a Module (e.g. snapshot files) + }; + modules.insert(m.name.clone(), (path, m)); + } + + // Include the embedded prelude (so the implicit fallback + // matches the loader's behaviour). + let prelude_json: &str = include_str!( + "../../../examples/prelude.ail.json" + ); + let prelude: Module = serde_json::from_str(prelude_json) + .expect("embedded prelude must parse"); + if !modules.contains_key("prelude") { + modules.insert( + "prelude".to_string(), + (PathBuf::new(), prelude), + ); + } + + // Pre-pass: per-module local-types index. + let mut local_types: BTreeMap> = + BTreeMap::new(); + for (name, (_, m)) in &modules { + let mut s = BTreeSet::new(); + for d in &m.defs { + if let Def::Type(t) = d { + s.insert(t.name.clone()); + } + } + local_types.insert(name.clone(), s); + } + + // Pass 2: rewrite each module's bare cross-module refs. + // Skip the synthetic prelude entry (no path to write to). + let mut rewritten = 0usize; + for (mod_name, (path, m)) in modules.iter_mut() { + if mod_name == "prelude" && path.as_os_str().is_empty() { + continue; + } + let import_names: Vec = + m.imports.iter().map(|i| i.module.clone()).collect(); + let owning = mod_name.clone(); + let mut changed = false; + for def in &mut m.defs { + rewrite_def( + def, + &owning, + &local_types, + &import_names, + &mut changed, + ); + } + if changed { + let bytes = serde_json::to_vec_pretty(m)?; + fs::write(&*path, bytes)?; + println!("migrated: {}", path.display()); + rewritten += 1; + } + } + println!("done. {} file(s) rewritten.", rewritten); + } Cmd::Describe { path, name, json, workspace } => { if workspace { let ws = ailang_core::load_workspace(&path)?; @@ -2238,6 +2329,429 @@ fn build_to( Ok(out_bin) } +/// ct.1 migration helper: rewrite bare cross-module Type::Con names +/// and Term::Ctor.type_name to their qualified form. Sets +/// `*changed` to `true` if any rewrite happened. Uses the +/// first-match-in-imports-order disambiguation rule with prelude as +/// implicit last-resort fallback, matching iter 23.1.3's typecheck +/// imports-fallback so the rewrite never silently re-aims a fixture. +fn rewrite_def( + def: &mut ailang_core::ast::Def, + owning_module: &str, + local_types: &std::collections::BTreeMap>, + import_names: &[String], + changed: &mut bool, +) { + use ailang_core::ast::{Def, Term, Type}; + + fn rewrite_name( + name: &mut String, + owning_module: &str, + local_types: &std::collections::BTreeMap>, + import_names: &[String], + changed: &mut bool, + ) { + if name.contains('.') { + return; + } + if matches!( + name.as_str(), + "Int" | "Bool" | "Str" | "Unit" | "Float" + ) { + return; + } + if local_types + .get(owning_module) + .map(|s| s.contains(name)) + .unwrap_or(false) + { + return; + } + // Bare cross-module — find owner via imports-in-order, with + // prelude as last-resort fallback. + let owner: Option<&str> = import_names + .iter() + .map(|s| s.as_str()) + .find(|imp| { + local_types + .get(*imp) + .map(|s| s.contains(name)) + .unwrap_or(false) + }) + .or_else(|| { + if local_types + .get("prelude") + .map(|s| s.contains(name)) + .unwrap_or(false) + { + Some("prelude") + } else { + None + } + }); + if let Some(owner) = owner { + *name = format!("{owner}.{name}"); + *changed = true; + } + // If no owner found, leave bare — validator will reject later. + } + + fn rewrite_type( + t: &mut Type, + owning_module: &str, + local_types: &std::collections::BTreeMap>, + import_names: &[String], + changed: &mut bool, + ) { + match t { + Type::Con { name, args } => { + rewrite_name( + name, + owning_module, + local_types, + import_names, + changed, + ); + for a in args { + rewrite_type( + a, + owning_module, + local_types, + import_names, + changed, + ); + } + } + Type::Fn { params, ret, .. } => { + for p in params { + rewrite_type( + p, + owning_module, + local_types, + import_names, + changed, + ); + } + rewrite_type( + ret, + owning_module, + local_types, + import_names, + changed, + ); + } + Type::Forall { body, constraints, .. } => { + for c in constraints { + rewrite_type( + &mut c.type_, + owning_module, + local_types, + import_names, + changed, + ); + } + rewrite_type( + body, + owning_module, + local_types, + import_names, + changed, + ); + } + Type::Var { .. } => {} + } + } + + fn rewrite_term( + t: &mut Term, + owning_module: &str, + local_types: &std::collections::BTreeMap>, + import_names: &[String], + changed: &mut bool, + ) { + match t { + Term::Lit { .. } | Term::Var { .. } => {} + Term::App { callee, args, .. } => { + rewrite_term( + callee, + owning_module, + local_types, + import_names, + changed, + ); + for a in args { + rewrite_term( + a, + owning_module, + local_types, + import_names, + changed, + ); + } + } + Term::Let { value, body, .. } => { + rewrite_term( + value, + owning_module, + local_types, + import_names, + changed, + ); + rewrite_term( + body, + owning_module, + local_types, + import_names, + changed, + ); + } + Term::LetRec { ty, body, in_term, .. } => { + rewrite_type( + ty, + owning_module, + local_types, + import_names, + changed, + ); + rewrite_term( + body, + owning_module, + local_types, + import_names, + changed, + ); + rewrite_term( + in_term, + owning_module, + local_types, + import_names, + changed, + ); + } + Term::If { cond, then, else_ } => { + rewrite_term( + cond, + owning_module, + local_types, + import_names, + changed, + ); + rewrite_term( + then, + owning_module, + local_types, + import_names, + changed, + ); + rewrite_term( + else_, + owning_module, + local_types, + import_names, + changed, + ); + } + Term::Do { args, .. } => { + for a in args { + rewrite_term( + a, + owning_module, + local_types, + import_names, + changed, + ); + } + } + Term::Ctor { type_name, args, .. } => { + rewrite_name( + type_name, + owning_module, + local_types, + import_names, + changed, + ); + for a in args { + rewrite_term( + a, + owning_module, + local_types, + import_names, + changed, + ); + } + } + Term::Match { scrutinee, arms } => { + rewrite_term( + scrutinee, + owning_module, + local_types, + import_names, + changed, + ); + for arm in arms { + rewrite_term( + &mut arm.body, + owning_module, + local_types, + import_names, + changed, + ); + } + } + Term::Lam { param_tys, ret_ty, body, .. } => { + for pt in param_tys { + rewrite_type( + pt, + owning_module, + local_types, + import_names, + changed, + ); + } + rewrite_type( + ret_ty, + owning_module, + local_types, + import_names, + changed, + ); + rewrite_term( + body, + owning_module, + local_types, + import_names, + changed, + ); + } + Term::Seq { lhs, rhs } => { + rewrite_term( + lhs, + owning_module, + local_types, + import_names, + changed, + ); + rewrite_term( + rhs, + owning_module, + local_types, + import_names, + changed, + ); + } + Term::Clone { value } => rewrite_term( + value, + owning_module, + local_types, + import_names, + changed, + ), + Term::ReuseAs { source, body } => { + rewrite_term( + source, + owning_module, + local_types, + import_names, + changed, + ); + rewrite_term( + body, + owning_module, + local_types, + import_names, + changed, + ); + } + } + } + + match def { + Def::Fn(fd) => { + rewrite_type( + &mut fd.ty, + owning_module, + local_types, + import_names, + changed, + ); + rewrite_term( + &mut fd.body, + owning_module, + local_types, + import_names, + changed, + ); + } + Def::Const(cd) => { + rewrite_type( + &mut cd.ty, + owning_module, + local_types, + import_names, + changed, + ); + rewrite_term( + &mut cd.value, + owning_module, + local_types, + import_names, + changed, + ); + } + Def::Type(td) => { + for c in &mut td.ctors { + for fty in &mut c.fields { + rewrite_type( + fty, + owning_module, + local_types, + import_names, + changed, + ); + } + } + } + Def::Class(cd) => { + for cm in &mut cd.methods { + rewrite_type( + &mut cm.ty, + owning_module, + local_types, + import_names, + changed, + ); + if let Some(body) = &mut cm.default { + rewrite_term( + body, + owning_module, + local_types, + import_names, + changed, + ); + } + } + } + Def::Instance(id) => { + rewrite_type( + &mut id.type_, + owning_module, + local_types, + import_names, + changed, + ); + for im in &mut id.methods { + rewrite_term( + &mut im.body, + owning_module, + local_types, + import_names, + changed, + ); + } + } + } +} + #[cfg(test)] mod tests { use super::compose_merge_prose_prompt; diff --git a/crates/ail/tests/migrate_canonical_types.rs b/crates/ail/tests/migrate_canonical_types.rs new file mode 100644 index 0000000..9e8f9e9 --- /dev/null +++ b/crates/ail/tests/migrate_canonical_types.rs @@ -0,0 +1,83 @@ +//! ct.1: E2E test for `ail migrate-canonical-types `. +//! +//! Builds a synthetic workspace in a tempdir with one fixture that +//! has a bare cross-module Type::Con ref, runs the migration, then +//! asserts: (a) the rewritten fixture's `Term::Ctor.type_name` is +//! now qualified; (b) running migration again produces no diff +//! (idempotence). +//! +//! Property protected: `ail migrate-canonical-types` rewrites bare +//! cross-module Type::Con / Term::Ctor.type_name refs to their +//! qualified form using first-match-in-imports-order, AND a second +//! run on the already-canonical workspace is a byte-stable no-op. + +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +fn tmp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!( + "ailang_migrate_test_{tag}_{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d +} + +fn ail_bin() -> PathBuf { + // CARGO_BIN_EXE_ is set by cargo when building integration tests. + PathBuf::from(env!("CARGO_BIN_EXE_ail")) +} + +#[test] +fn migrate_canonical_types_rewrites_bare_xmod_term_ctor() { + let dir = tmp_dir("rewrites_bare_xmod"); + // Defining module: `other` declares `type Foo`. + let other_path = dir.join("other.ail.json"); + fs::write(&other_path, serde_json::to_vec_pretty(&serde_json::json!({ + "schema": "ailang/v0", + "name": "other", + "imports": [], + "defs": [{ "kind": "type", "name": "Foo", "ctors": [{ "name": "MkFoo", "fields": [] }] }], + })).unwrap()).unwrap(); + // Consumer module: imports `other`, uses bare `Foo` Term::Ctor. + let main_path = dir.join("main.ail.json"); + let bare_module = serde_json::json!({ + "schema": "ailang/v0", + "name": "main", + "imports": [{ "module": "other" }], + "defs": [{ + "kind": "fn", "name": "f", + "type": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, "effects": [] }, + "params": [], + "body": { "t": "ctor", "type": "Foo", "ctor": "MkFoo", "args": [] } + }], + }); + fs::write(&main_path, serde_json::to_vec_pretty(&bare_module).unwrap()).unwrap(); + + // Run migration. + let status = Command::new(ail_bin()) + .args(["migrate-canonical-types", dir.to_str().unwrap()]) + .status() + .expect("ail binary must launch"); + assert!(status.success(), "migration exited non-zero"); + + // Inspect rewritten consumer. + let after: serde_json::Value = serde_json::from_slice( + &fs::read(&main_path).unwrap() + ).unwrap(); + let ctor_type = after["defs"][0]["body"]["type"].as_str().unwrap(); + assert_eq!(ctor_type, "other.Foo", + "Term::Ctor.type must be qualified after migration"); + + // Idempotence: run migration again, capture bytes, compare. + let bytes_before = fs::read(&main_path).unwrap(); + let status2 = Command::new(ail_bin()) + .args(["migrate-canonical-types", dir.to_str().unwrap()]) + .status() + .expect("ail binary must launch"); + assert!(status2.success(), "second migration exited non-zero"); + let bytes_after = fs::read(&main_path).unwrap(); + assert_eq!(bytes_before, bytes_after, "migration must be idempotent"); +}