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