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