1use 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#[derive(Debug, Clone, PartialEq)]
23#[non_exhaustive]
24pub enum LookupMatch {
25 Builtin {
28 kind: Kind,
30 id: String,
32 display_name: Option<String>,
34 signature: Option<Signature>,
36 },
37 EnumMember {
39 domain: String,
41 member: String,
43 display_name: Option<String>,
45 },
46 EnumDomain {
48 domain: String,
50 display_name: Option<String>,
52 members: Vec<LookupEnumMember>,
54 },
55 Setting {
58 definition: SettingDefinition,
60 display_name: String,
63 },
64 Parameter {
68 callable: String,
70 position: usize,
72 required: bool,
74 param: SignatureParam,
76 domain: Option<SignatureDomain>,
79 },
80 SettingPath {
84 path: String,
86 segment: String,
88 },
89}
90
91#[derive(Debug, Clone, PartialEq)]
93#[non_exhaustive]
94pub struct LookupEnumMember {
95 pub id: String,
97 pub display_name: Option<String>,
99}
100
101#[derive(Debug, Clone, PartialEq)]
104#[non_exhaustive]
105pub struct SignatureDomain {
106 pub domain: String,
108 pub members: Vec<LookupEnumMember>,
110}
111
112#[derive(Debug, Clone, PartialEq)]
114#[non_exhaustive]
115pub struct Signature {
116 pub text: String,
124 pub params: Vec<SignatureParam>,
126 pub required_params: usize,
129 pub variadic: bool,
131 pub return_type: Option<String>,
133 pub domains: Vec<SignatureDomain>,
136}
137
138#[derive(Debug, Clone, PartialEq)]
140#[non_exhaustive]
141pub struct SignatureParam {
142 pub name: String,
144 pub param_type: Option<String>,
146 pub domain: Option<String>,
148 pub default: Option<String>,
153}
154
155impl Catalog {
156 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 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 ¶ms {
518 let Some(domain) = ¶m.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, ¶ms),
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 ¶m.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 ¶m.domain {
560 Some(domain) => {
561 let Some(domain_entry) = catalog.enum_domain(domain) else {
562 match ¶m.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 ¶m.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}