1pub mod detect;
23
24use std::collections::{BTreeMap, HashMap};
25
26use serde::{Deserialize, Deserializer, Serialize};
27
28use crate::core::signatures::ExpectedDomain;
29
30pub const CATALOG_DATA: &str = include_str!("data/catalog.json");
32
33#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
35pub struct Locale(String);
36
37impl Locale {
38 pub fn new(value: &str) -> Locale {
40 Locale(value.trim().to_ascii_lowercase())
41 }
42
43 pub fn as_str(&self) -> &str {
45 &self.0
46 }
47}
48
49impl<'de> Deserialize<'de> for Locale {
50 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
51 where
52 D: Deserializer<'de>,
53 {
54 let value = String::deserialize(deserializer)?;
55 Ok(Self::new(&value))
56 }
57}
58
59impl std::fmt::Display for Locale {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.write_str(&self.0)
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum Kind {
68 Structural = 0,
70 Action = 1,
72 Value = 2,
74 Event = 3,
76 Operator = 4,
78 Enum = 5,
80 Setting = 6,
82}
83
84impl Kind {
85 pub const NUM_KINDS: usize = 7;
86
87 pub const fn as_index(self) -> usize {
88 self as usize
89 }
90
91 pub fn as_str(self) -> &'static str {
92 match self {
93 Kind::Structural => "structural",
94 Kind::Action => "action",
95 Kind::Value => "value",
96 Kind::Event => "event",
97 Kind::Operator => "operator",
98 Kind::Enum => "enum",
99 Kind::Setting => "setting",
100 }
101 }
102}
103
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
111#[serde(rename_all = "camelCase")]
112#[non_exhaustive]
113pub struct ParamCoercions {
114 #[serde(default)]
116 pub false_as_number: bool,
117 #[serde(default)]
119 pub true_as_number: bool,
120 #[serde(default)]
122 pub zero_as_null: bool,
123 #[serde(default)]
125 pub null_vector_as_null: bool,
126 #[serde(default)]
128 pub empty_array_as_string: bool,
129}
130
131#[derive(Debug, Clone)]
133pub struct CatalogEntry {
134 pub id: String,
135 pub kind: Kind,
136 pub(crate) params: Vec<String>,
138 pub(crate) param_names: Option<Vec<String>>,
140 pub(crate) param_aliases: Vec<HashMap<Locale, Vec<String>>>,
142 pub(crate) param_domains: Vec<Option<String>>,
150 pub(crate) param_defaults: Vec<Option<String>>,
154 pub(crate) param_types: Vec<Option<String>>,
157 pub(crate) param_coercions: Vec<Option<ParamCoercions>>,
159 pub(crate) return_type: Option<String>,
162 pub(crate) reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
169 pub(crate) variadic: bool,
171 pub(crate) aliases: HashMap<Locale, Vec<String>>,
172}
173
174#[derive(Debug, Clone)]
178pub struct LocalizedStringEntry {
179 pub id: String,
180 pub(crate) aliases: HashMap<Locale, Vec<String>>,
181}
182
183fn spelling<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> Option<&'a str> {
184 aliases
185 .get(locale)
186 .and_then(|spellings| spellings.first())
187 .map(String::as_str)
188}
189
190fn spellings_for<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> &'a [String] {
191 aliases.get(locale).map(Vec::as_slice).unwrap_or_default()
192}
193
194impl LocalizedStringEntry {
195 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
197 spelling(&self.aliases, locale)
198 }
199
200 pub fn spellings(&self, locale: &Locale) -> &[String] {
202 spellings_for(&self.aliases, locale)
203 }
204}
205
206impl CatalogEntry {
207 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
209 spelling(&self.aliases, locale)
210 }
211
212 pub fn spellings(&self, locale: &Locale) -> &[String] {
215 spellings_for(&self.aliases, locale)
216 }
217
218 pub fn resolve_param(&self, locale: &Locale, spelling: &str) -> Option<usize> {
221 let matches = self
222 .params
223 .iter()
224 .enumerate()
225 .filter(|(index, canonical)| {
226 canonical == &spelling
227 || self
228 .param_aliases
229 .get(*index)
230 .and_then(|aliases| aliases.get(locale))
231 .is_some_and(|aliases| aliases.iter().any(|alias| alias == spelling))
232 })
233 .map(|(index, _)| index)
234 .collect::<Vec<_>>();
235 (matches.len() == 1).then(|| matches[0])
236 }
237
238 pub fn params(&self) -> &[String] {
240 &self.params
241 }
242
243 pub fn param_count(&self) -> usize {
245 self.params.len()
246 }
247
248 pub fn param_name(&self, index: usize) -> Option<&str> {
250 let names = self.param_names.as_deref().unwrap_or(&self.params);
251 param_at(names, index, self.variadic).map(String::as_str)
252 }
253
254 pub fn required_param_count(&self) -> usize {
258 (0..self.params.len())
259 .rev()
260 .find(|index| {
261 self.param_defaults
262 .get(*index)
263 .and_then(Option::as_ref)
264 .is_none()
265 })
266 .map_or(0, |index| index + 1)
267 }
268
269 pub fn has_param_defaults(&self) -> bool {
271 self.param_defaults.iter().any(Option::is_some)
272 }
273
274 pub fn param_default(&self, index: usize) -> Option<&str> {
276 param_at(&self.param_defaults, index, self.variadic).and_then(Option::as_deref)
277 }
278
279 pub fn param_domain(&self, index: usize) -> Option<&str> {
281 param_at(&self.param_domains, index, self.variadic).and_then(Option::as_deref)
282 }
283
284 pub fn reevaluation_coverage(&self, member: &str) -> Option<&[usize]> {
293 self.reevaluation_coverage
294 .as_ref()
295 .and_then(|coverage| coverage.get(member).map(Vec::as_slice))
296 }
297
298 pub fn has_reevaluation_coverage(&self) -> bool {
301 self.reevaluation_coverage.is_some()
302 }
303
304 pub fn param_type(&self, index: usize) -> Option<&str> {
307 param_at(&self.param_types, index, self.variadic).and_then(Option::as_deref)
308 }
309
310 pub fn param_coercions(&self, index: usize) -> Option<&ParamCoercions> {
312 param_at(&self.param_coercions, index, self.variadic).and_then(Option::as_ref)
313 }
314
315 pub fn return_type(&self) -> Option<&str> {
317 self.return_type.as_deref()
318 }
319
320 pub fn is_variadic(&self) -> bool {
322 self.variadic
323 }
324
325 pub(crate) fn folds_player_variable_pair(&self) -> bool {
332 self.param_type(0) == Some("Player|Array") && self.param_type(1) == Some("Variable")
333 }
334
335 pub(crate) fn wir_args_folded(
340 &self,
341 program: &crate::wir::Program,
342 args: &[crate::wir::ValueId],
343 ) -> bool {
344 self.folds_player_variable_pair()
345 && matches!(
346 args.first()
347 .and_then(|arg| program.values.get(*arg))
348 .map(|node| &node.value),
349 Some(crate::wir::Value::PlayerVariable { .. })
350 )
351 }
352
353 pub(crate) fn wir_arg_param_index(
360 &self,
361 program: &crate::wir::Program,
362 args: &[crate::wir::ValueId],
363 wir_index: usize,
364 ) -> usize {
365 wir_index + usize::from(self.wir_args_folded(program, args))
366 }
367}
368
369fn param_at<T>(values: &[T], index: usize, variadic: bool) -> Option<&T> {
370 values
371 .get(index)
372 .or_else(|| variadic.then(|| values.last()).flatten())
373}
374
375#[derive(Debug, Clone)]
377pub struct EnumMember {
378 pub member: String,
379 pub(crate) aliases: HashMap<Locale, Vec<String>>,
380}
381
382impl EnumMember {
383 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
385 spelling(&self.aliases, locale)
386 }
387
388 pub fn spellings(&self, locale: &Locale) -> &[String] {
391 spellings_for(&self.aliases, locale)
392 }
393}
394
395#[derive(Debug, Clone)]
397pub struct EnumDomain {
398 pub domain: String,
399 pub(crate) aliases: HashMap<Locale, Vec<String>>,
400 pub members: Vec<EnumMember>,
401}
402
403impl EnumDomain {
404 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
405 spelling(&self.aliases, locale)
406 }
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
411#[non_exhaustive]
412pub struct TargetMeta {
413 pub game: String,
414 pub format: String,
415 pub surface: String,
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
420#[serde(rename_all = "camelCase")]
421#[non_exhaustive]
422pub struct Provenance {
423 pub generator: String,
424 pub generator_version: String,
425 pub source: String,
426 pub license: String,
427 pub reviewed: bool,
428 #[serde(default, skip_serializing_if = "Vec::is_empty")]
431 pub source_notes: Vec<String>,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
438#[non_exhaustive]
439pub struct LocaleCoverage {
440 pub locale: Locale,
441 pub mapped: usize,
443 pub total: usize,
445}
446
447#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
453#[serde(rename_all = "kebab-case")]
454#[non_exhaustive]
455pub struct CatalogIdentity {
456 pub implementation_version: String,
458 pub catalog_version: String,
460 pub catalog_digest: Option<String>,
463 pub locale_coverage: Vec<LocaleCoverage>,
465 pub target: TargetMeta,
467 pub provenance: Provenance,
469}
470
471pub(crate) type MemberIndexMap = HashMap<String, HashMap<String, (usize, usize)>>;
472
473type EnumMemberAliasMap = HashMap<String, HashMap<String, Option<(usize, usize)>>>;
476
477#[derive(Debug, Clone, Default)]
488pub(crate) struct NormalizedIndex {
489 pub(crate) alias_to_entry: HashMap<Locale, [HashMap<String, Option<usize>>; Kind::NUM_KINDS]>,
490 pub(crate) localized_string_alias: HashMap<Locale, HashMap<String, Option<usize>>>,
491 pub(crate) enum_alias_to_domain: HashMap<Locale, HashMap<String, Option<String>>>,
492 pub(crate) enum_alias_to_member: HashMap<Locale, EnumMemberAliasMap>,
493 pub(crate) bare_member: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
494}
495
496pub(crate) fn normalize_spelling(spelling: &str) -> String {
499 spelling.chars().filter(|c| !c.is_whitespace()).collect()
500}
501
502#[derive(Debug, Clone)]
504pub struct Catalog {
505 detection_index: Option<detect::AliasIndex>,
506 pub(crate) schema_version: u32,
507 pub(crate) locales: Vec<Locale>,
510 pub(crate) target: TargetMeta,
511 pub(crate) provenance: Provenance,
512 pub(crate) catalog_version: String,
514 pub(crate) catalog_digest: Option<String>,
517 pub(crate) entries: Vec<CatalogEntry>,
518 pub(crate) localized_strings: Vec<LocalizedStringEntry>,
519 pub(crate) enums: Vec<EnumDomain>,
520 pub(crate) by_id: [HashMap<String, usize>; Kind::NUM_KINDS],
521 pub(crate) alias_to_entry: HashMap<Locale, [HashMap<String, usize>; Kind::NUM_KINDS]>,
522 pub(crate) localized_string_by_id: HashMap<String, usize>,
523 pub(crate) localized_string_alias: HashMap<Locale, HashMap<String, usize>>,
524 pub(crate) enum_by_domain: HashMap<String, usize>,
525 pub(crate) enum_alias_to_domain: HashMap<Locale, HashMap<String, String>>,
526 pub(crate) enum_alias_to_member: HashMap<Locale, MemberIndexMap>,
527 pub(crate) bare_member_index: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
528 pub(crate) normalized: NormalizedIndex,
531}
532
533mod load;
534
535pub use load::{build_canonical, canonicalize, content_digest};
536
537impl Catalog {
538 pub(crate) fn schema_version(&self) -> u32 {
539 self.schema_version
540 }
541
542 pub fn locales(&self) -> &[Locale] {
544 &self.locales
545 }
546
547 pub fn primary_locale(&self) -> &Locale {
550 &self.locales[0]
551 }
552
553 pub fn supports(&self, locale: &Locale) -> bool {
555 self.locales.contains(locale)
556 }
557
558 pub fn catalog_version(&self) -> &str {
560 &self.catalog_version
561 }
562
563 pub fn catalog_digest(&self) -> Option<&str> {
566 self.catalog_digest.as_deref()
567 }
568
569 pub fn implementation_version() -> &'static str {
571 env!("CARGO_PKG_VERSION")
572 }
573
574 pub fn identity(&self) -> CatalogIdentity {
577 CatalogIdentity {
578 implementation_version: Self::implementation_version().to_string(),
579 catalog_version: self.catalog_version.clone(),
580 catalog_digest: self.catalog_digest.clone(),
581 locale_coverage: self
582 .locales
583 .iter()
584 .map(|locale| self.locale_coverage(locale))
585 .collect(),
586 target: self.target.clone(),
587 provenance: self.provenance.clone(),
588 }
589 }
590
591 pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
596 let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
597 let total = self.entries.len() + self.localized_strings.len() + member_total;
598 let mapped = self
599 .entries
600 .iter()
601 .filter(|entry| entry.aliases.contains_key(locale))
602 .count()
603 + self
604 .localized_strings
605 .iter()
606 .filter(|entry| entry.aliases.contains_key(locale))
607 .count()
608 + self
609 .enums
610 .iter()
611 .flat_map(|domain| &domain.members)
612 .filter(|member| member.aliases.contains_key(locale))
613 .count();
614 LocaleCoverage {
615 locale: locale.clone(),
616 mapped,
617 total,
618 }
619 }
620
621 pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
623 self.locales
624 .iter()
625 .map(|locale| self.locale_coverage(locale))
626 .collect()
627 }
628
629 pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
631 self.by_id[kind.as_index()]
632 .get(id)
633 .map(|i| &self.entries[*i])
634 }
635
636 pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
641 self.alias_to_entry
642 .get(locale)
643 .and_then(|by_kind| by_kind[kind.as_index()].get(spelling))
644 .map(|i| &self.entries[*i])
645 .or_else(|| {
646 self.normalized.alias_to_entry.get(locale)?[kind.as_index()]
647 .get(&normalize_spelling(spelling))
648 .copied()
649 .flatten()
650 .map(|i| &self.entries[i])
651 })
652 }
653
654 pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
656 self.entry(kind, id)?.spelling(locale)
657 }
658
659 pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
661 self.entries.iter().filter(move |entry| entry.kind == kind)
662 }
663
664 pub fn resolve_localized_string(
666 &self,
667 locale: &Locale,
668 spelling: &str,
669 ) -> Option<&LocalizedStringEntry> {
670 self.localized_string_alias
671 .get(locale)
672 .and_then(|map| map.get(spelling))
673 .map(|index| &self.localized_strings[*index])
674 .or_else(|| {
675 self.normalized
676 .localized_string_alias
677 .get(locale)?
678 .get(&normalize_spelling(spelling))
679 .copied()
680 .flatten()
681 .map(|index| &self.localized_strings[index])
682 })
683 }
684
685 pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
687 self.localized_string_by_id
688 .get(id)
689 .and_then(|i| self.localized_strings.get(*i))
690 .and_then(|entry| entry.spelling(locale))
691 }
692
693 pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
695 self.localized_strings.iter()
696 }
697
698 pub fn entry_count(&self) -> usize {
700 self.entries.len()
701 }
702
703 pub fn enum_domains_count(&self) -> usize {
705 self.enums.len()
706 }
707
708 pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
710 self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
711 }
712
713 pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
715 self.enum_by_domain
716 .get_key_value(spelling)
717 .map(|(domain, _)| domain.as_str())
718 .or_else(|| {
719 self.enum_alias_to_domain
720 .get(locale)
721 .and_then(|map| map.get(spelling))
722 .map(String::as_str)
723 })
724 .or_else(|| {
725 self.normalized
726 .enum_alias_to_domain
727 .get(locale)?
728 .get(&normalize_spelling(spelling))?
729 .as_deref()
730 })
731 }
732
733 pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
735 self.enums.iter()
736 }
737
738 pub fn resolve_enum_member(
740 &self,
741 domain: &str,
742 locale: &Locale,
743 spelling: &str,
744 ) -> Option<(String, String)> {
745 let indexes = self
746 .enum_alias_to_member
747 .get(locale)?
748 .get(domain)?
749 .get(spelling)
750 .copied()
751 .or_else(|| {
752 self.normalized
753 .enum_alias_to_member
754 .get(locale)?
755 .get(domain)?
756 .get(&normalize_spelling(spelling))
757 .copied()
758 .flatten()
759 })?;
760 let (domain_index, member_index) = indexes;
761 Some((
762 domain.to_string(),
763 self.enums[domain_index].members[member_index]
764 .member
765 .clone(),
766 ))
767 }
768
769 pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
771 let domain_index = self.enum_by_domain.get(domain)?;
772 let domain = &self.enums[*domain_index];
773 domain
774 .members
775 .iter()
776 .find(|candidate| candidate.member == member)?
777 .spelling(locale)
778 }
779
780 pub fn localized_enum_spelling(
784 &self,
785 domain: &str,
786 locale: &Locale,
787 member: &str,
788 ) -> Option<&str> {
789 self.enum_spelling(domain, locale, member).or_else(|| {
790 (domain == "Color" && member == "WHITE").then_some(match locale.as_str() {
791 "de-de" => "Weiß",
792 "es-es" | "es-mx" => "Blanco",
793 "fr-fr" => "Blanc",
794 "it-it" => "Bianco",
795 "ja-jp" => "白",
796 "ko-kr" => "흰색",
797 "pl-pl" => "Biały",
798 "pt-br" => "Branco",
799 "ru-ru" => "Белый",
800 "th-th" => "สีขาว",
801 "tr-tr" => "Beyaz",
802 "zh-tw" => "白色",
803 _ => return None,
804 })
805 })
806 }
807
808 pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
814 if let Some(matches) = self
815 .bare_member_index
816 .get(locale)
817 .and_then(|map| map.get(spelling))
818 {
819 return matches.clone();
820 }
821 self.normalized
822 .bare_member
823 .get(locale)
824 .and_then(|map| map.get(&normalize_spelling(spelling)))
825 .cloned()
826 .unwrap_or_default()
827 }
828
829 pub(crate) fn canonical_spellings<'a>(
834 &'a self,
835 kind: Kind,
836 locale: &'a Locale,
837 ) -> impl Iterator<Item = &'a str> {
838 self.entries_of(kind).flat_map(move |entry| {
839 std::iter::once(entry.id.as_str())
840 .chain(entry.spellings(locale).iter().map(String::as_str))
841 })
842 }
843
844 pub(crate) fn enum_member_form(
849 &self,
850 domain: &str,
851 member_spelling: &str,
852 locale: &Locale,
853 ) -> String {
854 if matches!(domain, "Hero" | "Button" | "Color" | "Map") {
855 let domain_display = self
856 .enum_domain(domain)
857 .and_then(|entry| entry.spelling(locale))
858 .unwrap_or(domain);
859 format!("{domain_display}({member_spelling})")
860 } else {
861 member_spelling.to_string()
862 }
863 }
864
865 pub(crate) fn canonical_member_spellings<'a>(
868 &'a self,
869 domain: &'a str,
870 locale: &'a Locale,
871 ) -> impl Iterator<Item = &'a str> {
872 self.enum_domain(domain)
873 .into_iter()
874 .flat_map(move |domain| {
875 domain.members.iter().flat_map(move |member| {
876 std::iter::once(member.member.as_str())
877 .chain(member.spellings(locale).iter().map(String::as_str))
878 })
879 })
880 }
881}
882
883impl ExpectedDomain for Catalog {
890 fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
891 for kind in [Kind::Action, Kind::Value] {
892 if let Some(entry) = self.entry(kind, catalog_id) {
893 if let Some(domain) = entry.param_domain(arg_index) {
894 return Some(domain);
895 }
896 }
897 }
898 None
899 }
900}