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. Yog rich
549/// text (`{b|…}`, `{#rrggbb|…}`: an opening with a `|` before any brace)
550/// and its `\` escapes stay in the text as they are.
551fn segments(text: &str) -> Result<Vec<Segment>, String> {
552    let mut out = Vec::new();
553    let mut lit = String::new();
554    let mut spans = 0usize;
555    let mut chars = text.chars().peekable();
556    while let Some(c) = chars.next() {
557        match c {
558            '{' if {
559                let rest: String = chars.clone().take_while(|&x| x != '{' && x != '}').collect();
560                rest.contains('|')
561            } =>
562            {
563                lit.push('{');
564                spans += 1;
565            }
566            '}' if spans > 0 => {
567                lit.push('}');
568                spans -= 1;
569            }
570            '{' if chars.peek() == Some(&'{') => {
571                chars.next();
572                lit.push_str("{{");
573            }
574            '}' if chars.peek() == Some(&'}') => {
575                chars.next();
576                lit.push_str("}}");
577            }
578            '{' => {
579                let mut arg = String::new();
580                loop {
581                    match chars.next() {
582                        Some('}') => break,
583                        Some(c) => arg.push(c),
584                        None => {
585                            return Err("`{` without `}` (write `{{` for a literal brace)".into());
586                        }
587                    }
588                }
589                let arg = arg.trim().to_string();
590                let numeric = !arg.is_empty() && arg.bytes().all(|b| b.is_ascii_digit());
591                if !numeric && (!ident_part(&arg) || RUST_KEYWORDS.contains(&arg.as_str())) {
592                    return Err(format!(
593                        "placeholder `{{{arg}}}` must be a number or an identifier (write `{{{{` for a literal brace)"
594                    ));
595                }
596                if !lit.is_empty() {
597                    out.push(Segment::Text(std::mem::take(&mut lit)));
598                }
599                out.push(Segment::Arg(arg));
600            }
601            '}' => return Err("`}` without `{` (write `}}` for a literal brace)".into()),
602            c => lit.push(c),
603        }
604    }
605    if !lit.is_empty() {
606        out.push(Segment::Text(lit));
607    }
608    Ok(out)
609}
610
611/// The parameters of a neutral text, in order: `{0}`, `{1}`, … by number
612/// (no gaps), named ones by first appearance; one style per text.
613fn params(segs: &[Segment]) -> Result<Vec<String>, String> {
614    let args: Vec<&String> = segs
615        .iter()
616        .filter_map(|s| match s {
617            Segment::Arg(a) => Some(a),
618            Segment::Text(_) => None,
619        })
620        .collect();
621    let numeric = args
622        .iter()
623        .filter(|a| a.bytes().all(|b| b.is_ascii_digit()))
624        .count();
625    if numeric > 0 && numeric < args.len() {
626        return Err("mixes numbered `{0}` and named `{name}` placeholders — use one style".into());
627    }
628    let mut out: Vec<String> = Vec::new();
629    if numeric > 0 {
630        let mut nums: Vec<usize> = args.iter().filter_map(|a| a.parse().ok()).collect();
631        nums.sort_unstable();
632        nums.dedup();
633        for (i, n) in nums.iter().enumerate() {
634            if *n != i {
635                return Err(format!(
636                    "placeholder {{{i}}} is missing (numbered placeholders start at {{0}}, no gaps)"
637                ));
638            }
639            out.push(n.to_string());
640        }
641    } else {
642        for a in args {
643            if !out.contains(a) {
644                out.push(a.clone());
645            }
646        }
647    }
648    Ok(out)
649}
650
651/// Minecraft's form of a text: `%` → `%%`, the placeholder for parameter
652/// `i` → `%{i+1}$s`. Errors on a placeholder not in `params`.
653fn lang_text(segs: &[Segment], params: &[String]) -> Result<String, String> {
654    let mut out = String::new();
655    for s in segs {
656        match s {
657            Segment::Text(t) => out.push_str(&t.replace('%', "%%")),
658            Segment::Arg(a) => {
659                let pos = params
660                    .iter()
661                    .position(|p| p == a)
662                    .ok_or_else(|| format!("placeholder `{{{a}}}` isn't in the neutral text"))?;
663                let _ = write!(out, "%{}$s", pos + 1);
664            }
665        }
666    }
667    Ok(out)
668}
669
670fn param_ident(p: &str) -> String {
671    if p.bytes().all(|b| b.is_ascii_digit()) {
672        format!("arg{p}")
673    } else {
674        accessor_name(p)
675    }
676}
677
678// ── Loading a mod's resources ────────────────────────────────────────────────
679
680/// One `.yres` file.
681#[derive(Debug, Clone)]
682pub struct ResFile {
683    pub path: PathBuf,
684    pub entries: Vec<Entry>,
685}
686
687/// A resource set: its neutral file and culture variants.
688#[derive(Debug, Clone)]
689pub struct Set {
690    pub name: String,
691    pub neutral: ResFile,
692    pub cultures: BTreeMap<String, ResFile>,
693}
694
695/// What `.yui` screens and `yog.toml` can refer to as `@<Set>.<name>`.
696#[derive(Debug, Clone, Default)]
697pub struct Refs {
698    /// `Set.name` → (translation key, neutral text).
699    pub texts: HashMap<String, (String, String)>,
700    /// `Set.name` → image source (`<mod id>:<texture>`).
701    pub images: HashMap<String, String>,
702    /// `Set.name` → color (`0xAARRGGBB`).
703    pub colors: HashMap<String, u32>,
704}
705
706/// Everything `yog build` makes of a mod's resources.
707#[derive(Debug, Default)]
708pub struct Compiled {
709    /// Files to add to the `.yog` (lang JSON, images, sounds, sounds.json) —
710    /// they replace a hand-written asset at the same path (merged into them).
711    pub outputs: Vec<Output>,
712    /// `<Set>` → generated Rust source.
713    pub sources: BTreeMap<String, String>,
714    pub refs: Refs,
715    pub warnings: Vec<String>,
716    /// Number of `.yres` files read.
717    pub files: usize,
718}
719
720/// Every `resources/**/*.yres` of the mod at `root`, sorted.
721pub fn find(root: &Path) -> Vec<PathBuf> {
722    fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
723        let Ok(entries) = std::fs::read_dir(dir) else {
724            return;
725        };
726        for entry in entries.flatten() {
727            let path = entry.path();
728            if path.is_dir() {
729                walk(&path, out);
730            } else if path.extension().is_some_and(|e| e == "yres") {
731                out.push(path);
732            }
733        }
734    }
735    let mut out = Vec::new();
736    walk(&root.join(RESOURCES_DIR), &mut out);
737    out.sort();
738    out
739}
740
741fn display(root: &Path, p: &Path) -> String {
742    p.strip_prefix(root)
743        .unwrap_or(p)
744        .to_string_lossy()
745        .replace('\\', "/")
746}
747
748/// Reads and checks every `.yres` of the mod — errors are `file:line: message`.
749fn load(root: &Path, errors: &mut Vec<String>) -> BTreeMap<String, Set> {
750    let mut neutral: BTreeMap<String, ResFile> = BTreeMap::new();
751    let mut cultures: Vec<(String, String, ResFile)> = Vec::new();
752    for path in find(root) {
753        let shown = display(root, &path);
754        let file_name = path
755            .file_name()
756            .unwrap_or_default()
757            .to_string_lossy()
758            .into_owned();
759        let Some((set, locale)) = split_file_name(&file_name) else {
760            continue;
761        };
762        if !valid_set_name(set) {
763            errors.push(format!(
764                "{shown}: `{set}` isn't a valid resource set name — use an identifier like Strings or Icons"
765            ));
766            continue;
767        }
768        if let Some(locale) = locale {
769            if locale == NEUTRAL_LOCALE {
770                errors.push(format!(
771                    "{shown}: the neutral file {set}.yres is already {NEUTRAL_LOCALE} — put these texts there"
772                ));
773                continue;
774            }
775            if !valid_locale(locale) {
776                errors.push(format!(
777                    "{shown}: `{locale}` isn't a Minecraft locale code (lower case, like ru_ru or pt_br)"
778                ));
779                continue;
780            }
781        }
782        let entries = match std::fs::read_to_string(&path)
783            .map_err(|e| e.to_string())
784            .and_then(|t| parse(&t))
785        {
786            Ok(entries) => entries,
787            Err(e) => {
788                errors.push(format!("{shown}:{}", e.trim_start_matches("line ")));
789                continue;
790            }
791        };
792        for e in &entries {
793            if !valid_name(&e.name) {
794                errors.push(format!(
795                    "{shown}:{}: {:?} isn't a valid name — identifiers joined by `.`, like screen.title",
796                    e.line, e.name
797                ));
798            }
799        }
800        let file = ResFile { path, entries };
801        match locale {
802            None => {
803                if let Some(other) = neutral.get(set) {
804                    errors.push(format!(
805                        "{shown}: resource set {set} is also defined by {}",
806                        display(root, &other.path)
807                    ));
808                    continue;
809                }
810                neutral.insert(set.to_string(), file);
811            }
812            Some(locale) => cultures.push((set.to_string(), locale.to_string(), file)),
813        }
814    }
815    let mut sets: BTreeMap<String, Set> = neutral
816        .into_iter()
817        .map(|(name, neutral)| {
818            (
819                name.clone(),
820                Set {
821                    name,
822                    neutral,
823                    cultures: BTreeMap::new(),
824                },
825            )
826        })
827        .collect();
828    for (set, locale, file) in cultures {
829        let shown = display(root, &file.path);
830        let Some(target) = sets.get_mut(&set) else {
831            errors.push(format!(
832                "{shown}: no neutral {set}.yres for this culture variant"
833            ));
834            continue;
835        };
836        if let Some(other) = target.cultures.get(&locale) {
837            errors.push(format!(
838                "{shown}: {set} {locale} is also defined by {}",
839                display(root, &other.path)
840            ));
841            continue;
842        }
843        for e in &file.entries {
844            if e.kind != Kind::String || e.key.is_some() {
845                errors.push(format!(
846                    "{shown}:{}: {:?}: culture variants hold texts only (no type other than string, no `key`)",
847                    e.line, e.name
848                ));
849            }
850        }
851        target.cultures.insert(locale, file);
852    }
853    sets
854}
855
856/// A path of an entry, relative to its `.yres` file → the path relative to
857/// `resources/` (`/`-separated, checked to stay inside and to exist).
858fn resource_path(root: &Path, yres: &Path, value: &str) -> Result<(String, PathBuf), String> {
859    let base = yres.parent().unwrap_or(root);
860    let mut parts: Vec<String> = base
861        .strip_prefix(root.join(RESOURCES_DIR))
862        .unwrap_or(Path::new(""))
863        .components()
864        .map(|c| c.as_os_str().to_string_lossy().into_owned())
865        .collect();
866    for c in Path::new(&value.replace('\\', "/")).components() {
867        match c {
868            Component::Normal(s) => parts.push(s.to_string_lossy().into_owned()),
869            Component::CurDir => {}
870            Component::ParentDir if !parts.is_empty() => {
871                parts.pop();
872            }
873            _ => return Err(format!("`{value}` must stay inside {RESOURCES_DIR}/")),
874        }
875    }
876    let rel = parts.join("/");
877    if rel.is_empty() {
878        return Err("the path is empty".into());
879    }
880    let full = root.join(RESOURCES_DIR).join(&rel);
881    if !full.is_file() {
882        return Err(format!("{RESOURCES_DIR}/{rel} doesn't exist"));
883    }
884    if !rel.bytes().all(|b| {
885        b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.' | b'/')
886    }) {
887        return Err(format!(
888            "{RESOURCES_DIR}/{rel}: Minecraft resource paths are lower case — only a-z, 0-9, `_`, `-`, `.` and `/`"
889        ));
890    }
891    Ok((rel, full))
892}
893
894fn parse_color(s: &str) -> Option<u32> {
895    let hex = s.trim().strip_prefix('#')?;
896    let v = u32::from_str_radix(hex, 16).ok()?;
897    match hex.len() {
898        6 => Some(0xFF00_0000 | v),
899        8 => Some(v),
900        _ => None,
901    }
902}
903
904/// The lang JSON of `entries` (key, text), order kept.
905fn lang_json(entries: &[(String, String)]) -> String {
906    let mut out = String::from("{");
907    for (n, (k, v)) in entries.iter().enumerate() {
908        out.push_str(if n == 0 { "\n  " } else { ",\n  " });
909        out.push_str(&serde_json::to_string(k).unwrap_or_default());
910        out.push_str(": ");
911        out.push_str(&serde_json::to_string(v).unwrap_or_default());
912    }
913    out.push_str(if entries.is_empty() { "}\n" } else { "\n}\n" });
914    out
915}
916
917fn preview(names: &[&str]) -> String {
918    const SHOWN: usize = 5;
919    let mut text = names
920        .iter()
921        .take(SHOWN)
922        .copied()
923        .collect::<Vec<_>>()
924        .join(", ");
925    if names.len() > SHOWN {
926        let _ = write!(text, ", … (+{})", names.len() - SHOWN);
927    }
928    text
929}
930
931fn doc_line(s: &str) -> String {
932    let one: String = s
933        .chars()
934        .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
935        .collect();
936    one.replace("*/", "* /")
937}
938
939/// Compiles the mod's resources (see the module docs). Any error fails the
940/// whole thing, listing every problem.
941pub fn compile(root: &Path, mod_id: &str) -> Result<Compiled, String> {
942    let mut errors = Vec::new();
943    let sets = load(root, &mut errors);
944    let mut c = Compiled {
945        files: sets.values().map(|s| 1 + s.cultures.len()).sum(),
946        ..Compiled::default()
947    };
948    // locale → (key, text), in set order then file order.
949    let mut lang: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
950    let mut key_origin: HashMap<String, String> = HashMap::new();
951    // Bundled path → the file it comes from.
952    let mut bundled: BTreeMap<String, PathBuf> = BTreeMap::new();
953    let mut sounds: BTreeMap<String, serde_json::Value> = BTreeMap::new();
954
955    for set in sets.values() {
956        let shown = display(root, &set.neutral.path);
957        let mut code = String::new();
958        let mut accessors: HashMap<String, usize> = HashMap::new();
959        // Neutral text params per name, for checking the cultures.
960        let mut text_params: HashMap<&str, (String, Vec<String>)> = HashMap::new();
961        let _ = writeln!(
962            code,
963            "/// The resources of `{shown}`.\n#[allow(dead_code)]\npub struct {name};\n\n#[allow(dead_code, non_snake_case, clippy::all)]\nimpl {name} {{",
964            name = set.name
965        );
966        for e in &set.neutral.entries {
967            let at = format!("{shown}:{}", e.line);
968            let acc = accessor_name(&e.name);
969            if RUST_KEYWORDS.contains(&acc.as_str()) {
970                errors.push(format!(
971                    "{at}: {:?} gives the accessor `{acc}`, a Rust keyword — rename it",
972                    e.name
973                ));
974            }
975            if let Some(first) = accessors.insert(acc.clone(), e.line) {
976                errors.push(format!(
977                    "{at}: {:?} gives the same accessor `{acc}` as the entry on line {first}",
978                    e.name
979                ));
980            }
981            let upper = acc.to_ascii_uppercase();
982            // Like Resources.Designer.cs: what the entry holds, then its comment.
983            let doc = {
984                let what = match e.kind {
985                    Kind::String => format!(
986                        "Looks up a localized string similar to \"{}\".",
987                        doc_line(e.text())
988                    ),
989                    Kind::Image => format!("The image `{}`.", doc_line(e.text())),
990                    Kind::File => format!("The file `{}`.", doc_line(e.text())),
991                    Kind::Sound => format!("The sound `{}`.", doc_line(e.text())),
992                    Kind::Color => format!("The color `{}`.", doc_line(e.text())),
993                    Kind::Number | Kind::Bool => match &e.value {
994                        Value::Num(n) => format!("The number `{n}`."),
995                        Value::Bool(b) => format!("The flag `{b}`."),
996                        Value::Str(v) => format!("The value `{}`.", doc_line(v)),
997                    },
998                };
999                let mut doc = format!("    /// {what}\n    ///\n    /// `{}`", e.name);
1000                if let Some(comment) = &e.comment {
1001                    let _ = write!(doc, " — {}", doc_line(comment));
1002                }
1003                let _ = writeln!(doc, " ({shown}:{})", e.line);
1004                doc
1005            };
1006            let reference = format!("{}.{}", set.name, e.name);
1007            match e.kind {
1008                Kind::String => {
1009                    let key = e
1010                        .key
1011                        .clone()
1012                        .unwrap_or_else(|| default_key(&set.name, mod_id, &e.name));
1013                    if let Some(first) = key_origin.insert(key.clone(), at.clone()) {
1014                        errors.push(format!(
1015                            "{at}: translation key {key:?} is also used at {first}"
1016                        ));
1017                    }
1018                    let segs = match segments(e.text()) {
1019                        Ok(s) => s,
1020                        Err(err) => {
1021                            errors.push(format!("{at}: {:?}: {err}", e.name));
1022                            continue;
1023                        }
1024                    };
1025                    let ps = match params(&segs) {
1026                        Ok(p) => p,
1027                        Err(err) => {
1028                            errors.push(format!("{at}: {:?}: {err}", e.name));
1029                            continue;
1030                        }
1031                    };
1032                    if let Ok(text) = lang_text(&segs, &ps) {
1033                        lang.entry(NEUTRAL_LOCALE.into())
1034                            .or_default()
1035                            .push((key.clone(), text));
1036                    }
1037                    let _ = writeln!(
1038                        code,
1039                        "    /// Translation key of [`Self::{acc}`]: `{key}`.\n    pub const {upper}_KEY: &'static str = {key:?};"
1040                    );
1041                    code.push_str(&doc);
1042                    if ps.is_empty() {
1043                        let _ = writeln!(
1044                            code,
1045                            "    pub fn {acc}() -> ::std::string::String {{\n        ::yog_api::tr(Self::{upper}_KEY)\n    }}"
1046                        );
1047                    } else {
1048                        let idents: Vec<String> = ps.iter().map(|p| param_ident(p)).collect();
1049                        let sig = idents
1050                            .iter()
1051                            .map(|p| format!("{p}: impl ::std::fmt::Display"))
1052                            .collect::<Vec<_>>()
1053                            .join(", ");
1054                        let args = idents
1055                            .iter()
1056                            .map(|p| format!("&{p}"))
1057                            .collect::<Vec<_>>()
1058                            .join(", ");
1059                        let _ = writeln!(
1060                            code,
1061                            "    pub fn {acc}({sig}) -> ::std::string::String {{\n        ::yog_api::tr_args(Self::{upper}_KEY, &[{args}])\n    }}"
1062                        );
1063                    }
1064                    c.refs
1065                        .texts
1066                        .insert(reference, (key.clone(), e.text().to_string()));
1067                    text_params.insert(&e.name, (key, ps));
1068                }
1069                Kind::Image | Kind::File | Kind::Sound => {
1070                    let (rel, full) = match resource_path(root, &set.neutral.path, e.text()) {
1071                        Ok(p) => p,
1072                        Err(err) => {
1073                            errors.push(format!("{at}: {:?}: {err}", e.name));
1074                            continue;
1075                        }
1076                    };
1077                    let (target, id) = match e.kind {
1078                        Kind::Image => {
1079                            let Some(stem) = rel.strip_suffix(".png") else {
1080                                errors
1081                                    .push(format!("{at}: {:?}: images must be .png files", e.name));
1082                                continue;
1083                            };
1084                            let texture = e
1085                                .texture
1086                                .as_deref()
1087                                .map(|t| t.trim_matches('/').trim_end_matches(".png").to_string())
1088                                .unwrap_or_else(|| stem.to_string());
1089                            let source = format!("{mod_id}:{texture}");
1090                            c.refs.images.insert(reference, source.clone());
1091                            (format!("assets/{mod_id}/textures/{texture}.png"), source)
1092                        }
1093                        Kind::Sound => {
1094                            let Some(stem) = rel.strip_suffix(".ogg") else {
1095                                errors
1096                                    .push(format!("{at}: {:?}: sounds must be .ogg files", e.name));
1097                                continue;
1098                            };
1099                            let event = e
1100                                .event
1101                                .clone()
1102                                .unwrap_or_else(|| e.name.to_ascii_lowercase());
1103                            let mut def =
1104                                serde_json::json!({ "sounds": [format!("{mod_id}:{stem}")] });
1105                            if let Some(sub) = &e.subtitle {
1106                                def["subtitle"] = serde_json::Value::String(sub.clone());
1107                            }
1108                            if sounds.insert(event.clone(), def).is_some() {
1109                                errors
1110                                    .push(format!("{at}: sound event {event:?} is defined twice"));
1111                            }
1112                            (
1113                                format!("assets/{mod_id}/sounds/{stem}.ogg"),
1114                                format!("{mod_id}:{event}"),
1115                            )
1116                        }
1117                        _ => (format!("assets/{mod_id}/{rel}"), format!("{mod_id}:{rel}")),
1118                    };
1119                    if let Some(other) = bundled.insert(target.clone(), full.clone())
1120                        && other != full
1121                    {
1122                        errors.push(format!(
1123                            "{at}: {:?} goes to {target}, like {}",
1124                            e.name,
1125                            display(root, &other)
1126                        ));
1127                    }
1128                    let _ = writeln!(
1129                        code,
1130                        "{doc}    pub const fn {acc}() -> &'static str {{\n        {id:?}\n    }}"
1131                    );
1132                }
1133                Kind::Color => match parse_color(e.text()) {
1134                    Some(v) => {
1135                        c.refs.colors.insert(reference, v);
1136                        let _ = writeln!(code, "{doc}    pub const {upper}: u32 = 0x{v:08X};");
1137                    }
1138                    None => errors.push(format!(
1139                        "{at}: {:?}: `{}` isn't a color — #RRGGBB or #AARRGGBB",
1140                        e.name,
1141                        e.text()
1142                    )),
1143                },
1144                Kind::Number => {
1145                    let Value::Num(lit) = &e.value else { continue };
1146                    let int = !lit.contains(['.', 'e', 'E']);
1147                    match (int, lit.parse::<i64>()) {
1148                        (true, Ok(v)) => {
1149                            let _ = writeln!(code, "{doc}    pub const {upper}: i64 = {v};");
1150                        }
1151                        _ => {
1152                            let v: f64 = lit.parse().unwrap_or_default();
1153                            let _ = writeln!(code, "{doc}    pub const {upper}: f64 = {v:?};");
1154                        }
1155                    }
1156                }
1157                Kind::Bool => {
1158                    let v = matches!(e.value, Value::Bool(true));
1159                    let _ = writeln!(code, "{doc}    pub const {upper}: bool = {v};");
1160                }
1161            }
1162        }
1163        code.push_str("}\n");
1164        c.sources.insert(set.name.clone(), code);
1165
1166        for (locale, file) in &set.cultures {
1167            let shown = display(root, &file.path);
1168            let mut missing: Vec<&str> = Vec::new();
1169            for e in &set.neutral.entries {
1170                if e.kind == Kind::String
1171                    && !file
1172                        .entries
1173                        .iter()
1174                        .any(|t| t.name == e.name && !t.text().is_empty())
1175                {
1176                    missing.push(&e.name);
1177                }
1178            }
1179            let mut extra: Vec<&str> = Vec::new();
1180            for t in &file.entries {
1181                let Some((key, ps)) = text_params.get(t.name.as_str()) else {
1182                    extra.push(&t.name);
1183                    continue;
1184                };
1185                if t.text().is_empty() {
1186                    continue;
1187                }
1188                match segments(t.text()).and_then(|segs| lang_text(&segs, ps)) {
1189                    Ok(text) => lang
1190                        .entry(locale.clone())
1191                        .or_default()
1192                        .push((key.clone(), text)),
1193                    Err(err) => errors.push(format!("{shown}:{}: {:?}: {err}", t.line, t.name)),
1194                }
1195            }
1196            if !missing.is_empty() {
1197                c.warnings.push(format!(
1198                    "{shown}: {} text(s) not translated (shown in {NEUTRAL_LOCALE}): {}",
1199                    missing.len(),
1200                    preview(&missing)
1201                ));
1202            }
1203            if !extra.is_empty() {
1204                c.warnings.push(format!(
1205                    "{shown}: {} text(s) not in {}.yres, left out: {}",
1206                    extra.len(),
1207                    set.name,
1208                    preview(&extra)
1209                ));
1210            }
1211        }
1212    }
1213
1214    // Lang files, merged into hand-written ones.
1215    for (locale, mut entries) in lang {
1216        let path = format!("assets/{mod_id}/lang/{locale}.json");
1217        if let Ok(text) = std::fs::read_to_string(root.join(&path)) {
1218            match parse(&text) {
1219                Ok(hand) => {
1220                    let mut merged: Vec<(String, String)> = Vec::new();
1221                    for h in hand {
1222                        if let Some(at) = key_origin.get(&h.name) {
1223                            errors.push(format!(
1224                                "{path}:{}: {:?} is also defined at {at}",
1225                                h.line, h.name
1226                            ));
1227                        }
1228                        merged.push((h.name.clone(), h.text().to_string()));
1229                    }
1230                    merged.append(&mut entries);
1231                    entries = merged;
1232                }
1233                Err(e) => errors.push(format!("{path}:{}", e.trim_start_matches("line "))),
1234            }
1235        }
1236        c.outputs.push((path, lang_json(&entries).into_bytes()));
1237    }
1238    for (target, source) in bundled {
1239        match std::fs::read(&source) {
1240            Ok(bytes) => c.outputs.push((target, bytes)),
1241            Err(e) => errors.push(format!("{}: {e}", display(root, &source))),
1242        }
1243    }
1244    if !sounds.is_empty() {
1245        let path = format!("assets/{mod_id}/sounds.json");
1246        let mut all = serde_json::Map::new();
1247        if let Ok(text) = std::fs::read_to_string(root.join(&path)) {
1248            match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&text) {
1249                Ok(hand) => all = hand,
1250                Err(e) => errors.push(format!("{path}: {e}")),
1251            }
1252        }
1253        for (event, def) in sounds {
1254            if all.insert(event.clone(), def).is_some() {
1255                errors.push(format!(
1256                    "{path}: sound event {event:?} is also defined by a .yres sound"
1257                ));
1258            }
1259        }
1260        let json =
1261            serde_json::to_string_pretty(&serde_json::Value::Object(all)).unwrap_or_default();
1262        c.outputs.push((path, format!("{json}\n").into_bytes()));
1263    }
1264
1265    if !errors.is_empty() {
1266        return Err(format!(
1267            "invalid .yres resources:\n  {}",
1268            errors.join("\n  ")
1269        ));
1270    }
1271    Ok(c)
1272}
1273
1274const 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";
1275
1276/// Writes `.yog-build/generated/resources/<Set>.g.rs` per set and
1277/// `.yog-build/generated/resources.g.rs` including them all (what
1278/// `yog_api::resources!()` pulls in as `crate::res`); deletes those of sets
1279/// that are gone.
1280pub fn write_sources(root: &Path, compiled: &Compiled) -> Result<(), String> {
1281    let dir = root.join(GENERATED_DIR);
1282    if let Ok(entries) = std::fs::read_dir(&dir) {
1283        for entry in entries.flatten() {
1284            let name = entry.file_name().to_string_lossy().into_owned();
1285            if name
1286                .strip_suffix(".g.rs")
1287                .is_some_and(|set| !compiled.sources.contains_key(set))
1288            {
1289                let _ = std::fs::remove_file(entry.path());
1290            }
1291        }
1292    }
1293    let mut index = BANNER.replace("{source}", "resources/**/*.yres");
1294    index.push_str("// Included by `yog_api::resources!()` as `crate::res`.\n");
1295    for (set, code) in &compiled.sources {
1296        let source = code
1297            .lines()
1298            .next()
1299            .and_then(|l| l.split('`').nth(1))
1300            .unwrap_or("resources/");
1301        let mut file = BANNER.replace("{source}", source);
1302        file.push_str(code);
1303        crate::write_if_changed(&dir.join(format!("{set}.g.rs")), &file)?;
1304        let _ = writeln!(
1305            index,
1306            "include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/resources/{set}.g.rs\"));"
1307        );
1308    }
1309    crate::write_if_changed(&root.join(GENERATED_FILE), &index)
1310}
1311
1312/// Resolves a `yog.toml` value: `@<Set>.<name>` of a text → (neutral text,
1313/// Some(key)); anything else as is. Errors on an unknown reference.
1314pub fn resolve_text(refs: &Refs, value: &str) -> Result<(String, Option<String>), String> {
1315    match value.strip_prefix('@') {
1316        Some(rest) if rest.starts_with('@') => Ok((rest.to_string(), None)),
1317        Some(rest) => match refs.texts.get(rest) {
1318            Some((key, text)) => Ok((text.clone(), Some(key.clone()))),
1319            None => Err(format!("`{value}`: no text `{rest}` in resources/*.yres")),
1320        },
1321        None => Ok((value.to_string(), None)),
1322    }
1323}
1324
1325/// Resolves a `yog.toml` image value: `@<Set>.<name>` → its image source.
1326pub fn resolve_image(refs: &Refs, value: &str) -> Result<String, String> {
1327    match value.strip_prefix('@') {
1328        Some(rest) => refs
1329            .images
1330            .get(rest)
1331            .cloned()
1332            .ok_or_else(|| format!("`{value}`: no image `{rest}` in resources/*.yres")),
1333        None => Ok(value.to_string()),
1334    }
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339
1340    #[test]
1341    fn rich_text_spans_are_not_placeholders() {
1342        let seg = segments("{#8d6acc|Media} and {b|x} for {name}").unwrap();
1343        assert_eq!(seg, [Segment::Text("{#8d6acc|Media} and {b|x} for ".into()), Segment::Arg("name".into())]);
1344    }
1345
1346    use super::*;
1347
1348    #[test]
1349    fn parses_all_shapes_in_order() {
1350        let entries = parse(
1351            "{\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",
1352        )
1353        .unwrap();
1354        assert_eq!(entries.len(), 6);
1355        assert_eq!(entries[0].name, "b");
1356        assert_eq!(entries[1].line, 3);
1357        assert_eq!(entries[1].value, Value::Str("A é".into()));
1358        assert_eq!(entries[1].key.as_deref(), Some("x.y"));
1359        assert_eq!(entries[2].kind, Kind::Number);
1360        assert_eq!(entries[3].value, Value::Bool(false));
1361        assert_eq!(entries[4].kind, Kind::Color);
1362        assert_eq!(entries[5].value, Value::Num("-1.5e2".into()));
1363    }
1364
1365    #[test]
1366    fn errors_carry_lines() {
1367        let err = parse("{\n  \"a\": \"1\",\n  \"a\": \"2\"\n}").unwrap_err();
1368        assert!(
1369            err.starts_with("line 3: duplicate name \"a\" (first on line 2)"),
1370            "{err}"
1371        );
1372        let err = parse("{\n  \"a\": { \"type\": \"bool\", \"value\": \"x\" }\n}").unwrap_err();
1373        assert!(
1374            err.starts_with("line 2:") && err.contains("true/false"),
1375            "{err}"
1376        );
1377        let err = parse("{\n\"a\": { \"value\": \"x\", \"note\": \"y\" }\n}").unwrap_err();
1378        assert!(err.contains("unknown field `note`"), "{err}");
1379        let err = parse("{\n\"a\": { \"type\": \"image\", \"value\": \"x\", \"key\": \"k\" }\n}")
1380            .unwrap_err();
1381        assert!(err.contains("`key` is only for string"), "{err}");
1382        let err = parse("{\n  \"a\": \"1\"\n  \"b\": \"2\"\n}").unwrap_err();
1383        assert!(err.starts_with("line 3: expected `,` or `}`"), "{err}");
1384        assert!(parse("[]").unwrap_err().starts_with("line 1: expected `{`"));
1385        assert!(parse("\u{FEFF}{ }").unwrap().is_empty());
1386    }
1387
1388    #[test]
1389    fn names_and_keys() {
1390        assert_eq!(accessor_name("screen.title"), "screen_title");
1391        assert_eq!(accessor_name("ModName"), "mod_name");
1392        assert!(valid_name("ruby.tooltip") && !valid_name("1a") && !valid_name("a..b"));
1393        assert!(valid_locale("pt_br") && valid_locale("lol_us") && !valid_locale("EN_US"));
1394        assert_eq!(default_key("Items", "m", "ruby"), "item.m.ruby");
1395        assert_eq!(default_key("Strings", "m", "title"), "m.title");
1396        assert_eq!(
1397            split_file_name("Strings.ru_ru.yres"),
1398            Some(("Strings", Some("ru_ru")))
1399        );
1400        assert_eq!(split_file_name("Icons.yres"), Some(("Icons", None)));
1401    }
1402
1403    #[test]
1404    fn placeholders_become_minecraft_format() {
1405        let segs = segments("{shown} of {total}: 100% {{ok}}").unwrap();
1406        let ps = params(&segs).unwrap();
1407        assert_eq!(ps, ["shown", "total"]);
1408        assert_eq!(lang_text(&segs, &ps).unwrap(), "%1$s of %2$s: 100%% {{ok}}");
1409        let ru = segments("из {total} показано {shown}").unwrap();
1410        assert_eq!(lang_text(&ru, &ps).unwrap(), "из %2$s показано %1$s");
1411        assert!(lang_text(&segments("{other}").unwrap(), &ps).is_err());
1412        let segs = segments("{1} and {0}").unwrap();
1413        assert_eq!(params(&segs).unwrap(), ["0", "1"]);
1414        assert!(params(&segments("{0} {name}").unwrap()).is_err());
1415        assert!(params(&segments("{1}").unwrap()).is_err());
1416        assert!(segments("a { b").is_err());
1417        assert!(segments("a } b").is_err());
1418    }
1419
1420    fn temp_mod(name: &str) -> PathBuf {
1421        let dir = std::env::temp_dir().join(format!("yog-yres-{name}-{}", std::process::id()));
1422        let _ = std::fs::remove_dir_all(&dir);
1423        std::fs::create_dir_all(dir.join(RESOURCES_DIR)).unwrap();
1424        dir
1425    }
1426
1427    #[test]
1428    fn compiles_a_mod() {
1429        let root = temp_mod("ok");
1430        let res = root.join(RESOURCES_DIR);
1431        std::fs::create_dir_all(res.join("images")).unwrap();
1432        std::fs::create_dir_all(res.join("sounds")).unwrap();
1433        std::fs::write(res.join("images/logo.png"), b"png").unwrap();
1434        std::fs::write(res.join("sounds/click.ogg"), b"ogg").unwrap();
1435        std::fs::write(
1436            res.join("Strings.yres"),
1437            r#"{"title": {"value": "Mods", "comment": "c"}, "count": "{n} mods", "gone": "G"}"#,
1438        )
1439        .unwrap();
1440        std::fs::write(
1441            res.join("Strings.ru_ru.yres"),
1442            r#"{"title": "Моды", "count": "Модов: {n}", "x": "X"}"#,
1443        )
1444        .unwrap();
1445        std::fs::write(
1446            res.join("Icons.yres"),
1447            r##"{"logo": {"type": "image", "value": "images/logo.png"}, "accent": {"type": "color", "value": "#FF8800"}, "click": {"type": "sound", "value": "sounds/click.ogg"}, "max": 3, "on": true}"##,
1448        )
1449        .unwrap();
1450        std::fs::write(res.join("Items.yres"), r#"{"ruby": "Ruby"}"#).unwrap();
1451        std::fs::create_dir_all(root.join("assets/m/lang")).unwrap();
1452        std::fs::write(
1453            root.join("assets/m/lang/en_us.json"),
1454            r#"{"hand.key": "Hand"}"#,
1455        )
1456        .unwrap();
1457
1458        let c = compile(&root, "m").unwrap();
1459        let out: BTreeMap<&str, String> = c
1460            .outputs
1461            .iter()
1462            .map(|(p, b)| (p.as_str(), String::from_utf8_lossy(b).into_owned()))
1463            .collect();
1464        assert_eq!(
1465            out["assets/m/lang/en_us.json"],
1466            "{\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"
1467        );
1468        assert_eq!(
1469            out["assets/m/lang/ru_ru.json"],
1470            "{\n  \"m.title\": \"Моды\",\n  \"m.count\": \"Модов: %1$s\"\n}\n"
1471        );
1472        assert_eq!(out["assets/m/textures/images/logo.png"], "png");
1473        assert_eq!(out["assets/m/sounds/sounds/click.ogg"], "ogg");
1474        assert!(out["assets/m/sounds.json"].contains("\"m:sounds/click\""));
1475        assert_eq!(c.refs.images["Icons.logo"], "m:images/logo");
1476        assert_eq!(
1477            c.refs.texts["Strings.title"],
1478            ("m.title".to_string(), "Mods".to_string())
1479        );
1480        let strings = &c.sources["Strings"];
1481        assert!(
1482            strings.contains("pub const TITLE_KEY: &'static str = \"m.title\";"),
1483            "{strings}"
1484        );
1485        assert!(
1486            strings.contains("pub fn count(n: impl ::std::fmt::Display)"),
1487            "{strings}"
1488        );
1489        assert!(
1490            strings.contains("::yog_api::tr_args(Self::COUNT_KEY, &[&n])"),
1491            "{strings}"
1492        );
1493        let icons = &c.sources["Icons"];
1494        assert!(
1495            icons.contains("pub const fn logo() -> &'static str {\n        \"m:images/logo\""),
1496            "{icons}"
1497        );
1498        assert!(
1499            icons.contains("pub const ACCENT: u32 = 0xFFFF8800;"),
1500            "{icons}"
1501        );
1502        assert!(icons.contains("pub const MAX: i64 = 3;"), "{icons}");
1503        assert!(icons.contains("pub const ON: bool = true;"), "{icons}");
1504        assert!(icons.contains("\"m:click\""), "{icons}");
1505        assert_eq!(c.warnings.len(), 2, "{:?}", c.warnings);
1506        assert!(
1507            c.warnings[0].contains("1 text(s) not translated") && c.warnings[0].contains("gone")
1508        );
1509        assert!(c.warnings[1].contains("left out: x"));
1510
1511        write_sources(&root, &c).unwrap();
1512        let icons_rs =
1513            std::fs::read_to_string(root.join(GENERATED_DIR).join("Icons.g.rs")).unwrap();
1514        assert!(icons_rs.starts_with("// <auto-generated>"), "{icons_rs}");
1515        assert!(icons_rs.contains("pub struct Icons;"), "{icons_rs}");
1516        let index = std::fs::read_to_string(root.join(GENERATED_FILE)).unwrap();
1517        assert!(
1518            index.contains("/generated/resources/Icons.g.rs\"));"),
1519            "{index}"
1520        );
1521        assert!(
1522            index.contains("/generated/resources/Strings.g.rs\"));"),
1523            "{index}"
1524        );
1525        let (name, key) = resolve_text(&c.refs, "@Strings.title").unwrap();
1526        assert_eq!((name.as_str(), key.as_deref()), ("Mods", Some("m.title")));
1527        assert!(resolve_text(&c.refs, "@Strings.nope").is_err());
1528        assert_eq!(
1529            resolve_image(&c.refs, "@Icons.logo").unwrap(),
1530            "m:images/logo"
1531        );
1532        let _ = std::fs::remove_dir_all(&root);
1533    }
1534
1535    #[test]
1536    fn reports_every_problem_with_file_and_line() {
1537        let root = temp_mod("errors");
1538        let res = root.join(RESOURCES_DIR);
1539        std::fs::write(
1540            res.join("Strings.yres"),
1541            "{\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}",
1542        )
1543        .unwrap();
1544        std::fs::write(res.join("Strings.en_us.yres"), "{}").unwrap();
1545        std::fs::write(res.join("Strings.de_de.yres"), "{\n  \"a\": 1\n}").unwrap();
1546        std::fs::write(res.join("Other.ru_ru.yres"), "{}").unwrap();
1547        let err = compile(&root, "m").unwrap_err();
1548        for needle in [
1549            "resources/Strings.yres:3: translation key \"m.a\" is also used at resources/Strings.yres:2",
1550            "resources/Strings.yres:4: \"img\": `../x.png` must stay inside resources/",
1551            "resources/Strings.yres:5: \"bad name\" isn't a valid name",
1552            "resources/Strings.yres:6: \"col\": `red` isn't a color",
1553            "Strings.en_us.yres: the neutral file Strings.yres is already en_us",
1554            "resources/Strings.de_de.yres:2: \"a\": culture variants hold texts only",
1555            "resources/Other.ru_ru.yres: no neutral Other.yres",
1556        ] {
1557            assert!(err.contains(needle), "missing {needle:?} in:\n{err}");
1558        }
1559        let _ = std::fs::remove_dir_all(&root);
1560    }
1561}