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