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