1use serde::Serialize;
15use workshop_rs::catalog::{Catalog, Kind};
16use workshop_rs::settings::{self, PathPart, SettingValueDomain};
17
18use crate::manifest::{
19 AliasKind, Function, FunctionKind, Manifest, Param, ParamDefault, ReceiverCategory,
20};
21
22pub const DEFAULT_LIMIT: usize = 8;
24
25struct SpecialFunction {
30 name: &'static str,
31 params: &'static [SpecialParam],
32}
33
34struct SpecialParam {
36 name: &'static str,
37 param_type: &'static str,
39 required: bool,
40 default: Option<&'static str>,
42}
43
44const fn required(name: &'static str, param_type: &'static str) -> SpecialParam {
45 SpecialParam {
46 name,
47 param_type,
48 required: true,
49 default: None,
50 }
51}
52
53const fn defaulted(
54 name: &'static str,
55 param_type: &'static str,
56 default: &'static str,
57) -> SpecialParam {
58 SpecialParam {
59 name,
60 param_type,
61 required: false,
62 default: Some(default),
63 }
64}
65
66const fn optional(name: &'static str, param_type: &'static str) -> SpecialParam {
67 SpecialParam {
68 name,
69 param_type,
70 required: false,
71 default: None,
72 }
73}
74
75const SPECIAL_FUNCTIONS: &[SpecialFunction] = &[
78 SpecialFunction {
79 name: "sorted",
80 params: &[
81 required("array", "Array"),
82 optional("key", "Lambda"),
85 ],
86 },
87 SpecialFunction {
88 name: "createWorkshopSetting",
89 params: &[
90 required("type", "Type"),
91 required("category", "CustomStringLiteral"),
92 required("name", "CustomStringLiteral"),
93 required("default", "BoolLiteral|IntLiteral|FloatLiteral|HeroLiteral"),
94 defaulted("sortOrder", "IntLiteral", "0"),
95 ],
96 },
97 SpecialFunction {
98 name: "createWorkshopSettingBool",
99 params: &[
100 required("category", "CustomStringLiteral"),
101 required("name", "CustomStringLiteral"),
102 required("default", "BoolLiteral"),
103 defaulted("sortOrder", "IntLiteral", "0"),
104 ],
105 },
106 SpecialFunction {
107 name: "createWorkshopSettingEnum",
108 params: &[
109 required("category", "CustomStringLiteral"),
110 required("name", "CustomStringLiteral"),
111 required("default", "UnsignedIntLiteral"),
112 required("options", "Array<CustomStringLiteral>"),
113 defaulted("sortOrder", "IntLiteral", "0"),
114 ],
115 },
116 SpecialFunction {
117 name: "createWorkshopSettingInt",
118 params: &[
119 required("category", "CustomStringLiteral"),
120 required("name", "CustomStringLiteral"),
121 required("default", "IntLiteral"),
122 required("min", "IntLiteral"),
123 required("max", "IntLiteral"),
124 defaulted("sortOrder", "IntLiteral", "0"),
125 ],
126 },
127 SpecialFunction {
128 name: "createWorkshopSettingFloat",
129 params: &[
130 required("category", "CustomStringLiteral"),
131 required("name", "CustomStringLiteral"),
132 required("default", "FloatLiteral"),
133 required("min", "FloatLiteral"),
134 required("max", "FloatLiteral"),
135 defaulted("sortOrder", "IntLiteral", "0"),
136 ],
137 },
138 SpecialFunction {
139 name: "createWorkshopSettingHero",
140 params: &[
141 required("category", "CustomStringLiteral"),
142 required("name", "CustomStringLiteral"),
143 required("default", "HeroLiteral"),
144 defaulted("sortOrder", "IntLiteral", "0"),
145 ],
146 },
147];
148
149#[derive(Debug, Clone, PartialEq)]
151pub struct LookupQuery {
152 pub text: String,
157 pub scope: LookupScope,
160 pub within: Option<LookupWithin>,
163 pub locale: Option<String>,
167 pub limit: usize,
169}
170
171impl LookupQuery {
172 pub fn new(text: impl Into<String>) -> LookupQuery {
174 LookupQuery {
175 text: text.into(),
176 scope: LookupScope::ALL,
177 within: None,
178 locale: None,
179 limit: 0,
180 }
181 }
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
186#[serde(rename_all = "camelCase")]
187pub struct LookupScope {
188 pub functions: bool,
190 pub enums: bool,
192 pub settings: bool,
194 pub events: bool,
196}
197
198impl LookupScope {
199 pub const ALL: LookupScope = LookupScope {
201 functions: true,
202 enums: true,
203 settings: true,
204 events: true,
205 };
206 pub const FUNCTIONS: LookupScope = LookupScope {
208 functions: true,
209 enums: false,
210 settings: false,
211 events: false,
212 };
213 pub const ENUMS: LookupScope = LookupScope {
215 functions: false,
216 enums: true,
217 settings: false,
218 events: false,
219 };
220 pub const SETTINGS: LookupScope = LookupScope {
222 functions: false,
223 enums: false,
224 settings: true,
225 events: false,
226 };
227 pub const EVENTS: LookupScope = LookupScope {
229 functions: false,
230 enums: false,
231 settings: false,
232 events: true,
233 };
234
235 fn any(self) -> bool {
236 self.functions || self.enums || self.settings || self.events
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Serialize)]
244#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
245pub enum LookupWithin {
246 Callable(String),
248 Enum(String),
250 Settings(String),
253 SettingEnum(String),
255}
256
257impl LookupWithin {
258 fn scope(&self) -> LookupScope {
260 match self {
261 LookupWithin::Callable(_) => LookupScope::FUNCTIONS,
262 LookupWithin::Enum(_) | LookupWithin::SettingEnum(_) => LookupScope::ENUMS,
263 LookupWithin::Settings(_) => LookupScope::SETTINGS,
264 }
265 }
266}
267
268#[derive(Debug, Clone, PartialEq, Serialize)]
270#[serde(
271 rename_all = "camelCase",
272 rename_all_fields = "camelCase",
273 tag = "kind"
274)]
275pub enum LookupOutcome {
276 Matched {
278 scope: LookupScope,
280 limit: usize,
282 results: Vec<LookupHit>,
284 },
285 UnknownWithin {
288 within: LookupWithin,
290 },
291 Unsupported {
294 scope: LookupScope,
296 reason: String,
299 },
300}
301
302impl LookupOutcome {
303 pub fn results(&self) -> &[LookupHit] {
305 match self {
306 LookupOutcome::Matched { results, .. } => results,
307 LookupOutcome::UnknownWithin { .. } | LookupOutcome::Unsupported { .. } => &[],
308 }
309 }
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
314#[serde(rename_all = "camelCase")]
315pub enum MatchKind {
316 OpySpelling,
318 CatalogId,
320 DisplayName,
322 Alias,
324 Path,
326 Near,
328}
329
330impl MatchKind {
331 pub fn as_str(&self) -> &'static str {
333 match self {
334 MatchKind::OpySpelling => "opySpelling",
335 MatchKind::CatalogId => "catalogId",
336 MatchKind::DisplayName => "displayName",
337 MatchKind::Alias => "alias",
338 MatchKind::Path => "path",
339 MatchKind::Near => "near",
340 }
341 }
342}
343
344#[derive(Debug, Clone, PartialEq, Serialize)]
346#[serde(rename_all = "camelCase", rename_all_fields = "camelCase", tag = "hit")]
347pub enum LookupHit {
348 Function {
350 spelling: String,
352 function_kind: LookupFunctionKind,
354 #[serde(skip_serializing_if = "Option::is_none")]
358 receiver: Option<ReceiverCategory>,
359 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
361 unbounded: bool,
362 params: Vec<LookupParam>,
365 catalog_id: Option<String>,
367 #[serde(skip_serializing_if = "Option::is_none")]
369 display_name: Option<String>,
370 matched_on: MatchKind,
372 },
373 EnumDomain {
375 domain: String,
377 members: Option<Vec<EnumMemberEntry>>,
380 #[serde(skip_serializing_if = "Option::is_none")]
382 display_name: Option<String>,
383 matched_on: MatchKind,
385 },
386 EnumMember {
388 spelling: String,
390 domain: String,
392 member: String,
394 display_name: Option<String>,
396 aliases: Vec<String>,
398 matched_on: MatchKind,
400 },
401 Setting {
403 path: String,
406 display_name: Option<String>,
408 value: SettingValueForm,
410 matched_on: MatchKind,
412 },
413 Parameter {
416 callable: String,
418 param: LookupParam,
420 matched_on: MatchKind,
422 },
423 SettingPath {
426 path: String,
428 matched_on: MatchKind,
430 },
431 Event {
435 spelling: String,
437 accepts_filters: bool,
440 #[serde(skip_serializing_if = "Option::is_none")]
442 display_name: Option<String>,
443 matched_on: MatchKind,
445 },
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
450#[serde(rename_all = "camelCase")]
451pub enum LookupFunctionKind {
452 Action,
454 Value,
456 MemberAction,
458 MemberValue,
460}
461
462impl LookupFunctionKind {
463 pub fn as_str(&self) -> &'static str {
465 match self {
466 LookupFunctionKind::Action => "action",
467 LookupFunctionKind::Value => "value",
468 LookupFunctionKind::MemberAction => "memberAction",
469 LookupFunctionKind::MemberValue => "memberValue",
470 }
471 }
472}
473
474#[derive(Debug, Clone, PartialEq, Serialize)]
476#[serde(rename_all = "camelCase")]
477pub struct LookupParam {
478 pub name: String,
480 #[serde(skip_serializing_if = "Vec::is_empty")]
482 pub alternate_names: Vec<String>,
483 pub required: bool,
485 #[serde(skip_serializing_if = "Option::is_none")]
487 pub domain: Option<String>,
488 #[serde(skip_serializing_if = "Option::is_none")]
492 pub members: Option<Vec<EnumMemberEntry>>,
493 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
495 pub param_type: Option<String>,
496 #[serde(skip_serializing_if = "Option::is_none")]
498 pub default: Option<String>,
499}
500
501#[derive(Debug, Clone, PartialEq, Serialize)]
503#[serde(rename_all = "camelCase")]
504pub struct EnumMemberEntry {
505 pub spelling: String,
507 pub id: String,
509 #[serde(skip_serializing_if = "Option::is_none")]
511 pub display_name: Option<String>,
512 #[serde(skip_serializing_if = "Vec::is_empty")]
514 pub aliases: Vec<String>,
515}
516
517#[derive(Debug, Clone, PartialEq, Serialize)]
519#[serde(rename_all = "camelCase")]
520pub struct SettingValueForm {
521 pub kind: SettingValueKind,
523 #[serde(skip_serializing_if = "Option::is_none")]
525 pub domain: Option<String>,
526 #[serde(skip_serializing_if = "Vec::is_empty")]
528 pub members: Vec<String>,
529 #[serde(skip_serializing_if = "Option::is_none")]
531 pub min: Option<f64>,
532 #[serde(skip_serializing_if = "Option::is_none")]
534 pub max: Option<f64>,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
539#[serde(rename_all = "camelCase")]
540pub enum SettingValueKind {
541 Presence,
543 Boolean,
545 Number,
547 Percent,
549 String,
551 Enum,
553 MapList,
555 HeroList,
557}
558
559impl SettingValueKind {
560 pub fn as_str(&self) -> &'static str {
562 match self {
563 SettingValueKind::Presence => "presence",
564 SettingValueKind::Boolean => "boolean",
565 SettingValueKind::Number => "number",
566 SettingValueKind::Percent => "percent",
567 SettingValueKind::String => "string",
568 SettingValueKind::Enum => "enum",
569 SettingValueKind::MapList => "mapList",
570 SettingValueKind::HeroList => "heroList",
571 }
572 }
573}
574
575pub fn lookup(query: &LookupQuery) -> LookupOutcome {
584 let limit = if query.limit == 0 {
585 DEFAULT_LIMIT
586 } else {
587 query.limit
588 };
589 let scope = query.scope;
590 let unsupported = |reason: &str| LookupOutcome::Unsupported {
591 scope,
592 reason: reason.to_string(),
593 };
594 if let Some(locale) = &query.locale {
595 if !locale.eq_ignore_ascii_case("en-US") {
596 return unsupported(
597 "localized display names are owned by workshop-rs; opy-rs \
598 resolves display names in the 'en-US' locale only",
599 );
600 }
601 }
602 let (manifest, catalog) = match (Manifest::builtin(), Catalog::builtin()) {
603 (Ok(manifest), Ok(catalog)) => (manifest, catalog),
604 _ => return unsupported("vocabulary data (manifest or catalog) is not bundled"),
605 };
606 let index = Index::new(manifest, &catalog);
607 if let Some(within) = &query.within {
608 let Some(children) = index.children(within) else {
609 return LookupOutcome::UnknownWithin {
610 within: within.clone(),
611 };
612 };
613 let mut results = index.rank(children.iter(), query);
614 results.truncate(limit);
615 return LookupOutcome::Matched {
616 scope: within.scope(),
617 limit,
618 results,
619 };
620 }
621 if !query.scope.any() {
622 return unsupported("the query scope selects no namespace");
623 }
624 let mut results = index.rank(
625 index
626 .candidates
627 .iter()
628 .filter(|candidate| index.in_scope(candidate, scope)),
629 query,
630 );
631 results.truncate(limit);
632 LookupOutcome::Matched {
633 scope,
634 limit,
635 results,
636 }
637}
638
639pub fn lookup_str(text: &str) -> LookupOutcome {
641 lookup(&LookupQuery::new(text))
642}
643
644struct ScoredHit {
649 score: u32,
650 hit: LookupHit,
651}
652
653impl LookupHit {
654 fn sort_key(&self) -> String {
655 match self {
656 LookupHit::Function { spelling, .. } => format!("0{spelling}"),
657 LookupHit::EnumMember { spelling, .. } => format!("1{spelling}"),
658 LookupHit::EnumDomain { domain, .. } => format!("2{domain}"),
659 LookupHit::Setting { path, .. } => format!("3{path}"),
660 LookupHit::Parameter {
661 callable, param, ..
662 } => {
663 format!("4{callable}.{}", param.name)
664 }
665 LookupHit::SettingPath { path, .. } => format!("5{path}"),
666 LookupHit::Event { spelling, .. } => format!("6{spelling}"),
667 }
668 }
669
670 fn listed(self) -> LookupHit {
673 let kind = match &self {
674 LookupHit::Setting { .. } | LookupHit::SettingPath { .. } => MatchKind::Path,
675 _ => MatchKind::OpySpelling,
676 };
677 self.with_match_kind(kind, 0, false)
678 }
679
680 fn with_match_kind(self, kind: MatchKind, score: u32, structural: bool) -> LookupHit {
684 let matched_on = if score == 0 || structural {
685 kind
686 } else {
687 MatchKind::Near
688 };
689 match self {
690 LookupHit::Function {
691 spelling,
692 function_kind,
693 receiver,
694 unbounded,
695 params,
696 catalog_id,
697 display_name,
698 ..
699 } => LookupHit::Function {
700 spelling,
701 function_kind,
702 receiver,
703 unbounded,
704 params,
705 catalog_id,
706 display_name,
707 matched_on,
708 },
709 LookupHit::EnumDomain {
710 domain,
711 members,
712 display_name,
713 ..
714 } => LookupHit::EnumDomain {
715 domain,
716 members,
717 display_name,
718 matched_on,
719 },
720 LookupHit::EnumMember {
721 spelling,
722 domain,
723 member,
724 display_name,
725 aliases,
726 ..
727 } => LookupHit::EnumMember {
728 spelling,
729 domain,
730 member,
731 display_name,
732 aliases,
733 matched_on,
734 },
735 LookupHit::Setting {
736 path,
737 display_name,
738 value,
739 ..
740 } => LookupHit::Setting {
741 path,
742 display_name,
743 value,
744 matched_on,
745 },
746 LookupHit::Parameter {
747 callable, param, ..
748 } => LookupHit::Parameter {
749 callable,
750 param,
751 matched_on,
752 },
753 LookupHit::SettingPath { path, .. } => LookupHit::SettingPath { path, matched_on },
754 LookupHit::Event {
755 spelling,
756 accepts_filters,
757 display_name,
758 ..
759 } => LookupHit::Event {
760 spelling,
761 accepts_filters,
762 display_name,
763 matched_on,
764 },
765 }
766 }
767}
768
769#[derive(Clone)]
772struct Candidate {
773 forms: Vec<(String, MatchKind)>,
774 hit: LookupHit,
775}
776
777struct Index<'a> {
778 manifest: &'a Manifest,
779 catalog: &'a Catalog,
780 candidates: Vec<Candidate>,
781}
782
783impl<'a> Index<'a> {
784 fn new(manifest: &'a Manifest, catalog: &'a Catalog) -> Index<'a> {
785 let mut index = Index {
786 manifest,
787 catalog,
788 candidates: Vec::new(),
789 };
790 index.index_functions();
791 index.index_enums();
792 index.index_settings();
793 index.index_events();
794 index
795 }
796
797 fn rank<'c>(
801 &self,
802 candidates: impl Iterator<Item = &'c Candidate>,
803 query: &LookupQuery,
804 ) -> Vec<LookupHit> {
805 if query.text.trim().is_empty() {
806 return candidates
807 .map(|candidate| candidate.hit.clone().listed())
808 .collect();
809 }
810 let mut hits: Vec<ScoredHit> = candidates
811 .filter_map(|candidate| {
812 candidate
813 .forms
814 .iter()
815 .filter_map(|(form, kind)| {
816 let (scored, structural) = if *kind == MatchKind::Path {
823 match crate::matcher::path_score(&query.text, form) {
824 Some(s) => (Some(s), true),
825 None => (crate::matcher::score(&query.text, form), false),
826 }
827 } else {
828 (crate::matcher::score(&query.text, form), false)
829 };
830 scored.map(|s| {
831 (
834 s,
835 form.eq_ignore_ascii_case(query.text.trim()),
836 *kind,
837 structural,
838 )
839 })
840 })
841 .min_by_key(|(s, exact, _, _)| (*s, !*exact))
842 .map(|(s, _, kind, structural)| ScoredHit {
843 score: s,
844 hit: candidate.hit.clone().with_match_kind(kind, s, structural),
845 })
846 })
847 .collect();
848 hits.sort_by(|a, b| {
849 a.score
850 .cmp(&b.score)
851 .then_with(|| a.hit.sort_key().cmp(&b.hit.sort_key()))
852 });
853 hits.into_iter().map(|scored| scored.hit).collect()
854 }
855
856 fn in_scope(&self, candidate: &Candidate, scope: LookupScope) -> bool {
857 match &candidate.hit {
858 LookupHit::Function { .. } | LookupHit::Parameter { .. } => scope.functions,
859 LookupHit::EnumDomain { .. } | LookupHit::EnumMember { .. } => scope.enums,
860 LookupHit::Setting { .. } | LookupHit::SettingPath { .. } => scope.settings,
861 LookupHit::Event { .. } => scope.events,
862 }
863 }
864
865 fn children(&self, within: &LookupWithin) -> Option<Vec<Candidate>> {
870 match within {
871 LookupWithin::Callable(name) => self.callable_children(name),
872 LookupWithin::Enum(domain) => self.enum_children(domain),
873 LookupWithin::Settings(prefix) => self.settings_children(prefix),
874 LookupWithin::SettingEnum(domain) => self.setting_enum_children(domain),
875 }
876 }
877
878 fn callable_children(&self, name: &str) -> Option<Vec<Candidate>> {
881 self.candidates
882 .iter()
883 .find_map(|candidate| match &candidate.hit {
884 LookupHit::Function {
885 spelling, params, ..
886 } if spelling == name => Some(
887 params
888 .iter()
889 .map(|param| {
890 let mut forms = vec![(param.name.clone(), MatchKind::OpySpelling)];
891 forms.extend(
892 param
893 .alternate_names
894 .iter()
895 .map(|name| (name.clone(), MatchKind::Alias)),
896 );
897 Candidate {
898 forms,
899 hit: LookupHit::Parameter {
900 callable: spelling.clone(),
901 param: param.clone(),
902 matched_on: MatchKind::OpySpelling,
903 },
904 }
905 })
906 .collect(),
907 ),
908 _ => None,
909 })
910 }
911
912 fn enum_children(&self, domain: &str) -> Option<Vec<Candidate>> {
915 let known = self.candidates.iter().any(|candidate| {
916 matches!(&candidate.hit, LookupHit::EnumDomain { domain: d, .. } if d == domain)
917 });
918 if !known {
919 return None;
920 }
921 Some(
922 crate::enums::domain_members(domain, self.catalog)
923 .map(|members| {
924 members
925 .iter()
926 .map(|member| self.member_candidate(domain, member))
927 .collect()
928 })
929 .unwrap_or_default(),
930 )
931 }
932
933 fn settings_children(&self, prefix: &str) -> Option<Vec<Candidate>> {
938 let prefix_segments: Vec<&str> =
939 prefix.split('.').filter(|part| !part.is_empty()).collect();
940 let mut children: Vec<Candidate> = Vec::new();
941 let mut seen: Vec<&str> = Vec::new();
942 for candidate in &self.candidates {
943 let LookupHit::Setting { path, .. } = &candidate.hit else {
944 continue;
945 };
946 let segments: Vec<&str> = path.split('.').collect();
947 if !segments.starts_with(prefix_segments.as_slice()) {
948 continue;
949 }
950 let Some(&child_segment) = segments.get(prefix_segments.len()) else {
951 continue;
952 };
953 if seen.contains(&child_segment) {
954 continue;
955 }
956 seen.push(child_segment);
957 if segments.len() == prefix_segments.len() + 1 {
958 children.push(candidate.clone());
959 } else {
960 let child_path = prefix_segments
961 .iter()
962 .copied()
963 .chain(std::iter::once(child_segment))
964 .collect::<Vec<_>>()
965 .join(".");
966 children.push(Candidate {
967 forms: vec![
968 (child_segment.to_string(), MatchKind::OpySpelling),
969 (child_path.clone(), MatchKind::Path),
970 ],
971 hit: LookupHit::SettingPath {
972 path: child_path,
973 matched_on: MatchKind::Path,
974 },
975 });
976 }
977 }
978 (!children.is_empty()).then_some(children)
979 }
980
981 fn setting_enum_children(&self, domain: &str) -> Option<Vec<Candidate>> {
984 let mut members: Vec<(String, String)> = Vec::new();
985 for definition in settings::definitions() {
986 for member in definition.enum_members() {
987 if member.domain() == domain && !members.iter().any(|(id, _)| id == member.id()) {
988 members.push((member.id().to_string(), member.english_name().to_string()));
989 }
990 }
991 }
992 if members.is_empty() {
993 return None;
994 }
995 Some(
996 members
997 .into_iter()
998 .map(|(id, name)| Candidate {
999 forms: vec![
1000 (id.clone(), MatchKind::OpySpelling),
1001 (name.clone(), MatchKind::DisplayName),
1002 ],
1003 hit: LookupHit::EnumMember {
1004 spelling: id.clone(),
1005 domain: domain.to_string(),
1006 member: id,
1007 display_name: (!name.is_empty()).then_some(name),
1008 aliases: Vec::new(),
1009 matched_on: MatchKind::OpySpelling,
1010 },
1011 })
1012 .collect(),
1013 )
1014 }
1015
1016 fn index_functions(&mut self) {
1019 let locale = crate::enums::en_us();
1020 for function in self.manifest.functions() {
1021 let mut forms = vec![(function.id.clone(), MatchKind::OpySpelling)];
1022 let mut display_name = None;
1023 if let Some(catalog_id) = &function.catalog_id {
1024 forms.push((catalog_id.clone(), MatchKind::CatalogId));
1025 let kind = match function.kind {
1026 FunctionKind::Action | FunctionKind::MemberAction => Kind::Action,
1027 FunctionKind::Value | FunctionKind::MemberValue => Kind::Value,
1028 };
1029 if let Some(entry) = self.catalog.entry(kind, catalog_id) {
1030 if let Some(spelling) = entry.spelling(&locale) {
1031 display_name = Some(spelling.to_string());
1032 forms.push((spelling.to_string(), MatchKind::DisplayName));
1033 }
1034 for spelling in entry.spellings(&locale) {
1035 forms.push((spelling.clone(), MatchKind::DisplayName));
1036 }
1037 }
1038 }
1039 for alias in self.manifest.aliases() {
1040 if alias_target(alias, self.manifest)
1041 .is_some_and(|target| std::ptr::eq(target, function))
1042 {
1043 forms.push((alias.source.clone(), MatchKind::Alias));
1044 }
1045 }
1046 let params = self.lookup_params(function);
1047 self.candidates.push(Candidate {
1048 forms,
1049 hit: LookupHit::Function {
1050 spelling: function.id.clone(),
1051 function_kind: match function.kind {
1052 FunctionKind::Action => LookupFunctionKind::Action,
1053 FunctionKind::Value => LookupFunctionKind::Value,
1054 FunctionKind::MemberAction => LookupFunctionKind::MemberAction,
1055 FunctionKind::MemberValue => LookupFunctionKind::MemberValue,
1056 },
1057 receiver: function.receiver,
1058 unbounded: function.unbounded,
1059 params,
1060 catalog_id: function.catalog_id.clone(),
1061 display_name,
1062 matched_on: MatchKind::OpySpelling,
1063 },
1064 });
1065 }
1066 for special in SPECIAL_FUNCTIONS {
1067 let params: Vec<LookupParam> = special
1068 .params
1069 .iter()
1070 .map(|param| LookupParam {
1071 name: param.name.to_string(),
1072 alternate_names: Vec::new(),
1073 required: param.required,
1074 domain: None,
1075 members: None,
1076 param_type: Some(param.param_type.to_string()),
1077 default: param.default.map(str::to_string),
1078 })
1079 .collect();
1080 self.candidates.push(Candidate {
1081 forms: vec![(special.name.to_string(), MatchKind::OpySpelling)],
1082 hit: LookupHit::Function {
1083 spelling: special.name.to_string(),
1084 function_kind: LookupFunctionKind::Value,
1085 receiver: None,
1086 unbounded: false,
1087 params,
1088 catalog_id: None,
1089 display_name: None,
1090 matched_on: MatchKind::OpySpelling,
1091 },
1092 });
1093 }
1094 }
1095
1096 fn lookup_params(&self, function: &Function) -> Vec<LookupParam> {
1097 let catalog_entry = function.catalog_id.as_deref().and_then(|catalog_id| {
1098 let kind = match function.kind {
1099 FunctionKind::Action | FunctionKind::MemberAction => Kind::Action,
1100 FunctionKind::Value | FunctionKind::MemberValue => Kind::Value,
1101 };
1102 self.catalog.entry(kind, catalog_id)
1103 });
1104 function
1105 .params
1106 .iter()
1107 .enumerate()
1108 .map(|(index, param)| {
1109 let catalog_index = self.catalog_param_index(catalog_entry, function, param, index);
1110 let (param_type, catalog_domain) = catalog_index
1111 .and_then(|i| {
1112 catalog_entry.map(|entry| {
1113 (
1114 entry.param_type(i).map(str::to_string),
1115 entry.param_domain(i).map(str::to_string),
1116 )
1117 })
1118 })
1119 .unwrap_or((None, None));
1120 let domain = param
1123 .domain
1124 .clone()
1125 .or(catalog_domain)
1126 .map(|domain| crate::enums::opy_domain(&domain).to_string());
1127 let required = !param.optional && param.default.is_none();
1128 let members = domain
1132 .as_deref()
1133 .and_then(|domain| crate::enums::domain_members(domain, self.catalog))
1134 .map(|members| members.iter().map(member_entry).collect::<Vec<_>>());
1135 let default = param
1136 .default
1137 .as_ref()
1138 .map(|d| default_text(d, domain.as_deref(), self.catalog));
1139 LookupParam {
1140 name: param.name.clone(),
1141 alternate_names: param.alternate_names.clone(),
1142 required,
1143 members,
1144 domain,
1145 param_type,
1146 default,
1147 }
1148 })
1149 .collect()
1150 }
1151
1152 fn catalog_param_index(
1156 &self,
1157 entry: Option<&workshop_rs::catalog::CatalogEntry>,
1158 function: &Function,
1159 param: &Param,
1160 index: usize,
1161 ) -> Option<usize> {
1162 let entry = entry?;
1163 let count = entry.param_count();
1164 let offset = if function.kind.is_member() && count == function.params.len() + 1 {
1165 1
1166 } else {
1167 0
1168 };
1169 if count == function.params.len() + offset {
1170 let candidate = index + offset;
1173 match (0..count).position(|i| {
1174 entry.param_name(i).is_some_and(|name| {
1175 crate::matcher::fold(name) == crate::matcher::fold(¶m.name)
1176 })
1177 }) {
1178 Some(i) => Some(i),
1179 None => (candidate < count).then_some(candidate),
1180 }
1181 } else {
1182 (0..count).find(|&i| {
1183 entry.param_name(i).is_some_and(|name| {
1184 crate::matcher::fold(name) == crate::matcher::fold(¶m.name)
1185 })
1186 })
1187 }
1188 }
1189
1190 fn index_enums(&mut self) {
1193 for domain in self.lookup_domains() {
1194 let members = crate::enums::domain_members(&domain, self.catalog);
1195 let mut domain_forms = vec![(domain.clone(), MatchKind::OpySpelling)];
1196 let mut display_name = None;
1197 if let Some(catalog_domain) = self
1198 .catalog
1199 .enum_domain(crate::enums::catalog_domain(&domain))
1200 {
1201 domain_forms.push((catalog_domain.domain.clone(), MatchKind::CatalogId));
1202 let locale = crate::enums::en_us();
1203 if let Some(spelling) = catalog_domain.spelling(&locale) {
1204 display_name = Some(spelling.to_string());
1205 domain_forms.push((spelling.to_string(), MatchKind::DisplayName));
1206 }
1207 }
1208 let entries: Option<Vec<EnumMemberEntry>> = members
1209 .as_ref()
1210 .map(|members| members.iter().map(member_entry).collect());
1211 self.candidates.push(Candidate {
1212 forms: domain_forms,
1213 hit: LookupHit::EnumDomain {
1214 domain: domain.clone(),
1215 members: entries,
1216 display_name,
1217 matched_on: MatchKind::OpySpelling,
1218 },
1219 });
1220 if let Some(members) = members {
1221 for member in &members {
1222 self.candidates.push(self.member_candidate(&domain, member));
1223 }
1224 }
1225 }
1226 }
1227
1228 fn member_candidate(&self, domain: &str, member: &crate::enums::DomainMember) -> Candidate {
1231 let entry = member_entry(member);
1232 let mut forms = vec![
1233 (
1234 format!("{domain}.{}", entry.spelling),
1235 MatchKind::OpySpelling,
1236 ),
1237 (entry.spelling.clone(), MatchKind::OpySpelling),
1238 ];
1239 if let Some(display) = &member.display_name {
1240 forms.push((display.clone(), MatchKind::DisplayName));
1241 }
1242 if let Some(catalog_member) = &member.catalog_member {
1243 forms.push((catalog_member.clone(), MatchKind::CatalogId));
1244 }
1245 for alias in &member.aliases {
1246 forms.push((alias.clone(), MatchKind::Alias));
1247 }
1248 Candidate {
1249 forms,
1250 hit: LookupHit::EnumMember {
1251 spelling: format!("{domain}.{}", entry.spelling),
1252 domain: domain.to_string(),
1253 member: entry.id,
1254 display_name: entry.display_name,
1255 aliases: entry.aliases,
1256 matched_on: MatchKind::OpySpelling,
1257 },
1258 }
1259 }
1260
1261 fn lookup_domains(&self) -> Vec<String> {
1268 let mut domains: Vec<String> = self
1269 .catalog
1270 .enum_domains()
1271 .filter(|domain| !crate::enums::NON_SOURCE_DOMAINS.contains(&domain.domain.as_str()))
1272 .map(|domain| crate::enums::opy_domain(&domain.domain).to_string())
1273 .collect();
1274 domains.extend(
1275 crate::enums::DOMAIN_ALIASES
1276 .iter()
1277 .map(|(source, _)| source.to_string()),
1278 );
1279 for domain in self
1280 .manifest
1281 .functions()
1282 .iter()
1283 .flat_map(|function| function.params.iter())
1284 .filter_map(|param| param.domain.as_deref())
1285 .map(crate::enums::opy_domain)
1286 {
1287 if !crate::enums::NON_SOURCE_DOMAINS.contains(&domain)
1288 && !domains.iter().any(|d| d == domain)
1289 {
1290 domains.push(domain.to_string());
1291 }
1292 }
1293 domains
1294 }
1295
1296 fn index_settings(&mut self) {
1299 for definition in settings::definitions() {
1300 self.push_setting(definition.path(), &definition);
1301 }
1302 let modes: Vec<String> = settings::definitions()
1306 .filter_map(|definition| {
1307 let mut parts = definition.path().split('.');
1308 match (parts.next(), parts.next(), parts.next()) {
1309 (Some("gamemodes"), Some(mode), Some(_)) if mode != "general" => {
1310 Some(mode.to_string())
1311 }
1312 _ => None,
1313 }
1314 })
1315 .collect();
1316 let modes: Vec<String> = {
1317 let mut seen = Vec::new();
1318 for mode in modes {
1319 if !seen.contains(&mode) {
1320 seen.push(mode);
1321 }
1322 }
1323 seen
1324 };
1325 for definition in settings::definitions() {
1326 let Some(leaf) = definition.path().strip_prefix("gamemodes.general.") else {
1327 continue;
1328 };
1329 for mode in &modes {
1330 let probe = [
1331 PathPart::Part("gamemodes"),
1332 PathPart::Part(mode.as_str()),
1333 PathPart::Part(leaf),
1334 ];
1335 if let Some(resolved) = settings::definition(&probe) {
1336 let path = format!("gamemodes.{mode}.{leaf}");
1337 if resolved.path() != path {
1338 self.push_setting(&path, &resolved);
1340 }
1341 }
1342 }
1343 }
1344 }
1345
1346 fn push_setting(&mut self, path: &str, definition: &settings::SettingDefinition) {
1347 let leaf = path.rsplit('.').next().unwrap_or(path);
1348 let english_name = definition.presentation().english_name;
1349 let mut forms = vec![
1350 (path.to_string(), MatchKind::Path),
1351 (leaf.to_string(), MatchKind::OpySpelling),
1352 ];
1353 if !english_name.is_empty() {
1354 forms.push((english_name.to_string(), MatchKind::DisplayName));
1355 }
1356 self.candidates.push(Candidate {
1357 forms,
1358 hit: LookupHit::Setting {
1359 path: path.to_string(),
1360 display_name: (!english_name.is_empty()).then(|| english_name.to_string()),
1361 value: setting_value_form(definition),
1362 matched_on: MatchKind::Path,
1363 },
1364 });
1365 }
1366
1367 fn index_events(&mut self) {
1374 let locale = crate::enums::en_us();
1375 for entry in self.catalog.entries_of(Kind::Event) {
1376 let accepts_filters = match entry.id.as_str() {
1377 "global" => false,
1378 id if id == "eachPlayer" || crate::compiler::player_event_kind(id).is_some() => {
1379 true
1380 }
1381 _ => continue,
1382 };
1383 let display_name = entry.spelling(&locale).map(str::to_string);
1384 let mut forms = vec![(entry.id.clone(), MatchKind::OpySpelling)];
1385 for spelling in entry.spellings(&locale) {
1386 forms.push((spelling.clone(), MatchKind::DisplayName));
1387 }
1388 self.candidates.push(Candidate {
1389 forms,
1390 hit: LookupHit::Event {
1391 spelling: entry.id.clone(),
1392 accepts_filters,
1393 display_name,
1394 matched_on: MatchKind::OpySpelling,
1395 },
1396 });
1397 }
1398 }
1399}
1400
1401fn alias_target<'a>(
1403 alias: &crate::manifest::Alias,
1404 manifest: &'a Manifest,
1405) -> Option<&'a Function> {
1406 match alias.kind {
1407 AliasKind::FunctionAlias => manifest.function(&alias.target),
1408 AliasKind::MemberAlias => manifest.member(&alias.target),
1409 }
1410}
1411
1412fn member_entry(member: &crate::enums::DomainMember) -> EnumMemberEntry {
1414 EnumMemberEntry {
1415 spelling: member.member.clone(),
1416 id: member
1417 .catalog_member
1418 .clone()
1419 .unwrap_or_else(|| member.member.clone()),
1420 display_name: member.display_name.clone(),
1421 aliases: member.aliases.clone(),
1422 }
1423}
1424
1425fn setting_value_form(definition: &settings::SettingDefinition) -> SettingValueForm {
1427 let (kind, domain, min, max) = match definition.domain() {
1428 SettingValueDomain::PresenceOnly => (SettingValueKind::Presence, None, None, None),
1429 SettingValueDomain::Boolean => (SettingValueKind::Boolean, None, None, None),
1430 SettingValueDomain::Number(bounds) => {
1431 (SettingValueKind::Number, None, bounds.min(), bounds.max())
1432 }
1433 SettingValueDomain::Percent(bounds) => {
1434 (SettingValueKind::Percent, None, bounds.min(), bounds.max())
1435 }
1436 SettingValueDomain::String => (SettingValueKind::String, None, None, None),
1437 SettingValueDomain::Enum { domain } => {
1438 (SettingValueKind::Enum, Some(domain.clone()), None, None)
1439 }
1440 SettingValueDomain::MapList => (SettingValueKind::MapList, None, None, None),
1441 SettingValueDomain::HeroList => (SettingValueKind::HeroList, None, None, None),
1442 other => unreachable!("unhandled SettingValueDomain variant {other:?}"),
1445 };
1446 let members: Vec<String> =
1449 if kind == SettingValueKind::Boolean && definition.enum_members().next().is_some() {
1450 vec!["true".to_string()]
1451 } else {
1452 definition
1453 .enum_members()
1454 .map(|member| member.id().to_string())
1455 .collect()
1456 };
1457 SettingValueForm {
1458 kind,
1459 domain,
1460 members,
1461 min,
1462 max,
1463 }
1464}
1465
1466fn default_text(default: &ParamDefault, domain: Option<&str>, catalog: &Catalog) -> String {
1468 match default {
1469 ParamDefault::Null { .. } => "null".to_string(),
1470 ParamDefault::Bool(value) => value.to_string(),
1471 ParamDefault::Number(value) => {
1472 if value.fract() == 0.0 {
1473 format!("{}", *value as i64)
1474 } else {
1475 value.to_string()
1476 }
1477 }
1478 ParamDefault::Call { call, args } => {
1479 let args = args
1480 .iter()
1481 .map(|arg| {
1482 if arg.fract() == 0.0 {
1483 format!("{}", *arg as i64)
1484 } else {
1485 arg.to_string()
1486 }
1487 })
1488 .collect::<Vec<_>>()
1489 .join(", ");
1490 format!("{call}({args})")
1491 }
1492 ParamDefault::EnumMember(member) => match domain {
1493 Some(domain) => {
1496 let spelling = crate::enums::domain_members(domain, catalog)
1497 .and_then(|members| {
1498 members
1499 .iter()
1500 .find(|entry| {
1501 entry.member == *member
1502 || entry.catalog_member.as_deref() == Some(member.as_str())
1503 })
1504 .map(|entry| entry.member.clone())
1505 })
1506 .unwrap_or_else(|| member.clone());
1507 format!("{domain}.{spelling}")
1508 }
1509 None => member.clone(),
1510 },
1511 }
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516 use super::*;
1517 use workshop_rs::catalog::Catalog;
1518
1519 #[test]
1523 fn reported_member_spellings_resolve_through_lowering() {
1524 let catalog = Catalog::builtin().expect("bundled catalog");
1525 let manifest = Manifest::builtin().expect("bundled manifest");
1526 let index = Index::new(manifest, &catalog);
1527 for domain in index.lookup_domains() {
1528 let Some(members) = crate::enums::domain_members(&domain, &catalog) else {
1529 continue;
1530 };
1531 for member in &members {
1532 for spelling in std::iter::once(&member.member).chain(&member.aliases) {
1533 let resolves = crate::enums::canonical_member(&domain, spelling, &catalog)
1537 .is_some()
1538 || (domain == "Color"
1539 && crate::enums::extra_color_member(spelling).is_some());
1540 assert!(
1541 resolves,
1542 "{domain}.{spelling} is lookup-visible but does not resolve"
1543 );
1544 }
1545 }
1546 }
1547 }
1548
1549 #[test]
1553 fn reported_function_spellings_resolve_through_the_manifest() {
1554 let manifest = Manifest::builtin().expect("bundled manifest");
1555 for function in manifest.functions() {
1556 let resolves = if function.kind.is_member() {
1557 manifest.resolve_member(&function.id).is_some()
1558 } else {
1559 manifest.resolve_function(&function.id).is_some()
1560 };
1561 assert!(
1562 resolves,
1563 "{} is lookup-visible but does not resolve",
1564 function.id
1565 );
1566 }
1567 let reported: Vec<&str> = SPECIAL_FUNCTIONS
1572 .iter()
1573 .map(|special| special.name)
1574 .collect();
1575 let dispatched: Vec<&str> = crate::lower::special_forms::SPECIAL_VALUE_CALLS
1576 .iter()
1577 .map(|(name, _)| *name)
1578 .collect();
1579 assert_eq!(
1580 reported, dispatched,
1581 "reported special-call spellings must be the dispatched set"
1582 );
1583 for special in SPECIAL_FUNCTIONS {
1587 let source = format!(
1588 "globalvar x\nrule \"r\":\n @Event global\n x = {}()\n",
1589 special.name
1590 );
1591 let diagnostics =
1592 crate::tooling::check(&source, "main.opy", std::path::Path::new("")).diagnostics;
1593 assert!(
1594 !diagnostics.iter().any(|diagnostic| matches!(
1595 diagnostic.code.as_str(),
1596 "unknown-value" | "unknown-action" | "invalid-iterable"
1597 )),
1598 "'{}' is lookup-reported but the lowerer rejects it: {diagnostics:?}",
1599 special.name
1600 );
1601 }
1602 }
1603
1604 #[test]
1613 fn reported_member_aliases_are_reference_spellings() {
1614 let catalog = Catalog::builtin().expect("bundled catalog");
1615 let manifest = Manifest::builtin().expect("bundled manifest");
1616 let index = Index::new(manifest, &catalog);
1617 let mut reported = Vec::new();
1618 for domain in index.lookup_domains() {
1619 let Some(members) = crate::enums::domain_members(&domain, &catalog) else {
1620 continue;
1621 };
1622 for member in members {
1623 for alias in member.aliases {
1624 assert_ne!(
1625 Some(alias.as_str()),
1626 member.catalog_member.as_deref(),
1627 "{domain}.{alias} advertises the catalog id as an OPY alias"
1628 );
1629 reported.push(format!("{domain}.{alias}"));
1630 }
1631 }
1632 }
1633 reported.sort();
1634 assert_eq!(
1635 reported,
1636 ["Hero.HAMMOND", "Hero.MCCREE", "HudPosition.ACTUALLY_LEFT"]
1637 );
1638 }
1639
1640 #[test]
1643 fn rank_prefers_close_spellings() {
1644 let pool = crate::matcher::bare_candidates(["hudText", "smallMessage", "destroyIcon"]);
1645 let ranked = crate::matcher::rank("hudTex", &pool);
1646 assert_eq!(ranked.first().map(String::as_str), Some("hudText"));
1647 assert!(
1648 !ranked.iter().any(|candidate| candidate == "hudTex"),
1649 "the rejected spelling is not a candidate"
1650 );
1651 }
1652
1653 #[test]
1656 fn sibling_keys_cover_inherited_gamemode_keys() {
1657 let keys =
1658 crate::matcher::sibling_keys(&[PathPart::Part("gamemodes"), PathPart::Part("ffa")]);
1659 assert!(
1660 keys.iter().any(|key| key == "scoreToWin"),
1661 "inherited general keys must be candidates: {keys:?}"
1662 );
1663 }
1664
1665 fn within_query(within: LookupWithin) -> LookupQuery {
1666 LookupQuery {
1667 text: String::new(),
1668 scope: LookupScope::ALL,
1669 within: Some(within),
1670 locale: None,
1671 limit: usize::MAX,
1672 }
1673 }
1674
1675 #[test]
1678 fn within_callable_lists_params_in_call_order() {
1679 let LookupOutcome::Matched { results, .. } =
1680 lookup(&within_query(LookupWithin::Callable("wait".to_string())))
1681 else {
1682 panic!("wait is a known callable");
1683 };
1684 let names: Vec<&str> = results
1685 .iter()
1686 .map(|hit| {
1687 let LookupHit::Parameter { param, .. } = hit else {
1688 panic!("callable children are parameters: {hit:?}");
1689 };
1690 param.name.as_str()
1691 })
1692 .collect();
1693 assert_eq!(names, ["time", "waitBehavior"]);
1694 let LookupOutcome::UnknownWithin { .. } = lookup(&within_query(LookupWithin::Callable(
1695 "notAFunction".to_string(),
1696 ))) else {
1697 panic!("an unknown callable is not a scope");
1698 };
1699 }
1700
1701 #[test]
1706 fn within_scopes_list_their_children() {
1707 let LookupOutcome::Matched { results, .. } =
1708 lookup(&within_query(LookupWithin::Enum("Button".to_string())))
1709 else {
1710 panic!("Button is a known enum domain");
1711 };
1712 let LookupHit::EnumMember { spelling, .. } = &results[0] else {
1713 panic!("enum children are members: {:?}", results[0]);
1714 };
1715 assert_eq!(spelling, "Button.PRIMARY_FIRE");
1716
1717 let LookupOutcome::Matched { results, .. } =
1718 lookup(&within_query(LookupWithin::Settings(String::new())))
1719 else {
1720 panic!("the settings root exists");
1721 };
1722 assert!(
1723 results
1724 .iter()
1725 .all(|hit| matches!(hit, LookupHit::SettingPath { .. })),
1726 "the root has only intermediate segments: {results:?}"
1727 );
1728
1729 let LookupOutcome::Matched { results, .. } = lookup(&within_query(LookupWithin::Settings(
1730 "gamemodes.ffa".to_string(),
1731 ))) else {
1732 panic!("gamemodes.ffa is a settings path prefix");
1733 };
1734 assert!(
1735 results.iter().any(|hit| matches!(
1736 hit,
1737 LookupHit::Setting { path, .. } if path == "gamemodes.ffa.scoreToWin"
1738 )),
1739 "a leaf child reports its Setting hit: {results:?}"
1740 );
1741
1742 let LookupOutcome::UnknownWithin { .. } = lookup(&within_query(LookupWithin::Settings(
1743 "gamemodes.ffa.scoreToWin".to_string(),
1744 ))) else {
1745 panic!("a leaf setting has no children");
1746 };
1747 }
1748
1749 #[test]
1753 fn within_settings_enum_lists_accepted_members() {
1754 let LookupOutcome::Matched { results, .. } = lookup(&within_query(
1755 LookupWithin::SettingEnum("mapRotation".to_string()),
1756 )) else {
1757 panic!("mapRotation is a settings enum domain");
1758 };
1759 let spellings: Vec<&str> = results
1760 .iter()
1761 .map(|hit| {
1762 let LookupHit::EnumMember { spelling, .. } = hit else {
1763 panic!("settings enum children are members: {hit:?}");
1764 };
1765 spelling.as_str()
1766 })
1767 .collect();
1768 assert_eq!(spellings, ["afterAGame", "afterMirrorMatch", "paused"]);
1769 let LookupOutcome::UnknownWithin { .. } =
1770 lookup(&within_query(LookupWithin::Enum("mapRotation".to_string())))
1771 else {
1772 panic!("mapRotation is not a source enum domain");
1773 };
1774 }
1775
1776 #[test]
1779 fn empty_text_lists_children_in_stable_order() {
1780 let outcome_a = lookup(&within_query(LookupWithin::Enum("Button".to_string())));
1781 let outcome_b = lookup(&within_query(LookupWithin::Enum("Button".to_string())));
1782 assert_eq!(outcome_a, outcome_b, "repeat listings are identical");
1783 let LookupOutcome::Matched { results, .. } = outcome_a else {
1784 panic!("Button is a known enum domain");
1785 };
1786 assert!(
1787 results.iter().all(|hit| matches!(
1788 hit,
1789 LookupHit::EnumMember {
1790 matched_on: MatchKind::OpySpelling,
1791 ..
1792 }
1793 )),
1794 "listed members report their spelling form: {results:?}"
1795 );
1796 }
1797}