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::borrow::Cow;
25use std::collections::{BTreeMap, HashMap};
26
27use serde::{Deserialize, Deserializer, Serialize};
28
29use crate::core::signatures::ExpectedDomain;
30
31/// The embedded catalog data.
32pub const CATALOG_DATA: &str = include_str!("data/catalog.json");
33
34/// A normalized Workshop client locale, e.g. `en-US`.
35#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
36pub struct Locale(String);
37
38impl Locale {
39    /// Build a locale from a client spelling, normalized to lowercase.
40    pub fn new(value: &str) -> Locale {
41        Locale(value.trim().to_ascii_lowercase())
42    }
43
44    /// The normalized locale string.
45    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/// The kind of a catalog builtin.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum Kind {
69    /// A structural keyword (If, End, Set Global Variable, …).
70    Structural = 0,
71    /// An action function.
72    Action = 1,
73    /// A value function.
74    Value = 2,
75    /// An event.
76    Event = 3,
77    /// An operator token (comparison operators).
78    Operator = 4,
79    /// An enumerated value domain.
80    Enum = 5,
81    /// A settings entry.
82    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/// Literal substitutions accepted at one parameter position. The authored
106/// literal is kept in WIR; these facts only decide acceptance.
107///
108/// These are deliberately per-parameter facts. They do not establish a
109/// global relationship between Workshop booleans, numbers, arrays, strings,
110/// vectors, or null.
111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
112#[serde(rename_all = "camelCase")]
113#[non_exhaustive]
114pub struct ParamCoercions {
115    /// Accept `False` as numeric zero.
116    #[serde(default)]
117    pub false_as_number: bool,
118    /// Accept `True` as numeric one.
119    #[serde(default)]
120    pub true_as_number: bool,
121    /// Accept numeric zero as `Null`.
122    #[serde(default)]
123    pub zero_as_null: bool,
124    /// Accept `Vector(0, 0, 0)` as `Null`.
125    #[serde(default)]
126    pub null_vector_as_null: bool,
127    /// Accept `Empty Array` as an empty string.
128    #[serde(default)]
129    pub empty_array_as_string: bool,
130}
131
132/// One catalog builtin.
133#[derive(Debug, Clone)]
134pub struct CatalogEntry {
135    pub id: String,
136    pub kind: Kind,
137    /// Whether `lookup` reports the entry's call signature and
138    /// `lookup_within` scopes to its parameters. Every action and value is
139    /// callable; a structural entry is callable only when the data declares
140    /// its parameter list (`"params": []` on the zero-argument `else`/`end`
141    /// constructs). Events, operators, and section keywords are not.
142    pub(crate) callable: bool,
143    /// Parameter names, when the catalog documents them.
144    pub(crate) params: Vec<String>,
145    /// Reviewed semantic names when they differ from the catalog parameters.
146    pub(crate) param_names: Option<Vec<String>>,
147    /// Reviewed localized spellings for each parameter, parallel to `params`.
148    pub(crate) param_aliases: Vec<HashMap<Locale, Vec<String>>>,
149    /// The canonical enum domain expected at each parameter position, when
150    /// the parameter takes an enumerated value (parallel to `params`).
151    /// `None` for non-enum parameters and for parameters whose accepted
152    /// values span multiple canonical domains. In particular, a filtered
153    /// rule event's `Player` parameter accepts `EventPlayer` members or
154    /// canonical `Hero` members; the WIR [`crate::wir::EventTarget`] carries
155    /// that union explicitly.
156    pub(crate) param_domains: Vec<Option<String>>,
157    /// Default value per parameter position (parallel to `params`),
158    /// resolved when a call omits the argument. See the catalog data
159    /// provenance for the value syntax and source.
160    pub(crate) param_defaults: Vec<Option<String>>,
161    /// Source-backed semantic type per parameter position. `None` means
162    /// the available sources do not establish a narrower type.
163    pub(crate) param_types: Vec<Option<String>>,
164    /// Contextual literal substitutions per parameter position.
165    pub(crate) param_coercions: Vec<Option<ParamCoercions>>,
166    /// Source-backed return type for Value entries. Actions must leave this
167    /// unset; an absent value remains unresolved.
168    pub(crate) return_type: Option<String>,
169    /// Reevaluation coverage for the action's `*Reeval` parameter, keyed by
170    /// enum member id: which parameter positions the selected reevaluation
171    /// member keeps re-evaluating. `None` when the action has no `*Reeval`
172    /// parameter or coverage is not reviewed. Members absent from the map
173    /// are unknown, not empty — an empty vector is a reviewed "reevaluates
174    /// nothing" claim.
175    pub(crate) reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
176    /// Whether the final declared parameter repeats for additional arguments.
177    pub(crate) variadic: bool,
178    pub(crate) aliases: HashMap<Locale, Vec<String>>,
179}
180
181/// A locale-independent identity for a preset used by the Workshop `String`
182/// value. Unlike a custom `Value::String`, this identity must resolve through
183/// reviewed client-locale aliases before it can be parsed or emitted.
184#[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    /// The deterministic emitted spelling in `locale`, when mapped.
203    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
204        spelling(&self.aliases, locale)
205    }
206
207    /// All reviewed spellings accepted for this locale.
208    pub fn spellings(&self, locale: &Locale) -> &[String] {
209        spellings_for(&self.aliases, locale)
210    }
211}
212
213impl CatalogEntry {
214    /// The localized spelling of this builtin in `locale`, when declared.
215    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
216        spelling(&self.aliases, locale)
217    }
218
219    /// Every reviewed localized spelling of this builtin, with the first
220    /// spelling reserved for deterministic emission.
221    pub fn spellings(&self, locale: &Locale) -> &[String] {
222        spellings_for(&self.aliases, locale)
223    }
224
225    /// Resolve a canonical or reviewed localized parameter spelling to its
226    /// unambiguous declared position.
227    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    /// The canonical parameter names in declaration order.
246    pub fn params(&self) -> &[String] {
247        &self.params
248    }
249
250    /// The number of declared arguments for this builtin.
251    pub fn param_count(&self) -> usize {
252        self.params.len()
253    }
254
255    /// The reviewed semantic name for an argument position, when declared.
256    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    /// The number of arguments that must be present when trailing defaults
262    /// are applied. A missing default in the middle of a signature remains a
263    /// required position; defaults only make the suffix optional.
264    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    /// Whether any declared parameter has a default value.
277    pub fn has_param_defaults(&self) -> bool {
278        self.param_defaults.iter().any(Option::is_some)
279    }
280
281    /// The default value for an argument position, when declared.
282    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    /// The declared enum domain for an argument position, when one exists.
287    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    /// The parameter positions the given `*Reeval` enum member keeps
292    /// re-evaluating on this action, when the action declares reviewed
293    /// reevaluation coverage. `Some(&[])` means the member re-evaluates no
294    /// parameter of this action; `None` means the member's coverage is
295    /// unknown for this action and callers must stay conservative.
296    ///
297    /// `member` is the canonical enum member id (e.g. `COLOR`), the same
298    /// spelling a parsed `Value::Enum` carries.
299    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    /// Whether this action declares reviewed per-parameter reevaluation
306    /// coverage for its `*Reeval` parameter.
307    pub fn has_reevaluation_coverage(&self) -> bool {
308        self.reevaluation_coverage.is_some()
309    }
310
311    /// The source-backed semantic type for an argument position, when
312    /// available. Enum domains remain exposed separately by `param_domain`.
313    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    /// The contextual literal substitutions for an argument position.
318    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    /// The source-backed return type of a Value, when available.
323    pub fn return_type(&self) -> Option<&str> {
324        self.return_type.as_deref()
325    }
326
327    /// Whether the final declared parameter repeats for additional arguments.
328    pub fn is_variadic(&self) -> bool {
329        self.variadic
330    }
331
332    /// Whether the surface signature's leading `(player, variable)`
333    /// parameters fold into one `PlayerVariable` WIR argument. The declared
334    /// shape is a `Player|Array` parameter directly followed by a `Variable`
335    /// parameter (`stopChasingPlayerVariable`, `chasePlayerVariableAtRate`,
336    /// `chasePlayerVariableOverTime`, `setPlayerVariableAtIndex`,
337    /// `modifyPlayerVariableAtIndex`).
338    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    /// Whether `args`, a WIR argument list for this entry, actually carries
343    /// the folded pair: the signature is foldable and WIR arg 0 is a
344    /// `PlayerVariable` value. An unparseable or hand-built argument list
345    /// that never folded still positions its arguments one-to-one.
346    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    /// The surface parameter index that `args[wir_index]` supplies for this
361    /// entry's signature. Positions map one-to-one unless `args` carries the
362    /// folded `(player, variable)` pair ([`wir_args_folded`]): the folded
363    /// `PlayerVariable` then sits at the `Variable` position and supplies
364    /// the `Player` position through its inner `player` field, and every
365    /// later WIR argument maps to param `wir_index + 1`.
366    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/// One enum member within a domain.
383#[derive(Debug, Clone)]
384pub struct EnumMember {
385    pub member: String,
386    pub(crate) aliases: HashMap<Locale, Vec<String>>,
387}
388
389impl EnumMember {
390    /// The localized spelling of this member in `locale`, when declared.
391    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
392        spelling(&self.aliases, locale)
393    }
394
395    /// Every reviewed localized spelling of this enum member, with the first
396    /// spelling reserved for deterministic emission.
397    pub fn spellings(&self, locale: &Locale) -> &[String] {
398        spellings_for(&self.aliases, locale)
399    }
400}
401
402/// One enum value domain (e.g. `Color`, `Beam`).
403#[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/// Target-format metadata recorded in the catalog.
417#[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/// Provenance of the catalog data.
426#[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    /// Additional immutable observations that qualify the dataset source,
436    /// including reviewed spelling conflicts retained as parse aliases.
437    #[serde(default, skip_serializing_if = "Vec::is_empty")]
438    pub source_notes: Vec<String>,
439}
440
441/// Per-locale mapping coverage: how many canonical entries (builtins,
442/// localized preset identities, and enum members) carry a mapping for the
443/// locale out of the declared total.
444#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
445#[non_exhaustive]
446pub struct LocaleCoverage {
447    pub locale: Locale,
448    /// Canonical entries with a declared mapping in this locale.
449    pub mapped: usize,
450    /// Canonical entries (builtins and enum members) declared by the catalog.
451    pub total: usize,
452}
453
454/// The machine-readable catalog identity (ADR-0001 Decision 5): the four
455/// identities that evolve independently — implementation version, catalog
456/// dataset version plus content digest, locale coverage, and target evidence
457/// — plus the data provenance record. Serialized with the ADR's kebab-case
458/// identity names.
459#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
460#[serde(rename_all = "kebab-case")]
461#[non_exhaustive]
462pub struct CatalogIdentity {
463    /// The `workshop-rs` package version (semver); bumped by code changes.
464    pub implementation_version: String,
465    /// The catalog dataset version; bumped by any dataset change.
466    pub catalog_version: String,
467    /// The deterministic content digest (sha256 hex) computed by the
468    /// pipeline; `None` when the data does not declare one.
469    pub catalog_digest: Option<String>,
470    /// Declared locales with per-locale mapping counts.
471    pub locale_coverage: Vec<LocaleCoverage>,
472    /// The declared target surface.
473    pub target: TargetMeta,
474    /// The provenance record of the catalog data.
475    pub provenance: Provenance,
476}
477
478pub(crate) type MemberIndexMap = HashMap<String, HashMap<String, (usize, usize)>>;
479
480/// domain -> normalized member spelling -> member index, for
481/// `NormalizedIndex::enum_alias_to_member`.
482type EnumMemberAliasMap = HashMap<String, HashMap<String, Option<(usize, usize)>>>;
483
484/// Whitespace-collapsed secondary spelling indexes (#448).
485///
486/// Reviewed export spellings carry whitespace the lexer cannot reproduce
487/// inside a token stream — trailing spaces (`ヒーロー: `,
488/// `Élévation à une puissance `), leading spaces, and spacing variants the
489/// client itself normalizes (`66号公路`/`66 号公路`, `1팀`/`1 팀`). Every
490/// verbatim index is mirrored here keyed by [`normalize_spelling`]; the
491/// verbatim index is always consulted first, and a normalized key shared
492/// by distinct targets (`None` after a collision) resolves nothing rather
493/// than guessing.
494#[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
503/// The whitespace-collapsed comparison form of a catalog spelling: all
504/// Unicode whitespace removed, characters otherwise unchanged (#448).
505pub(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
513/// The unambiguous target of `spelling` in a normalized index: a key shared
514/// by distinct targets is stored as `None` and resolves nothing.
515fn 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/// The validated canonical Workshop catalog.
523#[derive(Debug, Clone)]
524pub struct Catalog {
525    detection_index: Option<detect::AliasIndex>,
526    pub(crate) schema_version: u32,
527    /// The declared locales, normalized; the first one is the primary
528    /// locale and must be fully covered.
529    pub(crate) locales: Vec<Locale>,
530    pub(crate) target: TargetMeta,
531    pub(crate) provenance: Provenance,
532    /// The catalog dataset version (ADR-0001 `catalog-version`).
533    pub(crate) catalog_version: String,
534    /// The declared content digest (sha256 hex), verified at load when
535    /// present (ADR-0001 `catalog-version`).
536    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    /// Whitespace-collapsed mirrors of the verbatim indexes; see
549    /// [`NormalizedIndex`].
550    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    /// The declared locales, normalized; the first one is the primary locale.
563    pub fn locales(&self) -> &[Locale] {
564        &self.locales
565    }
566
567    /// The primary locale: the first declared one, whose mapping surface is
568    /// complete (`en-US` in the committed catalog).
569    pub fn primary_locale(&self) -> &Locale {
570        &self.locales[0]
571    }
572
573    /// Whether a locale is declared by the catalog.
574    pub fn supports(&self, locale: &Locale) -> bool {
575        self.locales.contains(locale)
576    }
577
578    /// The catalog dataset version (ADR-0001 `catalog-version`).
579    pub fn catalog_version(&self) -> &str {
580        &self.catalog_version
581    }
582
583    /// The declared content digest (sha256 hex) of the catalog dataset,
584    /// verified at load; `None` for data that declares none.
585    pub fn catalog_digest(&self) -> Option<&str> {
586        self.catalog_digest.as_deref()
587    }
588
589    /// The `workshop-rs` package version (ADR-0001 `implementation-version`).
590    pub fn implementation_version() -> &'static str {
591        env!("CARGO_PKG_VERSION")
592    }
593
594    /// The machine-readable catalog identity: implementation version, catalog
595    /// version + digest, locale coverage, target evidence, and provenance.
596    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    /// The mapping coverage of one declared locale: mapped entries out of the
612    /// declared total (builtins, localized preset identities, and enum members).
613    /// The primary locale is
614    /// always complete; other locales may be partially covered.
615    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    /// The mapping coverage of every declared locale, in declaration order.
642    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    /// The builtin with the given canonical id and kind.
650    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    /// Resolve a localized spelling to its canonical builtin. The verbatim
657    /// index answers first; on a miss the whitespace-collapsed index
658    /// answers a spelling whose declared form carries unwritable
659    /// whitespace (`ヒーロー: `, `Élévation à une puissance `, #448).
660    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    /// The localized spelling of a canonical builtin id.
672    pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
673        self.entry(kind, id)?.spelling(locale)
674    }
675
676    /// Every entry of a kind, in catalog order.
677    pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
678        self.entries.iter().filter(move |entry| entry.kind == kind)
679    }
680
681    /// Resolve a localized preset spelling to its stable identity.
682    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    /// Resolve the emitted spelling of a localized preset identity.
698    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    /// Every reviewed localized preset identity, in catalog order.
706    pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
707        self.localized_strings.iter()
708    }
709
710    /// The total number of builtin entries.
711    pub fn entry_count(&self) -> usize {
712        self.entries.len()
713    }
714
715    /// The number of enum domains.
716    pub fn enum_domains_count(&self) -> usize {
717        self.enums.len()
718    }
719
720    /// The enum domain with the given name.
721    pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
722        self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
723    }
724
725    /// Resolve a localized enum-domain spelling to its canonical domain id.
726    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    /// Every enum domain, in catalog order.
743    pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
744        self.enums.iter()
745    }
746
747    /// Resolve a localized enum member spelling to `(domain, canonical member)`.
748    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    /// The localized spelling of a canonical enum member.
778    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    /// Resolve a canonical enum member through the locale boundary, including
789    /// reviewed partial locale spellings that are not yet part of the full
790    /// catalog locale set.
791    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    /// Every `(domain, canonical member)` match for a bare (domain-less)
817    /// localized member spelling. Returns all matches so callers can report
818    /// ambiguity; a well-formed catalog has at most one meaningful match for
819    /// a given spelling. The verbatim index answers first; a spelling that
820    /// differs only in whitespace falls back to the collapsed index (#448).
821    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    /// The canonical ids and `locale` spellings of `kind` entries, for
838    /// nearest-candidate diagnostics that reject in the canonical space
839    /// (validation and emission, where the rejected name is an id or an
840    /// authored spelling).
841    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    /// The emitted form of `member_spelling` in `domain` under `locale`:
853    /// constructor-form domains (`Hero`, `Button`, `Color`, `Map`) write
854    /// `Domain(Member)` in every position and locale; every other domain
855    /// writes the member bare (docs/wrapper-forms.md).
856    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    /// The canonical ids and `locale` spellings of `domain` members, for
874    /// nearest-candidate diagnostics that reject in the canonical space.
875    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
891/// The catalog is the canonical source of expected enum domains for the
892/// Workshop surface it documents: `expected_domain(catalog_id, arg_index)`
893/// answers the domain declared for that parameter position (e.g. `createHudText`
894/// argument 9 is `HudReeval`), so the Workshop parser can resolve bare enum
895/// members that are ambiguous across domains (e.g. `Visible To and String`).
896/// Positions without a documented domain answer `None`.
897impl 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}