1pub mod detect;
23
24use std::borrow::Cow;
25use std::collections::{BTreeMap, HashMap};
26
27use serde::{Deserialize, Deserializer, Serialize};
28
29use crate::core::signatures::ExpectedDomain;
30
31pub const CATALOG_DATA: &str = include_str!("data/catalog.json");
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
36pub struct Locale(String);
37
38impl Locale {
39 pub fn new(value: &str) -> Locale {
41 Locale(value.trim().to_ascii_lowercase())
42 }
43
44 pub fn as_str(&self) -> &str {
46 &self.0
47 }
48}
49
50impl<'de> Deserialize<'de> for Locale {
51 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
52 where
53 D: Deserializer<'de>,
54 {
55 let value = String::deserialize(deserializer)?;
56 Ok(Self::new(&value))
57 }
58}
59
60impl std::fmt::Display for Locale {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.write_str(&self.0)
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum Kind {
69 Structural = 0,
71 Action = 1,
73 Value = 2,
75 Event = 3,
77 Operator = 4,
79 Enum = 5,
81 Setting = 6,
83}
84
85impl Kind {
86 pub const NUM_KINDS: usize = 7;
87
88 pub const fn as_index(self) -> usize {
89 self as usize
90 }
91
92 pub fn as_str(self) -> &'static str {
93 match self {
94 Kind::Structural => "structural",
95 Kind::Action => "action",
96 Kind::Value => "value",
97 Kind::Event => "event",
98 Kind::Operator => "operator",
99 Kind::Enum => "enum",
100 Kind::Setting => "setting",
101 }
102 }
103}
104
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
112#[serde(rename_all = "camelCase")]
113#[non_exhaustive]
114pub struct ParamCoercions {
115 #[serde(default)]
117 pub false_as_number: bool,
118 #[serde(default)]
120 pub true_as_number: bool,
121 #[serde(default)]
123 pub zero_as_null: bool,
124 #[serde(default)]
126 pub null_vector_as_null: bool,
127 #[serde(default)]
129 pub empty_array_as_string: bool,
130}
131
132#[derive(Debug, Clone)]
134pub struct CatalogEntry {
135 pub id: String,
136 pub kind: Kind,
137 pub(crate) params: Vec<String>,
139 pub(crate) param_names: Option<Vec<String>>,
141 pub(crate) param_aliases: Vec<HashMap<Locale, Vec<String>>>,
143 pub(crate) param_domains: Vec<Option<String>>,
151 pub(crate) param_defaults: Vec<Option<String>>,
155 pub(crate) param_types: Vec<Option<String>>,
158 pub(crate) param_coercions: Vec<Option<ParamCoercions>>,
160 pub(crate) return_type: Option<String>,
163 pub(crate) reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
170 pub(crate) variadic: bool,
172 pub(crate) aliases: HashMap<Locale, Vec<String>>,
173}
174
175#[derive(Debug, Clone)]
179pub struct LocalizedStringEntry {
180 pub id: String,
181 pub(crate) aliases: HashMap<Locale, Vec<String>>,
182}
183
184fn spelling<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> Option<&'a str> {
185 aliases
186 .get(locale)
187 .and_then(|spellings| spellings.first())
188 .map(String::as_str)
189}
190
191fn spellings_for<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> &'a [String] {
192 aliases.get(locale).map(Vec::as_slice).unwrap_or_default()
193}
194
195impl LocalizedStringEntry {
196 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
198 spelling(&self.aliases, locale)
199 }
200
201 pub fn spellings(&self, locale: &Locale) -> &[String] {
203 spellings_for(&self.aliases, locale)
204 }
205}
206
207impl CatalogEntry {
208 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
210 spelling(&self.aliases, locale)
211 }
212
213 pub fn spellings(&self, locale: &Locale) -> &[String] {
216 spellings_for(&self.aliases, locale)
217 }
218
219 pub fn resolve_param(&self, locale: &Locale, spelling: &str) -> Option<usize> {
222 let mut matches = self
223 .params
224 .iter()
225 .enumerate()
226 .filter(|(index, canonical)| {
227 canonical == &spelling
228 || self
229 .param_aliases
230 .get(*index)
231 .and_then(|aliases| aliases.get(locale))
232 .is_some_and(|aliases| aliases.iter().any(|alias| alias == spelling))
233 })
234 .map(|(index, _)| index);
235 let first = matches.next()?;
236 matches.next().is_none().then_some(first)
237 }
238
239 pub fn params(&self) -> &[String] {
241 &self.params
242 }
243
244 pub fn param_count(&self) -> usize {
246 self.params.len()
247 }
248
249 pub fn param_name(&self, index: usize) -> Option<&str> {
251 let names = self.param_names.as_deref().unwrap_or(&self.params);
252 param_at(names, index, self.variadic).map(String::as_str)
253 }
254
255 pub fn required_param_count(&self) -> usize {
259 (0..self.params.len())
260 .rev()
261 .find(|index| {
262 self.param_defaults
263 .get(*index)
264 .and_then(Option::as_ref)
265 .is_none()
266 })
267 .map_or(0, |index| index + 1)
268 }
269
270 pub fn has_param_defaults(&self) -> bool {
272 self.param_defaults.iter().any(Option::is_some)
273 }
274
275 pub fn param_default(&self, index: usize) -> Option<&str> {
277 param_at(&self.param_defaults, index, self.variadic).and_then(Option::as_deref)
278 }
279
280 pub fn param_domain(&self, index: usize) -> Option<&str> {
282 param_at(&self.param_domains, index, self.variadic).and_then(Option::as_deref)
283 }
284
285 pub fn reevaluation_coverage(&self, member: &str) -> Option<&[usize]> {
294 self.reevaluation_coverage
295 .as_ref()
296 .and_then(|coverage| coverage.get(member).map(Vec::as_slice))
297 }
298
299 pub fn has_reevaluation_coverage(&self) -> bool {
302 self.reevaluation_coverage.is_some()
303 }
304
305 pub fn param_type(&self, index: usize) -> Option<&str> {
308 param_at(&self.param_types, index, self.variadic).and_then(Option::as_deref)
309 }
310
311 pub fn param_coercions(&self, index: usize) -> Option<&ParamCoercions> {
313 param_at(&self.param_coercions, index, self.variadic).and_then(Option::as_ref)
314 }
315
316 pub fn return_type(&self) -> Option<&str> {
318 self.return_type.as_deref()
319 }
320
321 pub fn is_variadic(&self) -> bool {
323 self.variadic
324 }
325
326 pub(crate) fn folds_player_variable_pair(&self) -> bool {
333 self.param_type(0) == Some("Player|Array") && self.param_type(1) == Some("Variable")
334 }
335
336 pub(crate) fn wir_args_folded(
341 &self,
342 program: &crate::wir::Program,
343 args: &[crate::wir::ValueId],
344 ) -> bool {
345 self.folds_player_variable_pair()
346 && matches!(
347 args.first()
348 .and_then(|arg| program.values.get(*arg))
349 .map(|node| &node.value),
350 Some(crate::wir::Value::PlayerVariable { .. })
351 )
352 }
353
354 pub(crate) fn wir_arg_param_index(
361 &self,
362 program: &crate::wir::Program,
363 args: &[crate::wir::ValueId],
364 wir_index: usize,
365 ) -> usize {
366 wir_index + usize::from(self.wir_args_folded(program, args))
367 }
368}
369
370fn param_at<T>(values: &[T], index: usize, variadic: bool) -> Option<&T> {
371 values
372 .get(index)
373 .or_else(|| variadic.then(|| values.last()).flatten())
374}
375
376#[derive(Debug, Clone)]
378pub struct EnumMember {
379 pub member: String,
380 pub(crate) aliases: HashMap<Locale, Vec<String>>,
381}
382
383impl EnumMember {
384 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
386 spelling(&self.aliases, locale)
387 }
388
389 pub fn spellings(&self, locale: &Locale) -> &[String] {
392 spellings_for(&self.aliases, locale)
393 }
394}
395
396#[derive(Debug, Clone)]
398pub struct EnumDomain {
399 pub domain: String,
400 pub(crate) aliases: HashMap<Locale, Vec<String>>,
401 pub members: Vec<EnumMember>,
402}
403
404impl EnumDomain {
405 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
406 spelling(&self.aliases, locale)
407 }
408}
409
410#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
412#[non_exhaustive]
413pub struct TargetMeta {
414 pub game: String,
415 pub format: String,
416 pub surface: String,
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
421#[serde(rename_all = "camelCase")]
422#[non_exhaustive]
423pub struct Provenance {
424 pub generator: String,
425 pub generator_version: String,
426 pub source: String,
427 pub license: String,
428 pub reviewed: bool,
429 #[serde(default, skip_serializing_if = "Vec::is_empty")]
432 pub source_notes: Vec<String>,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
439#[non_exhaustive]
440pub struct LocaleCoverage {
441 pub locale: Locale,
442 pub mapped: usize,
444 pub total: usize,
446}
447
448#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
454#[serde(rename_all = "kebab-case")]
455#[non_exhaustive]
456pub struct CatalogIdentity {
457 pub implementation_version: String,
459 pub catalog_version: String,
461 pub catalog_digest: Option<String>,
464 pub locale_coverage: Vec<LocaleCoverage>,
466 pub target: TargetMeta,
468 pub provenance: Provenance,
470}
471
472pub(crate) type MemberIndexMap = HashMap<String, HashMap<String, (usize, usize)>>;
473
474type EnumMemberAliasMap = HashMap<String, HashMap<String, Option<(usize, usize)>>>;
477
478#[derive(Debug, Clone, Default)]
489pub(crate) struct NormalizedIndex {
490 pub(crate) alias_to_entry: HashMap<Locale, [HashMap<String, Option<usize>>; Kind::NUM_KINDS]>,
491 pub(crate) localized_string_alias: HashMap<Locale, HashMap<String, Option<usize>>>,
492 pub(crate) enum_alias_to_domain: HashMap<Locale, HashMap<String, Option<String>>>,
493 pub(crate) enum_alias_to_member: HashMap<Locale, EnumMemberAliasMap>,
494 pub(crate) bare_member: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
495}
496
497pub(crate) fn normalize_spelling(spelling: &str) -> Cow<'_, str> {
500 if spelling.chars().any(char::is_whitespace) {
501 Cow::Owned(spelling.chars().filter(|c| !c.is_whitespace()).collect())
502 } else {
503 Cow::Borrowed(spelling)
504 }
505}
506
507fn lookup_normalized<'a, V>(
510 index: &'a HashMap<String, Option<V>>,
511 spelling: &str,
512) -> Option<&'a V> {
513 index.get(normalize_spelling(spelling).as_ref())?.as_ref()
514}
515
516#[derive(Debug, Clone)]
518pub struct Catalog {
519 detection_index: Option<detect::AliasIndex>,
520 pub(crate) schema_version: u32,
521 pub(crate) locales: Vec<Locale>,
524 pub(crate) target: TargetMeta,
525 pub(crate) provenance: Provenance,
526 pub(crate) catalog_version: String,
528 pub(crate) catalog_digest: Option<String>,
531 pub(crate) entries: Vec<CatalogEntry>,
532 pub(crate) localized_strings: Vec<LocalizedStringEntry>,
533 pub(crate) enums: Vec<EnumDomain>,
534 pub(crate) by_id: [HashMap<String, usize>; Kind::NUM_KINDS],
535 pub(crate) alias_to_entry: HashMap<Locale, [HashMap<String, usize>; Kind::NUM_KINDS]>,
536 pub(crate) localized_string_by_id: HashMap<String, usize>,
537 pub(crate) localized_string_alias: HashMap<Locale, HashMap<String, usize>>,
538 pub(crate) enum_by_domain: HashMap<String, usize>,
539 pub(crate) enum_alias_to_domain: HashMap<Locale, HashMap<String, String>>,
540 pub(crate) enum_alias_to_member: HashMap<Locale, MemberIndexMap>,
541 pub(crate) bare_member_index: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
542 pub(crate) normalized: NormalizedIndex,
545}
546
547mod load;
548
549pub use load::{build_canonical, canonicalize, content_digest};
550
551impl Catalog {
552 pub(crate) fn schema_version(&self) -> u32 {
553 self.schema_version
554 }
555
556 pub fn locales(&self) -> &[Locale] {
558 &self.locales
559 }
560
561 pub fn primary_locale(&self) -> &Locale {
564 &self.locales[0]
565 }
566
567 pub fn supports(&self, locale: &Locale) -> bool {
569 self.locales.contains(locale)
570 }
571
572 pub fn catalog_version(&self) -> &str {
574 &self.catalog_version
575 }
576
577 pub fn catalog_digest(&self) -> Option<&str> {
580 self.catalog_digest.as_deref()
581 }
582
583 pub fn implementation_version() -> &'static str {
585 env!("CARGO_PKG_VERSION")
586 }
587
588 pub fn identity(&self) -> CatalogIdentity {
591 CatalogIdentity {
592 implementation_version: Self::implementation_version().to_string(),
593 catalog_version: self.catalog_version.clone(),
594 catalog_digest: self.catalog_digest.clone(),
595 locale_coverage: self
596 .locales
597 .iter()
598 .map(|locale| self.locale_coverage(locale))
599 .collect(),
600 target: self.target.clone(),
601 provenance: self.provenance.clone(),
602 }
603 }
604
605 pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
610 let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
611 let total = self.entries.len() + self.localized_strings.len() + member_total;
612 let mapped = self
613 .entries
614 .iter()
615 .filter(|entry| entry.aliases.contains_key(locale))
616 .count()
617 + self
618 .localized_strings
619 .iter()
620 .filter(|entry| entry.aliases.contains_key(locale))
621 .count()
622 + self
623 .enums
624 .iter()
625 .flat_map(|domain| &domain.members)
626 .filter(|member| member.aliases.contains_key(locale))
627 .count();
628 LocaleCoverage {
629 locale: locale.clone(),
630 mapped,
631 total,
632 }
633 }
634
635 pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
637 self.locales
638 .iter()
639 .map(|locale| self.locale_coverage(locale))
640 .collect()
641 }
642
643 pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
645 self.by_id[kind.as_index()]
646 .get(id)
647 .map(|i| &self.entries[*i])
648 }
649
650 pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
655 self.alias_to_entry
656 .get(locale)
657 .and_then(|by_kind| by_kind[kind.as_index()].get(spelling))
658 .map(|i| &self.entries[*i])
659 .or_else(|| {
660 let index = &self.normalized.alias_to_entry.get(locale)?[kind.as_index()];
661 lookup_normalized(index, spelling).map(|i| &self.entries[*i])
662 })
663 }
664
665 pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
667 self.entry(kind, id)?.spelling(locale)
668 }
669
670 pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
672 self.entries.iter().filter(move |entry| entry.kind == kind)
673 }
674
675 pub fn resolve_localized_string(
677 &self,
678 locale: &Locale,
679 spelling: &str,
680 ) -> Option<&LocalizedStringEntry> {
681 self.localized_string_alias
682 .get(locale)
683 .and_then(|map| map.get(spelling))
684 .map(|index| &self.localized_strings[*index])
685 .or_else(|| {
686 let index = self.normalized.localized_string_alias.get(locale)?;
687 lookup_normalized(index, spelling).map(|i| &self.localized_strings[*i])
688 })
689 }
690
691 pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
693 self.localized_string_by_id
694 .get(id)
695 .and_then(|i| self.localized_strings.get(*i))
696 .and_then(|entry| entry.spelling(locale))
697 }
698
699 pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
701 self.localized_strings.iter()
702 }
703
704 pub fn entry_count(&self) -> usize {
706 self.entries.len()
707 }
708
709 pub fn enum_domains_count(&self) -> usize {
711 self.enums.len()
712 }
713
714 pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
716 self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
717 }
718
719 pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
721 self.enum_by_domain
722 .get_key_value(spelling)
723 .map(|(domain, _)| domain.as_str())
724 .or_else(|| {
725 self.enum_alias_to_domain
726 .get(locale)
727 .and_then(|map| map.get(spelling))
728 .map(String::as_str)
729 })
730 .or_else(|| {
731 let index = self.normalized.enum_alias_to_domain.get(locale)?;
732 lookup_normalized(index, spelling).map(String::as_str)
733 })
734 }
735
736 pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
738 self.enums.iter()
739 }
740
741 pub fn resolve_enum_member(
743 &self,
744 domain: &str,
745 locale: &Locale,
746 spelling: &str,
747 ) -> Option<(String, String)> {
748 let indexes = self
749 .enum_alias_to_member
750 .get(locale)?
751 .get(domain)?
752 .get(spelling)
753 .copied()
754 .or_else(|| {
755 let index = self
756 .normalized
757 .enum_alias_to_member
758 .get(locale)?
759 .get(domain)?;
760 lookup_normalized(index, spelling).copied()
761 })?;
762 let (domain_index, member_index) = indexes;
763 Some((
764 domain.to_string(),
765 self.enums[domain_index].members[member_index]
766 .member
767 .clone(),
768 ))
769 }
770
771 pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
773 let domain_index = self.enum_by_domain.get(domain)?;
774 let domain = &self.enums[*domain_index];
775 domain
776 .members
777 .iter()
778 .find(|candidate| candidate.member == member)?
779 .spelling(locale)
780 }
781
782 pub fn localized_enum_spelling(
786 &self,
787 domain: &str,
788 locale: &Locale,
789 member: &str,
790 ) -> Option<&str> {
791 self.enum_spelling(domain, locale, member).or_else(|| {
792 (domain == "Color" && member == "WHITE").then_some(match locale.as_str() {
793 "de-de" => "Weiß",
794 "es-es" | "es-mx" => "Blanco",
795 "fr-fr" => "Blanc",
796 "it-it" => "Bianco",
797 "ja-jp" => "白",
798 "ko-kr" => "흰색",
799 "pl-pl" => "Biały",
800 "pt-br" => "Branco",
801 "ru-ru" => "Белый",
802 "th-th" => "สีขาว",
803 "tr-tr" => "Beyaz",
804 "zh-tw" => "白色",
805 _ => return None,
806 })
807 })
808 }
809
810 pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
816 if let Some(matches) = self
817 .bare_member_index
818 .get(locale)
819 .and_then(|map| map.get(spelling))
820 {
821 return matches.clone();
822 }
823 self.normalized
824 .bare_member
825 .get(locale)
826 .and_then(|map| map.get(normalize_spelling(spelling).as_ref()))
827 .cloned()
828 .unwrap_or_default()
829 }
830
831 pub(crate) fn canonical_spellings<'a>(
836 &'a self,
837 kind: Kind,
838 locale: &'a Locale,
839 ) -> impl Iterator<Item = &'a str> {
840 self.entries_of(kind).flat_map(move |entry| {
841 std::iter::once(entry.id.as_str())
842 .chain(entry.spellings(locale).iter().map(String::as_str))
843 })
844 }
845
846 pub(crate) fn enum_member_form(
851 &self,
852 domain: &str,
853 member_spelling: &str,
854 locale: &Locale,
855 ) -> String {
856 if matches!(domain, "Hero" | "Button" | "Color" | "Map") {
857 let domain_display = self
858 .enum_domain(domain)
859 .and_then(|entry| entry.spelling(locale))
860 .unwrap_or(domain);
861 format!("{domain_display}({member_spelling})")
862 } else {
863 member_spelling.to_string()
864 }
865 }
866
867 pub(crate) fn canonical_member_spellings<'a>(
870 &'a self,
871 domain: &'a str,
872 locale: &'a Locale,
873 ) -> impl Iterator<Item = &'a str> {
874 self.enum_domain(domain)
875 .into_iter()
876 .flat_map(move |domain| {
877 domain.members.iter().flat_map(move |member| {
878 std::iter::once(member.member.as_str())
879 .chain(member.spellings(locale).iter().map(String::as_str))
880 })
881 })
882 }
883}
884
885impl ExpectedDomain for Catalog {
892 fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
893 for kind in [Kind::Action, Kind::Value] {
894 if let Some(entry) = self.entry(kind, catalog_id) {
895 if let Some(domain) = entry.param_domain(arg_index) {
896 return Some(domain);
897 }
898 }
899 }
900 None
901 }
902}