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