1use std::collections::{BTreeMap, HashMap};
19use std::fmt::Write as _;
20use std::path::{Path, PathBuf};
21
22
23pub const NEUTRAL_LOCALE: &str = "en_us";
25pub const RESOURCES_DIR: &str = yog_ids::project::RESOURCES;
27pub const GENERATED_FILE: &str = ".yog-build/generated/resources.g.rs";
31pub const GENERATED_DIR: &str = ".yog-build/generated/resources";
33
34pub type Output = (String, Vec<u8>);
36
37pub use yog_formats::yres::{Entry, EntryObject, Kind, Value, parse, write};
40
41const RUST_KEYWORDS: &[&str] = &[
44 "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
45 "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
46 "ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
47 "where", "while", "abstract", "become", "box", "do", "final", "gen", "macro", "override",
48 "priv", "try", "typeof", "unsized", "virtual", "yield",
49];
50
51fn ident_part(s: &str) -> bool {
52 let mut chars = s.chars();
53 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
54 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
55}
56
57pub fn valid_name(name: &str) -> bool {
59 yog_data::typed::is_res_key(name)
60}
61
62pub fn name_problem(name: &str) -> Option<String> {
64 (!valid_name(name)).then(|| format!("{name:?} isn't a valid name — identifiers joined by `.`, like screen.title"))
65}
66
67pub fn valid_set_name(name: &str) -> bool {
69 ident_part(name) && !RUST_KEYWORDS.contains(&name)
70}
71
72pub fn valid_locale(locale: &str) -> bool {
74 let Some((lang, region)) = locale.split_once('_') else {
75 return false;
76 };
77 (2..=3).contains(&lang.len())
78 && lang.bytes().all(|b| b.is_ascii_lowercase())
79 && (2..=4).contains(®ion.len())
80 && region
81 .bytes()
82 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
83}
84
85pub fn accessor_name(name: &str) -> String {
88 let mut out = String::new();
89 for (n, part) in name.split('.').enumerate() {
90 if n > 0 {
91 out.push('_');
92 }
93 let mut prev_lower = false;
94 for c in part.chars() {
95 if c.is_ascii_uppercase() && prev_lower {
96 out.push('_');
97 }
98 prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
99 out.push(c.to_ascii_lowercase());
100 }
101 }
102 out
103}
104
105pub fn default_key(set: &str, mod_id: &str, name: &str) -> String {
107 match set {
108 "Items" => format!("item.{mod_id}.{name}"),
109 "Blocks" => format!("block.{mod_id}.{name}"),
110 "Entities" => format!("entity.{mod_id}.{name}"),
111 _ => format!("{mod_id}.{name}"),
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
118enum Segment {
119 Text(String),
120 Arg(String),
122}
123
124fn segments(text: &str) -> Result<Vec<Segment>, String> {
128 let mut out = Vec::new();
129 let mut lit = String::new();
130 let mut spans = 0usize;
131 let mut chars = text.chars().peekable();
132 while let Some(c) = chars.next() {
133 match c {
134 '{' if {
135 let rest: String = chars.clone().take_while(|&x| x != '{' && x != '}').collect();
136 rest.contains('|')
137 } =>
138 {
139 lit.push('{');
140 spans += 1;
141 }
142 '}' if spans > 0 => {
143 lit.push('}');
144 spans -= 1;
145 }
146 '{' if chars.peek() == Some(&'{') => {
147 chars.next();
148 lit.push_str("{{");
149 }
150 '}' if chars.peek() == Some(&'}') => {
151 chars.next();
152 lit.push_str("}}");
153 }
154 '{' => {
155 let mut arg = String::new();
156 loop {
157 match chars.next() {
158 Some('}') => break,
159 Some(c) => arg.push(c),
160 None => {
161 return Err("`{` without `}` (write `{{` for a literal brace)".into());
162 }
163 }
164 }
165 let arg = arg.trim().to_string();
166 let numeric = !arg.is_empty() && arg.bytes().all(|b| b.is_ascii_digit());
167 if !numeric && (!ident_part(&arg) || RUST_KEYWORDS.contains(&arg.as_str())) {
168 return Err(format!(
169 "placeholder `{{{arg}}}` must be a number or an identifier (write `{{{{` for a literal brace)"
170 ));
171 }
172 if !lit.is_empty() {
173 out.push(Segment::Text(std::mem::take(&mut lit)));
174 }
175 out.push(Segment::Arg(arg));
176 }
177 '}' => return Err("`}` without `{` (write `}}` for a literal brace)".into()),
178 c => lit.push(c),
179 }
180 }
181 if !lit.is_empty() {
182 out.push(Segment::Text(lit));
183 }
184 Ok(out)
185}
186
187fn params(segs: &[Segment]) -> Result<Vec<String>, String> {
190 let args: Vec<&String> = segs
191 .iter()
192 .filter_map(|s| match s {
193 Segment::Arg(a) => Some(a),
194 Segment::Text(_) => None,
195 })
196 .collect();
197 let numeric = args
198 .iter()
199 .filter(|a| a.bytes().all(|b| b.is_ascii_digit()))
200 .count();
201 if numeric > 0 && numeric < args.len() {
202 return Err("mixes numbered `{0}` and named `{name}` placeholders — use one style".into());
203 }
204 let mut out: Vec<String> = Vec::new();
205 if numeric > 0 {
206 let mut nums: Vec<usize> = args.iter().filter_map(|a| a.parse().ok()).collect();
207 nums.sort_unstable();
208 nums.dedup();
209 for (i, n) in nums.iter().enumerate() {
210 if *n != i {
211 return Err(format!(
212 "placeholder {{{i}}} is missing (numbered placeholders start at {{0}}, no gaps)"
213 ));
214 }
215 out.push(n.to_string());
216 }
217 } else {
218 for a in args {
219 if !out.contains(a) {
220 out.push(a.clone());
221 }
222 }
223 }
224 Ok(out)
225}
226
227fn lang_text(segs: &[Segment], params: &[String]) -> Result<String, String> {
230 let mut out = String::new();
231 for s in segs {
232 match s {
233 Segment::Text(t) => out.push_str(&t.replace('%', "%%")),
234 Segment::Arg(a) => {
235 let pos = params
236 .iter()
237 .position(|p| p == a)
238 .ok_or_else(|| format!("placeholder `{{{a}}}` isn't in the neutral text"))?;
239 let _ = write!(out, "%{}$s", pos + 1);
240 }
241 }
242 }
243 Ok(out)
244}
245
246fn param_ident(p: &str) -> String {
247 if p.bytes().all(|b| b.is_ascii_digit()) {
248 format!("arg{p}")
249 } else {
250 accessor_name(p)
251 }
252}
253
254#[derive(Debug, Clone)]
258pub struct ResFile {
259 pub path: PathBuf,
260 pub entries: Vec<Entry>,
261}
262
263#[derive(Debug, Clone)]
265pub struct Set {
266 pub name: String,
267 pub neutral: ResFile,
268 pub cultures: BTreeMap<String, ResFile>,
269}
270
271#[derive(Debug, Clone, Default)]
273pub struct Refs {
274 pub texts: HashMap<String, (String, String)>,
276 pub images: HashMap<String, String>,
278 pub colors: HashMap<String, u32>,
280 pub pictures: HashMap<String, Picture>,
282}
283
284#[derive(Debug, Clone, Default, PartialEq)]
287pub struct Picture {
288 pub png: Vec<u8>,
289 pub output: String,
290}
291
292fn valid_resource_path(path: &str) -> bool {
294 !path.is_empty() && path.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.' | b'/'))
295}
296
297#[derive(Debug, Default)]
299pub struct Compiled {
300 pub outputs: Vec<Output>,
303 pub sources: BTreeMap<String, String>,
305 pub refs: Refs,
306 pub warnings: Vec<String>,
307 pub files: usize,
309}
310
311pub fn find(root: &Path) -> Vec<PathBuf> {
313 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
314 let Ok(entries) = std::fs::read_dir(dir) else {
315 return;
316 };
317 for entry in entries.flatten() {
318 let path = entry.path();
319 if path.is_dir() {
320 walk(&path, out);
321 } else if yog_ids::Format::Res.is(&path) {
322 out.push(path);
323 }
324 }
325 }
326 let mut out = Vec::new();
327 walk(&root.join(RESOURCES_DIR), &mut out);
328 out.sort();
329 out
330}
331
332fn display(root: &Path, p: &Path) -> String {
333 p.strip_prefix(root)
334 .unwrap_or(p)
335 .to_string_lossy()
336 .replace('\\', "/")
337}
338
339fn load(root: &Path, errors: &mut Vec<String>) -> BTreeMap<String, Set> {
341 let mut neutral: BTreeMap<String, ResFile> = BTreeMap::new();
342 let mut cultures: Vec<(String, String, ResFile)> = Vec::new();
343 for path in find(root) {
344 let shown = display(root, &path);
345 let file_name = path
346 .file_name()
347 .unwrap_or_default()
348 .to_string_lossy()
349 .into_owned();
350 let Some((set, locale)) = yog_formats::yres::split_file_name(&file_name) else {
351 continue;
352 };
353 if !valid_set_name(set) {
354 errors.push(format!(
355 "{shown}: `{set}` isn't a valid resource set name — use an identifier like Strings or Icons"
356 ));
357 continue;
358 }
359 if let Some(locale) = locale {
360 if locale == NEUTRAL_LOCALE {
361 errors.push(format!(
362 "{shown}: the neutral file {set}.yres is already {NEUTRAL_LOCALE} — put these texts there"
363 ));
364 continue;
365 }
366 if !valid_locale(locale) {
367 errors.push(format!(
368 "{shown}: `{locale}` isn't a Minecraft locale code (lower case, like ru_ru or pt_br)"
369 ));
370 continue;
371 }
372 }
373 let entries = match std::fs::read_to_string(&path)
374 .map_err(|e| e.to_string())
375 .and_then(|t| parse(&t))
376 {
377 Ok(entries) => entries,
378 Err(e) => {
379 errors.push(format!("{shown}:{}", e.trim_start_matches("line ")));
380 continue;
381 }
382 };
383 for e in &entries {
384 if let Some(problem) = name_problem(&e.name) {
385 errors.push(format!("{shown}:{}: {problem}", e.line));
386 }
387 }
388 let file = ResFile { path, entries };
389 match locale {
390 None => {
391 if let Some(other) = neutral.get(set) {
392 errors.push(format!(
393 "{shown}: resource set {set} is also defined by {}",
394 display(root, &other.path)
395 ));
396 continue;
397 }
398 neutral.insert(set.to_string(), file);
399 }
400 Some(locale) => cultures.push((set.to_string(), locale.to_string(), file)),
401 }
402 }
403 let mut sets: BTreeMap<String, Set> = neutral
404 .into_iter()
405 .map(|(name, neutral)| {
406 (
407 name.clone(),
408 Set {
409 name,
410 neutral,
411 cultures: BTreeMap::new(),
412 },
413 )
414 })
415 .collect();
416 for (set, locale, file) in cultures {
417 let shown = display(root, &file.path);
418 let Some(target) = sets.get_mut(&set) else {
419 errors.push(format!(
420 "{shown}: no neutral {set}.yres for this culture variant"
421 ));
422 continue;
423 };
424 if let Some(other) = target.cultures.get(&locale) {
425 errors.push(format!(
426 "{shown}: {set} {locale} is also defined by {}",
427 display(root, &other.path)
428 ));
429 continue;
430 }
431 for e in &file.entries {
432 if e.kind != Kind::String || e.key.is_some() {
433 errors.push(format!(
434 "{shown}:{}: {:?}: culture variants hold texts only (no type other than string, no `key`)",
435 e.line, e.name
436 ));
437 }
438 }
439 target.cultures.insert(locale, file);
440 }
441 sets
442}
443
444pub fn value_problem(e: &Entry) -> Option<String> {
447 let media = |what: &str| format!("{:?}: {what} are bytes (`@bytes:…`)", e.name);
448 match (e.kind, &e.value) {
449 (Kind::Image | Kind::File | Kind::Sound, Value::Bytes(_)) if !valid_resource_path(&e.name.replace('.', yog_ids::sep::PATH)) => {
450 Some(format!("{:?}: Minecraft resource paths are lower case — only a-z, 0-9, `_`, `-`, `.`", e.name))
451 }
452 (Kind::Sound, Value::Bytes(b)) if !b.starts_with(yog_formats::yres::OGG_MAGIC) => Some(format!("{:?}: sounds are Ogg Vorbis", e.name)),
453 (Kind::Image | Kind::File | Kind::Sound, Value::Bytes(_)) => None,
454 (Kind::Image, _) => Some(media("images")),
455 (Kind::File, _) => Some(media("files")),
456 (Kind::Sound, _) => Some(media("sounds")),
457 (Kind::Color, _) => parse_color(e.text()).is_none().then(|| format!("{:?}: `{}` isn't a color — #RRGGBB or #AARRGGBB", e.name, e.text())),
458 (Kind::Number, Value::Num(_)) | (Kind::Bool, Value::Bool(_)) | (Kind::String, Value::Str(_)) => None,
459 (Kind::Number, _) => Some(format!("{:?}: a number", e.name)),
460 (Kind::Bool, _) => Some(format!("{:?}: true or false", e.name)),
461 (Kind::String, _) => Some(format!("{:?}: a text", e.name)),
462 }
463}
464
465pub fn color_of(e: &Entry) -> Option<u32> {
467 (e.kind == Kind::Color).then(|| parse_color(e.text())).flatten()
468}
469
470fn parse_color(s: &str) -> Option<u32> {
472 crate::yui::color(s).filter(|_| s.trim().starts_with(crate::yui::HEX_MARK))
473}
474
475fn lang_json(entries: &[(String, String)]) -> String {
477 let mut out = String::from("{");
478 for (n, (k, v)) in entries.iter().enumerate() {
479 out.push_str(if n == 0 { "\n " } else { ",\n " });
480 out.push_str(&serde_json::to_string(k).unwrap_or_default());
481 out.push_str(": ");
482 out.push_str(&serde_json::to_string(v).unwrap_or_default());
483 }
484 out.push_str(if entries.is_empty() { "}\n" } else { "\n}\n" });
485 out
486}
487
488fn preview(names: &[&str]) -> String {
489 const SHOWN: usize = 5;
490 let mut text = names
491 .iter()
492 .take(SHOWN)
493 .copied()
494 .collect::<Vec<_>>()
495 .join(", ");
496 if names.len() > SHOWN {
497 let _ = write!(text, ", … (+{})", names.len() - SHOWN);
498 }
499 text
500}
501
502fn doc_line(s: &str) -> String {
503 let one: String = s
504 .chars()
505 .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
506 .collect();
507 one.replace("*/", "* /")
508}
509
510pub fn compile(root: &Path, mod_id: &str) -> Result<Compiled, String> {
513 let mut errors = Vec::new();
514 let sets = load(root, &mut errors);
515 let mut c = Compiled {
516 files: sets.values().map(|s| 1 + s.cultures.len()).sum(),
517 ..Compiled::default()
518 };
519 let mut lang: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
521 let mut key_origin: HashMap<String, String> = HashMap::new();
522 let mut bundled: BTreeMap<String, (String, Vec<u8>)> = BTreeMap::new();
524 let mut sounds: BTreeMap<String, serde_json::Value> = BTreeMap::new();
525
526 for set in sets.values() {
527 let shown = display(root, &set.neutral.path);
528 let mut code = String::new();
529 let mut accessors: HashMap<String, usize> = HashMap::new();
530 let mut text_params: HashMap<&str, (String, Vec<String>)> = HashMap::new();
532 let _ = writeln!(
533 code,
534 "/// The resources of `{shown}`.\n#[allow(dead_code)]\npub struct {name};\n\n#[allow(dead_code, non_snake_case, clippy::all)]\nimpl {name} {{",
535 name = set.name
536 );
537 for e in &set.neutral.entries {
538 let at = format!("{shown}:{}", e.line);
539 let acc = accessor_name(&e.name);
540 if RUST_KEYWORDS.contains(&acc.as_str()) {
541 errors.push(format!(
542 "{at}: {:?} gives the accessor `{acc}`, a Rust keyword — rename it",
543 e.name
544 ));
545 }
546 if let Some(first) = accessors.insert(acc.clone(), e.line) {
547 errors.push(format!(
548 "{at}: {:?} gives the same accessor `{acc}` as the entry on line {first}",
549 e.name
550 ));
551 }
552 let upper = acc.to_ascii_uppercase();
553 let doc = {
555 let what = match e.kind {
556 Kind::String => format!(
557 "Looks up a localized string similar to \"{}\".",
558 doc_line(e.text())
559 ),
560 Kind::Image => format!("The image `{}`.", e.name),
561 Kind::File => format!("The file `{}`.", e.name),
562 Kind::Sound => format!("The sound `{}`.", e.name),
563 Kind::Color => format!("The color `{}`.", doc_line(e.text())),
564 Kind::Number | Kind::Bool => match &e.value {
565 Value::Num(n) => format!("The number `{n}`."),
566 Value::Bool(b) => format!("The flag `{b}`."),
567 Value::Str(v) => format!("The value `{}`.", doc_line(v)),
568 Value::Bytes(b) => format!("{} bytes.", b.len()),
569 },
570 };
571 let mut doc = format!(" /// {what}\n ///\n /// `{}`", e.name);
572 if let Some(comment) = &e.comment {
573 let _ = write!(doc, " — {}", doc_line(comment));
574 }
575 let _ = writeln!(doc, " ({shown}:{})", e.line);
576 doc
577 };
578 let reference = format!("{}.{}", set.name, e.name);
579 if let Some(problem) = value_problem(e) {
580 errors.push(format!("{at}: {problem}"));
581 continue;
582 }
583 match e.kind {
584 Kind::String => {
585 let key = e
586 .key
587 .clone()
588 .unwrap_or_else(|| default_key(&set.name, mod_id, &e.name));
589 if let Some(first) = key_origin.insert(key.clone(), at.clone()) {
590 errors.push(format!(
591 "{at}: translation key {key:?} is also used at {first}"
592 ));
593 }
594 let segs = match segments(e.text()) {
595 Ok(s) => s,
596 Err(err) => {
597 errors.push(format!("{at}: {:?}: {err}", e.name));
598 continue;
599 }
600 };
601 let ps = match params(&segs) {
602 Ok(p) => p,
603 Err(err) => {
604 errors.push(format!("{at}: {:?}: {err}", e.name));
605 continue;
606 }
607 };
608 if let Ok(text) = lang_text(&segs, &ps) {
609 lang.entry(NEUTRAL_LOCALE.into())
610 .or_default()
611 .push((key.clone(), text));
612 }
613 let _ = writeln!(
614 code,
615 " /// Translation key of [`Self::{acc}`]: `{key}`.\n pub const {upper}_KEY: &'static str = {key:?};"
616 );
617 code.push_str(&doc);
618 if ps.is_empty() {
619 let _ = writeln!(
620 code,
621 " pub fn {acc}() -> ::std::string::String {{\n ::yog_api::tr(Self::{upper}_KEY)\n }}"
622 );
623 } else {
624 let idents: Vec<String> = ps.iter().map(|p| param_ident(p)).collect();
625 let sig = idents
626 .iter()
627 .map(|p| format!("{p}: impl ::std::fmt::Display"))
628 .collect::<Vec<_>>()
629 .join(", ");
630 let args = idents
631 .iter()
632 .map(|p| format!("&{p}"))
633 .collect::<Vec<_>>()
634 .join(", ");
635 let _ = writeln!(
636 code,
637 " pub fn {acc}({sig}) -> ::std::string::String {{\n ::yog_api::tr_args(Self::{upper}_KEY, &[{args}])\n }}"
638 );
639 }
640 c.refs
641 .texts
642 .insert(reference, (key.clone(), e.text().to_string()));
643 text_params.insert(&e.name, (key, ps));
644 }
645 Kind::Image | Kind::File | Kind::Sound => {
646 let Value::Bytes(bytes) = &e.value else { continue };
647 let path = e.name.replace('.', yog_ids::sep::PATH);
649 let (target, id, data) = match e.kind {
650 Kind::Image => {
651 let png = match yog_media::image::decode(bytes).and_then(|img| yog_media::image::encode_png(&img)) {
652 Ok(png) => png,
653 Err(err) => {
654 errors.push(format!("{at}: {:?}: {err}", e.name));
655 continue;
656 }
657 };
658 let source = yog_ids::join(mod_id, &path);
659 let target = format!("{}/{mod_id}/{}", yog_ids::pack::ASSETS, yog_ids::texture_file(&path));
660 c.refs.images.insert(reference.clone(), source.clone());
661 c.refs.pictures.insert(reference, Picture { png: png.clone(), output: target.clone() });
662 (target, source, png)
663 }
664 Kind::Sound => {
665 let event = e
666 .event
667 .clone()
668 .unwrap_or_else(|| e.name.to_ascii_lowercase());
669 let mut def =
670 serde_json::json!({ "sounds": [yog_ids::join(mod_id, &path)] });
671 if let Some(sub) = &e.subtitle {
672 def["subtitle"] = serde_json::Value::String(sub.clone());
673 }
674 if sounds.insert(event.clone(), def).is_some() {
675 errors
676 .push(format!("{at}: sound event {event:?} is defined twice"));
677 }
678 (
679 format!("{}/{mod_id}/sounds/{path}.ogg", yog_ids::pack::ASSETS),
680 yog_ids::join(mod_id, &event),
681 bytes.clone(),
682 )
683 }
684 _ => (format!("{}/{mod_id}/{path}", yog_ids::pack::ASSETS), yog_ids::join(mod_id, &path), bytes.clone()),
685 };
686 if let Some(other) = bundled.insert(target.clone(), (e.name.clone(), data))
687 && other.0 != e.name
688 {
689 errors.push(format!("{at}: {:?} goes to {target}, like {:?}", e.name, other.0));
690 }
691 let _ = writeln!(
692 code,
693 "{doc} pub const fn {acc}() -> &'static str {{\n {id:?}\n }}"
694 );
695 }
696 Kind::Color => {
697 let Some(v) = color_of(e) else { continue };
698 c.refs.colors.insert(reference, v);
699 let _ = writeln!(code, "{doc} pub const {upper}: u32 = 0x{v:08X};");
700 }
701 Kind::Number => {
702 let Value::Num(lit) = &e.value else { continue };
703 let int = !lit.contains(['.', 'e', 'E']);
704 match (int, lit.parse::<i64>()) {
705 (true, Ok(v)) => {
706 let _ = writeln!(code, "{doc} pub const {upper}: i64 = {v};");
707 }
708 _ => {
709 let v: f64 = lit.parse().unwrap_or_default();
710 let _ = writeln!(code, "{doc} pub const {upper}: f64 = {v:?};");
711 }
712 }
713 }
714 Kind::Bool => {
715 let v = matches!(e.value, Value::Bool(true));
716 let _ = writeln!(code, "{doc} pub const {upper}: bool = {v};");
717 }
718 }
719 }
720 code.push_str("}\n");
721 c.sources.insert(set.name.clone(), code);
722
723 for (locale, file) in &set.cultures {
724 let shown = display(root, &file.path);
725 let mut missing: Vec<&str> = Vec::new();
726 for e in &set.neutral.entries {
727 if e.kind == Kind::String
728 && !file
729 .entries
730 .iter()
731 .any(|t| t.name == e.name && !t.text().is_empty())
732 {
733 missing.push(&e.name);
734 }
735 }
736 let mut extra: Vec<&str> = Vec::new();
737 for t in &file.entries {
738 let Some((key, ps)) = text_params.get(t.name.as_str()) else {
739 extra.push(&t.name);
740 continue;
741 };
742 if t.text().is_empty() {
743 continue;
744 }
745 match segments(t.text()).and_then(|segs| lang_text(&segs, ps)) {
746 Ok(text) => lang
747 .entry(locale.clone())
748 .or_default()
749 .push((key.clone(), text)),
750 Err(err) => errors.push(format!("{shown}:{}: {:?}: {err}", t.line, t.name)),
751 }
752 }
753 if !missing.is_empty() {
754 c.warnings.push(format!(
755 "{shown}: {} text(s) not translated (shown in {NEUTRAL_LOCALE}): {}",
756 missing.len(),
757 preview(&missing)
758 ));
759 }
760 if !extra.is_empty() {
761 c.warnings.push(format!(
762 "{shown}: {} text(s) not in {}.yres, left out: {}",
763 extra.len(),
764 set.name,
765 preview(&extra)
766 ));
767 }
768 }
769 }
770
771 for (locale, mut entries) in lang {
773 let path = format!("assets/{mod_id}/lang/{locale}.json");
774 if let Ok(text) = std::fs::read_to_string(root.join(&path)) {
775 match parse(&text) {
776 Ok(hand) => {
777 let mut merged: Vec<(String, String)> = Vec::new();
778 for h in hand {
779 if let Some(at) = key_origin.get(&h.name) {
780 errors.push(format!(
781 "{path}:{}: {:?} is also defined at {at}",
782 h.line, h.name
783 ));
784 }
785 merged.push((h.name.clone(), h.text().to_string()));
786 }
787 merged.append(&mut entries);
788 entries = merged;
789 }
790 Err(e) => errors.push(format!("{path}:{}", e.trim_start_matches("line "))),
791 }
792 }
793 c.outputs.push((path, lang_json(&entries).into_bytes()));
794 }
795 for (target, (_, bytes)) in bundled {
796 c.outputs.push((target, bytes));
797 }
798 if !sounds.is_empty() {
799 let path = format!("assets/{mod_id}/sounds.json");
800 let mut all = serde_json::Map::new();
801 if let Ok(text) = std::fs::read_to_string(root.join(&path)) {
802 match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&text) {
803 Ok(hand) => all = hand,
804 Err(e) => errors.push(format!("{path}: {e}")),
805 }
806 }
807 for (event, def) in sounds {
808 if all.insert(event.clone(), def).is_some() {
809 errors.push(format!(
810 "{path}: sound event {event:?} is also defined by a .yres sound"
811 ));
812 }
813 }
814 let json =
815 serde_json::to_string_pretty(&serde_json::Value::Object(all)).unwrap_or_default();
816 c.outputs.push((path, format!("{json}\n").into_bytes()));
817 }
818
819 if !errors.is_empty() {
820 return Err(format!(
821 "invalid .yres resources:\n {}",
822 errors.join("\n ")
823 ));
824 }
825 Ok(c)
826}
827
828const 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";
829
830pub fn write_sources(root: &Path, compiled: &Compiled) -> Result<(), String> {
835 let dir = root.join(GENERATED_DIR);
836 if let Ok(entries) = std::fs::read_dir(&dir) {
837 for entry in entries.flatten() {
838 let name = entry.file_name().to_string_lossy().into_owned();
839 if name
840 .strip_suffix(".g.rs")
841 .is_some_and(|set| !compiled.sources.contains_key(set))
842 {
843 let _ = std::fs::remove_file(entry.path());
844 }
845 }
846 }
847 let mut index = BANNER.replace("{source}", "resources/**/*.yres");
848 index.push_str("// Included by `yog_api::resources!()` as `crate::res`.\n");
849 for (set, code) in &compiled.sources {
850 let source = code
851 .lines()
852 .next()
853 .and_then(|l| l.split('`').nth(1))
854 .unwrap_or("resources/");
855 let mut file = BANNER.replace("{source}", source);
856 file.push_str(code);
857 crate::write_if_changed(&dir.join(format!("{set}.g.rs")), &file)?;
858 let _ = writeln!(
859 index,
860 "include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/resources/{set}.g.rs\"));"
861 );
862 }
863 crate::write_if_changed(&root.join(GENERATED_FILE), &index)
864}
865
866pub fn resolve_text(refs: &Refs, value: &str) -> Result<(String, Option<String>), String> {
869 match value.strip_prefix('@') {
870 Some(rest) if rest.starts_with('@') => Ok((rest.to_string(), None)),
871 Some(rest) => match refs.texts.get(rest) {
872 Some((key, text)) => Ok((text.clone(), Some(key.clone()))),
873 None => Err(format!("`{value}`: no text `{rest}` in resources/*.yres")),
874 },
875 None => Ok((value.to_string(), None)),
876 }
877}
878
879pub fn resolve_image(refs: &Refs, value: &str) -> Result<String, String> {
881 match value.strip_prefix('@') {
882 Some(rest) => refs
883 .images
884 .get(rest)
885 .cloned()
886 .ok_or_else(|| format!("`{value}`: no image `{rest}` in resources/*.yres")),
887 None => Ok(value.to_string()),
888 }
889}
890
891#[cfg(test)]
892mod tests {
893
894 #[test]
895 fn rich_text_spans_are_not_placeholders() {
896 let seg = segments("{#8d6acc|Media} and {b|x} for {name}").unwrap();
897 assert_eq!(seg, [Segment::Text("{#8d6acc|Media} and {b|x} for ".into()), Segment::Arg("name".into())]);
898 }
899
900 use super::*;
901
902 #[test]
903 fn parses_all_shapes_in_order() {
904 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";
905 let entries = parse(text).unwrap();
906 assert_eq!(entries.len(), 7);
907 assert_eq!(entries[0].name, "b");
908 assert_eq!(entries[1].line, 4);
909 assert_eq!(entries[1].value, Value::Str("A é".into()));
910 assert_eq!(entries[1].key.as_deref(), Some("x.y"));
911 assert_eq!(entries[2].kind, Kind::Number);
912 assert_eq!(entries[3].value, Value::Bool(false));
913 assert_eq!(entries[4].kind, Kind::Color);
914 assert_eq!(entries[5].value, Value::Num("-150.0".into()));
915 assert_eq!(entries[6].value, Value::Str("@everyone".into()));
916 let again = parse(&write(&entries)).unwrap();
918 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<_>>());
919 }
920
921 #[test]
922 fn errors_carry_lines() {
923 let err = parse("{\n \"a\": \"1\",\n \"a\": \"2\"\n}").unwrap_err();
924 assert_eq!(err, "3:3: `a` is already set in this object");
925 let err = parse("{\n \"a\": { \"type\": \"bool\", \"value\": \"x\" }\n}").unwrap_err();
926 assert_eq!(err, "2:35: a.value: a bool needs a true/false value");
927 let err = parse("{\n\"a\": { \"value\": \"x\", \"note\": \"y\" }\n}").unwrap_err();
928 assert!(err.contains("2:22: a.note: unknown field `note`"), "{err}");
929 let err = parse("{\n\"a\": { \"type\": \"image\", \"value\": \"@bytes:AAAA\", \"key\": \"k\" }\n}").unwrap_err();
930 assert!(err.contains("a.key: only string entries have a `key`"), "{err}");
931 let err = parse("{\n \"a\": \"1\"\n \"b\": \"2\"\n}").unwrap_err();
932 assert_eq!(err, "3:3: expected `,` or `}`");
933 let err = parse("{\n \"a\": [],\n \"b\": { \"type\": \"img\", \"value\": \"@Strings.x\" }\n}").unwrap_err();
935 assert_eq!(err.lines().collect::<Vec<_>>(), [
936 "2:8: a: expected a string, a number or true/false, found a list",
937 "3:18: b.type: unknown `img` — did you mean `image`?",
938 "3:34: b.value: expected plain text, found a resource (write `@@` for a literal `@`)",
939 ]);
940 assert!(parse("[]").unwrap_err().contains("expected `{`"));
941 assert!(parse("\u{FEFF}{ }").unwrap().is_empty());
942 }
943
944 #[test]
945 fn names_and_keys() {
946 assert_eq!(accessor_name("screen.title"), "screen_title");
947 assert_eq!(accessor_name("ModName"), "mod_name");
948 assert!(valid_name("ruby.tooltip") && !valid_name("1a") && !valid_name("a..b"));
949 assert!(valid_locale("pt_br") && valid_locale("lol_us") && !valid_locale("EN_US"));
950 assert_eq!(default_key("Items", "m", "ruby"), "item.m.ruby");
951 assert_eq!(default_key("Strings", "m", "title"), "m.title");
952 assert_eq!(
953 yog_formats::yres::split_file_name("Strings.ru_ru.yres"),
954 Some(("Strings", Some("ru_ru")))
955 );
956 assert_eq!(yog_formats::yres::split_file_name("Icons.yres"), Some(("Icons", None)));
957 }
958
959 #[test]
960 fn placeholders_become_minecraft_format() {
961 let segs = segments("{shown} of {total}: 100% {{ok}}").unwrap();
962 let ps = params(&segs).unwrap();
963 assert_eq!(ps, ["shown", "total"]);
964 assert_eq!(lang_text(&segs, &ps).unwrap(), "%1$s of %2$s: 100%% {{ok}}");
965 let ru = segments("из {total} показано {shown}").unwrap();
966 assert_eq!(lang_text(&ru, &ps).unwrap(), "из %2$s показано %1$s");
967 assert!(lang_text(&segments("{other}").unwrap(), &ps).is_err());
968 let segs = segments("{1} and {0}").unwrap();
969 assert_eq!(params(&segs).unwrap(), ["0", "1"]);
970 assert!(params(&segments("{0} {name}").unwrap()).is_err());
971 assert!(params(&segments("{1}").unwrap()).is_err());
972 assert!(segments("a { b").is_err());
973 assert!(segments("a } b").is_err());
974 }
975
976 fn temp_mod(name: &str) -> PathBuf {
977 let dir = std::env::temp_dir().join(format!("yog-yres-{name}-{}", std::process::id()));
978 let _ = std::fs::remove_dir_all(&dir);
979 std::fs::create_dir_all(dir.join(RESOURCES_DIR)).unwrap();
980 dir
981 }
982
983 #[test]
984 fn compiles_a_mod() {
985 let root = temp_mod("ok");
986 let res = root.join(RESOURCES_DIR);
987 std::fs::write(
988 res.join("Strings.yres"),
989 r#"{"title": {"value": "Mods", "comment": "c"}, "count": "{n} mods", "gone": "G"}"#,
990 )
991 .unwrap();
992 std::fs::write(
993 res.join("Strings.ru_ru.yres"),
994 r#"{"title": "Моды", "count": "Модов: {n}", "x": "X"}"#,
995 )
996 .unwrap();
997 std::fs::write(
998 res.join("Icons.yres"),
999 r##"{"images.logo": "@bytes:iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", "accent": {"type": "color", "value": "#FF8800"}, "sounds.click": {"value": "@bytes:T2dnUw==", "event": "click"}, "max": 3, "on": true}"##,
1000 )
1001 .unwrap();
1002 std::fs::write(res.join("Items.yres"), r#"{"ruby": "Ruby"}"#).unwrap();
1003 std::fs::create_dir_all(root.join("assets/m/lang")).unwrap();
1004 std::fs::write(
1005 root.join("assets/m/lang/en_us.json"),
1006 r#"{"hand.key": "Hand"}"#,
1007 )
1008 .unwrap();
1009
1010 let c = compile(&root, "m").unwrap();
1011 let out: BTreeMap<&str, String> = c
1012 .outputs
1013 .iter()
1014 .map(|(p, b)| (p.as_str(), String::from_utf8_lossy(b).into_owned()))
1015 .collect();
1016 assert_eq!(
1017 out["assets/m/lang/en_us.json"],
1018 "{\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"
1019 );
1020 assert_eq!(
1021 out["assets/m/lang/ru_ru.json"],
1022 "{\n \"m.title\": \"Моды\",\n \"m.count\": \"Модов: %1$s\"\n}\n"
1023 );
1024 assert!(out["assets/m/textures/images/logo.png"].starts_with("\u{89}PNG") || out.contains_key("assets/m/textures/images/logo.png"));
1025 assert_eq!(out["assets/m/sounds/sounds/click.ogg"], "OggS");
1026 assert!(out["assets/m/sounds.json"].contains("\"m:sounds/click\""));
1027 assert_eq!(c.refs.images["Icons.images.logo"], "m:images/logo");
1028 assert!(c.refs.pictures.contains_key("Icons.images.logo"));
1029 assert_eq!(
1030 c.refs.texts["Strings.title"],
1031 ("m.title".to_string(), "Mods".to_string())
1032 );
1033 let strings = &c.sources["Strings"];
1034 assert!(
1035 strings.contains("pub const TITLE_KEY: &'static str = \"m.title\";"),
1036 "{strings}"
1037 );
1038 assert!(
1039 strings.contains("pub fn count(n: impl ::std::fmt::Display)"),
1040 "{strings}"
1041 );
1042 assert!(
1043 strings.contains("::yog_api::tr_args(Self::COUNT_KEY, &[&n])"),
1044 "{strings}"
1045 );
1046 let icons = &c.sources["Icons"];
1047 assert!(
1048 icons.contains("pub const fn images_logo() -> &'static str {\n \"m:images/logo\""),
1049 "{icons}"
1050 );
1051 assert!(
1052 icons.contains("pub const ACCENT: u32 = 0xFFFF8800;"),
1053 "{icons}"
1054 );
1055 assert!(icons.contains("pub const MAX: i64 = 3;"), "{icons}");
1056 assert!(icons.contains("pub const ON: bool = true;"), "{icons}");
1057 assert!(icons.contains("\"m:click\""), "{icons}");
1058 assert_eq!(c.warnings.len(), 2, "{:?}", c.warnings);
1059 assert!(
1060 c.warnings[0].contains("1 text(s) not translated") && c.warnings[0].contains("gone")
1061 );
1062 assert!(c.warnings[1].contains("left out: x"));
1063
1064 write_sources(&root, &c).unwrap();
1065 let icons_rs =
1066 std::fs::read_to_string(root.join(GENERATED_DIR).join("Icons.g.rs")).unwrap();
1067 assert!(icons_rs.starts_with("// <auto-generated>"), "{icons_rs}");
1068 assert!(icons_rs.contains("pub struct Icons;"), "{icons_rs}");
1069 let index = std::fs::read_to_string(root.join(GENERATED_FILE)).unwrap();
1070 assert!(
1071 index.contains("/generated/resources/Icons.g.rs\"));"),
1072 "{index}"
1073 );
1074 assert!(
1075 index.contains("/generated/resources/Strings.g.rs\"));"),
1076 "{index}"
1077 );
1078 let (name, key) = resolve_text(&c.refs, "@Strings.title").unwrap();
1079 assert_eq!((name.as_str(), key.as_deref()), ("Mods", Some("m.title")));
1080 assert!(resolve_text(&c.refs, "@Strings.nope").is_err());
1081 assert_eq!(
1082 resolve_image(&c.refs, "@Icons.images.logo").unwrap(),
1083 "m:images/logo"
1084 );
1085 let _ = std::fs::remove_dir_all(&root);
1086 }
1087
1088 #[test]
1089 fn reports_every_problem_with_file_and_line() {
1090 let root = temp_mod("errors");
1091 let res = root.join(RESOURCES_DIR);
1092 std::fs::write(
1093 res.join("Strings.yres"),
1094 "{\n \"a\": \"A\",\n \"b\": { \"value\": \"B\", \"key\": \"m.a\" },\n \"img\": { \"type\": \"image\", \"value\": \"@bytes:AAAA\" },\n \"bad name\": \"x\",\n \"col\": { \"type\": \"color\", \"value\": \"red\" }\n}",
1095 )
1096 .unwrap();
1097 std::fs::write(res.join("Strings.en_us.yres"), "{}").unwrap();
1098 std::fs::write(res.join("Strings.de_de.yres"), "{\n \"a\": 1\n}").unwrap();
1099 std::fs::write(res.join("Other.ru_ru.yres"), "{}").unwrap();
1100 let err = compile(&root, "m").unwrap_err();
1101 for needle in [
1102 "resources/Strings.yres:3: translation key \"m.a\" is also used at resources/Strings.yres:2",
1103 "resources/Strings.yres:4: \"img\": not a picture this reads",
1104 "resources/Strings.yres:5: \"bad name\" isn't a valid name",
1105 "resources/Strings.yres:6: \"col\": `red` isn't a color",
1106 "Strings.en_us.yres: the neutral file Strings.yres is already en_us",
1107 "resources/Strings.de_de.yres:2: \"a\": culture variants hold texts only",
1108 "resources/Other.ru_ru.yres: no neutral Other.yres",
1109 ] {
1110 assert!(err.contains(needle), "missing {needle:?} in:\n{err}");
1111 }
1112 let _ = std::fs::remove_dir_all(&root);
1113 }
1114}