iter ct.1.4: dev-only ail migrate-canonical-types subcommand
This commit is contained in:
@@ -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 `<dir>` 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<String, (PathBuf, Module)> = 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<String, BTreeSet<String>> =
|
||||
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<String> =
|
||||
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<String, std::collections::BTreeSet<String>>,
|
||||
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<String, std::collections::BTreeSet<String>>,
|
||||
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<String, std::collections::BTreeSet<String>>,
|
||||
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<String, std::collections::BTreeSet<String>>,
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! ct.1: E2E test for `ail migrate-canonical-types <dir>`.
|
||||
//!
|
||||
//! 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_<name> 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");
|
||||
}
|
||||
Reference in New Issue
Block a user