Skip to main content

workshop_rs/
lookup.rs

1//! Canonical Workshop name lookup: display names, near spellings, and
2//! settings paths resolve to the catalog entries, enum members, and settings
3//! definitions the parser and emitter accept.
4//!
5//! [`Catalog::lookup`] answers in canonical Workshop terms: which identity a
6//! display name or guess refers to, which parameters and argument domains an
7//! action or value declares, which members an enum domain accepts, and which
8//! settings keys, kinds, and value forms a path or display name selects.
9//! Every answer derives from the same catalog and settings table that drive
10//! parsing and emission, so lookup results cannot drift from the accepted
11//! vocabulary. Consumers compose and present the matches; this module owns
12//! no data of its own.
13
14use crate::catalog::{Catalog, CatalogEntry, Kind, Locale};
15use crate::core::error::{Result, WorkshopError};
16use crate::core::suggest;
17use crate::settings::{self, PathPart, SettingDefinition, SettingValueDomain, table};
18
19/// Members of a parameter's enum domain are listed inline in a signature
20/// when the domain has at most this many members. Larger domains report
21/// their member count; a query on the domain itself lists them.
22const INLINE_MEMBER_LIMIT: usize = 32;
23
24/// One canonical construct matching a [`Catalog::lookup`] query.
25#[derive(Debug, Clone, PartialEq)]
26#[non_exhaustive]
27pub enum LookupMatch {
28    /// A catalog builtin: structural keyword, action, value, event, or
29    /// operator.
30    Builtin {
31        /// The entry's catalog kind.
32        kind: Kind,
33        /// The canonical identity (`createHudText`, `global`).
34        id: String,
35        /// The display name in the requested locale, when mapped.
36        display_name: Option<String>,
37        /// The call signature, for actions and values.
38        signature: Option<Signature>,
39    },
40    /// A member of a canonical enum domain (`Team.ALL`, `Hero.SOLDIER_76`).
41    EnumMember {
42        /// The canonical domain name.
43        domain: String,
44        /// The canonical member id.
45        member: String,
46        /// The member's display name in the requested locale, when mapped.
47        display_name: Option<String>,
48    },
49    /// A canonical enum domain and the members it accepts.
50    EnumDomain {
51        /// The canonical domain name.
52        domain: String,
53        /// The domain's display name in the requested locale, when mapped.
54        display_name: Option<String>,
55        /// Every member the domain accepts.
56        members: Vec<LookupEnumMember>,
57    },
58    /// A reviewed settings-table definition: a valid key with its path,
59    /// kind, and value forms.
60    Setting {
61        /// The canonical semantic definition.
62        definition: SettingDefinition,
63        /// The display name in the requested locale, or the English name
64        /// when the locale has no mapping.
65        display_name: String,
66    },
67    /// A parameter of a scoped callable, in call order. Returned only by
68    /// [`Catalog::lookup_within`]; an unscoped [`Catalog::lookup`] never
69    /// answers parameters.
70    Parameter {
71        /// The callable's canonical id.
72        callable: String,
73        /// The parameter's position in call order.
74        position: usize,
75        /// Whether the position must be supplied.
76        required: bool,
77        /// The parameter facts: name, declared type, domain, and default.
78        param: SignatureParam,
79        /// The enum domain this parameter accepts, with the members it
80        /// accepts, when the parameter is enum-typed.
81        domain: Option<SignatureDomain>,
82    },
83    /// The next path segment below a scoped settings prefix — an
84    /// intermediate selector such as `<team>` or `general`, not a leaf
85    /// key. Returned only by [`Catalog::lookup_within`].
86    SettingPath {
87        /// The full child path under the scoped prefix (`heroes.<team>`).
88        path: String,
89        /// The segment text (`<team>`, `general`).
90        segment: String,
91    },
92}
93
94/// A canonical enum member reported by a lookup.
95#[derive(Debug, Clone, PartialEq)]
96#[non_exhaustive]
97pub struct LookupEnumMember {
98    /// The canonical member id (`SOLDIER_76`, `ALL`).
99    pub id: String,
100    /// The member's display name in the requested locale, when mapped.
101    pub display_name: Option<String>,
102}
103
104/// An enum domain referenced by a [`Signature`] parameter, with the members
105/// it accepts.
106#[derive(Debug, Clone, PartialEq)]
107#[non_exhaustive]
108pub struct SignatureDomain {
109    /// The canonical domain name.
110    pub domain: String,
111    /// Every member the domain accepts.
112    pub members: Vec<LookupEnumMember>,
113}
114
115/// A compact signature for a catalog action or value.
116#[derive(Debug, Clone, PartialEq)]
117#[non_exhaustive]
118pub struct Signature {
119    /// The signature rendered in the requested locale's call syntax:
120    /// parameters appear in call order, a parameter without a declared
121    /// default reads `name: Type`, a parameter with a declared default reads
122    /// `name=default` or `name?` when the default is null, and an enum-typed
123    /// parameter without a default reads `name: Domain(members)` listing the
124    /// domain's members once per signature when it has at most
125    /// 32 members, or `name: Domain(count members)` for a larger domain.
126    pub text: String,
127    /// Parameters in call order.
128    pub params: Vec<SignatureParam>,
129    /// The argument count a call must supply: positions below this index
130    /// cannot be omitted; declared defaults only make the suffix optional.
131    pub required_params: usize,
132    /// Whether the final parameter repeats for extra arguments.
133    pub variadic: bool,
134    /// The source-backed return type for values, when declared.
135    pub return_type: Option<String>,
136    /// The enum domains referenced by parameters, each with the members it
137    /// accepts, for follow-up inspection of large domains.
138    pub domains: Vec<SignatureDomain>,
139}
140
141/// One parameter of a [`Signature`].
142#[derive(Debug, Clone, PartialEq)]
143#[non_exhaustive]
144pub struct SignatureParam {
145    /// The semantic parameter name, or the catalog parameter name.
146    pub name: String,
147    /// The source-backed semantic type, when declared.
148    pub param_type: Option<String>,
149    /// The canonical enum domain expected at this position, when declared.
150    pub domain: Option<String>,
151    /// The declared default applied when the argument is omitted. `None`
152    /// marks a parameter with no declared default; a declared default before
153    /// another required position does not make this position omittable (see
154    /// [`Signature::required_params`]).
155    pub default: Option<String>,
156}
157
158impl Catalog {
159    /// Find canonical constructs matching `query` under `locale`.
160    ///
161    /// `query` may be a display name in `locale`, a primary-locale display
162    /// name, a canonical id, a near spelling or guess, or a settings path
163    /// prefix (`gamemodes.control`, `heroes.<team>.<hero>`). Matches rank
164    /// exact spellings first, then near spellings, token prefixes, and
165    /// substring guesses; ties keep catalog and table order. All matches are
166    /// canonical constructs the parser and emitter accept, with the display
167    /// name in `locale` and, for actions and values, the compact signature.
168    ///
169    /// An empty result reports that nothing matched. `Err` reports a query
170    /// the catalog cannot answer: a locale the catalog does not declare.
171    ///
172    /// ```
173    /// use workshop_rs::catalog::{Catalog, Locale};
174    /// use workshop_rs::lookup::LookupMatch;
175    ///
176    /// let catalog = Catalog::builtin().unwrap();
177    /// let matches = catalog.lookup(&Locale::new("en-US"), "create hud txt").unwrap();
178    /// assert!(matches.iter().any(|m| matches!(
179    ///     m,
180    ///     LookupMatch::Builtin { id, .. } if id == "createHudText"
181    /// )));
182    /// ```
183    pub fn lookup(&self, locale: &Locale, query: &str) -> Result<Vec<LookupMatch>> {
184        if !self.supports(locale) {
185            return Err(WorkshopError::unsupported(
186                format!(
187                    "lookup for locale '{locale}' is not supported: \
188                     the catalog does not declare it"
189                ),
190                None,
191            ));
192        }
193        let primary = self.primary_locale();
194        let distinct_locale = locale != primary;
195        let prepared = PreparedQuery::new(query);
196        let query_segments: Vec<&str> = query.split('.').collect();
197        let mut matches: Vec<(u32, LookupMatch)> = Vec::new();
198        for entry in &self.entries {
199            let primary_spellings: &[String] = if distinct_locale {
200                entry.spellings(primary)
201            } else {
202                &[]
203            };
204            let score = std::iter::once(entry.id.as_str())
205                .chain(entry.spellings(locale).iter().map(String::as_str))
206                .chain(primary_spellings.iter().map(String::as_str))
207                .filter_map(|text| prepared.name_score(text))
208                .min();
209            if let Some(score) = score {
210                matches.push((
211                    score,
212                    LookupMatch::Builtin {
213                        kind: entry.kind,
214                        id: entry.id.clone(),
215                        display_name: entry.spelling(locale).map(String::from),
216                        signature: signature(self, entry, locale),
217                    },
218                ));
219            }
220        }
221        for domain in self.enum_domains() {
222            let score = std::iter::once(domain.domain.as_str())
223                .chain(domain.spelling(locale))
224                .chain(domain.spelling(primary).filter(|_| distinct_locale))
225                .filter_map(|text| prepared.name_score(text))
226                .min();
227            if let Some(score) = score {
228                matches.push((
229                    score,
230                    LookupMatch::EnumDomain {
231                        domain: domain.domain.clone(),
232                        display_name: domain.spelling(locale).map(String::from),
233                        members: self.enum_members(&domain.domain, locale),
234                    },
235                ));
236            }
237        }
238        for domain in self.enum_domains() {
239            for member in &domain.members {
240                let qualified = format!("{}.{}", domain.domain, member.member);
241                let primary_spellings: &[String] = if distinct_locale {
242                    member.spellings(primary)
243                } else {
244                    &[]
245                };
246                let score = std::iter::once(member.member.as_str())
247                    .chain(std::iter::once(qualified.as_str()))
248                    .chain(member.spellings(locale).iter().map(String::as_str))
249                    .chain(primary_spellings.iter().map(String::as_str))
250                    .filter_map(|text| prepared.name_score(text))
251                    .min();
252                if let Some(score) = score {
253                    matches.push((
254                        score,
255                        LookupMatch::EnumMember {
256                            domain: domain.domain.clone(),
257                            member: member.member.clone(),
258                            display_name: self
259                                .enum_spelling(&domain.domain, locale, &member.member)
260                                .map(String::from),
261                        },
262                    ));
263                }
264            }
265        }
266        for definition in settings::definitions() {
267            let name_texts = [
268                Some(definition.path().rsplit('.').next().unwrap_or_default()),
269                Some(definition.presentation().english_name),
270                definition.presentation().localized_name(locale.as_str()),
271                definition.id().map(|id| id.as_str()),
272                Some(definition.path()),
273            ];
274            let score = name_texts
275                .into_iter()
276                .flatten()
277                .filter_map(|text| prepared.name_score(text))
278                .min()
279                .into_iter()
280                .chain(path_prefix_score(&query_segments, definition.path()))
281                .min();
282            if let Some(score) = score {
283                matches.push((
284                    score,
285                    LookupMatch::Setting {
286                        display_name: definition
287                            .presentation()
288                            .localized_name(locale.as_str())
289                            .unwrap_or(definition.presentation().english_name)
290                            .to_string(),
291                        definition,
292                    },
293                ));
294            }
295        }
296        // Stable sort: equal-ranked matches keep catalog and table order.
297        matches.sort_by_key(|(score, _)| *score);
298        Ok(matches.into_iter().map(|(_, lookup)| lookup).collect())
299    }
300
301    /// The entries `within` contains under `locale` — the members of an
302    /// enum domain, the parameters of a callable, or the settings keys
303    /// and segments under a path prefix — optionally filtered and ranked
304    /// by `query` with the same matcher an unscoped [`Catalog::lookup`]
305    /// uses.
306    ///
307    /// `within` resolves in a fixed order: an enum domain (catalog or
308    /// settings-table), an action or value id or `locale` spelling, then
309    /// a settings path prefix. Template segments (`<team>`, `<hero>`)
310    /// accept their own template spelling or a canonical team/hero key
311    /// the settings table recognizes — never an arbitrary value — so a
312    /// literal path stays literal (`heroes.general` lists only the
313    /// `general` group's children). With no `query`, or an
314    /// empty one, children keep the scope's own order — domain order for
315    /// members, call order for parameters, table order for settings — and
316    /// a non-empty query filters and ranks them by the same scoring an
317    /// unscoped lookup applies to its candidates.
318    ///
319    /// `Err` reports a locale the catalog does not declare or a `within`
320    /// naming no known scope; an empty result reports a scope whose
321    /// children do not match `query`.
322    ///
323    /// ```
324    /// use workshop_rs::catalog::{Catalog, Locale};
325    /// use workshop_rs::lookup::LookupMatch;
326    ///
327    /// let catalog = Catalog::builtin().unwrap();
328    /// let members = catalog
329    ///     .lookup_within(&Locale::new("en-US"), "Team", None)
330    ///     .unwrap();
331    /// assert!(members.iter().all(|m| matches!(m, LookupMatch::EnumMember { domain, .. } if domain == "Team")));
332    /// ```
333    pub fn lookup_within(
334        &self,
335        locale: &Locale,
336        within: &str,
337        query: Option<&str>,
338    ) -> Result<Vec<LookupMatch>> {
339        if !self.supports(locale) {
340            return Err(WorkshopError::unsupported(
341                format!(
342                    "lookup for locale '{locale}' is not supported: \
343                     the catalog does not declare it"
344                ),
345                None,
346            ));
347        }
348        let prepared = query
349            .filter(|query| !query.is_empty())
350            .map(PreparedQuery::new);
351        let mut scored = self
352            .within_enum_domain(locale, within, prepared.as_ref())
353            .or_else(|| self.within_settings_enum(locale, within, prepared.as_ref()))
354            .or_else(|| self.within_callable(locale, within, prepared.as_ref()))
355            .or_else(|| within_settings(within, locale, prepared.as_ref()))
356            .ok_or_else(|| WorkshopError::unknown("lookup scope", within, locale.clone(), None))?;
357        if prepared.is_some() {
358            scored.retain(|(score, _)| *score != u32::MAX);
359            scored.sort_by_key(|(score, _)| *score);
360        }
361        Ok(scored.into_iter().map(|(_, lookup)| lookup).collect())
362    }
363
364    /// The members of one catalog enum domain — `value` a canonical domain
365    /// name or a `locale` spelling — scored against `prepared` when given.
366    fn within_enum_domain(
367        &self,
368        locale: &Locale,
369        value: &str,
370        prepared: Option<&PreparedQuery>,
371    ) -> Option<Vec<(u32, LookupMatch)>> {
372        let domain = if self.enum_domain(value).is_some() {
373            Some(value.to_string())
374        } else {
375            self.resolve_enum_domain(locale, value).map(str::to_string)
376        }?;
377        let domain = self.enum_domain(&domain)?;
378        let primary = self.primary_locale();
379        let distinct_locale = locale != primary;
380        Some(
381            domain
382                .members
383                .iter()
384                .map(|member| {
385                    let qualified = format!("{}.{}", domain.domain, member.member);
386                    let mut texts = vec![member.member.as_str(), qualified.as_str()];
387                    texts.extend(member.spellings(locale).iter().map(String::as_str));
388                    if distinct_locale {
389                        texts.extend(member.spellings(primary).iter().map(String::as_str));
390                    }
391                    (
392                        child_score(prepared, texts.into_iter()),
393                        LookupMatch::EnumMember {
394                            domain: domain.domain.clone(),
395                            member: member.member.clone(),
396                            display_name: self
397                                .enum_spelling(&domain.domain, locale, &member.member)
398                                .map(String::from),
399                        },
400                    )
401                })
402                .collect(),
403        )
404    }
405
406    /// The members of one settings-table enum domain — `value` a domain
407    /// name such as `mapRotation` — scored against `prepared` when given.
408    /// Member spellings in `locale` match and surface as the display name
409    /// the same way catalog enum members do.
410    fn within_settings_enum(
411        &self,
412        locale: &Locale,
413        value: &str,
414        prepared: Option<&PreparedQuery>,
415    ) -> Option<Vec<(u32, LookupMatch)>> {
416        let mut seen = std::collections::HashSet::new();
417        let mut members = Vec::new();
418        for definition in settings::definitions() {
419            let SettingValueDomain::Enum { domain } = definition.domain() else {
420                continue;
421            };
422            if domain.as_str() != value {
423                continue;
424            }
425            for member in definition.enum_members() {
426                if !seen.insert((member.domain().to_string(), member.id().to_string())) {
427                    continue;
428                }
429                let localized =
430                    table::localized_name(locale.as_str(), "enums", member.english_name());
431                let qualified = format!("{}.{}", member.domain(), member.id());
432                members.push((
433                    child_score(
434                        prepared,
435                        [
436                            member.id(),
437                            qualified.as_str(),
438                            member.english_name(),
439                            localized.unwrap_or_default(),
440                        ]
441                        .into_iter(),
442                    ),
443                    LookupMatch::EnumMember {
444                        domain: member.domain().to_string(),
445                        member: member.id().to_string(),
446                        display_name: Some(localized.unwrap_or(member.english_name()).to_string()),
447                    },
448                ));
449            }
450        }
451        (!members.is_empty()).then_some(members)
452    }
453
454    /// The parameters of one callable — `value` a canonical id or a
455    /// `locale` spelling — in call order, scored against `prepared` when
456    /// given.
457    fn within_callable(
458        &self,
459        locale: &Locale,
460        value: &str,
461        prepared: Option<&PreparedQuery>,
462    ) -> Option<Vec<(u32, LookupMatch)>> {
463        let entry = [Kind::Action, Kind::Value].into_iter().find_map(|kind| {
464            self.entry(kind, value)
465                .or_else(|| self.resolve(kind, locale, value))
466        })?;
467        let signature = signature(self, entry, locale)?;
468        Some(
469            signature
470                .params
471                .iter()
472                .enumerate()
473                .map(|(position, param)| {
474                    let qualified = format!("{}.{}", entry.id, param.name);
475                    (
476                        child_score(
477                            prepared,
478                            [param.name.as_str(), qualified.as_str()].into_iter(),
479                        ),
480                        LookupMatch::Parameter {
481                            callable: entry.id.clone(),
482                            position,
483                            required: position < signature.required_params,
484                            param: param.clone(),
485                            domain: param.domain.as_ref().and_then(|name| {
486                                signature
487                                    .domains
488                                    .iter()
489                                    .find(|domain| &domain.domain == name)
490                                    .cloned()
491                            }),
492                        },
493                    )
494                })
495                .collect(),
496        )
497    }
498
499    /// The members of `domain` with their display names under `locale`.
500    fn enum_members(&self, domain: &str, locale: &Locale) -> Vec<LookupEnumMember> {
501        self.enum_domain(domain)
502            .map(|domain| {
503                domain
504                    .members
505                    .iter()
506                    .map(|member| LookupEnumMember {
507                        id: member.member.clone(),
508                        display_name: self
509                            .enum_spelling(&domain.domain, locale, &member.member)
510                            .map(String::from),
511                    })
512                    .collect()
513            })
514            .unwrap_or_default()
515    }
516}
517
518/// The [`Signature`] for a catalog `entry` under `locale`, when the entry is
519/// an action or value.
520fn signature(catalog: &Catalog, entry: &CatalogEntry, locale: &Locale) -> Option<Signature> {
521    if !matches!(entry.kind, Kind::Action | Kind::Value) {
522        return None;
523    }
524    let params: Vec<SignatureParam> = (0..entry.param_count())
525        .map(|index| SignatureParam {
526            name: entry.param_name(index).unwrap_or_default().to_string(),
527            param_type: entry.param_type(index).map(String::from),
528            domain: entry.param_domain(index).map(String::from),
529            default: entry.param_default(index).map(String::from),
530        })
531        .collect();
532    let mut domains = Vec::new();
533    for param in &params {
534        let Some(domain) = &param.domain else {
535            continue;
536        };
537        if domains
538            .iter()
539            .any(|known: &SignatureDomain| known.domain == *domain)
540        {
541            continue;
542        }
543        domains.push(SignatureDomain {
544            domain: domain.clone(),
545            members: catalog.enum_members(domain, locale),
546        });
547    }
548    Some(Signature {
549        text: signature_text(catalog, entry, locale, &params),
550        params,
551        required_params: entry.required_param_count(),
552        variadic: entry.is_variadic(),
553        return_type: entry.return_type().map(String::from),
554        domains,
555    })
556}
557
558/// Render `entry`'s call signature in `locale`'s call syntax, following the
559/// shared signature form: `name: Type` without a declared default,
560/// `name=default` or `name?` with one, and `name: Domain(members|count)` for
561/// an enum-typed parameter without a default.
562fn signature_text(
563    catalog: &Catalog,
564    entry: &CatalogEntry,
565    locale: &Locale,
566    params: &[SignatureParam],
567) -> String {
568    let display = entry.spelling(locale).unwrap_or(entry.id.as_str());
569    let mut listed_domains = std::collections::HashSet::new();
570    let mut rendered: Vec<String> = Vec::with_capacity(params.len());
571    for param in params {
572        rendered.push(match &param.default {
573            Some(default) if default == "null" => format!("{}?", param.name),
574            Some(default) => format!(
575                "{}={}",
576                param.name,
577                render_default(catalog, locale, default)
578            ),
579            None => match &param.domain {
580                Some(domain) => {
581                    let Some(domain_entry) = catalog.enum_domain(domain) else {
582                        match &param.param_type {
583                            Some(param_type) => {
584                                rendered.push(format!("{}: {}", param.name, param_type));
585                            }
586                            None => rendered.push(param.name.clone()),
587                        }
588                        continue;
589                    };
590                    let domain_display = domain_entry
591                        .spelling(locale)
592                        .unwrap_or(domain_entry.domain.as_str());
593                    if !listed_domains.insert(domain.clone()) {
594                        format!("{}: {}", param.name, domain_display)
595                    } else if domain_entry.members.len() <= INLINE_MEMBER_LIMIT {
596                        let members = domain_entry
597                            .members
598                            .iter()
599                            .map(|member| {
600                                catalog
601                                    .enum_spelling(&domain_entry.domain, locale, &member.member)
602                                    .unwrap_or(member.member.as_str())
603                            })
604                            .collect::<Vec<_>>()
605                            .join("|");
606                        format!("{}: {}({})", param.name, domain_display, members)
607                    } else {
608                        format!(
609                            "{}: {}({} members)",
610                            param.name,
611                            domain_display,
612                            domain_entry.members.len()
613                        )
614                    }
615                }
616                None => match &param.param_type {
617                    Some(param_type) => format!("{}: {}", param.name, param_type),
618                    None => param.name.clone(),
619                },
620            },
621        });
622    }
623    if entry.is_variadic() {
624        rendered.push("...".to_string());
625    }
626    format!("{}({})", display, rendered.join(", "))
627}
628
629/// Render a declared parameter default in `locale`'s call syntax: a
630/// `Domain.Member` literal resolves to the localized emitted member form
631/// (constructor-form domains write `Domain(Member)`, other domains write
632/// the member bare), a canonical value id resolves to its localized
633/// spelling, and any other literal stays as declared.
634fn render_default(catalog: &Catalog, locale: &Locale, default: &str) -> String {
635    if let Some((domain, member)) = default.split_once('.') {
636        if let Some(member_spelling) = catalog.enum_spelling(domain, locale, member) {
637            return catalog.enum_member_form(domain, member_spelling, locale);
638        }
639    }
640    catalog
641        .spelling(Kind::Value, locale, default)
642        .unwrap_or(default)
643        .to_string()
644}
645
646/// The best [`PreparedQuery::name_score`] over `texts`, or `u32::MAX` when
647/// there is no prepared query or no text matched; scoped lookups drop
648/// `u32::MAX` children only when a query is present.
649fn child_score<'a>(prepared: Option<&PreparedQuery>, texts: impl Iterator<Item = &'a str>) -> u32 {
650    prepared
651        .and_then(|prepared| texts.filter_map(|text| prepared.name_score(text)).min())
652        .unwrap_or(u32::MAX)
653}
654
655/// Whether one declared path segment accepts the asked `within` segment:
656/// a literal key matches exactly; a `<team>`/`<hero>` template slot
657/// accepts its own template spelling or a canonical team/hero key the
658/// settings table recognizes (`allTeams`, `team1`, `mei`), never an
659/// arbitrary value.
660fn declared_segment_accepts(declared: PathPart<'_>, asked: &str) -> bool {
661    match declared {
662        PathPart::Part(name) => name == asked,
663        PathPart::Team => asked == "<team>" || table::team_name(asked).is_some(),
664        PathPart::Hero => asked == "<hero>" || table::hero_name(asked).is_some(),
665    }
666}
667
668/// The immediate settings children under `prefix`: leaf keys and the next
669/// path segment, matched segment by segment against the canonical paths.
670/// An empty `prefix` lists the root. `None` reports a prefix naming no
671/// known scope.
672fn within_settings(
673    prefix: &str,
674    locale: &Locale,
675    prepared: Option<&PreparedQuery>,
676) -> Option<Vec<(u32, LookupMatch)>> {
677    let prefix_segments: Vec<&str> = if prefix.is_empty() {
678        Vec::new()
679    } else {
680        prefix.split('.').collect()
681    };
682    let mut seen = std::collections::HashSet::new();
683    let mut children = Vec::new();
684    let mut known = prefix.is_empty();
685    for definition in settings::definitions() {
686        let parts = definition.path_parts();
687        if parts.len() < prefix_segments.len() {
688            continue;
689        }
690        let matches_prefix = prefix_segments
691            .iter()
692            .zip(parts.iter())
693            .all(|(asked, declared)| declared_segment_accepts(*declared, asked));
694        if !matches_prefix {
695            continue;
696        }
697        known = true;
698        if parts.len() == prefix_segments.len() {
699            // The prefix names this leaf itself — an existing but empty
700            // scope, not an unknown one.
701            continue;
702        }
703        let segment = match &parts[prefix_segments.len()] {
704            PathPart::Part(name) => *name,
705            PathPart::Team => "<team>",
706            PathPart::Hero => "<hero>",
707        };
708        let child_path = if prefix.is_empty() {
709            segment.to_string()
710        } else {
711            format!("{prefix}.{segment}")
712        };
713        if parts.len() == prefix_segments.len() + 1 {
714            if !seen.insert(definition.path().to_string()) {
715                continue;
716            }
717            let texts = [
718                Some(definition.path().rsplit('.').next().unwrap_or_default()),
719                Some(definition.presentation().english_name),
720                definition.presentation().localized_name(locale.as_str()),
721                definition.id().map(|id| id.as_str()),
722                Some(definition.path()),
723            ];
724            children.push((
725                child_score(prepared, texts.into_iter().flatten()),
726                LookupMatch::Setting {
727                    display_name: definition
728                        .presentation()
729                        .localized_name(locale.as_str())
730                        .unwrap_or(definition.presentation().english_name)
731                        .to_string(),
732                    definition,
733                },
734            ));
735        } else if seen.insert(child_path.clone()) {
736            children.push((
737                child_score(prepared, [segment, child_path.as_str()].into_iter()),
738                LookupMatch::SettingPath {
739                    path: child_path,
740                    segment: segment.to_string(),
741                },
742            ));
743        }
744    }
745    known.then_some(children)
746}
747
748/// A lookup query prepared once for comparison against every candidate:
749/// the raw text, its comparison fold, and its folded tokens.
750struct PreparedQuery {
751    raw: String,
752    folded: String,
753    folded_chars: Vec<char>,
754    tokens: Vec<String>,
755    max_distance: usize,
756}
757
758impl PreparedQuery {
759    fn new(query: &str) -> Self {
760        let folded = suggest::fold_loose(query);
761        let folded_chars: Vec<char> = folded.chars().collect();
762        Self {
763            raw: query.to_string(),
764            max_distance: suggest::max_distance(folded_chars.len()),
765            folded,
766            folded_chars,
767            tokens: query
768                .split(|character: char| !character.is_alphanumeric())
769                .map(suggest::fold_loose)
770                .filter(|token| !token.is_empty())
771                .collect(),
772        }
773    }
774
775    /// How closely the query resembles `candidate`: exact equality ranks 0,
776    /// a comparison-fold equality (case, accents, punctuation, and
777    /// whitespace are insignificant) ranks 1, a small edit distance ranks
778    /// 2 + distance, a token prefix match ranks 6, and a substring guess
779    /// ranks 8. Guesses shorter than three folded characters only rank on
780    /// exact or near forms so that vague queries do not flood the result.
781    fn name_score(&self, candidate: &str) -> Option<u32> {
782        if self.raw == candidate {
783            return Some(0);
784        }
785        let candidate_folded = fold_candidate(candidate);
786        if self.folded.is_empty() || candidate_folded.is_empty() {
787            return None;
788        }
789        if self.folded == candidate_folded {
790            return Some(1);
791        }
792        if candidate_folded
793            .chars()
794            .count()
795            .abs_diff(self.folded_chars.len())
796            <= self.max_distance
797        {
798            let candidate_chars: Vec<char> = candidate_folded.chars().collect();
799            let distance =
800                suggest::edit_distance(&self.folded_chars, &candidate_chars, self.max_distance);
801            if distance <= self.max_distance {
802                return Some(2 + distance as u32);
803            }
804        }
805        if self.folded_chars.len() >= 3 {
806            if self.token_prefix_match(candidate) {
807                return Some(6);
808            }
809            if candidate_folded.contains(&self.folded) {
810                return Some(8);
811            }
812        }
813        None
814    }
815
816    /// Whether every whitespace/punctuation-separated query token is a
817    /// prefix of some `candidate` token (`hud text` matches
818    /// `Create HUD Text`, `create hud` matches `Create HUD Text`).
819    fn token_prefix_match(&self, candidate: &str) -> bool {
820        let candidate_tokens = candidate
821            .split(|character: char| !character.is_alphanumeric())
822            .map(fold_candidate)
823            .filter(|token| !token.is_empty());
824        !self.tokens.is_empty()
825            && self.tokens.iter().all(|token| {
826                candidate_tokens
827                    .clone()
828                    .any(|candidate| candidate.starts_with(token))
829            })
830    }
831}
832
833/// The [`suggest::fold_loose`] of `candidate`, fast-pathed for ASCII
834/// spellings (the overwhelmingly common catalog and table form).
835fn fold_candidate(candidate: &str) -> String {
836    if candidate.is_ascii() {
837        candidate
838            .bytes()
839            .filter(u8::is_ascii_alphanumeric)
840            .map(|byte| char::from(byte.to_ascii_lowercase()))
841            .collect()
842    } else {
843        suggest::fold_loose(candidate)
844    }
845}
846
847/// Whether `query_segments` prefix-matches `path` segment for segment: a
848/// `<team>` or `<hero>` template segment accepts any query segment, and a
849/// literal segment accepts a case-insensitive equal query segment. A full
850/// match ranks 2; each unmatched trailing path segment lowers the rank.
851fn path_prefix_score(query_segments: &[&str], path: &str) -> Option<u32> {
852    let mut path_segments = path.split('.');
853    let mut consumed = 0usize;
854    for segment in query_segments {
855        match path_segments.next() {
856            Some("<team>") | Some("<hero>") => {}
857            Some(part) if part.eq_ignore_ascii_case(segment) => {}
858            _ => return None,
859        }
860        consumed += 1;
861    }
862    let depth = path.matches('.').count() + 1;
863    Some(2 + (depth - consumed) as u32)
864}