Share mod-owned generation and move dispatch tests to its crate

Delegate launcher trait, event and helper generation to horizon-mod-api-gen. Keep the old-source compatibility path, move dispatch fixtures out of xtask into an unpublished workspace member, and select launcher checks when shared generator code changes.
This commit is contained in:
arismoko committed 2026-11-03 02:31:27 +00:00
1 parent 8f6fe50c47
commit 616e855940
16 files changed
+245 -255

No files matched your search

Generated
+8 -1
View File
@@ -1585,6 +1585,14 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "horizon-mod-api-gen-tests"
version = "0.1.0"
dependencies = [
"horizon-mod-api-gen",
"serde_json",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
@@ -5165,7 +5173,6 @@ version = "0.1.0"
dependencies = [
"horizon-mod-api-gen",
"horizon-theme",
"serde_json",
]
[[package]]
+1
View File
@@ -32,6 +32,7 @@ members = [
"crates/horizon-relay",
"crates/horizon-mods",
"crates/horizon-mod-api-gen",
"crates/horizon-mod-api-gen/dispatch-tests",
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
@@ -0,0 +1,16 @@
[package]
name = "horizon-mod-api-gen-tests"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
description = "Generated mod API dispatch contract tests"
[dependencies]
serde_json = "1"
[build-dependencies]
horizon-mod-api-gen = { path = ".." }
[lints]
workspace = true
@@ -1,5 +1,6 @@
fn main() {
let root = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("..");
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../../..");
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate_test_hooks(&root, &out_dir)
.expect("generating dispatch test fixture");
@@ -1,3 +1,4 @@
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use std::time::Duration;
+190
View File
@@ -0,0 +1,190 @@
//! Generation of a mod's own trait methods, events and dispatch helpers.
use super::EventLifetimes;
use quote::{format_ident, quote};
use syn::visit_mut::VisitMut;
/// A trait declaration supplied to the staged game's build script after horizon-mixins has run.
pub struct TraitInjection {
name: syn::Ident,
/// The bindings' struct, as each catalog hook's (`InjectExampleTickEvent`).
event: syn::Ident,
parameters: Vec<(syn::Ident, syn::Type)>,
contract: TraitContract,
}
/// What a trait method may do with its event.
pub enum TraitContract {
/// HEAD: observe and change `&mut` bindings.
Listen,
/// Cancellable HEAD on a unit target, where `Flow::Cancel` returns early.
Cancel,
/// RETURN, MODIFY_ARG or MODIFY_EXPRESSION_VALUE: the ident names the `$value` binding,
/// which is moved into the event and returned after the call.
Value(syn::Ident),
}
struct EventFields {
lifetimes: EventLifetimes,
types: Vec<syn::Type>,
scalars: Vec<bool>,
}
impl TraitInjection {
/// Prepare a mod-owned trait method from parsed bindings and its return contract.
pub fn new(
name: syn::Ident,
parameters: Vec<(syn::Ident, syn::Type)>,
contract: TraitContract,
) -> Self {
let event = format_ident!(
"{}Event",
name.to_string()
.split('_')
.map(|word| {
let mut chars = word.chars();
chars.next().map_or(String::new(), |first| {
first.to_uppercase().chain(chars).collect()
})
})
.collect::<String>()
);
Self {
name,
event,
parameters,
contract,
}
}
/// The declarations supplied to the staged game's build script.
pub fn input(&self) -> serde_json::Value {
serde_json::json!({
"name": self.name.to_string(),
"event": self.event.to_string(),
"declaration": self.declaration(),
"event_declaration": self.event(),
})
}
/// Dispatch only to this injection’s owning mod, preserving its write-back contract.
pub fn helper(&self, owner: usize) -> String {
let name = &self.name;
let event = &self.event;
let params = self.parameters.iter().map(|(n, t)| quote!(#n: #t));
let scalars = self.fields().scalars;
let inits = self
.parameters
.iter()
.zip(&scalars)
.map(
|((n, _), scalar)| {
if *scalar { quote!(#n: *#n) } else { quote!(#n) }
},
);
let write_back = self
.parameters
.iter()
.zip(&scalars)
.filter(|(_, scalar)| **scalar)
.map(|((n, _), _)| quote!(*#n = event.#n;));
let call = quote! {
let mut event = crate::mod_api::#event { #(#inits),* };
let _flow = owner.#name(&mut event);
#(#write_back)*
};
// Before the mods are installed (an injection into code `main_with` runs first), or
// with fewer mods than the build had, the injection does nothing or passes the value on.
let (output, result, absent) = match &self.contract {
TraitContract::Listen => (quote!(), call, quote!()),
TraitContract::Cancel => (
quote!(-> core::ops::ControlFlow<()>),
quote! {
#call
if _flow == crate::mod_api::Flow::Cancel {
return core::ops::ControlFlow::Break(());
}
core::ops::ControlFlow::Continue(())
},
quote!(core::ops::ControlFlow::Continue(())),
),
TraitContract::Value(value) => {
let ty = self
.parameters
.iter()
.find(|(n, _)| n == value)
.map(|(_, t)| t);
(quote!(-> #ty), quote!(#call event.#value), quote!(#value))
}
};
quote! {
fn horizon_trait_dispatch(#(#params),*) #output {
let Some(owner) = crate::mod_api::mods().get(#owner) else {
return #absent;
};
#result
}
}
.to_string()
}
fn declaration(&self) -> String {
let name = &self.name;
let event = &self.event;
let (output, default) = match self.contract {
TraitContract::Cancel => (quote!(-> Flow), quote!(Flow::Continue)),
TraitContract::Listen | TraitContract::Value(_) => (quote!(), quote!()),
};
let lifetimes = self.fields().lifetimes;
let elided = match (lifetimes.a, lifetimes.b) {
(_, true) => quote!(<'_, '_>),
(true, false) => quote!(<'_>),
(false, false) => quote!(),
};
quote!(fn #name(&self, _: &mut #event #elided) #output { #default }).to_string()
}
/// Which lifetimes the event takes, its fields' types and which of them are scalars.
fn fields(&self) -> EventFields {
let mut lifetimes = EventLifetimes::default();
let (types, scalars) = self
.parameters
.iter()
.map(|(name, ty)| match &self.contract {
TraitContract::Value(value) if name == value => {
let mut field = ty.clone();
lifetimes.visit_type_mut(&mut field);
(field, false)
}
_ => lifetimes.field(ty),
})
.unzip();
EventFields {
lifetimes,
types,
scalars,
}
}
fn event(&self) -> String {
let event = &self.event;
let name = self.name.to_string();
let fields = self.fields();
let lifetimes = fields.lifetimes;
let types = fields.types;
let generics = match (lifetimes.a, lifetimes.b) {
(_, true) => quote!(<'a, 'b>),
(true, false) => quote!(<'a>),
(false, false) => quote!(),
};
let names = self.parameters.iter().map(|(n, _)| n);
let doc = format!(
" What [`Hooks::{name}`] is told. Fields may be added: the struct is `#[non_exhaustive]`."
);
quote! {
#[doc = #doc]
#[non_exhaustive]
pub struct #event #generics { #(pub #names: #types),* }
}
.to_string()
}
}
+2
View File
@@ -1146,6 +1146,8 @@ enum FillMode {
mod build;
pub use build::{generate, inject};
mod injection;
pub use injection::{TraitContract, TraitInjection};
#[cfg(test)]
mod injection_tests;
+7
View File
@@ -2837,6 +2837,13 @@ ordinary mixin helpers in other crates continue to work as before. The generated
ordinary helper of that mod: priority descending, mod id then injection id. It is not automatically
ordered after other mixins. Catalog hooks retain the mixin-before-trait order below.
The launcher and catalog build scripts share `horizon-mod-api-gen` for trait, event and helper generation.
For game sources with that build dependency, the launcher stages declarations in
`.horizon-mod-api-injections.json`; Cargo consumes them through `HORIZON_MOD_API_INJECTIONS` without
editing generated Rust in the source tree. The declarations stay with cached mixin output. Older released
sources without that build dependency still receive the same declarations in their committed generated
trait. Neither path requires rustfmt during a player build.
A linked trait mod implements `horizon_client::mod_api::Hooks`. Mixin injections run first.
An injection that returns early skips trait mods. A claimed cancellation also skips trait mods.
Otherwise, trait mods run in their supplied order. The first `Cancel` stops dispatch.
-1
View File
@@ -2700,7 +2700,6 @@ dependencies = [
"oo7",
"proc-macro2",
"pulldown-cmark",
"quote",
"raw-window-handle",
"security-framework 3.7.0",
"semver",
+2 -3
View File
@@ -17,9 +17,8 @@ gpui_platform = { package = "gpui-fast-platform", version = "0.1.13", features =
ureq = { version = "2", features = ["json", "tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Validate and render injected trait signatures before they reach rustc.
syn = { version = "2", features = ["full", "visit-mut"] }
quote = "1"
# Validate trait injection bindings before passing them to the shared generator.
syn = { version = "2", features = ["full"] }
# Checks the client jar and the version file found in other launchers' folders (sources.rs).
sha1 = "0.10"
# The mod browser checks Modrinth's SHA-512 before a pack reaches the game folder (browser/download.rs).
+3 -240
View File
@@ -1,100 +1,11 @@
//! Trait-backed injections, generated in the incoming copy and staged game.
//! Trait-backed injections, prepared as build-script input alongside the staged game.
use super::{api, Bundle, MANIFEST};
use quote::{format_ident, quote};
use horizon_mod_api_gen::{TraitContract as Contract, TraitInjection as Injection};
use std::{
collections::{HashMap, HashSet},
fs,
path::Path,
};
use syn::visit_mut::VisitMut;
/// A trait declaration to add to the staged game after horizon-mixins has run.
pub(super) struct Injection {
name: syn::Ident,
/// The bindings' struct, as each catalog hook's (`InjectExampleTickEvent`).
event: syn::Ident,
parameters: Vec<(syn::Ident, syn::Type)>,
contract: Contract,
}
/// What a trait method may do with its event.
enum Contract {
/// HEAD: observe and change `&mut` bindings.
Listen,
/// Cancellable HEAD on a unit target, where `Flow::Cancel` returns early.
Cancel,
/// RETURN, MODIFY_ARG or MODIFY_EXPRESSION_VALUE: the ident names the `$value` binding,
/// which is moved into the event and returned after the call.
Value(syn::Ident),
}
/// Turns a binding's type into its event field's: a `&mut` primitive scalar becomes the value,
/// written back after the call; every elided reference lifetime becomes `'a`, or `'b` inside a
/// `&mut`, which can't shorten it. A copy of xtask's rule for catalog hooks
/// (`horizon-mod-api-gen/src/lib.rs`, `EventLifetimes`); both test the same types.
#[derive(Default)]
struct EventLifetimes {
a: bool,
b: bool,
in_mut: bool,
}
impl EventLifetimes {
/// The field's type, and whether it is a scalar copied in and written back.
fn field(&mut self, ty: &syn::Type) -> (syn::Type, bool) {
let scalar = match ty {
syn::Type::Reference(reference) if reference.mutability.is_some() => {
match &*reference.elem {
syn::Type::Path(path)
if path.qself.is_none()
&& path.path.get_ident().is_some_and(|ident| {
[
"bool", "char", "i8", "i16", "i32", "i64", "i128", "isize",
"u8", "u16", "u32", "u64", "u128", "usize", "f32", "f64",
]
.iter()
.any(|scalar| ident == scalar)
}) =>
{
Some(&*reference.elem)
}
_ => None,
}
}
_ => None,
};
if let Some(scalar) = scalar {
return (scalar.clone(), true);
}
let mut field = ty.clone();
self.visit_type_mut(&mut field);
(field, false)
}
}
impl VisitMut for EventLifetimes {
fn visit_type_reference_mut(&mut self, reference: &mut syn::TypeReference) {
if reference.lifetime.is_none() {
reference.lifetime = Some(syn::parse_quote!('_));
}
let outer = self.in_mut;
self.visit_lifetime_mut(reference.lifetime.as_mut().unwrap());
self.in_mut |= reference.mutability.is_some();
self.visit_type_mut(&mut reference.elem);
self.in_mut = outer;
}
fn visit_lifetime_mut(&mut self, lifetime: &mut syn::Lifetime) {
if lifetime.ident == "_" {
*lifetime = if self.in_mut {
self.b = true;
syn::parse_quote!('b)
} else {
self.a = true;
syn::parse_quote!('a)
};
}
}
}
/// Replace trait injections with ordinary mixin helpers, leaving ordinary injections unchanged.
pub(super) fn prepare(mods: &[Bundle], source: &Path) -> Result<Vec<Injection>, String> {
@@ -223,23 +134,7 @@ pub(super) fn prepare(mods: &[Bundle], source: &Path) -> Result<Vec<Injection>,
));
}
};
let event = format_ident!(
"{}Event",
name.split('_')
.map(|word| {
let mut chars = word.chars();
chars.next().map_or(String::new(), |first| {
first.to_uppercase().chain(chars).collect()
})
})
.collect::<String>()
);
let injection = Injection {
name: ident,
event,
parameters,
contract,
};
let injection = Injection::new(ident, parameters, contract);
let helper_path = format!("horizon-trait-injection-{index}.rs");
let helper_file = bundle.dir.join(&helper_path);
if helper_file.exists() {
@@ -263,138 +158,6 @@ pub(super) fn prepare(mods: &[Bundle], source: &Path) -> Result<Vec<Injection>,
Ok(result)
}
impl Injection {
fn helper(&self, owner: usize) -> String {
let name = &self.name;
let event = &self.event;
let params = self.parameters.iter().map(|(n, t)| quote!(#n: #t));
let scalars = self.lifetimes().2;
let inits = self
.parameters
.iter()
.zip(&scalars)
.map(
|((n, _), scalar)| {
if *scalar {
quote!(#n: *#n)
} else {
quote!(#n)
}
},
);
let write_back = self
.parameters
.iter()
.zip(&scalars)
.filter(|(_, scalar)| **scalar)
.map(|((n, _), _)| quote!(*#n = event.#n;));
let call = quote! {
let mut event = crate::mod_api::#event { #(#inits),* };
let _flow = owner.#name(&mut event);
#(#write_back)*
};
// Before the mods are installed (an injection into code `main_with` runs first), or
// with fewer mods than the build had, the injection does nothing or passes the value on.
let (output, result, absent) = match &self.contract {
Contract::Listen => (quote!(), call, quote!()),
Contract::Cancel => (
quote!(-> core::ops::ControlFlow<()>),
quote! {
#call
if _flow == crate::mod_api::Flow::Cancel {
return core::ops::ControlFlow::Break(());
}
core::ops::ControlFlow::Continue(())
},
quote!(core::ops::ControlFlow::Continue(())),
),
Contract::Value(value) => {
let ty = self
.parameters
.iter()
.find(|(n, _)| n == value)
.map(|(_, t)| t);
(quote!(-> #ty), quote!(#call event.#value), quote!(#value))
}
};
quote! {
fn horizon_trait_dispatch(#(#params),*) #output {
let Some(owner) = crate::mod_api::mods().get(#owner) else {
return #absent;
};
#result
}
}
.to_string()
}
fn declaration(&self) -> String {
let name = &self.name;
let event = &self.event;
let (output, default) = match self.contract {
Contract::Cancel => (quote!(-> Flow), quote!(Flow::Continue)),
Contract::Listen | Contract::Value(_) => (quote!(), quote!()),
};
let elided = match self.lifetimes().0 {
(_, true) => quote!(<'_, '_>),
(true, false) => quote!(<'_>),
(false, false) => quote!(),
};
quote!(fn #name(&self, _: &mut #event #elided) #output { #default }).to_string()
}
/// Which lifetimes the event takes, its fields' types and which of them are scalars.
fn lifetimes(&self) -> ((bool, bool), Vec<syn::Type>, Vec<bool>) {
let mut lifetimes = EventLifetimes::default();
let (types, scalars) = self
.parameters
.iter()
.map(|(name, ty)| match &self.contract {
Contract::Value(value) if name == value => {
let mut field = ty.clone();
lifetimes.visit_type_mut(&mut field);
(field, false)
}
_ => lifetimes.field(ty),
})
.unzip();
((lifetimes.a, lifetimes.b), types, scalars)
}
fn event(&self) -> String {
let event = &self.event;
let name = self.name.to_string();
let (lifetimes, types, _) = self.lifetimes();
let generics = match lifetimes {
(_, true) => quote!(<'a, 'b>),
(true, false) => quote!(<'a>),
(false, false) => quote!(),
};
let names = self.parameters.iter().map(|(n, _)| n);
let doc = format!(
" What [`Hooks::{name}`] is told. Fields may be added: the struct is `#[non_exhaustive]`."
);
quote! {
#[doc = #doc]
#[non_exhaustive]
pub struct #event #generics { #(pub #names: #types),* }
}
.to_string()
}
}
/// Add declarations to the existing generated trait, not its dispatch implementations.
impl Injection {
fn input(&self) -> serde_json::Value {
serde_json::json!({
"name": self.name.to_string(),
"event": self.event.to_string(),
"declaration": self.declaration(),
"event_declaration": self.event(),
})
}
}
pub(super) const INPUT: &str = ".horizon-mod-api-injections.json";
pub(super) const ENV: &str = "HORIZON_MOD_API_INJECTIONS";
+1 -1
View File
@@ -27,7 +27,7 @@ class RustChangesTests(unittest.TestCase):
with self.subTest(path=path):
self.assertFalse(needs_launcher([path]))
for path in ["launcher/src/main.rs", "launcher/Cargo.lock", "crates/horizon-port-pack/src/net.rs",
"crates/horizon-mcp/src/lib.rs",
"crates/horizon-mcp/src/lib.rs", "crates/horizon-mod-api-gen/src/injection.rs",
"crates/horizon-theme/src/lib.rs", "Cargo.toml", "Cargo.lock",
".github/workflows/ci.yml"]:
with self.subTest(path=path):
+11
View File
@@ -63,6 +63,7 @@ class PrReadyTests(unittest.TestCase):
self.assertTrue(pr_ready.launcher_rust("crates/horizon-prepare/src/lib.rs"))
self.assertTrue(pr_ready.launcher_rust("crates/horizon-theme/src/lib.rs"))
self.assertTrue(pr_ready.launcher_rust("crates/horizon-telemetry/src/lib.rs"))
self.assertTrue(pr_ready.launcher_rust("crates/horizon-mod-api-gen/src/injection.rs"))
self.assertFalse(pr_ready.launcher_rust("launcher/links.json"))
self.assertFalse(pr_ready.launcher_rust("launcher/README.md"))
self.assertFalse(pr_ready.launcher_rust("crates/horizon-client/src/game.rs"))
@@ -122,6 +123,16 @@ class PrReadyTests(unittest.TestCase):
self.assertEqual(tests.command, "cargo test --locked -p horizon-client -p horizon-client-render "
"-p horizon-mod-api-gen --tests")
def test_shared_generator_edits_run_launcher_checks(self):
with mock.patch.object(pr_ready, "affected_here", return_value=[]):
steps = pr_ready.plan(args(quick=True), "origin/main",
{"crates/horizon-mod-api-gen/src/injection.rs"},
{**PACKAGES, "horizon-mod-api-gen": "crates/horizon-mod-api-gen"},
LIBRARIES | {"horizon-mod-api-gen"}, DEPENDENCIES)
by_name = {step.name: step for step in steps}
for name in ("launcher rustfmt", "launcher clippy", "launcher tests"):
self.assertIsNone(by_name[name].skip)
@unittest.skipUnless(POSIX_BASH, "needs a bash that is not WSL's")
def test_the_launcher_fallback_asks_for_a_feature_the_launcher_has(self):
"""The macOS fallback must name gpui_platform/runtime_shaders: gpui_platform is launcher/Cargo.toml's
+1
View File
@@ -71,6 +71,7 @@ LAUNCHER_PATHS = [
"crates/horizon-bench/",
"crates/horizon-friends/",
"crates/horizon-mcp/",
"crates/horizon-mod-api-gen/",
"crates/horizon-mods/",
"crates/horizon-port-pack/",
"crates/horizon-prepare/",
-4
View File
@@ -8,11 +8,7 @@ description = "Repository tasks: cargo run -p xtask -- package BINARY OUTDIR | t
[dependencies]
horizon-mod-api-gen = { path = "../crates/horizon-mod-api-gen" }
serde_json = "1"
horizon-theme = { path = "../crates/horizon-theme" }
[build-dependencies]
horizon-mod-api-gen = { path = "../crates/horizon-mod-api-gen" }
[lints]
workspace = true
-4
View File
@@ -7,7 +7,3 @@ pub fn run() -> Result<(), String> {
.ok_or("run xtask from within the Horizon checkout")?;
horizon_mod_api_gen::fill_hooks(root)
}
#[cfg(test)]
#[path = "mod_api_tests.rs"]
mod dispatch_tests;