mirror of
https://github.com/horizon-rust/horizon-mixins.git
synced 2026-11-07 10:05:58 -10:00
Add expression effects, interior cancellation and persistent instance state
This commit is contained in:
1 parent
76958abc43
commit
0500ff24aa
60 files changed
+3357
-121
No files matched your search
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,3 +12,5 @@ pub use diagnostic::{Diagnostic, Result};
|
||||
pub use plan::{Plan, PlannedInjection, build_plan};
|
||||
|
||||
pub mod modules;
|
||||
|
||||
pub mod state;
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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
@@ -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.
|
||||
@@ -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; }
|
||||
@@ -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(()) } }
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
fn after(value:&mut i32,trace:&Trace) { mark(trace,"after-number"); *value+=4; }
|
||||
@@ -0,0 +1 @@
|
||||
fn after(text:&mut String) { text.push_str("!"); }
|
||||
@@ -0,0 +1 @@
|
||||
fn result(value:i32)->i32 {value+1}
|
||||
@@ -0,0 +1 @@
|
||||
fn tick(entity:&mut Entity) { let health=entity.health; entity.__horizon_mixins_alpha_history_mut().push(health); }
|
||||
@@ -0,0 +1 @@
|
||||
fn after(trace:&Trace) { mark(trace,"after-beta"); }
|
||||
@@ -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
|
||||
Generated
+114
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members=["game","driver"]
|
||||
resolver="2"
|
||||
@@ -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).
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name="effects-driver"
|
||||
version="0.1.0"
|
||||
edition="2024"
|
||||
[dependencies]
|
||||
effects-game={path="../game"}
|
||||
serde_json="1"
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name="effects-game"
|
||||
version="0.1.0"
|
||||
edition="2024"
|
||||
[dependencies]
|
||||
serde={version="1", features=["derive"]}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.95.0"
|
||||
profile = "minimal"
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user