1use std::collections::{HashMap, HashSet};
29
30use crate::hir::types::{
31 Annotation as HirAnnotation, AnnotationArg as HirAnnotationArg, Declaration, Define,
32 DictEntry as HirDictEntry, Event, Expr as HirExpr, Generator, IfBranch, PROTOCOL_VERSION,
33 Position, PreprocessingState, Program as HirProgram, Protocol, Rule, RuleEntry,
34 Settings as HirSettings, SettingsNode as HirSettingsNode, SourceFile, Span as HirSpan,
35 Stmt as HirStmt, SwitchArm as HirSwitchArm, default_var_index,
36};
37
38use crate::cst::{self, CallArg, Decl, Expr, RuleEntry as CstRuleEntry, Stmt, TopLevel};
39use crate::diag::{OpyError, OpyResult, Span};
40use crate::manifest::{Function, FunctionKind, Manifest, Param, ParamDefault};
41use workshop_rs::catalog::Catalog;
42
43const PROTOCOL_NAME: &str = "wright/opy-hir";
45
46#[derive(Clone, Copy, PartialEq, Eq)]
49enum CallPosition {
50 Statement,
52 Value,
54 ForIterable,
56 LambdaArgument,
58 MacroBody,
61}
62
63struct Lowerer {
65 global_declarations: HashMap<String, usize>,
66 player_declarations: HashMap<String, usize>,
67 subroutine_declarations: HashMap<String, usize>,
68 subroutine_definitions: Vec<(String, usize)>,
69 constant_declarations: HashMap<String, usize>,
70 macro_declarations: HashMap<String, usize>,
71 enums: HashMap<String, Vec<String>>,
72 enum_declarations: HashMap<String, usize>,
73 locals: Vec<String>,
74 current_order: usize,
75 allow_dict_literal: bool,
76 manifest: &'static Manifest,
78 catalog: Catalog,
80 texture_used: bool,
81 setup_tags: bool,
82 errors: Vec<OpyError>,
83}
84
85impl Lowerer {
86 fn new(manifest: &'static Manifest, catalog: Catalog) -> Self {
87 Self {
88 global_declarations: HashMap::new(),
89 player_declarations: HashMap::new(),
90 subroutine_declarations: HashMap::new(),
91 subroutine_definitions: Vec::new(),
92 constant_declarations: HashMap::new(),
93 macro_declarations: HashMap::new(),
94 enums: HashMap::new(),
95 enum_declarations: HashMap::new(),
96 locals: Vec::new(),
97 current_order: 0,
98 allow_dict_literal: false,
99 manifest,
100 catalog,
101 texture_used: false,
102 setup_tags: false,
103 errors: Vec::new(),
104 }
105 }
106}
107
108mod declarations;
109mod expressions;
110pub(crate) mod policy;
111pub(crate) mod special_forms;
112mod statements;
113mod textures;
114
115pub(crate) fn compressed_component_mode(values: &[Vec<f64>]) -> Option<(bool, f64)> {
116 let component_count = values.first()?.len();
117 let is_vector = component_count == 3;
118 (matches!(component_count, 1 | 3) && values.iter().all(|value| value.len() == component_count))
119 .then_some((is_vector, if is_vector { 4999.0 } else { 49999.0 }))
120}
121
122pub fn lower(
124 program: &cst::Program,
125 files: Vec<SourceFile>,
126 defines: Vec<Define>,
127) -> OpyResult<HirProgram> {
128 lower_with_preprocessing(program, files, defines, &PreprocessingState::default())
129}
130
131pub fn lower_with_preprocessing(
132 program: &cst::Program,
133 files: Vec<SourceFile>,
134 defines: Vec<Define>,
135 preprocessing: &PreprocessingState,
136) -> OpyResult<HirProgram> {
137 let manifest = match Manifest::builtin() {
138 Ok(manifest) => manifest,
139 Err(error) => {
140 return Err(OpyError::new(
141 "manifest-error",
142 format!("cannot load the OPY semantic compatibility manifest: {error}"),
143 ));
144 }
145 };
146 let catalog = match Catalog::builtin() {
147 Ok(catalog) => catalog,
148 Err(error) => {
149 return Err(OpyError::new(
150 "catalog-error",
151 format!("cannot load the Workshop catalog: {error}"),
152 ));
153 }
154 };
155 lower_program(program, files, defines, preprocessing, manifest, catalog)
156}
157
158fn lower_program(
159 program: &cst::Program,
160 files: Vec<SourceFile>,
161 defines: Vec<Define>,
162 preprocessing: &PreprocessingState,
163 manifest: &'static Manifest,
164 catalog: Catalog,
165) -> OpyResult<HirProgram> {
166 let mut lowerer = Lowerer::new(manifest, catalog);
167 lowerer.setup_tags = preprocessing
168 .directives
169 .iter()
170 .any(|directive| matches!(directive.name.as_str(), "setupTags" | "setupTx"));
171 lowerer.collect_symbols(program);
172
173 let mut declarations = Vec::new();
174 let mut rules = Vec::new();
175 let mut implicit_subroutines = HashSet::new();
176 for (order, item) in program.top_level.iter().enumerate() {
177 lowerer.current_order = order;
178 match item {
179 TopLevel::Declaration(decl) => {
180 if let Some(declaration) = lowerer.lower_declaration(decl) {
181 declarations.push(declaration);
182 }
183 }
184 TopLevel::Rule(CstRuleEntry::Rule(rule)) => rules.push(RuleEntry::Rule(
185 lowerer.lower_rule(rule, files.as_slice(), preprocessing)?,
186 )),
187 TopLevel::Rule(CstRuleEntry::SubroutineDef {
188 name,
189 presentation_name,
190 span,
191 name_span,
192 body,
193 annotations,
194 rule_prefix,
195 }) => {
196 if !lowerer.subroutine_declarations.contains_key(name)
197 && implicit_subroutines.insert(name.clone())
198 {
199 declarations.push(Declaration::Subroutine {
200 name: name.clone(),
201 index: None,
202 span: Some(span.into()),
203 name_span: Some(name_span.into()),
204 });
205 }
206 let base_name = presentation_name
207 .as_deref()
208 .map(str::to_string)
209 .unwrap_or_else(|| name.clone());
210 let generated_name = render_rule_name(
211 &base_name,
212 rule_prefix.as_deref(),
213 false,
214 *span,
215 files.as_slice(),
216 preprocessing,
217 )?;
218 rules.push(RuleEntry::SubroutineDef {
219 kind: "subroutineDef".to_string(),
220 name: generated_name,
221 source_name: name.clone(),
222 span: Some(span.into()),
223 name_span: Some(name_span.into()),
224 body: lowerer.lower_block(body, &[], false, true, false),
225 annotations: lower_annotations(annotations),
226 });
227 }
228 }
229 }
230
231 if !lowerer.errors.is_empty() {
232 return Err(lowerer.errors.swap_remove(0));
233 }
234
235 if lowerer.texture_used || lowerer.setup_tags {
236 declarations.insert(
237 0,
238 Declaration::GlobalVariable {
239 name: "__holygrail__".to_string(),
240 index: Some(127),
241 span: None,
242 name_span: None,
243 initializer: None,
244 },
245 );
246 rules.insert(0, texture_setup_rule());
247 }
248
249 Ok(HirProgram {
250 protocol: Protocol {
251 name: PROTOCOL_NAME.to_string(),
252 version: PROTOCOL_VERSION.to_string(),
253 },
254 generator: Generator {
255 name: crate::LANGUAGE_NAME.to_string(),
256 version: crate::LANGUAGE_VERSION.to_string(),
257 frontend: crate::LANGUAGE_NAME.to_string(),
258 },
259 files,
260 defines,
261 declarations,
262 rules,
263 settings: program.settings.as_ref().map(lower_settings),
264 preprocessing: preprocessing.clone(),
265 })
266}
267
268fn texture_setup_rule() -> RuleEntry {
269 RuleEntry::Rule(Rule {
270 name: format!("OverPy <{}tx> / <{}fg> setup code", '\u{00ad}', '\u{00ad}'),
271 span: None,
272 name_span: None,
273 disabled: false,
274 delimiter: false,
275 new_page: None,
276 annotations: Vec::new(),
277 event: Event {
278 name: "global".to_string(),
279 args: Vec::new(),
280 span: None,
281 },
282 conditions: Vec::new(),
283 actions: vec![
284 synthetic_statement(synthetic_call(
285 "createDummy",
286 vec![
287 synthetic_call("getAllHeroes", Vec::new()),
288 texture_dummy_team(),
289 synthetic_boolean(false),
290 HirExpr::Null { span: None },
291 HirExpr::Null { span: None },
292 ],
293 )),
294 synthetic_statement(synthetic_method(
295 texture_dummy_player(),
296 "startForcingName",
297 vec![texture_marker('\u{303c}')],
298 )),
299 texture_state_assignment(synthetic_method(
300 texture_dummy_first_value(),
301 "split",
302 vec![HirExpr::Array {
303 elements: Vec::new(),
304 span: None,
305 }],
306 )),
307 synthetic_statement(synthetic_method(
308 texture_dummy_player(),
309 "startForcingName",
310 vec![texture_marker('\u{840}')],
311 )),
312 texture_state_assignment(synthetic_method(
313 synthetic_method(
314 texture_marker('\u{303c}'),
315 "replace",
316 vec![texture_state_variable(), texture_dummy_first_value()],
317 ),
318 "substring",
319 vec![synthetic_number(126.0), synthetic_boolean(true)],
320 )),
321 synthetic_statement(synthetic_call("destroyAllDummies", Vec::new())),
322 ],
323 })
324}
325
326fn synthetic_call(name: &str, args: Vec<HirExpr>) -> HirExpr {
327 HirExpr::Call {
328 name: name.to_string(),
329 args,
330 debug_source: None,
331 span: None,
332 }
333}
334
335fn synthetic_method(receiver: HirExpr, name: &str, args: Vec<HirExpr>) -> HirExpr {
336 HirExpr::ReceiverCall {
337 receiver: Box::new(receiver),
338 name: name.to_string(),
339 args,
340 span: None,
341 }
342}
343
344fn synthetic_enum(value_type: &str, value: &str) -> HirExpr {
345 HirExpr::Enum {
346 value_type: value_type.to_string(),
347 value: value.to_string(),
348 literal: false,
349 span: None,
350 }
351}
352
353fn synthetic_number(value: f64) -> HirExpr {
354 HirExpr::Number {
355 value,
356 text: value.to_string(),
357 span: None,
358 }
359}
360
361fn synthetic_boolean(value: bool) -> HirExpr {
362 HirExpr::Bool { value, span: None }
363}
364
365fn synthetic_index(array: HirExpr, index: HirExpr) -> HirExpr {
366 HirExpr::Index {
367 array: Box::new(array),
368 index: Box::new(index),
369 span: None,
370 }
371}
372
373fn synthetic_conditional(then_value: HirExpr, condition: HirExpr, else_value: HirExpr) -> HirExpr {
374 HirExpr::Conditional {
375 then_value: Box::new(then_value),
376 condition: Box::new(condition),
377 else_value: Box::new(else_value),
378 span: None,
379 }
380}
381
382fn synthetic_statement(expr: HirExpr) -> HirStmt {
383 HirStmt::Expr {
384 expr: Box::new(expr),
385 span: None,
386 }
387}
388
389fn texture_state_variable() -> HirExpr {
390 HirExpr::GlobalVar {
391 name: "__holygrail__".to_string(),
392 span: None,
393 }
394}
395
396fn texture_state_assignment(value: HirExpr) -> HirStmt {
397 HirStmt::Assign {
398 target: Box::new(texture_state_variable()),
399 value: Box::new(value),
400 span: None,
401 }
402}
403
404fn texture_dummy_player() -> HirExpr {
405 synthetic_call("getLastCreatedEntity", Vec::new())
406}
407
408fn texture_dummy_first_value() -> HirExpr {
409 synthetic_index(texture_dummy_player(), synthetic_number(0.0))
410}
411
412fn texture_dummy_team() -> HirExpr {
413 let team_1 = synthetic_enum("Team", "TEAM_1");
414 let team_2 = synthetic_enum("Team", "TEAM_2");
415 synthetic_conditional(
416 team_1.clone(),
417 synthetic_call("getNumberOfSlots", vec![team_1]),
418 synthetic_conditional(
419 team_2.clone(),
420 synthetic_call("getNumberOfSlots", vec![team_2]),
421 synthetic_boolean(true),
422 ),
423 )
424}
425
426fn texture_marker(suffix: char) -> HirExpr {
427 HirExpr::String {
428 value: format!("{}{}", "_".repeat(126), suffix),
429 span: None,
430 }
431}
432
433pub(crate) struct SettingsLowerer {
438 lowerer: Lowerer,
439}
440
441impl SettingsLowerer {
442 pub(crate) fn new(program: &cst::Program) -> OpyResult<Self> {
443 let manifest = Manifest::builtin().map_err(|error| {
444 OpyError::new(
445 "manifest-error",
446 format!("cannot load the OPY semantic compatibility manifest: {error}"),
447 )
448 })?;
449 let catalog = Catalog::builtin().map_err(|error| {
450 OpyError::new(
451 "catalog-error",
452 format!("cannot load the Workshop catalog: {error}"),
453 )
454 })?;
455 let mut lowerer = Lowerer::new(manifest, catalog);
456 lowerer.current_order = program.top_level.len();
457 lowerer.allow_dict_literal = true;
458 lowerer
462 .constant_declarations
463 .insert("Infinity".to_string(), 0);
464 lowerer.collect_symbols(program);
465 Ok(Self { lowerer })
466 }
467
468 pub(crate) fn lower(
471 &mut self,
472 text: &str,
473 file: u32,
474 origin: crate::diag::Position,
475 ) -> OpyResult<HirExpr> {
476 let expression = crate::parser::parse_expression_fragment(text, file, origin, None)?;
477 self.lowerer.errors.clear();
478 self.lowerer.texture_used = false;
479 let lowered = self
480 .lowerer
481 .lower_expr(&expression, &[], CallPosition::Value);
482 self.lowerer
483 .errors
484 .first()
485 .cloned()
486 .map_or(Ok(lowered), Err)
487 }
488}
489
490fn prefixed_rule_name(name: &str, prefix: Option<&str>, delimiter: bool) -> String {
491 match prefix {
492 Some(prefix) if !prefix.is_empty() && !delimiter && !name.is_empty() => {
493 format!("[{prefix}] {name}")
494 }
495 _ => name.to_string(),
496 }
497}
498
499pub(crate) fn strip_rule_name_formatting(text: &str) -> impl Iterator<Item = char> + '_ {
500 text.chars().filter(|character| {
501 !matches!(
502 character,
503 '\u{200B}' | '\u{200E}' | '\u{200F}' | '\u{FEFF}' | '\u{061C}'
504 )
505 })
506}
507
508#[derive(Clone, Debug)]
509enum TemplateValue {
510 String(String),
511 Bool(bool),
512}
513
514fn rule_template_values(
515 name: &str,
516 prefix: &str,
517 file: &str,
518 path: &str,
519 delimiter: bool,
520) -> [(&'static str, TemplateValue); 14] {
521 [
522 ("$rule", TemplateValue::String(name.to_string())),
523 ("$prefix", TemplateValue::String(prefix.to_string())),
524 ("$file", TemplateValue::String(file.to_string())),
525 ("$path", TemplateValue::String(path.to_string())),
526 ("$isDelimiter", TemplateValue::Bool(delimiter)),
527 ("$prefixTitle", TemplateValue::String(title_case(prefix))),
528 ("$prefixUpper", TemplateValue::String(prefix.to_uppercase())),
529 ("$prefixLower", TemplateValue::String(prefix.to_lowercase())),
530 ("$fileTitle", TemplateValue::String(title_case(file))),
531 ("$fileUpper", TemplateValue::String(file.to_uppercase())),
532 ("$fileLower", TemplateValue::String(file.to_lowercase())),
533 ("$pathTitle", TemplateValue::String(title_case(path))),
534 ("$pathUpper", TemplateValue::String(path.to_uppercase())),
535 ("$pathLower", TemplateValue::String(path.to_lowercase())),
536 ]
537}
538
539fn render_rule_name(
540 name: &str,
541 prefix: Option<&str>,
542 delimiter: bool,
543 span: Span,
544 files: &[SourceFile],
545 preprocessing: &PreprocessingState,
546) -> OpyResult<String> {
547 let Some(template) = preprocessing
548 .rule_prefix_template
549 .as_ref()
550 .map(|value| value.value.as_str())
551 else {
552 return Ok(
553 strip_rule_name_formatting(&prefixed_rule_name(name, prefix, delimiter)).collect(),
554 );
555 };
556 let (file, path) = rule_file_parts(span.file, files);
557 let prefix = prefix.unwrap_or_default();
558 let values = rule_template_values(name, prefix, &file, &path, delimiter);
559 evaluate_template(template, &values)
560 .map(|name| strip_rule_name_formatting(&name).collect())
561 .map_err(|message| {
562 OpyError::at(
563 "rule-prefix-template-invalid",
564 format!("could not resolve rule prefix template: {message}"),
565 span,
566 )
567 })
568}
569
570pub(crate) fn render_generated_rule_name(name: &str, preprocessing: &PreprocessingState) -> String {
573 let Some(template) = preprocessing
574 .rule_prefix_template
575 .as_ref()
576 .map(|value| value.value.as_str())
577 else {
578 return name.to_string();
579 };
580 let values = rule_template_values(name, "", "", "", false);
581 evaluate_template(template, &values)
582 .map(|rendered| strip_rule_name_formatting(&rendered).collect())
583 .unwrap_or_else(|_| name.to_string())
584}
585
586fn rule_file_parts(file_id: u32, files: &[SourceFile]) -> (String, String) {
587 let path = files
588 .iter()
589 .find(|file| file.id == file_id)
590 .map(|file| file.path.replace('\\', "/"))
591 .unwrap_or_default();
592 let without_extension = path
593 .strip_suffix(".opy")
594 .or_else(|| path.strip_suffix(".OPY"))
595 .unwrap_or(&path)
596 .to_string();
597 let file = without_extension
598 .rsplit('/')
599 .next()
600 .unwrap_or_default()
601 .to_string();
602 (file, without_extension)
603}
604
605fn title_case(value: &str) -> String {
606 let mut result = String::with_capacity(value.len());
607 let mut capitalize = true;
608 for ch in value.chars() {
609 if ch == '_' {
610 result.push(' ');
611 capitalize = true;
612 } else if capitalize && ch.is_ascii_alphabetic() {
613 result.push(ch.to_ascii_uppercase());
614 capitalize = false;
615 } else {
616 result.push(ch);
617 if !ch.is_whitespace() && ch != '/' {
618 capitalize = false;
619 }
620 }
621 if ch == '/' || ch.is_whitespace() {
622 capitalize = true;
623 }
624 }
625 result
626}
627
628fn evaluate_template(template: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
629 if let Some((then_value, condition, else_value)) = split_conditional(template) {
630 let branch = if evaluate_condition(condition, values)? {
631 then_value
632 } else {
633 else_value
634 };
635 return evaluate_string(branch, values);
636 }
637 evaluate_string(template, values)
638}
639
640fn split_conditional(value: &str) -> Option<(&str, &str, &str)> {
641 let mut quote = None;
642 let mut depth = 0usize;
643 let mut if_start = None;
644 let mut else_start = None;
645 for (index, ch) in value.char_indices() {
646 match (ch, quote) {
647 ('"' | '\'', None) => quote = Some(ch),
648 (ch, Some(current)) if ch == current => quote = None,
649 ('{', None) => depth += 1,
650 ('}', None) => depth = depth.saturating_sub(1),
651 _ => {}
652 }
653 if quote.is_none() && depth == 0 {
654 if value[index..].starts_with(" if ") && if_start.is_none() {
655 if_start = Some(index);
656 } else if value[index..].starts_with(" else ") && else_start.is_none() {
657 else_start = Some(index);
658 }
659 }
660 }
661 let (Some(if_start), Some(else_start)) = (if_start, else_start) else {
662 return None;
663 };
664 Some((
665 value[..if_start].trim(),
666 value[if_start + 4..else_start].trim(),
667 value[else_start + 6..].trim(),
668 ))
669}
670
671fn evaluate_condition(value: &str, values: &[(&str, TemplateValue)]) -> Result<bool, String> {
672 let value = value.trim();
673 if let Some(rest) = value.strip_prefix("not ") {
674 return Ok(!evaluate_condition(rest, values)?);
675 }
676 if let Some((left, right)) = value.split_once(" or ") {
677 return Ok(evaluate_condition(left, values)? || evaluate_condition(right, values)?);
678 }
679 if let Some((left, right)) = value.split_once(" and ") {
680 return Ok(evaluate_condition(left, values)? && evaluate_condition(right, values)?);
681 }
682 match lookup_template_value(value, values)? {
683 TemplateValue::Bool(value) => Ok(value),
684 TemplateValue::String(value) => Ok(!value.is_empty()),
685 }
686}
687
688fn evaluate_string(value: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
689 let value = value.trim();
690 if let Some(body) = value
691 .strip_prefix("f\"")
692 .and_then(|body| body.strip_suffix('"'))
693 {
694 return interpolate_fstring(body, values);
695 }
696 if let Some(body) = value
697 .strip_prefix("f'")
698 .and_then(|body| body.strip_suffix('\''))
699 {
700 return interpolate_fstring(body, values);
701 }
702 if value.len() >= 2
703 && ((value.starts_with('"') && value.ends_with('"'))
704 || (value.starts_with('\'') && value.ends_with('\'')))
705 {
706 return Ok(value[1..value.len() - 1].to_string());
707 }
708 match lookup_template_value(value, values)? {
709 TemplateValue::String(value) => Ok(value),
710 TemplateValue::Bool(value) => Ok(value.to_string()),
711 }
712}
713
714fn interpolate_fstring(body: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
715 let mut result = String::new();
716 let mut remaining = body;
717 while let Some(start) = remaining.find('{') {
718 result.push_str(&remaining[..start]);
719 let end = remaining[start + 1..]
720 .find('}')
721 .ok_or_else(|| "unterminated interpolation".to_string())?
722 + start
723 + 1;
724 result.push_str(&evaluate_string(&remaining[start + 1..end], values)?);
725 remaining = &remaining[end + 1..];
726 }
727 result.push_str(remaining);
728 Ok(result)
729}
730
731fn lookup_template_value(
732 value: &str,
733 values: &[(&str, TemplateValue)],
734) -> Result<TemplateValue, String> {
735 let value = value.trim();
736 let (base, mut methods) = value
737 .split_once('.')
738 .map_or((value, ""), |(base, methods)| (base, methods));
739 let mut result = values
740 .iter()
741 .find(|(name, _)| *name == base)
742 .map(|(_, value)| value.clone())
743 .ok_or_else(|| format!("unsupported expression '{value}'"))?;
744 while !methods.is_empty() {
745 let (method, rest) = methods
746 .split_once('.')
747 .map_or((methods, ""), |(method, rest)| (method, rest));
748 if method == "upper()" {
749 result = TemplateValue::String(as_string(&result).to_uppercase());
750 } else if method == "lower()" {
751 result = TemplateValue::String(as_string(&result).to_lowercase());
752 } else if let Some(args) = method
753 .strip_prefix("replace(")
754 .and_then(|v| v.strip_suffix(')'))
755 {
756 let (from, to) = args
757 .split_once(',')
758 .ok_or_else(|| "replace expects two arguments".to_string())?;
759 let from = unquote_template_arg(from.trim())?;
760 let to = unquote_template_arg(to.trim())?;
761 result = TemplateValue::String(as_string(&result).replace(&from, &to));
762 } else {
763 return Err(format!("unsupported method '{method}'"));
764 }
765 methods = rest;
766 }
767 Ok(result)
768}
769
770fn as_string(value: &TemplateValue) -> String {
771 match value {
772 TemplateValue::String(value) => value.clone(),
773 TemplateValue::Bool(value) => value.to_string(),
774 }
775}
776
777fn unquote_template_arg(value: &str) -> Result<String, String> {
778 if value.len() >= 2
779 && ((value.starts_with('"') && value.ends_with('"'))
780 || (value.starts_with('\'') && value.ends_with('\'')))
781 {
782 Ok(value[1..value.len() - 1].to_string())
783 } else {
784 Err(format!("expected a quoted string argument, got '{value}'"))
785 }
786}
787
788fn lower_annotations(annotations: &[cst::Annotation]) -> Vec<HirAnnotation> {
789 annotations
790 .iter()
791 .map(|annotation| HirAnnotation {
792 name: annotation.name.clone(),
793 args: annotation
794 .args
795 .iter()
796 .map(|arg| HirAnnotationArg {
797 text: arg.text.clone(),
798 span: Some(arg.span.into()),
799 })
800 .collect(),
801 span: Some(annotation.span.into()),
802 })
803 .collect()
804}
805
806fn lower_settings(settings: &cst::Settings) -> HirSettings {
808 HirSettings {
809 span: Some(settings.span.into()),
810 children: settings.children.iter().map(lower_settings_node).collect(),
811 }
812}
813
814fn lower_settings_node(node: &cst::SettingsNode) -> HirSettingsNode {
815 match node {
816 cst::SettingsNode::Group {
817 name,
818 children,
819 span,
820 } => HirSettingsNode::Group {
821 name: name.clone(),
822 children: children.iter().map(lower_settings_node).collect(),
823 span: Some((*span).into()),
824 },
825 cst::SettingsNode::Number { name, value, span } => HirSettingsNode::Number {
826 name: name.clone(),
827 value: *value,
828 span: Some((*span).into()),
829 },
830 cst::SettingsNode::Bool { name, value, span } => HirSettingsNode::Bool {
831 name: name.clone(),
832 value: *value,
833 span: Some((*span).into()),
834 },
835 cst::SettingsNode::String { name, value, span } => HirSettingsNode::String {
836 name: name.clone(),
837 value: value.clone(),
838 span: Some((*span).into()),
839 },
840 cst::SettingsNode::Raw { name, value, span } => HirSettingsNode::Raw {
841 name: name.clone(),
842 value: value.clone(),
843 span: Some((*span).into()),
844 },
845 cst::SettingsNode::List {
846 name,
847 elements,
848 span,
849 } => HirSettingsNode::List {
850 name: name.clone(),
851 elements: elements
852 .iter()
853 .map(|element| crate::hir::types::SettingsListElement {
854 value: element.value.clone(),
855 evaluated: (!element.expr).then(|| element.value.clone()),
856 span: Some(element.span.into()),
857 })
858 .collect(),
859 span: Some((*span).into()),
860 },
861 }
862}
863
864impl Lowerer {
865 fn error_at(&mut self, code: &str, message: String, span: Span) {
866 self.errors.push(OpyError::at(code, message, span));
867 }
868
869 fn error_at_candidates(
874 &mut self,
875 code: &str,
876 message: String,
877 span: Span,
878 rejected: &str,
879 pool: &[crate::matcher::MatchCandidate],
880 ) {
881 let ranked = crate::matcher::rank(rejected, pool);
882 self.error_at(code, crate::matcher::did_you_mean(message, &ranked), span);
883 }
884
885 fn error_at_closed_candidates(
889 &mut self,
890 code: &str,
891 message: String,
892 span: Span,
893 rejected: &str,
894 pool: &[crate::matcher::MatchCandidate],
895 ) {
896 let ranked = crate::matcher::rank_closed(rejected, pool);
897 self.error_at(code, crate::matcher::did_you_mean(message, &ranked), span);
898 }
899}
900
901fn keyword_spellings(param: &Param) -> Vec<String> {
903 let mut spellings = vec![param.name.clone()];
904 spellings.extend(param.alternate_names.iter().cloned());
905 spellings
906}
907
908fn arg_span(args: &[CallArg]) -> Span {
911 args.first().map(CallArg::span).unwrap_or_else(|| {
912 Span::new(
913 0,
914 crate::diag::Position::new(1, 1),
915 crate::diag::Position::new(1, 1),
916 )
917 })
918}
919
920fn context_player_expr(name: &str, span: Option<Span>) -> Option<HirExpr> {
925 match name {
926 "eventPlayer" => Some(HirExpr::EventPlayer {
927 span: span.map(Into::into),
928 }),
929 "localPlayer" | "attacker" | "victim" | "healer" | "healee" => Some(HirExpr::Call {
930 name: name.to_string(),
931 args: Vec::new(),
932 debug_source: None,
933 span: span.map(Into::into),
934 }),
935 "hostPlayer" => Some(HirExpr::HostPlayer {
936 span: span.map(Into::into),
937 }),
938 _ => None,
939 }
940}
941
942#[derive(Debug, Clone, Copy, PartialEq, Eq)]
947pub enum NameNamespace {
948 Global,
950 Player,
952 Subroutine,
954}
955
956pub fn is_reserved_name(name: &str, namespace: NameNamespace) -> bool {
967 match namespace {
968 NameNamespace::Player => matches!(name, "x" | "y" | "z"),
969 NameNamespace::Global => {
970 RESERVED_KEYWORDS.contains(&name) || RESERVED_GLOBAL_NAMES.contains(&name)
971 }
972 NameNamespace::Subroutine => {
973 RESERVED_KEYWORDS.contains(&name)
974 || Manifest::builtin().ok().is_some_and(|manifest| {
975 manifest
976 .resolve_function(name)
977 .is_some_and(|function| function.params.is_empty())
978 })
979 }
980 }
981}
982
983const RESERVED_KEYWORDS: &[&str] = &[
986 "and",
987 "bool",
988 "case",
989 "def",
990 "default",
991 "del",
992 "elif",
993 "else",
994 "enum",
995 "float",
996 "for",
997 "globalvar",
998 "goto",
999 "if",
1000 "in",
1001 "int",
1002 "lambda",
1003 "loc",
1004 "macro",
1005 "not",
1006 "or",
1007 "playervar",
1008 "rule",
1009 "self",
1010 "settings",
1011 "signed",
1012 "subroutine",
1013 "switch",
1014 "unsigned",
1015 "while",
1016];
1017
1018const RESERVED_GLOBAL_NAMES: &[&str] = &[
1023 "AccelReeval",
1024 "Array",
1025 "AssistId",
1026 "AssistReeval",
1027 "AsyncBehavior",
1028 "BarrierLos",
1029 "Beam",
1030 "BoolLiteral",
1031 "Button",
1032 "ButtonLiteral",
1033 "ChaseRateReeval",
1034 "ChaseTimeReeval",
1035 "Clip",
1036 "Color",
1037 "ColorLiteral",
1038 "Comms",
1039 "CustomStringLiteral",
1040 "DamageModificationId",
1041 "DamageReeval",
1042 "Dict",
1043 "DictElem",
1044 "DictKey",
1045 "Direction",
1046 "DotId",
1047 "DynamicEffect",
1048 "Effect",
1049 "EffectReeval",
1050 "EntityId",
1051 "FacingReeval",
1052 "FloatLiteral",
1053 "Gamemode",
1054 "GamemodeLiteral",
1055 "GlobalVariable",
1056 "HealingModificationId",
1057 "HealingReeval",
1058 "Health",
1059 "HealthPoolId",
1060 "Hero",
1061 "HeroLiteral",
1062 "HeroStat",
1063 "HotId",
1064 "HudPosition",
1065 "HudReeval",
1066 "Icon",
1067 "IconReeval",
1068 "Impulse",
1069 "IntLiteral",
1070 "Invis",
1071 "Label",
1072 "Lambda",
1073 "LocalizedStringLiteral",
1074 "LosCheck",
1075 "Map",
1076 "MapLiteral",
1077 "ModifyHealth",
1078 "Object",
1079 "OutlineVisibility",
1080 "Player",
1081 "PlayerVariable",
1082 "Position",
1083 "ProgressHudReeval",
1084 "ProgressWorldTextReeval",
1085 "Projectile",
1086 "ProjectileEffectReeval",
1087 "Raycast",
1088 "Relativity",
1089 "SignedFloatLiteral",
1090 "SignedIntLiteral",
1091 "SpecVisibility",
1092 "StartRuleBehavior",
1093 "Stat",
1094 "Status",
1095 "String",
1096 "StringLiteral",
1097 "Subroutine",
1098 "Team",
1099 "TeamLiteral",
1100 "TextId",
1101 "Throttle",
1102 "ThrottleReeval",
1103 "Transform",
1104 "Type",
1105 "UnsignedFloatLiteral",
1106 "UnsignedIntLiteral",
1107 "Value",
1108 "Vector",
1109 "Velocity",
1110 "Wait",
1111 "WorldTextReeval",
1112 "__ChaseReeval__",
1113 "__Operation__",
1114 "__Operator__",
1115 "__Rounding__",
1116 "__global__",
1117 "attacker",
1118 "break",
1119 "continue",
1120 "eventAbility",
1121 "eventDamage",
1122 "eventDirection",
1123 "eventHealing",
1124 "eventPlayer",
1125 "eventWasCriticalHit",
1126 "eventWasEnvironment",
1127 "eventWasHealthPack",
1128 "false",
1129 "healee",
1130 "healer",
1131 "hostPlayer",
1132 "localPlayer",
1133 "null",
1134 "pass",
1135 "return",
1136 "ruleCondition",
1137 "true",
1138 "victim",
1139 "void",
1140];
1141
1142fn assignable_receiver(receiver: &Expr) -> bool {
1147 match receiver {
1148 Expr::Name { name, .. } => !matches!(
1149 name.as_str(),
1150 "eventPlayer" | "hostPlayer" | "attacker" | "victim"
1151 ),
1152 Expr::Array { .. } | Expr::Index { .. } | Expr::Member { .. } => true,
1153 _ => false,
1154 }
1155}
1156
1157fn expr_identifier(expr: &Expr) -> Option<&str> {
1158 match expr {
1159 Expr::Name { name, .. } => Some(name.as_str()),
1160 _ => None,
1161 }
1162}
1163
1164fn indexed_expr_depth(expr: &Expr) -> usize {
1165 match expr {
1166 Expr::Index { array, .. } => 1 + indexed_expr_depth(array),
1167 _ => 0,
1168 }
1169}
1170
1171impl From<Span> for HirSpan {
1172 fn from(span: Span) -> HirSpan {
1173 HirSpan {
1174 file: span.file,
1175 start: Position {
1176 line: span.start.line,
1177 col: span.start.col,
1178 },
1179 end: Position {
1180 line: span.end.line,
1181 col: span.end.col,
1182 },
1183 }
1184 }
1185}
1186
1187impl From<&Span> for HirSpan {
1188 fn from(span: &Span) -> HirSpan {
1189 (*span).into()
1190 }
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195 use super::*;
1196 use crate::hir::types::{Expr as HirExpr, RuleEntry as HirRuleEntry, Stmt as HirStmt};
1197 use crate::lexer::{LexInput, lex};
1198 use crate::parser::parse;
1199
1200 fn lower_ok(text: &str) -> HirProgram {
1201 let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
1202 let output = parse(&tokens);
1203 assert!(
1204 output.errors.is_empty(),
1205 "unexpected parse errors: {:?}",
1206 output.errors
1207 );
1208 let program = output.program.expect("parse produces a program");
1209 lower(&program, vec![], vec![]).expect("lowers without errors")
1210 }
1211
1212 fn rule_conditions_and_actions(hir: &HirProgram) -> (&Vec<HirExpr>, &Vec<HirStmt>) {
1213 let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
1214 panic!("expected a rule");
1215 };
1216 (&rule.conditions, &rule.actions)
1217 }
1218
1219 #[test]
1220 fn producer_emits_the_v2_ordered_switch_contract() {
1221 let hir = lower_ok(
1222 "globalvar value\nrule \"r\":\n @Event global\n switch value:\n default:\n value = 1\n case 2:\n value = 2\n",
1223 );
1224 assert_eq!(hir.protocol.name, "wright/opy-hir");
1225 assert_eq!(hir.protocol.version, "2.0.0");
1226 let value = serde_json::to_value(&hir).expect("HIR must serialize");
1227 let switch = &value["rules"][0]["actions"][0];
1228 assert!(switch.get("arms").is_some());
1229 assert!(switch.get("cases").is_none());
1230 assert!(switch.get("default").is_none());
1231 }
1232
1233 #[test]
1234 fn receiver_calls_lower_to_receiver_call_hir() {
1235 let hir = lower_ok(
1239 "globalvar target\nrule \"r\":\n @Event eachPlayer\n eventPlayer.setMoveSpeed(100)\n target.setMoveSpeed(50)\n",
1240 );
1241 let (_, actions) = rule_conditions_and_actions(&hir);
1242 assert_eq!(actions.len(), 2);
1243
1244 let HirStmt::Expr { expr, .. } = &actions[0] else {
1245 panic!("expected expression statement");
1246 };
1247 let HirExpr::ReceiverCall {
1248 receiver,
1249 name,
1250 args,
1251 ..
1252 } = expr.as_ref()
1253 else {
1254 panic!("expected receiver call, got {expr:?}");
1255 };
1256 assert_eq!(name, "setMoveSpeed");
1257 assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
1258 assert_eq!(args.len(), 1);
1259 assert!(matches!(&args[0], HirExpr::Number { .. }));
1260
1261 let HirStmt::Expr { expr, .. } = &actions[1] else {
1262 panic!("expected expression statement");
1263 };
1264 let HirExpr::ReceiverCall { receiver, name, .. } = expr.as_ref() else {
1265 panic!("expected receiver call, got {expr:?}");
1266 };
1267 assert_eq!(name, "setMoveSpeed");
1268 assert!(
1269 matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "target"),
1270 "globalvar receiver must resolve to a GlobalVar"
1271 );
1272 }
1273
1274 #[test]
1275 fn bare_variable_member_expression_preserves_receiver_and_member() {
1276 let hir = lower_ok(
1277 "globalvar A\nplayervar B\nrule \"receiver\":\n @Event eachPlayer\n A = B.C\n",
1278 );
1279 let HirStmt::Assign { value, .. } = &hir
1280 .rules
1281 .iter()
1282 .find_map(|entry| {
1283 let RuleEntry::Rule(rule) = entry else {
1284 return None;
1285 };
1286 rule.actions.first()
1287 })
1288 .expect("assignment")
1289 else {
1290 panic!("expected assignment");
1291 };
1292 let HirExpr::Member {
1293 receiver, member, ..
1294 } = value.as_ref()
1295 else {
1296 panic!("expected opaque member expression, got {value:?}");
1297 };
1298 assert_eq!(member, "C");
1299 assert!(matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "B"));
1300 }
1301
1302 #[test]
1303 fn context_player_components_lower_to_member_expressions() {
1304 for (receiver_name, event) in [
1308 ("eventPlayer", "eachPlayer"),
1309 ("hostPlayer", "eachPlayer"),
1310 ("localPlayer", "eachPlayer"),
1311 ("attacker", "playerDied"),
1312 ("victim", "playerDied"),
1313 ] {
1314 for axis in ["x", "y", "z"] {
1315 let hir = lower_ok(&format!(
1316 "globalvar g\nrule \"r\":\n @Event {event}\n g = {receiver_name}.{axis}\n"
1317 ));
1318 let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
1319 panic!("expected a rule");
1320 };
1321 let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1322 panic!("expected an assign statement");
1323 };
1324 let HirExpr::Member {
1325 receiver, member, ..
1326 } = value.as_ref()
1327 else {
1328 panic!("{receiver_name}.{axis}: expected member expression, got {value:?}");
1329 };
1330 assert_eq!(member, axis, "{receiver_name}.{axis}");
1331 match receiver_name {
1332 "eventPlayer" => {
1333 assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }))
1334 }
1335 "hostPlayer" => {
1336 assert!(matches!(receiver.as_ref(), HirExpr::HostPlayer { .. }))
1337 }
1338 _ => assert!(
1339 matches!(receiver.as_ref(), HirExpr::Call { name, .. } if name == receiver_name),
1340 "{receiver_name}.{axis}: receiver must keep its source identity"
1341 ),
1342 }
1343 }
1344 }
1345 }
1346
1347 #[test]
1348 fn context_player_non_component_members_stay_player_variables() {
1349 let value = lowered_value(
1353 "globalvar g\nplayervar pos\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.pos\n",
1354 );
1355 assert!(
1356 matches!(&value, HirExpr::PlayerVar { name, .. } if name == "pos"),
1357 "declared playervar member must stay a player-variable reference, got {value:?}"
1358 );
1359
1360 let value = lowered_value(
1361 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.X\n",
1362 );
1363 assert!(
1364 matches!(&value, HirExpr::PlayerVar { name, .. } if name == "X"),
1365 "default-var member must stay a player-variable reference, got {value:?}"
1366 );
1367
1368 let error = compile_error(&action_source("g = eventPlayer.pos"), 4);
1369 assert_eq!(error.code, "unknown-member");
1370 }
1371
1372 #[test]
1373 fn rule_prefix_template_is_global_and_subroutine_identity_is_preserved() {
1374 let text = "rule \"before\":\n pass\ndef source_name():\n @Name \"Friendly\"\n pass\nrule \"after\":\n pass\n";
1375 let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
1376 let output = parse(&tokens);
1377 assert!(
1378 output.errors.is_empty(),
1379 "unexpected parse errors: {:?}",
1380 output.errors
1381 );
1382 let program = output.program.expect("program");
1383 let preprocessing = PreprocessingState {
1384 rule_prefix_template: Some(crate::hir::types::DirectiveValue {
1385 value: "f\"[{$pathTitle.replace('_', ' ')}] {$rule}\" if $rule and not $isDelimiter else $rule".to_string(),
1386 span: None,
1387 }),
1388 ..PreprocessingState::default()
1389 };
1390 let hir = lower_with_preprocessing(
1391 &program,
1392 vec![SourceFile {
1393 id: 0,
1394 path: "main.opy".to_string(),
1395 }],
1396 vec![],
1397 &preprocessing,
1398 )
1399 .expect("lowers");
1400 let names: Vec<_> = hir
1401 .rules
1402 .iter()
1403 .map(|entry| match entry {
1404 HirRuleEntry::Rule(rule) => rule.name.clone(),
1405 HirRuleEntry::SubroutineDef { name, .. } => name.clone(),
1406 })
1407 .collect();
1408 assert_eq!(
1409 names,
1410 vec!["[Main] before", "[Main] Friendly", "[Main] after"]
1411 );
1412 let HirRuleEntry::SubroutineDef {
1413 name, source_name, ..
1414 } = &hir.rules[1]
1415 else {
1416 panic!("expected subroutine definition");
1417 };
1418 assert_eq!(name, "[Main] Friendly");
1419 assert_eq!(source_name, "source_name");
1420 }
1421
1422 #[test]
1423 fn receiver_call_values_lower_in_conditions() {
1424 let hir = lower_ok(
1428 "rule \"r\":\n @Event eachPlayer\n @Condition eventPlayer.isAlive()\n eventPlayer.teleport(eventPlayer.getPosition())\n",
1429 );
1430 let (conditions, actions) = rule_conditions_and_actions(&hir);
1431 assert_eq!(conditions.len(), 1);
1432 let HirExpr::ReceiverCall { name, args, .. } = &conditions[0] else {
1433 panic!("expected receiver call condition, got {:?}", conditions[0]);
1434 };
1435 assert_eq!(name, "isAlive");
1436 assert_eq!(args.len(), 0);
1437
1438 let HirStmt::Expr { expr, .. } = &actions[0] else {
1439 panic!("expected expression statement");
1440 };
1441 let HirExpr::ReceiverCall {
1442 name,
1443 args,
1444 receiver,
1445 ..
1446 } = expr.as_ref()
1447 else {
1448 panic!("expected receiver call, got {expr:?}");
1449 };
1450 assert_eq!(name, "teleport");
1451 assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
1452 assert_eq!(args.len(), 1);
1453 assert!(matches!(
1454 &args[0],
1455 HirExpr::ReceiverCall { name, .. } if name == "getPosition"
1456 ));
1457 }
1458
1459 #[test]
1460 fn format_string_receiver_stays_a_format_node() {
1461 let hir = lower_ok(
1464 "rule \"r\":\n @Event global\n print(\"{} points\".format(len([1, 2])))\n",
1465 );
1466 let (_, actions) = rule_conditions_and_actions(&hir);
1467 let HirStmt::Expr { expr, .. } = &actions[0] else {
1468 panic!("expected expression statement");
1469 };
1470 assert!(
1471 has_format(expr),
1472 "string `.format()` must lower to a Format node"
1473 );
1474 }
1475
1476 fn has_format(expr: &HirExpr) -> bool {
1477 match expr {
1478 HirExpr::Format { .. } => true,
1479 HirExpr::Call { args, .. } => args.iter().any(has_format),
1480 HirExpr::ReceiverCall { args, .. } => args.iter().any(has_format),
1481 _ => false,
1482 }
1483 }
1484
1485 fn lowered_value(source: &str) -> HirExpr {
1487 let program = crate::compile(source, "test.opy", std::path::Path::new(""))
1488 .unwrap_or_else(|error| panic!("compile failed: {error}"));
1489 let RuleEntry::Rule(rule) = &program.rules[0] else {
1490 panic!("expected a rule");
1491 };
1492 let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1493 panic!("expected an assign statement");
1494 };
1495 (**value).clone()
1496 }
1497
1498 #[test]
1499 fn chase_time_reeval_none_lowers_to_the_catalog_enum() {
1500 let value = lowered_value(
1501 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.NONE\n",
1502 );
1503 assert_enum(&value, "ChaseTimeReeval", "NONE");
1504 }
1505
1506 #[test]
1507 fn chase_time_reeval_destination_and_duration_lowers_to_the_catalog_enum() {
1508 let value = lowered_value(
1509 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.DESTINATION_AND_DURATION\n",
1510 );
1511 assert_enum(&value, "ChaseTimeReeval", "DESTINATION_AND_DURATION");
1512 }
1513
1514 #[test]
1515 fn chase_rate_reeval_members_lower_to_the_catalog_enum() {
1516 for member in ["NONE", "DESTINATION_AND_RATE"] {
1517 let source = format!(
1518 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseRateReeval.{member}\n"
1519 );
1520 assert_enum(&lowered_value(&source), "ChaseRateReeval", member);
1521 }
1522 }
1523
1524 fn assert_enum(value: &HirExpr, domain: &str, member: &str) {
1527 match value {
1528 HirExpr::Enum {
1529 value_type, value, ..
1530 } => {
1531 assert_eq!(value_type, domain);
1532 assert_eq!(value, member);
1533 }
1534 other => panic!("expected enum {domain}.{member}, got {other:?}"),
1535 }
1536 }
1537
1538 #[test]
1539 fn unknown_chase_time_reeval_member_is_rejected_by_the_catalog() {
1540 let error = crate::compile(
1541 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.NOPE\n",
1542 "test.opy",
1543 std::path::Path::new(""),
1544 )
1545 .expect_err("unknown catalog member must be rejected");
1546 assert_eq!(error.code, "unknown-enum-member");
1547 }
1548
1549 #[test]
1550 fn unknown_enum_receiver_is_an_unsupported_member_error() {
1551 let error = crate::compile(
1552 "globalvar g\nrule \"r\":\n @Event global\n g = NotARealEnum.MEMBER\n",
1553 "test.opy",
1554 std::path::Path::new(""),
1555 )
1556 .expect_err("an unknown enum type must fail");
1557 assert_eq!(error.code, "unsupported-member");
1558 let span = error.span.expect("the error is source-located");
1559 assert_eq!(span.start.line, 4);
1560 }
1561
1562 #[test]
1563 fn unknown_texture_member_is_rejected_by_the_source_surface() {
1564 let error = crate::compile(
1565 "globalvar g\nrule \"r\":\n @Event global\n g = Texture.NOT_A_TEXTURE\n",
1566 "test.opy",
1567 std::path::Path::new(""),
1568 )
1569 .expect_err("unknown texture member must be rejected");
1570 assert_eq!(error.code, "unknown-texture-member");
1571 }
1572
1573 fn compile_error(source: &str, line: u32) -> OpyError {
1577 let error = crate::compile(source, "test.opy", std::path::Path::new(""))
1578 .expect_err("expected a compile failure");
1579 let span = error.span.expect("the error is source-located");
1580 assert_eq!(span.start.line, line, "code '{}'", error.code);
1581 error
1582 }
1583
1584 fn action_source(statement: &str) -> String {
1585 format!("globalvar g\nrule \"r\":\n @Event global\n {statement}\n")
1586 }
1587
1588 #[test]
1591 fn unknown_action_names_the_nearest_spellings_in_message() {
1592 let error = compile_error(&action_source("createHudText(g, \"t\")"), 4);
1593 assert_eq!(error.code, "unknown-action");
1594 assert!(
1596 error.message.contains("(did you mean 'hudText'"),
1597 "message: {}",
1598 error.message
1599 );
1600 }
1601
1602 #[test]
1603 fn unknown_action_finds_member_action_names() {
1604 let error = compile_error(&action_source("startForcingPlayerToBeHero(g)"), 4);
1605 assert_eq!(error.code, "unknown-action");
1606 assert!(
1607 error.message.contains("(did you mean 'startForcingHero'"),
1608 "message: {}",
1609 error.message
1610 );
1611 }
1612
1613 #[test]
1614 fn unknown_member_names_the_nearest_member_spellings() {
1615 let error = compile_error(&action_source("eventPlayer.teleprt(g)"), 4);
1616 assert_eq!(error.code, "unknown-member");
1617 assert_eq!(
1618 error.message,
1619 "unknown member 'teleprt' (did you mean 'teleport'?)"
1620 );
1621 }
1622
1623 #[test]
1624 fn unknown_value_includes_the_by_name_special_calls() {
1625 let error = compile_error(&action_source("g = sroted([1])"), 4);
1626 assert_eq!(error.code, "unknown-value");
1627 assert!(
1628 error.message.contains("'sorted'"),
1629 "message: {}",
1630 error.message
1631 );
1632 }
1633
1634 #[test]
1635 fn unknown_enum_member_names_the_domain_surface() {
1636 let error = compile_error(&action_source("g = Hero.SOLDIER76"), 4);
1640 assert_eq!(error.code, "unknown-enum-member");
1641 assert!(
1642 error.message.contains("'SOLDIER'"),
1643 "message: {}",
1644 error.message
1645 );
1646 }
1647
1648 #[test]
1649 fn hud_reeval_string_and_color_alias_resolves_and_ranks() {
1650 let hir = crate::compile(
1654 &action_source("g = HudReeval.VISIBILITY_STRING_AND_COLOR"),
1655 "test.opy",
1656 std::path::Path::new(""),
1657 )
1658 .expect("the accepted alternate resolves");
1659 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1660 panic!("expected a rule");
1661 };
1662 let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1663 panic!("expected an assignment");
1664 };
1665 assert!(matches!(
1666 value.as_ref(),
1667 HirExpr::Enum { value_type, value, .. }
1668 if value_type == "HudReeval" && value == "VISIBLE_TO_STRING_AND_COLOR"
1669 ));
1670
1671 let error = compile_error(
1672 &action_source("g = HudReeval.VISIBILITY_STRING_AND_COLO"),
1673 4,
1674 );
1675 assert_eq!(error.code, "unknown-enum-member");
1676 assert!(
1677 error.message.starts_with(
1678 "enum 'HudReeval' has no member 'VISIBILITY_STRING_AND_COLO' \
1679 (did you mean 'VISIBILITY_STRING_AND_COLOR'"
1680 ),
1681 "message: {}",
1682 error.message
1683 );
1684 }
1685
1686 #[test]
1687 fn unknown_custom_enum_member_names_the_declared_members() {
1688 let error = compile_error(
1689 "globalvar g\nenum Dir:\n NORTH\n SOUTH\nrule \"r\":\n @Event global\n g = Dir.NROTH\n",
1690 7,
1691 );
1692 assert_eq!(error.code, "unknown-enum-member");
1693 assert_eq!(
1694 error.message,
1695 "enum 'Dir' has no member 'NROTH' (did you mean 'NORTH'?)"
1696 );
1697 }
1698
1699 #[test]
1700 fn chase_over_time_resolves_and_compiles_with_reference_signatures() {
1701 let hir = crate::compile(
1703 &action_source("chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE)"),
1704 "test.opy",
1705 std::path::Path::new(""),
1706 )
1707 .expect("reference-supported chaseOverTime compiles");
1708 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1709 panic!("expected a rule");
1710 };
1711 let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1712 panic!("expected expression statement");
1713 };
1714 let HirExpr::Call { name, args, .. } = expr.as_ref() else {
1715 panic!("expected a call, got {expr:?}");
1716 };
1717 assert_eq!(name, "chaseOverTime");
1718 assert_eq!(args.len(), 4);
1719 assert!(matches!(
1720 &args[3],
1721 HirExpr::Enum { value_type, value, .. }
1722 if value_type == "ChaseTimeReeval" && value == "NONE"
1723 ));
1724
1725 let hir = crate::compile(
1727 &action_source("chaseOverTime(g, 10, 3)"),
1728 "test.opy",
1729 std::path::Path::new(""),
1730 )
1731 .expect("default-reevaluation chaseOverTime compiles");
1732 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1733 panic!("expected a rule");
1734 };
1735 let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1736 panic!("expected expression statement");
1737 };
1738 let HirExpr::Call { args, .. } = expr.as_ref() else {
1739 panic!("expected a call");
1740 };
1741 assert_eq!(args.len(), 4);
1742 assert!(matches!(
1743 &args[3],
1744 HirExpr::Enum { value_type, value, .. }
1745 if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1746 ));
1747 }
1748
1749 #[test]
1750 fn is_game_in_progress_resolves_as_a_builtin_value() {
1751 let hir = crate::compile(
1753 &action_source("@Condition isGameInProgress() == true"),
1754 "test.opy",
1755 std::path::Path::new(""),
1756 )
1757 .expect("reference-supported isGameInProgress compiles");
1758 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1759 panic!("expected a rule");
1760 };
1761 assert!(matches!(&rule.conditions[0], HirExpr::Binary { .. }));
1762 }
1763
1764 #[test]
1765 fn enum_gated_members_resolve_through_the_manifest() {
1766 let source = "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
1770 @Condition eventPlayer.getThrottle() != vect(0, 0, 0)\n \
1771 @Condition worldVector(vect(1, 2, 3), eventPlayer, Transform.ROTATION) != vect(0, 0, 0)\n \
1772 eventPlayer.setInvisibility(Invis.ALL)\n \
1773 eventPlayer.setStatusEffect(eventPlayer, Status.ROOTED, 2)\n";
1774 let hir = crate::compile(source, "test.opy", std::path::Path::new(""))
1775 .expect("enum-gated members compile");
1776 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1777 panic!("expected a rule");
1778 };
1779 assert_eq!(rule.actions.len(), 2);
1780 }
1781
1782 #[test]
1783 fn get_players_in_radius_fills_reference_enum_defaults() {
1784 let hir = crate::compile(
1787 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
1788 @Condition len(getPlayersInRadius(eventPlayer.getPosition(), 10)) > 0\n \
1789 disableInspector()\n",
1790 "test.opy",
1791 std::path::Path::new(""),
1792 )
1793 .expect("getPlayersInRadius with defaults compiles");
1794 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1795 panic!("expected a rule");
1796 };
1797 let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
1798 panic!("expected a comparison");
1799 };
1800 let HirExpr::Call { name, args, .. } = left.as_ref() else {
1801 panic!("expected len call");
1802 };
1803 assert_eq!(name, "len");
1804 let HirExpr::Call { name, args, .. } = &args[0] else {
1805 panic!("expected getPlayersInRadius call");
1806 };
1807 assert_eq!(name, "getPlayersInRadius");
1808 assert_eq!(args.len(), 4);
1809 assert!(matches!(
1810 &args[2],
1811 HirExpr::Enum { value_type, value, .. }
1812 if value_type == "Team" && value == "ALL"
1813 ));
1814 assert!(matches!(
1815 &args[3],
1816 HirExpr::Enum { value_type, value, .. }
1817 if value_type == "LosCheck" && value == "OFF"
1818 ));
1819 }
1820
1821 #[test]
1822 fn value_call_in_action_position_is_rejected() {
1823 let error = compile_error(&action_source("isGameInProgress()"), 4);
1824 assert_eq!(error.code, "value-in-action-position");
1825 }
1826
1827 #[test]
1828 fn value_member_in_action_position_is_rejected() {
1829 let error = compile_error(
1832 "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.isAlive()\n",
1833 4,
1834 );
1835 assert_eq!(error.code, "value-in-action-position");
1836 }
1837
1838 fn first_action_expr(source: &str) -> HirExpr {
1843 let program = crate::compile(source, "test.opy", std::path::Path::new(""))
1844 .unwrap_or_else(|error| panic!("compile failed: {error}"));
1845 let RuleEntry::Rule(rule) = &program.rules[0] else {
1846 panic!("expected a rule");
1847 };
1848 match &rule.actions[0] {
1849 HirStmt::Expr { expr, .. } => (**expr).clone(),
1850 HirStmt::Assign { value, .. } => (**value).clone(),
1851 other => panic!("expected an expression or assignment, got {other:?}"),
1852 }
1853 }
1854
1855 fn strip_spans(value: &mut serde_json::Value) {
1858 match value {
1859 serde_json::Value::Object(map) => {
1860 map.remove("span");
1861 map.remove("name_span");
1862 for nested in map.values_mut() {
1863 strip_spans(nested);
1864 }
1865 }
1866 serde_json::Value::Array(items) => {
1867 for item in items {
1868 strip_spans(item);
1869 }
1870 }
1871 _ => {}
1872 }
1873 }
1874
1875 #[test]
1876 fn chase_keyword_forms_dispatch_to_the_concrete_chase_functions() {
1877 let expr = first_action_expr(&action_source("chase(g, 10, rate=2, ChaseReeval.NONE)"));
1882 let HirExpr::Call { name, args, .. } = &expr else {
1883 panic!("expected a call, got {expr:?}");
1884 };
1885 assert_eq!(name, "chaseAtRate");
1886 assert!(matches!(
1887 &args[3],
1888 HirExpr::Enum { value_type, value, .. }
1889 if value_type == "ChaseRateReeval" && value == "NONE"
1890 ));
1891
1892 let expr = first_action_expr(&action_source(
1893 "chase(g, 10, duration=3, ChaseReeval.DESTINATION_AND_DURATION)",
1894 ));
1895 let HirExpr::Call { name, args, .. } = &expr else {
1896 panic!("expected a call, got {expr:?}");
1897 };
1898 assert_eq!(name, "chaseOverTime");
1899 assert!(matches!(
1900 &args[3],
1901 HirExpr::Enum { value_type, value, .. }
1902 if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1903 ));
1904
1905 let expr = first_action_expr(
1908 "playervar P\nrule \"r\":\n @Event eachPlayer\n \
1909 chase(eventPlayer.P, 0, rate=1, ChaseReeval.NONE)\n",
1910 );
1911 let HirExpr::Call { name, args, .. } = &expr else {
1912 panic!("expected a call, got {expr:?}");
1913 };
1914 assert_eq!(name, "chaseAtRate");
1915 assert!(matches!(&args[0], HirExpr::PlayerVar { .. }));
1916 }
1917
1918 #[test]
1919 fn chase_reeval_is_only_a_standalone_identity_inside_the_chase_context() {
1920 let error = compile_error(&action_source("g = ChaseReeval.NONE"), 4);
1924 assert_eq!(error.code, "unsupported-member");
1925
1926 let expr = first_action_expr(&action_source(
1931 "chase(g, 10, rate=2, ChaseReeval.DESTINATION_AND_DURATION)",
1932 ));
1933 let HirExpr::Call { name, args, .. } = &expr else {
1934 panic!("expected a call, got {expr:?}");
1935 };
1936 assert_eq!(name, "chaseAtRate");
1937 assert!(matches!(
1938 &args[3],
1939 HirExpr::Enum { value_type, value, .. }
1940 if value_type == "ChaseRateReeval" && value == "DESTINATION_AND_DURATION"
1941 ));
1942
1943 let expr = first_action_expr(&action_source("chase(g, 10, rate=2, 5)"));
1946 let HirExpr::Call { name, args, .. } = &expr else {
1947 panic!("expected a call, got {expr:?}");
1948 };
1949 assert_eq!(name, "chase");
1950 assert!(matches!(&args[3], HirExpr::Number { .. }));
1951 }
1952
1953 #[test]
1954 fn chase_requires_the_keyword_rate_or_duration_third_argument() {
1955 let error = compile_error(&action_source("chase(g, 10, 2, ChaseReeval.NONE)"), 4);
1956 assert_eq!(error.code, "keyword-required");
1957 assert!(error.message.contains("rate"));
1958 }
1959
1960 #[test]
1961 fn chase_family_requires_a_variable_first_argument() {
1962 let error = compile_error(&action_source("chase(10, 10, rate=2, ChaseReeval.NONE)"), 4);
1967 assert_eq!(error.code, "invalid-argument");
1968
1969 let error = compile_error(
1970 &action_source("chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)"),
1971 4,
1972 );
1973 assert_eq!(error.code, "invalid-argument");
1974
1975 let error = compile_error(
1976 &action_source("chaseAtRate(10, 0, 2, ChaseRateReeval.NONE)"),
1977 4,
1978 );
1979 assert_eq!(error.code, "invalid-argument");
1980
1981 crate::compile(
1983 &action_source("chaseAtRate(g, 0, 2, ChaseRateReeval.NONE)"),
1984 "test.opy",
1985 std::path::Path::new(""),
1986 )
1987 .expect("a variable first argument compiles");
1988 }
1989
1990 #[test]
1991 fn keyword_binding_matches_positional_binding_in_hir() {
1992 fn without_spans(expr: &HirExpr) -> serde_json::Value {
1996 let mut value = serde_json::to_value(expr).unwrap();
1997 strip_spans(&mut value);
1998 value
1999 }
2000 let keyword = without_spans(&first_action_expr(&action_source(
2001 "chaseOverTime(g, 10, duration=3)",
2002 )));
2003 let positional = without_spans(&first_action_expr(&action_source(
2004 "chaseOverTime(g, 10, 3)",
2005 )));
2006 assert_eq!(keyword, positional);
2007
2008 let keyword = without_spans(&first_action_expr(&action_source("wait(time=1)")));
2009 let positional = without_spans(&first_action_expr(&action_source("wait(1)")));
2010 assert_eq!(keyword, positional);
2011
2012 let keyword = without_spans(&first_action_expr(&action_source(
2014 "wait(waitBehavior=Wait.IGNORE_CONDITION, time=2)",
2015 )));
2016 let positional = without_spans(&first_action_expr(&action_source("wait(2)")));
2017 assert_eq!(keyword, positional);
2018
2019 let keyword = without_spans(&first_action_expr(&action_source(
2020 "g = vect(x=1, y=2, z=3)",
2021 )));
2022 let positional = without_spans(&first_action_expr(&action_source("g = vect(1, 2, 3)")));
2023 assert_eq!(keyword, positional);
2024 }
2025
2026 #[test]
2027 fn keyword_binding_diagnostics_are_structured_and_source_located() {
2028 let error = compile_error(&action_source("chaseOverTime(g, 10, bogus=1)"), 4);
2030 assert_eq!(error.code, "unknown-keyword");
2031 assert!(error.message.contains("bogus"));
2032
2033 let error = compile_error(
2035 &action_source(
2036 "chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE, \
2037 reevaluation=ChaseTimeReeval.NONE)",
2038 ),
2039 4,
2040 );
2041 assert_eq!(error.code, "duplicate-argument");
2042
2043 let error = compile_error(&action_source("chaseOverTime(g, duration=3, 5)"), 4);
2045 assert_eq!(error.code, "positional-after-keyword");
2046
2047 let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
2049 assert_eq!(error.code, "missing-argument");
2050
2051 let error = compile_error(
2054 &action_source("chase(variable=g, destination=10, rate=2, ChaseReeval.NONE)"),
2055 4,
2056 );
2057 assert_eq!(error.code, "unknown-keyword");
2058 }
2059
2060 #[test]
2061 fn keyword_arguments_are_rejected_for_reference_special_cases() {
2062 let error = compile_error(
2065 "globalvar g\nrule \"r\":\n @Event global\n \
2066 for I in range(start=0, stop=3):\n debug(I)\n",
2067 4,
2068 );
2069 assert_eq!(error.code, "keyword-unsupported");
2070
2071 let error = compile_error(&action_source("g = random.uniform(min=1, max=2)"), 4);
2072 assert_eq!(error.code, "keyword-unsupported");
2073
2074 let error = compile_error(&action_source("print(\"{} points\".format(value=1))"), 4);
2075 assert_eq!(error.code, "keyword-unsupported");
2076 }
2077
2078 #[test]
2079 fn sorted_rejects_keyword_arguments_beyond_the_key_lambda() {
2080 let error = compile_error(&action_source("g = sorted([1, 2], bogus=lambda v: v)"), 4);
2084 assert_eq!(error.code, "unknown-keyword");
2085
2086 let error = compile_error(
2087 &action_source("g = sorted(array=[1, 2], key=lambda v: -v)"),
2088 4,
2089 );
2090 assert_eq!(error.code, "unknown-keyword");
2091
2092 let error = compile_error(&action_source("g = sorted(key=lambda v: v)"), 4);
2093 assert_eq!(error.code, "unknown-keyword");
2094 }
2095
2096 #[test]
2097 fn call_args_reject_parenthesized_keyword_names() {
2098 let error = compile_error(&action_source("g = vect((g)=1, y=2, z=3)"), 4);
2101 assert_eq!(error.code, "parse-error");
2102
2103 let error = compile_error(&action_source("g = sorted([1, 2], (key)=lambda v: -v)"), 4);
2104 assert_eq!(error.code, "parse-error");
2105
2106 crate::compile(
2109 &action_source("g = vect(x=1, y=2, z=3) + vect((g), 2, 3)"),
2110 "test.opy",
2111 std::path::Path::new(""),
2112 )
2113 .expect("bare keyword arguments compile");
2114 }
2115
2116 #[test]
2117 fn call_args_reject_trailing_comma() {
2118 for statement in [
2121 "wait(1,)",
2122 "g = vect(1, 2, 3,)",
2123 "g = sorted([1, 2],)",
2124 "g = sorted([1, 2], key=lambda v: -v,)",
2125 ] {
2126 let error = compile_error(&action_source(statement), 4);
2127 assert_eq!(error.code, "parse-error", "statement: {statement}");
2128 }
2129
2130 crate::compile(
2133 &action_source("g = [1, 2,]"),
2134 "test.opy",
2135 std::path::Path::new(""),
2136 )
2137 .expect("array literal trailing comma compiles");
2138 crate::compile(
2139 &action_source("g = {\"a\": 1,}[\"a\"]"),
2140 "test.opy",
2141 std::path::Path::new(""),
2142 )
2143 .expect("dict literal trailing comma compiles");
2144 crate::compile(
2145 "rule \"r\":\n @Event playerDied(victim,)\n wait(1)\n",
2146 "test.opy",
2147 std::path::Path::new(""),
2148 )
2149 .expect("@Event argument trailing comma compiles");
2150 }
2151
2152 #[test]
2153 fn binder_lambda_rejects_parenthesized() {
2154 for statement in [
2157 "g = sorted([1, 2], (lambda v: -v))",
2158 "g = sorted([1, 2], key=(lambda v: -v))",
2159 "g = [1, 2].map((lambda v: v))",
2160 "g = [1, 2].filter((lambda v: v))",
2161 "g = [1, 2].all((lambda v: v))",
2162 "g = [1, 2].any((lambda v: v))",
2163 "g = sorted([1, 2], ((lambda v: -v)))",
2164 ] {
2165 let error = compile_error(&action_source(statement), 4);
2166 assert_eq!(error.code, "lambda-context", "statement: {statement}");
2167 }
2168
2169 crate::compile(
2171 &action_source("g = sorted([1, 2], key=lambda v: -v)"),
2172 "test.opy",
2173 std::path::Path::new(""),
2174 )
2175 .expect("bare lambda key compiles");
2176 }
2177
2178 #[test]
2179 fn wait_uses_the_reference_keyword_names() {
2180 let error = compile_error(&action_source("wait(duration=1)"), 4);
2184 assert_eq!(error.code, "unknown-keyword");
2185 assert!(error.message.contains("duration"));
2186 }
2187
2188 #[test]
2189 fn action_call_in_value_position_is_rejected() {
2190 let error = compile_error(&action_source("g = wait(1)"), 4);
2191 assert_eq!(error.code, "action-in-value-position");
2192 }
2193
2194 #[test]
2195 fn missing_required_argument_is_a_source_located_diagnostic() {
2196 let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
2200 assert_eq!(error.code, "missing-argument");
2201 assert!(error.message.contains("duration"));
2202
2203 let error = compile_error(&action_source("chaseOverTime(g, 10, 3, 4, 5)"), 4);
2204 assert_eq!(error.code, "invalid-arity");
2205 }
2206
2207 #[test]
2208 fn missing_member_argument_is_a_source_located_diagnostic() {
2209 let error = compile_error(
2214 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
2215 getPlayersInRadius(eventPlayer.getPosition(), 10).setStatusEffect(eventPlayer, 30)\n",
2216 4,
2217 );
2218 assert_eq!(error.code, "missing-argument");
2219 assert!(error.message.contains("duration"));
2220 }
2221
2222 #[test]
2223 fn invalid_receiver_categories_are_rejected() {
2224 let error = compile_error(&action_source("3.append(1)"), 4);
2228 assert_eq!(error.code, "invalid-receiver");
2229 assert!(error.message.contains("append"));
2230
2231 let error = compile_error(&action_source("attacker.append(1)"), 4);
2232 assert_eq!(error.code, "invalid-receiver");
2233 assert!(error.message.contains("append"));
2234
2235 let error = compile_error(&action_source("3.remove(1)"), 4);
2236 assert_eq!(error.code, "invalid-receiver");
2237 assert!(error.message.contains("remove"));
2238
2239 let error = compile_error(&action_source("print(3.format(\"{}\"))"), 4);
2240 assert_eq!(error.code, "invalid-receiver");
2241 assert!(error.message.contains("format"));
2242
2243 crate::compile(
2245 &action_source("g.append(1)\n g.remove(1)"),
2246 "test.opy",
2247 std::path::Path::new(""),
2248 )
2249 .expect("assignable receivers compile");
2250 crate::compile(
2251 &action_source("g = \"{}\".format(1)"),
2252 "test.opy",
2253 std::path::Path::new(""),
2254 )
2255 .expect("a string-literal receiver compiles");
2256 }
2257
2258 fn lower_with_manifest(source: &str, manifest: &'static Manifest) -> OpyResult<HirProgram> {
2262 let tokens = lex(LexInput {
2263 file_id: 0,
2264 text: source,
2265 })
2266 .expect("lexes");
2267 let output = parse(&tokens);
2268 assert!(
2269 output.errors.is_empty(),
2270 "unexpected parse errors: {:?}",
2271 output.errors
2272 );
2273 let program = output.program.expect("parse produces a program");
2274 lower_program(
2275 &program,
2276 vec![],
2277 vec![],
2278 &PreprocessingState::default(),
2279 manifest,
2280 Catalog::builtin().expect("the builtin catalog loads"),
2281 )
2282 }
2283
2284 #[test]
2285 fn special_enforcement_is_typed_policy_not_manifest_metadata() {
2286 let mut file: serde_json::Value =
2290 serde_json::from_str(crate::manifest::MANIFEST_DATA).unwrap();
2291 for function in file["functions"].as_array_mut().unwrap() {
2292 match function["id"].as_str().unwrap() {
2293 "chase" | "chaseAtRate" | "chaseOverTime" => {
2295 for param in function["params"].as_array_mut().unwrap() {
2296 param.as_object_mut().unwrap().remove("variable");
2297 }
2298 }
2299 "append" | "remove" | "format" => {
2302 function["receiver"] = "Any".into();
2303 }
2304 "wait" => function["params"][0]["variable"] = true.into(),
2308 "getPosition" => function["receiver"] = "Variable".into(),
2309 _ => {}
2310 }
2311 }
2312 let manifest: &'static Manifest = Box::leak(Box::new(
2313 Manifest::load(
2314 &serde_json::to_string(&file).unwrap(),
2315 crate::manifest::PROBES_DATA,
2316 )
2317 .expect("mutated metadata still validates"),
2318 ));
2319
2320 for call in [
2322 "chase(10, 10, rate=2, ChaseReeval.NONE)",
2323 "chaseAtRate(10, 0, 2, ChaseRateReeval.NONE)",
2324 "chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)",
2325 ] {
2326 let error = lower_with_manifest(&action_source(call), manifest)
2327 .expect_err("the chase family still requires a variable");
2328 assert_eq!(error.code, "invalid-argument", "{call}");
2329 }
2330 lower_with_manifest(&action_source("chaseOverTime(g, 10, 3)"), manifest)
2332 .expect("a variable first argument compiles");
2333
2334 for call in ["3.append(1)", "3.remove(1)", "print(3.format(\"{}\"))"] {
2337 let error = lower_with_manifest(&action_source(call), manifest)
2338 .expect_err("the enforced receiver requirement still rejects");
2339 assert_eq!(error.code, "invalid-receiver", "{call}");
2340 }
2341 lower_with_manifest(&action_source("g.append(1)"), manifest)
2342 .expect("an assignable receiver compiles");
2343 lower_with_manifest(&action_source("g = \"{}\".format(1)"), manifest)
2344 .expect("a string-literal receiver compiles");
2345
2346 lower_with_manifest(&action_source("wait(1)"), manifest)
2349 .expect("`wait`'s first argument is not variable-required");
2350 lower_with_manifest(
2351 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
2352 g = eventPlayer.getPosition()\n",
2353 manifest,
2354 )
2355 .expect("`getPosition`'s receiver is not assignable-enforced");
2356 }
2357
2358 #[test]
2359 fn cross_domain_enum_arguments_resolve_as_opaque_identities() {
2360 let expr = first_action_expr(&action_source("chaseOverTime(g, 10, 3, Invis.ALL)"));
2364 let HirExpr::Call { args, .. } = &expr else {
2365 panic!("expected a call, got {expr:?}");
2366 };
2367 assert!(matches!(
2368 &args[3],
2369 HirExpr::Enum { value_type, value, .. }
2370 if value_type == "Invis" && value == "ALL"
2371 ));
2372
2373 let expr = first_action_expr(&action_source(
2374 "eventPlayer.setInvisibility(ChaseTimeReeval.NONE)",
2375 ));
2376 let HirExpr::ReceiverCall { args, .. } = &expr else {
2377 panic!("expected a receiver call, got {expr:?}");
2378 };
2379 assert!(matches!(
2380 &args[0],
2381 HirExpr::Enum { value_type, value, .. }
2382 if value_type == "ChaseTimeReeval" && value == "NONE"
2383 ));
2384 }
2385
2386 #[test]
2387 fn non_enum_arguments_for_enum_parameters_are_carried_structurally() {
2388 let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(g)"));
2392 let HirExpr::ReceiverCall { args, .. } = &expr else {
2393 panic!("expected a receiver call, got {expr:?}");
2394 };
2395 assert!(matches!(
2396 &args[0],
2397 HirExpr::GlobalVar { name, .. } if name == "g"
2398 ));
2399
2400 let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(3)"));
2401 let HirExpr::ReceiverCall { args, .. } = &expr else {
2402 panic!("expected a receiver call, got {expr:?}");
2403 };
2404 assert!(matches!(&args[0], HirExpr::Number { .. }));
2405 }
2406
2407 #[test]
2408 fn unknown_builtins_fail_at_resolution_not_emission() {
2409 let error = compile_error(&action_source("frobnicate()"), 4);
2410 assert_eq!(error.code, "unknown-action");
2411
2412 let error = compile_error(&action_source("g = frobnicate()"), 4);
2413 assert_eq!(error.code, "unknown-value");
2414
2415 let error = compile_error(
2416 "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.frobnicate()\n",
2417 4,
2418 );
2419 assert_eq!(error.code, "unknown-member");
2420 }
2421
2422 #[test]
2423 fn wright_only_catalog_names_are_rejected() {
2424 let error = compile_error(&action_source("createHudText(1)"), 4);
2428 assert_eq!(error.code, "unknown-action");
2429
2430 let error = compile_error(&action_source("g = squareRoot(9)"), 4);
2431 assert_eq!(error.code, "unknown-value");
2432 }
2433
2434 #[test]
2435 fn canonical_ids_without_upstream_spellings_are_rejected() {
2436 for (statement, code) in [
2440 ("g = evaluateOnce(1)", "unknown-value"),
2441 ("g = lastCreatedEntity()", "unknown-value"),
2442 ("g = lastTextId()", "unknown-value"),
2443 ("g = allTankHeroes()", "unknown-value"),
2444 ("g = allDamageHeroes()", "unknown-value"),
2445 ("g = allSupportHeroes()", "unknown-value"),
2446 ("destroyAllHudText()", "unknown-action"),
2447 ] {
2448 let error = compile_error(&action_source(statement), 4);
2449 assert_eq!(error.code, code, "{statement}");
2450 }
2451
2452 let member = "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n";
2453 let error = compile_error(member, 4);
2454 assert_eq!(error.code, "unknown-member");
2455
2456 for axis in ["x", "y", "z"] {
2458 let source = format!(
2459 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.{axis}()\n"
2460 );
2461 let error = compile_error(&source, 4);
2462 assert_eq!(error.code, "unknown-member", "{axis}()");
2463 }
2464
2465 crate::compile(
2467 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = evalOnce(1)\n g = getLastCreatedEntity()\n g = getLastCreatedText()\n g = getTankHeroes()\n g = getDamageHeroes()\n g = getSupportHeroes()\n g = eventPlayer.isHoldingButton(Button.PRIMARY_FIRE)\n destroyAllHudTexts()\n",
2468 "test.opy",
2469 std::path::Path::new(""),
2470 )
2471 .expect("the upstream spellings compile");
2472 }
2473
2474 #[test]
2475 fn generic_member_only_actions_are_rejected() {
2476 let error = compile_error(&action_source("setMoveSpeed(eventPlayer, 100)"), 4);
2479 assert_eq!(error.code, "unknown-action");
2480 }
2481
2482 #[test]
2483 fn range_is_for_iterables_only() {
2484 let error = compile_error(&action_source("@Condition len(range(1, 5, 1)) > 0"), 4);
2487 assert_eq!(error.code, "invalid-call-context");
2488
2489 let error = compile_error(&action_source("for g in [1, 2]:\n debug(g)"), 4);
2490 assert_eq!(error.code, "invalid-iterable");
2491
2492 crate::compile(
2493 &action_source("for g in range(3):\n debug(g)"),
2494 "test.opy",
2495 std::path::Path::new(""),
2496 )
2497 .expect("the for-header range form compiles");
2498 }
2499
2500 #[test]
2501 fn source_aliases_resolve_to_canonical_names() {
2502 let hir = crate::compile(
2505 &action_source("stopChasingVariable(g)"),
2506 "test.opy",
2507 std::path::Path::new(""),
2508 )
2509 .expect("the alias target compiles");
2510 let RuleEntry::Rule(rule) = &hir.rules[0] else {
2511 panic!("expected a rule");
2512 };
2513 let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
2514 panic!("expected expression statement");
2515 };
2516 let HirExpr::Call { name, .. } = expr.as_ref() else {
2517 panic!("expected a call");
2518 };
2519 assert_eq!(name, "stopChasingVariable");
2520
2521 let hir = crate::compile(
2522 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
2523 @Condition eventPlayer.getCurrentHero() != null\n \
2524 @Condition eventPlayer.hasStatusEffect(Status.BURNING) == false\n \
2525 disableInspector()\n",
2526 "test.opy",
2527 std::path::Path::new(""),
2528 )
2529 .expect("member aliases compile");
2530 let RuleEntry::Rule(rule) = &hir.rules[0] else {
2531 panic!("expected a rule");
2532 };
2533 let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
2534 panic!("expected a comparison");
2535 };
2536 let HirExpr::ReceiverCall { name, .. } = left.as_ref() else {
2537 panic!("expected a receiver call");
2538 };
2539 assert_eq!(name, "getHero");
2540 }
2541
2542 #[test]
2543 fn unknown_catalog_enum_members_are_rejected() {
2544 for source in [
2545 "globalvar g\nrule \"r\":\n @Event global\n g = Color.CYAN\n",
2546 "globalvar g\nrule \"r\":\n @Event global\n g = DynamicEffect.SPARKLES\n",
2547 ] {
2548 let error = crate::compile(source, "test.opy", std::path::Path::new(""))
2549 .expect_err("unknown catalog member must be rejected");
2550 assert_eq!(error.code, "unknown-enum-member");
2551 }
2552 }
2553
2554 #[test]
2555 fn default_var_for_binder_resolves_at_all_range_arities() {
2556 for (binder, iterable) in [
2561 ("I", "range(0, 10)"),
2562 ("I", "range(3)"),
2563 ("I", "range(1, 5, 2)"),
2564 ] {
2565 let hir = lower_ok(&format!(
2566 "globalvar total\nrule \"r\":\n @Event global\n for {binder} in {iterable}:\n total += {binder}\n"
2567 ));
2568 let (_, actions) = rule_conditions_and_actions(&hir);
2569 let HirStmt::For { variable, body, .. } = &actions[0] else {
2570 panic!("expected a for statement");
2571 };
2572 assert!(
2573 matches!(variable.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2574 "the binder resolves to the implicit global 'I', got {variable:?}"
2575 );
2576 assert!(!body.is_empty(), "the loop body lowers");
2577 let HirStmt::Assign { value, .. } = &body[0] else {
2580 panic!("expected an assignment in the body");
2581 };
2582 let HirExpr::Binary { right, .. } = value.as_ref() else {
2583 panic!("expected a binary expression");
2584 };
2585 assert!(
2586 matches!(right.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2587 "the binder use inside the body resolves to the implicit global"
2588 );
2589 }
2590 }
2591
2592 #[test]
2593 fn player_variable_range_binder_preserves_host_player_receiver() {
2594 let hir = lower_ok(
2595 "playervar I\nrule \"r\":\n @Event global\n for hostPlayer.I in range(3):\n hostPlayer.I = 1\n",
2596 );
2597 let (_, actions) = rule_conditions_and_actions(&hir);
2598 let HirStmt::For { variable, .. } = &actions[0] else {
2599 panic!("expected a for statement");
2600 };
2601 let HirExpr::PlayerVar {
2602 player,
2603 name,
2604 member_span,
2605 span,
2606 } = variable.as_ref()
2607 else {
2608 panic!("expected a player-variable binder, got {variable:?}");
2609 };
2610 assert_eq!(name, "I");
2611 assert!(matches!(player.as_ref(), HirExpr::HostPlayer { .. }));
2612 assert_eq!(span.unwrap().start.line, 4);
2613 assert_eq!(span.unwrap().start.col, 9);
2614 assert_eq!(span.unwrap().end.line, 4);
2615 assert_eq!(span.unwrap().end.col, 21);
2616 let member_span = member_span.expect("player binder member span");
2617 assert_eq!(member_span.start.line, 4);
2618 assert_eq!(member_span.start.col, 20);
2619 assert_eq!(member_span.end.line, 4);
2620 assert_eq!(member_span.end.col, 21);
2621 }
2622
2623 #[test]
2624 fn default_var_names_resolve_as_implicit_globals() {
2625 let hir = lower_ok("rule \"r\":\n @Event global\n I = 5\n debug(I)\n");
2628 let (_, actions) = rule_conditions_and_actions(&hir);
2629 let HirStmt::Assign { target, .. } = &actions[0] else {
2630 panic!("expected an assignment");
2631 };
2632 assert!(
2633 matches!(target.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2634 "the implicit global resolves, got {target:?}"
2635 );
2636 assert_eq!(default_var_index("I"), Some(8));
2638 assert_eq!(default_var_index("AA"), Some(26));
2639 assert_eq!(default_var_index("Z"), Some(25));
2640 assert_eq!(default_var_index("DX"), Some(127));
2641 assert_eq!(default_var_index("DY"), None);
2642 assert_eq!(default_var_index("i"), None);
2643 }
2644
2645 #[test]
2646 fn nested_same_name_for_binders_reuse_the_implicit_global() {
2647 let hir = lower_ok(
2651 "rule \"r\":\n @Event global\n for I in range(3):\n for I in range(2):\n debug(I)\n",
2652 );
2653 let (_, actions) = rule_conditions_and_actions(&hir);
2654 let HirStmt::For {
2655 variable: outer,
2656 body,
2657 ..
2658 } = &actions[0]
2659 else {
2660 panic!("expected an outer for statement");
2661 };
2662 let HirStmt::For {
2663 variable: inner, ..
2664 } = &body[0]
2665 else {
2666 panic!("expected an inner for statement");
2667 };
2668 assert!(
2669 matches!(outer.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I")
2670 && matches!(inner.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2671 "both loops bind the same implicit global (spans differ per binder site)"
2672 );
2673 }
2674
2675 #[test]
2676 fn undeclared_lowercase_binder_is_still_an_unknown_identifier() {
2677 let error = compile_error(
2682 "rule \"r\":\n @Event global\n for i in range(3):\n debug(i)\n",
2683 3,
2684 );
2685 assert_eq!(error.code, "unknown-identifier");
2686 let span = error.span.expect("the error is source-located");
2687 assert_eq!(span.start.line, 3);
2688 }
2689
2690 #[test]
2691 fn syntax_constructs_lower_to_provenance_preserving_hir() {
2692 let hir = lower_ok(
2693 "globalvar x\nrule \"r\":\n @Event global\n do:\n x = {\"x\": 1}[\"x\"]\n while x not in [2, 3]\n switch x:\n case 0x10:\n x = 1 in [1, 2]\n default:\n x = 2\n x = [value * 2 for value, index in [1, 2] if value > index]\n x = sorted([1, 2], key=lambda value: value)\n x = w\"wide\"\n",
2694 );
2695 let (_, actions) = rule_conditions_and_actions(&hir);
2696 let HirStmt::DoWhile { condition, .. } = &actions[0] else {
2697 panic!("expected do-while");
2698 };
2699 assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in"));
2700 let HirStmt::Switch { arms, .. } = &actions[1] else {
2701 panic!("expected switch");
2702 };
2703 assert_eq!(arms.len(), 2);
2704 let HirSwitchArm::Case {
2705 value: case_value,
2706 body,
2707 ..
2708 } = &arms[0]
2709 else {
2710 panic!("expected case arm");
2711 };
2712 assert!(
2713 matches!(case_value.as_ref(), HirExpr::Number { value, .. } if *value == 0x10 as f64)
2714 );
2715 let HirStmt::Assign { value, .. } = &body[0] else {
2716 panic!("expected case assignment");
2717 };
2718 assert!(matches!(value.as_ref(), HirExpr::Binary { op, .. } if op == "in"));
2719 let HirSwitchArm::Default { body, .. } = &arms[1] else {
2720 panic!("expected default arm");
2721 };
2722 assert!(matches!(body[0], HirStmt::Assign { .. }));
2723 let HirStmt::Assign { value, .. } = &actions[2] else {
2724 panic!("expected comprehension assignment");
2725 };
2726 assert!(matches!(value.as_ref(), HirExpr::Comprehension { .. }));
2727 let HirStmt::Assign { value, .. } = &actions[3] else {
2728 panic!("expected sorted assignment");
2729 };
2730 assert!(
2731 matches!(value.as_ref(), HirExpr::Call { name, args, .. } if name == "sorted" && matches!(&args[1], HirExpr::Lambda { body, .. } if matches!(body.as_ref(), HirExpr::Local { name, .. } if name == "value")))
2732 );
2733 let HirStmt::Assign { value, .. } = &actions[4] else {
2734 panic!("expected string assignment");
2735 };
2736 assert!(
2737 matches!(value.as_ref(), HirExpr::StringModifier { modifier, .. } if modifier == "w")
2738 );
2739 }
2740
2741 #[test]
2742 fn syntax_rejects_reference_invalid_bare_dict_and_lambda() {
2743 let dict_error = compile_error(
2744 "globalvar x\nrule \"r\":\n @Event global\n x = {\"x\": 1}\n",
2745 4,
2746 );
2747 assert_eq!(dict_error.code, "dict-access");
2748 let lambda_error = compile_error(
2749 "globalvar x\nrule \"r\":\n @Event global\n x = lambda value: value\n",
2750 4,
2751 );
2752 assert_eq!(lambda_error.code, "lambda-context");
2753 }
2754
2755 #[test]
2756 fn do_while_requires_rule_or_definition_prefix_position() {
2757 let error = compile_error(
2758 "globalvar value\nrule \"r\":\n @Event global\n value = 1\n do:\n value += 1\n while value < 2\n",
2759 5,
2760 );
2761 assert_eq!(error.code, "do-while-placement");
2762 assert_eq!(
2763 error.message,
2764 "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it"
2765 );
2766 }
2767}