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 wait_uses_the_reference_keyword_names() {
1883 let error = compile_error(&action_source("wait(duration=1)"), 4);
1887 assert_eq!(error.code, "unknown-keyword");
1888 assert!(error.message.contains("duration"));
1889 }
1890
1891 #[test]
1892 fn action_call_in_value_position_is_rejected() {
1893 let error = compile_error(&action_source("g = wait(1)"), 4);
1894 assert_eq!(error.code, "action-in-value-position");
1895 }
1896
1897 #[test]
1898 fn missing_required_argument_is_a_source_located_diagnostic() {
1899 let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
1903 assert_eq!(error.code, "missing-argument");
1904 assert!(error.message.contains("duration"));
1905
1906 let error = compile_error(&action_source("chaseOverTime(g, 10, 3, 4, 5)"), 4);
1907 assert_eq!(error.code, "invalid-arity");
1908 }
1909
1910 #[test]
1911 fn missing_member_argument_is_a_source_located_diagnostic() {
1912 let error = compile_error(
1917 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
1918 getPlayersInRadius(eventPlayer.getPosition(), 10).setStatusEffect(eventPlayer, 30)\n",
1919 4,
1920 );
1921 assert_eq!(error.code, "missing-argument");
1922 assert!(error.message.contains("duration"));
1923 }
1924
1925 #[test]
1926 fn invalid_receiver_categories_are_rejected() {
1927 let error = compile_error(&action_source("3.append(1)"), 4);
1930 assert_eq!(error.code, "invalid-receiver");
1931 assert!(error.message.contains("append"));
1932
1933 let error = compile_error(&action_source("attacker.append(1)"), 4);
1934 assert_eq!(error.code, "invalid-receiver");
1935 assert!(error.message.contains("append"));
1936
1937 let error = compile_error(&action_source("print(3.format(\"{}\"))"), 4);
1938 assert_eq!(error.code, "invalid-receiver");
1939 assert!(error.message.contains("format"));
1940 }
1941
1942 #[test]
1943 fn cross_domain_enum_arguments_resolve_as_opaque_identities() {
1944 let expr = first_action_expr(&action_source("chaseOverTime(g, 10, 3, Invis.ALL)"));
1948 let HirExpr::Call { args, .. } = &expr else {
1949 panic!("expected a call, got {expr:?}");
1950 };
1951 assert!(matches!(
1952 &args[3],
1953 HirExpr::Enum { value_type, value, .. }
1954 if value_type == "Invis" && value == "ALL"
1955 ));
1956
1957 let expr = first_action_expr(&action_source(
1958 "eventPlayer.setInvisibility(ChaseTimeReeval.NONE)",
1959 ));
1960 let HirExpr::ReceiverCall { args, .. } = &expr else {
1961 panic!("expected a receiver call, got {expr:?}");
1962 };
1963 assert!(matches!(
1964 &args[0],
1965 HirExpr::Enum { value_type, value, .. }
1966 if value_type == "ChaseTimeReeval" && value == "NONE"
1967 ));
1968 }
1969
1970 #[test]
1971 fn non_enum_arguments_for_enum_parameters_are_carried_structurally() {
1972 let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(g)"));
1976 let HirExpr::ReceiverCall { args, .. } = &expr else {
1977 panic!("expected a receiver call, got {expr:?}");
1978 };
1979 assert!(matches!(
1980 &args[0],
1981 HirExpr::GlobalVar { name, .. } if name == "g"
1982 ));
1983
1984 let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(3)"));
1985 let HirExpr::ReceiverCall { args, .. } = &expr else {
1986 panic!("expected a receiver call, got {expr:?}");
1987 };
1988 assert!(matches!(&args[0], HirExpr::Number { .. }));
1989 }
1990
1991 #[test]
1992 fn unknown_builtins_fail_at_resolution_not_emission() {
1993 let error = compile_error(&action_source("frobnicate()"), 4);
1994 assert_eq!(error.code, "unknown-action");
1995
1996 let error = compile_error(&action_source("g = frobnicate()"), 4);
1997 assert_eq!(error.code, "unknown-value");
1998
1999 let error = compile_error(
2000 "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.frobnicate()\n",
2001 4,
2002 );
2003 assert_eq!(error.code, "unknown-member");
2004 }
2005
2006 #[test]
2007 fn wright_only_catalog_names_are_rejected() {
2008 let error = compile_error(&action_source("createHudText(1)"), 4);
2012 assert_eq!(error.code, "unknown-action");
2013
2014 let error = compile_error(&action_source("g = squareRoot(9)"), 4);
2015 assert_eq!(error.code, "unknown-value");
2016 }
2017
2018 #[test]
2019 fn canonical_ids_without_upstream_spellings_are_rejected() {
2020 for (statement, code) in [
2024 ("g = evaluateOnce(1)", "unknown-value"),
2025 ("g = lastCreatedEntity()", "unknown-value"),
2026 ("g = lastTextId()", "unknown-value"),
2027 ("g = allTankHeroes()", "unknown-value"),
2028 ("g = allDamageHeroes()", "unknown-value"),
2029 ("g = allSupportHeroes()", "unknown-value"),
2030 ("destroyAllHudText()", "unknown-action"),
2031 ] {
2032 let error = compile_error(&action_source(statement), 4);
2033 assert_eq!(error.code, code, "{statement}");
2034 }
2035
2036 let member = "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n";
2037 let error = compile_error(member, 4);
2038 assert_eq!(error.code, "unknown-member");
2039
2040 for axis in ["x", "y", "z"] {
2042 let source = format!(
2043 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.{axis}()\n"
2044 );
2045 let error = compile_error(&source, 4);
2046 assert_eq!(error.code, "unknown-member", "{axis}()");
2047 }
2048
2049 crate::compile(
2051 "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",
2052 "test.opy",
2053 std::path::Path::new(""),
2054 )
2055 .expect("the upstream spellings compile");
2056 }
2057
2058 #[test]
2059 fn generic_member_only_actions_are_rejected() {
2060 let error = compile_error(&action_source("setMoveSpeed(eventPlayer, 100)"), 4);
2063 assert_eq!(error.code, "unknown-action");
2064 }
2065
2066 #[test]
2067 fn range_is_for_iterables_only() {
2068 let error = compile_error(&action_source("@Condition len(range(1, 5, 1)) > 0"), 4);
2071 assert_eq!(error.code, "invalid-call-context");
2072
2073 let error = compile_error(&action_source("for g in [1, 2]:\n debug(g)"), 4);
2074 assert_eq!(error.code, "invalid-iterable");
2075
2076 crate::compile(
2077 &action_source("for g in range(3):\n debug(g)"),
2078 "test.opy",
2079 std::path::Path::new(""),
2080 )
2081 .expect("the for-header range form compiles");
2082 }
2083
2084 #[test]
2085 fn source_aliases_resolve_to_canonical_names() {
2086 let hir = crate::compile(
2089 &action_source("stopChasingVariable(g)"),
2090 "test.opy",
2091 std::path::Path::new(""),
2092 )
2093 .expect("the alias target compiles");
2094 let RuleEntry::Rule(rule) = &hir.rules[0] else {
2095 panic!("expected a rule");
2096 };
2097 let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
2098 panic!("expected expression statement");
2099 };
2100 let HirExpr::Call { name, .. } = expr.as_ref() else {
2101 panic!("expected a call");
2102 };
2103 assert_eq!(name, "stopChasingVariable");
2104
2105 let hir = crate::compile(
2106 "globalvar g\nrule \"r\":\n @Event eachPlayer\n \
2107 @Condition eventPlayer.getCurrentHero() != null\n \
2108 @Condition eventPlayer.hasStatusEffect(Status.BURNING) == false\n \
2109 disableInspector()\n",
2110 "test.opy",
2111 std::path::Path::new(""),
2112 )
2113 .expect("member aliases compile");
2114 let RuleEntry::Rule(rule) = &hir.rules[0] else {
2115 panic!("expected a rule");
2116 };
2117 let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
2118 panic!("expected a comparison");
2119 };
2120 let HirExpr::ReceiverCall { name, .. } = left.as_ref() else {
2121 panic!("expected a receiver call");
2122 };
2123 assert_eq!(name, "getHero");
2124 }
2125
2126 #[test]
2127 fn unknown_catalog_enum_members_are_rejected() {
2128 for source in [
2129 "globalvar g\nrule \"r\":\n @Event global\n g = Color.CYAN\n",
2130 "globalvar g\nrule \"r\":\n @Event global\n g = DynamicEffect.SPARKLES\n",
2131 ] {
2132 let error = crate::compile(source, "test.opy", std::path::Path::new(""))
2133 .expect_err("unknown catalog member must be rejected");
2134 assert_eq!(error.code, "unknown-enum-member");
2135 }
2136 }
2137
2138 #[test]
2139 fn default_var_for_binder_resolves_at_all_range_arities() {
2140 for (binder, iterable) in [
2145 ("I", "range(0, 10)"),
2146 ("I", "range(3)"),
2147 ("I", "range(1, 5, 2)"),
2148 ] {
2149 let hir = lower_ok(&format!(
2150 "globalvar total\nrule \"r\":\n @Event global\n for {binder} in {iterable}:\n total += {binder}\n"
2151 ));
2152 let (_, actions) = rule_conditions_and_actions(&hir);
2153 let HirStmt::For { variable, body, .. } = &actions[0] else {
2154 panic!("expected a for statement");
2155 };
2156 assert!(
2157 matches!(variable.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2158 "the binder resolves to the implicit global 'I', got {variable:?}"
2159 );
2160 assert!(!body.is_empty(), "the loop body lowers");
2161 let HirStmt::Assign { value, .. } = &body[0] else {
2164 panic!("expected an assignment in the body");
2165 };
2166 let HirExpr::Binary { right, .. } = value.as_ref() else {
2167 panic!("expected a binary expression");
2168 };
2169 assert!(
2170 matches!(right.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2171 "the binder use inside the body resolves to the implicit global"
2172 );
2173 }
2174 }
2175
2176 #[test]
2177 fn player_variable_range_binder_preserves_host_player_receiver() {
2178 let hir = lower_ok(
2179 "playervar I\nrule \"r\":\n @Event global\n for hostPlayer.I in range(3):\n hostPlayer.I = 1\n",
2180 );
2181 let (_, actions) = rule_conditions_and_actions(&hir);
2182 let HirStmt::For { variable, .. } = &actions[0] else {
2183 panic!("expected a for statement");
2184 };
2185 let HirExpr::PlayerVar {
2186 player,
2187 name,
2188 member_span,
2189 span,
2190 } = variable.as_ref()
2191 else {
2192 panic!("expected a player-variable binder, got {variable:?}");
2193 };
2194 assert_eq!(name, "I");
2195 assert!(matches!(player.as_ref(), HirExpr::HostPlayer { .. }));
2196 assert_eq!(span.unwrap().start.line, 4);
2197 assert_eq!(span.unwrap().start.col, 9);
2198 assert_eq!(span.unwrap().end.line, 4);
2199 assert_eq!(span.unwrap().end.col, 21);
2200 let member_span = member_span.expect("player binder member span");
2201 assert_eq!(member_span.start.line, 4);
2202 assert_eq!(member_span.start.col, 20);
2203 assert_eq!(member_span.end.line, 4);
2204 assert_eq!(member_span.end.col, 21);
2205 }
2206
2207 #[test]
2208 fn default_var_names_resolve_as_implicit_globals() {
2209 let hir = lower_ok("rule \"r\":\n @Event global\n I = 5\n debug(I)\n");
2212 let (_, actions) = rule_conditions_and_actions(&hir);
2213 let HirStmt::Assign { target, .. } = &actions[0] else {
2214 panic!("expected an assignment");
2215 };
2216 assert!(
2217 matches!(target.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2218 "the implicit global resolves, got {target:?}"
2219 );
2220 assert_eq!(default_var_index("I"), Some(8));
2222 assert_eq!(default_var_index("AA"), Some(26));
2223 assert_eq!(default_var_index("Z"), Some(25));
2224 assert_eq!(default_var_index("DX"), Some(127));
2225 assert_eq!(default_var_index("DY"), None);
2226 assert_eq!(default_var_index("i"), None);
2227 }
2228
2229 #[test]
2230 fn nested_same_name_for_binders_reuse_the_implicit_global() {
2231 let hir = lower_ok(
2235 "rule \"r\":\n @Event global\n for I in range(3):\n for I in range(2):\n debug(I)\n",
2236 );
2237 let (_, actions) = rule_conditions_and_actions(&hir);
2238 let HirStmt::For {
2239 variable: outer,
2240 body,
2241 ..
2242 } = &actions[0]
2243 else {
2244 panic!("expected an outer for statement");
2245 };
2246 let HirStmt::For {
2247 variable: inner, ..
2248 } = &body[0]
2249 else {
2250 panic!("expected an inner for statement");
2251 };
2252 assert!(
2253 matches!(outer.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I")
2254 && matches!(inner.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2255 "both loops bind the same implicit global (spans differ per binder site)"
2256 );
2257 }
2258
2259 #[test]
2260 fn undeclared_lowercase_binder_is_still_an_unknown_identifier() {
2261 let error = compile_error(
2266 "rule \"r\":\n @Event global\n for i in range(3):\n debug(i)\n",
2267 3,
2268 );
2269 assert_eq!(error.code, "unknown-identifier");
2270 let span = error.span.expect("the error is source-located");
2271 assert_eq!(span.start.line, 3);
2272 }
2273
2274 #[test]
2275 fn syntax_constructs_lower_to_provenance_preserving_hir() {
2276 let hir = lower_ok(
2277 "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",
2278 );
2279 let (_, actions) = rule_conditions_and_actions(&hir);
2280 let HirStmt::DoWhile { condition, .. } = &actions[0] else {
2281 panic!("expected do-while");
2282 };
2283 assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in"));
2284 let HirStmt::Switch { arms, .. } = &actions[1] else {
2285 panic!("expected switch");
2286 };
2287 assert_eq!(arms.len(), 2);
2288 let HirSwitchArm::Case {
2289 value: case_value,
2290 body,
2291 ..
2292 } = &arms[0]
2293 else {
2294 panic!("expected case arm");
2295 };
2296 assert!(
2297 matches!(case_value.as_ref(), HirExpr::Number { value, .. } if *value == 0x10 as f64)
2298 );
2299 let HirStmt::Assign { value, .. } = &body[0] else {
2300 panic!("expected case assignment");
2301 };
2302 assert!(matches!(value.as_ref(), HirExpr::Binary { op, .. } if op == "in"));
2303 let HirSwitchArm::Default { body, .. } = &arms[1] else {
2304 panic!("expected default arm");
2305 };
2306 assert!(matches!(body[0], HirStmt::Assign { .. }));
2307 let HirStmt::Assign { value, .. } = &actions[2] else {
2308 panic!("expected comprehension assignment");
2309 };
2310 assert!(matches!(value.as_ref(), HirExpr::Comprehension { .. }));
2311 let HirStmt::Assign { value, .. } = &actions[3] else {
2312 panic!("expected sorted assignment");
2313 };
2314 assert!(
2315 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")))
2316 );
2317 let HirStmt::Assign { value, .. } = &actions[4] else {
2318 panic!("expected string assignment");
2319 };
2320 assert!(
2321 matches!(value.as_ref(), HirExpr::StringModifier { modifier, .. } if modifier == "w")
2322 );
2323 }
2324
2325 #[test]
2326 fn syntax_rejects_reference_invalid_bare_dict_and_lambda() {
2327 let dict_error = compile_error(
2328 "globalvar x\nrule \"r\":\n @Event global\n x = {\"x\": 1}\n",
2329 4,
2330 );
2331 assert_eq!(dict_error.code, "dict-access");
2332 let lambda_error = compile_error(
2333 "globalvar x\nrule \"r\":\n @Event global\n x = lambda value: value\n",
2334 4,
2335 );
2336 assert_eq!(lambda_error.code, "lambda-context");
2337 }
2338
2339 #[test]
2340 fn do_while_requires_rule_or_definition_prefix_position() {
2341 let error = compile_error(
2342 "globalvar value\nrule \"r\":\n @Event global\n value = 1\n do:\n value += 1\n while value < 2\n",
2343 5,
2344 );
2345 assert_eq!(error.code, "do-while-placement");
2346 assert_eq!(
2347 error.message,
2348 "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it"
2349 );
2350 }
2351}