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