1use crate::catalog::{Catalog, CatalogEntry, EnumDomain, 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>,
37 },
38 EnumMember {
40 domain: String,
42 member: String,
44 display_name: Option<String>,
46 },
47 EnumDomain {
49 domain: String,
51 display_name: Option<String>,
53 members: Vec<LookupEnumMember>,
55 },
56 Setting {
59 definition: SettingDefinition,
61 display_name: String,
64 },
65 Parameter {
69 callable: String,
71 position: usize,
73 required: bool,
75 param: SignatureParam,
77 domain: Option<SignatureDomain>,
80 },
81 SettingPath {
85 path: String,
87 segment: String,
89 },
90 LocalizedString {
94 id: String,
96 display_name: Option<String>,
99 },
100}
101
102#[derive(Debug, Clone, PartialEq)]
104#[non_exhaustive]
105pub struct LookupEnumMember {
106 pub id: String,
108 pub display_name: Option<String>,
110}
111
112#[derive(Debug, Clone, PartialEq)]
115#[non_exhaustive]
116pub struct SignatureDomain {
117 pub domain: String,
119 pub members: Vec<LookupEnumMember>,
121}
122
123#[derive(Debug, Clone, PartialEq)]
127#[non_exhaustive]
128pub struct Signature {
129 pub text: String,
137 pub params: Vec<SignatureParam>,
139 pub required_params: usize,
142 pub variadic: bool,
144 pub return_type: Option<String>,
146 pub domains: Vec<SignatureDomain>,
149}
150
151#[derive(Debug, Clone, PartialEq)]
153#[non_exhaustive]
154pub struct SignatureParam {
155 pub name: String,
157 pub param_type: Option<String>,
159 pub domain: Option<String>,
161 pub default: Option<String>,
166}
167
168impl Catalog {
169 pub fn lookup(&self, locale: &Locale, query: &str) -> Result<Vec<LookupMatch>> {
196 self.check_locale(locale)?;
197 let primary = self.primary_locale();
198 let distinct_locale = locale != primary;
199 let prepared = PreparedQuery::new(query);
200 let query_segments: Vec<&str> = query.split('.').collect();
201 let mut matches: Vec<(u32, LookupMatch)> = Vec::new();
202 for entry in &self.entries {
203 let primary_spellings: &[String] = if distinct_locale {
204 entry.spellings(primary)
205 } else {
206 &[]
207 };
208 let score = std::iter::once(entry.id.as_str())
209 .chain(entry.spellings(locale).iter().map(String::as_str))
210 .chain(primary_spellings.iter().map(String::as_str))
211 .filter_map(|text| prepared.name_score(text))
212 .min();
213 if let Some(score) = score {
214 matches.push((score, self.builtin_match(entry, locale)));
215 }
216 }
217 for entry in &self.localized_strings {
218 let primary_spellings: &[String] = if distinct_locale {
219 entry.spellings(primary)
220 } else {
221 &[]
222 };
223 let score = std::iter::once(entry.id.as_str())
224 .chain(entry.spellings(locale).iter().map(String::as_str))
225 .chain(primary_spellings.iter().map(String::as_str))
226 .filter_map(|text| prepared.name_score(text))
227 .min();
228 if let Some(score) = score {
229 matches.push((
230 score,
231 LookupMatch::LocalizedString {
232 id: entry.id.clone(),
233 display_name: entry.spelling(locale).map(String::from),
234 },
235 ));
236 }
237 }
238 for domain in self.enum_domains() {
239 let score = std::iter::once(domain.domain.as_str())
240 .chain(domain.spelling(locale))
241 .chain(domain.spelling(primary).filter(|_| distinct_locale))
242 .filter_map(|text| prepared.name_score(text))
243 .min();
244 if let Some(score) = score {
245 matches.push((score, self.enum_domain_match(domain, locale)));
246 }
247 }
248 for domain in self.enum_domains() {
249 for member in &domain.members {
250 let qualified = format!("{}.{}", domain.domain, member.member);
251 let primary_spellings: &[String] = if distinct_locale {
252 member.spellings(primary)
253 } else {
254 &[]
255 };
256 let score = std::iter::once(member.member.as_str())
257 .chain(std::iter::once(qualified.as_str()))
258 .chain(member.spellings(locale).iter().map(String::as_str))
259 .chain(primary_spellings.iter().map(String::as_str))
260 .filter_map(|text| prepared.name_score(text))
261 .min();
262 if let Some(score) = score {
263 matches.push((
264 score,
265 LookupMatch::EnumMember {
266 domain: domain.domain.clone(),
267 member: member.member.clone(),
268 display_name: self
269 .enum_spelling(&domain.domain, locale, &member.member)
270 .map(String::from),
271 },
272 ));
273 }
274 }
275 }
276 for definition in settings::definitions() {
277 let name_texts = [
278 Some(definition.path().rsplit('.').next().unwrap_or_default()),
279 Some(definition.presentation().english_name),
280 definition.presentation().localized_name(locale.as_str()),
281 definition.id().map(|id| id.as_str()),
282 Some(definition.path()),
283 ];
284 let score = name_texts
285 .into_iter()
286 .flatten()
287 .filter_map(|text| prepared.name_score(text))
288 .min()
289 .into_iter()
290 .chain(path_prefix_score(&query_segments, definition.path()))
291 .min();
292 if let Some(score) = score {
293 matches.push((
294 score,
295 LookupMatch::Setting {
296 display_name: definition
297 .presentation()
298 .localized_name(locale.as_str())
299 .unwrap_or(definition.presentation().english_name)
300 .to_string(),
301 definition,
302 },
303 ));
304 }
305 }
306 matches.sort_by_key(|(score, _)| *score);
307 Ok(matches.into_iter().map(|(_, lookup)| lookup).collect())
308 }
309
310 pub fn lookup_within(
344 &self,
345 locale: &Locale,
346 within: &str,
347 query: Option<&str>,
348 ) -> Result<Vec<LookupMatch>> {
349 self.check_locale(locale)?;
350 let prepared = query
351 .filter(|query| !query.is_empty())
352 .map(PreparedQuery::new);
353 let mut scored = self
354 .within_enum_domain(locale, within, prepared.as_ref())
355 .or_else(|| self.within_settings_enum(locale, within, prepared.as_ref()))
356 .or_else(|| self.within_callable(locale, within, prepared.as_ref()))
357 .or_else(|| within_settings(within, locale, prepared.as_ref()))
358 .ok_or_else(|| WorkshopError::unknown("lookup scope", within, locale.clone(), None))?;
359 if prepared.is_some() {
360 scored.retain(|(score, _)| *score != u32::MAX);
361 scored.sort_by_key(|(score, _)| *score);
362 }
363 Ok(scored.into_iter().map(|(_, lookup)| lookup).collect())
364 }
365
366 pub fn lookup_entries(&self, locale: &Locale, kind: Kind) -> Result<Vec<LookupMatch>> {
397 self.check_locale(locale)?;
398 match kind {
399 Kind::Action | Kind::Value | Kind::Event => Ok(self
400 .entries_of(kind)
401 .map(|entry| self.builtin_match(entry, locale))
402 .collect()),
403 Kind::Enum => Ok(self
404 .enum_domains()
405 .map(|domain| self.enum_domain_match(domain, locale))
406 .collect()),
407 Kind::Structural | Kind::Operator | Kind::Setting => Err(WorkshopError::unsupported(
408 format!(
409 "listing kind '{}' is not supported: \
410 the listable kinds are action, value, event, and enum",
411 kind.as_str()
412 ),
413 None,
414 )),
415 }
416 }
417
418 fn check_locale(&self, locale: &Locale) -> Result<()> {
419 if self.supports(locale) {
420 return Ok(());
421 }
422 Err(WorkshopError::unsupported(
423 format!(
424 "lookup for locale '{locale}' is not supported: \
425 the catalog does not declare it"
426 ),
427 None,
428 ))
429 }
430
431 fn builtin_match(&self, entry: &CatalogEntry, locale: &Locale) -> LookupMatch {
432 LookupMatch::Builtin {
433 kind: entry.kind,
434 id: entry.id.clone(),
435 display_name: entry.spelling(locale).map(String::from),
436 signature: signature(self, entry, locale),
437 }
438 }
439
440 fn enum_domain_match(&self, domain: &EnumDomain, locale: &Locale) -> LookupMatch {
441 LookupMatch::EnumDomain {
442 domain: domain.domain.clone(),
443 display_name: domain.spelling(locale).map(String::from),
444 members: self.enum_members(&domain.domain, locale),
445 }
446 }
447
448 fn within_enum_domain(
449 &self,
450 locale: &Locale,
451 value: &str,
452 prepared: Option<&PreparedQuery>,
453 ) -> Option<Vec<(u32, LookupMatch)>> {
454 let domain = if self.enum_domain(value).is_some() {
455 Some(value.to_string())
456 } else {
457 self.resolve_enum_domain(locale, value).map(str::to_string)
458 }?;
459 let domain = self.enum_domain(&domain)?;
460 let primary = self.primary_locale();
461 let distinct_locale = locale != primary;
462 Some(
463 domain
464 .members
465 .iter()
466 .map(|member| {
467 let qualified = format!("{}.{}", domain.domain, member.member);
468 let mut texts = vec![member.member.as_str(), qualified.as_str()];
469 texts.extend(member.spellings(locale).iter().map(String::as_str));
470 if distinct_locale {
471 texts.extend(member.spellings(primary).iter().map(String::as_str));
472 }
473 (
474 child_score(prepared, texts.into_iter()),
475 LookupMatch::EnumMember {
476 domain: domain.domain.clone(),
477 member: member.member.clone(),
478 display_name: self
479 .enum_spelling(&domain.domain, locale, &member.member)
480 .map(String::from),
481 },
482 )
483 })
484 .collect(),
485 )
486 }
487
488 fn within_settings_enum(
489 &self,
490 locale: &Locale,
491 value: &str,
492 prepared: Option<&PreparedQuery>,
493 ) -> Option<Vec<(u32, LookupMatch)>> {
494 let mut seen = std::collections::HashSet::new();
495 let mut members = Vec::new();
496 for definition in settings::definitions() {
497 let SettingValueDomain::Enum { domain } = definition.domain() else {
498 continue;
499 };
500 if domain.as_str() != value {
501 continue;
502 }
503 for member in definition.enum_members() {
504 if !seen.insert((member.domain().to_string(), member.id().to_string())) {
505 continue;
506 }
507 let localized =
508 table::localized_name(locale.as_str(), "enums", member.english_name());
509 let qualified = format!("{}.{}", member.domain(), member.id());
510 members.push((
511 child_score(
512 prepared,
513 [
514 member.id(),
515 qualified.as_str(),
516 member.english_name(),
517 localized.unwrap_or_default(),
518 ]
519 .into_iter(),
520 ),
521 LookupMatch::EnumMember {
522 domain: member.domain().to_string(),
523 member: member.id().to_string(),
524 display_name: Some(localized.unwrap_or(member.english_name()).to_string()),
525 },
526 ));
527 }
528 }
529 (!members.is_empty()).then_some(members)
530 }
531
532 fn within_callable(
533 &self,
534 locale: &Locale,
535 value: &str,
536 prepared: Option<&PreparedQuery>,
537 ) -> Option<Vec<(u32, LookupMatch)>> {
538 let (entry, signature) = [Kind::Action, Kind::Value, Kind::Structural]
545 .into_iter()
546 .filter_map(|kind| {
547 self.entry(kind, value)
548 .or_else(|| self.resolve(kind, locale, value))
549 })
550 .find_map(|entry| signature(self, entry, locale).map(|signature| (entry, signature)))?;
551 Some(
552 signature
553 .params
554 .iter()
555 .enumerate()
556 .map(|(position, param)| {
557 let qualified = format!("{}.{}", entry.id, param.name);
558 (
559 child_score(
560 prepared,
561 [param.name.as_str(), qualified.as_str()].into_iter(),
562 ),
563 LookupMatch::Parameter {
564 callable: entry.id.clone(),
565 position,
566 required: position < signature.required_params,
567 param: param.clone(),
568 domain: param.domain.as_ref().and_then(|name| {
569 signature
570 .domains
571 .iter()
572 .find(|domain| &domain.domain == name)
573 .cloned()
574 }),
575 },
576 )
577 })
578 .collect(),
579 )
580 }
581
582 fn enum_members(&self, domain: &str, locale: &Locale) -> Vec<LookupEnumMember> {
583 self.enum_domain(domain)
584 .map(|domain| {
585 domain
586 .members
587 .iter()
588 .map(|member| LookupEnumMember {
589 id: member.member.clone(),
590 display_name: self
591 .enum_spelling(&domain.domain, locale, &member.member)
592 .map(String::from),
593 })
594 .collect()
595 })
596 .unwrap_or_default()
597 }
598}
599
600fn signature(catalog: &Catalog, entry: &CatalogEntry, locale: &Locale) -> Option<Signature> {
601 if !entry.callable {
602 return None;
603 }
604 let params: Vec<SignatureParam> = (0..entry.param_count())
605 .map(|index| SignatureParam {
606 name: entry.param_name(index).unwrap_or_default().to_string(),
607 param_type: entry.param_type(index).map(String::from),
608 domain: entry.param_domain(index).map(String::from),
609 default: entry.param_default(index).map(String::from),
610 })
611 .collect();
612 let mut domains = Vec::new();
613 for param in ¶ms {
614 let Some(domain) = ¶m.domain else {
615 continue;
616 };
617 if domains
618 .iter()
619 .any(|known: &SignatureDomain| known.domain == *domain)
620 {
621 continue;
622 }
623 domains.push(SignatureDomain {
624 domain: domain.clone(),
625 members: catalog.enum_members(domain, locale),
626 });
627 }
628 Some(Signature {
629 text: signature_text(catalog, entry, locale, ¶ms),
630 params,
631 required_params: entry.required_param_count(),
632 variadic: entry.is_variadic(),
633 return_type: entry.return_type().map(String::from),
634 domains,
635 })
636}
637
638fn signature_text(
639 catalog: &Catalog,
640 entry: &CatalogEntry,
641 locale: &Locale,
642 params: &[SignatureParam],
643) -> String {
644 let display = entry.spelling(locale).unwrap_or(entry.id.as_str());
645 let mut listed_domains = std::collections::HashSet::new();
646 let mut rendered: Vec<String> = Vec::with_capacity(params.len());
647 for param in params {
648 rendered.push(match ¶m.default {
649 Some(default) if default == "null" => format!("{}?", param.name),
650 Some(default) => format!(
651 "{}={}",
652 param.name,
653 render_default(catalog, locale, default)
654 ),
655 None => match ¶m.domain {
656 Some(domain) => {
657 let Some(domain_entry) = catalog.enum_domain(domain) else {
658 match ¶m.param_type {
659 Some(param_type) => {
660 rendered.push(format!("{}: {}", param.name, param_type));
661 }
662 None => rendered.push(param.name.clone()),
663 }
664 continue;
665 };
666 let domain_display = domain_entry
667 .spelling(locale)
668 .unwrap_or(domain_entry.domain.as_str());
669 if !listed_domains.insert(domain.clone()) {
670 format!("{}: {}", param.name, domain_display)
671 } else if domain_entry.members.len() <= INLINE_MEMBER_LIMIT {
672 let members = domain_entry
673 .members
674 .iter()
675 .map(|member| {
676 catalog
677 .enum_spelling(&domain_entry.domain, locale, &member.member)
678 .unwrap_or(member.member.as_str())
679 })
680 .collect::<Vec<_>>()
681 .join("|");
682 format!("{}: {}({})", param.name, domain_display, members)
683 } else {
684 format!(
685 "{}: {}({} members)",
686 param.name,
687 domain_display,
688 domain_entry.members.len()
689 )
690 }
691 }
692 None => match ¶m.param_type {
693 Some(param_type) => format!("{}: {}", param.name, param_type),
694 None => param.name.clone(),
695 },
696 },
697 });
698 }
699 if entry.is_variadic() {
700 rendered.push("...".to_string());
701 }
702 format!("{}({})", display, rendered.join(", "))
703}
704
705fn render_default(catalog: &Catalog, locale: &Locale, default: &str) -> String {
706 if let Some((domain, member)) = default.split_once('.') {
707 if let Some(member_spelling) = catalog.enum_spelling(domain, locale, member) {
708 return catalog.enum_member_form(domain, member_spelling, locale);
709 }
710 }
711 catalog
712 .spelling(Kind::Value, locale, default)
713 .unwrap_or(default)
714 .to_string()
715}
716
717fn child_score<'a>(prepared: Option<&PreparedQuery>, texts: impl Iterator<Item = &'a str>) -> u32 {
718 prepared
719 .and_then(|prepared| texts.filter_map(|text| prepared.name_score(text)).min())
720 .unwrap_or(u32::MAX)
721}
722
723fn declared_segment_accepts(declared: PathPart<'_>, asked: &str) -> bool {
724 match declared {
725 PathPart::Part(name) => name == asked,
726 PathPart::Team => asked == "<team>" || table::team_name(asked).is_some(),
727 PathPart::Hero => asked == "<hero>" || table::hero_name(asked).is_some(),
728 }
729}
730
731fn within_settings(
732 prefix: &str,
733 locale: &Locale,
734 prepared: Option<&PreparedQuery>,
735) -> Option<Vec<(u32, LookupMatch)>> {
736 let prefix_segments: Vec<&str> = if prefix.is_empty() {
737 Vec::new()
738 } else {
739 prefix.split('.').collect()
740 };
741 let mut seen = std::collections::HashSet::new();
742 let mut children = Vec::new();
743 let mut known = prefix.is_empty();
744 for definition in settings::definitions() {
745 let parts = definition.path_parts();
746 if parts.len() < prefix_segments.len() {
747 continue;
748 }
749 let matches_prefix = prefix_segments
750 .iter()
751 .zip(parts.iter())
752 .all(|(asked, declared)| declared_segment_accepts(*declared, asked));
753 if !matches_prefix {
754 continue;
755 }
756 known = true;
757 if parts.len() == prefix_segments.len() {
758 continue;
759 }
760 let segment = match &parts[prefix_segments.len()] {
761 PathPart::Part(name) => *name,
762 PathPart::Team => "<team>",
763 PathPart::Hero => "<hero>",
764 };
765 let child_path = if prefix.is_empty() {
766 segment.to_string()
767 } else {
768 format!("{prefix}.{segment}")
769 };
770 if parts.len() == prefix_segments.len() + 1 {
771 if !seen.insert(definition.path().to_string()) {
772 continue;
773 }
774 let texts = [
775 Some(definition.path().rsplit('.').next().unwrap_or_default()),
776 Some(definition.presentation().english_name),
777 definition.presentation().localized_name(locale.as_str()),
778 definition.id().map(|id| id.as_str()),
779 Some(definition.path()),
780 ];
781 children.push((
782 child_score(prepared, texts.into_iter().flatten()),
783 LookupMatch::Setting {
784 display_name: definition
785 .presentation()
786 .localized_name(locale.as_str())
787 .unwrap_or(definition.presentation().english_name)
788 .to_string(),
789 definition,
790 },
791 ));
792 } else if seen.insert(child_path.clone()) {
793 children.push((
794 child_score(prepared, [segment, child_path.as_str()].into_iter()),
795 LookupMatch::SettingPath {
796 path: child_path,
797 segment: segment.to_string(),
798 },
799 ));
800 }
801 }
802 known.then_some(children)
803}
804
805struct PreparedQuery {
806 raw: String,
807 folded: String,
808 folded_chars: Vec<char>,
809 tokens: Vec<String>,
810 max_distance: usize,
811 loose_min_chars: usize,
817}
818
819impl PreparedQuery {
820 fn new(query: &str) -> Self {
821 let folded = suggest::fold_loose(query);
822 let folded_chars: Vec<char> = folded.chars().collect();
823 let loose_min_chars = if folded_chars
824 .iter()
825 .any(|&character| is_unspaced_script(character))
826 {
827 2
828 } else {
829 3
830 };
831 Self {
832 raw: query.to_string(),
833 max_distance: suggest::max_distance(folded_chars.len()),
834 folded,
835 folded_chars,
836 tokens: query
837 .split(|character: char| !character.is_alphanumeric())
838 .map(suggest::fold_loose)
839 .filter(|token| !token.is_empty())
840 .collect(),
841 loose_min_chars,
842 }
843 }
844
845 fn name_score(&self, candidate: &str) -> Option<u32> {
846 if self.raw == candidate {
847 return Some(0);
848 }
849 let candidate_folded = fold_candidate(candidate);
850 if self.folded.is_empty() || candidate_folded.is_empty() {
851 return None;
852 }
853 if self.folded == candidate_folded {
854 return Some(1);
855 }
856 if candidate_folded
857 .chars()
858 .count()
859 .abs_diff(self.folded_chars.len())
860 <= self.max_distance
861 {
862 let candidate_chars: Vec<char> = candidate_folded.chars().collect();
863 let distance =
864 suggest::edit_distance(&self.folded_chars, &candidate_chars, self.max_distance);
865 if distance <= self.max_distance {
866 return Some(2 + distance as u32);
867 }
868 }
869 if self.folded_chars.len() >= self.loose_min_chars {
870 if self.token_prefix_match(candidate) {
871 return Some(6);
872 }
873 if candidate_folded.contains(&self.folded) {
874 return Some(8);
875 }
876 }
877 None
878 }
879
880 fn token_prefix_match(&self, candidate: &str) -> bool {
881 let candidate_tokens = candidate
882 .split(|character: char| !character.is_alphanumeric())
883 .map(fold_candidate)
884 .filter(|token| !token.is_empty());
885 !self.tokens.is_empty()
886 && self.tokens.iter().all(|token| {
887 candidate_tokens
888 .clone()
889 .any(|candidate| candidate.starts_with(token))
890 })
891 }
892}
893
894fn is_unspaced_script(character: char) -> bool {
898 matches!(
899 character,
900 '\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}' )
909}
910
911fn fold_candidate(candidate: &str) -> String {
912 if candidate.is_ascii() {
913 candidate
914 .bytes()
915 .filter(u8::is_ascii_alphanumeric)
916 .map(|byte| char::from(byte.to_ascii_lowercase()))
917 .collect()
918 } else {
919 suggest::fold_loose(candidate)
920 }
921}
922
923fn path_prefix_score(query_segments: &[&str], path: &str) -> Option<u32> {
924 let mut path_segments = path.split('.');
925 let mut consumed = 0usize;
926 for segment in query_segments {
927 match path_segments.next() {
928 Some("<team>") | Some("<hero>") => {}
929 Some(part) if part.eq_ignore_ascii_case(segment) => {}
930 _ => return None,
931 }
932 consumed += 1;
933 }
934 let depth = path.matches('.').count() + 1;
935 Some(2 + (depth - consumed) as u32)
936}