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