1use crate::catalog::descriptions::Description;
15use crate::catalog::{Catalog, CatalogEntry, EnumDomain, Kind, Locale, ParamContent};
16use crate::core::error::{Result, WorkshopError};
17use crate::core::suggest;
18use crate::settings::{self, PathPart, SettingDefinition, SettingValueDomain, table};
19
20const INLINE_MEMBER_LIMIT: usize = 32;
21
22#[derive(Debug, Clone, PartialEq)]
24#[non_exhaustive]
25pub enum LookupMatch {
26 Builtin {
29 kind: Kind,
31 id: String,
33 display_name: Option<String>,
35 signature: Option<Signature>,
38 description: Option<Description>,
42 },
43 EnumMember {
45 domain: String,
47 member: String,
49 display_name: Option<String>,
51 },
52 EnumDomain {
54 domain: String,
56 display_name: Option<String>,
58 members: Vec<LookupEnumMember>,
60 },
61 Setting {
64 definition: SettingDefinition,
66 display_name: String,
69 },
70 Parameter {
74 callable: String,
76 position: usize,
78 required: bool,
80 param: SignatureParam,
83 domain: Option<SignatureDomain>,
86 },
87 SettingPath {
91 path: String,
93 segment: String,
95 },
96 LocalizedString {
100 id: String,
102 display_name: Option<String>,
105 },
106}
107
108#[derive(Debug, Clone, PartialEq)]
110#[non_exhaustive]
111pub struct LookupEnumMember {
112 pub id: String,
114 pub display_name: Option<String>,
116}
117
118#[derive(Debug, Clone, PartialEq)]
121#[non_exhaustive]
122pub struct SignatureDomain {
123 pub domain: String,
125 pub members: Vec<LookupEnumMember>,
127}
128
129#[derive(Debug, Clone, PartialEq)]
133#[non_exhaustive]
134pub struct Signature {
135 pub text: String,
143 pub params: Vec<SignatureParam>,
145 pub required_params: usize,
148 pub variadic: bool,
150 pub return_type: Option<String>,
152 pub domains: Vec<SignatureDomain>,
155}
156
157#[derive(Debug, Clone, PartialEq)]
159#[non_exhaustive]
160pub struct SignatureParam {
161 pub name: String,
163 pub param_type: Option<String>,
165 pub domain: Option<String>,
167 pub default: Option<String>,
172 pub description: Option<Description>,
176 pub content: Option<ParamContent>,
182 pub display_name: Option<String>,
191}
192
193impl Catalog {
194 pub fn lookup(&self, locale: &Locale, query: &str) -> Result<Vec<LookupMatch>> {
221 self.check_locale(locale)?;
222 let primary = self.primary_locale();
223 let distinct_locale = locale != primary;
224 let prepared = PreparedQuery::new(query);
225 let query_segments: Vec<&str> = query.split('.').collect();
226 let mut matches: Vec<(u32, LookupMatch)> = Vec::new();
227 for entry in &self.entries {
228 let primary_spellings: &[String] = if distinct_locale {
229 entry.spellings(primary)
230 } else {
231 &[]
232 };
233 let score = std::iter::once(entry.id.as_str())
234 .chain(entry.spellings(locale).iter().map(String::as_str))
235 .chain(primary_spellings.iter().map(String::as_str))
236 .filter_map(|text| prepared.name_score(text))
237 .min();
238 if let Some(score) = score {
239 matches.push((score, self.builtin_match(entry, locale)));
240 }
241 }
242 for entry in &self.localized_strings {
243 let primary_spellings: &[String] = if distinct_locale {
244 entry.spellings(primary)
245 } else {
246 &[]
247 };
248 let score = std::iter::once(entry.id.as_str())
249 .chain(entry.spellings(locale).iter().map(String::as_str))
250 .chain(primary_spellings.iter().map(String::as_str))
251 .filter_map(|text| prepared.name_score(text))
252 .min();
253 if let Some(score) = score {
254 matches.push((
255 score,
256 LookupMatch::LocalizedString {
257 id: entry.id.clone(),
258 display_name: entry.spelling(locale).map(String::from),
259 },
260 ));
261 }
262 }
263 for domain in self.enum_domains() {
264 let score = std::iter::once(domain.domain.as_str())
265 .chain(domain.spelling(locale))
266 .chain(domain.spelling(primary).filter(|_| distinct_locale))
267 .filter_map(|text| prepared.name_score(text))
268 .min();
269 if let Some(score) = score {
270 matches.push((score, self.enum_domain_match(domain, locale)));
271 }
272 }
273 for domain in self.enum_domains() {
274 for member in &domain.members {
275 let qualified = format!("{}.{}", domain.domain, member.member);
276 let primary_spellings: &[String] = if distinct_locale {
277 member.spellings(primary)
278 } else {
279 &[]
280 };
281 let score = std::iter::once(member.member.as_str())
282 .chain(std::iter::once(qualified.as_str()))
283 .chain(member.spellings(locale).iter().map(String::as_str))
284 .chain(primary_spellings.iter().map(String::as_str))
285 .filter_map(|text| prepared.name_score(text))
286 .min();
287 if let Some(score) = score {
288 matches.push((
289 score,
290 LookupMatch::EnumMember {
291 domain: domain.domain.clone(),
292 member: member.member.clone(),
293 display_name: self
294 .enum_spelling(&domain.domain, locale, &member.member)
295 .map(String::from),
296 },
297 ));
298 }
299 }
300 }
301 for definition in settings::definitions() {
302 let name_texts = [
303 Some(definition.path().rsplit('.').next().unwrap_or_default()),
304 Some(definition.presentation().english_name),
305 definition.presentation().localized_name(locale.as_str()),
306 definition.id().map(|id| id.as_str()),
307 Some(definition.path()),
308 ];
309 let score = name_texts
310 .into_iter()
311 .flatten()
312 .filter_map(|text| prepared.name_score(text))
313 .min()
314 .into_iter()
315 .chain(path_prefix_score(&query_segments, definition.path()))
316 .min();
317 if let Some(score) = score {
318 matches.push((
319 score,
320 LookupMatch::Setting {
321 display_name: definition
322 .presentation()
323 .localized_name(locale.as_str())
324 .unwrap_or(definition.presentation().english_name)
325 .to_string(),
326 definition,
327 },
328 ));
329 }
330 }
331 matches.sort_by_key(|(score, _)| *score);
332 Ok(matches.into_iter().map(|(_, lookup)| lookup).collect())
333 }
334
335 pub fn lookup_within(
369 &self,
370 locale: &Locale,
371 within: &str,
372 query: Option<&str>,
373 ) -> Result<Vec<LookupMatch>> {
374 self.check_locale(locale)?;
375 let prepared = query
376 .filter(|query| !query.is_empty())
377 .map(PreparedQuery::new);
378 let mut scored = self
379 .within_enum_domain(locale, within, prepared.as_ref())
380 .or_else(|| self.within_settings_enum(locale, within, prepared.as_ref()))
381 .or_else(|| self.within_callable(locale, within, prepared.as_ref()))
382 .or_else(|| within_settings(within, locale, prepared.as_ref()))
383 .ok_or_else(|| WorkshopError::unknown("lookup scope", within, locale.clone(), None))?;
384 if prepared.is_some() {
385 scored.retain(|(score, _)| *score != u32::MAX);
386 scored.sort_by_key(|(score, _)| *score);
387 }
388 Ok(scored.into_iter().map(|(_, lookup)| lookup).collect())
389 }
390
391 pub fn lookup_entries(&self, locale: &Locale, kind: Kind) -> Result<Vec<LookupMatch>> {
422 self.check_locale(locale)?;
423 match kind {
424 Kind::Action | Kind::Value | Kind::Event => Ok(self
425 .entries_of(kind)
426 .map(|entry| self.builtin_match(entry, locale))
427 .collect()),
428 Kind::Enum => Ok(self
429 .enum_domains()
430 .map(|domain| self.enum_domain_match(domain, locale))
431 .collect()),
432 Kind::Structural | Kind::Operator | Kind::Setting => Err(WorkshopError::unsupported(
433 format!(
434 "listing kind '{}' is not supported: \
435 the listable kinds are action, value, event, and enum",
436 kind.as_str()
437 ),
438 None,
439 )),
440 }
441 }
442
443 fn check_locale(&self, locale: &Locale) -> Result<()> {
444 if self.supports(locale) {
445 return Ok(());
446 }
447 Err(WorkshopError::unsupported(
448 format!(
449 "lookup for locale '{locale}' is not supported: \
450 the catalog does not declare it"
451 ),
452 None,
453 ))
454 }
455
456 fn builtin_match(&self, entry: &CatalogEntry, locale: &Locale) -> LookupMatch {
457 LookupMatch::Builtin {
458 kind: entry.kind,
459 id: entry.id.clone(),
460 display_name: entry.spelling(locale).map(String::from),
461 signature: signature(self, entry, locale),
462 description: self.description(entry.kind, &entry.id, locale),
463 }
464 }
465
466 fn enum_domain_match(&self, domain: &EnumDomain, locale: &Locale) -> LookupMatch {
467 LookupMatch::EnumDomain {
468 domain: domain.domain.clone(),
469 display_name: domain.spelling(locale).map(String::from),
470 members: self.enum_members(&domain.domain, locale),
471 }
472 }
473
474 fn within_enum_domain(
475 &self,
476 locale: &Locale,
477 value: &str,
478 prepared: Option<&PreparedQuery>,
479 ) -> Option<Vec<(u32, LookupMatch)>> {
480 let domain = if self.enum_domain(value).is_some() {
481 Some(value.to_string())
482 } else {
483 self.resolve_enum_domain(locale, value).map(str::to_string)
484 }?;
485 let domain = self.enum_domain(&domain)?;
486 let primary = self.primary_locale();
487 let distinct_locale = locale != primary;
488 Some(
489 domain
490 .members
491 .iter()
492 .map(|member| {
493 let qualified = format!("{}.{}", domain.domain, member.member);
494 let mut texts = vec![member.member.as_str(), qualified.as_str()];
495 texts.extend(member.spellings(locale).iter().map(String::as_str));
496 if distinct_locale {
497 texts.extend(member.spellings(primary).iter().map(String::as_str));
498 }
499 (
500 child_score(prepared, texts.into_iter()),
501 LookupMatch::EnumMember {
502 domain: domain.domain.clone(),
503 member: member.member.clone(),
504 display_name: self
505 .enum_spelling(&domain.domain, locale, &member.member)
506 .map(String::from),
507 },
508 )
509 })
510 .collect(),
511 )
512 }
513
514 fn within_settings_enum(
515 &self,
516 locale: &Locale,
517 value: &str,
518 prepared: Option<&PreparedQuery>,
519 ) -> Option<Vec<(u32, LookupMatch)>> {
520 let mut seen = std::collections::HashSet::new();
521 let mut members = Vec::new();
522 for definition in settings::definitions() {
523 let SettingValueDomain::Enum { domain } = definition.domain() else {
524 continue;
525 };
526 if domain.as_str() != value {
527 continue;
528 }
529 for member in definition.enum_members() {
530 if !seen.insert((member.domain().to_string(), member.id().to_string())) {
531 continue;
532 }
533 let localized =
534 table::localized_name(locale.as_str(), "enums", member.english_name());
535 let qualified = format!("{}.{}", member.domain(), member.id());
536 members.push((
537 child_score(
538 prepared,
539 [
540 member.id(),
541 qualified.as_str(),
542 member.english_name(),
543 localized.unwrap_or_default(),
544 ]
545 .into_iter(),
546 ),
547 LookupMatch::EnumMember {
548 domain: member.domain().to_string(),
549 member: member.id().to_string(),
550 display_name: Some(localized.unwrap_or(member.english_name()).to_string()),
551 },
552 ));
553 }
554 }
555 (!members.is_empty()).then_some(members)
556 }
557
558 fn within_callable(
559 &self,
560 locale: &Locale,
561 value: &str,
562 prepared: Option<&PreparedQuery>,
563 ) -> Option<Vec<(u32, LookupMatch)>> {
564 let (entry, signature) = [Kind::Action, Kind::Value, Kind::Structural]
571 .into_iter()
572 .filter_map(|kind| {
573 self.entry(kind, value)
574 .or_else(|| self.resolve(kind, locale, value))
575 })
576 .find_map(|entry| signature(self, entry, locale).map(|signature| (entry, signature)))?;
577 Some(
578 signature
579 .params
580 .iter()
581 .enumerate()
582 .map(|(position, param)| {
583 let qualified = format!("{}.{}", entry.id, param.name);
584 (
585 child_score(
586 prepared,
587 [param.name.as_str(), qualified.as_str()].into_iter(),
588 ),
589 LookupMatch::Parameter {
590 callable: entry.id.clone(),
591 position,
592 required: position < signature.required_params,
593 param: param.clone(),
594 domain: param.domain.as_ref().and_then(|name| {
595 signature
596 .domains
597 .iter()
598 .find(|domain| &domain.domain == name)
599 .cloned()
600 }),
601 },
602 )
603 })
604 .collect(),
605 )
606 }
607
608 fn enum_members(&self, domain: &str, locale: &Locale) -> Vec<LookupEnumMember> {
609 self.enum_domain(domain)
610 .map(|domain| {
611 domain
612 .members
613 .iter()
614 .map(|member| LookupEnumMember {
615 id: member.member.clone(),
616 display_name: self
617 .enum_spelling(&domain.domain, locale, &member.member)
618 .map(String::from),
619 })
620 .collect()
621 })
622 .unwrap_or_default()
623 }
624}
625
626fn signature(catalog: &Catalog, entry: &CatalogEntry, locale: &Locale) -> Option<Signature> {
627 if !entry.callable {
628 return None;
629 }
630 let params: Vec<SignatureParam> = (0..entry.param_count())
631 .map(|index| SignatureParam {
632 name: entry.param_name(index).unwrap_or_default().to_string(),
633 param_type: entry.param_type(index).map(String::from),
634 domain: entry.param_domain(index).map(String::from),
635 default: entry.param_default(index).map(String::from),
636 description: catalog.param_description(entry.kind, &entry.id, index, locale),
637 content: entry.param_content(index).cloned(),
638 display_name: entry.param_display_name(locale, index).map(String::from),
639 })
640 .collect();
641 let mut domains = Vec::new();
642 for param in ¶ms {
643 let Some(domain) = ¶m.domain else {
644 continue;
645 };
646 if domains
647 .iter()
648 .any(|known: &SignatureDomain| known.domain == *domain)
649 {
650 continue;
651 }
652 domains.push(SignatureDomain {
653 domain: domain.clone(),
654 members: catalog.enum_members(domain, locale),
655 });
656 }
657 Some(Signature {
658 text: signature_text(catalog, entry, locale, ¶ms),
659 params,
660 required_params: entry.required_param_count(),
661 variadic: entry.is_variadic(),
662 return_type: entry.return_type().map(String::from),
663 domains,
664 })
665}
666
667fn signature_text(
668 catalog: &Catalog,
669 entry: &CatalogEntry,
670 locale: &Locale,
671 params: &[SignatureParam],
672) -> String {
673 let display = entry.spelling(locale).unwrap_or(entry.id.as_str());
674 let mut listed_domains = std::collections::HashSet::new();
675 let mut rendered: Vec<String> = Vec::with_capacity(params.len());
676 for param in params {
677 rendered.push(match ¶m.default {
678 Some(default) if default == "null" => format!("{}?", param.name),
679 Some(default) => format!(
680 "{}={}",
681 param.name,
682 render_default(catalog, locale, default)
683 ),
684 None => match ¶m.domain {
685 Some(domain) => {
686 let Some(domain_entry) = catalog.enum_domain(domain) else {
687 match ¶m.param_type {
688 Some(param_type) => {
689 rendered.push(format!("{}: {}", param.name, param_type));
690 }
691 None => rendered.push(param.name.clone()),
692 }
693 continue;
694 };
695 let domain_display = domain_entry
696 .spelling(locale)
697 .unwrap_or(domain_entry.domain.as_str());
698 if !listed_domains.insert(domain.clone()) {
699 format!("{}: {}", param.name, domain_display)
700 } else if domain_entry.members.len() <= INLINE_MEMBER_LIMIT {
701 let members = domain_entry
702 .members
703 .iter()
704 .map(|member| {
705 catalog
706 .enum_spelling(&domain_entry.domain, locale, &member.member)
707 .unwrap_or(member.member.as_str())
708 })
709 .collect::<Vec<_>>()
710 .join("|");
711 format!("{}: {}({})", param.name, domain_display, members)
712 } else {
713 format!(
714 "{}: {}({} members)",
715 param.name,
716 domain_display,
717 domain_entry.members.len()
718 )
719 }
720 }
721 None => match ¶m.param_type {
722 Some(param_type) => format!("{}: {}", param.name, param_type),
723 None => param.name.clone(),
724 },
725 },
726 });
727 }
728 if entry.is_variadic() {
729 rendered.push("...".to_string());
730 }
731 format!("{}({})", display, rendered.join(", "))
732}
733
734fn render_default(catalog: &Catalog, locale: &Locale, default: &str) -> String {
735 if let Some((domain, member)) = default.split_once('.')
736 && let Some(member_spelling) = catalog.enum_spelling(domain, locale, member)
737 {
738 return catalog.enum_member_form(domain, member_spelling, locale);
739 }
740 catalog
741 .spelling(Kind::Value, locale, default)
742 .unwrap_or(default)
743 .to_string()
744}
745
746fn child_score<'a>(prepared: Option<&PreparedQuery>, texts: impl Iterator<Item = &'a str>) -> u32 {
747 prepared
748 .and_then(|prepared| texts.filter_map(|text| prepared.name_score(text)).min())
749 .unwrap_or(u32::MAX)
750}
751
752fn declared_segment_accepts(declared: PathPart<'_>, asked: &str) -> bool {
753 match declared {
754 PathPart::Part(name) => name == asked,
755 PathPart::Team => asked == "<team>" || table::team_name(asked).is_some(),
756 PathPart::Hero => asked == "<hero>" || table::hero_name(asked).is_some(),
757 }
758}
759
760fn within_settings(
761 prefix: &str,
762 locale: &Locale,
763 prepared: Option<&PreparedQuery>,
764) -> Option<Vec<(u32, LookupMatch)>> {
765 let prefix_segments: Vec<&str> = if prefix.is_empty() {
766 Vec::new()
767 } else {
768 prefix.split('.').collect()
769 };
770 let mut seen = std::collections::HashSet::new();
771 let mut children = Vec::new();
772 let mut known = prefix.is_empty();
773 for definition in settings::definitions() {
774 let parts = definition.path_parts();
775 if parts.len() < prefix_segments.len() {
776 continue;
777 }
778 let matches_prefix = prefix_segments
779 .iter()
780 .zip(parts.iter())
781 .all(|(asked, declared)| declared_segment_accepts(*declared, asked));
782 if !matches_prefix {
783 continue;
784 }
785 known = true;
786 if parts.len() == prefix_segments.len() {
787 continue;
788 }
789 let segment = match &parts[prefix_segments.len()] {
790 PathPart::Part(name) => *name,
791 PathPart::Team => "<team>",
792 PathPart::Hero => "<hero>",
793 };
794 let child_path = if prefix.is_empty() {
795 segment.to_string()
796 } else {
797 format!("{prefix}.{segment}")
798 };
799 if parts.len() == prefix_segments.len() + 1 {
800 if !seen.insert(definition.path().to_string()) {
801 continue;
802 }
803 let texts = [
804 Some(definition.path().rsplit('.').next().unwrap_or_default()),
805 Some(definition.presentation().english_name),
806 definition.presentation().localized_name(locale.as_str()),
807 definition.id().map(|id| id.as_str()),
808 Some(definition.path()),
809 ];
810 children.push((
811 child_score(prepared, texts.into_iter().flatten()),
812 LookupMatch::Setting {
813 display_name: definition
814 .presentation()
815 .localized_name(locale.as_str())
816 .unwrap_or(definition.presentation().english_name)
817 .to_string(),
818 definition,
819 },
820 ));
821 } else if seen.insert(child_path.clone()) {
822 children.push((
823 child_score(prepared, [segment, child_path.as_str()].into_iter()),
824 LookupMatch::SettingPath {
825 path: child_path,
826 segment: segment.to_string(),
827 },
828 ));
829 }
830 }
831 known.then_some(children)
832}
833
834struct PreparedQuery {
835 raw: String,
836 folded: String,
837 folded_chars: Vec<char>,
838 tokens: Vec<String>,
839 max_distance: usize,
840 loose_min_chars: usize,
846}
847
848impl PreparedQuery {
849 fn new(query: &str) -> Self {
850 let folded = suggest::fold_loose(query);
851 let folded_chars: Vec<char> = folded.chars().collect();
852 let loose_min_chars = if folded_chars
853 .iter()
854 .any(|&character| is_unspaced_script(character))
855 {
856 2
857 } else {
858 3
859 };
860 Self {
861 raw: query.to_string(),
862 max_distance: suggest::max_distance(folded_chars.len()),
863 folded,
864 folded_chars,
865 tokens: query
866 .split(|character: char| !character.is_alphanumeric())
867 .map(suggest::fold_loose)
868 .filter(|token| !token.is_empty())
869 .collect(),
870 loose_min_chars,
871 }
872 }
873
874 fn name_score(&self, candidate: &str) -> Option<u32> {
875 if self.raw == candidate {
876 return Some(0);
877 }
878 let candidate_folded = fold_candidate(candidate);
879 if self.folded.is_empty() || candidate_folded.is_empty() {
880 return None;
881 }
882 if self.folded == candidate_folded {
883 return Some(1);
884 }
885 if candidate_folded
886 .chars()
887 .count()
888 .abs_diff(self.folded_chars.len())
889 <= self.max_distance
890 {
891 let candidate_chars: Vec<char> = candidate_folded.chars().collect();
892 let distance =
893 suggest::edit_distance(&self.folded_chars, &candidate_chars, self.max_distance);
894 if distance <= self.max_distance {
895 return Some(2 + distance as u32);
896 }
897 }
898 if self.folded_chars.len() >= self.loose_min_chars {
899 if self.token_prefix_match(candidate) {
900 return Some(6);
901 }
902 if candidate_folded.contains(&self.folded) {
903 return Some(8);
904 }
905 }
906 None
907 }
908
909 fn token_prefix_match(&self, candidate: &str) -> bool {
910 let candidate_tokens = candidate
911 .split(|character: char| !character.is_alphanumeric())
912 .map(fold_candidate)
913 .filter(|token| !token.is_empty());
914 !self.tokens.is_empty()
915 && self.tokens.iter().all(|token| {
916 candidate_tokens
917 .clone()
918 .any(|candidate| candidate.starts_with(token))
919 })
920 }
921}
922
923fn is_unspaced_script(character: char) -> bool {
927 matches!(
928 character,
929 '\u{1100}'..='\u{11FF}' | '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{AC00}'..='\u{D7AF}' | '\u{F900}'..='\u{FAFF}' | '\u{FF66}'..='\u{FF9D}' )
938}
939
940fn fold_candidate(candidate: &str) -> String {
941 if candidate.is_ascii() {
942 candidate
943 .bytes()
944 .filter(u8::is_ascii_alphanumeric)
945 .map(|byte| char::from(byte.to_ascii_lowercase()))
946 .collect()
947 } else {
948 suggest::fold_loose(candidate)
949 }
950}
951
952fn path_prefix_score(query_segments: &[&str], path: &str) -> Option<u32> {
953 let mut path_segments = path.split('.');
954 let mut consumed = 0usize;
955 for segment in query_segments {
956 match path_segments.next() {
957 Some("<team>") | Some("<hero>") => {}
958 Some(part) if part.eq_ignore_ascii_case(segment) => {}
959 _ => return None,
960 }
961 consumed += 1;
962 }
963 let depth = path.matches('.').count() + 1;
964 Some(2 + (depth - consumed) as u32)
965}