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