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