1use std::collections::{BTreeMap, HashMap, HashSet};
9
10use crate::hir::{self, Expr, RuleEntry, Span as HirSpan, Stmt, SwitchArm, default_var_index};
11use crate::manifest::{FunctionKind, Manifest};
12use serde::Serialize;
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
19#[cfg(test)]
20mod integration_tests;
21
22pub const WORKSHOP_RS_VERSION: &str = "0.1.16";
24
25pub const COMPILE_SCHEMA_VERSION: u32 = 1;
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub struct CompilerIdentity {
31 pub name: &'static str,
32 pub version: &'static str,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
37#[serde(rename_all = "lowercase")]
38pub enum CompileStatus {
39 Success,
40 Failure,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "kebab-case")]
46pub enum CompileFailureClass {
47 Frontend,
48 Integration,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "camelCase")]
54pub struct CompileDiagnostic {
55 pub severity: crate::tooling::DiagnosticSeverity,
56 pub code: String,
57 pub message: String,
58 pub span: Option<crate::tooling::SourceLocation>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub script: Option<ScriptDiagnostic>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65#[serde(rename_all = "camelCase")]
66pub struct CompileResult {
67 pub status: CompileStatus,
68 pub exit_code: u8,
69 pub failure_class: Option<CompileFailureClass>,
70 pub diagnostics: Vec<CompileDiagnostic>,
71 pub stdout: String,
72 pub workshop_exact: String,
73 pub workshop: String,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct CompileReport {
80 pub schema_version: u32,
81 pub compiler: CompilerIdentity,
82 pub catalog: CatalogIdentity,
83 pub compile: CompileResult,
84}
85
86impl CompilerIdentity {
87 fn current() -> Self {
88 Self {
89 name: "opy-rs",
90 version: env!("CARGO_PKG_VERSION"),
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct IntegrationDiagnostic {
98 pub code: String,
99 pub message: String,
100 pub span: Option<HirSpan>,
101 pub script: Option<Box<ScriptDiagnostic>>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106#[serde(rename_all = "camelCase")]
107pub struct ScriptDiagnostic {
108 pub source_name: Option<String>,
109 pub line: Option<u32>,
110 pub column: Option<u32>,
111 pub stack: Option<String>,
112}
113
114impl IntegrationDiagnostic {
115 fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
116 Self {
117 code: code.into(),
118 message: message.into(),
119 span,
120 script: None,
121 }
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct IntegrationError {
128 pub diagnostic: IntegrationDiagnostic,
129}
130
131impl IntegrationError {
132 fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
133 Self {
134 diagnostic: IntegrationDiagnostic::new(code, message, span),
135 }
136 }
137
138 fn post_compile_hook(error: crate::macro_js::MacroError, span: Option<HirSpan>) -> Self {
139 let message = error.to_string();
140 let script = match error {
141 crate::macro_js::MacroError::Script(error) => Some(Box::new(ScriptDiagnostic {
142 source_name: error.source_name,
143 line: error.line,
144 column: error.column,
145 stack: error.stack,
146 })),
147 crate::macro_js::MacroError::InvalidResult { .. }
148 | crate::macro_js::MacroError::Internal(_) => None,
149 };
150 Self {
151 diagnostic: IntegrationDiagnostic {
152 code: "post-compile-hook".to_string(),
153 message,
154 span,
155 script,
156 },
157 }
158 }
159}
160
161impl std::fmt::Display for IntegrationError {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 write!(f, "{}: {}", self.diagnostic.code, self.diagnostic.message)
164 }
165}
166
167impl std::error::Error for IntegrationError {}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub struct LinkReport {
172 pub catalog_ids_checked: usize,
173 pub domains_checked: usize,
174}
175
176pub(crate) fn cross_check_manifest(
180 manifest: &Manifest,
181 catalog: &Catalog,
182) -> Result<LinkReport, IntegrationError> {
183 let mut catalog_ids_checked = 0;
184 let mut domains_checked = 0;
185
186 for function in &manifest.functions {
187 if let Some(catalog_id) = &function.catalog_id {
188 let kind = match function.kind {
189 FunctionKind::Action | FunctionKind::MemberAction => Kind::Action,
190 FunctionKind::Value | FunctionKind::MemberValue => Kind::Value,
191 };
192 catalog_ids_checked += 1;
193 if catalog.entry(kind, catalog_id).is_none() {
194 return Err(IntegrationError::new(
195 "catalog-link-missing",
196 format!(
197 "manifest function '{}' links to missing {:?} catalog id '{}'",
198 function.id, kind, catalog_id
199 ),
200 None,
201 ));
202 }
203 }
204
205 for parameter in &function.params {
206 let Some(domain) = ¶meter.domain else {
207 continue;
208 };
209 let contextual = function
210 .contextual_domain
211 .as_ref()
212 .is_some_and(|context| context.domain == *domain);
213 if contextual {
214 continue;
215 }
216 domains_checked += 1;
217 if catalog.enum_domain(domain).is_none() {
218 return Err(IntegrationError::new(
219 "domain-link-missing",
220 format!(
221 "manifest function '{}' parameter '{}' links to missing enum domain '{}',",
222 function.id, parameter.name, domain
223 ),
224 None,
225 ));
226 }
227 }
228
229 if let Some(contextual) = &function.contextual_domain {
230 for option in contextual.options.values() {
231 domains_checked += 1;
232 if catalog.enum_domain(&option.domain).is_none() {
233 return Err(IntegrationError::new(
234 "domain-link-missing",
235 format!(
236 "manifest function '{}' contextual option links to missing enum domain '{}'",
237 function.id, option.domain
238 ),
239 None,
240 ));
241 }
242 }
243 }
244 }
245
246 Ok(LinkReport {
247 catalog_ids_checked,
248 domains_checked,
249 })
250}
251
252pub struct Compiler {
255 catalog: Catalog,
256 manifest: &'static Manifest,
257 links: LinkReport,
258}
259
260impl Compiler {
261 pub fn new() -> Result<Self, IntegrationError> {
262 let catalog = Catalog::builtin()
263 .map_err(|error| IntegrationError::new("catalog-load", error.to_string(), None))?;
264 let manifest = Manifest::builtin()
265 .map_err(|error| IntegrationError::new("manifest-load", error.to_string(), None))?;
266 let links = cross_check_manifest(manifest, &catalog)?;
267 let identity = catalog.identity();
268 if identity.implementation_version != WORKSHOP_RS_VERSION {
269 return Err(IntegrationError::new(
270 "workshop-contract-version",
271 format!(
272 "expected workshop-rs {}, loaded {}",
273 WORKSHOP_RS_VERSION, identity.implementation_version
274 ),
275 None,
276 ));
277 }
278 Ok(Self {
279 catalog,
280 manifest,
281 links,
282 })
283 }
284
285 pub fn catalog_identity(&self) -> CatalogIdentity {
286 self.catalog.identity()
287 }
288
289 pub fn link_report(&self) -> LinkReport {
290 self.links
291 }
292
293 pub fn compile_hir(&self, hir: &hir::Program) -> Result<CompilationArtifact, IntegrationError> {
296 self.compile_hir_with_locale(hir, &Locale::new("en-US"))
297 }
298
299 pub fn compile_hir_with_locale(
301 &self,
302 hir: &hir::Program,
303 locale: &Locale,
304 ) -> Result<CompilationArtifact, IntegrationError> {
305 if !self.catalog.supports(locale) {
306 return Err(IntegrationError::new(
307 "locale-unsupported",
308 format!("workshop catalog does not declare locale '{locale}'"),
309 None,
310 ));
311 }
312 reject_unlowered_directives(hir)?;
313 let expanded_hir = expand_macros(hir)?;
314 let mut lowering = Lowering::new(self, &expanded_hir)?;
315 lowering.copy_files()?;
316 lowering.lower_declarations()?;
317 lowering.lower_rules()?;
318
319 lowering.wir.validate().map_err(|error| {
320 let span = error
321 .span()
322 .and_then(|span| lowering.hir_span_from_workshop(span));
323 IntegrationError::new(error.code(), error.message(), span)
324 })?;
325 workshop_rs::validate::validate_canonical_ids(&lowering.wir, &self.catalog).map_err(
326 |error| {
327 let span = workshop_error_span(&error)
328 .and_then(|span| lowering.hir_span_from_workshop(span));
329 IntegrationError::new("catalog-validation", error.to_string(), span)
330 },
331 )?;
332 let emitted =
333 workshop_rs::emitter::emit(&lowering.wir, &self.catalog, locale).map_err(|error| {
334 let span = workshop_error_span(&error)
335 .and_then(|span| lowering.hir_span_from_workshop(span));
336 IntegrationError::new("workshop-emission", error.to_string(), span)
337 })?;
338
339 Ok(CompilationArtifact {
340 wir: lowering.wir,
341 final_output: emitted.clone(),
342 emitted,
343 catalog_identity: self.catalog.identity(),
344 hook_console_output: Vec::new(),
345 })
346 }
347
348 pub fn compile_source(
354 &self,
355 source: &str,
356 main_path: &str,
357 root: &std::path::Path,
358 ) -> Result<CompileOutput, IntegrationError> {
359 self.compile_source_with_language(source, main_path, root, "en-US")
360 }
361
362 pub fn compile_source_with_language(
365 &self,
366 source: &str,
367 main_path: &str,
368 root: &std::path::Path,
369 language: &str,
370 ) -> Result<CompileOutput, IntegrationError> {
371 self.compile_source_with_locale(source, main_path, root, &Locale::new(language))
372 .map(CompilationArtifact::into_output)
373 }
374
375 pub fn compile_source_with_locale(
380 &self,
381 source: &str,
382 main_path: &str,
383 root: &std::path::Path,
384 locale: &Locale,
385 ) -> Result<CompilationArtifact, IntegrationError> {
386 self.compile_source_internal(source, main_path, root, locale)
387 }
388
389 pub fn compile_source_artifact(
392 &self,
393 source: &str,
394 main_path: &str,
395 root: &std::path::Path,
396 ) -> Result<CompilationArtifact, IntegrationError> {
397 self.compile_source_with_locale(source, main_path, root, &Locale::new("en-US"))
398 }
399
400 pub fn compile_source_report(
403 &self,
404 source: &str,
405 main_path: &str,
406 root: &std::path::Path,
407 ) -> CompileReport {
408 self.compile_source_report_with_language(source, main_path, root, "en-US")
409 }
410
411 pub fn compile_source_report_with_language(
414 &self,
415 source: &str,
416 main_path: &str,
417 root: &std::path::Path,
418 language: &str,
419 ) -> CompileReport {
420 self.compile_source_report_with_locale(source, main_path, root, &Locale::new(language))
421 }
422
423 pub fn compile_source_report_with_locale(
426 &self,
427 source: &str,
428 main_path: &str,
429 root: &std::path::Path,
430 locale: &Locale,
431 ) -> CompileReport {
432 let outcome = crate::compile_with_overlay_outcome(
433 source,
434 main_path,
435 root,
436 &std::collections::BTreeMap::new(),
437 );
438 let catalog = self.catalog.identity();
439 let compiler = CompilerIdentity::current();
440 let Some(hir) = outcome.hir else {
441 let error = outcome
442 .error
443 .expect("failed frontend compile has diagnostic");
444 let diagnostic = CompileDiagnostic {
445 severity: crate::tooling::DiagnosticSeverity::Error,
446 code: error.code,
447 message: error.message,
448 span: error
449 .span
450 .and_then(|span| source_location_from_file_records(span, &outcome.files)),
451 script: None,
452 };
453 return CompileReport::failure(
454 compiler,
455 catalog,
456 CompileFailureClass::Frontend,
457 vec![diagnostic],
458 );
459 };
460
461 match self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale) {
462 Ok(artifact) => CompileReport::success(compiler, catalog, artifact),
463 Err(error) => CompileReport::failure(
464 compiler,
465 catalog,
466 CompileFailureClass::Integration,
467 vec![compile_diagnostic(error, &hir.files)],
468 ),
469 }
470 }
471
472 fn compile_source_internal(
473 &self,
474 source: &str,
475 main_path: &str,
476 root: &std::path::Path,
477 locale: &Locale,
478 ) -> Result<CompilationArtifact, IntegrationError> {
479 let outcome = crate::compile_with_overlay_outcome(
480 source,
481 main_path,
482 root,
483 &std::collections::BTreeMap::new(),
484 );
485 let hir = outcome.hir.ok_or_else(|| {
486 let error = outcome
487 .error
488 .expect("failed frontend compile has diagnostic");
489 IntegrationError::new(
490 error.code,
491 error.message,
492 error.span.map(hir_span_from_diag),
493 )
494 })?;
495 self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale)
496 }
497
498 fn compile_hir_with_locale_and_hook(
499 &self,
500 hir: &hir::Program,
501 hook: Option<crate::PostCompileHookRecord>,
502 locale: &Locale,
503 ) -> Result<CompilationArtifact, IntegrationError> {
504 let mut artifact = self.compile_hir_with_locale(hir, locale)?;
505 if let Some(hook) = hook {
506 let runtime = crate::macro_js::MacroRuntime::new(crate::macro_js::Limits::default());
507 let result = runtime
508 .run_hook(&hook.source, &artifact.emitted, &hook.script)
509 .map_err(|error| {
510 IntegrationError::post_compile_hook(error, hook.span.map(hir_span_from_diag))
511 })?;
512 artifact.final_output = result.text;
513 artifact.hook_console_output = result.console_output;
514 }
515 Ok(artifact)
516 }
517}
518
519impl CompileReport {
520 fn success(
521 compiler: CompilerIdentity,
522 catalog: CatalogIdentity,
523 artifact: CompilationArtifact,
524 ) -> Self {
525 Self {
526 schema_version: COMPILE_SCHEMA_VERSION,
527 compiler,
528 catalog,
529 compile: CompileResult {
530 status: CompileStatus::Success,
531 exit_code: 0,
532 failure_class: None,
533 diagnostics: Vec::new(),
534 stdout: String::new(),
535 workshop_exact: artifact.final_output.clone(),
536 workshop: normalize_workshop(&artifact.final_output),
537 },
538 }
539 }
540
541 fn failure(
542 compiler: CompilerIdentity,
543 catalog: CatalogIdentity,
544 failure_class: CompileFailureClass,
545 diagnostics: Vec<CompileDiagnostic>,
546 ) -> Self {
547 Self {
548 schema_version: COMPILE_SCHEMA_VERSION,
549 compiler,
550 catalog,
551 compile: CompileResult {
552 status: CompileStatus::Failure,
553 exit_code: 1,
554 failure_class: Some(failure_class),
555 diagnostics,
556 stdout: String::new(),
557 workshop_exact: String::new(),
558 workshop: String::new(),
559 },
560 }
561 }
562}
563
564fn compile_diagnostic(error: IntegrationError, files: &[hir::SourceFile]) -> CompileDiagnostic {
565 let diagnostic = error.diagnostic;
566 CompileDiagnostic {
567 severity: crate::tooling::DiagnosticSeverity::Error,
568 code: diagnostic.code,
569 message: diagnostic.message,
570 span: diagnostic
571 .span
572 .and_then(|span| source_location_from_hir(span, files)),
573 script: diagnostic.script.map(|script| *script),
574 }
575}
576
577fn source_location_from_file_records(
578 span: crate::diag::Span,
579 files: &[crate::preprocess::FileRecord],
580) -> Option<crate::tooling::SourceLocation> {
581 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
582 Some(crate::tooling::SourceLocation {
583 file_id: span.file,
584 path,
585 start: span.start,
586 end: span.end,
587 })
588}
589
590fn source_location_from_hir(
591 span: HirSpan,
592 files: &[hir::SourceFile],
593) -> Option<crate::tooling::SourceLocation> {
594 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
595 Some(crate::tooling::SourceLocation {
596 file_id: span.file,
597 path,
598 start: crate::diag::Position::new(span.start.line, span.start.col),
599 end: crate::diag::Position::new(span.end.line, span.end.col),
600 })
601}
602
603fn normalize_workshop(text: &str) -> String {
604 if text.is_empty() {
605 return String::new();
606 }
607 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
608 let mut lines = normalized
609 .split('\n')
610 .map(|line| line.trim_end_matches([' ', '\t']).to_owned())
611 .collect::<Vec<_>>();
612 while lines.last().is_some_and(|line| line.is_empty()) {
613 lines.pop();
614 }
615 if lines.is_empty() {
616 String::new()
617 } else {
618 lines.join("\n") + "\n"
619 }
620}
621
622fn reject_unlowered_directives(hir: &hir::Program) -> Result<(), IntegrationError> {
623 if let Some(replacement) = hir.preprocessing.replacements.first() {
624 let span = hir
625 .preprocessing
626 .directives
627 .iter()
628 .find(|directive| directive.name.starts_with("replace"))
629 .and_then(|directive| directive.span)
630 .or(replacement.span);
631 return Err(IntegrationError::new(
632 "backend-directive-unsupported",
633 format!(
634 "replacement directive '{}' has no canonical workshop-rs lowering",
635 replacement.value
636 ),
637 span,
638 ));
639 }
640 if let Some(replacement) = hir
641 .preprocessing
642 .directives
643 .iter()
644 .find(|directive| directive.name.starts_with("replace"))
645 {
646 return Err(IntegrationError::new(
647 "backend-directive-unsupported",
648 format!(
649 "replacement directive '{}' has no canonical workshop-rs lowering",
650 replacement.name
651 ),
652 replacement.span,
653 ));
654 }
655 Ok(())
656}
657
658type MacroBindings = HashMap<String, Expr>;
659
660struct MacroExpander {
661 macros: HashMap<String, (Vec<String>, Vec<Stmt>)>,
662 stack: Vec<String>,
663}
664
665fn expand_macros(program: &hir::Program) -> Result<hir::Program, IntegrationError> {
666 let macros = program
667 .declarations
668 .iter()
669 .filter_map(|declaration| match declaration {
670 hir::Declaration::Macro {
671 name, args, body, ..
672 } => Some((name.clone(), (args.clone(), body.clone()))),
673 _ => None,
674 })
675 .collect();
676 let mut expander = MacroExpander {
677 macros,
678 stack: Vec::new(),
679 };
680 let mut expanded = program.clone();
681 let bindings = MacroBindings::new();
682
683 for declaration in &mut expanded.declarations {
684 match declaration {
685 hir::Declaration::GlobalVariable { initializer, .. }
686 | hir::Declaration::PlayerVariable { initializer, .. } => {
687 if let Some(initializer) = initializer {
688 **initializer = expander.expand_expr(initializer, &bindings)?;
689 }
690 }
691 _ => {}
692 }
693 }
694 for entry in &mut expanded.rules {
695 match entry {
696 RuleEntry::Rule(rule) => {
697 for argument in &mut rule.event.args {
698 *argument = expander.expand_expr(argument, &bindings)?;
699 }
700 for condition in &mut rule.conditions {
701 *condition = expander.expand_expr(condition, &bindings)?;
702 }
703 rule.actions = expander.expand_stmts(&rule.actions, &bindings)?;
704 }
705 RuleEntry::SubroutineDef { body, .. } => {
706 *body = expander.expand_stmts(body, &bindings)?;
707 }
708 }
709 }
710 Ok(expanded)
711}
712
713impl MacroExpander {
714 fn expand_stmts(
715 &mut self,
716 statements: &[Stmt],
717 bindings: &MacroBindings,
718 ) -> Result<Vec<Stmt>, IntegrationError> {
719 let mut expanded = Vec::new();
720 for statement in statements {
721 if let Stmt::Expr { expr, .. } = statement {
722 if let Expr::MacroCall { name, args, span } = expr.as_ref() {
723 let args = args
724 .iter()
725 .map(|arg| self.expand_expr(arg, bindings))
726 .collect::<Result<Vec<_>, _>>()?;
727 expanded.extend(self.expand_macro_body(name, &args, *span)?);
728 continue;
729 }
730 }
731 expanded.push(self.expand_stmt(statement, bindings)?);
732 }
733 Ok(expanded)
734 }
735
736 fn expand_stmt(
737 &mut self,
738 statement: &Stmt,
739 bindings: &MacroBindings,
740 ) -> Result<Stmt, IntegrationError> {
741 Ok(match statement {
742 Stmt::Expr { expr, span } => Stmt::Expr {
743 expr: Box::new(self.expand_expr(expr, bindings)?),
744 span: *span,
745 },
746 Stmt::Assign {
747 target,
748 value,
749 span,
750 } => Stmt::Assign {
751 target: Box::new(self.expand_expr(target, bindings)?),
752 value: Box::new(self.expand_expr(value, bindings)?),
753 span: *span,
754 },
755 Stmt::If {
756 branches,
757 r#else,
758 span,
759 } => Stmt::If {
760 branches: branches
761 .iter()
762 .map(|branch| {
763 Ok(hir::types::IfBranch {
764 condition: Box::new(self.expand_expr(&branch.condition, bindings)?),
765 body: self.expand_stmts(&branch.body, bindings)?,
766 })
767 })
768 .collect::<Result<Vec<_>, IntegrationError>>()?,
769 r#else: r#else
770 .as_ref()
771 .map(|body| self.expand_stmts(body, bindings))
772 .transpose()?,
773 span: *span,
774 },
775 Stmt::For {
776 variable,
777 iterable,
778 body,
779 span,
780 } => Stmt::For {
781 variable: Box::new(self.expand_expr(variable, bindings)?),
782 iterable: Box::new(self.expand_expr(iterable, bindings)?),
783 body: self.expand_stmts(body, bindings)?,
784 span: *span,
785 },
786 Stmt::While {
787 condition,
788 body,
789 span,
790 } => Stmt::While {
791 condition: Box::new(self.expand_expr(condition, bindings)?),
792 body: self.expand_stmts(body, bindings)?,
793 span: *span,
794 },
795 Stmt::DoWhile {
796 condition,
797 body,
798 span,
799 } => Stmt::DoWhile {
800 condition: Box::new(self.expand_expr(condition, bindings)?),
801 body: self.expand_stmts(body, bindings)?,
802 span: *span,
803 },
804 Stmt::Switch { value, arms, span } => Stmt::Switch {
805 value: Box::new(self.expand_expr(value, bindings)?),
806 arms: arms
807 .iter()
808 .map(|arm| match arm {
809 SwitchArm::Case { value, body, span } => Ok(SwitchArm::Case {
810 value: Box::new(self.expand_expr(value, bindings)?),
811 body: self.expand_stmts(body, bindings)?,
812 span: *span,
813 }),
814 SwitchArm::Default { body, span } => Ok(SwitchArm::Default {
815 body: self.expand_stmts(body, bindings)?,
816 span: *span,
817 }),
818 })
819 .collect::<Result<Vec<_>, IntegrationError>>()?,
820 span: *span,
821 },
822 Stmt::Break { .. } | Stmt::CallSubroutine { .. } | Stmt::Pass { .. } => {
823 statement.clone()
824 }
825 })
826 }
827
828 fn expand_expr(
829 &mut self,
830 expression: &Expr,
831 bindings: &MacroBindings,
832 ) -> Result<Expr, IntegrationError> {
833 match expression {
834 Expr::MacroParam { name, span } => bindings.get(name).cloned().ok_or_else(|| {
835 IntegrationError::new(
836 "unsupported-integration-surface",
837 format!("macro parameter '{name}' has no expansion binding"),
838 *span,
839 )
840 }),
841 Expr::MacroCall { name, args, span } => {
842 let args = args
843 .iter()
844 .map(|arg| self.expand_expr(arg, bindings))
845 .collect::<Result<Vec<_>, _>>()?;
846 let body = self.expand_macro_body(name, &args, *span)?;
847 if body.len() != 1 {
848 return Err(IntegrationError::new(
849 "macro-invalid",
850 format!("macro '{name}' must produce one expression in value position"),
851 *span,
852 ));
853 }
854 match body.into_iter().next().expect("one macro body statement") {
855 Stmt::Expr { expr, .. } => Ok(*expr),
856 _ => Err(IntegrationError::new(
857 "macro-invalid",
858 format!("macro '{name}' must produce an expression in value position"),
859 *span,
860 )),
861 }
862 }
863 Expr::Array { elements, span } => Ok(Expr::Array {
864 elements: elements
865 .iter()
866 .map(|element| self.expand_expr(element, bindings))
867 .collect::<Result<Vec<_>, _>>()?,
868 span: *span,
869 }),
870 Expr::Dict { entries, span } => Ok(Expr::Dict {
871 entries: entries
872 .iter()
873 .map(|entry| {
874 Ok(hir::DictEntry {
875 key: Box::new(self.expand_expr(&entry.key, bindings)?),
876 value: Box::new(self.expand_expr(&entry.value, bindings)?),
877 span: entry.span,
878 })
879 })
880 .collect::<Result<Vec<_>, IntegrationError>>()?,
881 span: *span,
882 }),
883 Expr::Comprehension {
884 element,
885 variable,
886 variable_span,
887 index,
888 index_span,
889 iterable,
890 condition,
891 span,
892 } => Ok(Expr::Comprehension {
893 element: Box::new(self.expand_expr(element, bindings)?),
894 variable: variable.clone(),
895 variable_span: *variable_span,
896 index: index.clone(),
897 index_span: *index_span,
898 iterable: Box::new(self.expand_expr(iterable, bindings)?),
899 condition: condition
900 .as_ref()
901 .map(|condition| self.expand_expr(condition, bindings).map(Box::new))
902 .transpose()?,
903 span: *span,
904 }),
905 Expr::Lambda {
906 params,
907 param_spans,
908 body,
909 span,
910 } => Ok(Expr::Lambda {
911 params: params.clone(),
912 param_spans: param_spans.clone(),
913 body: Box::new(self.expand_expr(body, bindings)?),
914 span: *span,
915 }),
916 Expr::Vector { x, y, z, span } => Ok(Expr::Vector {
917 x: Box::new(self.expand_expr(x, bindings)?),
918 y: Box::new(self.expand_expr(y, bindings)?),
919 z: Box::new(self.expand_expr(z, bindings)?),
920 span: *span,
921 }),
922 Expr::PlayerVar { player, name, span } => Ok(Expr::PlayerVar {
923 player: Box::new(self.expand_expr(player, bindings)?),
924 name: name.clone(),
925 span: *span,
926 }),
927 Expr::Member {
928 receiver,
929 member,
930 member_span,
931 span,
932 } => Ok(Expr::Member {
933 receiver: Box::new(self.expand_expr(receiver, bindings)?),
934 member: member.clone(),
935 member_span: *member_span,
936 span: *span,
937 }),
938 Expr::Call { name, args, span } => Ok(Expr::Call {
939 name: name.clone(),
940 args: args
941 .iter()
942 .map(|arg| self.expand_expr(arg, bindings))
943 .collect::<Result<Vec<_>, _>>()?,
944 span: *span,
945 }),
946 Expr::ReceiverCall {
947 receiver,
948 name,
949 args,
950 span,
951 } => Ok(Expr::ReceiverCall {
952 receiver: Box::new(self.expand_expr(receiver, bindings)?),
953 name: name.clone(),
954 args: args
955 .iter()
956 .map(|arg| self.expand_expr(arg, bindings))
957 .collect::<Result<Vec<_>, _>>()?,
958 span: *span,
959 }),
960 Expr::Binary {
961 op,
962 left,
963 right,
964 span,
965 } => Ok(Expr::Binary {
966 op: op.clone(),
967 left: Box::new(self.expand_expr(left, bindings)?),
968 right: Box::new(self.expand_expr(right, bindings)?),
969 span: *span,
970 }),
971 Expr::Conditional {
972 then_value,
973 condition,
974 else_value,
975 span,
976 } => Ok(Expr::Conditional {
977 then_value: Box::new(self.expand_expr(then_value, bindings)?),
978 condition: Box::new(self.expand_expr(condition, bindings)?),
979 else_value: Box::new(self.expand_expr(else_value, bindings)?),
980 span: *span,
981 }),
982 Expr::Unary { op, operand, span } => Ok(Expr::Unary {
983 op: op.clone(),
984 operand: Box::new(self.expand_expr(operand, bindings)?),
985 span: *span,
986 }),
987 Expr::Index { array, index, span } => Ok(Expr::Index {
988 array: Box::new(self.expand_expr(array, bindings)?),
989 index: Box::new(self.expand_expr(index, bindings)?),
990 span: *span,
991 }),
992 Expr::Format { text, args, span } => Ok(Expr::Format {
993 text: text.clone(),
994 args: args
995 .iter()
996 .map(|arg| self.expand_expr(arg, bindings))
997 .collect::<Result<Vec<_>, _>>()?,
998 span: *span,
999 }),
1000 _ => Ok(expression.clone()),
1001 }
1002 }
1003
1004 fn expand_macro_body(
1005 &mut self,
1006 name: &str,
1007 args: &[Expr],
1008 span: Option<HirSpan>,
1009 ) -> Result<Vec<Stmt>, IntegrationError> {
1010 let Some((params, body)) = self.macros.get(name).cloned() else {
1011 return Err(IntegrationError::new(
1012 "unsupported-integration-surface",
1013 format!("macro '{name}' has no declaration"),
1014 span,
1015 ));
1016 };
1017 if params.len() != args.len() {
1018 return Err(IntegrationError::new(
1019 "macro-arity",
1020 format!(
1021 "macro '{name}' expects {} argument(s) but got {}",
1022 params.len(),
1023 args.len()
1024 ),
1025 span,
1026 ));
1027 }
1028 if self.stack.iter().any(|active| active == name) {
1029 return Err(IntegrationError::new(
1030 "macro-recursion",
1031 format!("recursive macro expansion detected for '{name}'"),
1032 span,
1033 ));
1034 }
1035 let mut bindings = MacroBindings::new();
1036 for (param, arg) in params.into_iter().zip(args.iter()) {
1037 bindings.insert(param, arg.clone());
1038 }
1039 self.stack.push(name.to_string());
1040 let result = self.expand_stmts(&body, &bindings);
1041 self.stack.pop();
1042 result
1043 }
1044}
1045
1046#[derive(Debug, Clone, PartialEq, Eq)]
1051pub struct CompileOutput {
1052 pub workshop: String,
1054 pub emitted_workshop: String,
1056 pub hook_console_output: Vec<String>,
1058}
1059
1060pub struct CompilationArtifact {
1063 pub wir: Program,
1064 pub emitted: String,
1065 pub catalog_identity: CatalogIdentity,
1066 pub final_output: String,
1067 pub hook_console_output: Vec<String>,
1068}
1069
1070impl CompilationArtifact {
1071 fn into_output(self) -> CompileOutput {
1072 CompileOutput {
1073 workshop: self.final_output,
1074 emitted_workshop: self.emitted,
1075 hook_console_output: self.hook_console_output,
1076 }
1077 }
1078}
1079
1080fn convert_settings(settings: crate::hir::Settings) -> workshop_rs::settings::Settings {
1081 workshop_rs::settings::Settings {
1082 span: settings.span.map(convert_settings_span),
1083 children: settings
1084 .children
1085 .into_iter()
1086 .map(convert_settings_node)
1087 .collect(),
1088 }
1089}
1090
1091fn convert_settings_node(node: crate::hir::SettingsNode) -> workshop_rs::settings::SettingsNode {
1092 use crate::hir::SettingsNode as SourceNode;
1093 use workshop_rs::settings::{SettingsListElement, SettingsNode as TargetNode};
1094
1095 match node {
1096 SourceNode::Group {
1097 name,
1098 children,
1099 span,
1100 } => TargetNode::Group {
1101 name,
1102 children: children.into_iter().map(convert_settings_node).collect(),
1103 span: span.map(convert_settings_span),
1104 },
1105 SourceNode::Number { name, value, span } => TargetNode::Number {
1106 name,
1107 value,
1108 span: span.map(convert_settings_span),
1109 },
1110 SourceNode::Bool { name, value, span } => TargetNode::Bool {
1111 name,
1112 value,
1113 span: span.map(convert_settings_span),
1114 },
1115 SourceNode::String { name, value, span } => TargetNode::String {
1116 name,
1117 value,
1118 span: span.map(convert_settings_span),
1119 },
1120 SourceNode::List {
1121 name,
1122 elements,
1123 span,
1124 } => TargetNode::List {
1125 name,
1126 elements: elements
1127 .into_iter()
1128 .map(|element| SettingsListElement {
1129 value: element.value,
1130 span: element.span.map(convert_settings_span),
1131 })
1132 .collect(),
1133 span: span.map(convert_settings_span),
1134 },
1135 }
1136}
1137
1138fn convert_settings_span(span: HirSpan) -> WorkshopSpan {
1139 WorkshopSpan::new(
1140 workshop_rs::source::FileId::from_index(span.file as usize),
1141 WorkshopPosition::new(span.start.line, span.start.col),
1142 WorkshopPosition::new(span.end.line, span.end.col),
1143 )
1144}
1145
1146struct Lowering<'a> {
1147 compiler: &'a Compiler,
1148 hir: &'a hir::Program,
1149 wir: Program,
1150 files: HashMap<u32, workshop_rs::source::FileId>,
1151 wir_to_hir_files: Vec<u32>,
1152 globals: HashMap<String, wir::GlobalVarId>,
1153 players: HashMap<String, wir::PlayerVarId>,
1154 subroutines: HashMap<String, wir::SubroutineId>,
1155 constants: HashMap<String, &'a Expr>,
1156 defined_subroutines: HashSet<wir::SubroutineId>,
1157}
1158
1159#[derive(Debug, Clone, Copy)]
1160enum BreakTarget {
1161 Loop,
1162 DoWhile,
1163 Switch,
1164}
1165
1166type SwitchBreak = (usize, HirSpan);
1167type LoweredSwitchBody = (Vec<wir::ActionId>, Option<SwitchBreak>);
1168
1169impl<'a> Lowering<'a> {
1170 fn new(compiler: &'a Compiler, hir: &'a hir::Program) -> Result<Self, IntegrationError> {
1171 Ok(Self {
1172 compiler,
1173 hir,
1174 wir: Program::default(),
1175 files: HashMap::new(),
1176 wir_to_hir_files: Vec::new(),
1177 globals: HashMap::new(),
1178 players: HashMap::new(),
1179 subroutines: HashMap::new(),
1180 constants: HashMap::new(),
1181 defined_subroutines: HashSet::new(),
1182 })
1183 }
1184
1185 fn copy_files(&mut self) -> Result<(), IntegrationError> {
1186 self.wir.settings = self.hir.settings.clone().map(convert_settings);
1187 for file in &self.hir.files {
1188 if self.files.contains_key(&file.id) {
1189 return Err(IntegrationError::new(
1190 "source-file",
1191 format!("duplicate HIR source file id {}", file.id),
1192 None,
1193 ));
1194 }
1195 let id = self.wir.files.push(SourceFile::new(file.path.clone()));
1196 self.files.insert(file.id, id);
1197 self.wir_to_hir_files.push(file.id);
1198 }
1199 Ok(())
1200 }
1201
1202 fn lower_declarations(&mut self) -> Result<(), IntegrationError> {
1203 let (implicit_globals, implicit_players) = implicit_default_variables(self.hir);
1204 for declaration in &self.hir.declarations {
1205 if let hir::Declaration::GlobalVariable {
1206 name,
1207 index: Some(index),
1208 span,
1209 ..
1210 } = declaration
1211 {
1212 for (implicit_name, implicit_span) in &implicit_globals {
1213 if default_var_index(implicit_name) == Some(*index) {
1214 return Err(IntegrationError::new(
1215 "index-collision",
1216 format!(
1217 "duplicate use of index {index} for global variables '{implicit_name}' and '{name}'"
1218 ),
1219 implicit_span.or(*span),
1220 ));
1221 }
1222 }
1223 }
1224 if let hir::Declaration::PlayerVariable {
1225 name,
1226 index: Some(index),
1227 span,
1228 ..
1229 } = declaration
1230 {
1231 for (implicit_name, implicit_span) in &implicit_players {
1232 if default_var_index(implicit_name) == Some(*index) {
1233 return Err(IntegrationError::new(
1234 "index-collision",
1235 format!(
1236 "duplicate use of index {index} for player variables '{implicit_name}' and '{name}'"
1237 ),
1238 implicit_span.or(*span),
1239 ));
1240 }
1241 }
1242 }
1243 }
1244
1245 let globals = self
1246 .hir
1247 .declarations
1248 .iter()
1249 .filter_map(|declaration| match declaration {
1250 hir::Declaration::GlobalVariable { index, span, .. } => Some((*index, *span)),
1251 _ => None,
1252 })
1253 .collect::<Vec<_>>();
1254 let players = self
1255 .hir
1256 .declarations
1257 .iter()
1258 .filter_map(|declaration| match declaration {
1259 hir::Declaration::PlayerVariable { index, span, .. } => Some((*index, *span)),
1260 _ => None,
1261 })
1262 .collect::<Vec<_>>();
1263 let subroutines = self
1264 .hir
1265 .declarations
1266 .iter()
1267 .filter_map(|declaration| match declaration {
1268 hir::Declaration::Subroutine { index, span, .. } => Some((*index, *span)),
1269 _ => None,
1270 })
1271 .collect::<Vec<_>>();
1272 let implicit_reserved = implicit_globals
1273 .keys()
1274 .map(|name| default_var_index(name).expect("implicit default variable names resolve"))
1275 .collect::<HashSet<_>>();
1276 let implicit_player_reserved = implicit_players
1277 .keys()
1278 .map(|name| default_var_index(name).expect("implicit default player names resolve"))
1279 .collect::<HashSet<_>>();
1280 let empty = HashSet::new();
1281 let global_indices = allocate_indices(&globals, &implicit_reserved, "global variable")?;
1282 let player_indices =
1283 allocate_indices(&players, &implicit_player_reserved, "player variable")?;
1284 let subroutine_indices = allocate_indices(&subroutines, &empty, "subroutine")?;
1285 let mut global_index = 0;
1286 let mut player_index = 0;
1287 let mut subroutine_index = 0;
1288
1289 let mut declared_globals: Vec<(&str, u32, Option<HirSpan>, Option<HirSpan>)> = Vec::new();
1294 let mut global_initializers = Vec::new();
1295 let mut declared_players: Vec<(&str, u32, Option<HirSpan>, Option<HirSpan>)> = Vec::new();
1296 let mut player_initializers = Vec::new();
1297 let mut declared_subroutines: Vec<(&str, u32, Option<HirSpan>, Option<HirSpan>)> =
1298 Vec::new();
1299
1300 for declaration in &self.hir.declarations {
1301 match declaration {
1302 hir::Declaration::GlobalVariable {
1303 name,
1304 index: _,
1305 span,
1306 name_span,
1307 initializer,
1308 } => {
1309 let assigned = global_indices[global_index];
1310 global_index += 1;
1311 if declared_globals
1312 .iter()
1313 .any(|(existing, ..)| *existing == name)
1314 {
1315 return Err(IntegrationError::new(
1316 "symbol-collision",
1317 format!("duplicate global variable '{name}'"),
1318 *span,
1319 ));
1320 }
1321 declared_globals.push((name, assigned, *span, *name_span));
1322 if let Some(init) = initializer {
1323 if !is_zero_initializer(init) {
1324 global_initializers.push((name, init, *span, *name_span));
1325 }
1326 }
1327 }
1328 hir::Declaration::PlayerVariable {
1329 name,
1330 index: _,
1331 span,
1332 name_span,
1333 initializer,
1334 } => {
1335 let assigned = player_indices[player_index];
1336 player_index += 1;
1337 if declared_players
1338 .iter()
1339 .any(|(existing, ..)| *existing == name)
1340 {
1341 return Err(IntegrationError::new(
1342 "symbol-collision",
1343 format!("duplicate player variable '{name}'"),
1344 *span,
1345 ));
1346 }
1347 declared_players.push((name, assigned, *span, *name_span));
1348 if let Some(init) = initializer {
1349 if !is_zero_initializer(init) {
1350 player_initializers.push((name, init, *span, *name_span));
1351 }
1352 }
1353 }
1354 hir::Declaration::Subroutine {
1355 name,
1356 span,
1357 name_span,
1358 ..
1359 } => {
1360 let assigned = subroutine_indices[subroutine_index];
1361 subroutine_index += 1;
1362 if declared_subroutines
1363 .iter()
1364 .any(|(existing, ..)| *existing == name)
1365 {
1366 return Err(IntegrationError::new(
1367 "symbol-collision",
1368 format!("duplicate subroutine '{name}'"),
1369 *span,
1370 ));
1371 }
1372 declared_subroutines.push((name, assigned, *span, *name_span));
1373 }
1374 hir::Declaration::Constant { name, value, span } => {
1375 if self.constants.insert(name.clone(), value).is_some() {
1376 return Err(IntegrationError::new(
1377 "symbol-collision",
1378 format!("duplicate constant '{name}'"),
1379 *span,
1380 ));
1381 }
1382 }
1383 hir::Declaration::Macro { .. } => {
1384 }
1387 }
1388 }
1389
1390 let mut planned_globals: Vec<(String, u32, Option<HirSpan>, Option<HirSpan>)> =
1391 declared_globals
1392 .into_iter()
1393 .map(|(name, index, span, name_span)| (name.to_string(), index, span, name_span))
1394 .collect();
1395 planned_globals.extend(implicit_globals.iter().map(|(name, span)| {
1396 (
1397 name.clone(),
1398 default_var_index(name).expect("implicit default variable names resolve"),
1399 *span,
1400 None,
1401 )
1402 }));
1403 planned_globals.sort_by_key(|(_, index, ..)| *index);
1404 for (name, assigned, span, name_span) in planned_globals {
1405 let id = self.wir.global_variables.push(wir::WorkshopVariable {
1406 name: name.clone(),
1407 index: assigned,
1408 span: self.wir_span(span)?,
1409 name_span: self.wir_span(name_span)?,
1410 });
1411 self.globals.insert(name.clone(), id);
1412 }
1413
1414 let mut planned_players: Vec<(String, u32, Option<HirSpan>, Option<HirSpan>)> =
1415 declared_players
1416 .into_iter()
1417 .map(|(name, index, span, name_span)| (name.to_string(), index, span, name_span))
1418 .collect();
1419 planned_players.extend(implicit_players.iter().map(|(name, span)| {
1420 (
1421 name.clone(),
1422 default_var_index(name).expect("implicit default player names resolve"),
1423 *span,
1424 None,
1425 )
1426 }));
1427 planned_players.sort_by_key(|(_, index, ..)| *index);
1428 for (name, assigned, span, name_span) in planned_players {
1429 let id = self.wir.player_variables.push(wir::WorkshopVariable {
1430 name: name.clone(),
1431 index: assigned,
1432 span: self.wir_span(span)?,
1433 name_span: self.wir_span(name_span)?,
1434 });
1435 self.players.insert(name, id);
1436 }
1437
1438 declared_subroutines.sort_by_key(|(_, index, ..)| *index);
1439 for (name, assigned, span, name_span) in declared_subroutines {
1440 let id = self.wir.subroutines.push(wir::WorkshopSubroutine {
1441 name: name.to_string(),
1442 index: assigned,
1443 span: self.wir_span(span)?,
1444 name_span: self.wir_span(name_span)?,
1445 });
1446 self.subroutines.insert(name.to_string(), id);
1447 }
1448
1449 if !global_initializers.is_empty() {
1450 let mut actions = Vec::with_capacity(global_initializers.len());
1451 for (name, init_expr, span, target_span) in global_initializers {
1452 let variable = *self.globals.get(name).expect("declared global is created");
1453 let value = self.lower_value(init_expr)?;
1454 actions.push(self.wir.actions.push(Action::SetGlobalVariable {
1455 variable,
1456 value,
1457 span: self.wir_span(span)?,
1458 target_span: self.wir_span(target_span)?,
1459 }));
1460 }
1461 self.wir.rules.push(wir::Rule {
1462 name: "Initialize global variables".to_string(),
1463 span: None,
1464 name_span: None,
1465 disabled: false,
1466 event: Event::Global,
1467 conditions: Vec::new(),
1468 actions,
1469 });
1470 }
1471
1472 if !player_initializers.is_empty() {
1473 let mut actions = Vec::with_capacity(player_initializers.len());
1474 for (name, init_expr, span, target_span) in player_initializers {
1475 let variable = *self
1476 .players
1477 .get(name)
1478 .expect("declared player variable is created");
1479 let player = self
1480 .wir
1481 .values
1482 .push(ValueNode::new(Value::EventPlayer, None));
1483 let value = self.lower_value(init_expr)?;
1484 actions.push(self.wir.actions.push(Action::SetPlayerVariable {
1485 player,
1486 variable,
1487 value,
1488 span: self.wir_span(span)?,
1489 target_span: self.wir_span(target_span)?,
1490 }));
1491 }
1492 self.wir.rules.push(wir::Rule {
1493 name: "Initialize player variables".to_string(),
1494 span: None,
1495 name_span: None,
1496 disabled: false,
1497 event: Event::EachPlayer,
1498 conditions: Vec::new(),
1499 actions,
1500 });
1501 }
1502
1503 Ok(())
1504 }
1505
1506 fn lower_rules(&mut self) -> Result<(), IntegrationError> {
1507 for entry in &self.hir.rules {
1508 match entry {
1509 RuleEntry::Rule(rule) => self.lower_rule(rule)?,
1510 RuleEntry::SubroutineDef {
1511 name,
1512 source_name,
1513 span,
1514 name_span,
1515 body,
1516 annotations,
1517 ..
1518 } => {
1519 self.lower_subroutine(name, source_name, *span, *name_span, body, annotations)?
1520 }
1521 }
1522 }
1523 Ok(())
1524 }
1525
1526 fn lower_rule(&mut self, rule: &hir::Rule) -> Result<(), IntegrationError> {
1527 self.reject_rule_metadata(rule)?;
1528 let event = self.lower_event(&rule.event, &rule.annotations)?;
1529 let conditions = rule
1530 .conditions
1531 .iter()
1532 .map(|expr| self.lower_condition(expr))
1533 .collect::<Result<Vec<_>, _>>()?;
1534 let mut actions = Vec::new();
1535 actions.extend(self.lower_actions(&rule.actions, None)?);
1536 self.wir.rules.push(wir::Rule {
1537 name: rule.name.clone(),
1538 span: self.wir_span(rule.span)?,
1539 name_span: self.wir_span(rule.name_span)?,
1540 disabled: rule.disabled,
1541 event,
1542 conditions,
1543 actions,
1544 });
1545 Ok(())
1546 }
1547
1548 fn lower_subroutine(
1549 &mut self,
1550 name: &str,
1551 source_name: &str,
1552 span: Option<HirSpan>,
1553 name_span: Option<HirSpan>,
1554 body: &[Stmt],
1555 annotations: &[hir::Annotation],
1556 ) -> Result<(), IntegrationError> {
1557 self.reject_subroutine_metadata(annotations)?;
1558 let source_name = if source_name.is_empty() {
1559 name
1560 } else {
1561 source_name
1562 };
1563 let subroutine = *self.subroutines.get(source_name).ok_or_else(|| {
1564 self.unsupported(
1565 format!("subroutine definition '{source_name}' has no declaration"),
1566 name_span.or(span),
1567 )
1568 })?;
1569 if !self.defined_subroutines.insert(subroutine) {
1570 return Err(self.unsupported(
1571 format!("subroutine '{source_name}' has multiple definitions"),
1572 name_span.or(span),
1573 ));
1574 }
1575 let mut actions = Vec::new();
1576 actions.extend(self.lower_actions(body, None)?);
1577 self.wir.rules.push(wir::Rule {
1578 name: self.subroutine_rule_name(name),
1579 span: self.wir_span(span)?,
1580 name_span: self.wir_span(name_span)?,
1581 disabled: false,
1582 event: Event::Subroutine(subroutine),
1583 conditions: Vec::new(),
1584 actions,
1585 });
1586 Ok(())
1587 }
1588
1589 fn reject_rule_metadata(&self, rule: &hir::Rule) -> Result<(), IntegrationError> {
1590 if rule.delimiter {
1591 let span = rule
1592 .annotations
1593 .iter()
1594 .find(|annotation| annotation.name == "Delimiter")
1595 .and_then(|annotation| annotation.span)
1596 .or(rule.span);
1597 return Err(self.unsupported(
1598 "rule delimiter metadata is not representable in canonical WIR",
1599 span,
1600 ));
1601 }
1602 if rule.new_page.is_some() {
1603 let span = rule
1604 .annotations
1605 .iter()
1606 .find(|annotation| annotation.name == "NewPage")
1607 .and_then(|annotation| annotation.span)
1608 .or(rule.span);
1609 return Err(self.unsupported(
1610 "rule new-page metadata is not representable in canonical WIR",
1611 span,
1612 ));
1613 }
1614 for annotation in &rule.annotations {
1615 match annotation.name.as_str() {
1616 "Event" | "Condition" | "Team" | "Slot" | "Hero" | "Disabled"
1617 | "SuppressWarnings" => {}
1618 _ => {
1619 return Err(self.unsupported(
1620 format!(
1621 "rule annotation '{}' is not representable in canonical WIR",
1622 annotation.name
1623 ),
1624 annotation.span.or(rule.span),
1625 ));
1626 }
1627 }
1628 }
1629 Ok(())
1630 }
1631
1632 fn reject_subroutine_metadata(
1633 &self,
1634 annotations: &[hir::Annotation],
1635 ) -> Result<(), IntegrationError> {
1636 for annotation in annotations {
1637 match annotation.name.as_str() {
1638 "Name" | "SuppressWarnings" => {}
1639 _ => {
1640 return Err(self.unsupported(
1641 format!(
1642 "subroutine annotation '{}' is not representable in canonical WIR",
1643 annotation.name
1644 ),
1645 annotation.span,
1646 ));
1647 }
1648 }
1649 }
1650 Ok(())
1651 }
1652
1653 fn subroutine_rule_name(&self, generated_name: &str) -> String {
1654 if self.hir.preprocessing.rule_prefix_template.is_some() {
1655 generated_name.to_string()
1656 } else {
1657 format!("Subroutine {generated_name}")
1658 }
1659 }
1660
1661 fn lower_event(
1662 &self,
1663 event: &hir::Event,
1664 annotations: &[hir::Annotation],
1665 ) -> Result<Event, IntegrationError> {
1666 if !event.args.is_empty() {
1667 return Err(self.unsupported(
1668 "event arguments are not representable in canonical WIR; use structural event filters",
1669 event.span,
1670 ));
1671 }
1672 let team = self.lower_event_team(annotations)?;
1673 let target = self.lower_event_target(annotations)?;
1674 let has_filters =
1675 !matches!(team, wir::EventTeam::All) || !matches!(target, wir::EventTarget::All);
1676 match event.name.as_str() {
1677 "global" => {
1678 if has_filters {
1679 return Err(
1680 self.unsupported("global events cannot have player filters", event.span)
1681 );
1682 }
1683 Ok(Event::Global)
1684 }
1685 "eachPlayer" => {
1686 if has_filters {
1687 Ok(Event::EachPlayerWithFilters { team, target })
1688 } else {
1689 Ok(Event::EachPlayer)
1690 }
1691 }
1692 name => player_event_kind(name).map_or_else(
1693 || {
1694 Err(self.unsupported(
1695 format!("event '{name}' is not supported by canonical WIR"),
1696 event.span,
1697 ))
1698 },
1699 |kind| Ok(Event::Player { kind, team, target }),
1700 ),
1701 }
1702 }
1703
1704 fn lower_event_team(
1705 &self,
1706 annotations: &[hir::Annotation],
1707 ) -> Result<wir::EventTeam, IntegrationError> {
1708 let team_annotations = annotations
1709 .iter()
1710 .filter(|annotation| annotation.name == "Team")
1711 .collect::<Vec<_>>();
1712 if team_annotations.len() > 1 {
1713 return Err(self.unsupported(
1714 "an event cannot have multiple @Team filters",
1715 team_annotations[1].span.or(team_annotations[0].span),
1716 ));
1717 }
1718 let Some(annotation) = team_annotations.first() else {
1719 return Ok(wir::EventTeam::All);
1720 };
1721 let argument = annotation
1722 .args
1723 .first()
1724 .ok_or_else(|| self.unsupported("@Team requires one filter value", annotation.span))?;
1725 if annotation.args.len() != 1 {
1726 return Err(
1727 self.unsupported("@Team requires exactly one filter value", annotation.span)
1728 );
1729 }
1730 let spelling = match argument.text.as_str() {
1731 "1" => "Team 1",
1732 "2" => "Team 2",
1733 value => value,
1734 };
1735 let (_, member) = self
1736 .compiler
1737 .catalog
1738 .resolve_enum_member("EventTeam", &Locale::new("en-US"), spelling)
1739 .ok_or_else(|| {
1740 self.unsupported(
1741 format!("unknown EventTeam filter '{spelling}'"),
1742 argument.span.or(annotation.span),
1743 )
1744 })?;
1745 match member.as_str() {
1746 "ALL" => Ok(wir::EventTeam::All),
1747 "TEAM_1" => Ok(wir::EventTeam::Team1),
1748 "TEAM_2" => Ok(wir::EventTeam::Team2),
1749 _ => Err(self.unsupported(
1750 format!("catalog EventTeam member '{member}' is not supported by canonical WIR"),
1751 argument.span.or(annotation.span),
1752 )),
1753 }
1754 }
1755
1756 fn lower_event_target(
1757 &self,
1758 annotations: &[hir::Annotation],
1759 ) -> Result<wir::EventTarget, IntegrationError> {
1760 let mut filters = Vec::new();
1761 for name in ["Slot", "Hero"] {
1762 let matches = annotations
1763 .iter()
1764 .filter(|annotation| annotation.name == name)
1765 .collect::<Vec<_>>();
1766 if matches.len() > 1 {
1767 return Err(self.unsupported(
1768 format!("an event cannot have multiple @{name} filters"),
1769 matches[1].span.or(matches[0].span),
1770 ));
1771 }
1772 filters.extend(matches);
1773 }
1774 if filters.len() > 1 {
1775 return Err(self.unsupported(
1776 "an event cannot combine @Slot and @Hero filters",
1777 filters[1].span.or(filters[0].span),
1778 ));
1779 }
1780 let Some(annotation) = filters.first() else {
1781 return Ok(wir::EventTarget::All);
1782 };
1783 let argument = annotation.args.first().ok_or_else(|| {
1784 self.unsupported(
1785 format!("@{} requires one filter value", annotation.name),
1786 annotation.span,
1787 )
1788 })?;
1789 if annotation.args.len() != 1 {
1790 return Err(self.unsupported(
1791 format!("@{} requires exactly one filter value", annotation.name),
1792 annotation.span,
1793 ));
1794 }
1795 let spelling = if annotation.name == "Slot" {
1796 match argument.text.as_str() {
1797 value if value.parse::<u8>().is_ok() => {
1798 format!("Slot {}", value.parse::<u8>().unwrap_or_default())
1799 }
1800 value => value.to_string(),
1801 }
1802 } else {
1803 argument.text.clone()
1804 };
1805 let domain = if annotation.name == "Slot" {
1806 "EventPlayer"
1807 } else {
1808 "Hero"
1809 };
1810 let (_, member) = self
1811 .compiler
1812 .catalog
1813 .resolve_enum_member(domain, &Locale::new("en-US"), &spelling)
1814 .ok_or_else(|| {
1815 self.unsupported(
1816 format!("unknown {domain} filter '{spelling}'"),
1817 argument.span.or(annotation.span),
1818 )
1819 })?;
1820 if domain == "EventPlayer" {
1821 if member == "ALL" {
1822 Ok(wir::EventTarget::All)
1823 } else if let Some(slot) = member.strip_prefix("SLOT_") {
1824 let slot = slot.parse::<u8>().map_err(|_| {
1825 self.unsupported(
1826 format!("catalog EventPlayer member '{member}' is not a slot"),
1827 argument.span.or(annotation.span),
1828 )
1829 })?;
1830 Ok(wir::EventTarget::Slot(slot))
1831 } else {
1832 Err(self.unsupported(
1833 format!(
1834 "catalog EventPlayer member '{member}' is not supported by canonical WIR"
1835 ),
1836 argument.span.or(annotation.span),
1837 ))
1838 }
1839 } else {
1840 Ok(wir::EventTarget::Hero(member))
1841 }
1842 }
1843
1844 fn lower_actions(
1845 &mut self,
1846 statements: &[Stmt],
1847 break_target: Option<BreakTarget>,
1848 ) -> Result<Vec<wir::ActionId>, IntegrationError> {
1849 let mut actions = Vec::new();
1850 for statement in statements {
1851 actions.extend(self.lower_action(statement, break_target)?);
1852 }
1853 Ok(actions)
1854 }
1855
1856 fn lower_action(
1857 &mut self,
1858 stmt: &Stmt,
1859 break_target: Option<BreakTarget>,
1860 ) -> Result<Vec<wir::ActionId>, IntegrationError> {
1861 match stmt {
1862 Stmt::Pass { .. } => Ok(Vec::new()),
1863 Stmt::Assign {
1864 target,
1865 value,
1866 span,
1867 } => self.lower_assign(target, value, *span).map(|action| vec![action]),
1868 Stmt::If {
1869 branches,
1870 r#else,
1871 span,
1872 } => {
1873 let branches = branches
1874 .iter()
1875 .map(|branch| {
1876 Ok(wir::IfBranch {
1877 condition: self.lower_value(&branch.condition)?,
1878 body: self.lower_actions(&branch.body, break_target)?,
1879 })
1880 })
1881 .collect::<Result<Vec<_>, IntegrationError>>()?;
1882 let else_body = r#else
1883 .as_ref()
1884 .map(|body| self.lower_actions(body, break_target))
1885 .transpose()?;
1886 Ok(vec![self.wir.actions.push(Action::If {
1887 branches,
1888 else_body,
1889 span: self.wir_span(*span)?,
1890 })])
1891 }
1892 Stmt::For {
1893 variable,
1894 iterable,
1895 body,
1896 span,
1897 } => {
1898 let Expr::GlobalVar {
1899 name,
1900 span: target_span,
1901 } = variable.as_ref()
1902 else {
1903 return Err(self.unsupported(
1904 "range loops require a global-variable binder in canonical WIR",
1905 variable.span().copied(),
1906 ));
1907 };
1908 let variable_id = *self.globals.get(name).ok_or_else(|| {
1909 self.unsupported(format!("unknown global variable '{name}'"), *target_span)
1910 })?;
1911 let (start, stop, step) = self.lower_range(iterable)?;
1912 let body = self.lower_actions(body, Some(BreakTarget::Loop))?;
1913 Ok(vec![self.wir.actions.push(Action::ForGlobalVariable {
1914 variable: variable_id,
1915 start,
1916 stop,
1917 step,
1918 body,
1919 span: self.wir_span(*span)?,
1920 target_span: self.wir_span(*target_span)?,
1921 })])
1922 }
1923 Stmt::While {
1924 condition,
1925 body,
1926 span,
1927 } => {
1928 let condition = self.lower_value(condition)?;
1929 let body = self.lower_actions(body, Some(BreakTarget::Loop))?;
1930 Ok(vec![self.wir.actions.push(Action::While {
1931 condition,
1932 body,
1933 span: self.wir_span(*span)?,
1934 })])
1935 }
1936 Stmt::DoWhile {
1937 condition,
1938 body,
1939 span,
1940 } => {
1941 let body = self.lower_do_while_body(body)?;
1942 let condition = self.lower_value(condition)?;
1943 let loop_if = self.wir.actions.push(Action::Call {
1944 name: "loopIf".to_string(),
1945 args: vec![condition],
1946 span: self.wir_span(*span)?,
1947 });
1948 let mut actions = body;
1951 actions.push(loop_if);
1952 Ok(actions)
1953 }
1954 Stmt::Switch {
1955 value,
1956 arms,
1957 span,
1958 } => self.lower_switch(value, arms, *span).map(|action| vec![action]),
1959 Stmt::Break { span } => match break_target {
1960 Some(BreakTarget::Loop) => Ok(vec![self.wir.actions.push(Action::Call {
1961 name: "break".to_string(),
1962 args: Vec::new(),
1963 span: self.wir_span(*span)?,
1964 })]),
1965 Some(BreakTarget::DoWhile) => Err(self.unsupported(
1966 "break inside a do-while must be a direct statement or a single conditional break",
1967 *span,
1968 )),
1969 Some(BreakTarget::Switch) => Err(self.unsupported(
1970 "break inside a nested conditional cannot be normalized into canonical switch control flow",
1971 *span,
1972 )),
1973 None => Err(self.unsupported(
1974 "break has no enclosing canonical loop or switch",
1975 *span,
1976 )),
1977 },
1978 Stmt::Expr { expr, span } => match expr.as_ref() {
1979 Expr::Call { name, args, .. } => {
1980 if name == "disableInspector" && args.is_empty() {
1981 Ok(vec![self.wir.actions.push(Action::Call {
1982 name: "disableInspector".to_string(),
1983 args: Vec::new(),
1984 span: self.wir_span(*span)?,
1985 })])
1986 } else if name == "debug" && args.len() == 1 {
1987 Ok(vec![self.lower_debug(&args[0], *span)?])
1988 } else if name == "print" && args.len() == 1 {
1989 Ok(vec![self.lower_print(&args[0], *span)?])
1990 } else {
1991 self.lower_action_call(name, args, *span).map(|action| vec![action])
1992 }
1993 }
1994 Expr::ReceiverCall {
1995 receiver,
1996 name,
1997 args,
1998 span: call_span,
1999 } => self
2000 .lower_receiver_action_call(receiver, name, args, *call_span)
2001 .map(|action| vec![action]),
2002 _ => Err(self.unsupported(
2003 "only action calls are currently representable as expression statements in canonical WIR",
2004 *span,
2005 )),
2006 },
2007 Stmt::CallSubroutine { name, span } => {
2008 let subroutine = *self.subroutines.get(name).ok_or_else(|| {
2009 self.unsupported(format!("unknown subroutine '{name}'"), *span)
2010 })?;
2011 let span = self.wir_span(*span)?;
2012 Ok(vec![self.wir.actions.push(Action::CallSubroutine {
2013 subroutine,
2014 span,
2015 callee_span: span,
2016 })])
2017 }
2018 }
2019 }
2020
2021 fn lower_do_while_body(
2022 &mut self,
2023 statements: &[Stmt],
2024 ) -> Result<Vec<wir::ActionId>, IntegrationError> {
2025 let mut actions = Vec::new();
2026 for (index, statement) in statements.iter().enumerate() {
2027 let direct_break = matches!(statement, Stmt::Break { .. });
2028 let conditional_break = match statement {
2029 Stmt::If {
2030 branches,
2031 r#else: None,
2032 ..
2033 } if branches.len() == 1 => {
2034 matches!(branches[0].body.as_slice(), [Stmt::Break { .. }])
2035 }
2036 _ => false,
2037 };
2038
2039 if direct_break || conditional_break {
2040 let tail = self.lower_do_while_body(&statements[index + 1..])?;
2041 let distance = self.canonical_action_width(&tail, statement.span().copied())? + 1;
2042 let (name, args, span) = if let Stmt::Break { span } = statement {
2043 ("skip", Vec::new(), *span)
2044 } else if let Stmt::If { branches, span, .. } = statement {
2045 (
2046 "skipIf",
2047 vec![self.lower_value(&branches[0].condition)?],
2048 *span,
2049 )
2050 } else {
2051 unreachable!("break shape was checked above")
2052 };
2053 let distance = self.wir.values.push(ValueNode::new(
2054 Value::Number {
2055 value: distance as f64,
2056 text: distance.to_string(),
2057 },
2058 self.wir_span(span)?,
2059 ));
2060 let mut args = args;
2061 args.push(distance);
2062 actions.push(self.wir.actions.push(Action::Call {
2063 name: name.to_string(),
2064 args,
2065 span: self.wir_span(span)?,
2066 }));
2067 actions.extend(tail);
2068 return Ok(actions);
2069 }
2070
2071 actions.extend(self.lower_action(statement, Some(BreakTarget::DoWhile))?);
2072 }
2073 Ok(actions)
2074 }
2075
2076 fn lower_range(
2077 &mut self,
2078 iterable: &Expr,
2079 ) -> Result<(wir::ValueId, wir::ValueId, wir::ValueId), IntegrationError> {
2080 let Expr::Call { name, args, .. } = iterable else {
2081 return Err(self.unsupported(
2082 "range loop iterable must be a range(...) call",
2083 iterable.span().copied(),
2084 ));
2085 };
2086 if name != "range" || !(1..=3).contains(&args.len()) {
2087 return Err(self.unsupported(
2088 "range loop requires one to three arguments",
2089 iterable.span().copied(),
2090 ));
2091 }
2092 let span = iterable.span().copied();
2093 let number = |this: &mut Self, value: f64| -> Result<wir::ValueId, IntegrationError> {
2094 Ok(this.wir.values.push(ValueNode::new(
2095 Value::Number {
2096 value,
2097 text: value.to_string(),
2098 },
2099 this.wir_span(span)?,
2100 )))
2101 };
2102 match args.as_slice() {
2103 [stop] => Ok((
2104 number(self, 0.0)?,
2105 self.lower_value(stop)?,
2106 number(self, 1.0)?,
2107 )),
2108 [start, stop] => Ok((
2109 self.lower_value(start)?,
2110 self.lower_value(stop)?,
2111 number(self, 1.0)?,
2112 )),
2113 [start, stop, step] => Ok((
2114 self.lower_value(start)?,
2115 self.lower_value(stop)?,
2116 self.lower_value(step)?,
2117 )),
2118 _ => unreachable!("range arity checked above"),
2119 }
2120 }
2121
2122 fn lower_switch(
2123 &mut self,
2124 value: &Expr,
2125 arms: &[SwitchArm],
2126 span: Option<HirSpan>,
2127 ) -> Result<wir::ActionId, IntegrationError> {
2128 let selector = self.lower_value(value)?;
2129 let mut case_values = Vec::new();
2130 let mut lowered_arms = Vec::with_capacity(arms.len());
2131 let mut case_offsets = Vec::new();
2132 let mut offset = 0usize;
2133 let mut default_offset = None;
2134
2135 for arm in arms {
2136 let (value, (body, break_at)) = match arm {
2137 SwitchArm::Case { value, body, .. } => {
2138 case_values.push(self.lower_value(value)?);
2139 (Some(value), self.lower_switch_body(body)?)
2140 }
2141 SwitchArm::Default { body, span } => {
2142 if default_offset.is_some() {
2143 return Err(
2144 self.unsupported("a switch may contain at most one default arm", *span)
2145 );
2146 }
2147 default_offset = Some(offset);
2148 (None, self.lower_switch_body(body)?)
2149 }
2150 };
2151 if value.is_some() {
2152 case_offsets.push(offset);
2153 }
2154 offset += self.canonical_action_width(&body, span)? + usize::from(break_at.is_some());
2155 lowered_arms.push((value, body, break_at));
2156 }
2157 let default_offset = default_offset.unwrap_or(offset);
2158
2159 let break_arms: Vec<_> = lowered_arms
2160 .iter()
2161 .enumerate()
2162 .filter_map(|(index, (_, _, break_at))| break_at.map(|break_at| (index, break_at)))
2163 .collect();
2164 if break_arms.len() > 1 {
2165 let (first_index, first_break) = break_arms[0];
2166 let has_actions_after_first = lowered_arms[first_index].1.len() > first_break.0
2167 || lowered_arms
2168 .iter()
2169 .skip(first_index + 1)
2170 .any(|(_, body, _)| !body.is_empty());
2171 if has_actions_after_first {
2172 return Err(self.unsupported(
2173 "multiple switch breaks with later reachable actions require canonical switch targets",
2174 Some(break_arms[1].1.1),
2175 ));
2176 }
2177 }
2178
2179 let case_values = self.lower_array(case_values, span)?;
2180 let value_span = self.wir_span(span)?;
2181 let offset_values = std::iter::once(default_offset)
2182 .chain(case_offsets)
2183 .map(|value| {
2184 self.wir.values.push(ValueNode::new(
2185 Value::Number {
2186 value: value as f64,
2187 text: value.to_string(),
2188 },
2189 value_span,
2190 ))
2191 })
2192 .collect();
2193 let offsets = self.lower_array(offset_values, span)?;
2194 let one = self.wir.values.push(ValueNode::new(
2195 Value::Number {
2196 value: 1.0,
2197 text: "1".to_string(),
2198 },
2199 self.wir_span(span)?,
2200 ));
2201 let index = self.wir.values.push(ValueNode::new(
2202 Value::Call {
2203 name: "indexOfArrayValue".to_string(),
2204 args: vec![case_values, selector],
2205 },
2206 self.wir_span(span)?,
2207 ));
2208 let case_offset = self.wir.values.push(ValueNode::new(
2209 Value::Call {
2210 name: "add".to_string(),
2211 args: vec![one, index],
2212 },
2213 self.wir_span(span)?,
2214 ));
2215 let skip_condition = self.wir.values.push(ValueNode::new(
2216 Value::Call {
2217 name: "valueInArray".to_string(),
2218 args: vec![offsets, case_offset],
2219 },
2220 self.wir_span(span)?,
2221 ));
2222 let skip = self.wir.actions.push(Action::Call {
2223 name: "skip".to_string(),
2224 args: vec![skip_condition],
2225 span: self.wir_span(span)?,
2226 });
2227 let true_value = self
2228 .wir
2229 .values
2230 .push(ValueNode::new(Value::Bool(true), self.wir_span(span)?));
2231
2232 let first_break = break_arms.first().copied();
2233 let mut branch_body = vec![skip];
2234 let else_body = if let Some((break_index, (break_at, _))) = first_break {
2235 for (index, (_, body, _)) in lowered_arms.iter().enumerate() {
2236 if index < break_index {
2237 branch_body.extend(body.iter().copied());
2238 } else if index == break_index {
2239 branch_body.extend(body[..break_at].iter().copied());
2240 }
2241 }
2242 let mut tail = Vec::new();
2243 tail.extend(lowered_arms[break_index].1[break_at..].iter().copied());
2244 for (_, body, _) in lowered_arms.iter().skip(break_index + 1) {
2245 tail.extend(body.iter().copied());
2246 }
2247 Some(tail)
2248 } else {
2249 for (_, body, _) in &lowered_arms {
2250 branch_body.extend(body.iter().copied());
2251 }
2252 None
2253 };
2254
2255 Ok(self.wir.actions.push(Action::If {
2256 branches: vec![wir::IfBranch {
2257 condition: true_value,
2258 body: branch_body,
2259 }],
2260 else_body,
2261 span: self.wir_span(span)?,
2262 }))
2263 }
2264
2265 fn lower_switch_body(
2266 &mut self,
2267 statements: &[Stmt],
2268 ) -> Result<LoweredSwitchBody, IntegrationError> {
2269 let mut actions = Vec::new();
2270 let mut break_at = None;
2271 for statement in statements {
2272 if let Stmt::Break { span } = statement {
2273 if break_at.is_some() {
2274 return Err(self.unsupported(
2275 "multiple switch breaks in one arm require canonical switch targets",
2276 *span,
2277 ));
2278 }
2279 break_at = Some((
2280 actions.len(),
2281 span.ok_or_else(|| {
2282 self.unsupported("switch break is missing source provenance", None)
2283 })?,
2284 ));
2285 continue;
2286 }
2287 actions.extend(self.lower_action(statement, Some(BreakTarget::Switch))?);
2288 }
2289 Ok((actions, break_at))
2290 }
2291
2292 fn canonical_action_width(
2294 &self,
2295 actions: &[wir::ActionId],
2296 fallback_span: Option<HirSpan>,
2297 ) -> Result<usize, IntegrationError> {
2298 workshop_rs::emitter::action_width(
2299 &self.wir,
2300 &self.compiler.catalog,
2301 &Locale::new("en-US"),
2302 actions,
2303 )
2304 .map(|layout| layout.width)
2305 .map_err(|error| {
2306 let workshop_span = match &error {
2307 workshop_rs::emitter::ActionLayoutError::InvalidWIR(error) => error.span(),
2308 workshop_rs::emitter::ActionLayoutError::Emission(error) => {
2309 workshop_error_span(error)
2310 }
2311 };
2312 let span = workshop_span
2313 .and_then(|span| self.hir_span_from_workshop(span))
2314 .or(fallback_span);
2315 IntegrationError::new("workshop-action-layout", error.to_string(), span)
2316 })
2317 }
2318
2319 fn lower_array(
2320 &mut self,
2321 elements: Vec<wir::ValueId>,
2322 span: Option<HirSpan>,
2323 ) -> Result<wir::ValueId, IntegrationError> {
2324 let name = if elements.is_empty() {
2325 "emptyArray"
2326 } else {
2327 "array"
2328 };
2329 Ok(self.wir.values.push(ValueNode::new(
2330 Value::Call {
2331 name: name.to_string(),
2332 args: elements,
2333 },
2334 self.wir_span(span)?,
2335 )))
2336 }
2337
2338 fn lower_debug(
2339 &mut self,
2340 expr: &Expr,
2341 span: Option<HirSpan>,
2342 ) -> Result<wir::ActionId, IntegrationError> {
2343 macro_rules! call {
2344 ($name:literal $(, $arg:expr)* $(,)?) => {{
2345 let args = vec![$($arg),*];
2346 self.push_call($name, args)
2347 }};
2348 }
2349
2350 let value = self.lower_value(expr)?;
2351 let array_text = if self.debug_value_is_array(value) {
2352 self.lower_debug_array_text(value)
2353 } else {
2354 value
2355 };
2356 let debug_label = canonical_debug_text(&debug_expr_text(expr));
2357 let debug_prefix = format!("{debug_label}\u{2028}= {{0}}");
2358 let inline_padding = 128 - debug_prefix.chars().count() - "{1}".chars().count();
2359 let padding_text = self.push_value(Value::String(" ".repeat(170 - inline_padding)));
2360 let padding = self.push_call("customString", vec![padding_text]);
2361 let debug_label = self.push_value(Value::String(format!(
2362 "{debug_prefix}{}{{1}}",
2363 " ".repeat(inline_padding)
2364 )));
2365 let text = self.push_call("customString", vec![debug_label, array_text, padding]);
2366 let all_teams = self.push_value(Value::Enum {
2367 value_type: "Team".to_string(),
2368 value: "ALL".to_string(),
2369 });
2370 let all_players = call!("allPlayers", all_teams);
2371 let null_value = self.push_value(Value::Null);
2372 let null_value_2 = self.push_value(Value::Null);
2373 let null_value_3 = self.push_value(Value::Null);
2374 let null_value_4 = self.push_value(Value::Null);
2375 let hud_position = self.push_value(Value::Enum {
2376 value_type: "HudPosition".to_string(),
2377 value: "LEFT".to_string(),
2378 });
2379 let sort_order = self.push_value(Value::Number {
2380 value: -9999.0,
2381 text: "-9999".to_string(),
2382 });
2383 let color = self.push_value(Value::Enum {
2384 value_type: "Color".to_string(),
2385 value: "WHITE".to_string(),
2386 });
2387 let reevaluation = self.push_value(Value::Enum {
2388 value_type: "HudReeval".to_string(),
2389 value: "VISIBILITY_SORT_ORDER_STRING_AND_COLOR".to_string(),
2390 });
2391 let visibility = self.push_value(Value::Enum {
2392 value_type: "SpecVisibility".to_string(),
2393 value: "DEFAULT".to_string(),
2394 });
2395 Ok(self.wir.actions.push(Action::Call {
2396 name: "createHudText".to_string(),
2397 args: vec![
2398 all_players,
2399 null_value,
2400 text,
2401 null_value_2,
2402 hud_position,
2403 sort_order,
2404 null_value_3,
2405 color,
2406 null_value_4,
2407 reevaluation,
2408 visibility,
2409 ],
2410 span: self.wir_span(span)?,
2411 }))
2412 }
2413
2414 fn lower_print(
2415 &mut self,
2416 expr: &Expr,
2417 span: Option<HirSpan>,
2418 ) -> Result<wir::ActionId, IntegrationError> {
2419 macro_rules! call {
2420 ($name:literal $(, $arg:expr)* $(,)?) => {{
2421 let args = vec![$($arg),*];
2422 self.push_call($name, args)
2423 }};
2424 }
2425
2426 let message = self.lower_value(expr)?;
2427 let padding_text = self.push_value(Value::String(" ".repeat(45)));
2428 let padding = self.push_call("customString", vec![padding_text]);
2429 let body_text = self.push_value(Value::String(format!("{}{{0}}", " ".repeat(125))));
2430 let body = self.push_call("customString", vec![body_text, padding]);
2431 let all_teams = self.push_value(Value::Enum {
2432 value_type: "Team".to_string(),
2433 value: "ALL".to_string(),
2434 });
2435 let all_players = call!("allPlayers", all_teams);
2436 let null_value = self.push_value(Value::Null);
2437 let null_value_2 = self.push_value(Value::Null);
2438 let null_value_3 = self.push_value(Value::Null);
2439 let hud_position = self.push_value(Value::Enum {
2440 value_type: "HudPosition".to_string(),
2441 value: "LEFT".to_string(),
2442 });
2443 let sort_order = self.push_value(Value::Number {
2444 value: -9999.0,
2445 text: "-9999".to_string(),
2446 });
2447 let color = self.push_value(Value::Enum {
2448 value_type: "Color".to_string(),
2449 value: "ORANGE".to_string(),
2450 });
2451 let reevaluation = self.push_value(Value::Enum {
2452 value_type: "HudReeval".to_string(),
2453 value: "VISIBILITY_AND_STRING".to_string(),
2454 });
2455 let visibility = self.push_value(Value::Enum {
2456 value_type: "SpecVisibility".to_string(),
2457 value: "DEFAULT".to_string(),
2458 });
2459 Ok(self.wir.actions.push(Action::Call {
2460 name: "createHudText".to_string(),
2461 args: vec![
2462 all_players,
2463 message,
2464 body,
2465 null_value,
2466 hud_position,
2467 sort_order,
2468 color,
2469 null_value_2,
2470 null_value_3,
2471 reevaluation,
2472 visibility,
2473 ],
2474 span: self.wir_span(span)?,
2475 }))
2476 }
2477
2478 fn lower_debug_array_text(&mut self, value: wir::ValueId) -> wir::ValueId {
2479 macro_rules! call {
2480 ($name:literal $(, $arg:expr)* $(,)?) => {{
2481 let args = vec![$($arg),*];
2482 self.push_call($name, args)
2483 }};
2484 }
2485
2486 let current_count = call!("countOf", call!("currentArrayElement"));
2487 let is_single = call!(
2488 "==",
2489 call!("countOf", call!("currentArrayElement")),
2490 self.push_number(1.0, "1")
2491 );
2492 let is_empty = call!("==", call!("currentArrayElement"), call!("emptyArray"));
2493 let not_null = call!(
2494 "!=",
2495 call!("currentArrayElement"),
2496 self.push_value(Value::Null)
2497 );
2498 let has_empty_array = call!("and", is_empty, not_null);
2499 let brackets = call!("or", is_single, has_empty_array);
2500 let first_element = call!(
2501 "customString",
2502 self.push_value(Value::String("[{0}]".to_string())),
2503 call!("currentArrayElement"),
2504 );
2505 let many_elements = call!(
2506 "customString",
2507 self.push_value(Value::String("[{0}, …+{1}]".to_string())),
2508 call!("currentArrayElement"),
2509 call!(
2510 "subtract",
2511 call!("countOf", call!("currentArrayElement")),
2512 self.push_number(1.0, "1"),
2513 ),
2514 );
2515 let element_text = call!(
2516 "ifThenElse",
2517 brackets,
2518 first_element,
2519 call!(
2520 "ifThenElse",
2521 current_count,
2522 many_elements,
2523 call!("currentArrayElement"),
2524 ),
2525 );
2526 let mapped_elements = call!("mappedArray", value, element_text,);
2527 let mapped_input = call!("array", mapped_elements);
2528 let current_array = call!("currentArrayElement");
2529 let actual_array = call!(
2530 "or",
2531 call!("countOf", current_array),
2532 call!(
2533 "and",
2534 call!("==", call!("currentArrayElement"), call!("emptyArray")),
2535 call!(
2536 "!=",
2537 call!("currentArrayElement"),
2538 self.push_value(Value::Null)
2539 ),
2540 ),
2541 );
2542 let empty_length = call!(
2543 "ifThenElse",
2544 call!(
2545 "and",
2546 call!("not", call!("countOf", call!("currentArrayElement"))),
2547 call!("!=", call!("currentArrayElement"), call!("emptyArray"),),
2548 ),
2549 self.push_number(3.0, "3"),
2550 call!(
2551 "multiply",
2552 call!("countOf", call!("currentArrayElement")),
2553 self.push_number(3.0, "3"),
2554 ),
2555 );
2556 let x = call!(
2557 "appendToArray",
2558 call!("appendToArray", actual_array, empty_length),
2559 current_array,
2560 );
2561 let x_input = call!("mappedArray", mapped_input, x);
2562 let x_length = |this: &mut Self| {
2563 let current = this.push_call("currentArrayElement", Vec::new());
2564 let index = this.push_number(1.0, "1");
2565 this.push_call("valueInArray", vec![current, index])
2566 };
2567 let x_value = |this: &mut Self, index: f64| {
2568 let current = this.push_call("currentArrayElement", Vec::new());
2569 let index_value = this.push_number(index, &index.to_string());
2570 this.push_call("valueInArray", vec![current, index_value])
2571 };
2572 let first = call!("firstOf", call!("currentArrayElement"));
2573 let array_tail = call!(
2574 "customString",
2575 self.push_value(Value::String("{0}, {1}, {2}".to_string())),
2576 x_value(self, 4.0),
2577 x_value(self, 5.0),
2578 call!(
2579 "customString",
2580 self.push_value(Value::String("{0}, {1}, …\u{0001}".to_string())),
2581 x_value(self, 6.0),
2582 x_value(self, 7.0),
2583 ),
2584 );
2585 let array_head = call!(
2586 "customString",
2587 self.push_value(Value::String("{0}, {1}, {2}".to_string())),
2588 x_value(self, 2.0),
2589 x_value(self, 3.0),
2590 array_tail,
2591 );
2592 let placeholder = call!(
2593 "customString",
2594 self.push_value(Value::String("0, 0, 0, 0, 0, 0, …\u{0001}".to_string())),
2595 );
2596 let length_for_slice = x_length(self);
2597 let end_length_for_slice = x_length(self);
2598 let slice = call!(
2599 "stringSlice",
2600 placeholder,
2601 call!("add", self.push_number(-2.0, "-2"), length_for_slice),
2602 call!(
2603 "subtract",
2604 self.push_number(22.0, "22"),
2605 end_length_for_slice,
2606 ),
2607 );
2608 let replaced = call!("stringReplace", array_head, slice, call!("emptyArray"),);
2609 let length_for_compare = x_length(self);
2610 let length_for_divide = x_length(self);
2611 let plus = call!(
2612 "ifThenElse",
2613 call!(">", length_for_compare, self.push_number(18.0, "18")),
2614 call!(
2615 "customString",
2616 self.push_value(Value::String("+{0}".to_string())),
2617 call!(
2618 "subtract",
2619 call!("divide", length_for_divide, self.push_number(3.0, "3")),
2620 self.push_number(6.0, "6"),
2621 ),
2622 ),
2623 call!("emptyArray"),
2624 );
2625 let formatted_array = call!(
2626 "customString",
2627 self.push_value(Value::String("[{0}{1}]".to_string())),
2628 replaced,
2629 plus,
2630 );
2631 let current_for_split = call!("currentArrayElement");
2632 let rendered = call!(
2633 "ifThenElse",
2634 first,
2635 formatted_array,
2636 call!(
2637 "stringSplit",
2638 call!(
2639 "valueInArray",
2640 current_for_split,
2641 self.push_number(2.0, "2")
2642 ),
2643 call!("emptyArray"),
2644 ),
2645 );
2646 call!("mappedArray", x_input, rendered)
2647 }
2648
2649 fn debug_value_is_array(&self, value: wir::ValueId) -> bool {
2650 match &self
2651 .wir
2652 .values
2653 .get(value)
2654 .expect("lowered value must exist")
2655 .value
2656 {
2657 Value::GlobalVariable(_) | Value::Array(_) => true,
2658 Value::Call { name, .. } if matches!(name.as_str(), "array" | "emptyArray") => true,
2659 Value::Call { name, .. } => self
2660 .compiler
2661 .catalog
2662 .entry(Kind::Value, name)
2663 .and_then(|entry| entry.return_type())
2664 .is_some_and(|return_type| {
2665 return_type.split('|').any(|part| part.trim() == "Array")
2666 }),
2667 _ => false,
2668 }
2669 }
2670
2671 fn push_value(&mut self, value: Value) -> wir::ValueId {
2672 self.wir.values.push(ValueNode::new(value, None))
2673 }
2674
2675 fn push_call(&mut self, name: &str, args: Vec<wir::ValueId>) -> wir::ValueId {
2676 self.push_value(Value::Call {
2677 name: name.to_string(),
2678 args,
2679 })
2680 }
2681
2682 fn push_number(&mut self, value: f64, text: &str) -> wir::ValueId {
2683 self.push_value(Value::Number {
2684 value,
2685 text: text.to_string(),
2686 })
2687 }
2688
2689 fn fold_numeric_binary(
2690 &self,
2691 op: &str,
2692 left: wir::ValueId,
2693 right: wir::ValueId,
2694 ) -> Option<f64> {
2695 let number = |id| match self.wir.values.get(id)?.value {
2696 Value::Number { value, .. } => Some(value),
2697 _ => None,
2698 };
2699 let left = number(left)?;
2700 let right = number(right)?;
2701 let value = match op {
2702 "+" => left + right,
2703 "-" => left - right,
2704 "*" => left * right,
2705 "/" if right != 0.0 => left / right,
2706 "%" if right != 0.0 => left % right,
2707 "**" => left.powf(right),
2708 _ => return None,
2709 };
2710 value.is_finite().then_some(value)
2711 }
2712
2713 fn lower_condition(&mut self, expr: &Expr) -> Result<wir::ValueId, IntegrationError> {
2714 let value = self.lower_value(expr)?;
2715 let is_comparison = |expr: &Expr| matches!(expr, Expr::Binary { op, .. } if matches!(op.as_str(), "==" | "!=" | "<" | "<=" | ">" | ">="));
2716 if is_comparison(expr)
2717 || matches!(expr, Expr::Unary { op, operand, .. } if op == "not" && is_comparison(operand))
2718 {
2719 return Ok(value);
2720 }
2721 let true_value = self.wir.values.push(ValueNode::new(
2722 Value::Bool(true),
2723 self.wir_span(expr.span().copied())?,
2724 ));
2725 Ok(self.wir.values.push(ValueNode::new(
2726 Value::Call {
2727 name: "==".to_string(),
2728 args: vec![value, true_value],
2729 },
2730 self.wir_span(expr.span().copied())?,
2731 )))
2732 }
2733
2734 fn lower_assign(
2735 &mut self,
2736 target: &Expr,
2737 value: &Expr,
2738 span: Option<HirSpan>,
2739 ) -> Result<wir::ActionId, IntegrationError> {
2740 match target {
2741 Expr::GlobalVar {
2742 name,
2743 span: target_span,
2744 } => {
2745 let variable = *self.globals.get(name).ok_or_else(|| {
2746 self.unsupported(format!("unknown global variable '{name}'"), *target_span)
2747 })?;
2748 if let Expr::Binary {
2749 op, left, right, ..
2750 } = value
2751 {
2752 if let Expr::GlobalVar {
2753 name: left_name, ..
2754 } = left.as_ref()
2755 {
2756 if left_name == name {
2757 if let Some(modify_op) = modify_op_from_str(op) {
2758 let val = self.lower_value(right)?;
2759 return Ok(self.wir.actions.push(Action::ModifyGlobalVariable {
2760 variable,
2761 op: modify_op,
2762 value: val,
2763 span: self.wir_span(span)?,
2764 target_span: self.wir_span(*target_span)?,
2765 }));
2766 }
2767 }
2768 }
2769 }
2770 let val = self.lower_value(value)?;
2771 Ok(self.wir.actions.push(Action::SetGlobalVariable {
2772 variable,
2773 value: val,
2774 span: self.wir_span(span)?,
2775 target_span: self.wir_span(*target_span)?,
2776 }))
2777 }
2778 Expr::PlayerVar {
2779 player,
2780 name,
2781 span: target_span,
2782 } => {
2783 let variable = *self.players.get(name).ok_or_else(|| {
2784 self.unsupported(format!("unknown player variable '{name}'"), *target_span)
2785 })?;
2786 let player_val = self.lower_value(player)?;
2787 if let Expr::Binary {
2788 op, left, right, ..
2789 } = value
2790 {
2791 if let Expr::PlayerVar {
2792 player: left_player,
2793 name: left_name,
2794 ..
2795 } = left.as_ref()
2796 {
2797 if left_name == name && left_player.as_ref() == player.as_ref() {
2798 if let Some(modify_op) = modify_op_from_str(op) {
2799 let val = self.lower_value(right)?;
2800 return Ok(self.wir.actions.push(Action::ModifyPlayerVariable {
2801 player: player_val,
2802 variable,
2803 op: modify_op,
2804 value: val,
2805 span: self.wir_span(span)?,
2806 target_span: self.wir_span(*target_span)?,
2807 }));
2808 }
2809 }
2810 }
2811 }
2812 let val = self.lower_value(value)?;
2813 Ok(self.wir.actions.push(Action::SetPlayerVariable {
2814 player: player_val,
2815 variable,
2816 value: val,
2817 span: self.wir_span(span)?,
2818 target_span: self.wir_span(*target_span)?,
2819 }))
2820 }
2821 Expr::Index {
2822 array,
2823 index,
2824 span: target_span,
2825 } => match array.as_ref() {
2826 Expr::GlobalVar {
2827 name,
2828 span: arr_span,
2829 } => {
2830 let variable = *self.globals.get(name).ok_or_else(|| {
2831 self.unsupported(format!("unknown global variable '{name}'"), *arr_span)
2832 })?;
2833 let var_node = self.wir.values.push(ValueNode::new(
2834 Value::GlobalVariable(variable),
2835 self.wir_span(*arr_span)?,
2836 ));
2837 let index_val = self.lower_value(index)?;
2838 if let Expr::Binary {
2839 op, left, right, ..
2840 } = value
2841 {
2842 if let Expr::Index {
2843 array: left_arr,
2844 index: left_idx,
2845 ..
2846 } = left.as_ref()
2847 {
2848 if left_arr.as_ref() == array.as_ref()
2849 && left_idx.as_ref() == index.as_ref()
2850 {
2851 if let Some(op_id) = modify_catalog_name_from_str(op) {
2852 let op_node = self.wir.values.push(ValueNode::new(
2853 Value::Call {
2854 name: op_id.to_string(),
2855 args: Vec::new(),
2856 },
2857 None,
2858 ));
2859 let right_val = self.lower_value(right)?;
2860 return Ok(self.wir.actions.push(Action::Call {
2861 name: "modifyGlobalVariableAtIndex".to_string(),
2862 args: vec![var_node, index_val, op_node, right_val],
2863 span: self.wir_span(span)?,
2864 }));
2865 }
2866 }
2867 }
2868 }
2869 let val = self.lower_value(value)?;
2870 Ok(self.wir.actions.push(Action::Call {
2871 name: "setGlobalVariableAtIndex".to_string(),
2872 args: vec![var_node, index_val, val],
2873 span: self.wir_span(span)?,
2874 }))
2875 }
2876 Expr::PlayerVar {
2877 player,
2878 name,
2879 span: arr_span,
2880 } => {
2881 let player_val = self.lower_value(player)?;
2882 let variable = *self.players.get(name).ok_or_else(|| {
2883 self.unsupported(format!("unknown player variable '{name}'"), *arr_span)
2884 })?;
2885 let var_node = self.wir.values.push(ValueNode::new(
2886 Value::PlayerVariable {
2887 player: player_val,
2888 variable,
2889 },
2890 self.wir_span(*arr_span)?,
2891 ));
2892 let index_val = self.lower_value(index)?;
2893 if let Expr::Binary {
2894 op, left, right, ..
2895 } = value
2896 {
2897 if let Expr::Index {
2898 array: left_arr,
2899 index: left_idx,
2900 ..
2901 } = left.as_ref()
2902 {
2903 if left_arr.as_ref() == array.as_ref()
2904 && left_idx.as_ref() == index.as_ref()
2905 {
2906 if let Some(op_id) = modify_catalog_name_from_str(op) {
2907 let op_node = self.wir.values.push(ValueNode::new(
2908 Value::Call {
2909 name: op_id.to_string(),
2910 args: Vec::new(),
2911 },
2912 None,
2913 ));
2914 let right_val = self.lower_value(right)?;
2915 return Ok(self.wir.actions.push(Action::Call {
2920 name: "modifyPlayerVariableAtIndex".to_string(),
2921 args: vec![var_node, index_val, op_node, right_val],
2922 span: self.wir_span(span)?,
2923 }));
2924 }
2925 }
2926 }
2927 }
2928 let val = self.lower_value(value)?;
2929 Ok(self.wir.actions.push(Action::Call {
2933 name: "setPlayerVariableAtIndex".to_string(),
2934 args: vec![var_node, index_val, val],
2935 span: self.wir_span(span)?,
2936 }))
2937 }
2938 _ => Err(self.unsupported(
2939 "indexing assignment is only representable for global or player variables",
2940 *target_span,
2941 )),
2942 },
2943 _ => Err(self.unsupported(
2944 "only global-variable, player-variable, or index assignment is currently representable in canonical WIR",
2945 span,
2946 )),
2947 }
2948 }
2949
2950 fn lower_action_call(
2951 &mut self,
2952 name: &str,
2953 args: &[Expr],
2954 span: Option<HirSpan>,
2955 ) -> Result<wir::ActionId, IntegrationError> {
2956 let function = self
2957 .compiler
2958 .manifest
2959 .resolve_function(name)
2960 .ok_or_else(|| self.unsupported(format!("unknown action '{name}'"), span))?;
2961 if !matches!(function.kind, FunctionKind::Action) {
2962 return Err(self.unsupported(format!("'{name}' is not a generic OPY action"), span));
2963 }
2964 if function.id == "hudSubheader" {
2965 return self.lower_hud_subheader(args, span);
2966 }
2967 let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
2968 self.unsupported(
2969 format!(
2970 "action '{}' requires a special lowering not in #46",
2971 function.id
2972 ),
2973 span,
2974 )
2975 })?;
2976 let args = args
2977 .iter()
2978 .map(|expr| self.lower_value(expr))
2979 .collect::<Result<Vec<_>, _>>()?;
2980 Ok(self.wir.actions.push(Action::Call {
2981 name: catalog_id.clone(),
2982 args,
2983 span: self.wir_span(span)?,
2984 }))
2985 }
2986
2987 fn lower_hud_subheader(
2988 &mut self,
2989 args: &[Expr],
2990 span: Option<HirSpan>,
2991 ) -> Result<wir::ActionId, IntegrationError> {
2992 let [
2993 visible_to,
2994 text,
2995 position,
2996 sort_order,
2997 color,
2998 reevaluation,
2999 spectators,
3000 ] = args
3001 else {
3002 return Err(
3003 self.unsupported("hudSubheader requires exactly seven bound arguments", span)
3004 );
3005 };
3006 let visible_to = self.lower_hud_visible_to(visible_to)?;
3007 let null_header = self.push_value(Value::Null);
3008 let null_text = self.push_value(Value::Null);
3009 let text_value = self.lower_value(text)?;
3010 let text = self.push_call("customString", vec![text_value]);
3011 let args = vec![
3012 visible_to,
3013 null_header,
3014 text,
3015 null_text,
3016 self.lower_value(position)?,
3017 self.lower_value(sort_order)?,
3018 self.push_value(Value::Null),
3019 self.lower_value(color)?,
3020 self.push_value(Value::Null),
3021 self.lower_value(reevaluation)?,
3022 self.lower_value(spectators)?,
3023 ];
3024 Ok(self.wir.actions.push(Action::Call {
3025 name: "createHudText".to_string(),
3026 args,
3027 span: self.wir_span(span)?,
3028 }))
3029 }
3030
3031 fn lower_hud_visible_to(&mut self, expr: &Expr) -> Result<wir::ValueId, IntegrationError> {
3032 if let Expr::Call { name, args, .. } = expr {
3033 if name == "getAllPlayers" && args.is_empty() {
3034 let all_teams = self.push_value(Value::Enum {
3035 value_type: "Team".to_string(),
3036 value: "ALL".to_string(),
3037 });
3038 return Ok(self.push_call("allPlayers", vec![all_teams]));
3039 }
3040 }
3041 self.lower_value(expr)
3042 }
3043
3044 fn lower_receiver_action_call(
3045 &mut self,
3046 receiver: &Expr,
3047 name: &str,
3048 args: &[Expr],
3049 span: Option<HirSpan>,
3050 ) -> Result<wir::ActionId, IntegrationError> {
3051 let function = self
3052 .compiler
3053 .manifest
3054 .resolve_member(name)
3055 .ok_or_else(|| self.unsupported(format!("unknown member action '{name}'"), span))?;
3056 if !matches!(function.kind, FunctionKind::MemberAction) {
3057 return Err(self.unsupported(format!("'{name}' is not a member action"), span));
3058 }
3059
3060 if function.id == "append" {
3063 let [value] = args else {
3064 return Err(self.unsupported("append requires exactly one argument", span));
3065 };
3066 let value = self.lower_value(value)?;
3067 return match receiver {
3068 Expr::GlobalVar {
3069 name,
3070 span: target_span,
3071 } => {
3072 let variable = *self.globals.get(name).ok_or_else(|| {
3073 self.unsupported(format!("unknown global variable '{name}'"), *target_span)
3074 })?;
3075 Ok(self.wir.actions.push(Action::ModifyGlobalVariable {
3076 variable,
3077 op: wir::ModifyOp::AppendToArray,
3078 value,
3079 span: self.wir_span(span)?,
3080 target_span: self.wir_span(*target_span)?,
3081 }))
3082 }
3083 Expr::PlayerVar {
3084 player,
3085 name,
3086 span: target_span,
3087 } => {
3088 let variable = *self.players.get(name).ok_or_else(|| {
3089 self.unsupported(format!("unknown player variable '{name}'"), *target_span)
3090 })?;
3091 let player = self.lower_value(player)?;
3092 Ok(self.wir.actions.push(Action::ModifyPlayerVariable {
3093 player,
3094 variable,
3095 op: wir::ModifyOp::AppendToArray,
3096 value,
3097 span: self.wir_span(span)?,
3098 target_span: self.wir_span(*target_span)?,
3099 }))
3100 }
3101 _ => Err(self.unsupported(
3102 "append requires a global or player variable receiver",
3103 receiver.span().copied().or(span),
3104 )),
3105 };
3106 }
3107
3108 let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
3109 self.unsupported(
3110 format!(
3111 "member action '{}' has no canonical catalog identity",
3112 function.id
3113 ),
3114 span,
3115 )
3116 })?;
3117 let mut lowered = Vec::with_capacity(args.len() + 1);
3118 lowered.push(self.lower_value(receiver)?);
3119 lowered.extend(
3120 args.iter()
3121 .map(|arg| self.lower_value(arg))
3122 .collect::<Result<Vec<_>, _>>()?,
3123 );
3124 Ok(self.wir.actions.push(Action::Call {
3125 name: catalog_id.clone(),
3126 args: lowered,
3127 span: self.wir_span(span)?,
3128 }))
3129 }
3130
3131 fn lower_value(&mut self, expr: &Expr) -> Result<wir::ValueId, IntegrationError> {
3132 let span = expr.span().copied();
3133 let value = match expr {
3134 Expr::Number { value, text, .. } => Value::Number {
3135 value: *value,
3136 text: canonical_number_text(*value, text),
3137 },
3138 Expr::String { value, .. } => Value::String(value.clone()),
3139 Expr::Bool { value, .. } => Value::Bool(*value),
3140 Expr::Null { .. } => Value::Null,
3141 Expr::GlobalVar { name, .. } => {
3142 let id = *self.globals.get(name).ok_or_else(|| {
3143 self.unsupported(format!("unknown global variable '{name}'"), span)
3144 })?;
3145 Value::GlobalVariable(id)
3146 }
3147 Expr::PlayerVar { player, name, .. } => {
3148 let player = self.lower_value(player)?;
3149 let id = *self.players.get(name).ok_or_else(|| {
3150 self.unsupported(format!("unknown player variable '{name}'"), span)
3151 })?;
3152 Value::PlayerVariable {
3153 player,
3154 variable: id,
3155 }
3156 }
3157 Expr::EventPlayer { .. } => Value::EventPlayer,
3158 Expr::Enum {
3159 value_type, value, ..
3160 } => {
3161 if self
3162 .compiler
3163 .catalog
3164 .enum_spelling(value_type, &Locale::new("en-US"), value)
3165 .is_none()
3166 {
3167 return Err(self.unsupported(
3168 format!("unknown catalog enum member '{value_type}.{value}'"),
3169 span,
3170 ));
3171 }
3172 Value::Enum {
3173 value_type: value_type.clone(),
3174 value: value.clone(),
3175 }
3176 }
3177 Expr::Array { elements, .. } => {
3178 let elements = elements
3179 .iter()
3180 .map(|element| self.lower_value(element))
3181 .collect::<Result<Vec<_>, _>>()?;
3182 return self.lower_array(elements, span);
3183 }
3184 Expr::Vector { x, y, z, .. } => Value::Call {
3185 name: "vector".to_string(),
3186 args: vec![
3187 self.lower_value(x)?,
3188 self.lower_value(y)?,
3189 self.lower_value(z)?,
3190 ],
3191 },
3192 Expr::Constant { name, .. } => {
3193 let const_expr = *self
3194 .constants
3195 .get(name)
3196 .ok_or_else(|| self.unsupported(format!("unknown constant '{name}'"), span))?;
3197 return self.lower_value(const_expr);
3198 }
3199 Expr::Index { array, index, .. } => {
3200 if matches!(index.as_ref(), Expr::Number { value, .. } if *value == 0.0) {
3204 Value::Call {
3205 name: "firstOf".to_string(),
3206 args: vec![self.lower_value(array)?],
3207 }
3208 } else {
3209 Value::Call {
3210 name: "valueInArray".to_string(),
3211 args: vec![self.lower_value(array)?, self.lower_value(index)?],
3212 }
3213 }
3214 }
3215 Expr::Format { text, args, .. } => {
3216 let text_node = self.wir.values.push(ValueNode::new(
3217 Value::String(canonical_format_text(text)),
3218 self.wir_span(span)?,
3219 ));
3220 let mut call_args = vec![text_node];
3221 for arg in args {
3222 call_args.push(self.lower_value(arg)?);
3223 }
3224 Value::Call {
3225 name: "customString".to_string(),
3226 args: call_args,
3227 }
3228 }
3229 Expr::Conditional {
3230 then_value,
3231 condition,
3232 else_value,
3233 ..
3234 } => Value::Call {
3235 name: "ifThenElse".to_string(),
3236 args: vec![
3237 self.lower_value(condition)?,
3238 self.lower_value(then_value)?,
3239 self.lower_value(else_value)?,
3240 ],
3241 },
3242 Expr::Binary {
3243 op, left, right, ..
3244 } => {
3245 let left = self.lower_value(left)?;
3246 let right = self.lower_value(right)?;
3247 if let Some(value) = self.fold_numeric_binary(op, left, right) {
3248 Value::Number {
3249 value,
3250 text: computed_number_text(value),
3251 }
3252 } else {
3253 match op.as_str() {
3254 "==" | "!=" | "<" | "<=" | ">" | ">=" => Value::Call {
3255 name: op.clone(),
3256 args: vec![left, right],
3257 },
3258 "+" => Value::Call {
3259 name: "add".to_string(),
3260 args: vec![left, right],
3261 },
3262 "-" => Value::Call {
3263 name: "subtract".to_string(),
3264 args: vec![left, right],
3265 },
3266 "*" => Value::Call {
3267 name: "multiply".to_string(),
3268 args: vec![left, right],
3269 },
3270 "/" => Value::Call {
3271 name: "divide".to_string(),
3272 args: vec![left, right],
3273 },
3274 "%" => Value::Call {
3275 name: "modulo".to_string(),
3276 args: vec![left, right],
3277 },
3278 "**" => Value::Call {
3279 name: "raiseToPower".to_string(),
3280 args: vec![left, right],
3281 },
3282 "and" => Value::Call {
3283 name: "and".to_string(),
3284 args: vec![left, right],
3285 },
3286 "or" => Value::Call {
3287 name: "or".to_string(),
3288 args: vec![left, right],
3289 },
3290 "in" => Value::Call {
3291 name: "arrayContains".to_string(),
3292 args: vec![right, left],
3293 },
3294 "not in" => {
3295 let wir_span = self.wir_span(span)?;
3296 let contains = self.wir.values.push(ValueNode::new(
3297 Value::Call {
3298 name: "arrayContains".to_string(),
3299 args: vec![right, left],
3300 },
3301 wir_span,
3302 ));
3303 Value::Call {
3304 name: "not".to_string(),
3305 args: vec![contains],
3306 }
3307 }
3308 _ => {
3309 return Err(self.unsupported(
3310 format!(
3311 "binary operator '{op}' is not currently representable in canonical WIR"
3312 ),
3313 span,
3314 ));
3315 }
3316 }
3317 }
3318 }
3319 Expr::Unary { op, operand, .. } => match op.as_str() {
3320 "not" => {
3321 if let Expr::Binary {
3326 op: comparison,
3327 left,
3328 right,
3329 ..
3330 } = operand.as_ref()
3331 {
3332 if let Some(negated) = negated_comparison(comparison) {
3333 Value::Call {
3334 name: negated.to_string(),
3335 args: vec![self.lower_value(left)?, self.lower_value(right)?],
3336 }
3337 } else {
3338 Value::Call {
3339 name: "not".to_string(),
3340 args: vec![self.lower_value(operand)?],
3341 }
3342 }
3343 } else {
3344 Value::Call {
3345 name: "not".to_string(),
3346 args: vec![self.lower_value(operand)?],
3347 }
3348 }
3349 }
3350 "-" => Value::Call {
3351 name: "-".to_string(),
3352 args: vec![self.lower_value(operand)?],
3353 },
3354 "+" => return self.lower_value(operand),
3355 _ => {
3356 return Err(self.unsupported(
3357 format!(
3358 "unary operator '{op}' is not currently representable in canonical WIR"
3359 ),
3360 span,
3361 ));
3362 }
3363 },
3364 Expr::Call { name, args, .. } => {
3365 if name == "vect" && args.len() == 3 {
3366 Value::Vector {
3367 x: self.lower_value(&args[0])?,
3368 y: self.lower_value(&args[1])?,
3369 z: self.lower_value(&args[2])?,
3370 }
3371 } else {
3372 let function = self
3373 .compiler
3374 .manifest
3375 .resolve_function(name)
3376 .ok_or_else(|| self.unsupported(format!("unknown value '{name}'"), span))?;
3377 if !matches!(function.kind, FunctionKind::Value) {
3378 return Err(
3379 self.unsupported(format!("'{name}' is not a generic OPY value"), span)
3380 );
3381 }
3382 let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
3383 self.unsupported(
3384 format!(
3385 "value '{}' requires a special lowering not in #46",
3386 function.id
3387 ),
3388 span,
3389 )
3390 })?;
3391 Value::Call {
3392 name: catalog_id.clone(),
3393 args: args
3394 .iter()
3395 .map(|arg| self.lower_value(arg))
3396 .collect::<Result<Vec<_>, _>>()?,
3397 }
3398 }
3399 }
3400 Expr::ReceiverCall {
3401 receiver,
3402 name,
3403 args,
3404 ..
3405 } => {
3406 let function = self.compiler.manifest.resolve_member(name).ok_or_else(|| {
3407 self.unsupported(format!("unknown member value '{name}'"), span)
3408 })?;
3409 if !matches!(function.kind, FunctionKind::MemberValue) {
3410 return Err(self.unsupported(format!("'{name}' is not a member value"), span));
3411 }
3412 let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
3413 self.unsupported(
3414 format!(
3415 "member value '{}' has no canonical catalog identity",
3416 function.id
3417 ),
3418 span,
3419 )
3420 })?;
3421 let mut lowered = Vec::with_capacity(args.len() + 1);
3422 lowered.push(self.lower_value(receiver)?);
3423 lowered.extend(
3424 args.iter()
3425 .map(|arg| self.lower_value(arg))
3426 .collect::<Result<Vec<_>, _>>()?,
3427 );
3428 Value::Call {
3429 name: catalog_id.clone(),
3430 args: lowered,
3431 }
3432 }
3433 Expr::Member {
3434 receiver, member, ..
3435 } => {
3436 let receiver = self.lower_value(receiver)?;
3437 let member = self.wir.values.push(ValueNode::new(
3438 Value::String(member.clone()),
3439 self.wir_span(span)?,
3440 ));
3441 Value::Call {
3442 name: "memberAccess".to_string(),
3443 args: vec![receiver, member],
3444 }
3445 }
3446 _ => {
3447 return Err(self.unsupported(
3448 format!(
3449 "expression '{}' is not currently representable in canonical WIR",
3450 expr.kind_name()
3451 ),
3452 span,
3453 ));
3454 }
3455 };
3456 Ok(self
3457 .wir
3458 .values
3459 .push(ValueNode::new(value, self.wir_span(span)?)))
3460 }
3461
3462 fn wir_span(&self, span: Option<HirSpan>) -> Result<Option<WorkshopSpan>, IntegrationError> {
3463 let Some(span) = span else {
3464 return Ok(None);
3465 };
3466 let file = *self.files.get(&span.file).ok_or_else(|| {
3467 IntegrationError::new(
3468 "source-file",
3469 format!("HIR span references unknown source file id {}", span.file),
3470 Some(span),
3471 )
3472 })?;
3473 Ok(Some(WorkshopSpan::new(
3474 file,
3475 WorkshopPosition::new(span.start.line, span.start.col),
3476 WorkshopPosition::new(span.end.line, span.end.col),
3477 )))
3478 }
3479
3480 fn hir_span_from_workshop(&self, span: WorkshopSpan) -> Option<HirSpan> {
3481 let file = *self.wir_to_hir_files.get(span.file.index())?;
3482 Some(HirSpan {
3483 file,
3484 start: hir::Position {
3485 line: span.start.line,
3486 col: span.start.col,
3487 },
3488 end: hir::Position {
3489 line: span.end.line,
3490 col: span.end.col,
3491 },
3492 })
3493 }
3494
3495 fn unsupported(&self, message: impl Into<String>, span: Option<HirSpan>) -> IntegrationError {
3496 IntegrationError::new("unsupported-integration-surface", message, span)
3497 }
3498}
3499
3500fn implicit_default_variables(
3504 hir: &hir::Program,
3505) -> (
3506 BTreeMap<String, Option<HirSpan>>,
3507 BTreeMap<String, Option<HirSpan>>,
3508) {
3509 let declared_globals = hir
3510 .declarations
3511 .iter()
3512 .filter_map(|declaration| match declaration {
3513 hir::Declaration::GlobalVariable { name, .. } => Some(name.as_str()),
3514 _ => None,
3515 })
3516 .collect::<HashSet<_>>();
3517 let declared_players = hir
3518 .declarations
3519 .iter()
3520 .filter_map(|declaration| match declaration {
3521 hir::Declaration::PlayerVariable { name, .. } => Some(name.as_str()),
3522 _ => None,
3523 })
3524 .collect::<HashSet<_>>();
3525 let mut globals = BTreeMap::new();
3526 let mut players = BTreeMap::new();
3527 for declaration in &hir.declarations {
3528 let initializer = match declaration {
3529 hir::Declaration::GlobalVariable { initializer, .. }
3530 | hir::Declaration::PlayerVariable { initializer, .. } => initializer.as_ref(),
3531 hir::Declaration::Constant { value, .. } => Some(value),
3532 _ => None,
3533 };
3534 if let Some(expr) = initializer {
3535 collect_implicit_expr(
3536 expr,
3537 &declared_globals,
3538 &declared_players,
3539 &mut globals,
3540 &mut players,
3541 );
3542 }
3543 }
3544 for entry in &hir.rules {
3545 match entry {
3546 RuleEntry::Rule(rule) => {
3547 for condition in &rule.conditions {
3548 collect_implicit_expr(
3549 condition,
3550 &declared_globals,
3551 &declared_players,
3552 &mut globals,
3553 &mut players,
3554 );
3555 }
3556 collect_implicit_stmts(
3557 &rule.actions,
3558 &declared_globals,
3559 &declared_players,
3560 &mut globals,
3561 &mut players,
3562 );
3563 }
3564 RuleEntry::SubroutineDef { body, .. } => collect_implicit_stmts(
3565 body,
3566 &declared_globals,
3567 &declared_players,
3568 &mut globals,
3569 &mut players,
3570 ),
3571 }
3572 }
3573 (globals, players)
3574}
3575
3576fn collect_implicit_stmts(
3577 statements: &[Stmt],
3578 declared_globals: &HashSet<&str>,
3579 declared_players: &HashSet<&str>,
3580 globals: &mut BTreeMap<String, Option<HirSpan>>,
3581 players: &mut BTreeMap<String, Option<HirSpan>>,
3582) {
3583 for statement in statements {
3584 match statement {
3585 Stmt::Expr { expr, .. } => {
3586 collect_implicit_expr(expr, declared_globals, declared_players, globals, players)
3587 }
3588 Stmt::Assign { target, value, .. } => {
3589 collect_implicit_expr(target, declared_globals, declared_players, globals, players);
3590 collect_implicit_expr(value, declared_globals, declared_players, globals, players);
3591 }
3592 Stmt::If {
3593 branches, r#else, ..
3594 } => {
3595 for branch in branches {
3596 collect_implicit_expr(
3597 &branch.condition,
3598 declared_globals,
3599 declared_players,
3600 globals,
3601 players,
3602 );
3603 collect_implicit_stmts(
3604 &branch.body,
3605 declared_globals,
3606 declared_players,
3607 globals,
3608 players,
3609 );
3610 }
3611 if let Some(default_body) = r#else {
3612 collect_implicit_stmts(
3613 default_body,
3614 declared_globals,
3615 declared_players,
3616 globals,
3617 players,
3618 );
3619 }
3620 }
3621 Stmt::For {
3622 variable,
3623 iterable,
3624 body,
3625 ..
3626 } => {
3627 collect_implicit_expr(
3628 variable,
3629 declared_globals,
3630 declared_players,
3631 globals,
3632 players,
3633 );
3634 collect_implicit_expr(
3635 iterable,
3636 declared_globals,
3637 declared_players,
3638 globals,
3639 players,
3640 );
3641 collect_implicit_stmts(body, declared_globals, declared_players, globals, players);
3642 }
3643 Stmt::While {
3644 condition, body, ..
3645 }
3646 | Stmt::DoWhile {
3647 condition, body, ..
3648 } => {
3649 collect_implicit_expr(
3650 condition,
3651 declared_globals,
3652 declared_players,
3653 globals,
3654 players,
3655 );
3656 collect_implicit_stmts(body, declared_globals, declared_players, globals, players);
3657 }
3658 Stmt::Switch { value, arms, .. } => {
3659 collect_implicit_expr(value, declared_globals, declared_players, globals, players);
3660 for arm in arms {
3661 match arm {
3662 SwitchArm::Case { value, body, .. } => {
3663 collect_implicit_expr(
3664 value,
3665 declared_globals,
3666 declared_players,
3667 globals,
3668 players,
3669 );
3670 collect_implicit_stmts(
3671 body,
3672 declared_globals,
3673 declared_players,
3674 globals,
3675 players,
3676 );
3677 }
3678 SwitchArm::Default { body, .. } => {
3679 collect_implicit_stmts(
3680 body,
3681 declared_globals,
3682 declared_players,
3683 globals,
3684 players,
3685 );
3686 }
3687 }
3688 }
3689 }
3690 Stmt::Break { .. } | Stmt::CallSubroutine { .. } | Stmt::Pass { .. } => {}
3691 }
3692 }
3693}
3694
3695fn collect_implicit_expr(
3696 expr: &Expr,
3697 declared_globals: &HashSet<&str>,
3698 declared_players: &HashSet<&str>,
3699 globals: &mut BTreeMap<String, Option<HirSpan>>,
3700 players: &mut BTreeMap<String, Option<HirSpan>>,
3701) {
3702 match expr {
3703 Expr::GlobalVar { name, span } => {
3704 if !declared_globals.contains(name.as_str()) && default_var_index(name).is_some() {
3705 globals.entry(name.clone()).or_insert(*span);
3706 }
3707 }
3708 Expr::Array { elements, .. } => {
3709 for element in elements {
3710 collect_implicit_expr(
3711 element,
3712 declared_globals,
3713 declared_players,
3714 globals,
3715 players,
3716 );
3717 }
3718 }
3719 Expr::Dict { entries, .. } => {
3720 for entry in entries {
3721 collect_implicit_expr(
3722 &entry.key,
3723 declared_globals,
3724 declared_players,
3725 globals,
3726 players,
3727 );
3728 collect_implicit_expr(
3729 &entry.value,
3730 declared_globals,
3731 declared_players,
3732 globals,
3733 players,
3734 );
3735 }
3736 }
3737 Expr::Comprehension {
3738 element,
3739 iterable,
3740 condition,
3741 ..
3742 } => {
3743 collect_implicit_expr(
3744 element,
3745 declared_globals,
3746 declared_players,
3747 globals,
3748 players,
3749 );
3750 collect_implicit_expr(
3751 iterable,
3752 declared_globals,
3753 declared_players,
3754 globals,
3755 players,
3756 );
3757 if let Some(condition) = condition {
3758 collect_implicit_expr(
3759 condition,
3760 declared_globals,
3761 declared_players,
3762 globals,
3763 players,
3764 );
3765 }
3766 }
3767 Expr::Lambda { body, .. } => {
3768 collect_implicit_expr(body, declared_globals, declared_players, globals, players)
3769 }
3770 Expr::Vector { x, y, z, .. } => {
3771 collect_implicit_expr(x, declared_globals, declared_players, globals, players);
3772 collect_implicit_expr(y, declared_globals, declared_players, globals, players);
3773 collect_implicit_expr(z, declared_globals, declared_players, globals, players);
3774 }
3775 Expr::PlayerVar { player, name, span } => {
3776 if matches!(player.as_ref(), Expr::EventPlayer { .. })
3777 && !declared_players.contains(name.as_str())
3778 && default_var_index(name).is_some()
3779 {
3780 players.entry(name.clone()).or_insert(*span);
3781 }
3782 collect_implicit_expr(player, declared_globals, declared_players, globals, players);
3783 }
3784 Expr::Member {
3785 receiver,
3786 member,
3787 span,
3788 ..
3789 } => {
3790 if !declared_players.contains(member.as_str()) && default_var_index(member).is_some() {
3791 players.entry(member.clone()).or_insert(*span);
3792 }
3793 collect_implicit_expr(
3794 receiver,
3795 declared_globals,
3796 declared_players,
3797 globals,
3798 players,
3799 );
3800 }
3801 Expr::Call { args, .. } | Expr::MacroCall { args, .. } => {
3802 for arg in args {
3803 collect_implicit_expr(arg, declared_globals, declared_players, globals, players);
3804 }
3805 }
3806 Expr::ReceiverCall { receiver, args, .. } => {
3807 collect_implicit_expr(
3808 receiver,
3809 declared_globals,
3810 declared_players,
3811 globals,
3812 players,
3813 );
3814 for arg in args {
3815 collect_implicit_expr(arg, declared_globals, declared_players, globals, players);
3816 }
3817 }
3818 Expr::Binary { left, right, .. } => {
3819 collect_implicit_expr(left, declared_globals, declared_players, globals, players);
3820 collect_implicit_expr(right, declared_globals, declared_players, globals, players);
3821 }
3822 Expr::Conditional {
3823 then_value,
3824 condition,
3825 else_value,
3826 ..
3827 } => {
3828 collect_implicit_expr(
3829 then_value,
3830 declared_globals,
3831 declared_players,
3832 globals,
3833 players,
3834 );
3835 collect_implicit_expr(
3836 condition,
3837 declared_globals,
3838 declared_players,
3839 globals,
3840 players,
3841 );
3842 collect_implicit_expr(
3843 else_value,
3844 declared_globals,
3845 declared_players,
3846 globals,
3847 players,
3848 );
3849 }
3850 Expr::Unary { operand, .. } => collect_implicit_expr(
3851 operand,
3852 declared_globals,
3853 declared_players,
3854 globals,
3855 players,
3856 ),
3857 Expr::Index { array, index, .. } => {
3858 collect_implicit_expr(array, declared_globals, declared_players, globals, players);
3859 collect_implicit_expr(index, declared_globals, declared_players, globals, players);
3860 }
3861 Expr::Format { args, .. } => {
3862 for arg in args {
3863 collect_implicit_expr(arg, declared_globals, declared_players, globals, players);
3864 }
3865 }
3866 Expr::Number { .. }
3867 | Expr::String { .. }
3868 | Expr::Bool { .. }
3869 | Expr::Null { .. }
3870 | Expr::StringModifier { .. }
3871 | Expr::Local { .. }
3872 | Expr::Enum { .. }
3873 | Expr::EventPlayer { .. }
3874 | Expr::Constant { .. }
3875 | Expr::MacroParam { .. } => {}
3876 }
3877}
3878
3879fn allocate_indices(
3880 entries: &[(Option<u32>, Option<HirSpan>)],
3881 pre_reserved: &HashSet<u32>,
3882 kind: &str,
3883) -> Result<Vec<u32>, IntegrationError> {
3884 let mut reserved = pre_reserved.clone();
3885 for (index, span) in entries {
3886 let Some(index) = index else {
3887 continue;
3888 };
3889 if !reserved.insert(*index) {
3890 return Err(IntegrationError::new(
3891 "index-collision",
3892 format!("duplicate explicit {kind} index {index}"),
3893 *span,
3894 ));
3895 }
3896 }
3897
3898 let mut next = 0;
3903 let mut allocated = Vec::with_capacity(entries.len());
3904 for (index, span) in entries {
3905 let assigned = if let Some(index) = index {
3906 *index
3907 } else {
3908 while reserved.contains(&next) {
3909 next = next.checked_add(1).ok_or_else(|| {
3910 IntegrationError::new(
3911 "index-exhausted",
3912 format!("no available {kind} index remains"),
3913 *span,
3914 )
3915 })?;
3916 }
3917 reserved.insert(next);
3918 let assigned = next;
3919 next = next.checked_add(1).ok_or_else(|| {
3920 IntegrationError::new(
3921 "index-exhausted",
3922 format!("no available {kind} index remains"),
3923 *span,
3924 )
3925 })?;
3926 assigned
3927 };
3928 allocated.push(assigned);
3929 }
3930 Ok(allocated)
3931}
3932
3933fn player_event_kind(name: &str) -> Option<PlayerEventKind> {
3934 Some(match name {
3935 "playerDealtDamage" => PlayerEventKind::DealtDamage,
3936 "playerDealtFinalBlow" => PlayerEventKind::DealtFinalBlow,
3937 "playerDealtHealing" => PlayerEventKind::DealtHealing,
3938 "playerDied" => PlayerEventKind::Died,
3939 "playerEarnedElimination" => PlayerEventKind::EarnedElimination,
3940 "playerJoined" => PlayerEventKind::Joined,
3941 "playerLeft" => PlayerEventKind::Left,
3942 "playerReceivedHealing" => PlayerEventKind::ReceivedHealing,
3943 "playerTookDamage" => PlayerEventKind::TookDamage,
3944 _ => return None,
3945 })
3946}
3947
3948fn is_zero_initializer(expr: &hir::Expr) -> bool {
3949 match expr {
3950 hir::Expr::Number { text, value, .. } => text == "0" && *value == 0.0,
3951 _ => false,
3952 }
3953}
3954
3955fn canonical_number_text(value: f64, text: &str) -> String {
3956 if text.starts_with("0x") || text.starts_with("0X") {
3957 value.to_string()
3958 } else {
3959 text.to_string()
3960 }
3961}
3962
3963fn computed_number_text(value: f64) -> String {
3964 if value.fract() == 0.0 {
3965 format!("{value:.0}")
3966 } else {
3967 value.to_string()
3968 }
3969}
3970
3971fn canonical_format_text(text: &str) -> String {
3972 let mut output = String::with_capacity(text.len());
3973 let mut chars = text.chars().peekable();
3974 let mut index = 0;
3975 while let Some(character) = chars.next() {
3976 if character == '{' && chars.peek() == Some(&'}') {
3977 chars.next();
3978 output.push('{');
3979 output.push_str(&index.to_string());
3980 output.push('}');
3981 index += 1;
3982 } else {
3983 output.push(character);
3984 }
3985 }
3986 output
3987}
3988
3989fn debug_expr_text(expr: &Expr) -> String {
3990 match expr {
3991 Expr::Number { text, .. } => text.clone(),
3992 Expr::String { value, .. } => {
3993 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
3994 }
3995 Expr::Bool { value, .. } => value.to_string(),
3996 Expr::Null { .. } => "null".to_string(),
3997 Expr::Array { elements, .. } => format!(
3998 "[{}]",
3999 elements
4000 .iter()
4001 .map(debug_expr_text)
4002 .collect::<Vec<_>>()
4003 .join(", ")
4004 ),
4005 Expr::Dict { entries, .. } => format!(
4006 "{{{}}}",
4007 entries
4008 .iter()
4009 .map(|entry| format!(
4010 "{}: {}",
4011 debug_expr_text(&entry.key),
4012 debug_expr_text(&entry.value)
4013 ))
4014 .collect::<Vec<_>>()
4015 .join(", ")
4016 ),
4017 Expr::Comprehension {
4018 element,
4019 variable,
4020 iterable,
4021 condition,
4022 ..
4023 } => {
4024 let condition = condition
4025 .as_deref()
4026 .map(|condition| format!(" if {}", debug_expr_text(condition)))
4027 .unwrap_or_default();
4028 format!(
4029 "[{} for {} in {}{}]",
4030 debug_expr_text(element),
4031 variable,
4032 debug_expr_text(iterable),
4033 condition
4034 )
4035 }
4036 Expr::Lambda { params, body, .. } => {
4037 format!("lambda {}: {}", params.join(", "), debug_expr_text(body))
4038 }
4039 Expr::StringModifier {
4040 modifier, value, ..
4041 } => format!("{}\"{}\"", modifier, value),
4042 Expr::Local { name, .. }
4043 | Expr::GlobalVar { name, .. }
4044 | Expr::Constant { name, .. }
4045 | Expr::MacroParam { name, .. } => name.clone(),
4046 Expr::Vector { x, y, z, .. } => format!(
4047 "vect({}, {}, {})",
4048 debug_expr_text(x),
4049 debug_expr_text(y),
4050 debug_expr_text(z)
4051 ),
4052 Expr::Enum {
4053 value_type, value, ..
4054 } => format!("{}.{}", value_type, value),
4055 Expr::PlayerVar { player, name, .. } => {
4056 format!("{}.{}", debug_expr_text(player), name)
4057 }
4058 Expr::Member {
4059 receiver, member, ..
4060 } => format!("{}.{}", debug_expr_text(receiver), member),
4061 Expr::EventPlayer { .. } => "eventPlayer".to_string(),
4062 Expr::Call { name, args, .. } | Expr::MacroCall { name, args, .. } => format!(
4063 "{}({})",
4064 name,
4065 args.iter()
4066 .map(debug_expr_text)
4067 .collect::<Vec<_>>()
4068 .join(", ")
4069 ),
4070 Expr::ReceiverCall {
4071 receiver,
4072 name,
4073 args,
4074 ..
4075 } => format!(
4076 "{}.{}({})",
4077 debug_expr_text(receiver),
4078 name,
4079 args.iter()
4080 .map(debug_expr_text)
4081 .collect::<Vec<_>>()
4082 .join(", ")
4083 ),
4084 Expr::Binary {
4085 left, op, right, ..
4086 } => format!(
4087 "{} {} {}",
4088 debug_expr_text(left),
4089 op,
4090 debug_expr_text(right)
4091 ),
4092 Expr::Conditional {
4093 then_value,
4094 condition,
4095 else_value,
4096 ..
4097 } => format!(
4098 "{} if {} else {}",
4099 debug_expr_text(then_value),
4100 debug_expr_text(condition),
4101 debug_expr_text(else_value)
4102 ),
4103 Expr::Unary { op, operand, .. } => format!("{} {}", op, debug_expr_text(operand)),
4104 Expr::Index { array, index, .. } => {
4105 format!("{}[{}]", debug_expr_text(array), debug_expr_text(index))
4106 }
4107 Expr::Format { text, args, .. } => format!(
4108 "\"{}\".format({})",
4109 text,
4110 args.iter()
4111 .map(debug_expr_text)
4112 .collect::<Vec<_>>()
4113 .join(", ")
4114 ),
4115 }
4116}
4117
4118fn canonical_debug_text(text: &str) -> String {
4119 text.chars()
4120 .map(|character| match character {
4121 'a' => 'ạ',
4122 'b' => 'ḅ',
4123 'c' => 'ƈ',
4124 'd' => 'ḍ',
4125 'e' => 'ẹ',
4126 'f' => 'ƒ',
4127 'g' => 'ǥ',
4128 'h' => 'һ',
4129 'i' => 'і',
4130 'j' => 'ј',
4131 'k' => 'ḳ',
4132 'l' => 'I',
4133 'm' => 'ṃ',
4134 'n' => 'ṇ',
4135 'o' => 'ο',
4136 'p' => 'ṗ',
4137 'q' => 'ǫ',
4138 'r' => 'ṛ',
4139 's' => 'ѕ',
4140 't' => 'ṭ',
4141 'u' => 'υ',
4142 'v' => 'ν',
4143 'w' => 'ẉ',
4144 'x' => 'ҳ',
4145 'y' => 'ỵ',
4146 'z' => 'ẓ',
4147 _ => character,
4148 })
4149 .collect()
4150}
4151
4152fn negated_comparison(op: &str) -> Option<&'static str> {
4153 Some(match op {
4154 "==" => "!=",
4155 "!=" => "==",
4156 "<" => ">=",
4157 ">" => "<=",
4158 "<=" => ">",
4159 ">=" => "<",
4160 _ => return None,
4161 })
4162}
4163
4164fn modify_op_from_str(op: &str) -> Option<wir::ModifyOp> {
4165 match op {
4166 "+" => Some(wir::ModifyOp::Add),
4167 "-" => Some(wir::ModifyOp::Subtract),
4168 "*" => Some(wir::ModifyOp::Multiply),
4169 "/" => Some(wir::ModifyOp::Divide),
4170 "%" => Some(wir::ModifyOp::Modulo),
4171 "**" => Some(wir::ModifyOp::RaiseToPower),
4172 _ => None,
4173 }
4174}
4175
4176fn modify_catalog_name_from_str(op: &str) -> Option<&'static str> {
4177 match op {
4178 "+" => Some("add"),
4179 "-" => Some("subtract"),
4180 "*" => Some("multiply"),
4181 "/" => Some("divide"),
4182 "%" => Some("modulo"),
4183 "**" => Some("raiseToPower"),
4184 _ => None,
4185 }
4186}
4187
4188fn hir_span_from_diag(span: crate::diag::Span) -> HirSpan {
4189 HirSpan {
4190 file: span.file,
4191 start: hir::Position {
4192 line: span.start.line,
4193 col: span.start.col,
4194 },
4195 end: hir::Position {
4196 line: span.end.line,
4197 col: span.end.col,
4198 },
4199 }
4200}
4201
4202fn workshop_error_span(error: &workshop_rs::WorkshopError) -> Option<WorkshopSpan> {
4203 match error {
4204 workshop_rs::WorkshopError::Unknown { span, .. }
4205 | workshop_rs::WorkshopError::Malformed { span, .. }
4206 | workshop_rs::WorkshopError::Unsupported { span, .. } => *span,
4207 workshop_rs::WorkshopError::Catalog(_)
4208 | workshop_rs::WorkshopError::MissingMapping { .. } => None,
4209 }
4210}
4211
4212#[cfg(test)]
4213mod tests {
4214 use super::{
4215 COMPILE_SCHEMA_VERSION, CompileFailureClass, CompileStatus, Compiler, WORKSHOP_RS_VERSION,
4216 cross_check_manifest,
4217 };
4218 use crate::manifest::Manifest;
4219 use std::path::Path;
4220 use workshop_rs::catalog::{Catalog, Locale};
4221
4222 #[test]
4223 fn public_contract_is_pinned_and_manifest_links_are_checked() {
4224 let compiler = Compiler::new().expect("released workshop contract must load");
4225 let identity = compiler.catalog_identity();
4226 assert_eq!(identity.implementation_version, WORKSHOP_RS_VERSION);
4227 assert!(compiler.link_report().catalog_ids_checked > 0);
4228 assert!(compiler.link_report().domains_checked > 0);
4229 }
4230
4231 #[test]
4232 fn compile_report_is_versioned_and_contains_reproducibility_identity() {
4233 let compiler = Compiler::new().unwrap();
4234 let report = compiler.compile_source_report_with_locale(
4235 "rule \"report\":\n @Event global\n disableInspector()\n",
4236 "report.opy",
4237 Path::new("."),
4238 &Locale::new("en-US"),
4239 );
4240 assert_eq!(report.schema_version, COMPILE_SCHEMA_VERSION);
4241 assert_eq!(report.compiler.name, "opy-rs");
4242 assert_eq!(report.catalog.implementation_version, WORKSHOP_RS_VERSION);
4243 assert_eq!(report.compile.status, CompileStatus::Success);
4244 assert_eq!(report.compile.exit_code, 0);
4245 assert!(report.compile.diagnostics.is_empty());
4246 assert_eq!(
4247 report.compile.workshop,
4248 report
4249 .compile
4250 .workshop_exact
4251 .trim_end_matches('\n')
4252 .to_owned()
4253 + "\n"
4254 );
4255 assert!(serde_json::to_value(report).unwrap()["catalog"]["catalog-version"].is_string());
4256 }
4257
4258 #[test]
4259 fn compile_report_preserves_frontend_failure_class_and_source_path() {
4260 let compiler = Compiler::new().unwrap();
4261 let report = compiler.compile_source_report_with_locale(
4262 "rule \"broken\":\n @Event global\n missing()\n",
4263 "broken.opy",
4264 Path::new("."),
4265 &Locale::new("en-US"),
4266 );
4267 assert_eq!(report.compile.status, CompileStatus::Failure);
4268 assert_eq!(
4269 report.compile.failure_class,
4270 Some(CompileFailureClass::Frontend)
4271 );
4272 assert_eq!(report.compile.exit_code, 1);
4273 let diagnostic = &report.compile.diagnostics[0];
4274 assert_eq!(diagnostic.code, "unknown-action");
4275 assert_eq!(diagnostic.span.as_ref().unwrap().path, "broken.opy");
4276 }
4277
4278 #[test]
4279 fn compile_report_preserves_integration_failure_class_and_source_path() {
4280 let compiler = Compiler::new().unwrap();
4281 let report = compiler.compile_source_report_with_locale(
4282 "globalvar A\nrule \"broken\":\n @Event global\n A[1][2] = 3\n",
4283 "broken.opy",
4284 Path::new("."),
4285 &Locale::new("en-US"),
4286 );
4287 assert_eq!(report.compile.status, CompileStatus::Failure);
4288 assert_eq!(
4289 report.compile.failure_class,
4290 Some(CompileFailureClass::Integration)
4291 );
4292 assert_eq!(
4293 report.compile.diagnostics[0].span.as_ref().unwrap().path,
4294 "broken.opy"
4295 );
4296 }
4297
4298 #[test]
4299 fn vertical_slice_preserves_source_files_spans_and_emits_workshop() {
4300 let compiler = Compiler::new().unwrap();
4301 let hir = crate::compile(
4302 "globalvar A\nrule \"issue 35 integration\":\n @Event global\n A = 1\n disableInspector()\n",
4303 "issue-35-integration.opy",
4304 Path::new("."),
4305 )
4306 .unwrap();
4307 let artifact = compiler.compile_hir(&hir).unwrap();
4308 assert_eq!(
4309 artifact
4310 .wir
4311 .files
4312 .get(workshop_rs::source::FileId::from_index(0))
4313 .unwrap()
4314 .path,
4315 "issue-35-integration.opy"
4316 );
4317 let rule = artifact
4318 .wir
4319 .rules
4320 .get(workshop_rs::wir::RuleId::from_index(0))
4321 .unwrap();
4322 assert_eq!(rule.span.unwrap().file.index(), 0);
4323 assert_eq!(rule.name_span.unwrap().start.line, 2);
4324 assert!(artifact.emitted.contains("Disable Inspector Recording;"));
4325 assert_eq!(artifact.catalog_identity.implementation_version, "0.1.16");
4326 }
4327
4328 #[test]
4329 fn stale_catalog_links_fail_explicitly() {
4330 let manifest = Manifest::builtin().unwrap().clone();
4331 let mut stale = manifest;
4332 stale.functions[0].catalog_id = Some("missing-catalog-id".to_string());
4333 let error = cross_check_manifest(&stale, &Catalog::builtin().unwrap()).unwrap_err();
4334 assert_eq!(error.diagnostic.code, "catalog-link-missing");
4335 }
4336
4337 #[test]
4338 fn while_lowering_is_source_attributed() {
4339 let compiler = Compiler::new().unwrap();
4340 let hir = crate::compile(
4341 "rule \"while\":\n @Event global\n while true:\n disableInspector()\n",
4342 "while.opy",
4343 Path::new("."),
4344 )
4345 .unwrap();
4346 let artifact = compiler.compile_hir(&hir).unwrap();
4347 let rule = artifact
4348 .wir
4349 .rules
4350 .get(workshop_rs::wir::RuleId::from_index(0))
4351 .unwrap();
4352 assert!(matches!(
4353 artifact.wir.actions.get(rule.actions[0]),
4354 Some(workshop_rs::wir::Action::While { .. })
4355 ));
4356 assert!(artifact.emitted.contains("While(True);"));
4357 }
4358
4359 #[test]
4360 fn structural_subroutines_lower_to_canonical_wir() {
4361 let compiler = Compiler::new().unwrap();
4362 let hir = crate::compile(
4363 "globalvar score\nsubroutine showStatus\ndef showStatus():\n @Name \"Friendly\"\n @SuppressWarnings unusedVariable\n disableInspector()\nrule \"caller\":\n @Event global\n showStatus()\n",
4364 "structure.opy",
4365 Path::new("."),
4366 )
4367 .unwrap();
4368 let artifact = compiler.compile_hir(&hir).unwrap();
4369 let subroutine = artifact
4370 .wir
4371 .subroutines
4372 .get(workshop_rs::wir::SubroutineId::from_index(0))
4373 .unwrap();
4374 assert_eq!(subroutine.name, "showStatus");
4375 assert_eq!(subroutine.index, 0);
4376 assert_eq!(subroutine.name_span.unwrap().start.line, 2);
4377 assert_eq!(artifact.wir.rules.len(), 2);
4378 let subroutine_rule = artifact
4379 .wir
4380 .rules
4381 .get(workshop_rs::wir::RuleId::from_index(0))
4382 .unwrap();
4383 let workshop_rs::wir::Event::Subroutine(subroutine_id) = subroutine_rule.event else {
4384 panic!("expected a subroutine event");
4385 };
4386 assert_eq!(
4387 artifact.wir.subroutines.get(subroutine_id).unwrap().name,
4388 "showStatus"
4389 );
4390 assert!(matches!(
4391 artifact
4392 .wir
4393 .actions
4394 .get(workshop_rs::wir::ActionId::from_index(1))
4395 .unwrap(),
4396 workshop_rs::wir::Action::CallSubroutine { .. }
4397 ));
4398 assert!(artifact.emitted.contains("Subroutine Friendly"));
4399 }
4400
4401 #[test]
4402 fn player_event_filters_resolve_through_canonical_catalog() {
4403 let compiler = Compiler::new().unwrap();
4404 let hir = crate::compile(
4405 "rule \"joined\":\n @Event playerJoined\n @Team 1\n @Slot 2\n disableInspector()\n",
4406 "filters.opy",
4407 Path::new("."),
4408 )
4409 .unwrap();
4410 let artifact = compiler.compile_hir(&hir).unwrap();
4411 assert!(matches!(
4412 &artifact
4413 .wir
4414 .rules
4415 .get(workshop_rs::wir::RuleId::from_index(0))
4416 .unwrap()
4417 .event,
4418 workshop_rs::wir::Event::Player {
4419 kind: workshop_rs::wir::PlayerEventKind::Joined,
4420 team: workshop_rs::wir::EventTeam::Team1,
4421 target: workshop_rs::wir::EventTarget::Slot(2),
4422 }
4423 ));
4424 assert!(artifact.emitted.contains("Player Joined Match;"));
4425 }
4426
4427 #[test]
4428 fn explicit_indices_are_reserved_before_deterministic_allocation() {
4429 let compiler = Compiler::new().unwrap();
4430 let hir = crate::compile(
4431 "globalvar first\nglobalvar reserved 0\nglobalvar next\nrule \"indices\":\n @Event global\n disableInspector()\n",
4432 "indices.opy",
4433 Path::new("."),
4434 )
4435 .unwrap();
4436 let artifact = compiler.compile_hir(&hir).unwrap();
4437 let by_name = artifact
4438 .wir
4439 .global_variables
4440 .iter()
4441 .map(|variable| (variable.name.as_str(), variable.index))
4442 .collect::<std::collections::BTreeMap<_, _>>();
4443 assert_eq!(
4444 by_name,
4445 std::collections::BTreeMap::from([("first", 1), ("reserved", 0), ("next", 2)])
4446 );
4447 let indices = artifact
4449 .wir
4450 .global_variables
4451 .iter()
4452 .map(|variable| variable.index)
4453 .collect::<Vec<_>>();
4454 assert_eq!(indices, vec![0, 1, 2]);
4455 }
4456
4457 #[test]
4458 fn implicit_default_variables_use_reference_fixed_slots() {
4459 let compiler = Compiler::new().unwrap();
4460 let hir = crate::compile(
4461 r#"
4462globalvar timer
4463globalvar extra 5
4464
4465rule "implicit":
4466 @Event global
4467 A = timer + 1
4468 B = A
4469 B += 2
4470 A[0] = 7
4471 DX = B * A
4472"#,
4473 "implicit.opy",
4474 Path::new("."),
4475 )
4476 .unwrap();
4477 let artifact = compiler.compile_hir(&hir).unwrap();
4478 let globals = artifact
4479 .wir
4480 .global_variables
4481 .iter()
4482 .map(|variable| (variable.name.clone(), variable.index))
4483 .collect::<Vec<_>>();
4484 assert_eq!(
4489 globals,
4490 vec![
4491 ("A".to_string(), 0),
4492 ("B".to_string(), 1),
4493 ("timer".to_string(), 2),
4494 ("extra".to_string(), 5),
4495 ("DX".to_string(), 127),
4496 ]
4497 );
4498 assert!(
4499 artifact
4500 .emitted
4501 .contains("Set Global Variable(A, Add(Global.timer, 1));")
4502 );
4503 assert!(
4504 artifact
4505 .emitted
4506 .contains("Set Global Variable(B, Global.A);")
4507 );
4508 assert!(
4509 artifact
4510 .emitted
4511 .contains("Modify Global Variable(B, Add, 2);")
4512 );
4513 assert!(
4514 artifact
4515 .emitted
4516 .contains("Set Global Variable At Index(A, 0, 7);")
4517 );
4518 assert!(
4519 artifact
4520 .emitted
4521 .contains("Set Global Variable(DX, Multiply(Global.B, Global.A));")
4522 );
4523 }
4524
4525 #[test]
4526 fn implicit_default_variable_slot_collision_is_source_attributed() {
4527 let compiler = Compiler::new().unwrap();
4528 let hir = crate::compile(
4529 "globalvar x 0\nrule \"collision\":\n @Event global\n x = 1\n A = 2\n",
4530 "collision.opy",
4531 Path::new("."),
4532 )
4533 .unwrap();
4534 let error = match compiler.compile_hir(&hir) {
4535 Ok(_) => panic!("slot collision unexpectedly succeeded"),
4536 Err(error) => error,
4537 };
4538 assert_eq!(error.diagnostic.code, "index-collision");
4539 assert_eq!(error.diagnostic.span.unwrap().start.line, 5);
4540 assert!(error.diagnostic.message.contains("'A' and 'x'"));
4541 }
4542
4543 #[test]
4544 fn implicit_default_player_variables_use_independent_reference_slots() {
4545 let compiler = Compiler::new().unwrap();
4546 let hir = crate::compile(
4547 r#"
4548playervar declaredPlayer
4549
4550rule "implicit player variables":
4551 @Event eachPlayer
4552 A = 1
4553 eventPlayer.A = 1
4554 eventPlayer.A += 2
4555 eventPlayer.E = eventPlayer.A
4556 eventPlayer.DX = eventPlayer.E
4557 eventPlayer.declaredPlayer = eventPlayer.A
4558"#,
4559 "implicit-player.opy",
4560 Path::new("."),
4561 )
4562 .unwrap();
4563 let artifact = compiler.compile_hir(&hir).unwrap();
4564 let globals = artifact
4565 .wir
4566 .global_variables
4567 .iter()
4568 .map(|variable| (variable.name.as_str(), variable.index))
4569 .collect::<std::collections::BTreeMap<_, _>>();
4570 let players = artifact
4571 .wir
4572 .player_variables
4573 .iter()
4574 .map(|variable| (variable.name.as_str(), variable.index))
4575 .collect::<std::collections::BTreeMap<_, _>>();
4576 assert_eq!(globals.get("A"), Some(&0));
4577 assert_eq!(players.get("A"), Some(&0));
4578 assert_eq!(players.get("declaredPlayer"), Some(&1));
4579 assert_eq!(players.get("E"), Some(&4));
4580 assert_eq!(players.get("DX"), Some(&127));
4581 assert!(
4582 artifact
4583 .emitted
4584 .contains("Set Player Variable(Event Player, A, 1);")
4585 );
4586 assert!(
4587 artifact
4588 .emitted
4589 .contains("Modify Player Variable(Event Player, A, Add, 2);")
4590 );
4591 assert!(
4592 artifact
4593 .emitted
4594 .contains("Set Player Variable(Event Player, E, (Event Player).A);")
4595 );
4596 }
4597
4598 #[test]
4599 fn implicit_default_player_slot_collision_is_source_attributed() {
4600 let compiler = Compiler::new().unwrap();
4601 let hir = crate::compile(
4602 "playervar declared 0\nrule \"collision\":\n @Event eachPlayer\n eventPlayer.A = 1\n",
4603 "player-collision.opy",
4604 Path::new("."),
4605 )
4606 .unwrap();
4607 let error = match compiler.compile_hir(&hir) {
4608 Ok(_) => panic!("player slot collision unexpectedly succeeded"),
4609 Err(error) => error,
4610 };
4611 assert_eq!(error.diagnostic.code, "index-collision");
4612 assert!(
4613 error
4614 .diagnostic
4615 .message
4616 .contains("player variables 'A' and 'declared'")
4617 );
4618 assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
4619 }
4620
4621 #[test]
4622 fn power_augmented_assignment_lowers_from_source() {
4623 let compiler = Compiler::new().unwrap();
4624 let hir = crate::compile(
4625 "globalvar g\nrule \"power\":\n @Event global\n g = 2\n g **= 3\n",
4626 "power.opy",
4627 Path::new("."),
4628 )
4629 .unwrap();
4630 let artifact = compiler.compile_hir(&hir).unwrap();
4631 assert!(artifact.emitted.contains("Set Global Variable(g, 2);"));
4632 assert!(
4633 artifact
4634 .emitted
4635 .contains("Modify Global Variable(g, Raise To Power, 3);")
4636 );
4637 }
4638
4639 #[test]
4640 fn opy_hex_numbers_are_normalized_at_the_wir_boundary() {
4641 let compiler = Compiler::new().unwrap();
4642 let hir = crate::compile(
4643 "globalvar large = 0x124BC\nglobalvar small = 0x124\nglobalvar scientific = 1e10\n",
4644 "numbers.opy",
4645 Path::new("."),
4646 )
4647 .unwrap();
4648 let artifact = compiler.compile_hir(&hir).unwrap();
4649 assert!(
4650 artifact
4651 .emitted
4652 .contains("Set Global Variable(large, 74940);")
4653 );
4654 assert!(
4655 artifact
4656 .emitted
4657 .contains("Set Global Variable(small, 292);")
4658 );
4659 assert!(
4660 artifact
4661 .emitted
4662 .contains("Set Global Variable(scientific, 1e10);")
4663 );
4664 assert!(!artifact.emitted.contains("0x124BC"));
4665 assert!(!artifact.emitted.contains("0x124"));
4666 }
4667
4668 #[test]
4669 fn unsupported_primitive_lowering_is_stable_and_source_attributed() {
4670 let compiler = Compiler::new().unwrap();
4671 let hir = crate::compile(
4672 "globalvar total\nrule \"negative\":\n @Event global\n total = {\"a\": 1, \"b\": 2}[\"a\"]\n",
4673 "negative.opy",
4674 Path::new("."),
4675 )
4676 .unwrap();
4677 let error = match compiler.compile_hir(&hir) {
4678 Ok(_) => panic!("dict primitive lowering unexpectedly succeeded"),
4679 Err(error) => error,
4680 };
4681 assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
4682 assert!(error.diagnostic.message.contains("dict"));
4683 assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
4684 }
4685
4686 #[test]
4687 fn auto_allocation_fills_free_slots_below_early_explicit_indices() {
4688 let compiler = Compiler::new().unwrap();
4689 let hir = crate::compile(
4690 r#"
4691globalvar reserved 5
4692globalvar auto1
4693globalvar auto2
4694
4695rule "allocation":
4696 @Event global
4697 auto1 = 1
4698 auto2 = 2
4699 B = 3
4700"#,
4701 "allocation.opy",
4702 Path::new("."),
4703 )
4704 .unwrap();
4705 let artifact = compiler.compile_hir(&hir).unwrap();
4706 let by_name = artifact
4707 .wir
4708 .global_variables
4709 .iter()
4710 .map(|variable| (variable.name.clone(), variable.index))
4711 .collect::<std::collections::BTreeMap<_, _>>();
4712 assert_eq!(
4717 by_name,
4718 std::collections::BTreeMap::from([
4719 ("B".to_string(), 1),
4720 ("auto1".to_string(), 0),
4721 ("auto2".to_string(), 2),
4722 ("reserved".to_string(), 5),
4723 ])
4724 );
4725 }
4726
4727 #[test]
4728 fn power_expressions_lower_through_the_canonical_contract() {
4729 let compiler = Compiler::new().unwrap();
4730 let hir = crate::compile(
4731 "globalvar a = [2, 4]\nglobalvar out\nrule \"power\":\n @Event global\n out = a ** 2\n a **= 2\n a[0] **= 2\n",
4732 "power.opy",
4733 Path::new("."),
4734 )
4735 .unwrap();
4736 let artifact = compiler.compile_hir(&hir).unwrap();
4737 assert!(
4738 artifact
4739 .emitted
4740 .contains("Set Global Variable(out, Raise To Power(Global.a, 2));")
4741 );
4742 assert!(
4743 artifact
4744 .emitted
4745 .contains("Modify Global Variable(a, Raise To Power, 2);")
4746 );
4747 assert!(
4748 artifact
4749 .emitted
4750 .contains("Modify Global Variable At Index(a, 0, Raise To Power, 2);")
4751 );
4752 }
4753
4754 #[test]
4755 fn unsupported_rule_metadata_is_explicit_and_source_attributed() {
4756 let compiler = Compiler::new().unwrap();
4757 let hir = crate::compile(
4758 "rule \"metadata\":\n @Event global\n @NewPage \"section\"\n disableInspector()\n",
4759 "metadata.opy",
4760 Path::new("."),
4761 )
4762 .unwrap();
4763 let error = match compiler.compile_hir(&hir) {
4764 Ok(_) => panic!("unsupported metadata unexpectedly succeeded"),
4765 Err(error) => error,
4766 };
4767 assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
4768 assert_eq!(error.diagnostic.span.unwrap().start.line, 3);
4769 }
4770
4771 #[test]
4772 fn issue_40_oracle_fixture_and_wir_lowering_agree() {
4773 let compiler = Compiler::new().unwrap();
4774 let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
4775 .join("../../compatibility/fixtures/synthetic/issue-40-structural");
4776 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
4777 let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
4778 let artifact = compiler.compile_hir(&hir).unwrap();
4779 let oracle: serde_json::Value =
4780 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
4781 .unwrap();
4782 let oracle_workshop = oracle["compile"]["workshop"].as_str().unwrap();
4783 let oracle_wir = workshop_rs::parser::parse(
4784 oracle_workshop,
4785 &Catalog::builtin().unwrap(),
4786 &Locale::new("en-US"),
4787 )
4788 .unwrap();
4789 assert!(workshop_rs::roundtrip::equivalent(
4790 &artifact.wir,
4791 &oracle_wir
4792 ));
4793
4794 assert!(oracle_workshop.contains("0: reserved"));
4795 assert!(oracle_workshop.contains("1: first"));
4796 assert!(oracle_workshop.contains("2: explicit"));
4797 assert!(oracle_workshop.contains("3: next"));
4798 assert!(oracle_workshop.contains("0: helper"));
4799 assert!(oracle_workshop.contains("Subroutine;\n helper;"));
4800 assert!(oracle_workshop.contains("Player Joined Match;\n Team 1;\n Slot 2;"));
4801
4802 let indices = artifact
4803 .wir
4804 .global_variables
4805 .iter()
4806 .map(|variable| variable.index)
4807 .collect::<Vec<_>>();
4808 assert_eq!(indices, vec![0, 1, 2, 3]);
4809 assert_eq!(
4810 artifact.wir.subroutines.iter().next().unwrap().name,
4811 "helper"
4812 );
4813 assert!(artifact.emitted.contains("[Source] renamed helper"));
4814 assert!(matches!(
4815 artifact
4816 .wir
4817 .rules
4818 .get(workshop_rs::wir::RuleId::from_index(1))
4819 .unwrap()
4820 .event,
4821 workshop_rs::wir::Event::Player {
4822 kind: workshop_rs::wir::PlayerEventKind::Joined,
4823 team: workshop_rs::wir::EventTeam::Team1,
4824 target: workshop_rs::wir::EventTarget::Slot(2),
4825 }
4826 ));
4827 }
4828
4829 #[test]
4830 fn assignments_and_modifications_lower_to_canonical_wir() {
4831 let compiler = Compiler::new().unwrap();
4832 let hir = crate::compile(
4833 r#"
4834globalvar g1
4835globalvar g2
4836playervar p1
4837playervar p2 = [1, 2, 3]
4838
4839rule "assignments":
4840 @Event eachPlayer
4841 g1 = 10
4842 g1 += 5
4843 g1 -= 2
4844 g1 *= 3
4845 g1 /= 2
4846 g1 %= 4
4847 g2 = [1, 2, 3]
4848 g2[0] = 99
4849 g2[1] += 1
4850 eventPlayer.p1 = 42
4851 eventPlayer.p1 += 8
4852 eventPlayer.p1 *= 2
4853 eventPlayer.p2[2] = 7
4854 eventPlayer.p2[0] -= 3
4855"#,
4856 "assign.opy",
4857 Path::new("."),
4858 )
4859 .unwrap();
4860 let artifact = compiler.compile_hir(&hir).unwrap();
4861 assert!(artifact.emitted.contains("Set Global Variable(g1, 10);"));
4862 assert!(
4863 artifact
4864 .emitted
4865 .contains("Modify Global Variable(g1, Add, 5);")
4866 );
4867 assert!(
4868 artifact
4869 .emitted
4870 .contains("Modify Global Variable(g1, Subtract, 2);")
4871 );
4872 assert!(
4873 artifact
4874 .emitted
4875 .contains("Modify Global Variable(g1, Multiply, 3);")
4876 );
4877 assert!(
4878 artifact
4879 .emitted
4880 .contains("Modify Global Variable(g1, Divide, 2);")
4881 );
4882 assert!(
4883 artifact
4884 .emitted
4885 .contains("Modify Global Variable(g1, Modulo, 4);")
4886 );
4887 assert!(
4888 artifact
4889 .emitted
4890 .contains("Set Global Variable At Index(g2, 0, 99);")
4891 );
4892 assert!(
4893 artifact
4894 .emitted
4895 .contains("Modify Global Variable At Index(g2, 1, Add, 1);")
4896 );
4897 assert!(
4898 artifact
4899 .emitted
4900 .contains("Set Player Variable(Event Player, p1, 42);")
4901 );
4902 assert!(
4903 artifact
4904 .emitted
4905 .contains("Modify Player Variable(Event Player, p1, Add, 8);")
4906 );
4907 assert!(
4908 artifact
4909 .emitted
4910 .contains("Modify Player Variable(Event Player, p1, Multiply, 2);")
4911 );
4912 assert!(
4913 artifact
4914 .emitted
4915 .contains("Set Player Variable At Index((Event Player).p2, 2, 7);")
4916 );
4917 assert!(
4918 artifact
4919 .emitted
4920 .contains("Modify Player Variable At Index((Event Player).p2, 0, Subtract, 3);")
4921 );
4922
4923 let rule = artifact
4927 .wir
4928 .rules
4929 .get(workshop_rs::wir::RuleId::from_index(1))
4930 .unwrap();
4931 let direct = artifact.wir.actions.get(rule.actions[0]).unwrap();
4932 match direct {
4933 workshop_rs::wir::Action::SetGlobalVariable {
4934 span,
4935 target_span,
4936 variable,
4937 ..
4938 } => {
4939 assert_eq!(span.unwrap().start.line, 9);
4940 assert_eq!(target_span.unwrap().start.line, 9);
4941 assert_eq!(
4942 artifact.wir.global_variables.get(*variable).unwrap().name,
4943 "g1"
4944 );
4945 }
4946 other => panic!("expected a direct global assignment, got {other:?}"),
4947 }
4948 let indexed = artifact.wir.actions.get(rule.actions[7]).unwrap();
4949 match indexed {
4950 workshop_rs::wir::Action::Call { span, .. } => {
4951 assert_eq!(span.unwrap().start.line, 16);
4952 }
4953 other => panic!("expected an indexed assignment call, got {other:?}"),
4954 }
4955 }
4956
4957 #[test]
4958 fn expressions_and_values_lower_to_canonical_wir() {
4959 let compiler = Compiler::new().unwrap();
4960 let hir = crate::compile(
4961 r#"
4962enum Consts:
4963 BASE
4964
4965globalvar total
4966globalvar arr = [1, 2, 3]
4967globalvar pos = vect(1, 2, 3)
4968
4969rule "expressions":
4970 @Event global
4971 @Condition total == 0
4972 @Condition not (pos == vect(0, 0, 0))
4973 @Condition 2 in arr
4974 total = Consts.BASE + arr[1] * 2 - (10 / 2) + (5 % 2)
4975 print("Total: {}".format(total))
4976 debug(pos)
4977"#,
4978 "expr.opy",
4979 Path::new("."),
4980 )
4981 .unwrap();
4982 let artifact = compiler.compile_hir(&hir).unwrap();
4983 assert!(artifact.emitted.contains("Global.total == 0;"));
4984 assert!(artifact.emitted.contains("Global.pos != Vector(0, 0, 0);"));
4987 assert!(
4988 artifact
4989 .emitted
4990 .contains("Array Contains(Global.arr, 2) == True;")
4991 );
4992 assert!(
4993 artifact
4994 .emitted
4995 .contains("Custom String(\"Total: {0}\", Global.total)")
4996 );
4997 }
4998
4999 #[test]
5000 fn pass_is_supported_as_source_level_noop() {
5001 let compiler = Compiler::new().unwrap();
5002 let hir = crate::compile(
5003 r#"
5004subroutine emptySub
5005
5006def emptySub():
5007 pass
5008
5009rule "empty rule":
5010 @Event global
5011 pass
5012"#,
5013 "pass.opy",
5014 Path::new("."),
5015 )
5016 .unwrap();
5017 let artifact = compiler.compile_hir(&hir).unwrap();
5018 let rule0 = artifact
5019 .wir
5020 .rules
5021 .get(workshop_rs::wir::RuleId::from_index(0))
5022 .unwrap();
5023 assert!(rule0.actions.is_empty());
5024 let rule1 = artifact
5025 .wir
5026 .rules
5027 .get(workshop_rs::wir::RuleId::from_index(1))
5028 .unwrap();
5029 assert!(rule1.actions.is_empty());
5030 }
5031
5032 #[test]
5033 fn variable_initializers_synthesize_initialize_rules() {
5034 let compiler = Compiler::new().unwrap();
5035 let hir = crate::compile(
5036 r#"
5037globalvar j = 5
5038globalvar h = 0
5039globalvar k = 0.0
5040playervar p = 7
5041playervar q = 0
5042
5043rule "main":
5044 @Event global
5045 disableInspector()
5046"#,
5047 "init.opy",
5048 Path::new("."),
5049 )
5050 .unwrap();
5051 let artifact = compiler.compile_hir(&hir).unwrap();
5052 assert_eq!(
5053 artifact
5054 .wir
5055 .rules
5056 .get(workshop_rs::wir::RuleId::from_index(0))
5057 .unwrap()
5058 .name,
5059 "Initialize global variables"
5060 );
5061 assert_eq!(
5062 artifact
5063 .wir
5064 .rules
5065 .get(workshop_rs::wir::RuleId::from_index(1))
5066 .unwrap()
5067 .name,
5068 "Initialize player variables"
5069 );
5070 assert_eq!(
5071 artifact
5072 .wir
5073 .rules
5074 .get(workshop_rs::wir::RuleId::from_index(2))
5075 .unwrap()
5076 .name,
5077 "main"
5078 );
5079 assert!(artifact.emitted.contains("Set Global Variable(j, 5);"));
5080 assert!(artifact.emitted.contains("Set Global Variable(k, 0.0);"));
5081 assert!(!artifact.emitted.contains("Set Global Variable(h,"));
5082 assert!(
5083 artifact
5084 .emitted
5085 .contains("Set Player Variable(Event Player, p, 7);")
5086 );
5087 assert!(
5088 !artifact
5089 .emitted
5090 .contains("Set Player Variable(Event Player, q,")
5091 );
5092 }
5093
5094 #[test]
5095 fn settings_lower_through_workshop_owned_emission() {
5096 let compiler = Compiler::new().unwrap();
5097 let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
5098 .join("../../compatibility/fixtures/synthetic/settings");
5099 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
5100 let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
5101 let artifact = compiler.compile_hir(&hir).unwrap();
5102 let oracle: serde_json::Value =
5103 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
5104 .unwrap();
5105 let expected = oracle["compile"]["workshop"]
5106 .as_str()
5107 .unwrap()
5108 .split("\n\nrule")
5109 .next()
5110 .unwrap();
5111 let actual = artifact.emitted.split("\n\nrule").next().unwrap();
5112 let oracle_wir = workshop_rs::parser::parse(
5113 oracle["compile"]["workshop"].as_str().unwrap(),
5114 &Catalog::builtin().unwrap(),
5115 &Locale::new("en-US"),
5116 )
5117 .unwrap();
5118 assert!(workshop_rs::roundtrip::equivalent(
5119 &artifact.wir,
5120 &oracle_wir
5121 ));
5122 assert_eq!(
5123 normalize_workshop_structural_whitespace(actual),
5124 normalize_workshop_structural_whitespace(expected)
5125 );
5126 }
5127
5128 #[test]
5129 fn unsupported_locale_has_no_fabricated_source_span() {
5130 let compiler = Compiler::new().unwrap();
5131 let hir = crate::compile(
5132 "#!translations en\nrule \"r\":\n @Event global\n pass\n",
5133 "locale.opy",
5134 Path::new("."),
5135 )
5136 .unwrap();
5137 let error = match compiler.compile_hir_with_locale(&hir, &Locale::new("xx-XX")) {
5138 Ok(_) => panic!("unsupported locale unexpectedly compiled"),
5139 Err(error) => error,
5140 };
5141 assert_eq!(error.diagnostic.code, "locale-unsupported");
5142 assert_eq!(error.diagnostic.span, None);
5143 }
5144
5145 #[test]
5146 fn locale_selection_emits_catalog_localized_workshop() {
5147 let compiler = Compiler::new().unwrap();
5148 let hir = crate::compile(
5149 "rule \"locale\":\n @Event global\n disableInspector()\n",
5150 "locale.opy",
5151 Path::new("."),
5152 )
5153 .unwrap();
5154 let artifact = compiler
5155 .compile_hir_with_locale(&hir, &Locale::new("zh-CN"))
5156 .unwrap();
5157 assert!(artifact.emitted.contains("规则 (\"locale\")"));
5158 assert!(artifact.emitted.contains("禁用查看器录制"));
5159 }
5160
5161 #[test]
5162 fn unsupported_backend_directives_fail_at_their_source_anchor() {
5163 let compiler = Compiler::new().unwrap();
5164 let hir = crate::compile(
5165 "#!replace0ByCapturePercentage\nrule \"r\":\n @Event global\n pass\n",
5166 "directives.opy",
5167 Path::new("."),
5168 )
5169 .unwrap();
5170 let error = match compiler.compile_hir(&hir) {
5171 Ok(_) => panic!("backend directive unexpectedly compiled"),
5172 Err(error) => error,
5173 };
5174 assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
5175 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
5176 }
5177
5178 #[test]
5179 fn optimizer_directives_remain_non_blocking_presentation_controls() {
5180 let compiler = Compiler::new().unwrap();
5181 let hir = crate::compile(
5182 "#!disableOptimizations\nrule \"r\":\n @Event global\n pass\n",
5183 "optimization.opy",
5184 Path::new("."),
5185 )
5186 .unwrap();
5187 compiler.compile_hir(&hir).unwrap();
5188 }
5189
5190 #[test]
5191 fn replacement_directive_records_are_checked_even_if_final_state_is_restored() {
5192 let compiler = Compiler::new().unwrap();
5193 let mut hir = crate::compile(
5194 "#!replace0ByCapturePercentage\nrule \"r\":\n @Event global\n pass\n",
5195 "directives.opy",
5196 Path::new("."),
5197 )
5198 .unwrap();
5199 hir.preprocessing.replacements.clear();
5200 let error = match compiler.compile_hir(&hir) {
5201 Ok(_) => panic!("replacement directive unexpectedly compiled"),
5202 Err(error) => error,
5203 };
5204 assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
5205 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
5206 }
5207
5208 #[test]
5209 fn active_replacement_state_is_checked_without_directive_history() {
5210 let compiler = Compiler::new().unwrap();
5211 let mut hir = crate::compile(
5212 "#!replace0ByCapturePercentage\nrule \"r\":\n @Event global\n pass\n",
5213 "directives.opy",
5214 Path::new("."),
5215 )
5216 .unwrap();
5217 hir.preprocessing.directives.clear();
5218 hir.preprocessing.replacements[0].span = None;
5219 let error = match compiler.compile_hir(&hir) {
5220 Ok(_) => panic!("active replacement state unexpectedly compiled"),
5221 Err(error) => error,
5222 };
5223 assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
5224 assert_eq!(error.diagnostic.span, None);
5225 }
5226
5227 #[test]
5228 fn post_compile_hook_receives_exact_emitted_workshop() {
5229 let compiler = Compiler::new().unwrap();
5230 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
5231 let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n pass\n";
5232 let artifact = compiler
5233 .compile_source_with_locale(source, "hook.opy", &root, &Locale::new("en-US"))
5234 .unwrap();
5235 assert!(artifact.emitted.contains("rule (\"setup\")"));
5236 assert!(artifact.final_output.contains("rule (\"transformed\")"));
5237 assert_ne!(artifact.final_output, artifact.emitted);
5238 }
5239
5240 #[test]
5241 fn post_compile_hook_failure_keeps_script_provenance_and_directive_anchor() {
5242 let compiler = Compiler::new().unwrap();
5243 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
5244 let source = "#!postCompileHook \"hook-boom.js\"\n\nrule \"setup\":\n pass\n";
5245 let error = match compiler.compile_source_with_locale(
5246 source,
5247 "hook.opy",
5248 &root,
5249 &Locale::new("en-US"),
5250 ) {
5251 Ok(_) => panic!("failing post-compile hook unexpectedly compiled"),
5252 Err(error) => error,
5253 };
5254 assert_eq!(error.diagnostic.code, "post-compile-hook");
5255 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
5256 let script = error.diagnostic.script.unwrap();
5257 assert_eq!(script.source_name.as_deref(), Some("hook-boom.js"));
5258 assert_eq!(script.line, Some(1));
5259 assert!(script.stack.unwrap().contains("hook-boom.js:1"));
5260 }
5261
5262 fn normalize_workshop_structural_whitespace(text: &str) -> String {
5263 let mut normalized = String::with_capacity(text.len());
5264 let mut quote = None;
5265 let mut escaped = false;
5266 for character in text.chars() {
5267 if let Some(delimiter) = quote {
5268 normalized.push(character);
5269 if escaped {
5270 escaped = false;
5271 } else if character == '\\' {
5272 escaped = true;
5273 } else if character == delimiter {
5274 quote = None;
5275 }
5276 } else if matches!(character, '\"' | '\'') {
5277 quote = Some(character);
5278 normalized.push(character);
5279 } else if !character.is_whitespace() {
5280 normalized.push(character);
5281 }
5282 }
5283 normalized
5284 }
5285
5286 #[test]
5287 fn settings_whitespace_normalization_preserves_quoted_values() {
5288 assert_ne!(
5289 normalize_workshop_structural_whitespace("Description: \"a b\""),
5290 normalize_workshop_structural_whitespace("Description: \"ab\"")
5291 );
5292 }
5293}