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) callable: bool,
143 pub(crate) params: Vec<String>,
145 pub(crate) param_names: Option<Vec<String>>,
147 pub(crate) param_aliases: Vec<HashMap<Locale, Vec<String>>>,
149 pub(crate) param_domains: Vec<Option<String>>,
157 pub(crate) param_defaults: Vec<Option<String>>,
161 pub(crate) param_types: Vec<Option<String>>,
164 pub(crate) param_coercions: Vec<Option<ParamCoercions>>,
166 pub(crate) return_type: Option<String>,
169 pub(crate) reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
176 pub(crate) variadic: bool,
178 pub(crate) aliases: HashMap<Locale, Vec<String>>,
179}
180
181#[derive(Debug, Clone)]
185pub struct LocalizedStringEntry {
186 pub id: String,
187 pub(crate) aliases: HashMap<Locale, Vec<String>>,
188}
189
190fn spelling<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> Option<&'a str> {
191 aliases
192 .get(locale)
193 .and_then(|spellings| spellings.first())
194 .map(String::as_str)
195}
196
197fn spellings_for<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> &'a [String] {
198 aliases.get(locale).map(Vec::as_slice).unwrap_or_default()
199}
200
201impl LocalizedStringEntry {
202 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
204 spelling(&self.aliases, locale)
205 }
206
207 pub fn spellings(&self, locale: &Locale) -> &[String] {
209 spellings_for(&self.aliases, locale)
210 }
211}
212
213impl CatalogEntry {
214 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
216 spelling(&self.aliases, locale)
217 }
218
219 pub fn spellings(&self, locale: &Locale) -> &[String] {
222 spellings_for(&self.aliases, locale)
223 }
224
225 pub fn resolve_param(&self, locale: &Locale, spelling: &str) -> Option<usize> {
228 let mut matches = self
229 .params
230 .iter()
231 .enumerate()
232 .filter(|(index, canonical)| {
233 canonical == &spelling
234 || self
235 .param_aliases
236 .get(*index)
237 .and_then(|aliases| aliases.get(locale))
238 .is_some_and(|aliases| aliases.iter().any(|alias| alias == spelling))
239 })
240 .map(|(index, _)| index);
241 let first = matches.next()?;
242 matches.next().is_none().then_some(first)
243 }
244
245 pub fn params(&self) -> &[String] {
247 &self.params
248 }
249
250 pub fn param_count(&self) -> usize {
252 self.params.len()
253 }
254
255 pub fn param_name(&self, index: usize) -> Option<&str> {
257 let names = self.param_names.as_deref().unwrap_or(&self.params);
258 param_at(names, index, self.variadic).map(String::as_str)
259 }
260
261 pub fn required_param_count(&self) -> usize {
265 (0..self.params.len())
266 .rev()
267 .find(|index| {
268 self.param_defaults
269 .get(*index)
270 .and_then(Option::as_ref)
271 .is_none()
272 })
273 .map_or(0, |index| index + 1)
274 }
275
276 pub fn has_param_defaults(&self) -> bool {
278 self.param_defaults.iter().any(Option::is_some)
279 }
280
281 pub fn param_default(&self, index: usize) -> Option<&str> {
283 param_at(&self.param_defaults, index, self.variadic).and_then(Option::as_deref)
284 }
285
286 pub fn param_domain(&self, index: usize) -> Option<&str> {
288 param_at(&self.param_domains, index, self.variadic).and_then(Option::as_deref)
289 }
290
291 pub fn reevaluation_coverage(&self, member: &str) -> Option<&[usize]> {
300 self.reevaluation_coverage
301 .as_ref()
302 .and_then(|coverage| coverage.get(member).map(Vec::as_slice))
303 }
304
305 pub fn has_reevaluation_coverage(&self) -> bool {
308 self.reevaluation_coverage.is_some()
309 }
310
311 pub fn param_type(&self, index: usize) -> Option<&str> {
314 param_at(&self.param_types, index, self.variadic).and_then(Option::as_deref)
315 }
316
317 pub fn param_coercions(&self, index: usize) -> Option<&ParamCoercions> {
319 param_at(&self.param_coercions, index, self.variadic).and_then(Option::as_ref)
320 }
321
322 pub fn return_type(&self) -> Option<&str> {
324 self.return_type.as_deref()
325 }
326
327 pub fn is_variadic(&self) -> bool {
329 self.variadic
330 }
331
332 pub(crate) fn folds_player_variable_pair(&self) -> bool {
339 self.param_type(0) == Some("Player|Array") && self.param_type(1) == Some("Variable")
340 }
341
342 pub(crate) fn wir_args_folded(
347 &self,
348 program: &crate::wir::Program,
349 args: &[crate::wir::ValueId],
350 ) -> bool {
351 self.folds_player_variable_pair()
352 && matches!(
353 args.first()
354 .and_then(|arg| program.values.get(*arg))
355 .map(|node| &node.value),
356 Some(crate::wir::Value::PlayerVariable { .. })
357 )
358 }
359
360 pub(crate) fn wir_arg_param_index(
367 &self,
368 program: &crate::wir::Program,
369 args: &[crate::wir::ValueId],
370 wir_index: usize,
371 ) -> usize {
372 wir_index + usize::from(self.wir_args_folded(program, args))
373 }
374}
375
376fn param_at<T>(values: &[T], index: usize, variadic: bool) -> Option<&T> {
377 values
378 .get(index)
379 .or_else(|| variadic.then(|| values.last()).flatten())
380}
381
382#[derive(Debug, Clone)]
384pub struct EnumMember {
385 pub member: String,
386 pub(crate) aliases: HashMap<Locale, Vec<String>>,
387}
388
389impl EnumMember {
390 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
392 spelling(&self.aliases, locale)
393 }
394
395 pub fn spellings(&self, locale: &Locale) -> &[String] {
398 spellings_for(&self.aliases, locale)
399 }
400}
401
402#[derive(Debug, Clone)]
404pub struct EnumDomain {
405 pub domain: String,
406 pub(crate) aliases: HashMap<Locale, Vec<String>>,
407 pub members: Vec<EnumMember>,
408}
409
410impl EnumDomain {
411 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
412 spelling(&self.aliases, locale)
413 }
414}
415
416#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
418#[non_exhaustive]
419pub struct TargetMeta {
420 pub game: String,
421 pub format: String,
422 pub surface: String,
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
427#[serde(rename_all = "camelCase")]
428#[non_exhaustive]
429pub struct Provenance {
430 pub generator: String,
431 pub generator_version: String,
432 pub source: String,
433 pub license: String,
434 pub reviewed: bool,
435 #[serde(default, skip_serializing_if = "Vec::is_empty")]
438 pub source_notes: Vec<String>,
439}
440
441#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
445#[non_exhaustive]
446pub struct LocaleCoverage {
447 pub locale: Locale,
448 pub mapped: usize,
450 pub total: usize,
452}
453
454#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
460#[serde(rename_all = "kebab-case")]
461#[non_exhaustive]
462pub struct CatalogIdentity {
463 pub implementation_version: String,
465 pub catalog_version: String,
467 pub catalog_digest: Option<String>,
470 pub locale_coverage: Vec<LocaleCoverage>,
472 pub target: TargetMeta,
474 pub provenance: Provenance,
476}
477
478pub(crate) type MemberIndexMap = HashMap<String, HashMap<String, (usize, usize)>>;
479
480type EnumMemberAliasMap = HashMap<String, HashMap<String, Option<(usize, usize)>>>;
483
484#[derive(Debug, Clone, Default)]
495pub(crate) struct NormalizedIndex {
496 pub(crate) alias_to_entry: HashMap<Locale, [HashMap<String, Option<usize>>; Kind::NUM_KINDS]>,
497 pub(crate) localized_string_alias: HashMap<Locale, HashMap<String, Option<usize>>>,
498 pub(crate) enum_alias_to_domain: HashMap<Locale, HashMap<String, Option<String>>>,
499 pub(crate) enum_alias_to_member: HashMap<Locale, EnumMemberAliasMap>,
500 pub(crate) bare_member: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
501}
502
503pub(crate) fn normalize_spelling(spelling: &str) -> Cow<'_, str> {
506 if spelling.chars().any(char::is_whitespace) {
507 Cow::Owned(spelling.chars().filter(|c| !c.is_whitespace()).collect())
508 } else {
509 Cow::Borrowed(spelling)
510 }
511}
512
513fn lookup_normalized<'a, V>(
516 index: &'a HashMap<String, Option<V>>,
517 spelling: &str,
518) -> Option<&'a V> {
519 index.get(normalize_spelling(spelling).as_ref())?.as_ref()
520}
521
522#[derive(Debug, Clone)]
524pub struct Catalog {
525 detection_index: Option<detect::AliasIndex>,
526 pub(crate) schema_version: u32,
527 pub(crate) locales: Vec<Locale>,
530 pub(crate) target: TargetMeta,
531 pub(crate) provenance: Provenance,
532 pub(crate) catalog_version: String,
534 pub(crate) catalog_digest: Option<String>,
537 pub(crate) entries: Vec<CatalogEntry>,
538 pub(crate) localized_strings: Vec<LocalizedStringEntry>,
539 pub(crate) enums: Vec<EnumDomain>,
540 pub(crate) by_id: [HashMap<String, usize>; Kind::NUM_KINDS],
541 pub(crate) alias_to_entry: HashMap<Locale, [HashMap<String, usize>; Kind::NUM_KINDS]>,
542 pub(crate) localized_string_by_id: HashMap<String, usize>,
543 pub(crate) localized_string_alias: HashMap<Locale, HashMap<String, usize>>,
544 pub(crate) enum_by_domain: HashMap<String, usize>,
545 pub(crate) enum_alias_to_domain: HashMap<Locale, HashMap<String, String>>,
546 pub(crate) enum_alias_to_member: HashMap<Locale, MemberIndexMap>,
547 pub(crate) bare_member_index: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
548 pub(crate) normalized: NormalizedIndex,
551}
552
553mod load;
554
555pub use load::{build_canonical, canonicalize, content_digest};
556
557impl Catalog {
558 pub(crate) fn schema_version(&self) -> u32 {
559 self.schema_version
560 }
561
562 pub fn locales(&self) -> &[Locale] {
564 &self.locales
565 }
566
567 pub fn primary_locale(&self) -> &Locale {
570 &self.locales[0]
571 }
572
573 pub fn supports(&self, locale: &Locale) -> bool {
575 self.locales.contains(locale)
576 }
577
578 pub fn catalog_version(&self) -> &str {
580 &self.catalog_version
581 }
582
583 pub fn catalog_digest(&self) -> Option<&str> {
586 self.catalog_digest.as_deref()
587 }
588
589 pub fn implementation_version() -> &'static str {
591 env!("CARGO_PKG_VERSION")
592 }
593
594 pub fn identity(&self) -> CatalogIdentity {
597 CatalogIdentity {
598 implementation_version: Self::implementation_version().to_string(),
599 catalog_version: self.catalog_version.clone(),
600 catalog_digest: self.catalog_digest.clone(),
601 locale_coverage: self
602 .locales
603 .iter()
604 .map(|locale| self.locale_coverage(locale))
605 .collect(),
606 target: self.target.clone(),
607 provenance: self.provenance.clone(),
608 }
609 }
610
611 pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
616 let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
617 let total = self.entries.len() + self.localized_strings.len() + member_total;
618 let mapped = self
619 .entries
620 .iter()
621 .filter(|entry| entry.aliases.contains_key(locale))
622 .count()
623 + self
624 .localized_strings
625 .iter()
626 .filter(|entry| entry.aliases.contains_key(locale))
627 .count()
628 + self
629 .enums
630 .iter()
631 .flat_map(|domain| &domain.members)
632 .filter(|member| member.aliases.contains_key(locale))
633 .count();
634 LocaleCoverage {
635 locale: locale.clone(),
636 mapped,
637 total,
638 }
639 }
640
641 pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
643 self.locales
644 .iter()
645 .map(|locale| self.locale_coverage(locale))
646 .collect()
647 }
648
649 pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
651 self.by_id[kind.as_index()]
652 .get(id)
653 .map(|i| &self.entries[*i])
654 }
655
656 pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
661 self.alias_to_entry
662 .get(locale)
663 .and_then(|by_kind| by_kind[kind.as_index()].get(spelling))
664 .map(|i| &self.entries[*i])
665 .or_else(|| {
666 let index = &self.normalized.alias_to_entry.get(locale)?[kind.as_index()];
667 lookup_normalized(index, spelling).map(|i| &self.entries[*i])
668 })
669 }
670
671 pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
673 self.entry(kind, id)?.spelling(locale)
674 }
675
676 pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
678 self.entries.iter().filter(move |entry| entry.kind == kind)
679 }
680
681 pub fn resolve_localized_string(
683 &self,
684 locale: &Locale,
685 spelling: &str,
686 ) -> Option<&LocalizedStringEntry> {
687 self.localized_string_alias
688 .get(locale)
689 .and_then(|map| map.get(spelling))
690 .map(|index| &self.localized_strings[*index])
691 .or_else(|| {
692 let index = self.normalized.localized_string_alias.get(locale)?;
693 lookup_normalized(index, spelling).map(|i| &self.localized_strings[*i])
694 })
695 }
696
697 pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
699 self.localized_string_by_id
700 .get(id)
701 .and_then(|i| self.localized_strings.get(*i))
702 .and_then(|entry| entry.spelling(locale))
703 }
704
705 pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
707 self.localized_strings.iter()
708 }
709
710 pub fn entry_count(&self) -> usize {
712 self.entries.len()
713 }
714
715 pub fn enum_domains_count(&self) -> usize {
717 self.enums.len()
718 }
719
720 pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
722 self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
723 }
724
725 pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
727 self.enum_by_domain
728 .get_key_value(spelling)
729 .map(|(domain, _)| domain.as_str())
730 .or_else(|| {
731 self.enum_alias_to_domain
732 .get(locale)
733 .and_then(|map| map.get(spelling))
734 .map(String::as_str)
735 })
736 .or_else(|| {
737 let index = self.normalized.enum_alias_to_domain.get(locale)?;
738 lookup_normalized(index, spelling).map(String::as_str)
739 })
740 }
741
742 pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
744 self.enums.iter()
745 }
746
747 pub fn resolve_enum_member(
749 &self,
750 domain: &str,
751 locale: &Locale,
752 spelling: &str,
753 ) -> Option<(String, String)> {
754 let indexes = self
755 .enum_alias_to_member
756 .get(locale)?
757 .get(domain)?
758 .get(spelling)
759 .copied()
760 .or_else(|| {
761 let index = self
762 .normalized
763 .enum_alias_to_member
764 .get(locale)?
765 .get(domain)?;
766 lookup_normalized(index, spelling).copied()
767 })?;
768 let (domain_index, member_index) = indexes;
769 Some((
770 domain.to_string(),
771 self.enums[domain_index].members[member_index]
772 .member
773 .clone(),
774 ))
775 }
776
777 pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
779 let domain_index = self.enum_by_domain.get(domain)?;
780 let domain = &self.enums[*domain_index];
781 domain
782 .members
783 .iter()
784 .find(|candidate| candidate.member == member)?
785 .spelling(locale)
786 }
787
788 pub fn localized_enum_spelling(
792 &self,
793 domain: &str,
794 locale: &Locale,
795 member: &str,
796 ) -> Option<&str> {
797 self.enum_spelling(domain, locale, member).or_else(|| {
798 (domain == "Color" && member == "WHITE").then_some(match locale.as_str() {
799 "de-de" => "Weiß",
800 "es-es" | "es-mx" => "Blanco",
801 "fr-fr" => "Blanc",
802 "it-it" => "Bianco",
803 "ja-jp" => "白",
804 "ko-kr" => "흰색",
805 "pl-pl" => "Biały",
806 "pt-br" => "Branco",
807 "ru-ru" => "Белый",
808 "th-th" => "สีขาว",
809 "tr-tr" => "Beyaz",
810 "zh-tw" => "白色",
811 _ => return None,
812 })
813 })
814 }
815
816 pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
822 if let Some(matches) = self
823 .bare_member_index
824 .get(locale)
825 .and_then(|map| map.get(spelling))
826 {
827 return matches.clone();
828 }
829 self.normalized
830 .bare_member
831 .get(locale)
832 .and_then(|map| map.get(normalize_spelling(spelling).as_ref()))
833 .cloned()
834 .unwrap_or_default()
835 }
836
837 pub(crate) fn canonical_spellings<'a>(
842 &'a self,
843 kind: Kind,
844 locale: &'a Locale,
845 ) -> impl Iterator<Item = &'a str> {
846 self.entries_of(kind).flat_map(move |entry| {
847 std::iter::once(entry.id.as_str())
848 .chain(entry.spellings(locale).iter().map(String::as_str))
849 })
850 }
851
852 pub(crate) fn enum_member_form(
857 &self,
858 domain: &str,
859 member_spelling: &str,
860 locale: &Locale,
861 ) -> String {
862 if matches!(domain, "Hero" | "Button" | "Color" | "Map") {
863 let domain_display = self
864 .enum_domain(domain)
865 .and_then(|entry| entry.spelling(locale))
866 .unwrap_or(domain);
867 format!("{domain_display}({member_spelling})")
868 } else {
869 member_spelling.to_string()
870 }
871 }
872
873 pub(crate) fn canonical_member_spellings<'a>(
876 &'a self,
877 domain: &'a str,
878 locale: &'a Locale,
879 ) -> impl Iterator<Item = &'a str> {
880 self.enum_domain(domain)
881 .into_iter()
882 .flat_map(move |domain| {
883 domain.members.iter().flat_map(move |member| {
884 std::iter::once(member.member.as_str())
885 .chain(member.spellings(locale).iter().map(String::as_str))
886 })
887 })
888 }
889}
890
891impl ExpectedDomain for Catalog {
898 fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
899 for kind in [Kind::Action, Kind::Value] {
900 if let Some(entry) = self.entry(kind, catalog_id) {
901 if let Some(domain) = entry.param_domain(arg_index) {
902 return Some(domain);
903 }
904 }
905 }
906 None
907 }
908}