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