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>,
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 LocalizedString {
93 id: String,
95 display_name: Option<String>,
98 },
99}
100
101#[derive(Debug, Clone, PartialEq)]
103#[non_exhaustive]
104pub struct LookupEnumMember {
105 pub id: String,
107 pub display_name: Option<String>,
109}
110
111#[derive(Debug, Clone, PartialEq)]
114#[non_exhaustive]
115pub struct SignatureDomain {
116 pub domain: String,
118 pub members: Vec<LookupEnumMember>,
120}
121
122#[derive(Debug, Clone, PartialEq)]
124#[non_exhaustive]
125pub struct Signature {
126 pub text: String,
134 pub params: Vec<SignatureParam>,
136 pub required_params: usize,
139 pub variadic: bool,
141 pub return_type: Option<String>,
143 pub domains: Vec<SignatureDomain>,
146}
147
148#[derive(Debug, Clone, PartialEq)]
150#[non_exhaustive]
151pub struct SignatureParam {
152 pub name: String,
154 pub param_type: Option<String>,
156 pub domain: Option<String>,
158 pub default: Option<String>,
163}
164
165impl Catalog {
166 pub fn lookup(&self, locale: &Locale, query: &str) -> Result<Vec<LookupMatch>> {
191 self.check_locale(locale)?;
192 let primary = self.primary_locale();
193 let distinct_locale = locale != primary;
194 let prepared = PreparedQuery::new(query);
195 let query_segments: Vec<&str> = query.split('.').collect();
196 let mut matches: Vec<(u32, LookupMatch)> = Vec::new();
197 for entry in &self.entries {
198 let primary_spellings: &[String] = if distinct_locale {
199 entry.spellings(primary)
200 } else {
201 &[]
202 };
203 let score = std::iter::once(entry.id.as_str())
204 .chain(entry.spellings(locale).iter().map(String::as_str))
205 .chain(primary_spellings.iter().map(String::as_str))
206 .filter_map(|text| prepared.name_score(text))
207 .min();
208 if let Some(score) = score {
209 matches.push((score, self.builtin_match(entry, locale)));
210 }
211 }
212 for entry in &self.localized_strings {
213 let primary_spellings: &[String] = if distinct_locale {
214 entry.spellings(primary)
215 } else {
216 &[]
217 };
218 let score = std::iter::once(entry.id.as_str())
219 .chain(entry.spellings(locale).iter().map(String::as_str))
220 .chain(primary_spellings.iter().map(String::as_str))
221 .filter_map(|text| prepared.name_score(text))
222 .min();
223 if let Some(score) = score {
224 matches.push((
225 score,
226 LookupMatch::LocalizedString {
227 id: entry.id.clone(),
228 display_name: entry.spelling(locale).map(String::from),
229 },
230 ));
231 }
232 }
233 for domain in self.enum_domains() {
234 let score = std::iter::once(domain.domain.as_str())
235 .chain(domain.spelling(locale))
236 .chain(domain.spelling(primary).filter(|_| distinct_locale))
237 .filter_map(|text| prepared.name_score(text))
238 .min();
239 if let Some(score) = score {
240 matches.push((score, self.enum_domain_match(domain, locale)));
241 }
242 }
243 for domain in self.enum_domains() {
244 for member in &domain.members {
245 let qualified = format!("{}.{}", domain.domain, member.member);
246 let primary_spellings: &[String] = if distinct_locale {
247 member.spellings(primary)
248 } else {
249 &[]
250 };
251 let score = std::iter::once(member.member.as_str())
252 .chain(std::iter::once(qualified.as_str()))
253 .chain(member.spellings(locale).iter().map(String::as_str))
254 .chain(primary_spellings.iter().map(String::as_str))
255 .filter_map(|text| prepared.name_score(text))
256 .min();
257 if let Some(score) = score {
258 matches.push((
259 score,
260 LookupMatch::EnumMember {
261 domain: domain.domain.clone(),
262 member: member.member.clone(),
263 display_name: self
264 .enum_spelling(&domain.domain, locale, &member.member)
265 .map(String::from),
266 },
267 ));
268 }
269 }
270 }
271 for definition in settings::definitions() {
272 let name_texts = [
273 Some(definition.path().rsplit('.').next().unwrap_or_default()),
274 Some(definition.presentation().english_name),
275 definition.presentation().localized_name(locale.as_str()),
276 definition.id().map(|id| id.as_str()),
277 Some(definition.path()),
278 ];
279 let score = name_texts
280 .into_iter()
281 .flatten()
282 .filter_map(|text| prepared.name_score(text))
283 .min()
284 .into_iter()
285 .chain(path_prefix_score(&query_segments, definition.path()))
286 .min();
287 if let Some(score) = score {
288 matches.push((
289 score,
290 LookupMatch::Setting {
291 display_name: definition
292 .presentation()
293 .localized_name(locale.as_str())
294 .unwrap_or(definition.presentation().english_name)
295 .to_string(),
296 definition,
297 },
298 ));
299 }
300 }
301 matches.sort_by_key(|(score, _)| *score);
302 Ok(matches.into_iter().map(|(_, lookup)| lookup).collect())
303 }
304
305 pub fn lookup_within(
338 &self,
339 locale: &Locale,
340 within: &str,
341 query: Option<&str>,
342 ) -> Result<Vec<LookupMatch>> {
343 self.check_locale(locale)?;
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 pub fn lookup_entries(&self, locale: &Locale, kind: Kind) -> Result<Vec<LookupMatch>> {
391 self.check_locale(locale)?;
392 match kind {
393 Kind::Action | Kind::Value | Kind::Event => Ok(self
394 .entries_of(kind)
395 .map(|entry| self.builtin_match(entry, locale))
396 .collect()),
397 Kind::Enum => Ok(self
398 .enum_domains()
399 .map(|domain| self.enum_domain_match(domain, locale))
400 .collect()),
401 Kind::Structural | Kind::Operator | Kind::Setting => Err(WorkshopError::unsupported(
402 format!(
403 "listing kind '{}' is not supported: \
404 the listable kinds are action, value, event, and enum",
405 kind.as_str()
406 ),
407 None,
408 )),
409 }
410 }
411
412 fn check_locale(&self, locale: &Locale) -> Result<()> {
413 if self.supports(locale) {
414 return Ok(());
415 }
416 Err(WorkshopError::unsupported(
417 format!(
418 "lookup for locale '{locale}' is not supported: \
419 the catalog does not declare it"
420 ),
421 None,
422 ))
423 }
424
425 fn builtin_match(&self, entry: &CatalogEntry, locale: &Locale) -> LookupMatch {
426 LookupMatch::Builtin {
427 kind: entry.kind,
428 id: entry.id.clone(),
429 display_name: entry.spelling(locale).map(String::from),
430 signature: signature(self, entry, 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 = [Kind::Action, Kind::Value].into_iter().find_map(|kind| {
533 self.entry(kind, value)
534 .or_else(|| self.resolve(kind, locale, value))
535 })?;
536 let signature = signature(self, entry, locale)?;
537 Some(
538 signature
539 .params
540 .iter()
541 .enumerate()
542 .map(|(position, param)| {
543 let qualified = format!("{}.{}", entry.id, param.name);
544 (
545 child_score(
546 prepared,
547 [param.name.as_str(), qualified.as_str()].into_iter(),
548 ),
549 LookupMatch::Parameter {
550 callable: entry.id.clone(),
551 position,
552 required: position < signature.required_params,
553 param: param.clone(),
554 domain: param.domain.as_ref().and_then(|name| {
555 signature
556 .domains
557 .iter()
558 .find(|domain| &domain.domain == name)
559 .cloned()
560 }),
561 },
562 )
563 })
564 .collect(),
565 )
566 }
567
568 fn enum_members(&self, domain: &str, locale: &Locale) -> Vec<LookupEnumMember> {
569 self.enum_domain(domain)
570 .map(|domain| {
571 domain
572 .members
573 .iter()
574 .map(|member| LookupEnumMember {
575 id: member.member.clone(),
576 display_name: self
577 .enum_spelling(&domain.domain, locale, &member.member)
578 .map(String::from),
579 })
580 .collect()
581 })
582 .unwrap_or_default()
583 }
584}
585
586fn signature(catalog: &Catalog, entry: &CatalogEntry, locale: &Locale) -> Option<Signature> {
587 if !matches!(entry.kind, Kind::Action | Kind::Value) {
588 return None;
589 }
590 let params: Vec<SignatureParam> = (0..entry.param_count())
591 .map(|index| SignatureParam {
592 name: entry.param_name(index).unwrap_or_default().to_string(),
593 param_type: entry.param_type(index).map(String::from),
594 domain: entry.param_domain(index).map(String::from),
595 default: entry.param_default(index).map(String::from),
596 })
597 .collect();
598 let mut domains = Vec::new();
599 for param in ¶ms {
600 let Some(domain) = ¶m.domain else {
601 continue;
602 };
603 if domains
604 .iter()
605 .any(|known: &SignatureDomain| known.domain == *domain)
606 {
607 continue;
608 }
609 domains.push(SignatureDomain {
610 domain: domain.clone(),
611 members: catalog.enum_members(domain, locale),
612 });
613 }
614 Some(Signature {
615 text: signature_text(catalog, entry, locale, ¶ms),
616 params,
617 required_params: entry.required_param_count(),
618 variadic: entry.is_variadic(),
619 return_type: entry.return_type().map(String::from),
620 domains,
621 })
622}
623
624fn signature_text(
625 catalog: &Catalog,
626 entry: &CatalogEntry,
627 locale: &Locale,
628 params: &[SignatureParam],
629) -> String {
630 let display = entry.spelling(locale).unwrap_or(entry.id.as_str());
631 let mut listed_domains = std::collections::HashSet::new();
632 let mut rendered: Vec<String> = Vec::with_capacity(params.len());
633 for param in params {
634 rendered.push(match ¶m.default {
635 Some(default) if default == "null" => format!("{}?", param.name),
636 Some(default) => format!(
637 "{}={}",
638 param.name,
639 render_default(catalog, locale, default)
640 ),
641 None => match ¶m.domain {
642 Some(domain) => {
643 let Some(domain_entry) = catalog.enum_domain(domain) else {
644 match ¶m.param_type {
645 Some(param_type) => {
646 rendered.push(format!("{}: {}", param.name, param_type));
647 }
648 None => rendered.push(param.name.clone()),
649 }
650 continue;
651 };
652 let domain_display = domain_entry
653 .spelling(locale)
654 .unwrap_or(domain_entry.domain.as_str());
655 if !listed_domains.insert(domain.clone()) {
656 format!("{}: {}", param.name, domain_display)
657 } else if domain_entry.members.len() <= INLINE_MEMBER_LIMIT {
658 let members = domain_entry
659 .members
660 .iter()
661 .map(|member| {
662 catalog
663 .enum_spelling(&domain_entry.domain, locale, &member.member)
664 .unwrap_or(member.member.as_str())
665 })
666 .collect::<Vec<_>>()
667 .join("|");
668 format!("{}: {}({})", param.name, domain_display, members)
669 } else {
670 format!(
671 "{}: {}({} members)",
672 param.name,
673 domain_display,
674 domain_entry.members.len()
675 )
676 }
677 }
678 None => match ¶m.param_type {
679 Some(param_type) => format!("{}: {}", param.name, param_type),
680 None => param.name.clone(),
681 },
682 },
683 });
684 }
685 if entry.is_variadic() {
686 rendered.push("...".to_string());
687 }
688 format!("{}({})", display, rendered.join(", "))
689}
690
691fn render_default(catalog: &Catalog, locale: &Locale, default: &str) -> String {
692 if let Some((domain, member)) = default.split_once('.') {
693 if let Some(member_spelling) = catalog.enum_spelling(domain, locale, member) {
694 return catalog.enum_member_form(domain, member_spelling, locale);
695 }
696 }
697 catalog
698 .spelling(Kind::Value, locale, default)
699 .unwrap_or(default)
700 .to_string()
701}
702
703fn child_score<'a>(prepared: Option<&PreparedQuery>, texts: impl Iterator<Item = &'a str>) -> u32 {
704 prepared
705 .and_then(|prepared| texts.filter_map(|text| prepared.name_score(text)).min())
706 .unwrap_or(u32::MAX)
707}
708
709fn declared_segment_accepts(declared: PathPart<'_>, asked: &str) -> bool {
710 match declared {
711 PathPart::Part(name) => name == asked,
712 PathPart::Team => asked == "<team>" || table::team_name(asked).is_some(),
713 PathPart::Hero => asked == "<hero>" || table::hero_name(asked).is_some(),
714 }
715}
716
717fn within_settings(
718 prefix: &str,
719 locale: &Locale,
720 prepared: Option<&PreparedQuery>,
721) -> Option<Vec<(u32, LookupMatch)>> {
722 let prefix_segments: Vec<&str> = if prefix.is_empty() {
723 Vec::new()
724 } else {
725 prefix.split('.').collect()
726 };
727 let mut seen = std::collections::HashSet::new();
728 let mut children = Vec::new();
729 let mut known = prefix.is_empty();
730 for definition in settings::definitions() {
731 let parts = definition.path_parts();
732 if parts.len() < prefix_segments.len() {
733 continue;
734 }
735 let matches_prefix = prefix_segments
736 .iter()
737 .zip(parts.iter())
738 .all(|(asked, declared)| declared_segment_accepts(*declared, asked));
739 if !matches_prefix {
740 continue;
741 }
742 known = true;
743 if parts.len() == prefix_segments.len() {
744 continue;
745 }
746 let segment = match &parts[prefix_segments.len()] {
747 PathPart::Part(name) => *name,
748 PathPart::Team => "<team>",
749 PathPart::Hero => "<hero>",
750 };
751 let child_path = if prefix.is_empty() {
752 segment.to_string()
753 } else {
754 format!("{prefix}.{segment}")
755 };
756 if parts.len() == prefix_segments.len() + 1 {
757 if !seen.insert(definition.path().to_string()) {
758 continue;
759 }
760 let texts = [
761 Some(definition.path().rsplit('.').next().unwrap_or_default()),
762 Some(definition.presentation().english_name),
763 definition.presentation().localized_name(locale.as_str()),
764 definition.id().map(|id| id.as_str()),
765 Some(definition.path()),
766 ];
767 children.push((
768 child_score(prepared, texts.into_iter().flatten()),
769 LookupMatch::Setting {
770 display_name: definition
771 .presentation()
772 .localized_name(locale.as_str())
773 .unwrap_or(definition.presentation().english_name)
774 .to_string(),
775 definition,
776 },
777 ));
778 } else if seen.insert(child_path.clone()) {
779 children.push((
780 child_score(prepared, [segment, child_path.as_str()].into_iter()),
781 LookupMatch::SettingPath {
782 path: child_path,
783 segment: segment.to_string(),
784 },
785 ));
786 }
787 }
788 known.then_some(children)
789}
790
791struct PreparedQuery {
792 raw: String,
793 folded: String,
794 folded_chars: Vec<char>,
795 tokens: Vec<String>,
796 max_distance: usize,
797 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 {
884 matches!(
885 character,
886 '\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}' )
895}
896
897fn fold_candidate(candidate: &str) -> String {
898 if candidate.is_ascii() {
899 candidate
900 .bytes()
901 .filter(u8::is_ascii_alphanumeric)
902 .map(|byte| char::from(byte.to_ascii_lowercase()))
903 .collect()
904 } else {
905 suggest::fold_loose(candidate)
906 }
907}
908
909fn path_prefix_score(query_segments: &[&str], path: &str) -> Option<u32> {
910 let mut path_segments = path.split('.');
911 let mut consumed = 0usize;
912 for segment in query_segments {
913 match path_segments.next() {
914 Some("<team>") | Some("<hero>") => {}
915 Some(part) if part.eq_ignore_ascii_case(segment) => {}
916 _ => return None,
917 }
918 consumed += 1;
919 }
920 let depth = path.matches('.').count() + 1;
921 Some(2 + (depth - consumed) as u32)
922}