1pub mod descriptions;
23pub mod detect;
24
25use std::borrow::Cow;
26use std::collections::{BTreeMap, HashMap};
27
28use serde::{Deserialize, Deserializer, Serialize};
29
30use crate::core::signatures::ExpectedDomain;
31
32pub 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,
72 Value = 2,
73 Event = 3,
74 Operator = 4,
76 Enum = 5,
78 Setting = 6,
80}
81
82impl Kind {
83 pub const NUM_KINDS: usize = 7;
84
85 pub const fn as_index(self) -> usize {
86 self as usize
87 }
88
89 pub fn as_str(self) -> &'static str {
90 match self {
91 Kind::Structural => "structural",
92 Kind::Action => "action",
93 Kind::Value => "value",
94 Kind::Event => "event",
95 Kind::Operator => "operator",
96 Kind::Enum => "enum",
97 Kind::Setting => "setting",
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
109#[serde(rename_all = "camelCase")]
110#[non_exhaustive]
111pub struct ParamCoercions {
112 #[serde(default)]
114 pub false_as_number: bool,
115 #[serde(default)]
117 pub true_as_number: bool,
118 #[serde(default)]
120 pub zero_as_null: bool,
121 #[serde(default)]
123 pub null_vector_as_null: bool,
124 #[serde(default)]
126 pub empty_array_as_string: bool,
127}
128
129#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
138#[serde(rename_all = "camelCase")]
139#[non_exhaustive]
140pub struct ParamContent {
141 #[serde(default)]
145 pub fold: Option<ParamContentFold>,
146 #[serde(default)]
151 pub operations: Vec<String>,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
165#[serde(rename_all = "camelCase")]
166#[non_exhaustive]
167pub struct ParamContentFold {
168 pub call: String,
170 pub arg: usize,
172}
173
174#[derive(Debug, Clone)]
176pub struct CatalogEntry {
177 pub id: String,
178 pub kind: Kind,
179 pub(crate) callable: bool,
180 pub(crate) params: Vec<String>,
181 pub(crate) param_names: Option<Vec<String>>,
182 pub(crate) param_aliases: Vec<HashMap<Locale, Vec<String>>>,
183 pub(crate) param_display_names: Vec<Option<HashMap<Locale, String>>>,
184 pub(crate) param_domains: Vec<Option<String>>,
185 pub(crate) param_defaults: Vec<Option<String>>,
186 pub(crate) param_types: Vec<Option<String>>,
187 pub(crate) param_coercions: Vec<Option<ParamCoercions>>,
188 pub(crate) param_content: Vec<Option<ParamContent>>,
189 pub(crate) return_type: Option<String>,
190 pub(crate) reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
191 pub(crate) variadic: bool,
192 pub(crate) aliases: HashMap<Locale, Vec<String>>,
193}
194
195#[derive(Debug, Clone)]
199pub struct LocalizedStringEntry {
200 pub id: String,
201 pub(crate) aliases: HashMap<Locale, Vec<String>>,
202}
203
204fn spelling<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> Option<&'a str> {
205 aliases
206 .get(locale)
207 .and_then(|spellings| spellings.first())
208 .map(String::as_str)
209}
210
211fn spellings_for<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> &'a [String] {
212 aliases.get(locale).map(Vec::as_slice).unwrap_or_default()
213}
214
215impl LocalizedStringEntry {
216 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
218 spelling(&self.aliases, locale)
219 }
220
221 pub fn spellings(&self, locale: &Locale) -> &[String] {
223 spellings_for(&self.aliases, locale)
224 }
225}
226
227impl CatalogEntry {
228 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
230 spelling(&self.aliases, locale)
231 }
232
233 pub fn spellings(&self, locale: &Locale) -> &[String] {
236 spellings_for(&self.aliases, locale)
237 }
238
239 pub fn resolve_param(&self, locale: &Locale, spelling: &str) -> Option<usize> {
242 let mut matches = self
243 .params
244 .iter()
245 .enumerate()
246 .filter(|(index, canonical)| {
247 canonical == &spelling
248 || self
249 .param_aliases
250 .get(*index)
251 .and_then(|aliases| aliases.get(locale))
252 .is_some_and(|aliases| aliases.iter().any(|alias| alias == spelling))
253 })
254 .map(|(index, _)| index);
255 let first = matches.next()?;
256 matches.next().is_none().then_some(first)
257 }
258
259 pub fn params(&self) -> &[String] {
261 &self.params
262 }
263
264 pub fn param_count(&self) -> usize {
266 self.params.len()
267 }
268
269 pub fn param_name(&self, index: usize) -> Option<&str> {
271 let names = self.param_names.as_deref().unwrap_or(&self.params);
272 param_at(names, index, self.variadic).map(String::as_str)
273 }
274
275 pub fn param_display_name(&self, locale: &Locale, index: usize) -> Option<&str> {
283 param_at(&self.param_display_names, index, self.variadic)
284 .and_then(Option::as_ref)
285 .and_then(|names| names.get(locale))
286 .map(String::as_str)
287 }
288
289 pub fn required_param_count(&self) -> usize {
293 (0..self.params.len())
294 .rev()
295 .find(|index| {
296 self.param_defaults
297 .get(*index)
298 .and_then(Option::as_ref)
299 .is_none()
300 })
301 .map_or(0, |index| index + 1)
302 }
303
304 pub fn has_param_defaults(&self) -> bool {
306 self.param_defaults.iter().any(Option::is_some)
307 }
308
309 pub fn param_default(&self, index: usize) -> Option<&str> {
311 param_at(&self.param_defaults, index, self.variadic).and_then(Option::as_deref)
312 }
313
314 pub fn param_domain(&self, index: usize) -> Option<&str> {
316 param_at(&self.param_domains, index, self.variadic).and_then(Option::as_deref)
317 }
318
319 pub fn reevaluation_coverage(&self, member: &str) -> Option<&[usize]> {
328 self.reevaluation_coverage
329 .as_ref()
330 .and_then(|coverage| coverage.get(member).map(Vec::as_slice))
331 }
332
333 pub fn has_reevaluation_coverage(&self) -> bool {
336 self.reevaluation_coverage.is_some()
337 }
338
339 pub fn param_type(&self, index: usize) -> Option<&str> {
342 param_at(&self.param_types, index, self.variadic).and_then(Option::as_deref)
343 }
344
345 pub fn param_coercions(&self, index: usize) -> Option<&ParamCoercions> {
347 param_at(&self.param_coercions, index, self.variadic).and_then(Option::as_ref)
348 }
349
350 pub fn param_content(&self, index: usize) -> Option<&ParamContent> {
354 param_at(&self.param_content, index, self.variadic).and_then(Option::as_ref)
355 }
356
357 pub fn return_type(&self) -> Option<&str> {
359 self.return_type.as_deref()
360 }
361
362 pub fn is_variadic(&self) -> bool {
364 self.variadic
365 }
366
367 pub(crate) fn folds_player_variable_pair(&self) -> bool {
368 self.param_type(0) == Some("Player|Array") && self.param_type(1) == Some("Variable")
369 }
370
371 pub(crate) fn wir_args_folded(
372 &self,
373 program: &crate::wir::Program,
374 args: &[crate::wir::ValueId],
375 ) -> bool {
376 self.folds_player_variable_pair()
377 && matches!(
378 args.first()
379 .and_then(|arg| program.values.get(*arg))
380 .map(|node| &node.value),
381 Some(crate::wir::Value::PlayerVariable { .. })
382 )
383 }
384
385 pub(crate) fn wir_arg_param_index(
386 &self,
387 program: &crate::wir::Program,
388 args: &[crate::wir::ValueId],
389 wir_index: usize,
390 ) -> usize {
391 wir_index + usize::from(self.wir_args_folded(program, args))
392 }
393}
394
395fn param_at<T>(values: &[T], index: usize, variadic: bool) -> Option<&T> {
396 values
397 .get(index)
398 .or_else(|| variadic.then(|| values.last()).flatten())
399}
400
401#[derive(Debug, Clone)]
403pub struct EnumMember {
404 pub member: String,
405 pub(crate) aliases: HashMap<Locale, Vec<String>>,
406}
407
408impl EnumMember {
409 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
411 spelling(&self.aliases, locale)
412 }
413
414 pub fn spellings(&self, locale: &Locale) -> &[String] {
417 spellings_for(&self.aliases, locale)
418 }
419}
420
421#[derive(Debug, Clone)]
423pub struct EnumDomain {
424 pub domain: String,
425 pub(crate) aliases: HashMap<Locale, Vec<String>>,
426 pub members: Vec<EnumMember>,
427}
428
429impl EnumDomain {
430 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
431 spelling(&self.aliases, locale)
432 }
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
437#[non_exhaustive]
438pub struct TargetMeta {
439 pub game: String,
440 pub format: String,
441 pub surface: String,
442}
443
444#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
446#[serde(rename_all = "camelCase")]
447#[non_exhaustive]
448pub struct Provenance {
449 pub generator: String,
450 pub generator_version: String,
451 pub source: String,
452 pub license: String,
453 pub reviewed: bool,
454 #[serde(default, skip_serializing_if = "Vec::is_empty")]
457 pub source_notes: Vec<String>,
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
464#[non_exhaustive]
465pub struct LocaleCoverage {
466 pub locale: Locale,
467 pub mapped: usize,
469 pub total: usize,
471}
472
473#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
479#[serde(rename_all = "kebab-case")]
480#[non_exhaustive]
481pub struct CatalogIdentity {
482 pub implementation_version: String,
484 pub catalog_version: String,
486 pub catalog_digest: Option<String>,
489 pub locale_coverage: Vec<LocaleCoverage>,
491 pub target: TargetMeta,
493 pub provenance: Provenance,
495}
496
497pub(crate) type MemberIndexMap = HashMap<String, HashMap<String, (usize, usize)>>;
498
499type EnumMemberAliasMap = HashMap<String, HashMap<String, Option<(usize, usize)>>>;
500
501#[derive(Debug, Clone, Default)]
502pub(crate) struct NormalizedIndex {
503 pub(crate) alias_to_entry: HashMap<Locale, [HashMap<String, Option<usize>>; Kind::NUM_KINDS]>,
504 pub(crate) localized_string_alias: HashMap<Locale, HashMap<String, Option<usize>>>,
505 pub(crate) enum_alias_to_domain: HashMap<Locale, HashMap<String, Option<String>>>,
506 pub(crate) enum_alias_to_member: HashMap<Locale, EnumMemberAliasMap>,
507 pub(crate) bare_member: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
508}
509
510pub(crate) fn normalize_spelling(spelling: &str) -> Cow<'_, str> {
511 if spelling.chars().any(char::is_whitespace) {
512 Cow::Owned(spelling.chars().filter(|c| !c.is_whitespace()).collect())
513 } else {
514 Cow::Borrowed(spelling)
515 }
516}
517
518fn lookup_normalized<'a, V>(
519 index: &'a HashMap<String, Option<V>>,
520 spelling: &str,
521) -> Option<&'a V> {
522 index.get(normalize_spelling(spelling).as_ref())?.as_ref()
523}
524
525#[derive(Debug, Clone)]
527pub struct Catalog {
528 detection_index: Option<detect::AliasIndex>,
529 pub(crate) schema_version: u32,
530 pub(crate) locales: Vec<Locale>,
531 pub(crate) target: TargetMeta,
532 pub(crate) provenance: Provenance,
533 pub(crate) catalog_version: String,
534 pub(crate) catalog_digest: Option<String>,
535 pub(crate) entries: Vec<CatalogEntry>,
536 pub(crate) localized_strings: Vec<LocalizedStringEntry>,
537 pub(crate) enums: Vec<EnumDomain>,
538 pub(crate) by_id: [HashMap<String, usize>; Kind::NUM_KINDS],
539 pub(crate) alias_to_entry: HashMap<Locale, [HashMap<String, usize>; Kind::NUM_KINDS]>,
540 pub(crate) localized_string_by_id: HashMap<String, usize>,
541 pub(crate) localized_string_alias: HashMap<Locale, HashMap<String, usize>>,
542 pub(crate) enum_by_domain: HashMap<String, usize>,
543 pub(crate) enum_alias_to_domain: HashMap<Locale, HashMap<String, String>>,
544 pub(crate) enum_alias_to_member: HashMap<Locale, MemberIndexMap>,
545 pub(crate) bare_member_index: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
546 pub(crate) normalized: NormalizedIndex,
547 pub(crate) descriptions: descriptions::Descriptions,
548}
549
550mod load;
551
552pub use load::{build_canonical, canonicalize, content_digest};
553
554impl Catalog {
555 pub(crate) fn schema_version(&self) -> u32 {
556 self.schema_version
557 }
558
559 pub fn locales(&self) -> &[Locale] {
561 &self.locales
562 }
563
564 pub fn primary_locale(&self) -> &Locale {
567 &self.locales[0]
568 }
569
570 pub fn supports(&self, locale: &Locale) -> bool {
572 self.locales.contains(locale)
573 }
574
575 pub fn catalog_version(&self) -> &str {
577 &self.catalog_version
578 }
579
580 pub fn catalog_digest(&self) -> Option<&str> {
583 self.catalog_digest.as_deref()
584 }
585
586 pub fn implementation_version() -> &'static str {
588 env!("CARGO_PKG_VERSION")
589 }
590
591 pub fn identity(&self) -> CatalogIdentity {
594 CatalogIdentity {
595 implementation_version: Self::implementation_version().to_string(),
596 catalog_version: self.catalog_version.clone(),
597 catalog_digest: self.catalog_digest.clone(),
598 locale_coverage: self
599 .locales
600 .iter()
601 .map(|locale| self.locale_coverage(locale))
602 .collect(),
603 target: self.target.clone(),
604 provenance: self.provenance.clone(),
605 }
606 }
607
608 pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
613 let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
614 let total = self.entries.len() + self.localized_strings.len() + member_total;
615 let mapped = self
616 .entries
617 .iter()
618 .filter(|entry| entry.aliases.contains_key(locale))
619 .count()
620 + self
621 .localized_strings
622 .iter()
623 .filter(|entry| entry.aliases.contains_key(locale))
624 .count()
625 + self
626 .enums
627 .iter()
628 .flat_map(|domain| &domain.members)
629 .filter(|member| member.aliases.contains_key(locale))
630 .count();
631 LocaleCoverage {
632 locale: locale.clone(),
633 mapped,
634 total,
635 }
636 }
637
638 pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
640 self.locales
641 .iter()
642 .map(|locale| self.locale_coverage(locale))
643 .collect()
644 }
645
646 pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
648 self.by_id[kind.as_index()]
649 .get(id)
650 .map(|i| &self.entries[*i])
651 }
652
653 pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
658 self.alias_to_entry
659 .get(locale)
660 .and_then(|by_kind| by_kind[kind.as_index()].get(spelling))
661 .map(|i| &self.entries[*i])
662 .or_else(|| {
663 let index = &self.normalized.alias_to_entry.get(locale)?[kind.as_index()];
664 lookup_normalized(index, spelling).map(|i| &self.entries[*i])
665 })
666 }
667
668 pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
670 self.entry(kind, id)?.spelling(locale)
671 }
672
673 pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
675 self.entries.iter().filter(move |entry| entry.kind == kind)
676 }
677
678 pub fn resolve_localized_string(
680 &self,
681 locale: &Locale,
682 spelling: &str,
683 ) -> Option<&LocalizedStringEntry> {
684 self.localized_string_alias
685 .get(locale)
686 .and_then(|map| map.get(spelling))
687 .map(|index| &self.localized_strings[*index])
688 .or_else(|| {
689 let index = self.normalized.localized_string_alias.get(locale)?;
690 lookup_normalized(index, spelling).map(|i| &self.localized_strings[*i])
691 })
692 }
693
694 pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
696 self.localized_string_by_id
697 .get(id)
698 .and_then(|i| self.localized_strings.get(*i))
699 .and_then(|entry| entry.spelling(locale))
700 }
701
702 pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
704 self.localized_strings.iter()
705 }
706
707 pub fn entry_count(&self) -> usize {
709 self.entries.len()
710 }
711
712 pub fn enum_domains_count(&self) -> usize {
713 self.enums.len()
714 }
715
716 pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
718 self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
719 }
720
721 pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
723 self.enum_by_domain
724 .get_key_value(spelling)
725 .map(|(domain, _)| domain.as_str())
726 .or_else(|| {
727 self.enum_alias_to_domain
728 .get(locale)
729 .and_then(|map| map.get(spelling))
730 .map(String::as_str)
731 })
732 .or_else(|| {
733 let index = self.normalized.enum_alias_to_domain.get(locale)?;
734 lookup_normalized(index, spelling).map(String::as_str)
735 })
736 }
737
738 pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
740 self.enums.iter()
741 }
742
743 pub fn resolve_enum_member(
745 &self,
746 domain: &str,
747 locale: &Locale,
748 spelling: &str,
749 ) -> Option<(String, String)> {
750 let indexes = self
751 .enum_alias_to_member
752 .get(locale)?
753 .get(domain)?
754 .get(spelling)
755 .copied()
756 .or_else(|| {
757 let index = self
758 .normalized
759 .enum_alias_to_member
760 .get(locale)?
761 .get(domain)?;
762 lookup_normalized(index, spelling).copied()
763 })?;
764 let (domain_index, member_index) = indexes;
765 Some((
766 domain.to_string(),
767 self.enums[domain_index].members[member_index]
768 .member
769 .clone(),
770 ))
771 }
772
773 pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
775 let domain_index = self.enum_by_domain.get(domain)?;
776 let domain = &self.enums[*domain_index];
777 domain
778 .members
779 .iter()
780 .find(|candidate| candidate.member == member)?
781 .spelling(locale)
782 }
783
784 pub fn localized_enum_spelling(
788 &self,
789 domain: &str,
790 locale: &Locale,
791 member: &str,
792 ) -> Option<&str> {
793 self.enum_spelling(domain, locale, member).or_else(|| {
794 (domain == "Color" && member == "WHITE").then_some(match locale.as_str() {
795 "de-de" => "Weiß",
796 "es-es" | "es-mx" => "Blanco",
797 "fr-fr" => "Blanc",
798 "it-it" => "Bianco",
799 "ja-jp" => "白",
800 "ko-kr" => "흰색",
801 "pl-pl" => "Biały",
802 "pt-br" => "Branco",
803 "ru-ru" => "Белый",
804 "th-th" => "สีขาว",
805 "tr-tr" => "Beyaz",
806 "zh-tw" => "白色",
807 _ => return None,
808 })
809 })
810 }
811
812 pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
818 if let Some(matches) = self
819 .bare_member_index
820 .get(locale)
821 .and_then(|map| map.get(spelling))
822 {
823 return matches.clone();
824 }
825 self.normalized
826 .bare_member
827 .get(locale)
828 .and_then(|map| map.get(normalize_spelling(spelling).as_ref()))
829 .cloned()
830 .unwrap_or_default()
831 }
832
833 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(
845 &self,
846 domain: &str,
847 member_spelling: &str,
848 locale: &Locale,
849 ) -> String {
850 if matches!(domain, "Hero" | "Button" | "Color" | "Map") {
851 let domain_display = self
852 .enum_domain(domain)
853 .and_then(|entry| entry.spelling(locale))
854 .unwrap_or(domain);
855 format!("{domain_display}({member_spelling})")
856 } else {
857 member_spelling.to_string()
858 }
859 }
860
861 pub(crate) fn canonical_member_spellings<'a>(
862 &'a self,
863 domain: &'a str,
864 locale: &'a Locale,
865 ) -> impl Iterator<Item = &'a str> {
866 self.enum_domain(domain)
867 .into_iter()
868 .flat_map(move |domain| {
869 domain.members.iter().flat_map(move |member| {
870 std::iter::once(member.member.as_str())
871 .chain(member.spellings(locale).iter().map(String::as_str))
872 })
873 })
874 }
875}
876
877impl ExpectedDomain for Catalog {
878 fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
879 for kind in [Kind::Action, Kind::Value] {
880 if let Some(entry) = self.entry(kind, catalog_id)
881 && let Some(domain) = entry.param_domain(arg_index)
882 {
883 return Some(domain);
884 }
885 }
886 None
887 }
888}