1use serde::Serialize;
14use workshop_rs::catalog::{Catalog, Kind};
15use workshop_rs::settings::{self, PathPart, SettingValueDomain};
16
17use crate::manifest::{
18 AliasKind, Function, FunctionKind, Manifest, Param, ParamDefault, ReceiverCategory,
19};
20
21pub const DEFAULT_LIMIT: usize = 8;
23
24struct SpecialFunction {
29 name: &'static str,
30 params: &'static [SpecialParam],
31}
32
33struct SpecialParam {
35 name: &'static str,
36 param_type: &'static str,
38 required: bool,
39 default: Option<&'static str>,
41}
42
43const fn required(name: &'static str, param_type: &'static str) -> SpecialParam {
44 SpecialParam {
45 name,
46 param_type,
47 required: true,
48 default: None,
49 }
50}
51
52const fn defaulted(
53 name: &'static str,
54 param_type: &'static str,
55 default: &'static str,
56) -> SpecialParam {
57 SpecialParam {
58 name,
59 param_type,
60 required: false,
61 default: Some(default),
62 }
63}
64
65const fn optional(name: &'static str, param_type: &'static str) -> SpecialParam {
66 SpecialParam {
67 name,
68 param_type,
69 required: false,
70 default: None,
71 }
72}
73
74const SPECIAL_FUNCTIONS: &[SpecialFunction] = &[
77 SpecialFunction {
78 name: "sorted",
79 params: &[
80 required("array", "Array"),
81 optional("key", "Lambda"),
84 ],
85 },
86 SpecialFunction {
87 name: "createWorkshopSetting",
88 params: &[
89 required("type", "Type"),
90 required("category", "CustomStringLiteral"),
91 required("name", "CustomStringLiteral"),
92 required("default", "BoolLiteral|IntLiteral|FloatLiteral|HeroLiteral"),
93 defaulted("sortOrder", "IntLiteral", "0"),
94 ],
95 },
96 SpecialFunction {
97 name: "createWorkshopSettingBool",
98 params: &[
99 required("category", "CustomStringLiteral"),
100 required("name", "CustomStringLiteral"),
101 required("default", "BoolLiteral"),
102 defaulted("sortOrder", "IntLiteral", "0"),
103 ],
104 },
105 SpecialFunction {
106 name: "createWorkshopSettingEnum",
107 params: &[
108 required("category", "CustomStringLiteral"),
109 required("name", "CustomStringLiteral"),
110 required("default", "UnsignedIntLiteral"),
111 required("options", "Array<CustomStringLiteral>"),
112 defaulted("sortOrder", "IntLiteral", "0"),
113 ],
114 },
115 SpecialFunction {
116 name: "createWorkshopSettingInt",
117 params: &[
118 required("category", "CustomStringLiteral"),
119 required("name", "CustomStringLiteral"),
120 required("default", "IntLiteral"),
121 required("min", "IntLiteral"),
122 required("max", "IntLiteral"),
123 defaulted("sortOrder", "IntLiteral", "0"),
124 ],
125 },
126 SpecialFunction {
127 name: "createWorkshopSettingFloat",
128 params: &[
129 required("category", "CustomStringLiteral"),
130 required("name", "CustomStringLiteral"),
131 required("default", "FloatLiteral"),
132 required("min", "FloatLiteral"),
133 required("max", "FloatLiteral"),
134 defaulted("sortOrder", "IntLiteral", "0"),
135 ],
136 },
137 SpecialFunction {
138 name: "createWorkshopSettingHero",
139 params: &[
140 required("category", "CustomStringLiteral"),
141 required("name", "CustomStringLiteral"),
142 required("default", "HeroLiteral"),
143 defaulted("sortOrder", "IntLiteral", "0"),
144 ],
145 },
146];
147
148#[derive(Debug, Clone, PartialEq)]
150pub struct LookupQuery {
151 pub text: String,
156 pub scope: LookupScope,
159 pub within: Option<LookupWithin>,
162 pub locale: Option<String>,
166 pub limit: usize,
168}
169
170impl LookupQuery {
171 pub fn new(text: impl Into<String>) -> LookupQuery {
173 LookupQuery {
174 text: text.into(),
175 scope: LookupScope::ALL,
176 within: None,
177 locale: None,
178 limit: 0,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
185#[serde(rename_all = "camelCase")]
186pub struct LookupScope {
187 pub functions: bool,
189 pub enums: bool,
191 pub settings: bool,
193}
194
195impl LookupScope {
196 pub const ALL: LookupScope = LookupScope {
198 functions: true,
199 enums: true,
200 settings: true,
201 };
202 pub const FUNCTIONS: LookupScope = LookupScope {
204 functions: true,
205 enums: false,
206 settings: false,
207 };
208 pub const ENUMS: LookupScope = LookupScope {
210 functions: false,
211 enums: true,
212 settings: false,
213 };
214 pub const SETTINGS: LookupScope = LookupScope {
216 functions: false,
217 enums: false,
218 settings: true,
219 };
220
221 fn any(self) -> bool {
222 self.functions || self.enums || self.settings
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Serialize)]
230#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
231pub enum LookupWithin {
232 Callable(String),
234 Enum(String),
236 Settings(String),
239 SettingEnum(String),
241}
242
243impl LookupWithin {
244 fn scope(&self) -> LookupScope {
246 match self {
247 LookupWithin::Callable(_) => LookupScope::FUNCTIONS,
248 LookupWithin::Enum(_) | LookupWithin::SettingEnum(_) => LookupScope::ENUMS,
249 LookupWithin::Settings(_) => LookupScope::SETTINGS,
250 }
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Serialize)]
256#[serde(
257 rename_all = "camelCase",
258 rename_all_fields = "camelCase",
259 tag = "kind"
260)]
261pub enum LookupOutcome {
262 Matched {
264 scope: LookupScope,
266 limit: usize,
268 results: Vec<LookupHit>,
270 },
271 UnknownWithin {
274 within: LookupWithin,
276 },
277 Unsupported {
280 scope: LookupScope,
282 reason: String,
285 },
286}
287
288impl LookupOutcome {
289 pub fn results(&self) -> &[LookupHit] {
291 match self {
292 LookupOutcome::Matched { results, .. } => results,
293 LookupOutcome::UnknownWithin { .. } | LookupOutcome::Unsupported { .. } => &[],
294 }
295 }
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
300#[serde(rename_all = "camelCase")]
301pub enum MatchKind {
302 OpySpelling,
304 CatalogId,
306 DisplayName,
308 Alias,
310 Path,
312 Near,
314}
315
316impl MatchKind {
317 pub fn as_str(&self) -> &'static str {
319 match self {
320 MatchKind::OpySpelling => "opySpelling",
321 MatchKind::CatalogId => "catalogId",
322 MatchKind::DisplayName => "displayName",
323 MatchKind::Alias => "alias",
324 MatchKind::Path => "path",
325 MatchKind::Near => "near",
326 }
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Serialize)]
332#[serde(rename_all = "camelCase", rename_all_fields = "camelCase", tag = "hit")]
333pub enum LookupHit {
334 Function {
336 spelling: String,
338 function_kind: LookupFunctionKind,
340 #[serde(skip_serializing_if = "Option::is_none")]
344 receiver: Option<ReceiverCategory>,
345 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
347 unbounded: bool,
348 params: Vec<LookupParam>,
351 catalog_id: Option<String>,
353 #[serde(skip_serializing_if = "Option::is_none")]
355 display_name: Option<String>,
356 matched_on: MatchKind,
358 },
359 EnumDomain {
361 domain: String,
363 members: Option<Vec<EnumMemberEntry>>,
366 #[serde(skip_serializing_if = "Option::is_none")]
368 display_name: Option<String>,
369 matched_on: MatchKind,
371 },
372 EnumMember {
374 spelling: String,
376 domain: String,
378 member: String,
380 display_name: Option<String>,
382 aliases: Vec<String>,
384 matched_on: MatchKind,
386 },
387 Setting {
389 path: String,
392 display_name: Option<String>,
394 value: SettingValueForm,
396 matched_on: MatchKind,
398 },
399 Parameter {
402 callable: String,
404 param: LookupParam,
406 matched_on: MatchKind,
408 },
409 SettingPath {
412 path: String,
414 matched_on: MatchKind,
416 },
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
421#[serde(rename_all = "camelCase")]
422pub enum LookupFunctionKind {
423 Action,
425 Value,
427 MemberAction,
429 MemberValue,
431}
432
433impl LookupFunctionKind {
434 pub fn as_str(&self) -> &'static str {
436 match self {
437 LookupFunctionKind::Action => "action",
438 LookupFunctionKind::Value => "value",
439 LookupFunctionKind::MemberAction => "memberAction",
440 LookupFunctionKind::MemberValue => "memberValue",
441 }
442 }
443}
444
445#[derive(Debug, Clone, PartialEq, Serialize)]
447#[serde(rename_all = "camelCase")]
448pub struct LookupParam {
449 pub name: String,
451 #[serde(skip_serializing_if = "Vec::is_empty")]
453 pub alternate_names: Vec<String>,
454 pub required: bool,
456 #[serde(skip_serializing_if = "Option::is_none")]
458 pub domain: Option<String>,
459 #[serde(skip_serializing_if = "Option::is_none")]
463 pub members: Option<Vec<EnumMemberEntry>>,
464 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
466 pub param_type: Option<String>,
467 #[serde(skip_serializing_if = "Option::is_none")]
469 pub default: Option<String>,
470}
471
472#[derive(Debug, Clone, PartialEq, Serialize)]
474#[serde(rename_all = "camelCase")]
475pub struct EnumMemberEntry {
476 pub spelling: String,
478 pub id: String,
480 #[serde(skip_serializing_if = "Option::is_none")]
482 pub display_name: Option<String>,
483 #[serde(skip_serializing_if = "Vec::is_empty")]
485 pub aliases: Vec<String>,
486}
487
488#[derive(Debug, Clone, PartialEq, Serialize)]
490#[serde(rename_all = "camelCase")]
491pub struct SettingValueForm {
492 pub kind: SettingValueKind,
494 #[serde(skip_serializing_if = "Option::is_none")]
496 pub domain: Option<String>,
497 #[serde(skip_serializing_if = "Vec::is_empty")]
499 pub members: Vec<String>,
500 #[serde(skip_serializing_if = "Option::is_none")]
502 pub min: Option<f64>,
503 #[serde(skip_serializing_if = "Option::is_none")]
505 pub max: Option<f64>,
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
510#[serde(rename_all = "camelCase")]
511pub enum SettingValueKind {
512 Presence,
514 Boolean,
516 Number,
518 Percent,
520 String,
522 Enum,
524 MapList,
526 HeroList,
528}
529
530impl SettingValueKind {
531 pub fn as_str(&self) -> &'static str {
533 match self {
534 SettingValueKind::Presence => "presence",
535 SettingValueKind::Boolean => "boolean",
536 SettingValueKind::Number => "number",
537 SettingValueKind::Percent => "percent",
538 SettingValueKind::String => "string",
539 SettingValueKind::Enum => "enum",
540 SettingValueKind::MapList => "mapList",
541 SettingValueKind::HeroList => "heroList",
542 }
543 }
544}
545
546pub fn lookup(query: &LookupQuery) -> LookupOutcome {
555 let limit = if query.limit == 0 {
556 DEFAULT_LIMIT
557 } else {
558 query.limit
559 };
560 let scope = query.scope;
561 let unsupported = |reason: &str| LookupOutcome::Unsupported {
562 scope,
563 reason: reason.to_string(),
564 };
565 if let Some(locale) = &query.locale {
566 if !locale.eq_ignore_ascii_case("en-US") {
567 return unsupported(
568 "localized display names are owned by workshop-rs; opy-rs \
569 resolves display names in the 'en-US' locale only",
570 );
571 }
572 }
573 let (manifest, catalog) = match (Manifest::builtin(), Catalog::builtin()) {
574 (Ok(manifest), Ok(catalog)) => (manifest, catalog),
575 _ => return unsupported("vocabulary data (manifest or catalog) is not bundled"),
576 };
577 let index = Index::new(manifest, &catalog);
578 if let Some(within) = &query.within {
579 let Some(children) = index.children(within) else {
580 return LookupOutcome::UnknownWithin {
581 within: within.clone(),
582 };
583 };
584 let mut results = index.rank(children.iter(), query);
585 results.truncate(limit);
586 return LookupOutcome::Matched {
587 scope: within.scope(),
588 limit,
589 results,
590 };
591 }
592 if !query.scope.any() {
593 return unsupported("the query scope selects no namespace");
594 }
595 let mut results = index.rank(
596 index
597 .candidates
598 .iter()
599 .filter(|candidate| index.in_scope(candidate, scope)),
600 query,
601 );
602 results.truncate(limit);
603 LookupOutcome::Matched {
604 scope,
605 limit,
606 results,
607 }
608}
609
610pub fn lookup_str(text: &str) -> LookupOutcome {
612 lookup(&LookupQuery::new(text))
613}
614
615struct ScoredHit {
620 score: u32,
621 hit: LookupHit,
622}
623
624impl LookupHit {
625 fn sort_key(&self) -> String {
626 match self {
627 LookupHit::Function { spelling, .. } => format!("0{spelling}"),
628 LookupHit::EnumMember { spelling, .. } => format!("1{spelling}"),
629 LookupHit::EnumDomain { domain, .. } => format!("2{domain}"),
630 LookupHit::Setting { path, .. } => format!("3{path}"),
631 LookupHit::Parameter {
632 callable, param, ..
633 } => {
634 format!("4{callable}.{}", param.name)
635 }
636 LookupHit::SettingPath { path, .. } => format!("5{path}"),
637 }
638 }
639
640 fn listed(self) -> LookupHit {
643 let kind = match &self {
644 LookupHit::Setting { .. } | LookupHit::SettingPath { .. } => MatchKind::Path,
645 _ => MatchKind::OpySpelling,
646 };
647 self.with_match_kind(kind, 0, false)
648 }
649
650 fn with_match_kind(self, kind: MatchKind, score: u32, structural: bool) -> LookupHit {
654 let matched_on = if score == 0 || structural {
655 kind
656 } else {
657 MatchKind::Near
658 };
659 match self {
660 LookupHit::Function {
661 spelling,
662 function_kind,
663 receiver,
664 unbounded,
665 params,
666 catalog_id,
667 display_name,
668 ..
669 } => LookupHit::Function {
670 spelling,
671 function_kind,
672 receiver,
673 unbounded,
674 params,
675 catalog_id,
676 display_name,
677 matched_on,
678 },
679 LookupHit::EnumDomain {
680 domain,
681 members,
682 display_name,
683 ..
684 } => LookupHit::EnumDomain {
685 domain,
686 members,
687 display_name,
688 matched_on,
689 },
690 LookupHit::EnumMember {
691 spelling,
692 domain,
693 member,
694 display_name,
695 aliases,
696 ..
697 } => LookupHit::EnumMember {
698 spelling,
699 domain,
700 member,
701 display_name,
702 aliases,
703 matched_on,
704 },
705 LookupHit::Setting {
706 path,
707 display_name,
708 value,
709 ..
710 } => LookupHit::Setting {
711 path,
712 display_name,
713 value,
714 matched_on,
715 },
716 LookupHit::Parameter {
717 callable, param, ..
718 } => LookupHit::Parameter {
719 callable,
720 param,
721 matched_on,
722 },
723 LookupHit::SettingPath { path, .. } => LookupHit::SettingPath { path, matched_on },
724 }
725 }
726}
727
728#[derive(Clone)]
731struct Candidate {
732 forms: Vec<(String, MatchKind)>,
733 hit: LookupHit,
734}
735
736struct Index<'a> {
737 manifest: &'a Manifest,
738 catalog: &'a Catalog,
739 candidates: Vec<Candidate>,
740}
741
742impl<'a> Index<'a> {
743 fn new(manifest: &'a Manifest, catalog: &'a Catalog) -> Index<'a> {
744 let mut index = Index {
745 manifest,
746 catalog,
747 candidates: Vec::new(),
748 };
749 index.index_functions();
750 index.index_enums();
751 index.index_settings();
752 index
753 }
754
755 fn rank<'c>(
759 &self,
760 candidates: impl Iterator<Item = &'c Candidate>,
761 query: &LookupQuery,
762 ) -> Vec<LookupHit> {
763 if query.text.trim().is_empty() {
764 return candidates
765 .map(|candidate| candidate.hit.clone().listed())
766 .collect();
767 }
768 let mut hits: Vec<ScoredHit> = candidates
769 .filter_map(|candidate| {
770 candidate
771 .forms
772 .iter()
773 .filter_map(|(form, kind)| {
774 let (scored, structural) = if *kind == MatchKind::Path {
781 match crate::matcher::path_score(&query.text, form) {
782 Some(s) => (Some(s), true),
783 None => (crate::matcher::score(&query.text, form), false),
784 }
785 } else {
786 (crate::matcher::score(&query.text, form), false)
787 };
788 scored.map(|s| {
789 (
792 s,
793 form.eq_ignore_ascii_case(query.text.trim()),
794 *kind,
795 structural,
796 )
797 })
798 })
799 .min_by_key(|(s, exact, _, _)| (*s, !*exact))
800 .map(|(s, _, kind, structural)| ScoredHit {
801 score: s,
802 hit: candidate.hit.clone().with_match_kind(kind, s, structural),
803 })
804 })
805 .collect();
806 hits.sort_by(|a, b| {
807 a.score
808 .cmp(&b.score)
809 .then_with(|| a.hit.sort_key().cmp(&b.hit.sort_key()))
810 });
811 hits.into_iter().map(|scored| scored.hit).collect()
812 }
813
814 fn in_scope(&self, candidate: &Candidate, scope: LookupScope) -> bool {
815 match &candidate.hit {
816 LookupHit::Function { .. } | LookupHit::Parameter { .. } => scope.functions,
817 LookupHit::EnumDomain { .. } | LookupHit::EnumMember { .. } => scope.enums,
818 LookupHit::Setting { .. } | LookupHit::SettingPath { .. } => scope.settings,
819 }
820 }
821
822 fn children(&self, within: &LookupWithin) -> Option<Vec<Candidate>> {
827 match within {
828 LookupWithin::Callable(name) => self.callable_children(name),
829 LookupWithin::Enum(domain) => self.enum_children(domain),
830 LookupWithin::Settings(prefix) => self.settings_children(prefix),
831 LookupWithin::SettingEnum(domain) => self.setting_enum_children(domain),
832 }
833 }
834
835 fn callable_children(&self, name: &str) -> Option<Vec<Candidate>> {
838 self.candidates
839 .iter()
840 .find_map(|candidate| match &candidate.hit {
841 LookupHit::Function {
842 spelling, params, ..
843 } if spelling == name => Some(
844 params
845 .iter()
846 .map(|param| {
847 let mut forms = vec![(param.name.clone(), MatchKind::OpySpelling)];
848 forms.extend(
849 param
850 .alternate_names
851 .iter()
852 .map(|name| (name.clone(), MatchKind::Alias)),
853 );
854 Candidate {
855 forms,
856 hit: LookupHit::Parameter {
857 callable: spelling.clone(),
858 param: param.clone(),
859 matched_on: MatchKind::OpySpelling,
860 },
861 }
862 })
863 .collect(),
864 ),
865 _ => None,
866 })
867 }
868
869 fn enum_children(&self, domain: &str) -> Option<Vec<Candidate>> {
872 let known = self.candidates.iter().any(|candidate| {
873 matches!(&candidate.hit, LookupHit::EnumDomain { domain: d, .. } if d == domain)
874 });
875 if !known {
876 return None;
877 }
878 Some(
879 crate::enums::domain_members(domain, self.catalog)
880 .map(|members| {
881 members
882 .iter()
883 .map(|member| self.member_candidate(domain, member))
884 .collect()
885 })
886 .unwrap_or_default(),
887 )
888 }
889
890 fn settings_children(&self, prefix: &str) -> Option<Vec<Candidate>> {
895 let prefix_segments: Vec<&str> =
896 prefix.split('.').filter(|part| !part.is_empty()).collect();
897 let mut children: Vec<Candidate> = Vec::new();
898 let mut seen: Vec<&str> = Vec::new();
899 for candidate in &self.candidates {
900 let LookupHit::Setting { path, .. } = &candidate.hit else {
901 continue;
902 };
903 let segments: Vec<&str> = path.split('.').collect();
904 if !segments.starts_with(prefix_segments.as_slice()) {
905 continue;
906 }
907 let Some(&child_segment) = segments.get(prefix_segments.len()) else {
908 continue;
909 };
910 if seen.contains(&child_segment) {
911 continue;
912 }
913 seen.push(child_segment);
914 if segments.len() == prefix_segments.len() + 1 {
915 children.push(candidate.clone());
916 } else {
917 let child_path = prefix_segments
918 .iter()
919 .copied()
920 .chain(std::iter::once(child_segment))
921 .collect::<Vec<_>>()
922 .join(".");
923 children.push(Candidate {
924 forms: vec![
925 (child_segment.to_string(), MatchKind::OpySpelling),
926 (child_path.clone(), MatchKind::Path),
927 ],
928 hit: LookupHit::SettingPath {
929 path: child_path,
930 matched_on: MatchKind::Path,
931 },
932 });
933 }
934 }
935 (!children.is_empty()).then_some(children)
936 }
937
938 fn setting_enum_children(&self, domain: &str) -> Option<Vec<Candidate>> {
941 let mut members: Vec<(String, String)> = Vec::new();
942 for definition in settings::definitions() {
943 for member in definition.enum_members() {
944 if member.domain() == domain && !members.iter().any(|(id, _)| id == member.id()) {
945 members.push((member.id().to_string(), member.english_name().to_string()));
946 }
947 }
948 }
949 if members.is_empty() {
950 return None;
951 }
952 Some(
953 members
954 .into_iter()
955 .map(|(id, name)| Candidate {
956 forms: vec![
957 (id.clone(), MatchKind::OpySpelling),
958 (name.clone(), MatchKind::DisplayName),
959 ],
960 hit: LookupHit::EnumMember {
961 spelling: id.clone(),
962 domain: domain.to_string(),
963 member: id,
964 display_name: (!name.is_empty()).then_some(name),
965 aliases: Vec::new(),
966 matched_on: MatchKind::OpySpelling,
967 },
968 })
969 .collect(),
970 )
971 }
972
973 fn index_functions(&mut self) {
976 let locale = crate::enums::en_us();
977 for function in self.manifest.functions() {
978 let mut forms = vec![(function.id.clone(), MatchKind::OpySpelling)];
979 let mut display_name = None;
980 if let Some(catalog_id) = &function.catalog_id {
981 forms.push((catalog_id.clone(), MatchKind::CatalogId));
982 let kind = match function.kind {
983 FunctionKind::Action | FunctionKind::MemberAction => Kind::Action,
984 FunctionKind::Value | FunctionKind::MemberValue => Kind::Value,
985 };
986 if let Some(entry) = self.catalog.entry(kind, catalog_id) {
987 if let Some(spelling) = entry.spelling(&locale) {
988 display_name = Some(spelling.to_string());
989 forms.push((spelling.to_string(), MatchKind::DisplayName));
990 }
991 for spelling in entry.spellings(&locale) {
992 forms.push((spelling.clone(), MatchKind::DisplayName));
993 }
994 }
995 }
996 for alias in self.manifest.aliases() {
997 if alias_target(alias, self.manifest)
998 .is_some_and(|target| std::ptr::eq(target, function))
999 {
1000 forms.push((alias.source.clone(), MatchKind::Alias));
1001 }
1002 }
1003 let params = self.lookup_params(function);
1004 self.candidates.push(Candidate {
1005 forms,
1006 hit: LookupHit::Function {
1007 spelling: function.id.clone(),
1008 function_kind: match function.kind {
1009 FunctionKind::Action => LookupFunctionKind::Action,
1010 FunctionKind::Value => LookupFunctionKind::Value,
1011 FunctionKind::MemberAction => LookupFunctionKind::MemberAction,
1012 FunctionKind::MemberValue => LookupFunctionKind::MemberValue,
1013 },
1014 receiver: function.receiver,
1015 unbounded: function.unbounded,
1016 params,
1017 catalog_id: function.catalog_id.clone(),
1018 display_name,
1019 matched_on: MatchKind::OpySpelling,
1020 },
1021 });
1022 }
1023 for special in SPECIAL_FUNCTIONS {
1024 let params: Vec<LookupParam> = special
1025 .params
1026 .iter()
1027 .map(|param| LookupParam {
1028 name: param.name.to_string(),
1029 alternate_names: Vec::new(),
1030 required: param.required,
1031 domain: None,
1032 members: None,
1033 param_type: Some(param.param_type.to_string()),
1034 default: param.default.map(str::to_string),
1035 })
1036 .collect();
1037 self.candidates.push(Candidate {
1038 forms: vec![(special.name.to_string(), MatchKind::OpySpelling)],
1039 hit: LookupHit::Function {
1040 spelling: special.name.to_string(),
1041 function_kind: LookupFunctionKind::Value,
1042 receiver: None,
1043 unbounded: false,
1044 params,
1045 catalog_id: None,
1046 display_name: None,
1047 matched_on: MatchKind::OpySpelling,
1048 },
1049 });
1050 }
1051 }
1052
1053 fn lookup_params(&self, function: &Function) -> Vec<LookupParam> {
1054 let catalog_entry = function.catalog_id.as_deref().and_then(|catalog_id| {
1055 let kind = match function.kind {
1056 FunctionKind::Action | FunctionKind::MemberAction => Kind::Action,
1057 FunctionKind::Value | FunctionKind::MemberValue => Kind::Value,
1058 };
1059 self.catalog.entry(kind, catalog_id)
1060 });
1061 function
1062 .params
1063 .iter()
1064 .enumerate()
1065 .map(|(index, param)| {
1066 let catalog_index = self.catalog_param_index(catalog_entry, function, param, index);
1067 let (param_type, catalog_domain) = catalog_index
1068 .and_then(|i| {
1069 catalog_entry.map(|entry| {
1070 (
1071 entry.param_type(i).map(str::to_string),
1072 entry.param_domain(i).map(str::to_string),
1073 )
1074 })
1075 })
1076 .unwrap_or((None, None));
1077 let domain = param
1080 .domain
1081 .clone()
1082 .or(catalog_domain)
1083 .map(|domain| crate::enums::opy_domain(&domain).to_string());
1084 let required = !param.optional && param.default.is_none();
1085 let members = domain
1089 .as_deref()
1090 .and_then(|domain| crate::enums::domain_members(domain, self.catalog))
1091 .map(|members| members.iter().map(member_entry).collect::<Vec<_>>());
1092 let default = param
1093 .default
1094 .as_ref()
1095 .map(|d| default_text(d, domain.as_deref(), self.catalog));
1096 LookupParam {
1097 name: param.name.clone(),
1098 alternate_names: param.alternate_names.clone(),
1099 required,
1100 members,
1101 domain,
1102 param_type,
1103 default,
1104 }
1105 })
1106 .collect()
1107 }
1108
1109 fn catalog_param_index(
1113 &self,
1114 entry: Option<&workshop_rs::catalog::CatalogEntry>,
1115 function: &Function,
1116 param: &Param,
1117 index: usize,
1118 ) -> Option<usize> {
1119 let entry = entry?;
1120 let count = entry.param_count();
1121 let offset = if function.kind.is_member() && count == function.params.len() + 1 {
1122 1
1123 } else {
1124 0
1125 };
1126 if count == function.params.len() + offset {
1127 let candidate = index + offset;
1130 match (0..count).position(|i| {
1131 entry.param_name(i).is_some_and(|name| {
1132 crate::matcher::fold(name) == crate::matcher::fold(¶m.name)
1133 })
1134 }) {
1135 Some(i) => Some(i),
1136 None => (candidate < count).then_some(candidate),
1137 }
1138 } else {
1139 (0..count).find(|&i| {
1140 entry.param_name(i).is_some_and(|name| {
1141 crate::matcher::fold(name) == crate::matcher::fold(¶m.name)
1142 })
1143 })
1144 }
1145 }
1146
1147 fn index_enums(&mut self) {
1150 for domain in self.lookup_domains() {
1151 let members = crate::enums::domain_members(&domain, self.catalog);
1152 let mut domain_forms = vec![(domain.clone(), MatchKind::OpySpelling)];
1153 let mut display_name = None;
1154 if let Some(catalog_domain) = self
1155 .catalog
1156 .enum_domain(crate::enums::catalog_domain(&domain))
1157 {
1158 domain_forms.push((catalog_domain.domain.clone(), MatchKind::CatalogId));
1159 let locale = crate::enums::en_us();
1160 if let Some(spelling) = catalog_domain.spelling(&locale) {
1161 display_name = Some(spelling.to_string());
1162 domain_forms.push((spelling.to_string(), MatchKind::DisplayName));
1163 }
1164 }
1165 let entries: Option<Vec<EnumMemberEntry>> = members
1166 .as_ref()
1167 .map(|members| members.iter().map(member_entry).collect());
1168 self.candidates.push(Candidate {
1169 forms: domain_forms,
1170 hit: LookupHit::EnumDomain {
1171 domain: domain.clone(),
1172 members: entries,
1173 display_name,
1174 matched_on: MatchKind::OpySpelling,
1175 },
1176 });
1177 if let Some(members) = members {
1178 for member in &members {
1179 self.candidates.push(self.member_candidate(&domain, member));
1180 }
1181 }
1182 }
1183 }
1184
1185 fn member_candidate(&self, domain: &str, member: &crate::enums::DomainMember) -> Candidate {
1188 let entry = member_entry(member);
1189 let mut forms = vec![
1190 (
1191 format!("{domain}.{}", entry.spelling),
1192 MatchKind::OpySpelling,
1193 ),
1194 (entry.spelling.clone(), MatchKind::OpySpelling),
1195 ];
1196 if let Some(display) = &member.display_name {
1197 forms.push((display.clone(), MatchKind::DisplayName));
1198 }
1199 if let Some(catalog_member) = &member.catalog_member {
1200 forms.push((catalog_member.clone(), MatchKind::CatalogId));
1201 }
1202 for alias in &member.aliases {
1203 forms.push((alias.clone(), MatchKind::Alias));
1204 }
1205 Candidate {
1206 forms,
1207 hit: LookupHit::EnumMember {
1208 spelling: format!("{domain}.{}", entry.spelling),
1209 domain: domain.to_string(),
1210 member: entry.id,
1211 display_name: entry.display_name,
1212 aliases: entry.aliases,
1213 matched_on: MatchKind::OpySpelling,
1214 },
1215 }
1216 }
1217
1218 fn lookup_domains(&self) -> Vec<String> {
1225 let mut domains: Vec<String> = self
1226 .catalog
1227 .enum_domains()
1228 .filter(|domain| !crate::enums::NON_SOURCE_DOMAINS.contains(&domain.domain.as_str()))
1229 .map(|domain| crate::enums::opy_domain(&domain.domain).to_string())
1230 .collect();
1231 domains.extend(
1232 crate::enums::DOMAIN_ALIASES
1233 .iter()
1234 .map(|(source, _)| source.to_string()),
1235 );
1236 for domain in self
1237 .manifest
1238 .functions()
1239 .iter()
1240 .flat_map(|function| function.params.iter())
1241 .filter_map(|param| param.domain.as_deref())
1242 .map(crate::enums::opy_domain)
1243 {
1244 if !crate::enums::NON_SOURCE_DOMAINS.contains(&domain)
1245 && !domains.iter().any(|d| d == domain)
1246 {
1247 domains.push(domain.to_string());
1248 }
1249 }
1250 domains
1251 }
1252
1253 fn index_settings(&mut self) {
1256 for definition in settings::definitions() {
1257 self.push_setting(definition.path(), &definition);
1258 }
1259 let modes: Vec<String> = settings::definitions()
1263 .filter_map(|definition| {
1264 let mut parts = definition.path().split('.');
1265 match (parts.next(), parts.next(), parts.next()) {
1266 (Some("gamemodes"), Some(mode), Some(_)) if mode != "general" => {
1267 Some(mode.to_string())
1268 }
1269 _ => None,
1270 }
1271 })
1272 .collect();
1273 let modes: Vec<String> = {
1274 let mut seen = Vec::new();
1275 for mode in modes {
1276 if !seen.contains(&mode) {
1277 seen.push(mode);
1278 }
1279 }
1280 seen
1281 };
1282 for definition in settings::definitions() {
1283 let Some(leaf) = definition.path().strip_prefix("gamemodes.general.") else {
1284 continue;
1285 };
1286 for mode in &modes {
1287 let probe = [
1288 PathPart::Part("gamemodes"),
1289 PathPart::Part(mode.as_str()),
1290 PathPart::Part(leaf),
1291 ];
1292 if let Some(resolved) = settings::definition(&probe) {
1293 let path = format!("gamemodes.{mode}.{leaf}");
1294 if resolved.path() != path {
1295 self.push_setting(&path, &resolved);
1297 }
1298 }
1299 }
1300 }
1301 }
1302
1303 fn push_setting(&mut self, path: &str, definition: &settings::SettingDefinition) {
1304 let leaf = path.rsplit('.').next().unwrap_or(path);
1305 let english_name = definition.presentation().english_name;
1306 let mut forms = vec![
1307 (path.to_string(), MatchKind::Path),
1308 (leaf.to_string(), MatchKind::OpySpelling),
1309 ];
1310 if !english_name.is_empty() {
1311 forms.push((english_name.to_string(), MatchKind::DisplayName));
1312 }
1313 self.candidates.push(Candidate {
1314 forms,
1315 hit: LookupHit::Setting {
1316 path: path.to_string(),
1317 display_name: (!english_name.is_empty()).then(|| english_name.to_string()),
1318 value: setting_value_form(definition),
1319 matched_on: MatchKind::Path,
1320 },
1321 });
1322 }
1323}
1324
1325fn alias_target<'a>(
1327 alias: &crate::manifest::Alias,
1328 manifest: &'a Manifest,
1329) -> Option<&'a Function> {
1330 match alias.kind {
1331 AliasKind::FunctionAlias => manifest.function(&alias.target),
1332 AliasKind::MemberAlias => manifest.member(&alias.target),
1333 }
1334}
1335
1336fn member_entry(member: &crate::enums::DomainMember) -> EnumMemberEntry {
1338 EnumMemberEntry {
1339 spelling: member.member.clone(),
1340 id: member
1341 .catalog_member
1342 .clone()
1343 .unwrap_or_else(|| member.member.clone()),
1344 display_name: member.display_name.clone(),
1345 aliases: member.aliases.clone(),
1346 }
1347}
1348
1349fn setting_value_form(definition: &settings::SettingDefinition) -> SettingValueForm {
1351 let (kind, domain, min, max) = match definition.domain() {
1352 SettingValueDomain::PresenceOnly => (SettingValueKind::Presence, None, None, None),
1353 SettingValueDomain::Boolean => (SettingValueKind::Boolean, None, None, None),
1354 SettingValueDomain::Number(bounds) => {
1355 (SettingValueKind::Number, None, bounds.min(), bounds.max())
1356 }
1357 SettingValueDomain::Percent(bounds) => {
1358 (SettingValueKind::Percent, None, bounds.min(), bounds.max())
1359 }
1360 SettingValueDomain::String => (SettingValueKind::String, None, None, None),
1361 SettingValueDomain::Enum { domain } => {
1362 (SettingValueKind::Enum, Some(domain.clone()), None, None)
1363 }
1364 SettingValueDomain::MapList => (SettingValueKind::MapList, None, None, None),
1365 SettingValueDomain::HeroList => (SettingValueKind::HeroList, None, None, None),
1366 other => unreachable!("unhandled SettingValueDomain variant {other:?}"),
1369 };
1370 let members: Vec<String> =
1373 if kind == SettingValueKind::Boolean && definition.enum_members().next().is_some() {
1374 vec!["true".to_string()]
1375 } else {
1376 definition
1377 .enum_members()
1378 .map(|member| member.id().to_string())
1379 .collect()
1380 };
1381 SettingValueForm {
1382 kind,
1383 domain,
1384 members,
1385 min,
1386 max,
1387 }
1388}
1389
1390fn default_text(default: &ParamDefault, domain: Option<&str>, catalog: &Catalog) -> String {
1392 match default {
1393 ParamDefault::Null { .. } => "null".to_string(),
1394 ParamDefault::Bool(value) => value.to_string(),
1395 ParamDefault::Number(value) => {
1396 if value.fract() == 0.0 {
1397 format!("{}", *value as i64)
1398 } else {
1399 value.to_string()
1400 }
1401 }
1402 ParamDefault::Call { call, args } => {
1403 let args = args
1404 .iter()
1405 .map(|arg| {
1406 if arg.fract() == 0.0 {
1407 format!("{}", *arg as i64)
1408 } else {
1409 arg.to_string()
1410 }
1411 })
1412 .collect::<Vec<_>>()
1413 .join(", ");
1414 format!("{call}({args})")
1415 }
1416 ParamDefault::EnumMember(member) => match domain {
1417 Some(domain) => {
1420 let spelling = crate::enums::domain_members(domain, catalog)
1421 .and_then(|members| {
1422 members
1423 .iter()
1424 .find(|entry| {
1425 entry.member == *member
1426 || entry.catalog_member.as_deref() == Some(member.as_str())
1427 })
1428 .map(|entry| entry.member.clone())
1429 })
1430 .unwrap_or_else(|| member.clone());
1431 format!("{domain}.{spelling}")
1432 }
1433 None => member.clone(),
1434 },
1435 }
1436}
1437
1438#[cfg(test)]
1439mod tests {
1440 use super::*;
1441 use workshop_rs::catalog::Catalog;
1442
1443 #[test]
1447 fn reported_member_spellings_resolve_through_lowering() {
1448 let catalog = Catalog::builtin().expect("bundled catalog");
1449 let manifest = Manifest::builtin().expect("bundled manifest");
1450 let index = Index::new(manifest, &catalog);
1451 for domain in index.lookup_domains() {
1452 let Some(members) = crate::enums::domain_members(&domain, &catalog) else {
1453 continue;
1454 };
1455 for member in &members {
1456 for spelling in std::iter::once(&member.member).chain(&member.aliases) {
1457 let resolves = crate::enums::canonical_member(&domain, spelling, &catalog)
1461 .is_some()
1462 || (domain == "Color"
1463 && crate::enums::extra_color_member(spelling).is_some());
1464 assert!(
1465 resolves,
1466 "{domain}.{spelling} is lookup-visible but does not resolve"
1467 );
1468 }
1469 }
1470 }
1471 }
1472
1473 #[test]
1477 fn reported_function_spellings_resolve_through_the_manifest() {
1478 let manifest = Manifest::builtin().expect("bundled manifest");
1479 for function in manifest.functions() {
1480 let resolves = if function.kind.is_member() {
1481 manifest.resolve_member(&function.id).is_some()
1482 } else {
1483 manifest.resolve_function(&function.id).is_some()
1484 };
1485 assert!(
1486 resolves,
1487 "{} is lookup-visible but does not resolve",
1488 function.id
1489 );
1490 }
1491 let reported: Vec<&str> = SPECIAL_FUNCTIONS
1496 .iter()
1497 .map(|special| special.name)
1498 .collect();
1499 let dispatched: Vec<&str> = crate::lower::special_forms::SPECIAL_VALUE_CALLS
1500 .iter()
1501 .map(|(name, _)| *name)
1502 .collect();
1503 assert_eq!(
1504 reported, dispatched,
1505 "reported special-call spellings must be the dispatched set"
1506 );
1507 for special in SPECIAL_FUNCTIONS {
1511 let source = format!(
1512 "globalvar x\nrule \"r\":\n @Event global\n x = {}()\n",
1513 special.name
1514 );
1515 let diagnostics =
1516 crate::tooling::check(&source, "main.opy", std::path::Path::new("")).diagnostics;
1517 assert!(
1518 !diagnostics.iter().any(|diagnostic| matches!(
1519 diagnostic.code.as_str(),
1520 "unknown-value" | "unknown-action" | "invalid-iterable"
1521 )),
1522 "'{}' is lookup-reported but the lowerer rejects it: {diagnostics:?}",
1523 special.name
1524 );
1525 }
1526 }
1527
1528 #[test]
1537 fn reported_member_aliases_are_reference_spellings() {
1538 let catalog = Catalog::builtin().expect("bundled catalog");
1539 let manifest = Manifest::builtin().expect("bundled manifest");
1540 let index = Index::new(manifest, &catalog);
1541 let mut reported = Vec::new();
1542 for domain in index.lookup_domains() {
1543 let Some(members) = crate::enums::domain_members(&domain, &catalog) else {
1544 continue;
1545 };
1546 for member in members {
1547 for alias in member.aliases {
1548 assert_ne!(
1549 Some(alias.as_str()),
1550 member.catalog_member.as_deref(),
1551 "{domain}.{alias} advertises the catalog id as an OPY alias"
1552 );
1553 reported.push(format!("{domain}.{alias}"));
1554 }
1555 }
1556 }
1557 reported.sort();
1558 assert_eq!(
1559 reported,
1560 ["Hero.HAMMOND", "Hero.MCCREE", "HudPosition.ACTUALLY_LEFT"]
1561 );
1562 }
1563
1564 #[test]
1567 fn rank_prefers_close_spellings() {
1568 let pool = crate::matcher::bare_candidates(["hudText", "smallMessage", "destroyIcon"]);
1569 let ranked = crate::matcher::rank("hudTex", &pool);
1570 assert_eq!(ranked.first().map(String::as_str), Some("hudText"));
1571 assert!(
1572 !ranked.iter().any(|candidate| candidate == "hudTex"),
1573 "the rejected spelling is not a candidate"
1574 );
1575 }
1576
1577 #[test]
1580 fn sibling_keys_cover_inherited_gamemode_keys() {
1581 let keys =
1582 crate::matcher::sibling_keys(&[PathPart::Part("gamemodes"), PathPart::Part("ffa")]);
1583 assert!(
1584 keys.iter().any(|key| key == "scoreToWin"),
1585 "inherited general keys must be candidates: {keys:?}"
1586 );
1587 }
1588
1589 fn within_query(within: LookupWithin) -> LookupQuery {
1590 LookupQuery {
1591 text: String::new(),
1592 scope: LookupScope::ALL,
1593 within: Some(within),
1594 locale: None,
1595 limit: usize::MAX,
1596 }
1597 }
1598
1599 #[test]
1602 fn within_callable_lists_params_in_call_order() {
1603 let LookupOutcome::Matched { results, .. } =
1604 lookup(&within_query(LookupWithin::Callable("wait".to_string())))
1605 else {
1606 panic!("wait is a known callable");
1607 };
1608 let names: Vec<&str> = results
1609 .iter()
1610 .map(|hit| {
1611 let LookupHit::Parameter { param, .. } = hit else {
1612 panic!("callable children are parameters: {hit:?}");
1613 };
1614 param.name.as_str()
1615 })
1616 .collect();
1617 assert_eq!(names, ["time", "waitBehavior"]);
1618 let LookupOutcome::UnknownWithin { .. } = lookup(&within_query(LookupWithin::Callable(
1619 "notAFunction".to_string(),
1620 ))) else {
1621 panic!("an unknown callable is not a scope");
1622 };
1623 }
1624
1625 #[test]
1630 fn within_scopes_list_their_children() {
1631 let LookupOutcome::Matched { results, .. } =
1632 lookup(&within_query(LookupWithin::Enum("Button".to_string())))
1633 else {
1634 panic!("Button is a known enum domain");
1635 };
1636 let LookupHit::EnumMember { spelling, .. } = &results[0] else {
1637 panic!("enum children are members: {:?}", results[0]);
1638 };
1639 assert_eq!(spelling, "Button.PRIMARY_FIRE");
1640
1641 let LookupOutcome::Matched { results, .. } =
1642 lookup(&within_query(LookupWithin::Settings(String::new())))
1643 else {
1644 panic!("the settings root exists");
1645 };
1646 assert!(
1647 results
1648 .iter()
1649 .all(|hit| matches!(hit, LookupHit::SettingPath { .. })),
1650 "the root has only intermediate segments: {results:?}"
1651 );
1652
1653 let LookupOutcome::Matched { results, .. } = lookup(&within_query(LookupWithin::Settings(
1654 "gamemodes.ffa".to_string(),
1655 ))) else {
1656 panic!("gamemodes.ffa is a settings path prefix");
1657 };
1658 assert!(
1659 results.iter().any(|hit| matches!(
1660 hit,
1661 LookupHit::Setting { path, .. } if path == "gamemodes.ffa.scoreToWin"
1662 )),
1663 "a leaf child reports its Setting hit: {results:?}"
1664 );
1665
1666 let LookupOutcome::UnknownWithin { .. } = lookup(&within_query(LookupWithin::Settings(
1667 "gamemodes.ffa.scoreToWin".to_string(),
1668 ))) else {
1669 panic!("a leaf setting has no children");
1670 };
1671 }
1672
1673 #[test]
1677 fn within_settings_enum_lists_accepted_members() {
1678 let LookupOutcome::Matched { results, .. } = lookup(&within_query(
1679 LookupWithin::SettingEnum("mapRotation".to_string()),
1680 )) else {
1681 panic!("mapRotation is a settings enum domain");
1682 };
1683 let spellings: Vec<&str> = results
1684 .iter()
1685 .map(|hit| {
1686 let LookupHit::EnumMember { spelling, .. } = hit else {
1687 panic!("settings enum children are members: {hit:?}");
1688 };
1689 spelling.as_str()
1690 })
1691 .collect();
1692 assert_eq!(spellings, ["afterAGame", "afterMirrorMatch", "paused"]);
1693 let LookupOutcome::UnknownWithin { .. } =
1694 lookup(&within_query(LookupWithin::Enum("mapRotation".to_string())))
1695 else {
1696 panic!("mapRotation is not a source enum domain");
1697 };
1698 }
1699
1700 #[test]
1703 fn empty_text_lists_children_in_stable_order() {
1704 let outcome_a = lookup(&within_query(LookupWithin::Enum("Button".to_string())));
1705 let outcome_b = lookup(&within_query(LookupWithin::Enum("Button".to_string())));
1706 assert_eq!(outcome_a, outcome_b, "repeat listings are identical");
1707 let LookupOutcome::Matched { results, .. } = outcome_a else {
1708 panic!("Button is a known enum domain");
1709 };
1710 assert!(
1711 results.iter().all(|hit| matches!(
1712 hit,
1713 LookupHit::EnumMember {
1714 matched_on: MatchKind::OpySpelling,
1715 ..
1716 }
1717 )),
1718 "listed members report their spelling form: {results:?}"
1719 );
1720 }
1721}