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