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