Skip to main content

opy_rs/
lookup.rs

1//! OPY vocabulary lookup: display names and guesses resolve to OPY spellings,
2//! parameter facts, enum members, and settings keys through the same manifest,
3//! catalog, and settings tables `check`/`compile` resolve against.
4//!
5//! This module is the answer surface for agents; it owns no data of its own.
6//! Callable spellings and parameter facts come from the compatibility manifest,
7//! enum members, event entries, and display names come from the `workshop-rs`
8//! catalog, and settings vocabulary comes from the `workshop-rs` emission
9//! table (which it expands to every effective path, including inherited
10//! per-mode keys).
11//! The same scoring helpers feed the candidate lists unknown-name diagnostics
12//! carry, so lookup results and rejection diagnostics cannot drift.
13
14use serde::Serialize;
15use std::sync::OnceLock;
16use workshop_rs::catalog::{Catalog, Kind};
17use workshop_rs::settings::{self, PathPart, SettingValueDomain};
18
19use crate::manifest::{
20    AliasKind, Function, FunctionKind, Manifest, Param, ParamDefault, ReceiverCategory,
21};
22
23/// The default bound on returned hits; also the diagnostic candidate bound.
24pub const DEFAULT_LIMIT: usize = 8;
25
26/// A source-level special form: a call name the lowerer handles by name
27/// rather than through the manifest (`crate::lower::expressions`). Their
28/// parameter facts mirror the reference's declared signatures — the same
29/// `(name, type, required, default)` facts manifest-backed callables report.
30struct SpecialFunction {
31    name: &'static str,
32    params: &'static [SpecialParam],
33}
34
35/// One ordered parameter of a special call, from the reference signature.
36struct SpecialParam {
37    name: &'static str,
38    /// The reference's declared argument type (`IntLiteral`, `Lambda`, …).
39    param_type: &'static str,
40    required: bool,
41    /// The declared default rendered in source syntax, when declared.
42    default: Option<&'static str>,
43}
44
45const fn required(name: &'static str, param_type: &'static str) -> SpecialParam {
46    SpecialParam {
47        name,
48        param_type,
49        required: true,
50        default: None,
51    }
52}
53
54const fn defaulted(
55    name: &'static str,
56    param_type: &'static str,
57    default: &'static str,
58) -> SpecialParam {
59    SpecialParam {
60        name,
61        param_type,
62        required: false,
63        default: Some(default),
64    }
65}
66
67const fn optional(name: &'static str, param_type: &'static str) -> SpecialParam {
68    SpecialParam {
69        name,
70        param_type,
71        required: false,
72        default: None,
73    }
74}
75
76/// The special-call spellings an `unknown-value` diagnostic can suggest and
77/// the lookup can describe, with the parameter facts the reference declares.
78const SPECIAL_FUNCTIONS: &[SpecialFunction] = &[
79    SpecialFunction {
80        name: "sorted",
81        params: &[
82            required("array", "Array"),
83            // The reference's signature names the slot `lambda`; `key` is
84            // the keyword spelling callers write (#437).
85            optional("key", "Lambda"),
86        ],
87    },
88    SpecialFunction {
89        name: "createWorkshopSetting",
90        params: &[
91            required("type", "Type"),
92            required("category", "CustomStringLiteral"),
93            required("name", "CustomStringLiteral"),
94            required("default", "BoolLiteral|IntLiteral|FloatLiteral|HeroLiteral"),
95            defaulted("sortOrder", "IntLiteral", "0"),
96        ],
97    },
98    SpecialFunction {
99        name: "createWorkshopSettingBool",
100        params: &[
101            required("category", "CustomStringLiteral"),
102            required("name", "CustomStringLiteral"),
103            required("default", "BoolLiteral"),
104            defaulted("sortOrder", "IntLiteral", "0"),
105        ],
106    },
107    SpecialFunction {
108        name: "createWorkshopSettingEnum",
109        params: &[
110            required("category", "CustomStringLiteral"),
111            required("name", "CustomStringLiteral"),
112            required("default", "UnsignedIntLiteral"),
113            required("options", "Array<CustomStringLiteral>"),
114            defaulted("sortOrder", "IntLiteral", "0"),
115        ],
116    },
117    SpecialFunction {
118        name: "createWorkshopSettingInt",
119        params: &[
120            required("category", "CustomStringLiteral"),
121            required("name", "CustomStringLiteral"),
122            required("default", "IntLiteral"),
123            required("min", "IntLiteral"),
124            required("max", "IntLiteral"),
125            defaulted("sortOrder", "IntLiteral", "0"),
126        ],
127    },
128    SpecialFunction {
129        name: "createWorkshopSettingFloat",
130        params: &[
131            required("category", "CustomStringLiteral"),
132            required("name", "CustomStringLiteral"),
133            required("default", "FloatLiteral"),
134            required("min", "FloatLiteral"),
135            required("max", "FloatLiteral"),
136            defaulted("sortOrder", "IntLiteral", "0"),
137        ],
138    },
139    SpecialFunction {
140        name: "createWorkshopSettingHero",
141        params: &[
142            required("category", "CustomStringLiteral"),
143            required("name", "CustomStringLiteral"),
144            required("default", "HeroLiteral"),
145            defaulted("sortOrder", "IntLiteral", "0"),
146        ],
147    },
148];
149
150/// A lookup request.
151#[derive(Debug, Clone, PartialEq)]
152pub struct LookupQuery {
153    /// The name or guess to resolve: an OPY spelling, a canonical Workshop
154    /// id, a Workshop display name, or a settings path prefix. Empty text
155    /// applies no text constraint and lists the in-scope entries instead of
156    /// scoring them.
157    pub text: String,
158    /// The namespaces the query searches; `LookupScope::ALL` is the default.
159    /// `within` selects its own namespace when set.
160    pub scope: LookupScope,
161    /// Restricts the answer to the children of one named scope instead of
162    /// searching every candidate in `scope`.
163    pub within: Option<LookupWithin>,
164    /// The display-name locale to read; only `en-US` is supported.
165    /// Localized display names remain `workshop-rs`-owned and are answered
166    /// as `LookupOutcome::Unsupported`, not silently returned in English.
167    pub locale: Option<String>,
168    /// The maximum number of returned hits; `0` selects `DEFAULT_LIMIT`.
169    pub limit: usize,
170}
171
172impl LookupQuery {
173    /// A query across every namespace.
174    pub fn new(text: impl Into<String>) -> LookupQuery {
175        LookupQuery {
176            text: text.into(),
177            scope: LookupScope::ALL,
178            within: None,
179            locale: None,
180            limit: 0,
181        }
182    }
183}
184
185/// The namespaces a query searches.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
187#[serde(rename_all = "camelCase")]
188pub struct LookupScope {
189    /// Callable spellings and parameter facts.
190    pub functions: bool,
191    /// Enum domains and members.
192    pub enums: bool,
193    /// Settings keys and value forms.
194    pub settings: bool,
195    /// Rule events the `@Event` header accepts.
196    pub events: bool,
197}
198
199impl LookupScope {
200    /// Every namespace.
201    pub const ALL: LookupScope = LookupScope {
202        functions: true,
203        enums: true,
204        settings: true,
205        events: true,
206    };
207    /// Callables only.
208    pub const FUNCTIONS: LookupScope = LookupScope {
209        functions: true,
210        enums: false,
211        settings: false,
212        events: false,
213    };
214    /// Enum domains and members only.
215    pub const ENUMS: LookupScope = LookupScope {
216        functions: false,
217        enums: true,
218        settings: false,
219        events: false,
220    };
221    /// Settings only.
222    pub const SETTINGS: LookupScope = LookupScope {
223        functions: false,
224        enums: false,
225        settings: true,
226        events: false,
227    };
228    /// `@Event` spellings only.
229    pub const EVENTS: LookupScope = LookupScope {
230        functions: false,
231        enums: false,
232        settings: false,
233        events: true,
234    };
235
236    fn any(self) -> bool {
237        self.functions || self.enums || self.settings || self.events
238    }
239}
240
241/// A named scope whose children a lookup lists instead of searching every
242/// namespace. `text` still filters or ranks the children; each variant names
243/// the scope in the vocabulary's own terms.
244#[derive(Debug, Clone, PartialEq, Serialize)]
245#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
246pub enum LookupWithin {
247    /// The parameters of the callable with this OPY spelling, in call order.
248    Callable(String),
249    /// The member entries of this OPY enum domain, in domain order.
250    Enum(String),
251    /// The settings keys and intermediate path segments directly below this
252    /// settings path prefix; an empty value lists the root.
253    Settings(String),
254    /// The accepted member spellings of a settings enum domain.
255    SettingEnum(String),
256}
257
258impl LookupWithin {
259    /// The namespace this scope's children belong to.
260    fn scope(&self) -> LookupScope {
261        match self {
262            LookupWithin::Callable(_) => LookupScope::FUNCTIONS,
263            LookupWithin::Enum(_) | LookupWithin::SettingEnum(_) => LookupScope::ENUMS,
264            LookupWithin::Settings(_) => LookupScope::SETTINGS,
265        }
266    }
267}
268
269/// The result of a lookup.
270#[derive(Debug, Clone, PartialEq, Serialize)]
271#[serde(
272    rename_all = "camelCase",
273    rename_all_fields = "camelCase",
274    tag = "kind"
275)]
276pub enum LookupOutcome {
277    /// The query was answered (possibly with no hits when nothing matched).
278    Matched {
279        /// The namespaces searched.
280        scope: LookupScope,
281        /// The maximum number of hits the bound permitted.
282        limit: usize,
283        /// The hits, best matches first.
284        results: Vec<LookupHit>,
285    },
286    /// The `within` selector names no scope the vocabulary knows; the
287    /// request carried no matches because the scope itself does not exist.
288    UnknownWithin {
289        /// The selector that named nothing.
290        within: LookupWithin,
291    },
292    /// The query is valid but the requested capability is not served:
293    /// nothing was searched.
294    Unsupported {
295        /// The namespaces that would have been searched.
296        scope: LookupScope,
297        /// Why the query cannot be answered (e.g. localized display names
298        /// are `workshop-rs`-owned).
299        reason: String,
300    },
301}
302
303impl LookupOutcome {
304    /// The hits of a `Matched` outcome, else an empty slice.
305    pub fn results(&self) -> &[LookupHit] {
306        match self {
307            LookupOutcome::Matched { results, .. } => results,
308            LookupOutcome::UnknownWithin { .. } | LookupOutcome::Unsupported { .. } => &[],
309        }
310    }
311}
312
313/// Which spelling form a hit matched on.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
315#[serde(rename_all = "camelCase")]
316pub enum MatchKind {
317    /// The OPY source spelling (e.g. `hudText`, `Hero.SOLDIER`).
318    OpySpelling,
319    /// The canonical Workshop id (e.g. `createHudText`).
320    CatalogId,
321    /// A Workshop display name (e.g. `Create HUD Text`).
322    DisplayName,
323    /// A declared OPY alias spelling (e.g. `getPlayers`).
324    Alias,
325    /// A settings path (e.g. `gamemodes.ffa.scoreToWin`).
326    Path,
327    /// No form matched exactly; the hit is the closest near-spelling.
328    Near,
329}
330
331impl MatchKind {
332    /// The serialized kind label.
333    pub fn as_str(&self) -> &'static str {
334        match self {
335            MatchKind::OpySpelling => "opySpelling",
336            MatchKind::CatalogId => "catalogId",
337            MatchKind::DisplayName => "displayName",
338            MatchKind::Alias => "alias",
339            MatchKind::Path => "path",
340            MatchKind::Near => "near",
341        }
342    }
343}
344
345/// One lookup hit.
346#[derive(Debug, Clone, PartialEq, Serialize)]
347#[serde(rename_all = "camelCase", rename_all_fields = "camelCase", tag = "hit")]
348pub enum LookupHit {
349    /// A callable.
350    Function {
351        /// The OPY call spelling (member calls carry their member name).
352        spelling: String,
353        /// Whether the callable is an action, a value, or a member call.
354        function_kind: LookupFunctionKind,
355        /// The declared receiver category of a member call — how it is
356        /// invoked (`eventPlayer.setMoveSpeed(…)` takes a `Player`
357        /// receiver). `None` for standalone calls.
358        #[serde(skip_serializing_if = "Option::is_none")]
359        receiver: Option<ReceiverCategory>,
360        /// Whether the argument count is unbounded (`.format` placeholders).
361        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
362        unbounded: bool,
363        /// The parameters in call order: the owner facts Wright renders
364        /// into a signature string.
365        params: Vec<LookupParam>,
366        /// The canonical catalog id the callable emits, when linked.
367        catalog_id: Option<String>,
368        /// The English display name the catalog records, when linked.
369        #[serde(skip_serializing_if = "Option::is_none")]
370        display_name: Option<String>,
371        /// Which spelling form matched.
372        matched_on: MatchKind,
373    },
374    /// A callable enum domain.
375    EnumDomain {
376        /// The OPY domain spelling (`Domain.MEMBER` head).
377        domain: String,
378        /// The members the domain accepts, or `None` when the domain is
379        /// manifest-only/contextual and carries no standalone member list.
380        members: Option<Vec<EnumMemberEntry>>,
381        /// The English display name the catalog records, when linked.
382        #[serde(skip_serializing_if = "Option::is_none")]
383        display_name: Option<String>,
384        /// Which spelling form matched.
385        matched_on: MatchKind,
386    },
387    /// One enum member.
388    EnumMember {
389        /// The OPY spelling (`Hero.SOLDIER`).
390        spelling: String,
391        /// The domain it belongs to.
392        domain: String,
393        /// The canonical catalog member id it lowers to.
394        member: String,
395        /// The English display name, when the catalog provides one.
396        display_name: Option<String>,
397        /// The other source spellings the member accepts.
398        aliases: Vec<String>,
399        /// Which spelling form matched.
400        matched_on: MatchKind,
401    },
402    /// A settings key.
403    Setting {
404        /// The effective `settings` path; per-team/per-hero slots stay
405        /// templated as `heroes.<team>.<hero>.<key>`.
406        path: String,
407        /// The English display name.
408        display_name: Option<String>,
409        /// The accepted value form.
410        value: SettingValueForm,
411        /// Which spelling form matched.
412        matched_on: MatchKind,
413    },
414    /// One parameter of a callable, listed inside a `LookupWithin::Callable`
415    /// scope.
416    Parameter {
417        /// The callable's OPY spelling.
418        callable: String,
419        /// The parameter facts.
420        param: LookupParam,
421        /// Which spelling form matched.
422        matched_on: MatchKind,
423    },
424    /// An intermediate settings path segment, listed inside a
425    /// `LookupWithin::Settings` scope. It names a scope, not a key.
426    SettingPath {
427        /// The settings path this node represents.
428        path: String,
429        /// Which spelling form matched.
430        matched_on: MatchKind,
431    },
432    /// A rule event the `@Event` header accepts. For events the OPY
433    /// spelling is also the canonical catalog id (`playerDied`,
434    /// `global`, `eachPlayer`).
435    Event {
436        /// The `@Event` spelling.
437        spelling: String,
438        /// Whether the event accepts `@Team`/`@Hero`/`@Slot` filters;
439        /// `global` events reject them.
440        accepts_filters: bool,
441        /// The English display name, when the catalog provides one.
442        #[serde(skip_serializing_if = "Option::is_none")]
443        display_name: Option<String>,
444        /// Which spelling form matched.
445        matched_on: MatchKind,
446    },
447}
448
449/// The call position a function hit occupies.
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
451#[serde(rename_all = "camelCase")]
452pub enum LookupFunctionKind {
453    /// A statement-position action.
454    Action,
455    /// A value-position function or special form.
456    Value,
457    /// An action that takes an event-player receiver.
458    MemberAction,
459    /// A value that takes a receiver.
460    MemberValue,
461}
462
463impl LookupFunctionKind {
464    /// The serialized kind label.
465    pub fn as_str(&self) -> &'static str {
466        match self {
467            LookupFunctionKind::Action => "action",
468            LookupFunctionKind::Value => "value",
469            LookupFunctionKind::MemberAction => "memberAction",
470            LookupFunctionKind::MemberValue => "memberValue",
471        }
472    }
473}
474
475/// One ordered parameter of a callable: the owner facts a caller renders.
476#[derive(Debug, Clone, PartialEq, Serialize)]
477#[serde(rename_all = "camelCase")]
478pub struct LookupParam {
479    /// The canonical OPY keyword spelling.
480    pub name: String,
481    /// The alternate keyword spellings the slot accepts.
482    #[serde(skip_serializing_if = "Vec::is_empty")]
483    pub alternate_names: Vec<String>,
484    /// Whether the slot must be supplied.
485    pub required: bool,
486    /// The enum domain the slot takes, when it takes one.
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub domain: Option<String>,
489    /// Every member spelling of that domain, when it is an enum slot —
490    /// the owner supplies the full inventory; which members a signature
491    /// renders is the caller's policy.
492    #[serde(skip_serializing_if = "Option::is_none")]
493    pub members: Option<Vec<EnumMemberEntry>>,
494    /// The semantic type the catalog records for the slot.
495    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
496    pub param_type: Option<String>,
497    /// The default value, rendered in source syntax.
498    #[serde(skip_serializing_if = "Option::is_none")]
499    pub default: Option<String>,
500}
501
502/// One member of an enum domain.
503#[derive(Debug, Clone, PartialEq, Serialize)]
504#[serde(rename_all = "camelCase")]
505pub struct EnumMemberEntry {
506    /// The OPY member spelling (`Hero.SOLDIER` minus its head).
507    pub spelling: String,
508    /// The canonical catalog member id it lowers to.
509    pub id: String,
510    /// The English display name, when the catalog provides one.
511    #[serde(skip_serializing_if = "Option::is_none")]
512    pub display_name: Option<String>,
513    /// The other source spellings the member accepts.
514    #[serde(skip_serializing_if = "Vec::is_empty")]
515    pub aliases: Vec<String>,
516}
517
518/// The value form a settings key accepts.
519#[derive(Debug, Clone, PartialEq, Serialize)]
520#[serde(rename_all = "camelCase")]
521pub struct SettingValueForm {
522    /// The value shape.
523    pub kind: SettingValueKind,
524    /// The settings enum domain an `enum` key accepts members of.
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub domain: Option<String>,
527    /// The member spellings an `enum` slot accepts.
528    #[serde(skip_serializing_if = "Vec::is_empty")]
529    pub members: Vec<String>,
530    /// The numeric bounds the table declares, when it declares them.
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub min: Option<f64>,
533    /// The numeric upper bound, when declared.
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub max: Option<f64>,
536}
537
538/// The value shape a settings key accepts.
539#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
540#[serde(rename_all = "camelCase")]
541pub enum SettingValueKind {
542    /// `key` with no value.
543    Presence,
544    /// `true` or `false`.
545    Boolean,
546    /// A number literal.
547    Number,
548    /// A number interpreted as a percent.
549    Percent,
550    /// A string literal.
551    String,
552    /// One of the `members` spellings.
553    Enum,
554    /// A list of map names.
555    MapList,
556    /// A list of hero names.
557    HeroList,
558}
559
560impl SettingValueKind {
561    /// The serialized kind label.
562    pub fn as_str(&self) -> &'static str {
563        match self {
564            SettingValueKind::Presence => "presence",
565            SettingValueKind::Boolean => "boolean",
566            SettingValueKind::Number => "number",
567            SettingValueKind::Percent => "percent",
568            SettingValueKind::String => "string",
569            SettingValueKind::Enum => "enum",
570            SettingValueKind::MapList => "mapList",
571            SettingValueKind::HeroList => "heroList",
572        }
573    }
574}
575
576/// Resolve a `LookupQuery` against the bundled manifest and catalog.
577///
578/// The answer is `LookupOutcome::Matched` (possibly with zero hits),
579/// `LookupOutcome::UnknownWithin` when the named scope does not exist, or
580/// `LookupOutcome::Unsupported` with a reason; vocabulary lookup never
581/// fails — a name that resolves to nothing is simply absent from `results`.
582/// An empty `text` applies no text constraint and lists the in-scope
583/// entries in the vocabulary's stable order.
584pub fn lookup(query: &LookupQuery) -> LookupOutcome {
585    let limit = if query.limit == 0 {
586        DEFAULT_LIMIT
587    } else {
588        query.limit
589    };
590    let scope = query.scope;
591    let unsupported = |reason: &str| LookupOutcome::Unsupported {
592        scope,
593        reason: reason.to_string(),
594    };
595    if let Some(locale) = &query.locale {
596        if !locale.eq_ignore_ascii_case("en-US") {
597            return unsupported(
598                "localized display names are owned by workshop-rs; opy-rs \
599                 resolves display names in the 'en-US' locale only",
600            );
601        }
602    }
603    let (manifest, catalog) = match (Manifest::builtin(), Catalog::builtin()) {
604        (Ok(manifest), Ok(catalog)) => (manifest, catalog),
605        _ => return unsupported("vocabulary data (manifest or catalog) is not bundled"),
606    };
607    let index = Index::new(manifest, &catalog);
608    if let Some(within) = &query.within {
609        let Some(children) = index.children(within) else {
610            return LookupOutcome::UnknownWithin {
611                within: within.clone(),
612            };
613        };
614        let mut results = index.rank(children.iter(), query);
615        results.truncate(limit);
616        return LookupOutcome::Matched {
617            scope: within.scope(),
618            limit,
619            results,
620        };
621    }
622    if !query.scope.any() {
623        return unsupported("the query scope selects no namespace");
624    }
625    let mut results = index.rank(
626        index
627            .candidates
628            .iter()
629            .filter(|candidate| index.in_scope(candidate, scope)),
630        query,
631    );
632    results.truncate(limit);
633    LookupOutcome::Matched {
634        scope,
635        limit,
636        results,
637    }
638}
639
640/// Lookup with a default query (all namespaces, `DEFAULT_LIMIT`).
641pub fn lookup_str(text: &str) -> LookupOutcome {
642    lookup(&LookupQuery::new(text))
643}
644
645// ---------------------------------------------------------------------------
646// lpp/resolveIds — canonical Workshop id → OPY spelling (issue #515)
647// ---------------------------------------------------------------------------
648
649/// Canonical call ids whose OPY spelling is source syntax rather than a
650/// manifest-declared callable name: every entry inverts an accepted OPY
651/// surface form (`compiler::lowering` emits the canonical id from this
652/// spelling). Several spellings may lower to one canonical id; the table
653/// records the dedicated form (`x += v` sugar aside, `appendToArray` is
654/// spelled `append`, the `chase*` player calls `chaseAtRate`/
655/// `chaseOverTime`).
656///
657/// Canonical ids absent here and unclaimed by a manifest `catalogId` have
658/// no dedicated OPY spelling and stay unresolved: assignments and member
659/// writes (`setGlobalVariable`, `assignMember`), declared names
660/// (`globalVariable`, `playerVariable`, `callSubroutine`, `memberAccess`),
661/// the dedent marker (`end`), literal, index, member-access, and ternary
662/// forms (`array`, `emptyArray`, `valueInArray`, `firstOf`, `ifThenElse`),
663/// lambda binders (`currentArrayElement`, `currentArrayIndex`), compound
664/// conditionals (`abortIf`, `loopIf`, `skipIf`, `loopIfConditionIsTrue`,
665/// `__abortIfConditionIsTrue__`), and spellings an argument selects
666/// (`roundToInteger`).
667const SYNTAX_SPELLINGS: &[(&str, &str)] = &[
668    // Operators the lowerer emits for OPY operator syntax.
669    ("add", "+"),
670    ("subtract", "-"),
671    ("multiply", "*"),
672    ("divide", "/"),
673    ("modulo", "%"),
674    ("raiseToPower", "**"),
675    ("==", "=="),
676    ("!=", "!="),
677    ("<", "<"),
678    ("<=", "<="),
679    (">", ">"),
680    (">=", ">="),
681    ("and", "and"),
682    ("or", "or"),
683    ("not", "not"),
684    ("-", "-"),
685    ("arrayContains", "in"),
686    // Member calls and special forms the manifest marks `special-lowering`
687    // (it claims no `catalogId`, but each canonical id still has exactly
688    // one dedicated OPY spelling).
689    ("appendToArray", "append"),
690    ("removeFromArray", "exclude"),
691    ("removeFromArrayByIndex", "del"),
692    ("removeFromArrayByValue", "remove"),
693    ("mappedArray", "map"),
694    ("sortedArray", "sorted"),
695    ("isTrueForAll", "all"),
696    ("isTrueForAny", "any"),
697    ("__xComponentOf__", "x"),
698    ("__yComponentOf__", "y"),
699    ("__zComponentOf__", "z"),
700    // Statements and reserved context values.
701    ("abort", "return"),
702    ("skip", "goto"),
703    ("break", "break"),
704    ("continue", "continue"),
705    ("if", "if"),
706    ("elseIf", "elif"),
707    ("else", "else"),
708    ("while", "while"),
709    ("forGlobalVariable", "for"),
710    ("forPlayerVariable", "for"),
711    ("__forPlayerVariable__", "for"),
712    ("eventPlayer", "eventPlayer"),
713    ("attacker", "attacker"),
714    ("victim", "victim"),
715    ("localPlayer", "localPlayer"),
716    ("hostPlayer", "hostPlayer"),
717    ("true", "true"),
718    ("false", "false"),
719    ("null", "null"),
720    // Canonical direction constants the language spells as `Vector` enum
721    // members rather than bare calls.
722    ("up", "Vector.UP"),
723    ("down", "Vector.DOWN"),
724    ("left", "Vector.LEFT"),
725    ("right", "Vector.RIGHT"),
726    ("forward", "Vector.FORWARD"),
727    ("backward", "Vector.BACKWARD"),
728    // The variable-kind target the manifest cannot claim for one spelling.
729    ("chasePlayerVariableAtRate", "chaseAtRate"),
730    ("chasePlayerVariableOverTime", "chaseOverTime"),
731    ("stopChasingPlayerVariable", "stopChasingVariable"),
732];
733
734/// The bundled catalog, loaded once — `Catalog::builtin` reparses the
735/// data on every call, which a `resolveIds` request paying per id cannot
736/// afford.
737fn resolve_catalog() -> Option<&'static Catalog> {
738    static CATALOG: OnceLock<Option<Catalog>> = OnceLock::new();
739    CATALOG.get_or_init(|| Catalog::builtin().ok()).as_ref()
740}
741
742/// `lpp/resolveIds` backing: the OPY spelling a canonical Workshop action,
743/// value, or operator id compiles from — the manifest `catalogId` link for
744/// declared callables, else [`SYNTAX_SPELLINGS`] for the ids source syntax
745/// produces. `None` when no dedicated OPY spelling exists or the id is
746/// unknown.
747pub fn call_spelling(canonical_id: &str) -> Option<&'static str> {
748    let manifest = Manifest::builtin().ok()?;
749    let catalog = resolve_catalog()?;
750    // A manifest entry's canonical identity is its `catalogId`, or — when
751    // it declares none — its own `id` when that id is a canonical call
752    // (`startRule`); a `catalogId`-free source name that is not a canonical
753    // id (`log`, `all`) claims nothing.
754    let is_canonical = |id: &str| {
755        catalog.entry(Kind::Value, id).is_some() || catalog.entry(Kind::Action, id).is_some()
756    };
757    // Distinct spellings may share one canonical id (`getPlayers` and
758    // `getAllPlayers` both claim `allPlayers`): prefer the entry whose own
759    // id is the canonical name, matching `resolve_call_entry` in
760    // `compiler::reconstruct`.
761    manifest
762        .functions()
763        .iter()
764        .filter(|function| match function.catalog_id.as_deref() {
765            Some(catalog_id) => catalog_id == canonical_id,
766            None => function.id == canonical_id && is_canonical(canonical_id),
767        })
768        .min_by_key(|function| function.id.as_str() != canonical_id)
769        .map(|function| function.id.as_str())
770        .or_else(|| {
771            SYNTAX_SPELLINGS
772                .iter()
773                .find_map(|(id, spelling)| (*id == canonical_id).then_some(*spelling))
774        })
775}
776
777/// `lpp/resolveIds` backing: the OPY member spelling for a canonical enum
778/// `domain`/`member` pair — the member half of the `Domain.MEMBER`
779/// spelling `lpp/lookup` reports. `None` for unknown pairs, for domains
780/// and members the reference does not spell in source, and for OPY-only
781/// members with no canonical id.
782pub fn enum_member_spelling<'a>(domain: &'a str, member: &'a str) -> Option<&'a str> {
783    let catalog = resolve_catalog()?;
784    let enum_domain = catalog.enum_domain(domain)?;
785    if !enum_domain
786        .members
787        .iter()
788        .any(|entry| entry.member == member)
789    {
790        return None;
791    }
792    crate::enums::spelling_of_member(domain, member).map(|(_, member)| member)
793}
794
795// ---------------------------------------------------------------------------
796// The search index — spellings projected from manifest + catalog data
797// ---------------------------------------------------------------------------
798
799struct ScoredHit {
800    score: u32,
801    hit: LookupHit,
802}
803
804impl LookupHit {
805    fn sort_key(&self) -> String {
806        match self {
807            LookupHit::Function { spelling, .. } => format!("0{spelling}"),
808            LookupHit::EnumMember { spelling, .. } => format!("1{spelling}"),
809            LookupHit::EnumDomain { domain, .. } => format!("2{domain}"),
810            LookupHit::Setting { path, .. } => format!("3{path}"),
811            LookupHit::Parameter {
812                callable, param, ..
813            } => {
814                format!("4{callable}.{}", param.name)
815            }
816            LookupHit::SettingPath { path, .. } => format!("5{path}"),
817            LookupHit::Event { spelling, .. } => format!("6{spelling}"),
818        }
819    }
820
821    /// Restamp `matched_on` for a listed answer: no text constraint scored,
822    /// so the entry's own primary form is what it answers to.
823    fn listed(self) -> LookupHit {
824        let kind = match &self {
825            LookupHit::Setting { .. } | LookupHit::SettingPath { .. } => MatchKind::Path,
826            _ => MatchKind::OpySpelling,
827        };
828        self.with_match_kind(kind, 0, false)
829    }
830
831    /// Stamp the matched-on kind: a folded-equal or structurally matching
832    /// (settings path-prefix) form reports its own kind; other hits are
833    /// `Near` near-misses.
834    fn with_match_kind(self, kind: MatchKind, score: u32, structural: bool) -> LookupHit {
835        let matched_on = if score == 0 || structural {
836            kind
837        } else {
838            MatchKind::Near
839        };
840        match self {
841            LookupHit::Function {
842                spelling,
843                function_kind,
844                receiver,
845                unbounded,
846                params,
847                catalog_id,
848                display_name,
849                ..
850            } => LookupHit::Function {
851                spelling,
852                function_kind,
853                receiver,
854                unbounded,
855                params,
856                catalog_id,
857                display_name,
858                matched_on,
859            },
860            LookupHit::EnumDomain {
861                domain,
862                members,
863                display_name,
864                ..
865            } => LookupHit::EnumDomain {
866                domain,
867                members,
868                display_name,
869                matched_on,
870            },
871            LookupHit::EnumMember {
872                spelling,
873                domain,
874                member,
875                display_name,
876                aliases,
877                ..
878            } => LookupHit::EnumMember {
879                spelling,
880                domain,
881                member,
882                display_name,
883                aliases,
884                matched_on,
885            },
886            LookupHit::Setting {
887                path,
888                display_name,
889                value,
890                ..
891            } => LookupHit::Setting {
892                path,
893                display_name,
894                value,
895                matched_on,
896            },
897            LookupHit::Parameter {
898                callable, param, ..
899            } => LookupHit::Parameter {
900                callable,
901                param,
902                matched_on,
903            },
904            LookupHit::SettingPath { path, .. } => LookupHit::SettingPath { path, matched_on },
905            LookupHit::Event {
906                spelling,
907                accepts_filters,
908                display_name,
909                ..
910            } => LookupHit::Event {
911                spelling,
912                accepts_filters,
913                display_name,
914                matched_on,
915            },
916        }
917    }
918}
919
920/// One searchable entry: every spelling form it answers to plus the payload
921/// returned when one matches.
922#[derive(Clone)]
923struct Candidate {
924    forms: Vec<(String, MatchKind)>,
925    hit: LookupHit,
926}
927
928struct Index<'a> {
929    manifest: &'a Manifest,
930    catalog: &'a Catalog,
931    candidates: Vec<Candidate>,
932}
933
934impl<'a> Index<'a> {
935    fn new(manifest: &'a Manifest, catalog: &'a Catalog) -> Index<'a> {
936        let mut index = Index {
937            manifest,
938            catalog,
939            candidates: Vec::new(),
940        };
941        index.index_functions();
942        index.index_enums();
943        index.index_settings();
944        index.index_events();
945        index
946    }
947
948    /// Rank `candidates` under `query`: a non-empty text scores each
949    /// candidate's best-matching form through the shared matcher pipeline,
950    /// while an empty text lists the candidates in their construction order.
951    fn rank<'c>(
952        &self,
953        candidates: impl Iterator<Item = &'c Candidate>,
954        query: &LookupQuery,
955    ) -> Vec<LookupHit> {
956        if query.text.trim().is_empty() {
957            return candidates
958                .map(|candidate| candidate.hit.clone().listed())
959                .collect();
960        }
961        let mut hits: Vec<ScoredHit> = candidates
962            .filter_map(|candidate| {
963                candidate
964                    .forms
965                    .iter()
966                    .filter_map(|(form, kind)| {
967                        // Settings paths match segment by segment: structure,
968                        // template segments and `%` suffixes stay meaningful
969                        // for exact/path-prefix queries; the folded `score`
970                        // only serves near-name ranking. Both scorers live in
971                        // `crate::matcher` — the same pipeline that produces
972                        // the `did you mean` candidates on rejected names.
973                        let (scored, structural) = if *kind == MatchKind::Path {
974                            match crate::matcher::path_score(&query.text, form) {
975                                Some(s) => (Some(s), true),
976                                None => (crate::matcher::score(&query.text, form), false),
977                            }
978                        } else {
979                            (crate::matcher::score(&query.text, form), false)
980                        };
981                        scored.map(|s| {
982                            // A raw (case-insensitive) hit reports the form
983                            // the user actually typed when folds collide.
984                            (
985                                s,
986                                form.eq_ignore_ascii_case(query.text.trim()),
987                                *kind,
988                                structural,
989                            )
990                        })
991                    })
992                    .min_by_key(|(s, exact, _, _)| (*s, !*exact))
993                    .map(|(s, _, kind, structural)| ScoredHit {
994                        score: s,
995                        hit: candidate.hit.clone().with_match_kind(kind, s, structural),
996                    })
997            })
998            .collect();
999        hits.sort_by(|a, b| {
1000            a.score
1001                .cmp(&b.score)
1002                .then_with(|| a.hit.sort_key().cmp(&b.hit.sort_key()))
1003        });
1004        hits.into_iter().map(|scored| scored.hit).collect()
1005    }
1006
1007    fn in_scope(&self, candidate: &Candidate, scope: LookupScope) -> bool {
1008        match &candidate.hit {
1009            LookupHit::Function { .. } | LookupHit::Parameter { .. } => scope.functions,
1010            LookupHit::EnumDomain { .. } | LookupHit::EnumMember { .. } => scope.enums,
1011            LookupHit::Setting { .. } | LookupHit::SettingPath { .. } => scope.settings,
1012            LookupHit::Event { .. } => scope.events,
1013        }
1014    }
1015
1016    // -- within scopes -----------------------------------------------------
1017
1018    /// The child candidates of one `within` scope, or `None` when `within`
1019    /// names no scope the index knows.
1020    fn children(&self, within: &LookupWithin) -> Option<Vec<Candidate>> {
1021        match within {
1022            LookupWithin::Callable(name) => self.callable_children(name),
1023            LookupWithin::Enum(domain) => self.enum_children(domain),
1024            LookupWithin::Settings(prefix) => self.settings_children(prefix),
1025            LookupWithin::SettingEnum(domain) => self.setting_enum_children(domain),
1026        }
1027    }
1028
1029    /// One candidate per declared parameter of the callable `name` (its OPY
1030    /// spelling), in call order.
1031    fn callable_children(&self, name: &str) -> Option<Vec<Candidate>> {
1032        self.candidates
1033            .iter()
1034            .find_map(|candidate| match &candidate.hit {
1035                LookupHit::Function {
1036                    spelling, params, ..
1037                } if spelling == name => Some(
1038                    params
1039                        .iter()
1040                        .map(|param| {
1041                            let mut forms = vec![(param.name.clone(), MatchKind::OpySpelling)];
1042                            forms.extend(
1043                                param
1044                                    .alternate_names
1045                                    .iter()
1046                                    .map(|name| (name.clone(), MatchKind::Alias)),
1047                            );
1048                            Candidate {
1049                                forms,
1050                                hit: LookupHit::Parameter {
1051                                    callable: spelling.clone(),
1052                                    param: param.clone(),
1053                                    matched_on: MatchKind::OpySpelling,
1054                                },
1055                            }
1056                        })
1057                        .collect(),
1058                ),
1059                _ => None,
1060            })
1061    }
1062
1063    /// One candidate per member of the enum domain `domain`, in domain
1064    /// order. A manifest-only domain is an existing but empty scope.
1065    fn enum_children(&self, domain: &str) -> Option<Vec<Candidate>> {
1066        let known = self.candidates.iter().any(|candidate| {
1067            matches!(&candidate.hit, LookupHit::EnumDomain { domain: d, .. } if d == domain)
1068        });
1069        if !known {
1070            return None;
1071        }
1072        Some(
1073            crate::enums::domain_members(domain, self.catalog)
1074                .map(|members| {
1075                    members
1076                        .iter()
1077                        .map(|member| self.member_candidate(domain, member))
1078                        .collect()
1079                })
1080                .unwrap_or_default(),
1081        )
1082    }
1083
1084    /// The settings keys and intermediate path segments directly below
1085    /// `prefix`, in settings-table order; an empty prefix lists the root.
1086    /// `None` when `prefix` is not a proper segment-wise prefix of any
1087    /// settings path.
1088    fn settings_children(&self, prefix: &str) -> Option<Vec<Candidate>> {
1089        let prefix_segments: Vec<&str> =
1090            prefix.split('.').filter(|part| !part.is_empty()).collect();
1091        let mut children: Vec<Candidate> = Vec::new();
1092        let mut seen: Vec<&str> = Vec::new();
1093        for candidate in &self.candidates {
1094            let LookupHit::Setting { path, .. } = &candidate.hit else {
1095                continue;
1096            };
1097            let segments: Vec<&str> = path.split('.').collect();
1098            if !segments.starts_with(prefix_segments.as_slice()) {
1099                continue;
1100            }
1101            let Some(&child_segment) = segments.get(prefix_segments.len()) else {
1102                continue;
1103            };
1104            if seen.contains(&child_segment) {
1105                continue;
1106            }
1107            seen.push(child_segment);
1108            if segments.len() == prefix_segments.len() + 1 {
1109                children.push(candidate.clone());
1110            } else {
1111                let child_path = prefix_segments
1112                    .iter()
1113                    .copied()
1114                    .chain(std::iter::once(child_segment))
1115                    .collect::<Vec<_>>()
1116                    .join(".");
1117                children.push(Candidate {
1118                    forms: vec![
1119                        (child_segment.to_string(), MatchKind::OpySpelling),
1120                        (child_path.clone(), MatchKind::Path),
1121                    ],
1122                    hit: LookupHit::SettingPath {
1123                        path: child_path,
1124                        matched_on: MatchKind::Path,
1125                    },
1126                });
1127            }
1128        }
1129        (!children.is_empty()).then_some(children)
1130    }
1131
1132    /// One candidate per accepted member of a settings enum domain, in
1133    /// table order. `None` when `domain` is not a settings enum domain.
1134    fn setting_enum_children(&self, domain: &str) -> Option<Vec<Candidate>> {
1135        let mut members: Vec<(String, String)> = Vec::new();
1136        for definition in settings::definitions() {
1137            for member in definition.enum_members() {
1138                if member.domain() == domain && !members.iter().any(|(id, _)| id == member.id()) {
1139                    members.push((member.id().to_string(), member.english_name().to_string()));
1140                }
1141            }
1142        }
1143        if members.is_empty() {
1144            return None;
1145        }
1146        Some(
1147            members
1148                .into_iter()
1149                .map(|(id, name)| Candidate {
1150                    forms: vec![
1151                        (id.clone(), MatchKind::OpySpelling),
1152                        (name.clone(), MatchKind::DisplayName),
1153                    ],
1154                    hit: LookupHit::EnumMember {
1155                        spelling: id.clone(),
1156                        domain: domain.to_string(),
1157                        member: id,
1158                        display_name: (!name.is_empty()).then_some(name),
1159                        aliases: Vec::new(),
1160                        matched_on: MatchKind::OpySpelling,
1161                    },
1162                })
1163                .collect(),
1164        )
1165    }
1166
1167    // -- functions --------------------------------------------------------
1168
1169    fn index_functions(&mut self) {
1170        let locale = crate::enums::en_us();
1171        for function in self.manifest.functions() {
1172            let mut forms = vec![(function.id.clone(), MatchKind::OpySpelling)];
1173            let mut display_name = None;
1174            if let Some(catalog_id) = &function.catalog_id {
1175                forms.push((catalog_id.clone(), MatchKind::CatalogId));
1176                let kind = match function.kind {
1177                    FunctionKind::Action | FunctionKind::MemberAction => Kind::Action,
1178                    FunctionKind::Value | FunctionKind::MemberValue => Kind::Value,
1179                };
1180                if let Some(entry) = self.catalog.entry(kind, catalog_id) {
1181                    if let Some(spelling) = entry.spelling(&locale) {
1182                        display_name = Some(spelling.to_string());
1183                        forms.push((spelling.to_string(), MatchKind::DisplayName));
1184                    }
1185                    for spelling in entry.spellings(&locale) {
1186                        forms.push((spelling.clone(), MatchKind::DisplayName));
1187                    }
1188                }
1189            }
1190            for alias in self.manifest.aliases() {
1191                if alias_target(alias, self.manifest)
1192                    .is_some_and(|target| std::ptr::eq(target, function))
1193                {
1194                    forms.push((alias.source.clone(), MatchKind::Alias));
1195                }
1196            }
1197            let params = self.lookup_params(function);
1198            self.candidates.push(Candidate {
1199                forms,
1200                hit: LookupHit::Function {
1201                    spelling: function.id.clone(),
1202                    function_kind: match function.kind {
1203                        FunctionKind::Action => LookupFunctionKind::Action,
1204                        FunctionKind::Value => LookupFunctionKind::Value,
1205                        FunctionKind::MemberAction => LookupFunctionKind::MemberAction,
1206                        FunctionKind::MemberValue => LookupFunctionKind::MemberValue,
1207                    },
1208                    receiver: function.receiver,
1209                    unbounded: function.unbounded,
1210                    params,
1211                    catalog_id: function.catalog_id.clone(),
1212                    display_name,
1213                    matched_on: MatchKind::OpySpelling,
1214                },
1215            });
1216        }
1217        for special in SPECIAL_FUNCTIONS {
1218            let params: Vec<LookupParam> = special
1219                .params
1220                .iter()
1221                .map(|param| LookupParam {
1222                    name: param.name.to_string(),
1223                    alternate_names: Vec::new(),
1224                    required: param.required,
1225                    domain: None,
1226                    members: None,
1227                    param_type: Some(param.param_type.to_string()),
1228                    default: param.default.map(str::to_string),
1229                })
1230                .collect();
1231            self.candidates.push(Candidate {
1232                forms: vec![(special.name.to_string(), MatchKind::OpySpelling)],
1233                hit: LookupHit::Function {
1234                    spelling: special.name.to_string(),
1235                    function_kind: LookupFunctionKind::Value,
1236                    receiver: None,
1237                    unbounded: false,
1238                    params,
1239                    catalog_id: None,
1240                    display_name: None,
1241                    matched_on: MatchKind::OpySpelling,
1242                },
1243            });
1244        }
1245    }
1246
1247    fn lookup_params(&self, function: &Function) -> Vec<LookupParam> {
1248        let catalog_entry = function.catalog_id.as_deref().and_then(|catalog_id| {
1249            let kind = match function.kind {
1250                FunctionKind::Action | FunctionKind::MemberAction => Kind::Action,
1251                FunctionKind::Value | FunctionKind::MemberValue => Kind::Value,
1252            };
1253            self.catalog.entry(kind, catalog_id)
1254        });
1255        function
1256            .params
1257            .iter()
1258            .enumerate()
1259            .map(|(index, param)| {
1260                let catalog_index = self.catalog_param_index(catalog_entry, function, param, index);
1261                let (param_type, catalog_domain) = catalog_index
1262                    .and_then(|i| {
1263                        catalog_entry.map(|entry| {
1264                            (
1265                                entry.param_type(i).map(str::to_string),
1266                                entry.param_domain(i).map(str::to_string),
1267                            )
1268                        })
1269                    })
1270                    .unwrap_or((None, None));
1271                // The reported domain is the reference source spelling:
1272                // a catalog-declared `Clipping` slot renders as `Clip`.
1273                let domain = param
1274                    .domain
1275                    .clone()
1276                    .or(catalog_domain)
1277                    .map(|domain| crate::enums::opy_domain(&domain).to_string());
1278                let required = !param.optional && param.default.is_none();
1279                // An enum slot carries its domain's full member inventory:
1280                // which members a rendered signature shows is the caller's
1281                // (Wright's) policy, not the owner's.
1282                let members = domain
1283                    .as_deref()
1284                    .and_then(|domain| crate::enums::domain_members(domain, self.catalog))
1285                    .map(|members| members.iter().map(member_entry).collect::<Vec<_>>());
1286                let default = param
1287                    .default
1288                    .as_ref()
1289                    .map(|d| default_text(d, domain.as_deref(), self.catalog));
1290                LookupParam {
1291                    name: param.name.clone(),
1292                    alternate_names: param.alternate_names.clone(),
1293                    required,
1294                    members,
1295                    domain,
1296                    param_type,
1297                    default,
1298                }
1299            })
1300            .collect()
1301    }
1302
1303    /// The catalog parameter index a manifest parameter occupies: member
1304    /// calls carry a receiver slot the manifest does not list, so they shift
1305    /// by one when the catalog side names a receiver first.
1306    fn catalog_param_index(
1307        &self,
1308        entry: Option<&workshop_rs::catalog::CatalogEntry>,
1309        function: &Function,
1310        param: &Param,
1311        index: usize,
1312    ) -> Option<usize> {
1313        let entry = entry?;
1314        let count = entry.param_count();
1315        let offset = if function.kind.is_member() && count == function.params.len() + 1 {
1316            1
1317        } else {
1318            0
1319        };
1320        if count == function.params.len() + offset {
1321            // Aligned declarations: the semantic name resolves the slot,
1322            // else position wins.
1323            let candidate = index + offset;
1324            match (0..count).position(|i| {
1325                entry.param_name(i).is_some_and(|name| {
1326                    crate::matcher::fold(name) == crate::matcher::fold(&param.name)
1327                })
1328            }) {
1329                Some(i) => Some(i),
1330                None => (candidate < count).then_some(candidate),
1331            }
1332        } else {
1333            (0..count).find(|&i| {
1334                entry.param_name(i).is_some_and(|name| {
1335                    crate::matcher::fold(name) == crate::matcher::fold(&param.name)
1336                })
1337            })
1338        }
1339    }
1340
1341    // -- enums ------------------------------------------------------------
1342
1343    fn index_enums(&mut self) {
1344        for domain in self.lookup_domains() {
1345            let members = crate::enums::domain_members(&domain, self.catalog);
1346            let mut domain_forms = vec![(domain.clone(), MatchKind::OpySpelling)];
1347            let mut display_name = None;
1348            if let Some(catalog_domain) = self
1349                .catalog
1350                .enum_domain(crate::enums::catalog_domain(&domain))
1351            {
1352                domain_forms.push((catalog_domain.domain.clone(), MatchKind::CatalogId));
1353                let locale = crate::enums::en_us();
1354                if let Some(spelling) = catalog_domain.spelling(&locale) {
1355                    display_name = Some(spelling.to_string());
1356                    domain_forms.push((spelling.to_string(), MatchKind::DisplayName));
1357                }
1358            }
1359            let entries: Option<Vec<EnumMemberEntry>> = members
1360                .as_ref()
1361                .map(|members| members.iter().map(member_entry).collect());
1362            self.candidates.push(Candidate {
1363                forms: domain_forms,
1364                hit: LookupHit::EnumDomain {
1365                    domain: domain.clone(),
1366                    members: entries,
1367                    display_name,
1368                    matched_on: MatchKind::OpySpelling,
1369                },
1370            });
1371            if let Some(members) = members {
1372                for member in &members {
1373                    self.candidates.push(self.member_candidate(&domain, member));
1374                }
1375            }
1376        }
1377    }
1378
1379    /// The member candidate the enum domain index and a `within: enum`
1380    /// scope share, so a member answers to the same forms in both.
1381    fn member_candidate(&self, domain: &str, member: &crate::enums::DomainMember) -> Candidate {
1382        let entry = member_entry(member);
1383        let mut forms = vec![
1384            (
1385                format!("{domain}.{}", entry.spelling),
1386                MatchKind::OpySpelling,
1387            ),
1388            (entry.spelling.clone(), MatchKind::OpySpelling),
1389        ];
1390        if let Some(display) = &member.display_name {
1391            forms.push((display.clone(), MatchKind::DisplayName));
1392        }
1393        if let Some(catalog_member) = &member.catalog_member {
1394            forms.push((catalog_member.clone(), MatchKind::CatalogId));
1395        }
1396        for alias in &member.aliases {
1397            forms.push((alias.clone(), MatchKind::Alias));
1398        }
1399        Candidate {
1400            forms,
1401            hit: LookupHit::EnumMember {
1402                spelling: format!("{domain}.{}", entry.spelling),
1403                domain: domain.to_string(),
1404                member: entry.id,
1405                display_name: entry.display_name,
1406                aliases: entry.aliases,
1407                matched_on: MatchKind::OpySpelling,
1408            },
1409        }
1410    }
1411
1412    /// Every enum domain the lookup reports: catalog domains under their
1413    /// reference source spelling (`Clipping` reports as `Clip`), plus
1414    /// source-level alias domains (`AsyncBehavior`) and manifest-only
1415    /// signature domains. Catalog domains that are not `Domain.MEMBER`
1416    /// sources in the reference (`EventTeam`, `Rounding`, …) are not
1417    /// reported — their members are spellings upstream rejects.
1418    fn lookup_domains(&self) -> Vec<String> {
1419        let mut domains: Vec<String> = self
1420            .catalog
1421            .enum_domains()
1422            .filter(|domain| !crate::enums::NON_SOURCE_DOMAINS.contains(&domain.domain.as_str()))
1423            .map(|domain| crate::enums::opy_domain(&domain.domain).to_string())
1424            .collect();
1425        domains.extend(
1426            crate::enums::DOMAIN_ALIASES
1427                .iter()
1428                .map(|(source, _)| source.to_string()),
1429        );
1430        for domain in self
1431            .manifest
1432            .functions()
1433            .iter()
1434            .flat_map(|function| function.params.iter())
1435            .filter_map(|param| param.domain.as_deref())
1436            .map(crate::enums::opy_domain)
1437        {
1438            if !crate::enums::NON_SOURCE_DOMAINS.contains(&domain)
1439                && !domains.iter().any(|d| d == domain)
1440            {
1441                domains.push(domain.to_string());
1442            }
1443        }
1444        domains
1445    }
1446
1447    // -- settings ---------------------------------------------------------
1448
1449    fn index_settings(&mut self) {
1450        for definition in settings::definitions() {
1451            self.push_setting(definition.path(), &definition);
1452        }
1453        // Per-mode keys inherited from `gamemodes.general` answer to their
1454        // effective path: the table resolves them through lookup, so index
1455        // the same way emission does rather than re-declaring them.
1456        let modes: Vec<String> = settings::definitions()
1457            .filter_map(|definition| {
1458                let mut parts = definition.path().split('.');
1459                match (parts.next(), parts.next(), parts.next()) {
1460                    (Some("gamemodes"), Some(mode), Some(_)) if mode != "general" => {
1461                        Some(mode.to_string())
1462                    }
1463                    _ => None,
1464                }
1465            })
1466            .collect();
1467        let modes: Vec<String> = {
1468            let mut seen = Vec::new();
1469            for mode in modes {
1470                if !seen.contains(&mode) {
1471                    seen.push(mode);
1472                }
1473            }
1474            seen
1475        };
1476        for definition in settings::definitions() {
1477            let Some(leaf) = definition.path().strip_prefix("gamemodes.general.") else {
1478                continue;
1479            };
1480            for mode in &modes {
1481                let probe = [
1482                    PathPart::Part("gamemodes"),
1483                    PathPart::Part(mode.as_str()),
1484                    PathPart::Part(leaf),
1485                ];
1486                if let Some(resolved) = settings::definition(&probe) {
1487                    let path = format!("gamemodes.{mode}.{leaf}");
1488                    if resolved.path() != path {
1489                        // The resolution is the inherited general entry.
1490                        self.push_setting(&path, &resolved);
1491                    }
1492                }
1493            }
1494        }
1495    }
1496
1497    fn push_setting(&mut self, path: &str, definition: &settings::SettingDefinition) {
1498        let leaf = path.rsplit('.').next().unwrap_or(path);
1499        let english_name = definition.presentation().english_name;
1500        let mut forms = vec![
1501            (path.to_string(), MatchKind::Path),
1502            (leaf.to_string(), MatchKind::OpySpelling),
1503        ];
1504        if !english_name.is_empty() {
1505            forms.push((english_name.to_string(), MatchKind::DisplayName));
1506        }
1507        self.candidates.push(Candidate {
1508            forms,
1509            hit: LookupHit::Setting {
1510                path: path.to_string(),
1511                display_name: (!english_name.is_empty()).then(|| english_name.to_string()),
1512                value: setting_value_form(definition),
1513                matched_on: MatchKind::Path,
1514            },
1515        });
1516    }
1517
1518    /// One candidate per event the `@Event` header accepts. The
1519    /// acceptance set is the lowerer's: `global`, `eachPlayer`, and the
1520    /// `player_event_kind` names — the catalog's `subroutine` event is
1521    /// not an `@Event` name (subroutine rules use `def`), so it is
1522    /// excluded. `global` rejects player filters; the others accept
1523    /// `@Team`/`@Hero`/`@Slot`.
1524    fn index_events(&mut self) {
1525        let locale = crate::enums::en_us();
1526        for entry in self.catalog.entries_of(Kind::Event) {
1527            let accepts_filters = match entry.id.as_str() {
1528                "global" => false,
1529                id if id == "eachPlayer" || crate::compiler::player_event_kind(id).is_some() => {
1530                    true
1531                }
1532                _ => continue,
1533            };
1534            let display_name = entry.spelling(&locale).map(str::to_string);
1535            let mut forms = vec![(entry.id.clone(), MatchKind::OpySpelling)];
1536            for spelling in entry.spellings(&locale) {
1537                forms.push((spelling.clone(), MatchKind::DisplayName));
1538            }
1539            self.candidates.push(Candidate {
1540                forms,
1541                hit: LookupHit::Event {
1542                    spelling: entry.id.clone(),
1543                    accepts_filters,
1544                    display_name,
1545                    matched_on: MatchKind::OpySpelling,
1546                },
1547            });
1548        }
1549    }
1550}
1551
1552/// The function an alias resolves to, through its declared kind.
1553fn alias_target<'a>(
1554    alias: &crate::manifest::Alias,
1555    manifest: &'a Manifest,
1556) -> Option<&'a Function> {
1557    match alias.kind {
1558        AliasKind::FunctionAlias => manifest.function(&alias.target),
1559        AliasKind::MemberAlias => manifest.member(&alias.target),
1560    }
1561}
1562
1563/// Render a manifest `DomainMember` as a lookup member entry.
1564fn member_entry(member: &crate::enums::DomainMember) -> EnumMemberEntry {
1565    EnumMemberEntry {
1566        spelling: member.member.clone(),
1567        id: member
1568            .catalog_member
1569            .clone()
1570            .unwrap_or_else(|| member.member.clone()),
1571        display_name: member.display_name.clone(),
1572        aliases: member.aliases.clone(),
1573    }
1574}
1575
1576/// The value form of one settings table definition.
1577fn setting_value_form(definition: &settings::SettingDefinition) -> SettingValueForm {
1578    let (kind, domain, min, max) = match definition.domain() {
1579        SettingValueDomain::PresenceOnly => (SettingValueKind::Presence, None, None, None),
1580        SettingValueDomain::Boolean => (SettingValueKind::Boolean, None, None, None),
1581        SettingValueDomain::Number(bounds) => {
1582            (SettingValueKind::Number, None, bounds.min(), bounds.max())
1583        }
1584        SettingValueDomain::Percent(bounds) => {
1585            (SettingValueKind::Percent, None, bounds.min(), bounds.max())
1586        }
1587        SettingValueDomain::String => (SettingValueKind::String, None, None, None),
1588        SettingValueDomain::Enum { domain } => {
1589            (SettingValueKind::Enum, Some(domain.clone()), None, None)
1590        }
1591        SettingValueDomain::MapList => (SettingValueKind::MapList, None, None, None),
1592        SettingValueDomain::HeroList => (SettingValueKind::HeroList, None, None, None),
1593        // `SettingValueDomain` is non-exhaustive upstream; a future variant
1594        // must be mapped here rather than silently re-typed.
1595        other => unreachable!("unhandled SettingValueDomain variant {other:?}"),
1596    };
1597    // A `BoolEnum` key is Boolean-typed over an enum domain and accepts
1598    // `true` only; a plain `Enum` key accepts its member ids.
1599    let members: Vec<String> =
1600        if kind == SettingValueKind::Boolean && definition.enum_members().next().is_some() {
1601            vec!["true".to_string()]
1602        } else {
1603            definition
1604                .enum_members()
1605                .map(|member| member.id().to_string())
1606                .collect()
1607        };
1608    SettingValueForm {
1609        kind,
1610        domain,
1611        members,
1612        min,
1613        max,
1614    }
1615}
1616
1617/// Render a parameter default in source syntax.
1618fn default_text(default: &ParamDefault, domain: Option<&str>, catalog: &Catalog) -> String {
1619    match default {
1620        ParamDefault::Null { .. } => "null".to_string(),
1621        ParamDefault::Bool(value) => value.to_string(),
1622        ParamDefault::Number(value) => {
1623            if value.fract() == 0.0 {
1624                format!("{}", *value as i64)
1625            } else {
1626                value.to_string()
1627            }
1628        }
1629        ParamDefault::Call { call, args } => {
1630            let args = args
1631                .iter()
1632                .map(|arg| {
1633                    if arg.fract() == 0.0 {
1634                        format!("{}", *arg as i64)
1635                    } else {
1636                        arg.to_string()
1637                    }
1638                })
1639                .collect::<Vec<_>>()
1640                .join(", ");
1641            format!("{call}({args})")
1642        }
1643        ParamDefault::EnumMember(member) => match domain {
1644            // Defaults render under the advertised OPY spelling: a member
1645            // recorded by its catalog id (`DO_NOT_CLIP`) shows as `NONE`.
1646            Some(domain) => {
1647                let spelling = crate::enums::domain_members(domain, catalog)
1648                    .and_then(|members| {
1649                        members
1650                            .iter()
1651                            .find(|entry| {
1652                                entry.member == *member
1653                                    || entry.catalog_member.as_deref() == Some(member.as_str())
1654                            })
1655                            .map(|entry| entry.member.clone())
1656                    })
1657                    .unwrap_or_else(|| member.clone());
1658                format!("{domain}.{spelling}")
1659            }
1660            None => member.clone(),
1661        },
1662    }
1663}
1664
1665#[cfg(test)]
1666mod tests {
1667    use super::*;
1668    use workshop_rs::catalog::Catalog;
1669
1670    /// The lookup cannot claim a member spelling the lowerer would reject:
1671    /// every member and alias `domain_members` reports must resolve through
1672    /// `canonical_member`, the same function lowering calls.
1673    #[test]
1674    fn reported_member_spellings_resolve_through_lowering() {
1675        let catalog = Catalog::builtin().expect("bundled catalog");
1676        let manifest = Manifest::builtin().expect("bundled manifest");
1677        let index = Index::new(manifest, &catalog);
1678        for domain in index.lookup_domains() {
1679            let Some(members) = crate::enums::domain_members(&domain, &catalog) else {
1680                continue;
1681            };
1682            for member in &members {
1683                for spelling in std::iter::once(&member.member).chain(&member.aliases) {
1684                    // OPY-only `Color` members lower to `rgb(...)` calls
1685                    // through `extra_color_member` rather than a canonical
1686                    // member id.
1687                    let resolves = crate::enums::canonical_member(&domain, spelling, &catalog)
1688                        .is_some()
1689                        || (domain == "Color"
1690                            && crate::enums::extra_color_member(spelling).is_some());
1691                    assert!(
1692                        resolves,
1693                        "{domain}.{spelling} is lookup-visible but does not resolve"
1694                    );
1695                }
1696            }
1697        }
1698    }
1699
1700    /// Every function spelling a lookup hit reports must resolve through
1701    /// the same manifest the lowerer resolves: lookup data is never a
1702    /// second table.
1703    #[test]
1704    fn reported_function_spellings_resolve_through_the_manifest() {
1705        let manifest = Manifest::builtin().expect("bundled manifest");
1706        for function in manifest.functions() {
1707            let resolves = if function.kind.is_member() {
1708                manifest.resolve_member(&function.id).is_some()
1709            } else {
1710                manifest.resolve_function(&function.id).is_some()
1711            };
1712            assert!(
1713                resolves,
1714                "{} is lookup-visible but does not resolve",
1715                function.id
1716            );
1717        }
1718        // Special-call spellings must be exactly the names `lower_call`
1719        // dispatches on: `SPECIAL_VALUE_CALLS` is the descriptor the resolver
1720        // consults, so a dropped or renamed special form makes the reported
1721        // set disagree and fails here.
1722        let reported: Vec<&str> = SPECIAL_FUNCTIONS
1723            .iter()
1724            .map(|special| special.name)
1725            .collect();
1726        let dispatched: Vec<&str> = crate::lower::special_forms::SPECIAL_VALUE_CALLS
1727            .iter()
1728            .map(|(name, _)| *name)
1729            .collect();
1730        assert_eq!(
1731            reported, dispatched,
1732            "reported special-call spellings must be the dispatched set"
1733        );
1734        // Each reported special must resolve through the real lowerer: a
1735        // call in value position is `unknown-value` when the dispatch table
1736        // no longer recognizes the name.
1737        for special in SPECIAL_FUNCTIONS {
1738            let source = format!(
1739                "globalvar x\nrule \"r\":\n    @Event global\n    x = {}()\n",
1740                special.name
1741            );
1742            let diagnostics =
1743                crate::tooling::check(&source, "main.opy", std::path::Path::new("")).diagnostics;
1744            assert!(
1745                !diagnostics.iter().any(|diagnostic| matches!(
1746                    diagnostic.code.as_str(),
1747                    "unknown-value" | "unknown-action" | "invalid-iterable"
1748                )),
1749                "'{}' is lookup-reported but the lowerer rejects it: {diagnostics:?}",
1750                special.name
1751            );
1752        }
1753    }
1754
1755    /// Every member alias the lookup reports must be an OPY spelling the
1756    /// pinned reference accepts — never a canonical catalog id
1757    /// (`Hero.SOLDIER_76` is a reference rejection, not an alias). The
1758    /// upstream-accepted extra spellings are the legacy hero names
1759    /// `MCCREE`/`HAMMOND` and the `HudPosition.ACTUALLY_LEFT` emit alias
1760    /// (overpy 9.7.10 rewrites all three in member access); `opy-compat
1761    /// probe-generate` emits a `:spelling` probe per reported spelling so
1762    /// the same set is checked against the oracle.
1763    #[test]
1764    fn reported_member_aliases_are_reference_spellings() {
1765        let catalog = Catalog::builtin().expect("bundled catalog");
1766        let manifest = Manifest::builtin().expect("bundled manifest");
1767        let index = Index::new(manifest, &catalog);
1768        let mut reported = Vec::new();
1769        for domain in index.lookup_domains() {
1770            let Some(members) = crate::enums::domain_members(&domain, &catalog) else {
1771                continue;
1772            };
1773            for member in members {
1774                for alias in member.aliases {
1775                    assert_ne!(
1776                        Some(alias.as_str()),
1777                        member.catalog_member.as_deref(),
1778                        "{domain}.{alias} advertises the catalog id as an OPY alias"
1779                    );
1780                    reported.push(format!("{domain}.{alias}"));
1781                }
1782            }
1783        }
1784        reported.sort();
1785        assert_eq!(
1786            reported,
1787            ["Hero.HAMMOND", "Hero.MCCREE", "HudPosition.ACTUALLY_LEFT"]
1788        );
1789    }
1790
1791    /// Ranking goes through the shared `crate::matcher` pipeline: exact and
1792    /// near matches come first, the rejected spelling is not a candidate.
1793    #[test]
1794    fn rank_prefers_close_spellings() {
1795        let pool = crate::matcher::bare_candidates(["hudText", "smallMessage", "destroyIcon"]);
1796        let ranked = crate::matcher::rank("hudTex", &pool);
1797        assert_eq!(ranked.first().map(String::as_str), Some("hudText"));
1798        assert!(
1799            !ranked.iter().any(|candidate| candidate == "hudTex"),
1800            "the rejected spelling is not a candidate"
1801        );
1802    }
1803
1804    /// The settings candidate walk finds sibling keys of an unknown member
1805    /// at a per-mode path (inherited `gamemodes.general` keys included).
1806    #[test]
1807    fn sibling_keys_cover_inherited_gamemode_keys() {
1808        let keys =
1809            crate::matcher::sibling_keys(&[PathPart::Part("gamemodes"), PathPart::Part("ffa")]);
1810        assert!(
1811            keys.iter().any(|key| key == "scoreToWin"),
1812            "inherited general keys must be candidates: {keys:?}"
1813        );
1814    }
1815
1816    fn within_query(within: LookupWithin) -> LookupQuery {
1817        LookupQuery {
1818            text: String::new(),
1819            scope: LookupScope::ALL,
1820            within: Some(within),
1821            locale: None,
1822            limit: usize::MAX,
1823        }
1824    }
1825
1826    /// A `within: callable` scope lists the callable's declared parameters
1827    /// as `Parameter` hits in call order, not ranked text matches.
1828    #[test]
1829    fn within_callable_lists_params_in_call_order() {
1830        let LookupOutcome::Matched { results, .. } =
1831            lookup(&within_query(LookupWithin::Callable("wait".to_string())))
1832        else {
1833            panic!("wait is a known callable");
1834        };
1835        let names: Vec<&str> = results
1836            .iter()
1837            .map(|hit| {
1838                let LookupHit::Parameter { param, .. } = hit else {
1839                    panic!("callable children are parameters: {hit:?}");
1840                };
1841                param.name.as_str()
1842            })
1843            .collect();
1844        assert_eq!(names, ["time", "waitBehavior"]);
1845        let LookupOutcome::UnknownWithin { .. } = lookup(&within_query(LookupWithin::Callable(
1846            "notAFunction".to_string(),
1847        ))) else {
1848            panic!("an unknown callable is not a scope");
1849        };
1850    }
1851
1852    /// A `within: enum` scope lists the domain's member spellings in domain
1853    /// order; a `within: settings` scope lists immediate children — leaf
1854    /// keys as `Setting` hits, intermediate segments as `SettingPath` hits —
1855    /// and an empty prefix lists the root.
1856    #[test]
1857    fn within_scopes_list_their_children() {
1858        let LookupOutcome::Matched { results, .. } =
1859            lookup(&within_query(LookupWithin::Enum("Button".to_string())))
1860        else {
1861            panic!("Button is a known enum domain");
1862        };
1863        let LookupHit::EnumMember { spelling, .. } = &results[0] else {
1864            panic!("enum children are members: {:?}", results[0]);
1865        };
1866        assert_eq!(spelling, "Button.PRIMARY_FIRE");
1867
1868        let LookupOutcome::Matched { results, .. } =
1869            lookup(&within_query(LookupWithin::Settings(String::new())))
1870        else {
1871            panic!("the settings root exists");
1872        };
1873        assert!(
1874            results
1875                .iter()
1876                .all(|hit| matches!(hit, LookupHit::SettingPath { .. })),
1877            "the root has only intermediate segments: {results:?}"
1878        );
1879
1880        let LookupOutcome::Matched { results, .. } = lookup(&within_query(LookupWithin::Settings(
1881            "gamemodes.ffa".to_string(),
1882        ))) else {
1883            panic!("gamemodes.ffa is a settings path prefix");
1884        };
1885        assert!(
1886            results.iter().any(|hit| matches!(
1887                hit,
1888                LookupHit::Setting { path, .. } if path == "gamemodes.ffa.scoreToWin"
1889            )),
1890            "a leaf child reports its Setting hit: {results:?}"
1891        );
1892
1893        let LookupOutcome::UnknownWithin { .. } = lookup(&within_query(LookupWithin::Settings(
1894            "gamemodes.ffa.scoreToWin".to_string(),
1895        ))) else {
1896            panic!("a leaf setting has no children");
1897        };
1898    }
1899
1900    /// A settings enum domain (`lobby.mapRotation` accepts `afterAGame`…)
1901    /// answers under `LookupWithin::SettingEnum` with the member spellings
1902    /// the settings table declares; it is not an OPY enum domain.
1903    #[test]
1904    fn within_settings_enum_lists_accepted_members() {
1905        let LookupOutcome::Matched { results, .. } = lookup(&within_query(
1906            LookupWithin::SettingEnum("mapRotation".to_string()),
1907        )) else {
1908            panic!("mapRotation is a settings enum domain");
1909        };
1910        let spellings: Vec<&str> = results
1911            .iter()
1912            .map(|hit| {
1913                let LookupHit::EnumMember { spelling, .. } = hit else {
1914                    panic!("settings enum children are members: {hit:?}");
1915                };
1916                spelling.as_str()
1917            })
1918            .collect();
1919        assert_eq!(spellings, ["afterAGame", "afterMirrorMatch", "paused"]);
1920        let LookupOutcome::UnknownWithin { .. } =
1921            lookup(&within_query(LookupWithin::Enum("mapRotation".to_string())))
1922        else {
1923            panic!("mapRotation is not a source enum domain");
1924        };
1925    }
1926
1927    /// An empty text lists the scope's children in construction order with
1928    /// each hit's primary match kind stamped; it is not a `Near` ranking.
1929    #[test]
1930    fn empty_text_lists_children_in_stable_order() {
1931        let outcome_a = lookup(&within_query(LookupWithin::Enum("Button".to_string())));
1932        let outcome_b = lookup(&within_query(LookupWithin::Enum("Button".to_string())));
1933        assert_eq!(outcome_a, outcome_b, "repeat listings are identical");
1934        let LookupOutcome::Matched { results, .. } = outcome_a else {
1935            panic!("Button is a known enum domain");
1936        };
1937        assert!(
1938            results.iter().all(|hit| matches!(
1939                hit,
1940                LookupHit::EnumMember {
1941                    matched_on: MatchKind::OpySpelling,
1942                    ..
1943                }
1944            )),
1945            "listed members report their spelling form: {results:?}"
1946        );
1947    }
1948
1949    /// `call_spelling` reports a spelling for every canonical call id a
1950    /// manifest entry claims, and the reported spelling is one of the
1951    /// claiming entries — never a canonical id itself when the language
1952    /// spells it differently (`createHudText` -> `hudText`).
1953    #[test]
1954    fn call_spelling_reports_an_accepted_spelling() {
1955        let manifest = Manifest::builtin().expect("bundled manifest");
1956        for function in manifest.functions() {
1957            let Some(catalog_id) = function.catalog_id.as_deref() else {
1958                continue;
1959            };
1960            let spelling = call_spelling(catalog_id)
1961                .unwrap_or_else(|| panic!("{catalog_id} claims no spelling"));
1962            let claims = manifest.functions().iter().any(|entry| {
1963                entry.id == spelling && entry.catalog_id.as_deref() == Some(catalog_id)
1964            });
1965            assert!(
1966                claims,
1967                "{catalog_id} resolved to {spelling}, which does not claim it"
1968            );
1969        }
1970    }
1971
1972    /// `SYNTAX_SPELLINGS` entries resolve, and ids that are not canonical
1973    /// calls — source-only spellings like `evalOnce` or `all` — resolve
1974    /// nothing.
1975    #[test]
1976    fn call_spelling_answers_only_canonical_ids() {
1977        for (canonical_id, spelling) in SYNTAX_SPELLINGS {
1978            assert_eq!(
1979                call_spelling(canonical_id),
1980                Some(*spelling),
1981                "{canonical_id}"
1982            );
1983        }
1984        for id in [
1985            "evalOnce",
1986            "all",
1987            "log",
1988            "getPlayers",
1989            "end",
1990            "valueInArray",
1991            "setGlobalVariable",
1992            "notAnId",
1993        ] {
1994            assert_eq!(call_spelling(id), None, "{id} must not resolve");
1995        }
1996    }
1997
1998    /// `enum_member_spelling` reports the member half of the same
1999    /// `Domain.MEMBER` spelling `lpp/lookup` reports — renamed domains and
2000    /// members included — and nothing for unknown or non-source pairs.
2001    #[test]
2002    fn enum_member_spelling_matches_the_member_table() {
2003        assert_eq!(enum_member_spelling("Status", "STUNNED"), Some("STUNNED"));
2004        assert_eq!(enum_member_spelling("Map", "ROUTE_66"), Some("ROUTE66"));
2005        assert_eq!(
2006            enum_member_spelling("Clipping", "DO_NOT_CLIP"),
2007            Some("NONE")
2008        );
2009        assert_eq!(enum_member_spelling("Status", "NO_SUCH_MEMBER"), None);
2010        assert_eq!(enum_member_spelling("Rounding", "DOWN"), None);
2011        assert_eq!(enum_member_spelling("NoSuchDomain", "ANY"), None);
2012    }
2013}