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, SettingDefinition};
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}
68
69/// A canonical enum member reported by a lookup.
70#[derive(Debug, Clone, PartialEq)]
71#[non_exhaustive]
72pub struct LookupEnumMember {
73    /// The canonical member id (`SOLDIER_76`, `ALL`).
74    pub id: String,
75    /// The member's display name in the requested locale, when mapped.
76    pub display_name: Option<String>,
77}
78
79/// An enum domain referenced by a [`Signature`] parameter, with the members
80/// it accepts.
81#[derive(Debug, Clone, PartialEq)]
82#[non_exhaustive]
83pub struct SignatureDomain {
84    /// The canonical domain name.
85    pub domain: String,
86    /// Every member the domain accepts.
87    pub members: Vec<LookupEnumMember>,
88}
89
90/// A compact signature for a catalog action or value.
91#[derive(Debug, Clone, PartialEq)]
92#[non_exhaustive]
93pub struct Signature {
94    /// The signature rendered in the requested locale's call syntax:
95    /// parameters appear in call order, a parameter without a declared
96    /// default reads `name: Type`, a parameter with a declared default reads
97    /// `name=default` or `name?` when the default is null, and an enum-typed
98    /// parameter without a default reads `name: Domain(members)` listing the
99    /// domain's members once per signature when it has at most
100    /// 32 members, or `name: Domain(count members)` for a larger domain.
101    pub text: String,
102    /// Parameters in call order.
103    pub params: Vec<SignatureParam>,
104    /// The argument count a call must supply: positions below this index
105    /// cannot be omitted; declared defaults only make the suffix optional.
106    pub required_params: usize,
107    /// Whether the final parameter repeats for extra arguments.
108    pub variadic: bool,
109    /// The source-backed return type for values, when declared.
110    pub return_type: Option<String>,
111    /// The enum domains referenced by parameters, each with the members it
112    /// accepts, for follow-up inspection of large domains.
113    pub domains: Vec<SignatureDomain>,
114}
115
116/// One parameter of a [`Signature`].
117#[derive(Debug, Clone, PartialEq)]
118#[non_exhaustive]
119pub struct SignatureParam {
120    /// The semantic parameter name, or the catalog parameter name.
121    pub name: String,
122    /// The source-backed semantic type, when declared.
123    pub param_type: Option<String>,
124    /// The canonical enum domain expected at this position, when declared.
125    pub domain: Option<String>,
126    /// The declared default applied when the argument is omitted. `None`
127    /// marks a parameter with no declared default; a declared default before
128    /// another required position does not make this position omittable (see
129    /// [`Signature::required_params`]).
130    pub default: Option<String>,
131}
132
133impl Catalog {
134    /// Find canonical constructs matching `query` under `locale`.
135    ///
136    /// `query` may be a display name in `locale`, a primary-locale display
137    /// name, a canonical id, a near spelling or guess, or a settings path
138    /// prefix (`gamemodes.control`, `heroes.<team>.<hero>`). Matches rank
139    /// exact spellings first, then near spellings, token prefixes, and
140    /// substring guesses; ties keep catalog and table order. All matches are
141    /// canonical constructs the parser and emitter accept, with the display
142    /// name in `locale` and, for actions and values, the compact signature.
143    ///
144    /// An empty result reports that nothing matched. `Err` reports a query
145    /// the catalog cannot answer: a locale the catalog does not declare.
146    ///
147    /// ```
148    /// use workshop_rs::catalog::{Catalog, Locale};
149    /// use workshop_rs::lookup::LookupMatch;
150    ///
151    /// let catalog = Catalog::builtin().unwrap();
152    /// let matches = catalog.lookup(&Locale::new("en-US"), "create hud txt").unwrap();
153    /// assert!(matches.iter().any(|m| matches!(
154    ///     m,
155    ///     LookupMatch::Builtin { id, .. } if id == "createHudText"
156    /// )));
157    /// ```
158    pub fn lookup(&self, locale: &Locale, query: &str) -> Result<Vec<LookupMatch>> {
159        if !self.supports(locale) {
160            return Err(WorkshopError::unsupported(
161                format!(
162                    "lookup for locale '{locale}' is not supported: \
163                     the catalog does not declare it"
164                ),
165                None,
166            ));
167        }
168        let primary = self.primary_locale();
169        let distinct_locale = locale != primary;
170        let prepared = PreparedQuery::new(query);
171        let query_segments: Vec<&str> = query.split('.').collect();
172        let mut matches: Vec<(u32, LookupMatch)> = Vec::new();
173        for entry in &self.entries {
174            let primary_spellings: &[String] = if distinct_locale {
175                entry.spellings(primary)
176            } else {
177                &[]
178            };
179            let score = std::iter::once(entry.id.as_str())
180                .chain(entry.spellings(locale).iter().map(String::as_str))
181                .chain(primary_spellings.iter().map(String::as_str))
182                .filter_map(|text| prepared.name_score(text))
183                .min();
184            if let Some(score) = score {
185                matches.push((
186                    score,
187                    LookupMatch::Builtin {
188                        kind: entry.kind,
189                        id: entry.id.clone(),
190                        display_name: entry.spelling(locale).map(String::from),
191                        signature: signature(self, entry, locale),
192                    },
193                ));
194            }
195        }
196        for domain in self.enum_domains() {
197            let score = std::iter::once(domain.domain.as_str())
198                .chain(domain.spelling(locale))
199                .chain(domain.spelling(primary).filter(|_| distinct_locale))
200                .filter_map(|text| prepared.name_score(text))
201                .min();
202            if let Some(score) = score {
203                matches.push((
204                    score,
205                    LookupMatch::EnumDomain {
206                        domain: domain.domain.clone(),
207                        display_name: domain.spelling(locale).map(String::from),
208                        members: self.enum_members(&domain.domain, locale),
209                    },
210                ));
211            }
212        }
213        for domain in self.enum_domains() {
214            for member in &domain.members {
215                let qualified = format!("{}.{}", domain.domain, member.member);
216                let primary_spellings: &[String] = if distinct_locale {
217                    member.spellings(primary)
218                } else {
219                    &[]
220                };
221                let score = std::iter::once(member.member.as_str())
222                    .chain(std::iter::once(qualified.as_str()))
223                    .chain(member.spellings(locale).iter().map(String::as_str))
224                    .chain(primary_spellings.iter().map(String::as_str))
225                    .filter_map(|text| prepared.name_score(text))
226                    .min();
227                if let Some(score) = score {
228                    matches.push((
229                        score,
230                        LookupMatch::EnumMember {
231                            domain: domain.domain.clone(),
232                            member: member.member.clone(),
233                            display_name: self
234                                .enum_spelling(&domain.domain, locale, &member.member)
235                                .map(String::from),
236                        },
237                    ));
238                }
239            }
240        }
241        for definition in settings::definitions() {
242            let name_texts = [
243                Some(definition.path().rsplit('.').next().unwrap_or_default()),
244                Some(definition.presentation().english_name),
245                definition.presentation().localized_name(locale.as_str()),
246                definition.id().map(|id| id.as_str()),
247                Some(definition.path()),
248            ];
249            let score = name_texts
250                .into_iter()
251                .flatten()
252                .filter_map(|text| prepared.name_score(text))
253                .min()
254                .into_iter()
255                .chain(path_prefix_score(&query_segments, definition.path()))
256                .min();
257            if let Some(score) = score {
258                matches.push((
259                    score,
260                    LookupMatch::Setting {
261                        display_name: definition
262                            .presentation()
263                            .localized_name(locale.as_str())
264                            .unwrap_or(definition.presentation().english_name)
265                            .to_string(),
266                        definition,
267                    },
268                ));
269            }
270        }
271        // Stable sort: equal-ranked matches keep catalog and table order.
272        matches.sort_by_key(|(score, _)| *score);
273        Ok(matches.into_iter().map(|(_, lookup)| lookup).collect())
274    }
275
276    /// The members of `domain` with their display names under `locale`.
277    fn enum_members(&self, domain: &str, locale: &Locale) -> Vec<LookupEnumMember> {
278        self.enum_domain(domain)
279            .map(|domain| {
280                domain
281                    .members
282                    .iter()
283                    .map(|member| LookupEnumMember {
284                        id: member.member.clone(),
285                        display_name: self
286                            .enum_spelling(&domain.domain, locale, &member.member)
287                            .map(String::from),
288                    })
289                    .collect()
290            })
291            .unwrap_or_default()
292    }
293}
294
295/// The [`Signature`] for a catalog `entry` under `locale`, when the entry is
296/// an action or value.
297fn signature(catalog: &Catalog, entry: &CatalogEntry, locale: &Locale) -> Option<Signature> {
298    if !matches!(entry.kind, Kind::Action | Kind::Value) {
299        return None;
300    }
301    let params: Vec<SignatureParam> = (0..entry.param_count())
302        .map(|index| SignatureParam {
303            name: entry.param_name(index).unwrap_or_default().to_string(),
304            param_type: entry.param_type(index).map(String::from),
305            domain: entry.param_domain(index).map(String::from),
306            default: entry.param_default(index).map(String::from),
307        })
308        .collect();
309    let mut domains = Vec::new();
310    for param in &params {
311        let Some(domain) = &param.domain else {
312            continue;
313        };
314        if domains
315            .iter()
316            .any(|known: &SignatureDomain| known.domain == *domain)
317        {
318            continue;
319        }
320        domains.push(SignatureDomain {
321            domain: domain.clone(),
322            members: catalog.enum_members(domain, locale),
323        });
324    }
325    Some(Signature {
326        text: signature_text(catalog, entry, locale, &params),
327        params,
328        required_params: entry.required_param_count(),
329        variadic: entry.is_variadic(),
330        return_type: entry.return_type().map(String::from),
331        domains,
332    })
333}
334
335/// Render `entry`'s call signature in `locale`'s call syntax, following the
336/// shared signature form: `name: Type` without a declared default,
337/// `name=default` or `name?` with one, and `name: Domain(members|count)` for
338/// an enum-typed parameter without a default.
339fn signature_text(
340    catalog: &Catalog,
341    entry: &CatalogEntry,
342    locale: &Locale,
343    params: &[SignatureParam],
344) -> String {
345    let display = entry.spelling(locale).unwrap_or(entry.id.as_str());
346    let mut listed_domains = std::collections::HashSet::new();
347    let mut rendered: Vec<String> = Vec::with_capacity(params.len());
348    for param in params {
349        rendered.push(match &param.default {
350            Some(default) if default == "null" => format!("{}?", param.name),
351            Some(default) => format!(
352                "{}={}",
353                param.name,
354                render_default(catalog, locale, default)
355            ),
356            None => match &param.domain {
357                Some(domain) => {
358                    let Some(domain_entry) = catalog.enum_domain(domain) else {
359                        match &param.param_type {
360                            Some(param_type) => {
361                                rendered.push(format!("{}: {}", param.name, param_type));
362                            }
363                            None => rendered.push(param.name.clone()),
364                        }
365                        continue;
366                    };
367                    let domain_display = domain_entry
368                        .spelling(locale)
369                        .unwrap_or(domain_entry.domain.as_str());
370                    if !listed_domains.insert(domain.clone()) {
371                        format!("{}: {}", param.name, domain_display)
372                    } else if domain_entry.members.len() <= INLINE_MEMBER_LIMIT {
373                        let members = domain_entry
374                            .members
375                            .iter()
376                            .map(|member| {
377                                catalog
378                                    .enum_spelling(&domain_entry.domain, locale, &member.member)
379                                    .unwrap_or(member.member.as_str())
380                            })
381                            .collect::<Vec<_>>()
382                            .join("|");
383                        format!("{}: {}({})", param.name, domain_display, members)
384                    } else {
385                        format!(
386                            "{}: {}({} members)",
387                            param.name,
388                            domain_display,
389                            domain_entry.members.len()
390                        )
391                    }
392                }
393                None => match &param.param_type {
394                    Some(param_type) => format!("{}: {}", param.name, param_type),
395                    None => param.name.clone(),
396                },
397            },
398        });
399    }
400    if entry.is_variadic() {
401        rendered.push("...".to_string());
402    }
403    format!("{}({})", display, rendered.join(", "))
404}
405
406/// Render a declared parameter default in `locale`'s call syntax: a
407/// `Domain.Member` literal resolves to the localized emitted member form
408/// (constructor-form domains write `Domain(Member)`, other domains write
409/// the member bare), a canonical value id resolves to its localized
410/// spelling, and any other literal stays as declared.
411fn render_default(catalog: &Catalog, locale: &Locale, default: &str) -> String {
412    if let Some((domain, member)) = default.split_once('.') {
413        if let Some(member_spelling) = catalog.enum_spelling(domain, locale, member) {
414            return catalog.enum_member_form(domain, member_spelling, locale);
415        }
416    }
417    catalog
418        .spelling(Kind::Value, locale, default)
419        .unwrap_or(default)
420        .to_string()
421}
422
423/// A lookup query prepared once for comparison against every candidate:
424/// the raw text, its comparison fold, and its folded tokens.
425struct PreparedQuery {
426    raw: String,
427    folded: String,
428    folded_chars: Vec<char>,
429    tokens: Vec<String>,
430    max_distance: usize,
431}
432
433impl PreparedQuery {
434    fn new(query: &str) -> Self {
435        let folded = suggest::fold_loose(query);
436        let folded_chars: Vec<char> = folded.chars().collect();
437        Self {
438            raw: query.to_string(),
439            max_distance: suggest::max_distance(folded_chars.len()),
440            folded,
441            folded_chars,
442            tokens: query
443                .split(|character: char| !character.is_alphanumeric())
444                .map(suggest::fold_loose)
445                .filter(|token| !token.is_empty())
446                .collect(),
447        }
448    }
449
450    /// How closely the query resembles `candidate`: exact equality ranks 0,
451    /// a comparison-fold equality (case, accents, punctuation, and
452    /// whitespace are insignificant) ranks 1, a small edit distance ranks
453    /// 2 + distance, a token prefix match ranks 6, and a substring guess
454    /// ranks 8. Guesses shorter than three folded characters only rank on
455    /// exact or near forms so that vague queries do not flood the result.
456    fn name_score(&self, candidate: &str) -> Option<u32> {
457        if self.raw == candidate {
458            return Some(0);
459        }
460        let candidate_folded = fold_candidate(candidate);
461        if self.folded.is_empty() || candidate_folded.is_empty() {
462            return None;
463        }
464        if self.folded == candidate_folded {
465            return Some(1);
466        }
467        if candidate_folded
468            .chars()
469            .count()
470            .abs_diff(self.folded_chars.len())
471            <= self.max_distance
472        {
473            let candidate_chars: Vec<char> = candidate_folded.chars().collect();
474            let distance =
475                suggest::edit_distance(&self.folded_chars, &candidate_chars, self.max_distance);
476            if distance <= self.max_distance {
477                return Some(2 + distance as u32);
478            }
479        }
480        if self.folded_chars.len() >= 3 {
481            if self.token_prefix_match(candidate) {
482                return Some(6);
483            }
484            if candidate_folded.contains(&self.folded) {
485                return Some(8);
486            }
487        }
488        None
489    }
490
491    /// Whether every whitespace/punctuation-separated query token is a
492    /// prefix of some `candidate` token (`hud text` matches
493    /// `Create HUD Text`, `create hud` matches `Create HUD Text`).
494    fn token_prefix_match(&self, candidate: &str) -> bool {
495        let candidate_tokens = candidate
496            .split(|character: char| !character.is_alphanumeric())
497            .map(fold_candidate)
498            .filter(|token| !token.is_empty());
499        !self.tokens.is_empty()
500            && self.tokens.iter().all(|token| {
501                candidate_tokens
502                    .clone()
503                    .any(|candidate| candidate.starts_with(token))
504            })
505    }
506}
507
508/// The [`suggest::fold_loose`] of `candidate`, fast-pathed for ASCII
509/// spellings (the overwhelmingly common catalog and table form).
510fn fold_candidate(candidate: &str) -> String {
511    if candidate.is_ascii() {
512        candidate
513            .bytes()
514            .filter(u8::is_ascii_alphanumeric)
515            .map(|byte| char::from(byte.to_ascii_lowercase()))
516            .collect()
517    } else {
518        suggest::fold_loose(candidate)
519    }
520}
521
522/// Whether `query_segments` prefix-matches `path` segment for segment: a
523/// `<team>` or `<hero>` template segment accepts any query segment, and a
524/// literal segment accepts a case-insensitive equal query segment. A full
525/// match ranks 2; each unmatched trailing path segment lowers the rank.
526fn path_prefix_score(query_segments: &[&str], path: &str) -> Option<u32> {
527    let mut path_segments = path.split('.');
528    let mut consumed = 0usize;
529    for segment in query_segments {
530        match path_segments.next() {
531            Some("<team>") | Some("<hero>") => {}
532            Some(part) if part.eq_ignore_ascii_case(segment) => {}
533            _ => return None,
534        }
535        consumed += 1;
536    }
537    let depth = path.matches('.').count() + 1;
538    Some(2 + (depth - consumed) as u32)
539}