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::{Component, Path, PathBuf};
21
22/// The locale of the neutral `<Set>.yres` files.
23pub const NEUTRAL_LOCALE: &str = "en_us";
24/// Where resource files live, under the mod root.
25pub const RESOURCES_DIR: &str = "resources";
26/// Where the generated accessors go, under the mod root.
27/// The file `yog_api::resources!()` includes; it includes one
28/// `resources/<Set>.g.rs` per set (shown under its `.yres` in Yog-IDLE).
29pub const GENERATED_FILE: &str = ".yog-build/generated/resources.g.rs";
30/// Where the per-set `<Set>.g.rs` files go.
31pub const GENERATED_DIR: &str = ".yog-build/generated/resources";
32
33/// A generated file: path inside the `.yog` and its bytes.
34pub type Output = (String, Vec<u8>);
35
36// ── Parsing ───────────────────────────────────────────────────────────────────
37
38/// An entry's type.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Kind {
41    String,
42    Image,
43    File,
44    Sound,
45    Color,
46    Number,
47    Bool,
48}
49
50impl Kind {
51    fn parse(s: &str) -> Option<Kind> {
52        Some(match s {
53            "string" => Kind::String,
54            "image" => Kind::Image,
55            "file" => Kind::File,
56            "sound" => Kind::Sound,
57            "color" => Kind::Color,
58            "number" => Kind::Number,
59            "bool" => Kind::Bool,
60            _ => return None,
61        })
62    }
63
64    fn name(self) -> &'static str {
65        match self {
66            Kind::String => "string",
67            Kind::Image => "image",
68            Kind::File => "file",
69            Kind::Sound => "sound",
70            Kind::Color => "color",
71            Kind::Number => "number",
72            Kind::Bool => "bool",
73        }
74    }
75}
76
77/// A JSON scalar as written: a string, a number (its literal) or a bool.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum Value {
80    Str(String),
81    Num(String),
82    Bool(bool),
83}
84
85/// One entry of a `.yres` file.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Entry {
88    pub name: String,
89    pub kind: Kind,
90    pub value: Value,
91    pub comment: Option<String>,
92    pub key: Option<String>,
93    pub texture: Option<String>,
94    pub event: Option<String>,
95    pub subtitle: Option<String>,
96    /// 1-based line of the name.
97    pub line: usize,
98}
99
100impl Entry {
101    fn text(&self) -> &str {
102        match &self.value {
103            Value::Str(s) => s,
104            _ => "",
105        }
106    }
107}
108
109/// Parses `.yres` text. Errors are `line N: message`. Duplicate names are
110/// an error; names aren't validated here (see [`valid_name`]).
111pub fn parse(text: &str) -> Result<Vec<Entry>, String> {
112    let mut p = Parser {
113        s: text.as_bytes(),
114        i: 0,
115        line: 1,
116    };
117    // A UTF-8 BOM is tolerated, as editors on Windows like to add one.
118    if p.s.starts_with(b"\xEF\xBB\xBF") {
119        p.i = 3;
120    }
121    p.ws();
122    if !p.eat(b'{') {
123        return Err(p.err("expected `{` — a .yres file is one JSON object of named resources"));
124    }
125    let mut entries: Vec<Entry> = Vec::new();
126    let mut seen: HashMap<String, usize> = HashMap::new();
127    p.ws();
128    if !p.eat(b'}') {
129        loop {
130            p.ws();
131            let line = p.line;
132            let name = p
133                .string()
134                .map_err(|e| format!("{e} (expected a resource name)"))?;
135            if let Some(first) = seen.get(&name) {
136                return Err(format!(
137                    "line {line}: duplicate name {name:?} (first on line {first})"
138                ));
139            }
140            p.ws();
141            if !p.eat(b':') {
142                return Err(p.err("expected `:` after the name"));
143            }
144            p.ws();
145            let mut entry = Entry {
146                name: name.clone(),
147                kind: Kind::String,
148                value: Value::Str(String::new()),
149                comment: None,
150                key: None,
151                texture: None,
152                event: None,
153                subtitle: None,
154                line,
155            };
156            match p.peek() {
157                Some(b'{') => p.entry_object(&mut entry)?,
158                _ => {
159                    entry.value = p.scalar().map_err(|e| {
160                        format!("{e} — {name:?} must be a string or {{ \"value\": ..., \"type\": ..., \"comment\": ... }}")
161                    })?;
162                    entry.kind = match entry.value {
163                        Value::Str(_) => Kind::String,
164                        Value::Num(_) => Kind::Number,
165                        Value::Bool(_) => Kind::Bool,
166                    };
167                }
168            }
169            seen.insert(name, line);
170            entries.push(entry);
171            p.ws();
172            if p.eat(b',') {
173                continue;
174            }
175            if p.eat(b'}') {
176                break;
177            }
178            return Err(p.err("expected `,` or `}`"));
179        }
180    }
181    p.ws();
182    if p.i < p.s.len() {
183        return Err(p.err("unexpected text after the closing `}`"));
184    }
185    Ok(entries)
186}
187
188struct Parser<'a> {
189    s: &'a [u8],
190    i: usize,
191    line: usize,
192}
193
194impl Parser<'_> {
195    fn peek(&self) -> Option<u8> {
196        self.s.get(self.i).copied()
197    }
198
199    fn eat(&mut self, c: u8) -> bool {
200        if self.peek() == Some(c) {
201            self.i += 1;
202            true
203        } else {
204            false
205        }
206    }
207
208    fn eat_word(&mut self, word: &str) -> bool {
209        if self.s[self.i..].starts_with(word.as_bytes()) {
210            self.i += word.len();
211            true
212        } else {
213            false
214        }
215    }
216
217    fn ws(&mut self) {
218        while let Some(c) = self.peek() {
219            match c {
220                b'\n' => self.line += 1,
221                b' ' | b'\t' | b'\r' => {}
222                _ => return,
223            }
224            self.i += 1;
225        }
226    }
227
228    fn err(&self, msg: &str) -> String {
229        match self.peek() {
230            None => format!("line {}: {msg}, found the end of the file", self.line),
231            Some(_) => format!("line {}: {msg}", self.line),
232        }
233    }
234
235    /// A string, number or `true`/`false`.
236    fn scalar(&mut self) -> Result<Value, String> {
237        match self.peek() {
238            Some(b'"') => Ok(Value::Str(self.string()?)),
239            Some(b't') if self.eat_word("true") => Ok(Value::Bool(true)),
240            Some(b'f') if self.eat_word("false") => Ok(Value::Bool(false)),
241            Some(c) if c == b'-' || c.is_ascii_digit() => {
242                let start = self.i;
243                while let Some(c) = self.peek() {
244                    if c.is_ascii_digit() || matches!(c, b'-' | b'+' | b'.' | b'e' | b'E') {
245                        self.i += 1;
246                    } else {
247                        break;
248                    }
249                }
250                let lit = String::from_utf8_lossy(&self.s[start..self.i]).into_owned();
251                if lit.parse::<f64>().is_err() {
252                    return Err(format!("line {}: invalid number `{lit}`", self.line));
253                }
254                Ok(Value::Num(lit))
255            }
256            _ => Err(self.err("expected a value")),
257        }
258    }
259
260    /// A JSON string literal, unescaped.
261    fn string(&mut self) -> Result<String, String> {
262        if !self.eat(b'"') {
263            return Err(self.err("expected a string"));
264        }
265        let mut out: Vec<u8> = Vec::new();
266        loop {
267            let Some(c) = self.peek() else {
268                return Err(self.err("unterminated string"));
269            };
270            self.i += 1;
271            match c {
272                b'"' => break,
273                b'\n' => {
274                    return Err(format!(
275                        "line {}: unterminated string (strings can't span lines — use \\n)",
276                        self.line
277                    ));
278                }
279                b'\\' => {
280                    let Some(e) = self.peek() else {
281                        return Err(self.err("unterminated string"));
282                    };
283                    self.i += 1;
284                    let ch = match e {
285                        b'"' => '"',
286                        b'\\' => '\\',
287                        b'/' => '/',
288                        b'b' => '\u{8}',
289                        b'f' => '\u{c}',
290                        b'n' => '\n',
291                        b'r' => '\r',
292                        b't' => '\t',
293                        b'u' => self.unicode_escape()?,
294                        other => {
295                            return Err(format!(
296                                "line {}: invalid escape `\\{}`",
297                                self.line, other as char
298                            ));
299                        }
300                    };
301                    let mut buf = [0u8; 4];
302                    out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
303                }
304                c if c < 0x20 => {
305                    return Err(format!(
306                        "line {}: control character in a string — escape it",
307                        self.line
308                    ));
309                }
310                c => out.push(c),
311            }
312        }
313        String::from_utf8(out)
314            .map_err(|_| format!("line {}: the string isn't valid UTF-8", self.line))
315    }
316
317    fn hex4(&mut self) -> Result<u32, String> {
318        let digits = self
319            .s
320            .get(self.i..self.i + 4)
321            .ok_or_else(|| self.err("truncated \\u escape"))?;
322        let text = std::str::from_utf8(digits).map_err(|_| self.err("invalid \\u escape"))?;
323        let v = u32::from_str_radix(text, 16)
324            .map_err(|_| format!("line {}: invalid \\u escape `\\u{text}`", self.line))?;
325        self.i += 4;
326        Ok(v)
327    }
328
329    fn unicode_escape(&mut self) -> Result<char, String> {
330        let hi = self.hex4()?;
331        let code = if (0xD800..0xDC00).contains(&hi) {
332            if !(self.eat(b'\\') && self.eat(b'u')) {
333                return Err(format!(
334                    "line {}: lone surrogate in a \\u escape",
335                    self.line
336                ));
337            }
338            let lo = self.hex4()?;
339            if !(0xDC00..0xE000).contains(&lo) {
340                return Err(format!(
341                    "line {}: invalid surrogate pair in a \\u escape",
342                    self.line
343                ));
344            }
345            0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00)
346        } else {
347            hi
348        };
349        char::from_u32(code).ok_or_else(|| format!("line {}: invalid \\u escape", self.line))
350    }
351
352    /// `{ "type": ..., "value": ..., "comment": ..., ... }` into `entry`.
353    fn entry_object(&mut self, entry: &mut Entry) -> Result<(), String> {
354        let name = entry.name.clone();
355        let start = self.line;
356        self.eat(b'{');
357        let mut value = None;
358        let mut kind = None;
359        let mut seen: Vec<String> = Vec::new();
360        self.ws();
361        if !self.eat(b'}') {
362            loop {
363                self.ws();
364                let field_line = self.line;
365                let field = self.string()?;
366                self.ws();
367                if !self.eat(b':') {
368                    return Err(self.err("expected `:`"));
369                }
370                self.ws();
371                if seen.contains(&field) {
372                    return Err(format!(
373                        "line {field_line}: {name:?}: duplicate field `{field}`"
374                    ));
375                }
376                let v = self.scalar()?;
377                let text = |v: Value| match v {
378                    Value::Str(s) => Ok(s),
379                    _ => Err(format!(
380                        "line {field_line}: {name:?}: `{field}` must be a string"
381                    )),
382                };
383                match field.as_str() {
384                    "value" => value = Some(v),
385                    "type" => {
386                        let t = text(v)?;
387                        kind = Some(Kind::parse(&t).ok_or_else(|| {
388                            format!(
389                                "line {field_line}: {name:?}: unknown type `{t}` (string, image, file, sound, color, number or bool)"
390                            )
391                        })?);
392                    }
393                    "comment" => entry.comment = Some(text(v)?),
394                    "key" => entry.key = Some(text(v)?),
395                    "texture" => entry.texture = Some(text(v)?),
396                    "event" => entry.event = Some(text(v)?),
397                    "subtitle" => entry.subtitle = Some(text(v)?),
398                    other => {
399                        return Err(format!(
400                            "line {field_line}: {name:?}: unknown field `{other}` (value, type, comment, key, texture, event, subtitle)"
401                        ));
402                    }
403                }
404                seen.push(field);
405                self.ws();
406                if self.eat(b',') {
407                    continue;
408                }
409                if self.eat(b'}') {
410                    break;
411                }
412                return Err(self.err("expected `,` or `}`"));
413            }
414        }
415        let value = value.ok_or_else(|| format!("line {start}: {name:?}: missing `value`"))?;
416        let kind = kind.unwrap_or(match value {
417            Value::Str(_) => Kind::String,
418            Value::Num(_) => Kind::Number,
419            Value::Bool(_) => Kind::Bool,
420        });
421        let fits = matches!(
422            (kind, &value),
423            (Kind::Number, Value::Num(_))
424                | (Kind::Bool, Value::Bool(_))
425                | (
426                    Kind::String | Kind::Image | Kind::File | Kind::Sound | Kind::Color,
427                    Value::Str(_)
428                )
429        );
430        if !fits {
431            return Err(format!(
432                "line {start}: {name:?}: a {} needs a {} value",
433                kind.name(),
434                match kind {
435                    Kind::Number => "number",
436                    Kind::Bool => "true/false",
437                    _ => "string",
438                }
439            ));
440        }
441        let misplaced = [
442            ("key", entry.key.is_some(), Kind::String),
443            ("texture", entry.texture.is_some(), Kind::Image),
444            ("event", entry.event.is_some(), Kind::Sound),
445            ("subtitle", entry.subtitle.is_some(), Kind::Sound),
446        ];
447        for (field, present, only) in misplaced {
448            if present && kind != only {
449                return Err(format!(
450                    "line {start}: {name:?}: `{field}` is only for {} entries",
451                    only.name()
452                ));
453            }
454        }
455        entry.kind = kind;
456        entry.value = value;
457        Ok(())
458    }
459}
460
461// ── Names ─────────────────────────────────────────────────────────────────────
462
463const RUST_KEYWORDS: &[&str] = &[
464    "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
465    "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
466    "ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
467    "where", "while", "abstract", "become", "box", "do", "final", "gen", "macro", "override",
468    "priv", "try", "typeof", "unsized", "virtual", "yield",
469];
470
471fn ident_part(s: &str) -> bool {
472    let mut chars = s.chars();
473    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
474        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
475}
476
477/// An entry name: identifiers joined by `.` (`title`, `screen.title`).
478pub fn valid_name(name: &str) -> bool {
479    !name.is_empty() && name.split('.').all(ident_part)
480}
481
482/// A resource set name: one identifier (`Strings`, `Icons`).
483pub fn valid_set_name(name: &str) -> bool {
484    ident_part(name) && !RUST_KEYWORDS.contains(&name)
485}
486
487/// Minecraft's locale codes: lower case, `en_us`, `pt_br`, `lol_us`, ...
488pub fn valid_locale(locale: &str) -> bool {
489    let Some((lang, region)) = locale.split_once('_') else {
490        return false;
491    };
492    (2..=3).contains(&lang.len())
493        && lang.bytes().all(|b| b.is_ascii_lowercase())
494        && (2..=4).contains(&region.len())
495        && region
496            .bytes()
497            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
498}
499
500/// The accessor for entry `name`: `screen.title` → `screen_title`,
501/// `ModName` → `mod_name`.
502pub fn accessor_name(name: &str) -> String {
503    let mut out = String::new();
504    for (n, part) in name.split('.').enumerate() {
505        if n > 0 {
506            out.push('_');
507        }
508        let mut prev_lower = false;
509        for c in part.chars() {
510            if c.is_ascii_uppercase() && prev_lower {
511                out.push('_');
512            }
513            prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
514            out.push(c.to_ascii_lowercase());
515        }
516    }
517    out
518}
519
520/// The default translation key of text `name` in set `set`.
521pub fn default_key(set: &str, mod_id: &str, name: &str) -> String {
522    match set {
523        "Items" => format!("item.{mod_id}.{name}"),
524        "Blocks" => format!("block.{mod_id}.{name}"),
525        "Entities" => format!("entity.{mod_id}.{name}"),
526        _ => format!("{mod_id}.{name}"),
527    }
528}
529
530/// `resources/.../Strings.ru_ru.yres` → `("Strings", Some("ru_ru"))`.
531pub fn split_file_name(file_name: &str) -> Option<(&str, Option<&str>)> {
532    let stem = file_name.strip_suffix(".yres")?;
533    Some(match stem.split_once('.') {
534        Some((set, locale)) => (set, Some(locale)),
535        None => (stem, None),
536    })
537}
538
539// ── Placeholders ──────────────────────────────────────────────────────────────
540
541#[derive(Debug, Clone, PartialEq, Eq)]
542enum Segment {
543    Text(String),
544    /// `{name}` or `{0}`.
545    Arg(String),
546}
547
548/// Splits a text into literal parts and `{arg}` placeholders.
549fn segments(text: &str) -> Result<Vec<Segment>, String> {
550    let mut out = Vec::new();
551    let mut lit = String::new();
552    let mut chars = text.chars().peekable();
553    while let Some(c) = chars.next() {
554        match c {
555            '{' if chars.peek() == Some(&'{') => {
556                chars.next();
557                lit.push('{');
558            }
559            '}' if chars.peek() == Some(&'}') => {
560                chars.next();
561                lit.push('}');
562            }
563            '{' => {
564                let mut arg = String::new();
565                loop {
566                    match chars.next() {
567                        Some('}') => break,
568                        Some(c) => arg.push(c),
569                        None => {
570                            return Err("`{` without `}` (write `{{` for a literal brace)".into());
571                        }
572                    }
573                }
574                let arg = arg.trim().to_string();
575                let numeric = !arg.is_empty() && arg.bytes().all(|b| b.is_ascii_digit());
576                if !numeric && (!ident_part(&arg) || RUST_KEYWORDS.contains(&arg.as_str())) {
577                    return Err(format!(
578                        "placeholder `{{{arg}}}` must be a number or an identifier (write `{{{{` for a literal brace)"
579                    ));
580                }
581                if !lit.is_empty() {
582                    out.push(Segment::Text(std::mem::take(&mut lit)));
583                }
584                out.push(Segment::Arg(arg));
585            }
586            '}' => return Err("`}` without `{` (write `}}` for a literal brace)".into()),
587            c => lit.push(c),
588        }
589    }
590    if !lit.is_empty() {
591        out.push(Segment::Text(lit));
592    }
593    Ok(out)
594}
595
596/// The parameters of a neutral text, in order: `{0}`, `{1}`, … by number
597/// (no gaps), named ones by first appearance; one style per text.
598fn params(segs: &[Segment]) -> Result<Vec<String>, String> {
599    let args: Vec<&String> = segs
600        .iter()
601        .filter_map(|s| match s {
602            Segment::Arg(a) => Some(a),
603            Segment::Text(_) => None,
604        })
605        .collect();
606    let numeric = args
607        .iter()
608        .filter(|a| a.bytes().all(|b| b.is_ascii_digit()))
609        .count();
610    if numeric > 0 && numeric < args.len() {
611        return Err("mixes numbered `{0}` and named `{name}` placeholders — use one style".into());
612    }
613    let mut out: Vec<String> = Vec::new();
614    if numeric > 0 {
615        let mut nums: Vec<usize> = args.iter().filter_map(|a| a.parse().ok()).collect();
616        nums.sort_unstable();
617        nums.dedup();
618        for (i, n) in nums.iter().enumerate() {
619            if *n != i {
620                return Err(format!(
621                    "placeholder {{{i}}} is missing (numbered placeholders start at {{0}}, no gaps)"
622                ));
623            }
624            out.push(n.to_string());
625        }
626    } else {
627        for a in args {
628            if !out.contains(a) {
629                out.push(a.clone());
630            }
631        }
632    }
633    Ok(out)
634}
635
636/// Minecraft's form of a text: `%` → `%%`, the placeholder for parameter
637/// `i` → `%{i+1}$s`. Errors on a placeholder not in `params`.
638fn lang_text(segs: &[Segment], params: &[String]) -> Result<String, String> {
639    let mut out = String::new();
640    for s in segs {
641        match s {
642            Segment::Text(t) => out.push_str(&t.replace('%', "%%")),
643            Segment::Arg(a) => {
644                let pos = params
645                    .iter()
646                    .position(|p| p == a)
647                    .ok_or_else(|| format!("placeholder `{{{a}}}` isn't in the neutral text"))?;
648                let _ = write!(out, "%{}$s", pos + 1);
649            }
650        }
651    }
652    Ok(out)
653}
654
655fn param_ident(p: &str) -> String {
656    if p.bytes().all(|b| b.is_ascii_digit()) {
657        format!("arg{p}")
658    } else {
659        accessor_name(p)
660    }
661}
662
663// ── Loading a mod's resources ────────────────────────────────────────────────
664
665/// One `.yres` file.
666#[derive(Debug, Clone)]
667pub struct ResFile {
668    pub path: PathBuf,
669    pub entries: Vec<Entry>,
670}
671
672/// A resource set: its neutral file and culture variants.
673#[derive(Debug, Clone)]
674pub struct Set {
675    pub name: String,
676    pub neutral: ResFile,
677    pub cultures: BTreeMap<String, ResFile>,
678}
679
680/// What `.yui` screens and `yog.toml` can refer to as `@<Set>.<name>`.
681#[derive(Debug, Clone, Default)]
682pub struct Refs {
683    /// `Set.name` → (translation key, neutral text).
684    pub texts: HashMap<String, (String, String)>,
685    /// `Set.name` → image source (`<mod id>:<texture>`).
686    pub images: HashMap<String, String>,
687    /// `Set.name` → color (`0xAARRGGBB`).
688    pub colors: HashMap<String, u32>,
689}
690
691/// Everything `yog build` makes of a mod's resources.
692#[derive(Debug, Default)]
693pub struct Compiled {
694    /// Files to add to the `.yog` (lang JSON, images, sounds, sounds.json) —
695    /// they replace a hand-written asset at the same path (merged into them).
696    pub outputs: Vec<Output>,
697    /// `<Set>` → generated Rust source.
698    pub sources: BTreeMap<String, String>,
699    pub refs: Refs,
700    pub warnings: Vec<String>,
701    /// Number of `.yres` files read.
702    pub files: usize,
703}
704
705/// Every `resources/**/*.yres` of the mod at `root`, sorted.
706pub fn find(root: &Path) -> Vec<PathBuf> {
707    fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
708        let Ok(entries) = std::fs::read_dir(dir) else {
709            return;
710        };
711        for entry in entries.flatten() {
712            let path = entry.path();
713            if path.is_dir() {
714                walk(&path, out);
715            } else if path.extension().is_some_and(|e| e == "yres") {
716                out.push(path);
717            }
718        }
719    }
720    let mut out = Vec::new();
721    walk(&root.join(RESOURCES_DIR), &mut out);
722    out.sort();
723    out
724}
725
726fn display(root: &Path, p: &Path) -> String {
727    p.strip_prefix(root)
728        .unwrap_or(p)
729        .to_string_lossy()
730        .replace('\\', "/")
731}
732
733/// Reads and checks every `.yres` of the mod — errors are `file:line: message`.
734fn load(root: &Path, errors: &mut Vec<String>) -> BTreeMap<String, Set> {
735    let mut neutral: BTreeMap<String, ResFile> = BTreeMap::new();
736    let mut cultures: Vec<(String, String, ResFile)> = Vec::new();
737    for path in find(root) {
738        let shown = display(root, &path);
739        let file_name = path
740            .file_name()
741            .unwrap_or_default()
742            .to_string_lossy()
743            .into_owned();
744        let Some((set, locale)) = split_file_name(&file_name) else {
745            continue;
746        };
747        if !valid_set_name(set) {
748            errors.push(format!(
749                "{shown}: `{set}` isn't a valid resource set name — use an identifier like Strings or Icons"
750            ));
751            continue;
752        }
753        if let Some(locale) = locale {
754            if locale == NEUTRAL_LOCALE {
755                errors.push(format!(
756                    "{shown}: the neutral file {set}.yres is already {NEUTRAL_LOCALE} — put these texts there"
757                ));
758                continue;
759            }
760            if !valid_locale(locale) {
761                errors.push(format!(
762                    "{shown}: `{locale}` isn't a Minecraft locale code (lower case, like ru_ru or pt_br)"
763                ));
764                continue;
765            }
766        }
767        let entries = match std::fs::read_to_string(&path)
768            .map_err(|e| e.to_string())
769            .and_then(|t| parse(&t))
770        {
771            Ok(entries) => entries,
772            Err(e) => {
773                errors.push(format!("{shown}:{}", e.trim_start_matches("line ")));
774                continue;
775            }
776        };
777        for e in &entries {
778            if !valid_name(&e.name) {
779                errors.push(format!(
780                    "{shown}:{}: {:?} isn't a valid name — identifiers joined by `.`, like screen.title",
781                    e.line, e.name
782                ));
783            }
784        }
785        let file = ResFile { path, entries };
786        match locale {
787            None => {
788                if let Some(other) = neutral.get(set) {
789                    errors.push(format!(
790                        "{shown}: resource set {set} is also defined by {}",
791                        display(root, &other.path)
792                    ));
793                    continue;
794                }
795                neutral.insert(set.to_string(), file);
796            }
797            Some(locale) => cultures.push((set.to_string(), locale.to_string(), file)),
798        }
799    }
800    let mut sets: BTreeMap<String, Set> = neutral
801        .into_iter()
802        .map(|(name, neutral)| {
803            (
804                name.clone(),
805                Set {
806                    name,
807                    neutral,
808                    cultures: BTreeMap::new(),
809                },
810            )
811        })
812        .collect();
813    for (set, locale, file) in cultures {
814        let shown = display(root, &file.path);
815        let Some(target) = sets.get_mut(&set) else {
816            errors.push(format!(
817                "{shown}: no neutral {set}.yres for this culture variant"
818            ));
819            continue;
820        };
821        if let Some(other) = target.cultures.get(&locale) {
822            errors.push(format!(
823                "{shown}: {set} {locale} is also defined by {}",
824                display(root, &other.path)
825            ));
826            continue;
827        }
828        for e in &file.entries {
829            if e.kind != Kind::String || e.key.is_some() {
830                errors.push(format!(
831                    "{shown}:{}: {:?}: culture variants hold texts only (no type other than string, no `key`)",
832                    e.line, e.name
833                ));
834            }
835        }
836        target.cultures.insert(locale, file);
837    }
838    sets
839}
840
841/// A path of an entry, relative to its `.yres` file → the path relative to
842/// `resources/` (`/`-separated, checked to stay inside and to exist).
843fn resource_path(root: &Path, yres: &Path, value: &str) -> Result<(String, PathBuf), String> {
844    let base = yres.parent().unwrap_or(root);
845    let mut parts: Vec<String> = base
846        .strip_prefix(root.join(RESOURCES_DIR))
847        .unwrap_or(Path::new(""))
848        .components()
849        .map(|c| c.as_os_str().to_string_lossy().into_owned())
850        .collect();
851    for c in Path::new(&value.replace('\\', "/")).components() {
852        match c {
853            Component::Normal(s) => parts.push(s.to_string_lossy().into_owned()),
854            Component::CurDir => {}
855            Component::ParentDir if !parts.is_empty() => {
856                parts.pop();
857            }
858            _ => return Err(format!("`{value}` must stay inside {RESOURCES_DIR}/")),
859        }
860    }
861    let rel = parts.join("/");
862    if rel.is_empty() {
863        return Err("the path is empty".into());
864    }
865    let full = root.join(RESOURCES_DIR).join(&rel);
866    if !full.is_file() {
867        return Err(format!("{RESOURCES_DIR}/{rel} doesn't exist"));
868    }
869    if !rel.bytes().all(|b| {
870        b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.' | b'/')
871    }) {
872        return Err(format!(
873            "{RESOURCES_DIR}/{rel}: Minecraft resource paths are lower case — only a-z, 0-9, `_`, `-`, `.` and `/`"
874        ));
875    }
876    Ok((rel, full))
877}
878
879fn parse_color(s: &str) -> Option<u32> {
880    let hex = s.trim().strip_prefix('#')?;
881    let v = u32::from_str_radix(hex, 16).ok()?;
882    match hex.len() {
883        6 => Some(0xFF00_0000 | v),
884        8 => Some(v),
885        _ => None,
886    }
887}
888
889/// The lang JSON of `entries` (key, text), order kept.
890fn lang_json(entries: &[(String, String)]) -> String {
891    let mut out = String::from("{");
892    for (n, (k, v)) in entries.iter().enumerate() {
893        out.push_str(if n == 0 { "\n  " } else { ",\n  " });
894        out.push_str(&serde_json::to_string(k).unwrap_or_default());
895        out.push_str(": ");
896        out.push_str(&serde_json::to_string(v).unwrap_or_default());
897    }
898    out.push_str(if entries.is_empty() { "}\n" } else { "\n}\n" });
899    out
900}
901
902fn preview(names: &[&str]) -> String {
903    const SHOWN: usize = 5;
904    let mut text = names
905        .iter()
906        .take(SHOWN)
907        .copied()
908        .collect::<Vec<_>>()
909        .join(", ");
910    if names.len() > SHOWN {
911        let _ = write!(text, ", … (+{})", names.len() - SHOWN);
912    }
913    text
914}
915
916fn doc_line(s: &str) -> String {
917    let one: String = s
918        .chars()
919        .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
920        .collect();
921    one.replace("*/", "* /")
922}
923
924/// Compiles the mod's resources (see the module docs). Any error fails the
925/// whole thing, listing every problem.
926pub fn compile(root: &Path, mod_id: &str) -> Result<Compiled, String> {
927    let mut errors = Vec::new();
928    let sets = load(root, &mut errors);
929    let mut c = Compiled {
930        files: sets.values().map(|s| 1 + s.cultures.len()).sum(),
931        ..Compiled::default()
932    };
933    // locale → (key, text), in set order then file order.
934    let mut lang: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
935    let mut key_origin: HashMap<String, String> = HashMap::new();
936    // Bundled path → the file it comes from.
937    let mut bundled: BTreeMap<String, PathBuf> = BTreeMap::new();
938    let mut sounds: BTreeMap<String, serde_json::Value> = BTreeMap::new();
939
940    for set in sets.values() {
941        let shown = display(root, &set.neutral.path);
942        let mut code = String::new();
943        let mut accessors: HashMap<String, usize> = HashMap::new();
944        // Neutral text params per name, for checking the cultures.
945        let mut text_params: HashMap<&str, (String, Vec<String>)> = HashMap::new();
946        let _ = writeln!(
947            code,
948            "/// The resources of `{shown}`.\n#[allow(dead_code)]\npub struct {name};\n\n#[allow(dead_code, non_snake_case, clippy::all)]\nimpl {name} {{",
949            name = set.name
950        );
951        for e in &set.neutral.entries {
952            let at = format!("{shown}:{}", e.line);
953            let acc = accessor_name(&e.name);
954            if RUST_KEYWORDS.contains(&acc.as_str()) {
955                errors.push(format!(
956                    "{at}: {:?} gives the accessor `{acc}`, a Rust keyword — rename it",
957                    e.name
958                ));
959            }
960            if let Some(first) = accessors.insert(acc.clone(), e.line) {
961                errors.push(format!(
962                    "{at}: {:?} gives the same accessor `{acc}` as the entry on line {first}",
963                    e.name
964                ));
965            }
966            let upper = acc.to_ascii_uppercase();
967            // Like Resources.Designer.cs: what the entry holds, then its comment.
968            let doc = {
969                let what = match e.kind {
970                    Kind::String => format!(
971                        "Looks up a localized string similar to \"{}\".",
972                        doc_line(e.text())
973                    ),
974                    Kind::Image => format!("The image `{}`.", doc_line(e.text())),
975                    Kind::File => format!("The file `{}`.", doc_line(e.text())),
976                    Kind::Sound => format!("The sound `{}`.", doc_line(e.text())),
977                    Kind::Color => format!("The color `{}`.", doc_line(e.text())),
978                    Kind::Number | Kind::Bool => match &e.value {
979                        Value::Num(n) => format!("The number `{n}`."),
980                        Value::Bool(b) => format!("The flag `{b}`."),
981                        Value::Str(v) => format!("The value `{}`.", doc_line(v)),
982                    },
983                };
984                let mut doc = format!("    /// {what}\n    ///\n    /// `{}`", e.name);
985                if let Some(comment) = &e.comment {
986                    let _ = write!(doc, " — {}", doc_line(comment));
987                }
988                let _ = writeln!(doc, " ({shown}:{})", e.line);
989                doc
990            };
991            let reference = format!("{}.{}", set.name, e.name);
992            match e.kind {
993                Kind::String => {
994                    let key = e
995                        .key
996                        .clone()
997                        .unwrap_or_else(|| default_key(&set.name, mod_id, &e.name));
998                    if let Some(first) = key_origin.insert(key.clone(), at.clone()) {
999                        errors.push(format!(
1000                            "{at}: translation key {key:?} is also used at {first}"
1001                        ));
1002                    }
1003                    let segs = match segments(e.text()) {
1004                        Ok(s) => s,
1005                        Err(err) => {
1006                            errors.push(format!("{at}: {:?}: {err}", e.name));
1007                            continue;
1008                        }
1009                    };
1010                    let ps = match params(&segs) {
1011                        Ok(p) => p,
1012                        Err(err) => {
1013                            errors.push(format!("{at}: {:?}: {err}", e.name));
1014                            continue;
1015                        }
1016                    };
1017                    if let Ok(text) = lang_text(&segs, &ps) {
1018                        lang.entry(NEUTRAL_LOCALE.into())
1019                            .or_default()
1020                            .push((key.clone(), text));
1021                    }
1022                    let _ = writeln!(
1023                        code,
1024                        "    /// Translation key of [`Self::{acc}`]: `{key}`.\n    pub const {upper}_KEY: &'static str = {key:?};"
1025                    );
1026                    code.push_str(&doc);
1027                    if ps.is_empty() {
1028                        let _ = writeln!(
1029                            code,
1030                            "    pub fn {acc}() -> ::std::string::String {{\n        ::yog_api::tr(Self::{upper}_KEY)\n    }}"
1031                        );
1032                    } else {
1033                        let idents: Vec<String> = ps.iter().map(|p| param_ident(p)).collect();
1034                        let sig = idents
1035                            .iter()
1036                            .map(|p| format!("{p}: impl ::std::fmt::Display"))
1037                            .collect::<Vec<_>>()
1038                            .join(", ");
1039                        let args = idents
1040                            .iter()
1041                            .map(|p| format!("&{p}"))
1042                            .collect::<Vec<_>>()
1043                            .join(", ");
1044                        let _ = writeln!(
1045                            code,
1046                            "    pub fn {acc}({sig}) -> ::std::string::String {{\n        ::yog_api::tr_args(Self::{upper}_KEY, &[{args}])\n    }}"
1047                        );
1048                    }
1049                    c.refs
1050                        .texts
1051                        .insert(reference, (key.clone(), e.text().to_string()));
1052                    text_params.insert(&e.name, (key, ps));
1053                }
1054                Kind::Image | Kind::File | Kind::Sound => {
1055                    let (rel, full) = match resource_path(root, &set.neutral.path, e.text()) {
1056                        Ok(p) => p,
1057                        Err(err) => {
1058                            errors.push(format!("{at}: {:?}: {err}", e.name));
1059                            continue;
1060                        }
1061                    };
1062                    let (target, id) = match e.kind {
1063                        Kind::Image => {
1064                            let Some(stem) = rel.strip_suffix(".png") else {
1065                                errors
1066                                    .push(format!("{at}: {:?}: images must be .png files", e.name));
1067                                continue;
1068                            };
1069                            let texture = e
1070                                .texture
1071                                .as_deref()
1072                                .map(|t| t.trim_matches('/').trim_end_matches(".png").to_string())
1073                                .unwrap_or_else(|| stem.to_string());
1074                            let source = format!("{mod_id}:{texture}");
1075                            c.refs.images.insert(reference, source.clone());
1076                            (format!("assets/{mod_id}/textures/{texture}.png"), source)
1077                        }
1078                        Kind::Sound => {
1079                            let Some(stem) = rel.strip_suffix(".ogg") else {
1080                                errors
1081                                    .push(format!("{at}: {:?}: sounds must be .ogg files", e.name));
1082                                continue;
1083                            };
1084                            let event = e
1085                                .event
1086                                .clone()
1087                                .unwrap_or_else(|| e.name.to_ascii_lowercase());
1088                            let mut def =
1089                                serde_json::json!({ "sounds": [format!("{mod_id}:{stem}")] });
1090                            if let Some(sub) = &e.subtitle {
1091                                def["subtitle"] = serde_json::Value::String(sub.clone());
1092                            }
1093                            if sounds.insert(event.clone(), def).is_some() {
1094                                errors
1095                                    .push(format!("{at}: sound event {event:?} is defined twice"));
1096                            }
1097                            (
1098                                format!("assets/{mod_id}/sounds/{stem}.ogg"),
1099                                format!("{mod_id}:{event}"),
1100                            )
1101                        }
1102                        _ => (format!("assets/{mod_id}/{rel}"), format!("{mod_id}:{rel}")),
1103                    };
1104                    if let Some(other) = bundled.insert(target.clone(), full.clone())
1105                        && other != full
1106                    {
1107                        errors.push(format!(
1108                            "{at}: {:?} goes to {target}, like {}",
1109                            e.name,
1110                            display(root, &other)
1111                        ));
1112                    }
1113                    let _ = writeln!(
1114                        code,
1115                        "{doc}    pub const fn {acc}() -> &'static str {{\n        {id:?}\n    }}"
1116                    );
1117                }
1118                Kind::Color => match parse_color(e.text()) {
1119                    Some(v) => {
1120                        c.refs.colors.insert(reference, v);
1121                        let _ = writeln!(code, "{doc}    pub const {upper}: u32 = 0x{v:08X};");
1122                    }
1123                    None => errors.push(format!(
1124                        "{at}: {:?}: `{}` isn't a color — #RRGGBB or #AARRGGBB",
1125                        e.name,
1126                        e.text()
1127                    )),
1128                },
1129                Kind::Number => {
1130                    let Value::Num(lit) = &e.value else { continue };
1131                    let int = !lit.contains(['.', 'e', 'E']);
1132                    match (int, lit.parse::<i64>()) {
1133                        (true, Ok(v)) => {
1134                            let _ = writeln!(code, "{doc}    pub const {upper}: i64 = {v};");
1135                        }
1136                        _ => {
1137                            let v: f64 = lit.parse().unwrap_or_default();
1138                            let _ = writeln!(code, "{doc}    pub const {upper}: f64 = {v:?};");
1139                        }
1140                    }
1141                }
1142                Kind::Bool => {
1143                    let v = matches!(e.value, Value::Bool(true));
1144                    let _ = writeln!(code, "{doc}    pub const {upper}: bool = {v};");
1145                }
1146            }
1147        }
1148        code.push_str("}\n");
1149        c.sources.insert(set.name.clone(), code);
1150
1151        for (locale, file) in &set.cultures {
1152            let shown = display(root, &file.path);
1153            let mut missing: Vec<&str> = Vec::new();
1154            for e in &set.neutral.entries {
1155                if e.kind == Kind::String
1156                    && !file
1157                        .entries
1158                        .iter()
1159                        .any(|t| t.name == e.name && !t.text().is_empty())
1160                {
1161                    missing.push(&e.name);
1162                }
1163            }
1164            let mut extra: Vec<&str> = Vec::new();
1165            for t in &file.entries {
1166                let Some((key, ps)) = text_params.get(t.name.as_str()) else {
1167                    extra.push(&t.name);
1168                    continue;
1169                };
1170                if t.text().is_empty() {
1171                    continue;
1172                }
1173                match segments(t.text()).and_then(|segs| lang_text(&segs, ps)) {
1174                    Ok(text) => lang
1175                        .entry(locale.clone())
1176                        .or_default()
1177                        .push((key.clone(), text)),
1178                    Err(err) => errors.push(format!("{shown}:{}: {:?}: {err}", t.line, t.name)),
1179                }
1180            }
1181            if !missing.is_empty() {
1182                c.warnings.push(format!(
1183                    "{shown}: {} text(s) not translated (shown in {NEUTRAL_LOCALE}): {}",
1184                    missing.len(),
1185                    preview(&missing)
1186                ));
1187            }
1188            if !extra.is_empty() {
1189                c.warnings.push(format!(
1190                    "{shown}: {} text(s) not in {}.yres, left out: {}",
1191                    extra.len(),
1192                    set.name,
1193                    preview(&extra)
1194                ));
1195            }
1196        }
1197    }
1198
1199    // Lang files, merged into hand-written ones.
1200    for (locale, mut entries) in lang {
1201        let path = format!("assets/{mod_id}/lang/{locale}.json");
1202        if let Ok(text) = std::fs::read_to_string(root.join(&path)) {
1203            match parse(&text) {
1204                Ok(hand) => {
1205                    let mut merged: Vec<(String, String)> = Vec::new();
1206                    for h in hand {
1207                        if let Some(at) = key_origin.get(&h.name) {
1208                            errors.push(format!(
1209                                "{path}:{}: {:?} is also defined at {at}",
1210                                h.line, h.name
1211                            ));
1212                        }
1213                        merged.push((h.name.clone(), h.text().to_string()));
1214                    }
1215                    merged.append(&mut entries);
1216                    entries = merged;
1217                }
1218                Err(e) => errors.push(format!("{path}:{}", e.trim_start_matches("line "))),
1219            }
1220        }
1221        c.outputs.push((path, lang_json(&entries).into_bytes()));
1222    }
1223    for (target, source) in bundled {
1224        match std::fs::read(&source) {
1225            Ok(bytes) => c.outputs.push((target, bytes)),
1226            Err(e) => errors.push(format!("{}: {e}", display(root, &source))),
1227        }
1228    }
1229    if !sounds.is_empty() {
1230        let path = format!("assets/{mod_id}/sounds.json");
1231        let mut all = serde_json::Map::new();
1232        if let Ok(text) = std::fs::read_to_string(root.join(&path)) {
1233            match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&text) {
1234                Ok(hand) => all = hand,
1235                Err(e) => errors.push(format!("{path}: {e}")),
1236            }
1237        }
1238        for (event, def) in sounds {
1239            if all.insert(event.clone(), def).is_some() {
1240                errors.push(format!(
1241                    "{path}: sound event {event:?} is also defined by a .yres sound"
1242                ));
1243            }
1244        }
1245        let json =
1246            serde_json::to_string_pretty(&serde_json::Value::Object(all)).unwrap_or_default();
1247        c.outputs.push((path, format!("{json}\n").into_bytes()));
1248    }
1249
1250    if !errors.is_empty() {
1251        return Err(format!(
1252            "invalid .yres resources:\n  {}",
1253            errors.join("\n  ")
1254        ));
1255    }
1256    Ok(c)
1257}
1258
1259const 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";
1260
1261/// Writes `.yog-build/generated/resources/<Set>.g.rs` per set and
1262/// `.yog-build/generated/resources.g.rs` including them all (what
1263/// `yog_api::resources!()` pulls in as `crate::res`); deletes those of sets
1264/// that are gone.
1265pub fn write_sources(root: &Path, compiled: &Compiled) -> Result<(), String> {
1266    let dir = root.join(GENERATED_DIR);
1267    if let Ok(entries) = std::fs::read_dir(&dir) {
1268        for entry in entries.flatten() {
1269            let name = entry.file_name().to_string_lossy().into_owned();
1270            if name
1271                .strip_suffix(".g.rs")
1272                .is_some_and(|set| !compiled.sources.contains_key(set))
1273            {
1274                let _ = std::fs::remove_file(entry.path());
1275            }
1276        }
1277    }
1278    let mut index = BANNER.replace("{source}", "resources/**/*.yres");
1279    index.push_str("// Included by `yog_api::resources!()` as `crate::res`.\n");
1280    for (set, code) in &compiled.sources {
1281        let source = code
1282            .lines()
1283            .next()
1284            .and_then(|l| l.split('`').nth(1))
1285            .unwrap_or("resources/");
1286        let mut file = BANNER.replace("{source}", source);
1287        file.push_str(code);
1288        crate::write_if_changed(&dir.join(format!("{set}.g.rs")), &file)?;
1289        let _ = writeln!(
1290            index,
1291            "include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/resources/{set}.g.rs\"));"
1292        );
1293    }
1294    crate::write_if_changed(&root.join(GENERATED_FILE), &index)
1295}
1296
1297/// Resolves a `yog.toml` value: `@<Set>.<name>` of a text → (neutral text,
1298/// Some(key)); anything else as is. Errors on an unknown reference.
1299pub fn resolve_text(refs: &Refs, value: &str) -> Result<(String, Option<String>), String> {
1300    match value.strip_prefix('@') {
1301        Some(rest) if rest.starts_with('@') => Ok((rest.to_string(), None)),
1302        Some(rest) => match refs.texts.get(rest) {
1303            Some((key, text)) => Ok((text.clone(), Some(key.clone()))),
1304            None => Err(format!("`{value}`: no text `{rest}` in resources/*.yres")),
1305        },
1306        None => Ok((value.to_string(), None)),
1307    }
1308}
1309
1310/// Resolves a `yog.toml` image value: `@<Set>.<name>` → its image source.
1311pub fn resolve_image(refs: &Refs, value: &str) -> Result<String, String> {
1312    match value.strip_prefix('@') {
1313        Some(rest) => refs
1314            .images
1315            .get(rest)
1316            .cloned()
1317            .ok_or_else(|| format!("`{value}`: no image `{rest}` in resources/*.yres")),
1318        None => Ok(value.to_string()),
1319    }
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324    use super::*;
1325
1326    #[test]
1327    fn parses_all_shapes_in_order() {
1328        let entries = parse(
1329            "{\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}\n",
1330        )
1331        .unwrap();
1332        assert_eq!(entries.len(), 6);
1333        assert_eq!(entries[0].name, "b");
1334        assert_eq!(entries[1].line, 3);
1335        assert_eq!(entries[1].value, Value::Str("A é".into()));
1336        assert_eq!(entries[1].key.as_deref(), Some("x.y"));
1337        assert_eq!(entries[2].kind, Kind::Number);
1338        assert_eq!(entries[3].value, Value::Bool(false));
1339        assert_eq!(entries[4].kind, Kind::Color);
1340        assert_eq!(entries[5].value, Value::Num("-1.5e2".into()));
1341    }
1342
1343    #[test]
1344    fn errors_carry_lines() {
1345        let err = parse("{\n  \"a\": \"1\",\n  \"a\": \"2\"\n}").unwrap_err();
1346        assert!(
1347            err.starts_with("line 3: duplicate name \"a\" (first on line 2)"),
1348            "{err}"
1349        );
1350        let err = parse("{\n  \"a\": { \"type\": \"bool\", \"value\": \"x\" }\n}").unwrap_err();
1351        assert!(
1352            err.starts_with("line 2:") && err.contains("true/false"),
1353            "{err}"
1354        );
1355        let err = parse("{\n\"a\": { \"value\": \"x\", \"note\": \"y\" }\n}").unwrap_err();
1356        assert!(err.contains("unknown field `note`"), "{err}");
1357        let err = parse("{\n\"a\": { \"type\": \"image\", \"value\": \"x\", \"key\": \"k\" }\n}")
1358            .unwrap_err();
1359        assert!(err.contains("`key` is only for string"), "{err}");
1360        let err = parse("{\n  \"a\": \"1\"\n  \"b\": \"2\"\n}").unwrap_err();
1361        assert!(err.starts_with("line 3: expected `,` or `}`"), "{err}");
1362        assert!(parse("[]").unwrap_err().starts_with("line 1: expected `{`"));
1363        assert!(parse("\u{FEFF}{ }").unwrap().is_empty());
1364    }
1365
1366    #[test]
1367    fn names_and_keys() {
1368        assert_eq!(accessor_name("screen.title"), "screen_title");
1369        assert_eq!(accessor_name("ModName"), "mod_name");
1370        assert!(valid_name("ruby.tooltip") && !valid_name("1a") && !valid_name("a..b"));
1371        assert!(valid_locale("pt_br") && valid_locale("lol_us") && !valid_locale("EN_US"));
1372        assert_eq!(default_key("Items", "m", "ruby"), "item.m.ruby");
1373        assert_eq!(default_key("Strings", "m", "title"), "m.title");
1374        assert_eq!(
1375            split_file_name("Strings.ru_ru.yres"),
1376            Some(("Strings", Some("ru_ru")))
1377        );
1378        assert_eq!(split_file_name("Icons.yres"), Some(("Icons", None)));
1379    }
1380
1381    #[test]
1382    fn placeholders_become_minecraft_format() {
1383        let segs = segments("{shown} of {total}: 100% {{ok}}").unwrap();
1384        let ps = params(&segs).unwrap();
1385        assert_eq!(ps, ["shown", "total"]);
1386        assert_eq!(lang_text(&segs, &ps).unwrap(), "%1$s of %2$s: 100%% {ok}");
1387        let ru = segments("из {total} показано {shown}").unwrap();
1388        assert_eq!(lang_text(&ru, &ps).unwrap(), "из %2$s показано %1$s");
1389        assert!(lang_text(&segments("{other}").unwrap(), &ps).is_err());
1390        let segs = segments("{1} and {0}").unwrap();
1391        assert_eq!(params(&segs).unwrap(), ["0", "1"]);
1392        assert!(params(&segments("{0} {name}").unwrap()).is_err());
1393        assert!(params(&segments("{1}").unwrap()).is_err());
1394        assert!(segments("a { b").is_err());
1395        assert!(segments("a } b").is_err());
1396    }
1397
1398    fn temp_mod(name: &str) -> PathBuf {
1399        let dir = std::env::temp_dir().join(format!("yog-yres-{name}-{}", std::process::id()));
1400        let _ = std::fs::remove_dir_all(&dir);
1401        std::fs::create_dir_all(dir.join(RESOURCES_DIR)).unwrap();
1402        dir
1403    }
1404
1405    #[test]
1406    fn compiles_a_mod() {
1407        let root = temp_mod("ok");
1408        let res = root.join(RESOURCES_DIR);
1409        std::fs::create_dir_all(res.join("images")).unwrap();
1410        std::fs::create_dir_all(res.join("sounds")).unwrap();
1411        std::fs::write(res.join("images/logo.png"), b"png").unwrap();
1412        std::fs::write(res.join("sounds/click.ogg"), b"ogg").unwrap();
1413        std::fs::write(
1414            res.join("Strings.yres"),
1415            r#"{"title": {"value": "Mods", "comment": "c"}, "count": "{n} mods", "gone": "G"}"#,
1416        )
1417        .unwrap();
1418        std::fs::write(
1419            res.join("Strings.ru_ru.yres"),
1420            r#"{"title": "Моды", "count": "Модов: {n}", "x": "X"}"#,
1421        )
1422        .unwrap();
1423        std::fs::write(
1424            res.join("Icons.yres"),
1425            r##"{"logo": {"type": "image", "value": "images/logo.png"}, "accent": {"type": "color", "value": "#FF8800"}, "click": {"type": "sound", "value": "sounds/click.ogg"}, "max": 3, "on": true}"##,
1426        )
1427        .unwrap();
1428        std::fs::write(res.join("Items.yres"), r#"{"ruby": "Ruby"}"#).unwrap();
1429        std::fs::create_dir_all(root.join("assets/m/lang")).unwrap();
1430        std::fs::write(
1431            root.join("assets/m/lang/en_us.json"),
1432            r#"{"hand.key": "Hand"}"#,
1433        )
1434        .unwrap();
1435
1436        let c = compile(&root, "m").unwrap();
1437        let out: BTreeMap<&str, String> = c
1438            .outputs
1439            .iter()
1440            .map(|(p, b)| (p.as_str(), String::from_utf8_lossy(b).into_owned()))
1441            .collect();
1442        assert_eq!(
1443            out["assets/m/lang/en_us.json"],
1444            "{\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"
1445        );
1446        assert_eq!(
1447            out["assets/m/lang/ru_ru.json"],
1448            "{\n  \"m.title\": \"Моды\",\n  \"m.count\": \"Модов: %1$s\"\n}\n"
1449        );
1450        assert_eq!(out["assets/m/textures/images/logo.png"], "png");
1451        assert_eq!(out["assets/m/sounds/sounds/click.ogg"], "ogg");
1452        assert!(out["assets/m/sounds.json"].contains("\"m:sounds/click\""));
1453        assert_eq!(c.refs.images["Icons.logo"], "m:images/logo");
1454        assert_eq!(
1455            c.refs.texts["Strings.title"],
1456            ("m.title".to_string(), "Mods".to_string())
1457        );
1458        let strings = &c.sources["Strings"];
1459        assert!(
1460            strings.contains("pub const TITLE_KEY: &'static str = \"m.title\";"),
1461            "{strings}"
1462        );
1463        assert!(
1464            strings.contains("pub fn count(n: impl ::std::fmt::Display)"),
1465            "{strings}"
1466        );
1467        assert!(
1468            strings.contains("::yog_api::tr_args(Self::COUNT_KEY, &[&n])"),
1469            "{strings}"
1470        );
1471        let icons = &c.sources["Icons"];
1472        assert!(
1473            icons.contains("pub const fn logo() -> &'static str {\n        \"m:images/logo\""),
1474            "{icons}"
1475        );
1476        assert!(
1477            icons.contains("pub const ACCENT: u32 = 0xFFFF8800;"),
1478            "{icons}"
1479        );
1480        assert!(icons.contains("pub const MAX: i64 = 3;"), "{icons}");
1481        assert!(icons.contains("pub const ON: bool = true;"), "{icons}");
1482        assert!(icons.contains("\"m:click\""), "{icons}");
1483        assert_eq!(c.warnings.len(), 2, "{:?}", c.warnings);
1484        assert!(
1485            c.warnings[0].contains("1 text(s) not translated") && c.warnings[0].contains("gone")
1486        );
1487        assert!(c.warnings[1].contains("left out: x"));
1488
1489        write_sources(&root, &c).unwrap();
1490        let icons_rs =
1491            std::fs::read_to_string(root.join(GENERATED_DIR).join("Icons.g.rs")).unwrap();
1492        assert!(icons_rs.starts_with("// <auto-generated>"), "{icons_rs}");
1493        assert!(icons_rs.contains("pub struct Icons;"), "{icons_rs}");
1494        let index = std::fs::read_to_string(root.join(GENERATED_FILE)).unwrap();
1495        assert!(
1496            index.contains("/generated/resources/Icons.g.rs\"));"),
1497            "{index}"
1498        );
1499        assert!(
1500            index.contains("/generated/resources/Strings.g.rs\"));"),
1501            "{index}"
1502        );
1503        let (name, key) = resolve_text(&c.refs, "@Strings.title").unwrap();
1504        assert_eq!((name.as_str(), key.as_deref()), ("Mods", Some("m.title")));
1505        assert!(resolve_text(&c.refs, "@Strings.nope").is_err());
1506        assert_eq!(
1507            resolve_image(&c.refs, "@Icons.logo").unwrap(),
1508            "m:images/logo"
1509        );
1510        let _ = std::fs::remove_dir_all(&root);
1511    }
1512
1513    #[test]
1514    fn reports_every_problem_with_file_and_line() {
1515        let root = temp_mod("errors");
1516        let res = root.join(RESOURCES_DIR);
1517        std::fs::write(
1518            res.join("Strings.yres"),
1519            "{\n  \"a\": \"A\",\n  \"b\": { \"value\": \"B\", \"key\": \"m.a\" },\n  \"img\": { \"type\": \"image\", \"value\": \"../x.png\" },\n  \"bad name\": \"x\",\n  \"col\": { \"type\": \"color\", \"value\": \"red\" }\n}",
1520        )
1521        .unwrap();
1522        std::fs::write(res.join("Strings.en_us.yres"), "{}").unwrap();
1523        std::fs::write(res.join("Strings.de_de.yres"), "{\n  \"a\": 1\n}").unwrap();
1524        std::fs::write(res.join("Other.ru_ru.yres"), "{}").unwrap();
1525        let err = compile(&root, "m").unwrap_err();
1526        for needle in [
1527            "resources/Strings.yres:3: translation key \"m.a\" is also used at resources/Strings.yres:2",
1528            "resources/Strings.yres:4: \"img\": `../x.png` must stay inside resources/",
1529            "resources/Strings.yres:5: \"bad name\" isn't a valid name",
1530            "resources/Strings.yres:6: \"col\": `red` isn't a color",
1531            "Strings.en_us.yres: the neutral file Strings.yres is already en_us",
1532            "resources/Strings.de_de.yres:2: \"a\": culture variants hold texts only",
1533            "resources/Other.ru_ru.yres: no neutral Other.yres",
1534        ] {
1535            assert!(err.contains(needle), "missing {needle:?} in:\n{err}");
1536        }
1537        let _ = std::fs::remove_dir_all(&root);
1538    }
1539}