Add expression effects, interior cancellation and persistent instance state

This commit is contained in:
levzzz committed 2026-10-28 03:57:32 +05:00
1 parent 76958abc43
commit 0500ff24aa
60 files changed
+3357 -121

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ A working CLI that rewrites a tiny pinned Rust workspace where **two independent
- Start with declarative TOML, *not* proc-macro attributes.
- Index free functions, inline/external modules, inherent methods and trait impl methods; reject unresolved cfg and ambiguous paths.
- Resolve every injection **against pristine original AST** before rewriting. Sort same-site inserts by priority descending, mod ID ascending, injection ID ascending. Reject competing OVERWRITE and OVERWRITE mixed with insertion.
- The baseline implements HEAD, BEFORE_CALL and OVERWRITE. The user-authorized extension adds cancellable HEAD, RETURN, MODIFY_ARG, MODIFY_CONSTANT and WRAP_OPERATION; follow docs/advanced-injections.md. The next user-authorized extension adds local capture, accessor/invoker bridges, slices, MODIFY_ARGS and MODIFY_VARIABLE; follow docs/scopes-and-bridges.md. Version 0.4.0 adds lifetime-method HEAD support and support modules; follow docs/boundary-head-and-modules.md. Syntactic call selectors require explicit match counts and ordinals; never silently skip.
- The baseline implements HEAD, BEFORE_CALL and OVERWRITE. The user-authorized extension adds cancellable HEAD, RETURN, MODIFY_ARG, MODIFY_CONSTANT and WRAP_OPERATION; follow docs/advanced-injections.md. The next user-authorized extension adds local capture, accessor/invoker bridges, slices, MODIFY_ARGS and MODIFY_VARIABLE; follow docs/scopes-and-bridges.md. Version 0.4.0 adds lifetime-method HEAD support and support modules; follow docs/boundary-head-and-modules.md. The user-authorized 0.5.0 extension adds MODIFY_EXPRESSION_VALUE, AFTER_CALL, AFTER_ASSIGN, interior cancellation and per-instance state; follow docs/effects-and-state.md. Syntactic call selectors require explicit match counts and ordinals; never silently skip.
- Test preservation of expression return value and exactly-once argument evaluation. Reject unsupported macro-expanded sites, closures/async cases not proven safe.
- Respect Rust privacy and crate dependency cycles: generated same-crate bridge or another tested approach, not magical cross-crate private access.
- Atomic staging, unchanged-file mtime preservation and locked Cargo target dirs.
Generated
+3 -3
View File
@@ -247,7 +247,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "horizon-mixins-cli"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"clap",
"fs2",
@@ -262,7 +262,7 @@ dependencies = [
[[package]]
name = "horizon-mixins-core"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"cargo_metadata",
"proc-macro2",
@@ -277,7 +277,7 @@ dependencies = [
[[package]]
name = "horizon-mixins-transform"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"horizon-mixins-core",
"prettyplease",
+2 -2
View File
@@ -1,10 +1,10 @@
[workspace]
members = ["crates/horizon-mixins-core", "crates/horizon-mixins-transform", "crates/horizon-mixins-cli"]
exclude = ["fixtures/feasibility", "fixtures/privacy-failure", "fixtures/cycle", "fixtures/game", "fixtures/advanced", "fixtures/scoped"]
exclude = ["fixtures/feasibility", "fixtures/privacy-failure", "fixtures/cycle", "fixtures/game", "fixtures/advanced", "fixtures/scoped", "fixtures/effects"]
resolver = "2"
[workspace.package]
version = "0.4.0"
version = "0.5.0"
edition = "2024"
rust-version = "1.95"
license = "MIT"
+18 -5
View File
@@ -2,9 +2,10 @@
A working stable-Rust CLI for deterministic, build-time source mixins for
[Horizon](https://github.com/ludovic111/horizon). The MVP implements declarative
TOML mods, HEAD (including cancellation), BEFORE_CALL, RETURN, MODIFY_ARG,
TOML mods, HEAD and interior cancellation, BEFORE_CALL, AFTER_CALL, AFTER_ASSIGN,
MODIFY_EXPRESSION_VALUE, RETURN, MODIFY_ARG,
MODIFY_ARGS, MODIFY_CONSTANT, MODIFY_VARIABLE, WRAP_OPERATION, OVERWRITE,
lexical local capture, slices, accessor/invoker bridges, JSON planning and atomic
lexical local capture, slices, accessor/invoker bridges, per-instance state, JSON planning and atomic
Linux staging. Native mods run with your user privileges.
```text
@@ -103,7 +104,7 @@ cargo run --locked -p horizon-mixins-cli -- check \
```
The [scoped injection and bridge guide](docs/scopes-and-bridges.md) documents
the exact contracts and limits. Plans use schema 4; regenerate older plans.
the exact contracts and limits. Plans use schema 5; regenerate older plans.
Validate the implementation:
@@ -115,7 +116,7 @@ cargo test --workspace --locked
python3 tools/benchmark.py # fresh target/benchmark; use --out for another run
```
The 48-test integration suite includes generated source/plan snapshots, positive and
The 64-test integration suite includes generated source/plan snapshots, positive and
negative compilation, deterministic ordering, immutable originals, stale-source
rejection, concurrent checks, and unchanged mtimes. Linux CI runs the same checks.
@@ -136,7 +137,7 @@ Further documentation: [API](docs/mixin-api.md),
[transformations](docs/transformations.md), [architecture](docs/architecture.md),
[staging and cache](docs/build-and-cache.md), [security](docs/security.md),
[roadmap](docs/roadmap.md), [feasibility and measurements](docs/implementation.md).
Additional injection kinds, structural mixins, launcher integration, semantic
Additional injection kinds, broader structural mixins, launcher integration, semantic
resolution, and a complete executable cache remain future work. License: [MIT](LICENSE).
Version 0.4.0 supports lifetime-bearing impl methods, HEAD over preserved
@@ -144,3 +145,15 @@ closure/`?` bodies, and declarative same-crate support modules. See
[boundary HEAD and support modules](docs/boundary-head-and-modules.md).
The separate [Veinminer port](https://github.com/levzzz5154/horizon-veinminer)
uses three TOML HEAD hooks and one module on the pinned complete Horizon game.
Version 0.5.0 adds expression-result modification, after-call/after-assignment
hooks, interior cancellation and namespaced per-instance state with explicit
constructors and serde defaults. See [effects and state](docs/effects-and-state.md)
for exact contracts, composition order, save-data behavior and supported limits.
```sh
cargo fetch --locked --manifest-path fixtures/effects/Cargo.toml
cargo run --locked -p horizon-mixins-cli -- check \
--workspace fixtures/effects --mods fixtures/effects-mods \
--out target/effects-game --trust-native-mods
```
+2 -2
View File
@@ -74,7 +74,7 @@ fn run(cli: Cli) -> Result<serde_json::Value> {
let _lock = staging::lock(&out, "plan")?;
staging::write_atomic(&out, &bytes)?;
return Ok(
json!({"status":"ok", "plan":out, "injections":plan.injections.len(), "bridges":plan.bridges.len(), "modules":plan.modules.len(), "seconds":start.elapsed().as_secs_f64()}),
json!({"status":"ok", "plan":out, "injections":plan.injections.len(), "bridges":plan.bridges.len(), "modules":plan.modules.len(), "states":plan.states.len(), "seconds":start.elapsed().as_secs_f64()}),
);
}
Action::Apply(common) => (common, None),
@@ -120,7 +120,7 @@ fn run(cli: Cli) -> Result<serde_json::Value> {
let files = horizon_mixins_transform::transform(&root, &plan)?;
let stage_changed = staging::publish(&root, &out, files, &plan)?;
let transformation_seconds = start.elapsed().as_secs_f64();
let mut result = json!({"status":"ok", "stage":out, "injections":plan.injections.len(), "bridges":plan.bridges.len(), "modules":plan.modules.len(),
let mut result = json!({"status":"ok", "stage":out, "injections":plan.injections.len(), "bridges":plan.bridges.len(), "modules":plan.modules.len(), "states":plan.states.len(),
"stage_changed":stage_changed, "transformation_seconds":transformation_seconds});
if let Some((_, packages)) = check {
let target = target.unwrap();
@@ -68,7 +68,7 @@ fn all_features_compile_compose_and_preserve_source_and_mtimes() {
fn advanced_source_and_plan_snapshots_ignore_discovery_order() {
let f = fixture();
let plan = build_plan(&f.root, &f.mods).unwrap();
assert_eq!(plan.schema, 4);
assert_eq!(plan.schema, 5);
fs::rename(f.mods.join("alpha"), f.mods.join("z-alpha")).unwrap();
fs::rename(f.mods.join("beta"), f.mods.join("a-beta")).unwrap();
assert_eq!(
@@ -0,0 +1,560 @@
use super::*;
fn fixture() -> Fixture {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("original");
let mods = temp.path().join("mods");
copy(&repo().join("fixtures/effects"), &root);
copy(&repo().join("fixtures/effects-mods"), &mods);
Fixture {
out: temp.path().join("stage"),
_temp: temp,
root,
mods,
}
}
fn edit(
f: &Fixture,
section: &str,
id: &str,
change: impl FnOnce(&mut toml::map::Map<String, toml::Value>),
) {
let path = f.mods.join("alpha/horizon-mixin.toml");
let mut v: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
change(
v[section]
.as_array_mut()
.unwrap()
.iter_mut()
.find(|v| v["id"].as_str() == Some(id))
.unwrap()
.as_table_mut()
.unwrap(),
);
fs::write(path, toml::to_string(&v).unwrap()).unwrap();
}
fn failure(f: &Fixture, code: &str) {
let before = source::files(&f.root).unwrap();
let error = build_plan(&f.root, &f.mods).unwrap_err();
assert_eq!(error.code, code, "{error:?}");
assert!(!error.message.is_empty());
assert_eq!(source::files(&f.root).unwrap(), before);
assert!(!f.out.exists());
}
#[test]
fn effects_compile_and_preserve_pristine_files_and_mtimes() {
let f = fixture();
let original = source::files(&f.root).unwrap();
let times = mtimes(&f.root);
let result = f.run("check");
assert_eq!(result["states"], 2);
assert!(
result["cargo_stdout"]
.as_str()
.unwrap()
.contains("5 passed")
);
let staged = source::files(&f.out).unwrap();
let staged_times = mtimes(&f.out);
assert_eq!(f.run("check")["stage_changed"], false);
assert_eq!(source::files(&f.out).unwrap(), staged);
assert_eq!(mtimes(&f.out), staged_times);
assert_eq!(source::files(&f.root).unwrap(), original);
assert_eq!(mtimes(&f.root), times);
}
#[test]
fn effects_snapshots_and_discovery_order_are_deterministic() {
let f = fixture();
let plan = build_plan(&f.root, &f.mods).unwrap();
assert_eq!(plan.schema, 5);
fs::rename(f.mods.join("alpha"), f.mods.join("z-alpha")).unwrap();
fs::rename(f.mods.join("beta"), f.mods.join("a-beta")).unwrap();
assert_eq!(
serde_json::to_vec(&plan).unwrap(),
serde_json::to_vec(&build_plan(&f.root, &f.mods).unwrap()).unwrap()
);
let output = horizon_mixins_transform::transform(&f.root, &plan).unwrap();
let mut portable = serde_json::to_value(&plan).unwrap();
portable.as_object_mut().unwrap().remove("inputs");
portable["rustc"] = "<pinned toolchain>".into();
let json = serde_json::to_string_pretty(&portable).unwrap() + "\n";
let snapshots = repo().join("crates/horizon-mixins-cli/tests/snapshots");
for (name, bytes) in [
("effects.rs", output["game/src/lib.rs"].as_slice()),
("effects-plan.json", json.as_bytes()),
] {
if std::env::var_os("UPDATE_SNAPSHOTS").is_some() {
fs::write(snapshots.join(name), bytes).unwrap();
}
assert_eq!(bytes, fs::read(snapshots.join(name)).unwrap(), "{name}");
}
}
#[test]
fn expression_selection_is_exact_and_counts_original_sites() {
let f = fixture();
edit(&f, "injection", "expression", |v| {
v.insert("require".into(), 2.into());
});
failure(&f, "E_MATCH_COUNT");
let f = fixture();
edit(&f, "injection", "expression", |v| {
v.insert("ordinal".into(), 1.into());
});
failure(&f, "E_ORDINAL");
}
#[test]
fn expression_places_and_owned_reads_are_rejected() {
let f = fixture();
edit(&f, "injection", "expression", |v| {
v.insert("selector".into(), "trace".into());
v.insert("require".into(), 1.into());
v["bindings"].as_array_mut().unwrap()[0]["ty"] = "Trace".into();
});
f.replace(
"mods/alpha/expression.rs",
"fn modify(value:i32,trace:&Trace)->i32",
"fn modify(value:Trace,trace:&Trace)->Trace",
);
failure(&f, "E_UNSUPPORTED_SITE");
let f = fixture();
edit(&f, "injection", "expression", |v| {
v.insert("selector".into(), "value".into());
});
// A scalar read is safe and compiles; replacing a place on an assignment is not.
f.replace(
"game/src/lib.rs",
"let value = add(number(trace),3)*2; value",
"let mut value = add(number(trace),3)*2; value=9; value",
);
edit(&f, "injection", "expression", |v| {
v.insert("require".into(), 2.into());
});
failure(&f, "E_UNSUPPORTED_SITE");
}
#[test]
fn expression_and_call_contracts_must_agree() {
let f = fixture();
edit(&f, "injection", "nested", |v| {
v.insert("target".into(), "effects_game::expression".into());
v.insert("selector".into(), "add".into());
v.insert("result".into(), "u32".into());
v["bindings"].as_array_mut().unwrap()[0]["ty"] = "&mut u32".into();
});
f.replace("mods/alpha/nested.rs", "&mut i32", "&mut u32");
failure(&f, "E_VALUE_SIGNATURE");
}
#[test]
fn after_call_requires_explicit_owned_result_and_correct_borrow() {
let f = fixture();
edit(&f, "injection", "nested", |v| {
v.insert("result".into(), "&i32".into());
});
failure(&f, "E_AFTER_RESULT");
let f = fixture();
edit(&f, "injection", "nested", |v| {
v.insert("result".into(), "u32".into());
});
failure(&f, "E_AFTER_RESULT");
}
#[test]
fn cancellation_and_assignment_reject_unproven_interior_sites() {
let f = fixture();
edit(&f, "injection", "before", |v| {
v.insert("target".into(), "effects_game::nested".into());
});
failure(&f, "E_UNSUPPORTED_SITE");
let f = fixture();
edit(&f, "injection", "assigned-first", |v| {
v.insert("target".into(), "effects_game::nested".into());
v.insert("require".into(), 1.into());
});
failure(&f, "E_UNSUPPORTED_SITE");
let f = fixture();
f.replace(
"game/src/lib.rs",
"let mut value: i32=number(trace);",
"let mut value: i32=number(trace)+1;",
);
failure(&f, "E_UNSUPPORTED_SITE");
}
#[test]
fn return_capture_must_exist_at_added_cancellation_exits() {
let f = fixture();
edit(&f, "injection", "return", |v| {
v["bindings"]
.as_array_mut()
.unwrap()
.push(toml::Value::Table(toml::map::Map::from_iter([
("name".into(), "local".into()),
("ty".into(), "&i32".into()),
("value".into(), "$local_ref:value:0".into()),
])));
});
f.replace(
"mods/alpha/return.rs",
"fn result(value:i32)->i32 {value+1}",
"fn result(value:i32,local:&i32)->i32 {value+*local}",
);
failure(&f, "E_LOCAL_NOT_VISIBLE");
}
#[test]
fn state_requires_safe_layout_ownership_and_complete_constructor_coverage() {
let f = fixture();
f.replace(
"game/src/lib.rs",
"pub struct Entity",
"#[repr(C)] pub struct Entity",
);
failure(&f, "E_STATE");
let f = fixture();
f.replace("game/src/lib.rs", "health:i32 }", "pub health:i32 }");
failure(&f, "E_STATE");
let f = fixture();
edit(&f, "state", "history", |v| {
v.insert("ty".into(), "&'static str".into());
});
failure(&f, "E_STATE");
let f = fixture();
edit(&f, "state", "history", |v| {
v["constructor"].as_array_mut().unwrap().pop();
});
failure(&f, "E_STATE");
let f = fixture();
edit(&f, "state", "history", |v| {
v["constructor"].as_array_mut().unwrap()[0]["require"] = 2.into();
});
failure(&f, "E_MATCH_COUNT");
}
#[test]
fn state_rejects_collisions_patterns_aliases_and_unproven_defaults() {
let f = fixture();
f.replace(
"game/src/lib.rs",
"health:i32 }",
"health:i32, __horizon_mixins_alpha_history:Vec<i32> }",
);
failure(&f, "E_STATE_CONFLICT");
let f = fixture();
f.replace(
"game/src/lib.rs",
"pub fn health(&self)->i32 {self.health}",
"pub fn health(&self)->i32 { let Self { health } = self; *health }",
);
failure(&f, "E_STATE");
let f = fixture();
edit(&f, "state", "history", |v| {
v["constructor"].as_array_mut().unwrap()[0]["selector"] = "Alias".into();
});
failure(&f, "E_STATE");
let f = fixture();
f.replace(
"game/src/lib.rs",
"derive(Clone, Debug, Serialize, Deserialize)",
"derive(Clone, Debug, Default, Serialize, Deserialize)",
);
failure(&f, "E_STATE");
}
#[test]
fn malformed_state_ir_fails_before_rewriting() {
let f = fixture();
let mut plan = build_plan(&f.root, &f.mods).unwrap();
plan.states[0].constructors[0].node.clear();
assert_eq!(
horizon_mixins_transform::transform(&f.root, &plan)
.unwrap_err()
.code,
"E_PLAN_SITE"
);
let mut plan = build_plan(&f.root, &f.mods).unwrap();
plan.states[0].constructors[0].fingerprint = "changed".into();
assert_eq!(
horizon_mixins_transform::transform(&f.root, &plan)
.unwrap_err()
.code,
"E_STALE_SOURCE"
);
}
fn compiler_failure(f: &Fixture, expected: &str) {
let before = source::files(&f.root).unwrap();
let output = f.command("check").output().unwrap();
assert!(!output.status.success());
let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
assert_eq!(error["code"], "E_BUILD", "{error}");
assert!(
error["message"].as_str().unwrap().contains(expected),
"{error}"
);
assert_eq!(source::files(&f.root).unwrap(), before);
}
#[test]
fn new_contracts_remain_checked_by_rust() {
let f = fixture();
edit(&f, "injection", "boolean", |v| {
v["bindings"].as_array_mut().unwrap()[0]["ty"] = "i32".into();
});
f.replace(
"mods/alpha/boolean.rs",
"fn modify(value:bool,trace:&Trace)->bool",
"fn modify(value:i32,trace:&Trace)->i32",
);
compiler_failure(&f, "mismatched types");
let f = fixture();
edit(&f, "injection", "owned", |v| {
v["bindings"].as_array_mut().unwrap()[0]["ty"] = "String".into();
v["bindings"].as_array_mut().unwrap()[0]["value"] = "$local:text:0".into();
});
f.replace(
"mods/alpha/owned.rs",
"fn after(text:&mut String)",
"fn after(mut text:String)",
);
compiler_failure(&f, "use of moved value");
let f = fixture();
edit(&f, "injection", "nested", |v| {
v["bindings"]
.as_array_mut()
.unwrap()
.push(toml::Value::Table(toml::map::Map::from_iter([
("name".into(), "second".into()),
("ty".into(), "&mut i32".into()),
("value".into(), "$result_mut".into()),
])));
});
f.replace(
"mods/alpha/nested.rs",
"fn after(value:&mut i32,trace:&Trace)",
"fn after(value:&mut i32,trace:&Trace,second:&mut i32)",
);
compiler_failure(&f, "more than once at a time");
let f = fixture();
edit(&f, "state", "history", |v| {
v.insert("initial".into(), "missing_initializer()".into());
});
compiler_failure(&f, "cannot find function");
}
#[test]
fn return_local_can_run_at_post_assignment_cancellation() {
let f = fixture();
f.append("mods/alpha/horizon-mixin.toml",r#"
[[injection]]
id="assigned-return"
target="effects_game::assigned"
kind="RETURN"
helper="assigned-return.rs"
bindings=[{name="value",ty="i32",value="$value"},{name="local",ty="&i32",value="$local_ref:value:0"}]
"#);
fs::write(
f.mods.join("alpha/assigned-return.rs"),
"fn result(value:i32,local:&i32)->i32 {value+*local}",
)
.unwrap();
f.replace(
"driver/src/lib.rs",
"assigned(&t,false),12",
"assigned(&t,false),24",
);
f.replace(
"driver/src/lib.rs",
"assigned(&t,true),102",
"assigned(&t,true),104",
);
f.run("check");
}
#[test]
fn owned_expression_results_and_non_cancellable_discard_preserve_drop_order() {
let f = fixture();
f.append(
"mods/alpha/horizon-mixin.toml",
r#"
[[injection]]
id="owned-value"
target="effects_game::after_owned"
kind="MODIFY_EXPRESSION_VALUE"
helper="owned-value.rs"
selector='String::from("hello")'
require=1
ordinal=0
bindings=[{name="value",ty="String",value="$value"}]
"#,
);
fs::write(
f.mods.join("alpha/owned-value.rs"),
"fn modify(mut value:String)->String {value.push_str(\"?\");value}",
)
.unwrap();
f.replace(
"driver/src/lib.rs",
"after_owned(&t),\"hello!\"",
"after_owned(&t),\"hello?!\"",
);
edit(&f, "injection", "after", |v| {
v.insert("cancellable".into(), false.into());
});
fs::write(f.mods.join("alpha/after.rs"),"fn after(result:&Token,cancel:bool,trace:&Trace) {let _=(result,cancel);mark(trace,\"after-alpha\");}").unwrap();
f.replace(
"driver/src/lib.rs",
"discarded(&t,true),41",
"discarded(&t,true),5",
);
f.replace("driver/src/lib.rs","vec![\"receiver\",\"call\",\"after-alpha\",\"result-drop\",\"receiver-drop\"]","vec![\"receiver\",\"call\",\"after-alpha\",\"after-beta\",\"result-drop\",\"receiver-drop\",\"body\"]");
f.append(
"game/src/lib.rs",
r#"
pub fn stored_drop(trace:&Trace) { let result=receiver(trace).produce(); mark(trace,"body"); }
pub fn borrowed()->&'static str {"original"}
pub fn stored_borrow()->usize {let mut value=borrowed(); value.len()}
"#,
);
f.append("mods/alpha/horizon-mixin.toml",r#"
[[injection]]
id="stored-drop"
target="effects_game::stored_drop"
kind="AFTER_ASSIGN"
selector="receiver(trace).produce"
require=1
ordinal=0
helper="stored-drop.rs"
bindings=[{name="result",ty="&Token",value="$local_ref:result:0"},{name="trace",ty="&Trace",value="trace"}]
[[injection]]
id="borrowed-value"
target="effects_game::stored_borrow"
kind="MODIFY_EXPRESSION_VALUE"
selector="borrowed()"
require=1
ordinal=0
helper="borrowed-value.rs"
bindings=[{name="value",ty="&'static str",value="$value"}]
[[injection]]
id="stored-borrow"
target="effects_game::stored_borrow"
kind="AFTER_ASSIGN"
selector="borrowed"
require=1
ordinal=0
helper="stored-borrow.rs"
bindings=[{name="value",ty="&mut &'static str",value="$local_mut:value:0"}]
"#);
fs::write(
f.mods.join("alpha/stored-drop.rs"),
"fn after(result:&Token,trace:&Trace) {let _=result; mark(trace,\"stored\");}",
)
.unwrap();
fs::write(
f.mods.join("alpha/borrowed-value.rs"),
"fn modify(value:&'static str)->&'static str {let _=value; \"modified\"}",
)
.unwrap();
fs::write(
f.mods.join("alpha/stored-borrow.rs"),
"fn after(value:&mut &'static str) {assert_eq!(*value,\"modified\"); *value=\"abc\";}",
)
.unwrap();
f.append("driver/src/lib.rs",r#"
#[test] fn stored_drop_and_borrow() {
let trace=effects_game::Trace::default(); effects_game::stored_drop(&trace);
assert_eq!(*trace.borrow(), vec!["receiver","call","receiver-drop","stored","body","result-drop"]);
assert_eq!(effects_game::stored_borrow(),3);
}
"#);
f.run("check");
}
#[test]
fn state_constructor_overwrite_and_namespaced_collisions_fail_in_planning() {
let f = fixture();
f.append(
"mods/alpha/horizon-mixin.toml",
r#"
[[injection]]
id="overwrite-new"
target="effects_game::state::Entity::new"
kind="OVERWRITE"
helper="overwrite-new.rs"
bindings=[{name="health",ty="i32",value="health"}]
"#,
);
fs::write(
f.mods.join("alpha/overwrite-new.rs"),
"fn replacement(health:i32)->Entity {Entity::new(health)}",
)
.unwrap();
failure(&f, "E_STATE_CONFLICT");
let f = fixture();
f.append(
"mods/alpha/horizon-mixin.toml",
r#"
[[bridge]]
id="history"
kind="GET"
target="effects_game::state::Entity"
member="health"
"#,
);
failure(&f, "E_DUPLICATE_ID");
let f = fixture();
let path = f.mods.join("alpha/horizon-mixin.toml");
let mut manifest: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let mut extra = manifest["state"].as_array().unwrap()[0].clone();
extra["id"] = "history_mut".into();
manifest["state"].as_array_mut().unwrap().push(extra);
fs::write(path, toml::to_string(&manifest).unwrap()).unwrap();
failure(&f, "E_STATE_CONFLICT");
}
#[test]
fn state_only_bundles_preserve_derived_default_and_drop_lifecycle() {
let f = fixture();
for bundle in ["alpha", "beta"] {
let path = f.mods.join(bundle).join("horizon-mixin.toml");
let mut manifest: toml::Value =
toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
manifest.as_table_mut().unwrap().remove("injection");
manifest["state"].as_array_mut().unwrap()[0]["initial"] =
"::core::default::Default::default()".into();
fs::write(path, toml::to_string(&manifest).unwrap()).unwrap();
}
f.replace(
"game/src/lib.rs",
"derive(Clone, Debug, Serialize, Deserialize)",
"derive(Clone, Debug, Default, Serialize, Deserialize)",
);
f.append(
"mods/alpha/horizon-mixin.toml",
r#"
[[state]]
id="drop"
target="effects_game::state::Entity"
ty="DropState"
initial="::core::default::Default::default()"
[[state.constructor]]
target="effects_game::state::Entity::new"
selector="Self"
require=1
[[state.constructor]]
target="effects_game::state::Entity::with_health"
selector="Self"
require=1
"#,
);
f.replace(
"game/src/lib.rs",
"pub static INITIALIZATIONS",
r#"
#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct DropState;
pub static DROPS:AtomicUsize=AtomicUsize::new(0);
impl Drop for DropState { fn drop(&mut self) {DROPS.fetch_add(1,Ordering::SeqCst);} }
pub static INITIALIZATIONS"#,
);
fs::write(f.root.join("driver/src/lib.rs"),r#"
#[test] fn defaults_and_drop() {
use effects_game::state::{Entity,DROPS}; use std::sync::atomic::Ordering;
let mut a=Entity::default(); let b=Entity::new(5);
a.__horizon_mixins_alpha_history_mut().push(1);
assert!(b.__horizon_mixins_alpha_history().is_empty()); assert_eq!(*b.__horizon_mixins_beta_counter(),0);
let before=DROPS.load(Ordering::SeqCst);
let a=a.with_health(3); assert_eq!(DROPS.load(Ordering::SeqCst),before);
drop(a); assert_eq!(DROPS.load(Ordering::SeqCst),before+1);
drop(b); assert_eq!(DROPS.load(Ordering::SeqCst),before+2);
}
"#).unwrap();
let result = f.run("check");
assert_eq!(result["injections"], 0);
assert_eq!(result["states"], 3);
}
@@ -88,7 +88,7 @@ fn scoped_features_compile_preserve_inputs_and_unchanged_mtimes() {
fn scoped_source_plan_and_bridge_snapshots_ignore_discovery_order() {
let f = fixture();
let plan = build_plan(&f.root, &f.mods).unwrap();
assert_eq!(plan.schema, 4);
assert_eq!(plan.schema, 5);
assert_eq!(plan.bridges.len(), 9);
let captures = &plan
.injections
@@ -797,3 +797,6 @@ mod scoped;
#[path = "cases/boundary.rs"]
mod boundary;
#[path = "cases/effects.rs"]
mod effects;
@@ -1513,6 +1513,6 @@
"target": "advanced_game::nested_wrap"
}
],
"schema": 4,
"transformer": "0.4.0"
"schema": 5,
"transformer": "0.5.0"
}
@@ -1,6 +1,6 @@
{
"schema": 4,
"transformer": "0.4.0",
"schema": 5,
"transformer": "0.5.0",
"rustc": "rustc 1.95.0 (59807616e 2026-04-14)",
"inputs": {
"Cargo.lock": "40777af0bd0e981e825291ef434d33605137cbd48dc6a3b105f7a4ee32d6ed5d",
@@ -0,0 +1,615 @@
{
"injections": [
{
"argument": null,
"bindings": [
{
"name": "value",
"ty": "i32",
"value": "$value"
},
{
"name": "trace",
"ty": "&Trace",
"value": "trace"
}
],
"call_site": null,
"cancellable": false,
"constant_site": null,
"contract": {
"operation": null,
"output": "i32",
"value": "i32"
},
"expression_site": 1,
"file": "game/src/lib.rs",
"function_fingerprint": "5a27cb693d603632ab2f26649dcb9a1c5bba5c422ebfce6546d549a5bc898938",
"helper_source": "fn modify(value:i32,trace:&Trace)->i32 { mark(trace,\"expr-alpha\"); value+1 }\n",
"injection_id": "expression",
"kind": "MODIFY_EXPRESSION_VALUE",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
11
],
"ordinal": 0,
"priority": 100,
"require": 1,
"selector": "add(number(trace),3)",
"site_fingerprint": "a1301145ca78937ed4955db9acf4980b03fddadd20ace5424ced80526d1ff423",
"target": "effects_game::expression"
},
{
"argument": null,
"bindings": [
{
"name": "value",
"ty": "i32",
"value": "$value"
},
{
"name": "trace",
"ty": "&Trace",
"value": "trace"
}
],
"call_site": null,
"cancellable": false,
"constant_site": null,
"contract": {
"operation": null,
"output": "i32",
"value": "i32"
},
"expression_site": 1,
"file": "game/src/lib.rs",
"function_fingerprint": "5a27cb693d603632ab2f26649dcb9a1c5bba5c422ebfce6546d549a5bc898938",
"helper_source": "fn modify(value:i32,trace:&Trace)->i32 { mark(trace,\"expr-beta\"); value*2 }\n",
"injection_id": "expression",
"kind": "MODIFY_EXPRESSION_VALUE",
"mod_id": "beta",
"mod_version": "0.1.0",
"node": [
11
],
"ordinal": 0,
"priority": 0,
"require": 1,
"selector": "add(number(trace),3)",
"site_fingerprint": "a1301145ca78937ed4955db9acf4980b03fddadd20ace5424ced80526d1ff423",
"target": "effects_game::expression"
},
{
"argument": null,
"bindings": [
{
"name": "value",
"ty": "&mut i32",
"value": "$result_mut"
},
{
"name": "trace",
"ty": "&Trace",
"value": "trace"
}
],
"call_site": 1,
"cancellable": false,
"constant_site": null,
"contract": {
"operation": null,
"output": "()",
"result": "i32",
"value": null
},
"expression_site": 2,
"file": "game/src/lib.rs",
"function_fingerprint": "0e1f9869f67977c6bdf52ae9fa7e7c09963def87c32b06bc192a548d29abf62f",
"helper_source": "fn after(value:&mut i32,trace:&Trace) { mark(trace,\"after-number\"); *value+=4; }\n",
"injection_id": "nested",
"kind": "AFTER_CALL",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
12
],
"ordinal": 0,
"priority": 100,
"require": 1,
"selector": "number",
"site_fingerprint": "d09bd862ef92c28693461dedddddbafdfc3d171e671c66b082943ca0813dae29",
"target": "effects_game::nested"
},
{
"argument": null,
"bindings": [
{
"name": "value",
"ty": "bool",
"value": "$value"
},
{
"name": "trace",
"ty": "&Trace",
"value": "trace"
}
],
"call_site": null,
"cancellable": false,
"constant_site": null,
"contract": {
"operation": null,
"output": "bool",
"value": "bool"
},
"expression_site": 2,
"file": "game/src/lib.rs",
"function_fingerprint": "a08790528eb06e380c4b99c417146ed2e9d7dc1dca5934e3dbfe07d5790f5806",
"helper_source": "fn modify(value:bool,trace:&Trace)->bool { mark(trace,\"boolean\"); !value }\n",
"injection_id": "boolean",
"kind": "MODIFY_EXPRESSION_VALUE",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
13
],
"ordinal": 0,
"priority": 100,
"require": 1,
"selector": "number(trace)>0",
"site_fingerprint": "ee9b86bfb163201562917ebff091fba719335087af8f43c986ba64e4d83812f8",
"target": "effects_game::short"
},
{
"argument": null,
"bindings": [
{
"name": "value",
"ty": "i32",
"value": "$value"
}
],
"call_site": null,
"cancellable": false,
"constant_site": null,
"contract": {
"operation": null,
"output": "i32",
"value": "i32"
},
"file": "game/src/lib.rs",
"function_fingerprint": "8933ab32bfe2b9f8171984b8b3c4dc06f56080b71d9a7d0e8aba97d397614498",
"helper_source": "fn result(value:i32)->i32 {value+1}\n",
"injection_id": "return",
"kind": "RETURN",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
14
],
"ordinal": null,
"priority": 100,
"require": null,
"selector": null,
"site_fingerprint": null,
"target": "effects_game::before"
},
{
"argument": null,
"bindings": [
{
"name": "cancel",
"ty": "bool",
"value": "cancel"
},
{
"name": "trace",
"ty": "&Trace",
"value": "trace"
}
],
"call_site": 1,
"cancellable": true,
"constant_site": null,
"contract": {
"operation": null,
"output": ":: core :: ops :: ControlFlow < i32 >",
"value": null
},
"expression_site": 4,
"file": "game/src/lib.rs",
"function_fingerprint": "8933ab32bfe2b9f8171984b8b3c4dc06f56080b71d9a7d0e8aba97d397614498",
"helper_source": "fn before(cancel:bool,trace:&Trace)->::core::ops::ControlFlow<i32> { mark(trace,\"before\"); if cancel { ::core::ops::ControlFlow::Break(41) } else { ::core::ops::ControlFlow::Continue(()) } }\n",
"injection_id": "before",
"kind": "BEFORE_CALL",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
14
],
"ordinal": 0,
"priority": 100,
"require": 1,
"selector": "number",
"site_fingerprint": "d09bd862ef92c28693461dedddddbafdfc3d171e671c66b082943ca0813dae29",
"target": "effects_game::before"
},
{
"argument": null,
"bindings": [
{
"name": "result",
"ty": "&Token",
"value": "$result_ref"
},
{
"name": "cancel",
"ty": "bool",
"value": "cancel"
},
{
"name": "trace",
"ty": "&Trace",
"value": "trace"
}
],
"call_site": 0,
"cancellable": true,
"constant_site": null,
"contract": {
"operation": null,
"output": ":: core :: ops :: ControlFlow < i32 >",
"result": "Token",
"value": null
},
"expression_site": 0,
"file": "game/src/lib.rs",
"function_fingerprint": "93eee4d3d5fd6c9ac6f1f8df0539ccf8b55ec142397f30da46c04355f2f877ca",
"helper_source": "fn after(result:&Token,cancel:bool,trace:&Trace)->::core::ops::ControlFlow<i32> { let _ = result; mark(trace,\"after-alpha\"); if cancel { ::core::ops::ControlFlow::Break(41) } else { ::core::ops::ControlFlow::Continue(()) } }\n",
"injection_id": "after",
"kind": "AFTER_CALL",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
15
],
"ordinal": 0,
"priority": 100,
"require": 1,
"selector": "receiver(trace).produce",
"site_fingerprint": "266921abe3bdadabf2ea0bc7fd42633a026b3a4870792f8e98c12479b18bf440",
"target": "effects_game::discarded"
},
{
"argument": null,
"bindings": [
{
"name": "trace",
"ty": "&Trace",
"value": "trace"
}
],
"call_site": 0,
"cancellable": false,
"constant_site": null,
"contract": {
"operation": null,
"output": "()",
"result": "Token",
"value": null
},
"expression_site": 0,
"file": "game/src/lib.rs",
"function_fingerprint": "93eee4d3d5fd6c9ac6f1f8df0539ccf8b55ec142397f30da46c04355f2f877ca",
"helper_source": "fn after(trace:&Trace) { mark(trace,\"after-beta\"); }\n",
"injection_id": "after",
"kind": "AFTER_CALL",
"mod_id": "beta",
"mod_version": "0.1.0",
"node": [
15
],
"ordinal": 0,
"priority": 0,
"require": 1,
"selector": "receiver(trace).produce",
"site_fingerprint": "266921abe3bdadabf2ea0bc7fd42633a026b3a4870792f8e98c12479b18bf440",
"target": "effects_game::discarded"
},
{
"argument": null,
"bindings": [
{
"name": "value",
"ty": "&i32",
"value": "$local_ref:value:0"
},
{
"name": "cancel",
"ty": "bool",
"value": "cancel"
},
{
"name": "trace",
"ty": "&Trace",
"value": "trace"
}
],
"call_site": 0,
"cancellable": true,
"captures": [
{
"binding": "value",
"expression": "&value",
"local": {
"fingerprint": "15ddb6e1b9ccbc4b05e3e9a47cfea201dd315452d78391aa076b4660b9c3c2d1",
"id": 2,
"mutable": true,
"name": "value",
"ordinal": 0,
"parameter": false,
"supported": true,
"ty": "i32"
},
"ty": "&i32"
}
],
"constant_site": null,
"contract": {
"operation": null,
"output": ":: core :: ops :: ControlFlow < i32 >",
"value": null
},
"expression_site": 0,
"file": "game/src/lib.rs",
"function_fingerprint": "6c2e4475a010e500a52abe52700999b78d20ae4a5b84f19791dc3dac0733c5fd",
"helper_source": "fn after(value:&i32,cancel:bool,trace:&Trace)->::core::ops::ControlFlow<i32> { mark(trace,\"assigned-first\"); if cancel { ::core::ops::ControlFlow::Break(*value+100) } else { ::core::ops::ControlFlow::Continue(()) } }\n",
"injection_id": "assigned-first",
"kind": "AFTER_ASSIGN",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
16
],
"ordinal": 0,
"priority": 100,
"require": 2,
"selector": "number",
"site_fingerprint": "d09bd862ef92c28693461dedddddbafdfc3d171e671c66b082943ca0813dae29",
"target": "effects_game::assigned"
},
{
"argument": null,
"bindings": [
{
"name": "value",
"ty": "&mut i32",
"value": "$local_mut:value:0"
},
{
"name": "trace",
"ty": "&Trace",
"value": "trace"
}
],
"call_site": 2,
"cancellable": false,
"captures": [
{
"binding": "value",
"expression": "&mut value",
"local": {
"fingerprint": "15ddb6e1b9ccbc4b05e3e9a47cfea201dd315452d78391aa076b4660b9c3c2d1",
"id": 2,
"mutable": true,
"name": "value",
"ordinal": 0,
"parameter": false,
"supported": true,
"ty": "i32"
},
"ty": "&mut i32"
}
],
"constant_site": null,
"contract": {
"operation": null,
"output": "()",
"value": null
},
"expression_site": 9,
"file": "game/src/lib.rs",
"function_fingerprint": "6c2e4475a010e500a52abe52700999b78d20ae4a5b84f19791dc3dac0733c5fd",
"helper_source": "fn after(value:&mut i32,trace:&Trace) { mark(trace,\"assigned-second\"); *value+=10; }\n",
"injection_id": "assigned-second",
"kind": "AFTER_ASSIGN",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
16
],
"ordinal": 1,
"priority": 100,
"require": 2,
"selector": "number",
"site_fingerprint": "d09bd862ef92c28693461dedddddbafdfc3d171e671c66b082943ca0813dae29",
"target": "effects_game::assigned"
},
{
"argument": null,
"bindings": [
{
"name": "text",
"ty": "&mut String",
"value": "$local_mut:text:0"
}
],
"call_site": 0,
"cancellable": false,
"captures": [
{
"binding": "text",
"expression": "&mut text",
"local": {
"fingerprint": "2057b3c12e716344caaaa8c3ce9fa9b2c3c88f2ea544d9412d06d48983bca614",
"id": 1,
"mutable": true,
"name": "text",
"ordinal": 0,
"parameter": false,
"supported": true,
"ty": null
},
"ty": "&mut String"
}
],
"constant_site": null,
"contract": {
"operation": null,
"output": "()",
"value": null
},
"expression_site": 0,
"file": "game/src/lib.rs",
"function_fingerprint": "4a81302047e1ba021251783f1d319ba603b47468d8a66b4cfb1e9592df0a73dc",
"helper_source": "fn after(text:&mut String) { text.push_str(\"!\"); }\n",
"injection_id": "owned",
"kind": "AFTER_ASSIGN",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
17
],
"ordinal": 0,
"priority": 100,
"require": 1,
"selector": "String::from",
"site_fingerprint": "2fcddbd162bbea6e2cd1c18e7b8b138da108f5222b97277a8a972442c7d61902",
"target": "effects_game::after_owned"
},
{
"argument": null,
"bindings": [
{
"name": "entity",
"ty": "&mut Entity",
"value": "self"
}
],
"call_site": null,
"cancellable": false,
"constant_site": null,
"contract": {
"operation": null,
"output": "()",
"value": null
},
"file": "game/src/lib.rs",
"function_fingerprint": "e4692c3a56ffb6465fa276a4015bc997f8d287b045f6e4a2eae344490443953c",
"helper_source": "fn tick(entity:&mut Entity) { let health=entity.health; entity.__horizon_mixins_alpha_history_mut().push(health); }\n",
"injection_id": "tick",
"kind": "HEAD",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
18,
5,
2
],
"ordinal": null,
"priority": 100,
"require": null,
"selector": null,
"site_fingerprint": null,
"target": "effects_game::state::Entity::tick"
}
],
"rustc": "<pinned toolchain>",
"schema": 5,
"states": [
{
"constructors": [
{
"expression": 0,
"file": "game/src/lib.rs",
"fingerprint": "374ffffbb49c1aedd0d49b14eb9194a303945c7e78f30df54451f5e426c1aa0f",
"function_fingerprint": "69180dd33eb63d4d50b1edd23916fe75c1247fcbdf3ea147136068d3a88606c8",
"node": [
18,
5,
0
]
},
{
"expression": 0,
"file": "game/src/lib.rs",
"fingerprint": "5892d6f7e1379aa939242b9a971a7df5ec31975e2c174b74d99cead90a532df6",
"function_fingerprint": "80490798716f3d14605e97ec47ca66e75cf90fcf46e898be5bd1c31008e6c5a4",
"node": [
18,
5,
1
]
}
],
"deserialize": true,
"file": "game/src/lib.rs",
"fingerprint": "5c0db814524c74e09af75f7453e0a8da0768684c9a52cc2dd36016d433261e4b",
"initial": ":: core :: default :: Default :: default ()",
"mod_id": "alpha",
"mod_version": "0.1.0",
"name": "__horizon_mixins_alpha_history",
"node": [
18,
4
],
"priority": 100,
"state_id": "history",
"target": "effects_game::state::Entity",
"ty": "Vec < i32 >"
},
{
"constructors": [
{
"expression": 0,
"file": "game/src/lib.rs",
"fingerprint": "374ffffbb49c1aedd0d49b14eb9194a303945c7e78f30df54451f5e426c1aa0f",
"function_fingerprint": "69180dd33eb63d4d50b1edd23916fe75c1247fcbdf3ea147136068d3a88606c8",
"node": [
18,
5,
0
]
},
{
"expression": 0,
"file": "game/src/lib.rs",
"fingerprint": "5892d6f7e1379aa939242b9a971a7df5ec31975e2c174b74d99cead90a532df6",
"function_fingerprint": "80490798716f3d14605e97ec47ca66e75cf90fcf46e898be5bd1c31008e6c5a4",
"node": [
18,
5,
1
]
}
],
"deserialize": true,
"file": "game/src/lib.rs",
"fingerprint": "5c0db814524c74e09af75f7453e0a8da0768684c9a52cc2dd36016d433261e4b",
"initial": "initial_counter ()",
"mod_id": "beta",
"mod_version": "0.1.0",
"name": "__horizon_mixins_beta_counter",
"node": [
18,
4
],
"priority": 0,
"state_id": "counter",
"target": "effects_game::state::Entity",
"ty": "usize"
}
],
"transformer": "0.5.0"
}
@@ -0,0 +1,289 @@
use std::{cell::RefCell, rc::Rc};
pub type Trace = Rc<RefCell<Vec<&'static str>>>;
pub fn mark(trace: &Trace, event: &'static str) {
trace.borrow_mut().push(event);
}
pub struct Token {
trace: Trace,
event: &'static str,
}
impl Drop for Token {
fn drop(&mut self) {
mark(&self.trace, self.event);
}
}
pub struct Receiver {
trace: Trace,
}
impl Drop for Receiver {
fn drop(&mut self) {
mark(&self.trace, "receiver-drop");
}
}
impl Receiver {
pub fn produce(&self) -> Token {
mark(&self.trace, "call");
Token {
trace: self.trace.clone(),
event: "result-drop",
}
}
}
pub fn receiver(trace: &Trace) -> Receiver {
mark(trace, "receiver");
Receiver { trace: trace.clone() }
}
pub fn number(trace: &Trace) -> i32 {
mark(trace, "number");
2
}
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn expression(trace: &Trace) -> i32 {
let value = ({
fn modify(value: i32, trace: &Trace) -> i32 {
mark(trace, "expr-beta");
value * 2
}
modify
})(
({
fn modify(value: i32, trace: &Trace) -> i32 {
mark(trace, "expr-alpha");
value + 1
}
modify
})(add(number(trace), 3), trace),
trace,
) * 2;
value
}
pub fn nested(trace: &Trace) -> i32 {
add(
({
fn __horizon_mixins_after(
mut __horizon_mixins_after_value: i32,
__horizon_mixins_context_1: &Trace,
) -> i32 {
fn after(value: &mut i32, trace: &Trace) {
mark(trace, "after-number");
*value += 4;
}
after(&mut __horizon_mixins_after_value, __horizon_mixins_context_1);
__horizon_mixins_after_value
}
__horizon_mixins_after
})(number(trace), trace),
3,
)
}
pub fn short(trace: &Trace, enabled: bool) -> bool {
enabled
&& ({
fn modify(value: bool, trace: &Trace) -> bool {
mark(trace, "boolean");
!value
}
modify
})(number(trace) > 0, trace)
}
pub fn before(trace: &Trace, cancel: bool) -> i32 {
let _guard = Token {
trace: trace.clone(),
event: "guard-drop",
};
let value = {
if let ::core::ops::ControlFlow::Break(__horizon_mixins_cancel) = {
fn before(cancel: bool, trace: &Trace) -> ::core::ops::ControlFlow<i32> {
mark(trace, "before");
if cancel {
::core::ops::ControlFlow::Break(41)
} else {
::core::ops::ControlFlow::Continue(())
}
}
before(cancel, trace)
} {
return ({
fn result(value: i32) -> i32 {
value + 1
}
result
})(__horizon_mixins_cancel);
}
number(trace)
};
mark(trace, "body");
({
fn result(value: i32) -> i32 {
value + 1
}
result
})(value)
}
pub fn discarded(trace: &Trace, cancel: bool) -> i32 {
match ({
fn __horizon_mixins_after(
__horizon_mixins_after_value: Token,
__horizon_mixins_context_1: bool,
__horizon_mixins_context_2: &Trace,
) -> ::core::ops::ControlFlow<i32, Token> {
fn after(
result: &Token,
cancel: bool,
trace: &Trace,
) -> ::core::ops::ControlFlow<i32> {
let _ = result;
mark(trace, "after-alpha");
if cancel {
::core::ops::ControlFlow::Break(41)
} else {
::core::ops::ControlFlow::Continue(())
}
}
if let ::core::ops::ControlFlow::Break(value) = after(
&__horizon_mixins_after_value,
__horizon_mixins_context_1,
__horizon_mixins_context_2,
) {
return ::core::ops::ControlFlow::Break(value);
}
::core::ops::ControlFlow::Continue(__horizon_mixins_after_value)
}
__horizon_mixins_after
})(receiver(trace).produce(), cancel, trace) {
::core::ops::ControlFlow::Break(__horizon_mixins_cancel) => {
return __horizon_mixins_cancel;
}
::core::ops::ControlFlow::Continue(__horizon_mixins_after_next) => {
match ({
fn __horizon_mixins_after(
__horizon_mixins_after_value: Token,
__horizon_mixins_context_0: &Trace,
) -> ::core::ops::ControlFlow<i32, Token> {
fn after(trace: &Trace) {
mark(trace, "after-beta");
}
after(__horizon_mixins_context_0);
::core::ops::ControlFlow::Continue(__horizon_mixins_after_value)
}
__horizon_mixins_after
})(__horizon_mixins_after_next, trace) {
::core::ops::ControlFlow::Break(__horizon_mixins_cancel) => {
return __horizon_mixins_cancel;
}
::core::ops::ControlFlow::Continue(__horizon_mixins_after_next) => {
let _ = __horizon_mixins_after_next;
}
}
}
};
mark(trace, "body");
5
}
pub fn assigned(trace: &Trace, cancel: bool) -> i32 {
let mut value: i32 = number(trace);
if let ::core::ops::ControlFlow::Break(__horizon_mixins_cancel) = ({
fn after(
value: &i32,
cancel: bool,
trace: &Trace,
) -> ::core::ops::ControlFlow<i32> {
mark(trace, "assigned-first");
if cancel {
::core::ops::ControlFlow::Break(*value + 100)
} else {
::core::ops::ControlFlow::Continue(())
}
}
after
})(&value, cancel, trace) {
return __horizon_mixins_cancel;
}
mark(trace, "between");
value = number(trace);
({
fn after(value: &mut i32, trace: &Trace) {
mark(trace, "assigned-second");
*value += 10;
}
after
})(&mut value, trace);
mark(trace, "body");
value
}
pub fn after_owned(_trace: &Trace) -> String {
let mut text = String::from("hello");
({
fn after(text: &mut String) {
text.push_str("!");
}
after
})(&mut text);
text
}
pub mod state {
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicUsize, Ordering};
pub static INITIALIZATIONS: AtomicUsize = AtomicUsize::new(0);
pub fn initial_counter() -> usize {
INITIALIZATIONS.fetch_add(1, Ordering::SeqCst);
7
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Entity {
health: i32,
#[serde(default = "__horizon_mixins_alpha_history_init")]
__horizon_mixins_alpha_history: Vec<i32>,
#[serde(default = "__horizon_mixins_beta_counter_init")]
__horizon_mixins_beta_counter: usize,
}
impl Entity {
pub fn new(health: i32) -> Self {
Self {
health,
__horizon_mixins_alpha_history: crate::state::__horizon_mixins_alpha_history_init(),
__horizon_mixins_beta_counter: crate::state::__horizon_mixins_beta_counter_init(),
}
}
pub fn with_health(self, health: i32) -> Self {
Self { health, ..self }
}
pub fn tick(&mut self) {
{
fn tick(entity: &mut Entity) {
let health = entity.health;
entity.__horizon_mixins_alpha_history_mut().push(health);
}
tick(self)
};
self.health += 1;
}
pub fn health(&self) -> i32 {
self.health
}
}
pub(crate) fn __horizon_mixins_alpha_history_init() -> Vec<i32> {
::core::default::Default::default()
}
impl Entity {
pub fn __horizon_mixins_alpha_history(&self) -> &Vec<i32> {
&self.__horizon_mixins_alpha_history
}
pub fn __horizon_mixins_alpha_history_mut(&mut self) -> &mut Vec<i32> {
&mut self.__horizon_mixins_alpha_history
}
}
pub(crate) fn __horizon_mixins_beta_counter_init() -> usize {
initial_counter()
}
impl Entity {
pub fn __horizon_mixins_beta_counter(&self) -> &usize {
&self.__horizon_mixins_beta_counter
}
pub fn __horizon_mixins_beta_counter_mut(&mut self) -> &mut usize {
&mut self.__horizon_mixins_beta_counter
}
}
}
@@ -278,6 +278,6 @@
"target": "fixture_game::overwrite::value"
}
],
"schema": 4,
"transformer": "0.4.0"
"schema": 5,
"transformer": "0.5.0"
}
@@ -1464,6 +1464,6 @@
"target": "scoped_game::temporary"
}
],
"schema": 4,
"transformer": "0.4.0"
"schema": 5,
"transformer": "0.5.0"
}
+2 -2
View File
@@ -42,7 +42,7 @@ pub fn item<'a>(items: &'a [Item], node: &[usize]) -> Result<&'a Item> {
"bridge parent is not an inline module",
))
}
fn owner<'a>(index: &'a Index, id: &str) -> Result<&'a TypeDefinition> {
pub(crate) fn owner<'a>(index: &'a Index, id: &str) -> Result<&'a TypeDefinition> {
if index
.blocked
.iter()
@@ -96,7 +96,7 @@ fn owner<'a>(index: &'a Index, id: &str) -> Result<&'a TypeDefinition> {
}
Ok(ty)
}
fn type_id(module: &str, ty: &Type) -> Result<String> {
pub(crate) fn type_id(module: &str, ty: &Type) -> Result<String> {
let Type::Path(p) = ty else {
return Err(Diagnostic::new(
"E_BRIDGE_TARGET",
+43 -3
View File
@@ -25,6 +25,8 @@ pub struct Contract {
pub operation: Option<Operation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bundle: Option<ArgumentBundle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -91,7 +93,7 @@ pub fn normalize_type(ty: &Type) -> String {
fn parse_type(text: &str) -> Result<Type> {
syn::parse_str(text).map_err(|e| Diagnostic::new("E_INVALID_BINDING", e.to_string()))
}
fn unsupported_type(ty: &Type, references: bool) -> bool {
pub(crate) fn unsupported_type(ty: &Type, references: bool) -> bool {
struct Check {
bad: bool,
references: bool,
@@ -166,6 +168,7 @@ pub fn contract(
| Kind::ModifyConstant
| Kind::ModifyVariable
| Kind::ModifyArgs
| Kind::ModifyExpressionValue
) {
let special = if kind == Kind::ModifyArgs {
"$args"
@@ -290,10 +293,42 @@ pub fn contract(
} else {
None
};
let after_result = if kind == Kind::AfterCall {
let result = parse_type(call_result.ok_or_else(|| {
Diagnostic::new(
"E_AFTER_RESULT",
"AFTER_CALL requires the explicit result type",
)
})?)?;
if unsupported_type(&result, true) {
return Err(Diagnostic::new(
"E_AFTER_RESULT",
"AFTER_CALL currently requires an owned call result or unit",
));
}
for binding in bindings {
if matches!(binding.value.as_str(), "$result_ref" | "$result_mut") {
let expected: Type = if binding.value == "$result_mut" {
syn::parse_quote!(&mut #result)
} else {
syn::parse_quote!(&#result)
};
if normalize_type(&parse_type(&binding.ty)?) != normalize_type(&expected) {
return Err(Diagnostic::new(
"E_AFTER_RESULT",
"result borrow type disagrees with the call result",
));
}
}
}
Some(tokens(&result))
} else {
None
};
let output = if let Some(operation) = &operation {
operation.output.clone()
} else if cancellable {
if unsupported_type(&result, false) {
if unsupported_type(&result, kind != Kind::Head) {
return Err(Diagnostic::new(
"E_UNSUPPORTED_SIGNATURE",
"cancellation needs a concrete target result type",
@@ -324,6 +359,7 @@ pub fn contract(
value: value.as_ref().map(tokens),
operation,
bundle,
result: after_result,
})
}
pub fn validate_helper(
@@ -354,7 +390,7 @@ pub fn validate_helper(
!= normalize_type(&parse_type(&contract.output)?)
{
let why = if cancellable {
"cancellable HEAD helper must return core::ops::ControlFlow<TargetResult>"
"cancellable hook helper must return core::ops::ControlFlow<TargetResult>"
} else {
"helper return type must match its injection contract"
};
@@ -420,6 +456,10 @@ pub fn validate_helper(
"$value"
}
};
let valid = valid
|| (kind == Kind::AfterCall
&& contract.result.is_some()
&& matches!(binding.value.as_str(), "$result_ref" | "$result_mut"));
if !valid {
return Err(Diagnostic::new(
"E_INVALID_BINDING",
+2
View File
@@ -12,3 +12,5 @@ pub use diagnostic::{Diagnostic, Result};
pub use plan::{Plan, PlannedInjection, build_plan};
pub mod modules;
pub mod state;
+55 -8
View File
@@ -15,6 +15,25 @@ pub struct Manifest {
pub bridge: Vec<Bridge>,
#[serde(default)]
pub module: Vec<Module>,
#[serde(default)]
pub state: Vec<State>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct State {
pub id: String,
pub target: String,
pub ty: String,
pub initial: String,
pub priority: Option<i32>,
pub constructor: Vec<Constructor>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Constructor {
pub target: String,
pub selector: String,
pub require: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -49,16 +68,28 @@ pub enum Kind {
WrapOperation,
ModifyArgs,
ModifyVariable,
ModifyExpressionValue,
AfterCall,
AfterAssign,
}
impl Kind {
pub fn selects_call(self) -> bool {
matches!(
self,
Self::BeforeCall | Self::ModifyArg | Self::ModifyArgs | Self::WrapOperation
Self::BeforeCall
| Self::AfterCall
| Self::AfterAssign
| Self::ModifyArg
| Self::ModifyArgs
| Self::WrapOperation
)
}
pub fn selects_site(self) -> bool {
self.selects_call() || matches!(self, Self::ModifyConstant | Self::ModifyVariable)
self.selects_call()
|| matches!(
self,
Self::ModifyConstant | Self::ModifyVariable | Self::ModifyExpressionValue
)
}
}
#[derive(Debug, Clone, Deserialize)]
@@ -187,11 +218,12 @@ pub fn load(root: &Path) -> Result<Vec<LoadedMod>> {
|| manifest.info.version.is_empty()
|| (manifest.injection.is_empty()
&& manifest.bridge.is_empty()
&& manifest.module.is_empty())
&& manifest.module.is_empty()
&& manifest.state.is_empty())
{
return Err(fail(
"E_MANIFEST",
"mod needs an ASCII id, a version and at least one injection, bridge or module"
"mod needs an ASCII id, a version and at least one injection, bridge, module or state"
.into(),
));
}
@@ -204,6 +236,14 @@ pub fn load(root: &Path) -> Result<Vec<LoadedMod>> {
let mut injections = BTreeSet::new();
let mut helpers = Vec::new();
let mut modules = Vec::new();
for state in &manifest.state {
if !valid_id(&state.id) || !injections.insert(&state.id) {
return Err(fail(
"E_DUPLICATE_ID",
format!("invalid or duplicate state id '{}'", state.id),
));
}
}
for module in &manifest.module {
if !valid_id(&module.id) || !injections.insert(&module.id) {
return Err(fail(
@@ -236,10 +276,15 @@ pub fn load(root: &Path) -> Result<Vec<LoadedMod>> {
format!("invalid or duplicate injection id '{}'", injection.id),
)));
}
if injection.cancellable && injection.kind != Kind::Head {
if injection.cancellable
&& !matches!(
injection.kind,
Kind::Head | Kind::BeforeCall | Kind::AfterCall | Kind::AfterAssign
)
{
return Err(context(fail(
"E_MANIFEST",
"cancellable is supported only for HEAD".into(),
"cancellable requires HEAD, BEFORE_CALL, AFTER_CALL or AFTER_ASSIGN".into(),
)));
}
if (injection.kind == Kind::ModifyArg) != injection.argument.is_some() {
@@ -254,10 +299,12 @@ pub fn load(root: &Path) -> Result<Vec<LoadedMod>> {
"variable is required only for MODIFY_VARIABLE".into(),
)));
}
if (injection.kind == Kind::ModifyArgs) != injection.result.is_some() {
if matches!(injection.kind, Kind::ModifyArgs | Kind::AfterCall)
!= injection.result.is_some()
{
return Err(context(fail(
"E_ARGUMENT_SIGNATURE",
"result is required only for MODIFY_ARGS".into(),
"result is required only for MODIFY_ARGS and AFTER_CALL".into(),
)));
}
if injection.kind.selects_site() {
+208 -21
View File
@@ -27,6 +27,8 @@ pub struct Plan {
pub bridges: Vec<bridges::PlannedBridge>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub modules: Vec<PlannedModule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub states: Vec<crate::state::PlannedState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -105,6 +107,8 @@ impl Plan {
p.constant_site.is_some() && p.call_site.is_none() && p.variable_site.is_none()
} else if p.kind == Kind::ModifyVariable {
p.variable_site.is_some() && p.call_site.is_none() && p.constant_site.is_none()
} else if p.kind == Kind::ModifyExpressionValue {
p.call_site.is_none() && p.constant_site.is_none() && p.variable_site.is_none()
} else {
p.call_site.is_some() && p.constant_site.is_none() && p.variable_site.is_none()
}) && p.site_fingerprint.is_some()
@@ -125,7 +129,11 @@ impl Plan {
&& p.selector.is_none()
&& p.ordinal.is_none()
&& p.require.is_none()
} && (!p.cancellable || p.kind == Kind::Head)
} && (!p.cancellable
|| matches!(
p.kind,
Kind::Head | Kind::BeforeCall | Kind::AfterCall | Kind::AfterAssign
))
&& ((p.kind == Kind::ModifyArg) == p.argument.is_some())
&& ((p.kind == Kind::WrapOperation) == p.contract.operation.is_some())
&& ((p.kind == Kind::ModifyArgs) == p.contract.bundle.is_some())
@@ -137,7 +145,9 @@ impl Plan {
| Kind::ModifyConstant
| Kind::ModifyArgs
| Kind::ModifyVariable
) == p.contract.value.is_some());
| Kind::ModifyExpressionValue
) == p.contract.value.is_some())
&& ((p.kind == Kind::AfterCall) == p.contract.result.is_some());
let metadata_valid = p.slice.as_ref().is_none_or(|s| {
s.start < s.end && p.expression_site.is_some_and(|i| s.start <= i && i < s.end)
}) && (p.kind != Kind::ModifyVariable
@@ -167,6 +177,69 @@ impl Plan {
));
}
}
let mut names = std::collections::BTreeSet::new();
let mut initializers = std::collections::BTreeSet::new();
for s in &self.states {
source::relative(Path::new(&s.file))?;
if s.node.is_empty()
|| !self.inputs.contains_key(&s.file)
|| !ids.insert((&s.mod_id, &s.state_id))
|| !s.name.starts_with("__horizon_mixins_")
|| s.constructors.is_empty()
{
return Err(Diagnostic::new(
"E_PLAN_SITE",
"invalid state location, name or duplicate id",
));
}
if !names.insert((s.target.clone(), s.name.clone()))
|| !names.insert((s.target.clone(), format!("{}_mut", s.name)))
|| !initializers.insert((
&s.file,
&s.node[..s.node.len() - 1],
format!("{}_init", s.name),
))
{
return Err(Diagnostic::new(
"E_STATE_CONFLICT",
"normalized state names collide",
));
}
s.field()?;
s.items()?;
s.initializer()?;
let mut sites = std::collections::BTreeSet::new();
for c in &s.constructors {
source::relative(Path::new(&c.file))?;
if c.node.is_empty()
|| !self.inputs.contains_key(&c.file)
|| !sites.insert((&c.file, &c.node, c.expression))
{
return Err(Diagnostic::new(
"E_PLAN_SITE",
"invalid state constructor site",
));
}
if self
.injections
.iter()
.any(|p| p.file == c.file && p.node == c.node && p.kind == Kind::Overwrite)
{
return Err(Diagnostic::new(
"E_STATE_CONFLICT",
"OVERWRITE removes a planned state constructor",
));
}
}
if self.bridges.iter().any(|b| {
b.owner == s.target && [s.name.clone(), format!("{}_mut", s.name)].contains(&b.name)
}) {
return Err(Diagnostic::new(
"E_STATE_CONFLICT",
"state accessor conflicts with bridge",
));
}
}
Ok(())
}
}
@@ -195,6 +268,7 @@ pub fn build_plan(workspace: &Path, mods: &Path) -> Result<Plan> {
let index = Index::load(&root)?;
let mut injections = Vec::new();
let mut planned_bridges = Vec::new();
let mut planned_states = Vec::new();
let mut planned_modules: Vec<PlannedModule> = Vec::new();
for bundle in mods {
if let Some(revision) = &bundle.manifest.compatibility.horizon_revision {
@@ -230,6 +304,18 @@ pub fn build_plan(workspace: &Path, mods: &Path) -> Result<Plan> {
.file(&bundle.file));
}
}
for state in &bundle.manifest.state {
planned_states.push(
crate::state::plan(
&index,
&bundle.manifest.info.id,
&bundle.manifest.info.version,
bundle.manifest.info.priority,
state,
)
.map_err(|e| e.file(&bundle.file))?,
);
}
for (module, payload) in bundle.manifest.module.iter().zip(&bundle.modules) {
let tree = index.files.get(&module.file).ok_or_else(|| {
Diagnostic::new(
@@ -412,6 +498,15 @@ pub fn build_plan(workspace: &Path, mods: &Path) -> Result<Plan> {
&& sites::constant_key(&s.expr).as_ref() == Some(&key)
})
.collect()
} else if injection.kind == Kind::ModifyExpressionValue {
let expression: Expr = syn::parse_str(selector)
.map_err(|e| context(Diagnostic::new("E_SELECTOR", e.to_string())))?;
let key = tokens(&expression);
catalog
.values
.iter()
.filter(|s| inside(s.expression) && tokens(&s.expr) == key)
.collect()
} else {
let expression = sites::parse_call_selector(selector).map_err(context)?;
catalog
@@ -441,9 +536,20 @@ pub fn build_plan(workspace: &Path, mods: &Path) -> Result<Plan> {
if site.const_context {
return Err(context(Diagnostic::new("E_UNSUPPORTED_SITE", "injection cannot introduce runtime helpers into a compile-time array length or const argument").file(&f.file)));
}
if injection.kind == Kind::BeforeCall && !site.whole_expression {
if (injection.kind == Kind::BeforeCall
|| (injection.kind == Kind::AfterCall && injection.cancellable))
&& !site.whole_expression
{
return Err(context(Diagnostic::new("E_UNSUPPORTED_SITE", "BEFORE_CALL requires a complete statement, plain let initializer, or block/function tail; nesting can change temporary drop timing").file(&f.file)));
}
if injection.kind == Kind::AfterAssign
&& !scopes.after_assign.contains_key(&site.expression)
{
return Err(context(Diagnostic::new(
"E_UNSUPPORTED_SITE",
"AFTER_ASSIGN requires a complete call initializer of a plain let, or the complete RHS of a simple local/parameter assignment statement",
)));
}
if injection.kind == Kind::ModifyArg {
let arguments = match &site.expr {
Expr::Call(c) => c.args.len(),
@@ -481,7 +587,11 @@ pub fn build_plan(workspace: &Path, mods: &Path) -> Result<Plan> {
.map(|s| s.expression)
.or_else(|| selected.map(|s| s.expression));
let contexts: Vec<_> = if let Some(site) = expression_site {
vec![&scopes.contexts[site]]
vec![if injection.kind == Kind::AfterAssign {
&scopes.after_assign[&site]
} else {
&scopes.contexts[site]
}]
} else if injection.kind == Kind::Return {
let mut points: Vec<_> = scopes
.returns
@@ -503,6 +613,15 @@ pub fn build_plan(workspace: &Path, mods: &Path) -> Result<Plan> {
injection.kind,
)
.map_err(context)?;
if injection.kind == Kind::ModifyExpressionValue {
let site = selected.unwrap();
sites::validate_value_site(
&site.expr,
scopes.contexts[site.expression].place,
contract.value.as_deref().unwrap(),
)
.map_err(context)?;
}
if let Some(variable) = &injection.variable {
let local = &scopes.locals[variable_selected.unwrap().local];
let value_type = contract.value.as_ref().unwrap();
@@ -607,6 +726,13 @@ pub fn build_plan(workspace: &Path, mods: &Path) -> Result<Plan> {
});
}
}
planned_states.sort_by(|a, b| {
(&a.file, &a.node)
.cmp(&(&b.file, &b.node))
.then(b.priority.cmp(&a.priority))
.then(a.mod_id.cmp(&b.mod_id))
.then(a.state_id.cmp(&b.state_id))
});
planned_modules.sort_by(|a, b| {
a.file
.cmp(&b.file)
@@ -621,17 +747,27 @@ pub fn build_plan(workspace: &Path, mods: &Path) -> Result<Plan> {
.then(a.bridge_id.cmp(&b.bridge_id))
});
for p in &injections {
if p.kind == Kind::Return
&& !p.captures.is_empty()
&& injections
.iter()
.any(|h| h.file == p.file && h.node == p.node && h.cancellable)
if p.kind != Kind::Return {
continue;
}
let f = index.resolve(&p.target)?;
let scopes = Scopes::analyze(f);
for cancel in injections
.iter()
.filter(|c| c.file == p.file && c.node == p.node && c.cancellable)
{
return Err(Diagnostic::new(
"E_LOCAL_NOT_VISIBLE",
"cancellation occurs before RETURN locals are declared",
)
.file(&p.file));
let mut entry = f.clone();
entry.block = syn::parse_quote!({});
let entry_scopes = Scopes::analyze(&entry);
let context = if cancel.kind == Kind::Head {
entry_scopes.end.as_ref().unwrap()
} else if cancel.kind == Kind::AfterAssign {
&scopes.after_assign[&cancel.expression_site.unwrap()]
} else {
&scopes.contexts[cancel.expression_site.unwrap()]
};
scopes::resolve_bindings(f, &scopes, &p.bindings, &[context], Kind::Return)
.map_err(|e| e.file(&p.file))?;
}
}
injections.sort_by(|a, b| {
@@ -685,17 +821,20 @@ pub fn build_plan(workspace: &Path, mods: &Path) -> Result<Plan> {
"workspace changed during planning; retry from pristine source",
));
}
Ok(Plan {
schema: 4,
let plan = Plan {
schema: 5,
transformer: env!("CARGO_PKG_VERSION").into(),
rustc: rustc(&root)?,
inputs,
injections,
bridges: planned_bridges,
modules: planned_modules,
})
states: planned_states,
};
plan.validate_structure()?;
Ok(plan)
}
fn validate_target(f: &Function, kind: Kind) -> Result<()> {
pub(crate) fn validate_target(f: &Function, kind: Kind) -> Result<()> {
let s = &f.signature;
if s.ident.to_string().starts_with("__horizon_mixins_") {
return Err(Diagnostic::new(
@@ -1000,15 +1139,32 @@ fn validate_composition(entries: &[&PlannedInjection]) -> Result<()> {
}
}
}
type ValueSite = (Option<usize>, Option<usize>, Option<usize>, Option<usize>);
type ValueSite = (
Option<usize>,
Option<usize>,
Option<usize>,
Option<usize>,
Option<usize>,
);
let mut values: BTreeMap<ValueSite, &PlannedInjection> = BTreeMap::new();
for p in entries {
if matches!(
p.kind,
Kind::ModifyArg | Kind::ModifyConstant | Kind::ModifyVariable
Kind::ModifyArg
| Kind::ModifyConstant
| Kind::ModifyVariable
| Kind::ModifyExpressionValue
) {
let ty = p.contract.value.as_deref().unwrap();
let key = (p.call_site, p.constant_site, p.variable_site, p.argument);
let key = (
p.call_site,
p.constant_site,
p.variable_site,
p.argument,
(p.kind == Kind::ModifyExpressionValue)
.then_some(p.expression_site)
.flatten(),
);
if let Some(previous) = values.insert(key, p)
&& previous.contract.value.as_deref() != Some(ty)
{
@@ -1046,5 +1202,36 @@ fn validate_composition(entries: &[&PlannedInjection]) -> Result<()> {
}
}
}
let mut results: BTreeMap<usize, (&PlannedInjection, String)> = BTreeMap::new();
for p in entries {
let ty = if matches!(
p.kind,
Kind::ModifyExpressionValue | Kind::ModifyConstant | Kind::ModifyVariable
) {
p.contract.value.clone()
} else if p.kind == Kind::AfterCall {
p.contract.result.clone()
} else if p.kind == Kind::WrapOperation {
p.contract.operation.as_ref().map(|op| op.output.clone())
} else if p.kind == Kind::ModifyArgs {
p.contract.bundle.as_ref().map(|b| b.output.clone())
} else {
None
};
if let Some(ty) = ty {
let expression = p.expression_site.unwrap();
if let Some((previous, expected)) = results.insert(expression, (p, ty.clone()))
&& helpers::normalize_type(&syn::parse_str(&expected).unwrap())
!= helpers::normalize_type(&syn::parse_str(&ty).unwrap())
{
return Err(composition_error(
p,
previous,
"E_VALUE_SIGNATURE",
"call result/expression modifier contracts disagree at the same original expression",
));
}
}
}
Ok(())
}
+25
View File
@@ -30,6 +30,7 @@ pub struct Context {
pub block: usize,
pub depth: usize,
pub constant: bool,
pub place: bool,
}
#[derive(Debug, Clone)]
pub struct VariableSite {
@@ -99,6 +100,7 @@ pub struct Scopes {
pub contexts: Vec<Context>,
pub accesses: Vec<VariableSite>,
pub returns: Vec<usize>,
pub after_assign: BTreeMap<usize, Context>,
pub end: Option<Context>,
frames: Vec<(usize, BTreeMap<String, usize>)>,
next_block: usize,
@@ -150,6 +152,7 @@ impl Scopes {
block: self.frames.last().map_or(0, |f| f.0),
depth: self.frames.len() - 1,
constant: self.constant,
place: self.place,
}
}
fn push(&mut self) {
@@ -290,8 +293,12 @@ impl<'ast> Visit<'ast> for Scopes {
if let syn::Stmt::Local(local) = stmt {
let fingerprint = source::fingerprint(tokens(local));
let ids = self.add_pattern(&local.pat, true, &fingerprint);
let mut call = None;
if let Some(init) = &local.init {
let expression = self.contexts.len();
if sites::plain_pattern(&local.pat) && init.diverge.is_none() {
call = sites::root_call(&init.expr, expression);
}
self.visit_expr(&init.expr);
for id in &ids {
self.accesses.push(VariableSite {
@@ -307,8 +314,21 @@ impl<'ast> Visit<'ast> for Scopes {
}
}
self.expose(&ids);
if let Some(call) = call {
self.after_assign.insert(call, self.context());
}
} else {
let expression = self.contexts.len();
visit::visit_stmt(self, stmt);
if let syn::Stmt::Expr(Expr::Assign(e), Some(_)) = stmt
&& matches!(&*e.left,Expr::Path(p) if p.path.segments.len()==1)
{
// Assignment visits its LHS before its RHS in all shared traversals.
let left_count = expression_count(&e.left);
if let Some(call) = sites::root_call(&e.right, expression + 1 + left_count) {
self.after_assign.insert(call, self.context());
}
}
}
}
fn visit_expr_if(&mut self, e: &'ast syn::ExprIf) {
@@ -560,3 +580,8 @@ pub fn resolve_bindings(
let _ = f;
Ok(captures)
}
fn expression_count(expr: &Expr) -> usize {
let block: syn::Block = syn::parse_quote!({ #expr });
sites::Sites::in_block(&block).expressions
}
+65 -1
View File
@@ -107,6 +107,7 @@ pub struct Sites {
pub calls: Vec<Site>,
pub constants: Vec<Site>,
pub expressions: usize,
pub values: Vec<Site>,
next_is_root: bool,
const_context: bool,
}
@@ -129,7 +130,7 @@ impl<'ast> Visit<'ast> for Sites {
}
syn::Stmt::Local(local) => {
if let Some(init) = &local.init {
self.next_is_root = matches!(&local.pat, syn::Pat::Ident(p) if p.by_ref.is_none() && p.subpat.is_none());
self.next_is_root = plain_pattern(&local.pat);
self.visit_expr(&init.expr);
if let Some((_, expr)) = &init.diverge {
self.visit_expr(expr);
@@ -166,6 +167,7 @@ impl<'ast> Visit<'ast> for Sites {
whole_expression: root,
const_context: self.const_context,
};
self.values.push(site(expression));
if is_call(expr) {
self.calls.push(site(self.calls.len()));
}
@@ -220,3 +222,65 @@ pub fn always_exits(expr: &Expr) -> bool {
pub fn is_unit(ty: &syn::Type) -> bool {
matches!(ty, syn::Type::Tuple(t) if t.elems.is_empty())
}
/// Whole call under parentheses, with its pristine global expression coordinate.
pub fn root_call(expr: &Expr, expression: usize) -> Option<usize> {
match expr {
Expr::Paren(p) => root_call(&p.expr, expression + 1),
_ if is_call(expr) => Some(expression),
_ => None,
}
}
pub fn plain_pattern(pat: &syn::Pat) -> bool {
match pat {
syn::Pat::Ident(p) => p.by_ref.is_none() && p.subpat.is_none(),
syn::Pat::Type(p) => plain_pattern(&p.pat),
_ => false,
}
}
pub fn scalar(ty: &str) -> bool {
[
"bool", "char", "u8", "u16", "u32", "u64", "u128", "usize", "i8", "i16", "i32", "i64",
"i128", "isize", "f32", "f64",
]
.contains(&ty)
}
pub fn validate_value_site(expr: &Expr, place: bool, ty: &str) -> Result<()> {
let supported = !place
&& match expr {
Expr::Call(_)
| Expr::MethodCall(_)
| Expr::Lit(_)
| Expr::If(_)
| Expr::Match(_)
| Expr::Block(_)
| Expr::Tuple(_)
| Expr::Array(_)
| Expr::Struct(_)
| Expr::Cast(_) => true,
Expr::Unary(e) => matches!(e.op, syn::UnOp::Neg(_) | syn::UnOp::Not(_)),
Expr::Binary(e) => !matches!(
e.op,
syn::BinOp::AddAssign(_)
| syn::BinOp::SubAssign(_)
| syn::BinOp::MulAssign(_)
| syn::BinOp::DivAssign(_)
| syn::BinOp::RemAssign(_)
| syn::BinOp::BitXorAssign(_)
| syn::BinOp::BitAndAssign(_)
| syn::BinOp::BitOrAssign(_)
| syn::BinOp::ShlAssign(_)
| syn::BinOp::ShrAssign(_)
),
Expr::Path(_) | Expr::Field(_) | Expr::Index(_) => scalar(ty),
_ => false,
};
if supported {
Ok(())
} else {
Err(Diagnostic::new(
"E_UNSUPPORTED_SITE",
"expression modifiers require a runtime value; place/reference/control-transfer uses and owned place reads are unsupported",
))
}
}
+338
View File
@@ -0,0 +1,338 @@
//! Explicit per-instance state. No global registry or implicit serialization hooks.
use crate::{
Diagnostic, Result, bridges, helpers,
index::{Function, Index, tokens},
manifest::{Kind, State},
plan,
sites::Sites,
source,
};
use quote::{format_ident, quote};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use syn::{
Expr, Item, Type,
visit::{self, Visit},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConstructorSite {
pub file: String,
pub node: Vec<usize>,
pub function_fingerprint: String,
pub expression: usize,
pub fingerprint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlannedState {
pub mod_id: String,
pub mod_version: String,
pub state_id: String,
pub priority: i32,
pub target: String,
pub file: String,
pub node: Vec<usize>,
pub fingerprint: String,
pub name: String,
pub ty: String,
pub initial: String,
pub deserialize: bool,
pub constructors: Vec<ConstructorSite>,
}
fn fail(message: impl Into<String>) -> Diagnostic {
Diagnostic::new("E_STATE", message)
}
fn literal_owner(f: &Function, path: &syn::Path) -> Option<String> {
let ty = if path.is_ident("Self") {
f.self_type.clone()?
} else {
Type::Path(syn::TypePath {
qself: None,
path: path.clone(),
})
};
bridges::type_id(&f.module, &ty).ok()
}
pub fn plan(
index: &Index,
mod_id: &str,
version: &str,
priority: i32,
state: &State,
) -> Result<PlannedState> {
let owner = bridges::owner(index, &state.target)?;
let syn::Fields::Named(fields) = &owner.item.fields else {
return Err(fail("state requires a named-field struct"));
};
if owner.item.attrs.iter().any(|a| a.path().is_ident("repr")) {
return Err(fail(
"state changes layout; repr/FFI structs are unsupported",
));
}
if matches!(owner.item.vis, syn::Visibility::Public(_))
&& fields
.named
.iter()
.all(|f| matches!(f.vis, syn::Visibility::Public(_)))
&& !owner
.item
.attrs
.iter()
.any(|a| a.path().is_ident("non_exhaustive"))
{
return Err(fail(
"state requires a private field or non_exhaustive struct; external construction would break",
));
}
if fields.named.iter().any(|f| crate::index::cfg(&f.attrs)) {
return Err(fail("state owner fields cannot have unresolved cfg"));
}
let ty: Type = syn::parse_str(&state.ty).map_err(|e| fail(e.to_string()))?;
if helpers::unsupported_type(&ty, true) {
return Err(fail(
"state type must be explicit and owned, without references, Self or inferred types",
));
}
let initial: Expr = syn::parse_str(&state.initial).map_err(|e| fail(e.to_string()))?;
// The initializer becomes a standalone function in the owner's module.
let mut deserialize = false;
for attr in owner
.item
.attrs
.iter()
.filter(|a| a.path().is_ident("derive"))
{
let paths = attr
.parse_args_with(
syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
)
.map_err(|e| fail(e.to_string()))?;
for path in paths {
let name = path.segments.last().unwrap().ident.to_string();
match name.as_str() {
"Default"
if tokens(&initial)
!= tokens(
&syn::parse_str::<Expr>("::core::default::Default::default()").unwrap(),
) =>
{
return Err(fail(
"derived Default requires initial = \"::core::default::Default::default()\"; custom defaults must declare constructor literals",
));
}
"Deserialize" => deserialize = true,
"Default" | "Clone" | "Copy" | "Debug" | "PartialEq" | "Eq" | "PartialOrd"
| "Ord" | "Hash" | "Serialize" => {}
_ => return Err(fail(format!("unproven derive {name} on state owner"))),
}
}
}
let normalize = |s: &str| s.replace('-', "_").to_ascii_lowercase();
let name = format!(
"__horizon_mixins_{}_{}",
normalize(mod_id),
normalize(&state.id)
);
if fields
.named
.iter()
.any(|f| f.ident.as_ref().is_some_and(|i| i == &name))
|| index.functions.iter().any(|f| {
[name.clone(), format!("{name}_mut"), format!("{name}_init")]
.iter()
.any(|n| f.signature.ident == n)
})
{
return Err(Diagnostic::new(
"E_STATE_CONFLICT",
format!("reserved state name {name} already exists"),
));
}
let parent = if owner.node.len() == 1 {
&index.files[&owner.file].items
} else {
let Item::Mod(module) = bridges::item(
&index.files[&owner.file].items,
&owner.node[..owner.node.len() - 1],
)?
else {
return Err(fail("state owner parent is not a module"));
};
&module
.content
.as_ref()
.ok_or_else(|| fail("state module is external"))?
.1
};
let init_name = format!("{name}_init");
if parent.iter().any(|i| match i {
Item::Const(i) => i.ident == init_name,
Item::Static(i) => i.ident == init_name,
Item::Fn(i) => i.sig.ident == init_name,
_ => false,
}) {
return Err(Diagnostic::new(
"E_STATE_CONFLICT",
"generated initializer name already exists",
));
}
if state.constructor.is_empty() {
return Err(fail(
"state requires explicit constructors with positive require counts",
));
}
let mut constructors = Vec::new();
let mut seen = BTreeSet::new();
for c in &state.constructor {
if c.require == 0 || !seen.insert((&c.target, &c.selector)) {
return Err(fail(
"constructor requires a positive count and a unique target/selector",
));
}
let f = index.resolve(&c.target)?;
plan::validate_target(f, Kind::ModifyExpressionValue)?;
let selector: syn::Path = syn::parse_str(&c.selector).map_err(|e| fail(e.to_string()))?;
if literal_owner(f, &selector).as_ref() != Some(&state.target) {
return Err(fail(
"constructor selector must resolve syntactically to the state owner (aliases unsupported)",
));
}
let sites = Sites::in_block(&f.block);
let matches: Vec<_> = sites
.values
.iter()
.filter(|s| matches!(&s.expr, Expr::Struct(e) if tokens(&e.path) == tokens(&selector)))
.collect();
if matches.len() != c.require {
return Err(Diagnostic::new(
"E_MATCH_COUNT",
format!(
"state constructor {} expected {} '{}' literals, found {}",
c.target,
c.require,
c.selector,
matches.len()
),
));
}
for s in matches {
constructors.push(ConstructorSite {
file: f.file.clone(),
node: f.node.clone(),
function_fingerprint: source::fingerprint(format!(
"{}{}",
tokens(&f.signature),
tokens(&f.block)
)),
expression: s.expression,
fingerprint: s.fingerprint.clone(),
});
}
}
let selected: BTreeSet<_> = constructors
.iter()
.map(|c| (&c.file, &c.node, c.expression))
.collect();
if selected.len() != constructors.len() {
return Err(fail("duplicate state constructor site"));
}
// Prove coverage for visible, syntactically resolved literals; aliases and macro expansion
// are deliberately not guessed. Cargo checks the final workspace as the last boundary.
for f in &index.functions {
for site in Sites::in_block(&f.block).values {
if let Expr::Struct(e) = &site.expr
&& literal_owner(f, &e.path).as_ref() == Some(&state.target)
&& !selected.contains(&(&f.file, &f.node, site.expression))
{
return Err(fail(format!(
"unlisted constructor literal in {}; declare its selector/count",
f.id
)));
}
}
struct Patterns<'a> {
f: &'a Function,
target: &'a str,
bad: bool,
}
impl<'ast> Visit<'ast> for Patterns<'_> {
fn visit_pat_struct(&mut self, p: &'ast syn::PatStruct) {
if p.rest.is_none()
&& literal_owner(self.f, &p.path).as_deref() == Some(self.target)
{
self.bad = true;
}
visit::visit_pat_struct(self, p);
}
}
let mut p = Patterns {
f,
target: &state.target,
bad: false,
};
p.visit_signature(&f.signature);
p.visit_block(&f.block);
if p.bad {
return Err(fail(format!(
"{} destructures the state owner without '..'; add an explicit rest pattern",
f.id
)));
}
}
constructors
.sort_by(|a, b| (&a.file, &a.node, a.expression).cmp(&(&b.file, &b.node, b.expression)));
Ok(PlannedState {
mod_id: mod_id.into(),
mod_version: version.into(),
state_id: state.id.clone(),
priority: state.priority.unwrap_or(priority),
target: state.target.clone(),
file: owner.file.clone(),
node: owner.node.clone(),
fingerprint: source::fingerprint(tokens(&Item::Struct(owner.item.clone()))),
name,
ty: tokens(&ty),
initial: tokens(&initial),
deserialize,
constructors,
})
}
impl PlannedState {
pub fn initializer(&self) -> Result<syn::Path> {
let module = self
.target
.rsplit_once("::")
.ok_or_else(|| fail("invalid state target"))?
.0;
let path = format!(
"crate{}::{}_init",
module.find("::").map(|i| &module[i..]).unwrap_or(""),
self.name
);
syn::parse_str(&path).map_err(|e| fail(e.to_string()))
}
pub fn field(&self) -> Result<syn::Field> {
let name = syn::parse_str::<syn::Ident>(&self.name).map_err(|e| fail(e.to_string()))?;
let ty = syn::parse_str::<Type>(&self.ty).map_err(|e| fail(e.to_string()))?;
let attr = if self.deserialize {
let init = format!("{}_init", self.name);
quote!(#[serde(default = #init)])
} else {
quote!()
};
Ok(syn::parse_quote!(#attr #name: #ty))
}
pub fn items(&self) -> Result<Vec<Item>> {
let name = syn::parse_str::<syn::Ident>(&self.name).map_err(|e| fail(e.to_string()))?;
let mutable = format_ident!("{}_mut", name);
let init = format_ident!("{}_init", name);
let owner = syn::parse_str::<syn::Ident>(self.target.rsplit("::").next().unwrap())
.map_err(|e| fail(e.to_string()))?;
let ty = syn::parse_str::<Type>(&self.ty).map_err(|e| fail(e.to_string()))?;
let initial = syn::parse_str::<Expr>(&self.initial).map_err(|e| fail(e.to_string()))?;
let file:syn::File=syn::parse2(quote!(pub(crate) fn #init()->#ty { #initial } impl #owner { pub fn #name(&self)-> &#ty { &self.#name } pub fn #mutable(&mut self)-> &mut #ty { &mut self.#name } })).map_err(|e| fail(e.to_string()))?;
Ok(file.items)
}
}
+288 -26
View File
@@ -13,7 +13,7 @@ use syn::{
/// Rewrite pristine expression coordinates once. Generated code is never matched.
pub fn transform(root: &Path, plan: &Plan) -> Result<BTreeMap<String, Vec<u8>>> {
if plan.schema != 4 || plan.transformer != env!("CARGO_PKG_VERSION") {
if plan.schema != 5 || plan.transformer != env!("CARGO_PKG_VERSION") {
return Err(Diagnostic::new(
"E_PLAN_VERSION",
"unsupported plan schema/transformer",
@@ -44,6 +44,11 @@ pub fn transform(root: &Path, plan: &Plan) -> Result<BTreeMap<String, Vec<u8>>>
.or_default()
.push(patch);
}
for s in &plan.states {
for c in &s.constructors {
groups.entry((&c.file, &c.node)).or_default();
}
}
let mut trees = BTreeMap::new();
for (file, _) in groups.keys() {
if !trees.contains_key(*file) {
@@ -76,6 +81,29 @@ pub fn transform(root: &Path, plan: &Plan) -> Result<BTreeMap<String, Vec<u8>>>
);
}
}
for s in &plan.states {
if !trees.contains_key(&s.file) {
let bytes = files
.get(&s.file)
.ok_or_else(|| Diagnostic::new("E_PLAN_SITE", "state file absent"))?;
trees.insert(
s.file.clone(),
syn::parse_file(
std::str::from_utf8(bytes)
.map_err(|e| Diagnostic::new("E_RUST_SYNTAX", e.to_string()))?,
)
.map_err(syntax)?,
);
}
if source::fingerprint(tokens(bridges::item(&trees[&s.file].items, &s.node)?))
!= s.fingerprint
{
return Err(Diagnostic::new(
"E_STALE_SOURCE",
"state owner fingerprint mismatch",
));
}
}
for module in &plan.modules {
if !trees.contains_key(&module.file) {
let bytes = files
@@ -166,11 +194,45 @@ pub fn transform(root: &Path, plan: &Plan) -> Result<BTreeMap<String, Vec<u8>>>
.is_some_and(|v| v.at == horizon_mixins_core::manifest::Access::Store)
});
}
let expected = calls.len() + constants.len() + variables.len();
let mut expressions: BTreeMap<usize, Vec<&PlannedInjection>> = BTreeMap::new();
for p in &patches {
if p.kind == Kind::ModifyExpressionValue {
expressions
.entry(p.expression_site.unwrap())
.or_default()
.push(*p);
}
}
let mut states: BTreeMap<
usize,
Vec<(
&horizon_mixins_core::state::PlannedState,
&horizon_mixins_core::state::ConstructorSite,
)>,
> = BTreeMap::new();
for s in &plan.states {
for c in &s.constructors {
if c.file == file && c.node == node {
if c.function_fingerprint != fingerprint {
return Err(Diagnostic::new(
"E_STALE_SOURCE",
"state constructor fingerprint mismatch",
));
}
states.entry(c.expression).or_default().push((s, c));
}
}
}
let expected =
calls.len() + constants.len() + variables.len() + expressions.len() + states.len();
let mut rewrite = Rewrite {
calls,
constants,
variables,
expressions,
states,
target_result: horizon_mixins_core::helpers::return_type(signature),
after_stack: Vec::new(),
expression_cursor: 0,
call_cursor: 0,
constant_cursor: 0,
@@ -232,6 +294,21 @@ pub fn transform(root: &Path, plan: &Plan) -> Result<BTreeMap<String, Vec<u8>>>
head.append(&mut block.stmts);
block.stmts = head;
}
for s in &plan.states {
let tree = trees.get_mut(&s.file).unwrap();
let parent = parent_items(&mut tree.items, &s.node[..s.node.len() - 1])?;
let Some(Item::Struct(owner)) = parent.get_mut(*s.node.last().unwrap()) else {
return Err(Diagnostic::new("E_PLAN_SITE", "state owner is not struct"));
};
let syn::Fields::Named(fields) = &mut owner.fields else {
return Err(Diagnostic::new(
"E_PLAN_SITE",
"state owner has no named fields",
));
};
fields.named.push(s.field()?);
parent.extend(s.items()?);
}
for bridge in &plan.bridges {
let generated: Item = syn::parse_str(&bridge.generated).map_err(syntax)?;
if !matches!(generated, Item::Impl(_)) {
@@ -315,6 +392,97 @@ fn value_chain(mut expr: Expr, patches: &[&PlannedInjection]) -> Result<Expr> {
}
Ok(expr)
}
fn control_statement(patch: &PlannedInjection) -> Result<syn::Stmt> {
let call = helper_call(patch)?;
if patch.cancellable {
syn::parse2(quote!(if let ::core::ops::ControlFlow::Break(__horizon_mixins_cancel) = #call { return __horizon_mixins_cancel; })).map_err(syntax)
} else {
syn::parse2(quote!(#call;)).map_err(syntax)
}
}
fn after_adapter(value: Expr, patch: &PlannedInjection, result: &Type, flow: bool) -> Result<Expr> {
let ty: Type =
syn::parse_str(
patch.contract.result.as_deref().ok_or_else(|| {
Diagnostic::new("E_PLAN_SITE", "missing AFTER_CALL result contract")
})?,
)
.map_err(syntax)?;
let mut params = Vec::new();
let mut args = vec![value];
let mut helper_args: Vec<Expr> = Vec::new();
let value_name = format_ident!("__horizon_mixins_after_value");
for (i, binding) in patch.bindings.iter().enumerate() {
if binding.value == "$result_ref" {
helper_args.push(syn::parse_quote!(&#value_name));
} else if binding.value == "$result_mut" {
helper_args.push(syn::parse_quote!(&mut #value_name));
} else {
let name = format_ident!("__horizon_mixins_context_{i}");
let ty: Type = syn::parse_str(&binding.ty).map_err(syntax)?;
params.push(quote!(#name:#ty));
args.push(binding_value(patch, binding, &BTreeMap::new())?);
helper_args.push(syn::parse_quote!(#name));
}
}
let helper = plan::parse_helper(&patch.helper_source)?;
let name = &helper.sig.ident;
let call = quote!(#name(#(#helper_args),*));
let output: Type = if flow {
syn::parse_quote!(::core::ops::ControlFlow<#result,#ty>)
} else {
ty.clone()
};
let body = if patch.cancellable {
quote!(if let ::core::ops::ControlFlow::Break(value)=#call {return ::core::ops::ControlFlow::Break(value);})
} else {
quote!(#call;)
};
let tail = if flow {
quote!(::core::ops::ControlFlow::Continue(#value_name))
} else {
quote!(#value_name)
};
let mutable = patch
.bindings
.iter()
.any(|b| b.value == "$result_mut")
.then(|| quote!(mut));
syn::parse2(quote!(({fn __horizon_mixins_after(#mutable #value_name:#ty,#(#params),*)->#output{#helper #body #tail} __horizon_mixins_after})(#(#args),*))).map_err(syntax)
}
fn after_chain(
value: Expr,
patches: &[&PlannedInjection],
target: &Type,
discarded: bool,
) -> Result<Expr> {
if !patches.iter().any(|p| p.cancellable) {
let mut value = value;
for patch in patches {
value = after_adapter(value, patch, target, false)?;
}
return Ok(value);
}
fn next(
value: Expr,
patches: &[&PlannedInjection],
target: &Type,
discarded: bool,
) -> Result<Expr> {
if patches.is_empty() {
return if discarded {
syn::parse2(quote!({let _ = #value;})).map_err(syntax)
} else {
Ok(value)
};
}
let call = after_adapter(value, patches[0], target, true)?;
let value: Expr = syn::parse_quote!(__horizon_mixins_after_next);
let tail = next(value, &patches[1..], target, discarded)?;
syn::parse2(quote!(match #call {::core::ops::ControlFlow::Break(__horizon_mixins_cancel)=>return __horizon_mixins_cancel,::core::ops::ControlFlow::Continue(__horizon_mixins_after_next)=>#tail})).map_err(syntax)
}
next(value, patches, target, discarded)
}
fn wrap_operation(call: &Expr, wrappers: &[&PlannedInjection]) -> Result<Expr> {
let operation = wrappers[0].contract.operation.as_ref().unwrap();
let names: Vec<_> = (0..operation.inputs.len())
@@ -465,6 +633,16 @@ struct Rewrite<'a> {
calls: BTreeMap<usize, Vec<&'a PlannedInjection>>,
constants: BTreeMap<usize, Vec<&'a PlannedInjection>>,
variables: BTreeMap<usize, Vec<&'a PlannedInjection>>,
expressions: BTreeMap<usize, Vec<&'a PlannedInjection>>,
states: BTreeMap<
usize,
Vec<(
&'a horizon_mixins_core::state::PlannedState,
&'a horizon_mixins_core::state::ConstructorSite,
)>,
>,
target_result: Type,
after_stack: Vec<Vec<syn::Stmt>>,
expression_cursor: usize,
call_cursor: usize,
constant_cursor: usize,
@@ -476,6 +654,16 @@ impl VisitMut for Rewrite<'_> {
fn visit_attribute_mut(&mut self, _: &mut syn::Attribute) {}
fn visit_type_mut(&mut self, _: &mut Type) {}
fn visit_pat_mut(&mut self, _: &mut syn::Pat) {}
fn visit_block_mut(&mut self, block: &mut Block) {
let mut output = Vec::new();
for mut stmt in std::mem::take(&mut block.stmts) {
self.after_stack.push(Vec::new());
self.visit_stmt_mut(&mut stmt);
output.push(stmt);
output.extend(self.after_stack.pop().unwrap());
}
block.stmts = output;
}
fn visit_stmt_mut(&mut self, stmt: &mut syn::Stmt) {
self.next_discards_result = matches!(stmt, syn::Stmt::Expr(_, Some(_)));
visit_mut::visit_stmt_mut(self, stmt);
@@ -506,7 +694,7 @@ impl VisitMut for Rewrite<'_> {
Err(e) => self.error = Some(e),
}
}
self.modify_variable(expr, expression, &expression_fingerprint);
self.modify_values(expr, expression, &expression_fingerprint);
return;
}
let site = if sites::is_call(expr) {
@@ -562,16 +750,32 @@ impl VisitMut for Rewrite<'_> {
} else if !wrappers.is_empty() {
new = wrap_operation(&new, &wrappers)?;
}
let after: Vec<_> = patches
.iter()
.filter(|p| p.kind == Kind::AfterCall)
.copied()
.collect();
if !after.is_empty() {
new = after_chain(new, &after, &self.target_result, discarded)?;
}
for p in patches.iter().filter(|p| p.kind == Kind::AfterAssign) {
self.after_stack
.last_mut()
.ok_or_else(|| {
Diagnostic::new("E_PLAN_SITE", "AFTER_ASSIGN has no statement")
})?
.push(control_statement(p)?);
}
let before: Vec<_> = patches
.iter()
.filter(|p| p.kind == Kind::BeforeCall)
.map(|p| helper_call(p))
.map(|p| control_statement(p))
.collect::<Result<_>>()?;
if !before.is_empty() {
new = if discarded {
syn::parse2(quote!({ #(#before;)* #new; }))
syn::parse2(quote!({#(#before)* #new;}))
} else {
syn::parse2(quote!({ #(#before;)* #new }))
syn::parse2(quote!({#(#before)* #new}))
}
.map_err(syntax)?;
}
@@ -585,32 +789,90 @@ impl VisitMut for Rewrite<'_> {
Err(e) => self.error = Some(e),
}
}
self.modify_variable(expr, expression, &expression_fingerprint);
}
}
impl Rewrite<'_> {
fn modify_variable(&mut self, expr: &mut Expr, expression: usize, fingerprint: &str) {
if let Some(patches) = self.variables.get(&expression) {
if patches
.iter()
.any(|p| p.site_fingerprint.as_deref() != Some(fingerprint))
{
self.error = Some(Diagnostic::new(
"E_STALE_SOURCE",
"variable access fingerprint mismatch",
));
return;
}
match value_chain(expr.clone(), patches) {
Ok(new) => {
*expr = new;
self.matched += 1;
if let Some(states) = self.states.get(&expression) {
let result = (|| {
let Expr::Struct(literal) = expr else {
return Err(Diagnostic::new(
"E_PLAN_SITE",
"state constructor is not a literal",
));
};
for (s, c) in states {
if c.fingerprint != expression_fingerprint {
return Err(Diagnostic::new(
"E_STALE_SOURCE",
"state literal fingerprint mismatch",
));
}
// Struct update moves the existing state, preserving its lifecycle.
if literal.rest.is_none() {
let name: syn::Ident = syn::parse_str(&s.name).map_err(syntax)?;
let init = s.initializer()?;
literal.fields.push(syn::parse_quote!(#name: #init()));
}
}
Ok(())
})();
match result {
Ok(()) => self.matched += 1,
Err(e) => self.error = Some(e),
}
}
self.modify_values(expr, expression, &expression_fingerprint);
}
}
impl Rewrite<'_> {
fn modify_values(&mut self, expr: &mut Expr, expression: usize, fingerprint: &str) {
let variable = self.variables.get(&expression).cloned().unwrap_or_default();
let modifiers = self
.expressions
.get(&expression)
.cloned()
.unwrap_or_default();
if variable.is_empty() && modifiers.is_empty() {
return;
}
if variable
.iter()
.chain(&modifiers)
.any(|p| p.site_fingerprint.as_deref() != Some(fingerprint))
{
self.error = Some(Diagnostic::new(
"E_STALE_SOURCE",
"value expression fingerprint mismatch",
));
return;
}
let load: Vec<_> = variable
.iter()
.filter(|p| {
p.variable.as_ref().unwrap().at == horizon_mixins_core::manifest::Access::Load
})
.copied()
.collect();
let store: Vec<_> = variable
.iter()
.filter(|p| {
p.variable.as_ref().unwrap().at == horizon_mixins_core::manifest::Access::Store
})
.copied()
.collect();
let result = (|| {
let value = value_chain(expr.clone(), &load)?;
let value = value_chain(value, &modifiers)?;
value_chain(value, &store)
})();
match result {
Ok(value) => {
*expr = value;
self.matched +=
usize::from(!variable.is_empty()) + usize::from(!modifiers.is_empty());
}
Err(e) => self.error = Some(e),
}
}
}
struct ReturnRewrite<'a> {
patches: &'a [&'a PlannedInjection],
error: Option<Diagnostic>,
+6 -2
View File
@@ -11,7 +11,7 @@ expression, receiver or argument is duplicated. Generated functions remain in
the target crate. Body-rewriting operations retain macro/cfg/async/closure/try/generic safety
checks. HEAD permits preserved body control flow as specified in
[boundary-head-and-modules.md](boundary-head-and-modules.md). Generated operation continuations are the compilation-tested exception
to the source-closure restriction. Current plans use schema 4; older plans must
to the source-closure restriction. Current plans use schema 5; older plans must
be regenerated. Function definitions and bindings beginning `__horizon_mixins_`
are reserved; references to generated bridge APIs are allowed.
@@ -67,7 +67,7 @@ argument modifiers at their original sites) → tuple modifiers → wrapper chai
BEFORE_CALL hooks at a wrapped call still execute before receiver/argument
evaluation. RETURN transformation and generated code are never revisited for
selector matching. The integration suite compiles positive/negative examples, checks composition
and order, and snapshots the generated source and schema-4 plan.
and order, and snapshots the generated source and schema-5 plan.
## Run the complete fixture
@@ -239,3 +239,7 @@ OVERWRITE remains incompatible with every other kind on its function.
The [Sponge feature map](spongepowered-feature-gaps.md) records what remains
missing and distinguishes these Rust contracts from Java annotation behavior.
The 0.5.0 [effects and state extension](effects-and-state.md) adds expression-value
chains, after-call/after-assignment phases and interior ControlFlow cancellation.
RETURN also processes those cancellation exits; priorities order each phase.
+12 -2
View File
@@ -15,7 +15,7 @@ recursive/escaping module files, ambiguous identities and source files compiled
under more than one module/target identity. Tests/examples/benches are copied
and compiled by Cargo but are not injection targets in the MVP.
Every injection and bridge resolves before any transformation. The schema-4 JSON plan
Every injection, bridge and state constructor resolves before any transformation. The schema-5 JSON plan
records transformer/rustc version, every input file's SHA-256, mod identity and
version, target ID, original file and item-index path, signature/body fingerprint,
call preorder number/fingerprint, selector/count/ordinal, priority, helper source,
@@ -38,7 +38,7 @@ modification precedes STORE modification when they select the same expression.
Per-argument chains precede tuple chains, which precede operation wrappers.
Bridge source fingerprints are checked before rewriting; generated impls are
appended in their owning module after all function edits, keeping item paths
stable. Only files with selected functions or bridges are pretty-printed;
stable. Only files with selected functions, bridges, modules or state are pretty-printed;
other files retain bytes.
Non-doc comments in transformed files are not preserved by `syn`/`prettyplease`.
@@ -73,3 +73,13 @@ Boundary-only HEAD validation and support-module planning are described in
[boundary-head-and-modules.md](boundary-head-and-modules.md). Modules are
fingerprinted before rewrites and appended afterward; original item paths stay
stable. Lifetime-only impls have canonical self-type identities.
Schema 5 includes explicit AFTER_CALL result types and state owner/constructor
fingerprints. Expression modifiers use original expression coordinates. AFTER_ASSIGN
captures resolve in the original post-statement environment; generated statements
are appended after that statement and never traversed. Interior cancellation
returns from the target; exit rewriting also processes those generated exits.
State literal fields are inserted after original child traversal, with owner
fields/getters/initializer functions appended after all body changes. This keeps
all original function/expression indices stable. Struct updates move existing
state through their rest expression. See [effects and state](effects-and-state.md).
+1 -1
View File
@@ -1,6 +1,6 @@
# Lifetime methods, boundary HEAD hooks and support modules (0.4.0)
Veinminer is the first full-game consumer of this extension. Plans use schema 4;
Veinminer is the first full-game consumer of this extension. Plans use schema 5;
regenerate older plans. It declares three cancellable HEAD injections and one
support module. All changes to the exported Horizon Rust source are performed
by the mixin planner/transformer and atomic staging CLI.
+5
View File
@@ -44,3 +44,8 @@ packages, final executable caching, successful-binary publication and rollback.
No launcher integration is implemented. Rust incremental artifacts are not
assumed portable. Horizon's `fast` profile is not selected automatically; the
MVP uses Cargo's test profile and measures it.
Expression, after-hook and state changes use the same atomic stage, unchanged
mtime preservation and Cargo target lock. Constructor source, explicit state
initializers and helper sources participate in the canonical plan/stage identity.
No runtime injection or save-data migration is performed by staging.
+220
View File
@@ -0,0 +1,220 @@
# Expression results, after hooks, interior cancellation and state (0.5.0)
Plans use schema 5. Regenerate older plans; the transformer verifies its version,
Rust toolchain, source hashes and original site fingerprints before rewriting.
These are build-time Rust contracts, not Java annotation compatibility.
## Expression-result modification
`MODIFY_EXPRESSION_VALUE` selects a complete original expression by exact syntax.
Whitespace is ignored through Rust token parsing; parentheses and path spelling
remain significant. `require` is the exact number of matching original
expressions in the optional slice, and `ordinal` is zero-based. Each declaration
selects one match. Helpers and generated initializer calls are never matched.
```toml
[[injection]]
id = "result"
target = "game::calculate"
kind = "MODIFY_EXPRESSION_VALUE"
selector = "calculate_bonus(input) > 0"
require = 1
ordinal = 0
helper = "result.rs"
bindings = [{ name = "value", ty = "bool", value = "$value" }]
```
```rust
fn modify(value: bool) -> bool { !value }
```
Exactly one `$value` binding is required; the helper returns that same explicit
type. Matching modifiers form a chain in priority descending, mod ID ascending,
injection ID ascending order. An original expression evaluates once and keeps
its branch/short-circuit behavior. Calls, literals, arithmetic/comparisons,
blocks, if/match values, tuples, arrays, struct construction and casts are
supported runtime value shapes. Paths, fields and indexing are limited to
primitive scalar reads; place expressions, method receivers/base places,
reference construction, assignment, control transfers, const contexts and
unsupported macro/async/closure contexts fail closed. The declared type is a
contract, not inferred type information; staged Cargo verifies it. Owned call
results can be transformed without cloning, and reference-returning helpers
must have ordinary valid Rust lifetime signatures.
## After a call, before storing its result
`AFTER_CALL` uses the same callable selector/count/ordinal syntax as BEFORE_CALL.
An explicit `result` names the original call's owned or unit result type. The
helper returns unit, with optional `$result_ref` and `$result_mut` bindings for
borrowing that result. Ordinary parameter bindings and lexical captures also
work. Rust rejects overlapping mutable borrows.
```toml
[[injection]]
id = "observe"
target = "game::calculate"
kind = "AFTER_CALL"
selector = "calculate_bonus"
require = 1
ordinal = 0
result = "i32"
helper = "observe.rs"
bindings = [{ name = "result", ty = "&mut i32", value = "$result_mut" }]
```
```rust
fn observe(result: &mut i32) { *result += 1; }
```
The call, receiver and arguments evaluate once. Hooks run after a successful
call, before the result is stored or consumed by its enclosing expression.
They do not run if the call panics. Multiple hooks observe previous mutations;
the result is forwarded without Clone/Copy. Noncancellable after hooks support
nested call sites through typed adapters whose original arguments stay outside
new block scopes. Borrowed call results are deliberately rejected
(`E_AFTER_RESULT`); use AFTER_ASSIGN to inspect a stored borrowed result or an
expression modifier with a valid explicit lifetime contract. Discarded owned
results drop before receiver/argument temporaries, including when cancelling.
## After assigning the result
`AFTER_ASSIGN` runs after the complete original statement, including assignment
and destruction of statement temporaries. It requires a whole call initializer
of a plain `let` (optional type annotation/parentheses), or the whole RHS of a
semicolon-terminated simple local/parameter assignment. It rejects destructured
lets, let-else, nested RHS calls, field/index assignments. No `result` declaration or `$result_*` bindings apply here.
```toml
[[injection]]
id = "stored"
target = "game::calculate"
kind = "AFTER_ASSIGN"
selector = "calculate_bonus"
require = 1
ordinal = 0
helper = "stored.rs"
bindings = [{ name = "value", ty = "&mut i32", value = "$local_mut:value:0" }]
```
```rust
fn stored(value: &mut i32) { *value += 10; }
```
For `let mut value = calculate_bonus(input);`, the newly initialized binding is
visible to this hook. Captures resolve against the original post-statement
lexical environment, including shadowing. A mutable capture requires `let mut`.
Hooks can borrow, change or consume the stored binding; Cargo checks subsequent
uses. A RETURN helper's capture must also exist at every added cancellation
exit; captures visible at all exits are supported.
## Interior cancellation
BEFORE_CALL, AFTER_CALL and AFTER_ASSIGN accept `cancellable = true`. Their
helper returns `::core::ops::ControlFlow<TargetResult>`; `Continue(())` continues,
`Break(value)` returns from the target function. This is not just cancellation
of a single operation. Target results must be explicit owned types or unit for
interior cancellation; HEAD retains its previous lifetime contract.
BEFORE_CALL cancels before evaluating the selected receiver/callee or arguments.
AFTER_CALL cancels after the call and after observing/mutating its result.
AFTER_ASSIGN cancels after the result has been stored. The first Break skips
later same-phase hooks and all remaining target body code. RETURN modifiers
also process cancelled values. Rust drops initialized locals and temporaries;
no panic, hidden exception, global flag or duplicated expression is used.
Cancellable AFTER_CALL is limited to whole-expression sites (statements, plain
let initializers, tails), like BEFORE_CALL. AFTER_ASSIGN uses its stricter
statement proof. Arbitrary nested interior cancellation is unsupported because
it can change temporary lifetimes. A nested AFTER_CALL without cancellation
remains supported.
At one call site the phases are BEFORE_CALL → argument modifiers → argument
tuple modifiers → operation wrapping/original call → AFTER_CALL → expression
result modification → variable STORE → AFTER_ASSIGN → eventual RETURN.
Constants and variable LOAD run where their original operands evaluate.
Priorities order hooks within a phase; they do not reorder these phases.
An AFTER_CALL cancellation bypasses later expression/STORE/AFTER_ASSIGN phases.
## Persistent per-instance state
A `[[state]]` declaration adds an owned, private namespaced field to a named,
nongeneric struct. It persists for the lifetime of that instance, independently
of every other instance. State initialization and access happen inside the
owner crate; no cross-crate private access or dependency cycle is introduced.
```toml
[[state]]
id = "history"
target = "game::entity::Entity"
ty = "Vec<i32>"
initial = "::core::default::Default::default()"
# priority = 100 # defaults to mod priority
[[state.constructor]]
target = "game::entity::Entity::new"
selector = "Self"
require = 1
```
For mod ID `example`, this creates private field
`__horizon_mixins_example_history`, getter of the same name returning `&Vec<i32>`,
and `__horizon_mixins_example_history_mut()` returning `&mut Vec<i32>`. Generated
identifiers lowercase IDs and replace hyphens with underscores; collisions fail
planning. The type must be explicit and owned. Its initializer is an expression
compiled in a standalone function in the owner's module, with no constructor
parameters or local captures. An initializer may call existing/support-module
APIs, using ordinary Rust paths. Neither dependencies nor serialization traits
are added to Cargo manifests.
Declare every syntactically resolved struct literal using exact constructor
function IDs, literal path selectors and positive exact counts. All matching
literals for each constructor declaration are planned. Unlike injections,
constructors have no ordinal: each declared literal must remain initialized.
`Self`, simple owner names and explicit crate/self/super paths are resolved;
import aliases and generic paths are not inferred. Missing visible constructors,
duplicate sites, invalid counts and OVERWRITE removing a constructor fail
planning. Unsupported constructor function bodies fail closed. Cargo additionally
catches hidden/aliased literals or patterns that need updating; `check` is required.
New construction evaluates original fields first, then state initializers in
priority/ID order. A struct update `Self { health, ..old }` moves its existing
state through `..old`; it does not reset state. Derived Clone clones state;
Drop runs with normal Rust ownership. Derived Default is allowed only with the
literal initial expression `::core::default::Default::default()` for every added
state, so derived and explicit construction agree. A custom Default impl must
declare its own literals as constructors.
Named structs with repr/layout attributes, unresolved cfg or macros, unknown
derives, generic owners, tuple/unit owners, or wholly public externally
constructible fields are unsupported. Use a private field or a non_exhaustive
struct to keep external construction closed. Exact struct destructuring must
have `..`; the planner diagnoses visible patterns without it. State changes the
layout; it does not implement arbitrary members, trait merging or Java Unique.
For structs already deriving serde Serialize/Deserialize, new fields participate
in their normal derived serialization. Deserialize fields get
`#[serde(default = "<namespaced initializer>")]`, allowing older records to load.
The fixture checks an actual JSON roundtrip and old-data defaults. Types must
already implement required derives; serde must already be a dependency. Renaming
mod/state IDs changes the serialized field name. Other save formats, custom
serialization, schema migrations and game world persistence need explicit mod
code. Instance lifetime support does not automatically connect state to a
world's save system. Removing a mod removes its staged fields; keep original
save data/backups according to that save system's own policy.
## Validation
```sh
cargo fetch --locked --manifest-path fixtures/effects/Cargo.toml
cargo run --locked -p horizon-mixins-cli -- check \
--workspace fixtures/effects --mods fixtures/effects-mods \
--out target/effects-game --trust-native-mods
```
The two-mod fixture compiles five runtime tests for once-only evaluation,
short-circuit behavior, hook order, cancellation/RETURN, Drop timing, typed
post-assignment captures, state isolation, clone/update lifecycle and serde.
Engine tests add failure cases, move/type errors, cross-phase signature checks,
stale IR, deterministic source/plan snapshots and unchanged source/mtime checks.
`tools/effects-feasibility.rs` independently compiles the adapter/lifecycle spike
through `tools/feasibility.py`.
+34
View File
@@ -149,3 +149,37 @@ verifies all other original syntax is preserved. First native check/test Cargo
wall times were 25.882 s / 49.394 s; full-workspace plan plus apply cost about
26 s including two planner passes. These measurements cover source builds and
tests without game assets; live gameplay remains unverified.
Version 0.5.0 implements the [effects and state contracts](effects-and-state.md).
Before implementation, `tools/effects-feasibility.rs` compiled typed value/after
adapters and compared original/result/argument Drop order, including cancellation,
then checked once-only expression evaluation and per-instance ownership. Its two
Rust tests pass through the phase-0 script. Existing privacy and dependency-cycle
failures still occur as expected.
The effects workspace contains twelve injections and two state declarations
across independent mods. Five runtime tests compile together. Engine integration
cases additionally check move/type/alias failures, original count/ordinal errors,
post-assignment visibility, cancellation/RETURN capture visibility, unsupported
layouts/derives/patterns/aliases, constructor coverage and overwrite conflicts,
state-only bundles, derived Default, Drop lifecycle and namespaced collisions.
The 64 integration tests pass, including byte-identical plan/source snapshots,
immutable original files and unchanged stage mtimes. Rustfmt and clippy with
warnings denied pass. Plans and package version are schema 5 / 0.5.0.
Measurements on the same Linux/rustc machine, fresh Cargo target for the effects
fixture (includes its existing serde dependencies):
| Scenario | Transform/stage wall | Cargo compile | Cargo build + tests |
|---|---:|---:|---:|
| First effects/state build | 0.063 s | 2.09 s | 2.133 s |
| Unchanged run | 0.065 s | 0.01 s | 0.048 s |
Raw reports are generated under `target/effects-measure-v050/`. These are tiny
fixture measurements; no full-game rebuild speed claim follows from them.
The pinned isolated horizon-core experiment was also repeated under
`target/horizon-prototype-v050/`: the original upstream tests and two added HEAD
probes pass. First modded transform/stage: 0.369 s; Cargo compile: 2.53 s; Cargo
plus tests: 12.169 s. Unchanged: 0.363 s transform/stage, 0.00 s compile,
9.706 s Cargo plus tests. It still uses the same committed Horizon pin and no
Minecraft assets or upstream source edits.
+10 -4
View File
@@ -98,16 +98,22 @@ MODIFY_CONSTANT and WRAP_OPERATION; see [contracts and runnable
examples](advanced-injections.md). Version 0.3.0 adds local capture, slices,
MODIFY_ARGS, MODIFY_VARIABLE and reusable accessor/invoker bridges; see
[scoped contracts and examples](scopes-and-bridges.md). A bundle may contain
injections, bridges, or both; IDs are unique across both lists in a bundle.
Plans use schema 4, including lexical identities, original expression
injections, bridges, support modules and/or state; IDs are unique within a bundle.
Plans use schema 5, including lexical identities, original expression
coordinates, resolved regions and versioned bridge metadata. Regenerate older
plans after upgrading.
Future attribute syntax, additional dependencies, arbitrary helper modules,
semantic selectors, AFTER_CALL, REDIRECT and structural state changes require separate
Future attribute syntax, additional dependencies, semantic selectors, REDIRECT
and broader structural transformations require separate
specifications and tests. The [SpongePowered feature map](spongepowered-feature-gaps.md)
documents the remaining gaps.
HEAD has a compilation-tested exception for preserved closure/`?` bodies and
lifetime-bearing impls. Version 0.4.0 also adds `[[module]]` for shared same-crate
implementation code. See [boundary contracts](boundary-head-and-modules.md).
Version 0.5.0 adds `MODIFY_EXPRESSION_VALUE`, `AFTER_CALL`, `AFTER_ASSIGN`,
`cancellable` interior hooks and `[[state]]` declarations. See
[effects and state](effects-and-state.md) for explicit result contracts,
post-assignment captures, constructor counts, initialization and serialization.
CLI success reports now include `states` as well as injections/bridges/modules.
+13 -4
View File
@@ -15,7 +15,8 @@ Full game and launcher integration remain future work.
| 6a — Advanced value/control features | Implemented: cancellable HEAD, RETURN, MODIFY_ARG, MODIFY_CONSTANT and composable WRAP_OPERATION with typed contracts, original-site planning and compiled tests |
| 6b — Scoped features and bridges | Implemented: lexical local capture, same-block call-bounded slices, GET/GET_MUT/SET/INVOKE APIs, MODIFY_ARGS and MODIFY_VARIABLE STORE/scalar LOAD |
| 6c — Native consumer | Implemented: lifetime impl method IDs, HEAD over preserved closure/`?` bodies, support modules and a real TOML Veinminer bundle; complete game binary compilation and AST audit |
| 6d — Remaining parity | Future: AFTER_CALL, REDIRECT, broader sites/receivers/patterns, structural mixins, optional groups and semantic matching; see [feature map](spongepowered-feature-gaps.md) |
| 6d — Effects and state | Implemented: expression results, AFTER_CALL/AFTER_ASSIGN, interior cancellation, explicit per-instance fields/constructors and serde old-data defaults |
| 6e — Remaining parity | Future: REDIRECT, broader sites/receivers/patterns, arbitrary members/traits, optional groups and semantic matching; see [feature map](spongepowered-feature-gaps.md) |
`cargo test --workspace --locked` checks two independent mods, same-site priority
and ID tie order, original selectors unaffected by injected calls, call results
@@ -34,12 +35,20 @@ a separate change; it is not part of this MVP.
The advanced fixture adds 38 declarations across two mods and eight runtime
tests. Integration tests cover cancellation precedence, return exits and Drop,
argument/constant value chains, FnOnce enforcement, method/self reborrowing,
combined phases, signature conflicts, invalid sites and deterministic schema-4
combined phases, signature conflicts, invalid sites and deterministic schema-5
plans. See [advanced-injections.md](advanced-injections.md) for supported limits.
The scoped fixture adds 25 injections, nine bridges and eight runtime tests
across two mods. The 48 engine integration tests include lexical declaration
across two mods. The engine integration tests include lexical declaration
identity and shadowing, borrowed/owned capture errors, bridge privacy and mutable
borrows, slice boundaries/counts, argument tuple composition, STORE destruction,
LOAD-before-STORE order, bridge-only bundles, and deterministic schema-4 plans
LOAD-before-STORE order, bridge-only bundles, and deterministic schema-5 plans
and source. See [scopes-and-bridges.md](scopes-and-bridges.md).
The effects fixture adds two independently declared states and twelve hooks,
with five runtime tests and source/plan snapshots. Additional compiled cases
cover owned expression results, noncancellable result destruction, post-store
RETURN captures, state-only bundles, derived Default and Drop lifecycles.
See [effects-and-state.md](effects-and-state.md). The full engine suite has
64 integration tests; the independent feasibility spike runs two additional
Rust tests before the suite.
+7 -2
View File
@@ -2,7 +2,7 @@
Version 0.3.0 implements lexical local capture, accessor/invoker bridges,
original-source slices, MODIFY_ARGS and MODIFY_VARIABLE. The baseline and
advanced fixtures continue to pass. Plans use schema 4; regenerate older plans.
advanced fixtures continue to pass. Plans use schema 5; regenerate older plans.
Run the two-mod fixture containing 25 injections, nine bridges and eight runtime
tests:
@@ -270,6 +270,11 @@ cfg/macro/closure/async/generic injection safety gates still apply.
The 48-test integration suite compiles the earlier fixtures and this fixture,
including positive/negative scope, shadowing, privacy, exact-once evaluation,
owned value destruction and mutable-borrow examples. Source, bridge and schema-4
owned value destruction and mutable-borrow examples. Source, bridge and schema-5
plan snapshots are checked independently of bundle discovery order. Plans are
review artifacts; `apply` and `check` rebuild them from original inputs.
For AFTER_ASSIGN, the captured environment is the original post-statement scope,
so the new result binding is available. RETURN capture validation includes all
interior cancellation exits rather than rejecting all captured cancellation
combinations. See [effects and state](effects-and-state.md).
+7
View File
@@ -30,3 +30,10 @@ Horizon's WebAssembly mod system remains separate.
No game binaries, Minecraft assets, decompiled Java or prepared resources belong
in this repository. The Horizon prototype exports only committed Rust crate
source. License: MIT; see the repository LICENSE.
Per-instance state changes the staged struct's Rust layout. repr/FFI and openly
constructible public structs are rejected; constructor literals and initializers
are explicit and fingerprinted. State/serialization code has the same native
privileges as helpers. Serde defaults support old records, but custom game save
systems and schema migrations require explicit integration; see
[effects and state](effects-and-state.md).
+23 -21
View File
@@ -1,6 +1,6 @@
# SpongePowered Mixin feature map and remaining work
Status: Horizon Mixins 0.4.0. This is a capability comparison and design backlog,
Status: Horizon Mixins 0.5.0. This is a capability comparison and design backlog,
not Java annotation, bytecode or mod compatibility. The implementation uses Rust
source before Cargo compilation; Sponge Mixin integrates Java bytecode with the
runtime classloading pipeline. [Sponge overview](https://github.com/SpongePowered/Mixin/blob/master/README.MD).
@@ -14,7 +14,7 @@ features are not accepted TOML kinds. Unknown fields/kinds still fail validation
| Sponge capability | Horizon counterpart | Supported boundary |
|---|---|---|
| HEAD callback | HEAD | Explicit typed target-parameter context |
| Cancellable callback | HEAD with `cancellable = true` | `ControlFlow<Result>`; first cancellation stops later HEAD hooks and original body |
| Cancellable callback | HEAD, BEFORE_CALL, AFTER_CALL, AFTER_ASSIGN with `cancellable = true` | ControlFlow returns from the target; whole-expression/statement proofs for interior sites |
| Return callbacks/result replacement | RETURN | All normal explicit/implicit exits, including cancellation; no individual return selectors, async, `?` or closure exits |
| INVOKE callback | BEFORE_CALL | Exact syntax; only whole statements, plain let initializers and block/function tails |
| Overwrite | OVERWRITE | Entire body; exclusive against every other injection |
@@ -26,6 +26,9 @@ features are not accepted TOML kinds. Unknown fields/kinds still fail validation
| Slices | Optional call-selected from/to bounds | Pristine expression interval, same lexical block; inclusive start/exclusive end; explicit boundary counts/ordinals |
| Accessor / Invoker | GET, GET_MUT, SET, INVOKE bridges | Public namespaced inherent methods on owned nongeneric structs; fields and exact existing inherent/trait calls |
| Priorities, ordinals and count validation | priority, ordinal, require | Deterministic ID tie-breaks; require is an exact original match count here |
| After INVOKE / INVOKE_ASSIGN | AFTER_CALL / AFTER_ASSIGN | Owned/unit result borrows before consumption, or post-statement lexical captures after the store/temporary drops |
| MixinExtras ModifyExpressionValue | MODIFY_EXPRESSION_VALUE | Exact syntax expression selectors and explicit value types; runtime values and scalar reads, excluding places |
| Unique instance fields | Namespaced state fields/getters | Owned named nongeneric structs, explicit constructors and derived serde defaults; custom saves remain explicit |
| MixinExtras WrapOperation | WRAP_OPERATION | Chained FnOnce continuations; module-level free calls or borrowed parameter receivers; owned result/unit |
See [advanced-injections.md](advanced-injections.md) and
@@ -38,7 +41,6 @@ match count. [Callback documentation](https://github.com/SpongePowered/Mixin/wik
| Feature | What Sponge provides | Rust design needed |
|---|---|---|
| AFTER_CALL / INVOKE_ASSIGN | Hooks after a call and, for INVOKE_ASSIGN, after an immediately following result store | Define whether the result is observed before/after moves and assignment; preserve temporary destruction |
| TAIL and individual RETURN selection | Final bytecode return, or a chosen return ordinal | Define Rust source exit identities; TAIL is not an alias for every return and must not silently omit early exits |
| FIELD, NEW, JUMP sites | Field accesses, allocation/construction and branches | Distinguish place expressions from values, struct literals from constructors, and branches from enclosing expressions |
| Redirect | Replace calls, field/array operations, object creation and selected type checks | Independent conflict rules; preserve places, indexing evaluation, borrow order, receiver effects and replacement types |
@@ -55,9 +57,10 @@ References: [injection point reference](https://github.com/SpongePowered/Mixin/w
[Slice](https://github.com/SpongePowered/Mixin/blob/master/src/main/java/org/spongepowered/asm/mixin/injection/Slice.java),
[custom injection points](https://github.com/SpongePowered/Mixin/blob/master/src/main/java/org/spongepowered/asm/mixin/injection/InjectionPoint.java).
An AFTER_CALL prototype must test owned and borrowed results, statement-discarded
results with Drop, argument temporaries, and failure paths. The local-variable
fixture demonstrates that two bindings with the same name cannot be confused.
AFTER_CALL now compiles owned/unit adapters and Drop/cancellation cases; borrowed
results are rejected. AFTER_ASSIGN can capture stored borrowed results through
ordinary Rust types. The local-variable fixture demonstrates that two bindings
with the same name cannot be confused.
A redirect prototype must reject competing replacements before staging.
None should infer method identity from a bare method name.
@@ -67,7 +70,7 @@ None should infer method identity from a bare method name.
|---|---|---|
| Shadow | No declarative placeholders for existing fields/methods | Explicit member contracts validated in the target module; local helpers already have ordinary same-crate privacy |
| Broader accessor / invoker support | Nongeneric owned structs only; unsafe/async/const/generic signatures and unresolved associated projections rejected | Extend signature/owner resolution with compiled privacy/lifetime examples; current bridges add no dependency edges |
| New fields / methods and Unique | Local helpers, self-contained support modules and generated bridge methods; no arbitrary methods or persistent instance state | Stable namespacing; initialization at every construction site; reject layout/FFI-sensitive structs until proven safe |
| Broader fields / methods and Unique | Namespaced owned instance state, support modules and generated getters/bridges are implemented; arbitrary members and generic state owners are missing | Extend explicit constructor, layout, derive and serialization contracts with compiled cases |
| Interface / soft-implements mixins | No generated trait implementations or duck-interface adapters | Rust coherence/orphan rules, trait dependencies, associated items and existing impl conflicts must be checked by Cargo |
| Intrinsic proxies / superclass behavior | No equivalent proxy mechanism | Define explicit delegation to original functions; Rust has no direct Java superclass model |
| Final / Mutable controls | No Java-style field finality transformation | Rust mutability and interior-mutability rules remain enforced; an annotation must never promise to bypass borrowing |
@@ -80,10 +83,9 @@ References: [Shadow](https://github.com/SpongePowered/Mixin/blob/master/src/main
[Final](https://github.com/SpongePowered/Mixin/blob/master/src/main/java/org/spongepowered/asm/mixin/Final.java),
[Mutable](https://github.com/SpongePowered/Mixin/blob/master/src/main/java/org/spongepowered/asm/mixin/Mutable.java).
Adding struct fields needs a separate design review covering literals in other
crates, default constructors, serialization, pattern matching, repr/layout and
save-data compatibility. A side table keyed by a stable game entity ID may be
more appropriate for some state; it is a proposal, not implemented storage.
Instance state uses explicit constructor coverage, rejects repr/open external
construction, and supplies serde defaults for missing fields. Custom saves and
entity-ID side tables remain explicit mod code. See [state contracts](effects-and-state.md).
## Missing configuration, tooling and compatibility features
@@ -110,21 +112,21 @@ MixinExtras project. Our WRAP_OPERATION follows the general chaining model but
uses typed Rust FnOnce and our own manifests/order rules; it does not load Java
MixinExtras mods. [WrapOperation documentation](https://github.com/LlamaLad7/MixinExtras/wiki/WrapOperation).
General expression-value modification is still missing. MODIFY_CONSTANT,
MODIFY_ARG and RETURN cover specific values, but do not select arbitrary
comparisons, field values or expression results. A future general modifier needs
an explicit expression identity and type contract, with place/value and Drop
semantics tests. [ModifyExpressionValue documentation](https://github.com/LlamaLad7/MixinExtras/wiki/ModifyExpressionValue).
`MODIFY_EXPRESSION_VALUE` now selects exact original syntax and preserves
once-only evaluation and short-circuit behavior. Place expressions, implicit
borrowed owned reads, compile-time values and unproven control flow are excluded.
See [effects and state](effects-and-state.md) and upstream
[ModifyExpressionValue documentation](https://github.com/LlamaLad7/MixinExtras/wiki/ModifyExpressionValue).
## Suggested next feature work
1. Exercise the supported operations on real horizon-world state in an isolated
pinned checkout and widen receiver/context support based on compiled cases.
2. Design AFTER_CALL with owned/borrowed result and destruction-order tests.
2. Widen borrowed-result adapters only with lifetime and destruction-order proofs.
3. Add optional declarative compatibility groups and extend existing receiver,
pattern and bridge support only for compiled use cases.
4. Consider redirects, arbitrary expression modification and structural
state changes only with independent semantics and conflict tests.
4. Consider redirects and broader member/trait transformations with independent
semantics and conflict tests.
Launcher/build-cache work is tracked separately in [roadmap.md](roadmap.md).
Feature parity should be judged by tested modding use cases, not by copying
@@ -132,5 +134,5 @@ annotation names onto syntax transformations with different semantics.
Version 0.4.0 also supports lifetime-bearing impls and HEAD over preserved
closure/`?` bodies; see [native consumer contracts](boundary-head-and-modules.md).
This enables the real Veinminer bundle without arbitrary fields or constructor
rewrites. Other body-rewriting kinds retain their existing safety limits.
The newer state extension adds explicit constructor rewriting separately. Other
body-rewriting kinds retain their existing safety limits.
+13 -1
View File
@@ -68,7 +68,7 @@ Value modifiers use a scoped function-item callee with original values as
ordinary call arguments, avoiding an added block around original expressions.
Operation wrappers use typed, generated FnOnce continuations. Return modifiers
run on explicit exits, implicit tails and cancelled HEAD results; generated
helper/continuation returns are not revisited. Current schema-4 plans store
helper/continuation returns are not revisited. Current schema-5 plans store
value/output contracts, argument indices and original constant coordinates,
plus lexical/access identities, captures, resolved regions and bridge metadata.
Regenerate older plans after upgrading.
@@ -95,3 +95,15 @@ and `E_ARGUMENT_SIGNATURE` diagnose value/tuple contracts. `E_SLICE_*` errors
describe invalid boundaries, scopes and order; `E_BRIDGE_TARGET` and
`E_BRIDGE_CONFLICT` describe unsupported owners/signatures and name collisions.
Rust type, lifetime, borrowing and move errors still come from staged Cargo.
Version 0.5.0 adds [effects and state](effects-and-state.md). Typed AFTER_CALL
adapters observe the original result before consumption; AFTER_ASSIGN statements
run after its store and temporary destruction. Expression-value modifiers run
after call adapters and before STORE modifiers. Interior cancellation rewrites
to a real target return, including RETURN processing and ordinary Rust drops.
Original struct literals receive deterministically ordered state initializers;
struct fields and accessors are added after body rewrites. `E_AFTER_RESULT`
diagnoses invalid result borrow contracts; `E_STATE` diagnoses unsupported owner,
constructor, initialization and pattern cases; `E_STATE_CONFLICT` rejects
namespaced collisions and OVERWRITE removing a constructor. Cargo checks types,
moves, derives, serialization and any constructions hidden behind aliases/macros.
+1
View File
@@ -0,0 +1 @@
fn after(result:&Token,cancel:bool,trace:&Trace)->::core::ops::ControlFlow<i32> { let _ = result; mark(trace,"after-alpha"); if cancel { ::core::ops::ControlFlow::Break(41) } else { ::core::ops::ControlFlow::Continue(()) } }
@@ -0,0 +1 @@
fn after(value:&i32,cancel:bool,trace:&Trace)->::core::ops::ControlFlow<i32> { mark(trace,"assigned-first"); if cancel { ::core::ops::ControlFlow::Break(*value+100) } else { ::core::ops::ControlFlow::Continue(()) } }
@@ -0,0 +1 @@
fn after(value:&mut i32,trace:&Trace) { mark(trace,"assigned-second"); *value+=10; }
+1
View File
@@ -0,0 +1 @@
fn before(cancel:bool,trace:&Trace)->::core::ops::ControlFlow<i32> { mark(trace,"before"); if cancel { ::core::ops::ControlFlow::Break(41) } else { ::core::ops::ControlFlow::Continue(()) } }
+1
View File
@@ -0,0 +1 @@
fn modify(value:bool,trace:&Trace)->bool { mark(trace,"boolean"); !value }
@@ -0,0 +1 @@
fn modify(value:i32,trace:&Trace)->i32 { mark(trace,"expr-alpha"); value+1 }
@@ -0,0 +1,117 @@
[mod]
id="alpha"
version="0.1.0"
priority=100
[[injection]]
id="expression"
target="effects_game::expression"
kind="MODIFY_EXPRESSION_VALUE"
helper="expression.rs"
bindings=[{name="value",ty="i32",value="$value"},{name="trace",ty="&Trace",value="trace"}]
selector="add(number(trace),3)"
ordinal=0
require=1
[[injection]]
id="boolean"
target="effects_game::short"
kind="MODIFY_EXPRESSION_VALUE"
helper="boolean.rs"
bindings=[{name="value",ty="bool",value="$value"},{name="trace",ty="&Trace",value="trace"}]
selector="number(trace)>0"
ordinal=0
require=1
[[injection]]
id="nested"
target="effects_game::nested"
kind="AFTER_CALL"
helper="nested.rs"
bindings=[{name="value",ty="&mut i32",value="$result_mut"},{name="trace",ty="&Trace",value="trace"}]
result="i32"
selector="number"
ordinal=0
require=1
[[injection]]
id="before"
target="effects_game::before"
kind="BEFORE_CALL"
helper="before.rs"
bindings=[{name="cancel",ty="bool",value="cancel"},{name="trace",ty="&Trace",value="trace"}]
cancellable=true
selector="number"
ordinal=0
require=1
[[injection]]
id="return"
target="effects_game::before"
kind="RETURN"
helper="return.rs"
bindings=[{name="value",ty="i32",value="$value"}]
[[injection]]
id="after"
target="effects_game::discarded"
kind="AFTER_CALL"
helper="after.rs"
bindings=[{name="result",ty="&Token",value="$result_ref"},{name="cancel",ty="bool",value="cancel"},{name="trace",ty="&Trace",value="trace"}]
result="Token"
cancellable=true
selector="receiver(trace).produce"
ordinal=0
require=1
[[injection]]
id="assigned-first"
target="effects_game::assigned"
kind="AFTER_ASSIGN"
helper="assigned-first.rs"
bindings=[{name="value",ty="&i32",value="$local_ref:value:0"},{name="cancel",ty="bool",value="cancel"},{name="trace",ty="&Trace",value="trace"}]
cancellable=true
selector="number"
ordinal=0
require=2
[[injection]]
id="assigned-second"
target="effects_game::assigned"
kind="AFTER_ASSIGN"
helper="assigned-second.rs"
bindings=[{name="value",ty="&mut i32",value="$local_mut:value:0"},{name="trace",ty="&Trace",value="trace"}]
selector="number"
ordinal=1
require=2
[[injection]]
id="owned"
target="effects_game::after_owned"
kind="AFTER_ASSIGN"
helper="owned.rs"
bindings=[{name="text",ty="&mut String",value="$local_mut:text:0"}]
selector="String::from"
ordinal=0
require=1
[[injection]]
id="tick"
target="effects_game::state::Entity::tick"
kind="HEAD"
helper="tick.rs"
bindings=[{name="entity",ty="&mut Entity",value="self"}]
[[state]]
id="history"
target="effects_game::state::Entity"
ty="Vec<i32>"
initial="::core::default::Default::default()"
[[state.constructor]]
target="effects_game::state::Entity::new"
selector="Self"
require=1
[[state.constructor]]
target="effects_game::state::Entity::with_health"
selector="Self"
require=1
+1
View File
@@ -0,0 +1 @@
fn after(value:&mut i32,trace:&Trace) { mark(trace,"after-number"); *value+=4; }
+1
View File
@@ -0,0 +1 @@
fn after(text:&mut String) { text.push_str("!"); }
+1
View File
@@ -0,0 +1 @@
fn result(value:i32)->i32 {value+1}
+1
View File
@@ -0,0 +1 @@
fn tick(entity:&mut Entity) { let health=entity.health; entity.__horizon_mixins_alpha_history_mut().push(health); }
+1
View File
@@ -0,0 +1 @@
fn after(trace:&Trace) { mark(trace,"after-beta"); }
+1
View File
@@ -0,0 +1 @@
fn modify(value:i32,trace:&Trace)->i32 { mark(trace,"expr-beta"); value*2 }
@@ -0,0 +1,39 @@
[mod]
id="beta"
version="0.1.0"
priority=0
[[injection]]
id="expression"
target="effects_game::expression"
kind="MODIFY_EXPRESSION_VALUE"
helper="expression.rs"
bindings=[{name="value",ty="i32",value="$value"},{name="trace",ty="&Trace",value="trace"}]
selector="add(number(trace),3)"
ordinal=0
require=1
[[injection]]
id="after"
target="effects_game::discarded"
kind="AFTER_CALL"
helper="after.rs"
bindings=[{name="trace",ty="&Trace",value="trace"}]
result="Token"
selector="receiver(trace).produce"
ordinal=0
require=1
[[state]]
id="counter"
target="effects_game::state::Entity"
ty="usize"
initial="initial_counter()"
[[state.constructor]]
target="effects_game::state::Entity::new"
selector="Self"
require=1
[[state.constructor]]
target="effects_game::state::Entity::with_health"
selector="Self"
require=1
+114
View File
@@ -0,0 +1,114 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "effects-driver"
version = "0.1.0"
dependencies = [
"effects-game",
"serde_json",
]
[[package]]
name = "effects-game"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+3
View File
@@ -0,0 +1,3 @@
[workspace]
members=["game","driver"]
resolver="2"
+9
View File
@@ -0,0 +1,9 @@
# Effects and state compilation fixture
This pinned workspace is transformed using `../effects-mods`. Two mods modify
the same expression/call and add independent fields to the same private-layout
struct. Five driver tests cover short-circuit and once-only evaluation,
BEFORE/AFTER cancellation, RETURN, temporary Drop order, typed post-assignment
captures and per-instance/clone/update/serde behavior. The original driver
intentionally expects generated APIs; compile the staged workspace with CLI
`check`. See [contracts](../../docs/effects-and-state.md).
+7
View File
@@ -0,0 +1,7 @@
[package]
name="effects-driver"
version="0.1.0"
edition="2024"
[dependencies]
effects-game={path="../game"}
serde_json="1"
+35
View File
@@ -0,0 +1,35 @@
#[cfg(test)]
mod tests {
use effects_game::*;
fn trace()->Trace { Default::default() }
#[test] fn expression_once_and_ordered() {
let t=trace(); assert_eq!(expression(&t),24); assert_eq!(*t.borrow(),vec!["number","expr-alpha","expr-beta"]);
t.borrow_mut().clear(); assert_eq!(nested(&t),9); assert_eq!(*t.borrow(),vec!["number","after-number"]);
t.borrow_mut().clear(); assert!(!short(&t,false)); assert!(t.borrow().is_empty()); assert!(!short(&t,true)); assert_eq!(*t.borrow(),vec!["number","boolean"]);
}
#[test] fn before_cancels_before_arguments_and_return_modifies_break() {
let t=trace(); assert_eq!(before(&t,true),42); assert_eq!(*t.borrow(),vec!["before","guard-drop"]);
t.borrow_mut().clear(); assert_eq!(before(&t,false),3); assert_eq!(*t.borrow(),vec!["before","number","body","guard-drop"]);
}
#[test] fn after_call_drop_and_cancel_order() {
let t=trace(); assert_eq!(discarded(&t,false),5); assert_eq!(*t.borrow(),vec!["receiver","call","after-alpha","after-beta","result-drop","receiver-drop","body"]);
t.borrow_mut().clear(); assert_eq!(discarded(&t,true),41); assert_eq!(*t.borrow(),vec!["receiver","call","after-alpha","result-drop","receiver-drop"]);
}
#[test] fn after_assignment_has_new_binding_and_store_is_complete() {
let t=trace(); assert_eq!(assigned(&t,false),12); assert_eq!(*t.borrow(),vec!["number","assigned-first","between","number","assigned-second","body"]);
t.borrow_mut().clear(); assert_eq!(assigned(&t,true),102); assert_eq!(*t.borrow(),vec!["number","assigned-first"]);
assert_eq!(after_owned(&t),"hello!");
}
#[test] fn state_lives_with_instances_and_roundtrips() {
use state::{Entity,INITIALIZATIONS}; use std::sync::atomic::Ordering;
let before=INITIALIZATIONS.load(Ordering::SeqCst);
let mut a=Entity::new(1); let b=Entity::new(5);
assert_eq!(INITIALIZATIONS.load(Ordering::SeqCst)-before,2);
a.tick(); a.tick(); assert_eq!(a.__horizon_mixins_alpha_history(),&vec![1,2]); assert!(b.__horizon_mixins_alpha_history().is_empty());
*a.__horizon_mixins_beta_counter_mut()=99;
let a=a.with_health(8); assert_eq!(*a.__horizon_mixins_beta_counter(),99); assert_eq!(INITIALIZATIONS.load(Ordering::SeqCst)-before,2);
let clone=a.clone(); assert_eq!(clone.__horizon_mixins_alpha_history(),a.__horizon_mixins_alpha_history());
let json=serde_json::to_string(&a).unwrap(); let restored:Entity=serde_json::from_str(&json).unwrap(); assert_eq!(*restored.__horizon_mixins_beta_counter(),99);
let old:Entity=serde_json::from_str(r#"{"health":10}"#).unwrap(); assert_eq!(*old.__horizon_mixins_beta_counter(),7); assert!(old.__horizon_mixins_alpha_history().is_empty());
}
}
+6
View File
@@ -0,0 +1,6 @@
[package]
name="effects-game"
version="0.1.0"
edition="2024"
[dependencies]
serde={version="1", features=["derive"]}
+32
View File
@@ -0,0 +1,32 @@
use std::{cell::RefCell, rc::Rc};
pub type Trace = Rc<RefCell<Vec<&'static str>>>;
pub fn mark(trace: &Trace, event: &'static str) { trace.borrow_mut().push(event); }
pub struct Token { trace: Trace, event: &'static str }
impl Drop for Token { fn drop(&mut self) { mark(&self.trace, self.event); } }
pub struct Receiver { trace: Trace }
impl Drop for Receiver { fn drop(&mut self) { mark(&self.trace, "receiver-drop"); } }
impl Receiver { pub fn produce(&self) -> Token { mark(&self.trace,"call"); Token { trace: self.trace.clone(), event:"result-drop" } } }
pub fn receiver(trace:&Trace) -> Receiver { mark(trace,"receiver"); Receiver {trace: trace.clone()} }
pub fn number(trace:&Trace) -> i32 { mark(trace,"number"); 2 }
pub fn add(a:i32,b:i32)->i32 { a+b }
pub fn expression(trace:&Trace)->i32 { let value = add(number(trace),3)*2; value }
pub fn nested(trace:&Trace)->i32 { add(number(trace),3) }
pub fn short(trace:&Trace, enabled:bool)->bool { enabled && number(trace)>0 }
pub fn before(trace:&Trace,cancel:bool)->i32 { let _guard = Token { trace:trace.clone(),event:"guard-drop" }; let value=number(trace); mark(trace,"body"); value }
pub fn discarded(trace:&Trace,cancel:bool)->i32 { receiver(trace).produce(); mark(trace,"body"); 5 }
pub fn assigned(trace:&Trace,cancel:bool)->i32 { let mut value: i32=number(trace); mark(trace,"between"); value=number(trace); mark(trace,"body"); value }
pub fn after_owned(_trace:&Trace)->String { let mut text=String::from("hello"); text }
pub mod state {
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicUsize,Ordering};
pub static INITIALIZATIONS:AtomicUsize=AtomicUsize::new(0);
pub fn initial_counter()->usize { INITIALIZATIONS.fetch_add(1,Ordering::SeqCst); 7 }
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Entity { health:i32 }
impl Entity {
pub fn new(health:i32)->Self { Self {health} }
pub fn with_health(self,health:i32)->Self { Self {health,..self} }
pub fn tick(&mut self) { self.health+=1; }
pub fn health(&self)->i32 {self.health}
}
}
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.95.0"
profile = "minimal"
+95
View File
@@ -0,0 +1,95 @@
use std::{cell::RefCell, ops::ControlFlow, rc::Rc};
type Log = Rc<RefCell<Vec<&'static str>>>;
struct Token(Log, &'static str);
impl Drop for Token {
fn drop(&mut self) {
self.0.borrow_mut().push(self.1);
}
}
fn make(log: &Log, name: &'static str) -> Token {
Token(log.clone(), name)
}
fn call(log: &Log, _arg: &Token) -> Token {
log.borrow_mut().push("call");
make(log, "result")
}
fn observed(value: Token) -> Token {
value.0.borrow_mut().push("after");
value
}
fn cancel(value: Token) -> ControlFlow<i32, Token> {
value.0.borrow_mut().push("after");
ControlFlow::Continue(value)
}
fn original(log: &Log) {
call(log, &make(log, "arg"));
log.borrow_mut().push("tail");
}
fn modified(log: &Log) {
({
fn adapter(value: Token) -> Token {
observed(value)
}
adapter
})(call(log, &make(log, "arg")));
log.borrow_mut().push("tail");
}
fn cancellable(log: &Log) -> i32 {
match cancel(call(log, &make(log, "arg"))) {
ControlFlow::Break(v) => return v,
ControlFlow::Continue(value) => {
drop(value);
}
};
log.borrow_mut().push("tail");
0
}
#[test]
fn drop_order() {
let logs: Vec<_> = (0..3).map(|_| Rc::new(RefCell::new(Vec::new()))).collect();
original(&logs[0]);
modified(&logs[1]);
cancellable(&logs[2]);
for log in &logs[1..] {
let filtered: Vec<_> = log
.borrow()
.iter()
.filter(|s| **s != "after")
.copied()
.collect();
assert_eq!(*logs[0].borrow(), filtered);
}
assert_eq!(
*logs[1].borrow(),
vec!["call", "after", "result", "arg", "tail"]
);
}
#[test]
fn expression_once_and_state() {
let mut calls = 0;
let v = ({
fn modify(v: bool) -> bool {
!v
}
modify
})({
calls += 1;
3 > 2
});
assert!(!v);
assert_eq!(calls, 1);
struct Owner {
value: i32,
state: Vec<i32>,
}
fn new() -> Owner {
Owner {
value: 1,
state: Default::default(),
}
}
let mut a = new();
let b = new();
a.state.push(a.value);
assert!(b.state.is_empty());
}
+5
View File
@@ -22,4 +22,9 @@ for fixture, command, expected in [
assert expected in run.stderr, run.stderr
results.append({"fixture": fixture, "seconds": round(time.monotonic() - start, 3),
"expected_failure": expected is not None})
start = time.monotonic()
spike = root / "target" / "effects-feasibility"
subprocess.run(["rustc", "--edition=2024", "--test", str(root / "tools" / "effects-feasibility.rs"), "-o", str(spike)], check=True, capture_output=True, text=True)
subprocess.run([str(spike)], check=True, capture_output=True, text=True)
results.append({"fixture": "effects-feasibility", "seconds": round(time.monotonic() - start, 3), "expected_failure": False})
print(json.dumps(results, indent=2))