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 ¶ms {
311 let Some(domain) = ¶m.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, ¶ms),
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 ¶m.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 ¶m.domain {
357 Some(domain) => {
358 let Some(domain_entry) = catalog.enum_domain(domain) else {
359 match ¶m.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 ¶m.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}