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