From 6d99c9af2b3cccb322943d4f994ac778e7dedbb5 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Thu, 21 May 2026 21:36:37 +0100 Subject: [PATCH] Add muscle-memory aliases: post, upper, lower, capitalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes ILO-78, ILO-79, ILO-81. Mirrors the rand/rnd alias pattern. - `post` → `pst` (pre-0.12.0 muscle memory) - `upper`/`lower` → `upr`/`lwr` (Python/JS/Go/Rust naming) - `capitalize` → `cap` (Python/Ruby naming) Updated skills/ilo/ilo-builtins.md to use `pst` as canonical. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/string-aliases.ilo | 21 ++++ src/ast/mod.rs | 17 +++ tests/regression_capitalize_alias.rs | 122 +++++++++++++++++++ tests/regression_post_alias.rs | 84 +++++++++++++ tests/regression_upper_lower_alias.rs | 168 ++++++++++++++++++++++++++ 5 files changed, 412 insertions(+) create mode 100644 examples/string-aliases.ilo create mode 100644 tests/regression_capitalize_alias.rs create mode 100644 tests/regression_post_alias.rs create mode 100644 tests/regression_upper_lower_alias.rs diff --git a/examples/string-aliases.ilo b/examples/string-aliases.ilo new file mode 100644 index 00000000..08a59fdd --- /dev/null +++ b/examples/string-aliases.ilo @@ -0,0 +1,21 @@ +-- string-aliases.ilo: demonstrates muscle-memory long-form aliases for +-- string case-conversion builtins, added in 0.12.1 (ILO-79, ILO-81). +-- +-- Canonical names: upr lwr cap +-- Alias names: upper lower capitalize +-- +-- On first run with an alias the runtime emits a one-time hint pointing to +-- the canonical short form. Subsequent runs are silent. +-- +-- run: demo "> demo" +-- +-- Expected output (modulo hint lines on first run): +-- HELLO +-- hello +-- Hello + +demo > t +s = "hello" +prnt upper s -- alias for upr: "HELLO" +prnt lower (upr s) -- alias for lwr: back to "hello" +capitalize s -- alias for cap: "Hello" diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 3b2b8020..b49c20e4 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -504,8 +504,25 @@ const BUILTIN_ALIASES: &[(&str, &str)] = &[ ("flatten", "flat"), ("concat", "cat"), ("contains", "has"), + // `upper`/`lower` mirror the Python/JS/Go/Rust method names for case + // conversion. Canonical 3-char names `upr`/`lwr` stay unchanged in + // bytecode and fmt output; these aliases only rewrite the parse-time + // name so newcomers from those languages don't hit an unknown-builtin + // error on first run. + ("upper", "upr"), + ("lower", "lwr"), + // `capitalize` mirrors the Python/Ruby method name. Canonical name + // stays `cap`; alias is long-form discoverability only. + ("capitalize", "cap"), ("group", "grp"), ("average", "avg"), + // `post` was the canonical HTTP-POST verb name before 0.12.0 when it + // was renamed to the 3-char `pst` to match the short-form convention. + // Personas that learned the language on pre-0.12.0 examples reach for + // `post` as muscle memory; aliasing it back to `pst` closes that gap + // without widening the canonical surface (bytecode, fmt, docs all stay + // on `pst`). + ("post", "pst"), ("print", "prnt"), ("trim", "trm"), ("split", "spl"), diff --git a/tests/regression_capitalize_alias.rs b/tests/regression_capitalize_alias.rs new file mode 100644 index 00000000..432c19ae --- /dev/null +++ b/tests/regression_capitalize_alias.rs @@ -0,0 +1,122 @@ +// Regression tests pinning the `capitalize` → `cap` alias contract (ILO-81). +// +// `capitalize` is the standard method name for title-casing the first letter +// in Python and Ruby. Personas from those languages reach for the long form. +// The alias rewrites `capitalize` to canonical `cap` at parse time; bytecode +// and fmt output stay on `cap`. +// +// Contracts to lock in: +// 1. `capitalize` resolves to `cap` at the alias-table level. +// 2. `cap` remains canonical in the builtin registry. +// 3. `capitalize "hello"` runs correctly cross-engine and produces "Hello". +// 4. `capitalize` as a binding name is rejected with ILO-P011. +// 5. A hint mentioning both `capitalize` and `cap` is emitted on first use. + +use ilo::ast::resolve_alias; +use ilo::builtins::Builtin; +use std::process::Command; + +fn ilo() -> Command { + Command::new(env!("CARGO_BIN_EXE_ilo")) +} + +fn run(engine: &str, src: &str, entry: &str) -> String { + let out = ilo() + .args([src, engine, entry]) + .output() + .expect("failed to run ilo"); + assert!( + out.status.success(), + "ilo {engine} {src:?} failed: stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +#[cfg(feature = "cranelift")] +const ENGINES_ALL: &[&str] = &["--vm", "--jit"]; +#[cfg(not(feature = "cranelift"))] +const ENGINES_ALL: &[&str] = &["--vm"]; + +#[test] +fn cap_remains_canonical() { + let b = Builtin::from_name("cap").expect("`cap` must be a canonical builtin"); + assert_eq!(b.name(), "cap"); + assert!( + Builtin::from_name("capitalize").is_none(), + "`capitalize` must not be a canonical name; it is an alias for `cap`" + ); + assert_eq!( + resolve_alias("capitalize"), + Some("cap"), + "`capitalize` must resolve to canonical `cap`" + ); +} + +#[test] +fn capitalize_dispatches_cross_engine() { + for engine in ENGINES_ALL { + let out = run(engine, "f>t;capitalize \"hello\"", "f"); + assert_eq!( + out, "Hello", + "{engine}: `capitalize \"hello\"` expected Hello, got {out}" + ); + } +} + +#[test] +fn cap_canonical_still_works() { + for engine in ENGINES_ALL { + let out = run(engine, "f>t;cap \"world\"", "f"); + assert_eq!(out, "World"); + } +} + +#[test] +fn capitalize_rejected_as_binding_name() { + let out = ilo() + .args(["main>t;capitalize=\"hi\";capitalize"]) + .output() + .expect("failed to run ilo"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success()); + assert!( + stderr.contains("ILO-P011"), + "expected ILO-P011, got: {stderr}" + ); + assert!( + stderr.contains("capitalize") && stderr.contains("cap"), + "error must name alias and canonical, got: {stderr}" + ); +} + +#[test] +fn capitalize_rejected_as_user_function_name() { + let out = ilo() + .args(["capitalize s:t>t;cap s"]) + .output() + .expect("failed to run ilo"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success()); + assert!( + stderr.contains("ILO-P011"), + "expected ILO-P011, got: {stderr}" + ); +} + +#[test] +fn capitalize_emits_canonical_hint() { + let out = ilo() + .args(["f>t;capitalize \"world\"", "--vm", "f"]) + .output() + .expect("failed to run ilo"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!( + combined.contains("capitalize") && combined.contains("cap"), + "expected hint mentioning `capitalize` and `cap`, got: {combined}" + ); +} diff --git a/tests/regression_post_alias.rs b/tests/regression_post_alias.rs new file mode 100644 index 00000000..cd6ff062 --- /dev/null +++ b/tests/regression_post_alias.rs @@ -0,0 +1,84 @@ +// Regression tests pinning the `post` → `pst` alias contract (ILO-78). +// +// `post` was the canonical HTTP-POST verb name before 0.12.0 when it was +// renamed to the 3-char `pst` to match the short-form convention. Users who +// learned the language pre-0.12.0 have `post` as muscle memory. The alias +// resolves `post` → `pst` at parse time so those users get a canonical-name +// hint on first run and keep working without modification. +// +// Contracts to lock in: +// 1. `post` resolves to `pst` at the alias-table level. +// 2. `pst` remains the canonical name in the builtin registry. +// 3. `post` as a binding name is rejected at parse time with ILO-P011. +// 4. A hint mentioning both `post` and `pst` is emitted on first use. + +use ilo::ast::resolve_alias; +use ilo::builtins::Builtin; +use std::process::Command; + +fn ilo() -> Command { + Command::new(env!("CARGO_BIN_EXE_ilo")) +} + +#[test] +fn pst_remains_the_canonical_name() { + let b = Builtin::from_name("pst").expect("`pst` must be a canonical builtin"); + assert_eq!(b.name(), "pst", "canonical name is `pst`"); + assert!( + Builtin::from_name("post").is_none(), + "`post` must not be a canonical name; it is an alias for `pst`" + ); + assert_eq!( + resolve_alias("post"), + Some("pst"), + "`post` must resolve to canonical `pst`" + ); +} + +#[test] +fn post_rejected_as_binding_name() { + let out = ilo() + .args(["main>t;post=\"body\";post"]) + .output() + .expect("failed to run ilo"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success(), + "expected `post=\"body\"` to fail at parse time" + ); + assert!( + stderr.contains("ILO-P011"), + "expected ILO-P011 reserved-name error, got: {stderr}" + ); + assert!( + stderr.contains("post") && stderr.contains("pst"), + "error must name the alias and the canonical builtin, got: {stderr}" + ); +} + +#[test] +fn post_rejected_as_user_function_name() { + let out = ilo() + .args(["post url:t body:t>R t t;pst url body"]) + .output() + .expect("failed to run ilo"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success(), + "expected `post url:t body:t>...` to fail at parse time" + ); + assert!( + stderr.contains("ILO-P011"), + "expected ILO-P011 reserved-name error, got: {stderr}" + ); +} + +#[test] +fn post_alias_hint_data_is_correct() { + // The hint system calls `resolve_alias(word)` on each lexed identifier and + // emits "hint: `word` → `canonical` (canonical form)" on successful runs. + // We verify the alias-table data that drives the hint rather than firing a + // real HTTP request in tests (which would be network-dependent). + let alias = resolve_alias("post").expect("`post` must be in BUILTIN_ALIASES"); + assert_eq!(alias, "pst", "hint would say `post` → `pst`"); +} diff --git a/tests/regression_upper_lower_alias.rs b/tests/regression_upper_lower_alias.rs new file mode 100644 index 00000000..d0fd8837 --- /dev/null +++ b/tests/regression_upper_lower_alias.rs @@ -0,0 +1,168 @@ +// Regression tests pinning the `upper` → `upr` and `lower` → `lwr` alias +// contracts (ILO-79). +// +// `upper`/`lower` are the standard method names for case conversion in +// Python, JavaScript, Go, and Rust. Personas from those languages reach for +// the verbose forms first. The aliases rewrite to canonical 3-char `upr`/`lwr` +// at parse time; bytecode and fmt output stay on the canonical names. +// +// Contracts to lock in: +// 1. `upper` resolves to `upr` and `lower` resolves to `lwr` at alias-table level. +// 2. `upr` and `lwr` remain canonical in the builtin registry. +// 3. `upper`/`lower` run correctly cross-engine and produce the right output. +// 4. `upper`/`lower` as binding names are rejected with ILO-P011. +// 5. A hint mentioning both forms is emitted on first use. + +use ilo::ast::resolve_alias; +use ilo::builtins::Builtin; +use std::process::Command; + +fn ilo() -> Command { + Command::new(env!("CARGO_BIN_EXE_ilo")) +} + +fn run(engine: &str, src: &str, entry: &str) -> String { + let out = ilo() + .args([src, engine, entry]) + .output() + .expect("failed to run ilo"); + assert!( + out.status.success(), + "ilo {engine} {src:?} failed: stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +#[cfg(feature = "cranelift")] +const ENGINES_ALL: &[&str] = &["--vm", "--jit"]; +#[cfg(not(feature = "cranelift"))] +const ENGINES_ALL: &[&str] = &["--vm"]; + +#[test] +fn upr_lwr_remain_canonical() { + let b = Builtin::from_name("upr").expect("`upr` must be a canonical builtin"); + assert_eq!(b.name(), "upr"); + let b = Builtin::from_name("lwr").expect("`lwr` must be a canonical builtin"); + assert_eq!(b.name(), "lwr"); + + assert!( + Builtin::from_name("upper").is_none(), + "`upper` must not be a canonical name" + ); + assert!( + Builtin::from_name("lower").is_none(), + "`lower` must not be a canonical name" + ); + + assert_eq!(resolve_alias("upper"), Some("upr")); + assert_eq!(resolve_alias("lower"), Some("lwr")); +} + +#[test] +fn upper_dispatches_cross_engine() { + for engine in ENGINES_ALL { + let out = run(engine, "f>t;upper \"hello\"", "f"); + assert_eq!( + out, "HELLO", + "{engine}: `upper \"hello\"` expected HELLO, got {out}" + ); + } +} + +#[test] +fn lower_dispatches_cross_engine() { + for engine in ENGINES_ALL { + let out = run(engine, "f>t;lower \"HELLO\"", "f"); + assert_eq!( + out, "hello", + "{engine}: `lower \"HELLO\"` expected hello, got {out}" + ); + } +} + +#[test] +fn upr_canonical_still_works() { + for engine in ENGINES_ALL { + let out = run(engine, "f>t;upr \"world\"", "f"); + assert_eq!(out, "WORLD"); + } +} + +#[test] +fn lwr_canonical_still_works() { + for engine in ENGINES_ALL { + let out = run(engine, "f>t;lwr \"WORLD\"", "f"); + assert_eq!(out, "world"); + } +} + +#[test] +fn upper_rejected_as_binding_name() { + let out = ilo() + .args(["main>t;upper=\"hi\";upper"]) + .output() + .expect("failed to run ilo"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success()); + assert!( + stderr.contains("ILO-P011"), + "expected ILO-P011, got: {stderr}" + ); + assert!( + stderr.contains("upper") && stderr.contains("upr"), + "error must name alias and canonical, got: {stderr}" + ); +} + +#[test] +fn lower_rejected_as_binding_name() { + let out = ilo() + .args(["main>t;lower=\"hi\";lower"]) + .output() + .expect("failed to run ilo"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success()); + assert!( + stderr.contains("ILO-P011"), + "expected ILO-P011, got: {stderr}" + ); + assert!( + stderr.contains("lower") && stderr.contains("lwr"), + "error must name alias and canonical, got: {stderr}" + ); +} + +#[test] +fn upper_emits_canonical_hint() { + let out = ilo() + .args(["f>t;upper \"abc\"", "--vm", "f"]) + .output() + .expect("failed to run ilo"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!( + combined.contains("upper") && combined.contains("upr"), + "expected hint mentioning `upper` and `upr`, got: {combined}" + ); +} + +#[test] +fn lower_emits_canonical_hint() { + let out = ilo() + .args(["f>t;lower \"ABC\"", "--vm", "f"]) + .output() + .expect("failed to run ilo"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!( + combined.contains("lower") && combined.contains("lwr"), + "expected hint mentioning `lower` and `lwr`, got: {combined}" + ); +}