use std::collections::{BTreeMap, HashMap};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
pub const NEUTRAL_LOCALE: &str = "en_us";
pub const RESOURCES_DIR: &str = yog_ids::project::RESOURCES;
pub const GENERATED_FILE: &str = ".yog-build/generated/resources.g.rs";
pub const GENERATED_DIR: &str = ".yog-build/generated/resources";
pub type Output = (String, Vec<u8>);
pub use yog_formats::yres::{Entry, EntryObject, Kind, Value, parse, write};
const RUST_KEYWORDS: &[&str] = &[
"as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
"false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
"ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
"where", "while", "abstract", "become", "box", "do", "final", "gen", "macro", "override",
"priv", "try", "typeof", "unsized", "virtual", "yield",
];
fn ident_part(s: &str) -> bool {
let mut chars = s.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn valid_name(name: &str) -> bool {
yog_data::typed::is_res_key(name)
}
pub fn name_problem(name: &str) -> Option<String> {
(!valid_name(name)).then(|| format!("{name:?} isn't a valid name — identifiers joined by `.`, like screen.title"))
}
pub fn valid_set_name(name: &str) -> bool {
ident_part(name) && !RUST_KEYWORDS.contains(&name)
}
pub fn valid_locale(locale: &str) -> bool {
let Some((lang, region)) = locale.split_once('_') else {
return false;
};
(2..=3).contains(&lang.len())
&& lang.bytes().all(|b| b.is_ascii_lowercase())
&& (2..=4).contains(®ion.len())
&& region
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
}
pub fn accessor_name(name: &str) -> String {
let mut out = String::new();
for (n, part) in name.split('.').enumerate() {
if n > 0 {
out.push('_');
}
let mut prev_lower = false;
for c in part.chars() {
if c.is_ascii_uppercase() && prev_lower {
out.push('_');
}
prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
out.push(c.to_ascii_lowercase());
}
}
out
}
pub fn default_key(set: &str, mod_id: &str, name: &str) -> String {
match set {
"Items" => format!("item.{mod_id}.{name}"),
"Blocks" => format!("block.{mod_id}.{name}"),
"Entities" => format!("entity.{mod_id}.{name}"),
_ => format!("{mod_id}.{name}"),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Segment {
Text(String),
Arg(String),
}
fn segments(text: &str) -> Result<Vec<Segment>, String> {
let mut out = Vec::new();
let mut lit = String::new();
let mut spans = 0usize;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'{' if {
let rest: String = chars.clone().take_while(|&x| x != '{' && x != '}').collect();
rest.contains('|')
} =>
{
lit.push('{');
spans += 1;
}
'}' if spans > 0 => {
lit.push('}');
spans -= 1;
}
'{' if chars.peek() == Some(&'{') => {
chars.next();
lit.push_str("{{");
}
'}' if chars.peek() == Some(&'}') => {
chars.next();
lit.push_str("}}");
}
'{' => {
let mut arg = String::new();
loop {
match chars.next() {
Some('}') => break,
Some(c) => arg.push(c),
None => {
return Err("`{` without `}` (write `{{` for a literal brace)".into());
}
}
}
let arg = arg.trim().to_string();
let numeric = !arg.is_empty() && arg.bytes().all(|b| b.is_ascii_digit());
if !numeric && (!ident_part(&arg) || RUST_KEYWORDS.contains(&arg.as_str())) {
return Err(format!(
"placeholder `{{{arg}}}` must be a number or an identifier (write `{{{{` for a literal brace)"
));
}
if !lit.is_empty() {
out.push(Segment::Text(std::mem::take(&mut lit)));
}
out.push(Segment::Arg(arg));
}
'}' => return Err("`}` without `{` (write `}}` for a literal brace)".into()),
c => lit.push(c),
}
}
if !lit.is_empty() {
out.push(Segment::Text(lit));
}
Ok(out)
}
fn params(segs: &[Segment]) -> Result<Vec<String>, String> {
let args: Vec<&String> = segs
.iter()
.filter_map(|s| match s {
Segment::Arg(a) => Some(a),
Segment::Text(_) => None,
})
.collect();
let numeric = args
.iter()
.filter(|a| a.bytes().all(|b| b.is_ascii_digit()))
.count();
if numeric > 0 && numeric < args.len() {
return Err("mixes numbered `{0}` and named `{name}` placeholders — use one style".into());
}
let mut out: Vec<String> = Vec::new();
if numeric > 0 {
let mut nums: Vec<usize> = args.iter().filter_map(|a| a.parse().ok()).collect();
nums.sort_unstable();
nums.dedup();
for (i, n) in nums.iter().enumerate() {
if *n != i {
return Err(format!(
"placeholder {{{i}}} is missing (numbered placeholders start at {{0}}, no gaps)"
));
}
out.push(n.to_string());
}
} else {
for a in args {
if !out.contains(a) {
out.push(a.clone());
}
}
}
Ok(out)
}
fn lang_text(segs: &[Segment], params: &[String]) -> Result<String, String> {
let mut out = String::new();
for s in segs {
match s {
Segment::Text(t) => out.push_str(&t.replace('%', "%%")),
Segment::Arg(a) => {
let pos = params
.iter()
.position(|p| p == a)
.ok_or_else(|| format!("placeholder `{{{a}}}` isn't in the neutral text"))?;
let _ = write!(out, "%{}$s", pos + 1);
}
}
}
Ok(out)
}
fn param_ident(p: &str) -> String {
if p.bytes().all(|b| b.is_ascii_digit()) {
format!("arg{p}")
} else {
accessor_name(p)
}
}
#[derive(Debug, Clone)]
pub struct ResFile {
pub path: PathBuf,
pub entries: Vec<Entry>,
}
#[derive(Debug, Clone)]
pub struct Set {
pub name: String,
pub neutral: ResFile,
pub cultures: BTreeMap<String, ResFile>,
}
#[derive(Debug, Clone, Default)]
pub struct Refs {
pub texts: HashMap<String, (String, String)>,
pub images: HashMap<String, String>,
pub colors: HashMap<String, u32>,
pub pictures: HashMap<String, Picture>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Picture {
pub png: Vec<u8>,
pub output: String,
}
fn valid_resource_path(path: &str) -> bool {
!path.is_empty() && path.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.' | b'/'))
}
#[derive(Debug, Default)]
pub struct Compiled {
pub outputs: Vec<Output>,
pub sources: BTreeMap<String, String>,
pub refs: Refs,
pub warnings: Vec<String>,
pub files: usize,
}
pub fn find(root: &Path) -> Vec<PathBuf> {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, out);
} else if yog_ids::Format::Res.is(&path) {
out.push(path);
}
}
}
let mut out = Vec::new();
walk(&root.join(RESOURCES_DIR), &mut out);
out.sort();
out
}
fn display(root: &Path, p: &Path) -> String {
p.strip_prefix(root)
.unwrap_or(p)
.to_string_lossy()
.replace('\\', "/")
}
fn load(root: &Path, errors: &mut Vec<String>) -> BTreeMap<String, Set> {
let mut neutral: BTreeMap<String, ResFile> = BTreeMap::new();
let mut cultures: Vec<(String, String, ResFile)> = Vec::new();
for path in find(root) {
let shown = display(root, &path);
let file_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let Some((set, locale)) = yog_formats::yres::split_file_name(&file_name) else {
continue;
};
if !valid_set_name(set) {
errors.push(format!(
"{shown}: `{set}` isn't a valid resource set name — use an identifier like Strings or Icons"
));
continue;
}
if let Some(locale) = locale {
if locale == NEUTRAL_LOCALE {
errors.push(format!(
"{shown}: the neutral file {set}.yres is already {NEUTRAL_LOCALE} — put these texts there"
));
continue;
}
if !valid_locale(locale) {
errors.push(format!(
"{shown}: `{locale}` isn't a Minecraft locale code (lower case, like ru_ru or pt_br)"
));
continue;
}
}
let entries = match std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|t| parse(&t))
{
Ok(entries) => entries,
Err(e) => {
errors.push(format!("{shown}:{}", e.trim_start_matches("line ")));
continue;
}
};
for e in &entries {
if let Some(problem) = name_problem(&e.name) {
errors.push(format!("{shown}:{}: {problem}", e.line));
}
}
let file = ResFile { path, entries };
match locale {
None => {
if let Some(other) = neutral.get(set) {
errors.push(format!(
"{shown}: resource set {set} is also defined by {}",
display(root, &other.path)
));
continue;
}
neutral.insert(set.to_string(), file);
}
Some(locale) => cultures.push((set.to_string(), locale.to_string(), file)),
}
}
let mut sets: BTreeMap<String, Set> = neutral
.into_iter()
.map(|(name, neutral)| {
(
name.clone(),
Set {
name,
neutral,
cultures: BTreeMap::new(),
},
)
})
.collect();
for (set, locale, file) in cultures {
let shown = display(root, &file.path);
let Some(target) = sets.get_mut(&set) else {
errors.push(format!(
"{shown}: no neutral {set}.yres for this culture variant"
));
continue;
};
if let Some(other) = target.cultures.get(&locale) {
errors.push(format!(
"{shown}: {set} {locale} is also defined by {}",
display(root, &other.path)
));
continue;
}
for e in &file.entries {
if e.kind != Kind::String || e.key.is_some() {
errors.push(format!(
"{shown}:{}: {:?}: culture variants hold texts only (no type other than string, no `key`)",
e.line, e.name
));
}
}
target.cultures.insert(locale, file);
}
sets
}
pub fn value_problem(e: &Entry) -> Option<String> {
let media = |what: &str| format!("{:?}: {what} are bytes (`@bytes:…`)", e.name);
match (e.kind, &e.value) {
(Kind::Image | Kind::File | Kind::Sound, Value::Bytes(_)) if !valid_resource_path(&e.name.replace('.', yog_ids::sep::PATH)) => {
Some(format!("{:?}: Minecraft resource paths are lower case — only a-z, 0-9, `_`, `-`, `.`", e.name))
}
(Kind::Sound, Value::Bytes(b)) if !b.starts_with(yog_formats::yres::OGG_MAGIC) => Some(format!("{:?}: sounds are Ogg Vorbis", e.name)),
(Kind::Image | Kind::File | Kind::Sound, Value::Bytes(_)) => None,
(Kind::Image, _) => Some(media("images")),
(Kind::File, _) => Some(media("files")),
(Kind::Sound, _) => Some(media("sounds")),
(Kind::Color, _) => parse_color(e.text()).is_none().then(|| format!("{:?}: `{}` isn't a color — #RRGGBB or #AARRGGBB", e.name, e.text())),
(Kind::Number, Value::Num(_)) | (Kind::Bool, Value::Bool(_)) | (Kind::String, Value::Str(_)) => None,
(Kind::Number, _) => Some(format!("{:?}: a number", e.name)),
(Kind::Bool, _) => Some(format!("{:?}: true or false", e.name)),
(Kind::String, _) => Some(format!("{:?}: a text", e.name)),
}
}
pub fn color_of(e: &Entry) -> Option<u32> {
(e.kind == Kind::Color).then(|| parse_color(e.text())).flatten()
}
fn parse_color(s: &str) -> Option<u32> {
crate::yui::color(s).filter(|_| s.trim().starts_with(crate::yui::HEX_MARK))
}
fn lang_json(entries: &[(String, String)]) -> String {
let mut out = String::from("{");
for (n, (k, v)) in entries.iter().enumerate() {
out.push_str(if n == 0 { "\n " } else { ",\n " });
out.push_str(&serde_json::to_string(k).unwrap_or_default());
out.push_str(": ");
out.push_str(&serde_json::to_string(v).unwrap_or_default());
}
out.push_str(if entries.is_empty() { "}\n" } else { "\n}\n" });
out
}
fn preview(names: &[&str]) -> String {
const SHOWN: usize = 5;
let mut text = names
.iter()
.take(SHOWN)
.copied()
.collect::<Vec<_>>()
.join(", ");
if names.len() > SHOWN {
let _ = write!(text, ", … (+{})", names.len() - SHOWN);
}
text
}
fn doc_line(s: &str) -> String {
let one: String = s
.chars()
.map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
.collect();
one.replace("*/", "* /")
}
pub fn compile(root: &Path, mod_id: &str) -> Result<Compiled, String> {
let mut errors = Vec::new();
let sets = load(root, &mut errors);
let mut c = Compiled {
files: sets.values().map(|s| 1 + s.cultures.len()).sum(),
..Compiled::default()
};
let mut lang: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
let mut key_origin: HashMap<String, String> = HashMap::new();
let mut bundled: BTreeMap<String, (String, Vec<u8>)> = BTreeMap::new();
let mut sounds: BTreeMap<String, serde_json::Value> = BTreeMap::new();
for set in sets.values() {
let shown = display(root, &set.neutral.path);
let mut code = String::new();
let mut accessors: HashMap<String, usize> = HashMap::new();
let mut text_params: HashMap<&str, (String, Vec<String>)> = HashMap::new();
let _ = writeln!(
code,
"/// The resources of `{shown}`.\n#[allow(dead_code)]\npub struct {name};\n\n#[allow(dead_code, non_snake_case, clippy::all)]\nimpl {name} {{",
name = set.name
);
for e in &set.neutral.entries {
let at = format!("{shown}:{}", e.line);
let acc = accessor_name(&e.name);
if RUST_KEYWORDS.contains(&acc.as_str()) {
errors.push(format!(
"{at}: {:?} gives the accessor `{acc}`, a Rust keyword — rename it",
e.name
));
}
if let Some(first) = accessors.insert(acc.clone(), e.line) {
errors.push(format!(
"{at}: {:?} gives the same accessor `{acc}` as the entry on line {first}",
e.name
));
}
let upper = acc.to_ascii_uppercase();
let doc = {
let what = match e.kind {
Kind::String => format!(
"Looks up a localized string similar to \"{}\".",
doc_line(e.text())
),
Kind::Image => format!("The image `{}`.", e.name),
Kind::File => format!("The file `{}`.", e.name),
Kind::Sound => format!("The sound `{}`.", e.name),
Kind::Color => format!("The color `{}`.", doc_line(e.text())),
Kind::Number | Kind::Bool => match &e.value {
Value::Num(n) => format!("The number `{n}`."),
Value::Bool(b) => format!("The flag `{b}`."),
Value::Str(v) => format!("The value `{}`.", doc_line(v)),
Value::Bytes(b) => format!("{} bytes.", b.len()),
},
};
let mut doc = format!(" /// {what}\n ///\n /// `{}`", e.name);
if let Some(comment) = &e.comment {
let _ = write!(doc, " — {}", doc_line(comment));
}
let _ = writeln!(doc, " ({shown}:{})", e.line);
doc
};
let reference = format!("{}.{}", set.name, e.name);
if let Some(problem) = value_problem(e) {
errors.push(format!("{at}: {problem}"));
continue;
}
match e.kind {
Kind::String => {
let key = e
.key
.clone()
.unwrap_or_else(|| default_key(&set.name, mod_id, &e.name));
if let Some(first) = key_origin.insert(key.clone(), at.clone()) {
errors.push(format!(
"{at}: translation key {key:?} is also used at {first}"
));
}
let segs = match segments(e.text()) {
Ok(s) => s,
Err(err) => {
errors.push(format!("{at}: {:?}: {err}", e.name));
continue;
}
};
let ps = match params(&segs) {
Ok(p) => p,
Err(err) => {
errors.push(format!("{at}: {:?}: {err}", e.name));
continue;
}
};
if let Ok(text) = lang_text(&segs, &ps) {
lang.entry(NEUTRAL_LOCALE.into())
.or_default()
.push((key.clone(), text));
}
let _ = writeln!(
code,
" /// Translation key of [`Self::{acc}`]: `{key}`.\n pub const {upper}_KEY: &'static str = {key:?};"
);
code.push_str(&doc);
if ps.is_empty() {
let _ = writeln!(
code,
" pub fn {acc}() -> ::std::string::String {{\n ::yog_api::tr(Self::{upper}_KEY)\n }}"
);
} else {
let idents: Vec<String> = ps.iter().map(|p| param_ident(p)).collect();
let sig = idents
.iter()
.map(|p| format!("{p}: impl ::std::fmt::Display"))
.collect::<Vec<_>>()
.join(", ");
let args = idents
.iter()
.map(|p| format!("&{p}"))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(
code,
" pub fn {acc}({sig}) -> ::std::string::String {{\n ::yog_api::tr_args(Self::{upper}_KEY, &[{args}])\n }}"
);
}
c.refs
.texts
.insert(reference, (key.clone(), e.text().to_string()));
text_params.insert(&e.name, (key, ps));
}
Kind::Image | Kind::File | Kind::Sound => {
let Value::Bytes(bytes) = &e.value else { continue };
let path = e.name.replace('.', yog_ids::sep::PATH);
let (target, id, data) = match e.kind {
Kind::Image => {
let png = match yog_media::image::decode(bytes).and_then(|img| yog_media::image::encode_png(&img)) {
Ok(png) => png,
Err(err) => {
errors.push(format!("{at}: {:?}: {err}", e.name));
continue;
}
};
let source = yog_ids::join(mod_id, &path);
let target = format!("{}/{mod_id}/{}", yog_ids::pack::ASSETS, yog_ids::texture_file(&path));
c.refs.images.insert(reference.clone(), source.clone());
c.refs.pictures.insert(reference, Picture { png: png.clone(), output: target.clone() });
(target, source, png)
}
Kind::Sound => {
let event = e
.event
.clone()
.unwrap_or_else(|| e.name.to_ascii_lowercase());
let mut def =
serde_json::json!({ "sounds": [yog_ids::join(mod_id, &path)] });
if let Some(sub) = &e.subtitle {
def["subtitle"] = serde_json::Value::String(sub.clone());
}
if sounds.insert(event.clone(), def).is_some() {
errors
.push(format!("{at}: sound event {event:?} is defined twice"));
}
(
format!("{}/{mod_id}/sounds/{path}.ogg", yog_ids::pack::ASSETS),
yog_ids::join(mod_id, &event),
bytes.clone(),
)
}
_ => (format!("{}/{mod_id}/{path}", yog_ids::pack::ASSETS), yog_ids::join(mod_id, &path), bytes.clone()),
};
if let Some(other) = bundled.insert(target.clone(), (e.name.clone(), data))
&& other.0 != e.name
{
errors.push(format!("{at}: {:?} goes to {target}, like {:?}", e.name, other.0));
}
let _ = writeln!(
code,
"{doc} pub const fn {acc}() -> &'static str {{\n {id:?}\n }}"
);
}
Kind::Color => {
let Some(v) = color_of(e) else { continue };
c.refs.colors.insert(reference, v);
let _ = writeln!(code, "{doc} pub const {upper}: u32 = 0x{v:08X};");
}
Kind::Number => {
let Value::Num(lit) = &e.value else { continue };
let int = !lit.contains(['.', 'e', 'E']);
match (int, lit.parse::<i64>()) {
(true, Ok(v)) => {
let _ = writeln!(code, "{doc} pub const {upper}: i64 = {v};");
}
_ => {
let v: f64 = lit.parse().unwrap_or_default();
let _ = writeln!(code, "{doc} pub const {upper}: f64 = {v:?};");
}
}
}
Kind::Bool => {
let v = matches!(e.value, Value::Bool(true));
let _ = writeln!(code, "{doc} pub const {upper}: bool = {v};");
}
}
}
code.push_str("}\n");
c.sources.insert(set.name.clone(), code);
for (locale, file) in &set.cultures {
let shown = display(root, &file.path);
let mut missing: Vec<&str> = Vec::new();
for e in &set.neutral.entries {
if e.kind == Kind::String
&& !file
.entries
.iter()
.any(|t| t.name == e.name && !t.text().is_empty())
{
missing.push(&e.name);
}
}
let mut extra: Vec<&str> = Vec::new();
for t in &file.entries {
let Some((key, ps)) = text_params.get(t.name.as_str()) else {
extra.push(&t.name);
continue;
};
if t.text().is_empty() {
continue;
}
match segments(t.text()).and_then(|segs| lang_text(&segs, ps)) {
Ok(text) => lang
.entry(locale.clone())
.or_default()
.push((key.clone(), text)),
Err(err) => errors.push(format!("{shown}:{}: {:?}: {err}", t.line, t.name)),
}
}
if !missing.is_empty() {
c.warnings.push(format!(
"{shown}: {} text(s) not translated (shown in {NEUTRAL_LOCALE}): {}",
missing.len(),
preview(&missing)
));
}
if !extra.is_empty() {
c.warnings.push(format!(
"{shown}: {} text(s) not in {}.yres, left out: {}",
extra.len(),
set.name,
preview(&extra)
));
}
}
}
for (locale, mut entries) in lang {
let path = format!("assets/{mod_id}/lang/{locale}.json");
if let Ok(text) = std::fs::read_to_string(root.join(&path)) {
match parse(&text) {
Ok(hand) => {
let mut merged: Vec<(String, String)> = Vec::new();
for h in hand {
if let Some(at) = key_origin.get(&h.name) {
errors.push(format!(
"{path}:{}: {:?} is also defined at {at}",
h.line, h.name
));
}
merged.push((h.name.clone(), h.text().to_string()));
}
merged.append(&mut entries);
entries = merged;
}
Err(e) => errors.push(format!("{path}:{}", e.trim_start_matches("line "))),
}
}
c.outputs.push((path, lang_json(&entries).into_bytes()));
}
for (target, (_, bytes)) in bundled {
c.outputs.push((target, bytes));
}
if !sounds.is_empty() {
let path = format!("assets/{mod_id}/sounds.json");
let mut all = serde_json::Map::new();
if let Ok(text) = std::fs::read_to_string(root.join(&path)) {
match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&text) {
Ok(hand) => all = hand,
Err(e) => errors.push(format!("{path}: {e}")),
}
}
for (event, def) in sounds {
if all.insert(event.clone(), def).is_some() {
errors.push(format!(
"{path}: sound event {event:?} is also defined by a .yres sound"
));
}
}
let json =
serde_json::to_string_pretty(&serde_json::Value::Object(all)).unwrap_or_default();
c.outputs.push((path, format!("{json}\n").into_bytes()));
}
if !errors.is_empty() {
return Err(format!(
"invalid .yres resources:\n {}",
errors.join("\n ")
));
}
Ok(c)
}
const BANNER: &str = "// <auto-generated>\n// Generated by `yog build` from {source} — don't edit,\n// it's rewritten on every build.\n// </auto-generated>\n\n";
pub fn write_sources(root: &Path, compiled: &Compiled) -> Result<(), String> {
let dir = root.join(GENERATED_DIR);
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name
.strip_suffix(".g.rs")
.is_some_and(|set| !compiled.sources.contains_key(set))
{
let _ = std::fs::remove_file(entry.path());
}
}
}
let mut index = BANNER.replace("{source}", "resources/**/*.yres");
index.push_str("// Included by `yog_api::resources!()` as `crate::res`.\n");
for (set, code) in &compiled.sources {
let source = code
.lines()
.next()
.and_then(|l| l.split('`').nth(1))
.unwrap_or("resources/");
let mut file = BANNER.replace("{source}", source);
file.push_str(code);
crate::write_if_changed(&dir.join(format!("{set}.g.rs")), &file)?;
let _ = writeln!(
index,
"include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/resources/{set}.g.rs\"));"
);
}
crate::write_if_changed(&root.join(GENERATED_FILE), &index)
}
pub fn resolve_text(refs: &Refs, value: &str) -> Result<(String, Option<String>), String> {
match value.strip_prefix('@') {
Some(rest) if rest.starts_with('@') => Ok((rest.to_string(), None)),
Some(rest) => match refs.texts.get(rest) {
Some((key, text)) => Ok((text.clone(), Some(key.clone()))),
None => Err(format!("`{value}`: no text `{rest}` in resources/*.yres")),
},
None => Ok((value.to_string(), None)),
}
}
pub fn resolve_image(refs: &Refs, value: &str) -> Result<String, String> {
match value.strip_prefix('@') {
Some(rest) => refs
.images
.get(rest)
.cloned()
.ok_or_else(|| format!("`{value}`: no image `{rest}` in resources/*.yres")),
None => Ok(value.to_string()),
}
}
#[cfg(test)]
mod tests {
#[test]
fn rich_text_spans_are_not_placeholders() {
let seg = segments("{#8d6acc|Media} and {b|x} for {name}").unwrap();
assert_eq!(seg, [Segment::Text("{#8d6acc|Media} and {b|x} for ".into()), Segment::Arg("name".into())]);
}
use super::*;
#[test]
fn parses_all_shapes_in_order() {
let text = "{\n // Comments are fine.\n \"b\": \"B\",\n \"a\": { \"value\": \"A \\u00e9\", \"comment\": \"note\", \"key\": \"x.y\" },\n \"n\": 5,\n \"f\": false,\n \"c\": { \"type\": \"color\", \"value\": \"#FF0000\" },\n \"r\": { \"type\": \"number\", \"value\": -1.5e2 },\n \"at\": \"@@everyone\",\n}\n";
let entries = parse(text).unwrap();
assert_eq!(entries.len(), 7);
assert_eq!(entries[0].name, "b");
assert_eq!(entries[1].line, 4);
assert_eq!(entries[1].value, Value::Str("A é".into()));
assert_eq!(entries[1].key.as_deref(), Some("x.y"));
assert_eq!(entries[2].kind, Kind::Number);
assert_eq!(entries[3].value, Value::Bool(false));
assert_eq!(entries[4].kind, Kind::Color);
assert_eq!(entries[5].value, Value::Num("-150.0".into()));
assert_eq!(entries[6].value, Value::Str("@everyone".into()));
let again = parse(&write(&entries)).unwrap();
assert_eq!(again.iter().map(|e| (&e.name, e.kind, &e.value, &e.key)).collect::<Vec<_>>(), entries.iter().map(|e| (&e.name, e.kind, &e.value, &e.key)).collect::<Vec<_>>());
}
#[test]
fn errors_carry_lines() {
let err = parse("{\n \"a\": \"1\",\n \"a\": \"2\"\n}").unwrap_err();
assert_eq!(err, "3:3: `a` is already set in this object");
let err = parse("{\n \"a\": { \"type\": \"bool\", \"value\": \"x\" }\n}").unwrap_err();
assert_eq!(err, "2:35: a.value: a bool needs a true/false value");
let err = parse("{\n\"a\": { \"value\": \"x\", \"note\": \"y\" }\n}").unwrap_err();
assert!(err.contains("2:22: a.note: unknown field `note`"), "{err}");
let err = parse("{\n\"a\": { \"type\": \"image\", \"value\": \"@bytes:AAAA\", \"key\": \"k\" }\n}").unwrap_err();
assert!(err.contains("a.key: only string entries have a `key`"), "{err}");
let err = parse("{\n \"a\": \"1\"\n \"b\": \"2\"\n}").unwrap_err();
assert_eq!(err, "3:3: expected `,` or `}`");
let err = parse("{\n \"a\": [],\n \"b\": { \"type\": \"img\", \"value\": \"@Strings.x\" }\n}").unwrap_err();
assert_eq!(err.lines().collect::<Vec<_>>(), [
"2:8: a: expected a string, a number or true/false, found a list",
"3:18: b.type: unknown `img` — did you mean `image`?",
"3:34: b.value: expected plain text, found a resource (write `@@` for a literal `@`)",
]);
assert!(parse("[]").unwrap_err().contains("expected `{`"));
assert!(parse("\u{FEFF}{ }").unwrap().is_empty());
}
#[test]
fn names_and_keys() {
assert_eq!(accessor_name("screen.title"), "screen_title");
assert_eq!(accessor_name("ModName"), "mod_name");
assert!(valid_name("ruby.tooltip") && !valid_name("1a") && !valid_name("a..b"));
assert!(valid_locale("pt_br") && valid_locale("lol_us") && !valid_locale("EN_US"));
assert_eq!(default_key("Items", "m", "ruby"), "item.m.ruby");
assert_eq!(default_key("Strings", "m", "title"), "m.title");
assert_eq!(
yog_formats::yres::split_file_name("Strings.ru_ru.yres"),
Some(("Strings", Some("ru_ru")))
);
assert_eq!(yog_formats::yres::split_file_name("Icons.yres"), Some(("Icons", None)));
}
#[test]
fn placeholders_become_minecraft_format() {
let segs = segments("{shown} of {total}: 100% {{ok}}").unwrap();
let ps = params(&segs).unwrap();
assert_eq!(ps, ["shown", "total"]);
assert_eq!(lang_text(&segs, &ps).unwrap(), "%1$s of %2$s: 100%% {{ok}}");
let ru = segments("из {total} показано {shown}").unwrap();
assert_eq!(lang_text(&ru, &ps).unwrap(), "из %2$s показано %1$s");
assert!(lang_text(&segments("{other}").unwrap(), &ps).is_err());
let segs = segments("{1} and {0}").unwrap();
assert_eq!(params(&segs).unwrap(), ["0", "1"]);
assert!(params(&segments("{0} {name}").unwrap()).is_err());
assert!(params(&segments("{1}").unwrap()).is_err());
assert!(segments("a { b").is_err());
assert!(segments("a } b").is_err());
}
fn temp_mod(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("yog-yres-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(RESOURCES_DIR)).unwrap();
dir
}
#[test]
fn compiles_a_mod() {
let root = temp_mod("ok");
let res = root.join(RESOURCES_DIR);
std::fs::write(
res.join("Strings.yres"),
r#"{"title": {"value": "Mods", "comment": "c"}, "count": "{n} mods", "gone": "G"}"#,
)
.unwrap();
std::fs::write(
res.join("Strings.ru_ru.yres"),
r#"{"title": "Моды", "count": "Модов: {n}", "x": "X"}"#,
)
.unwrap();
std::fs::write(
res.join("Icons.yres"),
r##"{"images.logo": "@bytes:iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", "accent": {"type": "color", "value": "#FF8800"}, "sounds.click": {"value": "@bytes:T2dnUw==", "event": "click"}, "max": 3, "on": true}"##,
)
.unwrap();
std::fs::write(res.join("Items.yres"), r#"{"ruby": "Ruby"}"#).unwrap();
std::fs::create_dir_all(root.join("assets/m/lang")).unwrap();
std::fs::write(
root.join("assets/m/lang/en_us.json"),
r#"{"hand.key": "Hand"}"#,
)
.unwrap();
let c = compile(&root, "m").unwrap();
let out: BTreeMap<&str, String> = c
.outputs
.iter()
.map(|(p, b)| (p.as_str(), String::from_utf8_lossy(b).into_owned()))
.collect();
assert_eq!(
out["assets/m/lang/en_us.json"],
"{\n \"hand.key\": \"Hand\",\n \"item.m.ruby\": \"Ruby\",\n \"m.title\": \"Mods\",\n \"m.count\": \"%1$s mods\",\n \"m.gone\": \"G\"\n}\n"
);
assert_eq!(
out["assets/m/lang/ru_ru.json"],
"{\n \"m.title\": \"Моды\",\n \"m.count\": \"Модов: %1$s\"\n}\n"
);
assert!(out["assets/m/textures/images/logo.png"].starts_with("\u{89}PNG") || out.contains_key("assets/m/textures/images/logo.png"));
assert_eq!(out["assets/m/sounds/sounds/click.ogg"], "OggS");
assert!(out["assets/m/sounds.json"].contains("\"m:sounds/click\""));
assert_eq!(c.refs.images["Icons.images.logo"], "m:images/logo");
assert!(c.refs.pictures.contains_key("Icons.images.logo"));
assert_eq!(
c.refs.texts["Strings.title"],
("m.title".to_string(), "Mods".to_string())
);
let strings = &c.sources["Strings"];
assert!(
strings.contains("pub const TITLE_KEY: &'static str = \"m.title\";"),
"{strings}"
);
assert!(
strings.contains("pub fn count(n: impl ::std::fmt::Display)"),
"{strings}"
);
assert!(
strings.contains("::yog_api::tr_args(Self::COUNT_KEY, &[&n])"),
"{strings}"
);
let icons = &c.sources["Icons"];
assert!(
icons.contains("pub const fn images_logo() -> &'static str {\n \"m:images/logo\""),
"{icons}"
);
assert!(
icons.contains("pub const ACCENT: u32 = 0xFFFF8800;"),
"{icons}"
);
assert!(icons.contains("pub const MAX: i64 = 3;"), "{icons}");
assert!(icons.contains("pub const ON: bool = true;"), "{icons}");
assert!(icons.contains("\"m:click\""), "{icons}");
assert_eq!(c.warnings.len(), 2, "{:?}", c.warnings);
assert!(
c.warnings[0].contains("1 text(s) not translated") && c.warnings[0].contains("gone")
);
assert!(c.warnings[1].contains("left out: x"));
write_sources(&root, &c).unwrap();
let icons_rs =
std::fs::read_to_string(root.join(GENERATED_DIR).join("Icons.g.rs")).unwrap();
assert!(icons_rs.starts_with("// <auto-generated>"), "{icons_rs}");
assert!(icons_rs.contains("pub struct Icons;"), "{icons_rs}");
let index = std::fs::read_to_string(root.join(GENERATED_FILE)).unwrap();
assert!(
index.contains("/generated/resources/Icons.g.rs\"));"),
"{index}"
);
assert!(
index.contains("/generated/resources/Strings.g.rs\"));"),
"{index}"
);
let (name, key) = resolve_text(&c.refs, "@Strings.title").unwrap();
assert_eq!((name.as_str(), key.as_deref()), ("Mods", Some("m.title")));
assert!(resolve_text(&c.refs, "@Strings.nope").is_err());
assert_eq!(
resolve_image(&c.refs, "@Icons.images.logo").unwrap(),
"m:images/logo"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn reports_every_problem_with_file_and_line() {
let root = temp_mod("errors");
let res = root.join(RESOURCES_DIR);
std::fs::write(
res.join("Strings.yres"),
"{\n \"a\": \"A\",\n \"b\": { \"value\": \"B\", \"key\": \"m.a\" },\n \"img\": { \"type\": \"image\", \"value\": \"@bytes:AAAA\" },\n \"bad name\": \"x\",\n \"col\": { \"type\": \"color\", \"value\": \"red\" }\n}",
)
.unwrap();
std::fs::write(res.join("Strings.en_us.yres"), "{}").unwrap();
std::fs::write(res.join("Strings.de_de.yres"), "{\n \"a\": 1\n}").unwrap();
std::fs::write(res.join("Other.ru_ru.yres"), "{}").unwrap();
let err = compile(&root, "m").unwrap_err();
for needle in [
"resources/Strings.yres:3: translation key \"m.a\" is also used at resources/Strings.yres:2",
"resources/Strings.yres:4: \"img\": not a picture this reads",
"resources/Strings.yres:5: \"bad name\" isn't a valid name",
"resources/Strings.yres:6: \"col\": `red` isn't a color",
"Strings.en_us.yres: the neutral file Strings.yres is already en_us",
"resources/Strings.de_de.yres:2: \"a\": culture variants hold texts only",
"resources/Other.ru_ru.yres: no neutral Other.yres",
] {
assert!(err.contains(needle), "missing {needle:?} in:\n{err}");
}
let _ = std::fs::remove_dir_all(&root);
}
}