1pub mod detect;
23
24use std::collections::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) variadic: bool,
164 pub(crate) aliases: HashMap<Locale, Vec<String>>,
165}
166
167#[derive(Debug, Clone)]
171pub struct LocalizedStringEntry {
172 pub id: String,
173 pub(crate) aliases: HashMap<Locale, Vec<String>>,
174}
175
176fn spelling<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> Option<&'a str> {
177 aliases
178 .get(locale)
179 .and_then(|spellings| spellings.first())
180 .map(String::as_str)
181}
182
183fn spellings_for<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> &'a [String] {
184 aliases.get(locale).map(Vec::as_slice).unwrap_or_default()
185}
186
187impl LocalizedStringEntry {
188 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
190 spelling(&self.aliases, locale)
191 }
192
193 pub fn spellings(&self, locale: &Locale) -> &[String] {
195 spellings_for(&self.aliases, locale)
196 }
197}
198
199impl CatalogEntry {
200 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
202 spelling(&self.aliases, locale)
203 }
204
205 pub fn spellings(&self, locale: &Locale) -> &[String] {
208 spellings_for(&self.aliases, locale)
209 }
210
211 pub fn resolve_param(&self, locale: &Locale, spelling: &str) -> Option<usize> {
214 let matches = self
215 .params
216 .iter()
217 .enumerate()
218 .filter(|(index, canonical)| {
219 canonical == &spelling
220 || self
221 .param_aliases
222 .get(*index)
223 .and_then(|aliases| aliases.get(locale))
224 .is_some_and(|aliases| aliases.iter().any(|alias| alias == spelling))
225 })
226 .map(|(index, _)| index)
227 .collect::<Vec<_>>();
228 (matches.len() == 1).then(|| matches[0])
229 }
230
231 pub fn params(&self) -> &[String] {
233 &self.params
234 }
235
236 pub fn param_count(&self) -> usize {
238 self.params.len()
239 }
240
241 pub fn param_name(&self, index: usize) -> Option<&str> {
243 let names = self.param_names.as_deref().unwrap_or(&self.params);
244 param_at(names, index, self.variadic).map(String::as_str)
245 }
246
247 pub fn required_param_count(&self) -> usize {
251 (0..self.params.len())
252 .rev()
253 .find(|index| {
254 self.param_defaults
255 .get(*index)
256 .and_then(Option::as_ref)
257 .is_none()
258 })
259 .map_or(0, |index| index + 1)
260 }
261
262 pub fn has_param_defaults(&self) -> bool {
264 self.param_defaults.iter().any(Option::is_some)
265 }
266
267 pub fn param_default(&self, index: usize) -> Option<&str> {
269 param_at(&self.param_defaults, index, self.variadic).and_then(Option::as_deref)
270 }
271
272 pub fn param_domain(&self, index: usize) -> Option<&str> {
274 param_at(&self.param_domains, index, self.variadic).and_then(Option::as_deref)
275 }
276
277 pub fn param_type(&self, index: usize) -> Option<&str> {
280 param_at(&self.param_types, index, self.variadic).and_then(Option::as_deref)
281 }
282
283 pub fn param_coercions(&self, index: usize) -> Option<&ParamCoercions> {
285 param_at(&self.param_coercions, index, self.variadic).and_then(Option::as_ref)
286 }
287
288 pub fn return_type(&self) -> Option<&str> {
290 self.return_type.as_deref()
291 }
292
293 pub fn is_variadic(&self) -> bool {
295 self.variadic
296 }
297}
298
299fn param_at<T>(values: &[T], index: usize, variadic: bool) -> Option<&T> {
300 values
301 .get(index)
302 .or_else(|| variadic.then(|| values.last()).flatten())
303}
304
305#[derive(Debug, Clone)]
307pub struct EnumMember {
308 pub member: String,
309 pub(crate) aliases: HashMap<Locale, Vec<String>>,
310}
311
312impl EnumMember {
313 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
315 spelling(&self.aliases, locale)
316 }
317
318 pub fn spellings(&self, locale: &Locale) -> &[String] {
321 spellings_for(&self.aliases, locale)
322 }
323}
324
325#[derive(Debug, Clone)]
327pub struct EnumDomain {
328 pub domain: String,
329 pub(crate) aliases: HashMap<Locale, Vec<String>>,
330 pub members: Vec<EnumMember>,
331}
332
333impl EnumDomain {
334 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
335 spelling(&self.aliases, locale)
336 }
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
341#[non_exhaustive]
342pub struct TargetMeta {
343 pub game: String,
344 pub format: String,
345 pub surface: String,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
350#[serde(rename_all = "camelCase")]
351#[non_exhaustive]
352pub struct Provenance {
353 pub generator: String,
354 pub generator_version: String,
355 pub source: String,
356 pub license: String,
357 pub reviewed: bool,
358 #[serde(default, skip_serializing_if = "Vec::is_empty")]
361 pub source_notes: Vec<String>,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
368#[non_exhaustive]
369pub struct LocaleCoverage {
370 pub locale: Locale,
371 pub mapped: usize,
373 pub total: usize,
375}
376
377#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
383#[serde(rename_all = "kebab-case")]
384#[non_exhaustive]
385pub struct CatalogIdentity {
386 pub implementation_version: String,
388 pub catalog_version: String,
390 pub catalog_digest: Option<String>,
393 pub locale_coverage: Vec<LocaleCoverage>,
395 pub target: TargetMeta,
397 pub provenance: Provenance,
399}
400
401pub(crate) type MemberIndexMap = HashMap<String, HashMap<String, (usize, usize)>>;
402
403#[derive(Debug, Clone)]
405pub struct Catalog {
406 detection_index: Option<detect::AliasIndex>,
407 pub(crate) schema_version: u32,
408 pub(crate) locales: Vec<Locale>,
411 pub(crate) target: TargetMeta,
412 pub(crate) provenance: Provenance,
413 pub(crate) catalog_version: String,
415 pub(crate) catalog_digest: Option<String>,
418 pub(crate) entries: Vec<CatalogEntry>,
419 pub(crate) localized_strings: Vec<LocalizedStringEntry>,
420 pub(crate) enums: Vec<EnumDomain>,
421 pub(crate) by_id: [HashMap<String, usize>; Kind::NUM_KINDS],
422 pub(crate) alias_to_entry: HashMap<Locale, [HashMap<String, usize>; Kind::NUM_KINDS]>,
423 pub(crate) localized_string_by_id: HashMap<String, usize>,
424 pub(crate) localized_string_alias: HashMap<Locale, HashMap<String, usize>>,
425 pub(crate) enum_by_domain: HashMap<String, usize>,
426 pub(crate) enum_alias_to_domain: HashMap<Locale, HashMap<String, String>>,
427 pub(crate) enum_alias_to_member: HashMap<Locale, MemberIndexMap>,
428 pub(crate) bare_member_index: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
429}
430
431mod load;
433
434pub use load::{build_canonical, canonicalize, content_digest};
435
436impl Catalog {
437 pub(crate) fn schema_version(&self) -> u32 {
438 self.schema_version
439 }
440
441 pub fn locales(&self) -> &[Locale] {
443 &self.locales
444 }
445
446 pub fn primary_locale(&self) -> &Locale {
449 &self.locales[0]
450 }
451
452 pub fn supports(&self, locale: &Locale) -> bool {
454 self.locales.contains(locale)
455 }
456
457 pub fn catalog_version(&self) -> &str {
459 &self.catalog_version
460 }
461
462 pub fn catalog_digest(&self) -> Option<&str> {
465 self.catalog_digest.as_deref()
466 }
467
468 pub fn implementation_version() -> &'static str {
470 env!("CARGO_PKG_VERSION")
471 }
472
473 pub fn identity(&self) -> CatalogIdentity {
476 CatalogIdentity {
477 implementation_version: Self::implementation_version().to_string(),
478 catalog_version: self.catalog_version.clone(),
479 catalog_digest: self.catalog_digest.clone(),
480 locale_coverage: self
481 .locales
482 .iter()
483 .map(|locale| self.locale_coverage(locale))
484 .collect(),
485 target: self.target.clone(),
486 provenance: self.provenance.clone(),
487 }
488 }
489
490 pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
495 let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
496 let total = self.entries.len() + self.localized_strings.len() + member_total;
497 let mapped = self
498 .entries
499 .iter()
500 .filter(|entry| entry.aliases.contains_key(locale))
501 .count()
502 + self
503 .localized_strings
504 .iter()
505 .filter(|entry| entry.aliases.contains_key(locale))
506 .count()
507 + self
508 .enums
509 .iter()
510 .flat_map(|domain| &domain.members)
511 .filter(|member| member.aliases.contains_key(locale))
512 .count();
513 LocaleCoverage {
514 locale: locale.clone(),
515 mapped,
516 total,
517 }
518 }
519
520 pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
522 self.locales
523 .iter()
524 .map(|locale| self.locale_coverage(locale))
525 .collect()
526 }
527
528 pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
530 self.by_id[kind.as_index()]
531 .get(id)
532 .map(|i| &self.entries[*i])
533 }
534
535 pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
537 self.alias_to_entry
538 .get(locale)
539 .and_then(|by_kind| by_kind[kind.as_index()].get(spelling))
540 .map(|i| &self.entries[*i])
541 }
542
543 pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
545 self.entry(kind, id)?.spelling(locale)
546 }
547
548 pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
550 self.entries.iter().filter(move |entry| entry.kind == kind)
551 }
552
553 pub fn resolve_localized_string(
555 &self,
556 locale: &Locale,
557 spelling: &str,
558 ) -> Option<&LocalizedStringEntry> {
559 self.localized_string_alias
560 .get(locale)
561 .and_then(|map| map.get(spelling))
562 .map(|index| &self.localized_strings[*index])
563 }
564
565 pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
567 self.localized_string_by_id
568 .get(id)
569 .and_then(|i| self.localized_strings.get(*i))
570 .and_then(|entry| entry.spelling(locale))
571 }
572
573 pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
575 self.localized_strings.iter()
576 }
577
578 pub fn entry_count(&self) -> usize {
580 self.entries.len()
581 }
582
583 pub fn enum_domains_count(&self) -> usize {
585 self.enums.len()
586 }
587
588 pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
590 self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
591 }
592
593 pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
595 self.enum_by_domain
596 .get_key_value(spelling)
597 .map(|(domain, _)| domain.as_str())
598 .or_else(|| {
599 self.enum_alias_to_domain
600 .get(locale)
601 .and_then(|map| map.get(spelling))
602 .map(String::as_str)
603 })
604 }
605
606 pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
608 self.enums.iter()
609 }
610
611 pub fn resolve_enum_member(
613 &self,
614 domain: &str,
615 locale: &Locale,
616 spelling: &str,
617 ) -> Option<(String, String)> {
618 let &(domain_index, member_index) = self
619 .enum_alias_to_member
620 .get(locale)?
621 .get(domain)?
622 .get(spelling)?;
623 Some((
624 domain.to_string(),
625 self.enums[domain_index].members[member_index]
626 .member
627 .clone(),
628 ))
629 }
630
631 pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
633 let domain_index = self.enum_by_domain.get(domain)?;
634 let domain = &self.enums[*domain_index];
635 domain
636 .members
637 .iter()
638 .find(|candidate| candidate.member == member)?
639 .spelling(locale)
640 }
641
642 pub fn localized_enum_spelling(
646 &self,
647 domain: &str,
648 locale: &Locale,
649 member: &str,
650 ) -> Option<&str> {
651 self.enum_spelling(domain, locale, member).or_else(|| {
652 (domain == "Color" && member == "WHITE").then_some(match locale.as_str() {
653 "de-de" => "Weiß",
654 "es-es" | "es-mx" => "Blanco",
655 "fr-fr" => "Blanc",
656 "it-it" => "Bianco",
657 "ja-jp" => "白",
658 "ko-kr" => "흰색",
659 "pl-pl" => "Biały",
660 "pt-br" => "Branco",
661 "ru-ru" => "Белый",
662 "th-th" => "สีขาว",
663 "tr-tr" => "Beyaz",
664 "zh-tw" => "白色",
665 _ => return None,
666 })
667 })
668 }
669
670 pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
675 self.bare_member_index
676 .get(locale)
677 .and_then(|map| map.get(spelling))
678 .cloned()
679 .unwrap_or_default()
680 }
681}
682
683impl ExpectedDomain for Catalog {
690 fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
691 for kind in [Kind::Action, Kind::Value] {
692 if let Some(entry) = self.entry(kind, catalog_id) {
693 if let Some(domain) = entry.param_domain(arg_index) {
694 return Some(domain);
695 }
696 }
697 }
698 None
699 }
700}