use std::path::{Path, PathBuf};
pub const MANIFEST: &str = "yog.toml";
pub const BUILD_DIR: &str = ".yog-build";
pub const DEPS_SECTION: &str = "[dependencies]";
pub const EXPORT_DEPS_SECTION: &str = "[dependencies.exports]";
pub const DEFAULT_EDITION: &str = "2024";
pub const DEFAULT_LICENSE: &str = "MIT OR Apache-2.0";
const FALLBACK_API_VERSION: &str = "0.94";
pub fn project_root() -> Result<PathBuf, String> {
let root = std::env::current_dir().map_err(|e| e.to_string())?;
if root.join(MANIFEST).is_file() {
Ok(root)
} else {
Err(format!("no {MANIFEST} found in the current directory"))
}
}
#[derive(Debug)]
pub struct YogToml {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub name_key: Option<String>,
pub description_key: Option<String>,
pub icon: Option<String>,
pub authors: Vec<String>,
pub license: String,
pub exports_license: Option<String>,
pub datapack: Option<String>,
pub edition: Option<String>,
pub yog_api_path: Option<String>,
pub yog_api_version: Option<String>,
pub dependencies: Vec<(String, String)>,
pub mod_dependencies: Vec<(String, String)>,
pub dev_dependencies: Vec<(String, String)>,
pub run_configs: Vec<RunConfig>,
pub externals: Vec<External>,
}
#[derive(Debug, Default, Clone)]
pub struct External {
pub namespace: String,
pub ids: Vec<(String, Vec<String>)>,
}
#[derive(Debug, Default, Clone)]
pub struct RunConfig {
pub name: String,
pub display_name: Option<String>,
pub export_dir: Option<String>,
pub command: Option<String>,
pub args: Vec<String>,
pub cwd: Option<String>,
pub env: Vec<(String, String)>,
}
impl YogToml {
pub 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)
}
pub 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)
}
}
use yog_data::{Text, Value};
use yog_formats::manifest::ManifestFile;
fn toml_inline(v: &Value) -> String {
let quote = |s: &str| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
match v {
Value::Map(m) => {
let fields: Vec<String> = m
.iter()
.map(|(k, v)| {
let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { k.to_string() } else { quote(k) };
format!("{key} = {}", toml_inline(v))
})
.collect();
format!("{{ {} }}", fields.join(", "))
}
Value::List(items) => format!("[{}]", items.iter().map(toml_inline).collect::<Vec<_>>().join(", ")),
Value::Bool(b) => b.to_string(),
Value::Int(n) => n.to_string(),
Value::Float(f) => f.to_string(),
other => quote(other.as_str().unwrap_or_default()),
}
}
fn text_form(t: Text) -> Result<String, String> {
match t {
Text::Literal(s) => Ok(yog_data::typed::escape_at(&s)),
Text::Res(r) => Ok(format!("@{}.{}", r.set, r.key)),
Text::NameOf(_) | Text::Lang(_) => Err("a mod's name and description are text or a `.yres` text (`@Strings.mod_name`)".into()),
}
}
pub fn parse_yog_toml(text: &str) -> Result<YogToml, String> {
for (i, line) in text.lines().enumerate() {
let l = line.trim();
if let Some(name) = l.strip_prefix("[run.").and_then(|r| r.strip_suffix(']'))
&& name.contains('.')
&& !name.starts_with('"')
{
return Err(format!("yog.toml:{}: write the run target's name in quotes: [run.\"{name}\"]", i + 1));
}
}
let read = yog_data::schema::from_toml::<ManifestFile>(text).map_err(|e| {
e.0.iter()
.map(|l| match l.at {
Some(at) => format!("yog.toml:{}: {}{}", at.line, if l.path.is_root() { String::new() } else { format!("{}: ", l.path) }, l.message),
None => format!("yog.toml: {}", l.message),
})
.collect::<Vec<_>>()
.join("\n")
})?;
let f = read.value;
let m = f.module;
let mut dependencies = Vec::new();
let mut mod_dependencies = Vec::new();
let mut yog_api_version = None;
for (name, spec) in f.dependencies.0 {
match (name.as_str(), &spec) {
("exports", Value::Map(exports)) => mod_dependencies.extend(exports.iter().map(|(k, v)| (k.to_string(), toml_inline(v)))),
("yog-api" | "yog_api" | "yog_api_version", _) => yog_api_version = Some(spec.as_str().map(str::to_string).unwrap_or_else(|| toml_inline(&spec))),
_ => dependencies.push((name, toml_inline(&spec))),
}
}
let run_configs = f
.run
.0
.into_iter()
.map(|(name, r)| RunConfig {
name,
display_name: r.name,
export_dir: r.export_dir,
command: r.command,
args: r.args,
cwd: r.cwd,
env: r.env.into_iter().filter_map(|kv| kv.split_once('=').map(|(k, v)| (k.to_string(), v.to_string()))).collect(),
})
.collect();
let externals = f
.external
.0
.into_iter()
.map(|(namespace, e)| External {
namespace,
ids: [("items", e.items), ("blocks", e.blocks), ("entities", e.entities), ("recipes", e.recipes)]
.into_iter()
.filter(|(_, v)| !v.is_empty())
.map(|(k, v)| (k.to_string(), v))
.collect(),
})
.collect();
let name = m.name.map(text_form).transpose()?;
let description = m.description.map(text_form).transpose()?;
let icon = m.icon.map(|i| match i {
yog_data::Image::Source(s) => s,
yog_data::Image::Res(r) => format!("@{}.{}", r.set, r.key),
});
Ok(YogToml {
name: name.unwrap_or_else(|| m.id.clone()),
version: m.version.unwrap_or_else(|| "0.1.0".into()),
description: description.unwrap_or_default(),
name_key: None,
description_key: None,
icon,
authors: m.authors,
license: m.license.unwrap_or_else(|| DEFAULT_LICENSE.into()),
exports_license: m.exports_license,
datapack: m.datapack,
edition: m.edition,
yog_api_path: f.dev.and_then(|d| d.yog_api_path),
yog_api_version,
dependencies,
mod_dependencies,
dev_dependencies: f.dev_dependencies.0.into_iter().map(|(k, v)| (k, toml_inline(&v))).collect(),
run_configs,
externals,
id: m.id,
})
}
pub 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.ends_with('_') {
out.push('_');
}
} else if c.is_uppercase() {
if !out.is_empty() && !out.ends_with('_') {
out.push('_');
}
out.extend(c.to_lowercase());
} else {
out.push(c);
}
}
out
}
pub fn workspace_rkyv_version() -> String {
env!("RKYV_VERSION").to_string()
}
pub 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(|| FALLBACK_API_VERSION.into())
}
_ => FALLBACK_API_VERSION.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const VLSI: &str = r#"
[mod]
id = "yog-vlsi"
name = "@Strings.mod_name"
version = "0.3.0"
description = "@Strings.mod_description"
authors = ["F000NK", "Yog Team"]
license = "AGPL-3.0-only"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
yog-api = "0.93"
yog-kit = "0.4"
[dependencies.exports]
yog-pipes = "0.5"
## a comment
[run."forge-1.21.1"]
name = "Forge 1.21.1"
export_dir = "../Yog-Mod-Loader/forge/run/yog-mods"
command = "./gradlew"
args = ["runClient", "-Pminecraft_version=1.21.1"]
cwd = "../Yog-Mod-Loader/forge"
env = ["JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64"]
[external.farmersdelight]
items = ["cutting_board", "tomato"]
blocks = ["stove"]
"#;
#[test]
fn reads_a_real_manifest() {
let t = parse_yog_toml(VLSI).unwrap();
assert_eq!((t.id.as_str(), t.name.as_str(), t.version.as_str()), ("yog-vlsi", "@Strings.mod_name", "0.3.0"));
assert_eq!(t.authors, ["F000NK", "Yog Team"]);
assert_eq!(t.license, "AGPL-3.0-only");
assert_eq!(t.yog_api_version.as_deref(), Some("0.93"), "yog-api is taken out of the dependencies");
let deps: Vec<&str> = t.dependencies.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(deps, ["serde", "serde_json", "yog-kit"]);
assert_eq!(t.mod_dependencies, [("yog-pipes".to_string(), "\"0.5\"".to_string())]);
let run = &t.run_configs[0];
assert_eq!((run.name.as_str(), run.display_name.as_deref()), ("forge-1.21.1", Some("Forge 1.21.1")));
assert_eq!(run.args, ["runClient", "-Pminecraft_version=1.21.1"]);
assert_eq!(run.env, [("JAVA_HOME".to_string(), "/usr/lib/jvm/java-21-openjdk-amd64".to_string())]);
let ext = &t.externals[0];
assert_eq!(ext.namespace, "farmersdelight");
assert_eq!(ext.ids[0], ("items".to_string(), vec!["cutting_board".to_string(), "tomato".to_string()]));
}
#[test]
fn problems_say_where() {
let e = parse_yog_toml("[mod]\nid = \"m\"\n\n[run.forge-1.21.1]\nname = \"F\"\n").unwrap_err();
assert_eq!(e, "yog.toml:4: write the run target's name in quotes: [run.\"forge-1.21.1\"]");
let e = parse_yog_toml("[mod]\nid = \"m\"\nautors = [\"me\"]\n\n[external.create]\nitem = [\"wrench\"]\n").unwrap_err();
assert_eq!(e.lines().collect::<Vec<_>>(), [
"yog.toml:3: mod.autors: unknown field `autors` — did you mean `authors`?",
"yog.toml:6: external.create.item: unknown field `item` — did you mean `items`?",
]);
let t = parse_yog_toml("[mod]\nid = \"m\"\nname = \"@@me\"\n").unwrap();
assert_eq!(t.name, "@@me", "a literal @ stays escaped for resolve_metadata");
}
#[test]
fn needs_an_id() {
assert!(parse_yog_toml("[mod]\nname = \"x\"").is_err());
let t = parse_yog_toml("[mod]\nid = \"m\"").unwrap();
assert_eq!((t.name.as_str(), t.version.as_str()), ("m", "0.1.0"), "defaults");
}
}