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