use std::collections::{HashMap, HashSet};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::Command;
fn main() {
let args: Vec<String> = std::env::args().collect();
let result = match args.get(1).map(String::as_str) {
Some("build") => build().map(|_| ()),
Some("new") => new_mod(args.get(2).map(String::as_str)),
Some("setup") => setup(),
Some("add") => add_dep(args.get(2).map(String::as_str)),
Some("remove") => remove_dep(args.get(2).map(String::as_str)),
Some("run") => match args.get(2) {
Some(name) => run_config(name),
None => {
Err("usage: yog run <config_name> (see [run.<config_name>] in yog.toml)".into())
}
},
Some("publish") => {
let dry_run = args.iter().any(|a| a == "--dry-run");
match args.get(2).map(String::as_str) {
Some("exports") => publish_exports(dry_run),
Some(other) => Err(format!("unknown publish subcommand: {other}")),
None => Err("usage: yog publish exports [--dry-run]".into()),
}
}
Some("-h") | Some("--help") | Some("help") | None => {
print_usage();
return;
}
Some(other) => Err(format!("unknown command: {other}")),
};
if let Err(e) = result {
eprintln!("yog: error: {e}");
std::process::exit(1);
}
}
fn print_usage() {
println!(
"yog — Yog mod build tool\n\n\
Usage: yog <command> [args]\n\n\
Commands:\n\
\x20 new <name> Create a new mod project in ./<name>/\n\
\x20 build Compile the current mod and package it as artifacts/<id>.yog\n\
\x20 setup Check and install build dependencies (Rust, Zig, targets)\n\
\x20 add <crate> Add a Rust dependency to yog.toml\n\
\x20 remove <crate> Remove a dependency from yog.toml\n\
\x20 run <config> Build, export, and launch a [run.<config>] dev instance\n\
\x20 help Show this message\n\n\
Mod projects use yog.toml instead of Cargo.toml.\n\
Cross-compilation requires cargo-zigbuild + zig (yog setup installs them)."
);
}
#[derive(Debug)]
struct YogToml {
id: String,
name: String,
version: String,
description: String,
authors: Vec<String>,
license: String,
exports_license: Option<String>,
edition: Option<String>,
yog_api_path: Option<String>,
yog_api_version: Option<String>,
dependencies: Vec<(String, String)>,
run_configs: Vec<RunConfig>,
}
#[derive(Debug, Default, Clone)]
struct RunConfig {
name: String,
export_dir: Option<String>,
command: Option<String>,
args: Vec<String>,
cwd: Option<String>,
env: Vec<(String, String)>,
}
impl YogToml {
fn read(path: &Path) -> Result<Self, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("reading {}: {e}", path.display()))?;
parse_yog_toml(&text)
}
fn api_dep(&self) -> String {
if let Ok(p) = std::env::var("YOG_API_PATH") {
let abs = PathBuf::from(&p)
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(&p));
return format!("yog-api = {{ path = {:?} }}", abs.to_string_lossy());
}
if let Some(p) = &self.yog_api_path {
return format!("yog-api = {{ path = {p:?} }}");
}
let version = {
let env_version = std::env::var("YOG_API_VERSION").ok();
self.yog_api_version
.as_deref()
.or(env_version.as_deref())
.map(|v| v.to_owned())
.unwrap_or_else(|| latest_yog_api_version())
};
format!("yog-api = {:?}", version)
}
}
fn parse_yog_toml(text: &str) -> Result<YogToml, String> {
let mut section = "";
let mut id = None::<String>;
let mut name = None::<String>;
let mut version = None::<String>;
let mut description = None::<String>;
let mut authors: Vec<String> = Vec::new();
let mut license = None::<String>;
let mut exports_license = None::<String>;
let mut edition = None::<String>;
let mut yog_api_path = None::<String>;
let mut dependencies: Vec<(String, String)> = Vec::new();
let mut run_configs: HashMap<String, RunConfig> = HashMap::new();
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('[') {
section = line.trim_matches(|c| c == '[' || c == ']');
continue;
}
match section {
"mod" | "package" => {
if let Some(v) = field(line, "id") {
id = Some(v);
}
if let Some(v) = field(line, "name") {
name = Some(v);
}
if let Some(v) = field(line, "version") {
version = Some(v);
}
if let Some(v) = field(line, "description") {
description = Some(v);
}
if let Some(v) = field(line, "license") {
license = Some(v);
}
if let Some(v) = field(line, "exports_license") {
exports_license = Some(v);
}
if let Some(v) = field(line, "edition") {
edition = Some(v);
}
if line.trim_start().starts_with("authors") {
authors = parse_string_array(line);
}
}
"dev" => {
if let Some(v) = field(line, "yog_api_path") {
yog_api_path = Some(v);
}
}
"dependencies" => {
if let Some((name, spec)) = parse_dep_line(line) {
dependencies.push((name, spec));
}
}
_ if section.starts_with("run.") => {
let run_name = §ion["run.".len()..];
let cfg = run_configs
.entry(run_name.to_string())
.or_insert_with(|| RunConfig {
name: run_name.to_string(),
..Default::default()
});
if let Some(v) = field(line, "export_dir") {
cfg.export_dir = Some(v);
}
if let Some(v) = field(line, "command") {
cfg.command = Some(v);
}
if let Some(v) = field(line, "cwd") {
cfg.cwd = Some(v);
}
if line.trim_start().starts_with("args") {
cfg.args = parse_string_array(line);
}
if line.trim_start().starts_with("env") {
cfg.env = parse_string_array(line)
.into_iter()
.filter_map(|kv| {
kv.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
})
.collect();
}
}
_ => {}
}
}
let run_configs: Vec<RunConfig> = run_configs.into_values().collect();
let id = id.ok_or("yog.toml: missing [mod] id")?;
let mut yog_api_version = None;
let mut filtered_deps = Vec::new();
for (name, spec) in dependencies {
if name == "yog_api_version" {
yog_api_version = Some(spec.trim_matches('"').to_string());
} else if name == "yog-api" {
yog_api_version = Some(spec.trim_matches('"').to_string());
} else if name == "yog_api" {
yog_api_version = Some(spec.trim_matches('"').to_string());
} else {
filtered_deps.push((name, spec));
}
}
Ok(YogToml {
name: name.unwrap_or_else(|| id.clone()),
version: version.unwrap_or_else(|| "0.1.0".into()),
description: description.unwrap_or_default(),
authors,
license: license.unwrap_or_else(|| "MIT OR Apache-2.0".into()),
exports_license,
edition,
yog_api_path,
yog_api_version,
dependencies: filtered_deps,
run_configs,
id,
})
}
fn parse_dep_line(line: &str) -> Option<(String, String)> {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.is_empty() || trimmed.starts_with('[') {
return None;
}
let eq_pos = trimmed.find('=')?;
let name = trimmed[..eq_pos].trim().to_string();
let spec = trimmed[eq_pos + 1..].trim().to_string();
Some((name, spec))
}
fn field(line: &str, key: &str) -> Option<String> {
let rest = line.strip_prefix(key)?.trim_start();
let rest = rest.strip_prefix('=')?.trim();
Some(rest.trim_matches('"').to_string())
}
fn parse_string_array(line: &str) -> Vec<String> {
let inner = line
.find('[')
.and_then(|s| line.rfind(']').map(|e| &line[s + 1..e]));
inner
.unwrap_or("")
.split(',')
.map(|s| s.trim().trim_matches('"').to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn new_mod(name: Option<&str>) -> Result<(), String> {
let name = name.ok_or("usage: yog new <name>")?;
if name.is_empty() || name.contains('/') || name.contains('\\') {
return Err(format!("invalid mod name: {name:?}"));
}
let root = PathBuf::from(name);
if root.exists() {
return Err(format!("directory {name} already exists"));
}
std::fs::create_dir_all(root.join("src")).map_err(|e| e.to_string())?;
let yog_toml = format!(
r#"[mod]
id = "{name}"
name = "{display}"
version = "0.1.0"
description = "A Yog mod."
authors = ["Your Name"]
license = "MIT OR Apache-2.0"
# Uncomment for local/monorepo development:
# [dev]
# yog_api_path = "../path/to/yog-api"
# Uncomment to enable `yog run <name>` — builds, drops the .yog into
# export_dir, then launches `command` with `args`/`cwd`/`env`. Add as many
# [run.<name>] sections as you like (e.g. one per loader/version).
# [run.dev]
# export_dir = "../my-test-instance/yog-mods"
# command = "java"
# args = ["-jar", "server.jar", "--nogui"]
# cwd = "../my-test-instance"
# env = ["JAVA_OPTS=-Xmx4G"]
"#,
name = name,
display = to_display_name(name),
);
write_file(&root.join("yog.toml"), yog_toml.as_bytes())?;
let lib_rs = format!(
r#"use yog_api::{{Mod, Registry}};
pub struct {struct_name};
impl Mod for {struct_name} {{
fn register(registry: &mut Registry) {{
registry.on_server_started(|srv| {{
srv.broadcast("{name} loaded!");
}});
}}
}}
yog_api::export_mod!({struct_name});
"#,
name = name,
struct_name = to_struct_name(name),
);
write_file(&root.join("src/lib.rs"), lib_rs.as_bytes())?;
write_file(
&root.join(".gitignore"),
b".yog-build/\ntarget/\nartifacts/\n",
)?;
eprintln!("==> created {name}/");
eprintln!(" yog.toml ← edit mod metadata here");
eprintln!(" src/lib.rs ← write your mod here");
eprintln!("");
eprintln!("Next: cd {name} && yog build");
Ok(())
}
fn to_display_name(id: &str) -> String {
id.replace('-', " ")
.split_whitespace()
.map(|w| {
let mut c = w.chars();
c.next()
.map(|f| f.to_uppercase().to_string())
.unwrap_or_default()
+ c.as_str()
})
.collect::<Vec<_>>()
.join(" ")
}
fn to_struct_name(id: &str) -> String {
id.split(|c: char| c == '-' || c == '_')
.map(|w| {
let mut c = w.chars();
c.next()
.map(|f| f.to_uppercase().to_string())
.unwrap_or_default()
+ c.as_str()
})
.collect()
}
fn to_snake_name(id: &str) -> String {
let mut out = String::with_capacity(id.len());
for c in id.chars() {
if c == '-' || c == '_' {
if out.chars().last() != Some('_') {
out.push('_');
}
} else if c.is_uppercase() {
if !out.is_empty() && out.chars().last() != Some('_') {
out.push('_');
}
out.extend(c.to_lowercase());
} else {
out.push(c);
}
}
out
}
struct Target {
triple: &'static str,
tag: &'static str,
os: &'static str,
}
const TARGETS: &[Target] = &[
Target {
triple: "x86_64-unknown-linux-gnu",
tag: "linux-x86_64",
os: "linux",
},
Target {
triple: "aarch64-unknown-linux-gnu",
tag: "linux-aarch64",
os: "linux",
},
Target {
triple: "x86_64-pc-windows-gnu",
tag: "windows-x86_64",
os: "windows",
},
Target {
triple: "x86_64-apple-darwin",
tag: "macos-x86_64",
os: "macos",
},
Target {
triple: "aarch64-apple-darwin",
tag: "macos-aarch64",
os: "macos",
},
];
fn build() -> Result<(YogToml, PathBuf), String> {
let root = std::env::current_dir().map_err(|e| e.to_string())?;
let yog_toml_path = root.join("yog.toml");
if !yog_toml_path.exists() {
return Err("no yog.toml found in the current directory".into());
}
let root_cargo = root.join("Cargo.toml");
if root_cargo.exists() {
return Err("Cargo.toml found in mod root — this is forbidden. Use yog.toml only. Remove Cargo.toml and retry.".into());
}
let mut meta = YogToml::read(&yog_toml_path)?;
if let Some(rel) = &meta.yog_api_path {
let abs = root
.join(rel)
.canonicalize()
.unwrap_or_else(|_| root.join(rel));
meta.yog_api_path = Some(abs.to_string_lossy().into_owned());
}
let build_dir = root.join(".yog-build");
std::fs::create_dir_all(&build_dir).map_err(|e| e.to_string())?;
let cargo_toml = generate_cargo_toml(&meta);
write_file(&build_dir.join("Cargo.toml"), cargo_toml.as_bytes())?;
let yog_lock = root.join("yog.lock");
let cargo_lock = build_dir.join("Cargo.lock");
if yog_lock.exists() && !cargo_lock.exists() {
std::fs::copy(&yog_lock, &cargo_lock).map_err(|e| e.to_string())?;
}
let builder = Builder::detect();
eprintln!(
"==> building {} {} with `cargo {}`",
meta.id,
meta.version,
builder.subcmd()
);
let installed = installed_targets();
let mut bundled: Vec<(String, PathBuf)> = Vec::new();
for t in TARGETS {
if !installed.iter().any(|s| s == t.triple) {
eprintln!(
" skip {} (rustup target not installed; run: yog setup)",
t.tag
);
continue;
}
match builder.build(&build_dir, t.triple, &root) {
Ok(()) => {
let lib = lib_filename(&meta.id, t.os);
let path = root
.join("target")
.join(t.triple)
.join("release")
.join(&lib);
if path.exists() {
eprintln!(" built {}", t.tag);
bundled.push((t.tag.to_string(), path));
} else {
eprintln!(" skip {} (built but output not found: {})", t.tag, lib);
}
}
Err(_) => eprintln!(" skip {} (build failed)", t.tag),
}
}
if bundled.is_empty() {
return Err(
"no platform built — run `yog setup` to install cross-compilation tools".into(),
);
}
let new_lock = build_dir.join("Cargo.lock");
if new_lock.exists() {
std::fs::copy(&new_lock, &yog_lock).map_err(|e| e.to_string())?;
}
let assets = gather_assets(&root);
if !assets.is_empty() {
eprintln!(" bundled {} asset file(s)", assets.len());
}
let artifacts = root.join("artifacts");
std::fs::create_dir_all(&artifacts).map_err(|e| e.to_string())?;
let out = artifacts.join(format!("{}.yog", meta.id));
package(&out, &meta, &bundled, &assets)?;
let tags: Vec<&str> = bundled.iter().map(|(t, _)| t.as_str()).collect();
eprintln!("==> packaged {} [{}]", out.display(), tags.join(", "));
Ok((meta, out))
}
fn run_config(config_name: &str) -> Result<(), String> {
let root = std::env::current_dir().map_err(|e| e.to_string())?;
let yog_toml_path = root.join("yog.toml");
if !yog_toml_path.exists() {
return Err("no yog.toml found in the current directory".into());
}
let meta = YogToml::read(&yog_toml_path)?;
let cfg = meta.run_configs.iter().find(|c| c.name == config_name)
.ok_or_else(|| {
if meta.run_configs.is_empty() {
format!("no [run.{config_name}] section in yog.toml, and no [run.*] sections are defined at all.\n\
Add one, e.g.:\n\n[run.{config_name}]\nexport_dir = \"../my-instance/yog-mods\"\ncommand = \"java\"\nargs = [\"-jar\", \"server.jar\", \"--nogui\"]\ncwd = \"../my-instance\"")
} else {
let available: Vec<&str> = meta.run_configs.iter().map(|c| c.name.as_str()).collect();
format!("no [run.{config_name}] section in yog.toml. Available: {}", available.join(", "))
}
})?
.clone();
let (meta, artifact) = build()?;
if let Some(export_dir) = &cfg.export_dir {
let dir = resolve(&root, export_dir);
std::fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
let dest = dir.join(format!("{}.yog", meta.id));
std::fs::copy(&artifact, &dest)
.map_err(|e| format!("copying to {}: {e}", dest.display()))?;
eprintln!("==> exported {} -> {}", meta.id, dest.display());
}
let Some(command) = &cfg.command else {
eprintln!("==> [run.{config_name}] has no `command` set — export-only, nothing to launch.");
return Ok(());
};
eprintln!(
"==> launching [run.{config_name}]: {command} {}",
cfg.args.join(" ")
);
let mut proc = Command::new(command);
proc.args(&cfg.args);
if let Some(cwd) = &cfg.cwd {
proc.current_dir(resolve(&root, cwd));
}
for (k, v) in &cfg.env {
proc.env(k, v);
}
let status = proc
.status()
.map_err(|e| format!("failed to launch `{command}`: {e}"))?;
if !status.success() {
return Err(format!("`{command}` exited with {status}"));
}
Ok(())
}
fn resolve(root: &Path, path: &str) -> PathBuf {
let expanded = expand_tilde(path);
let p = PathBuf::from(&expanded);
let joined = if p.is_absolute() { p } else { root.join(p) };
joined.canonicalize().unwrap_or(joined)
}
fn expand_tilde(path: &str) -> String {
if path == "~" {
if let Ok(home) = std::env::var("HOME") {
return home;
}
}
if path.starts_with("~/") {
if let Ok(home) = std::env::var("HOME") {
return home + &path[1..];
}
}
path.to_string()
}
fn generate_cargo_toml(meta: &YogToml) -> String {
let authors_toml = if meta.authors.is_empty() {
String::new()
} else {
let list = meta
.authors
.iter()
.map(|a| format!("{a:?}"))
.collect::<Vec<_>>()
.join(", ");
format!("authors = [{list}]\n")
};
let edition = meta.edition.as_deref().unwrap_or("2021");
let patch = if meta.yog_api_path.is_some() || std::env::var("YOG_API_PATH").is_ok() {
let env_path = std::env::var("YOG_API_PATH").ok();
let api_path = meta
.yog_api_path
.as_deref()
.or(env_path.as_deref())
.unwrap_or("");
let interop_path = std::path::Path::new(api_path)
.parent()
.map(|p| p.join("yog-interop"))
.unwrap_or_else(|| std::path::PathBuf::from("crates/yog-interop"));
let abs = interop_path.canonicalize().unwrap_or(interop_path);
format!(
"\n[patch.crates-io]\nyog-interop = {{ path = {:?} }}\n",
abs.to_string_lossy()
)
} else {
String::new()
};
let mut deps_lines: Vec<String> = Vec::new();
for (name, spec) in &meta.dependencies {
if name.contains('-') {
let exports_name = format!("{}-exports", name);
let dep = if spec.starts_with('{') {
let inner = spec.trim_start_matches('{').trim_end_matches('}').trim();
if inner.is_empty() {
format!("yog-exports = {{ package = \"{exports_name}\" }}")
} else {
format!("yog-exports = {{ package = \"{exports_name}\", {inner} }}")
}
} else {
format!("yog-exports = {{ package = \"{exports_name}\", version = {spec} }}")
};
deps_lines.push(dep);
} else {
deps_lines.push(format!("{} = {}", name, spec));
}
}
format!(
r#"# Generated by yog from yog.toml — do not edit.
[package]
name = "{id}"
version = "{version}"
edition = "{edition}"
description = {description:?}
{authors_line}license = {license:?}
[lib]
name = "{lib_name}"
crate-type = ["cdylib"]
path = "../src/lib.rs"
[dependencies]
{api_dep}
rkyv = "{rkyv_ver}"
{deps}
{patch}"#,
id = meta.id,
rkyv_ver = workspace_rkyv_version(),
lib_name = to_snake_name(&meta.id),
version = meta.version,
edition = edition,
description = meta.description,
authors_line = authors_toml,
license = meta.license,
api_dep = meta.api_dep(),
deps = deps_lines.join("\n"),
patch = patch,
)
}
fn setup() -> Result<(), String> {
eprintln!("==> yog setup — checking build dependencies\n");
let rust_ok = check_rust();
let zig_build_ok = if rust_ok { check_zigbuild() } else { false };
let _zig_ok = check_zig();
if rust_ok && zig_build_ok {
check_targets();
}
eprintln!("");
if rust_ok && zig_build_ok {
eprintln!("==> all good — `yog build` should produce all 5 platforms.");
} else if rust_ok {
eprintln!(
"==> Rust OK but cross-compilation incomplete. Fix the above, then re-run `yog setup`."
);
} else {
eprintln!("==> Install Rust first, then re-run `yog setup`.");
}
Ok(())
}
fn check_rust() -> bool {
eprint!(" [?] Rust / cargo ... ");
let ok = Command::new("cargo")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok {
let ver = Command::new("cargo")
.arg("--version")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default();
eprintln!("OK ({})", ver.trim());
true
} else {
eprintln!("NOT FOUND");
eprintln!(" Install Rust via rustup:");
eprintln!(" curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh");
false
}
}
fn check_zigbuild() -> bool {
eprint!(" [?] cargo-zigbuild ... ");
let ok = Command::new("cargo")
.args(["zigbuild", "--help"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok {
eprintln!("OK");
true
} else {
eprintln!("NOT FOUND");
eprint!(" Install cargo-zigbuild? [Y/n]: ");
std::io::stdout().flush().ok();
let mut input = String::new();
std::io::stdin().read_line(&mut input).ok();
let input = input.trim().to_lowercase();
if input.is_empty() || input == "y" {
eprintln!(" Running: cargo install cargo-zigbuild");
let status = Command::new("cargo")
.args(["install", "cargo-zigbuild"])
.status();
match status {
Ok(s) if s.success() => {
eprintln!(" cargo-zigbuild installed.");
return true;
}
_ => eprintln!(
" Installation failed. Install manually: cargo install cargo-zigbuild"
),
}
} else {
eprintln!(" Skipped. Cross-compilation will only work for the host platform.");
}
false
}
}
fn check_zig() -> bool {
eprint!(" [?] zig ... ");
let ok = Command::new("zig")
.arg("version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok {
let ver = Command::new("zig")
.arg("version")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default();
eprintln!("OK ({})", ver.trim());
true
} else {
eprintln!("NOT FOUND");
eprint!(" Install zig? [Y/n]: ");
std::io::stdout().flush().ok();
let mut input = String::new();
std::io::stdin().read_line(&mut input).ok();
let input = input.trim().to_lowercase();
if input.is_empty() || input == "y" {
if try_install_zig() {
return true;
}
}
eprintln!(" Download zig from: https://ziglang.org/download/");
eprintln!(" Extract, add to PATH, then re-run `yog setup`.");
false
}
}
fn try_install_zig() -> bool {
if Command::new("snap")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
eprintln!(" Running: snap install zig --classic --beta");
if Command::new("snap")
.args(["install", "zig", "--classic", "--beta"])
.status()
.map(|s| s.success())
.unwrap_or(false)
{
eprintln!(" zig installed via snap.");
return true;
}
}
if Command::new("brew")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
eprintln!(" Running: brew install zig");
if Command::new("brew")
.args(["install", "zig"])
.status()
.map(|s| s.success())
.unwrap_or(false)
{
eprintln!(" zig installed via brew.");
return true;
}
}
eprintln!(" Could not auto-install zig (no snap or brew found).");
false
}
fn check_targets() {
eprintln!(" [?] rustup cross-compile targets ...");
let installed = installed_targets();
let needed: Vec<&str> = TARGETS
.iter()
.map(|t| t.triple)
.filter(|triple| !installed.iter().any(|s| s == triple))
.collect();
if needed.is_empty() {
eprintln!(" all 5 targets installed.");
return;
}
eprintln!(" missing: {}", needed.join(", "));
eprint!(" Install missing targets? [Y/n]: ");
std::io::stdout().flush().ok();
let mut input = String::new();
std::io::stdin().read_line(&mut input).ok();
let input = input.trim().to_lowercase();
if input.is_empty() || input == "y" {
for triple in needed {
eprint!(" rustup target add {triple} ... ");
let ok = Command::new("rustup")
.args(["target", "add", triple])
.status()
.map(|s| s.success())
.unwrap_or(false);
eprintln!("{}", if ok { "done" } else { "FAILED" });
}
} else {
eprintln!(" Skipped. Only host-platform builds will work.");
}
}
enum Builder {
Zig,
Cargo,
}
impl Builder {
fn detect() -> Self {
let ok = Command::new("cargo")
.args(["zigbuild", "--help"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok {
Builder::Zig
} else {
Builder::Cargo
}
}
fn subcmd(&self) -> &'static str {
match self {
Builder::Zig => "zigbuild",
Builder::Cargo => "build",
}
}
fn build(&self, build_dir: &Path, triple: &str, root: &Path) -> Result<(), ()> {
let output = Command::new("cargo")
.current_dir(build_dir)
.env("CARGO_TARGET_DIR", root.join("target"))
.args([self.subcmd(), "--release", "--target", triple])
.output();
match output {
Ok(o) => {
eprint!(
"{}",
filter_benign_warnings(&String::from_utf8_lossy(&o.stderr))
);
if o.status.success() {
Ok(())
} else {
Err(())
}
}
_ => Err(()),
}
}
}
fn filter_benign_warnings(stderr: &str) -> String {
let mut out = String::new();
let mut in_block = false;
let mut suppressed = 0usize;
for line in stderr.lines() {
let t = line.trim_start();
if t.starts_with("warning:") && t.contains("xcrun") && t.contains("failed") {
in_block = true;
suppressed += 1;
continue;
}
if in_block {
if t == "|" || t.starts_with("= note:") || t.starts_with("= help:") || t.is_empty() {
continue;
}
in_block = false;
}
if suppressed > 0 && t.starts_with("warning:") && t.ends_with("generated 1 warning") {
suppressed -= 1;
continue;
}
out.push_str(line);
out.push('\n');
}
out
}
fn installed_targets() -> Vec<String> {
Command::new("rustup")
.args(["target", "list", "--installed"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
})
.unwrap_or_default()
}
fn lib_filename(name: &str, os: &str) -> String {
let stem = to_snake_name(name);
match os {
"windows" => format!("{stem}.dll"),
"macos" => format!("lib{stem}.dylib"),
_ => format!("lib{stem}.so"),
}
}
fn gather_assets(root: &Path) -> Vec<(String, Vec<u8>)> {
let mut present: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut out: Vec<(String, Vec<u8>)> = Vec::new();
let mut stack: Vec<PathBuf> = ["assets", "data"]
.iter()
.map(|d| root.join(d))
.filter(|p| p.is_dir())
.collect();
if stack.is_empty() {
return out;
}
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(rel) = path.strip_prefix(root) {
let key = rel.to_string_lossy().replace('\\', "/");
if let Ok(bytes) = std::fs::read(&path) {
present.insert(key.clone());
out.push((key, bytes));
}
}
}
}
let keys: Vec<String> = present.iter().cloned().collect();
let mut generated: Vec<(String, Vec<u8>)> = Vec::new();
for key in &keys {
if let Some((ns, name)) = parse_texture(key, "item") {
let path = format!("assets/{ns}/models/item/{name}.json");
if present.insert(path.clone()) {
generated.push((path, format!(r#"{{"parent":"item/generated","textures":{{"layer0":"{ns}:item/{name}"}}}}"#).into_bytes()));
}
} else if let Some((ns, name)) = parse_texture(key, "block") {
for (path, json) in [
(
format!("assets/{ns}/blockstates/{name}.json"),
format!(r#"{{"variants":{{"":{{"model":"{ns}:block/{name}"}}}}}}"#),
),
(
format!("assets/{ns}/models/block/{name}.json"),
format!(
r#"{{"parent":"block/cube_all","textures":{{"all":"{ns}:block/{name}"}}}}"#
),
),
(
format!("assets/{ns}/models/item/{name}.json"),
format!(r#"{{"parent":"{ns}:block/{name}"}}"#),
),
] {
if present.insert(path.clone()) {
generated.push((path, json.into_bytes()));
}
}
}
}
out.extend(generated);
out
}
fn parse_texture(entry: &str, kind: &str) -> Option<(String, String)> {
let parts: Vec<&str> = entry.split('/').collect();
if parts.len() == 5
&& parts[0] == "assets"
&& parts[2] == "textures"
&& parts[3] == kind
&& parts[4].ends_with(".png")
{
Some((
parts[1].to_string(),
parts[4].strip_suffix(".png")?.to_string(),
))
} else {
None
}
}
fn package(
out: &Path,
meta: &YogToml,
bundled: &[(String, PathBuf)],
assets: &[(String, Vec<u8>)],
) -> Result<(), String> {
let file = std::fs::File::create(out).map_err(|e| e.to_string())?;
let mut zip = zip::ZipWriter::new(file);
let opts = zip::write::FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Deflated);
for (tag, native) in bundled {
let lib_name = native
.file_name()
.and_then(|n| n.to_str())
.ok_or("bad native filename")?;
let entry = format!("natives/{tag}/{lib_name}");
let bytes = std::fs::read(native).map_err(|e| e.to_string())?;
zip.start_file(&entry, opts).map_err(|e| e.to_string())?;
zip.write_all(&bytes).map_err(|e| e.to_string())?;
}
for (entry, bytes) in assets {
zip.start_file(entry, opts).map_err(|e| e.to_string())?;
zip.write_all(bytes).map_err(|e| e.to_string())?;
}
let platforms = bundled
.iter()
.map(|(t, _)| format!("{t:?}"))
.collect::<Vec<_>>()
.join(", ");
let authors = meta
.authors
.iter()
.map(|a| format!("{a:?}"))
.collect::<Vec<_>>()
.join(", ");
let manifest = format!(
"id = {:?}\nname = {:?}\nversion = {:?}\ndescription = {:?}\nauthors = [{}]\nlicense = {:?}\nabi = 2\nplatforms = [{platforms}]\n",
meta.id, meta.name, meta.version, meta.description, authors, meta.license,
);
zip.start_file("yog.toml", opts)
.map_err(|e| e.to_string())?;
zip.write_all(manifest.as_bytes())
.map_err(|e| e.to_string())?;
zip.finish().map_err(|e| e.to_string())?;
Ok(())
}
fn add_dep(crate_name: Option<&str>) -> Result<(), String> {
let name = crate_name.ok_or("usage: yog add <crate>")?;
let root = std::env::current_dir().map_err(|e| e.to_string())?;
let yog_toml_path = root.join("yog.toml");
if !yog_toml_path.exists() {
return Err("no yog.toml found in the current directory".into());
}
let text = std::fs::read_to_string(&yog_toml_path).map_err(|e| e.to_string())?;
let mut lines: Vec<String> = text.lines().map(|s| s.to_string()).collect();
let mut has_deps = false;
for line in &lines {
if line.trim().starts_with('[') && line.trim().contains("dependencies") {
has_deps = true;
break;
}
}
if !has_deps {
let insert_idx = lines
.iter()
.position(|l| {
l.trim().starts_with('[') && l.trim() != "[mod]" && l.trim() != "[package]"
})
.unwrap_or(lines.len());
lines.insert(insert_idx, "[dependencies]".to_string());
lines.insert(insert_idx + 1, format!("{} = \"*\"", name));
} else {
let mut in_deps = false;
for i in 0..lines.len() {
let trimmed = lines[i].trim();
if trimmed.starts_with('[') && !trimmed.contains("dependencies") {
if in_deps {
lines.insert(i, format!("{} = \"*\"", name));
break;
}
} else if trimmed == "[dependencies]" {
in_deps = true;
}
}
if !in_deps {
lines.push(format!("{} = \"*\"", name));
}
}
let new_text = lines.join("\n") + "\n";
std::fs::write(&yog_toml_path, new_text).map_err(|e| e.to_string())?;
eprintln!("==> added {} to yog.toml", name);
Ok(())
}
fn remove_dep(crate_name: Option<&str>) -> Result<(), String> {
let name = crate_name.ok_or("usage: yog remove <crate>")?;
let root = std::env::current_dir().map_err(|e| e.to_string())?;
let yog_toml_path = root.join("yog.toml");
if !yog_toml_path.exists() {
return Err("no yog.toml found in the current directory".into());
}
let text = std::fs::read_to_string(&yog_toml_path).map_err(|e| e.to_string())?;
let mut lines: Vec<String> = Vec::new();
let mut removed = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with(name) && trimmed.contains('=') && !trimmed.starts_with('[') {
removed = true;
continue;
}
lines.push(line.to_string());
}
if !removed {
return Err(format!("dependency {} not found in yog.toml", name));
}
let new_text = lines.join("\n") + "\n";
std::fs::write(&yog_toml_path, new_text).map_err(|e| e.to_string())?;
eprintln!("==> removed {} from yog.toml", name);
Ok(())
}
#[derive(Debug)]
struct ExportItem {
kind: String, name: String,
source: String, }
fn collect_uses(exports: &[ExportItem], src_dir: &Path) -> (Vec<String>, Vec<String>) {
use std::collections::HashSet;
let mut primitives: HashSet<&str> = HashSet::new();
for p in [
"bool",
"u8",
"u16",
"u32",
"u64",
"u128",
"usize",
"i8",
"i16",
"i32",
"i64",
"i128",
"isize",
"f32",
"f64",
"char",
"str",
"String",
"Vec",
"Option",
"Result",
"Box",
"Rc",
"Arc",
"Cow",
"PhantomData",
"Ordering",
"Self",
"()",
"HashMap",
"HashSet",
"BTreeMap",
"BTreeSet",
"LinkedList",
"VecDeque",
"BinaryHeap",
"Path",
"PathBuf",
"Duration",
"Instant",
"SystemTime",
"Error",
"fmt",
"ops",
"cmp",
"iter",
"slice",
"cell",
"sync",
"collections",
"convert",
"marker",
"mem",
"ptr",
"hint",
"borrow",
"hash",
"default",
"num",
"array",
"range",
] {
primitives.insert(p);
}
let prelude_prefixes: &[&str] = &["std::", "core::", "alloc::"];
let local_names: HashSet<String> = exports.iter().map(|e| e.name.clone()).collect();
let mut import_paths: Vec<String> = Vec::new(); let mut system_imports: HashSet<String> = HashSet::new();
let mut type_imports: HashSet<String> = HashSet::new();
if let Ok(files) = collect_rs_files(src_dir) {
for path in files {
let Ok(src) = std::fs::read_to_string(&path) else {
continue;
};
for line in src.lines() {
let t = line.trim();
if !t.starts_with("use ") {
continue;
}
let path_part = t[4..].trim_end_matches(';').trim();
if path_part.is_empty() {
continue;
}
if path_part.contains('{') && !path_part.contains('}') {
continue;
}
if path_part.starts_with("std::")
|| path_part.starts_with("core::")
|| path_part.starts_with("alloc::")
{
system_imports.insert(path_part.to_string());
} else {
type_imports.insert(path_part.to_string());
}
import_paths.push(path_part.to_string());
}
}
}
fn resolve_name(
name: &str,
import_paths: &[String],
local_names: &HashSet<String>,
primitives: &HashSet<&str>,
prelude_prefixes: &[&str],
) -> Option<String> {
if primitives.contains(name) {
return None;
}
if prelude_prefixes.iter().any(|p| name.starts_with(p)) {
return None;
}
if local_names.contains(name) {
return None;
}
let mut best: Option<(usize, String)> = None;
for p in import_paths {
let last = p.rsplit("::").next().unwrap_or(p);
if last == name || last == "*" {
let len = p.len();
if best.as_ref().map(|(l, _)| len < *l).unwrap_or(true) {
best = Some((len, p.clone()));
}
}
}
best.map(|(_, p)| p)
}
for item in exports {
let body = &item.source;
for m in body.match_indices("::") {
let start = m.0;
let slice = &body[start..];
let mut end = 0;
let mut depth: i32 = 0;
for (i, ch) in slice.char_indices() {
match ch {
'<' => depth += 1,
'>' => depth -= 1,
' ' | ',' | ';' | '{' | '}' | '(' | ')' | ':' | '[' | ']' | '.' => {
if depth == 0 {
end = i;
break;
}
}
_ => {}
}
if depth < 0 {
end = i;
break;
}
}
let path = slice[..end].trim().trim_end_matches("::");
if path.is_empty() {
continue;
}
let first = path
.trim_start_matches(':')
.split("::")
.next()
.unwrap_or("");
if prelude_prefixes.iter().any(|p| path.starts_with(p)) {
system_imports.insert(path.to_string());
} else if first == "yog_api" || first == "crate" || first == "super" || first == "self"
{
type_imports.insert(path.to_string());
}
}
let re_check: Vec<&str> = body
.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|s| !s.is_empty())
.collect();
for tok in re_check {
if tok
.chars()
.next()
.map(|c| c.is_uppercase())
.unwrap_or(false)
{
if let Some(p) = resolve_name(
tok,
&import_paths,
&local_names,
&primitives,
prelude_prefixes,
) {
if p.starts_with("std::") || p.starts_with("core::") || p.starts_with("alloc::")
{
system_imports.insert(p);
} else {
type_imports.insert(p);
}
}
}
}
}
fn normalize(vec: &mut Vec<String>) {
vec.sort();
let mut braces: Vec<String> = Vec::new();
let mut simples: Vec<String> = Vec::new();
for p in vec.drain(..) {
if p.contains('{') {
braces.push(p);
} else {
simples.push(p);
}
}
let mut keep: Vec<String> = Vec::new();
for brace in braces {
let prefix = brace.split('{').next().unwrap_or("");
let inside = brace
.split('{')
.nth(1)
.and_then(|s| s.split('}').next())
.unwrap_or("");
let items: HashSet<&str> = inside.split(',').map(|s| s.trim()).collect();
simples.retain(|s| {
if s.starts_with(prefix) {
!items.contains(&s.strip_prefix(prefix).unwrap_or(s).trim())
} else {
true
}
});
keep.push(brace);
}
keep.extend(simples);
keep.sort();
*vec = keep;
}
let mut system_vec: Vec<String> = system_imports.into_iter().collect();
let mut type_vec: Vec<String> = type_imports.into_iter().collect();
normalize(&mut system_vec);
normalize(&mut type_vec);
(system_vec, type_vec)
}
fn publish_exports(dry_run: bool) -> Result<(), String> {
let proj = std::env::current_dir().map_err(|e| e.to_string())?;
let manifest = YogToml::read(&proj.join("yog.toml"))?;
let mod_id = manifest.id;
let version = manifest.version;
let edition = manifest.edition.as_deref().unwrap_or("2021");
let license = manifest
.exports_license
.clone()
.unwrap_or_else(|| "MIT OR Apache-2.0".to_string());
let authors = manifest.authors.join(", ");
let exports_crate_name = format!("{}_exports", mod_id.replace('-', "_"));
let src_dir = proj.join("src");
if !src_dir.exists() {
return Err("no src/ directory — nothing to export".into());
}
let mut exports: Vec<ExportItem> = Vec::new();
for path in collect_rs_files(&src_dir)? {
let src = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let lines: Vec<&str> = src.lines().collect();
let mut i = 0;
while i < lines.len() {
let trimmed = lines[i].trim();
if trimmed == "#[yog_export]" || trimmed.starts_with("#[yog_export(") {
i += 1;
while i < lines.len() && lines[i].trim() == "#[yog_export]" {
i += 1;
}
if i >= lines.len() {
break;
}
let first = lines[i].trim();
let kind = if first.starts_with("pub struct ") {
"struct"
} else if first.starts_with("pub enum ") {
"enum"
} else if first.starts_with("pub fn ") {
"fn"
} else {
i += 1;
continue;
};
let name = if kind == "fn" {
first
.trim_start_matches("pub fn ")
.split('(')
.next()
.unwrap_or("")
.trim()
.to_string()
} else if kind == "struct" {
first
.trim_start_matches("pub struct ")
.split(&['{', '(', ' ', '<', ';', '\t'][..])
.next()
.unwrap_or("")
.trim()
.to_string()
} else {
first
.trim_start_matches("pub enum ")
.split(&['{', ' ', '<', ';', '\t'][..])
.next()
.unwrap_or("")
.trim()
.to_string()
};
if name.is_empty() {
i += 1;
continue;
}
let mut depth: i32 = 0;
let mut found_open = false;
let mut item_lines: Vec<String> = Vec::new();
let _start_i = i;
while i < lines.len() {
let line = lines[i];
item_lines.push(line.to_string());
for ch in line.chars() {
if ch == '{' {
depth += 1;
found_open = true;
} else if ch == '}' {
depth -= 1;
}
}
if found_open && depth <= 0 {
item_lines.push(String::new());
i += 1;
break;
}
if !found_open && line.trim().ends_with(';') {
i += 1;
break;
}
i += 1;
}
let source = item_lines.join("\n");
exports.push(ExportItem {
kind: kind.to_string(),
name,
source,
});
}
i += 1;
}
}
if exports.is_empty() {
eprintln!("==> no #[yog_export] items found — nothing to publish");
return Ok(());
}
let (system_uses, type_uses) = collect_uses(&exports, &src_dir);
eprintln!(
"==> generating {exports_crate_name} ({items} export(s))",
items = exports.len()
);
eprintln!(" system_uses: {:?}", system_uses);
eprintln!(" type_uses: {:?}", type_uses);
let build_dir = proj
.join(".yog-build")
.join("exports")
.join(&exports_crate_name);
let _ = std::fs::remove_dir_all(&build_dir);
std::fs::create_dir_all(build_dir.join("src")).map_err(|e| e.to_string())?;
let yog_api_ver = latest_yog_api_version();
let rkyv_ver = workspace_rkyv_version();
let yog_api_override = manifest.yog_api_version.as_deref().unwrap_or(&yog_api_ver);
let maybe_authors = if authors.is_empty() {
String::new()
} else {
format!("authors = [\"{}\"]\n", authors.replace(", ", "\", \""))
};
let cargo_toml = format!(
r#"[package]
name = "{name}"
version = "{version}"
edition = "{edition}"
{maybe_authors}license = "{license}"
description = "Exports from the {mod_id} Yog mod — generated by `yog publish exports`."
[dependencies]
yog-api = "{yog_api_override}"
rkyv = "{rkyv_ver}"
[lib]
crate-type = ["cdylib", "lib"]
"#,
name = exports_crate_name,
version = version,
edition = edition,
maybe_authors = maybe_authors,
license = license,
mod_id = mod_id,
yog_api_override = yog_api_override,
rkyv_ver = rkyv_ver,
);
write_file(&build_dir.join("Cargo.toml"), cargo_toml.as_bytes())?;
let mut lib_rs = format!(
"//! Auto-generated exports from `{mod_id}` v{version}.\n\
//! DO NOT EDIT — generated by `yog publish exports`.\n\n\
#![allow(unused_imports)]\n\n"
);
if !system_uses.is_empty() {
lib_rs.push_str("// System dependencies\n");
for u in &system_uses {
lib_rs.push_str(&format!("use {};\n", u));
}
lib_rs.push('\n');
}
let exported_names: HashSet<String> = exports.iter().map(|e| e.name.clone()).collect();
if !type_uses.is_empty() {
lib_rs.push_str("// Type dependencies\n");
for u in &type_uses {
let first_part = u.split("::").next().unwrap_or("");
if first_part == "crate" || first_part == "super" || first_part == "self" {
continue;
}
let last_part = u.rsplit("::").next().unwrap_or(u);
if exported_names.contains(last_part) {
continue;
}
lib_rs.push_str(&format!("use {};\n", u));
}
lib_rs.push('\n');
}
for item in &exports {
match item.kind.as_str() {
"struct" | "enum" => {
let cleaned = strip_derive_attrs(&item.source);
lib_rs.push_str(&format!(
"#[derive(::yog_api::rkyv::Archive, ::yog_api::rkyv::Serialize, ::yog_api::rkyv::Deserialize, Debug, Clone, PartialEq)]\n\
{}\n",
cleaned,
));
}
"fn" => {
let wrapper = generate_fn_wrapper(&item.name, &item.source);
lib_rs.push_str(&wrapper);
}
_ => {}
}
}
write_file(&build_dir.join("src").join("lib.rs"), lib_rs.as_bytes())?;
if dry_run {
eprintln!("==> dry-run: skipping cargo publish");
eprintln!(" generated crate is at: {}", build_dir.display());
eprintln!(" Run `cargo publish` manually from that directory, or");
eprintln!(" re-run without --dry-run to publish automatically.");
return Ok(());
}
eprintln!("==> cargo publish (--allow-dirty)");
let status = std::process::Command::new("cargo")
.args(["publish", "--allow-dirty"])
.current_dir(&build_dir)
.status()
.map_err(|e| format!("cargo publish failed: {e}"))?;
if !status.success() {
return Err(format!("cargo publish exited with {}", status));
}
let _ = std::fs::remove_dir_all(&build_dir);
eprintln!("==> published {exports_crate_name} v{version}");
Ok(())
}
fn strip_derive_attrs(source: &str) -> String {
source
.lines()
.filter(|line| !line.trim().starts_with("#[derive("))
.collect::<Vec<_>>()
.join("\n")
}
fn generate_fn_wrapper(name: &str, source: &str) -> String {
let lines: Vec<&str> = source.lines().collect();
let sig_line = lines.first().unwrap_or(&"").trim();
let paren_start = sig_line.find('(');
let paren_end = paren_start.and_then(|ps| {
let after = &sig_line[ps..];
let mut depth: i32 = 0;
for (i, ch) in after.char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(ps + i);
}
}
_ => {}
}
}
None
});
fn split_params(params: &str) -> Vec<&str> {
let mut result = Vec::new();
let mut depth: i32 = 0;
let mut start = 0;
for (i, ch) in params.char_indices() {
match ch {
'<' | '(' | '[' => depth += 1,
'>' | ')' | ']' => depth -= 1,
',' if depth == 0 => {
result.push(params[start..i].trim());
start = i + 1;
}
_ => {}
}
}
let last = params[start..].trim();
if !last.is_empty() {
result.push(last);
}
result
}
let mut typed_params: Vec<(String, String)> =
if let (Some(ps), Some(pe)) = (paren_start, paren_end) {
let params = sig_line[ps + 1..pe].trim();
split_params(params)
.into_iter()
.filter_map(|p| {
let parts: Vec<&str> = p.splitn(2, ':').collect();
if parts.len() == 2 {
Some((parts[0].trim().to_string(), parts[1].trim().to_string()))
} else {
None
}
})
.collect()
} else {
Vec::new()
};
if typed_params
.first()
.map(|(_, ty)| ty.replace(' ', "").ends_with("Registry"))
.unwrap_or(false)
{
typed_params.remove(0);
}
let arg_names: Vec<String> = (0..typed_params.len())
.map(|i| format!("__yog_arg{i}"))
.collect();
let data_types: Vec<&str> = typed_params.iter().map(|(_, ty)| ty.as_str()).collect();
let params_sig = arg_names
.iter()
.zip(data_types.iter())
.map(|(n, t)| format!("{n}: {t}"))
.collect::<Vec<_>>()
.join(", ");
let tuple_expr = match arg_names.len() {
0 => String::new(),
1 => arg_names[0].clone(),
_ => format!("({})", arg_names.join(", ")),
};
let output_type = match paren_end {
Some(pe) => {
let arrow_rel = if pe < sig_line.len() {
sig_line[pe..].find("->")
} else {
None
};
match arrow_rel {
Some(rel) => {
let arrow = pe + rel;
let rest: &str = sig_line[arrow + 2..].trim();
let mut depth: i32 = 0;
let mut end = 0;
for (i, ch) in rest.char_indices() {
match ch {
'<' => depth += 1,
'>' => depth -= 1,
'{' | ';' => break,
_ => {}
}
if depth < 0 {
break;
}
if ch == '=' {
break;
}
end = i + ch.len_utf8();
}
rest[..end].trim().to_string()
}
None => "()".to_string(),
}
}
None => "()".to_string(),
};
let slot_name = format!("__YOG_SLOT_{}", name.to_uppercase());
let bind_name = format!("__yog_bind_{}", name);
let output_type_fmt = output_type.replace('{', "{{").replace('}', "}}");
let params_sig_fmt = params_sig.replace('{', "{{").replace('}', "}}");
let slot_name_fmt = slot_name.replace('{', "{{").replace('}', "}}");
let bind_name_fmt = bind_name.replace('{', "{{").replace('}', "}}");
let wrapper_ty = format!(
"unsafe extern \"C\" fn(input_ptr: *const u8, input_len: u32, \
out_data: *mut *mut u8, out_len: *mut u32, out_cap: *mut u32)"
);
let serialize_block = if tuple_expr.is_empty() {
"let input_bytes: Vec<u8> = Vec::new();".to_string()
} else {
format!(
"let aligned = ::yog_api::rkyv::to_bytes::<::yog_api::rkyv::rancor::Error>(\
&{tuple_expr}\
).unwrap_or_default();\n \
let input_bytes: Vec<u8> = aligned.to_vec();"
)
};
format!(
r#"#[allow(non_snake_case)]
pub fn {name}({params_sig_fmt}) -> {output_type_fmt} {{
{serialize_block}
let f = {slot_name_fmt}.get()
.expect(concat!("yog: export '", "{name}", "' not bound"));
let mut out_data: *mut u8 = std::ptr::null_mut();
let mut out_len: u32 = 0;
let mut out_cap: u32 = 0;
unsafe {{
f(input_bytes.as_ptr(), input_bytes.len() as u32,
&mut out_data, &mut out_len, &mut out_cap);
let output_slice = std::slice::from_raw_parts(out_data, out_len as usize);
let result: {output_type_fmt} = ::yog_api::rkyv::from_bytes::<_, ::yog_api::rkyv::rancor::Error>(output_slice)
.expect(concat!("yog: deser failed in export '", "{name}", "'"));
let _ = Vec::from_raw_parts(out_data, out_len as usize, out_cap as usize);
result
}}
}}
static {slot_name_fmt}: ::std::sync::OnceLock<{wrapper_ty}> = ::std::sync::OnceLock::new();
#[doc(hidden)]
#[no_mangle]
pub unsafe extern "C" fn {bind_name_fmt}(ptr: *const ::std::os::raw::c_void) {{
let f: {wrapper_ty} = ::std::mem::transmute(ptr);
{slot_name_fmt}.set(f).ok();
}}
"#,
name = name,
params_sig_fmt = params_sig_fmt,
output_type_fmt = output_type_fmt,
slot_name_fmt = slot_name_fmt,
wrapper_ty = wrapper_ty,
bind_name_fmt = bind_name_fmt,
serialize_block = serialize_block,
)
}
fn write_file(path: &Path, data: &[u8]) -> Result<(), String> {
std::fs::write(path, data).map_err(|e| format!("writing {}: {e}", path.display()))
}
fn collect_rs_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.is_dir() {
out.extend(collect_rs_files(&path)?);
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(path);
}
}
Ok(out)
}
fn workspace_rkyv_version() -> String {
env!("RKYV_VERSION").to_string()
}
fn latest_yog_api_version() -> String {
match std::process::Command::new("cargo")
.args(["search", "yog-api", "--limit", "1"])
.output()
{
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.and_then(|l| {
let mut parts = l.split_whitespace();
parts.next(); if parts.next() == Some("=") {
parts.next().map(|v| v.trim_matches('"').to_owned())
} else {
None
}
})
.unwrap_or_else(|| "0.5".into())
}
_ => "0.5".into(), }
}