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