1use std::collections::{BTreeMap, HashMap, HashSet};
10
11use opy_rs::hir::{self, Expr, RuleEntry, Span as HirSpan, Stmt, SwitchArm, default_var_index};
12use opy_rs::manifest::{FunctionKind, Manifest};
13use workshop_rs::catalog::{Catalog, CatalogIdentity, Kind, Locale};
14use workshop_rs::source::{Position as WorkshopPosition, SourceFile, Span as WorkshopSpan};
15use workshop_rs::wir::{self, Action, Event, PlayerEventKind, Program, Value, ValueNode};
16
17pub mod reconstruct;
18
19pub const WORKSHOP_RS_VERSION: &str = "0.1.11";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct IntegrationDiagnostic {
25 pub code: String,
26 pub message: String,
27 pub span: Option<HirSpan>,
28 pub script: Option<Box<ScriptDiagnostic>>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ScriptDiagnostic {
34 pub source_name: Option<String>,
35 pub line: Option<u32>,
36 pub column: Option<u32>,
37 pub stack: Option<String>,
38}
39
40impl IntegrationDiagnostic {
41 fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
42 Self {
43 code: code.into(),
44 message: message.into(),
45 span,
46 script: None,
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct IntegrationError {
54 pub diagnostic: IntegrationDiagnostic,
55}
56
57impl IntegrationError {
58 fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
59 Self {
60 diagnostic: IntegrationDiagnostic::new(code, message, span),
61 }
62 }
63
64 fn post_compile_hook(error: opy_macro_js::MacroError, span: Option<HirSpan>) -> Self {
65 let message = error.to_string();
66 let script = match error {
67 opy_macro_js::MacroError::Script(error) => Some(Box::new(ScriptDiagnostic {
68 source_name: error.source_name,
69 line: error.line,
70 column: error.column,
71 stack: error.stack,
72 })),
73 opy_macro_js::MacroError::InvalidResult { .. }
74 | opy_macro_js::MacroError::Internal(_) => None,
75 };
76 Self {
77 diagnostic: IntegrationDiagnostic {
78 code: "post-compile-hook".to_string(),
79 message,
80 span,
81 script,
82 },
83 }
84 }
85}
86
87impl std::fmt::Display for IntegrationError {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 write!(f, "{}: {}", self.diagnostic.code, self.diagnostic.message)
90 }
91}
92
93impl std::error::Error for IntegrationError {}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub struct LinkReport {
98 pub catalog_ids_checked: usize,
99 pub domains_checked: usize,
100}
101
102pub fn cross_check_manifest(
106 manifest: &Manifest,
107 catalog: &Catalog,
108) -> Result<LinkReport, IntegrationError> {
109 let mut catalog_ids_checked = 0;
110 let mut domains_checked = 0;
111
112 for function in &manifest.functions {
113 if let Some(catalog_id) = &function.catalog_id {
114 let kind = match function.kind {
115 FunctionKind::Action | FunctionKind::MemberAction => Kind::Action,
116 FunctionKind::Value | FunctionKind::MemberValue => Kind::Value,
117 };
118 catalog_ids_checked += 1;
119 if catalog.entry(kind, catalog_id).is_none() {
120 return Err(IntegrationError::new(
121 "catalog-link-missing",
122 format!(
123 "manifest function '{}' links to missing {:?} catalog id '{}'",
124 function.id, kind, catalog_id
125 ),
126 None,
127 ));
128 }
129 }
130
131 for parameter in &function.params {
132 let Some(domain) = ¶meter.domain else {
133 continue;
134 };
135 let contextual = function
136 .contextual_domain
137 .as_ref()
138 .is_some_and(|context| context.domain == *domain);
139 if contextual {
140 continue;
141 }
142 domains_checked += 1;
143 if catalog.enum_domain(domain).is_none() {
144 return Err(IntegrationError::new(
145 "domain-link-missing",
146 format!(
147 "manifest function '{}' parameter '{}' links to missing enum domain '{}',",
148 function.id, parameter.name, domain
149 ),
150 None,
151 ));
152 }
153 }
154
155 if let Some(contextual) = &function.contextual_domain {
156 for option in contextual.options.values() {
157 domains_checked += 1;
158 if catalog.enum_domain(&option.domain).is_none() {
159 return Err(IntegrationError::new(
160 "domain-link-missing",
161 format!(
162 "manifest function '{}' contextual option links to missing enum domain '{}'",
163 function.id, option.domain
164 ),
165 None,
166 ));
167 }
168 }
169 }
170 }
171
172 Ok(LinkReport {
173 catalog_ids_checked,
174 domains_checked,
175 })
176}
177
178pub struct Compiler {
181 catalog: Catalog,
182 manifest: &'static Manifest,
183 links: LinkReport,
184}
185
186impl Compiler {
187 pub fn new() -> Result<Self, IntegrationError> {
188 let catalog = Catalog::builtin()
189 .map_err(|error| IntegrationError::new("catalog-load", error.to_string(), None))?;
190 let manifest = Manifest::builtin()
191 .map_err(|error| IntegrationError::new("manifest-load", error.to_string(), None))?;
192 let links = cross_check_manifest(manifest, &catalog)?;
193 let identity = catalog.identity();
194 if identity.implementation_version != WORKSHOP_RS_VERSION {
195 return Err(IntegrationError::new(
196 "workshop-contract-version",
197 format!(
198 "expected workshop-rs {}, loaded {}",
199 WORKSHOP_RS_VERSION, identity.implementation_version
200 ),
201 None,
202 ));
203 }
204 Ok(Self {
205 catalog,
206 manifest,
207 links,
208 })
209 }
210
211 pub fn catalog_identity(&self) -> CatalogIdentity {
212 self.catalog.identity()
213 }
214
215 pub fn link_report(&self) -> LinkReport {
216 self.links
217 }
218
219 pub fn compile_hir(&self, hir: &hir::Program) -> Result<CompilationArtifact, IntegrationError> {
222 self.compile_hir_with_locale(hir, &Locale::new("en-US"))
223 }
224
225 pub fn compile_hir_with_locale(
227 &self,
228 hir: &hir::Program,
229 locale: &Locale,
230 ) -> Result<CompilationArtifact, IntegrationError> {
231 if !self.catalog.supports(locale) {
232 return Err(IntegrationError::new(
233 "locale-unsupported",
234 format!("workshop catalog does not declare locale '{locale}'"),
235 None,
236 ));
237 }
238 reject_unlowered_directives(hir)?;
239 let mut lowering = Lowering::new(self, hir)?;
240 lowering.copy_files()?;
241 lowering.lower_declarations()?;
242 lowering.lower_rules()?;
243
244 lowering.wir.validate().map_err(|error| {
245 let span = error
246 .span()
247 .and_then(|span| lowering.hir_span_from_workshop(span));
248 IntegrationError::new(error.code(), error.message(), span)
249 })?;
250 workshop_rs::validate::validate_canonical_ids(&lowering.wir, &self.catalog).map_err(
251 |error| {
252 let span = workshop_error_span(&error)
253 .and_then(|span| lowering.hir_span_from_workshop(span));
254 IntegrationError::new("catalog-validation", error.to_string(), span)
255 },
256 )?;
257 let emitted =
258 workshop_rs::emitter::emit(&lowering.wir, &self.catalog, locale).map_err(|error| {
259 let span = workshop_error_span(&error)
260 .and_then(|span| lowering.hir_span_from_workshop(span));
261 IntegrationError::new("workshop-emission", error.to_string(), span)
262 })?;
263
264 Ok(CompilationArtifact {
265 wir: lowering.wir,
266 final_output: emitted.clone(),
267 emitted,
268 catalog_identity: self.catalog.identity(),
269 hook_console_output: Vec::new(),
270 })
271 }
272
273 pub fn compile_source(
276 &self,
277 source: &str,
278 main_path: &str,
279 root: &std::path::Path,
280 locale: &Locale,
281 ) -> Result<CompilationArtifact, IntegrationError> {
282 let outcome = opy_rs::compile_with_overlay_outcome(
283 source,
284 main_path,
285 root,
286 &std::collections::BTreeMap::new(),
287 );
288 let hir = outcome.hir.ok_or_else(|| {
289 let error = outcome
290 .error
291 .expect("failed frontend compile has diagnostic");
292 IntegrationError::new(
293 error.code,
294 error.message,
295 error.span.map(hir_span_from_diag),
296 )
297 })?;
298 let hook = outcome.post_compile_hook;
299 let mut artifact = self.compile_hir_with_locale(&hir, locale)?;
300 if let Some(hook) = hook {
301 let runtime = opy_macro_js::MacroRuntime::new(opy_macro_js::Limits::default());
302 let result = runtime
303 .run_hook(&hook.source, &artifact.emitted, &hook.script)
304 .map_err(|error| {
305 IntegrationError::post_compile_hook(error, hook.span.map(hir_span_from_diag))
306 })?;
307 artifact.final_output = result.text;
308 artifact.hook_console_output = result.console_output;
309 }
310 Ok(artifact)
311 }
312}
313
314fn reject_unlowered_directives(hir: &hir::Program) -> Result<(), IntegrationError> {
315 if let Some(replacement) = hir.preprocessing.replacements.first() {
316 let span = hir
317 .preprocessing
318 .directives
319 .iter()
320 .find(|directive| directive.name.starts_with("replace"))
321 .and_then(|directive| directive.span)
322 .or(replacement.span);
323 return Err(IntegrationError::new(
324 "backend-directive-unsupported",
325 format!(
326 "replacement directive '{}' has no canonical workshop-rs lowering",
327 replacement.value
328 ),
329 span,
330 ));
331 }
332 if let Some(replacement) = hir
333 .preprocessing
334 .directives
335 .iter()
336 .find(|directive| directive.name.starts_with("replace"))
337 {
338 return Err(IntegrationError::new(
339 "backend-directive-unsupported",
340 format!(
341 "replacement directive '{}' has no canonical workshop-rs lowering",
342 replacement.name
343 ),
344 replacement.span,
345 ));
346 }
347 Ok(())
348}
349
350pub struct CompilationArtifact {
352 pub wir: Program,
353 pub emitted: String,
354 pub catalog_identity: CatalogIdentity,
355 pub final_output: String,
356 pub hook_console_output: Vec<String>,
357}
358
359fn convert_settings(settings: opy_rs::hir::Settings) -> workshop_rs::settings::Settings {
360 workshop_rs::settings::Settings {
361 span: settings.span.map(convert_settings_span),
362 children: settings
363 .children
364 .into_iter()
365 .map(convert_settings_node)
366 .collect(),
367 }
368}
369
370fn convert_settings_node(node: opy_rs::hir::SettingsNode) -> workshop_rs::settings::SettingsNode {
371 use opy_rs::hir::SettingsNode as SourceNode;
372 use workshop_rs::settings::{SettingsListElement, SettingsNode as TargetNode};
373
374 match node {
375 SourceNode::Group {
376 name,
377 children,
378 span,
379 } => TargetNode::Group {
380 name,
381 children: children.into_iter().map(convert_settings_node).collect(),
382 span: span.map(convert_settings_span),
383 },
384 SourceNode::Number { name, value, span } => TargetNode::Number {
385 name,
386 value,
387 span: span.map(convert_settings_span),
388 },
389 SourceNode::Bool { name, value, span } => TargetNode::Bool {
390 name,
391 value,
392 span: span.map(convert_settings_span),
393 },
394 SourceNode::String { name, value, span } => TargetNode::String {
395 name,
396 value,
397 span: span.map(convert_settings_span),
398 },
399 SourceNode::List {
400 name,
401 elements,
402 span,
403 } => TargetNode::List {
404 name,
405 elements: elements
406 .into_iter()
407 .map(|element| SettingsListElement {
408 value: element.value,
409 span: element.span.map(convert_settings_span),
410 })
411 .collect(),
412 span: span.map(convert_settings_span),
413 },
414 }
415}
416
417fn convert_settings_span(span: HirSpan) -> WorkshopSpan {
418 WorkshopSpan::new(
419 workshop_rs::source::FileId::from_index(span.file as usize),
420 WorkshopPosition::new(span.start.line, span.start.col),
421 WorkshopPosition::new(span.end.line, span.end.col),
422 )
423}
424
425struct Lowering<'a> {
426 compiler: &'a Compiler,
427 hir: &'a hir::Program,
428 wir: Program,
429 files: HashMap<u32, workshop_rs::source::FileId>,
430 wir_to_hir_files: Vec<u32>,
431 globals: HashMap<String, wir::GlobalVarId>,
432 players: HashMap<String, wir::PlayerVarId>,
433 subroutines: HashMap<String, wir::SubroutineId>,
434 constants: HashMap<String, &'a Expr>,
435 defined_subroutines: HashSet<wir::SubroutineId>,
436}
437
438#[derive(Debug, Clone, Copy)]
439enum BreakTarget {
440 Loop,
441 DoWhile,
442 Switch,
443}
444
445type SwitchBreak = (usize, HirSpan);
446type LoweredSwitchBody = (Vec<wir::ActionId>, Option<SwitchBreak>);
447
448impl<'a> Lowering<'a> {
449 fn new(compiler: &'a Compiler, hir: &'a hir::Program) -> Result<Self, IntegrationError> {
450 Ok(Self {
451 compiler,
452 hir,
453 wir: Program::default(),
454 files: HashMap::new(),
455 wir_to_hir_files: Vec::new(),
456 globals: HashMap::new(),
457 players: HashMap::new(),
458 subroutines: HashMap::new(),
459 constants: HashMap::new(),
460 defined_subroutines: HashSet::new(),
461 })
462 }
463
464 fn copy_files(&mut self) -> Result<(), IntegrationError> {
465 self.wir.settings = self.hir.settings.clone().map(convert_settings);
466 for file in &self.hir.files {
467 if self.files.contains_key(&file.id) {
468 return Err(IntegrationError::new(
469 "source-file",
470 format!("duplicate HIR source file id {}", file.id),
471 None,
472 ));
473 }
474 let id = self.wir.files.push(SourceFile::new(file.path.clone()));
475 self.files.insert(file.id, id);
476 self.wir_to_hir_files.push(file.id);
477 }
478 Ok(())
479 }
480
481 fn lower_declarations(&mut self) -> Result<(), IntegrationError> {
482 let (implicit_globals, implicit_players) = implicit_default_variables(self.hir);
483 for declaration in &self.hir.declarations {
484 if let hir::Declaration::GlobalVariable {
485 name,
486 index: Some(index),
487 span,
488 ..
489 } = declaration
490 {
491 for (implicit_name, implicit_span) in &implicit_globals {
492 if default_var_index(implicit_name) == Some(*index) {
493 return Err(IntegrationError::new(
494 "index-collision",
495 format!(
496 "duplicate use of index {index} for global variables '{implicit_name}' and '{name}'"
497 ),
498 implicit_span.or(*span),
499 ));
500 }
501 }
502 }
503 if let hir::Declaration::PlayerVariable {
504 name,
505 index: Some(index),
506 span,
507 ..
508 } = declaration
509 {
510 for (implicit_name, implicit_span) in &implicit_players {
511 if default_var_index(implicit_name) == Some(*index) {
512 return Err(IntegrationError::new(
513 "index-collision",
514 format!(
515 "duplicate use of index {index} for player variables '{implicit_name}' and '{name}'"
516 ),
517 implicit_span.or(*span),
518 ));
519 }
520 }
521 }
522 }
523
524 let globals = self
525 .hir
526 .declarations
527 .iter()
528 .filter_map(|declaration| match declaration {
529 hir::Declaration::GlobalVariable { index, span, .. } => Some((*index, *span)),
530 _ => None,
531 })
532 .collect::<Vec<_>>();
533 let players = self
534 .hir
535 .declarations
536 .iter()
537 .filter_map(|declaration| match declaration {
538 hir::Declaration::PlayerVariable { index, span, .. } => Some((*index, *span)),
539 _ => None,
540 })
541 .collect::<Vec<_>>();
542 let subroutines = self
543 .hir
544 .declarations
545 .iter()
546 .filter_map(|declaration| match declaration {
547 hir::Declaration::Subroutine { index, span, .. } => Some((*index, *span)),
548 _ => None,
549 })
550 .collect::<Vec<_>>();
551 let implicit_reserved = implicit_globals
552 .keys()
553 .map(|name| default_var_index(name).expect("implicit default variable names resolve"))
554 .collect::<HashSet<_>>();
555 let implicit_player_reserved = implicit_players
556 .keys()
557 .map(|name| default_var_index(name).expect("implicit default player names resolve"))
558 .collect::<HashSet<_>>();
559 let empty = HashSet::new();
560 let global_indices = allocate_indices(&globals, &implicit_reserved, "global variable")?;
561 let player_indices =
562 allocate_indices(&players, &implicit_player_reserved, "player variable")?;
563 let subroutine_indices = allocate_indices(&subroutines, &empty, "subroutine")?;
564 let mut global_index = 0;
565 let mut player_index = 0;
566 let mut subroutine_index = 0;
567
568 let mut declared_globals: Vec<(&str, u32, Option<HirSpan>, Option<HirSpan>)> = Vec::new();
573 let mut global_initializers = Vec::new();
574 let mut declared_players: Vec<(&str, u32, Option<HirSpan>, Option<HirSpan>)> = Vec::new();
575 let mut player_initializers = Vec::new();
576 let mut declared_subroutines: Vec<(&str, u32, Option<HirSpan>, Option<HirSpan>)> =
577 Vec::new();
578
579 for declaration in &self.hir.declarations {
580 match declaration {
581 hir::Declaration::GlobalVariable {
582 name,
583 index: _,
584 span,
585 name_span,
586 initializer,
587 } => {
588 let assigned = global_indices[global_index];
589 global_index += 1;
590 if declared_globals
591 .iter()
592 .any(|(existing, ..)| *existing == name)
593 {
594 return Err(IntegrationError::new(
595 "symbol-collision",
596 format!("duplicate global variable '{name}'"),
597 *span,
598 ));
599 }
600 declared_globals.push((name, assigned, *span, *name_span));
601 if let Some(init) = initializer {
602 if !is_zero_initializer(init) {
603 global_initializers.push((name, init, *span, *name_span));
604 }
605 }
606 }
607 hir::Declaration::PlayerVariable {
608 name,
609 index: _,
610 span,
611 name_span,
612 initializer,
613 } => {
614 let assigned = player_indices[player_index];
615 player_index += 1;
616 if declared_players
617 .iter()
618 .any(|(existing, ..)| *existing == name)
619 {
620 return Err(IntegrationError::new(
621 "symbol-collision",
622 format!("duplicate player variable '{name}'"),
623 *span,
624 ));
625 }
626 declared_players.push((name, assigned, *span, *name_span));
627 if let Some(init) = initializer {
628 if !is_zero_initializer(init) {
629 player_initializers.push((name, init, *span, *name_span));
630 }
631 }
632 }
633 hir::Declaration::Subroutine {
634 name,
635 span,
636 name_span,
637 ..
638 } => {
639 let assigned = subroutine_indices[subroutine_index];
640 subroutine_index += 1;
641 if declared_subroutines
642 .iter()
643 .any(|(existing, ..)| *existing == name)
644 {
645 return Err(IntegrationError::new(
646 "symbol-collision",
647 format!("duplicate subroutine '{name}'"),
648 *span,
649 ));
650 }
651 declared_subroutines.push((name, assigned, *span, *name_span));
652 }
653 hir::Declaration::Constant { name, value, span } => {
654 if self.constants.insert(name.clone(), value).is_some() {
655 return Err(IntegrationError::new(
656 "symbol-collision",
657 format!("duplicate constant '{name}'"),
658 *span,
659 ));
660 }
661 }
662 hir::Declaration::Macro { .. } => {
663 }
665 }
666 }
667
668 let mut planned_globals: Vec<(String, u32, Option<HirSpan>, Option<HirSpan>)> =
669 declared_globals
670 .into_iter()
671 .map(|(name, index, span, name_span)| (name.to_string(), index, span, name_span))
672 .collect();
673 planned_globals.extend(implicit_globals.iter().map(|(name, span)| {
674 (
675 name.clone(),
676 default_var_index(name).expect("implicit default variable names resolve"),
677 *span,
678 None,
679 )
680 }));
681 planned_globals.sort_by_key(|(_, index, ..)| *index);
682 for (name, assigned, span, name_span) in planned_globals {
683 let id = self.wir.global_variables.push(wir::WorkshopVariable {
684 name: name.clone(),
685 index: assigned,
686 span: self.wir_span(span)?,
687 name_span: self.wir_span(name_span)?,
688 });
689 self.globals.insert(name.clone(), id);
690 }
691
692 let mut planned_players: Vec<(String, u32, Option<HirSpan>, Option<HirSpan>)> =
693 declared_players
694 .into_iter()
695 .map(|(name, index, span, name_span)| (name.to_string(), index, span, name_span))
696 .collect();
697 planned_players.extend(implicit_players.iter().map(|(name, span)| {
698 (
699 name.clone(),
700 default_var_index(name).expect("implicit default player names resolve"),
701 *span,
702 None,
703 )
704 }));
705 planned_players.sort_by_key(|(_, index, ..)| *index);
706 for (name, assigned, span, name_span) in planned_players {
707 let id = self.wir.player_variables.push(wir::WorkshopVariable {
708 name: name.clone(),
709 index: assigned,
710 span: self.wir_span(span)?,
711 name_span: self.wir_span(name_span)?,
712 });
713 self.players.insert(name, id);
714 }
715
716 declared_subroutines.sort_by_key(|(_, index, ..)| *index);
717 for (name, assigned, span, name_span) in declared_subroutines {
718 let id = self.wir.subroutines.push(wir::WorkshopSubroutine {
719 name: name.to_string(),
720 index: assigned,
721 span: self.wir_span(span)?,
722 name_span: self.wir_span(name_span)?,
723 });
724 self.subroutines.insert(name.to_string(), id);
725 }
726
727 if !global_initializers.is_empty() {
728 let mut actions = Vec::with_capacity(global_initializers.len());
729 for (name, init_expr, span, target_span) in global_initializers {
730 let variable = *self.globals.get(name).expect("declared global is created");
731 let value = self.lower_value(init_expr)?;
732 actions.push(self.wir.actions.push(Action::SetGlobalVariable {
733 variable,
734 value,
735 span: self.wir_span(span)?,
736 target_span: self.wir_span(target_span)?,
737 }));
738 }
739 self.wir.rules.push(wir::Rule {
740 name: "Initialize global variables".to_string(),
741 span: None,
742 name_span: None,
743 disabled: false,
744 event: Event::Global,
745 conditions: Vec::new(),
746 actions,
747 });
748 }
749
750 if !player_initializers.is_empty() {
751 let mut actions = Vec::with_capacity(player_initializers.len());
752 for (name, init_expr, span, target_span) in player_initializers {
753 let variable = *self
754 .players
755 .get(name)
756 .expect("declared player variable is created");
757 let player = self
758 .wir
759 .values
760 .push(ValueNode::new(Value::EventPlayer, None));
761 let value = self.lower_value(init_expr)?;
762 actions.push(self.wir.actions.push(Action::SetPlayerVariable {
763 player,
764 variable,
765 value,
766 span: self.wir_span(span)?,
767 target_span: self.wir_span(target_span)?,
768 }));
769 }
770 self.wir.rules.push(wir::Rule {
771 name: "Initialize player variables".to_string(),
772 span: None,
773 name_span: None,
774 disabled: false,
775 event: Event::EachPlayer,
776 conditions: Vec::new(),
777 actions,
778 });
779 }
780
781 Ok(())
782 }
783
784 fn lower_rules(&mut self) -> Result<(), IntegrationError> {
785 for entry in &self.hir.rules {
786 match entry {
787 RuleEntry::Rule(rule) => self.lower_rule(rule)?,
788 RuleEntry::SubroutineDef {
789 name,
790 source_name,
791 span,
792 name_span,
793 body,
794 annotations,
795 ..
796 } => {
797 self.lower_subroutine(name, source_name, *span, *name_span, body, annotations)?
798 }
799 }
800 }
801 Ok(())
802 }
803
804 fn lower_rule(&mut self, rule: &hir::Rule) -> Result<(), IntegrationError> {
805 self.reject_rule_metadata(rule)?;
806 let event = self.lower_event(&rule.event, &rule.annotations)?;
807 let conditions = rule
808 .conditions
809 .iter()
810 .map(|expr| self.lower_value(expr))
811 .collect::<Result<Vec<_>, _>>()?;
812 let mut actions = Vec::new();
813 actions.extend(self.lower_actions(&rule.actions, None)?);
814 self.wir.rules.push(wir::Rule {
815 name: rule.name.clone(),
816 span: self.wir_span(rule.span)?,
817 name_span: self.wir_span(rule.name_span)?,
818 disabled: rule.disabled,
819 event,
820 conditions,
821 actions,
822 });
823 Ok(())
824 }
825
826 fn lower_subroutine(
827 &mut self,
828 name: &str,
829 source_name: &str,
830 span: Option<HirSpan>,
831 name_span: Option<HirSpan>,
832 body: &[Stmt],
833 annotations: &[hir::Annotation],
834 ) -> Result<(), IntegrationError> {
835 self.reject_subroutine_metadata(annotations)?;
836 let source_name = if source_name.is_empty() {
837 name
838 } else {
839 source_name
840 };
841 let subroutine = *self.subroutines.get(source_name).ok_or_else(|| {
842 self.unsupported(
843 format!("subroutine definition '{source_name}' has no declaration"),
844 name_span.or(span),
845 )
846 })?;
847 if !self.defined_subroutines.insert(subroutine) {
848 return Err(self.unsupported(
849 format!("subroutine '{source_name}' has multiple definitions"),
850 name_span.or(span),
851 ));
852 }
853 let mut actions = Vec::new();
854 actions.extend(self.lower_actions(body, None)?);
855 self.wir.rules.push(wir::Rule {
856 name: self.subroutine_rule_name(name),
857 span: self.wir_span(span)?,
858 name_span: self.wir_span(name_span)?,
859 disabled: false,
860 event: Event::Subroutine(subroutine),
861 conditions: Vec::new(),
862 actions,
863 });
864 Ok(())
865 }
866
867 fn reject_rule_metadata(&self, rule: &hir::Rule) -> Result<(), IntegrationError> {
868 if rule.delimiter {
869 let span = rule
870 .annotations
871 .iter()
872 .find(|annotation| annotation.name == "Delimiter")
873 .and_then(|annotation| annotation.span)
874 .or(rule.span);
875 return Err(self.unsupported(
876 "rule delimiter metadata is not representable in canonical WIR",
877 span,
878 ));
879 }
880 if rule.new_page.is_some() {
881 let span = rule
882 .annotations
883 .iter()
884 .find(|annotation| annotation.name == "NewPage")
885 .and_then(|annotation| annotation.span)
886 .or(rule.span);
887 return Err(self.unsupported(
888 "rule new-page metadata is not representable in canonical WIR",
889 span,
890 ));
891 }
892 for annotation in &rule.annotations {
893 match annotation.name.as_str() {
894 "Event" | "Condition" | "Team" | "Slot" | "Hero" | "Disabled"
895 | "SuppressWarnings" => {}
896 _ => {
897 return Err(self.unsupported(
898 format!(
899 "rule annotation '{}' is not representable in canonical WIR",
900 annotation.name
901 ),
902 annotation.span.or(rule.span),
903 ));
904 }
905 }
906 }
907 Ok(())
908 }
909
910 fn reject_subroutine_metadata(
911 &self,
912 annotations: &[hir::Annotation],
913 ) -> Result<(), IntegrationError> {
914 for annotation in annotations {
915 match annotation.name.as_str() {
916 "Name" | "SuppressWarnings" => {}
917 _ => {
918 return Err(self.unsupported(
919 format!(
920 "subroutine annotation '{}' is not representable in canonical WIR",
921 annotation.name
922 ),
923 annotation.span,
924 ));
925 }
926 }
927 }
928 Ok(())
929 }
930
931 fn subroutine_rule_name(&self, generated_name: &str) -> String {
932 if self.hir.preprocessing.rule_prefix_template.is_some() {
933 generated_name.to_string()
934 } else {
935 format!("Subroutine {generated_name}")
936 }
937 }
938
939 fn lower_event(
940 &self,
941 event: &hir::Event,
942 annotations: &[hir::Annotation],
943 ) -> Result<Event, IntegrationError> {
944 if !event.args.is_empty() {
945 return Err(self.unsupported(
946 "event arguments are not representable in canonical WIR; use structural event filters",
947 event.span,
948 ));
949 }
950 let team = self.lower_event_team(annotations)?;
951 let target = self.lower_event_target(annotations)?;
952 let has_filters =
953 !matches!(team, wir::EventTeam::All) || !matches!(target, wir::EventTarget::All);
954 match event.name.as_str() {
955 "global" => {
956 if has_filters {
957 return Err(
958 self.unsupported("global events cannot have player filters", event.span)
959 );
960 }
961 Ok(Event::Global)
962 }
963 "eachPlayer" => {
964 if has_filters {
965 Ok(Event::EachPlayerWithFilters { team, target })
966 } else {
967 Ok(Event::EachPlayer)
968 }
969 }
970 name => player_event_kind(name).map_or_else(
971 || {
972 Err(self.unsupported(
973 format!("event '{name}' is not supported by canonical WIR"),
974 event.span,
975 ))
976 },
977 |kind| Ok(Event::Player { kind, team, target }),
978 ),
979 }
980 }
981
982 fn lower_event_team(
983 &self,
984 annotations: &[hir::Annotation],
985 ) -> Result<wir::EventTeam, IntegrationError> {
986 let team_annotations = annotations
987 .iter()
988 .filter(|annotation| annotation.name == "Team")
989 .collect::<Vec<_>>();
990 if team_annotations.len() > 1 {
991 return Err(self.unsupported(
992 "an event cannot have multiple @Team filters",
993 team_annotations[1].span.or(team_annotations[0].span),
994 ));
995 }
996 let Some(annotation) = team_annotations.first() else {
997 return Ok(wir::EventTeam::All);
998 };
999 let argument = annotation
1000 .args
1001 .first()
1002 .ok_or_else(|| self.unsupported("@Team requires one filter value", annotation.span))?;
1003 if annotation.args.len() != 1 {
1004 return Err(
1005 self.unsupported("@Team requires exactly one filter value", annotation.span)
1006 );
1007 }
1008 let spelling = match argument.text.as_str() {
1009 "1" => "Team 1",
1010 "2" => "Team 2",
1011 value => value,
1012 };
1013 let (_, member) = self
1014 .compiler
1015 .catalog
1016 .resolve_enum_member("EventTeam", &Locale::new("en-US"), spelling)
1017 .ok_or_else(|| {
1018 self.unsupported(
1019 format!("unknown EventTeam filter '{spelling}'"),
1020 argument.span.or(annotation.span),
1021 )
1022 })?;
1023 match member.as_str() {
1024 "ALL" => Ok(wir::EventTeam::All),
1025 "TEAM_1" => Ok(wir::EventTeam::Team1),
1026 "TEAM_2" => Ok(wir::EventTeam::Team2),
1027 _ => Err(self.unsupported(
1028 format!("catalog EventTeam member '{member}' is not supported by canonical WIR"),
1029 argument.span.or(annotation.span),
1030 )),
1031 }
1032 }
1033
1034 fn lower_event_target(
1035 &self,
1036 annotations: &[hir::Annotation],
1037 ) -> Result<wir::EventTarget, IntegrationError> {
1038 let mut filters = Vec::new();
1039 for name in ["Slot", "Hero"] {
1040 let matches = annotations
1041 .iter()
1042 .filter(|annotation| annotation.name == name)
1043 .collect::<Vec<_>>();
1044 if matches.len() > 1 {
1045 return Err(self.unsupported(
1046 format!("an event cannot have multiple @{name} filters"),
1047 matches[1].span.or(matches[0].span),
1048 ));
1049 }
1050 filters.extend(matches);
1051 }
1052 if filters.len() > 1 {
1053 return Err(self.unsupported(
1054 "an event cannot combine @Slot and @Hero filters",
1055 filters[1].span.or(filters[0].span),
1056 ));
1057 }
1058 let Some(annotation) = filters.first() else {
1059 return Ok(wir::EventTarget::All);
1060 };
1061 let argument = annotation.args.first().ok_or_else(|| {
1062 self.unsupported(
1063 format!("@{} requires one filter value", annotation.name),
1064 annotation.span,
1065 )
1066 })?;
1067 if annotation.args.len() != 1 {
1068 return Err(self.unsupported(
1069 format!("@{} requires exactly one filter value", annotation.name),
1070 annotation.span,
1071 ));
1072 }
1073 let spelling = if annotation.name == "Slot" {
1074 match argument.text.as_str() {
1075 value if value.parse::<u8>().is_ok() => {
1076 format!("Slot {}", value.parse::<u8>().unwrap_or_default())
1077 }
1078 value => value.to_string(),
1079 }
1080 } else {
1081 argument.text.clone()
1082 };
1083 let domain = if annotation.name == "Slot" {
1084 "EventPlayer"
1085 } else {
1086 "Hero"
1087 };
1088 let (_, member) = self
1089 .compiler
1090 .catalog
1091 .resolve_enum_member(domain, &Locale::new("en-US"), &spelling)
1092 .ok_or_else(|| {
1093 self.unsupported(
1094 format!("unknown {domain} filter '{spelling}'"),
1095 argument.span.or(annotation.span),
1096 )
1097 })?;
1098 if domain == "EventPlayer" {
1099 if member == "ALL" {
1100 Ok(wir::EventTarget::All)
1101 } else if let Some(slot) = member.strip_prefix("SLOT_") {
1102 let slot = slot.parse::<u8>().map_err(|_| {
1103 self.unsupported(
1104 format!("catalog EventPlayer member '{member}' is not a slot"),
1105 argument.span.or(annotation.span),
1106 )
1107 })?;
1108 Ok(wir::EventTarget::Slot(slot))
1109 } else {
1110 Err(self.unsupported(
1111 format!(
1112 "catalog EventPlayer member '{member}' is not supported by canonical WIR"
1113 ),
1114 argument.span.or(annotation.span),
1115 ))
1116 }
1117 } else {
1118 Ok(wir::EventTarget::Hero(member))
1119 }
1120 }
1121
1122 fn lower_actions(
1123 &mut self,
1124 statements: &[Stmt],
1125 break_target: Option<BreakTarget>,
1126 ) -> Result<Vec<wir::ActionId>, IntegrationError> {
1127 let mut actions = Vec::new();
1128 for statement in statements {
1129 actions.extend(self.lower_action(statement, break_target)?);
1130 }
1131 Ok(actions)
1132 }
1133
1134 fn lower_action(
1135 &mut self,
1136 stmt: &Stmt,
1137 break_target: Option<BreakTarget>,
1138 ) -> Result<Vec<wir::ActionId>, IntegrationError> {
1139 match stmt {
1140 Stmt::Pass { .. } => Ok(Vec::new()),
1141 Stmt::Assign {
1142 target,
1143 value,
1144 span,
1145 } => self.lower_assign(target, value, *span).map(|action| vec![action]),
1146 Stmt::If {
1147 branches,
1148 r#else,
1149 span,
1150 } => {
1151 let branches = branches
1152 .iter()
1153 .map(|branch| {
1154 Ok(wir::IfBranch {
1155 condition: self.lower_value(&branch.condition)?,
1156 body: self.lower_actions(&branch.body, break_target)?,
1157 })
1158 })
1159 .collect::<Result<Vec<_>, IntegrationError>>()?;
1160 let else_body = r#else
1161 .as_ref()
1162 .map(|body| self.lower_actions(body, break_target))
1163 .transpose()?;
1164 Ok(vec![self.wir.actions.push(Action::If {
1165 branches,
1166 else_body,
1167 span: self.wir_span(*span)?,
1168 })])
1169 }
1170 Stmt::For {
1171 variable,
1172 iterable,
1173 body,
1174 span,
1175 } => {
1176 let Expr::GlobalVar {
1177 name,
1178 span: target_span,
1179 } = variable.as_ref()
1180 else {
1181 return Err(self.unsupported(
1182 "range loops require a global-variable binder in canonical WIR",
1183 variable.span().copied(),
1184 ));
1185 };
1186 let variable_id = *self.globals.get(name).ok_or_else(|| {
1187 self.unsupported(format!("unknown global variable '{name}'"), *target_span)
1188 })?;
1189 let (start, stop, step) = self.lower_range(iterable)?;
1190 let body = self.lower_actions(body, Some(BreakTarget::Loop))?;
1191 Ok(vec![self.wir.actions.push(Action::ForGlobalVariable {
1192 variable: variable_id,
1193 start,
1194 stop,
1195 step,
1196 body,
1197 span: self.wir_span(*span)?,
1198 target_span: self.wir_span(*target_span)?,
1199 })])
1200 }
1201 Stmt::While {
1202 condition,
1203 body,
1204 span,
1205 } => {
1206 let condition = self.lower_value(condition)?;
1207 let body = self.lower_actions(body, Some(BreakTarget::Loop))?;
1208 Ok(vec![self.wir.actions.push(Action::While {
1209 condition,
1210 body,
1211 span: self.wir_span(*span)?,
1212 })])
1213 }
1214 Stmt::DoWhile {
1215 condition,
1216 body,
1217 span,
1218 } => {
1219 let body = self.lower_do_while_body(body)?;
1220 let condition = self.lower_value(condition)?;
1221 let loop_if = self.wir.actions.push(Action::Call {
1222 name: "loopIf".to_string(),
1223 args: vec![condition],
1224 span: self.wir_span(*span)?,
1225 });
1226 let mut actions = body;
1229 actions.push(loop_if);
1230 Ok(actions)
1231 }
1232 Stmt::Switch {
1233 value,
1234 arms,
1235 span,
1236 } => self.lower_switch(value, arms, *span).map(|action| vec![action]),
1237 Stmt::Break { span } => match break_target {
1238 Some(BreakTarget::Loop) => Ok(vec![self.wir.actions.push(Action::Call {
1239 name: "break".to_string(),
1240 args: Vec::new(),
1241 span: self.wir_span(*span)?,
1242 })]),
1243 Some(BreakTarget::DoWhile) => Err(self.unsupported(
1244 "break inside a do-while must be a direct statement or a single conditional break",
1245 *span,
1246 )),
1247 Some(BreakTarget::Switch) => Err(self.unsupported(
1248 "break inside a nested conditional cannot be normalized into canonical switch control flow",
1249 *span,
1250 )),
1251 None => Err(self.unsupported(
1252 "break has no enclosing canonical loop or switch",
1253 *span,
1254 )),
1255 },
1256 Stmt::Expr { expr, span } => match expr.as_ref() {
1257 Expr::Call { name, args, .. } => {
1258 if name == "disableInspector" && args.is_empty() {
1259 Ok(vec![self.wir.actions.push(Action::Call {
1260 name: "disableInspector".to_string(),
1261 args: Vec::new(),
1262 span: self.wir_span(*span)?,
1263 })])
1264 } else if name == "debug" && args.len() == 1 {
1265 let val = self.lower_value(&args[0])?;
1266 Ok(vec![self.wir.actions.push(Action::Debug {
1267 value: val,
1268 span: self.wir_span(*span)?,
1269 })])
1270 } else if name == "print" && args.len() == 1 {
1271 let msg = self.lower_value(&args[0])?;
1272 Ok(vec![self.wir.actions.push(Action::Print {
1273 message: msg,
1274 span: self.wir_span(*span)?,
1275 })])
1276 } else {
1277 self.lower_action_call(name, args, *span).map(|action| vec![action])
1278 }
1279 }
1280 Expr::ReceiverCall {
1281 receiver,
1282 name,
1283 args,
1284 span: call_span,
1285 } => self
1286 .lower_receiver_action_call(receiver, name, args, *call_span)
1287 .map(|action| vec![action]),
1288 _ => Err(self.unsupported(
1289 "only action calls are currently representable as expression statements in canonical WIR",
1290 *span,
1291 )),
1292 },
1293 Stmt::CallSubroutine { name, span } => {
1294 let subroutine = *self.subroutines.get(name).ok_or_else(|| {
1295 self.unsupported(format!("unknown subroutine '{name}'"), *span)
1296 })?;
1297 let span = self.wir_span(*span)?;
1298 Ok(vec![self.wir.actions.push(Action::CallSubroutine {
1299 subroutine,
1300 span,
1301 callee_span: span,
1302 })])
1303 }
1304 }
1305 }
1306
1307 fn lower_do_while_body(
1308 &mut self,
1309 statements: &[Stmt],
1310 ) -> Result<Vec<wir::ActionId>, IntegrationError> {
1311 let mut actions = Vec::new();
1312 for (index, statement) in statements.iter().enumerate() {
1313 let direct_break = matches!(statement, Stmt::Break { .. });
1314 let conditional_break = match statement {
1315 Stmt::If {
1316 branches,
1317 r#else: None,
1318 ..
1319 } if branches.len() == 1 => {
1320 matches!(branches[0].body.as_slice(), [Stmt::Break { .. }])
1321 }
1322 _ => false,
1323 };
1324
1325 if direct_break || conditional_break {
1326 let tail = self.lower_do_while_body(&statements[index + 1..])?;
1327 let distance = self.canonical_action_width(&tail, statement.span().copied())? + 1;
1328 let (name, args, span) = if let Stmt::Break { span } = statement {
1329 ("skip", Vec::new(), *span)
1330 } else if let Stmt::If { branches, span, .. } = statement {
1331 (
1332 "skipIf",
1333 vec![self.lower_value(&branches[0].condition)?],
1334 *span,
1335 )
1336 } else {
1337 unreachable!("break shape was checked above")
1338 };
1339 let distance = self.wir.values.push(ValueNode::new(
1340 Value::Number {
1341 value: distance as f64,
1342 text: distance.to_string(),
1343 },
1344 self.wir_span(span)?,
1345 ));
1346 let mut args = args;
1347 args.push(distance);
1348 actions.push(self.wir.actions.push(Action::Call {
1349 name: name.to_string(),
1350 args,
1351 span: self.wir_span(span)?,
1352 }));
1353 actions.extend(tail);
1354 return Ok(actions);
1355 }
1356
1357 actions.extend(self.lower_action(statement, Some(BreakTarget::DoWhile))?);
1358 }
1359 Ok(actions)
1360 }
1361
1362 fn lower_range(
1363 &mut self,
1364 iterable: &Expr,
1365 ) -> Result<(wir::ValueId, wir::ValueId, wir::ValueId), IntegrationError> {
1366 let Expr::Call { name, args, .. } = iterable else {
1367 return Err(self.unsupported(
1368 "range loop iterable must be a range(...) call",
1369 iterable.span().copied(),
1370 ));
1371 };
1372 if name != "range" || !(1..=3).contains(&args.len()) {
1373 return Err(self.unsupported(
1374 "range loop requires one to three arguments",
1375 iterable.span().copied(),
1376 ));
1377 }
1378 let span = iterable.span().copied();
1379 let number = |this: &mut Self, value: f64| -> Result<wir::ValueId, IntegrationError> {
1380 Ok(this.wir.values.push(ValueNode::new(
1381 Value::Number {
1382 value,
1383 text: value.to_string(),
1384 },
1385 this.wir_span(span)?,
1386 )))
1387 };
1388 match args.as_slice() {
1389 [stop] => Ok((
1390 number(self, 0.0)?,
1391 self.lower_value(stop)?,
1392 number(self, 1.0)?,
1393 )),
1394 [start, stop] => Ok((
1395 self.lower_value(start)?,
1396 self.lower_value(stop)?,
1397 number(self, 1.0)?,
1398 )),
1399 [start, stop, step] => Ok((
1400 self.lower_value(start)?,
1401 self.lower_value(stop)?,
1402 self.lower_value(step)?,
1403 )),
1404 _ => unreachable!("range arity checked above"),
1405 }
1406 }
1407
1408 fn lower_switch(
1409 &mut self,
1410 value: &Expr,
1411 arms: &[SwitchArm],
1412 span: Option<HirSpan>,
1413 ) -> Result<wir::ActionId, IntegrationError> {
1414 let selector = self.lower_value(value)?;
1415 let mut case_values = Vec::new();
1416 let mut lowered_arms = Vec::with_capacity(arms.len());
1417 let mut case_offsets = Vec::new();
1418 let mut offset = 0usize;
1419 let mut default_offset = None;
1420
1421 for arm in arms {
1422 let (value, (body, break_at)) = match arm {
1423 SwitchArm::Case { value, body, .. } => {
1424 case_values.push(self.lower_value(value)?);
1425 (Some(value), self.lower_switch_body(body)?)
1426 }
1427 SwitchArm::Default { body, span } => {
1428 if default_offset.is_some() {
1429 return Err(
1430 self.unsupported("a switch may contain at most one default arm", *span)
1431 );
1432 }
1433 default_offset = Some(offset);
1434 (None, self.lower_switch_body(body)?)
1435 }
1436 };
1437 if value.is_some() {
1438 case_offsets.push(offset);
1439 }
1440 offset += self.canonical_action_width(&body, span)? + usize::from(break_at.is_some());
1441 lowered_arms.push((value, body, break_at));
1442 }
1443 let default_offset = default_offset.unwrap_or(offset);
1444
1445 let break_arms: Vec<_> = lowered_arms
1446 .iter()
1447 .enumerate()
1448 .filter_map(|(index, (_, _, break_at))| break_at.map(|break_at| (index, break_at)))
1449 .collect();
1450 if break_arms.len() > 1 {
1451 let (first_index, first_break) = break_arms[0];
1452 let has_actions_after_first = lowered_arms[first_index].1.len() > first_break.0
1453 || lowered_arms
1454 .iter()
1455 .skip(first_index + 1)
1456 .any(|(_, body, _)| !body.is_empty());
1457 if has_actions_after_first {
1458 return Err(self.unsupported(
1459 "multiple switch breaks with later reachable actions require canonical switch targets",
1460 Some(break_arms[1].1.1),
1461 ));
1462 }
1463 }
1464
1465 let case_values = self.wir.values.push(ValueNode::new(
1466 Value::Array(case_values),
1467 self.wir_span(span)?,
1468 ));
1469 let value_span = self.wir_span(span)?;
1470 let offset_values = std::iter::once(default_offset)
1471 .chain(case_offsets)
1472 .map(|value| {
1473 self.wir.values.push(ValueNode::new(
1474 Value::Number {
1475 value: value as f64,
1476 text: value.to_string(),
1477 },
1478 value_span,
1479 ))
1480 })
1481 .collect();
1482 let offsets = self
1483 .wir
1484 .values
1485 .push(ValueNode::new(Value::Array(offset_values), value_span));
1486 let one = self.wir.values.push(ValueNode::new(
1487 Value::Number {
1488 value: 1.0,
1489 text: "1".to_string(),
1490 },
1491 self.wir_span(span)?,
1492 ));
1493 let index = self.wir.values.push(ValueNode::new(
1494 Value::Call {
1495 name: "indexOfArrayValue".to_string(),
1496 args: vec![case_values, selector],
1497 },
1498 self.wir_span(span)?,
1499 ));
1500 let case_offset = self.wir.values.push(ValueNode::new(
1501 Value::Call {
1502 name: "add".to_string(),
1503 args: vec![one, index],
1504 },
1505 self.wir_span(span)?,
1506 ));
1507 let skip_condition = self.wir.values.push(ValueNode::new(
1508 Value::Call {
1509 name: "valueInArray".to_string(),
1510 args: vec![offsets, case_offset],
1511 },
1512 self.wir_span(span)?,
1513 ));
1514 let skip = self.wir.actions.push(Action::Call {
1515 name: "skip".to_string(),
1516 args: vec![skip_condition],
1517 span: self.wir_span(span)?,
1518 });
1519 let true_value = self
1520 .wir
1521 .values
1522 .push(ValueNode::new(Value::Bool(true), self.wir_span(span)?));
1523
1524 let first_break = break_arms.first().copied();
1525 let mut branch_body = vec![skip];
1526 let else_body = if let Some((break_index, (break_at, _))) = first_break {
1527 for (index, (_, body, _)) in lowered_arms.iter().enumerate() {
1528 if index < break_index {
1529 branch_body.extend(body.iter().copied());
1530 } else if index == break_index {
1531 branch_body.extend(body[..break_at].iter().copied());
1532 }
1533 }
1534 let mut tail = Vec::new();
1535 tail.extend(lowered_arms[break_index].1[break_at..].iter().copied());
1536 for (_, body, _) in lowered_arms.iter().skip(break_index + 1) {
1537 tail.extend(body.iter().copied());
1538 }
1539 Some(tail)
1540 } else {
1541 for (_, body, _) in &lowered_arms {
1542 branch_body.extend(body.iter().copied());
1543 }
1544 None
1545 };
1546
1547 Ok(self.wir.actions.push(Action::If {
1548 branches: vec![wir::IfBranch {
1549 condition: true_value,
1550 body: branch_body,
1551 }],
1552 else_body,
1553 span: self.wir_span(span)?,
1554 }))
1555 }
1556
1557 fn lower_switch_body(
1558 &mut self,
1559 statements: &[Stmt],
1560 ) -> Result<LoweredSwitchBody, IntegrationError> {
1561 let mut actions = Vec::new();
1562 let mut break_at = None;
1563 for statement in statements {
1564 if let Stmt::Break { span } = statement {
1565 if break_at.is_some() {
1566 return Err(self.unsupported(
1567 "multiple switch breaks in one arm require canonical switch targets",
1568 *span,
1569 ));
1570 }
1571 break_at = Some((
1572 actions.len(),
1573 span.ok_or_else(|| {
1574 self.unsupported("switch break is missing source provenance", None)
1575 })?,
1576 ));
1577 continue;
1578 }
1579 actions.extend(self.lower_action(statement, Some(BreakTarget::Switch))?);
1580 }
1581 Ok((actions, break_at))
1582 }
1583
1584 fn canonical_action_width(
1586 &self,
1587 actions: &[wir::ActionId],
1588 fallback_span: Option<HirSpan>,
1589 ) -> Result<usize, IntegrationError> {
1590 workshop_rs::emitter::action_width(
1591 &self.wir,
1592 &self.compiler.catalog,
1593 &Locale::new("en-US"),
1594 actions,
1595 )
1596 .map(|layout| layout.width)
1597 .map_err(|error| {
1598 let workshop_span = match &error {
1599 workshop_rs::emitter::ActionLayoutError::InvalidWIR(error) => error.span(),
1600 workshop_rs::emitter::ActionLayoutError::Emission(error) => {
1601 workshop_error_span(error)
1602 }
1603 };
1604 let span = workshop_span
1605 .and_then(|span| self.hir_span_from_workshop(span))
1606 .or(fallback_span);
1607 IntegrationError::new("workshop-action-layout", error.to_string(), span)
1608 })
1609 }
1610
1611 fn lower_assign(
1612 &mut self,
1613 target: &Expr,
1614 value: &Expr,
1615 span: Option<HirSpan>,
1616 ) -> Result<wir::ActionId, IntegrationError> {
1617 match target {
1618 Expr::GlobalVar {
1619 name,
1620 span: target_span,
1621 } => {
1622 let variable = *self.globals.get(name).ok_or_else(|| {
1623 self.unsupported(format!("unknown global variable '{name}'"), *target_span)
1624 })?;
1625 if let Expr::Binary {
1626 op, left, right, ..
1627 } = value
1628 {
1629 if let Expr::GlobalVar {
1630 name: left_name, ..
1631 } = left.as_ref()
1632 {
1633 if left_name == name {
1634 if let Some(modify_op) = modify_op_from_str(op) {
1635 let val = self.lower_value(right)?;
1636 return Ok(self.wir.actions.push(Action::ModifyGlobalVariable {
1637 variable,
1638 op: modify_op,
1639 value: val,
1640 span: self.wir_span(span)?,
1641 target_span: self.wir_span(*target_span)?,
1642 }));
1643 }
1644 }
1645 }
1646 }
1647 let val = self.lower_value(value)?;
1648 Ok(self.wir.actions.push(Action::SetGlobalVariable {
1649 variable,
1650 value: val,
1651 span: self.wir_span(span)?,
1652 target_span: self.wir_span(*target_span)?,
1653 }))
1654 }
1655 Expr::PlayerVar {
1656 player,
1657 name,
1658 span: target_span,
1659 } => {
1660 let variable = *self.players.get(name).ok_or_else(|| {
1661 self.unsupported(format!("unknown player variable '{name}'"), *target_span)
1662 })?;
1663 let player_val = self.lower_value(player)?;
1664 if let Expr::Binary {
1665 op, left, right, ..
1666 } = value
1667 {
1668 if let Expr::PlayerVar {
1669 player: left_player,
1670 name: left_name,
1671 ..
1672 } = left.as_ref()
1673 {
1674 if left_name == name && left_player.as_ref() == player.as_ref() {
1675 if let Some(modify_op) = modify_op_from_str(op) {
1676 let val = self.lower_value(right)?;
1677 return Ok(self.wir.actions.push(Action::ModifyPlayerVariable {
1678 player: player_val,
1679 variable,
1680 op: modify_op,
1681 value: val,
1682 span: self.wir_span(span)?,
1683 target_span: self.wir_span(*target_span)?,
1684 }));
1685 }
1686 }
1687 }
1688 }
1689 let val = self.lower_value(value)?;
1690 Ok(self.wir.actions.push(Action::SetPlayerVariable {
1691 player: player_val,
1692 variable,
1693 value: val,
1694 span: self.wir_span(span)?,
1695 target_span: self.wir_span(*target_span)?,
1696 }))
1697 }
1698 Expr::Index {
1699 array,
1700 index,
1701 span: target_span,
1702 } => match array.as_ref() {
1703 Expr::GlobalVar {
1704 name,
1705 span: arr_span,
1706 } => {
1707 let variable = *self.globals.get(name).ok_or_else(|| {
1708 self.unsupported(format!("unknown global variable '{name}'"), *arr_span)
1709 })?;
1710 let var_node = self.wir.values.push(ValueNode::new(
1711 Value::GlobalVariable(variable),
1712 self.wir_span(*arr_span)?,
1713 ));
1714 let index_val = self.lower_value(index)?;
1715 if let Expr::Binary {
1716 op, left, right, ..
1717 } = value
1718 {
1719 if let Expr::Index {
1720 array: left_arr,
1721 index: left_idx,
1722 ..
1723 } = left.as_ref()
1724 {
1725 if left_arr.as_ref() == array.as_ref()
1726 && left_idx.as_ref() == index.as_ref()
1727 {
1728 if let Some(op_id) = modify_catalog_name_from_str(op) {
1729 let op_node = self.wir.values.push(ValueNode::new(
1730 Value::Call {
1731 name: op_id.to_string(),
1732 args: Vec::new(),
1733 },
1734 None,
1735 ));
1736 let right_val = self.lower_value(right)?;
1737 return Ok(self.wir.actions.push(Action::Call {
1738 name: "modifyGlobalVariableAtIndex".to_string(),
1739 args: vec![var_node, index_val, op_node, right_val],
1740 span: self.wir_span(span)?,
1741 }));
1742 }
1743 }
1744 }
1745 }
1746 let val = self.lower_value(value)?;
1747 Ok(self.wir.actions.push(Action::Call {
1748 name: "setGlobalVariableAtIndex".to_string(),
1749 args: vec![var_node, index_val, val],
1750 span: self.wir_span(span)?,
1751 }))
1752 }
1753 Expr::PlayerVar {
1754 player,
1755 name,
1756 span: arr_span,
1757 } => {
1758 let player_val = self.lower_value(player)?;
1759 let variable = *self.players.get(name).ok_or_else(|| {
1760 self.unsupported(format!("unknown player variable '{name}'"), *arr_span)
1761 })?;
1762 let var_node = self.wir.values.push(ValueNode::new(
1763 Value::PlayerVariable {
1764 player: player_val,
1765 variable,
1766 },
1767 self.wir_span(*arr_span)?,
1768 ));
1769 let index_val = self.lower_value(index)?;
1770 if let Expr::Binary {
1771 op, left, right, ..
1772 } = value
1773 {
1774 if let Expr::Index {
1775 array: left_arr,
1776 index: left_idx,
1777 ..
1778 } = left.as_ref()
1779 {
1780 if left_arr.as_ref() == array.as_ref()
1781 && left_idx.as_ref() == index.as_ref()
1782 {
1783 if let Some(op_id) = modify_catalog_name_from_str(op) {
1784 let op_node = self.wir.values.push(ValueNode::new(
1785 Value::Call {
1786 name: op_id.to_string(),
1787 args: Vec::new(),
1788 },
1789 None,
1790 ));
1791 let right_val = self.lower_value(right)?;
1792 return Ok(self.wir.actions.push(Action::Call {
1797 name: "modifyPlayerVariableAtIndex".to_string(),
1798 args: vec![var_node, index_val, op_node, right_val],
1799 span: self.wir_span(span)?,
1800 }));
1801 }
1802 }
1803 }
1804 }
1805 let val = self.lower_value(value)?;
1806 Ok(self.wir.actions.push(Action::Call {
1810 name: "setPlayerVariableAtIndex".to_string(),
1811 args: vec![var_node, index_val, val],
1812 span: self.wir_span(span)?,
1813 }))
1814 }
1815 _ => Err(self.unsupported(
1816 "indexing assignment is only representable for global or player variables",
1817 *target_span,
1818 )),
1819 },
1820 _ => Err(self.unsupported(
1821 "only global-variable, player-variable, or index assignment is currently representable in canonical WIR",
1822 span,
1823 )),
1824 }
1825 }
1826
1827 fn lower_action_call(
1828 &mut self,
1829 name: &str,
1830 args: &[Expr],
1831 span: Option<HirSpan>,
1832 ) -> Result<wir::ActionId, IntegrationError> {
1833 let function = self
1834 .compiler
1835 .manifest
1836 .resolve_function(name)
1837 .ok_or_else(|| self.unsupported(format!("unknown action '{name}'"), span))?;
1838 if !matches!(function.kind, FunctionKind::Action) {
1839 return Err(self.unsupported(format!("'{name}' is not a generic OPY action"), span));
1840 }
1841 let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
1842 self.unsupported(
1843 format!(
1844 "action '{}' requires a special lowering not in #46",
1845 function.id
1846 ),
1847 span,
1848 )
1849 })?;
1850 let args = args
1851 .iter()
1852 .map(|expr| self.lower_value(expr))
1853 .collect::<Result<Vec<_>, _>>()?;
1854 Ok(self.wir.actions.push(Action::Call {
1855 name: catalog_id.clone(),
1856 args,
1857 span: self.wir_span(span)?,
1858 }))
1859 }
1860
1861 fn lower_receiver_action_call(
1862 &mut self,
1863 receiver: &Expr,
1864 name: &str,
1865 args: &[Expr],
1866 span: Option<HirSpan>,
1867 ) -> Result<wir::ActionId, IntegrationError> {
1868 let function = self
1869 .compiler
1870 .manifest
1871 .resolve_member(name)
1872 .ok_or_else(|| self.unsupported(format!("unknown member action '{name}'"), span))?;
1873 if !matches!(function.kind, FunctionKind::MemberAction) {
1874 return Err(self.unsupported(format!("'{name}' is not a member action"), span));
1875 }
1876
1877 if function.id == "append" {
1880 let [value] = args else {
1881 return Err(self.unsupported("append requires exactly one argument", span));
1882 };
1883 let value = self.lower_value(value)?;
1884 return match receiver {
1885 Expr::GlobalVar {
1886 name,
1887 span: target_span,
1888 } => {
1889 let variable = *self.globals.get(name).ok_or_else(|| {
1890 self.unsupported(format!("unknown global variable '{name}'"), *target_span)
1891 })?;
1892 Ok(self.wir.actions.push(Action::ModifyGlobalVariable {
1893 variable,
1894 op: wir::ModifyOp::AppendToArray,
1895 value,
1896 span: self.wir_span(span)?,
1897 target_span: self.wir_span(*target_span)?,
1898 }))
1899 }
1900 Expr::PlayerVar {
1901 player,
1902 name,
1903 span: target_span,
1904 } => {
1905 let variable = *self.players.get(name).ok_or_else(|| {
1906 self.unsupported(format!("unknown player variable '{name}'"), *target_span)
1907 })?;
1908 let player = self.lower_value(player)?;
1909 Ok(self.wir.actions.push(Action::ModifyPlayerVariable {
1910 player,
1911 variable,
1912 op: wir::ModifyOp::AppendToArray,
1913 value,
1914 span: self.wir_span(span)?,
1915 target_span: self.wir_span(*target_span)?,
1916 }))
1917 }
1918 _ => Err(self.unsupported(
1919 "append requires a global or player variable receiver",
1920 receiver.span().copied().or(span),
1921 )),
1922 };
1923 }
1924
1925 let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
1926 self.unsupported(
1927 format!(
1928 "member action '{}' has no canonical catalog identity",
1929 function.id
1930 ),
1931 span,
1932 )
1933 })?;
1934 let mut lowered = Vec::with_capacity(args.len() + 1);
1935 lowered.push(self.lower_value(receiver)?);
1936 lowered.extend(
1937 args.iter()
1938 .map(|arg| self.lower_value(arg))
1939 .collect::<Result<Vec<_>, _>>()?,
1940 );
1941 Ok(self.wir.actions.push(Action::Call {
1942 name: catalog_id.clone(),
1943 args: lowered,
1944 span: self.wir_span(span)?,
1945 }))
1946 }
1947
1948 fn lower_value(&mut self, expr: &Expr) -> Result<wir::ValueId, IntegrationError> {
1949 let span = expr.span().copied();
1950 let value = match expr {
1951 Expr::Number { value, text, .. } => Value::Number {
1952 value: *value,
1953 text: canonical_number_text(*value, text),
1954 },
1955 Expr::String { value, .. } => Value::String(value.clone()),
1956 Expr::Bool { value, .. } => Value::Bool(*value),
1957 Expr::Null { .. } => Value::Null,
1958 Expr::GlobalVar { name, .. } => {
1959 let id = *self.globals.get(name).ok_or_else(|| {
1960 self.unsupported(format!("unknown global variable '{name}'"), span)
1961 })?;
1962 Value::GlobalVariable(id)
1963 }
1964 Expr::PlayerVar { player, name, .. } => {
1965 let player = self.lower_value(player)?;
1966 let id = *self.players.get(name).ok_or_else(|| {
1967 self.unsupported(format!("unknown player variable '{name}'"), span)
1968 })?;
1969 Value::PlayerVariable {
1970 player,
1971 variable: id,
1972 }
1973 }
1974 Expr::EventPlayer { .. } => Value::EventPlayer,
1975 Expr::Enum {
1976 value_type, value, ..
1977 } => {
1978 if self
1979 .compiler
1980 .catalog
1981 .enum_spelling(value_type, &Locale::new("en-US"), value)
1982 .is_none()
1983 {
1984 return Err(self.unsupported(
1985 format!("unknown catalog enum member '{value_type}.{value}'"),
1986 span,
1987 ));
1988 }
1989 Value::Enum {
1990 value_type: value_type.clone(),
1991 value: value.clone(),
1992 }
1993 }
1994 Expr::Array { elements, .. } => Value::Array(
1995 elements
1996 .iter()
1997 .map(|element| self.lower_value(element))
1998 .collect::<Result<Vec<_>, _>>()?,
1999 ),
2000 Expr::Vector { x, y, z, .. } => Value::Vector {
2001 x: self.lower_value(x)?,
2002 y: self.lower_value(y)?,
2003 z: self.lower_value(z)?,
2004 },
2005 Expr::Constant { name, .. } => {
2006 let const_expr = *self
2007 .constants
2008 .get(name)
2009 .ok_or_else(|| self.unsupported(format!("unknown constant '{name}'"), span))?;
2010 return self.lower_value(const_expr);
2011 }
2012 Expr::Index { array, index, .. } => {
2013 if matches!(index.as_ref(), Expr::Number { value, .. } if *value == 0.0) {
2017 Value::Call {
2018 name: "firstOf".to_string(),
2019 args: vec![self.lower_value(array)?],
2020 }
2021 } else {
2022 Value::Call {
2023 name: "valueInArray".to_string(),
2024 args: vec![self.lower_value(array)?, self.lower_value(index)?],
2025 }
2026 }
2027 }
2028 Expr::Format { text, args, .. } => {
2029 let text_node = self.wir.values.push(ValueNode::new(
2030 Value::String(text.clone()),
2031 self.wir_span(span)?,
2032 ));
2033 let mut call_args = vec![text_node];
2034 for arg in args {
2035 call_args.push(self.lower_value(arg)?);
2036 }
2037 Value::Call {
2038 name: "customString".to_string(),
2039 args: call_args,
2040 }
2041 }
2042 Expr::Binary {
2043 op, left, right, ..
2044 } => match op.as_str() {
2045 "==" | "!=" | "<" | "<=" | ">" | ">=" => Value::Call {
2046 name: op.clone(),
2047 args: vec![self.lower_value(left)?, self.lower_value(right)?],
2048 },
2049 "+" => Value::Call {
2050 name: "add".to_string(),
2051 args: vec![self.lower_value(left)?, self.lower_value(right)?],
2052 },
2053 "-" => Value::Call {
2054 name: "subtract".to_string(),
2055 args: vec![self.lower_value(left)?, self.lower_value(right)?],
2056 },
2057 "*" => Value::Call {
2058 name: "multiply".to_string(),
2059 args: vec![self.lower_value(left)?, self.lower_value(right)?],
2060 },
2061 "/" => Value::Call {
2062 name: "divide".to_string(),
2063 args: vec![self.lower_value(left)?, self.lower_value(right)?],
2064 },
2065 "%" => Value::Call {
2066 name: "modulo".to_string(),
2067 args: vec![self.lower_value(left)?, self.lower_value(right)?],
2068 },
2069 "**" => Value::Call {
2070 name: "raiseToPower".to_string(),
2071 args: vec![self.lower_value(left)?, self.lower_value(right)?],
2072 },
2073 "and" => Value::Call {
2074 name: "and".to_string(),
2075 args: vec![self.lower_value(left)?, self.lower_value(right)?],
2076 },
2077 "or" => Value::Call {
2078 name: "or".to_string(),
2079 args: vec![self.lower_value(left)?, self.lower_value(right)?],
2080 },
2081 "in" => Value::Call {
2082 name: "arrayContains".to_string(),
2083 args: vec![self.lower_value(right)?, self.lower_value(left)?],
2084 },
2085 "not in" => {
2086 let right_val = self.lower_value(right)?;
2087 let left_val = self.lower_value(left)?;
2088 let wir_span = self.wir_span(span)?;
2089 let contains = self.wir.values.push(ValueNode::new(
2090 Value::Call {
2091 name: "arrayContains".to_string(),
2092 args: vec![right_val, left_val],
2093 },
2094 wir_span,
2095 ));
2096 Value::Call {
2097 name: "not".to_string(),
2098 args: vec![contains],
2099 }
2100 }
2101 _ => {
2102 return Err(self.unsupported(
2103 format!(
2104 "binary operator '{op}' is not currently representable in canonical WIR"
2105 ),
2106 span,
2107 ));
2108 }
2109 },
2110 Expr::Unary { op, operand, .. } => match op.as_str() {
2111 "not" => {
2112 if let Expr::Binary {
2117 op: comparison,
2118 left,
2119 right,
2120 ..
2121 } = operand.as_ref()
2122 {
2123 if let Some(negated) = negated_comparison(comparison) {
2124 Value::Call {
2125 name: negated.to_string(),
2126 args: vec![self.lower_value(left)?, self.lower_value(right)?],
2127 }
2128 } else {
2129 Value::Call {
2130 name: "not".to_string(),
2131 args: vec![self.lower_value(operand)?],
2132 }
2133 }
2134 } else {
2135 Value::Call {
2136 name: "not".to_string(),
2137 args: vec![self.lower_value(operand)?],
2138 }
2139 }
2140 }
2141 "-" => Value::Call {
2142 name: "-".to_string(),
2143 args: vec![self.lower_value(operand)?],
2144 },
2145 "+" => return self.lower_value(operand),
2146 _ => {
2147 return Err(self.unsupported(
2148 format!(
2149 "unary operator '{op}' is not currently representable in canonical WIR"
2150 ),
2151 span,
2152 ));
2153 }
2154 },
2155 Expr::Call { name, args, .. } => {
2156 if name == "vect" && args.len() == 3 {
2157 Value::Vector {
2158 x: self.lower_value(&args[0])?,
2159 y: self.lower_value(&args[1])?,
2160 z: self.lower_value(&args[2])?,
2161 }
2162 } else {
2163 let function = self
2164 .compiler
2165 .manifest
2166 .resolve_function(name)
2167 .ok_or_else(|| self.unsupported(format!("unknown value '{name}'"), span))?;
2168 if !matches!(function.kind, FunctionKind::Value) {
2169 return Err(
2170 self.unsupported(format!("'{name}' is not a generic OPY value"), span)
2171 );
2172 }
2173 let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
2174 self.unsupported(
2175 format!(
2176 "value '{}' requires a special lowering not in #46",
2177 function.id
2178 ),
2179 span,
2180 )
2181 })?;
2182 Value::Call {
2183 name: catalog_id.clone(),
2184 args: args
2185 .iter()
2186 .map(|arg| self.lower_value(arg))
2187 .collect::<Result<Vec<_>, _>>()?,
2188 }
2189 }
2190 }
2191 Expr::ReceiverCall {
2192 receiver,
2193 name,
2194 args,
2195 ..
2196 } => {
2197 let function = self.compiler.manifest.resolve_member(name).ok_or_else(|| {
2198 self.unsupported(format!("unknown member value '{name}'"), span)
2199 })?;
2200 if !matches!(function.kind, FunctionKind::MemberValue) {
2201 return Err(self.unsupported(format!("'{name}' is not a member value"), span));
2202 }
2203 let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
2204 self.unsupported(
2205 format!(
2206 "member value '{}' has no canonical catalog identity",
2207 function.id
2208 ),
2209 span,
2210 )
2211 })?;
2212 let mut lowered = Vec::with_capacity(args.len() + 1);
2213 lowered.push(self.lower_value(receiver)?);
2214 lowered.extend(
2215 args.iter()
2216 .map(|arg| self.lower_value(arg))
2217 .collect::<Result<Vec<_>, _>>()?,
2218 );
2219 Value::Call {
2220 name: catalog_id.clone(),
2221 args: lowered,
2222 }
2223 }
2224 Expr::Member {
2225 receiver, member, ..
2226 } => {
2227 let receiver = self.lower_value(receiver)?;
2228 let member = self.wir.values.push(ValueNode::new(
2229 Value::String(member.clone()),
2230 self.wir_span(span)?,
2231 ));
2232 Value::Call {
2233 name: "memberAccess".to_string(),
2234 args: vec![receiver, member],
2235 }
2236 }
2237 _ => {
2238 return Err(self.unsupported(
2239 format!(
2240 "expression '{}' is not currently representable in canonical WIR",
2241 expr.kind_name()
2242 ),
2243 span,
2244 ));
2245 }
2246 };
2247 Ok(self
2248 .wir
2249 .values
2250 .push(ValueNode::new(value, self.wir_span(span)?)))
2251 }
2252
2253 fn wir_span(&self, span: Option<HirSpan>) -> Result<Option<WorkshopSpan>, IntegrationError> {
2254 let Some(span) = span else {
2255 return Ok(None);
2256 };
2257 let file = *self.files.get(&span.file).ok_or_else(|| {
2258 IntegrationError::new(
2259 "source-file",
2260 format!("HIR span references unknown source file id {}", span.file),
2261 Some(span),
2262 )
2263 })?;
2264 Ok(Some(WorkshopSpan::new(
2265 file,
2266 WorkshopPosition::new(span.start.line, span.start.col),
2267 WorkshopPosition::new(span.end.line, span.end.col),
2268 )))
2269 }
2270
2271 fn hir_span_from_workshop(&self, span: WorkshopSpan) -> Option<HirSpan> {
2272 let file = *self.wir_to_hir_files.get(span.file.index())?;
2273 Some(HirSpan {
2274 file,
2275 start: hir::Position {
2276 line: span.start.line,
2277 col: span.start.col,
2278 },
2279 end: hir::Position {
2280 line: span.end.line,
2281 col: span.end.col,
2282 },
2283 })
2284 }
2285
2286 fn unsupported(&self, message: impl Into<String>, span: Option<HirSpan>) -> IntegrationError {
2287 IntegrationError::new("unsupported-integration-surface", message, span)
2288 }
2289}
2290
2291fn implicit_default_variables(
2295 hir: &hir::Program,
2296) -> (
2297 BTreeMap<String, Option<HirSpan>>,
2298 BTreeMap<String, Option<HirSpan>>,
2299) {
2300 let declared_globals = hir
2301 .declarations
2302 .iter()
2303 .filter_map(|declaration| match declaration {
2304 hir::Declaration::GlobalVariable { name, .. } => Some(name.as_str()),
2305 _ => None,
2306 })
2307 .collect::<HashSet<_>>();
2308 let declared_players = hir
2309 .declarations
2310 .iter()
2311 .filter_map(|declaration| match declaration {
2312 hir::Declaration::PlayerVariable { name, .. } => Some(name.as_str()),
2313 _ => None,
2314 })
2315 .collect::<HashSet<_>>();
2316 let mut globals = BTreeMap::new();
2317 let mut players = BTreeMap::new();
2318 for declaration in &hir.declarations {
2319 let initializer = match declaration {
2320 hir::Declaration::GlobalVariable { initializer, .. }
2321 | hir::Declaration::PlayerVariable { initializer, .. } => initializer.as_ref(),
2322 hir::Declaration::Constant { value, .. } => Some(value),
2323 _ => None,
2324 };
2325 if let Some(expr) = initializer {
2326 collect_implicit_expr(
2327 expr,
2328 &declared_globals,
2329 &declared_players,
2330 &mut globals,
2331 &mut players,
2332 );
2333 }
2334 }
2335 for entry in &hir.rules {
2336 match entry {
2337 RuleEntry::Rule(rule) => {
2338 for condition in &rule.conditions {
2339 collect_implicit_expr(
2340 condition,
2341 &declared_globals,
2342 &declared_players,
2343 &mut globals,
2344 &mut players,
2345 );
2346 }
2347 collect_implicit_stmts(
2348 &rule.actions,
2349 &declared_globals,
2350 &declared_players,
2351 &mut globals,
2352 &mut players,
2353 );
2354 }
2355 RuleEntry::SubroutineDef { body, .. } => collect_implicit_stmts(
2356 body,
2357 &declared_globals,
2358 &declared_players,
2359 &mut globals,
2360 &mut players,
2361 ),
2362 }
2363 }
2364 (globals, players)
2365}
2366
2367fn collect_implicit_stmts(
2368 statements: &[Stmt],
2369 declared_globals: &HashSet<&str>,
2370 declared_players: &HashSet<&str>,
2371 globals: &mut BTreeMap<String, Option<HirSpan>>,
2372 players: &mut BTreeMap<String, Option<HirSpan>>,
2373) {
2374 for statement in statements {
2375 match statement {
2376 Stmt::Expr { expr, .. } => {
2377 collect_implicit_expr(expr, declared_globals, declared_players, globals, players)
2378 }
2379 Stmt::Assign { target, value, .. } => {
2380 collect_implicit_expr(target, declared_globals, declared_players, globals, players);
2381 collect_implicit_expr(value, declared_globals, declared_players, globals, players);
2382 }
2383 Stmt::If {
2384 branches, r#else, ..
2385 } => {
2386 for branch in branches {
2387 collect_implicit_expr(
2388 &branch.condition,
2389 declared_globals,
2390 declared_players,
2391 globals,
2392 players,
2393 );
2394 collect_implicit_stmts(
2395 &branch.body,
2396 declared_globals,
2397 declared_players,
2398 globals,
2399 players,
2400 );
2401 }
2402 if let Some(default_body) = r#else {
2403 collect_implicit_stmts(
2404 default_body,
2405 declared_globals,
2406 declared_players,
2407 globals,
2408 players,
2409 );
2410 }
2411 }
2412 Stmt::For {
2413 variable,
2414 iterable,
2415 body,
2416 ..
2417 } => {
2418 collect_implicit_expr(
2419 variable,
2420 declared_globals,
2421 declared_players,
2422 globals,
2423 players,
2424 );
2425 collect_implicit_expr(
2426 iterable,
2427 declared_globals,
2428 declared_players,
2429 globals,
2430 players,
2431 );
2432 collect_implicit_stmts(body, declared_globals, declared_players, globals, players);
2433 }
2434 Stmt::While {
2435 condition, body, ..
2436 }
2437 | Stmt::DoWhile {
2438 condition, body, ..
2439 } => {
2440 collect_implicit_expr(
2441 condition,
2442 declared_globals,
2443 declared_players,
2444 globals,
2445 players,
2446 );
2447 collect_implicit_stmts(body, declared_globals, declared_players, globals, players);
2448 }
2449 Stmt::Switch { value, arms, .. } => {
2450 collect_implicit_expr(value, declared_globals, declared_players, globals, players);
2451 for arm in arms {
2452 match arm {
2453 SwitchArm::Case { value, body, .. } => {
2454 collect_implicit_expr(
2455 value,
2456 declared_globals,
2457 declared_players,
2458 globals,
2459 players,
2460 );
2461 collect_implicit_stmts(
2462 body,
2463 declared_globals,
2464 declared_players,
2465 globals,
2466 players,
2467 );
2468 }
2469 SwitchArm::Default { body, .. } => {
2470 collect_implicit_stmts(
2471 body,
2472 declared_globals,
2473 declared_players,
2474 globals,
2475 players,
2476 );
2477 }
2478 }
2479 }
2480 }
2481 Stmt::Break { .. } | Stmt::CallSubroutine { .. } | Stmt::Pass { .. } => {}
2482 }
2483 }
2484}
2485
2486fn collect_implicit_expr(
2487 expr: &Expr,
2488 declared_globals: &HashSet<&str>,
2489 declared_players: &HashSet<&str>,
2490 globals: &mut BTreeMap<String, Option<HirSpan>>,
2491 players: &mut BTreeMap<String, Option<HirSpan>>,
2492) {
2493 match expr {
2494 Expr::GlobalVar { name, span } => {
2495 if !declared_globals.contains(name.as_str()) && default_var_index(name).is_some() {
2496 globals.entry(name.clone()).or_insert(*span);
2497 }
2498 }
2499 Expr::Array { elements, .. } => {
2500 for element in elements {
2501 collect_implicit_expr(
2502 element,
2503 declared_globals,
2504 declared_players,
2505 globals,
2506 players,
2507 );
2508 }
2509 }
2510 Expr::Dict { entries, .. } => {
2511 for entry in entries {
2512 collect_implicit_expr(
2513 &entry.key,
2514 declared_globals,
2515 declared_players,
2516 globals,
2517 players,
2518 );
2519 collect_implicit_expr(
2520 &entry.value,
2521 declared_globals,
2522 declared_players,
2523 globals,
2524 players,
2525 );
2526 }
2527 }
2528 Expr::Comprehension {
2529 element,
2530 iterable,
2531 condition,
2532 ..
2533 } => {
2534 collect_implicit_expr(
2535 element,
2536 declared_globals,
2537 declared_players,
2538 globals,
2539 players,
2540 );
2541 collect_implicit_expr(
2542 iterable,
2543 declared_globals,
2544 declared_players,
2545 globals,
2546 players,
2547 );
2548 if let Some(condition) = condition {
2549 collect_implicit_expr(
2550 condition,
2551 declared_globals,
2552 declared_players,
2553 globals,
2554 players,
2555 );
2556 }
2557 }
2558 Expr::Lambda { body, .. } => {
2559 collect_implicit_expr(body, declared_globals, declared_players, globals, players)
2560 }
2561 Expr::Vector { x, y, z, .. } => {
2562 collect_implicit_expr(x, declared_globals, declared_players, globals, players);
2563 collect_implicit_expr(y, declared_globals, declared_players, globals, players);
2564 collect_implicit_expr(z, declared_globals, declared_players, globals, players);
2565 }
2566 Expr::PlayerVar { player, name, span } => {
2567 if matches!(player.as_ref(), Expr::EventPlayer { .. })
2568 && !declared_players.contains(name.as_str())
2569 && default_var_index(name).is_some()
2570 {
2571 players.entry(name.clone()).or_insert(*span);
2572 }
2573 collect_implicit_expr(player, declared_globals, declared_players, globals, players);
2574 }
2575 Expr::Member { receiver, .. } => collect_implicit_expr(
2576 receiver,
2577 declared_globals,
2578 declared_players,
2579 globals,
2580 players,
2581 ),
2582 Expr::Call { args, .. } | Expr::MacroCall { args, .. } => {
2583 for arg in args {
2584 collect_implicit_expr(arg, declared_globals, declared_players, globals, players);
2585 }
2586 }
2587 Expr::ReceiverCall { receiver, args, .. } => {
2588 collect_implicit_expr(
2589 receiver,
2590 declared_globals,
2591 declared_players,
2592 globals,
2593 players,
2594 );
2595 for arg in args {
2596 collect_implicit_expr(arg, declared_globals, declared_players, globals, players);
2597 }
2598 }
2599 Expr::Binary { left, right, .. } => {
2600 collect_implicit_expr(left, declared_globals, declared_players, globals, players);
2601 collect_implicit_expr(right, declared_globals, declared_players, globals, players);
2602 }
2603 Expr::Unary { operand, .. } => collect_implicit_expr(
2604 operand,
2605 declared_globals,
2606 declared_players,
2607 globals,
2608 players,
2609 ),
2610 Expr::Index { array, index, .. } => {
2611 collect_implicit_expr(array, declared_globals, declared_players, globals, players);
2612 collect_implicit_expr(index, declared_globals, declared_players, globals, players);
2613 }
2614 Expr::Format { args, .. } => {
2615 for arg in args {
2616 collect_implicit_expr(arg, declared_globals, declared_players, globals, players);
2617 }
2618 }
2619 Expr::Number { .. }
2620 | Expr::String { .. }
2621 | Expr::Bool { .. }
2622 | Expr::Null { .. }
2623 | Expr::StringModifier { .. }
2624 | Expr::Local { .. }
2625 | Expr::Enum { .. }
2626 | Expr::EventPlayer { .. }
2627 | Expr::Constant { .. }
2628 | Expr::MacroParam { .. } => {}
2629 }
2630}
2631
2632fn allocate_indices(
2633 entries: &[(Option<u32>, Option<HirSpan>)],
2634 pre_reserved: &HashSet<u32>,
2635 kind: &str,
2636) -> Result<Vec<u32>, IntegrationError> {
2637 let mut reserved = pre_reserved.clone();
2638 for (index, span) in entries {
2639 let Some(index) = index else {
2640 continue;
2641 };
2642 if !reserved.insert(*index) {
2643 return Err(IntegrationError::new(
2644 "index-collision",
2645 format!("duplicate explicit {kind} index {index}"),
2646 *span,
2647 ));
2648 }
2649 }
2650
2651 let mut next = 0;
2656 let mut allocated = Vec::with_capacity(entries.len());
2657 for (index, span) in entries {
2658 let assigned = if let Some(index) = index {
2659 *index
2660 } else {
2661 while reserved.contains(&next) {
2662 next = next.checked_add(1).ok_or_else(|| {
2663 IntegrationError::new(
2664 "index-exhausted",
2665 format!("no available {kind} index remains"),
2666 *span,
2667 )
2668 })?;
2669 }
2670 reserved.insert(next);
2671 let assigned = next;
2672 next = next.checked_add(1).ok_or_else(|| {
2673 IntegrationError::new(
2674 "index-exhausted",
2675 format!("no available {kind} index remains"),
2676 *span,
2677 )
2678 })?;
2679 assigned
2680 };
2681 allocated.push(assigned);
2682 }
2683 Ok(allocated)
2684}
2685
2686fn player_event_kind(name: &str) -> Option<PlayerEventKind> {
2687 Some(match name {
2688 "playerDealtDamage" => PlayerEventKind::DealtDamage,
2689 "playerDealtFinalBlow" => PlayerEventKind::DealtFinalBlow,
2690 "playerDealtHealing" => PlayerEventKind::DealtHealing,
2691 "playerDied" => PlayerEventKind::Died,
2692 "playerEarnedElimination" => PlayerEventKind::EarnedElimination,
2693 "playerJoined" => PlayerEventKind::Joined,
2694 "playerLeft" => PlayerEventKind::Left,
2695 "playerReceivedHealing" => PlayerEventKind::ReceivedHealing,
2696 "playerTookDamage" => PlayerEventKind::TookDamage,
2697 _ => return None,
2698 })
2699}
2700
2701fn is_zero_initializer(expr: &hir::Expr) -> bool {
2702 match expr {
2703 hir::Expr::Number { text, value, .. } => text == "0" && *value == 0.0,
2704 _ => false,
2705 }
2706}
2707
2708fn canonical_number_text(value: f64, text: &str) -> String {
2709 if text.starts_with("0x") || text.starts_with("0X") {
2710 value.to_string()
2711 } else {
2712 text.to_string()
2713 }
2714}
2715
2716fn negated_comparison(op: &str) -> Option<&'static str> {
2717 Some(match op {
2718 "==" => "!=",
2719 "!=" => "==",
2720 "<" => ">=",
2721 ">" => "<=",
2722 "<=" => ">",
2723 ">=" => "<",
2724 _ => return None,
2725 })
2726}
2727
2728fn modify_op_from_str(op: &str) -> Option<wir::ModifyOp> {
2729 match op {
2730 "+" => Some(wir::ModifyOp::Add),
2731 "-" => Some(wir::ModifyOp::Subtract),
2732 "*" => Some(wir::ModifyOp::Multiply),
2733 "/" => Some(wir::ModifyOp::Divide),
2734 "%" => Some(wir::ModifyOp::Modulo),
2735 "**" => Some(wir::ModifyOp::RaiseToPower),
2736 _ => None,
2737 }
2738}
2739
2740fn modify_catalog_name_from_str(op: &str) -> Option<&'static str> {
2741 match op {
2742 "+" => Some("add"),
2743 "-" => Some("subtract"),
2744 "*" => Some("multiply"),
2745 "/" => Some("divide"),
2746 "%" => Some("modulo"),
2747 "**" => Some("raiseToPower"),
2748 _ => None,
2749 }
2750}
2751
2752fn hir_span_from_diag(span: opy_rs::diag::Span) -> HirSpan {
2753 HirSpan {
2754 file: span.file,
2755 start: hir::Position {
2756 line: span.start.line,
2757 col: span.start.col,
2758 },
2759 end: hir::Position {
2760 line: span.end.line,
2761 col: span.end.col,
2762 },
2763 }
2764}
2765
2766fn workshop_error_span(error: &workshop_rs::WorkshopError) -> Option<WorkshopSpan> {
2767 match error {
2768 workshop_rs::WorkshopError::Unknown { span, .. }
2769 | workshop_rs::WorkshopError::Malformed { span, .. }
2770 | workshop_rs::WorkshopError::Unsupported { span, .. } => *span,
2771 workshop_rs::WorkshopError::Catalog(_)
2772 | workshop_rs::WorkshopError::MissingMapping { .. } => None,
2773 }
2774}
2775
2776#[cfg(test)]
2777mod tests {
2778 use super::{Compiler, WORKSHOP_RS_VERSION, cross_check_manifest};
2779 use opy_rs::manifest::Manifest;
2780 use std::path::Path;
2781 use workshop_rs::catalog::{Catalog, Locale};
2782
2783 #[test]
2784 fn public_contract_is_pinned_and_manifest_links_are_checked() {
2785 let compiler = Compiler::new().expect("released workshop contract must load");
2786 let identity = compiler.catalog_identity();
2787 assert_eq!(identity.implementation_version, WORKSHOP_RS_VERSION);
2788 assert!(compiler.link_report().catalog_ids_checked > 0);
2789 assert!(compiler.link_report().domains_checked > 0);
2790 }
2791
2792 #[test]
2793 fn vertical_slice_preserves_source_files_spans_and_emits_workshop() {
2794 let compiler = Compiler::new().unwrap();
2795 let hir = opy_rs::compile(
2796 "globalvar A\nrule \"issue 35 integration\":\n @Event global\n A = 1\n disableInspector()\n",
2797 "issue-35-integration.opy",
2798 Path::new("."),
2799 )
2800 .unwrap();
2801 let artifact = compiler.compile_hir(&hir).unwrap();
2802 assert_eq!(
2803 artifact
2804 .wir
2805 .files
2806 .get(workshop_rs::source::FileId::from_index(0))
2807 .unwrap()
2808 .path,
2809 "issue-35-integration.opy"
2810 );
2811 let rule = artifact
2812 .wir
2813 .rules
2814 .get(workshop_rs::wir::RuleId::from_index(0))
2815 .unwrap();
2816 assert_eq!(rule.span.unwrap().file.index(), 0);
2817 assert_eq!(rule.name_span.unwrap().start.line, 2);
2818 assert!(artifact.emitted.contains("Disable Inspector Recording;"));
2819 assert_eq!(artifact.catalog_identity.implementation_version, "0.1.11");
2820 }
2821
2822 #[test]
2823 fn stale_catalog_links_fail_explicitly() {
2824 let manifest = Manifest::builtin().unwrap().clone();
2825 let mut stale = manifest;
2826 stale.functions[0].catalog_id = Some("missing-catalog-id".to_string());
2827 let error = cross_check_manifest(&stale, &Catalog::builtin().unwrap()).unwrap_err();
2828 assert_eq!(error.diagnostic.code, "catalog-link-missing");
2829 }
2830
2831 #[test]
2832 fn while_lowering_is_source_attributed() {
2833 let compiler = Compiler::new().unwrap();
2834 let hir = opy_rs::compile(
2835 "rule \"while\":\n @Event global\n while true:\n disableInspector()\n",
2836 "while.opy",
2837 Path::new("."),
2838 )
2839 .unwrap();
2840 let artifact = compiler.compile_hir(&hir).unwrap();
2841 let rule = artifact
2842 .wir
2843 .rules
2844 .get(workshop_rs::wir::RuleId::from_index(0))
2845 .unwrap();
2846 assert!(matches!(
2847 artifact.wir.actions.get(rule.actions[0]),
2848 Some(workshop_rs::wir::Action::While { .. })
2849 ));
2850 assert!(artifact.emitted.contains("While(True);"));
2851 }
2852
2853 #[test]
2854 fn structural_subroutines_lower_to_canonical_wir() {
2855 let compiler = Compiler::new().unwrap();
2856 let hir = opy_rs::compile(
2857 "globalvar score\nsubroutine showStatus\ndef showStatus():\n @Name \"Friendly\"\n @SuppressWarnings unusedVariable\n disableInspector()\nrule \"caller\":\n @Event global\n showStatus()\n",
2858 "structure.opy",
2859 Path::new("."),
2860 )
2861 .unwrap();
2862 let artifact = compiler.compile_hir(&hir).unwrap();
2863 let subroutine = artifact
2864 .wir
2865 .subroutines
2866 .get(workshop_rs::wir::SubroutineId::from_index(0))
2867 .unwrap();
2868 assert_eq!(subroutine.name, "showStatus");
2869 assert_eq!(subroutine.index, 0);
2870 assert_eq!(subroutine.name_span.unwrap().start.line, 2);
2871 assert_eq!(artifact.wir.rules.len(), 2);
2872 let subroutine_rule = artifact
2873 .wir
2874 .rules
2875 .get(workshop_rs::wir::RuleId::from_index(0))
2876 .unwrap();
2877 let workshop_rs::wir::Event::Subroutine(subroutine_id) = subroutine_rule.event else {
2878 panic!("expected a subroutine event");
2879 };
2880 assert_eq!(
2881 artifact.wir.subroutines.get(subroutine_id).unwrap().name,
2882 "showStatus"
2883 );
2884 assert!(matches!(
2885 artifact
2886 .wir
2887 .actions
2888 .get(workshop_rs::wir::ActionId::from_index(1))
2889 .unwrap(),
2890 workshop_rs::wir::Action::CallSubroutine { .. }
2891 ));
2892 assert!(artifact.emitted.contains("Subroutine Friendly"));
2893 }
2894
2895 #[test]
2896 fn player_event_filters_resolve_through_canonical_catalog() {
2897 let compiler = Compiler::new().unwrap();
2898 let hir = opy_rs::compile(
2899 "rule \"joined\":\n @Event playerJoined\n @Team 1\n @Slot 2\n disableInspector()\n",
2900 "filters.opy",
2901 Path::new("."),
2902 )
2903 .unwrap();
2904 let artifact = compiler.compile_hir(&hir).unwrap();
2905 assert!(matches!(
2906 &artifact
2907 .wir
2908 .rules
2909 .get(workshop_rs::wir::RuleId::from_index(0))
2910 .unwrap()
2911 .event,
2912 workshop_rs::wir::Event::Player {
2913 kind: workshop_rs::wir::PlayerEventKind::Joined,
2914 team: workshop_rs::wir::EventTeam::Team1,
2915 target: workshop_rs::wir::EventTarget::Slot(2),
2916 }
2917 ));
2918 assert!(artifact.emitted.contains("Player Joined Match;"));
2919 }
2920
2921 #[test]
2922 fn explicit_indices_are_reserved_before_deterministic_allocation() {
2923 let compiler = Compiler::new().unwrap();
2924 let hir = opy_rs::compile(
2925 "globalvar first\nglobalvar reserved 0\nglobalvar next\nrule \"indices\":\n @Event global\n disableInspector()\n",
2926 "indices.opy",
2927 Path::new("."),
2928 )
2929 .unwrap();
2930 let artifact = compiler.compile_hir(&hir).unwrap();
2931 let by_name = artifact
2932 .wir
2933 .global_variables
2934 .iter()
2935 .map(|variable| (variable.name.as_str(), variable.index))
2936 .collect::<std::collections::BTreeMap<_, _>>();
2937 assert_eq!(
2938 by_name,
2939 std::collections::BTreeMap::from([("first", 1), ("reserved", 0), ("next", 2)])
2940 );
2941 let indices = artifact
2943 .wir
2944 .global_variables
2945 .iter()
2946 .map(|variable| variable.index)
2947 .collect::<Vec<_>>();
2948 assert_eq!(indices, vec![0, 1, 2]);
2949 }
2950
2951 #[test]
2952 fn implicit_default_variables_use_reference_fixed_slots() {
2953 let compiler = Compiler::new().unwrap();
2954 let hir = opy_rs::compile(
2955 r#"
2956globalvar timer
2957globalvar extra 5
2958
2959rule "implicit":
2960 @Event global
2961 A = timer + 1
2962 B = A
2963 B += 2
2964 A[0] = 7
2965 DX = B * A
2966"#,
2967 "implicit.opy",
2968 Path::new("."),
2969 )
2970 .unwrap();
2971 let artifact = compiler.compile_hir(&hir).unwrap();
2972 let globals = artifact
2973 .wir
2974 .global_variables
2975 .iter()
2976 .map(|variable| (variable.name.clone(), variable.index))
2977 .collect::<Vec<_>>();
2978 assert_eq!(
2983 globals,
2984 vec![
2985 ("A".to_string(), 0),
2986 ("B".to_string(), 1),
2987 ("timer".to_string(), 2),
2988 ("extra".to_string(), 5),
2989 ("DX".to_string(), 127),
2990 ]
2991 );
2992 assert!(
2993 artifact
2994 .emitted
2995 .contains("Set Global Variable(A, Add(Global.timer, 1));")
2996 );
2997 assert!(
2998 artifact
2999 .emitted
3000 .contains("Set Global Variable(B, Global.A);")
3001 );
3002 assert!(
3003 artifact
3004 .emitted
3005 .contains("Modify Global Variable(B, Add, 2);")
3006 );
3007 assert!(
3008 artifact
3009 .emitted
3010 .contains("Set Global Variable At Index(A, 0, 7);")
3011 );
3012 assert!(
3013 artifact
3014 .emitted
3015 .contains("Set Global Variable(DX, Multiply(Global.B, Global.A));")
3016 );
3017 }
3018
3019 #[test]
3020 fn implicit_default_variable_slot_collision_is_source_attributed() {
3021 let compiler = Compiler::new().unwrap();
3022 let hir = opy_rs::compile(
3023 "globalvar x 0\nrule \"collision\":\n @Event global\n x = 1\n A = 2\n",
3024 "collision.opy",
3025 Path::new("."),
3026 )
3027 .unwrap();
3028 let error = match compiler.compile_hir(&hir) {
3029 Ok(_) => panic!("slot collision unexpectedly succeeded"),
3030 Err(error) => error,
3031 };
3032 assert_eq!(error.diagnostic.code, "index-collision");
3033 assert_eq!(error.diagnostic.span.unwrap().start.line, 5);
3034 assert!(error.diagnostic.message.contains("'A' and 'x'"));
3035 }
3036
3037 #[test]
3038 fn implicit_default_player_variables_use_independent_reference_slots() {
3039 let compiler = Compiler::new().unwrap();
3040 let hir = opy_rs::compile(
3041 r#"
3042playervar declaredPlayer
3043
3044rule "implicit player variables":
3045 @Event eachPlayer
3046 A = 1
3047 eventPlayer.A = 1
3048 eventPlayer.A += 2
3049 eventPlayer.E = eventPlayer.A
3050 eventPlayer.DX = eventPlayer.E
3051 eventPlayer.declaredPlayer = eventPlayer.A
3052"#,
3053 "implicit-player.opy",
3054 Path::new("."),
3055 )
3056 .unwrap();
3057 let artifact = compiler.compile_hir(&hir).unwrap();
3058 let globals = artifact
3059 .wir
3060 .global_variables
3061 .iter()
3062 .map(|variable| (variable.name.as_str(), variable.index))
3063 .collect::<std::collections::BTreeMap<_, _>>();
3064 let players = artifact
3065 .wir
3066 .player_variables
3067 .iter()
3068 .map(|variable| (variable.name.as_str(), variable.index))
3069 .collect::<std::collections::BTreeMap<_, _>>();
3070 assert_eq!(globals.get("A"), Some(&0));
3071 assert_eq!(players.get("A"), Some(&0));
3072 assert_eq!(players.get("declaredPlayer"), Some(&1));
3073 assert_eq!(players.get("E"), Some(&4));
3074 assert_eq!(players.get("DX"), Some(&127));
3075 assert!(
3076 artifact
3077 .emitted
3078 .contains("Set Player Variable(Event Player, A, 1);")
3079 );
3080 assert!(
3081 artifact
3082 .emitted
3083 .contains("Modify Player Variable(Event Player, A, Add, 2);")
3084 );
3085 assert!(
3086 artifact
3087 .emitted
3088 .contains("Set Player Variable(Event Player, E, (Event Player).A);")
3089 );
3090 }
3091
3092 #[test]
3093 fn implicit_default_player_slot_collision_is_source_attributed() {
3094 let compiler = Compiler::new().unwrap();
3095 let hir = opy_rs::compile(
3096 "playervar declared 0\nrule \"collision\":\n @Event eachPlayer\n eventPlayer.A = 1\n",
3097 "player-collision.opy",
3098 Path::new("."),
3099 )
3100 .unwrap();
3101 let error = match compiler.compile_hir(&hir) {
3102 Ok(_) => panic!("player slot collision unexpectedly succeeded"),
3103 Err(error) => error,
3104 };
3105 assert_eq!(error.diagnostic.code, "index-collision");
3106 assert!(
3107 error
3108 .diagnostic
3109 .message
3110 .contains("player variables 'A' and 'declared'")
3111 );
3112 assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
3113 }
3114
3115 #[test]
3116 fn power_augmented_assignment_lowers_from_source() {
3117 let compiler = Compiler::new().unwrap();
3118 let hir = opy_rs::compile(
3119 "globalvar g\nrule \"power\":\n @Event global\n g = 2\n g **= 3\n",
3120 "power.opy",
3121 Path::new("."),
3122 )
3123 .unwrap();
3124 let artifact = compiler.compile_hir(&hir).unwrap();
3125 assert!(artifact.emitted.contains("Set Global Variable(g, 2);"));
3126 assert!(
3127 artifact
3128 .emitted
3129 .contains("Modify Global Variable(g, Raise To Power, 3);")
3130 );
3131 }
3132
3133 #[test]
3134 fn opy_hex_numbers_are_normalized_at_the_wir_boundary() {
3135 let compiler = Compiler::new().unwrap();
3136 let hir = opy_rs::compile(
3137 "globalvar large = 0x124BC\nglobalvar small = 0x124\nglobalvar scientific = 1e10\n",
3138 "numbers.opy",
3139 Path::new("."),
3140 )
3141 .unwrap();
3142 let artifact = compiler.compile_hir(&hir).unwrap();
3143 assert!(
3144 artifact
3145 .emitted
3146 .contains("Set Global Variable(large, 74940);")
3147 );
3148 assert!(
3149 artifact
3150 .emitted
3151 .contains("Set Global Variable(small, 292);")
3152 );
3153 assert!(
3154 artifact
3155 .emitted
3156 .contains("Set Global Variable(scientific, 1e10);")
3157 );
3158 assert!(!artifact.emitted.contains("0x124BC"));
3159 assert!(!artifact.emitted.contains("0x124"));
3160 }
3161
3162 #[test]
3163 fn unsupported_primitive_lowering_is_stable_and_source_attributed() {
3164 let compiler = Compiler::new().unwrap();
3165 let hir = opy_rs::compile(
3166 "globalvar total\nrule \"negative\":\n @Event global\n total = {\"a\": 1, \"b\": 2}[\"a\"]\n",
3167 "negative.opy",
3168 Path::new("."),
3169 )
3170 .unwrap();
3171 let error = match compiler.compile_hir(&hir) {
3172 Ok(_) => panic!("dict primitive lowering unexpectedly succeeded"),
3173 Err(error) => error,
3174 };
3175 assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
3176 assert!(error.diagnostic.message.contains("dict"));
3177 assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
3178 }
3179
3180 #[test]
3181 fn auto_allocation_fills_free_slots_below_early_explicit_indices() {
3182 let compiler = Compiler::new().unwrap();
3183 let hir = opy_rs::compile(
3184 r#"
3185globalvar reserved 5
3186globalvar auto1
3187globalvar auto2
3188
3189rule "allocation":
3190 @Event global
3191 auto1 = 1
3192 auto2 = 2
3193 B = 3
3194"#,
3195 "allocation.opy",
3196 Path::new("."),
3197 )
3198 .unwrap();
3199 let artifact = compiler.compile_hir(&hir).unwrap();
3200 let by_name = artifact
3201 .wir
3202 .global_variables
3203 .iter()
3204 .map(|variable| (variable.name.clone(), variable.index))
3205 .collect::<std::collections::BTreeMap<_, _>>();
3206 assert_eq!(
3211 by_name,
3212 std::collections::BTreeMap::from([
3213 ("B".to_string(), 1),
3214 ("auto1".to_string(), 0),
3215 ("auto2".to_string(), 2),
3216 ("reserved".to_string(), 5),
3217 ])
3218 );
3219 }
3220
3221 #[test]
3222 fn power_expressions_lower_through_the_canonical_contract() {
3223 let compiler = Compiler::new().unwrap();
3224 let hir = opy_rs::compile(
3225 "globalvar a = [2, 4]\nglobalvar out\nrule \"power\":\n @Event global\n out = a ** 2\n a **= 2\n a[0] **= 2\n",
3226 "power.opy",
3227 Path::new("."),
3228 )
3229 .unwrap();
3230 let artifact = compiler.compile_hir(&hir).unwrap();
3231 assert!(
3232 artifact
3233 .emitted
3234 .contains("Set Global Variable(out, Raise To Power(Global.a, 2));")
3235 );
3236 assert!(
3237 artifact
3238 .emitted
3239 .contains("Modify Global Variable(a, Raise To Power, 2);")
3240 );
3241 assert!(
3242 artifact
3243 .emitted
3244 .contains("Modify Global Variable At Index(a, 0, Raise To Power, 2);")
3245 );
3246 }
3247
3248 #[test]
3249 fn unsupported_rule_metadata_is_explicit_and_source_attributed() {
3250 let compiler = Compiler::new().unwrap();
3251 let hir = opy_rs::compile(
3252 "rule \"metadata\":\n @Event global\n @NewPage \"section\"\n disableInspector()\n",
3253 "metadata.opy",
3254 Path::new("."),
3255 )
3256 .unwrap();
3257 let error = match compiler.compile_hir(&hir) {
3258 Ok(_) => panic!("unsupported metadata unexpectedly succeeded"),
3259 Err(error) => error,
3260 };
3261 assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
3262 assert_eq!(error.diagnostic.span.unwrap().start.line, 3);
3263 }
3264
3265 #[test]
3266 fn issue_40_oracle_fixture_and_wir_lowering_agree() {
3267 let compiler = Compiler::new().unwrap();
3268 let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
3269 .join("../../compatibility/fixtures/synthetic/issue-40-structural");
3270 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
3271 let hir = opy_rs::compile(&source, "source.opy", &fixture).unwrap();
3272 let artifact = compiler.compile_hir(&hir).unwrap();
3273 let oracle: serde_json::Value =
3274 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
3275 .unwrap();
3276 let oracle_workshop = oracle["compile"]["workshop"].as_str().unwrap();
3277
3278 assert!(oracle_workshop.contains("0: reserved"));
3279 assert!(oracle_workshop.contains("1: first"));
3280 assert!(oracle_workshop.contains("2: explicit"));
3281 assert!(oracle_workshop.contains("3: next"));
3282 assert!(oracle_workshop.contains("0: helper"));
3283 assert!(oracle_workshop.contains("Subroutine;\n helper;"));
3284 assert!(oracle_workshop.contains("Player Joined Match;\n Team 1;\n Slot 2;"));
3285
3286 let indices = artifact
3287 .wir
3288 .global_variables
3289 .iter()
3290 .map(|variable| variable.index)
3291 .collect::<Vec<_>>();
3292 assert_eq!(indices, vec![0, 1, 2, 3]);
3293 assert_eq!(
3294 artifact.wir.subroutines.iter().next().unwrap().name,
3295 "helper"
3296 );
3297 assert!(artifact.emitted.contains("[Source] renamed helper"));
3298 assert!(matches!(
3299 artifact
3300 .wir
3301 .rules
3302 .get(workshop_rs::wir::RuleId::from_index(1))
3303 .unwrap()
3304 .event,
3305 workshop_rs::wir::Event::Player {
3306 kind: workshop_rs::wir::PlayerEventKind::Joined,
3307 team: workshop_rs::wir::EventTeam::Team1,
3308 target: workshop_rs::wir::EventTarget::Slot(2),
3309 }
3310 ));
3311 }
3312
3313 #[test]
3314 fn assignments_and_modifications_lower_to_canonical_wir() {
3315 let compiler = Compiler::new().unwrap();
3316 let hir = opy_rs::compile(
3317 r#"
3318globalvar g1
3319globalvar g2
3320playervar p1
3321playervar p2 = [1, 2, 3]
3322
3323rule "assignments":
3324 @Event eachPlayer
3325 g1 = 10
3326 g1 += 5
3327 g1 -= 2
3328 g1 *= 3
3329 g1 /= 2
3330 g1 %= 4
3331 g2 = [1, 2, 3]
3332 g2[0] = 99
3333 g2[1] += 1
3334 eventPlayer.p1 = 42
3335 eventPlayer.p1 += 8
3336 eventPlayer.p1 *= 2
3337 eventPlayer.p2[2] = 7
3338 eventPlayer.p2[0] -= 3
3339"#,
3340 "assign.opy",
3341 Path::new("."),
3342 )
3343 .unwrap();
3344 let artifact = compiler.compile_hir(&hir).unwrap();
3345 assert!(artifact.emitted.contains("Set Global Variable(g1, 10);"));
3346 assert!(
3347 artifact
3348 .emitted
3349 .contains("Modify Global Variable(g1, Add, 5);")
3350 );
3351 assert!(
3352 artifact
3353 .emitted
3354 .contains("Modify Global Variable(g1, Subtract, 2);")
3355 );
3356 assert!(
3357 artifact
3358 .emitted
3359 .contains("Modify Global Variable(g1, Multiply, 3);")
3360 );
3361 assert!(
3362 artifact
3363 .emitted
3364 .contains("Modify Global Variable(g1, Divide, 2);")
3365 );
3366 assert!(
3367 artifact
3368 .emitted
3369 .contains("Modify Global Variable(g1, Modulo, 4);")
3370 );
3371 assert!(
3372 artifact
3373 .emitted
3374 .contains("Set Global Variable At Index(g2, 0, 99);")
3375 );
3376 assert!(
3377 artifact
3378 .emitted
3379 .contains("Modify Global Variable At Index(g2, 1, Add, 1);")
3380 );
3381 assert!(
3382 artifact
3383 .emitted
3384 .contains("Set Player Variable(Event Player, p1, 42);")
3385 );
3386 assert!(
3387 artifact
3388 .emitted
3389 .contains("Modify Player Variable(Event Player, p1, Add, 8);")
3390 );
3391 assert!(
3392 artifact
3393 .emitted
3394 .contains("Modify Player Variable(Event Player, p1, Multiply, 2);")
3395 );
3396 assert!(
3397 artifact
3398 .emitted
3399 .contains("Set Player Variable At Index((Event Player).p2, 2, 7);")
3400 );
3401 assert!(
3402 artifact
3403 .emitted
3404 .contains("Modify Player Variable At Index((Event Player).p2, 0, Subtract, 3);")
3405 );
3406
3407 let rule = artifact
3411 .wir
3412 .rules
3413 .get(workshop_rs::wir::RuleId::from_index(1))
3414 .unwrap();
3415 let direct = artifact.wir.actions.get(rule.actions[0]).unwrap();
3416 match direct {
3417 workshop_rs::wir::Action::SetGlobalVariable {
3418 span,
3419 target_span,
3420 variable,
3421 ..
3422 } => {
3423 assert_eq!(span.unwrap().start.line, 9);
3424 assert_eq!(target_span.unwrap().start.line, 9);
3425 assert_eq!(
3426 artifact.wir.global_variables.get(*variable).unwrap().name,
3427 "g1"
3428 );
3429 }
3430 other => panic!("expected a direct global assignment, got {other:?}"),
3431 }
3432 let indexed = artifact.wir.actions.get(rule.actions[7]).unwrap();
3433 match indexed {
3434 workshop_rs::wir::Action::Call { span, .. } => {
3435 assert_eq!(span.unwrap().start.line, 16);
3436 }
3437 other => panic!("expected an indexed assignment call, got {other:?}"),
3438 }
3439 }
3440
3441 #[test]
3442 fn expressions_and_values_lower_to_canonical_wir() {
3443 let compiler = Compiler::new().unwrap();
3444 let hir = opy_rs::compile(
3445 r#"
3446enum Consts:
3447 BASE
3448
3449globalvar total
3450globalvar arr = [1, 2, 3]
3451globalvar pos = vect(1, 2, 3)
3452
3453rule "expressions":
3454 @Event global
3455 @Condition total == 0
3456 @Condition not (pos == vect(0, 0, 0))
3457 @Condition 2 in arr
3458 total = Consts.BASE + arr[1] * 2 - (10 / 2) + (5 % 2)
3459 print("Total: {}".format(total))
3460 debug(pos)
3461"#,
3462 "expr.opy",
3463 Path::new("."),
3464 )
3465 .unwrap();
3466 let artifact = compiler.compile_hir(&hir).unwrap();
3467 assert!(artifact.emitted.contains("Global.total == 0;"));
3468 assert!(artifact.emitted.contains("Global.pos != Vector(0, 0, 0);"));
3471 assert!(
3472 artifact
3473 .emitted
3474 .contains("Array Contains(Global.arr, 2) == True;")
3475 );
3476 assert!(
3477 artifact
3478 .emitted
3479 .contains("Custom String(\"Total: {0}\", Global.total)")
3480 );
3481 }
3482
3483 #[test]
3484 fn pass_is_supported_as_source_level_noop() {
3485 let compiler = Compiler::new().unwrap();
3486 let hir = opy_rs::compile(
3487 r#"
3488subroutine emptySub
3489
3490def emptySub():
3491 pass
3492
3493rule "empty rule":
3494 @Event global
3495 pass
3496"#,
3497 "pass.opy",
3498 Path::new("."),
3499 )
3500 .unwrap();
3501 let artifact = compiler.compile_hir(&hir).unwrap();
3502 let rule0 = artifact
3503 .wir
3504 .rules
3505 .get(workshop_rs::wir::RuleId::from_index(0))
3506 .unwrap();
3507 assert!(rule0.actions.is_empty());
3508 let rule1 = artifact
3509 .wir
3510 .rules
3511 .get(workshop_rs::wir::RuleId::from_index(1))
3512 .unwrap();
3513 assert!(rule1.actions.is_empty());
3514 }
3515
3516 #[test]
3517 fn variable_initializers_synthesize_initialize_rules() {
3518 let compiler = Compiler::new().unwrap();
3519 let hir = opy_rs::compile(
3520 r#"
3521globalvar j = 5
3522globalvar h = 0
3523globalvar k = 0.0
3524playervar p = 7
3525playervar q = 0
3526
3527rule "main":
3528 @Event global
3529 disableInspector()
3530"#,
3531 "init.opy",
3532 Path::new("."),
3533 )
3534 .unwrap();
3535 let artifact = compiler.compile_hir(&hir).unwrap();
3536 assert_eq!(
3537 artifact
3538 .wir
3539 .rules
3540 .get(workshop_rs::wir::RuleId::from_index(0))
3541 .unwrap()
3542 .name,
3543 "Initialize global variables"
3544 );
3545 assert_eq!(
3546 artifact
3547 .wir
3548 .rules
3549 .get(workshop_rs::wir::RuleId::from_index(1))
3550 .unwrap()
3551 .name,
3552 "Initialize player variables"
3553 );
3554 assert_eq!(
3555 artifact
3556 .wir
3557 .rules
3558 .get(workshop_rs::wir::RuleId::from_index(2))
3559 .unwrap()
3560 .name,
3561 "main"
3562 );
3563 assert!(artifact.emitted.contains("Set Global Variable(j, 5);"));
3564 assert!(artifact.emitted.contains("Set Global Variable(k, 0.0);"));
3565 assert!(!artifact.emitted.contains("Set Global Variable(h,"));
3566 assert!(
3567 artifact
3568 .emitted
3569 .contains("Set Player Variable(Event Player, p, 7);")
3570 );
3571 assert!(
3572 !artifact
3573 .emitted
3574 .contains("Set Player Variable(Event Player, q,")
3575 );
3576 }
3577
3578 #[test]
3579 fn settings_lower_through_workshop_owned_emission() {
3580 let compiler = Compiler::new().unwrap();
3581 let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
3582 .join("../../compatibility/fixtures/synthetic/settings");
3583 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
3584 let hir = opy_rs::compile(&source, "source.opy", &fixture).unwrap();
3585 let artifact = compiler.compile_hir(&hir).unwrap();
3586 let oracle: serde_json::Value =
3587 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
3588 .unwrap();
3589 let expected = oracle["compile"]["workshop"]
3590 .as_str()
3591 .unwrap()
3592 .split("\n\nrule")
3593 .next()
3594 .unwrap();
3595 let actual = artifact.emitted.split("\n\nrule").next().unwrap();
3596 assert_eq!(
3597 normalize_workshop_structural_whitespace(actual),
3598 normalize_workshop_structural_whitespace(expected)
3599 );
3600 }
3601
3602 #[test]
3603 fn unsupported_locale_has_no_fabricated_source_span() {
3604 let compiler = Compiler::new().unwrap();
3605 let hir = opy_rs::compile(
3606 "#!translations en\nrule \"r\":\n @Event global\n pass\n",
3607 "locale.opy",
3608 Path::new("."),
3609 )
3610 .unwrap();
3611 let error = match compiler.compile_hir_with_locale(&hir, &Locale::new("xx-XX")) {
3612 Ok(_) => panic!("unsupported locale unexpectedly compiled"),
3613 Err(error) => error,
3614 };
3615 assert_eq!(error.diagnostic.code, "locale-unsupported");
3616 assert_eq!(error.diagnostic.span, None);
3617 }
3618
3619 #[test]
3620 fn locale_selection_emits_catalog_localized_workshop() {
3621 let compiler = Compiler::new().unwrap();
3622 let hir = opy_rs::compile(
3623 "rule \"locale\":\n @Event global\n disableInspector()\n",
3624 "locale.opy",
3625 Path::new("."),
3626 )
3627 .unwrap();
3628 let artifact = compiler
3629 .compile_hir_with_locale(&hir, &Locale::new("zh-CN"))
3630 .unwrap();
3631 assert!(artifact.emitted.contains("规则 (\"locale\")"));
3632 assert!(artifact.emitted.contains("禁用查看器录制"));
3633 }
3634
3635 #[test]
3636 fn unsupported_backend_directives_fail_at_their_source_anchor() {
3637 let compiler = Compiler::new().unwrap();
3638 let hir = opy_rs::compile(
3639 "#!replace0ByCapturePercentage\nrule \"r\":\n @Event global\n pass\n",
3640 "directives.opy",
3641 Path::new("."),
3642 )
3643 .unwrap();
3644 let error = match compiler.compile_hir(&hir) {
3645 Ok(_) => panic!("backend directive unexpectedly compiled"),
3646 Err(error) => error,
3647 };
3648 assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
3649 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
3650 }
3651
3652 #[test]
3653 fn optimizer_directives_remain_non_blocking_presentation_controls() {
3654 let compiler = Compiler::new().unwrap();
3655 let hir = opy_rs::compile(
3656 "#!disableOptimizations\nrule \"r\":\n @Event global\n pass\n",
3657 "optimization.opy",
3658 Path::new("."),
3659 )
3660 .unwrap();
3661 compiler.compile_hir(&hir).unwrap();
3662 }
3663
3664 #[test]
3665 fn replacement_directive_records_are_checked_even_if_final_state_is_restored() {
3666 let compiler = Compiler::new().unwrap();
3667 let mut hir = opy_rs::compile(
3668 "#!replace0ByCapturePercentage\nrule \"r\":\n @Event global\n pass\n",
3669 "directives.opy",
3670 Path::new("."),
3671 )
3672 .unwrap();
3673 hir.preprocessing.replacements.clear();
3674 let error = match compiler.compile_hir(&hir) {
3675 Ok(_) => panic!("replacement directive unexpectedly compiled"),
3676 Err(error) => error,
3677 };
3678 assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
3679 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
3680 }
3681
3682 #[test]
3683 fn active_replacement_state_is_checked_without_directive_history() {
3684 let compiler = Compiler::new().unwrap();
3685 let mut hir = opy_rs::compile(
3686 "#!replace0ByCapturePercentage\nrule \"r\":\n @Event global\n pass\n",
3687 "directives.opy",
3688 Path::new("."),
3689 )
3690 .unwrap();
3691 hir.preprocessing.directives.clear();
3692 hir.preprocessing.replacements[0].span = None;
3693 let error = match compiler.compile_hir(&hir) {
3694 Ok(_) => panic!("active replacement state unexpectedly compiled"),
3695 Err(error) => error,
3696 };
3697 assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
3698 assert_eq!(error.diagnostic.span, None);
3699 }
3700
3701 #[test]
3702 fn post_compile_hook_receives_exact_emitted_workshop() {
3703 let compiler = Compiler::new().unwrap();
3704 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../opy-rs/tests/fixtures/macros");
3705 let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n pass\n";
3706 let artifact = compiler
3707 .compile_source(source, "hook.opy", &root, &Locale::new("en-US"))
3708 .unwrap();
3709 assert!(artifact.emitted.contains("rule (\"setup\")"));
3710 assert!(artifact.final_output.contains("rule (\"transformed\")"));
3711 assert_ne!(artifact.final_output, artifact.emitted);
3712 }
3713
3714 #[test]
3715 fn post_compile_hook_failure_keeps_script_provenance_and_directive_anchor() {
3716 let compiler = Compiler::new().unwrap();
3717 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../opy-rs/tests/fixtures/macros");
3718 let source = "#!postCompileHook \"hook-boom.js\"\n\nrule \"setup\":\n pass\n";
3719 let error = match compiler.compile_source(source, "hook.opy", &root, &Locale::new("en-US"))
3720 {
3721 Ok(_) => panic!("failing post-compile hook unexpectedly compiled"),
3722 Err(error) => error,
3723 };
3724 assert_eq!(error.diagnostic.code, "post-compile-hook");
3725 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
3726 let script = error.diagnostic.script.unwrap();
3727 assert_eq!(script.source_name.as_deref(), Some("hook-boom.js"));
3728 assert_eq!(script.line, Some(1));
3729 assert!(script.stack.unwrap().contains("hook-boom.js:1"));
3730 }
3731
3732 fn normalize_workshop_structural_whitespace(text: &str) -> String {
3733 let mut normalized = String::with_capacity(text.len());
3734 let mut quote = None;
3735 let mut escaped = false;
3736 for character in text.chars() {
3737 if let Some(delimiter) = quote {
3738 normalized.push(character);
3739 if escaped {
3740 escaped = false;
3741 } else if character == '\\' {
3742 escaped = true;
3743 } else if character == delimiter {
3744 quote = None;
3745 }
3746 } else if matches!(character, '\"' | '\'') {
3747 quote = Some(character);
3748 normalized.push(character);
3749 } else if !character.is_whitespace() {
3750 normalized.push(character);
3751 }
3752 }
3753 normalized
3754 }
3755
3756 #[test]
3757 fn settings_whitespace_normalization_preserves_quoted_values() {
3758 assert_ne!(
3759 normalize_workshop_structural_whitespace("Description: \"a b\""),
3760 normalize_workshop_structural_whitespace("Description: \"ab\"")
3761 );
3762 }
3763}