Skip to main content

yog_cli/
yres.rs

1//! `.yres` resource files — a mod's texts, images, sounds, files, colors,
2//! numbers and flags, like a C# project's `Resources/*.resx`. See
3//! Yog-IDLE's `docs/yres.md` for the format.
4//!
5//! `yog build` reads `resources/**/*.yres`: `<Set>.yres` is the neutral
6//! (en_us) file of resource set `<Set>`, `<Set>.<locale>.yres` its culture
7//! variants (texts only). It
8//!
9//! - writes the texts into `assets/<mod id>/lang/<locale>.json` (merged with
10//!   a hand-written one), `{name}`/`{0}` placeholders turned into `%1$s`;
11//! - bundles image/file/sound entries' files under `assets/<mod id>/...`,
12//!   plus a `sounds.json` for the sounds;
13//! - generates `.yog-build/generated/resources/<Set>.g.rs` — a type per
14//!   set with a typed accessor per entry — and `resources.g.rs` including
15//!   them, pulled in as `crate::res` by `yog_api::resources!();`;
16//! - resolves `@<Set>.<name>` references for `.yui` screens and `yog.toml`.
17
18use std::collections::{BTreeMap, HashMap};
19use std::fmt::Write as _;
20use std::path::{Path, PathBuf};
21
22
23/// The locale of the neutral `<Set>.yres` files.
24pub const NEUTRAL_LOCALE: &str = "en_us";
25/// Where resource files live, under the mod root.
26pub const RESOURCES_DIR: &str = yog_ids::project::RESOURCES;
27/// Where the generated accessors go, under the mod root.
28/// The file `yog_api::resources!()` includes; it includes one
29/// `resources/<Set>.g.rs` per set (shown under its `.yres` in Yog-IDLE).
30pub const GENERATED_FILE: &str = ".yog-build/generated/resources.g.rs";
31/// Where the per-set `<Set>.g.rs` files go.
32pub const GENERATED_DIR: &str = ".yog-build/generated/resources";
33
34/// A generated file: path inside the `.yog` and its bytes.
35pub type Output = (String, Vec<u8>);
36
37// ── Parsing ───────────────────────────────────────────────────────────────────
38
39pub use yog_formats::yres::{Entry, EntryObject, Kind, Value, parse, write};
40
41// ── Names ─────────────────────────────────────────────────────────────────────
42
43const RUST_KEYWORDS: &[&str] = &[
44    "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
45    "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
46    "ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
47    "where", "while", "abstract", "become", "box", "do", "final", "gen", "macro", "override",
48    "priv", "try", "typeof", "unsized", "virtual", "yield",
49];
50
51fn ident_part(s: &str) -> bool {
52    let mut chars = s.chars();
53    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
54        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
55}
56
57/// An entry name: identifiers joined by `.` (`title`, `screen.title`).
58pub fn valid_name(name: &str) -> bool {
59    yog_data::typed::is_res_key(name)
60}
61
62/// What's wrong with entry name `name`, if anything.
63pub fn name_problem(name: &str) -> Option<String> {
64    (!valid_name(name)).then(|| format!("{name:?} isn't a valid name — identifiers joined by `.`, like screen.title"))
65}
66
67/// A resource set name: one identifier (`Strings`, `Icons`).
68pub fn valid_set_name(name: &str) -> bool {
69    ident_part(name) && !RUST_KEYWORDS.contains(&name)
70}
71
72/// Minecraft's locale codes: lower case, `en_us`, `pt_br`, `lol_us`, ...
73pub fn valid_locale(locale: &str) -> bool {
74    let Some((lang, region)) = locale.split_once('_') else {
75        return false;
76    };
77    (2..=3).contains(&lang.len())
78        && lang.bytes().all(|b| b.is_ascii_lowercase())
79        && (2..=4).contains(&region.len())
80        && region
81            .bytes()
82            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
83}
84
85/// The accessor for entry `name`: `screen.title` → `screen_title`,
86/// `ModName` → `mod_name`.
87pub fn accessor_name(name: &str) -> String {
88    let mut out = String::new();
89    for (n, part) in name.split('.').enumerate() {
90        if n > 0 {
91            out.push('_');
92        }
93        let mut prev_lower = false;
94        for c in part.chars() {
95            if c.is_ascii_uppercase() && prev_lower {
96                out.push('_');
97            }
98            prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
99            out.push(c.to_ascii_lowercase());
100        }
101    }
102    out
103}
104
105/// The default translation key of text `name` in set `set`.
106pub fn default_key(set: &str, mod_id: &str, name: &str) -> String {
107    match set {
108        "Items" => format!("item.{mod_id}.{name}"),
109        "Blocks" => format!("block.{mod_id}.{name}"),
110        "Entities" => format!("entity.{mod_id}.{name}"),
111        _ => format!("{mod_id}.{name}"),
112    }
113}
114
115// ── Placeholders ──────────────────────────────────────────────────────────────
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118enum Segment {
119    Text(String),
120    /// `{name}` or `{0}`.
121    Arg(String),
122}
123
124/// Splits a text into literal parts and `{arg}` placeholders. Yog rich
125/// text (`{b|…}`, `{#rrggbb|…}`: an opening with a `|` before any brace)
126/// and its `\` escapes stay in the text as they are.
127fn segments(text: &str) -> Result<Vec<Segment>, String> {
128    let mut out = Vec::new();
129    let mut lit = String::new();
130    let mut spans = 0usize;
131    let mut chars = text.chars().peekable();
132    while let Some(c) = chars.next() {
133        match c {
134            '{' if {
135                let rest: String = chars.clone().take_while(|&x| x != '{' && x != '}').collect();
136                rest.contains('|')
137            } =>
138            {
139                lit.push('{');
140                spans += 1;
141            }
142            '}' if spans > 0 => {
143                lit.push('}');
144                spans -= 1;
145            }
146            '{' if chars.peek() == Some(&'{') => {
147                chars.next();
148                lit.push_str("{{");
149            }
150            '}' if chars.peek() == Some(&'}') => {
151                chars.next();
152                lit.push_str("}}");
153            }
154            '{' => {
155                let mut arg = String::new();
156                loop {
157                    match chars.next() {
158                        Some('}') => break,
159                        Some(c) => arg.push(c),
160                        None => {
161                            return Err("`{` without `}` (write `{{` for a literal brace)".into());
162                        }
163                    }
164                }
165                let arg = arg.trim().to_string();
166                let numeric = !arg.is_empty() && arg.bytes().all(|b| b.is_ascii_digit());
167                if !numeric && (!ident_part(&arg) || RUST_KEYWORDS.contains(&arg.as_str())) {
168                    return Err(format!(
169                        "placeholder `{{{arg}}}` must be a number or an identifier (write `{{{{` for a literal brace)"
170                    ));
171                }
172                if !lit.is_empty() {
173                    out.push(Segment::Text(std::mem::take(&mut lit)));
174                }
175                out.push(Segment::Arg(arg));
176            }
177            '}' => return Err("`}` without `{` (write `}}` for a literal brace)".into()),
178            c => lit.push(c),
179        }
180    }
181    if !lit.is_empty() {
182        out.push(Segment::Text(lit));
183    }
184    Ok(out)
185}
186
187/// The parameters of a neutral text, in order: `{0}`, `{1}`, … by number
188/// (no gaps), named ones by first appearance; one style per text.
189fn params(segs: &[Segment]) -> Result<Vec<String>, String> {
190    let args: Vec<&String> = segs
191        .iter()
192        .filter_map(|s| match s {
193            Segment::Arg(a) => Some(a),
194            Segment::Text(_) => None,
195        })
196        .collect();
197    let numeric = args
198        .iter()
199        .filter(|a| a.bytes().all(|b| b.is_ascii_digit()))
200        .count();
201    if numeric > 0 && numeric < args.len() {
202        return Err("mixes numbered `{0}` and named `{name}` placeholders — use one style".into());
203    }
204    let mut out: Vec<String> = Vec::new();
205    if numeric > 0 {
206        let mut nums: Vec<usize> = args.iter().filter_map(|a| a.parse().ok()).collect();
207        nums.sort_unstable();
208        nums.dedup();
209        for (i, n) in nums.iter().enumerate() {
210            if *n != i {
211                return Err(format!(
212                    "placeholder {{{i}}} is missing (numbered placeholders start at {{0}}, no gaps)"
213                ));
214            }
215            out.push(n.to_string());
216        }
217    } else {
218        for a in args {
219            if !out.contains(a) {
220                out.push(a.clone());
221            }
222        }
223    }
224    Ok(out)
225}
226
227/// Minecraft's form of a text: `%` → `%%`, the placeholder for parameter
228/// `i` → `%{i+1}$s`. Errors on a placeholder not in `params`.
229fn lang_text(segs: &[Segment], params: &[String]) -> Result<String, String> {
230    let mut out = String::new();
231    for s in segs {
232        match s {
233            Segment::Text(t) => out.push_str(&t.replace('%', "%%")),
234            Segment::Arg(a) => {
235                let pos = params
236                    .iter()
237                    .position(|p| p == a)
238                    .ok_or_else(|| format!("placeholder `{{{a}}}` isn't in the neutral text"))?;
239                let _ = write!(out, "%{}$s", pos + 1);
240            }
241        }
242    }
243    Ok(out)
244}
245
246fn param_ident(p: &str) -> String {
247    if p.bytes().all(|b| b.is_ascii_digit()) {
248        format!("arg{p}")
249    } else {
250        accessor_name(p)
251    }
252}
253
254// ── Loading a mod's resources ────────────────────────────────────────────────
255
256/// One `.yres` file.
257#[derive(Debug, Clone)]
258pub struct ResFile {
259    pub path: PathBuf,
260    pub entries: Vec<Entry>,
261}
262
263/// A resource set: its neutral file and culture variants.
264#[derive(Debug, Clone)]
265pub struct Set {
266    pub name: String,
267    pub neutral: ResFile,
268    pub cultures: BTreeMap<String, ResFile>,
269}
270
271/// What `.yui` screens and `yog.toml` can refer to as `@<Set>.<name>`.
272#[derive(Debug, Clone, Default)]
273pub struct Refs {
274    /// `Set.name` → (translation key, neutral text).
275    pub texts: HashMap<String, (String, String)>,
276    /// `Set.name` → image source (`<mod id>:<texture>`).
277    pub images: HashMap<String, String>,
278    /// `Set.name` → color (`0xAARRGGBB`).
279    pub colors: HashMap<String, u32>,
280    /// `Set.name` → an image's picture, as the build writes it.
281    pub pictures: HashMap<String, Picture>,
282}
283
284/// An image's picture: its PNG and the file the build writes it to — which
285/// a texture packed into an atlas takes the place of.
286#[derive(Debug, Clone, Default, PartialEq)]
287pub struct Picture {
288    pub png: Vec<u8>,
289    pub output: String,
290}
291
292/// Whether `path` is a valid resource path: lower case a-z, 0-9, `_`, `-`, `.`, `/`.
293fn valid_resource_path(path: &str) -> bool {
294    !path.is_empty() && path.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.' | b'/'))
295}
296
297/// Everything `yog build` makes of a mod's resources.
298#[derive(Debug, Default)]
299pub struct Compiled {
300    /// Files to add to the `.yog` (lang JSON, images, sounds, sounds.json) —
301    /// they replace a hand-written asset at the same path (merged into them).
302    pub outputs: Vec<Output>,
303    /// `<Set>` → generated Rust source.
304    pub sources: BTreeMap<String, String>,
305    pub refs: Refs,
306    pub warnings: Vec<String>,
307    /// Number of `.yres` files read.
308    pub files: usize,
309}
310
311/// Every `resources/**/*.yres` of the mod at `root`, sorted.
312pub fn find(root: &Path) -> Vec<PathBuf> {
313    fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
314        let Ok(entries) = std::fs::read_dir(dir) else {
315            return;
316        };
317        for entry in entries.flatten() {
318            let path = entry.path();
319            if path.is_dir() {
320                walk(&path, out);
321            } else if yog_ids::Format::Res.is(&path) {
322                out.push(path);
323            }
324        }
325    }
326    let mut out = Vec::new();
327    walk(&root.join(RESOURCES_DIR), &mut out);
328    out.sort();
329    out
330}
331
332fn display(root: &Path, p: &Path) -> String {
333    p.strip_prefix(root)
334        .unwrap_or(p)
335        .to_string_lossy()
336        .replace('\\', "/")
337}
338
339/// Reads and checks every `.yres` of the mod — errors are `file:line: message`.
340fn load(root: &Path, errors: &mut Vec<String>) -> BTreeMap<String, Set> {
341    let mut neutral: BTreeMap<String, ResFile> = BTreeMap::new();
342    let mut cultures: Vec<(String, String, ResFile)> = Vec::new();
343    for path in find(root) {
344        let shown = display(root, &path);
345        let file_name = path
346            .file_name()
347            .unwrap_or_default()
348            .to_string_lossy()
349            .into_owned();
350        let Some((set, locale)) = yog_formats::yres::split_file_name(&file_name) else {
351            continue;
352        };
353        if !valid_set_name(set) {
354            errors.push(format!(
355                "{shown}: `{set}` isn't a valid resource set name — use an identifier like Strings or Icons"
356            ));
357            continue;
358        }
359        if let Some(locale) = locale {
360            if locale == NEUTRAL_LOCALE {
361                errors.push(format!(
362                    "{shown}: the neutral file {set}.yres is already {NEUTRAL_LOCALE} — put these texts there"
363                ));
364                continue;
365            }
366            if !valid_locale(locale) {
367                errors.push(format!(
368                    "{shown}: `{locale}` isn't a Minecraft locale code (lower case, like ru_ru or pt_br)"
369                ));
370                continue;
371            }
372        }
373        let entries = match std::fs::read_to_string(&path)
374            .map_err(|e| e.to_string())
375            .and_then(|t| parse(&t))
376        {
377            Ok(entries) => entries,
378            Err(e) => {
379                errors.push(format!("{shown}:{}", e.trim_start_matches("line ")));
380                continue;
381            }
382        };
383        for e in &entries {
384            if let Some(problem) = name_problem(&e.name) {
385                errors.push(format!("{shown}:{}: {problem}", e.line));
386            }
387        }
388        let file = ResFile { path, entries };
389        match locale {
390            None => {
391                if let Some(other) = neutral.get(set) {
392                    errors.push(format!(
393                        "{shown}: resource set {set} is also defined by {}",
394                        display(root, &other.path)
395                    ));
396                    continue;
397                }
398                neutral.insert(set.to_string(), file);
399            }
400            Some(locale) => cultures.push((set.to_string(), locale.to_string(), file)),
401        }
402    }
403    let mut sets: BTreeMap<String, Set> = neutral
404        .into_iter()
405        .map(|(name, neutral)| {
406            (
407                name.clone(),
408                Set {
409                    name,
410                    neutral,
411                    cultures: BTreeMap::new(),
412                },
413            )
414        })
415        .collect();
416    for (set, locale, file) in cultures {
417        let shown = display(root, &file.path);
418        let Some(target) = sets.get_mut(&set) else {
419            errors.push(format!(
420                "{shown}: no neutral {set}.yres for this culture variant"
421            ));
422            continue;
423        };
424        if let Some(other) = target.cultures.get(&locale) {
425            errors.push(format!(
426                "{shown}: {set} {locale} is also defined by {}",
427                display(root, &other.path)
428            ));
429            continue;
430        }
431        for e in &file.entries {
432            if e.kind != Kind::String || e.key.is_some() {
433                errors.push(format!(
434                    "{shown}:{}: {:?}: culture variants hold texts only (no type other than string, no `key`)",
435                    e.line, e.name
436                ));
437            }
438        }
439        target.cultures.insert(locale, file);
440    }
441    sets
442}
443
444/// What's wrong with `e`'s value for its type, if anything — the build's
445/// rule, which editors show too.
446pub fn value_problem(e: &Entry) -> Option<String> {
447    let media = |what: &str| format!("{:?}: {what} are bytes (`@bytes:…`)", e.name);
448    match (e.kind, &e.value) {
449        (Kind::Image | Kind::File | Kind::Sound, Value::Bytes(_)) if !valid_resource_path(&e.name.replace('.', yog_ids::sep::PATH)) => {
450            Some(format!("{:?}: Minecraft resource paths are lower case — only a-z, 0-9, `_`, `-`, `.`", e.name))
451        }
452        (Kind::Sound, Value::Bytes(b)) if !b.starts_with(yog_formats::yres::OGG_MAGIC) => Some(format!("{:?}: sounds are Ogg Vorbis", e.name)),
453        (Kind::Image | Kind::File | Kind::Sound, Value::Bytes(_)) => None,
454        (Kind::Image, _) => Some(media("images")),
455        (Kind::File, _) => Some(media("files")),
456        (Kind::Sound, _) => Some(media("sounds")),
457        (Kind::Color, _) => parse_color(e.text()).is_none().then(|| format!("{:?}: `{}` isn't a color — #RRGGBB or #AARRGGBB", e.name, e.text())),
458        (Kind::Number, Value::Num(_)) | (Kind::Bool, Value::Bool(_)) | (Kind::String, Value::Str(_)) => None,
459        (Kind::Number, _) => Some(format!("{:?}: a number", e.name)),
460        (Kind::Bool, _) => Some(format!("{:?}: true or false", e.name)),
461        (Kind::String, _) => Some(format!("{:?}: a text", e.name)),
462    }
463}
464
465/// A color entry's value as ARGB, when it is one.
466pub fn color_of(e: &Entry) -> Option<u32> {
467    (e.kind == Kind::Color).then(|| parse_color(e.text())).flatten()
468}
469
470/// `#RRGGBB` / `#AARRGGBB`: a `.yres` color is written with its `#`.
471fn parse_color(s: &str) -> Option<u32> {
472    crate::yui::color(s).filter(|_| s.trim().starts_with(crate::yui::HEX_MARK))
473}
474
475/// The lang JSON of `entries` (key, text), order kept.
476fn lang_json(entries: &[(String, String)]) -> String {
477    let mut out = String::from("{");
478    for (n, (k, v)) in entries.iter().enumerate() {
479        out.push_str(if n == 0 { "\n  " } else { ",\n  " });
480        out.push_str(&serde_json::to_string(k).unwrap_or_default());
481        out.push_str(": ");
482        out.push_str(&serde_json::to_string(v).unwrap_or_default());
483    }
484    out.push_str(if entries.is_empty() { "}\n" } else { "\n}\n" });
485    out
486}
487
488fn preview(names: &[&str]) -> String {
489    const SHOWN: usize = 5;
490    let mut text = names
491        .iter()
492        .take(SHOWN)
493        .copied()
494        .collect::<Vec<_>>()
495        .join(", ");
496    if names.len() > SHOWN {
497        let _ = write!(text, ", … (+{})", names.len() - SHOWN);
498    }
499    text
500}
501
502fn doc_line(s: &str) -> String {
503    let one: String = s
504        .chars()
505        .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
506        .collect();
507    one.replace("*/", "* /")
508}
509
510/// Compiles the mod's resources (see the module docs). Any error fails the
511/// whole thing, listing every problem.
512pub fn compile(root: &Path, mod_id: &str) -> Result<Compiled, String> {
513    let mut errors = Vec::new();
514    let sets = load(root, &mut errors);
515    let mut c = Compiled {
516        files: sets.values().map(|s| 1 + s.cultures.len()).sum(),
517        ..Compiled::default()
518    };
519    // locale → (key, text), in set order then file order.
520    let mut lang: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
521    let mut key_origin: HashMap<String, String> = HashMap::new();
522    // Bundled path → the entry it comes from, and its bytes.
523    let mut bundled: BTreeMap<String, (String, Vec<u8>)> = BTreeMap::new();
524    let mut sounds: BTreeMap<String, serde_json::Value> = BTreeMap::new();
525
526    for set in sets.values() {
527        let shown = display(root, &set.neutral.path);
528        let mut code = String::new();
529        let mut accessors: HashMap<String, usize> = HashMap::new();
530        // Neutral text params per name, for checking the cultures.
531        let mut text_params: HashMap<&str, (String, Vec<String>)> = HashMap::new();
532        let _ = writeln!(
533            code,
534            "/// The resources of `{shown}`.\n#[allow(dead_code)]\npub struct {name};\n\n#[allow(dead_code, non_snake_case, clippy::all)]\nimpl {name} {{",
535            name = set.name
536        );
537        for e in &set.neutral.entries {
538            let at = format!("{shown}:{}", e.line);
539            let acc = accessor_name(&e.name);
540            if RUST_KEYWORDS.contains(&acc.as_str()) {
541                errors.push(format!(
542                    "{at}: {:?} gives the accessor `{acc}`, a Rust keyword — rename it",
543                    e.name
544                ));
545            }
546            if let Some(first) = accessors.insert(acc.clone(), e.line) {
547                errors.push(format!(
548                    "{at}: {:?} gives the same accessor `{acc}` as the entry on line {first}",
549                    e.name
550                ));
551            }
552            let upper = acc.to_ascii_uppercase();
553            // Like Resources.Designer.cs: what the entry holds, then its comment.
554            let doc = {
555                let what = match e.kind {
556                    Kind::String => format!(
557                        "Looks up a localized string similar to \"{}\".",
558                        doc_line(e.text())
559                    ),
560                    Kind::Image => format!("The image `{}`.", e.name),
561                    Kind::File => format!("The file `{}`.", e.name),
562                    Kind::Sound => format!("The sound `{}`.", e.name),
563                    Kind::Color => format!("The color `{}`.", doc_line(e.text())),
564                    Kind::Number | Kind::Bool => match &e.value {
565                        Value::Num(n) => format!("The number `{n}`."),
566                        Value::Bool(b) => format!("The flag `{b}`."),
567                        Value::Str(v) => format!("The value `{}`.", doc_line(v)),
568                        Value::Bytes(b) => format!("{} bytes.", b.len()),
569                    },
570                };
571                let mut doc = format!("    /// {what}\n    ///\n    /// `{}`", e.name);
572                if let Some(comment) = &e.comment {
573                    let _ = write!(doc, " — {}", doc_line(comment));
574                }
575                let _ = writeln!(doc, " ({shown}:{})", e.line);
576                doc
577            };
578            let reference = format!("{}.{}", set.name, e.name);
579            if let Some(problem) = value_problem(e) {
580                errors.push(format!("{at}: {problem}"));
581                continue;
582            }
583            match e.kind {
584                Kind::String => {
585                    let key = e
586                        .key
587                        .clone()
588                        .unwrap_or_else(|| default_key(&set.name, mod_id, &e.name));
589                    if let Some(first) = key_origin.insert(key.clone(), at.clone()) {
590                        errors.push(format!(
591                            "{at}: translation key {key:?} is also used at {first}"
592                        ));
593                    }
594                    let segs = match segments(e.text()) {
595                        Ok(s) => s,
596                        Err(err) => {
597                            errors.push(format!("{at}: {:?}: {err}", e.name));
598                            continue;
599                        }
600                    };
601                    let ps = match params(&segs) {
602                        Ok(p) => p,
603                        Err(err) => {
604                            errors.push(format!("{at}: {:?}: {err}", e.name));
605                            continue;
606                        }
607                    };
608                    if let Ok(text) = lang_text(&segs, &ps) {
609                        lang.entry(NEUTRAL_LOCALE.into())
610                            .or_default()
611                            .push((key.clone(), text));
612                    }
613                    let _ = writeln!(
614                        code,
615                        "    /// Translation key of [`Self::{acc}`]: `{key}`.\n    pub const {upper}_KEY: &'static str = {key:?};"
616                    );
617                    code.push_str(&doc);
618                    if ps.is_empty() {
619                        let _ = writeln!(
620                            code,
621                            "    pub fn {acc}() -> ::std::string::String {{\n        ::yog_api::tr(Self::{upper}_KEY)\n    }}"
622                        );
623                    } else {
624                        let idents: Vec<String> = ps.iter().map(|p| param_ident(p)).collect();
625                        let sig = idents
626                            .iter()
627                            .map(|p| format!("{p}: impl ::std::fmt::Display"))
628                            .collect::<Vec<_>>()
629                            .join(", ");
630                        let args = idents
631                            .iter()
632                            .map(|p| format!("&{p}"))
633                            .collect::<Vec<_>>()
634                            .join(", ");
635                        let _ = writeln!(
636                            code,
637                            "    pub fn {acc}({sig}) -> ::std::string::String {{\n        ::yog_api::tr_args(Self::{upper}_KEY, &[{args}])\n    }}"
638                        );
639                    }
640                    c.refs
641                        .texts
642                        .insert(reference, (key.clone(), e.text().to_string()));
643                    text_params.insert(&e.name, (key, ps));
644                }
645                Kind::Image | Kind::File | Kind::Sound => {
646                    let Value::Bytes(bytes) = &e.value else { continue };
647                    // A medium's path is its name, dotted: `block.ruby` → `block/ruby`.
648                    let path = e.name.replace('.', yog_ids::sep::PATH);
649                    let (target, id, data) = match e.kind {
650                        Kind::Image => {
651                            let png = match yog_media::image::decode(bytes).and_then(|img| yog_media::image::encode_png(&img)) {
652                                Ok(png) => png,
653                                Err(err) => {
654                                    errors.push(format!("{at}: {:?}: {err}", e.name));
655                                    continue;
656                                }
657                            };
658                            let source = yog_ids::join(mod_id, &path);
659                            let target = format!("{}/{mod_id}/{}", yog_ids::pack::ASSETS, yog_ids::texture_file(&path));
660                            c.refs.images.insert(reference.clone(), source.clone());
661                            c.refs.pictures.insert(reference, Picture { png: png.clone(), output: target.clone() });
662                            (target, source, png)
663                        }
664                        Kind::Sound => {
665                            let event = e
666                                .event
667                                .clone()
668                                .unwrap_or_else(|| e.name.to_ascii_lowercase());
669                            let mut def =
670                                serde_json::json!({ "sounds": [yog_ids::join(mod_id, &path)] });
671                            if let Some(sub) = &e.subtitle {
672                                def["subtitle"] = serde_json::Value::String(sub.clone());
673                            }
674                            if sounds.insert(event.clone(), def).is_some() {
675                                errors
676                                    .push(format!("{at}: sound event {event:?} is defined twice"));
677                            }
678                            (
679                                format!("{}/{mod_id}/sounds/{path}.ogg", yog_ids::pack::ASSETS),
680                                yog_ids::join(mod_id, &event),
681                                bytes.clone(),
682                            )
683                        }
684                        _ => (format!("{}/{mod_id}/{path}", yog_ids::pack::ASSETS), yog_ids::join(mod_id, &path), bytes.clone()),
685                    };
686                    if let Some(other) = bundled.insert(target.clone(), (e.name.clone(), data))
687                        && other.0 != e.name
688                    {
689                        errors.push(format!("{at}: {:?} goes to {target}, like {:?}", e.name, other.0));
690                    }
691                    let _ = writeln!(
692                        code,
693                        "{doc}    pub const fn {acc}() -> &'static str {{\n        {id:?}\n    }}"
694                    );
695                }
696                Kind::Color => {
697                    let Some(v) = color_of(e) else { continue };
698                    c.refs.colors.insert(reference, v);
699                    let _ = writeln!(code, "{doc}    pub const {upper}: u32 = 0x{v:08X};");
700                }
701                Kind::Number => {
702                    let Value::Num(lit) = &e.value else { continue };
703                    let int = !lit.contains(['.', 'e', 'E']);
704                    match (int, lit.parse::<i64>()) {
705                        (true, Ok(v)) => {
706                            let _ = writeln!(code, "{doc}    pub const {upper}: i64 = {v};");
707                        }
708                        _ => {
709                            let v: f64 = lit.parse().unwrap_or_default();
710                            let _ = writeln!(code, "{doc}    pub const {upper}: f64 = {v:?};");
711                        }
712                    }
713                }
714                Kind::Bool => {
715                    let v = matches!(e.value, Value::Bool(true));
716                    let _ = writeln!(code, "{doc}    pub const {upper}: bool = {v};");
717                }
718            }
719        }
720        code.push_str("}\n");
721        c.sources.insert(set.name.clone(), code);
722
723        for (locale, file) in &set.cultures {
724            let shown = display(root, &file.path);
725            let mut missing: Vec<&str> = Vec::new();
726            for e in &set.neutral.entries {
727                if e.kind == Kind::String
728                    && !file
729                        .entries
730                        .iter()
731                        .any(|t| t.name == e.name && !t.text().is_empty())
732                {
733                    missing.push(&e.name);
734                }
735            }
736            let mut extra: Vec<&str> = Vec::new();
737            for t in &file.entries {
738                let Some((key, ps)) = text_params.get(t.name.as_str()) else {
739                    extra.push(&t.name);
740                    continue;
741                };
742                if t.text().is_empty() {
743                    continue;
744                }
745                match segments(t.text()).and_then(|segs| lang_text(&segs, ps)) {
746                    Ok(text) => lang
747                        .entry(locale.clone())
748                        .or_default()
749                        .push((key.clone(), text)),
750                    Err(err) => errors.push(format!("{shown}:{}: {:?}: {err}", t.line, t.name)),
751                }
752            }
753            if !missing.is_empty() {
754                c.warnings.push(format!(
755                    "{shown}: {} text(s) not translated (shown in {NEUTRAL_LOCALE}): {}",
756                    missing.len(),
757                    preview(&missing)
758                ));
759            }
760            if !extra.is_empty() {
761                c.warnings.push(format!(
762                    "{shown}: {} text(s) not in {}.yres, left out: {}",
763                    extra.len(),
764                    set.name,
765                    preview(&extra)
766                ));
767            }
768        }
769    }
770
771    // Lang files, merged into hand-written ones.
772    for (locale, mut entries) in lang {
773        let path = format!("assets/{mod_id}/lang/{locale}.json");
774        if let Ok(text) = std::fs::read_to_string(root.join(&path)) {
775            match parse(&text) {
776                Ok(hand) => {
777                    let mut merged: Vec<(String, String)> = Vec::new();
778                    for h in hand {
779                        if let Some(at) = key_origin.get(&h.name) {
780                            errors.push(format!(
781                                "{path}:{}: {:?} is also defined at {at}",
782                                h.line, h.name
783                            ));
784                        }
785                        merged.push((h.name.clone(), h.text().to_string()));
786                    }
787                    merged.append(&mut entries);
788                    entries = merged;
789                }
790                Err(e) => errors.push(format!("{path}:{}", e.trim_start_matches("line "))),
791            }
792        }
793        c.outputs.push((path, lang_json(&entries).into_bytes()));
794    }
795    for (target, (_, bytes)) in bundled {
796        c.outputs.push((target, bytes));
797    }
798    if !sounds.is_empty() {
799        let path = format!("assets/{mod_id}/sounds.json");
800        let mut all = serde_json::Map::new();
801        if let Ok(text) = std::fs::read_to_string(root.join(&path)) {
802            match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&text) {
803                Ok(hand) => all = hand,
804                Err(e) => errors.push(format!("{path}: {e}")),
805            }
806        }
807        for (event, def) in sounds {
808            if all.insert(event.clone(), def).is_some() {
809                errors.push(format!(
810                    "{path}: sound event {event:?} is also defined by a .yres sound"
811                ));
812            }
813        }
814        let json =
815            serde_json::to_string_pretty(&serde_json::Value::Object(all)).unwrap_or_default();
816        c.outputs.push((path, format!("{json}\n").into_bytes()));
817    }
818
819    if !errors.is_empty() {
820        return Err(format!(
821            "invalid .yres resources:\n  {}",
822            errors.join("\n  ")
823        ));
824    }
825    Ok(c)
826}
827
828const 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";
829
830/// Writes `.yog-build/generated/resources/<Set>.g.rs` per set and
831/// `.yog-build/generated/resources.g.rs` including them all (what
832/// `yog_api::resources!()` pulls in as `crate::res`); deletes those of sets
833/// that are gone.
834pub fn write_sources(root: &Path, compiled: &Compiled) -> Result<(), String> {
835    let dir = root.join(GENERATED_DIR);
836    if let Ok(entries) = std::fs::read_dir(&dir) {
837        for entry in entries.flatten() {
838            let name = entry.file_name().to_string_lossy().into_owned();
839            if name
840                .strip_suffix(".g.rs")
841                .is_some_and(|set| !compiled.sources.contains_key(set))
842            {
843                let _ = std::fs::remove_file(entry.path());
844            }
845        }
846    }
847    let mut index = BANNER.replace("{source}", "resources/**/*.yres");
848    index.push_str("// Included by `yog_api::resources!()` as `crate::res`.\n");
849    for (set, code) in &compiled.sources {
850        let source = code
851            .lines()
852            .next()
853            .and_then(|l| l.split('`').nth(1))
854            .unwrap_or("resources/");
855        let mut file = BANNER.replace("{source}", source);
856        file.push_str(code);
857        crate::write_if_changed(&dir.join(format!("{set}.g.rs")), &file)?;
858        let _ = writeln!(
859            index,
860            "include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/resources/{set}.g.rs\"));"
861        );
862    }
863    crate::write_if_changed(&root.join(GENERATED_FILE), &index)
864}
865
866/// Resolves a `yog.toml` value: `@<Set>.<name>` of a text → (neutral text,
867/// Some(key)); anything else as is. Errors on an unknown reference.
868pub fn resolve_text(refs: &Refs, value: &str) -> Result<(String, Option<String>), String> {
869    match value.strip_prefix('@') {
870        Some(rest) if rest.starts_with('@') => Ok((rest.to_string(), None)),
871        Some(rest) => match refs.texts.get(rest) {
872            Some((key, text)) => Ok((text.clone(), Some(key.clone()))),
873            None => Err(format!("`{value}`: no text `{rest}` in resources/*.yres")),
874        },
875        None => Ok((value.to_string(), None)),
876    }
877}
878
879/// Resolves a `yog.toml` image value: `@<Set>.<name>` → its image source.
880pub fn resolve_image(refs: &Refs, value: &str) -> Result<String, String> {
881    match value.strip_prefix('@') {
882        Some(rest) => refs
883            .images
884            .get(rest)
885            .cloned()
886            .ok_or_else(|| format!("`{value}`: no image `{rest}` in resources/*.yres")),
887        None => Ok(value.to_string()),
888    }
889}
890
891#[cfg(test)]
892mod tests {
893
894    #[test]
895    fn rich_text_spans_are_not_placeholders() {
896        let seg = segments("{#8d6acc|Media} and {b|x} for {name}").unwrap();
897        assert_eq!(seg, [Segment::Text("{#8d6acc|Media} and {b|x} for ".into()), Segment::Arg("name".into())]);
898    }
899
900    use super::*;
901
902    #[test]
903    fn parses_all_shapes_in_order() {
904        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";
905        let entries = parse(text).unwrap();
906        assert_eq!(entries.len(), 7);
907        assert_eq!(entries[0].name, "b");
908        assert_eq!(entries[1].line, 4);
909        assert_eq!(entries[1].value, Value::Str("A é".into()));
910        assert_eq!(entries[1].key.as_deref(), Some("x.y"));
911        assert_eq!(entries[2].kind, Kind::Number);
912        assert_eq!(entries[3].value, Value::Bool(false));
913        assert_eq!(entries[4].kind, Kind::Color);
914        assert_eq!(entries[5].value, Value::Num("-150.0".into()));
915        assert_eq!(entries[6].value, Value::Str("@everyone".into()));
916        // Written back, it reads the same.
917        let again = parse(&write(&entries)).unwrap();
918        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<_>>());
919    }
920
921    #[test]
922    fn errors_carry_lines() {
923        let err = parse("{\n  \"a\": \"1\",\n  \"a\": \"2\"\n}").unwrap_err();
924        assert_eq!(err, "3:3: `a` is already set in this object");
925        let err = parse("{\n  \"a\": { \"type\": \"bool\", \"value\": \"x\" }\n}").unwrap_err();
926        assert_eq!(err, "2:35: a.value: a bool needs a true/false value");
927        let err = parse("{\n\"a\": { \"value\": \"x\", \"note\": \"y\" }\n}").unwrap_err();
928        assert!(err.contains("2:22: a.note: unknown field `note`"), "{err}");
929        let err = parse("{\n\"a\": { \"type\": \"image\", \"value\": \"@bytes:AAAA\", \"key\": \"k\" }\n}").unwrap_err();
930        assert!(err.contains("a.key: only string entries have a `key`"), "{err}");
931        let err = parse("{\n  \"a\": \"1\"\n  \"b\": \"2\"\n}").unwrap_err();
932        assert_eq!(err, "3:3: expected `,` or `}`");
933        // Every problem at once.
934        let err = parse("{\n  \"a\": [],\n  \"b\": { \"type\": \"img\", \"value\": \"@Strings.x\" }\n}").unwrap_err();
935        assert_eq!(err.lines().collect::<Vec<_>>(), [
936            "2:8: a: expected a string, a number or true/false, found a list",
937            "3:18: b.type: unknown `img` — did you mean `image`?",
938            "3:34: b.value: expected plain text, found a resource (write `@@` for a literal `@`)",
939        ]);
940        assert!(parse("[]").unwrap_err().contains("expected `{`"));
941        assert!(parse("\u{FEFF}{ }").unwrap().is_empty());
942    }
943
944    #[test]
945    fn names_and_keys() {
946        assert_eq!(accessor_name("screen.title"), "screen_title");
947        assert_eq!(accessor_name("ModName"), "mod_name");
948        assert!(valid_name("ruby.tooltip") && !valid_name("1a") && !valid_name("a..b"));
949        assert!(valid_locale("pt_br") && valid_locale("lol_us") && !valid_locale("EN_US"));
950        assert_eq!(default_key("Items", "m", "ruby"), "item.m.ruby");
951        assert_eq!(default_key("Strings", "m", "title"), "m.title");
952        assert_eq!(
953            yog_formats::yres::split_file_name("Strings.ru_ru.yres"),
954            Some(("Strings", Some("ru_ru")))
955        );
956        assert_eq!(yog_formats::yres::split_file_name("Icons.yres"), Some(("Icons", None)));
957    }
958
959    #[test]
960    fn placeholders_become_minecraft_format() {
961        let segs = segments("{shown} of {total}: 100% {{ok}}").unwrap();
962        let ps = params(&segs).unwrap();
963        assert_eq!(ps, ["shown", "total"]);
964        assert_eq!(lang_text(&segs, &ps).unwrap(), "%1$s of %2$s: 100%% {{ok}}");
965        let ru = segments("из {total} показано {shown}").unwrap();
966        assert_eq!(lang_text(&ru, &ps).unwrap(), "из %2$s показано %1$s");
967        assert!(lang_text(&segments("{other}").unwrap(), &ps).is_err());
968        let segs = segments("{1} and {0}").unwrap();
969        assert_eq!(params(&segs).unwrap(), ["0", "1"]);
970        assert!(params(&segments("{0} {name}").unwrap()).is_err());
971        assert!(params(&segments("{1}").unwrap()).is_err());
972        assert!(segments("a { b").is_err());
973        assert!(segments("a } b").is_err());
974    }
975
976    fn temp_mod(name: &str) -> PathBuf {
977        let dir = std::env::temp_dir().join(format!("yog-yres-{name}-{}", std::process::id()));
978        let _ = std::fs::remove_dir_all(&dir);
979        std::fs::create_dir_all(dir.join(RESOURCES_DIR)).unwrap();
980        dir
981    }
982
983    #[test]
984    fn compiles_a_mod() {
985        let root = temp_mod("ok");
986        let res = root.join(RESOURCES_DIR);
987        std::fs::write(
988            res.join("Strings.yres"),
989            r#"{"title": {"value": "Mods", "comment": "c"}, "count": "{n} mods", "gone": "G"}"#,
990        )
991        .unwrap();
992        std::fs::write(
993            res.join("Strings.ru_ru.yres"),
994            r#"{"title": "Моды", "count": "Модов: {n}", "x": "X"}"#,
995        )
996        .unwrap();
997        std::fs::write(
998            res.join("Icons.yres"),
999            r##"{"images.logo": "@bytes:iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", "accent": {"type": "color", "value": "#FF8800"}, "sounds.click": {"value": "@bytes:T2dnUw==", "event": "click"}, "max": 3, "on": true}"##,
1000        )
1001        .unwrap();
1002        std::fs::write(res.join("Items.yres"), r#"{"ruby": "Ruby"}"#).unwrap();
1003        std::fs::create_dir_all(root.join("assets/m/lang")).unwrap();
1004        std::fs::write(
1005            root.join("assets/m/lang/en_us.json"),
1006            r#"{"hand.key": "Hand"}"#,
1007        )
1008        .unwrap();
1009
1010        let c = compile(&root, "m").unwrap();
1011        let out: BTreeMap<&str, String> = c
1012            .outputs
1013            .iter()
1014            .map(|(p, b)| (p.as_str(), String::from_utf8_lossy(b).into_owned()))
1015            .collect();
1016        assert_eq!(
1017            out["assets/m/lang/en_us.json"],
1018            "{\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"
1019        );
1020        assert_eq!(
1021            out["assets/m/lang/ru_ru.json"],
1022            "{\n  \"m.title\": \"Моды\",\n  \"m.count\": \"Модов: %1$s\"\n}\n"
1023        );
1024        assert!(out["assets/m/textures/images/logo.png"].starts_with("\u{89}PNG") || out.contains_key("assets/m/textures/images/logo.png"));
1025        assert_eq!(out["assets/m/sounds/sounds/click.ogg"], "OggS");
1026        assert!(out["assets/m/sounds.json"].contains("\"m:sounds/click\""));
1027        assert_eq!(c.refs.images["Icons.images.logo"], "m:images/logo");
1028        assert!(c.refs.pictures.contains_key("Icons.images.logo"));
1029        assert_eq!(
1030            c.refs.texts["Strings.title"],
1031            ("m.title".to_string(), "Mods".to_string())
1032        );
1033        let strings = &c.sources["Strings"];
1034        assert!(
1035            strings.contains("pub const TITLE_KEY: &'static str = \"m.title\";"),
1036            "{strings}"
1037        );
1038        assert!(
1039            strings.contains("pub fn count(n: impl ::std::fmt::Display)"),
1040            "{strings}"
1041        );
1042        assert!(
1043            strings.contains("::yog_api::tr_args(Self::COUNT_KEY, &[&n])"),
1044            "{strings}"
1045        );
1046        let icons = &c.sources["Icons"];
1047        assert!(
1048            icons.contains("pub const fn images_logo() -> &'static str {\n        \"m:images/logo\""),
1049            "{icons}"
1050        );
1051        assert!(
1052            icons.contains("pub const ACCENT: u32 = 0xFFFF8800;"),
1053            "{icons}"
1054        );
1055        assert!(icons.contains("pub const MAX: i64 = 3;"), "{icons}");
1056        assert!(icons.contains("pub const ON: bool = true;"), "{icons}");
1057        assert!(icons.contains("\"m:click\""), "{icons}");
1058        assert_eq!(c.warnings.len(), 2, "{:?}", c.warnings);
1059        assert!(
1060            c.warnings[0].contains("1 text(s) not translated") && c.warnings[0].contains("gone")
1061        );
1062        assert!(c.warnings[1].contains("left out: x"));
1063
1064        write_sources(&root, &c).unwrap();
1065        let icons_rs =
1066            std::fs::read_to_string(root.join(GENERATED_DIR).join("Icons.g.rs")).unwrap();
1067        assert!(icons_rs.starts_with("// <auto-generated>"), "{icons_rs}");
1068        assert!(icons_rs.contains("pub struct Icons;"), "{icons_rs}");
1069        let index = std::fs::read_to_string(root.join(GENERATED_FILE)).unwrap();
1070        assert!(
1071            index.contains("/generated/resources/Icons.g.rs\"));"),
1072            "{index}"
1073        );
1074        assert!(
1075            index.contains("/generated/resources/Strings.g.rs\"));"),
1076            "{index}"
1077        );
1078        let (name, key) = resolve_text(&c.refs, "@Strings.title").unwrap();
1079        assert_eq!((name.as_str(), key.as_deref()), ("Mods", Some("m.title")));
1080        assert!(resolve_text(&c.refs, "@Strings.nope").is_err());
1081        assert_eq!(
1082            resolve_image(&c.refs, "@Icons.images.logo").unwrap(),
1083            "m:images/logo"
1084        );
1085        let _ = std::fs::remove_dir_all(&root);
1086    }
1087
1088    #[test]
1089    fn reports_every_problem_with_file_and_line() {
1090        let root = temp_mod("errors");
1091        let res = root.join(RESOURCES_DIR);
1092        std::fs::write(
1093            res.join("Strings.yres"),
1094            "{\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}",
1095        )
1096        .unwrap();
1097        std::fs::write(res.join("Strings.en_us.yres"), "{}").unwrap();
1098        std::fs::write(res.join("Strings.de_de.yres"), "{\n  \"a\": 1\n}").unwrap();
1099        std::fs::write(res.join("Other.ru_ru.yres"), "{}").unwrap();
1100        let err = compile(&root, "m").unwrap_err();
1101        for needle in [
1102            "resources/Strings.yres:3: translation key \"m.a\" is also used at resources/Strings.yres:2",
1103            "resources/Strings.yres:4: \"img\": not a picture this reads",
1104            "resources/Strings.yres:5: \"bad name\" isn't a valid name",
1105            "resources/Strings.yres:6: \"col\": `red` isn't a color",
1106            "Strings.en_us.yres: the neutral file Strings.yres is already en_us",
1107            "resources/Strings.de_de.yres:2: \"a\": culture variants hold texts only",
1108            "resources/Other.ru_ru.yres: no neutral Other.yres",
1109        ] {
1110            assert!(err.contains(needle), "missing {needle:?} in:\n{err}");
1111        }
1112        let _ = std::fs::remove_dir_all(&root);
1113    }
1114}