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, ReceiverCategory};
41use workshop_rs::catalog::{Catalog, Locale};
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 errors: Vec<OpyError>,
81}
82
83mod declarations;
84mod expressions;
85pub(crate) mod policy;
86mod special_forms;
87mod statements;
88
89pub fn lower(
91 program: &cst::Program,
92 files: Vec<SourceFile>,
93 defines: Vec<Define>,
94) -> OpyResult<HirProgram> {
95 lower_with_preprocessing(program, files, defines, &PreprocessingState::default())
96}
97
98pub fn lower_with_preprocessing(
99 program: &cst::Program,
100 files: Vec<SourceFile>,
101 defines: Vec<Define>,
102 preprocessing: &PreprocessingState,
103) -> OpyResult<HirProgram> {
104 let manifest = match Manifest::builtin() {
105 Ok(manifest) => manifest,
106 Err(error) => {
107 return Err(OpyError::new(
108 "manifest-error",
109 format!("cannot load the OPY semantic compatibility manifest: {error}"),
110 ));
111 }
112 };
113 let catalog = match Catalog::builtin() {
114 Ok(catalog) => catalog,
115 Err(error) => {
116 return Err(OpyError::new(
117 "catalog-error",
118 format!("cannot load the Workshop catalog: {error}"),
119 ));
120 }
121 };
122 let mut lowerer = Lowerer {
123 global_declarations: HashMap::new(),
124 player_declarations: HashMap::new(),
125 subroutine_declarations: HashMap::new(),
126 subroutine_definitions: Vec::new(),
127 constant_declarations: HashMap::new(),
128 macro_declarations: HashMap::new(),
129 enums: HashMap::new(),
130 enum_declarations: HashMap::new(),
131 locals: Vec::new(),
132 current_order: 0,
133 allow_dict_literal: false,
134 manifest,
135 catalog,
136 errors: Vec::new(),
137 };
138 lowerer.collect_symbols(program);
139
140 let mut declarations = Vec::new();
141 let mut rules = Vec::new();
142 let mut implicit_subroutines = HashSet::new();
143 for (order, item) in program.top_level.iter().enumerate() {
144 lowerer.current_order = order;
145 match item {
146 TopLevel::Declaration(decl) => {
147 if let Some(declaration) = lowerer.lower_declaration(decl) {
148 declarations.push(declaration);
149 }
150 }
151 TopLevel::Rule(CstRuleEntry::Rule(rule)) => rules.push(RuleEntry::Rule(
152 lowerer.lower_rule(rule, files.as_slice(), preprocessing)?,
153 )),
154 TopLevel::Rule(CstRuleEntry::SubroutineDef {
155 name,
156 presentation_name,
157 span,
158 name_span,
159 body,
160 annotations,
161 rule_prefix,
162 }) => {
163 if !lowerer.subroutine_declarations.contains_key(name)
164 && implicit_subroutines.insert(name.clone())
165 {
166 declarations.push(Declaration::Subroutine {
167 name: name.clone(),
168 index: None,
169 span: Some(span.into()),
170 name_span: Some(name_span.into()),
171 });
172 }
173 let base_name = presentation_name
174 .as_deref()
175 .map(str::to_string)
176 .unwrap_or_else(|| name.clone());
177 let generated_name = render_rule_name(
178 &base_name,
179 rule_prefix.as_deref(),
180 false,
181 *span,
182 files.as_slice(),
183 preprocessing,
184 )?;
185 rules.push(RuleEntry::SubroutineDef {
186 kind: "subroutineDef".to_string(),
187 name: generated_name,
188 source_name: name.clone(),
189 span: Some(span.into()),
190 name_span: Some(name_span.into()),
191 body: lowerer.lower_block(body, &[], false, true, false),
192 annotations: lower_annotations(annotations),
193 });
194 }
195 }
196 }
197
198 if !lowerer.errors.is_empty() {
199 return Err(lowerer.errors.swap_remove(0));
200 }
201
202 Ok(HirProgram {
203 protocol: Protocol {
204 name: PROTOCOL_NAME.to_string(),
205 version: PROTOCOL_VERSION.to_string(),
206 },
207 generator: Generator {
208 name: crate::LANGUAGE_NAME.to_string(),
209 version: crate::LANGUAGE_VERSION.to_string(),
210 frontend: crate::LANGUAGE_NAME.to_string(),
211 },
212 files,
213 defines,
214 declarations,
215 rules,
216 settings: program.settings.as_ref().map(lower_settings),
217 preprocessing: preprocessing.clone(),
218 })
219}
220
221pub(crate) fn lower_settings_expression(
224 program: &cst::Program,
225 text: &str,
226 file: u32,
227 origin: crate::diag::Position,
228) -> OpyResult<HirExpr> {
229 let expression = crate::parser::parse_expression_fragment(text, file, origin)?;
230 let manifest = Manifest::builtin().map_err(|error| {
231 OpyError::new(
232 "manifest-error",
233 format!("cannot load the OPY semantic compatibility manifest: {error}"),
234 )
235 })?;
236 let catalog = Catalog::builtin().map_err(|error| {
237 OpyError::new(
238 "catalog-error",
239 format!("cannot load the Workshop catalog: {error}"),
240 )
241 })?;
242 let mut lowerer = Lowerer {
243 global_declarations: HashMap::new(),
244 player_declarations: HashMap::new(),
245 subroutine_declarations: HashMap::new(),
246 subroutine_definitions: Vec::new(),
247 constant_declarations: HashMap::new(),
248 macro_declarations: HashMap::new(),
249 enums: HashMap::new(),
250 enum_declarations: HashMap::new(),
251 locals: Vec::new(),
252 current_order: program.top_level.len(),
253 allow_dict_literal: true,
254 manifest,
255 catalog,
256 errors: Vec::new(),
257 };
258 lowerer.collect_symbols(program);
259 let lowered = lowerer.lower_expr(&expression, &[], CallPosition::Value);
260 lowerer.errors.into_iter().next().map_or(Ok(lowered), Err)
261}
262
263fn prefixed_rule_name(name: &str, prefix: Option<&str>, delimiter: bool) -> String {
264 match prefix {
265 Some(prefix) if !prefix.is_empty() && !delimiter && !name.is_empty() => {
266 format!("[{prefix}] {name}")
267 }
268 _ => name.to_string(),
269 }
270}
271
272#[derive(Clone, Debug)]
273enum TemplateValue {
274 String(String),
275 Bool(bool),
276}
277
278fn render_rule_name(
279 name: &str,
280 prefix: Option<&str>,
281 delimiter: bool,
282 span: Span,
283 files: &[SourceFile],
284 preprocessing: &PreprocessingState,
285) -> OpyResult<String> {
286 let Some(template) = preprocessing
287 .rule_prefix_template
288 .as_ref()
289 .map(|value| value.value.as_str())
290 else {
291 return Ok(prefixed_rule_name(name, prefix, delimiter));
292 };
293 let (file, path) = rule_file_parts(span.file, files);
294 let prefix = prefix.unwrap_or_default();
295 let values = [
296 ("$rule", TemplateValue::String(name.to_string())),
297 ("$prefix", TemplateValue::String(prefix.to_string())),
298 ("$file", TemplateValue::String(file.clone())),
299 ("$path", TemplateValue::String(path.clone())),
300 ("$isDelimiter", TemplateValue::Bool(delimiter)),
301 ("$prefixTitle", TemplateValue::String(title_case(prefix))),
302 ("$prefixUpper", TemplateValue::String(prefix.to_uppercase())),
303 ("$prefixLower", TemplateValue::String(prefix.to_lowercase())),
304 ("$fileTitle", TemplateValue::String(title_case(&file))),
305 ("$fileUpper", TemplateValue::String(file.to_uppercase())),
306 ("$fileLower", TemplateValue::String(file.to_lowercase())),
307 ("$pathTitle", TemplateValue::String(title_case(&path))),
308 ("$pathUpper", TemplateValue::String(path.to_uppercase())),
309 ("$pathLower", TemplateValue::String(path.to_lowercase())),
310 ];
311 evaluate_template(template, &values).map_err(|message| {
312 OpyError::at(
313 "rule-prefix-template-invalid",
314 format!("could not resolve rule prefix template: {message}"),
315 span,
316 )
317 })
318}
319
320fn rule_file_parts(file_id: u32, files: &[SourceFile]) -> (String, String) {
321 let path = files
322 .iter()
323 .find(|file| file.id == file_id)
324 .map(|file| file.path.replace('\\', "/"))
325 .unwrap_or_default();
326 let without_extension = path
327 .strip_suffix(".opy")
328 .or_else(|| path.strip_suffix(".OPY"))
329 .unwrap_or(&path)
330 .to_string();
331 let file = without_extension
332 .rsplit('/')
333 .next()
334 .unwrap_or_default()
335 .to_string();
336 (file, without_extension)
337}
338
339fn title_case(value: &str) -> String {
340 let mut result = String::with_capacity(value.len());
341 let mut capitalize = true;
342 for ch in value.chars() {
343 if ch == '_' {
344 result.push(' ');
345 capitalize = true;
346 } else if capitalize && ch.is_ascii_alphabetic() {
347 result.push(ch.to_ascii_uppercase());
348 capitalize = false;
349 } else {
350 result.push(ch);
351 if !ch.is_whitespace() && ch != '/' {
352 capitalize = false;
353 }
354 }
355 if ch == '/' || ch.is_whitespace() {
356 capitalize = true;
357 }
358 }
359 result
360}
361
362fn evaluate_template(template: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
363 if let Some((then_value, condition, else_value)) = split_conditional(template) {
364 let branch = if evaluate_condition(condition, values)? {
365 then_value
366 } else {
367 else_value
368 };
369 return evaluate_string(branch, values);
370 }
371 evaluate_string(template, values)
372}
373
374fn split_conditional(value: &str) -> Option<(&str, &str, &str)> {
375 let mut quote = None;
376 let mut depth = 0usize;
377 let mut if_start = None;
378 let mut else_start = None;
379 for (index, ch) in value.char_indices() {
380 match (ch, quote) {
381 ('"' | '\'', None) => quote = Some(ch),
382 (ch, Some(current)) if ch == current => quote = None,
383 ('{', None) => depth += 1,
384 ('}', None) => depth = depth.saturating_sub(1),
385 _ => {}
386 }
387 if quote.is_none() && depth == 0 {
388 if value[index..].starts_with(" if ") && if_start.is_none() {
389 if_start = Some(index);
390 } else if value[index..].starts_with(" else ") && else_start.is_none() {
391 else_start = Some(index);
392 }
393 }
394 }
395 let (Some(if_start), Some(else_start)) = (if_start, else_start) else {
396 return None;
397 };
398 Some((
399 value[..if_start].trim(),
400 value[if_start + 4..else_start].trim(),
401 value[else_start + 6..].trim(),
402 ))
403}
404
405fn evaluate_condition(value: &str, values: &[(&str, TemplateValue)]) -> Result<bool, String> {
406 let value = value.trim();
407 if let Some(rest) = value.strip_prefix("not ") {
408 return Ok(!evaluate_condition(rest, values)?);
409 }
410 if let Some((left, right)) = value.split_once(" or ") {
411 return Ok(evaluate_condition(left, values)? || evaluate_condition(right, values)?);
412 }
413 if let Some((left, right)) = value.split_once(" and ") {
414 return Ok(evaluate_condition(left, values)? && evaluate_condition(right, values)?);
415 }
416 match lookup_template_value(value, values)? {
417 TemplateValue::Bool(value) => Ok(value),
418 TemplateValue::String(value) => Ok(!value.is_empty()),
419 }
420}
421
422fn evaluate_string(value: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
423 let value = value.trim();
424 if let Some(body) = value
425 .strip_prefix("f\"")
426 .and_then(|body| body.strip_suffix('"'))
427 {
428 return interpolate_fstring(body, values);
429 }
430 if let Some(body) = value
431 .strip_prefix("f'")
432 .and_then(|body| body.strip_suffix('\''))
433 {
434 return interpolate_fstring(body, values);
435 }
436 if value.len() >= 2
437 && ((value.starts_with('"') && value.ends_with('"'))
438 || (value.starts_with('\'') && value.ends_with('\'')))
439 {
440 return Ok(value[1..value.len() - 1].to_string());
441 }
442 match lookup_template_value(value, values)? {
443 TemplateValue::String(value) => Ok(value),
444 TemplateValue::Bool(value) => Ok(value.to_string()),
445 }
446}
447
448fn interpolate_fstring(body: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
449 let mut result = String::new();
450 let mut remaining = body;
451 while let Some(start) = remaining.find('{') {
452 result.push_str(&remaining[..start]);
453 let end = remaining[start + 1..]
454 .find('}')
455 .ok_or_else(|| "unterminated interpolation".to_string())?
456 + start
457 + 1;
458 result.push_str(&evaluate_string(&remaining[start + 1..end], values)?);
459 remaining = &remaining[end + 1..];
460 }
461 result.push_str(remaining);
462 Ok(result)
463}
464
465fn lookup_template_value(
466 value: &str,
467 values: &[(&str, TemplateValue)],
468) -> Result<TemplateValue, String> {
469 let value = value.trim();
470 let (base, mut methods) = value
471 .split_once('.')
472 .map_or((value, ""), |(base, methods)| (base, methods));
473 let mut result = values
474 .iter()
475 .find(|(name, _)| *name == base)
476 .map(|(_, value)| value.clone())
477 .ok_or_else(|| format!("unsupported expression '{value}'"))?;
478 while !methods.is_empty() {
479 let (method, rest) = methods
480 .split_once('.')
481 .map_or((methods, ""), |(method, rest)| (method, rest));
482 if method == "upper()" {
483 result = TemplateValue::String(as_string(&result).to_uppercase());
484 } else if method == "lower()" {
485 result = TemplateValue::String(as_string(&result).to_lowercase());
486 } else if let Some(args) = method
487 .strip_prefix("replace(")
488 .and_then(|v| v.strip_suffix(')'))
489 {
490 let (from, to) = args
491 .split_once(',')
492 .ok_or_else(|| "replace expects two arguments".to_string())?;
493 let from = unquote_template_arg(from.trim())?;
494 let to = unquote_template_arg(to.trim())?;
495 result = TemplateValue::String(as_string(&result).replace(&from, &to));
496 } else {
497 return Err(format!("unsupported method '{method}'"));
498 }
499 methods = rest;
500 }
501 Ok(result)
502}
503
504fn as_string(value: &TemplateValue) -> String {
505 match value {
506 TemplateValue::String(value) => value.clone(),
507 TemplateValue::Bool(value) => value.to_string(),
508 }
509}
510
511fn unquote_template_arg(value: &str) -> Result<String, String> {
512 if value.len() >= 2
513 && ((value.starts_with('"') && value.ends_with('"'))
514 || (value.starts_with('\'') && value.ends_with('\'')))
515 {
516 Ok(value[1..value.len() - 1].to_string())
517 } else {
518 Err(format!("expected a quoted string argument, got '{value}'"))
519 }
520}
521
522fn lower_annotations(annotations: &[cst::Annotation]) -> Vec<HirAnnotation> {
523 annotations
524 .iter()
525 .map(|annotation| HirAnnotation {
526 name: annotation.name.clone(),
527 args: annotation
528 .args
529 .iter()
530 .map(|arg| HirAnnotationArg {
531 text: arg.text.clone(),
532 span: Some(arg.span.into()),
533 })
534 .collect(),
535 span: Some(annotation.span.into()),
536 })
537 .collect()
538}
539
540fn lower_settings(settings: &cst::Settings) -> HirSettings {
542 HirSettings {
543 span: Some(settings.span.into()),
544 children: settings.children.iter().map(lower_settings_node).collect(),
545 }
546}
547
548fn lower_settings_node(node: &cst::SettingsNode) -> HirSettingsNode {
549 match node {
550 cst::SettingsNode::Group {
551 name,
552 children,
553 span,
554 } => HirSettingsNode::Group {
555 name: name.clone(),
556 children: children.iter().map(lower_settings_node).collect(),
557 span: Some((*span).into()),
558 },
559 cst::SettingsNode::Number { name, value, span } => HirSettingsNode::Number {
560 name: name.clone(),
561 value: *value,
562 span: Some((*span).into()),
563 },
564 cst::SettingsNode::Bool { name, value, span } => HirSettingsNode::Bool {
565 name: name.clone(),
566 value: *value,
567 span: Some((*span).into()),
568 },
569 cst::SettingsNode::String { name, value, span } => HirSettingsNode::String {
570 name: name.clone(),
571 value: value.clone(),
572 span: Some((*span).into()),
573 },
574 cst::SettingsNode::Raw { name, value, span } => HirSettingsNode::Raw {
575 name: name.clone(),
576 value: value.clone(),
577 span: Some((*span).into()),
578 },
579 cst::SettingsNode::List {
580 name,
581 elements,
582 span,
583 } => HirSettingsNode::List {
584 name: name.clone(),
585 elements: elements
586 .iter()
587 .map(|element| crate::hir::types::SettingsListElement {
588 value: element.value.clone(),
589 span: Some(element.span.into()),
590 })
591 .collect(),
592 span: Some((*span).into()),
593 },
594 }
595}
596
597impl Lowerer {
598 fn error_at(&mut self, code: &str, message: String, span: Span) {
599 self.errors.push(OpyError::at(code, message, span));
600 }
601}
602
603fn keyword_spellings(param: &Param) -> Vec<String> {
605 let mut spellings = vec![param.name.clone()];
606 spellings.extend(param.alternate_names.iter().cloned());
607 spellings
608}
609
610fn arg_span(args: &[CallArg]) -> Span {
613 args.first().map(CallArg::span).unwrap_or_else(|| {
614 Span::new(
615 0,
616 crate::diag::Position::new(1, 1),
617 crate::diag::Position::new(1, 1),
618 )
619 })
620}
621
622fn context_player_expr(name: &str, span: Option<Span>) -> Option<HirExpr> {
627 match name {
628 "eventPlayer" => Some(HirExpr::EventPlayer {
629 span: span.map(Into::into),
630 }),
631 "localPlayer" => Some(HirExpr::Call {
632 name: name.to_string(),
633 args: Vec::new(),
634 debug_source: None,
635 span: span.map(Into::into),
636 }),
637 "hostPlayer" => Some(HirExpr::HostPlayer {
638 span: span.map(Into::into),
639 }),
640 "attacker" | "victim" | "healer" | "healee" => Some(HirExpr::Call {
641 name: name.to_string(),
642 args: Vec::new(),
643 debug_source: None,
644 span: span.map(Into::into),
645 }),
646 _ => None,
647 }
648}
649
650fn assignable_receiver(receiver: &Expr) -> bool {
655 match receiver {
656 Expr::Name { name, .. } => !matches!(
657 name.as_str(),
658 "eventPlayer" | "hostPlayer" | "attacker" | "victim"
659 ),
660 Expr::Array { .. } | Expr::Index { .. } | Expr::Member { .. } => true,
661 _ => false,
662 }
663}
664
665fn expr_identifier(expr: &Expr) -> Option<&str> {
666 match expr {
667 Expr::Name { name, .. } => Some(name.as_str()),
668 _ => None,
669 }
670}
671
672fn indexed_expr_depth(expr: &Expr) -> usize {
673 match expr {
674 Expr::Index { array, .. } => 1 + indexed_expr_depth(array),
675 _ => 0,
676 }
677}
678
679impl From<Span> for HirSpan {
680 fn from(span: Span) -> HirSpan {
681 HirSpan {
682 file: span.file,
683 start: Position {
684 line: span.start.line,
685 col: span.start.col,
686 },
687 end: Position {
688 line: span.end.line,
689 col: span.end.col,
690 },
691 }
692 }
693}
694
695impl From<&Span> for HirSpan {
696 fn from(span: &Span) -> HirSpan {
697 (*span).into()
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704 use crate::hir::types::{Expr as HirExpr, RuleEntry as HirRuleEntry, Stmt as HirStmt};
705 use crate::lexer::{LexInput, lex};
706 use crate::parser::parse;
707
708 fn lower_ok(text: &str) -> HirProgram {
709 let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
710 let output = parse(&tokens);
711 assert!(
712 output.errors.is_empty(),
713 "unexpected parse errors: {:?}",
714 output.errors
715 );
716 let program = output.program.expect("parse produces a program");
717 lower(&program, vec![], vec![]).expect("lowers without errors")
718 }
719
720 fn rule_conditions_and_actions(hir: &HirProgram) -> (&Vec<HirExpr>, &Vec<HirStmt>) {
721 let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
722 panic!("expected a rule");
723 };
724 (&rule.conditions, &rule.actions)
725 }
726
727 #[test]
728 fn producer_emits_the_v2_ordered_switch_contract() {
729 let hir = lower_ok(
730 "globalvar value\nrule \"r\":\n @Event global\n switch value:\n default:\n value = 1\n case 2:\n value = 2\n",
731 );
732 assert_eq!(hir.protocol.name, "wright/opy-hir");
733 assert_eq!(hir.protocol.version, "2.0.0");
734 let value = serde_json::to_value(&hir).expect("HIR must serialize");
735 let switch = &value["rules"][0]["actions"][0];
736 assert!(switch.get("arms").is_some());
737 assert!(switch.get("cases").is_none());
738 assert!(switch.get("default").is_none());
739 }
740
741 #[test]
742 fn receiver_calls_lower_to_receiver_call_hir() {
743 let hir = lower_ok(
747 "globalvar target\nrule \"r\":\n @Event eachPlayer\n eventPlayer.setMoveSpeed(100)\n target.setMoveSpeed(50)\n",
748 );
749 let (_, actions) = rule_conditions_and_actions(&hir);
750 assert_eq!(actions.len(), 2);
751
752 let HirStmt::Expr { expr, .. } = &actions[0] else {
753 panic!("expected expression statement");
754 };
755 let HirExpr::ReceiverCall {
756 receiver,
757 name,
758 args,
759 ..
760 } = expr.as_ref()
761 else {
762 panic!("expected receiver call, got {expr:?}");
763 };
764 assert_eq!(name, "setMoveSpeed");
765 assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
766 assert_eq!(args.len(), 1);
767 assert!(matches!(&args[0], HirExpr::Number { .. }));
768
769 let HirStmt::Expr { expr, .. } = &actions[1] else {
770 panic!("expected expression statement");
771 };
772 let HirExpr::ReceiverCall { receiver, name, .. } = expr.as_ref() else {
773 panic!("expected receiver call, got {expr:?}");
774 };
775 assert_eq!(name, "setMoveSpeed");
776 assert!(
777 matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "target"),
778 "globalvar receiver must resolve to a GlobalVar"
779 );
780 }
781
782 #[test]
783 fn bare_variable_member_expression_preserves_receiver_and_member() {
784 let hir = lower_ok(
785 "globalvar A\nplayervar B\nrule \"receiver\":\n @Event eachPlayer\n A = B.C\n",
786 );
787 let HirStmt::Assign { value, .. } = &hir
788 .rules
789 .iter()
790 .find_map(|entry| {
791 let RuleEntry::Rule(rule) = entry else {
792 return None;
793 };
794 rule.actions.first()
795 })
796 .expect("assignment")
797 else {
798 panic!("expected assignment");
799 };
800 let HirExpr::Member {
801 receiver, member, ..
802 } = value.as_ref()
803 else {
804 panic!("expected opaque member expression, got {value:?}");
805 };
806 assert_eq!(member, "C");
807 assert!(matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "B"));
808 }
809
810 #[test]
811 fn rule_prefix_template_is_global_and_subroutine_identity_is_preserved() {
812 let text = "rule \"before\":\n pass\ndef source_name():\n @Name \"Friendly\"\n pass\nrule \"after\":\n pass\n";
813 let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
814 let output = parse(&tokens);
815 assert!(
816 output.errors.is_empty(),
817 "unexpected parse errors: {:?}",
818 output.errors
819 );
820 let program = output.program.expect("program");
821 let preprocessing = PreprocessingState {
822 rule_prefix_template: Some(crate::hir::types::DirectiveValue {
823 value: "f\"[{$pathTitle.replace('_', ' ')}] {$rule}\" if $rule and not $isDelimiter else $rule".to_string(),
824 span: None,
825 }),
826 ..PreprocessingState::default()
827 };
828 let hir = lower_with_preprocessing(
829 &program,
830 vec![SourceFile {
831 id: 0,
832 path: "main.opy".to_string(),
833 }],
834 vec![],
835 &preprocessing,
836 )
837 .expect("lowers");
838 let names: Vec<_> = hir
839 .rules
840 .iter()
841 .map(|entry| match entry {
842 HirRuleEntry::Rule(rule) => rule.name.clone(),
843 HirRuleEntry::SubroutineDef { name, .. } => name.clone(),
844 })
845 .collect();
846 assert_eq!(
847 names,
848 vec!["[Main] before", "[Main] Friendly", "[Main] after"]
849 );
850 let HirRuleEntry::SubroutineDef {
851 name, source_name, ..
852 } = &hir.rules[1]
853 else {
854 panic!("expected subroutine definition");
855 };
856 assert_eq!(name, "[Main] Friendly");
857 assert_eq!(source_name, "source_name");
858 }
859
860 #[test]
861 fn receiver_call_values_lower_in_conditions() {
862 let hir = lower_ok(
866 "rule \"r\":\n @Event eachPlayer\n @Condition eventPlayer.isAlive()\n eventPlayer.teleport(eventPlayer.getPosition())\n",
867 );
868 let (conditions, actions) = rule_conditions_and_actions(&hir);
869 assert_eq!(conditions.len(), 1);
870 let HirExpr::ReceiverCall { name, args, .. } = &conditions[0] else {
871 panic!("expected receiver call condition, got {:?}", conditions[0]);
872 };
873 assert_eq!(name, "isAlive");
874 assert_eq!(args.len(), 0);
875
876 let HirStmt::Expr { expr, .. } = &actions[0] else {
877 panic!("expected expression statement");
878 };
879 let HirExpr::ReceiverCall {
880 name,
881 args,
882 receiver,
883 ..
884 } = expr.as_ref()
885 else {
886 panic!("expected receiver call, got {expr:?}");
887 };
888 assert_eq!(name, "teleport");
889 assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
890 assert_eq!(args.len(), 1);
891 assert!(matches!(
892 &args[0],
893 HirExpr::ReceiverCall { name, .. } if name == "getPosition"
894 ));
895 }
896
897 #[test]
898 fn format_string_receiver_stays_a_format_node() {
899 let hir = lower_ok(
902 "rule \"r\":\n @Event global\n print(\"{} points\".format(len([1, 2])))\n",
903 );
904 let (_, actions) = rule_conditions_and_actions(&hir);
905 let HirStmt::Expr { expr, .. } = &actions[0] else {
906 panic!("expected expression statement");
907 };
908 assert!(
909 has_format(expr),
910 "string `.format()` must lower to a Format node"
911 );
912 }
913
914 fn has_format(expr: &HirExpr) -> bool {
915 match expr {
916 HirExpr::Format { .. } => true,
917 HirExpr::Call { args, .. } => args.iter().any(has_format),
918 HirExpr::ReceiverCall { args, .. } => args.iter().any(has_format),
919 _ => false,
920 }
921 }
922
923 fn lowered_value(source: &str) -> HirExpr {
925 let program = crate::compile(source, "test.opy", std::path::Path::new(""))
926 .unwrap_or_else(|error| panic!("compile failed: {error}"));
927 let RuleEntry::Rule(rule) = &program.rules[0] else {
928 panic!("expected a rule");
929 };
930 let HirStmt::Assign { value, .. } = &rule.actions[0] else {
931 panic!("expected an assign statement");
932 };
933 (**value).clone()
934 }
935
936 #[test]
937 fn chase_time_reeval_none_lowers_to_the_catalog_enum() {
938 let value = lowered_value(
939 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.NONE\n",
940 );
941 assert_enum(&value, "ChaseTimeReeval", "NONE");
942 }
943
944 #[test]
945 fn chase_time_reeval_destination_and_duration_lowers_to_the_catalog_enum() {
946 let value = lowered_value(
947 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.DESTINATION_AND_DURATION\n",
948 );
949 assert_enum(&value, "ChaseTimeReeval", "DESTINATION_AND_DURATION");
950 }
951
952 #[test]
953 fn chase_rate_reeval_members_lower_to_the_catalog_enum() {
954 for member in ["NONE", "DESTINATION_AND_RATE"] {
955 let source = format!(
956 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseRateReeval.{member}\n"
957 );
958 assert_enum(&lowered_value(&source), "ChaseRateReeval", member);
959 }
960 }
961
962 fn assert_enum(value: &HirExpr, domain: &str, member: &str) {
965 match value {
966 HirExpr::Enum {
967 value_type, value, ..
968 } => {
969 assert_eq!(value_type, domain);
970 assert_eq!(value, member);
971 }
972 other => panic!("expected enum {domain}.{member}, got {other:?}"),
973 }
974 }
975
976 #[test]
977 fn unknown_chase_time_reeval_member_is_rejected_by_the_catalog() {
978 let error = crate::compile(
979 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.NOPE\n",
980 "test.opy",
981 std::path::Path::new(""),
982 )
983 .expect_err("unknown catalog member must be rejected");
984 assert_eq!(error.code, "unknown-enum-member");
985 }
986
987 #[test]
988 fn unknown_enum_receiver_is_an_unsupported_member_error() {
989 let error = crate::compile(
990 "globalvar g\nrule \"r\":\n @Event global\n g = NotARealEnum.MEMBER\n",
991 "test.opy",
992 std::path::Path::new(""),
993 )
994 .expect_err("an unknown enum type must fail");
995 assert_eq!(error.code, "unsupported-member");
996 let span = error.span.expect("the error is source-located");
997 assert_eq!(span.start.line, 4);
998 }
999
1000 fn compile_error(source: &str, line: u32) -> OpyError {
1004 let error = crate::compile(source, "test.opy", std::path::Path::new(""))
1005 .expect_err("expected a compile failure");
1006 let span = error.span.expect("the error is source-located");
1007 assert_eq!(span.start.line, line, "code '{}'", error.code);
1008 error
1009 }
1010
1011 fn action_source(statement: &str) -> String {
1012 format!("globalvar g\nrule \"r\":\n @Event global\n {statement}\n")
1013 }
1014
1015 #[test]
1016 fn chase_over_time_resolves_and_compiles_with_reference_signatures() {
1017 let hir = crate::compile(
1019 &action_source("chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE)"),
1020 "test.opy",
1021 std::path::Path::new(""),
1022 )
1023 .expect("reference-supported chaseOverTime compiles");
1024 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1025 panic!("expected a rule");
1026 };
1027 let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1028 panic!("expected expression statement");
1029 };
1030 let HirExpr::Call { name, args, .. } = expr.as_ref() else {
1031 panic!("expected a call, got {expr:?}");
1032 };
1033 assert_eq!(name, "chaseOverTime");
1034 assert_eq!(args.len(), 4);
1035 assert!(matches!(
1036 &args[3],
1037 HirExpr::Enum { value_type, value, .. }
1038 if value_type == "ChaseTimeReeval" && value == "NONE"
1039 ));
1040
1041 let hir = crate::compile(
1043 &action_source("chaseOverTime(g, 10, 3)"),
1044 "test.opy",
1045 std::path::Path::new(""),
1046 )
1047 .expect("default-reevaluation chaseOverTime compiles");
1048 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1049 panic!("expected a rule");
1050 };
1051 let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1052 panic!("expected expression statement");
1053 };
1054 let HirExpr::Call { args, .. } = expr.as_ref() else {
1055 panic!("expected a call");
1056 };
1057 assert_eq!(args.len(), 4);
1058 assert!(matches!(
1059 &args[3],
1060 HirExpr::Enum { value_type, value, .. }
1061 if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1062 ));
1063 }
1064
1065 #[test]
1066 fn is_game_in_progress_resolves_as_a_builtin_value() {
1067 let hir = crate::compile(
1069 &action_source("@Condition isGameInProgress() == true"),
1070 "test.opy",
1071 std::path::Path::new(""),
1072 )
1073 .expect("reference-supported isGameInProgress compiles");
1074 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1075 panic!("expected a rule");
1076 };
1077 assert!(matches!(&rule.conditions[0], HirExpr::Binary { .. }));
1078 }
1079
1080 #[test]
1081 fn enum_gated_members_resolve_through_the_manifest() {
1082 let source = "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
1086 @Condition eventPlayer.getThrottle() != vect(0, 0, 0)\n \
1087 @Condition worldVector(vect(1, 2, 3), eventPlayer, Transform.ROTATION) != vect(0, 0, 0)\n \
1088 eventPlayer.setInvisibility(Invis.ALL)\n \
1089 eventPlayer.setStatusEffect(eventPlayer, Status.ROOTED, 2)\n";
1090 let hir = crate::compile(source, "test.opy", std::path::Path::new(""))
1091 .expect("enum-gated members compile");
1092 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1093 panic!("expected a rule");
1094 };
1095 assert_eq!(rule.actions.len(), 2);
1096 }
1097
1098 #[test]
1099 fn get_players_in_radius_fills_reference_enum_defaults() {
1100 let hir = crate::compile(
1103 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
1104 @Condition len(getPlayersInRadius(eventPlayer.getPosition(), 10)) > 0\n \
1105 disableInspector()\n",
1106 "test.opy",
1107 std::path::Path::new(""),
1108 )
1109 .expect("getPlayersInRadius with defaults compiles");
1110 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1111 panic!("expected a rule");
1112 };
1113 let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
1114 panic!("expected a comparison");
1115 };
1116 let HirExpr::Call { name, args, .. } = left.as_ref() else {
1117 panic!("expected len call");
1118 };
1119 assert_eq!(name, "len");
1120 let HirExpr::Call { name, args, .. } = &args[0] else {
1121 panic!("expected getPlayersInRadius call");
1122 };
1123 assert_eq!(name, "getPlayersInRadius");
1124 assert_eq!(args.len(), 4);
1125 assert!(matches!(
1126 &args[2],
1127 HirExpr::Enum { value_type, value, .. }
1128 if value_type == "Team" && value == "ALL"
1129 ));
1130 assert!(matches!(
1131 &args[3],
1132 HirExpr::Enum { value_type, value, .. }
1133 if value_type == "LosCheck" && value == "OFF"
1134 ));
1135 }
1136
1137 #[test]
1138 fn value_call_in_action_position_is_rejected() {
1139 let error = compile_error(&action_source("isGameInProgress()"), 4);
1140 assert_eq!(error.code, "value-in-action-position");
1141 }
1142
1143 #[test]
1144 fn value_member_in_action_position_is_rejected() {
1145 let error = compile_error(
1148 "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.isAlive()\n",
1149 4,
1150 );
1151 assert_eq!(error.code, "value-in-action-position");
1152 }
1153
1154 fn first_action_expr(source: &str) -> HirExpr {
1159 let program = crate::compile(source, "test.opy", std::path::Path::new(""))
1160 .unwrap_or_else(|error| panic!("compile failed: {error}"));
1161 let RuleEntry::Rule(rule) = &program.rules[0] else {
1162 panic!("expected a rule");
1163 };
1164 match &rule.actions[0] {
1165 HirStmt::Expr { expr, .. } => (**expr).clone(),
1166 HirStmt::Assign { value, .. } => (**value).clone(),
1167 other => panic!("expected an expression or assignment, got {other:?}"),
1168 }
1169 }
1170
1171 fn strip_spans(value: &mut serde_json::Value) {
1174 match value {
1175 serde_json::Value::Object(map) => {
1176 map.remove("span");
1177 map.remove("name_span");
1178 for nested in map.values_mut() {
1179 strip_spans(nested);
1180 }
1181 }
1182 serde_json::Value::Array(items) => {
1183 for item in items {
1184 strip_spans(item);
1185 }
1186 }
1187 _ => {}
1188 }
1189 }
1190
1191 #[test]
1192 fn chase_keyword_forms_dispatch_to_the_concrete_chase_functions() {
1193 let expr = first_action_expr(&action_source("chase(g, 10, rate=2, ChaseReeval.NONE)"));
1198 let HirExpr::Call { name, args, .. } = &expr else {
1199 panic!("expected a call, got {expr:?}");
1200 };
1201 assert_eq!(name, "chaseAtRate");
1202 assert!(matches!(
1203 &args[3],
1204 HirExpr::Enum { value_type, value, .. }
1205 if value_type == "ChaseRateReeval" && value == "NONE"
1206 ));
1207
1208 let expr = first_action_expr(&action_source(
1209 "chase(g, 10, duration=3, ChaseReeval.DESTINATION_AND_DURATION)",
1210 ));
1211 let HirExpr::Call { name, args, .. } = &expr else {
1212 panic!("expected a call, got {expr:?}");
1213 };
1214 assert_eq!(name, "chaseOverTime");
1215 assert!(matches!(
1216 &args[3],
1217 HirExpr::Enum { value_type, value, .. }
1218 if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1219 ));
1220
1221 let expr = first_action_expr(
1224 "playervar P\nrule \"r\":\n @Event eachPlayer\n \
1225 chase(eventPlayer.P, 0, rate=1, ChaseReeval.NONE)\n",
1226 );
1227 let HirExpr::Call { name, args, .. } = &expr else {
1228 panic!("expected a call, got {expr:?}");
1229 };
1230 assert_eq!(name, "chaseAtRate");
1231 assert!(matches!(&args[0], HirExpr::PlayerVar { .. }));
1232 }
1233
1234 #[test]
1235 fn chase_reeval_is_only_a_standalone_identity_inside_the_chase_context() {
1236 let error = compile_error(&action_source("g = ChaseReeval.NONE"), 4);
1240 assert_eq!(error.code, "unsupported-member");
1241
1242 let expr = first_action_expr(&action_source(
1247 "chase(g, 10, rate=2, ChaseReeval.DESTINATION_AND_DURATION)",
1248 ));
1249 let HirExpr::Call { name, args, .. } = &expr else {
1250 panic!("expected a call, got {expr:?}");
1251 };
1252 assert_eq!(name, "chaseAtRate");
1253 assert!(matches!(
1254 &args[3],
1255 HirExpr::Enum { value_type, value, .. }
1256 if value_type == "ChaseRateReeval" && value == "DESTINATION_AND_DURATION"
1257 ));
1258
1259 let expr = first_action_expr(&action_source("chase(g, 10, rate=2, 5)"));
1262 let HirExpr::Call { name, args, .. } = &expr else {
1263 panic!("expected a call, got {expr:?}");
1264 };
1265 assert_eq!(name, "chase");
1266 assert!(matches!(&args[3], HirExpr::Number { .. }));
1267 }
1268
1269 #[test]
1270 fn chase_requires_the_keyword_rate_or_duration_third_argument() {
1271 let error = compile_error(&action_source("chase(g, 10, 2, ChaseReeval.NONE)"), 4);
1272 assert_eq!(error.code, "keyword-required");
1273 assert!(error.message.contains("rate"));
1274 }
1275
1276 #[test]
1277 fn chase_family_requires_a_variable_first_argument() {
1278 let error = compile_error(&action_source("chase(10, 10, rate=2, ChaseReeval.NONE)"), 4);
1283 assert_eq!(error.code, "invalid-argument");
1284
1285 let error = compile_error(
1286 &action_source("chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)"),
1287 4,
1288 );
1289 assert_eq!(error.code, "invalid-argument");
1290 }
1291
1292 #[test]
1293 fn keyword_binding_matches_positional_binding_in_hir() {
1294 fn without_spans(expr: &HirExpr) -> serde_json::Value {
1298 let mut value = serde_json::to_value(expr).unwrap();
1299 strip_spans(&mut value);
1300 value
1301 }
1302 let keyword = without_spans(&first_action_expr(&action_source(
1303 "chaseOverTime(g, 10, duration=3)",
1304 )));
1305 let positional = without_spans(&first_action_expr(&action_source(
1306 "chaseOverTime(g, 10, 3)",
1307 )));
1308 assert_eq!(keyword, positional);
1309
1310 let keyword = without_spans(&first_action_expr(&action_source("wait(time=1)")));
1311 let positional = without_spans(&first_action_expr(&action_source("wait(1)")));
1312 assert_eq!(keyword, positional);
1313
1314 let keyword = without_spans(&first_action_expr(&action_source(
1316 "wait(waitBehavior=Wait.IGNORE_CONDITION, time=2)",
1317 )));
1318 let positional = without_spans(&first_action_expr(&action_source("wait(2)")));
1319 assert_eq!(keyword, positional);
1320
1321 let keyword = without_spans(&first_action_expr(&action_source(
1322 "g = vect(x=1, y=2, z=3)",
1323 )));
1324 let positional = without_spans(&first_action_expr(&action_source("g = vect(1, 2, 3)")));
1325 assert_eq!(keyword, positional);
1326 }
1327
1328 #[test]
1329 fn keyword_binding_diagnostics_are_structured_and_source_located() {
1330 let error = compile_error(&action_source("chaseOverTime(g, 10, bogus=1)"), 4);
1332 assert_eq!(error.code, "unknown-keyword");
1333 assert!(error.message.contains("bogus"));
1334
1335 let error = compile_error(
1337 &action_source(
1338 "chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE, \
1339 reevaluation=ChaseTimeReeval.NONE)",
1340 ),
1341 4,
1342 );
1343 assert_eq!(error.code, "duplicate-argument");
1344
1345 let error = compile_error(&action_source("chaseOverTime(g, duration=3, 5)"), 4);
1347 assert_eq!(error.code, "positional-after-keyword");
1348
1349 let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
1351 assert_eq!(error.code, "missing-argument");
1352
1353 let error = compile_error(
1356 &action_source("chase(variable=g, destination=10, rate=2, ChaseReeval.NONE)"),
1357 4,
1358 );
1359 assert_eq!(error.code, "unknown-keyword");
1360 }
1361
1362 #[test]
1363 fn keyword_arguments_are_rejected_for_reference_special_cases() {
1364 let error = compile_error(
1367 "globalvar g\nrule \"r\":\n @Event global\n \
1368 for I in range(start=0, stop=3):\n debug(I)\n",
1369 4,
1370 );
1371 assert_eq!(error.code, "keyword-unsupported");
1372
1373 let error = compile_error(&action_source("g = random.uniform(min=1, max=2)"), 4);
1374 assert_eq!(error.code, "keyword-unsupported");
1375
1376 let error = compile_error(&action_source("print(\"{} points\".format(value=1))"), 4);
1377 assert_eq!(error.code, "keyword-unsupported");
1378 }
1379
1380 #[test]
1381 fn wait_uses_the_reference_keyword_names() {
1382 let error = compile_error(&action_source("wait(duration=1)"), 4);
1386 assert_eq!(error.code, "unknown-keyword");
1387 assert!(error.message.contains("duration"));
1388 }
1389
1390 #[test]
1391 fn action_call_in_value_position_is_rejected() {
1392 let error = compile_error(&action_source("g = wait(1)"), 4);
1393 assert_eq!(error.code, "action-in-value-position");
1394 }
1395
1396 #[test]
1397 fn missing_required_argument_is_a_source_located_diagnostic() {
1398 let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
1402 assert_eq!(error.code, "missing-argument");
1403 assert!(error.message.contains("duration"));
1404
1405 let error = compile_error(&action_source("chaseOverTime(g, 10, 3, 4, 5)"), 4);
1406 assert_eq!(error.code, "invalid-arity");
1407 }
1408
1409 #[test]
1410 fn missing_member_argument_is_a_source_located_diagnostic() {
1411 let error = compile_error(
1416 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
1417 getPlayersInRadius(eventPlayer.getPosition(), 10).setStatusEffect(eventPlayer, 30)\n",
1418 4,
1419 );
1420 assert_eq!(error.code, "missing-argument");
1421 assert!(error.message.contains("duration"));
1422 }
1423
1424 #[test]
1425 fn invalid_receiver_categories_are_rejected() {
1426 let error = compile_error(&action_source("3.append(1)"), 4);
1429 assert_eq!(error.code, "invalid-receiver");
1430 assert!(error.message.contains("append"));
1431
1432 let error = compile_error(&action_source("attacker.append(1)"), 4);
1433 assert_eq!(error.code, "invalid-receiver");
1434 assert!(error.message.contains("append"));
1435
1436 let error = compile_error(&action_source("print(3.format(\"{}\"))"), 4);
1437 assert_eq!(error.code, "invalid-receiver");
1438 assert!(error.message.contains("format"));
1439 }
1440
1441 #[test]
1442 fn cross_domain_enum_arguments_resolve_as_opaque_identities() {
1443 let expr = first_action_expr(&action_source("chaseOverTime(g, 10, 3, Invis.ALL)"));
1447 let HirExpr::Call { args, .. } = &expr else {
1448 panic!("expected a call, got {expr:?}");
1449 };
1450 assert!(matches!(
1451 &args[3],
1452 HirExpr::Enum { value_type, value, .. }
1453 if value_type == "Invis" && value == "ALL"
1454 ));
1455
1456 let expr = first_action_expr(&action_source(
1457 "eventPlayer.setInvisibility(ChaseTimeReeval.NONE)",
1458 ));
1459 let HirExpr::ReceiverCall { args, .. } = &expr else {
1460 panic!("expected a receiver call, got {expr:?}");
1461 };
1462 assert!(matches!(
1463 &args[0],
1464 HirExpr::Enum { value_type, value, .. }
1465 if value_type == "ChaseTimeReeval" && value == "NONE"
1466 ));
1467 }
1468
1469 #[test]
1470 fn non_enum_arguments_for_enum_parameters_are_carried_structurally() {
1471 let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(g)"));
1475 let HirExpr::ReceiverCall { args, .. } = &expr else {
1476 panic!("expected a receiver call, got {expr:?}");
1477 };
1478 assert!(matches!(
1479 &args[0],
1480 HirExpr::GlobalVar { name, .. } if name == "g"
1481 ));
1482
1483 let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(3)"));
1484 let HirExpr::ReceiverCall { args, .. } = &expr else {
1485 panic!("expected a receiver call, got {expr:?}");
1486 };
1487 assert!(matches!(&args[0], HirExpr::Number { .. }));
1488 }
1489
1490 #[test]
1491 fn unknown_builtins_fail_at_resolution_not_emission() {
1492 let error = compile_error(&action_source("frobnicate()"), 4);
1493 assert_eq!(error.code, "unknown-action");
1494
1495 let error = compile_error(&action_source("g = frobnicate()"), 4);
1496 assert_eq!(error.code, "unknown-value");
1497
1498 let error = compile_error(
1499 "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.frobnicate()\n",
1500 4,
1501 );
1502 assert_eq!(error.code, "unknown-member");
1503 }
1504
1505 #[test]
1506 fn wright_only_catalog_names_are_rejected() {
1507 let error = compile_error(&action_source("createHudText(1)"), 4);
1511 assert_eq!(error.code, "unknown-action");
1512
1513 let error = compile_error(&action_source("g = squareRoot(9)"), 4);
1514 assert_eq!(error.code, "unknown-value");
1515 }
1516
1517 #[test]
1518 fn generic_member_only_actions_are_rejected() {
1519 let error = compile_error(&action_source("setMoveSpeed(eventPlayer, 100)"), 4);
1522 assert_eq!(error.code, "unknown-action");
1523 }
1524
1525 #[test]
1526 fn range_is_for_iterables_only() {
1527 let error = compile_error(&action_source("@Condition len(range(1, 5, 1)) > 0"), 4);
1530 assert_eq!(error.code, "invalid-call-context");
1531
1532 let error = compile_error(&action_source("for g in [1, 2]:\n debug(g)"), 4);
1533 assert_eq!(error.code, "invalid-iterable");
1534
1535 crate::compile(
1536 &action_source("for g in range(3):\n debug(g)"),
1537 "test.opy",
1538 std::path::Path::new(""),
1539 )
1540 .expect("the for-header range form compiles");
1541 }
1542
1543 #[test]
1544 fn source_aliases_resolve_to_canonical_names() {
1545 let hir = crate::compile(
1548 &action_source("stopChasingVariable(g)"),
1549 "test.opy",
1550 std::path::Path::new(""),
1551 )
1552 .expect("the alias target compiles");
1553 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1554 panic!("expected a rule");
1555 };
1556 let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1557 panic!("expected expression statement");
1558 };
1559 let HirExpr::Call { name, .. } = expr.as_ref() else {
1560 panic!("expected a call");
1561 };
1562 assert_eq!(name, "stopChasingVariable");
1563
1564 let hir = crate::compile(
1565 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
1566 @Condition eventPlayer.getCurrentHero() != null\n \
1567 @Condition eventPlayer.hasStatusEffect(Status.BURNING) == false\n \
1568 disableInspector()\n",
1569 "test.opy",
1570 std::path::Path::new(""),
1571 )
1572 .expect("member aliases compile");
1573 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1574 panic!("expected a rule");
1575 };
1576 let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
1577 panic!("expected a comparison");
1578 };
1579 let HirExpr::ReceiverCall { name, .. } = left.as_ref() else {
1580 panic!("expected a receiver call");
1581 };
1582 assert_eq!(name, "getHero");
1583 }
1584
1585 #[test]
1586 fn unknown_catalog_enum_members_are_rejected() {
1587 for source in [
1588 "globalvar g\nrule \"r\":\n @Event global\n g = Color.CYAN\n",
1589 "globalvar g\nrule \"r\":\n @Event global\n g = DynamicEffect.SPARKLES\n",
1590 ] {
1591 let error = crate::compile(source, "test.opy", std::path::Path::new(""))
1592 .expect_err("unknown catalog member must be rejected");
1593 assert_eq!(error.code, "unknown-enum-member");
1594 }
1595 }
1596
1597 #[test]
1598 fn default_var_for_binder_resolves_at_all_range_arities() {
1599 for (binder, iterable) in [
1604 ("I", "range(0, 10)"),
1605 ("I", "range(3)"),
1606 ("I", "range(1, 5, 2)"),
1607 ] {
1608 let hir = lower_ok(&format!(
1609 "globalvar total\nrule \"r\":\n @Event global\n for {binder} in {iterable}:\n total += {binder}\n"
1610 ));
1611 let (_, actions) = rule_conditions_and_actions(&hir);
1612 let HirStmt::For { variable, body, .. } = &actions[0] else {
1613 panic!("expected a for statement");
1614 };
1615 assert!(
1616 matches!(variable.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
1617 "the binder resolves to the implicit global 'I', got {variable:?}"
1618 );
1619 assert!(!body.is_empty(), "the loop body lowers");
1620 let HirStmt::Assign { value, .. } = &body[0] else {
1623 panic!("expected an assignment in the body");
1624 };
1625 let HirExpr::Binary { right, .. } = value.as_ref() else {
1626 panic!("expected a binary expression");
1627 };
1628 assert!(
1629 matches!(right.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
1630 "the binder use inside the body resolves to the implicit global"
1631 );
1632 }
1633 }
1634
1635 #[test]
1636 fn player_variable_range_binder_preserves_host_player_receiver() {
1637 let hir = lower_ok(
1638 "playervar I\nrule \"r\":\n @Event global\n for hostPlayer.I in range(3):\n hostPlayer.I = 1\n",
1639 );
1640 let (_, actions) = rule_conditions_and_actions(&hir);
1641 let HirStmt::For { variable, .. } = &actions[0] else {
1642 panic!("expected a for statement");
1643 };
1644 let HirExpr::PlayerVar {
1645 player,
1646 name,
1647 member_span,
1648 span,
1649 } = variable.as_ref()
1650 else {
1651 panic!("expected a player-variable binder, got {variable:?}");
1652 };
1653 assert_eq!(name, "I");
1654 assert!(matches!(player.as_ref(), HirExpr::HostPlayer { .. }));
1655 assert_eq!(span.unwrap().start.line, 4);
1656 assert_eq!(span.unwrap().start.col, 9);
1657 assert_eq!(span.unwrap().end.line, 4);
1658 assert_eq!(span.unwrap().end.col, 21);
1659 let member_span = member_span.expect("player binder member span");
1660 assert_eq!(member_span.start.line, 4);
1661 assert_eq!(member_span.start.col, 20);
1662 assert_eq!(member_span.end.line, 4);
1663 assert_eq!(member_span.end.col, 21);
1664 }
1665
1666 #[test]
1667 fn default_var_names_resolve_as_implicit_globals() {
1668 let hir = lower_ok("rule \"r\":\n @Event global\n I = 5\n debug(I)\n");
1671 let (_, actions) = rule_conditions_and_actions(&hir);
1672 let HirStmt::Assign { target, .. } = &actions[0] else {
1673 panic!("expected an assignment");
1674 };
1675 assert!(
1676 matches!(target.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
1677 "the implicit global resolves, got {target:?}"
1678 );
1679 assert_eq!(default_var_index("I"), Some(8));
1681 assert_eq!(default_var_index("AA"), Some(26));
1682 assert_eq!(default_var_index("Z"), Some(25));
1683 assert_eq!(default_var_index("DX"), Some(127));
1684 assert_eq!(default_var_index("DY"), None);
1685 assert_eq!(default_var_index("i"), None);
1686 }
1687
1688 #[test]
1689 fn nested_same_name_for_binders_reuse_the_implicit_global() {
1690 let hir = lower_ok(
1694 "rule \"r\":\n @Event global\n for I in range(3):\n for I in range(2):\n debug(I)\n",
1695 );
1696 let (_, actions) = rule_conditions_and_actions(&hir);
1697 let HirStmt::For {
1698 variable: outer,
1699 body,
1700 ..
1701 } = &actions[0]
1702 else {
1703 panic!("expected an outer for statement");
1704 };
1705 let HirStmt::For {
1706 variable: inner, ..
1707 } = &body[0]
1708 else {
1709 panic!("expected an inner for statement");
1710 };
1711 assert!(
1712 matches!(outer.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I")
1713 && matches!(inner.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
1714 "both loops bind the same implicit global (spans differ per binder site)"
1715 );
1716 }
1717
1718 #[test]
1719 fn undeclared_lowercase_binder_is_still_an_unknown_identifier() {
1720 let error = compile_error(
1725 "rule \"r\":\n @Event global\n for i in range(3):\n debug(i)\n",
1726 3,
1727 );
1728 assert_eq!(error.code, "unknown-identifier");
1729 let span = error.span.expect("the error is source-located");
1730 assert_eq!(span.start.line, 3);
1731 }
1732
1733 #[test]
1734 fn syntax_constructs_lower_to_provenance_preserving_hir() {
1735 let hir = lower_ok(
1736 "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",
1737 );
1738 let (_, actions) = rule_conditions_and_actions(&hir);
1739 let HirStmt::DoWhile { condition, .. } = &actions[0] else {
1740 panic!("expected do-while");
1741 };
1742 assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in"));
1743 let HirStmt::Switch { arms, .. } = &actions[1] else {
1744 panic!("expected switch");
1745 };
1746 assert_eq!(arms.len(), 2);
1747 let HirSwitchArm::Case {
1748 value: case_value,
1749 body,
1750 ..
1751 } = &arms[0]
1752 else {
1753 panic!("expected case arm");
1754 };
1755 assert!(
1756 matches!(case_value.as_ref(), HirExpr::Number { value, .. } if *value == 0x10 as f64)
1757 );
1758 let HirStmt::Assign { value, .. } = &body[0] else {
1759 panic!("expected case assignment");
1760 };
1761 assert!(matches!(value.as_ref(), HirExpr::Binary { op, .. } if op == "in"));
1762 let HirSwitchArm::Default { body, .. } = &arms[1] else {
1763 panic!("expected default arm");
1764 };
1765 assert!(matches!(body[0], HirStmt::Assign { .. }));
1766 let HirStmt::Assign { value, .. } = &actions[2] else {
1767 panic!("expected comprehension assignment");
1768 };
1769 assert!(matches!(value.as_ref(), HirExpr::Comprehension { .. }));
1770 let HirStmt::Assign { value, .. } = &actions[3] else {
1771 panic!("expected sorted assignment");
1772 };
1773 assert!(
1774 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")))
1775 );
1776 let HirStmt::Assign { value, .. } = &actions[4] else {
1777 panic!("expected string assignment");
1778 };
1779 assert!(
1780 matches!(value.as_ref(), HirExpr::StringModifier { modifier, .. } if modifier == "w")
1781 );
1782 }
1783
1784 #[test]
1785 fn syntax_rejects_reference_invalid_bare_dict_and_lambda() {
1786 let dict_error = compile_error(
1787 "globalvar x\nrule \"r\":\n @Event global\n x = {\"x\": 1}\n",
1788 4,
1789 );
1790 assert_eq!(dict_error.code, "dict-access");
1791 let lambda_error = compile_error(
1792 "globalvar x\nrule \"r\":\n @Event global\n x = lambda value: value\n",
1793 4,
1794 );
1795 assert_eq!(lambda_error.code, "lambda-context");
1796 }
1797
1798 #[test]
1799 fn do_while_requires_rule_or_definition_prefix_position() {
1800 let error = compile_error(
1801 "globalvar value\nrule \"r\":\n @Event global\n value = 1\n do:\n value += 1\n while value < 2\n",
1802 5,
1803 );
1804 assert_eq!(error.code, "do-while-placement");
1805 assert_eq!(
1806 error.message,
1807 "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it"
1808 );
1809 }
1810}