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