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 descriptions;
23pub mod detect;
24
25use std::borrow::Cow;
26use std::collections::{BTreeMap, HashMap};
27
28use serde::{Deserialize, Deserializer, Serialize};
29
30use crate::core::signatures::ExpectedDomain;
31
32pub const CATALOG_DATA: &str = include_str!("data/catalog.json");
33
34/// 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    Action = 1,
72    Value = 2,
73    Event = 3,
74    /// An operator token (comparison operators).
75    Operator = 4,
76    /// An enumerated value domain.
77    Enum = 5,
78    /// A settings entry.
79    Setting = 6,
80}
81
82impl Kind {
83    pub const NUM_KINDS: usize = 7;
84
85    pub const fn as_index(self) -> usize {
86        self as usize
87    }
88
89    pub fn as_str(self) -> &'static str {
90        match self {
91            Kind::Structural => "structural",
92            Kind::Action => "action",
93            Kind::Value => "value",
94            Kind::Event => "event",
95            Kind::Operator => "operator",
96            Kind::Enum => "enum",
97            Kind::Setting => "setting",
98        }
99    }
100}
101
102/// Literal substitutions accepted at one parameter position. The authored
103/// literal is kept in WIR; these facts only decide acceptance.
104///
105/// These are deliberately per-parameter facts. They do not establish a
106/// global relationship between Workshop booleans, numbers, arrays, strings,
107/// vectors, or null.
108#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
109#[serde(rename_all = "camelCase")]
110#[non_exhaustive]
111pub struct ParamCoercions {
112    /// Accept `False` as numeric zero.
113    #[serde(default)]
114    pub false_as_number: bool,
115    /// Accept `True` as numeric one.
116    #[serde(default)]
117    pub true_as_number: bool,
118    /// Accept numeric zero as `Null`.
119    #[serde(default)]
120    pub zero_as_null: bool,
121    /// Accept `Vector(0, 0, 0)` as `Null`.
122    #[serde(default)]
123    pub null_vector_as_null: bool,
124    /// Accept `Empty Array` as an empty string.
125    #[serde(default)]
126    pub empty_array_as_string: bool,
127}
128
129/// `rule-content-v1` facts for one parameter position, declared when the
130/// surface parameter list and the structured document's argument list
131/// differ. Absent elements and entries without the array map their
132/// parameters one-to-one to plain content arguments.
133///
134/// These are deliberately per-parameter facts describing the emitted
135/// document's layout; they do not change the surface signature, which
136/// follows the Workshop call syntax.
137#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
138#[serde(rename_all = "camelCase")]
139#[non_exhaustive]
140pub struct ParamContent {
141    /// The folded variable-target call this parameter contributes to, when
142    /// the call's variable target folds surface parameters into one
143    /// `globalVariable`/`playerVariable` content argument.
144    #[serde(default)]
145    pub fold: Option<ParamContentFold>,
146    /// The modify-operator call ids accepted at this position (`add`,
147    /// `removeFromArrayByIndex`, …). In `rule-content-v1` the operand is a
148    /// zero-argument call node named by one of these ids — not an enum
149    /// member, so the position declares no enum domain.
150    #[serde(default)]
151    pub operations: Vec<String>,
152}
153
154/// Membership in a folded variable-target call: this surface parameter
155/// supplies one argument of the `globalVariable`/`playerVariable` call the
156/// `rule-content-v1` target renders as. All members of a fold group share
157/// the same `call` and are contiguous surface parameters; the folded call
158/// occupies the content-argument position of the group's first member.
159///
160/// The member's node form follows its declared parameter type: a
161/// `Variable`-typed parameter supplies the tagged `{"variable": "<name>"}`
162/// node bound to the declared variable name, and any other parameter
163/// supplies a normal value node.
164#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
165#[serde(rename_all = "camelCase")]
166#[non_exhaustive]
167pub struct ParamContentFold {
168    /// The reserved target call id (`globalVariable` or `playerVariable`).
169    pub call: String,
170    /// The argument index inside the folded call this parameter supplies.
171    pub arg: usize,
172}
173
174/// One catalog builtin.
175#[derive(Debug, Clone)]
176pub struct CatalogEntry {
177    pub id: String,
178    pub kind: Kind,
179    pub(crate) callable: bool,
180    pub(crate) params: Vec<String>,
181    pub(crate) param_names: Option<Vec<String>>,
182    pub(crate) param_aliases: Vec<HashMap<Locale, Vec<String>>>,
183    pub(crate) param_display_names: Vec<Option<HashMap<Locale, String>>>,
184    pub(crate) param_domains: Vec<Option<String>>,
185    pub(crate) param_defaults: Vec<Option<String>>,
186    pub(crate) param_types: Vec<Option<String>>,
187    pub(crate) param_coercions: Vec<Option<ParamCoercions>>,
188    pub(crate) param_content: Vec<Option<ParamContent>>,
189    pub(crate) return_type: Option<String>,
190    pub(crate) reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
191    pub(crate) variadic: bool,
192    pub(crate) aliases: HashMap<Locale, Vec<String>>,
193}
194
195/// A locale-independent identity for a preset used by the Workshop `String`
196/// value. Unlike a custom `Value::String`, this identity must resolve through
197/// reviewed client-locale aliases before it can be parsed or emitted.
198#[derive(Debug, Clone)]
199pub struct LocalizedStringEntry {
200    pub id: String,
201    pub(crate) aliases: HashMap<Locale, Vec<String>>,
202}
203
204fn spelling<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> Option<&'a str> {
205    aliases
206        .get(locale)
207        .and_then(|spellings| spellings.first())
208        .map(String::as_str)
209}
210
211fn spellings_for<'a>(aliases: &'a HashMap<Locale, Vec<String>>, locale: &Locale) -> &'a [String] {
212    aliases.get(locale).map(Vec::as_slice).unwrap_or_default()
213}
214
215impl LocalizedStringEntry {
216    /// The deterministic emitted spelling in `locale`, when mapped.
217    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
218        spelling(&self.aliases, locale)
219    }
220
221    /// All reviewed spellings accepted for this locale.
222    pub fn spellings(&self, locale: &Locale) -> &[String] {
223        spellings_for(&self.aliases, locale)
224    }
225}
226
227impl CatalogEntry {
228    /// The localized spelling of this builtin in `locale`, when declared.
229    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
230        spelling(&self.aliases, locale)
231    }
232
233    /// Every reviewed localized spelling of this builtin, with the first
234    /// spelling reserved for deterministic emission.
235    pub fn spellings(&self, locale: &Locale) -> &[String] {
236        spellings_for(&self.aliases, locale)
237    }
238
239    /// Resolve a canonical or reviewed localized parameter spelling to its
240    /// unambiguous declared position.
241    pub fn resolve_param(&self, locale: &Locale, spelling: &str) -> Option<usize> {
242        let mut matches = self
243            .params
244            .iter()
245            .enumerate()
246            .filter(|(index, canonical)| {
247                canonical == &spelling
248                    || self
249                        .param_aliases
250                        .get(*index)
251                        .and_then(|aliases| aliases.get(locale))
252                        .is_some_and(|aliases| aliases.iter().any(|alias| alias == spelling))
253            })
254            .map(|(index, _)| index);
255        let first = matches.next()?;
256        matches.next().is_none().then_some(first)
257    }
258
259    /// The canonical parameter names in declaration order.
260    pub fn params(&self) -> &[String] {
261        &self.params
262    }
263
264    /// The number of declared arguments for this builtin.
265    pub fn param_count(&self) -> usize {
266        self.params.len()
267    }
268
269    /// The reviewed semantic name for an argument position, when declared.
270    pub fn param_name(&self, index: usize) -> Option<&str> {
271        let names = self.param_names.as_deref().unwrap_or(&self.params);
272        param_at(names, index, self.variadic).map(String::as_str)
273    }
274
275    /// The localized parameter display name the Workshop editor shows for
276    /// the argument at `index` in `locale`, when the catalog records one.
277    /// This is a reviewed editor-presentation fact only — it does not
278    /// affect parsing or emission — and it stays separate from
279    /// [`param_name`], `paramAliases`, and `paramDefaults`.
280    ///
281    /// [`param_name`]: Self::param_name
282    pub fn param_display_name(&self, locale: &Locale, index: usize) -> Option<&str> {
283        param_at(&self.param_display_names, index, self.variadic)
284            .and_then(Option::as_ref)
285            .and_then(|names| names.get(locale))
286            .map(String::as_str)
287    }
288
289    /// The number of arguments that must be present when trailing defaults
290    /// are applied. A missing default in the middle of a signature remains a
291    /// required position; defaults only make the suffix optional.
292    pub fn required_param_count(&self) -> usize {
293        (0..self.params.len())
294            .rev()
295            .find(|index| {
296                self.param_defaults
297                    .get(*index)
298                    .and_then(Option::as_ref)
299                    .is_none()
300            })
301            .map_or(0, |index| index + 1)
302    }
303
304    /// Whether any declared parameter has a default value.
305    pub fn has_param_defaults(&self) -> bool {
306        self.param_defaults.iter().any(Option::is_some)
307    }
308
309    /// The default value for an argument position, when declared.
310    pub fn param_default(&self, index: usize) -> Option<&str> {
311        param_at(&self.param_defaults, index, self.variadic).and_then(Option::as_deref)
312    }
313
314    /// The declared enum domain for an argument position, when one exists.
315    pub fn param_domain(&self, index: usize) -> Option<&str> {
316        param_at(&self.param_domains, index, self.variadic).and_then(Option::as_deref)
317    }
318
319    /// The parameter positions the given `*Reeval` enum member keeps
320    /// re-evaluating on this action, when the action declares reviewed
321    /// reevaluation coverage. `Some(&[])` means the member re-evaluates no
322    /// parameter of this action; `None` means the member's coverage is
323    /// unknown for this action and callers must stay conservative.
324    ///
325    /// `member` is the canonical enum member id (e.g. `COLOR`), the same
326    /// spelling a parsed `Value::Enum` carries.
327    pub fn reevaluation_coverage(&self, member: &str) -> Option<&[usize]> {
328        self.reevaluation_coverage
329            .as_ref()
330            .and_then(|coverage| coverage.get(member).map(Vec::as_slice))
331    }
332
333    /// Whether this action declares reviewed per-parameter reevaluation
334    /// coverage for its `*Reeval` parameter.
335    pub fn has_reevaluation_coverage(&self) -> bool {
336        self.reevaluation_coverage.is_some()
337    }
338
339    /// The source-backed semantic type for an argument position, when
340    /// available. Enum domains remain exposed separately by `param_domain`.
341    pub fn param_type(&self, index: usize) -> Option<&str> {
342        param_at(&self.param_types, index, self.variadic).and_then(Option::as_deref)
343    }
344
345    /// The contextual literal substitutions for an argument position.
346    pub fn param_coercions(&self, index: usize) -> Option<&ParamCoercions> {
347        param_at(&self.param_coercions, index, self.variadic).and_then(Option::as_ref)
348    }
349
350    /// The `rule-content-v1` facts for an argument position: how the
351    /// parameter appears in the structured document when the surface
352    /// signature and the content layout differ.
353    pub fn param_content(&self, index: usize) -> Option<&ParamContent> {
354        param_at(&self.param_content, index, self.variadic).and_then(Option::as_ref)
355    }
356
357    /// The source-backed return type of a Value, when available.
358    pub fn return_type(&self) -> Option<&str> {
359        self.return_type.as_deref()
360    }
361
362    /// Whether the final declared parameter repeats for additional arguments.
363    pub fn is_variadic(&self) -> bool {
364        self.variadic
365    }
366
367    pub(crate) fn folds_player_variable_pair(&self) -> bool {
368        self.param_type(0) == Some("Player|Array") && self.param_type(1) == Some("Variable")
369    }
370
371    pub(crate) fn wir_args_folded(
372        &self,
373        program: &crate::wir::Program,
374        args: &[crate::wir::ValueId],
375    ) -> bool {
376        self.folds_player_variable_pair()
377            && matches!(
378                args.first()
379                    .and_then(|arg| program.values.get(*arg))
380                    .map(|node| &node.value),
381                Some(crate::wir::Value::PlayerVariable { .. })
382            )
383    }
384
385    pub(crate) fn wir_arg_param_index(
386        &self,
387        program: &crate::wir::Program,
388        args: &[crate::wir::ValueId],
389        wir_index: usize,
390    ) -> usize {
391        wir_index + usize::from(self.wir_args_folded(program, args))
392    }
393}
394
395fn param_at<T>(values: &[T], index: usize, variadic: bool) -> Option<&T> {
396    values
397        .get(index)
398        .or_else(|| variadic.then(|| values.last()).flatten())
399}
400
401/// One enum member within a domain.
402#[derive(Debug, Clone)]
403pub struct EnumMember {
404    pub member: String,
405    pub(crate) aliases: HashMap<Locale, Vec<String>>,
406}
407
408impl EnumMember {
409    /// The localized spelling of this member in `locale`, when declared.
410    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
411        spelling(&self.aliases, locale)
412    }
413
414    /// Every reviewed localized spelling of this enum member, with the first
415    /// spelling reserved for deterministic emission.
416    pub fn spellings(&self, locale: &Locale) -> &[String] {
417        spellings_for(&self.aliases, locale)
418    }
419}
420
421/// One enum value domain (e.g. `Color`, `Beam`).
422#[derive(Debug, Clone)]
423pub struct EnumDomain {
424    pub domain: String,
425    pub(crate) aliases: HashMap<Locale, Vec<String>>,
426    pub members: Vec<EnumMember>,
427}
428
429impl EnumDomain {
430    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
431        spelling(&self.aliases, locale)
432    }
433}
434
435/// Target-format metadata recorded in the catalog.
436#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
437#[non_exhaustive]
438pub struct TargetMeta {
439    pub game: String,
440    pub format: String,
441    pub surface: String,
442}
443
444/// Provenance of the catalog data.
445#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
446#[serde(rename_all = "camelCase")]
447#[non_exhaustive]
448pub struct Provenance {
449    pub generator: String,
450    pub generator_version: String,
451    pub source: String,
452    pub license: String,
453    pub reviewed: bool,
454    /// Additional immutable observations that qualify the dataset source,
455    /// including reviewed spelling conflicts retained as parse aliases.
456    #[serde(default, skip_serializing_if = "Vec::is_empty")]
457    pub source_notes: Vec<String>,
458}
459
460/// Per-locale mapping coverage: how many canonical entries (builtins,
461/// localized preset identities, and enum members) carry a mapping for the
462/// locale out of the declared total.
463#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
464#[non_exhaustive]
465pub struct LocaleCoverage {
466    pub locale: Locale,
467    /// Canonical entries with a declared mapping in this locale.
468    pub mapped: usize,
469    /// Canonical entries (builtins and enum members) declared by the catalog.
470    pub total: usize,
471}
472
473/// The machine-readable catalog identity (ADR-0001 Decision 5): the four
474/// identities that evolve independently — implementation version, catalog
475/// dataset version plus content digest, locale coverage, and target evidence
476/// — plus the data provenance record. Serialized with the ADR's kebab-case
477/// identity names.
478#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
479#[serde(rename_all = "kebab-case")]
480#[non_exhaustive]
481pub struct CatalogIdentity {
482    /// The `workshop-rs` package version (semver); bumped by code changes.
483    pub implementation_version: String,
484    /// The catalog dataset version; bumped by any dataset change.
485    pub catalog_version: String,
486    /// The deterministic content digest (sha256 hex) computed by the
487    /// pipeline; `None` when the data does not declare one.
488    pub catalog_digest: Option<String>,
489    /// Declared locales with per-locale mapping counts.
490    pub locale_coverage: Vec<LocaleCoverage>,
491    /// The declared target surface.
492    pub target: TargetMeta,
493    /// The provenance record of the catalog data.
494    pub provenance: Provenance,
495}
496
497pub(crate) type MemberIndexMap = HashMap<String, HashMap<String, (usize, usize)>>;
498
499type EnumMemberAliasMap = HashMap<String, HashMap<String, Option<(usize, usize)>>>;
500
501#[derive(Debug, Clone, Default)]
502pub(crate) struct NormalizedIndex {
503    pub(crate) alias_to_entry: HashMap<Locale, [HashMap<String, Option<usize>>; Kind::NUM_KINDS]>,
504    pub(crate) localized_string_alias: HashMap<Locale, HashMap<String, Option<usize>>>,
505    pub(crate) enum_alias_to_domain: HashMap<Locale, HashMap<String, Option<String>>>,
506    pub(crate) enum_alias_to_member: HashMap<Locale, EnumMemberAliasMap>,
507    pub(crate) bare_member: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
508}
509
510pub(crate) fn normalize_spelling(spelling: &str) -> Cow<'_, str> {
511    if spelling.chars().any(char::is_whitespace) {
512        Cow::Owned(spelling.chars().filter(|c| !c.is_whitespace()).collect())
513    } else {
514        Cow::Borrowed(spelling)
515    }
516}
517
518fn lookup_normalized<'a, V>(
519    index: &'a HashMap<String, Option<V>>,
520    spelling: &str,
521) -> Option<&'a V> {
522    index.get(normalize_spelling(spelling).as_ref())?.as_ref()
523}
524
525/// The validated canonical Workshop catalog.
526#[derive(Debug, Clone)]
527pub struct Catalog {
528    detection_index: Option<detect::AliasIndex>,
529    pub(crate) schema_version: u32,
530    pub(crate) locales: Vec<Locale>,
531    pub(crate) target: TargetMeta,
532    pub(crate) provenance: Provenance,
533    pub(crate) catalog_version: String,
534    pub(crate) catalog_digest: Option<String>,
535    pub(crate) entries: Vec<CatalogEntry>,
536    pub(crate) localized_strings: Vec<LocalizedStringEntry>,
537    pub(crate) enums: Vec<EnumDomain>,
538    pub(crate) by_id: [HashMap<String, usize>; Kind::NUM_KINDS],
539    pub(crate) alias_to_entry: HashMap<Locale, [HashMap<String, usize>; Kind::NUM_KINDS]>,
540    pub(crate) localized_string_by_id: HashMap<String, usize>,
541    pub(crate) localized_string_alias: HashMap<Locale, HashMap<String, usize>>,
542    pub(crate) enum_by_domain: HashMap<String, usize>,
543    pub(crate) enum_alias_to_domain: HashMap<Locale, HashMap<String, String>>,
544    pub(crate) enum_alias_to_member: HashMap<Locale, MemberIndexMap>,
545    pub(crate) bare_member_index: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
546    pub(crate) normalized: NormalizedIndex,
547    pub(crate) descriptions: descriptions::Descriptions,
548}
549
550mod load;
551
552pub use load::{build_canonical, canonicalize, content_digest};
553
554impl Catalog {
555    pub(crate) fn schema_version(&self) -> u32 {
556        self.schema_version
557    }
558
559    /// The declared locales, normalized; the first one is the primary locale.
560    pub fn locales(&self) -> &[Locale] {
561        &self.locales
562    }
563
564    /// The primary locale: the first declared one, whose mapping surface is
565    /// complete (`en-US` in the committed catalog).
566    pub fn primary_locale(&self) -> &Locale {
567        &self.locales[0]
568    }
569
570    /// Whether a locale is declared by the catalog.
571    pub fn supports(&self, locale: &Locale) -> bool {
572        self.locales.contains(locale)
573    }
574
575    /// The catalog dataset version (ADR-0001 `catalog-version`).
576    pub fn catalog_version(&self) -> &str {
577        &self.catalog_version
578    }
579
580    /// The declared content digest (sha256 hex) of the catalog dataset,
581    /// verified at load; `None` for data that declares none.
582    pub fn catalog_digest(&self) -> Option<&str> {
583        self.catalog_digest.as_deref()
584    }
585
586    /// The `workshop-rs` package version (ADR-0001 `implementation-version`).
587    pub fn implementation_version() -> &'static str {
588        env!("CARGO_PKG_VERSION")
589    }
590
591    /// The machine-readable catalog identity: implementation version, catalog
592    /// version + digest, locale coverage, target evidence, and provenance.
593    pub fn identity(&self) -> CatalogIdentity {
594        CatalogIdentity {
595            implementation_version: Self::implementation_version().to_string(),
596            catalog_version: self.catalog_version.clone(),
597            catalog_digest: self.catalog_digest.clone(),
598            locale_coverage: self
599                .locales
600                .iter()
601                .map(|locale| self.locale_coverage(locale))
602                .collect(),
603            target: self.target.clone(),
604            provenance: self.provenance.clone(),
605        }
606    }
607
608    /// The mapping coverage of one declared locale: mapped entries out of the
609    /// declared total (builtins, localized preset identities, and enum members).
610    /// The primary locale is
611    /// always complete; other locales may be partially covered.
612    pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
613        let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
614        let total = self.entries.len() + self.localized_strings.len() + member_total;
615        let mapped = self
616            .entries
617            .iter()
618            .filter(|entry| entry.aliases.contains_key(locale))
619            .count()
620            + self
621                .localized_strings
622                .iter()
623                .filter(|entry| entry.aliases.contains_key(locale))
624                .count()
625            + self
626                .enums
627                .iter()
628                .flat_map(|domain| &domain.members)
629                .filter(|member| member.aliases.contains_key(locale))
630                .count();
631        LocaleCoverage {
632            locale: locale.clone(),
633            mapped,
634            total,
635        }
636    }
637
638    /// The mapping coverage of every declared locale, in declaration order.
639    pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
640        self.locales
641            .iter()
642            .map(|locale| self.locale_coverage(locale))
643            .collect()
644    }
645
646    /// The builtin with the given canonical id and kind.
647    pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
648        self.by_id[kind.as_index()]
649            .get(id)
650            .map(|i| &self.entries[*i])
651    }
652
653    /// Resolve a localized spelling to its canonical builtin. The verbatim
654    /// index answers first; on a miss the whitespace-collapsed index
655    /// answers a spelling whose declared form carries unwritable
656    /// whitespace (`ヒーロー: `, `Élévation à une puissance `, #448).
657    pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
658        self.alias_to_entry
659            .get(locale)
660            .and_then(|by_kind| by_kind[kind.as_index()].get(spelling))
661            .map(|i| &self.entries[*i])
662            .or_else(|| {
663                let index = &self.normalized.alias_to_entry.get(locale)?[kind.as_index()];
664                lookup_normalized(index, spelling).map(|i| &self.entries[*i])
665            })
666    }
667
668    /// The localized spelling of a canonical builtin id.
669    pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
670        self.entry(kind, id)?.spelling(locale)
671    }
672
673    /// Every entry of a kind, in catalog order.
674    pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
675        self.entries.iter().filter(move |entry| entry.kind == kind)
676    }
677
678    /// Resolve a localized preset spelling to its stable identity.
679    pub fn resolve_localized_string(
680        &self,
681        locale: &Locale,
682        spelling: &str,
683    ) -> Option<&LocalizedStringEntry> {
684        self.localized_string_alias
685            .get(locale)
686            .and_then(|map| map.get(spelling))
687            .map(|index| &self.localized_strings[*index])
688            .or_else(|| {
689                let index = self.normalized.localized_string_alias.get(locale)?;
690                lookup_normalized(index, spelling).map(|i| &self.localized_strings[*i])
691            })
692    }
693
694    /// Resolve the emitted spelling of a localized preset identity.
695    pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
696        self.localized_string_by_id
697            .get(id)
698            .and_then(|i| self.localized_strings.get(*i))
699            .and_then(|entry| entry.spelling(locale))
700    }
701
702    /// Every reviewed localized preset identity, in catalog order.
703    pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
704        self.localized_strings.iter()
705    }
706
707    /// The total number of builtin entries.
708    pub fn entry_count(&self) -> usize {
709        self.entries.len()
710    }
711
712    pub fn enum_domains_count(&self) -> usize {
713        self.enums.len()
714    }
715
716    /// The enum domain with the given name.
717    pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
718        self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
719    }
720
721    /// Resolve a localized enum-domain spelling to its canonical domain id.
722    pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
723        self.enum_by_domain
724            .get_key_value(spelling)
725            .map(|(domain, _)| domain.as_str())
726            .or_else(|| {
727                self.enum_alias_to_domain
728                    .get(locale)
729                    .and_then(|map| map.get(spelling))
730                    .map(String::as_str)
731            })
732            .or_else(|| {
733                let index = self.normalized.enum_alias_to_domain.get(locale)?;
734                lookup_normalized(index, spelling).map(String::as_str)
735            })
736    }
737
738    /// Every enum domain, in catalog order.
739    pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
740        self.enums.iter()
741    }
742
743    /// Resolve a localized enum member spelling to `(domain, canonical member)`.
744    pub fn resolve_enum_member(
745        &self,
746        domain: &str,
747        locale: &Locale,
748        spelling: &str,
749    ) -> Option<(String, String)> {
750        let indexes = self
751            .enum_alias_to_member
752            .get(locale)?
753            .get(domain)?
754            .get(spelling)
755            .copied()
756            .or_else(|| {
757                let index = self
758                    .normalized
759                    .enum_alias_to_member
760                    .get(locale)?
761                    .get(domain)?;
762                lookup_normalized(index, spelling).copied()
763            })?;
764        let (domain_index, member_index) = indexes;
765        Some((
766            domain.to_string(),
767            self.enums[domain_index].members[member_index]
768                .member
769                .clone(),
770        ))
771    }
772
773    /// The localized spelling of a canonical enum member.
774    pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
775        let domain_index = self.enum_by_domain.get(domain)?;
776        let domain = &self.enums[*domain_index];
777        domain
778            .members
779            .iter()
780            .find(|candidate| candidate.member == member)?
781            .spelling(locale)
782    }
783
784    /// Resolve a canonical enum member through the locale boundary, including
785    /// reviewed partial locale spellings that are not yet part of the full
786    /// catalog locale set.
787    pub fn localized_enum_spelling(
788        &self,
789        domain: &str,
790        locale: &Locale,
791        member: &str,
792    ) -> Option<&str> {
793        self.enum_spelling(domain, locale, member).or_else(|| {
794            (domain == "Color" && member == "WHITE").then_some(match locale.as_str() {
795                "de-de" => "Weiß",
796                "es-es" | "es-mx" => "Blanco",
797                "fr-fr" => "Blanc",
798                "it-it" => "Bianco",
799                "ja-jp" => "白",
800                "ko-kr" => "흰색",
801                "pl-pl" => "Biały",
802                "pt-br" => "Branco",
803                "ru-ru" => "Белый",
804                "th-th" => "สีขาว",
805                "tr-tr" => "Beyaz",
806                "zh-tw" => "白色",
807                _ => return None,
808            })
809        })
810    }
811
812    /// Every `(domain, canonical member)` match for a bare (domain-less)
813    /// localized member spelling. Returns all matches so callers can report
814    /// ambiguity; a well-formed catalog has at most one meaningful match for
815    /// a given spelling. The verbatim index answers first; a spelling that
816    /// differs only in whitespace falls back to the collapsed index (#448).
817    pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
818        if let Some(matches) = self
819            .bare_member_index
820            .get(locale)
821            .and_then(|map| map.get(spelling))
822        {
823            return matches.clone();
824        }
825        self.normalized
826            .bare_member
827            .get(locale)
828            .and_then(|map| map.get(normalize_spelling(spelling).as_ref()))
829            .cloned()
830            .unwrap_or_default()
831    }
832
833    pub(crate) fn canonical_spellings<'a>(
834        &'a self,
835        kind: Kind,
836        locale: &'a Locale,
837    ) -> impl Iterator<Item = &'a str> {
838        self.entries_of(kind).flat_map(move |entry| {
839            std::iter::once(entry.id.as_str())
840                .chain(entry.spellings(locale).iter().map(String::as_str))
841        })
842    }
843
844    pub(crate) fn enum_member_form(
845        &self,
846        domain: &str,
847        member_spelling: &str,
848        locale: &Locale,
849    ) -> String {
850        if matches!(domain, "Hero" | "Button" | "Color" | "Map") {
851            let domain_display = self
852                .enum_domain(domain)
853                .and_then(|entry| entry.spelling(locale))
854                .unwrap_or(domain);
855            format!("{domain_display}({member_spelling})")
856        } else {
857            member_spelling.to_string()
858        }
859    }
860
861    pub(crate) fn canonical_member_spellings<'a>(
862        &'a self,
863        domain: &'a str,
864        locale: &'a Locale,
865    ) -> impl Iterator<Item = &'a str> {
866        self.enum_domain(domain)
867            .into_iter()
868            .flat_map(move |domain| {
869                domain.members.iter().flat_map(move |member| {
870                    std::iter::once(member.member.as_str())
871                        .chain(member.spellings(locale).iter().map(String::as_str))
872                })
873            })
874    }
875}
876
877impl ExpectedDomain for Catalog {
878    fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
879        for kind in [Kind::Action, Kind::Value] {
880            if let Some(entry) = self.entry(kind, catalog_id)
881                && let Some(domain) = entry.param_domain(arg_index)
882            {
883                return Some(domain);
884            }
885        }
886        None
887    }
888}