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