1use std::collections::{HashMap, HashSet};
29
30use crate::hir::types::{
31 Annotation as HirAnnotation, AnnotationArg as HirAnnotationArg, Declaration, Define,
32 DictEntry as HirDictEntry, Event, Expr as HirExpr, Generator, IfBranch, PROTOCOL_VERSION,
33 Position, PreprocessingState, Program as HirProgram, Protocol, Rule, RuleEntry,
34 Settings as HirSettings, SettingsNode as HirSettingsNode, SourceFile, Span as HirSpan,
35 Stmt as HirStmt, SwitchArm as HirSwitchArm, default_var_index,
36};
37
38use crate::cst::{self, CallArg, Decl, Expr, RuleEntry as CstRuleEntry, Stmt, TopLevel};
39use crate::diag::{OpyError, OpyResult, Span};
40use crate::manifest::{Function, FunctionKind, Manifest, Param, ParamDefault};
41use workshop_rs::catalog::{Catalog, 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
85impl Lowerer {
86 fn new(manifest: &'static Manifest, catalog: Catalog) -> Self {
87 Self {
88 global_declarations: HashMap::new(),
89 player_declarations: HashMap::new(),
90 subroutine_declarations: HashMap::new(),
91 subroutine_definitions: Vec::new(),
92 constant_declarations: HashMap::new(),
93 macro_declarations: HashMap::new(),
94 enums: HashMap::new(),
95 enum_declarations: HashMap::new(),
96 locals: Vec::new(),
97 current_order: 0,
98 allow_dict_literal: false,
99 manifest,
100 catalog,
101 texture_used: false,
102 setup_tags: false,
103 errors: Vec::new(),
104 }
105 }
106}
107
108mod declarations;
109mod expressions;
110pub(crate) mod policy;
111mod special_forms;
112mod statements;
113mod textures;
114
115pub(crate) fn compressed_component_mode(values: &[Vec<f64>]) -> Option<(bool, f64)> {
116 let component_count = values.first()?.len();
117 let is_vector = component_count == 3;
118 (matches!(component_count, 1 | 3) && values.iter().all(|value| value.len() == component_count))
119 .then_some((is_vector, if is_vector { 4999.0 } else { 49999.0 }))
120}
121
122pub fn lower(
124 program: &cst::Program,
125 files: Vec<SourceFile>,
126 defines: Vec<Define>,
127) -> OpyResult<HirProgram> {
128 lower_with_preprocessing(program, files, defines, &PreprocessingState::default())
129}
130
131pub fn lower_with_preprocessing(
132 program: &cst::Program,
133 files: Vec<SourceFile>,
134 defines: Vec<Define>,
135 preprocessing: &PreprocessingState,
136) -> OpyResult<HirProgram> {
137 let manifest = match Manifest::builtin() {
138 Ok(manifest) => manifest,
139 Err(error) => {
140 return Err(OpyError::new(
141 "manifest-error",
142 format!("cannot load the OPY semantic compatibility manifest: {error}"),
143 ));
144 }
145 };
146 let catalog = match Catalog::builtin() {
147 Ok(catalog) => catalog,
148 Err(error) => {
149 return Err(OpyError::new(
150 "catalog-error",
151 format!("cannot load the Workshop catalog: {error}"),
152 ));
153 }
154 };
155 lower_program(program, files, defines, preprocessing, manifest, catalog)
156}
157
158fn lower_program(
159 program: &cst::Program,
160 files: Vec<SourceFile>,
161 defines: Vec<Define>,
162 preprocessing: &PreprocessingState,
163 manifest: &'static Manifest,
164 catalog: Catalog,
165) -> OpyResult<HirProgram> {
166 let mut lowerer = Lowerer::new(manifest, catalog);
167 lowerer.setup_tags = preprocessing
168 .directives
169 .iter()
170 .any(|directive| matches!(directive.name.as_str(), "setupTags" | "setupTx"));
171 lowerer.collect_symbols(program);
172
173 let mut declarations = Vec::new();
174 let mut rules = Vec::new();
175 let mut implicit_subroutines = HashSet::new();
176 for (order, item) in program.top_level.iter().enumerate() {
177 lowerer.current_order = order;
178 match item {
179 TopLevel::Declaration(decl) => {
180 if let Some(declaration) = lowerer.lower_declaration(decl) {
181 declarations.push(declaration);
182 }
183 }
184 TopLevel::Rule(CstRuleEntry::Rule(rule)) => rules.push(RuleEntry::Rule(
185 lowerer.lower_rule(rule, files.as_slice(), preprocessing)?,
186 )),
187 TopLevel::Rule(CstRuleEntry::SubroutineDef {
188 name,
189 presentation_name,
190 span,
191 name_span,
192 body,
193 annotations,
194 rule_prefix,
195 }) => {
196 if !lowerer.subroutine_declarations.contains_key(name)
197 && implicit_subroutines.insert(name.clone())
198 {
199 declarations.push(Declaration::Subroutine {
200 name: name.clone(),
201 index: None,
202 span: Some(span.into()),
203 name_span: Some(name_span.into()),
204 });
205 }
206 let base_name = presentation_name
207 .as_deref()
208 .map(str::to_string)
209 .unwrap_or_else(|| name.clone());
210 let generated_name = render_rule_name(
211 &base_name,
212 rule_prefix.as_deref(),
213 false,
214 *span,
215 files.as_slice(),
216 preprocessing,
217 )?;
218 rules.push(RuleEntry::SubroutineDef {
219 kind: "subroutineDef".to_string(),
220 name: generated_name,
221 source_name: name.clone(),
222 span: Some(span.into()),
223 name_span: Some(name_span.into()),
224 body: lowerer.lower_block(body, &[], false, true, false),
225 annotations: lower_annotations(annotations),
226 });
227 }
228 }
229 }
230
231 if !lowerer.errors.is_empty() {
232 return Err(lowerer.errors.swap_remove(0));
233 }
234
235 if lowerer.texture_used || lowerer.setup_tags {
236 declarations.insert(
237 0,
238 Declaration::GlobalVariable {
239 name: "__holygrail__".to_string(),
240 index: Some(127),
241 span: None,
242 name_span: None,
243 initializer: None,
244 },
245 );
246 rules.insert(0, texture_setup_rule());
247 }
248
249 Ok(HirProgram {
250 protocol: Protocol {
251 name: PROTOCOL_NAME.to_string(),
252 version: PROTOCOL_VERSION.to_string(),
253 },
254 generator: Generator {
255 name: crate::LANGUAGE_NAME.to_string(),
256 version: crate::LANGUAGE_VERSION.to_string(),
257 frontend: crate::LANGUAGE_NAME.to_string(),
258 },
259 files,
260 defines,
261 declarations,
262 rules,
263 settings: program.settings.as_ref().map(lower_settings),
264 preprocessing: preprocessing.clone(),
265 })
266}
267
268fn texture_setup_rule() -> RuleEntry {
269 RuleEntry::Rule(Rule {
270 name: format!("OverPy <{}tx> / <{}fg> setup code", '\u{00ad}', '\u{00ad}'),
271 span: None,
272 name_span: None,
273 disabled: false,
274 delimiter: false,
275 new_page: None,
276 annotations: Vec::new(),
277 event: Event {
278 name: "global".to_string(),
279 args: Vec::new(),
280 span: None,
281 },
282 conditions: Vec::new(),
283 actions: vec![
284 synthetic_statement(synthetic_call(
285 "createDummy",
286 vec![
287 synthetic_call("getAllHeroes", Vec::new()),
288 texture_dummy_team(),
289 synthetic_boolean(false),
290 HirExpr::Null { span: None },
291 HirExpr::Null { span: None },
292 ],
293 )),
294 synthetic_statement(synthetic_method(
295 texture_dummy_player(),
296 "startForcingName",
297 vec![texture_marker('\u{303c}')],
298 )),
299 texture_state_assignment(synthetic_method(
300 texture_dummy_first_value(),
301 "split",
302 vec![HirExpr::Array {
303 elements: Vec::new(),
304 span: None,
305 }],
306 )),
307 synthetic_statement(synthetic_method(
308 texture_dummy_player(),
309 "startForcingName",
310 vec![texture_marker('\u{840}')],
311 )),
312 texture_state_assignment(synthetic_method(
313 synthetic_method(
314 texture_marker('\u{303c}'),
315 "replace",
316 vec![texture_state_variable(), texture_dummy_first_value()],
317 ),
318 "substring",
319 vec![synthetic_number(126.0), synthetic_boolean(true)],
320 )),
321 synthetic_statement(synthetic_call("destroyAllDummies", Vec::new())),
322 ],
323 })
324}
325
326fn synthetic_call(name: &str, args: Vec<HirExpr>) -> HirExpr {
327 HirExpr::Call {
328 name: name.to_string(),
329 args,
330 debug_source: None,
331 span: None,
332 }
333}
334
335fn synthetic_method(receiver: HirExpr, name: &str, args: Vec<HirExpr>) -> HirExpr {
336 HirExpr::ReceiverCall {
337 receiver: Box::new(receiver),
338 name: name.to_string(),
339 args,
340 span: None,
341 }
342}
343
344fn synthetic_enum(value_type: &str, value: &str) -> HirExpr {
345 HirExpr::Enum {
346 value_type: value_type.to_string(),
347 value: value.to_string(),
348 span: None,
349 }
350}
351
352fn synthetic_number(value: f64) -> HirExpr {
353 HirExpr::Number {
354 value,
355 text: value.to_string(),
356 span: None,
357 }
358}
359
360fn synthetic_boolean(value: bool) -> HirExpr {
361 HirExpr::Bool { value, span: None }
362}
363
364fn synthetic_index(array: HirExpr, index: HirExpr) -> HirExpr {
365 HirExpr::Index {
366 array: Box::new(array),
367 index: Box::new(index),
368 span: None,
369 }
370}
371
372fn synthetic_conditional(then_value: HirExpr, condition: HirExpr, else_value: HirExpr) -> HirExpr {
373 HirExpr::Conditional {
374 then_value: Box::new(then_value),
375 condition: Box::new(condition),
376 else_value: Box::new(else_value),
377 span: None,
378 }
379}
380
381fn synthetic_statement(expr: HirExpr) -> HirStmt {
382 HirStmt::Expr {
383 expr: Box::new(expr),
384 span: None,
385 }
386}
387
388fn texture_state_variable() -> HirExpr {
389 HirExpr::GlobalVar {
390 name: "__holygrail__".to_string(),
391 span: None,
392 }
393}
394
395fn texture_state_assignment(value: HirExpr) -> HirStmt {
396 HirStmt::Assign {
397 target: Box::new(texture_state_variable()),
398 value: Box::new(value),
399 span: None,
400 }
401}
402
403fn texture_dummy_player() -> HirExpr {
404 synthetic_call("getLastCreatedEntity", Vec::new())
405}
406
407fn texture_dummy_first_value() -> HirExpr {
408 synthetic_index(texture_dummy_player(), synthetic_number(0.0))
409}
410
411fn texture_dummy_team() -> HirExpr {
412 let team_1 = synthetic_enum("Team", "TEAM_1");
413 let team_2 = synthetic_enum("Team", "TEAM_2");
414 synthetic_conditional(
415 team_1.clone(),
416 synthetic_call("getNumberOfSlots", vec![team_1]),
417 synthetic_conditional(
418 team_2.clone(),
419 synthetic_call("getNumberOfSlots", vec![team_2]),
420 synthetic_boolean(true),
421 ),
422 )
423}
424
425fn texture_marker(suffix: char) -> HirExpr {
426 HirExpr::String {
427 value: format!("{}{}", "_".repeat(126), suffix),
428 span: None,
429 }
430}
431
432pub(crate) fn lower_settings_expression(
435 program: &cst::Program,
436 text: &str,
437 file: u32,
438 origin: crate::diag::Position,
439) -> OpyResult<HirExpr> {
440 let expression = crate::parser::parse_expression_fragment(text, file, origin)?;
441 let manifest = Manifest::builtin().map_err(|error| {
442 OpyError::new(
443 "manifest-error",
444 format!("cannot load the OPY semantic compatibility manifest: {error}"),
445 )
446 })?;
447 let catalog = Catalog::builtin().map_err(|error| {
448 OpyError::new(
449 "catalog-error",
450 format!("cannot load the Workshop catalog: {error}"),
451 )
452 })?;
453 let mut lowerer = Lowerer::new(manifest, catalog);
454 lowerer.current_order = program.top_level.len();
455 lowerer.allow_dict_literal = true;
456 lowerer.collect_symbols(program);
457 let lowered = lowerer.lower_expr(&expression, &[], CallPosition::Value);
458 lowerer.errors.into_iter().next().map_or(Ok(lowered), Err)
459}
460
461fn prefixed_rule_name(name: &str, prefix: Option<&str>, delimiter: bool) -> String {
462 match prefix {
463 Some(prefix) if !prefix.is_empty() && !delimiter && !name.is_empty() => {
464 format!("[{prefix}] {name}")
465 }
466 _ => name.to_string(),
467 }
468}
469
470pub(crate) fn strip_rule_name_formatting(text: &str) -> impl Iterator<Item = char> + '_ {
471 text.chars().filter(|character| {
472 !matches!(
473 character,
474 '\u{200B}' | '\u{200E}' | '\u{200F}' | '\u{FEFF}' | '\u{061C}'
475 )
476 })
477}
478
479#[derive(Clone, Debug)]
480enum TemplateValue {
481 String(String),
482 Bool(bool),
483}
484
485fn rule_template_values(
486 name: &str,
487 prefix: &str,
488 file: &str,
489 path: &str,
490 delimiter: bool,
491) -> [(&'static str, TemplateValue); 14] {
492 [
493 ("$rule", TemplateValue::String(name.to_string())),
494 ("$prefix", TemplateValue::String(prefix.to_string())),
495 ("$file", TemplateValue::String(file.to_string())),
496 ("$path", TemplateValue::String(path.to_string())),
497 ("$isDelimiter", TemplateValue::Bool(delimiter)),
498 ("$prefixTitle", TemplateValue::String(title_case(prefix))),
499 ("$prefixUpper", TemplateValue::String(prefix.to_uppercase())),
500 ("$prefixLower", TemplateValue::String(prefix.to_lowercase())),
501 ("$fileTitle", TemplateValue::String(title_case(file))),
502 ("$fileUpper", TemplateValue::String(file.to_uppercase())),
503 ("$fileLower", TemplateValue::String(file.to_lowercase())),
504 ("$pathTitle", TemplateValue::String(title_case(path))),
505 ("$pathUpper", TemplateValue::String(path.to_uppercase())),
506 ("$pathLower", TemplateValue::String(path.to_lowercase())),
507 ]
508}
509
510fn render_rule_name(
511 name: &str,
512 prefix: Option<&str>,
513 delimiter: bool,
514 span: Span,
515 files: &[SourceFile],
516 preprocessing: &PreprocessingState,
517) -> OpyResult<String> {
518 let Some(template) = preprocessing
519 .rule_prefix_template
520 .as_ref()
521 .map(|value| value.value.as_str())
522 else {
523 return Ok(
524 strip_rule_name_formatting(&prefixed_rule_name(name, prefix, delimiter)).collect(),
525 );
526 };
527 let (file, path) = rule_file_parts(span.file, files);
528 let prefix = prefix.unwrap_or_default();
529 let values = rule_template_values(name, prefix, &file, &path, delimiter);
530 evaluate_template(template, &values)
531 .map(|name| strip_rule_name_formatting(&name).collect())
532 .map_err(|message| {
533 OpyError::at(
534 "rule-prefix-template-invalid",
535 format!("could not resolve rule prefix template: {message}"),
536 span,
537 )
538 })
539}
540
541pub(crate) fn render_generated_rule_name(name: &str, preprocessing: &PreprocessingState) -> String {
544 let Some(template) = preprocessing
545 .rule_prefix_template
546 .as_ref()
547 .map(|value| value.value.as_str())
548 else {
549 return name.to_string();
550 };
551 let values = rule_template_values(name, "", "", "", false);
552 evaluate_template(template, &values)
553 .map(|rendered| strip_rule_name_formatting(&rendered).collect())
554 .unwrap_or_else(|_| name.to_string())
555}
556
557fn rule_file_parts(file_id: u32, files: &[SourceFile]) -> (String, String) {
558 let path = files
559 .iter()
560 .find(|file| file.id == file_id)
561 .map(|file| file.path.replace('\\', "/"))
562 .unwrap_or_default();
563 let without_extension = path
564 .strip_suffix(".opy")
565 .or_else(|| path.strip_suffix(".OPY"))
566 .unwrap_or(&path)
567 .to_string();
568 let file = without_extension
569 .rsplit('/')
570 .next()
571 .unwrap_or_default()
572 .to_string();
573 (file, without_extension)
574}
575
576fn title_case(value: &str) -> String {
577 let mut result = String::with_capacity(value.len());
578 let mut capitalize = true;
579 for ch in value.chars() {
580 if ch == '_' {
581 result.push(' ');
582 capitalize = true;
583 } else if capitalize && ch.is_ascii_alphabetic() {
584 result.push(ch.to_ascii_uppercase());
585 capitalize = false;
586 } else {
587 result.push(ch);
588 if !ch.is_whitespace() && ch != '/' {
589 capitalize = false;
590 }
591 }
592 if ch == '/' || ch.is_whitespace() {
593 capitalize = true;
594 }
595 }
596 result
597}
598
599fn evaluate_template(template: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
600 if let Some((then_value, condition, else_value)) = split_conditional(template) {
601 let branch = if evaluate_condition(condition, values)? {
602 then_value
603 } else {
604 else_value
605 };
606 return evaluate_string(branch, values);
607 }
608 evaluate_string(template, values)
609}
610
611fn split_conditional(value: &str) -> Option<(&str, &str, &str)> {
612 let mut quote = None;
613 let mut depth = 0usize;
614 let mut if_start = None;
615 let mut else_start = None;
616 for (index, ch) in value.char_indices() {
617 match (ch, quote) {
618 ('"' | '\'', None) => quote = Some(ch),
619 (ch, Some(current)) if ch == current => quote = None,
620 ('{', None) => depth += 1,
621 ('}', None) => depth = depth.saturating_sub(1),
622 _ => {}
623 }
624 if quote.is_none() && depth == 0 {
625 if value[index..].starts_with(" if ") && if_start.is_none() {
626 if_start = Some(index);
627 } else if value[index..].starts_with(" else ") && else_start.is_none() {
628 else_start = Some(index);
629 }
630 }
631 }
632 let (Some(if_start), Some(else_start)) = (if_start, else_start) else {
633 return None;
634 };
635 Some((
636 value[..if_start].trim(),
637 value[if_start + 4..else_start].trim(),
638 value[else_start + 6..].trim(),
639 ))
640}
641
642fn evaluate_condition(value: &str, values: &[(&str, TemplateValue)]) -> Result<bool, String> {
643 let value = value.trim();
644 if let Some(rest) = value.strip_prefix("not ") {
645 return Ok(!evaluate_condition(rest, values)?);
646 }
647 if let Some((left, right)) = value.split_once(" or ") {
648 return Ok(evaluate_condition(left, values)? || evaluate_condition(right, values)?);
649 }
650 if let Some((left, right)) = value.split_once(" and ") {
651 return Ok(evaluate_condition(left, values)? && evaluate_condition(right, values)?);
652 }
653 match lookup_template_value(value, values)? {
654 TemplateValue::Bool(value) => Ok(value),
655 TemplateValue::String(value) => Ok(!value.is_empty()),
656 }
657}
658
659fn evaluate_string(value: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
660 let value = value.trim();
661 if let Some(body) = value
662 .strip_prefix("f\"")
663 .and_then(|body| body.strip_suffix('"'))
664 {
665 return interpolate_fstring(body, values);
666 }
667 if let Some(body) = value
668 .strip_prefix("f'")
669 .and_then(|body| body.strip_suffix('\''))
670 {
671 return interpolate_fstring(body, values);
672 }
673 if value.len() >= 2
674 && ((value.starts_with('"') && value.ends_with('"'))
675 || (value.starts_with('\'') && value.ends_with('\'')))
676 {
677 return Ok(value[1..value.len() - 1].to_string());
678 }
679 match lookup_template_value(value, values)? {
680 TemplateValue::String(value) => Ok(value),
681 TemplateValue::Bool(value) => Ok(value.to_string()),
682 }
683}
684
685fn interpolate_fstring(body: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
686 let mut result = String::new();
687 let mut remaining = body;
688 while let Some(start) = remaining.find('{') {
689 result.push_str(&remaining[..start]);
690 let end = remaining[start + 1..]
691 .find('}')
692 .ok_or_else(|| "unterminated interpolation".to_string())?
693 + start
694 + 1;
695 result.push_str(&evaluate_string(&remaining[start + 1..end], values)?);
696 remaining = &remaining[end + 1..];
697 }
698 result.push_str(remaining);
699 Ok(result)
700}
701
702fn lookup_template_value(
703 value: &str,
704 values: &[(&str, TemplateValue)],
705) -> Result<TemplateValue, String> {
706 let value = value.trim();
707 let (base, mut methods) = value
708 .split_once('.')
709 .map_or((value, ""), |(base, methods)| (base, methods));
710 let mut result = values
711 .iter()
712 .find(|(name, _)| *name == base)
713 .map(|(_, value)| value.clone())
714 .ok_or_else(|| format!("unsupported expression '{value}'"))?;
715 while !methods.is_empty() {
716 let (method, rest) = methods
717 .split_once('.')
718 .map_or((methods, ""), |(method, rest)| (method, rest));
719 if method == "upper()" {
720 result = TemplateValue::String(as_string(&result).to_uppercase());
721 } else if method == "lower()" {
722 result = TemplateValue::String(as_string(&result).to_lowercase());
723 } else if let Some(args) = method
724 .strip_prefix("replace(")
725 .and_then(|v| v.strip_suffix(')'))
726 {
727 let (from, to) = args
728 .split_once(',')
729 .ok_or_else(|| "replace expects two arguments".to_string())?;
730 let from = unquote_template_arg(from.trim())?;
731 let to = unquote_template_arg(to.trim())?;
732 result = TemplateValue::String(as_string(&result).replace(&from, &to));
733 } else {
734 return Err(format!("unsupported method '{method}'"));
735 }
736 methods = rest;
737 }
738 Ok(result)
739}
740
741fn as_string(value: &TemplateValue) -> String {
742 match value {
743 TemplateValue::String(value) => value.clone(),
744 TemplateValue::Bool(value) => value.to_string(),
745 }
746}
747
748fn unquote_template_arg(value: &str) -> Result<String, String> {
749 if value.len() >= 2
750 && ((value.starts_with('"') && value.ends_with('"'))
751 || (value.starts_with('\'') && value.ends_with('\'')))
752 {
753 Ok(value[1..value.len() - 1].to_string())
754 } else {
755 Err(format!("expected a quoted string argument, got '{value}'"))
756 }
757}
758
759fn lower_annotations(annotations: &[cst::Annotation]) -> Vec<HirAnnotation> {
760 annotations
761 .iter()
762 .map(|annotation| HirAnnotation {
763 name: annotation.name.clone(),
764 args: annotation
765 .args
766 .iter()
767 .map(|arg| HirAnnotationArg {
768 text: arg.text.clone(),
769 span: Some(arg.span.into()),
770 })
771 .collect(),
772 span: Some(annotation.span.into()),
773 })
774 .collect()
775}
776
777fn lower_settings(settings: &cst::Settings) -> HirSettings {
779 HirSettings {
780 span: Some(settings.span.into()),
781 children: settings.children.iter().map(lower_settings_node).collect(),
782 }
783}
784
785fn lower_settings_node(node: &cst::SettingsNode) -> HirSettingsNode {
786 match node {
787 cst::SettingsNode::Group {
788 name,
789 children,
790 span,
791 } => HirSettingsNode::Group {
792 name: name.clone(),
793 children: children.iter().map(lower_settings_node).collect(),
794 span: Some((*span).into()),
795 },
796 cst::SettingsNode::Number { name, value, span } => HirSettingsNode::Number {
797 name: name.clone(),
798 value: *value,
799 span: Some((*span).into()),
800 },
801 cst::SettingsNode::Bool { name, value, span } => HirSettingsNode::Bool {
802 name: name.clone(),
803 value: *value,
804 span: Some((*span).into()),
805 },
806 cst::SettingsNode::String { name, value, span } => HirSettingsNode::String {
807 name: name.clone(),
808 value: value.clone(),
809 span: Some((*span).into()),
810 },
811 cst::SettingsNode::Raw { name, value, span } => HirSettingsNode::Raw {
812 name: name.clone(),
813 value: value.clone(),
814 span: Some((*span).into()),
815 },
816 cst::SettingsNode::List {
817 name,
818 elements,
819 span,
820 } => HirSettingsNode::List {
821 name: name.clone(),
822 elements: elements
823 .iter()
824 .map(|element| crate::hir::types::SettingsListElement {
825 value: element.value.clone(),
826 span: Some(element.span.into()),
827 })
828 .collect(),
829 span: Some((*span).into()),
830 },
831 }
832}
833
834impl Lowerer {
835 fn error_at(&mut self, code: &str, message: String, span: Span) {
836 self.errors.push(OpyError::at(code, message, span));
837 }
838}
839
840fn keyword_spellings(param: &Param) -> Vec<String> {
842 let mut spellings = vec![param.name.clone()];
843 spellings.extend(param.alternate_names.iter().cloned());
844 spellings
845}
846
847fn arg_span(args: &[CallArg]) -> Span {
850 args.first().map(CallArg::span).unwrap_or_else(|| {
851 Span::new(
852 0,
853 crate::diag::Position::new(1, 1),
854 crate::diag::Position::new(1, 1),
855 )
856 })
857}
858
859fn context_player_expr(name: &str, span: Option<Span>) -> Option<HirExpr> {
864 match name {
865 "eventPlayer" => Some(HirExpr::EventPlayer {
866 span: span.map(Into::into),
867 }),
868 "localPlayer" | "attacker" | "victim" | "healer" | "healee" => Some(HirExpr::Call {
869 name: name.to_string(),
870 args: Vec::new(),
871 debug_source: None,
872 span: span.map(Into::into),
873 }),
874 "hostPlayer" => Some(HirExpr::HostPlayer {
875 span: span.map(Into::into),
876 }),
877 _ => None,
878 }
879}
880
881#[derive(Debug, Clone, Copy, PartialEq, Eq)]
886pub enum NameNamespace {
887 Global,
889 Player,
891 Subroutine,
893}
894
895pub fn is_reserved_name(name: &str, namespace: NameNamespace) -> bool {
906 match namespace {
907 NameNamespace::Player => matches!(name, "x" | "y" | "z"),
908 NameNamespace::Global => {
909 RESERVED_KEYWORDS.contains(&name) || RESERVED_GLOBAL_NAMES.contains(&name)
910 }
911 NameNamespace::Subroutine => {
912 RESERVED_KEYWORDS.contains(&name)
913 || Manifest::builtin().ok().is_some_and(|manifest| {
914 manifest
915 .resolve_function(name)
916 .is_some_and(|function| function.params.is_empty())
917 })
918 }
919 }
920}
921
922const RESERVED_KEYWORDS: &[&str] = &[
925 "and",
926 "bool",
927 "case",
928 "def",
929 "default",
930 "del",
931 "elif",
932 "else",
933 "enum",
934 "float",
935 "for",
936 "globalvar",
937 "goto",
938 "if",
939 "in",
940 "int",
941 "lambda",
942 "loc",
943 "macro",
944 "not",
945 "or",
946 "playervar",
947 "rule",
948 "self",
949 "settings",
950 "signed",
951 "subroutine",
952 "switch",
953 "unsigned",
954 "while",
955];
956
957const RESERVED_GLOBAL_NAMES: &[&str] = &[
962 "AccelReeval",
963 "Array",
964 "AssistId",
965 "AssistReeval",
966 "AsyncBehavior",
967 "BarrierLos",
968 "Beam",
969 "BoolLiteral",
970 "Button",
971 "ButtonLiteral",
972 "ChaseRateReeval",
973 "ChaseTimeReeval",
974 "Clip",
975 "Color",
976 "ColorLiteral",
977 "Comms",
978 "CustomStringLiteral",
979 "DamageModificationId",
980 "DamageReeval",
981 "Dict",
982 "DictElem",
983 "DictKey",
984 "Direction",
985 "DotId",
986 "DynamicEffect",
987 "Effect",
988 "EffectReeval",
989 "EntityId",
990 "FacingReeval",
991 "FloatLiteral",
992 "Gamemode",
993 "GamemodeLiteral",
994 "GlobalVariable",
995 "HealingModificationId",
996 "HealingReeval",
997 "Health",
998 "HealthPoolId",
999 "Hero",
1000 "HeroLiteral",
1001 "HeroStat",
1002 "HotId",
1003 "HudPosition",
1004 "HudReeval",
1005 "Icon",
1006 "IconReeval",
1007 "Impulse",
1008 "IntLiteral",
1009 "Invis",
1010 "Label",
1011 "Lambda",
1012 "LocalizedStringLiteral",
1013 "LosCheck",
1014 "Map",
1015 "MapLiteral",
1016 "ModifyHealth",
1017 "Object",
1018 "OutlineVisibility",
1019 "Player",
1020 "PlayerVariable",
1021 "Position",
1022 "ProgressHudReeval",
1023 "ProgressWorldTextReeval",
1024 "Projectile",
1025 "ProjectileEffectReeval",
1026 "Raycast",
1027 "Relativity",
1028 "SignedFloatLiteral",
1029 "SignedIntLiteral",
1030 "SpecVisibility",
1031 "StartRuleBehavior",
1032 "Stat",
1033 "Status",
1034 "String",
1035 "StringLiteral",
1036 "Subroutine",
1037 "Team",
1038 "TeamLiteral",
1039 "TextId",
1040 "Throttle",
1041 "ThrottleReeval",
1042 "Transform",
1043 "Type",
1044 "UnsignedFloatLiteral",
1045 "UnsignedIntLiteral",
1046 "Value",
1047 "Vector",
1048 "Velocity",
1049 "Wait",
1050 "WorldTextReeval",
1051 "__ChaseReeval__",
1052 "__Operation__",
1053 "__Operator__",
1054 "__Rounding__",
1055 "__global__",
1056 "attacker",
1057 "break",
1058 "continue",
1059 "eventAbility",
1060 "eventDamage",
1061 "eventDirection",
1062 "eventHealing",
1063 "eventPlayer",
1064 "eventWasCriticalHit",
1065 "eventWasEnvironment",
1066 "eventWasHealthPack",
1067 "false",
1068 "healee",
1069 "healer",
1070 "hostPlayer",
1071 "localPlayer",
1072 "null",
1073 "pass",
1074 "return",
1075 "ruleCondition",
1076 "true",
1077 "victim",
1078 "void",
1079];
1080
1081fn assignable_receiver(receiver: &Expr) -> bool {
1086 match receiver {
1087 Expr::Name { name, .. } => !matches!(
1088 name.as_str(),
1089 "eventPlayer" | "hostPlayer" | "attacker" | "victim"
1090 ),
1091 Expr::Array { .. } | Expr::Index { .. } | Expr::Member { .. } => true,
1092 _ => false,
1093 }
1094}
1095
1096fn expr_identifier(expr: &Expr) -> Option<&str> {
1097 match expr {
1098 Expr::Name { name, .. } => Some(name.as_str()),
1099 _ => None,
1100 }
1101}
1102
1103fn indexed_expr_depth(expr: &Expr) -> usize {
1104 match expr {
1105 Expr::Index { array, .. } => 1 + indexed_expr_depth(array),
1106 _ => 0,
1107 }
1108}
1109
1110impl From<Span> for HirSpan {
1111 fn from(span: Span) -> HirSpan {
1112 HirSpan {
1113 file: span.file,
1114 start: Position {
1115 line: span.start.line,
1116 col: span.start.col,
1117 },
1118 end: Position {
1119 line: span.end.line,
1120 col: span.end.col,
1121 },
1122 }
1123 }
1124}
1125
1126impl From<&Span> for HirSpan {
1127 fn from(span: &Span) -> HirSpan {
1128 (*span).into()
1129 }
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134 use super::*;
1135 use crate::hir::types::{Expr as HirExpr, RuleEntry as HirRuleEntry, Stmt as HirStmt};
1136 use crate::lexer::{LexInput, lex};
1137 use crate::parser::parse;
1138
1139 fn lower_ok(text: &str) -> HirProgram {
1140 let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
1141 let output = parse(&tokens);
1142 assert!(
1143 output.errors.is_empty(),
1144 "unexpected parse errors: {:?}",
1145 output.errors
1146 );
1147 let program = output.program.expect("parse produces a program");
1148 lower(&program, vec![], vec![]).expect("lowers without errors")
1149 }
1150
1151 fn rule_conditions_and_actions(hir: &HirProgram) -> (&Vec<HirExpr>, &Vec<HirStmt>) {
1152 let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
1153 panic!("expected a rule");
1154 };
1155 (&rule.conditions, &rule.actions)
1156 }
1157
1158 #[test]
1159 fn producer_emits_the_v2_ordered_switch_contract() {
1160 let hir = lower_ok(
1161 "globalvar value\nrule \"r\":\n @Event global\n switch value:\n default:\n value = 1\n case 2:\n value = 2\n",
1162 );
1163 assert_eq!(hir.protocol.name, "wright/opy-hir");
1164 assert_eq!(hir.protocol.version, "2.0.0");
1165 let value = serde_json::to_value(&hir).expect("HIR must serialize");
1166 let switch = &value["rules"][0]["actions"][0];
1167 assert!(switch.get("arms").is_some());
1168 assert!(switch.get("cases").is_none());
1169 assert!(switch.get("default").is_none());
1170 }
1171
1172 #[test]
1173 fn receiver_calls_lower_to_receiver_call_hir() {
1174 let hir = lower_ok(
1178 "globalvar target\nrule \"r\":\n @Event eachPlayer\n eventPlayer.setMoveSpeed(100)\n target.setMoveSpeed(50)\n",
1179 );
1180 let (_, actions) = rule_conditions_and_actions(&hir);
1181 assert_eq!(actions.len(), 2);
1182
1183 let HirStmt::Expr { expr, .. } = &actions[0] else {
1184 panic!("expected expression statement");
1185 };
1186 let HirExpr::ReceiverCall {
1187 receiver,
1188 name,
1189 args,
1190 ..
1191 } = expr.as_ref()
1192 else {
1193 panic!("expected receiver call, got {expr:?}");
1194 };
1195 assert_eq!(name, "setMoveSpeed");
1196 assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
1197 assert_eq!(args.len(), 1);
1198 assert!(matches!(&args[0], HirExpr::Number { .. }));
1199
1200 let HirStmt::Expr { expr, .. } = &actions[1] else {
1201 panic!("expected expression statement");
1202 };
1203 let HirExpr::ReceiverCall { receiver, name, .. } = expr.as_ref() else {
1204 panic!("expected receiver call, got {expr:?}");
1205 };
1206 assert_eq!(name, "setMoveSpeed");
1207 assert!(
1208 matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "target"),
1209 "globalvar receiver must resolve to a GlobalVar"
1210 );
1211 }
1212
1213 #[test]
1214 fn bare_variable_member_expression_preserves_receiver_and_member() {
1215 let hir = lower_ok(
1216 "globalvar A\nplayervar B\nrule \"receiver\":\n @Event eachPlayer\n A = B.C\n",
1217 );
1218 let HirStmt::Assign { value, .. } = &hir
1219 .rules
1220 .iter()
1221 .find_map(|entry| {
1222 let RuleEntry::Rule(rule) = entry else {
1223 return None;
1224 };
1225 rule.actions.first()
1226 })
1227 .expect("assignment")
1228 else {
1229 panic!("expected assignment");
1230 };
1231 let HirExpr::Member {
1232 receiver, member, ..
1233 } = value.as_ref()
1234 else {
1235 panic!("expected opaque member expression, got {value:?}");
1236 };
1237 assert_eq!(member, "C");
1238 assert!(matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "B"));
1239 }
1240
1241 #[test]
1242 fn context_player_components_lower_to_member_expressions() {
1243 for (receiver_name, event) in [
1247 ("eventPlayer", "eachPlayer"),
1248 ("hostPlayer", "eachPlayer"),
1249 ("localPlayer", "eachPlayer"),
1250 ("attacker", "playerDied"),
1251 ("victim", "playerDied"),
1252 ] {
1253 for axis in ["x", "y", "z"] {
1254 let hir = lower_ok(&format!(
1255 "globalvar g\nrule \"r\":\n @Event {event}\n g = {receiver_name}.{axis}\n"
1256 ));
1257 let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
1258 panic!("expected a rule");
1259 };
1260 let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1261 panic!("expected an assign statement");
1262 };
1263 let HirExpr::Member {
1264 receiver, member, ..
1265 } = value.as_ref()
1266 else {
1267 panic!("{receiver_name}.{axis}: expected member expression, got {value:?}");
1268 };
1269 assert_eq!(member, axis, "{receiver_name}.{axis}");
1270 match receiver_name {
1271 "eventPlayer" => {
1272 assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }))
1273 }
1274 "hostPlayer" => {
1275 assert!(matches!(receiver.as_ref(), HirExpr::HostPlayer { .. }))
1276 }
1277 _ => assert!(
1278 matches!(receiver.as_ref(), HirExpr::Call { name, .. } if name == receiver_name),
1279 "{receiver_name}.{axis}: receiver must keep its source identity"
1280 ),
1281 }
1282 }
1283 }
1284 }
1285
1286 #[test]
1287 fn context_player_non_component_members_stay_player_variables() {
1288 let value = lowered_value(
1292 "globalvar g\nplayervar pos\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.pos\n",
1293 );
1294 assert!(
1295 matches!(&value, HirExpr::PlayerVar { name, .. } if name == "pos"),
1296 "declared playervar member must stay a player-variable reference, got {value:?}"
1297 );
1298
1299 let value = lowered_value(
1300 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.X\n",
1301 );
1302 assert!(
1303 matches!(&value, HirExpr::PlayerVar { name, .. } if name == "X"),
1304 "default-var member must stay a player-variable reference, got {value:?}"
1305 );
1306
1307 let error = compile_error(&action_source("g = eventPlayer.pos"), 4);
1308 assert_eq!(error.code, "unknown-member");
1309 }
1310
1311 #[test]
1312 fn rule_prefix_template_is_global_and_subroutine_identity_is_preserved() {
1313 let text = "rule \"before\":\n pass\ndef source_name():\n @Name \"Friendly\"\n pass\nrule \"after\":\n pass\n";
1314 let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
1315 let output = parse(&tokens);
1316 assert!(
1317 output.errors.is_empty(),
1318 "unexpected parse errors: {:?}",
1319 output.errors
1320 );
1321 let program = output.program.expect("program");
1322 let preprocessing = PreprocessingState {
1323 rule_prefix_template: Some(crate::hir::types::DirectiveValue {
1324 value: "f\"[{$pathTitle.replace('_', ' ')}] {$rule}\" if $rule and not $isDelimiter else $rule".to_string(),
1325 span: None,
1326 }),
1327 ..PreprocessingState::default()
1328 };
1329 let hir = lower_with_preprocessing(
1330 &program,
1331 vec![SourceFile {
1332 id: 0,
1333 path: "main.opy".to_string(),
1334 }],
1335 vec![],
1336 &preprocessing,
1337 )
1338 .expect("lowers");
1339 let names: Vec<_> = hir
1340 .rules
1341 .iter()
1342 .map(|entry| match entry {
1343 HirRuleEntry::Rule(rule) => rule.name.clone(),
1344 HirRuleEntry::SubroutineDef { name, .. } => name.clone(),
1345 })
1346 .collect();
1347 assert_eq!(
1348 names,
1349 vec!["[Main] before", "[Main] Friendly", "[Main] after"]
1350 );
1351 let HirRuleEntry::SubroutineDef {
1352 name, source_name, ..
1353 } = &hir.rules[1]
1354 else {
1355 panic!("expected subroutine definition");
1356 };
1357 assert_eq!(name, "[Main] Friendly");
1358 assert_eq!(source_name, "source_name");
1359 }
1360
1361 #[test]
1362 fn receiver_call_values_lower_in_conditions() {
1363 let hir = lower_ok(
1367 "rule \"r\":\n @Event eachPlayer\n @Condition eventPlayer.isAlive()\n eventPlayer.teleport(eventPlayer.getPosition())\n",
1368 );
1369 let (conditions, actions) = rule_conditions_and_actions(&hir);
1370 assert_eq!(conditions.len(), 1);
1371 let HirExpr::ReceiverCall { name, args, .. } = &conditions[0] else {
1372 panic!("expected receiver call condition, got {:?}", conditions[0]);
1373 };
1374 assert_eq!(name, "isAlive");
1375 assert_eq!(args.len(), 0);
1376
1377 let HirStmt::Expr { expr, .. } = &actions[0] else {
1378 panic!("expected expression statement");
1379 };
1380 let HirExpr::ReceiverCall {
1381 name,
1382 args,
1383 receiver,
1384 ..
1385 } = expr.as_ref()
1386 else {
1387 panic!("expected receiver call, got {expr:?}");
1388 };
1389 assert_eq!(name, "teleport");
1390 assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
1391 assert_eq!(args.len(), 1);
1392 assert!(matches!(
1393 &args[0],
1394 HirExpr::ReceiverCall { name, .. } if name == "getPosition"
1395 ));
1396 }
1397
1398 #[test]
1399 fn format_string_receiver_stays_a_format_node() {
1400 let hir = lower_ok(
1403 "rule \"r\":\n @Event global\n print(\"{} points\".format(len([1, 2])))\n",
1404 );
1405 let (_, actions) = rule_conditions_and_actions(&hir);
1406 let HirStmt::Expr { expr, .. } = &actions[0] else {
1407 panic!("expected expression statement");
1408 };
1409 assert!(
1410 has_format(expr),
1411 "string `.format()` must lower to a Format node"
1412 );
1413 }
1414
1415 fn has_format(expr: &HirExpr) -> bool {
1416 match expr {
1417 HirExpr::Format { .. } => true,
1418 HirExpr::Call { args, .. } => args.iter().any(has_format),
1419 HirExpr::ReceiverCall { args, .. } => args.iter().any(has_format),
1420 _ => false,
1421 }
1422 }
1423
1424 fn lowered_value(source: &str) -> HirExpr {
1426 let program = crate::compile(source, "test.opy", std::path::Path::new(""))
1427 .unwrap_or_else(|error| panic!("compile failed: {error}"));
1428 let RuleEntry::Rule(rule) = &program.rules[0] else {
1429 panic!("expected a rule");
1430 };
1431 let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1432 panic!("expected an assign statement");
1433 };
1434 (**value).clone()
1435 }
1436
1437 #[test]
1438 fn chase_time_reeval_none_lowers_to_the_catalog_enum() {
1439 let value = lowered_value(
1440 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.NONE\n",
1441 );
1442 assert_enum(&value, "ChaseTimeReeval", "NONE");
1443 }
1444
1445 #[test]
1446 fn chase_time_reeval_destination_and_duration_lowers_to_the_catalog_enum() {
1447 let value = lowered_value(
1448 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.DESTINATION_AND_DURATION\n",
1449 );
1450 assert_enum(&value, "ChaseTimeReeval", "DESTINATION_AND_DURATION");
1451 }
1452
1453 #[test]
1454 fn chase_rate_reeval_members_lower_to_the_catalog_enum() {
1455 for member in ["NONE", "DESTINATION_AND_RATE"] {
1456 let source = format!(
1457 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseRateReeval.{member}\n"
1458 );
1459 assert_enum(&lowered_value(&source), "ChaseRateReeval", member);
1460 }
1461 }
1462
1463 fn assert_enum(value: &HirExpr, domain: &str, member: &str) {
1466 match value {
1467 HirExpr::Enum {
1468 value_type, value, ..
1469 } => {
1470 assert_eq!(value_type, domain);
1471 assert_eq!(value, member);
1472 }
1473 other => panic!("expected enum {domain}.{member}, got {other:?}"),
1474 }
1475 }
1476
1477 #[test]
1478 fn unknown_chase_time_reeval_member_is_rejected_by_the_catalog() {
1479 let error = crate::compile(
1480 "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.NOPE\n",
1481 "test.opy",
1482 std::path::Path::new(""),
1483 )
1484 .expect_err("unknown catalog member must be rejected");
1485 assert_eq!(error.code, "unknown-enum-member");
1486 }
1487
1488 #[test]
1489 fn unknown_enum_receiver_is_an_unsupported_member_error() {
1490 let error = crate::compile(
1491 "globalvar g\nrule \"r\":\n @Event global\n g = NotARealEnum.MEMBER\n",
1492 "test.opy",
1493 std::path::Path::new(""),
1494 )
1495 .expect_err("an unknown enum type must fail");
1496 assert_eq!(error.code, "unsupported-member");
1497 let span = error.span.expect("the error is source-located");
1498 assert_eq!(span.start.line, 4);
1499 }
1500
1501 #[test]
1502 fn unknown_texture_member_is_rejected_by_the_source_surface() {
1503 let error = crate::compile(
1504 "globalvar g\nrule \"r\":\n @Event global\n g = Texture.NOT_A_TEXTURE\n",
1505 "test.opy",
1506 std::path::Path::new(""),
1507 )
1508 .expect_err("unknown texture member must be rejected");
1509 assert_eq!(error.code, "unknown-texture-member");
1510 }
1511
1512 fn compile_error(source: &str, line: u32) -> OpyError {
1516 let error = crate::compile(source, "test.opy", std::path::Path::new(""))
1517 .expect_err("expected a compile failure");
1518 let span = error.span.expect("the error is source-located");
1519 assert_eq!(span.start.line, line, "code '{}'", error.code);
1520 error
1521 }
1522
1523 fn action_source(statement: &str) -> String {
1524 format!("globalvar g\nrule \"r\":\n @Event global\n {statement}\n")
1525 }
1526
1527 #[test]
1528 fn chase_over_time_resolves_and_compiles_with_reference_signatures() {
1529 let hir = crate::compile(
1531 &action_source("chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE)"),
1532 "test.opy",
1533 std::path::Path::new(""),
1534 )
1535 .expect("reference-supported chaseOverTime compiles");
1536 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1537 panic!("expected a rule");
1538 };
1539 let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1540 panic!("expected expression statement");
1541 };
1542 let HirExpr::Call { name, args, .. } = expr.as_ref() else {
1543 panic!("expected a call, got {expr:?}");
1544 };
1545 assert_eq!(name, "chaseOverTime");
1546 assert_eq!(args.len(), 4);
1547 assert!(matches!(
1548 &args[3],
1549 HirExpr::Enum { value_type, value, .. }
1550 if value_type == "ChaseTimeReeval" && value == "NONE"
1551 ));
1552
1553 let hir = crate::compile(
1555 &action_source("chaseOverTime(g, 10, 3)"),
1556 "test.opy",
1557 std::path::Path::new(""),
1558 )
1559 .expect("default-reevaluation chaseOverTime compiles");
1560 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1561 panic!("expected a rule");
1562 };
1563 let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1564 panic!("expected expression statement");
1565 };
1566 let HirExpr::Call { args, .. } = expr.as_ref() else {
1567 panic!("expected a call");
1568 };
1569 assert_eq!(args.len(), 4);
1570 assert!(matches!(
1571 &args[3],
1572 HirExpr::Enum { value_type, value, .. }
1573 if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1574 ));
1575 }
1576
1577 #[test]
1578 fn is_game_in_progress_resolves_as_a_builtin_value() {
1579 let hir = crate::compile(
1581 &action_source("@Condition isGameInProgress() == true"),
1582 "test.opy",
1583 std::path::Path::new(""),
1584 )
1585 .expect("reference-supported isGameInProgress compiles");
1586 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1587 panic!("expected a rule");
1588 };
1589 assert!(matches!(&rule.conditions[0], HirExpr::Binary { .. }));
1590 }
1591
1592 #[test]
1593 fn enum_gated_members_resolve_through_the_manifest() {
1594 let source = "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
1598 @Condition eventPlayer.getThrottle() != vect(0, 0, 0)\n \
1599 @Condition worldVector(vect(1, 2, 3), eventPlayer, Transform.ROTATION) != vect(0, 0, 0)\n \
1600 eventPlayer.setInvisibility(Invis.ALL)\n \
1601 eventPlayer.setStatusEffect(eventPlayer, Status.ROOTED, 2)\n";
1602 let hir = crate::compile(source, "test.opy", std::path::Path::new(""))
1603 .expect("enum-gated members compile");
1604 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1605 panic!("expected a rule");
1606 };
1607 assert_eq!(rule.actions.len(), 2);
1608 }
1609
1610 #[test]
1611 fn get_players_in_radius_fills_reference_enum_defaults() {
1612 let hir = crate::compile(
1615 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
1616 @Condition len(getPlayersInRadius(eventPlayer.getPosition(), 10)) > 0\n \
1617 disableInspector()\n",
1618 "test.opy",
1619 std::path::Path::new(""),
1620 )
1621 .expect("getPlayersInRadius with defaults compiles");
1622 let RuleEntry::Rule(rule) = &hir.rules[0] else {
1623 panic!("expected a rule");
1624 };
1625 let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
1626 panic!("expected a comparison");
1627 };
1628 let HirExpr::Call { name, args, .. } = left.as_ref() else {
1629 panic!("expected len call");
1630 };
1631 assert_eq!(name, "len");
1632 let HirExpr::Call { name, args, .. } = &args[0] else {
1633 panic!("expected getPlayersInRadius call");
1634 };
1635 assert_eq!(name, "getPlayersInRadius");
1636 assert_eq!(args.len(), 4);
1637 assert!(matches!(
1638 &args[2],
1639 HirExpr::Enum { value_type, value, .. }
1640 if value_type == "Team" && value == "ALL"
1641 ));
1642 assert!(matches!(
1643 &args[3],
1644 HirExpr::Enum { value_type, value, .. }
1645 if value_type == "LosCheck" && value == "OFF"
1646 ));
1647 }
1648
1649 #[test]
1650 fn value_call_in_action_position_is_rejected() {
1651 let error = compile_error(&action_source("isGameInProgress()"), 4);
1652 assert_eq!(error.code, "value-in-action-position");
1653 }
1654
1655 #[test]
1656 fn value_member_in_action_position_is_rejected() {
1657 let error = compile_error(
1660 "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.isAlive()\n",
1661 4,
1662 );
1663 assert_eq!(error.code, "value-in-action-position");
1664 }
1665
1666 fn first_action_expr(source: &str) -> HirExpr {
1671 let program = crate::compile(source, "test.opy", std::path::Path::new(""))
1672 .unwrap_or_else(|error| panic!("compile failed: {error}"));
1673 let RuleEntry::Rule(rule) = &program.rules[0] else {
1674 panic!("expected a rule");
1675 };
1676 match &rule.actions[0] {
1677 HirStmt::Expr { expr, .. } => (**expr).clone(),
1678 HirStmt::Assign { value, .. } => (**value).clone(),
1679 other => panic!("expected an expression or assignment, got {other:?}"),
1680 }
1681 }
1682
1683 fn strip_spans(value: &mut serde_json::Value) {
1686 match value {
1687 serde_json::Value::Object(map) => {
1688 map.remove("span");
1689 map.remove("name_span");
1690 for nested in map.values_mut() {
1691 strip_spans(nested);
1692 }
1693 }
1694 serde_json::Value::Array(items) => {
1695 for item in items {
1696 strip_spans(item);
1697 }
1698 }
1699 _ => {}
1700 }
1701 }
1702
1703 #[test]
1704 fn chase_keyword_forms_dispatch_to_the_concrete_chase_functions() {
1705 let expr = first_action_expr(&action_source("chase(g, 10, rate=2, ChaseReeval.NONE)"));
1710 let HirExpr::Call { name, args, .. } = &expr else {
1711 panic!("expected a call, got {expr:?}");
1712 };
1713 assert_eq!(name, "chaseAtRate");
1714 assert!(matches!(
1715 &args[3],
1716 HirExpr::Enum { value_type, value, .. }
1717 if value_type == "ChaseRateReeval" && value == "NONE"
1718 ));
1719
1720 let expr = first_action_expr(&action_source(
1721 "chase(g, 10, duration=3, ChaseReeval.DESTINATION_AND_DURATION)",
1722 ));
1723 let HirExpr::Call { name, args, .. } = &expr else {
1724 panic!("expected a call, got {expr:?}");
1725 };
1726 assert_eq!(name, "chaseOverTime");
1727 assert!(matches!(
1728 &args[3],
1729 HirExpr::Enum { value_type, value, .. }
1730 if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1731 ));
1732
1733 let expr = first_action_expr(
1736 "playervar P\nrule \"r\":\n @Event eachPlayer\n \
1737 chase(eventPlayer.P, 0, rate=1, ChaseReeval.NONE)\n",
1738 );
1739 let HirExpr::Call { name, args, .. } = &expr else {
1740 panic!("expected a call, got {expr:?}");
1741 };
1742 assert_eq!(name, "chaseAtRate");
1743 assert!(matches!(&args[0], HirExpr::PlayerVar { .. }));
1744 }
1745
1746 #[test]
1747 fn chase_reeval_is_only_a_standalone_identity_inside_the_chase_context() {
1748 let error = compile_error(&action_source("g = ChaseReeval.NONE"), 4);
1752 assert_eq!(error.code, "unsupported-member");
1753
1754 let expr = first_action_expr(&action_source(
1759 "chase(g, 10, rate=2, ChaseReeval.DESTINATION_AND_DURATION)",
1760 ));
1761 let HirExpr::Call { name, args, .. } = &expr else {
1762 panic!("expected a call, got {expr:?}");
1763 };
1764 assert_eq!(name, "chaseAtRate");
1765 assert!(matches!(
1766 &args[3],
1767 HirExpr::Enum { value_type, value, .. }
1768 if value_type == "ChaseRateReeval" && value == "DESTINATION_AND_DURATION"
1769 ));
1770
1771 let expr = first_action_expr(&action_source("chase(g, 10, rate=2, 5)"));
1774 let HirExpr::Call { name, args, .. } = &expr else {
1775 panic!("expected a call, got {expr:?}");
1776 };
1777 assert_eq!(name, "chase");
1778 assert!(matches!(&args[3], HirExpr::Number { .. }));
1779 }
1780
1781 #[test]
1782 fn chase_requires_the_keyword_rate_or_duration_third_argument() {
1783 let error = compile_error(&action_source("chase(g, 10, 2, ChaseReeval.NONE)"), 4);
1784 assert_eq!(error.code, "keyword-required");
1785 assert!(error.message.contains("rate"));
1786 }
1787
1788 #[test]
1789 fn chase_family_requires_a_variable_first_argument() {
1790 let error = compile_error(&action_source("chase(10, 10, rate=2, ChaseReeval.NONE)"), 4);
1795 assert_eq!(error.code, "invalid-argument");
1796
1797 let error = compile_error(
1798 &action_source("chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)"),
1799 4,
1800 );
1801 assert_eq!(error.code, "invalid-argument");
1802
1803 let error = compile_error(
1804 &action_source("chaseAtRate(10, 0, 2, ChaseRateReeval.NONE)"),
1805 4,
1806 );
1807 assert_eq!(error.code, "invalid-argument");
1808
1809 crate::compile(
1811 &action_source("chaseAtRate(g, 0, 2, ChaseRateReeval.NONE)"),
1812 "test.opy",
1813 std::path::Path::new(""),
1814 )
1815 .expect("a variable first argument compiles");
1816 }
1817
1818 #[test]
1819 fn keyword_binding_matches_positional_binding_in_hir() {
1820 fn without_spans(expr: &HirExpr) -> serde_json::Value {
1824 let mut value = serde_json::to_value(expr).unwrap();
1825 strip_spans(&mut value);
1826 value
1827 }
1828 let keyword = without_spans(&first_action_expr(&action_source(
1829 "chaseOverTime(g, 10, duration=3)",
1830 )));
1831 let positional = without_spans(&first_action_expr(&action_source(
1832 "chaseOverTime(g, 10, 3)",
1833 )));
1834 assert_eq!(keyword, positional);
1835
1836 let keyword = without_spans(&first_action_expr(&action_source("wait(time=1)")));
1837 let positional = without_spans(&first_action_expr(&action_source("wait(1)")));
1838 assert_eq!(keyword, positional);
1839
1840 let keyword = without_spans(&first_action_expr(&action_source(
1842 "wait(waitBehavior=Wait.IGNORE_CONDITION, time=2)",
1843 )));
1844 let positional = without_spans(&first_action_expr(&action_source("wait(2)")));
1845 assert_eq!(keyword, positional);
1846
1847 let keyword = without_spans(&first_action_expr(&action_source(
1848 "g = vect(x=1, y=2, z=3)",
1849 )));
1850 let positional = without_spans(&first_action_expr(&action_source("g = vect(1, 2, 3)")));
1851 assert_eq!(keyword, positional);
1852 }
1853
1854 #[test]
1855 fn keyword_binding_diagnostics_are_structured_and_source_located() {
1856 let error = compile_error(&action_source("chaseOverTime(g, 10, bogus=1)"), 4);
1858 assert_eq!(error.code, "unknown-keyword");
1859 assert!(error.message.contains("bogus"));
1860
1861 let error = compile_error(
1863 &action_source(
1864 "chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE, \
1865 reevaluation=ChaseTimeReeval.NONE)",
1866 ),
1867 4,
1868 );
1869 assert_eq!(error.code, "duplicate-argument");
1870
1871 let error = compile_error(&action_source("chaseOverTime(g, duration=3, 5)"), 4);
1873 assert_eq!(error.code, "positional-after-keyword");
1874
1875 let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
1877 assert_eq!(error.code, "missing-argument");
1878
1879 let error = compile_error(
1882 &action_source("chase(variable=g, destination=10, rate=2, ChaseReeval.NONE)"),
1883 4,
1884 );
1885 assert_eq!(error.code, "unknown-keyword");
1886 }
1887
1888 #[test]
1889 fn keyword_arguments_are_rejected_for_reference_special_cases() {
1890 let error = compile_error(
1893 "globalvar g\nrule \"r\":\n @Event global\n \
1894 for I in range(start=0, stop=3):\n debug(I)\n",
1895 4,
1896 );
1897 assert_eq!(error.code, "keyword-unsupported");
1898
1899 let error = compile_error(&action_source("g = random.uniform(min=1, max=2)"), 4);
1900 assert_eq!(error.code, "keyword-unsupported");
1901
1902 let error = compile_error(&action_source("print(\"{} points\".format(value=1))"), 4);
1903 assert_eq!(error.code, "keyword-unsupported");
1904 }
1905
1906 #[test]
1907 fn sorted_rejects_keyword_arguments_beyond_the_key_lambda() {
1908 let error = compile_error(&action_source("g = sorted([1, 2], bogus=lambda v: v)"), 4);
1912 assert_eq!(error.code, "unknown-keyword");
1913
1914 let error = compile_error(
1915 &action_source("g = sorted(array=[1, 2], key=lambda v: -v)"),
1916 4,
1917 );
1918 assert_eq!(error.code, "unknown-keyword");
1919
1920 let error = compile_error(&action_source("g = sorted(key=lambda v: v)"), 4);
1921 assert_eq!(error.code, "unknown-keyword");
1922 }
1923
1924 #[test]
1925 fn call_args_reject_parenthesized_keyword_names() {
1926 let error = compile_error(&action_source("g = vect((g)=1, y=2, z=3)"), 4);
1929 assert_eq!(error.code, "parse-error");
1930
1931 let error = compile_error(&action_source("g = sorted([1, 2], (key)=lambda v: -v)"), 4);
1932 assert_eq!(error.code, "parse-error");
1933
1934 crate::compile(
1937 &action_source("g = vect(x=1, y=2, z=3) + vect((g), 2, 3)"),
1938 "test.opy",
1939 std::path::Path::new(""),
1940 )
1941 .expect("bare keyword arguments compile");
1942 }
1943
1944 #[test]
1945 fn call_args_reject_trailing_comma() {
1946 for statement in [
1949 "wait(1,)",
1950 "g = vect(1, 2, 3,)",
1951 "g = sorted([1, 2],)",
1952 "g = sorted([1, 2], key=lambda v: -v,)",
1953 ] {
1954 let error = compile_error(&action_source(statement), 4);
1955 assert_eq!(error.code, "parse-error", "statement: {statement}");
1956 }
1957
1958 crate::compile(
1961 &action_source("g = [1, 2,]"),
1962 "test.opy",
1963 std::path::Path::new(""),
1964 )
1965 .expect("array literal trailing comma compiles");
1966 crate::compile(
1967 &action_source("g = {\"a\": 1,}[\"a\"]"),
1968 "test.opy",
1969 std::path::Path::new(""),
1970 )
1971 .expect("dict literal trailing comma compiles");
1972 crate::compile(
1973 "rule \"r\":\n @Event playerDied(victim,)\n wait(1)\n",
1974 "test.opy",
1975 std::path::Path::new(""),
1976 )
1977 .expect("@Event argument trailing comma compiles");
1978 }
1979
1980 #[test]
1981 fn binder_lambda_rejects_parenthesized() {
1982 for statement in [
1985 "g = sorted([1, 2], (lambda v: -v))",
1986 "g = sorted([1, 2], key=(lambda v: -v))",
1987 "g = [1, 2].map((lambda v: v))",
1988 "g = [1, 2].filter((lambda v: v))",
1989 "g = [1, 2].all((lambda v: v))",
1990 "g = [1, 2].any((lambda v: v))",
1991 "g = sorted([1, 2], ((lambda v: -v)))",
1992 ] {
1993 let error = compile_error(&action_source(statement), 4);
1994 assert_eq!(error.code, "lambda-context", "statement: {statement}");
1995 }
1996
1997 crate::compile(
1999 &action_source("g = sorted([1, 2], key=lambda v: -v)"),
2000 "test.opy",
2001 std::path::Path::new(""),
2002 )
2003 .expect("bare lambda key compiles");
2004 }
2005
2006 #[test]
2007 fn wait_uses_the_reference_keyword_names() {
2008 let error = compile_error(&action_source("wait(duration=1)"), 4);
2012 assert_eq!(error.code, "unknown-keyword");
2013 assert!(error.message.contains("duration"));
2014 }
2015
2016 #[test]
2017 fn action_call_in_value_position_is_rejected() {
2018 let error = compile_error(&action_source("g = wait(1)"), 4);
2019 assert_eq!(error.code, "action-in-value-position");
2020 }
2021
2022 #[test]
2023 fn missing_required_argument_is_a_source_located_diagnostic() {
2024 let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
2028 assert_eq!(error.code, "missing-argument");
2029 assert!(error.message.contains("duration"));
2030
2031 let error = compile_error(&action_source("chaseOverTime(g, 10, 3, 4, 5)"), 4);
2032 assert_eq!(error.code, "invalid-arity");
2033 }
2034
2035 #[test]
2036 fn missing_member_argument_is_a_source_located_diagnostic() {
2037 let error = compile_error(
2042 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
2043 getPlayersInRadius(eventPlayer.getPosition(), 10).setStatusEffect(eventPlayer, 30)\n",
2044 4,
2045 );
2046 assert_eq!(error.code, "missing-argument");
2047 assert!(error.message.contains("duration"));
2048 }
2049
2050 #[test]
2051 fn invalid_receiver_categories_are_rejected() {
2052 let error = compile_error(&action_source("3.append(1)"), 4);
2056 assert_eq!(error.code, "invalid-receiver");
2057 assert!(error.message.contains("append"));
2058
2059 let error = compile_error(&action_source("attacker.append(1)"), 4);
2060 assert_eq!(error.code, "invalid-receiver");
2061 assert!(error.message.contains("append"));
2062
2063 let error = compile_error(&action_source("3.remove(1)"), 4);
2064 assert_eq!(error.code, "invalid-receiver");
2065 assert!(error.message.contains("remove"));
2066
2067 let error = compile_error(&action_source("print(3.format(\"{}\"))"), 4);
2068 assert_eq!(error.code, "invalid-receiver");
2069 assert!(error.message.contains("format"));
2070
2071 crate::compile(
2073 &action_source("g.append(1)\n g.remove(1)"),
2074 "test.opy",
2075 std::path::Path::new(""),
2076 )
2077 .expect("assignable receivers compile");
2078 crate::compile(
2079 &action_source("g = \"{}\".format(1)"),
2080 "test.opy",
2081 std::path::Path::new(""),
2082 )
2083 .expect("a string-literal receiver compiles");
2084 }
2085
2086 fn lower_with_manifest(source: &str, manifest: &'static Manifest) -> OpyResult<HirProgram> {
2090 let tokens = lex(LexInput {
2091 file_id: 0,
2092 text: source,
2093 })
2094 .expect("lexes");
2095 let output = parse(&tokens);
2096 assert!(
2097 output.errors.is_empty(),
2098 "unexpected parse errors: {:?}",
2099 output.errors
2100 );
2101 let program = output.program.expect("parse produces a program");
2102 lower_program(
2103 &program,
2104 vec![],
2105 vec![],
2106 &PreprocessingState::default(),
2107 manifest,
2108 Catalog::builtin().expect("the builtin catalog loads"),
2109 )
2110 }
2111
2112 #[test]
2113 fn special_enforcement_is_typed_policy_not_manifest_metadata() {
2114 let mut file: serde_json::Value =
2118 serde_json::from_str(crate::manifest::MANIFEST_DATA).unwrap();
2119 for function in file["functions"].as_array_mut().unwrap() {
2120 match function["id"].as_str().unwrap() {
2121 "chase" | "chaseAtRate" | "chaseOverTime" => {
2123 for param in function["params"].as_array_mut().unwrap() {
2124 param.as_object_mut().unwrap().remove("variable");
2125 }
2126 }
2127 "append" | "remove" | "format" => {
2130 function["receiver"] = "Any".into();
2131 }
2132 "wait" => function["params"][0]["variable"] = true.into(),
2136 "getPosition" => function["receiver"] = "Variable".into(),
2137 _ => {}
2138 }
2139 }
2140 let manifest: &'static Manifest = Box::leak(Box::new(
2141 Manifest::load(
2142 &serde_json::to_string(&file).unwrap(),
2143 crate::manifest::PROBES_DATA,
2144 )
2145 .expect("mutated metadata still validates"),
2146 ));
2147
2148 for call in [
2150 "chase(10, 10, rate=2, ChaseReeval.NONE)",
2151 "chaseAtRate(10, 0, 2, ChaseRateReeval.NONE)",
2152 "chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)",
2153 ] {
2154 let error = lower_with_manifest(&action_source(call), manifest)
2155 .expect_err("the chase family still requires a variable");
2156 assert_eq!(error.code, "invalid-argument", "{call}");
2157 }
2158 lower_with_manifest(&action_source("chaseOverTime(g, 10, 3)"), manifest)
2160 .expect("a variable first argument compiles");
2161
2162 for call in ["3.append(1)", "3.remove(1)", "print(3.format(\"{}\"))"] {
2165 let error = lower_with_manifest(&action_source(call), manifest)
2166 .expect_err("the enforced receiver requirement still rejects");
2167 assert_eq!(error.code, "invalid-receiver", "{call}");
2168 }
2169 lower_with_manifest(&action_source("g.append(1)"), manifest)
2170 .expect("an assignable receiver compiles");
2171 lower_with_manifest(&action_source("g = \"{}\".format(1)"), manifest)
2172 .expect("a string-literal receiver compiles");
2173
2174 lower_with_manifest(&action_source("wait(1)"), manifest)
2177 .expect("`wait`'s first argument is not variable-required");
2178 lower_with_manifest(
2179 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
2180 g = eventPlayer.getPosition()\n",
2181 manifest,
2182 )
2183 .expect("`getPosition`'s receiver is not assignable-enforced");
2184 }
2185
2186 #[test]
2187 fn cross_domain_enum_arguments_resolve_as_opaque_identities() {
2188 let expr = first_action_expr(&action_source("chaseOverTime(g, 10, 3, Invis.ALL)"));
2192 let HirExpr::Call { args, .. } = &expr else {
2193 panic!("expected a call, got {expr:?}");
2194 };
2195 assert!(matches!(
2196 &args[3],
2197 HirExpr::Enum { value_type, value, .. }
2198 if value_type == "Invis" && value == "ALL"
2199 ));
2200
2201 let expr = first_action_expr(&action_source(
2202 "eventPlayer.setInvisibility(ChaseTimeReeval.NONE)",
2203 ));
2204 let HirExpr::ReceiverCall { args, .. } = &expr else {
2205 panic!("expected a receiver call, got {expr:?}");
2206 };
2207 assert!(matches!(
2208 &args[0],
2209 HirExpr::Enum { value_type, value, .. }
2210 if value_type == "ChaseTimeReeval" && value == "NONE"
2211 ));
2212 }
2213
2214 #[test]
2215 fn non_enum_arguments_for_enum_parameters_are_carried_structurally() {
2216 let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(g)"));
2220 let HirExpr::ReceiverCall { args, .. } = &expr else {
2221 panic!("expected a receiver call, got {expr:?}");
2222 };
2223 assert!(matches!(
2224 &args[0],
2225 HirExpr::GlobalVar { name, .. } if name == "g"
2226 ));
2227
2228 let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(3)"));
2229 let HirExpr::ReceiverCall { args, .. } = &expr else {
2230 panic!("expected a receiver call, got {expr:?}");
2231 };
2232 assert!(matches!(&args[0], HirExpr::Number { .. }));
2233 }
2234
2235 #[test]
2236 fn unknown_builtins_fail_at_resolution_not_emission() {
2237 let error = compile_error(&action_source("frobnicate()"), 4);
2238 assert_eq!(error.code, "unknown-action");
2239
2240 let error = compile_error(&action_source("g = frobnicate()"), 4);
2241 assert_eq!(error.code, "unknown-value");
2242
2243 let error = compile_error(
2244 "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.frobnicate()\n",
2245 4,
2246 );
2247 assert_eq!(error.code, "unknown-member");
2248 }
2249
2250 #[test]
2251 fn wright_only_catalog_names_are_rejected() {
2252 let error = compile_error(&action_source("createHudText(1)"), 4);
2256 assert_eq!(error.code, "unknown-action");
2257
2258 let error = compile_error(&action_source("g = squareRoot(9)"), 4);
2259 assert_eq!(error.code, "unknown-value");
2260 }
2261
2262 #[test]
2263 fn canonical_ids_without_upstream_spellings_are_rejected() {
2264 for (statement, code) in [
2268 ("g = evaluateOnce(1)", "unknown-value"),
2269 ("g = lastCreatedEntity()", "unknown-value"),
2270 ("g = lastTextId()", "unknown-value"),
2271 ("g = allTankHeroes()", "unknown-value"),
2272 ("g = allDamageHeroes()", "unknown-value"),
2273 ("g = allSupportHeroes()", "unknown-value"),
2274 ("destroyAllHudText()", "unknown-action"),
2275 ] {
2276 let error = compile_error(&action_source(statement), 4);
2277 assert_eq!(error.code, code, "{statement}");
2278 }
2279
2280 let member = "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n";
2281 let error = compile_error(member, 4);
2282 assert_eq!(error.code, "unknown-member");
2283
2284 for axis in ["x", "y", "z"] {
2286 let source = format!(
2287 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.{axis}()\n"
2288 );
2289 let error = compile_error(&source, 4);
2290 assert_eq!(error.code, "unknown-member", "{axis}()");
2291 }
2292
2293 crate::compile(
2295 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = evalOnce(1)\n g = getLastCreatedEntity()\n g = getLastCreatedText()\n g = getTankHeroes()\n g = getDamageHeroes()\n g = getSupportHeroes()\n g = eventPlayer.isHoldingButton(Button.PRIMARY_FIRE)\n destroyAllHudTexts()\n",
2296 "test.opy",
2297 std::path::Path::new(""),
2298 )
2299 .expect("the upstream spellings compile");
2300 }
2301
2302 #[test]
2303 fn generic_member_only_actions_are_rejected() {
2304 let error = compile_error(&action_source("setMoveSpeed(eventPlayer, 100)"), 4);
2307 assert_eq!(error.code, "unknown-action");
2308 }
2309
2310 #[test]
2311 fn range_is_for_iterables_only() {
2312 let error = compile_error(&action_source("@Condition len(range(1, 5, 1)) > 0"), 4);
2315 assert_eq!(error.code, "invalid-call-context");
2316
2317 let error = compile_error(&action_source("for g in [1, 2]:\n debug(g)"), 4);
2318 assert_eq!(error.code, "invalid-iterable");
2319
2320 crate::compile(
2321 &action_source("for g in range(3):\n debug(g)"),
2322 "test.opy",
2323 std::path::Path::new(""),
2324 )
2325 .expect("the for-header range form compiles");
2326 }
2327
2328 #[test]
2329 fn source_aliases_resolve_to_canonical_names() {
2330 let hir = crate::compile(
2333 &action_source("stopChasingVariable(g)"),
2334 "test.opy",
2335 std::path::Path::new(""),
2336 )
2337 .expect("the alias target compiles");
2338 let RuleEntry::Rule(rule) = &hir.rules[0] else {
2339 panic!("expected a rule");
2340 };
2341 let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
2342 panic!("expected expression statement");
2343 };
2344 let HirExpr::Call { name, .. } = expr.as_ref() else {
2345 panic!("expected a call");
2346 };
2347 assert_eq!(name, "stopChasingVariable");
2348
2349 let hir = crate::compile(
2350 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
2351 @Condition eventPlayer.getCurrentHero() != null\n \
2352 @Condition eventPlayer.hasStatusEffect(Status.BURNING) == false\n \
2353 disableInspector()\n",
2354 "test.opy",
2355 std::path::Path::new(""),
2356 )
2357 .expect("member aliases compile");
2358 let RuleEntry::Rule(rule) = &hir.rules[0] else {
2359 panic!("expected a rule");
2360 };
2361 let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
2362 panic!("expected a comparison");
2363 };
2364 let HirExpr::ReceiverCall { name, .. } = left.as_ref() else {
2365 panic!("expected a receiver call");
2366 };
2367 assert_eq!(name, "getHero");
2368 }
2369
2370 #[test]
2371 fn unknown_catalog_enum_members_are_rejected() {
2372 for source in [
2373 "globalvar g\nrule \"r\":\n @Event global\n g = Color.CYAN\n",
2374 "globalvar g\nrule \"r\":\n @Event global\n g = DynamicEffect.SPARKLES\n",
2375 ] {
2376 let error = crate::compile(source, "test.opy", std::path::Path::new(""))
2377 .expect_err("unknown catalog member must be rejected");
2378 assert_eq!(error.code, "unknown-enum-member");
2379 }
2380 }
2381
2382 #[test]
2383 fn default_var_for_binder_resolves_at_all_range_arities() {
2384 for (binder, iterable) in [
2389 ("I", "range(0, 10)"),
2390 ("I", "range(3)"),
2391 ("I", "range(1, 5, 2)"),
2392 ] {
2393 let hir = lower_ok(&format!(
2394 "globalvar total\nrule \"r\":\n @Event global\n for {binder} in {iterable}:\n total += {binder}\n"
2395 ));
2396 let (_, actions) = rule_conditions_and_actions(&hir);
2397 let HirStmt::For { variable, body, .. } = &actions[0] else {
2398 panic!("expected a for statement");
2399 };
2400 assert!(
2401 matches!(variable.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2402 "the binder resolves to the implicit global 'I', got {variable:?}"
2403 );
2404 assert!(!body.is_empty(), "the loop body lowers");
2405 let HirStmt::Assign { value, .. } = &body[0] else {
2408 panic!("expected an assignment in the body");
2409 };
2410 let HirExpr::Binary { right, .. } = value.as_ref() else {
2411 panic!("expected a binary expression");
2412 };
2413 assert!(
2414 matches!(right.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2415 "the binder use inside the body resolves to the implicit global"
2416 );
2417 }
2418 }
2419
2420 #[test]
2421 fn player_variable_range_binder_preserves_host_player_receiver() {
2422 let hir = lower_ok(
2423 "playervar I\nrule \"r\":\n @Event global\n for hostPlayer.I in range(3):\n hostPlayer.I = 1\n",
2424 );
2425 let (_, actions) = rule_conditions_and_actions(&hir);
2426 let HirStmt::For { variable, .. } = &actions[0] else {
2427 panic!("expected a for statement");
2428 };
2429 let HirExpr::PlayerVar {
2430 player,
2431 name,
2432 member_span,
2433 span,
2434 } = variable.as_ref()
2435 else {
2436 panic!("expected a player-variable binder, got {variable:?}");
2437 };
2438 assert_eq!(name, "I");
2439 assert!(matches!(player.as_ref(), HirExpr::HostPlayer { .. }));
2440 assert_eq!(span.unwrap().start.line, 4);
2441 assert_eq!(span.unwrap().start.col, 9);
2442 assert_eq!(span.unwrap().end.line, 4);
2443 assert_eq!(span.unwrap().end.col, 21);
2444 let member_span = member_span.expect("player binder member span");
2445 assert_eq!(member_span.start.line, 4);
2446 assert_eq!(member_span.start.col, 20);
2447 assert_eq!(member_span.end.line, 4);
2448 assert_eq!(member_span.end.col, 21);
2449 }
2450
2451 #[test]
2452 fn default_var_names_resolve_as_implicit_globals() {
2453 let hir = lower_ok("rule \"r\":\n @Event global\n I = 5\n debug(I)\n");
2456 let (_, actions) = rule_conditions_and_actions(&hir);
2457 let HirStmt::Assign { target, .. } = &actions[0] else {
2458 panic!("expected an assignment");
2459 };
2460 assert!(
2461 matches!(target.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2462 "the implicit global resolves, got {target:?}"
2463 );
2464 assert_eq!(default_var_index("I"), Some(8));
2466 assert_eq!(default_var_index("AA"), Some(26));
2467 assert_eq!(default_var_index("Z"), Some(25));
2468 assert_eq!(default_var_index("DX"), Some(127));
2469 assert_eq!(default_var_index("DY"), None);
2470 assert_eq!(default_var_index("i"), None);
2471 }
2472
2473 #[test]
2474 fn nested_same_name_for_binders_reuse_the_implicit_global() {
2475 let hir = lower_ok(
2479 "rule \"r\":\n @Event global\n for I in range(3):\n for I in range(2):\n debug(I)\n",
2480 );
2481 let (_, actions) = rule_conditions_and_actions(&hir);
2482 let HirStmt::For {
2483 variable: outer,
2484 body,
2485 ..
2486 } = &actions[0]
2487 else {
2488 panic!("expected an outer for statement");
2489 };
2490 let HirStmt::For {
2491 variable: inner, ..
2492 } = &body[0]
2493 else {
2494 panic!("expected an inner for statement");
2495 };
2496 assert!(
2497 matches!(outer.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I")
2498 && matches!(inner.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2499 "both loops bind the same implicit global (spans differ per binder site)"
2500 );
2501 }
2502
2503 #[test]
2504 fn undeclared_lowercase_binder_is_still_an_unknown_identifier() {
2505 let error = compile_error(
2510 "rule \"r\":\n @Event global\n for i in range(3):\n debug(i)\n",
2511 3,
2512 );
2513 assert_eq!(error.code, "unknown-identifier");
2514 let span = error.span.expect("the error is source-located");
2515 assert_eq!(span.start.line, 3);
2516 }
2517
2518 #[test]
2519 fn syntax_constructs_lower_to_provenance_preserving_hir() {
2520 let hir = lower_ok(
2521 "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",
2522 );
2523 let (_, actions) = rule_conditions_and_actions(&hir);
2524 let HirStmt::DoWhile { condition, .. } = &actions[0] else {
2525 panic!("expected do-while");
2526 };
2527 assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in"));
2528 let HirStmt::Switch { arms, .. } = &actions[1] else {
2529 panic!("expected switch");
2530 };
2531 assert_eq!(arms.len(), 2);
2532 let HirSwitchArm::Case {
2533 value: case_value,
2534 body,
2535 ..
2536 } = &arms[0]
2537 else {
2538 panic!("expected case arm");
2539 };
2540 assert!(
2541 matches!(case_value.as_ref(), HirExpr::Number { value, .. } if *value == 0x10 as f64)
2542 );
2543 let HirStmt::Assign { value, .. } = &body[0] else {
2544 panic!("expected case assignment");
2545 };
2546 assert!(matches!(value.as_ref(), HirExpr::Binary { op, .. } if op == "in"));
2547 let HirSwitchArm::Default { body, .. } = &arms[1] else {
2548 panic!("expected default arm");
2549 };
2550 assert!(matches!(body[0], HirStmt::Assign { .. }));
2551 let HirStmt::Assign { value, .. } = &actions[2] else {
2552 panic!("expected comprehension assignment");
2553 };
2554 assert!(matches!(value.as_ref(), HirExpr::Comprehension { .. }));
2555 let HirStmt::Assign { value, .. } = &actions[3] else {
2556 panic!("expected sorted assignment");
2557 };
2558 assert!(
2559 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")))
2560 );
2561 let HirStmt::Assign { value, .. } = &actions[4] else {
2562 panic!("expected string assignment");
2563 };
2564 assert!(
2565 matches!(value.as_ref(), HirExpr::StringModifier { modifier, .. } if modifier == "w")
2566 );
2567 }
2568
2569 #[test]
2570 fn syntax_rejects_reference_invalid_bare_dict_and_lambda() {
2571 let dict_error = compile_error(
2572 "globalvar x\nrule \"r\":\n @Event global\n x = {\"x\": 1}\n",
2573 4,
2574 );
2575 assert_eq!(dict_error.code, "dict-access");
2576 let lambda_error = compile_error(
2577 "globalvar x\nrule \"r\":\n @Event global\n x = lambda value: value\n",
2578 4,
2579 );
2580 assert_eq!(lambda_error.code, "lambda-context");
2581 }
2582
2583 #[test]
2584 fn do_while_requires_rule_or_definition_prefix_position() {
2585 let error = compile_error(
2586 "globalvar value\nrule \"r\":\n @Event global\n value = 1\n do:\n value += 1\n while value < 2\n",
2587 5,
2588 );
2589 assert_eq!(error.code, "do-while-placement");
2590 assert_eq!(
2591 error.message,
2592 "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it"
2593 );
2594 }
2595}