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::{BTreeMap, 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    /// Reevaluation coverage for the action's `*Reeval` parameter, keyed by
163    /// enum member id: which parameter positions the selected reevaluation
164    /// member keeps re-evaluating. `None` when the action has no `*Reeval`
165    /// parameter or coverage is not reviewed. Members absent from the map
166    /// are unknown, not empty — an empty vector is a reviewed "reevaluates
167    /// nothing" claim.
168    pub(crate) reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
169    /// Whether the final declared parameter repeats for additional arguments.
170    pub(crate) variadic: bool,
171    pub(crate) aliases: HashMap<Locale, Vec<String>>,
172}
173
174/// A locale-independent identity for a preset used by the Workshop `String`
175/// value. Unlike a custom `Value::String`, this identity must resolve through
176/// reviewed client-locale aliases before it can be parsed or emitted.
177#[derive(Debug, Clone)]
178pub struct LocalizedStringEntry {
179    pub id: String,
180    pub(crate) aliases: HashMap<Locale, Vec<String>>,
181}
182
183fn spelling<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> Option<&'a str> {
184    aliases
185        .get(locale)
186        .and_then(|spellings| spellings.first())
187        .map(String::as_str)
188}
189
190fn spellings_for<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> &'a [String] {
191    aliases.get(locale).map(Vec::as_slice).unwrap_or_default()
192}
193
194impl LocalizedStringEntry {
195    /// The deterministic emitted spelling in `locale`, when mapped.
196    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
197        spelling(&self.aliases, locale)
198    }
199
200    /// All reviewed spellings accepted for this locale.
201    pub fn spellings(&self, locale: &Locale) -> &[String] {
202        spellings_for(&self.aliases, locale)
203    }
204}
205
206impl CatalogEntry {
207    /// The localized spelling of this builtin in `locale`, when declared.
208    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
209        spelling(&self.aliases, locale)
210    }
211
212    /// Every reviewed localized spelling of this builtin, with the first
213    /// spelling reserved for deterministic emission.
214    pub fn spellings(&self, locale: &Locale) -> &[String] {
215        spellings_for(&self.aliases, locale)
216    }
217
218    /// Resolve a canonical or reviewed localized parameter spelling to its
219    /// unambiguous declared position.
220    pub fn resolve_param(&self, locale: &Locale, spelling: &str) -> Option<usize> {
221        let matches = self
222            .params
223            .iter()
224            .enumerate()
225            .filter(|(index, canonical)| {
226                canonical == &spelling
227                    || self
228                        .param_aliases
229                        .get(*index)
230                        .and_then(|aliases| aliases.get(locale))
231                        .is_some_and(|aliases| aliases.iter().any(|alias| alias == spelling))
232            })
233            .map(|(index, _)| index)
234            .collect::<Vec<_>>();
235        (matches.len() == 1).then(|| matches[0])
236    }
237
238    /// The canonical parameter names in declaration order.
239    pub fn params(&self) -> &[String] {
240        &self.params
241    }
242
243    /// The number of declared arguments for this builtin.
244    pub fn param_count(&self) -> usize {
245        self.params.len()
246    }
247
248    /// The reviewed semantic name for an argument position, when declared.
249    pub fn param_name(&self, index: usize) -> Option<&str> {
250        let names = self.param_names.as_deref().unwrap_or(&self.params);
251        param_at(names, index, self.variadic).map(String::as_str)
252    }
253
254    /// The number of arguments that must be present when trailing defaults
255    /// are applied. A missing default in the middle of a signature remains a
256    /// required position; defaults only make the suffix optional.
257    pub fn required_param_count(&self) -> usize {
258        (0..self.params.len())
259            .rev()
260            .find(|index| {
261                self.param_defaults
262                    .get(*index)
263                    .and_then(Option::as_ref)
264                    .is_none()
265            })
266            .map_or(0, |index| index + 1)
267    }
268
269    /// Whether any declared parameter has a default value.
270    pub fn has_param_defaults(&self) -> bool {
271        self.param_defaults.iter().any(Option::is_some)
272    }
273
274    /// The default value for an argument position, when declared.
275    pub fn param_default(&self, index: usize) -> Option<&str> {
276        param_at(&self.param_defaults, index, self.variadic).and_then(Option::as_deref)
277    }
278
279    /// The declared enum domain for an argument position, when one exists.
280    pub fn param_domain(&self, index: usize) -> Option<&str> {
281        param_at(&self.param_domains, index, self.variadic).and_then(Option::as_deref)
282    }
283
284    /// The parameter positions the given `*Reeval` enum member keeps
285    /// re-evaluating on this action, when the action declares reviewed
286    /// reevaluation coverage. `Some(&[])` means the member re-evaluates no
287    /// parameter of this action; `None` means the member's coverage is
288    /// unknown for this action and callers must stay conservative.
289    ///
290    /// `member` is the canonical enum member id (e.g. `COLOR`), the same
291    /// spelling a parsed `Value::Enum` carries.
292    pub fn reevaluation_coverage(&self, member: &str) -> Option<&[usize]> {
293        self.reevaluation_coverage
294            .as_ref()
295            .and_then(|coverage| coverage.get(member).map(Vec::as_slice))
296    }
297
298    /// Whether this action declares reviewed per-parameter reevaluation
299    /// coverage for its `*Reeval` parameter.
300    pub fn has_reevaluation_coverage(&self) -> bool {
301        self.reevaluation_coverage.is_some()
302    }
303
304    /// The source-backed semantic type for an argument position, when
305    /// available. Enum domains remain exposed separately by `param_domain`.
306    pub fn param_type(&self, index: usize) -> Option<&str> {
307        param_at(&self.param_types, index, self.variadic).and_then(Option::as_deref)
308    }
309
310    /// The contextual literal substitutions for an argument position.
311    pub fn param_coercions(&self, index: usize) -> Option<&ParamCoercions> {
312        param_at(&self.param_coercions, index, self.variadic).and_then(Option::as_ref)
313    }
314
315    /// The source-backed return type of a Value, when available.
316    pub fn return_type(&self) -> Option<&str> {
317        self.return_type.as_deref()
318    }
319
320    /// Whether the final declared parameter repeats for additional arguments.
321    pub fn is_variadic(&self) -> bool {
322        self.variadic
323    }
324
325    /// Whether the surface signature's leading `(player, variable)`
326    /// parameters fold into one `PlayerVariable` WIR argument. The declared
327    /// shape is a `Player|Array` parameter directly followed by a `Variable`
328    /// parameter (`stopChasingPlayerVariable`, `chasePlayerVariableAtRate`,
329    /// `chasePlayerVariableOverTime`, `setPlayerVariableAtIndex`,
330    /// `modifyPlayerVariableAtIndex`).
331    pub(crate) fn folds_player_variable_pair(&self) -> bool {
332        self.param_type(0) == Some("Player|Array") && self.param_type(1) == Some("Variable")
333    }
334
335    /// Whether `args`, a WIR argument list for this entry, actually carries
336    /// the folded pair: the signature is foldable and WIR arg 0 is a
337    /// `PlayerVariable` value. An unparseable or hand-built argument list
338    /// that never folded still positions its arguments one-to-one.
339    pub(crate) fn wir_args_folded(
340        &self,
341        program: &crate::wir::Program,
342        args: &[crate::wir::ValueId],
343    ) -> bool {
344        self.folds_player_variable_pair()
345            && matches!(
346                args.first()
347                    .and_then(|arg| program.values.get(*arg))
348                    .map(|node| &node.value),
349                Some(crate::wir::Value::PlayerVariable { .. })
350            )
351    }
352
353    /// The surface parameter index that `args[wir_index]` supplies for this
354    /// entry's signature. Positions map one-to-one unless `args` carries the
355    /// folded `(player, variable)` pair ([`wir_args_folded`]): the folded
356    /// `PlayerVariable` then sits at the `Variable` position and supplies
357    /// the `Player` position through its inner `player` field, and every
358    /// later WIR argument maps to param `wir_index + 1`.
359    pub(crate) fn wir_arg_param_index(
360        &self,
361        program: &crate::wir::Program,
362        args: &[crate::wir::ValueId],
363        wir_index: usize,
364    ) -> usize {
365        wir_index + usize::from(self.wir_args_folded(program, args))
366    }
367}
368
369fn param_at<T>(values: &[T], index: usize, variadic: bool) -> Option<&T> {
370    values
371        .get(index)
372        .or_else(|| variadic.then(|| values.last()).flatten())
373}
374
375/// One enum member within a domain.
376#[derive(Debug, Clone)]
377pub struct EnumMember {
378    pub member: String,
379    pub(crate) aliases: HashMap<Locale, Vec<String>>,
380}
381
382impl EnumMember {
383    /// The localized spelling of this member in `locale`, when declared.
384    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
385        spelling(&self.aliases, locale)
386    }
387
388    /// Every reviewed localized spelling of this enum member, with the first
389    /// spelling reserved for deterministic emission.
390    pub fn spellings(&self, locale: &Locale) -> &[String] {
391        spellings_for(&self.aliases, locale)
392    }
393}
394
395/// One enum value domain (e.g. `Color`, `Beam`).
396#[derive(Debug, Clone)]
397pub struct EnumDomain {
398    pub domain: String,
399    pub(crate) aliases: HashMap<Locale, Vec<String>>,
400    pub members: Vec<EnumMember>,
401}
402
403impl EnumDomain {
404    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
405        spelling(&self.aliases, locale)
406    }
407}
408
409/// Target-format metadata recorded in the catalog.
410#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
411#[non_exhaustive]
412pub struct TargetMeta {
413    pub game: String,
414    pub format: String,
415    pub surface: String,
416}
417
418/// Provenance of the catalog data.
419#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
420#[serde(rename_all = "camelCase")]
421#[non_exhaustive]
422pub struct Provenance {
423    pub generator: String,
424    pub generator_version: String,
425    pub source: String,
426    pub license: String,
427    pub reviewed: bool,
428    /// Additional immutable observations that qualify the dataset source,
429    /// including reviewed spelling conflicts retained as parse aliases.
430    #[serde(default, skip_serializing_if = "Vec::is_empty")]
431    pub source_notes: Vec<String>,
432}
433
434/// Per-locale mapping coverage: how many canonical entries (builtins,
435/// localized preset identities, and enum members) carry a mapping for the
436/// locale out of the declared total.
437#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
438#[non_exhaustive]
439pub struct LocaleCoverage {
440    pub locale: Locale,
441    /// Canonical entries with a declared mapping in this locale.
442    pub mapped: usize,
443    /// Canonical entries (builtins and enum members) declared by the catalog.
444    pub total: usize,
445}
446
447/// The machine-readable catalog identity (ADR-0001 Decision 5): the four
448/// identities that evolve independently — implementation version, catalog
449/// dataset version plus content digest, locale coverage, and target evidence
450/// — plus the data provenance record. Serialized with the ADR's kebab-case
451/// identity names.
452#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
453#[serde(rename_all = "kebab-case")]
454#[non_exhaustive]
455pub struct CatalogIdentity {
456    /// The `workshop-rs` package version (semver); bumped by code changes.
457    pub implementation_version: String,
458    /// The catalog dataset version; bumped by any dataset change.
459    pub catalog_version: String,
460    /// The deterministic content digest (sha256 hex) computed by the
461    /// pipeline; `None` when the data does not declare one.
462    pub catalog_digest: Option<String>,
463    /// Declared locales with per-locale mapping counts.
464    pub locale_coverage: Vec<LocaleCoverage>,
465    /// The declared target surface.
466    pub target: TargetMeta,
467    /// The provenance record of the catalog data.
468    pub provenance: Provenance,
469}
470
471pub(crate) type MemberIndexMap = HashMap<String, HashMap<String, (usize, usize)>>;
472
473/// domain -> normalized member spelling -> member index, for
474/// `NormalizedIndex::enum_alias_to_member`.
475type EnumMemberAliasMap = HashMap<String, HashMap<String, Option<(usize, usize)>>>;
476
477/// Whitespace-collapsed secondary spelling indexes (#448).
478///
479/// Reviewed export spellings carry whitespace the lexer cannot reproduce
480/// inside a token stream — trailing spaces (`ヒーロー: `,
481/// `Élévation à une puissance `), leading spaces, and spacing variants the
482/// client itself normalizes (`66号公路`/`66 号公路`, `1팀`/`1 팀`). Every
483/// verbatim index is mirrored here keyed by [`normalize_spelling`]; the
484/// verbatim index is always consulted first, and a normalized key shared
485/// by distinct targets (`None` after a collision) resolves nothing rather
486/// than guessing.
487#[derive(Debug, Clone, Default)]
488pub(crate) struct NormalizedIndex {
489    pub(crate) alias_to_entry: HashMap<Locale, [HashMap<String, Option<usize>>; Kind::NUM_KINDS]>,
490    pub(crate) localized_string_alias: HashMap<Locale, HashMap<String, Option<usize>>>,
491    pub(crate) enum_alias_to_domain: HashMap<Locale, HashMap<String, Option<String>>>,
492    pub(crate) enum_alias_to_member: HashMap<Locale, EnumMemberAliasMap>,
493    pub(crate) bare_member: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
494}
495
496/// The whitespace-collapsed comparison form of a catalog spelling: all
497/// Unicode whitespace removed, characters otherwise unchanged (#448).
498pub(crate) fn normalize_spelling(spelling: &str) -> String {
499    spelling.chars().filter(|c| !c.is_whitespace()).collect()
500}
501
502/// The validated canonical Workshop catalog.
503#[derive(Debug, Clone)]
504pub struct Catalog {
505    detection_index: Option<detect::AliasIndex>,
506    pub(crate) schema_version: u32,
507    /// The declared locales, normalized; the first one is the primary
508    /// locale and must be fully covered.
509    pub(crate) locales: Vec<Locale>,
510    pub(crate) target: TargetMeta,
511    pub(crate) provenance: Provenance,
512    /// The catalog dataset version (ADR-0001 `catalog-version`).
513    pub(crate) catalog_version: String,
514    /// The declared content digest (sha256 hex), verified at load when
515    /// present (ADR-0001 `catalog-version`).
516    pub(crate) catalog_digest: Option<String>,
517    pub(crate) entries: Vec<CatalogEntry>,
518    pub(crate) localized_strings: Vec<LocalizedStringEntry>,
519    pub(crate) enums: Vec<EnumDomain>,
520    pub(crate) by_id: [HashMap<String, usize>; Kind::NUM_KINDS],
521    pub(crate) alias_to_entry: HashMap<Locale, [HashMap<String, usize>; Kind::NUM_KINDS]>,
522    pub(crate) localized_string_by_id: HashMap<String, usize>,
523    pub(crate) localized_string_alias: HashMap<Locale, HashMap<String, usize>>,
524    pub(crate) enum_by_domain: HashMap<String, usize>,
525    pub(crate) enum_alias_to_domain: HashMap<Locale, HashMap<String, String>>,
526    pub(crate) enum_alias_to_member: HashMap<Locale, MemberIndexMap>,
527    pub(crate) bare_member_index: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
528    /// Whitespace-collapsed mirrors of the verbatim indexes; see
529    /// [`NormalizedIndex`].
530    pub(crate) normalized: NormalizedIndex,
531}
532
533/// Catalog loading and digest machinery lives in `load.rs`.
534mod load;
535
536pub use load::{build_canonical, canonicalize, content_digest};
537
538impl Catalog {
539    pub(crate) fn schema_version(&self) -> u32 {
540        self.schema_version
541    }
542
543    /// The declared locales, normalized; the first one is the primary locale.
544    pub fn locales(&self) -> &[Locale] {
545        &self.locales
546    }
547
548    /// The primary locale: the first declared one, whose mapping surface is
549    /// complete (`en-US` in the committed catalog).
550    pub fn primary_locale(&self) -> &Locale {
551        &self.locales[0]
552    }
553
554    /// Whether a locale is declared by the catalog.
555    pub fn supports(&self, locale: &Locale) -> bool {
556        self.locales.contains(locale)
557    }
558
559    /// The catalog dataset version (ADR-0001 `catalog-version`).
560    pub fn catalog_version(&self) -> &str {
561        &self.catalog_version
562    }
563
564    /// The declared content digest (sha256 hex) of the catalog dataset,
565    /// verified at load; `None` for data that declares none.
566    pub fn catalog_digest(&self) -> Option<&str> {
567        self.catalog_digest.as_deref()
568    }
569
570    /// The `workshop-rs` package version (ADR-0001 `implementation-version`).
571    pub fn implementation_version() -> &'static str {
572        env!("CARGO_PKG_VERSION")
573    }
574
575    /// The machine-readable catalog identity: implementation version, catalog
576    /// version + digest, locale coverage, target evidence, and provenance.
577    pub fn identity(&self) -> CatalogIdentity {
578        CatalogIdentity {
579            implementation_version: Self::implementation_version().to_string(),
580            catalog_version: self.catalog_version.clone(),
581            catalog_digest: self.catalog_digest.clone(),
582            locale_coverage: self
583                .locales
584                .iter()
585                .map(|locale| self.locale_coverage(locale))
586                .collect(),
587            target: self.target.clone(),
588            provenance: self.provenance.clone(),
589        }
590    }
591
592    /// The mapping coverage of one declared locale: mapped entries out of the
593    /// declared total (builtins, localized preset identities, and enum members).
594    /// The primary locale is
595    /// always complete; other locales may be partially covered.
596    pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
597        let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
598        let total = self.entries.len() + self.localized_strings.len() + member_total;
599        let mapped = self
600            .entries
601            .iter()
602            .filter(|entry| entry.aliases.contains_key(locale))
603            .count()
604            + self
605                .localized_strings
606                .iter()
607                .filter(|entry| entry.aliases.contains_key(locale))
608                .count()
609            + self
610                .enums
611                .iter()
612                .flat_map(|domain| &domain.members)
613                .filter(|member| member.aliases.contains_key(locale))
614                .count();
615        LocaleCoverage {
616            locale: locale.clone(),
617            mapped,
618            total,
619        }
620    }
621
622    /// The mapping coverage of every declared locale, in declaration order.
623    pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
624        self.locales
625            .iter()
626            .map(|locale| self.locale_coverage(locale))
627            .collect()
628    }
629
630    /// The builtin with the given canonical id and kind.
631    pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
632        self.by_id[kind.as_index()]
633            .get(id)
634            .map(|i| &self.entries[*i])
635    }
636
637    /// Resolve a localized spelling to its canonical builtin. The verbatim
638    /// index answers first; on a miss the whitespace-collapsed index
639    /// answers a spelling whose declared form carries unwritable
640    /// whitespace (`ヒーロー: `, `Élévation à une puissance `, #448).
641    pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
642        self.alias_to_entry
643            .get(locale)
644            .and_then(|by_kind| by_kind[kind.as_index()].get(spelling))
645            .map(|i| &self.entries[*i])
646            .or_else(|| {
647                self.normalized.alias_to_entry.get(locale)?[kind.as_index()]
648                    .get(&normalize_spelling(spelling))
649                    .copied()
650                    .flatten()
651                    .map(|i| &self.entries[i])
652            })
653    }
654
655    /// The localized spelling of a canonical builtin id.
656    pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
657        self.entry(kind, id)?.spelling(locale)
658    }
659
660    /// Every entry of a kind, in catalog order.
661    pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
662        self.entries.iter().filter(move |entry| entry.kind == kind)
663    }
664
665    /// Resolve a localized preset spelling to its stable identity.
666    pub fn resolve_localized_string(
667        &self,
668        locale: &Locale,
669        spelling: &str,
670    ) -> Option<&LocalizedStringEntry> {
671        self.localized_string_alias
672            .get(locale)
673            .and_then(|map| map.get(spelling))
674            .map(|index| &self.localized_strings[*index])
675            .or_else(|| {
676                self.normalized
677                    .localized_string_alias
678                    .get(locale)?
679                    .get(&normalize_spelling(spelling))
680                    .copied()
681                    .flatten()
682                    .map(|index| &self.localized_strings[index])
683            })
684    }
685
686    /// Resolve the emitted spelling of a localized preset identity.
687    pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
688        self.localized_string_by_id
689            .get(id)
690            .and_then(|i| self.localized_strings.get(*i))
691            .and_then(|entry| entry.spelling(locale))
692    }
693
694    /// Every reviewed localized preset identity, in catalog order.
695    pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
696        self.localized_strings.iter()
697    }
698
699    /// The total number of builtin entries.
700    pub fn entry_count(&self) -> usize {
701        self.entries.len()
702    }
703
704    /// The number of enum domains.
705    pub fn enum_domains_count(&self) -> usize {
706        self.enums.len()
707    }
708
709    /// The enum domain with the given name.
710    pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
711        self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
712    }
713
714    /// Resolve a localized enum-domain spelling to its canonical domain id.
715    pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
716        self.enum_by_domain
717            .get_key_value(spelling)
718            .map(|(domain, _)| domain.as_str())
719            .or_else(|| {
720                self.enum_alias_to_domain
721                    .get(locale)
722                    .and_then(|map| map.get(spelling))
723                    .map(String::as_str)
724            })
725            .or_else(|| {
726                self.normalized
727                    .enum_alias_to_domain
728                    .get(locale)?
729                    .get(&normalize_spelling(spelling))?
730                    .as_deref()
731            })
732    }
733
734    /// Every enum domain, in catalog order.
735    pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
736        self.enums.iter()
737    }
738
739    /// Resolve a localized enum member spelling to `(domain, canonical member)`.
740    pub fn resolve_enum_member(
741        &self,
742        domain: &str,
743        locale: &Locale,
744        spelling: &str,
745    ) -> Option<(String, String)> {
746        let indexes = self
747            .enum_alias_to_member
748            .get(locale)?
749            .get(domain)?
750            .get(spelling)
751            .copied()
752            .or_else(|| {
753                self.normalized
754                    .enum_alias_to_member
755                    .get(locale)?
756                    .get(domain)?
757                    .get(&normalize_spelling(spelling))
758                    .copied()
759                    .flatten()
760            })?;
761        let (domain_index, member_index) = indexes;
762        Some((
763            domain.to_string(),
764            self.enums[domain_index].members[member_index]
765                .member
766                .clone(),
767        ))
768    }
769
770    /// The localized spelling of a canonical enum member.
771    pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
772        let domain_index = self.enum_by_domain.get(domain)?;
773        let domain = &self.enums[*domain_index];
774        domain
775            .members
776            .iter()
777            .find(|candidate| candidate.member == member)?
778            .spelling(locale)
779    }
780
781    /// Resolve a canonical enum member through the locale boundary, including
782    /// reviewed partial locale spellings that are not yet part of the full
783    /// catalog locale set.
784    pub fn localized_enum_spelling(
785        &self,
786        domain: &str,
787        locale: &Locale,
788        member: &str,
789    ) -> Option<&str> {
790        self.enum_spelling(domain, locale, member).or_else(|| {
791            (domain == "Color" && member == "WHITE").then_some(match locale.as_str() {
792                "de-de" => "Weiß",
793                "es-es" | "es-mx" => "Blanco",
794                "fr-fr" => "Blanc",
795                "it-it" => "Bianco",
796                "ja-jp" => "白",
797                "ko-kr" => "흰색",
798                "pl-pl" => "Biały",
799                "pt-br" => "Branco",
800                "ru-ru" => "Белый",
801                "th-th" => "สีขาว",
802                "tr-tr" => "Beyaz",
803                "zh-tw" => "白色",
804                _ => return None,
805            })
806        })
807    }
808
809    /// Every `(domain, canonical member)` match for a bare (domain-less)
810    /// localized member spelling. Returns all matches so callers can report
811    /// ambiguity; a well-formed catalog has at most one meaningful match for
812    /// a given spelling. The verbatim index answers first; a spelling that
813    /// differs only in whitespace falls back to the collapsed index (#448).
814    pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
815        if let Some(matches) = self
816            .bare_member_index
817            .get(locale)
818            .and_then(|map| map.get(spelling))
819        {
820            return matches.clone();
821        }
822        self.normalized
823            .bare_member
824            .get(locale)
825            .and_then(|map| map.get(&normalize_spelling(spelling)))
826            .cloned()
827            .unwrap_or_default()
828    }
829
830    /// The canonical ids and `locale` spellings of `kind` entries, for
831    /// nearest-candidate diagnostics that reject in the canonical space
832    /// (validation and emission, where the rejected name is an id or an
833    /// authored spelling).
834    pub(crate) fn canonical_spellings<'a>(
835        &'a self,
836        kind: Kind,
837        locale: &'a Locale,
838    ) -> impl Iterator<Item = &'a str> {
839        self.entries_of(kind).flat_map(move |entry| {
840            std::iter::once(entry.id.as_str())
841                .chain(entry.spellings(locale).iter().map(String::as_str))
842        })
843    }
844
845    /// The emitted form of `member_spelling` in `domain` under `locale`:
846    /// constructor-form domains (`Hero`, `Button`, `Color`, `Map`) write
847    /// `Domain(Member)` in every position and locale; every other domain
848    /// writes the member bare (docs/wrapper-forms.md).
849    pub(crate) fn enum_member_form(
850        &self,
851        domain: &str,
852        member_spelling: &str,
853        locale: &Locale,
854    ) -> String {
855        if matches!(domain, "Hero" | "Button" | "Color" | "Map") {
856            let domain_display = self
857                .enum_domain(domain)
858                .and_then(|entry| entry.spelling(locale))
859                .unwrap_or(domain);
860            format!("{domain_display}({member_spelling})")
861        } else {
862            member_spelling.to_string()
863        }
864    }
865
866    /// The canonical ids and `locale` spellings of `domain` members, for
867    /// nearest-candidate diagnostics that reject in the canonical space.
868    pub(crate) fn canonical_member_spellings<'a>(
869        &'a self,
870        domain: &'a str,
871        locale: &'a Locale,
872    ) -> impl Iterator<Item = &'a str> {
873        self.enum_domain(domain)
874            .into_iter()
875            .flat_map(move |domain| {
876                domain.members.iter().flat_map(move |member| {
877                    std::iter::once(member.member.as_str())
878                        .chain(member.spellings(locale).iter().map(String::as_str))
879                })
880            })
881    }
882}
883
884/// The catalog is the canonical source of expected enum domains for the
885/// Workshop surface it documents: `expected_domain(catalog_id, arg_index)`
886/// answers the domain declared for that parameter position (e.g. `createHudText`
887/// argument 9 is `HudReeval`), so the Workshop parser can resolve bare enum
888/// members that are ambiguous across domains (e.g. `Visible To and String`).
889/// Positions without a documented domain answer `None`.
890impl ExpectedDomain for Catalog {
891    fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
892        for kind in [Kind::Action, Kind::Value] {
893            if let Some(entry) = self.entry(kind, catalog_id) {
894                if let Some(domain) = entry.param_domain(arg_index) {
895                    return Some(domain);
896                }
897            }
898        }
899        None
900    }
901}