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