1use std::collections::{BTreeMap, HashMap};
19use std::fmt::Write as _;
20use std::path::{Component, Path, PathBuf};
21
22use yog_data::{Reader, Schema, Seg, Shape};
23
24pub const NEUTRAL_LOCALE: &str = "en_us";
26pub const RESOURCES_DIR: &str = "resources";
28pub const GENERATED_FILE: &str = ".yog-build/generated/resources.g.rs";
32pub const GENERATED_DIR: &str = ".yog-build/generated/resources";
34
35pub type Output = (String, Vec<u8>);
37
38#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum Value {
78 Str(String),
79 Num(String),
80 Bool(bool),
81}
82
83#[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 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#[derive(Schema)]
109struct EntryObject {
110 #[data(rename = "type")]
112 kind: Option<Kind>,
113 value: yog_data::Value,
115 comment: Option<String>,
117 key: Option<String>,
119 texture: Option<String>,
121 event: Option<String>,
123 subtitle: Option<String>,
125}
126
127struct YresFile(Vec<Entry>);
129
130fn 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 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 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
242pub 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 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
265pub fn write(entries: &[Entry]) -> String {
267 yog_data::text::to_string_pretty(&YresFile(entries.to_vec()).write())
268}
269
270const 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
286pub fn valid_name(name: &str) -> bool {
288 !name.is_empty() && name.split('.').all(ident_part)
289}
290
291pub fn valid_set_name(name: &str) -> bool {
293 ident_part(name) && !RUST_KEYWORDS.contains(&name)
294}
295
296pub 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(®ion.len())
304 && region
305 .bytes()
306 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
307}
308
309pub 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
329pub 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
339pub 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#[derive(Debug, Clone, PartialEq, Eq)]
351enum Segment {
352 Text(String),
353 Arg(String),
355}
356
357fn 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
420fn 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
460fn 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#[derive(Debug, Clone)]
491pub struct ResFile {
492 pub path: PathBuf,
493 pub entries: Vec<Entry>,
494}
495
496#[derive(Debug, Clone)]
498pub struct Set {
499 pub name: String,
500 pub neutral: ResFile,
501 pub cultures: BTreeMap<String, ResFile>,
502}
503
504#[derive(Debug, Clone, Default)]
506pub struct Refs {
507 pub texts: HashMap<String, (String, String)>,
509 pub images: HashMap<String, String>,
511 pub colors: HashMap<String, u32>,
513}
514
515#[derive(Debug, Default)]
517pub struct Compiled {
518 pub outputs: Vec<Output>,
521 pub sources: BTreeMap<String, String>,
523 pub refs: Refs,
524 pub warnings: Vec<String>,
525 pub files: usize,
527}
528
529pub 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
557fn 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
665fn 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
713fn 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
748pub 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 let mut lang: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
759 let mut key_origin: HashMap<String, String> = HashMap::new();
760 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 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 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 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
1085pub 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
1121pub 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
1134pub 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 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 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}