Skip to main content

workshop_rs/catalog/
mod.rs

1//! The canonical Workshop catalog.
2//!
3//! The catalog is the locale-independent semantic identity layer between
4//! textual Workshop spellings and WIR. Every builtin has a canonical `id` and
5//! a [`Kind`]; locale tables map canonical identities to client spellings and
6//! back, so parser, emitter, analyzer, and tooling never embed
7//! locale-specific strings as identity.
8//!
9//! Locale coverage is data ([ADR-0001](https://github.com/wrightkit/workshop-rs/blob/main/docs/adr/0001-catalog-boundaries.md)):
10//! the primary locale (the first declared one, `en-US`) is complete — every
11//! entry and enum member carries a primary-locale alias — while additional
12//! declared locales may be partially covered. Missing target-locale mappings
13//! fail explicitly at conversion/emission time; the catalog reports exact
14//! per-locale coverage machine-readably ([`Catalog::locale_coverage`],
15//! [`Catalog::identity`]).
16//!
17//! The catalog dataset declares its own `version` and a deterministic content
18//! `digest` (sha256) recomputed by the catalog pipeline
19//! (`workshop-catalog-gen build`); [`Catalog::load`] rejects a digest
20//! mismatch, so dataset changes are deliberate and reproducible.
21
22pub mod detect;
23
24use std::collections::HashMap;
25
26use serde::{Deserialize, Deserializer, Serialize};
27
28use crate::core::signatures::ExpectedDomain;
29
30/// The embedded catalog data.
31pub const CATALOG_DATA: &str = include_str!("data/catalog.json");
32
33/// A normalized Workshop client locale, e.g. `en-US`.
34#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
35pub struct Locale(String);
36
37impl Locale {
38    /// Build a locale from a client spelling, normalized to lowercase.
39    pub fn new(value: &str) -> Locale {
40        Locale(value.trim().to_ascii_lowercase())
41    }
42
43    /// The normalized locale string.
44    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/// The kind of a catalog builtin.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum Kind {
68    /// A structural keyword (If, End, Set Global Variable, …).
69    Structural = 0,
70    /// An action function.
71    Action = 1,
72    /// A value function.
73    Value = 2,
74    /// An event.
75    Event = 3,
76    /// An operator token (comparison operators).
77    Operator = 4,
78    /// An enumerated value domain.
79    Enum = 5,
80    /// A settings entry.
81    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/// Literal substitutions accepted at one parameter position. The authored
105/// literal is kept in WIR; these facts only decide acceptance.
106///
107/// These are deliberately per-parameter facts. They do not establish a
108/// global relationship between Workshop booleans, numbers, arrays, strings,
109/// vectors, or null.
110#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
111#[serde(rename_all = "camelCase")]
112#[non_exhaustive]
113pub struct ParamCoercions {
114    /// Accept `False` as numeric zero.
115    #[serde(default)]
116    pub false_as_number: bool,
117    /// Accept `True` as numeric one.
118    #[serde(default)]
119    pub true_as_number: bool,
120    /// Accept numeric zero as `Null`.
121    #[serde(default)]
122    pub zero_as_null: bool,
123    /// Accept `Vector(0, 0, 0)` as `Null`.
124    #[serde(default)]
125    pub null_vector_as_null: bool,
126    /// Accept `Empty Array` as an empty string.
127    #[serde(default)]
128    pub empty_array_as_string: bool,
129}
130
131/// One catalog builtin.
132#[derive(Debug, Clone)]
133pub struct CatalogEntry {
134    pub id: String,
135    pub kind: Kind,
136    /// Parameter names, when the catalog documents them.
137    pub(crate) params: Vec<String>,
138    /// Reviewed semantic names when they differ from the catalog parameters.
139    pub(crate) param_names: Option<Vec<String>>,
140    /// Reviewed localized spellings for each parameter, parallel to `params`.
141    pub(crate) param_aliases: Vec<HashMap<Locale, Vec<String>>>,
142    /// The canonical enum domain expected at each parameter position, when
143    /// the parameter takes an enumerated value (parallel to `params`).
144    /// `None` for non-enum parameters and for parameters whose accepted
145    /// values span multiple canonical domains. In particular, a filtered
146    /// rule event's `Player` parameter accepts `EventPlayer` members or
147    /// canonical `Hero` members; the WIR [`crate::wir::EventTarget`] carries
148    /// that union explicitly.
149    pub(crate) param_domains: Vec<Option<String>>,
150    /// Default value per parameter position (parallel to `params`),
151    /// resolved when a call omits the argument. See the catalog data
152    /// provenance for the value syntax and source.
153    pub(crate) param_defaults: Vec<Option<String>>,
154    /// Source-backed semantic type per parameter position. `None` means
155    /// the available sources do not establish a narrower type.
156    pub(crate) param_types: Vec<Option<String>>,
157    /// Contextual literal substitutions per parameter position.
158    pub(crate) param_coercions: Vec<Option<ParamCoercions>>,
159    /// Source-backed return type for Value entries. Actions must leave this
160    /// unset; an absent value remains unresolved.
161    pub(crate) return_type: Option<String>,
162    /// Whether the final declared parameter repeats for additional arguments.
163    pub(crate) variadic: bool,
164    pub(crate) aliases: HashMap<Locale, Vec<String>>,
165}
166
167/// A locale-independent identity for a preset used by the Workshop `String`
168/// value. Unlike a custom `Value::String`, this identity must resolve through
169/// reviewed client-locale aliases before it can be parsed or emitted.
170#[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    /// The deterministic emitted spelling in `locale`, when mapped.
189    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
190        spelling(&self.aliases, locale)
191    }
192
193    /// All reviewed spellings accepted for this locale.
194    pub fn spellings(&self, locale: &Locale) -> &[String] {
195        spellings_for(&self.aliases, locale)
196    }
197}
198
199impl CatalogEntry {
200    /// The localized spelling of this builtin in `locale`, when declared.
201    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
202        spelling(&self.aliases, locale)
203    }
204
205    /// Every reviewed localized spelling of this builtin, with the first
206    /// spelling reserved for deterministic emission.
207    pub fn spellings(&self, locale: &Locale) -> &[String] {
208        spellings_for(&self.aliases, locale)
209    }
210
211    /// Resolve a canonical or reviewed localized parameter spelling to its
212    /// unambiguous declared position.
213    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    /// The canonical parameter names in declaration order.
232    pub fn params(&self) -> &[String] {
233        &self.params
234    }
235
236    /// The number of declared arguments for this builtin.
237    pub fn param_count(&self) -> usize {
238        self.params.len()
239    }
240
241    /// The reviewed semantic name for an argument position, when declared.
242    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    /// The number of arguments that must be present when trailing defaults
248    /// are applied. A missing default in the middle of a signature remains a
249    /// required position; defaults only make the suffix optional.
250    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    /// Whether any declared parameter has a default value.
263    pub fn has_param_defaults(&self) -> bool {
264        self.param_defaults.iter().any(Option::is_some)
265    }
266
267    /// The default value for an argument position, when declared.
268    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    /// The declared enum domain for an argument position, when one exists.
273    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    /// The source-backed semantic type for an argument position, when
278    /// available. Enum domains remain exposed separately by `param_domain`.
279    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    /// The contextual literal substitutions for an argument position.
284    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    /// The source-backed return type of a Value, when available.
289    pub fn return_type(&self) -> Option<&str> {
290        self.return_type.as_deref()
291    }
292
293    /// Whether the final declared parameter repeats for additional arguments.
294    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/// One enum member within a domain.
306#[derive(Debug, Clone)]
307pub struct EnumMember {
308    pub member: String,
309    pub(crate) aliases: HashMap<Locale, Vec<String>>,
310}
311
312impl EnumMember {
313    /// The localized spelling of this member in `locale`, when declared.
314    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
315        spelling(&self.aliases, locale)
316    }
317
318    /// Every reviewed localized spelling of this enum member, with the first
319    /// spelling reserved for deterministic emission.
320    pub fn spellings(&self, locale: &Locale) -> &[String] {
321        spellings_for(&self.aliases, locale)
322    }
323}
324
325/// One enum value domain (e.g. `Color`, `Beam`).
326#[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/// Target-format metadata recorded in the catalog.
340#[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/// Provenance of the catalog data.
349#[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    /// Additional immutable observations that qualify the dataset source,
359    /// including reviewed spelling conflicts retained as parse aliases.
360    #[serde(default, skip_serializing_if = "Vec::is_empty")]
361    pub source_notes: Vec<String>,
362}
363
364/// Per-locale mapping coverage: how many canonical entries (builtins,
365/// localized preset identities, and enum members) carry a mapping for the
366/// locale out of the declared total.
367#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
368#[non_exhaustive]
369pub struct LocaleCoverage {
370    pub locale: Locale,
371    /// Canonical entries with a declared mapping in this locale.
372    pub mapped: usize,
373    /// Canonical entries (builtins and enum members) declared by the catalog.
374    pub total: usize,
375}
376
377/// The machine-readable catalog identity (ADR-0001 Decision 5): the four
378/// identities that evolve independently — implementation version, catalog
379/// dataset version plus content digest, locale coverage, and target evidence
380/// — plus the data provenance record. Serialized with the ADR's kebab-case
381/// identity names.
382#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
383#[serde(rename_all = "kebab-case")]
384#[non_exhaustive]
385pub struct CatalogIdentity {
386    /// The `workshop-rs` package version (semver); bumped by code changes.
387    pub implementation_version: String,
388    /// The catalog dataset version; bumped by any dataset change.
389    pub catalog_version: String,
390    /// The deterministic content digest (sha256 hex) computed by the
391    /// pipeline; `None` when the data does not declare one.
392    pub catalog_digest: Option<String>,
393    /// Declared locales with per-locale mapping counts.
394    pub locale_coverage: Vec<LocaleCoverage>,
395    /// The declared target surface.
396    pub target: TargetMeta,
397    /// The provenance record of the catalog data.
398    pub provenance: Provenance,
399}
400
401pub(crate) type MemberIndexMap = HashMap<String, HashMap<String, (usize, usize)>>;
402
403/// The validated canonical Workshop catalog.
404#[derive(Debug, Clone)]
405pub struct Catalog {
406    detection_index: Option<detect::AliasIndex>,
407    pub(crate) schema_version: u32,
408    /// The declared locales, normalized; the first one is the primary
409    /// locale and must be fully covered.
410    pub(crate) locales: Vec<Locale>,
411    pub(crate) target: TargetMeta,
412    pub(crate) provenance: Provenance,
413    /// The catalog dataset version (ADR-0001 `catalog-version`).
414    pub(crate) catalog_version: String,
415    /// The declared content digest (sha256 hex), verified at load when
416    /// present (ADR-0001 `catalog-version`).
417    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
431/// Catalog loading and digest machinery lives in `load.rs`.
432mod 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    /// The declared locales, normalized; the first one is the primary locale.
442    pub fn locales(&self) -> &[Locale] {
443        &self.locales
444    }
445
446    /// The primary locale: the first declared one, whose mapping surface is
447    /// complete (`en-US` in the committed catalog).
448    pub fn primary_locale(&self) -> &Locale {
449        &self.locales[0]
450    }
451
452    /// Whether a locale is declared by the catalog.
453    pub fn supports(&self, locale: &Locale) -> bool {
454        self.locales.contains(locale)
455    }
456
457    /// The catalog dataset version (ADR-0001 `catalog-version`).
458    pub fn catalog_version(&self) -> &str {
459        &self.catalog_version
460    }
461
462    /// The declared content digest (sha256 hex) of the catalog dataset,
463    /// verified at load; `None` for data that declares none.
464    pub fn catalog_digest(&self) -> Option<&str> {
465        self.catalog_digest.as_deref()
466    }
467
468    /// The `workshop-rs` package version (ADR-0001 `implementation-version`).
469    pub fn implementation_version() -> &'static str {
470        env!("CARGO_PKG_VERSION")
471    }
472
473    /// The machine-readable catalog identity: implementation version, catalog
474    /// version + digest, locale coverage, target evidence, and provenance.
475    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    /// The mapping coverage of one declared locale: mapped entries out of the
491    /// declared total (builtins, localized preset identities, and enum members).
492    /// The primary locale is
493    /// always complete; other locales may be partially covered.
494    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    /// The mapping coverage of every declared locale, in declaration order.
521    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    /// The builtin with the given canonical id and kind.
529    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    /// Resolve a localized spelling to its canonical builtin.
536    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    /// The localized spelling of a canonical builtin id.
544    pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
545        self.entry(kind, id)?.spelling(locale)
546    }
547
548    /// Every entry of a kind, in catalog order.
549    pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
550        self.entries.iter().filter(move |entry| entry.kind == kind)
551    }
552
553    /// Resolve a localized preset spelling to its stable identity.
554    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    /// Resolve the emitted spelling of a localized preset identity.
566    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    /// Every reviewed localized preset identity, in catalog order.
574    pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
575        self.localized_strings.iter()
576    }
577
578    /// The total number of builtin entries.
579    pub fn entry_count(&self) -> usize {
580        self.entries.len()
581    }
582
583    /// The number of enum domains.
584    pub fn enum_domains_count(&self) -> usize {
585        self.enums.len()
586    }
587
588    /// The enum domain with the given name.
589    pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
590        self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
591    }
592
593    /// Resolve a localized enum-domain spelling to its canonical domain id.
594    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    /// Every enum domain, in catalog order.
607    pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
608        self.enums.iter()
609    }
610
611    /// Resolve a localized enum member spelling to `(domain, canonical member)`.
612    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    /// The localized spelling of a canonical enum member.
632    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    /// Resolve a canonical enum member through the locale boundary, including
643    /// reviewed partial locale spellings that are not yet part of the full
644    /// catalog locale set.
645    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    /// Every `(domain, canonical member)` match for a bare (domain-less)
671    /// localized member spelling. Returns all matches so callers can report
672    /// ambiguity; a well-formed catalog has at most one meaningful match for
673    /// a given spelling.
674    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
683/// The catalog is the canonical source of expected enum domains for the
684/// Workshop surface it documents: `expected_domain(catalog_id, arg_index)`
685/// answers the domain declared for that parameter position (e.g. `createHudText`
686/// argument 9 is `HudReeval`), so the Workshop parser can resolve bare enum
687/// members that are ambiguous across domains (e.g. `Visible To and String`).
688/// Positions without a documented domain answer `None`.
689impl 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}