Skip to main content

workshop_rs/
lookup.rs

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