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.collect_symbols(program);
459 Ok(Self { lowerer })
460 }
461
462 pub(crate) fn lower(
465 &mut self,
466 text: &str,
467 file: u32,
468 origin: crate::diag::Position,
469 ) -> OpyResult<HirExpr> {
470 let expression = crate::parser::parse_expression_fragment(text, file, origin)?;
471 self.lowerer.errors.clear();
472 self.lowerer.texture_used = false;
473 let lowered = self
474 .lowerer
475 .lower_expr(&expression, &[], CallPosition::Value);
476 self.lowerer
477 .errors
478 .first()
479 .cloned()
480 .map_or(Ok(lowered), Err)
481 }
482}
483
484fn prefixed_rule_name(name: &str, prefix: Option<&str>, delimiter: bool) -> String {
485 match prefix {
486 Some(prefix) if !prefix.is_empty() && !delimiter && !name.is_empty() => {
487 format!("[{prefix}] {name}")
488 }
489 _ => name.to_string(),
490 }
491}
492
493pub(crate) fn strip_rule_name_formatting(text: &str) -> impl Iterator<Item = char> + '_ {
494 text.chars().filter(|character| {
495 !matches!(
496 character,
497 '\u{200B}' | '\u{200E}' | '\u{200F}' | '\u{FEFF}' | '\u{061C}'
498 )
499 })
500}
501
502#[derive(Clone, Debug)]
503enum TemplateValue {
504 String(String),
505 Bool(bool),
506}
507
508fn rule_template_values(
509 name: &str,
510 prefix: &str,
511 file: &str,
512 path: &str,
513 delimiter: bool,
514) -> [(&'static str, TemplateValue); 14] {
515 [
516 ("$rule", TemplateValue::String(name.to_string())),
517 ("$prefix", TemplateValue::String(prefix.to_string())),
518 ("$file", TemplateValue::String(file.to_string())),
519 ("$path", TemplateValue::String(path.to_string())),
520 ("$isDelimiter", TemplateValue::Bool(delimiter)),
521 ("$prefixTitle", TemplateValue::String(title_case(prefix))),
522 ("$prefixUpper", TemplateValue::String(prefix.to_uppercase())),
523 ("$prefixLower", TemplateValue::String(prefix.to_lowercase())),
524 ("$fileTitle", TemplateValue::String(title_case(file))),
525 ("$fileUpper", TemplateValue::String(file.to_uppercase())),
526 ("$fileLower", TemplateValue::String(file.to_lowercase())),
527 ("$pathTitle", TemplateValue::String(title_case(path))),
528 ("$pathUpper", TemplateValue::String(path.to_uppercase())),
529 ("$pathLower", TemplateValue::String(path.to_lowercase())),
530 ]
531}
532
533fn render_rule_name(
534 name: &str,
535 prefix: Option<&str>,
536 delimiter: bool,
537 span: Span,
538 files: &[SourceFile],
539 preprocessing: &PreprocessingState,
540) -> OpyResult<String> {
541 let Some(template) = preprocessing
542 .rule_prefix_template
543 .as_ref()
544 .map(|value| value.value.as_str())
545 else {
546 return Ok(
547 strip_rule_name_formatting(&prefixed_rule_name(name, prefix, delimiter)).collect(),
548 );
549 };
550 let (file, path) = rule_file_parts(span.file, files);
551 let prefix = prefix.unwrap_or_default();
552 let values = rule_template_values(name, prefix, &file, &path, delimiter);
553 evaluate_template(template, &values)
554 .map(|name| strip_rule_name_formatting(&name).collect())
555 .map_err(|message| {
556 OpyError::at(
557 "rule-prefix-template-invalid",
558 format!("could not resolve rule prefix template: {message}"),
559 span,
560 )
561 })
562}
563
564pub(crate) fn render_generated_rule_name(name: &str, preprocessing: &PreprocessingState) -> String {
567 let Some(template) = preprocessing
568 .rule_prefix_template
569 .as_ref()
570 .map(|value| value.value.as_str())
571 else {
572 return name.to_string();
573 };
574 let values = rule_template_values(name, "", "", "", false);
575 evaluate_template(template, &values)
576 .map(|rendered| strip_rule_name_formatting(&rendered).collect())
577 .unwrap_or_else(|_| name.to_string())
578}
579
580fn rule_file_parts(file_id: u32, files: &[SourceFile]) -> (String, String) {
581 let path = files
582 .iter()
583 .find(|file| file.id == file_id)
584 .map(|file| file.path.replace('\\', "/"))
585 .unwrap_or_default();
586 let without_extension = path
587 .strip_suffix(".opy")
588 .or_else(|| path.strip_suffix(".OPY"))
589 .unwrap_or(&path)
590 .to_string();
591 let file = without_extension
592 .rsplit('/')
593 .next()
594 .unwrap_or_default()
595 .to_string();
596 (file, without_extension)
597}
598
599fn title_case(value: &str) -> String {
600 let mut result = String::with_capacity(value.len());
601 let mut capitalize = true;
602 for ch in value.chars() {
603 if ch == '_' {
604 result.push(' ');
605 capitalize = true;
606 } else if capitalize && ch.is_ascii_alphabetic() {
607 result.push(ch.to_ascii_uppercase());
608 capitalize = false;
609 } else {
610 result.push(ch);
611 if !ch.is_whitespace() && ch != '/' {
612 capitalize = false;
613 }
614 }
615 if ch == '/' || ch.is_whitespace() {
616 capitalize = true;
617 }
618 }
619 result
620}
621
622fn evaluate_template(template: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
623 if let Some((then_value, condition, else_value)) = split_conditional(template) {
624 let branch = if evaluate_condition(condition, values)? {
625 then_value
626 } else {
627 else_value
628 };
629 return evaluate_string(branch, values);
630 }
631 evaluate_string(template, values)
632}
633
634fn split_conditional(value: &str) -> Option<(&str, &str, &str)> {
635 let mut quote = None;
636 let mut depth = 0usize;
637 let mut if_start = None;
638 let mut else_start = None;
639 for (index, ch) in value.char_indices() {
640 match (ch, quote) {
641 ('"' | '\'', None) => quote = Some(ch),
642 (ch, Some(current)) if ch == current => quote = None,
643 ('{', None) => depth += 1,
644 ('}', None) => depth = depth.saturating_sub(1),
645 _ => {}
646 }
647 if quote.is_none() && depth == 0 {
648 if value[index..].starts_with(" if ") && if_start.is_none() {
649 if_start = Some(index);
650 } else if value[index..].starts_with(" else ") && else_start.is_none() {
651 else_start = Some(index);
652 }
653 }
654 }
655 let (Some(if_start), Some(else_start)) = (if_start, else_start) else {
656 return None;
657 };
658 Some((
659 value[..if_start].trim(),
660 value[if_start + 4..else_start].trim(),
661 value[else_start + 6..].trim(),
662 ))
663}
664
665fn evaluate_condition(value: &str, values: &[(&str, TemplateValue)]) -> Result<bool, String> {
666 let value = value.trim();
667 if let Some(rest) = value.strip_prefix("not ") {
668 return Ok(!evaluate_condition(rest, values)?);
669 }
670 if let Some((left, right)) = value.split_once(" or ") {
671 return Ok(evaluate_condition(left, values)? || evaluate_condition(right, values)?);
672 }
673 if let Some((left, right)) = value.split_once(" and ") {
674 return Ok(evaluate_condition(left, values)? && evaluate_condition(right, values)?);
675 }
676 match lookup_template_value(value, values)? {
677 TemplateValue::Bool(value) => Ok(value),
678 TemplateValue::String(value) => Ok(!value.is_empty()),
679 }
680}
681
682fn evaluate_string(value: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
683 let value = value.trim();
684 if let Some(body) = value
685 .strip_prefix("f\"")
686 .and_then(|body| body.strip_suffix('"'))
687 {
688 return interpolate_fstring(body, values);
689 }
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 value.len() >= 2
697 && ((value.starts_with('"') && value.ends_with('"'))
698 || (value.starts_with('\'') && value.ends_with('\'')))
699 {
700 return Ok(value[1..value.len() - 1].to_string());
701 }
702 match lookup_template_value(value, values)? {
703 TemplateValue::String(value) => Ok(value),
704 TemplateValue::Bool(value) => Ok(value.to_string()),
705 }
706}
707
708fn interpolate_fstring(body: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
709 let mut result = String::new();
710 let mut remaining = body;
711 while let Some(start) = remaining.find('{') {
712 result.push_str(&remaining[..start]);
713 let end = remaining[start + 1..]
714 .find('}')
715 .ok_or_else(|| "unterminated interpolation".to_string())?
716 + start
717 + 1;
718 result.push_str(&evaluate_string(&remaining[start + 1..end], values)?);
719 remaining = &remaining[end + 1..];
720 }
721 result.push_str(remaining);
722 Ok(result)
723}
724
725fn lookup_template_value(
726 value: &str,
727 values: &[(&str, TemplateValue)],
728) -> Result<TemplateValue, String> {
729 let value = value.trim();
730 let (base, mut methods) = value
731 .split_once('.')
732 .map_or((value, ""), |(base, methods)| (base, methods));
733 let mut result = values
734 .iter()
735 .find(|(name, _)| *name == base)
736 .map(|(_, value)| value.clone())
737 .ok_or_else(|| format!("unsupported expression '{value}'"))?;
738 while !methods.is_empty() {
739 let (method, rest) = methods
740 .split_once('.')
741 .map_or((methods, ""), |(method, rest)| (method, rest));
742 if method == "upper()" {
743 result = TemplateValue::String(as_string(&result).to_uppercase());
744 } else if method == "lower()" {
745 result = TemplateValue::String(as_string(&result).to_lowercase());
746 } else if let Some(args) = method
747 .strip_prefix("replace(")
748 .and_then(|v| v.strip_suffix(')'))
749 {
750 let (from, to) = args
751 .split_once(',')
752 .ok_or_else(|| "replace expects two arguments".to_string())?;
753 let from = unquote_template_arg(from.trim())?;
754 let to = unquote_template_arg(to.trim())?;
755 result = TemplateValue::String(as_string(&result).replace(&from, &to));
756 } else {
757 return Err(format!("unsupported method '{method}'"));
758 }
759 methods = rest;
760 }
761 Ok(result)
762}
763
764fn as_string(value: &TemplateValue) -> String {
765 match value {
766 TemplateValue::String(value) => value.clone(),
767 TemplateValue::Bool(value) => value.to_string(),
768 }
769}
770
771fn unquote_template_arg(value: &str) -> Result<String, String> {
772 if value.len() >= 2
773 && ((value.starts_with('"') && value.ends_with('"'))
774 || (value.starts_with('\'') && value.ends_with('\'')))
775 {
776 Ok(value[1..value.len() - 1].to_string())
777 } else {
778 Err(format!("expected a quoted string argument, got '{value}'"))
779 }
780}
781
782fn lower_annotations(annotations: &[cst::Annotation]) -> Vec<HirAnnotation> {
783 annotations
784 .iter()
785 .map(|annotation| HirAnnotation {
786 name: annotation.name.clone(),
787 args: annotation
788 .args
789 .iter()
790 .map(|arg| HirAnnotationArg {
791 text: arg.text.clone(),
792 span: Some(arg.span.into()),
793 })
794 .collect(),
795 span: Some(annotation.span.into()),
796 })
797 .collect()
798}
799
800fn lower_settings(settings: &cst::Settings) -> HirSettings {
802 HirSettings {
803 span: Some(settings.span.into()),
804 children: settings.children.iter().map(lower_settings_node).collect(),
805 }
806}
807
808fn lower_settings_node(node: &cst::SettingsNode) -> HirSettingsNode {
809 match node {
810 cst::SettingsNode::Group {
811 name,
812 children,
813 span,
814 } => HirSettingsNode::Group {
815 name: name.clone(),
816 children: children.iter().map(lower_settings_node).collect(),
817 span: Some((*span).into()),
818 },
819 cst::SettingsNode::Number { name, value, span } => HirSettingsNode::Number {
820 name: name.clone(),
821 value: *value,
822 span: Some((*span).into()),
823 },
824 cst::SettingsNode::Bool { name, value, span } => HirSettingsNode::Bool {
825 name: name.clone(),
826 value: *value,
827 span: Some((*span).into()),
828 },
829 cst::SettingsNode::String { name, value, span } => HirSettingsNode::String {
830 name: name.clone(),
831 value: value.clone(),
832 span: Some((*span).into()),
833 },
834 cst::SettingsNode::Raw { name, value, span } => HirSettingsNode::Raw {
835 name: name.clone(),
836 value: value.clone(),
837 span: Some((*span).into()),
838 },
839 cst::SettingsNode::List {
840 name,
841 elements,
842 span,
843 } => HirSettingsNode::List {
844 name: name.clone(),
845 elements: elements
846 .iter()
847 .map(|element| crate::hir::types::SettingsListElement {
848 value: element.value.clone(),
849 span: Some(element.span.into()),
850 })
851 .collect(),
852 span: Some((*span).into()),
853 },
854 }
855}
856
857impl Lowerer {
858 fn error_at(&mut self, code: &str, message: String, span: Span) {
859 self.errors.push(OpyError::at(code, message, span));
860 }
861
862 fn error_at_candidates(
867 &mut self,
868 code: &str,
869 message: String,
870 span: Span,
871 rejected: &str,
872 pool: &[crate::matcher::MatchCandidate],
873 ) {
874 let ranked = crate::matcher::rank(rejected, pool);
875 self.error_at(code, crate::matcher::did_you_mean(message, &ranked), span);
876 }
877
878 fn error_at_closed_candidates(
882 &mut self,
883 code: &str,
884 message: String,
885 span: Span,
886 rejected: &str,
887 pool: &[crate::matcher::MatchCandidate],
888 ) {
889 let ranked = crate::matcher::rank_closed(rejected, pool);
890 self.error_at(code, crate::matcher::did_you_mean(message, &ranked), span);
891 }
892}
893
894fn keyword_spellings(param: &Param) -> Vec<String> {
896 let mut spellings = vec![param.name.clone()];
897 spellings.extend(param.alternate_names.iter().cloned());
898 spellings
899}
900
901fn arg_span(args: &[CallArg]) -> Span {
904 args.first().map(CallArg::span).unwrap_or_else(|| {
905 Span::new(
906 0,
907 crate::diag::Position::new(1, 1),
908 crate::diag::Position::new(1, 1),
909 )
910 })
911}
912
913fn context_player_expr(name: &str, span: Option<Span>) -> Option<HirExpr> {
918 match name {
919 "eventPlayer" => Some(HirExpr::EventPlayer {
920 span: span.map(Into::into),
921 }),
922 "localPlayer" | "attacker" | "victim" | "healer" | "healee" => Some(HirExpr::Call {
923 name: name.to_string(),
924 args: Vec::new(),
925 debug_source: None,
926 span: span.map(Into::into),
927 }),
928 "hostPlayer" => Some(HirExpr::HostPlayer {
929 span: span.map(Into::into),
930 }),
931 _ => None,
932 }
933}
934
935#[derive(Debug, Clone, Copy, PartialEq, Eq)]
940pub enum NameNamespace {
941 Global,
943 Player,
945 Subroutine,
947}
948
949pub fn is_reserved_name(name: &str, namespace: NameNamespace) -> bool {
960 match namespace {
961 NameNamespace::Player => matches!(name, "x" | "y" | "z"),
962 NameNamespace::Global => {
963 RESERVED_KEYWORDS.contains(&name) || RESERVED_GLOBAL_NAMES.contains(&name)
964 }
965 NameNamespace::Subroutine => {
966 RESERVED_KEYWORDS.contains(&name)
967 || Manifest::builtin().ok().is_some_and(|manifest| {
968 manifest
969 .resolve_function(name)
970 .is_some_and(|function| function.params.is_empty())
971 })
972 }
973 }
974}
975
976const RESERVED_KEYWORDS: &[&str] = &[
979 "and",
980 "bool",
981 "case",
982 "def",
983 "default",
984 "del",
985 "elif",
986 "else",
987 "enum",
988 "float",
989 "for",
990 "globalvar",
991 "goto",
992 "if",
993 "in",
994 "int",
995 "lambda",
996 "loc",
997 "macro",
998 "not",
999 "or",
1000 "playervar",
1001 "rule",
1002 "self",
1003 "settings",
1004 "signed",
1005 "subroutine",
1006 "switch",
1007 "unsigned",
1008 "while",
1009];
1010
1011const RESERVED_GLOBAL_NAMES: &[&str] = &[
1016 "AccelReeval",
1017 "Array",
1018 "AssistId",
1019 "AssistReeval",
1020 "AsyncBehavior",
1021 "BarrierLos",
1022 "Beam",
1023 "BoolLiteral",
1024 "Button",
1025 "ButtonLiteral",
1026 "ChaseRateReeval",
1027 "ChaseTimeReeval",
1028 "Clip",
1029 "Color",
1030 "ColorLiteral",
1031 "Comms",
1032 "CustomStringLiteral",
1033 "DamageModificationId",
1034 "DamageReeval",
1035 "Dict",
1036 "DictElem",
1037 "DictKey",
1038 "Direction",
1039 "DotId",
1040 "DynamicEffect",
1041 "Effect",
1042 "EffectReeval",
1043 "EntityId",
1044 "FacingReeval",
1045 "FloatLiteral",
1046 "Gamemode",
1047 "GamemodeLiteral",
1048 "GlobalVariable",
1049 "HealingModificationId",
1050 "HealingReeval",
1051 "Health",
1052 "HealthPoolId",
1053 "Hero",
1054 "HeroLiteral",
1055 "HeroStat",
1056 "HotId",
1057 "HudPosition",
1058 "HudReeval",
1059 "Icon",
1060 "IconReeval",
1061 "Impulse",
1062 "IntLiteral",
1063 "Invis",
1064 "Label",
1065 "Lambda",
1066 "LocalizedStringLiteral",
1067 "LosCheck",
1068 "Map",
1069 "MapLiteral",
1070 "ModifyHealth",
1071 "Object",
1072 "OutlineVisibility",
1073 "Player",
1074 "PlayerVariable",
1075 "Position",
1076 "ProgressHudReeval",
1077 "ProgressWorldTextReeval",
1078 "Projectile",
1079 "ProjectileEffectReeval",
1080 "Raycast",
1081 "Relativity",
1082 "SignedFloatLiteral",
1083 "SignedIntLiteral",
1084 "SpecVisibility",
1085 "StartRuleBehavior",
1086 "Stat",
1087 "Status",
1088 "String",
1089 "StringLiteral",
1090 "Subroutine",
1091 "Team",
1092 "TeamLiteral",
1093 "TextId",
1094 "Throttle",
1095 "ThrottleReeval",
1096 "Transform",
1097 "Type",
1098 "UnsignedFloatLiteral",
1099 "UnsignedIntLiteral",
1100 "Value",
1101 "Vector",
1102 "Velocity",
1103 "Wait",
1104 "WorldTextReeval",
1105 "__ChaseReeval__",
1106 "__Operation__",
1107 "__Operator__",
1108 "__Rounding__",
1109 "__global__",
1110 "attacker",
1111 "break",
1112 "continue",
1113 "eventAbility",
1114 "eventDamage",
1115 "eventDirection",
1116 "eventHealing",
1117 "eventPlayer",
1118 "eventWasCriticalHit",
1119 "eventWasEnvironment",
1120 "eventWasHealthPack",
1121 "false",
1122 "healee",
1123 "healer",
1124 "hostPlayer",
1125 "localPlayer",
1126 "null",
1127 "pass",
1128 "return",
1129 "ruleCondition",
1130 "true",
1131 "victim",
1132 "void",
1133];
1134
1135fn assignable_receiver(receiver: &Expr) -> bool {
1140 match receiver {
1141 Expr::Name { name, .. } => !matches!(
1142 name.as_str(),
1143 "eventPlayer" | "hostPlayer" | "attacker" | "victim"
1144 ),
1145 Expr::Array { .. } | Expr::Index { .. } | Expr::Member { .. } => true,
1146 _ => false,
1147 }
1148}
1149
1150fn expr_identifier(expr: &Expr) -> Option<&str> {
1151 match expr {
1152 Expr::Name { name, .. } => Some(name.as_str()),
1153 _ => None,
1154 }
1155}
1156
1157fn indexed_expr_depth(expr: &Expr) -> usize {
1158 match expr {
1159 Expr::Index { array, .. } => 1 + indexed_expr_depth(array),
1160 _ => 0,
1161 }
1162}
1163
1164impl From<Span> for HirSpan {
1165 fn from(span: Span) -> HirSpan {
1166 HirSpan {
1167 file: span.file,
1168 start: Position {
1169 line: span.start.line,
1170 col: span.start.col,
1171 },
1172 end: Position {
1173 line: span.end.line,
1174 col: span.end.col,
1175 },
1176 }
1177 }
1178}
1179
1180impl From<&Span> for HirSpan {
1181 fn from(span: &Span) -> HirSpan {
1182 (*span).into()
1183 }
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188 use super::*;
1189 use crate::hir::types::{Expr as HirExpr, RuleEntry as HirRuleEntry, Stmt as HirStmt};
1190 use crate::lexer::{LexInput, lex};
1191 use crate::parser::parse;
1192
1193 fn lower_ok(text: &str) -> HirProgram {
1194 let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
1195 let output = parse(&tokens);
1196 assert!(
1197 output.errors.is_empty(),
1198 "unexpected parse errors: {:?}",
1199 output.errors
1200 );
1201 let program = output.program.expect("parse produces a program");
1202 lower(&program, vec![], vec![]).expect("lowers without errors")
1203 }
1204
1205 fn rule_conditions_and_actions(hir: &HirProgram) -> (&Vec<HirExpr>, &Vec<HirStmt>) {
1206 let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
1207 panic!("expected a rule");
1208 };
1209 (&rule.conditions, &rule.actions)
1210 }
1211
1212 #[test]
1213 fn producer_emits_the_v2_ordered_switch_contract() {
1214 let hir = lower_ok(
1215 "globalvar value\nrule \"r\":\n @Event global\n switch value:\n default:\n value = 1\n case 2:\n value = 2\n",
1216 );
1217 assert_eq!(hir.protocol.name, "wright/opy-hir");
1218 assert_eq!(hir.protocol.version, "2.0.0");
1219 let value = serde_json::to_value(&hir).expect("HIR must serialize");
1220 let switch = &value["rules"][0]["actions"][0];
1221 assert!(switch.get("arms").is_some());
1222 assert!(switch.get("cases").is_none());
1223 assert!(switch.get("default").is_none());
1224 }
1225
1226 #[test]
1227 fn receiver_calls_lower_to_receiver_call_hir() {
1228 let hir = lower_ok(
1232 "globalvar target\nrule \"r\":\n @Event eachPlayer\n eventPlayer.setMoveSpeed(100)\n target.setMoveSpeed(50)\n",
1233 );
1234 let (_, actions) = rule_conditions_and_actions(&hir);
1235 assert_eq!(actions.len(), 2);
1236
1237 let HirStmt::Expr { expr, .. } = &actions[0] else {
1238 panic!("expected expression statement");
1239 };
1240 let HirExpr::ReceiverCall {
1241 receiver,
1242 name,
1243 args,
1244 ..
1245 } = expr.as_ref()
1246 else {
1247 panic!("expected receiver call, got {expr:?}");
1248 };
1249 assert_eq!(name, "setMoveSpeed");
1250 assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
1251 assert_eq!(args.len(), 1);
1252 assert!(matches!(&args[0], HirExpr::Number { .. }));
1253
1254 let HirStmt::Expr { expr, .. } = &actions[1] else {
1255 panic!("expected expression statement");
1256 };
1257 let HirExpr::ReceiverCall { receiver, name, .. } = expr.as_ref() else {
1258 panic!("expected receiver call, got {expr:?}");
1259 };
1260 assert_eq!(name, "setMoveSpeed");
1261 assert!(
1262 matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "target"),
1263 "globalvar receiver must resolve to a GlobalVar"
1264 );
1265 }
1266
1267 #[test]
1268 fn bare_variable_member_expression_preserves_receiver_and_member() {
1269 let hir = lower_ok(
1270 "globalvar A\nplayervar B\nrule \"receiver\":\n @Event eachPlayer\n A = B.C\n",
1271 );
1272 let HirStmt::Assign { value, .. } = &hir
1273 .rules
1274 .iter()
1275 .find_map(|entry| {
1276 let RuleEntry::Rule(rule) = entry else {
1277 return None;
1278 };
1279 rule.actions.first()
1280 })
1281 .expect("assignment")
1282 else {
1283 panic!("expected assignment");
1284 };
1285 let HirExpr::Member {
1286 receiver, member, ..
1287 } = value.as_ref()
1288 else {
1289 panic!("expected opaque member expression, got {value:?}");
1290 };
1291 assert_eq!(member, "C");
1292 assert!(matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "B"));
1293 }
1294
1295 #[test]
1296 fn context_player_components_lower_to_member_expressions() {
1297 for (receiver_name, event) in [
1301 ("eventPlayer", "eachPlayer"),
1302 ("hostPlayer", "eachPlayer"),
1303 ("localPlayer", "eachPlayer"),
1304 ("attacker", "playerDied"),
1305 ("victim", "playerDied"),
1306 ] {
1307 for axis in ["x", "y", "z"] {
1308 let hir = lower_ok(&format!(
1309 "globalvar g\nrule \"r\":\n @Event {event}\n g = {receiver_name}.{axis}\n"
1310 ));
1311 let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
1312 panic!("expected a rule");
1313 };
1314 let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1315 panic!("expected an assign statement");
1316 };
1317 let HirExpr::Member {
1318 receiver, member, ..
1319 } = value.as_ref()
1320 else {
1321 panic!("{receiver_name}.{axis}: expected member expression, got {value:?}");
1322 };
1323 assert_eq!(member, axis, "{receiver_name}.{axis}");
1324 match receiver_name {
1325 "eventPlayer" => {
1326 assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }))
1327 }
1328 "hostPlayer" => {
1329 assert!(matches!(receiver.as_ref(), HirExpr::HostPlayer { .. }))
1330 }
1331 _ => assert!(
1332 matches!(receiver.as_ref(), HirExpr::Call { name, .. } if name == receiver_name),
1333 "{receiver_name}.{axis}: receiver must keep its source identity"
1334 ),
1335 }
1336 }
1337 }
1338 }
1339
1340 #[test]
1341 fn context_player_non_component_members_stay_player_variables() {
1342 let value = lowered_value(
1346 "globalvar g\nplayervar pos\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.pos\n",
1347 );
1348 assert!(
1349 matches!(&value, HirExpr::PlayerVar { name, .. } if name == "pos"),
1350 "declared playervar member must stay a player-variable reference, got {value:?}"
1351 );
1352
1353 let value = lowered_value(
1354 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.X\n",
1355 );
1356 assert!(
1357 matches!(&value, HirExpr::PlayerVar { name, .. } if name == "X"),
1358 "default-var member must stay a player-variable reference, got {value:?}"
1359 );
1360
1361 let error = compile_error(&action_source("g = eventPlayer.pos"), 4);
1362 assert_eq!(error.code, "unknown-member");
1363 }
1364
1365 #[test]
1366 fn rule_prefix_template_is_global_and_subroutine_identity_is_preserved() {
1367 let text = "rule \"before\":\n pass\ndef source_name():\n @Name \"Friendly\"\n pass\nrule \"after\":\n pass\n";
1368 let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
1369 let output = parse(&tokens);
1370 assert!(
1371 output.errors.is_empty(),
1372 "unexpected parse errors: {:?}",
1373 output.errors
1374 );
1375 let program = output.program.expect("program");
1376 let preprocessing = PreprocessingState {
1377 rule_prefix_template: Some(crate::hir::types::DirectiveValue {
1378 value: "f\"[{$pathTitle.replace('_', ' ')}] {$rule}\" if $rule and not $isDelimiter else $rule".to_string(),
1379 span: None,
1380 }),
1381 ..PreprocessingState::default()
1382 };
1383 let hir = lower_with_preprocessing(
1384 &program,
1385 vec![SourceFile {
1386 id: 0,
1387 path: "main.opy".to_string(),
1388 }],
1389 vec![],
1390 &preprocessing,
1391 )
1392 .expect("lowers");
1393 let names: Vec<_> = hir
1394 .rules
1395 .iter()
1396 .map(|entry| match entry {
1397 HirRuleEntry::Rule(rule) => rule.name.clone(),
1398 HirRuleEntry::SubroutineDef { name, .. } => name.clone(),
1399 })
1400 .collect();
1401 assert_eq!(
1402 names,
1403 vec!["[Main] before", "[Main] Friendly", "[Main] after"]
1404 );
1405 let HirRuleEntry::SubroutineDef {
1406 name, source_name, ..
1407 } = &hir.rules[1]
1408 else {
1409 panic!("expected subroutine definition");
1410 };
1411 assert_eq!(name, "[Main] Friendly");
1412 assert_eq!(source_name, "source_name");
1413 }
1414
1415 #[test]
1416 fn receiver_call_values_lower_in_conditions() {
1417 let hir = lower_ok(
1421 "rule \"r\":\n @Event eachPlayer\n @Condition eventPlayer.isAlive()\n eventPlayer.teleport(eventPlayer.getPosition())\n",
1422 );
1423 let (conditions, actions) = rule_conditions_and_actions(&hir);
1424 assert_eq!(conditions.len(), 1);
1425 let HirExpr::ReceiverCall { name, args, .. } = &conditions[0] else {
1426 panic!("expected receiver call condition, got {:?}", conditions[0]);
1427 };
1428 assert_eq!(name, "isAlive");
1429 assert_eq!(args.len(), 0);
1430
1431 let HirStmt::Expr { expr, .. } = &actions[0] else {
1432 panic!("expected expression statement");
1433 };
1434 let HirExpr::ReceiverCall {
1435 name,
1436 args,
1437 receiver,
1438 ..
1439 } = expr.as_ref()
1440 else {
1441 panic!("expected receiver call, got {expr:?}");
1442 };
1443 assert_eq!(name, "teleport");
1444 assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
1445 assert_eq!(args.len(), 1);
1446 assert!(matches!(
1447 &args[0],
1448 HirExpr::ReceiverCall { name, .. } if name == "getPosition"
1449 ));
1450 }
1451
1452 #[test]
1453 fn format_string_receiver_stays_a_format_node() {
1454 let hir = lower_ok(
1457 "rule \"r\":\n @Event global\n print(\"{} points\".format(len([1, 2])))\n",
1458 );
1459 let (_, actions) = rule_conditions_and_actions(&hir);
1460 let HirStmt::Expr { expr, .. } = &actions[0] else {
1461 panic!("expected expression statement");
1462 };
1463 assert!(
1464 has_format(expr),
1465 "string `.format()` must lower to a Format node"
1466 );
1467 }
1468
1469 fn has_format(expr: &HirExpr) -> bool {
1470 match expr {
1471 HirExpr::Format { .. } => true,
1472 HirExpr::Call { args, .. } => args.iter().any(has_format),
1473 HirExpr::ReceiverCall { args, .. } => args.iter().any(has_format),
1474 _ => false,
1475 }
1476 }
1477
1478 fn lowered_value(source: &str) -> HirExpr {
1480 let program = crate::compile(source, "test.opy", std::path::Path::new(""))
1481 .unwrap_or_else(|error| panic!("compile failed: {error}"));
1482 let RuleEntry::Rule(rule) = &program.rules[0] else {
1483 panic!("expected a rule");
1484 };
1485 let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1486 panic!("expected an assign statement");
1487 };
1488 (**value).clone()
1489 }
1490
1491 #[test]
1492 fn chase_time_reeval_none_lowers_to_the_catalog_enum() {
1493 let value = lowered_value(
1494 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.NONE\n",
1495 );
1496 assert_enum(&value, "ChaseTimeReeval", "NONE");
1497 }
1498
1499 #[test]
1500 fn chase_time_reeval_destination_and_duration_lowers_to_the_catalog_enum() {
1501 let value = lowered_value(
1502 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.DESTINATION_AND_DURATION\n",
1503 );
1504 assert_enum(&value, "ChaseTimeReeval", "DESTINATION_AND_DURATION");
1505 }
1506
1507 #[test]
1508 fn chase_rate_reeval_members_lower_to_the_catalog_enum() {
1509 for member in ["NONE", "DESTINATION_AND_RATE"] {
1510 let source = format!(
1511 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseRateReeval.{member}\n"
1512 );
1513 assert_enum(&lowered_value(&source), "ChaseRateReeval", member);
1514 }
1515 }
1516
1517 fn assert_enum(value: &HirExpr, domain: &str, member: &str) {
1520 match value {
1521 HirExpr::Enum {
1522 value_type, value, ..
1523 } => {
1524 assert_eq!(value_type, domain);
1525 assert_eq!(value, member);
1526 }
1527 other => panic!("expected enum {domain}.{member}, got {other:?}"),
1528 }
1529 }
1530
1531 #[test]
1532 fn unknown_chase_time_reeval_member_is_rejected_by_the_catalog() {
1533 let error = crate::compile(
1534 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.NOPE\n",
1535 "test.opy",
1536 std::path::Path::new(""),
1537 )
1538 .expect_err("unknown catalog member must be rejected");
1539 assert_eq!(error.code, "unknown-enum-member");
1540 }
1541
1542 #[test]
1543 fn unknown_enum_receiver_is_an_unsupported_member_error() {
1544 let error = crate::compile(
1545 "globalvar g\nrule \"r\":\n @Event global\n g = NotARealEnum.MEMBER\n",
1546 "test.opy",
1547 std::path::Path::new(""),
1548 )
1549 .expect_err("an unknown enum type must fail");
1550 assert_eq!(error.code, "unsupported-member");
1551 let span = error.span.expect("the error is source-located");
1552 assert_eq!(span.start.line, 4);
1553 }
1554
1555 #[test]
1556 fn unknown_texture_member_is_rejected_by_the_source_surface() {
1557 let error = crate::compile(
1558 "globalvar g\nrule \"r\":\n @Event global\n g = Texture.NOT_A_TEXTURE\n",
1559 "test.opy",
1560 std::path::Path::new(""),
1561 )
1562 .expect_err("unknown texture member must be rejected");
1563 assert_eq!(error.code, "unknown-texture-member");
1564 }
1565
1566 fn compile_error(source: &str, line: u32) -> OpyError {
1570 let error = crate::compile(source, "test.opy", std::path::Path::new(""))
1571 .expect_err("expected a compile failure");
1572 let span = error.span.expect("the error is source-located");
1573 assert_eq!(span.start.line, line, "code '{}'", error.code);
1574 error
1575 }
1576
1577 fn action_source(statement: &str) -> String {
1578 format!("globalvar g\nrule \"r\":\n @Event global\n {statement}\n")
1579 }
1580
1581 #[test]
1584 fn unknown_action_names_the_nearest_spellings_in_message() {
1585 let error = compile_error(&action_source("createHudText(g, \"t\")"), 4);
1586 assert_eq!(error.code, "unknown-action");
1587 assert!(
1589 error.message.contains("(did you mean 'hudText'"),
1590 "message: {}",
1591 error.message
1592 );
1593 }
1594
1595 #[test]
1596 fn unknown_action_finds_member_action_names() {
1597 let error = compile_error(&action_source("startForcingPlayerToBeHero(g)"), 4);
1598 assert_eq!(error.code, "unknown-action");
1599 assert!(
1600 error.message.contains("(did you mean 'startForcingHero'"),
1601 "message: {}",
1602 error.message
1603 );
1604 }
1605
1606 #[test]
1607 fn unknown_member_names_the_nearest_member_spellings() {
1608 let error = compile_error(&action_source("eventPlayer.teleprt(g)"), 4);
1609 assert_eq!(error.code, "unknown-member");
1610 assert_eq!(
1611 error.message,
1612 "unknown member 'teleprt' (did you mean 'teleport'?)"
1613 );
1614 }
1615
1616 #[test]
1617 fn unknown_value_includes_the_by_name_special_calls() {
1618 let error = compile_error(&action_source("g = sroted([1])"), 4);
1619 assert_eq!(error.code, "unknown-value");
1620 assert!(
1621 error.message.contains("'sorted'"),
1622 "message: {}",
1623 error.message
1624 );
1625 }
1626
1627 #[test]
1628 fn unknown_enum_member_names_the_domain_surface() {
1629 let error = compile_error(&action_source("g = Hero.SOLDIER76"), 4);
1633 assert_eq!(error.code, "unknown-enum-member");
1634 assert!(
1635 error.message.contains("'SOLDIER'"),
1636 "message: {}",
1637 error.message
1638 );
1639 }
1640
1641 #[test]
1642 fn hud_reeval_string_and_color_alias_resolves_and_ranks() {
1643 let hir = crate::compile(
1647 &action_source("g = HudReeval.VISIBILITY_STRING_AND_COLOR"),
1648 "test.opy",
1649 std::path::Path::new(""),
1650 )
1651 .expect("the accepted alternate resolves");
1652 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1653 panic!("expected a rule");
1654 };
1655 let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1656 panic!("expected an assignment");
1657 };
1658 assert!(matches!(
1659 value.as_ref(),
1660 HirExpr::Enum { value_type, value, .. }
1661 if value_type == "HudReeval" && value == "VISIBLE_TO_STRING_AND_COLOR"
1662 ));
1663
1664 let error = compile_error(
1665 &action_source("g = HudReeval.VISIBILITY_STRING_AND_COLO"),
1666 4,
1667 );
1668 assert_eq!(error.code, "unknown-enum-member");
1669 assert!(
1670 error.message.starts_with(
1671 "enum 'HudReeval' has no member 'VISIBILITY_STRING_AND_COLO' \
1672 (did you mean 'VISIBILITY_STRING_AND_COLOR'"
1673 ),
1674 "message: {}",
1675 error.message
1676 );
1677 }
1678
1679 #[test]
1680 fn unknown_custom_enum_member_names_the_declared_members() {
1681 let error = compile_error(
1682 "globalvar g\nenum Dir:\n NORTH\n SOUTH\nrule \"r\":\n @Event global\n g = Dir.NROTH\n",
1683 7,
1684 );
1685 assert_eq!(error.code, "unknown-enum-member");
1686 assert_eq!(
1687 error.message,
1688 "enum 'Dir' has no member 'NROTH' (did you mean 'NORTH'?)"
1689 );
1690 }
1691
1692 #[test]
1693 fn chase_over_time_resolves_and_compiles_with_reference_signatures() {
1694 let hir = crate::compile(
1696 &action_source("chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE)"),
1697 "test.opy",
1698 std::path::Path::new(""),
1699 )
1700 .expect("reference-supported chaseOverTime compiles");
1701 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1702 panic!("expected a rule");
1703 };
1704 let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1705 panic!("expected expression statement");
1706 };
1707 let HirExpr::Call { name, args, .. } = expr.as_ref() else {
1708 panic!("expected a call, got {expr:?}");
1709 };
1710 assert_eq!(name, "chaseOverTime");
1711 assert_eq!(args.len(), 4);
1712 assert!(matches!(
1713 &args[3],
1714 HirExpr::Enum { value_type, value, .. }
1715 if value_type == "ChaseTimeReeval" && value == "NONE"
1716 ));
1717
1718 let hir = crate::compile(
1720 &action_source("chaseOverTime(g, 10, 3)"),
1721 "test.opy",
1722 std::path::Path::new(""),
1723 )
1724 .expect("default-reevaluation chaseOverTime compiles");
1725 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1726 panic!("expected a rule");
1727 };
1728 let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1729 panic!("expected expression statement");
1730 };
1731 let HirExpr::Call { args, .. } = expr.as_ref() else {
1732 panic!("expected a call");
1733 };
1734 assert_eq!(args.len(), 4);
1735 assert!(matches!(
1736 &args[3],
1737 HirExpr::Enum { value_type, value, .. }
1738 if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1739 ));
1740 }
1741
1742 #[test]
1743 fn is_game_in_progress_resolves_as_a_builtin_value() {
1744 let hir = crate::compile(
1746 &action_source("@Condition isGameInProgress() == true"),
1747 "test.opy",
1748 std::path::Path::new(""),
1749 )
1750 .expect("reference-supported isGameInProgress compiles");
1751 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1752 panic!("expected a rule");
1753 };
1754 assert!(matches!(&rule.conditions[0], HirExpr::Binary { .. }));
1755 }
1756
1757 #[test]
1758 fn enum_gated_members_resolve_through_the_manifest() {
1759 let source = "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
1763 @Condition eventPlayer.getThrottle() != vect(0, 0, 0)\n \
1764 @Condition worldVector(vect(1, 2, 3), eventPlayer, Transform.ROTATION) != vect(0, 0, 0)\n \
1765 eventPlayer.setInvisibility(Invis.ALL)\n \
1766 eventPlayer.setStatusEffect(eventPlayer, Status.ROOTED, 2)\n";
1767 let hir = crate::compile(source, "test.opy", std::path::Path::new(""))
1768 .expect("enum-gated members compile");
1769 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1770 panic!("expected a rule");
1771 };
1772 assert_eq!(rule.actions.len(), 2);
1773 }
1774
1775 #[test]
1776 fn get_players_in_radius_fills_reference_enum_defaults() {
1777 let hir = crate::compile(
1780 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
1781 @Condition len(getPlayersInRadius(eventPlayer.getPosition(), 10)) > 0\n \
1782 disableInspector()\n",
1783 "test.opy",
1784 std::path::Path::new(""),
1785 )
1786 .expect("getPlayersInRadius with defaults compiles");
1787 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1788 panic!("expected a rule");
1789 };
1790 let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
1791 panic!("expected a comparison");
1792 };
1793 let HirExpr::Call { name, args, .. } = left.as_ref() else {
1794 panic!("expected len call");
1795 };
1796 assert_eq!(name, "len");
1797 let HirExpr::Call { name, args, .. } = &args[0] else {
1798 panic!("expected getPlayersInRadius call");
1799 };
1800 assert_eq!(name, "getPlayersInRadius");
1801 assert_eq!(args.len(), 4);
1802 assert!(matches!(
1803 &args[2],
1804 HirExpr::Enum { value_type, value, .. }
1805 if value_type == "Team" && value == "ALL"
1806 ));
1807 assert!(matches!(
1808 &args[3],
1809 HirExpr::Enum { value_type, value, .. }
1810 if value_type == "LosCheck" && value == "OFF"
1811 ));
1812 }
1813
1814 #[test]
1815 fn value_call_in_action_position_is_rejected() {
1816 let error = compile_error(&action_source("isGameInProgress()"), 4);
1817 assert_eq!(error.code, "value-in-action-position");
1818 }
1819
1820 #[test]
1821 fn value_member_in_action_position_is_rejected() {
1822 let error = compile_error(
1825 "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.isAlive()\n",
1826 4,
1827 );
1828 assert_eq!(error.code, "value-in-action-position");
1829 }
1830
1831 fn first_action_expr(source: &str) -> HirExpr {
1836 let program = crate::compile(source, "test.opy", std::path::Path::new(""))
1837 .unwrap_or_else(|error| panic!("compile failed: {error}"));
1838 let RuleEntry::Rule(rule) = &program.rules[0] else {
1839 panic!("expected a rule");
1840 };
1841 match &rule.actions[0] {
1842 HirStmt::Expr { expr, .. } => (**expr).clone(),
1843 HirStmt::Assign { value, .. } => (**value).clone(),
1844 other => panic!("expected an expression or assignment, got {other:?}"),
1845 }
1846 }
1847
1848 fn strip_spans(value: &mut serde_json::Value) {
1851 match value {
1852 serde_json::Value::Object(map) => {
1853 map.remove("span");
1854 map.remove("name_span");
1855 for nested in map.values_mut() {
1856 strip_spans(nested);
1857 }
1858 }
1859 serde_json::Value::Array(items) => {
1860 for item in items {
1861 strip_spans(item);
1862 }
1863 }
1864 _ => {}
1865 }
1866 }
1867
1868 #[test]
1869 fn chase_keyword_forms_dispatch_to_the_concrete_chase_functions() {
1870 let expr = first_action_expr(&action_source("chase(g, 10, rate=2, ChaseReeval.NONE)"));
1875 let HirExpr::Call { name, args, .. } = &expr else {
1876 panic!("expected a call, got {expr:?}");
1877 };
1878 assert_eq!(name, "chaseAtRate");
1879 assert!(matches!(
1880 &args[3],
1881 HirExpr::Enum { value_type, value, .. }
1882 if value_type == "ChaseRateReeval" && value == "NONE"
1883 ));
1884
1885 let expr = first_action_expr(&action_source(
1886 "chase(g, 10, duration=3, ChaseReeval.DESTINATION_AND_DURATION)",
1887 ));
1888 let HirExpr::Call { name, args, .. } = &expr else {
1889 panic!("expected a call, got {expr:?}");
1890 };
1891 assert_eq!(name, "chaseOverTime");
1892 assert!(matches!(
1893 &args[3],
1894 HirExpr::Enum { value_type, value, .. }
1895 if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1896 ));
1897
1898 let expr = first_action_expr(
1901 "playervar P\nrule \"r\":\n @Event eachPlayer\n \
1902 chase(eventPlayer.P, 0, rate=1, ChaseReeval.NONE)\n",
1903 );
1904 let HirExpr::Call { name, args, .. } = &expr else {
1905 panic!("expected a call, got {expr:?}");
1906 };
1907 assert_eq!(name, "chaseAtRate");
1908 assert!(matches!(&args[0], HirExpr::PlayerVar { .. }));
1909 }
1910
1911 #[test]
1912 fn chase_reeval_is_only_a_standalone_identity_inside_the_chase_context() {
1913 let error = compile_error(&action_source("g = ChaseReeval.NONE"), 4);
1917 assert_eq!(error.code, "unsupported-member");
1918
1919 let expr = first_action_expr(&action_source(
1924 "chase(g, 10, rate=2, ChaseReeval.DESTINATION_AND_DURATION)",
1925 ));
1926 let HirExpr::Call { name, args, .. } = &expr else {
1927 panic!("expected a call, got {expr:?}");
1928 };
1929 assert_eq!(name, "chaseAtRate");
1930 assert!(matches!(
1931 &args[3],
1932 HirExpr::Enum { value_type, value, .. }
1933 if value_type == "ChaseRateReeval" && value == "DESTINATION_AND_DURATION"
1934 ));
1935
1936 let expr = first_action_expr(&action_source("chase(g, 10, rate=2, 5)"));
1939 let HirExpr::Call { name, args, .. } = &expr else {
1940 panic!("expected a call, got {expr:?}");
1941 };
1942 assert_eq!(name, "chase");
1943 assert!(matches!(&args[3], HirExpr::Number { .. }));
1944 }
1945
1946 #[test]
1947 fn chase_requires_the_keyword_rate_or_duration_third_argument() {
1948 let error = compile_error(&action_source("chase(g, 10, 2, ChaseReeval.NONE)"), 4);
1949 assert_eq!(error.code, "keyword-required");
1950 assert!(error.message.contains("rate"));
1951 }
1952
1953 #[test]
1954 fn chase_family_requires_a_variable_first_argument() {
1955 let error = compile_error(&action_source("chase(10, 10, rate=2, ChaseReeval.NONE)"), 4);
1960 assert_eq!(error.code, "invalid-argument");
1961
1962 let error = compile_error(
1963 &action_source("chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)"),
1964 4,
1965 );
1966 assert_eq!(error.code, "invalid-argument");
1967
1968 let error = compile_error(
1969 &action_source("chaseAtRate(10, 0, 2, ChaseRateReeval.NONE)"),
1970 4,
1971 );
1972 assert_eq!(error.code, "invalid-argument");
1973
1974 crate::compile(
1976 &action_source("chaseAtRate(g, 0, 2, ChaseRateReeval.NONE)"),
1977 "test.opy",
1978 std::path::Path::new(""),
1979 )
1980 .expect("a variable first argument compiles");
1981 }
1982
1983 #[test]
1984 fn keyword_binding_matches_positional_binding_in_hir() {
1985 fn without_spans(expr: &HirExpr) -> serde_json::Value {
1989 let mut value = serde_json::to_value(expr).unwrap();
1990 strip_spans(&mut value);
1991 value
1992 }
1993 let keyword = without_spans(&first_action_expr(&action_source(
1994 "chaseOverTime(g, 10, duration=3)",
1995 )));
1996 let positional = without_spans(&first_action_expr(&action_source(
1997 "chaseOverTime(g, 10, 3)",
1998 )));
1999 assert_eq!(keyword, positional);
2000
2001 let keyword = without_spans(&first_action_expr(&action_source("wait(time=1)")));
2002 let positional = without_spans(&first_action_expr(&action_source("wait(1)")));
2003 assert_eq!(keyword, positional);
2004
2005 let keyword = without_spans(&first_action_expr(&action_source(
2007 "wait(waitBehavior=Wait.IGNORE_CONDITION, time=2)",
2008 )));
2009 let positional = without_spans(&first_action_expr(&action_source("wait(2)")));
2010 assert_eq!(keyword, positional);
2011
2012 let keyword = without_spans(&first_action_expr(&action_source(
2013 "g = vect(x=1, y=2, z=3)",
2014 )));
2015 let positional = without_spans(&first_action_expr(&action_source("g = vect(1, 2, 3)")));
2016 assert_eq!(keyword, positional);
2017 }
2018
2019 #[test]
2020 fn keyword_binding_diagnostics_are_structured_and_source_located() {
2021 let error = compile_error(&action_source("chaseOverTime(g, 10, bogus=1)"), 4);
2023 assert_eq!(error.code, "unknown-keyword");
2024 assert!(error.message.contains("bogus"));
2025
2026 let error = compile_error(
2028 &action_source(
2029 "chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE, \
2030 reevaluation=ChaseTimeReeval.NONE)",
2031 ),
2032 4,
2033 );
2034 assert_eq!(error.code, "duplicate-argument");
2035
2036 let error = compile_error(&action_source("chaseOverTime(g, duration=3, 5)"), 4);
2038 assert_eq!(error.code, "positional-after-keyword");
2039
2040 let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
2042 assert_eq!(error.code, "missing-argument");
2043
2044 let error = compile_error(
2047 &action_source("chase(variable=g, destination=10, rate=2, ChaseReeval.NONE)"),
2048 4,
2049 );
2050 assert_eq!(error.code, "unknown-keyword");
2051 }
2052
2053 #[test]
2054 fn keyword_arguments_are_rejected_for_reference_special_cases() {
2055 let error = compile_error(
2058 "globalvar g\nrule \"r\":\n @Event global\n \
2059 for I in range(start=0, stop=3):\n debug(I)\n",
2060 4,
2061 );
2062 assert_eq!(error.code, "keyword-unsupported");
2063
2064 let error = compile_error(&action_source("g = random.uniform(min=1, max=2)"), 4);
2065 assert_eq!(error.code, "keyword-unsupported");
2066
2067 let error = compile_error(&action_source("print(\"{} points\".format(value=1))"), 4);
2068 assert_eq!(error.code, "keyword-unsupported");
2069 }
2070
2071 #[test]
2072 fn sorted_rejects_keyword_arguments_beyond_the_key_lambda() {
2073 let error = compile_error(&action_source("g = sorted([1, 2], bogus=lambda v: v)"), 4);
2077 assert_eq!(error.code, "unknown-keyword");
2078
2079 let error = compile_error(
2080 &action_source("g = sorted(array=[1, 2], key=lambda v: -v)"),
2081 4,
2082 );
2083 assert_eq!(error.code, "unknown-keyword");
2084
2085 let error = compile_error(&action_source("g = sorted(key=lambda v: v)"), 4);
2086 assert_eq!(error.code, "unknown-keyword");
2087 }
2088
2089 #[test]
2090 fn call_args_reject_parenthesized_keyword_names() {
2091 let error = compile_error(&action_source("g = vect((g)=1, y=2, z=3)"), 4);
2094 assert_eq!(error.code, "parse-error");
2095
2096 let error = compile_error(&action_source("g = sorted([1, 2], (key)=lambda v: -v)"), 4);
2097 assert_eq!(error.code, "parse-error");
2098
2099 crate::compile(
2102 &action_source("g = vect(x=1, y=2, z=3) + vect((g), 2, 3)"),
2103 "test.opy",
2104 std::path::Path::new(""),
2105 )
2106 .expect("bare keyword arguments compile");
2107 }
2108
2109 #[test]
2110 fn call_args_reject_trailing_comma() {
2111 for statement in [
2114 "wait(1,)",
2115 "g = vect(1, 2, 3,)",
2116 "g = sorted([1, 2],)",
2117 "g = sorted([1, 2], key=lambda v: -v,)",
2118 ] {
2119 let error = compile_error(&action_source(statement), 4);
2120 assert_eq!(error.code, "parse-error", "statement: {statement}");
2121 }
2122
2123 crate::compile(
2126 &action_source("g = [1, 2,]"),
2127 "test.opy",
2128 std::path::Path::new(""),
2129 )
2130 .expect("array literal trailing comma compiles");
2131 crate::compile(
2132 &action_source("g = {\"a\": 1,}[\"a\"]"),
2133 "test.opy",
2134 std::path::Path::new(""),
2135 )
2136 .expect("dict literal trailing comma compiles");
2137 crate::compile(
2138 "rule \"r\":\n @Event playerDied(victim,)\n wait(1)\n",
2139 "test.opy",
2140 std::path::Path::new(""),
2141 )
2142 .expect("@Event argument trailing comma compiles");
2143 }
2144
2145 #[test]
2146 fn binder_lambda_rejects_parenthesized() {
2147 for statement in [
2150 "g = sorted([1, 2], (lambda v: -v))",
2151 "g = sorted([1, 2], key=(lambda v: -v))",
2152 "g = [1, 2].map((lambda v: v))",
2153 "g = [1, 2].filter((lambda v: v))",
2154 "g = [1, 2].all((lambda v: v))",
2155 "g = [1, 2].any((lambda v: v))",
2156 "g = sorted([1, 2], ((lambda v: -v)))",
2157 ] {
2158 let error = compile_error(&action_source(statement), 4);
2159 assert_eq!(error.code, "lambda-context", "statement: {statement}");
2160 }
2161
2162 crate::compile(
2164 &action_source("g = sorted([1, 2], key=lambda v: -v)"),
2165 "test.opy",
2166 std::path::Path::new(""),
2167 )
2168 .expect("bare lambda key compiles");
2169 }
2170
2171 #[test]
2172 fn wait_uses_the_reference_keyword_names() {
2173 let error = compile_error(&action_source("wait(duration=1)"), 4);
2177 assert_eq!(error.code, "unknown-keyword");
2178 assert!(error.message.contains("duration"));
2179 }
2180
2181 #[test]
2182 fn action_call_in_value_position_is_rejected() {
2183 let error = compile_error(&action_source("g = wait(1)"), 4);
2184 assert_eq!(error.code, "action-in-value-position");
2185 }
2186
2187 #[test]
2188 fn missing_required_argument_is_a_source_located_diagnostic() {
2189 let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
2193 assert_eq!(error.code, "missing-argument");
2194 assert!(error.message.contains("duration"));
2195
2196 let error = compile_error(&action_source("chaseOverTime(g, 10, 3, 4, 5)"), 4);
2197 assert_eq!(error.code, "invalid-arity");
2198 }
2199
2200 #[test]
2201 fn missing_member_argument_is_a_source_located_diagnostic() {
2202 let error = compile_error(
2207 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
2208 getPlayersInRadius(eventPlayer.getPosition(), 10).setStatusEffect(eventPlayer, 30)\n",
2209 4,
2210 );
2211 assert_eq!(error.code, "missing-argument");
2212 assert!(error.message.contains("duration"));
2213 }
2214
2215 #[test]
2216 fn invalid_receiver_categories_are_rejected() {
2217 let error = compile_error(&action_source("3.append(1)"), 4);
2221 assert_eq!(error.code, "invalid-receiver");
2222 assert!(error.message.contains("append"));
2223
2224 let error = compile_error(&action_source("attacker.append(1)"), 4);
2225 assert_eq!(error.code, "invalid-receiver");
2226 assert!(error.message.contains("append"));
2227
2228 let error = compile_error(&action_source("3.remove(1)"), 4);
2229 assert_eq!(error.code, "invalid-receiver");
2230 assert!(error.message.contains("remove"));
2231
2232 let error = compile_error(&action_source("print(3.format(\"{}\"))"), 4);
2233 assert_eq!(error.code, "invalid-receiver");
2234 assert!(error.message.contains("format"));
2235
2236 crate::compile(
2238 &action_source("g.append(1)\n g.remove(1)"),
2239 "test.opy",
2240 std::path::Path::new(""),
2241 )
2242 .expect("assignable receivers compile");
2243 crate::compile(
2244 &action_source("g = \"{}\".format(1)"),
2245 "test.opy",
2246 std::path::Path::new(""),
2247 )
2248 .expect("a string-literal receiver compiles");
2249 }
2250
2251 fn lower_with_manifest(source: &str, manifest: &'static Manifest) -> OpyResult<HirProgram> {
2255 let tokens = lex(LexInput {
2256 file_id: 0,
2257 text: source,
2258 })
2259 .expect("lexes");
2260 let output = parse(&tokens);
2261 assert!(
2262 output.errors.is_empty(),
2263 "unexpected parse errors: {:?}",
2264 output.errors
2265 );
2266 let program = output.program.expect("parse produces a program");
2267 lower_program(
2268 &program,
2269 vec![],
2270 vec![],
2271 &PreprocessingState::default(),
2272 manifest,
2273 Catalog::builtin().expect("the builtin catalog loads"),
2274 )
2275 }
2276
2277 #[test]
2278 fn special_enforcement_is_typed_policy_not_manifest_metadata() {
2279 let mut file: serde_json::Value =
2283 serde_json::from_str(crate::manifest::MANIFEST_DATA).unwrap();
2284 for function in file["functions"].as_array_mut().unwrap() {
2285 match function["id"].as_str().unwrap() {
2286 "chase" | "chaseAtRate" | "chaseOverTime" => {
2288 for param in function["params"].as_array_mut().unwrap() {
2289 param.as_object_mut().unwrap().remove("variable");
2290 }
2291 }
2292 "append" | "remove" | "format" => {
2295 function["receiver"] = "Any".into();
2296 }
2297 "wait" => function["params"][0]["variable"] = true.into(),
2301 "getPosition" => function["receiver"] = "Variable".into(),
2302 _ => {}
2303 }
2304 }
2305 let manifest: &'static Manifest = Box::leak(Box::new(
2306 Manifest::load(
2307 &serde_json::to_string(&file).unwrap(),
2308 crate::manifest::PROBES_DATA,
2309 )
2310 .expect("mutated metadata still validates"),
2311 ));
2312
2313 for call in [
2315 "chase(10, 10, rate=2, ChaseReeval.NONE)",
2316 "chaseAtRate(10, 0, 2, ChaseRateReeval.NONE)",
2317 "chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)",
2318 ] {
2319 let error = lower_with_manifest(&action_source(call), manifest)
2320 .expect_err("the chase family still requires a variable");
2321 assert_eq!(error.code, "invalid-argument", "{call}");
2322 }
2323 lower_with_manifest(&action_source("chaseOverTime(g, 10, 3)"), manifest)
2325 .expect("a variable first argument compiles");
2326
2327 for call in ["3.append(1)", "3.remove(1)", "print(3.format(\"{}\"))"] {
2330 let error = lower_with_manifest(&action_source(call), manifest)
2331 .expect_err("the enforced receiver requirement still rejects");
2332 assert_eq!(error.code, "invalid-receiver", "{call}");
2333 }
2334 lower_with_manifest(&action_source("g.append(1)"), manifest)
2335 .expect("an assignable receiver compiles");
2336 lower_with_manifest(&action_source("g = \"{}\".format(1)"), manifest)
2337 .expect("a string-literal receiver compiles");
2338
2339 lower_with_manifest(&action_source("wait(1)"), manifest)
2342 .expect("`wait`'s first argument is not variable-required");
2343 lower_with_manifest(
2344 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
2345 g = eventPlayer.getPosition()\n",
2346 manifest,
2347 )
2348 .expect("`getPosition`'s receiver is not assignable-enforced");
2349 }
2350
2351 #[test]
2352 fn cross_domain_enum_arguments_resolve_as_opaque_identities() {
2353 let expr = first_action_expr(&action_source("chaseOverTime(g, 10, 3, Invis.ALL)"));
2357 let HirExpr::Call { args, .. } = &expr else {
2358 panic!("expected a call, got {expr:?}");
2359 };
2360 assert!(matches!(
2361 &args[3],
2362 HirExpr::Enum { value_type, value, .. }
2363 if value_type == "Invis" && value == "ALL"
2364 ));
2365
2366 let expr = first_action_expr(&action_source(
2367 "eventPlayer.setInvisibility(ChaseTimeReeval.NONE)",
2368 ));
2369 let HirExpr::ReceiverCall { args, .. } = &expr else {
2370 panic!("expected a receiver call, got {expr:?}");
2371 };
2372 assert!(matches!(
2373 &args[0],
2374 HirExpr::Enum { value_type, value, .. }
2375 if value_type == "ChaseTimeReeval" && value == "NONE"
2376 ));
2377 }
2378
2379 #[test]
2380 fn non_enum_arguments_for_enum_parameters_are_carried_structurally() {
2381 let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(g)"));
2385 let HirExpr::ReceiverCall { args, .. } = &expr else {
2386 panic!("expected a receiver call, got {expr:?}");
2387 };
2388 assert!(matches!(
2389 &args[0],
2390 HirExpr::GlobalVar { name, .. } if name == "g"
2391 ));
2392
2393 let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(3)"));
2394 let HirExpr::ReceiverCall { args, .. } = &expr else {
2395 panic!("expected a receiver call, got {expr:?}");
2396 };
2397 assert!(matches!(&args[0], HirExpr::Number { .. }));
2398 }
2399
2400 #[test]
2401 fn unknown_builtins_fail_at_resolution_not_emission() {
2402 let error = compile_error(&action_source("frobnicate()"), 4);
2403 assert_eq!(error.code, "unknown-action");
2404
2405 let error = compile_error(&action_source("g = frobnicate()"), 4);
2406 assert_eq!(error.code, "unknown-value");
2407
2408 let error = compile_error(
2409 "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.frobnicate()\n",
2410 4,
2411 );
2412 assert_eq!(error.code, "unknown-member");
2413 }
2414
2415 #[test]
2416 fn wright_only_catalog_names_are_rejected() {
2417 let error = compile_error(&action_source("createHudText(1)"), 4);
2421 assert_eq!(error.code, "unknown-action");
2422
2423 let error = compile_error(&action_source("g = squareRoot(9)"), 4);
2424 assert_eq!(error.code, "unknown-value");
2425 }
2426
2427 #[test]
2428 fn canonical_ids_without_upstream_spellings_are_rejected() {
2429 for (statement, code) in [
2433 ("g = evaluateOnce(1)", "unknown-value"),
2434 ("g = lastCreatedEntity()", "unknown-value"),
2435 ("g = lastTextId()", "unknown-value"),
2436 ("g = allTankHeroes()", "unknown-value"),
2437 ("g = allDamageHeroes()", "unknown-value"),
2438 ("g = allSupportHeroes()", "unknown-value"),
2439 ("destroyAllHudText()", "unknown-action"),
2440 ] {
2441 let error = compile_error(&action_source(statement), 4);
2442 assert_eq!(error.code, code, "{statement}");
2443 }
2444
2445 let member = "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n";
2446 let error = compile_error(member, 4);
2447 assert_eq!(error.code, "unknown-member");
2448
2449 for axis in ["x", "y", "z"] {
2451 let source = format!(
2452 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.{axis}()\n"
2453 );
2454 let error = compile_error(&source, 4);
2455 assert_eq!(error.code, "unknown-member", "{axis}()");
2456 }
2457
2458 crate::compile(
2460 "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",
2461 "test.opy",
2462 std::path::Path::new(""),
2463 )
2464 .expect("the upstream spellings compile");
2465 }
2466
2467 #[test]
2468 fn generic_member_only_actions_are_rejected() {
2469 let error = compile_error(&action_source("setMoveSpeed(eventPlayer, 100)"), 4);
2472 assert_eq!(error.code, "unknown-action");
2473 }
2474
2475 #[test]
2476 fn range_is_for_iterables_only() {
2477 let error = compile_error(&action_source("@Condition len(range(1, 5, 1)) > 0"), 4);
2480 assert_eq!(error.code, "invalid-call-context");
2481
2482 let error = compile_error(&action_source("for g in [1, 2]:\n debug(g)"), 4);
2483 assert_eq!(error.code, "invalid-iterable");
2484
2485 crate::compile(
2486 &action_source("for g in range(3):\n debug(g)"),
2487 "test.opy",
2488 std::path::Path::new(""),
2489 )
2490 .expect("the for-header range form compiles");
2491 }
2492
2493 #[test]
2494 fn source_aliases_resolve_to_canonical_names() {
2495 let hir = crate::compile(
2498 &action_source("stopChasingVariable(g)"),
2499 "test.opy",
2500 std::path::Path::new(""),
2501 )
2502 .expect("the alias target compiles");
2503 let RuleEntry::Rule(rule) = &hir.rules[0] else {
2504 panic!("expected a rule");
2505 };
2506 let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
2507 panic!("expected expression statement");
2508 };
2509 let HirExpr::Call { name, .. } = expr.as_ref() else {
2510 panic!("expected a call");
2511 };
2512 assert_eq!(name, "stopChasingVariable");
2513
2514 let hir = crate::compile(
2515 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
2516 @Condition eventPlayer.getCurrentHero() != null\n \
2517 @Condition eventPlayer.hasStatusEffect(Status.BURNING) == false\n \
2518 disableInspector()\n",
2519 "test.opy",
2520 std::path::Path::new(""),
2521 )
2522 .expect("member aliases compile");
2523 let RuleEntry::Rule(rule) = &hir.rules[0] else {
2524 panic!("expected a rule");
2525 };
2526 let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
2527 panic!("expected a comparison");
2528 };
2529 let HirExpr::ReceiverCall { name, .. } = left.as_ref() else {
2530 panic!("expected a receiver call");
2531 };
2532 assert_eq!(name, "getHero");
2533 }
2534
2535 #[test]
2536 fn unknown_catalog_enum_members_are_rejected() {
2537 for source in [
2538 "globalvar g\nrule \"r\":\n @Event global\n g = Color.CYAN\n",
2539 "globalvar g\nrule \"r\":\n @Event global\n g = DynamicEffect.SPARKLES\n",
2540 ] {
2541 let error = crate::compile(source, "test.opy", std::path::Path::new(""))
2542 .expect_err("unknown catalog member must be rejected");
2543 assert_eq!(error.code, "unknown-enum-member");
2544 }
2545 }
2546
2547 #[test]
2548 fn default_var_for_binder_resolves_at_all_range_arities() {
2549 for (binder, iterable) in [
2554 ("I", "range(0, 10)"),
2555 ("I", "range(3)"),
2556 ("I", "range(1, 5, 2)"),
2557 ] {
2558 let hir = lower_ok(&format!(
2559 "globalvar total\nrule \"r\":\n @Event global\n for {binder} in {iterable}:\n total += {binder}\n"
2560 ));
2561 let (_, actions) = rule_conditions_and_actions(&hir);
2562 let HirStmt::For { variable, body, .. } = &actions[0] else {
2563 panic!("expected a for statement");
2564 };
2565 assert!(
2566 matches!(variable.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2567 "the binder resolves to the implicit global 'I', got {variable:?}"
2568 );
2569 assert!(!body.is_empty(), "the loop body lowers");
2570 let HirStmt::Assign { value, .. } = &body[0] else {
2573 panic!("expected an assignment in the body");
2574 };
2575 let HirExpr::Binary { right, .. } = value.as_ref() else {
2576 panic!("expected a binary expression");
2577 };
2578 assert!(
2579 matches!(right.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2580 "the binder use inside the body resolves to the implicit global"
2581 );
2582 }
2583 }
2584
2585 #[test]
2586 fn player_variable_range_binder_preserves_host_player_receiver() {
2587 let hir = lower_ok(
2588 "playervar I\nrule \"r\":\n @Event global\n for hostPlayer.I in range(3):\n hostPlayer.I = 1\n",
2589 );
2590 let (_, actions) = rule_conditions_and_actions(&hir);
2591 let HirStmt::For { variable, .. } = &actions[0] else {
2592 panic!("expected a for statement");
2593 };
2594 let HirExpr::PlayerVar {
2595 player,
2596 name,
2597 member_span,
2598 span,
2599 } = variable.as_ref()
2600 else {
2601 panic!("expected a player-variable binder, got {variable:?}");
2602 };
2603 assert_eq!(name, "I");
2604 assert!(matches!(player.as_ref(), HirExpr::HostPlayer { .. }));
2605 assert_eq!(span.unwrap().start.line, 4);
2606 assert_eq!(span.unwrap().start.col, 9);
2607 assert_eq!(span.unwrap().end.line, 4);
2608 assert_eq!(span.unwrap().end.col, 21);
2609 let member_span = member_span.expect("player binder member span");
2610 assert_eq!(member_span.start.line, 4);
2611 assert_eq!(member_span.start.col, 20);
2612 assert_eq!(member_span.end.line, 4);
2613 assert_eq!(member_span.end.col, 21);
2614 }
2615
2616 #[test]
2617 fn default_var_names_resolve_as_implicit_globals() {
2618 let hir = lower_ok("rule \"r\":\n @Event global\n I = 5\n debug(I)\n");
2621 let (_, actions) = rule_conditions_and_actions(&hir);
2622 let HirStmt::Assign { target, .. } = &actions[0] else {
2623 panic!("expected an assignment");
2624 };
2625 assert!(
2626 matches!(target.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2627 "the implicit global resolves, got {target:?}"
2628 );
2629 assert_eq!(default_var_index("I"), Some(8));
2631 assert_eq!(default_var_index("AA"), Some(26));
2632 assert_eq!(default_var_index("Z"), Some(25));
2633 assert_eq!(default_var_index("DX"), Some(127));
2634 assert_eq!(default_var_index("DY"), None);
2635 assert_eq!(default_var_index("i"), None);
2636 }
2637
2638 #[test]
2639 fn nested_same_name_for_binders_reuse_the_implicit_global() {
2640 let hir = lower_ok(
2644 "rule \"r\":\n @Event global\n for I in range(3):\n for I in range(2):\n debug(I)\n",
2645 );
2646 let (_, actions) = rule_conditions_and_actions(&hir);
2647 let HirStmt::For {
2648 variable: outer,
2649 body,
2650 ..
2651 } = &actions[0]
2652 else {
2653 panic!("expected an outer for statement");
2654 };
2655 let HirStmt::For {
2656 variable: inner, ..
2657 } = &body[0]
2658 else {
2659 panic!("expected an inner for statement");
2660 };
2661 assert!(
2662 matches!(outer.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I")
2663 && matches!(inner.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2664 "both loops bind the same implicit global (spans differ per binder site)"
2665 );
2666 }
2667
2668 #[test]
2669 fn undeclared_lowercase_binder_is_still_an_unknown_identifier() {
2670 let error = compile_error(
2675 "rule \"r\":\n @Event global\n for i in range(3):\n debug(i)\n",
2676 3,
2677 );
2678 assert_eq!(error.code, "unknown-identifier");
2679 let span = error.span.expect("the error is source-located");
2680 assert_eq!(span.start.line, 3);
2681 }
2682
2683 #[test]
2684 fn syntax_constructs_lower_to_provenance_preserving_hir() {
2685 let hir = lower_ok(
2686 "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",
2687 );
2688 let (_, actions) = rule_conditions_and_actions(&hir);
2689 let HirStmt::DoWhile { condition, .. } = &actions[0] else {
2690 panic!("expected do-while");
2691 };
2692 assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in"));
2693 let HirStmt::Switch { arms, .. } = &actions[1] else {
2694 panic!("expected switch");
2695 };
2696 assert_eq!(arms.len(), 2);
2697 let HirSwitchArm::Case {
2698 value: case_value,
2699 body,
2700 ..
2701 } = &arms[0]
2702 else {
2703 panic!("expected case arm");
2704 };
2705 assert!(
2706 matches!(case_value.as_ref(), HirExpr::Number { value, .. } if *value == 0x10 as f64)
2707 );
2708 let HirStmt::Assign { value, .. } = &body[0] else {
2709 panic!("expected case assignment");
2710 };
2711 assert!(matches!(value.as_ref(), HirExpr::Binary { op, .. } if op == "in"));
2712 let HirSwitchArm::Default { body, .. } = &arms[1] else {
2713 panic!("expected default arm");
2714 };
2715 assert!(matches!(body[0], HirStmt::Assign { .. }));
2716 let HirStmt::Assign { value, .. } = &actions[2] else {
2717 panic!("expected comprehension assignment");
2718 };
2719 assert!(matches!(value.as_ref(), HirExpr::Comprehension { .. }));
2720 let HirStmt::Assign { value, .. } = &actions[3] else {
2721 panic!("expected sorted assignment");
2722 };
2723 assert!(
2724 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")))
2725 );
2726 let HirStmt::Assign { value, .. } = &actions[4] else {
2727 panic!("expected string assignment");
2728 };
2729 assert!(
2730 matches!(value.as_ref(), HirExpr::StringModifier { modifier, .. } if modifier == "w")
2731 );
2732 }
2733
2734 #[test]
2735 fn syntax_rejects_reference_invalid_bare_dict_and_lambda() {
2736 let dict_error = compile_error(
2737 "globalvar x\nrule \"r\":\n @Event global\n x = {\"x\": 1}\n",
2738 4,
2739 );
2740 assert_eq!(dict_error.code, "dict-access");
2741 let lambda_error = compile_error(
2742 "globalvar x\nrule \"r\":\n @Event global\n x = lambda value: value\n",
2743 4,
2744 );
2745 assert_eq!(lambda_error.code, "lambda-context");
2746 }
2747
2748 #[test]
2749 fn do_while_requires_rule_or_definition_prefix_position() {
2750 let error = compile_error(
2751 "globalvar value\nrule \"r\":\n @Event global\n value = 1\n do:\n value += 1\n while value < 2\n",
2752 5,
2753 );
2754 assert_eq!(error.code, "do-while-placement");
2755 assert_eq!(
2756 error.message,
2757 "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it"
2758 );
2759 }
2760}