1use serde::Serialize;
15use std::sync::OnceLock;
16use workshop_rs::catalog::{Catalog, Kind};
17use workshop_rs::settings::{self, PathPart, SettingValueDomain};
18
19use crate::manifest::{
20 AliasKind, Function, FunctionKind, Manifest, Param, ParamDefault, ReceiverCategory,
21};
22
23pub const DEFAULT_LIMIT: usize = 8;
25
26struct SpecialFunction {
31 name: &'static str,
32 params: &'static [SpecialParam],
33}
34
35struct SpecialParam {
37 name: &'static str,
38 param_type: &'static str,
40 required: bool,
41 default: Option<&'static str>,
43}
44
45const fn required(name: &'static str, param_type: &'static str) -> SpecialParam {
46 SpecialParam {
47 name,
48 param_type,
49 required: true,
50 default: None,
51 }
52}
53
54const fn defaulted(
55 name: &'static str,
56 param_type: &'static str,
57 default: &'static str,
58) -> SpecialParam {
59 SpecialParam {
60 name,
61 param_type,
62 required: false,
63 default: Some(default),
64 }
65}
66
67const fn optional(name: &'static str, param_type: &'static str) -> SpecialParam {
68 SpecialParam {
69 name,
70 param_type,
71 required: false,
72 default: None,
73 }
74}
75
76const SPECIAL_FUNCTIONS: &[SpecialFunction] = &[
79 SpecialFunction {
80 name: "sorted",
81 params: &[
82 required("array", "Array"),
83 optional("key", "Lambda"),
86 ],
87 },
88 SpecialFunction {
89 name: "createWorkshopSetting",
90 params: &[
91 required("type", "Type"),
92 required("category", "CustomStringLiteral"),
93 required("name", "CustomStringLiteral"),
94 required("default", "BoolLiteral|IntLiteral|FloatLiteral|HeroLiteral"),
95 defaulted("sortOrder", "IntLiteral", "0"),
96 ],
97 },
98 SpecialFunction {
99 name: "createWorkshopSettingBool",
100 params: &[
101 required("category", "CustomStringLiteral"),
102 required("name", "CustomStringLiteral"),
103 required("default", "BoolLiteral"),
104 defaulted("sortOrder", "IntLiteral", "0"),
105 ],
106 },
107 SpecialFunction {
108 name: "createWorkshopSettingEnum",
109 params: &[
110 required("category", "CustomStringLiteral"),
111 required("name", "CustomStringLiteral"),
112 required("default", "UnsignedIntLiteral"),
113 required("options", "Array<CustomStringLiteral>"),
114 defaulted("sortOrder", "IntLiteral", "0"),
115 ],
116 },
117 SpecialFunction {
118 name: "createWorkshopSettingInt",
119 params: &[
120 required("category", "CustomStringLiteral"),
121 required("name", "CustomStringLiteral"),
122 required("default", "IntLiteral"),
123 required("min", "IntLiteral"),
124 required("max", "IntLiteral"),
125 defaulted("sortOrder", "IntLiteral", "0"),
126 ],
127 },
128 SpecialFunction {
129 name: "createWorkshopSettingFloat",
130 params: &[
131 required("category", "CustomStringLiteral"),
132 required("name", "CustomStringLiteral"),
133 required("default", "FloatLiteral"),
134 required("min", "FloatLiteral"),
135 required("max", "FloatLiteral"),
136 defaulted("sortOrder", "IntLiteral", "0"),
137 ],
138 },
139 SpecialFunction {
140 name: "createWorkshopSettingHero",
141 params: &[
142 required("category", "CustomStringLiteral"),
143 required("name", "CustomStringLiteral"),
144 required("default", "HeroLiteral"),
145 defaulted("sortOrder", "IntLiteral", "0"),
146 ],
147 },
148];
149
150#[derive(Debug, Clone, PartialEq)]
152pub struct LookupQuery {
153 pub text: String,
158 pub scope: LookupScope,
161 pub within: Option<LookupWithin>,
164 pub locale: Option<String>,
168 pub limit: usize,
170}
171
172impl LookupQuery {
173 pub fn new(text: impl Into<String>) -> LookupQuery {
175 LookupQuery {
176 text: text.into(),
177 scope: LookupScope::ALL,
178 within: None,
179 locale: None,
180 limit: 0,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
187#[serde(rename_all = "camelCase")]
188pub struct LookupScope {
189 pub functions: bool,
191 pub enums: bool,
193 pub settings: bool,
195 pub events: bool,
197}
198
199impl LookupScope {
200 pub const ALL: LookupScope = LookupScope {
202 functions: true,
203 enums: true,
204 settings: true,
205 events: true,
206 };
207 pub const FUNCTIONS: LookupScope = LookupScope {
209 functions: true,
210 enums: false,
211 settings: false,
212 events: false,
213 };
214 pub const ENUMS: LookupScope = LookupScope {
216 functions: false,
217 enums: true,
218 settings: false,
219 events: false,
220 };
221 pub const SETTINGS: LookupScope = LookupScope {
223 functions: false,
224 enums: false,
225 settings: true,
226 events: false,
227 };
228 pub const EVENTS: LookupScope = LookupScope {
230 functions: false,
231 enums: false,
232 settings: false,
233 events: true,
234 };
235
236 fn any(self) -> bool {
237 self.functions || self.enums || self.settings || self.events
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Serialize)]
245#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
246pub enum LookupWithin {
247 Callable(String),
249 Enum(String),
251 Settings(String),
254 SettingEnum(String),
256}
257
258impl LookupWithin {
259 fn scope(&self) -> LookupScope {
261 match self {
262 LookupWithin::Callable(_) => LookupScope::FUNCTIONS,
263 LookupWithin::Enum(_) | LookupWithin::SettingEnum(_) => LookupScope::ENUMS,
264 LookupWithin::Settings(_) => LookupScope::SETTINGS,
265 }
266 }
267}
268
269#[derive(Debug, Clone, PartialEq, Serialize)]
271#[serde(
272 rename_all = "camelCase",
273 rename_all_fields = "camelCase",
274 tag = "kind"
275)]
276pub enum LookupOutcome {
277 Matched {
279 scope: LookupScope,
281 limit: usize,
283 results: Vec<LookupHit>,
285 },
286 UnknownWithin {
289 within: LookupWithin,
291 },
292 Unsupported {
295 scope: LookupScope,
297 reason: String,
300 },
301}
302
303impl LookupOutcome {
304 pub fn results(&self) -> &[LookupHit] {
306 match self {
307 LookupOutcome::Matched { results, .. } => results,
308 LookupOutcome::UnknownWithin { .. } | LookupOutcome::Unsupported { .. } => &[],
309 }
310 }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
315#[serde(rename_all = "camelCase")]
316pub enum MatchKind {
317 OpySpelling,
319 CatalogId,
321 DisplayName,
323 Alias,
325 Path,
327 Near,
329}
330
331impl MatchKind {
332 pub fn as_str(&self) -> &'static str {
334 match self {
335 MatchKind::OpySpelling => "opySpelling",
336 MatchKind::CatalogId => "catalogId",
337 MatchKind::DisplayName => "displayName",
338 MatchKind::Alias => "alias",
339 MatchKind::Path => "path",
340 MatchKind::Near => "near",
341 }
342 }
343}
344
345#[derive(Debug, Clone, PartialEq, Serialize)]
347#[serde(rename_all = "camelCase", rename_all_fields = "camelCase", tag = "hit")]
348pub enum LookupHit {
349 Function {
351 spelling: String,
353 function_kind: LookupFunctionKind,
355 #[serde(skip_serializing_if = "Option::is_none")]
359 receiver: Option<ReceiverCategory>,
360 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
362 unbounded: bool,
363 params: Vec<LookupParam>,
366 catalog_id: Option<String>,
368 #[serde(skip_serializing_if = "Option::is_none")]
370 display_name: Option<String>,
371 matched_on: MatchKind,
373 },
374 EnumDomain {
376 domain: String,
378 members: Option<Vec<EnumMemberEntry>>,
381 #[serde(skip_serializing_if = "Option::is_none")]
383 display_name: Option<String>,
384 matched_on: MatchKind,
386 },
387 EnumMember {
389 spelling: String,
391 domain: String,
393 member: String,
395 display_name: Option<String>,
397 aliases: Vec<String>,
399 matched_on: MatchKind,
401 },
402 Setting {
404 path: String,
407 display_name: Option<String>,
409 value: SettingValueForm,
411 matched_on: MatchKind,
413 },
414 Parameter {
417 callable: String,
419 param: LookupParam,
421 matched_on: MatchKind,
423 },
424 SettingPath {
427 path: String,
429 matched_on: MatchKind,
431 },
432 Event {
436 spelling: String,
438 accepts_filters: bool,
441 #[serde(skip_serializing_if = "Option::is_none")]
443 display_name: Option<String>,
444 matched_on: MatchKind,
446 },
447}
448
449#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
451#[serde(rename_all = "camelCase")]
452pub enum LookupFunctionKind {
453 Action,
455 Value,
457 MemberAction,
459 MemberValue,
461}
462
463impl LookupFunctionKind {
464 pub fn as_str(&self) -> &'static str {
466 match self {
467 LookupFunctionKind::Action => "action",
468 LookupFunctionKind::Value => "value",
469 LookupFunctionKind::MemberAction => "memberAction",
470 LookupFunctionKind::MemberValue => "memberValue",
471 }
472 }
473}
474
475#[derive(Debug, Clone, PartialEq, Serialize)]
477#[serde(rename_all = "camelCase")]
478pub struct LookupParam {
479 pub name: String,
481 #[serde(skip_serializing_if = "Vec::is_empty")]
483 pub alternate_names: Vec<String>,
484 pub required: bool,
486 #[serde(skip_serializing_if = "Option::is_none")]
488 pub domain: Option<String>,
489 #[serde(skip_serializing_if = "Option::is_none")]
493 pub members: Option<Vec<EnumMemberEntry>>,
494 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
496 pub param_type: Option<String>,
497 #[serde(skip_serializing_if = "Option::is_none")]
499 pub default: Option<String>,
500}
501
502#[derive(Debug, Clone, PartialEq, Serialize)]
504#[serde(rename_all = "camelCase")]
505pub struct EnumMemberEntry {
506 pub spelling: String,
508 pub id: String,
510 #[serde(skip_serializing_if = "Option::is_none")]
512 pub display_name: Option<String>,
513 #[serde(skip_serializing_if = "Vec::is_empty")]
515 pub aliases: Vec<String>,
516}
517
518#[derive(Debug, Clone, PartialEq, Serialize)]
520#[serde(rename_all = "camelCase")]
521pub struct SettingValueForm {
522 pub kind: SettingValueKind,
524 #[serde(skip_serializing_if = "Option::is_none")]
526 pub domain: Option<String>,
527 #[serde(skip_serializing_if = "Vec::is_empty")]
529 pub members: Vec<String>,
530 #[serde(skip_serializing_if = "Option::is_none")]
532 pub min: Option<f64>,
533 #[serde(skip_serializing_if = "Option::is_none")]
535 pub max: Option<f64>,
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
540#[serde(rename_all = "camelCase")]
541pub enum SettingValueKind {
542 Presence,
544 Boolean,
546 Number,
548 Percent,
550 String,
552 Enum,
554 MapList,
556 HeroList,
558}
559
560impl SettingValueKind {
561 pub fn as_str(&self) -> &'static str {
563 match self {
564 SettingValueKind::Presence => "presence",
565 SettingValueKind::Boolean => "boolean",
566 SettingValueKind::Number => "number",
567 SettingValueKind::Percent => "percent",
568 SettingValueKind::String => "string",
569 SettingValueKind::Enum => "enum",
570 SettingValueKind::MapList => "mapList",
571 SettingValueKind::HeroList => "heroList",
572 }
573 }
574}
575
576pub fn lookup(query: &LookupQuery) -> LookupOutcome {
585 let limit = if query.limit == 0 {
586 DEFAULT_LIMIT
587 } else {
588 query.limit
589 };
590 let scope = query.scope;
591 let unsupported = |reason: &str| LookupOutcome::Unsupported {
592 scope,
593 reason: reason.to_string(),
594 };
595 if let Some(locale) = &query.locale {
596 if !locale.eq_ignore_ascii_case("en-US") {
597 return unsupported(
598 "localized display names are owned by workshop-rs; opy-rs \
599 resolves display names in the 'en-US' locale only",
600 );
601 }
602 }
603 let (manifest, catalog) = match (Manifest::builtin(), Catalog::builtin()) {
604 (Ok(manifest), Ok(catalog)) => (manifest, catalog),
605 _ => return unsupported("vocabulary data (manifest or catalog) is not bundled"),
606 };
607 let index = Index::new(manifest, &catalog);
608 if let Some(within) = &query.within {
609 let Some(children) = index.children(within) else {
610 return LookupOutcome::UnknownWithin {
611 within: within.clone(),
612 };
613 };
614 let mut results = index.rank(children.iter(), query);
615 results.truncate(limit);
616 return LookupOutcome::Matched {
617 scope: within.scope(),
618 limit,
619 results,
620 };
621 }
622 if !query.scope.any() {
623 return unsupported("the query scope selects no namespace");
624 }
625 let mut results = index.rank(
626 index
627 .candidates
628 .iter()
629 .filter(|candidate| index.in_scope(candidate, scope)),
630 query,
631 );
632 results.truncate(limit);
633 LookupOutcome::Matched {
634 scope,
635 limit,
636 results,
637 }
638}
639
640pub fn lookup_str(text: &str) -> LookupOutcome {
642 lookup(&LookupQuery::new(text))
643}
644
645const SYNTAX_SPELLINGS: &[(&str, &str)] = &[
668 ("add", "+"),
670 ("subtract", "-"),
671 ("multiply", "*"),
672 ("divide", "/"),
673 ("modulo", "%"),
674 ("raiseToPower", "**"),
675 ("==", "=="),
676 ("!=", "!="),
677 ("<", "<"),
678 ("<=", "<="),
679 (">", ">"),
680 (">=", ">="),
681 ("and", "and"),
682 ("or", "or"),
683 ("not", "not"),
684 ("-", "-"),
685 ("arrayContains", "in"),
686 ("appendToArray", "append"),
690 ("removeFromArray", "exclude"),
691 ("removeFromArrayByIndex", "del"),
692 ("removeFromArrayByValue", "remove"),
693 ("mappedArray", "map"),
694 ("sortedArray", "sorted"),
695 ("isTrueForAll", "all"),
696 ("isTrueForAny", "any"),
697 ("__xComponentOf__", "x"),
698 ("__yComponentOf__", "y"),
699 ("__zComponentOf__", "z"),
700 ("abort", "return"),
702 ("skip", "goto"),
703 ("break", "break"),
704 ("continue", "continue"),
705 ("if", "if"),
706 ("elseIf", "elif"),
707 ("else", "else"),
708 ("while", "while"),
709 ("forGlobalVariable", "for"),
710 ("forPlayerVariable", "for"),
711 ("__forPlayerVariable__", "for"),
712 ("eventPlayer", "eventPlayer"),
713 ("attacker", "attacker"),
714 ("victim", "victim"),
715 ("localPlayer", "localPlayer"),
716 ("hostPlayer", "hostPlayer"),
717 ("true", "true"),
718 ("false", "false"),
719 ("null", "null"),
720 ("up", "Vector.UP"),
723 ("down", "Vector.DOWN"),
724 ("left", "Vector.LEFT"),
725 ("right", "Vector.RIGHT"),
726 ("forward", "Vector.FORWARD"),
727 ("backward", "Vector.BACKWARD"),
728 ("chasePlayerVariableAtRate", "chaseAtRate"),
730 ("chasePlayerVariableOverTime", "chaseOverTime"),
731 ("stopChasingPlayerVariable", "stopChasingVariable"),
732];
733
734fn resolve_catalog() -> Option<&'static Catalog> {
738 static CATALOG: OnceLock<Option<Catalog>> = OnceLock::new();
739 CATALOG.get_or_init(|| Catalog::builtin().ok()).as_ref()
740}
741
742pub fn call_spelling(canonical_id: &str) -> Option<&'static str> {
748 let manifest = Manifest::builtin().ok()?;
749 let catalog = resolve_catalog()?;
750 let is_canonical = |id: &str| {
755 catalog.entry(Kind::Value, id).is_some() || catalog.entry(Kind::Action, id).is_some()
756 };
757 manifest
762 .functions()
763 .iter()
764 .filter(|function| match function.catalog_id.as_deref() {
765 Some(catalog_id) => catalog_id == canonical_id,
766 None => function.id == canonical_id && is_canonical(canonical_id),
767 })
768 .min_by_key(|function| function.id.as_str() != canonical_id)
769 .map(|function| function.id.as_str())
770 .or_else(|| {
771 SYNTAX_SPELLINGS
772 .iter()
773 .find_map(|(id, spelling)| (*id == canonical_id).then_some(*spelling))
774 })
775}
776
777pub fn enum_member_spelling<'a>(domain: &'a str, member: &'a str) -> Option<&'a str> {
783 let catalog = resolve_catalog()?;
784 let enum_domain = catalog.enum_domain(domain)?;
785 if !enum_domain
786 .members
787 .iter()
788 .any(|entry| entry.member == member)
789 {
790 return None;
791 }
792 crate::enums::spelling_of_member(domain, member).map(|(_, member)| member)
793}
794
795struct ScoredHit {
800 score: u32,
801 hit: LookupHit,
802}
803
804impl LookupHit {
805 fn sort_key(&self) -> String {
806 match self {
807 LookupHit::Function { spelling, .. } => format!("0{spelling}"),
808 LookupHit::EnumMember { spelling, .. } => format!("1{spelling}"),
809 LookupHit::EnumDomain { domain, .. } => format!("2{domain}"),
810 LookupHit::Setting { path, .. } => format!("3{path}"),
811 LookupHit::Parameter {
812 callable, param, ..
813 } => {
814 format!("4{callable}.{}", param.name)
815 }
816 LookupHit::SettingPath { path, .. } => format!("5{path}"),
817 LookupHit::Event { spelling, .. } => format!("6{spelling}"),
818 }
819 }
820
821 fn listed(self) -> LookupHit {
824 let kind = match &self {
825 LookupHit::Setting { .. } | LookupHit::SettingPath { .. } => MatchKind::Path,
826 _ => MatchKind::OpySpelling,
827 };
828 self.with_match_kind(kind, 0, false)
829 }
830
831 fn with_match_kind(self, kind: MatchKind, score: u32, structural: bool) -> LookupHit {
835 let matched_on = if score == 0 || structural {
836 kind
837 } else {
838 MatchKind::Near
839 };
840 match self {
841 LookupHit::Function {
842 spelling,
843 function_kind,
844 receiver,
845 unbounded,
846 params,
847 catalog_id,
848 display_name,
849 ..
850 } => LookupHit::Function {
851 spelling,
852 function_kind,
853 receiver,
854 unbounded,
855 params,
856 catalog_id,
857 display_name,
858 matched_on,
859 },
860 LookupHit::EnumDomain {
861 domain,
862 members,
863 display_name,
864 ..
865 } => LookupHit::EnumDomain {
866 domain,
867 members,
868 display_name,
869 matched_on,
870 },
871 LookupHit::EnumMember {
872 spelling,
873 domain,
874 member,
875 display_name,
876 aliases,
877 ..
878 } => LookupHit::EnumMember {
879 spelling,
880 domain,
881 member,
882 display_name,
883 aliases,
884 matched_on,
885 },
886 LookupHit::Setting {
887 path,
888 display_name,
889 value,
890 ..
891 } => LookupHit::Setting {
892 path,
893 display_name,
894 value,
895 matched_on,
896 },
897 LookupHit::Parameter {
898 callable, param, ..
899 } => LookupHit::Parameter {
900 callable,
901 param,
902 matched_on,
903 },
904 LookupHit::SettingPath { path, .. } => LookupHit::SettingPath { path, matched_on },
905 LookupHit::Event {
906 spelling,
907 accepts_filters,
908 display_name,
909 ..
910 } => LookupHit::Event {
911 spelling,
912 accepts_filters,
913 display_name,
914 matched_on,
915 },
916 }
917 }
918}
919
920#[derive(Clone)]
923struct Candidate {
924 forms: Vec<(String, MatchKind)>,
925 hit: LookupHit,
926}
927
928struct Index<'a> {
929 manifest: &'a Manifest,
930 catalog: &'a Catalog,
931 candidates: Vec<Candidate>,
932}
933
934impl<'a> Index<'a> {
935 fn new(manifest: &'a Manifest, catalog: &'a Catalog) -> Index<'a> {
936 let mut index = Index {
937 manifest,
938 catalog,
939 candidates: Vec::new(),
940 };
941 index.index_functions();
942 index.index_enums();
943 index.index_settings();
944 index.index_events();
945 index
946 }
947
948 fn rank<'c>(
952 &self,
953 candidates: impl Iterator<Item = &'c Candidate>,
954 query: &LookupQuery,
955 ) -> Vec<LookupHit> {
956 if query.text.trim().is_empty() {
957 return candidates
958 .map(|candidate| candidate.hit.clone().listed())
959 .collect();
960 }
961 let mut hits: Vec<ScoredHit> = candidates
962 .filter_map(|candidate| {
963 candidate
964 .forms
965 .iter()
966 .filter_map(|(form, kind)| {
967 let (scored, structural) = if *kind == MatchKind::Path {
974 match crate::matcher::path_score(&query.text, form) {
975 Some(s) => (Some(s), true),
976 None => (crate::matcher::score(&query.text, form), false),
977 }
978 } else {
979 (crate::matcher::score(&query.text, form), false)
980 };
981 scored.map(|s| {
982 (
985 s,
986 form.eq_ignore_ascii_case(query.text.trim()),
987 *kind,
988 structural,
989 )
990 })
991 })
992 .min_by_key(|(s, exact, _, _)| (*s, !*exact))
993 .map(|(s, _, kind, structural)| ScoredHit {
994 score: s,
995 hit: candidate.hit.clone().with_match_kind(kind, s, structural),
996 })
997 })
998 .collect();
999 hits.sort_by(|a, b| {
1000 a.score
1001 .cmp(&b.score)
1002 .then_with(|| a.hit.sort_key().cmp(&b.hit.sort_key()))
1003 });
1004 hits.into_iter().map(|scored| scored.hit).collect()
1005 }
1006
1007 fn in_scope(&self, candidate: &Candidate, scope: LookupScope) -> bool {
1008 match &candidate.hit {
1009 LookupHit::Function { .. } | LookupHit::Parameter { .. } => scope.functions,
1010 LookupHit::EnumDomain { .. } | LookupHit::EnumMember { .. } => scope.enums,
1011 LookupHit::Setting { .. } | LookupHit::SettingPath { .. } => scope.settings,
1012 LookupHit::Event { .. } => scope.events,
1013 }
1014 }
1015
1016 fn children(&self, within: &LookupWithin) -> Option<Vec<Candidate>> {
1021 match within {
1022 LookupWithin::Callable(name) => self.callable_children(name),
1023 LookupWithin::Enum(domain) => self.enum_children(domain),
1024 LookupWithin::Settings(prefix) => self.settings_children(prefix),
1025 LookupWithin::SettingEnum(domain) => self.setting_enum_children(domain),
1026 }
1027 }
1028
1029 fn callable_children(&self, name: &str) -> Option<Vec<Candidate>> {
1032 self.candidates
1033 .iter()
1034 .find_map(|candidate| match &candidate.hit {
1035 LookupHit::Function {
1036 spelling, params, ..
1037 } if spelling == name => Some(
1038 params
1039 .iter()
1040 .map(|param| {
1041 let mut forms = vec![(param.name.clone(), MatchKind::OpySpelling)];
1042 forms.extend(
1043 param
1044 .alternate_names
1045 .iter()
1046 .map(|name| (name.clone(), MatchKind::Alias)),
1047 );
1048 Candidate {
1049 forms,
1050 hit: LookupHit::Parameter {
1051 callable: spelling.clone(),
1052 param: param.clone(),
1053 matched_on: MatchKind::OpySpelling,
1054 },
1055 }
1056 })
1057 .collect(),
1058 ),
1059 _ => None,
1060 })
1061 }
1062
1063 fn enum_children(&self, domain: &str) -> Option<Vec<Candidate>> {
1066 let known = self.candidates.iter().any(|candidate| {
1067 matches!(&candidate.hit, LookupHit::EnumDomain { domain: d, .. } if d == domain)
1068 });
1069 if !known {
1070 return None;
1071 }
1072 Some(
1073 crate::enums::domain_members(domain, self.catalog)
1074 .map(|members| {
1075 members
1076 .iter()
1077 .map(|member| self.member_candidate(domain, member))
1078 .collect()
1079 })
1080 .unwrap_or_default(),
1081 )
1082 }
1083
1084 fn settings_children(&self, prefix: &str) -> Option<Vec<Candidate>> {
1089 let prefix_segments: Vec<&str> =
1090 prefix.split('.').filter(|part| !part.is_empty()).collect();
1091 let mut children: Vec<Candidate> = Vec::new();
1092 let mut seen: Vec<&str> = Vec::new();
1093 for candidate in &self.candidates {
1094 let LookupHit::Setting { path, .. } = &candidate.hit else {
1095 continue;
1096 };
1097 let segments: Vec<&str> = path.split('.').collect();
1098 if !segments.starts_with(prefix_segments.as_slice()) {
1099 continue;
1100 }
1101 let Some(&child_segment) = segments.get(prefix_segments.len()) else {
1102 continue;
1103 };
1104 if seen.contains(&child_segment) {
1105 continue;
1106 }
1107 seen.push(child_segment);
1108 if segments.len() == prefix_segments.len() + 1 {
1109 children.push(candidate.clone());
1110 } else {
1111 let child_path = prefix_segments
1112 .iter()
1113 .copied()
1114 .chain(std::iter::once(child_segment))
1115 .collect::<Vec<_>>()
1116 .join(".");
1117 children.push(Candidate {
1118 forms: vec![
1119 (child_segment.to_string(), MatchKind::OpySpelling),
1120 (child_path.clone(), MatchKind::Path),
1121 ],
1122 hit: LookupHit::SettingPath {
1123 path: child_path,
1124 matched_on: MatchKind::Path,
1125 },
1126 });
1127 }
1128 }
1129 (!children.is_empty()).then_some(children)
1130 }
1131
1132 fn setting_enum_children(&self, domain: &str) -> Option<Vec<Candidate>> {
1135 let mut members: Vec<(String, String)> = Vec::new();
1136 for definition in settings::definitions() {
1137 for member in definition.enum_members() {
1138 if member.domain() == domain && !members.iter().any(|(id, _)| id == member.id()) {
1139 members.push((member.id().to_string(), member.english_name().to_string()));
1140 }
1141 }
1142 }
1143 if members.is_empty() {
1144 return None;
1145 }
1146 Some(
1147 members
1148 .into_iter()
1149 .map(|(id, name)| Candidate {
1150 forms: vec![
1151 (id.clone(), MatchKind::OpySpelling),
1152 (name.clone(), MatchKind::DisplayName),
1153 ],
1154 hit: LookupHit::EnumMember {
1155 spelling: id.clone(),
1156 domain: domain.to_string(),
1157 member: id,
1158 display_name: (!name.is_empty()).then_some(name),
1159 aliases: Vec::new(),
1160 matched_on: MatchKind::OpySpelling,
1161 },
1162 })
1163 .collect(),
1164 )
1165 }
1166
1167 fn index_functions(&mut self) {
1170 let locale = crate::enums::en_us();
1171 for function in self.manifest.functions() {
1172 let mut forms = vec![(function.id.clone(), MatchKind::OpySpelling)];
1173 let mut display_name = None;
1174 if let Some(catalog_id) = &function.catalog_id {
1175 forms.push((catalog_id.clone(), MatchKind::CatalogId));
1176 let kind = match function.kind {
1177 FunctionKind::Action | FunctionKind::MemberAction => Kind::Action,
1178 FunctionKind::Value | FunctionKind::MemberValue => Kind::Value,
1179 };
1180 if let Some(entry) = self.catalog.entry(kind, catalog_id) {
1181 if let Some(spelling) = entry.spelling(&locale) {
1182 display_name = Some(spelling.to_string());
1183 forms.push((spelling.to_string(), MatchKind::DisplayName));
1184 }
1185 for spelling in entry.spellings(&locale) {
1186 forms.push((spelling.clone(), MatchKind::DisplayName));
1187 }
1188 }
1189 }
1190 for alias in self.manifest.aliases() {
1191 if alias_target(alias, self.manifest)
1192 .is_some_and(|target| std::ptr::eq(target, function))
1193 {
1194 forms.push((alias.source.clone(), MatchKind::Alias));
1195 }
1196 }
1197 let params = self.lookup_params(function);
1198 self.candidates.push(Candidate {
1199 forms,
1200 hit: LookupHit::Function {
1201 spelling: function.id.clone(),
1202 function_kind: match function.kind {
1203 FunctionKind::Action => LookupFunctionKind::Action,
1204 FunctionKind::Value => LookupFunctionKind::Value,
1205 FunctionKind::MemberAction => LookupFunctionKind::MemberAction,
1206 FunctionKind::MemberValue => LookupFunctionKind::MemberValue,
1207 },
1208 receiver: function.receiver,
1209 unbounded: function.unbounded,
1210 params,
1211 catalog_id: function.catalog_id.clone(),
1212 display_name,
1213 matched_on: MatchKind::OpySpelling,
1214 },
1215 });
1216 }
1217 for special in SPECIAL_FUNCTIONS {
1218 let params: Vec<LookupParam> = special
1219 .params
1220 .iter()
1221 .map(|param| LookupParam {
1222 name: param.name.to_string(),
1223 alternate_names: Vec::new(),
1224 required: param.required,
1225 domain: None,
1226 members: None,
1227 param_type: Some(param.param_type.to_string()),
1228 default: param.default.map(str::to_string),
1229 })
1230 .collect();
1231 self.candidates.push(Candidate {
1232 forms: vec![(special.name.to_string(), MatchKind::OpySpelling)],
1233 hit: LookupHit::Function {
1234 spelling: special.name.to_string(),
1235 function_kind: LookupFunctionKind::Value,
1236 receiver: None,
1237 unbounded: false,
1238 params,
1239 catalog_id: None,
1240 display_name: None,
1241 matched_on: MatchKind::OpySpelling,
1242 },
1243 });
1244 }
1245 }
1246
1247 fn lookup_params(&self, function: &Function) -> Vec<LookupParam> {
1248 let catalog_entry = function.catalog_id.as_deref().and_then(|catalog_id| {
1249 let kind = match function.kind {
1250 FunctionKind::Action | FunctionKind::MemberAction => Kind::Action,
1251 FunctionKind::Value | FunctionKind::MemberValue => Kind::Value,
1252 };
1253 self.catalog.entry(kind, catalog_id)
1254 });
1255 function
1256 .params
1257 .iter()
1258 .enumerate()
1259 .map(|(index, param)| {
1260 let catalog_index = self.catalog_param_index(catalog_entry, function, param, index);
1261 let (param_type, catalog_domain) = catalog_index
1262 .and_then(|i| {
1263 catalog_entry.map(|entry| {
1264 (
1265 entry.param_type(i).map(str::to_string),
1266 entry.param_domain(i).map(str::to_string),
1267 )
1268 })
1269 })
1270 .unwrap_or((None, None));
1271 let domain = param
1274 .domain
1275 .clone()
1276 .or(catalog_domain)
1277 .map(|domain| crate::enums::opy_domain(&domain).to_string());
1278 let required = !param.optional && param.default.is_none();
1279 let members = domain
1283 .as_deref()
1284 .and_then(|domain| crate::enums::domain_members(domain, self.catalog))
1285 .map(|members| members.iter().map(member_entry).collect::<Vec<_>>());
1286 let default = param
1287 .default
1288 .as_ref()
1289 .map(|d| default_text(d, domain.as_deref(), self.catalog));
1290 LookupParam {
1291 name: param.name.clone(),
1292 alternate_names: param.alternate_names.clone(),
1293 required,
1294 members,
1295 domain,
1296 param_type,
1297 default,
1298 }
1299 })
1300 .collect()
1301 }
1302
1303 fn catalog_param_index(
1307 &self,
1308 entry: Option<&workshop_rs::catalog::CatalogEntry>,
1309 function: &Function,
1310 param: &Param,
1311 index: usize,
1312 ) -> Option<usize> {
1313 let entry = entry?;
1314 let count = entry.param_count();
1315 let offset = if function.kind.is_member() && count == function.params.len() + 1 {
1316 1
1317 } else {
1318 0
1319 };
1320 if count == function.params.len() + offset {
1321 let candidate = index + offset;
1324 match (0..count).position(|i| {
1325 entry.param_name(i).is_some_and(|name| {
1326 crate::matcher::fold(name) == crate::matcher::fold(¶m.name)
1327 })
1328 }) {
1329 Some(i) => Some(i),
1330 None => (candidate < count).then_some(candidate),
1331 }
1332 } else {
1333 (0..count).find(|&i| {
1334 entry.param_name(i).is_some_and(|name| {
1335 crate::matcher::fold(name) == crate::matcher::fold(¶m.name)
1336 })
1337 })
1338 }
1339 }
1340
1341 fn index_enums(&mut self) {
1344 for domain in self.lookup_domains() {
1345 let members = crate::enums::domain_members(&domain, self.catalog);
1346 let mut domain_forms = vec![(domain.clone(), MatchKind::OpySpelling)];
1347 let mut display_name = None;
1348 if let Some(catalog_domain) = self
1349 .catalog
1350 .enum_domain(crate::enums::catalog_domain(&domain))
1351 {
1352 domain_forms.push((catalog_domain.domain.clone(), MatchKind::CatalogId));
1353 let locale = crate::enums::en_us();
1354 if let Some(spelling) = catalog_domain.spelling(&locale) {
1355 display_name = Some(spelling.to_string());
1356 domain_forms.push((spelling.to_string(), MatchKind::DisplayName));
1357 }
1358 }
1359 let entries: Option<Vec<EnumMemberEntry>> = members
1360 .as_ref()
1361 .map(|members| members.iter().map(member_entry).collect());
1362 self.candidates.push(Candidate {
1363 forms: domain_forms,
1364 hit: LookupHit::EnumDomain {
1365 domain: domain.clone(),
1366 members: entries,
1367 display_name,
1368 matched_on: MatchKind::OpySpelling,
1369 },
1370 });
1371 if let Some(members) = members {
1372 for member in &members {
1373 self.candidates.push(self.member_candidate(&domain, member));
1374 }
1375 }
1376 }
1377 }
1378
1379 fn member_candidate(&self, domain: &str, member: &crate::enums::DomainMember) -> Candidate {
1382 let entry = member_entry(member);
1383 let mut forms = vec![
1384 (
1385 format!("{domain}.{}", entry.spelling),
1386 MatchKind::OpySpelling,
1387 ),
1388 (entry.spelling.clone(), MatchKind::OpySpelling),
1389 ];
1390 if let Some(display) = &member.display_name {
1391 forms.push((display.clone(), MatchKind::DisplayName));
1392 }
1393 if let Some(catalog_member) = &member.catalog_member {
1394 forms.push((catalog_member.clone(), MatchKind::CatalogId));
1395 }
1396 for alias in &member.aliases {
1397 forms.push((alias.clone(), MatchKind::Alias));
1398 }
1399 Candidate {
1400 forms,
1401 hit: LookupHit::EnumMember {
1402 spelling: format!("{domain}.{}", entry.spelling),
1403 domain: domain.to_string(),
1404 member: entry.id,
1405 display_name: entry.display_name,
1406 aliases: entry.aliases,
1407 matched_on: MatchKind::OpySpelling,
1408 },
1409 }
1410 }
1411
1412 fn lookup_domains(&self) -> Vec<String> {
1419 let mut domains: Vec<String> = self
1420 .catalog
1421 .enum_domains()
1422 .filter(|domain| !crate::enums::NON_SOURCE_DOMAINS.contains(&domain.domain.as_str()))
1423 .map(|domain| crate::enums::opy_domain(&domain.domain).to_string())
1424 .collect();
1425 domains.extend(
1426 crate::enums::DOMAIN_ALIASES
1427 .iter()
1428 .map(|(source, _)| source.to_string()),
1429 );
1430 for domain in self
1431 .manifest
1432 .functions()
1433 .iter()
1434 .flat_map(|function| function.params.iter())
1435 .filter_map(|param| param.domain.as_deref())
1436 .map(crate::enums::opy_domain)
1437 {
1438 if !crate::enums::NON_SOURCE_DOMAINS.contains(&domain)
1439 && !domains.iter().any(|d| d == domain)
1440 {
1441 domains.push(domain.to_string());
1442 }
1443 }
1444 domains
1445 }
1446
1447 fn index_settings(&mut self) {
1450 for definition in settings::definitions() {
1451 self.push_setting(definition.path(), &definition);
1452 }
1453 let modes: Vec<String> = settings::definitions()
1457 .filter_map(|definition| {
1458 let mut parts = definition.path().split('.');
1459 match (parts.next(), parts.next(), parts.next()) {
1460 (Some("gamemodes"), Some(mode), Some(_)) if mode != "general" => {
1461 Some(mode.to_string())
1462 }
1463 _ => None,
1464 }
1465 })
1466 .collect();
1467 let modes: Vec<String> = {
1468 let mut seen = Vec::new();
1469 for mode in modes {
1470 if !seen.contains(&mode) {
1471 seen.push(mode);
1472 }
1473 }
1474 seen
1475 };
1476 for definition in settings::definitions() {
1477 let Some(leaf) = definition.path().strip_prefix("gamemodes.general.") else {
1478 continue;
1479 };
1480 for mode in &modes {
1481 let probe = [
1482 PathPart::Part("gamemodes"),
1483 PathPart::Part(mode.as_str()),
1484 PathPart::Part(leaf),
1485 ];
1486 if let Some(resolved) = settings::definition(&probe) {
1487 let path = format!("gamemodes.{mode}.{leaf}");
1488 if resolved.path() != path {
1489 self.push_setting(&path, &resolved);
1491 }
1492 }
1493 }
1494 }
1495 }
1496
1497 fn push_setting(&mut self, path: &str, definition: &settings::SettingDefinition) {
1498 let leaf = path.rsplit('.').next().unwrap_or(path);
1499 let english_name = definition.presentation().english_name;
1500 let mut forms = vec![
1501 (path.to_string(), MatchKind::Path),
1502 (leaf.to_string(), MatchKind::OpySpelling),
1503 ];
1504 if !english_name.is_empty() {
1505 forms.push((english_name.to_string(), MatchKind::DisplayName));
1506 }
1507 self.candidates.push(Candidate {
1508 forms,
1509 hit: LookupHit::Setting {
1510 path: path.to_string(),
1511 display_name: (!english_name.is_empty()).then(|| english_name.to_string()),
1512 value: setting_value_form(definition),
1513 matched_on: MatchKind::Path,
1514 },
1515 });
1516 }
1517
1518 fn index_events(&mut self) {
1525 let locale = crate::enums::en_us();
1526 for entry in self.catalog.entries_of(Kind::Event) {
1527 let accepts_filters = match entry.id.as_str() {
1528 "global" => false,
1529 id if id == "eachPlayer" || crate::compiler::player_event_kind(id).is_some() => {
1530 true
1531 }
1532 _ => continue,
1533 };
1534 let display_name = entry.spelling(&locale).map(str::to_string);
1535 let mut forms = vec![(entry.id.clone(), MatchKind::OpySpelling)];
1536 for spelling in entry.spellings(&locale) {
1537 forms.push((spelling.clone(), MatchKind::DisplayName));
1538 }
1539 self.candidates.push(Candidate {
1540 forms,
1541 hit: LookupHit::Event {
1542 spelling: entry.id.clone(),
1543 accepts_filters,
1544 display_name,
1545 matched_on: MatchKind::OpySpelling,
1546 },
1547 });
1548 }
1549 }
1550}
1551
1552fn alias_target<'a>(
1554 alias: &crate::manifest::Alias,
1555 manifest: &'a Manifest,
1556) -> Option<&'a Function> {
1557 match alias.kind {
1558 AliasKind::FunctionAlias => manifest.function(&alias.target),
1559 AliasKind::MemberAlias => manifest.member(&alias.target),
1560 }
1561}
1562
1563fn member_entry(member: &crate::enums::DomainMember) -> EnumMemberEntry {
1565 EnumMemberEntry {
1566 spelling: member.member.clone(),
1567 id: member
1568 .catalog_member
1569 .clone()
1570 .unwrap_or_else(|| member.member.clone()),
1571 display_name: member.display_name.clone(),
1572 aliases: member.aliases.clone(),
1573 }
1574}
1575
1576fn setting_value_form(definition: &settings::SettingDefinition) -> SettingValueForm {
1578 let (kind, domain, min, max) = match definition.domain() {
1579 SettingValueDomain::PresenceOnly => (SettingValueKind::Presence, None, None, None),
1580 SettingValueDomain::Boolean => (SettingValueKind::Boolean, None, None, None),
1581 SettingValueDomain::Number(bounds) => {
1582 (SettingValueKind::Number, None, bounds.min(), bounds.max())
1583 }
1584 SettingValueDomain::Percent(bounds) => {
1585 (SettingValueKind::Percent, None, bounds.min(), bounds.max())
1586 }
1587 SettingValueDomain::String => (SettingValueKind::String, None, None, None),
1588 SettingValueDomain::Enum { domain } => {
1589 (SettingValueKind::Enum, Some(domain.clone()), None, None)
1590 }
1591 SettingValueDomain::MapList => (SettingValueKind::MapList, None, None, None),
1592 SettingValueDomain::HeroList => (SettingValueKind::HeroList, None, None, None),
1593 other => unreachable!("unhandled SettingValueDomain variant {other:?}"),
1596 };
1597 let members: Vec<String> =
1600 if kind == SettingValueKind::Boolean && definition.enum_members().next().is_some() {
1601 vec!["true".to_string()]
1602 } else {
1603 definition
1604 .enum_members()
1605 .map(|member| member.id().to_string())
1606 .collect()
1607 };
1608 SettingValueForm {
1609 kind,
1610 domain,
1611 members,
1612 min,
1613 max,
1614 }
1615}
1616
1617fn default_text(default: &ParamDefault, domain: Option<&str>, catalog: &Catalog) -> String {
1619 match default {
1620 ParamDefault::Null { .. } => "null".to_string(),
1621 ParamDefault::Bool(value) => value.to_string(),
1622 ParamDefault::Number(value) => {
1623 if value.fract() == 0.0 {
1624 format!("{}", *value as i64)
1625 } else {
1626 value.to_string()
1627 }
1628 }
1629 ParamDefault::Call { call, args } => {
1630 let args = args
1631 .iter()
1632 .map(|arg| {
1633 if arg.fract() == 0.0 {
1634 format!("{}", *arg as i64)
1635 } else {
1636 arg.to_string()
1637 }
1638 })
1639 .collect::<Vec<_>>()
1640 .join(", ");
1641 format!("{call}({args})")
1642 }
1643 ParamDefault::EnumMember(member) => match domain {
1644 Some(domain) => {
1647 let spelling = crate::enums::domain_members(domain, catalog)
1648 .and_then(|members| {
1649 members
1650 .iter()
1651 .find(|entry| {
1652 entry.member == *member
1653 || entry.catalog_member.as_deref() == Some(member.as_str())
1654 })
1655 .map(|entry| entry.member.clone())
1656 })
1657 .unwrap_or_else(|| member.clone());
1658 format!("{domain}.{spelling}")
1659 }
1660 None => member.clone(),
1661 },
1662 }
1663}
1664
1665#[cfg(test)]
1666mod tests {
1667 use super::*;
1668 use workshop_rs::catalog::Catalog;
1669
1670 #[test]
1674 fn reported_member_spellings_resolve_through_lowering() {
1675 let catalog = Catalog::builtin().expect("bundled catalog");
1676 let manifest = Manifest::builtin().expect("bundled manifest");
1677 let index = Index::new(manifest, &catalog);
1678 for domain in index.lookup_domains() {
1679 let Some(members) = crate::enums::domain_members(&domain, &catalog) else {
1680 continue;
1681 };
1682 for member in &members {
1683 for spelling in std::iter::once(&member.member).chain(&member.aliases) {
1684 let resolves = crate::enums::canonical_member(&domain, spelling, &catalog)
1688 .is_some()
1689 || (domain == "Color"
1690 && crate::enums::extra_color_member(spelling).is_some());
1691 assert!(
1692 resolves,
1693 "{domain}.{spelling} is lookup-visible but does not resolve"
1694 );
1695 }
1696 }
1697 }
1698 }
1699
1700 #[test]
1704 fn reported_function_spellings_resolve_through_the_manifest() {
1705 let manifest = Manifest::builtin().expect("bundled manifest");
1706 for function in manifest.functions() {
1707 let resolves = if function.kind.is_member() {
1708 manifest.resolve_member(&function.id).is_some()
1709 } else {
1710 manifest.resolve_function(&function.id).is_some()
1711 };
1712 assert!(
1713 resolves,
1714 "{} is lookup-visible but does not resolve",
1715 function.id
1716 );
1717 }
1718 let reported: Vec<&str> = SPECIAL_FUNCTIONS
1723 .iter()
1724 .map(|special| special.name)
1725 .collect();
1726 let dispatched: Vec<&str> = crate::lower::special_forms::SPECIAL_VALUE_CALLS
1727 .iter()
1728 .map(|(name, _)| *name)
1729 .collect();
1730 assert_eq!(
1731 reported, dispatched,
1732 "reported special-call spellings must be the dispatched set"
1733 );
1734 for special in SPECIAL_FUNCTIONS {
1738 let source = format!(
1739 "globalvar x\nrule \"r\":\n @Event global\n x = {}()\n",
1740 special.name
1741 );
1742 let diagnostics =
1743 crate::tooling::check(&source, "main.opy", std::path::Path::new("")).diagnostics;
1744 assert!(
1745 !diagnostics.iter().any(|diagnostic| matches!(
1746 diagnostic.code.as_str(),
1747 "unknown-value" | "unknown-action" | "invalid-iterable"
1748 )),
1749 "'{}' is lookup-reported but the lowerer rejects it: {diagnostics:?}",
1750 special.name
1751 );
1752 }
1753 }
1754
1755 #[test]
1764 fn reported_member_aliases_are_reference_spellings() {
1765 let catalog = Catalog::builtin().expect("bundled catalog");
1766 let manifest = Manifest::builtin().expect("bundled manifest");
1767 let index = Index::new(manifest, &catalog);
1768 let mut reported = Vec::new();
1769 for domain in index.lookup_domains() {
1770 let Some(members) = crate::enums::domain_members(&domain, &catalog) else {
1771 continue;
1772 };
1773 for member in members {
1774 for alias in member.aliases {
1775 assert_ne!(
1776 Some(alias.as_str()),
1777 member.catalog_member.as_deref(),
1778 "{domain}.{alias} advertises the catalog id as an OPY alias"
1779 );
1780 reported.push(format!("{domain}.{alias}"));
1781 }
1782 }
1783 }
1784 reported.sort();
1785 assert_eq!(
1786 reported,
1787 ["Hero.HAMMOND", "Hero.MCCREE", "HudPosition.ACTUALLY_LEFT"]
1788 );
1789 }
1790
1791 #[test]
1794 fn rank_prefers_close_spellings() {
1795 let pool = crate::matcher::bare_candidates(["hudText", "smallMessage", "destroyIcon"]);
1796 let ranked = crate::matcher::rank("hudTex", &pool);
1797 assert_eq!(ranked.first().map(String::as_str), Some("hudText"));
1798 assert!(
1799 !ranked.iter().any(|candidate| candidate == "hudTex"),
1800 "the rejected spelling is not a candidate"
1801 );
1802 }
1803
1804 #[test]
1807 fn sibling_keys_cover_inherited_gamemode_keys() {
1808 let keys =
1809 crate::matcher::sibling_keys(&[PathPart::Part("gamemodes"), PathPart::Part("ffa")]);
1810 assert!(
1811 keys.iter().any(|key| key == "scoreToWin"),
1812 "inherited general keys must be candidates: {keys:?}"
1813 );
1814 }
1815
1816 fn within_query(within: LookupWithin) -> LookupQuery {
1817 LookupQuery {
1818 text: String::new(),
1819 scope: LookupScope::ALL,
1820 within: Some(within),
1821 locale: None,
1822 limit: usize::MAX,
1823 }
1824 }
1825
1826 #[test]
1829 fn within_callable_lists_params_in_call_order() {
1830 let LookupOutcome::Matched { results, .. } =
1831 lookup(&within_query(LookupWithin::Callable("wait".to_string())))
1832 else {
1833 panic!("wait is a known callable");
1834 };
1835 let names: Vec<&str> = results
1836 .iter()
1837 .map(|hit| {
1838 let LookupHit::Parameter { param, .. } = hit else {
1839 panic!("callable children are parameters: {hit:?}");
1840 };
1841 param.name.as_str()
1842 })
1843 .collect();
1844 assert_eq!(names, ["time", "waitBehavior"]);
1845 let LookupOutcome::UnknownWithin { .. } = lookup(&within_query(LookupWithin::Callable(
1846 "notAFunction".to_string(),
1847 ))) else {
1848 panic!("an unknown callable is not a scope");
1849 };
1850 }
1851
1852 #[test]
1857 fn within_scopes_list_their_children() {
1858 let LookupOutcome::Matched { results, .. } =
1859 lookup(&within_query(LookupWithin::Enum("Button".to_string())))
1860 else {
1861 panic!("Button is a known enum domain");
1862 };
1863 let LookupHit::EnumMember { spelling, .. } = &results[0] else {
1864 panic!("enum children are members: {:?}", results[0]);
1865 };
1866 assert_eq!(spelling, "Button.PRIMARY_FIRE");
1867
1868 let LookupOutcome::Matched { results, .. } =
1869 lookup(&within_query(LookupWithin::Settings(String::new())))
1870 else {
1871 panic!("the settings root exists");
1872 };
1873 assert!(
1874 results
1875 .iter()
1876 .all(|hit| matches!(hit, LookupHit::SettingPath { .. })),
1877 "the root has only intermediate segments: {results:?}"
1878 );
1879
1880 let LookupOutcome::Matched { results, .. } = lookup(&within_query(LookupWithin::Settings(
1881 "gamemodes.ffa".to_string(),
1882 ))) else {
1883 panic!("gamemodes.ffa is a settings path prefix");
1884 };
1885 assert!(
1886 results.iter().any(|hit| matches!(
1887 hit,
1888 LookupHit::Setting { path, .. } if path == "gamemodes.ffa.scoreToWin"
1889 )),
1890 "a leaf child reports its Setting hit: {results:?}"
1891 );
1892
1893 let LookupOutcome::UnknownWithin { .. } = lookup(&within_query(LookupWithin::Settings(
1894 "gamemodes.ffa.scoreToWin".to_string(),
1895 ))) else {
1896 panic!("a leaf setting has no children");
1897 };
1898 }
1899
1900 #[test]
1904 fn within_settings_enum_lists_accepted_members() {
1905 let LookupOutcome::Matched { results, .. } = lookup(&within_query(
1906 LookupWithin::SettingEnum("mapRotation".to_string()),
1907 )) else {
1908 panic!("mapRotation is a settings enum domain");
1909 };
1910 let spellings: Vec<&str> = results
1911 .iter()
1912 .map(|hit| {
1913 let LookupHit::EnumMember { spelling, .. } = hit else {
1914 panic!("settings enum children are members: {hit:?}");
1915 };
1916 spelling.as_str()
1917 })
1918 .collect();
1919 assert_eq!(spellings, ["afterAGame", "afterMirrorMatch", "paused"]);
1920 let LookupOutcome::UnknownWithin { .. } =
1921 lookup(&within_query(LookupWithin::Enum("mapRotation".to_string())))
1922 else {
1923 panic!("mapRotation is not a source enum domain");
1924 };
1925 }
1926
1927 #[test]
1930 fn empty_text_lists_children_in_stable_order() {
1931 let outcome_a = lookup(&within_query(LookupWithin::Enum("Button".to_string())));
1932 let outcome_b = lookup(&within_query(LookupWithin::Enum("Button".to_string())));
1933 assert_eq!(outcome_a, outcome_b, "repeat listings are identical");
1934 let LookupOutcome::Matched { results, .. } = outcome_a else {
1935 panic!("Button is a known enum domain");
1936 };
1937 assert!(
1938 results.iter().all(|hit| matches!(
1939 hit,
1940 LookupHit::EnumMember {
1941 matched_on: MatchKind::OpySpelling,
1942 ..
1943 }
1944 )),
1945 "listed members report their spelling form: {results:?}"
1946 );
1947 }
1948
1949 #[test]
1954 fn call_spelling_reports_an_accepted_spelling() {
1955 let manifest = Manifest::builtin().expect("bundled manifest");
1956 for function in manifest.functions() {
1957 let Some(catalog_id) = function.catalog_id.as_deref() else {
1958 continue;
1959 };
1960 let spelling = call_spelling(catalog_id)
1961 .unwrap_or_else(|| panic!("{catalog_id} claims no spelling"));
1962 let claims = manifest.functions().iter().any(|entry| {
1963 entry.id == spelling && entry.catalog_id.as_deref() == Some(catalog_id)
1964 });
1965 assert!(
1966 claims,
1967 "{catalog_id} resolved to {spelling}, which does not claim it"
1968 );
1969 }
1970 }
1971
1972 #[test]
1976 fn call_spelling_answers_only_canonical_ids() {
1977 for (canonical_id, spelling) in SYNTAX_SPELLINGS {
1978 assert_eq!(
1979 call_spelling(canonical_id),
1980 Some(*spelling),
1981 "{canonical_id}"
1982 );
1983 }
1984 for id in [
1985 "evalOnce",
1986 "all",
1987 "log",
1988 "getPlayers",
1989 "end",
1990 "valueInArray",
1991 "setGlobalVariable",
1992 "notAnId",
1993 ] {
1994 assert_eq!(call_spelling(id), None, "{id} must not resolve");
1995 }
1996 }
1997
1998 #[test]
2002 fn enum_member_spelling_matches_the_member_table() {
2003 assert_eq!(enum_member_spelling("Status", "STUNNED"), Some("STUNNED"));
2004 assert_eq!(enum_member_spelling("Map", "ROUTE_66"), Some("ROUTE66"));
2005 assert_eq!(
2006 enum_member_spelling("Clipping", "DO_NOT_CLIP"),
2007 Some("NONE")
2008 );
2009 assert_eq!(enum_member_spelling("Status", "NO_SUCH_MEMBER"), None);
2010 assert_eq!(enum_member_spelling("Rounding", "DOWN"), None);
2011 assert_eq!(enum_member_spelling("NoSuchDomain", "ANY"), None);
2012 }
2013}