Skip to main content

opy_rs/compiler/
mod.rs

1//! OPY-to-Workshop integration, kept behind the `opy-rs` library boundary.
2//!
3//! This module pins the released `workshop-rs` v0.1.16 contract, checks the OPY
4//! manifest links against the canonical catalog, and lowers the supported OPY
5//! program structure into canonical WIR before validation and deterministic
6//! Workshop emission.
7
8use 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
22/// The exact released dependency contract consumed by this crate.
23pub const WORKSHOP_RS_VERSION: &str = "0.1.16";
24
25const TRANSLATION_HELPER_NAME: &str = "__overpyTranslationHelper__";
26
27/// Version of the machine-readable compile report contract.
28pub const COMPILE_SCHEMA_VERSION: u32 = 1;
29
30/// Stable identity of the compiler that produced a compile report.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct CompilerIdentity {
33    pub name: &'static str,
34    pub version: &'static str,
35}
36
37/// Whether compilation produced a valid Workshop artifact.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "lowercase")]
40pub enum CompileStatus {
41    Success,
42    Failure,
43}
44
45/// Stable classification for a compile failure.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
47#[serde(rename_all = "kebab-case")]
48pub enum CompileFailureClass {
49    Frontend,
50    Integration,
51}
52
53/// A versioned, source-attributed diagnostic exposed by the compile API.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct CompileDiagnostic {
57    pub severity: crate::tooling::DiagnosticSeverity,
58    pub code: String,
59    pub message: String,
60    pub span: Option<crate::tooling::SourceLocation>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub script: Option<ScriptDiagnostic>,
63}
64
65/// The machine-readable result for one compile operation.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67#[serde(rename_all = "camelCase")]
68pub struct CompileResult {
69    pub status: CompileStatus,
70    pub exit_code: u8,
71    pub failure_class: Option<CompileFailureClass>,
72    pub diagnostics: Vec<CompileDiagnostic>,
73    pub stdout: String,
74    pub workshop_exact: String,
75    pub workshop: String,
76}
77
78/// Complete versioned compile report for CLI, CI, and embedding consumers.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "camelCase")]
81pub struct CompileReport {
82    pub schema_version: u32,
83    pub compiler: CompilerIdentity,
84    pub catalog: CatalogIdentity,
85    pub compile: CompileResult,
86}
87
88impl CompilerIdentity {
89    fn current() -> Self {
90        Self {
91            name: "opy-rs",
92            version: env!("CARGO_PKG_VERSION"),
93        }
94    }
95}
96
97/// A source-attributed integration diagnostic.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct IntegrationDiagnostic {
100    pub code: String,
101    pub message: String,
102    pub span: Option<HirSpan>,
103    pub script: Option<Box<ScriptDiagnostic>>,
104}
105
106/// Script-runtime provenance retained alongside the OPY directive anchor.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
108#[serde(rename_all = "camelCase")]
109pub struct ScriptDiagnostic {
110    pub source_name: Option<String>,
111    pub line: Option<u32>,
112    pub column: Option<u32>,
113    pub stack: Option<String>,
114}
115
116impl IntegrationDiagnostic {
117    fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
118        Self {
119            code: code.into(),
120            message: message.into(),
121            span,
122            script: None,
123        }
124    }
125}
126
127/// An integration boundary failure.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct IntegrationError {
130    pub diagnostic: IntegrationDiagnostic,
131}
132
133impl IntegrationError {
134    fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
135        Self {
136            diagnostic: IntegrationDiagnostic::new(code, message, span),
137        }
138    }
139
140    fn post_compile_hook(error: crate::macro_js::MacroError, span: Option<HirSpan>) -> Self {
141        let message = error.to_string();
142        let script = match error {
143            crate::macro_js::MacroError::Script(error) => Some(Box::new(ScriptDiagnostic {
144                source_name: error.source_name,
145                line: error.line,
146                column: error.column,
147                stack: error.stack,
148            })),
149            crate::macro_js::MacroError::InvalidResult { .. }
150            | crate::macro_js::MacroError::Internal(_) => None,
151        };
152        Self {
153            diagnostic: IntegrationDiagnostic {
154                code: "post-compile-hook".to_string(),
155                message,
156                span,
157                script,
158            },
159        }
160    }
161}
162
163impl std::fmt::Display for IntegrationError {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        write!(f, "{}: {}", self.diagnostic.code, self.diagnostic.message)
166    }
167}
168
169impl std::error::Error for IntegrationError {}
170
171/// Results of the manifest-to-catalog cross-check.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub struct LinkReport {
174    pub catalog_ids_checked: usize,
175    pub domains_checked: usize,
176}
177
178/// Cross-check every OPY manifest `catalogId` and domain identity against the
179/// canonical Workshop catalog. No local catalog copy or spelling allowlist is
180/// involved.
181pub(crate) fn cross_check_manifest(
182    manifest: &Manifest,
183    catalog: &Catalog,
184) -> Result<LinkReport, IntegrationError> {
185    let mut catalog_ids_checked = 0;
186    let mut domains_checked = 0;
187
188    for function in &manifest.functions {
189        if let Some(catalog_id) = &function.catalog_id {
190            let kind = match function.kind {
191                FunctionKind::Action | FunctionKind::MemberAction => Kind::Action,
192                FunctionKind::Value | FunctionKind::MemberValue => Kind::Value,
193            };
194            catalog_ids_checked += 1;
195            if catalog.entry(kind, catalog_id).is_none() {
196                return Err(IntegrationError::new(
197                    "catalog-link-missing",
198                    format!(
199                        "manifest function '{}' links to missing {:?} catalog id '{}'",
200                        function.id, kind, catalog_id
201                    ),
202                    None,
203                ));
204            }
205        }
206
207        for parameter in &function.params {
208            let Some(domain) = &parameter.domain else {
209                continue;
210            };
211            let contextual = function
212                .contextual_domain
213                .as_ref()
214                .is_some_and(|context| context.domain == *domain);
215            if contextual {
216                continue;
217            }
218            domains_checked += 1;
219            if catalog.enum_domain(domain).is_none() {
220                return Err(IntegrationError::new(
221                    "domain-link-missing",
222                    format!(
223                        "manifest function '{}' parameter '{}' links to missing enum domain '{}',",
224                        function.id, parameter.name, domain
225                    ),
226                    None,
227                ));
228            }
229        }
230
231        if let Some(contextual) = &function.contextual_domain {
232            for option in contextual.options.values() {
233                domains_checked += 1;
234                if catalog.enum_domain(&option.domain).is_none() {
235                    return Err(IntegrationError::new(
236                        "domain-link-missing",
237                        format!(
238                            "manifest function '{}' contextual option links to missing enum domain '{}'",
239                            function.id, option.domain
240                        ),
241                        None,
242                    ));
243                }
244            }
245        }
246    }
247
248    Ok(LinkReport {
249        catalog_ids_checked,
250        domains_checked,
251    })
252}
253
254/// The compiler-facing integration object. Construction validates the public
255/// manifest/catalog contract once and exposes the pinned catalog identity.
256pub struct Compiler {
257    catalog: Catalog,
258    manifest: &'static Manifest,
259    links: LinkReport,
260}
261
262impl Compiler {
263    pub fn new() -> Result<Self, IntegrationError> {
264        let catalog = Catalog::builtin()
265            .map_err(|error| IntegrationError::new("catalog-load", error.to_string(), None))?;
266        let manifest = Manifest::builtin()
267            .map_err(|error| IntegrationError::new("manifest-load", error.to_string(), None))?;
268        let links = cross_check_manifest(manifest, &catalog)?;
269        let identity = catalog.identity();
270        if identity.implementation_version != WORKSHOP_RS_VERSION {
271            return Err(IntegrationError::new(
272                "workshop-contract-version",
273                format!(
274                    "expected workshop-rs {}, loaded {}",
275                    WORKSHOP_RS_VERSION, identity.implementation_version
276                ),
277                None,
278            ));
279        }
280        Ok(Self {
281            catalog,
282            manifest,
283            links,
284        })
285    }
286
287    pub fn catalog_identity(&self) -> CatalogIdentity {
288        self.catalog.identity()
289    }
290
291    pub fn link_report(&self) -> LinkReport {
292        self.links
293    }
294
295    /// Lower a resolved OPY HIR program into canonical WIR, validate it
296    /// against the canonical catalog, and emit deterministic en-US Workshop.
297    pub fn compile_hir(&self, hir: &hir::Program) -> Result<CompilationArtifact, IntegrationError> {
298        self.compile_hir_with_locale(hir, &Locale::new("en-US"))
299    }
300
301    /// Lower and emit using a locale declared by the canonical catalog.
302    pub fn compile_hir_with_locale(
303        &self,
304        hir: &hir::Program,
305        locale: &Locale,
306    ) -> Result<CompilationArtifact, IntegrationError> {
307        if !self.catalog.supports(locale) {
308            return Err(IntegrationError::new(
309                "locale-unsupported",
310                format!("workshop catalog does not declare locale '{locale}'"),
311                None,
312            ));
313        }
314        reject_unlowered_directives(hir)?;
315        let expanded_hir = expand_macros(hir)?;
316        let mut lowering = Lowering::new(self, &expanded_hir)?;
317        lowering.copy_files()?;
318        lowering.lower_declarations()?;
319        lowering.lower_rules()?;
320
321        lowering.wir.validate().map_err(|error| {
322            let span = error
323                .span()
324                .and_then(|span| lowering.hir_span_from_workshop(span));
325            IntegrationError::new(error.code(), error.message(), span)
326        })?;
327        workshop_rs::validate::validate_canonical_ids(&lowering.wir, &self.catalog).map_err(
328            |error| {
329                let span = workshop_error_span(&error)
330                    .and_then(|span| lowering.hir_span_from_workshop(span));
331                IntegrationError::new("catalog-validation", error.to_string(), span)
332            },
333        )?;
334        let emitted =
335            workshop_rs::emitter::emit(&lowering.wir, &self.catalog, locale).map_err(|error| {
336                let span = workshop_error_span(&error)
337                    .and_then(|span| lowering.hir_span_from_workshop(span));
338                IntegrationError::new("workshop-emission", error.to_string(), span)
339            })?;
340
341        Ok(CompilationArtifact {
342            wir: lowering.wir,
343            final_output: emitted.clone(),
344            emitted,
345            catalog_identity: self.catalog.identity(),
346            hook_console_output: Vec::new(),
347        })
348    }
349
350    /// Compile source using the default `en-US` catalog locale.
351    ///
352    /// This is the ordinary embedding API. It returns Workshop text and does
353    /// not require callers to construct a `workshop-rs` locale or understand
354    /// canonical WIR types.
355    pub fn compile_source(
356        &self,
357        source: &str,
358        main_path: &str,
359        root: &std::path::Path,
360    ) -> Result<CompileOutput, IntegrationError> {
361        self.compile_source_with_language(source, main_path, root, "en-US")
362    }
363
364    /// Compile source using a catalog locale name without exposing the
365    /// `workshop-rs` locale type to ordinary embedding callers.
366    pub fn compile_source_with_language(
367        &self,
368        source: &str,
369        main_path: &str,
370        root: &std::path::Path,
371        language: &str,
372    ) -> Result<CompileOutput, IntegrationError> {
373        self.compile_source_with_locale(source, main_path, root, &Locale::new(language))
374            .map(CompilationArtifact::into_output)
375    }
376
377    /// Compile source with an explicit canonical Workshop locale.
378    ///
379    /// This is an advanced integration API. Use [`Self::compile_source`] or
380    /// [`Self::compile_source_with_language`] for ordinary embedding.
381    pub fn compile_source_with_locale(
382        &self,
383        source: &str,
384        main_path: &str,
385        root: &std::path::Path,
386        locale: &Locale,
387    ) -> Result<CompilationArtifact, IntegrationError> {
388        self.compile_source_internal(source, main_path, root, locale)
389    }
390
391    /// Compile source and return the canonical WIR artifact for advanced
392    /// integrations.
393    pub fn compile_source_artifact(
394        &self,
395        source: &str,
396        main_path: &str,
397        root: &std::path::Path,
398    ) -> Result<CompilationArtifact, IntegrationError> {
399        self.compile_source_with_locale(source, main_path, root, &Locale::new("en-US"))
400    }
401
402    /// Compile source into the versioned machine-readable result contract
403    /// using the default `en-US` catalog locale.
404    pub fn compile_source_report(
405        &self,
406        source: &str,
407        main_path: &str,
408        root: &std::path::Path,
409    ) -> CompileReport {
410        self.compile_source_report_with_language(source, main_path, root, "en-US")
411    }
412
413    /// Compile source into the versioned machine-readable result contract
414    /// using a catalog locale name.
415    pub fn compile_source_report_with_language(
416        &self,
417        source: &str,
418        main_path: &str,
419        root: &std::path::Path,
420        language: &str,
421    ) -> CompileReport {
422        self.compile_source_report_with_locale(source, main_path, root, &Locale::new(language))
423    }
424
425    /// Compile source into the report contract with an explicit canonical
426    /// Workshop locale. This is an advanced integration API.
427    pub fn compile_source_report_with_locale(
428        &self,
429        source: &str,
430        main_path: &str,
431        root: &std::path::Path,
432        locale: &Locale,
433    ) -> CompileReport {
434        let outcome = crate::compile_with_overlay_outcome(
435            source,
436            main_path,
437            root,
438            &std::collections::BTreeMap::new(),
439        );
440        let catalog = self.catalog.identity();
441        let compiler = CompilerIdentity::current();
442        let frontend_diagnostics = outcome
443            .diagnostics
444            .iter()
445            .map(compile_frontend_diagnostic)
446            .collect::<Vec<_>>();
447        let Some(hir) = outcome.hir else {
448            return CompileReport::failure(
449                compiler,
450                catalog,
451                CompileFailureClass::Frontend,
452                frontend_diagnostics,
453            );
454        };
455
456        match self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale) {
457            Ok(artifact) => {
458                CompileReport::success(compiler, catalog, artifact, frontend_diagnostics)
459            }
460            Err(error) => {
461                let mut diagnostics = frontend_diagnostics;
462                diagnostics.push(compile_diagnostic(error, &hir.files));
463                CompileReport::failure(
464                    compiler,
465                    catalog,
466                    CompileFailureClass::Integration,
467                    diagnostics,
468                )
469            }
470        }
471    }
472
473    fn compile_source_internal(
474        &self,
475        source: &str,
476        main_path: &str,
477        root: &std::path::Path,
478        locale: &Locale,
479    ) -> Result<CompilationArtifact, IntegrationError> {
480        let outcome = crate::compile_with_overlay_outcome(
481            source,
482            main_path,
483            root,
484            &std::collections::BTreeMap::new(),
485        );
486        let hir = outcome.hir.ok_or_else(|| {
487            let error = outcome
488                .error
489                .expect("failed frontend compile has diagnostic");
490            IntegrationError::new(
491                error.code,
492                error.message,
493                error.span.map(hir_span_from_diag),
494            )
495        })?;
496        self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale)
497    }
498
499    fn compile_hir_with_locale_and_hook(
500        &self,
501        hir: &hir::Program,
502        hook: Option<crate::PostCompileHookRecord>,
503        locale: &Locale,
504    ) -> Result<CompilationArtifact, IntegrationError> {
505        let mut artifact = self.compile_hir_with_locale(hir, locale)?;
506        if let Some(hook) = hook {
507            let runtime = crate::macro_js::MacroRuntime::new(crate::macro_js::Limits::default());
508            let result = runtime
509                .run_hook(&hook.source, &artifact.emitted, &hook.script)
510                .map_err(|error| {
511                    IntegrationError::post_compile_hook(error, hook.span.map(hir_span_from_diag))
512                })?;
513            artifact.final_output = result.text;
514            artifact.hook_console_output = result.console_output;
515        }
516        Ok(artifact)
517    }
518}
519
520impl CompileReport {
521    fn success(
522        compiler: CompilerIdentity,
523        catalog: CatalogIdentity,
524        artifact: CompilationArtifact,
525        diagnostics: Vec<CompileDiagnostic>,
526    ) -> Self {
527        Self {
528            schema_version: COMPILE_SCHEMA_VERSION,
529            compiler,
530            catalog,
531            compile: CompileResult {
532                status: CompileStatus::Success,
533                exit_code: 0,
534                failure_class: None,
535                diagnostics,
536                stdout: String::new(),
537                workshop_exact: artifact.final_output.clone(),
538                workshop: normalize_workshop(&artifact.final_output),
539            },
540        }
541    }
542
543    fn failure(
544        compiler: CompilerIdentity,
545        catalog: CatalogIdentity,
546        failure_class: CompileFailureClass,
547        diagnostics: Vec<CompileDiagnostic>,
548    ) -> Self {
549        Self {
550            schema_version: COMPILE_SCHEMA_VERSION,
551            compiler,
552            catalog,
553            compile: CompileResult {
554                status: CompileStatus::Failure,
555                exit_code: 1,
556                failure_class: Some(failure_class),
557                diagnostics,
558                stdout: String::new(),
559                workshop_exact: String::new(),
560                workshop: String::new(),
561            },
562        }
563    }
564}
565
566fn compile_diagnostic(error: IntegrationError, files: &[hir::SourceFile]) -> CompileDiagnostic {
567    let diagnostic = error.diagnostic;
568    CompileDiagnostic {
569        severity: crate::tooling::DiagnosticSeverity::Error,
570        code: diagnostic.code,
571        message: diagnostic.message,
572        span: diagnostic
573            .span
574            .and_then(|span| source_location_from_hir(span, files)),
575        script: diagnostic.script.map(|script| *script),
576    }
577}
578
579fn compile_frontend_diagnostic(diagnostic: &crate::tooling::Diagnostic) -> CompileDiagnostic {
580    CompileDiagnostic {
581        severity: diagnostic.severity,
582        code: diagnostic.code.clone(),
583        message: diagnostic.message.clone(),
584        span: diagnostic.span.clone(),
585        script: None,
586    }
587}
588
589fn source_location_from_hir(
590    span: HirSpan,
591    files: &[hir::SourceFile],
592) -> Option<crate::tooling::SourceLocation> {
593    let path = files.iter().find(|file| file.id == span.file)?.path.clone();
594    Some(crate::tooling::SourceLocation {
595        file_id: span.file,
596        path,
597        start: crate::diag::Position::new(span.start.line, span.start.col),
598        end: crate::diag::Position::new(span.end.line, span.end.col),
599    })
600}
601
602fn normalize_workshop(text: &str) -> String {
603    if text.is_empty() {
604        return String::new();
605    }
606    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
607    let mut lines = normalized
608        .split('\n')
609        .map(|line| line.trim_end_matches([' ', '\t']).to_owned())
610        .collect::<Vec<_>>();
611    while lines.last().is_some_and(|line| line.is_empty()) {
612        lines.pop();
613    }
614    if lines.is_empty() {
615        String::new()
616    } else {
617        lines.join("\n") + "\n"
618    }
619}
620
621fn reject_unlowered_directives(hir: &hir::Program) -> Result<(), IntegrationError> {
622    if let Some(replacement) = hir.preprocessing.replacements.first() {
623        let span = hir
624            .preprocessing
625            .directives
626            .iter()
627            .find(|directive| directive.name.starts_with("replace"))
628            .and_then(|directive| directive.span)
629            .or(replacement.span);
630        return Err(IntegrationError::new(
631            "backend-directive-unsupported",
632            format!(
633                "replacement directive '{}' has no canonical workshop-rs lowering",
634                replacement.value
635            ),
636            span,
637        ));
638    }
639    if let Some(replacement) = hir
640        .preprocessing
641        .directives
642        .iter()
643        .find(|directive| directive.name.starts_with("replace"))
644    {
645        return Err(IntegrationError::new(
646            "backend-directive-unsupported",
647            format!(
648                "replacement directive '{}' has no canonical workshop-rs lowering",
649                replacement.name
650            ),
651            replacement.span,
652        ));
653    }
654    Ok(())
655}
656
657type MacroBindings = HashMap<String, Expr>;
658
659struct MacroExpander {
660    macros: HashMap<String, (Vec<String>, Vec<Stmt>)>,
661    stack: Vec<String>,
662}
663
664fn expand_macros(program: &hir::Program) -> Result<hir::Program, IntegrationError> {
665    let macros = program
666        .declarations
667        .iter()
668        .filter_map(|declaration| match declaration {
669            hir::Declaration::Macro {
670                name, args, body, ..
671            } => Some((name.clone(), (args.clone(), body.clone()))),
672            _ => None,
673        })
674        .collect();
675    let mut expander = MacroExpander {
676        macros,
677        stack: Vec::new(),
678    };
679    let mut expanded = program.clone();
680    let bindings = MacroBindings::new();
681
682    for declaration in &mut expanded.declarations {
683        match declaration {
684            hir::Declaration::GlobalVariable { initializer, .. }
685            | hir::Declaration::PlayerVariable { initializer, .. } => {
686                if let Some(initializer) = initializer {
687                    **initializer = expander.expand_expr(initializer, &bindings)?;
688                }
689            }
690            _ => {}
691        }
692    }
693    for entry in &mut expanded.rules {
694        match entry {
695            RuleEntry::Rule(rule) => {
696                for argument in &mut rule.event.args {
697                    *argument = expander.expand_expr(argument, &bindings)?;
698                }
699                for condition in &mut rule.conditions {
700                    *condition = expander.expand_expr(condition, &bindings)?;
701                }
702                rule.actions = expander.expand_stmts(&rule.actions, &bindings)?;
703            }
704            RuleEntry::SubroutineDef { body, .. } => {
705                *body = expander.expand_stmts(body, &bindings)?;
706            }
707        }
708    }
709    Ok(expanded)
710}
711
712impl MacroExpander {
713    fn expand_stmts(
714        &mut self,
715        statements: &[Stmt],
716        bindings: &MacroBindings,
717    ) -> Result<Vec<Stmt>, IntegrationError> {
718        let mut expanded = Vec::new();
719        for statement in statements {
720            if let Stmt::Expr { expr, .. } = statement {
721                if let Expr::MacroCall { name, args, span } = expr.as_ref() {
722                    let args = args
723                        .iter()
724                        .map(|arg| self.expand_expr(arg, bindings))
725                        .collect::<Result<Vec<_>, _>>()?;
726                    expanded.extend(self.expand_macro_body(name, &args, *span)?);
727                    continue;
728                }
729            }
730            expanded.push(self.expand_stmt(statement, bindings)?);
731        }
732        Ok(expanded)
733    }
734
735    fn expand_stmt(
736        &mut self,
737        statement: &Stmt,
738        bindings: &MacroBindings,
739    ) -> Result<Stmt, IntegrationError> {
740        Ok(match statement {
741            Stmt::Expr { expr, span } => Stmt::Expr {
742                expr: Box::new(self.expand_expr(expr, bindings)?),
743                span: *span,
744            },
745            Stmt::Assign {
746                target,
747                value,
748                span,
749            } => Stmt::Assign {
750                target: Box::new(self.expand_expr(target, bindings)?),
751                value: Box::new(self.expand_expr(value, bindings)?),
752                span: *span,
753            },
754            Stmt::If {
755                branches,
756                r#else,
757                span,
758            } => Stmt::If {
759                branches: branches
760                    .iter()
761                    .map(|branch| {
762                        Ok(hir::types::IfBranch {
763                            condition: Box::new(self.expand_expr(&branch.condition, bindings)?),
764                            body: self.expand_stmts(&branch.body, bindings)?,
765                        })
766                    })
767                    .collect::<Result<Vec<_>, IntegrationError>>()?,
768                r#else: r#else
769                    .as_ref()
770                    .map(|body| self.expand_stmts(body, bindings))
771                    .transpose()?,
772                span: *span,
773            },
774            Stmt::For {
775                variable,
776                iterable,
777                body,
778                span,
779            } => Stmt::For {
780                variable: Box::new(self.expand_expr(variable, bindings)?),
781                iterable: Box::new(self.expand_expr(iterable, bindings)?),
782                body: self.expand_stmts(body, bindings)?,
783                span: *span,
784            },
785            Stmt::While {
786                condition,
787                body,
788                span,
789            } => Stmt::While {
790                condition: Box::new(self.expand_expr(condition, bindings)?),
791                body: self.expand_stmts(body, bindings)?,
792                span: *span,
793            },
794            Stmt::DoWhile {
795                condition,
796                body,
797                span,
798            } => Stmt::DoWhile {
799                condition: Box::new(self.expand_expr(condition, bindings)?),
800                body: self.expand_stmts(body, bindings)?,
801                span: *span,
802            },
803            Stmt::Switch { value, arms, span } => Stmt::Switch {
804                value: Box::new(self.expand_expr(value, bindings)?),
805                arms: arms
806                    .iter()
807                    .map(|arm| match arm {
808                        SwitchArm::Case { value, body, span } => Ok(SwitchArm::Case {
809                            value: Box::new(self.expand_expr(value, bindings)?),
810                            body: self.expand_stmts(body, bindings)?,
811                            span: *span,
812                        }),
813                        SwitchArm::Default { body, span } => Ok(SwitchArm::Default {
814                            body: self.expand_stmts(body, bindings)?,
815                            span: *span,
816                        }),
817                    })
818                    .collect::<Result<Vec<_>, IntegrationError>>()?,
819                span: *span,
820            },
821            Stmt::Delete { target, span } => Stmt::Delete {
822                target: Box::new(self.expand_expr(target, bindings)?),
823                span: *span,
824            },
825            Stmt::Goto {
826                label,
827                offset,
828                rule_start,
829                span,
830            } => Stmt::Goto {
831                label: label.clone(),
832                offset: offset
833                    .as_ref()
834                    .map(|offset| self.expand_expr(offset, bindings).map(Box::new))
835                    .transpose()?,
836                rule_start: *rule_start,
837                span: *span,
838            },
839            Stmt::Break { .. }
840            | Stmt::Return { .. }
841            | Stmt::CallSubroutine { .. }
842            | Stmt::Pass { .. } => statement.clone(),
843            Stmt::Continue { .. } | Stmt::Label { .. } => statement.clone(),
844        })
845    }
846
847    fn expand_expr(
848        &mut self,
849        expression: &Expr,
850        bindings: &MacroBindings,
851    ) -> Result<Expr, IntegrationError> {
852        match expression {
853            Expr::MacroParam { name, span } => bindings.get(name).cloned().ok_or_else(|| {
854                IntegrationError::new(
855                    "unsupported-integration-surface",
856                    format!("macro parameter '{name}' has no expansion binding"),
857                    *span,
858                )
859            }),
860            Expr::MacroCall { name, args, span } => {
861                let args = args
862                    .iter()
863                    .map(|arg| self.expand_expr(arg, bindings))
864                    .collect::<Result<Vec<_>, _>>()?;
865                let body = self.expand_macro_body(name, &args, *span)?;
866                if body.len() != 1 {
867                    return Err(IntegrationError::new(
868                        "macro-invalid",
869                        format!("macro '{name}' must produce one expression in value position"),
870                        *span,
871                    ));
872                }
873                match body.into_iter().next().expect("one macro body statement") {
874                    Stmt::Expr { expr, .. } => Ok(*expr),
875                    _ => Err(IntegrationError::new(
876                        "macro-invalid",
877                        format!("macro '{name}' must produce an expression in value position"),
878                        *span,
879                    )),
880                }
881            }
882            Expr::Array { elements, span } => Ok(Expr::Array {
883                elements: elements
884                    .iter()
885                    .map(|element| self.expand_expr(element, bindings))
886                    .collect::<Result<Vec<_>, _>>()?,
887                span: *span,
888            }),
889            Expr::Dict { entries, span } => Ok(Expr::Dict {
890                entries: entries
891                    .iter()
892                    .map(|entry| {
893                        Ok(hir::DictEntry {
894                            key: Box::new(self.expand_expr(&entry.key, bindings)?),
895                            value: Box::new(self.expand_expr(&entry.value, bindings)?),
896                            span: entry.span,
897                        })
898                    })
899                    .collect::<Result<Vec<_>, IntegrationError>>()?,
900                span: *span,
901            }),
902            Expr::Comprehension {
903                element,
904                variable,
905                variable_span,
906                index,
907                index_span,
908                iterable,
909                condition,
910                span,
911            } => Ok(Expr::Comprehension {
912                element: Box::new(self.expand_expr(element, bindings)?),
913                variable: variable.clone(),
914                variable_span: *variable_span,
915                index: index.clone(),
916                index_span: *index_span,
917                iterable: Box::new(self.expand_expr(iterable, bindings)?),
918                condition: condition
919                    .as_ref()
920                    .map(|condition| self.expand_expr(condition, bindings).map(Box::new))
921                    .transpose()?,
922                span: *span,
923            }),
924            Expr::Lambda {
925                params,
926                param_spans,
927                body,
928                span,
929            } => Ok(Expr::Lambda {
930                params: params.clone(),
931                param_spans: param_spans.clone(),
932                body: Box::new(self.expand_expr(body, bindings)?),
933                span: *span,
934            }),
935            Expr::Type { name, args, span } => Ok(Expr::Type {
936                name: name.clone(),
937                args: args
938                    .iter()
939                    .map(|arg| self.expand_expr(arg, bindings))
940                    .collect::<Result<Vec<_>, _>>()?,
941                span: *span,
942            }),
943            Expr::Vector { x, y, z, span } => Ok(Expr::Vector {
944                x: Box::new(self.expand_expr(x, bindings)?),
945                y: Box::new(self.expand_expr(y, bindings)?),
946                z: Box::new(self.expand_expr(z, bindings)?),
947                span: *span,
948            }),
949            Expr::PlayerVar {
950                player,
951                name,
952                member_span,
953                span,
954            } => Ok(Expr::PlayerVar {
955                player: Box::new(self.expand_expr(player, bindings)?),
956                name: name.clone(),
957                member_span: *member_span,
958                span: *span,
959            }),
960            Expr::Member {
961                receiver,
962                member,
963                member_span,
964                span,
965            } => Ok(Expr::Member {
966                receiver: Box::new(self.expand_expr(receiver, bindings)?),
967                member: member.clone(),
968                member_span: *member_span,
969                span: *span,
970            }),
971            Expr::Call { name, args, span } => Ok(Expr::Call {
972                name: name.clone(),
973                args: args
974                    .iter()
975                    .map(|arg| self.expand_expr(arg, bindings))
976                    .collect::<Result<Vec<_>, _>>()?,
977                span: *span,
978            }),
979            Expr::ReceiverCall {
980                receiver,
981                name,
982                args,
983                span,
984            } => Ok(Expr::ReceiverCall {
985                receiver: Box::new(self.expand_expr(receiver, bindings)?),
986                name: name.clone(),
987                args: args
988                    .iter()
989                    .map(|arg| self.expand_expr(arg, bindings))
990                    .collect::<Result<Vec<_>, _>>()?,
991                span: *span,
992            }),
993            Expr::Binary {
994                op,
995                left,
996                right,
997                span,
998            } => Ok(Expr::Binary {
999                op: op.clone(),
1000                left: Box::new(self.expand_expr(left, bindings)?),
1001                right: Box::new(self.expand_expr(right, bindings)?),
1002                span: *span,
1003            }),
1004            Expr::Conditional {
1005                then_value,
1006                condition,
1007                else_value,
1008                span,
1009            } => Ok(Expr::Conditional {
1010                then_value: Box::new(self.expand_expr(then_value, bindings)?),
1011                condition: Box::new(self.expand_expr(condition, bindings)?),
1012                else_value: Box::new(self.expand_expr(else_value, bindings)?),
1013                span: *span,
1014            }),
1015            Expr::Unary { op, operand, span } => Ok(Expr::Unary {
1016                op: op.clone(),
1017                operand: Box::new(self.expand_expr(operand, bindings)?),
1018                span: *span,
1019            }),
1020            Expr::Index { array, index, span } => Ok(Expr::Index {
1021                array: Box::new(self.expand_expr(array, bindings)?),
1022                index: Box::new(self.expand_expr(index, bindings)?),
1023                span: *span,
1024            }),
1025            Expr::Format { text, args, span } => Ok(Expr::Format {
1026                text: text.clone(),
1027                args: args
1028                    .iter()
1029                    .map(|arg| self.expand_expr(arg, bindings))
1030                    .collect::<Result<Vec<_>, _>>()?,
1031                span: *span,
1032            }),
1033            _ => Ok(expression.clone()),
1034        }
1035    }
1036
1037    fn expand_macro_body(
1038        &mut self,
1039        name: &str,
1040        args: &[Expr],
1041        span: Option<HirSpan>,
1042    ) -> Result<Vec<Stmt>, IntegrationError> {
1043        let Some((params, body)) = self.macros.get(name).cloned() else {
1044            return Err(IntegrationError::new(
1045                "unsupported-integration-surface",
1046                format!("macro '{name}' has no declaration"),
1047                span,
1048            ));
1049        };
1050        if params.len() != args.len() {
1051            return Err(IntegrationError::new(
1052                "macro-arity",
1053                format!(
1054                    "macro '{name}' expects {} argument(s) but got {}",
1055                    params.len(),
1056                    args.len()
1057                ),
1058                span,
1059            ));
1060        }
1061        if self.stack.iter().any(|active| active == name) {
1062            return Err(IntegrationError::new(
1063                "macro-recursion",
1064                format!("recursive macro expansion detected for '{name}'"),
1065                span,
1066            ));
1067        }
1068        let mut bindings = MacroBindings::new();
1069        for (param, arg) in params.into_iter().zip(args.iter()) {
1070            bindings.insert(param, arg.clone());
1071        }
1072        self.stack.push(name.to_string());
1073        let result = self.expand_stmts(&body, &bindings);
1074        self.stack.pop();
1075        result
1076    }
1077}
1078
1079/// A source compile result for ordinary embedding callers.
1080///
1081/// The result contains only emitted text and hook output. Callers that need
1082/// canonical WIR should use the explicit advanced artifact APIs instead.
1083#[derive(Debug, Clone, PartialEq, Eq)]
1084pub struct CompileOutput {
1085    /// Workshop text after a declared post-compile hook, if any.
1086    pub workshop: String,
1087    /// Workshop text emitted before a declared post-compile hook.
1088    pub emitted_workshop: String,
1089    /// Console lines captured while running a declared post-compile hook.
1090    pub hook_console_output: Vec<String>,
1091}
1092
1093/// A validated WIR program and its emitted Workshop artifact for advanced
1094/// integrations.
1095pub struct CompilationArtifact {
1096    pub wir: Program,
1097    pub emitted: String,
1098    pub catalog_identity: CatalogIdentity,
1099    pub final_output: String,
1100    pub hook_console_output: Vec<String>,
1101}
1102
1103impl CompilationArtifact {
1104    fn into_output(self) -> CompileOutput {
1105        CompileOutput {
1106            workshop: self.final_output,
1107            emitted_workshop: self.emitted,
1108            hook_console_output: self.hook_console_output,
1109        }
1110    }
1111}
1112
1113fn expand_settings_constants(
1114    settings: crate::hir::Settings,
1115    constants: &HashMap<String, &Expr>,
1116) -> crate::hir::Settings {
1117    crate::hir::Settings {
1118        span: settings.span,
1119        children: settings
1120            .children
1121            .into_iter()
1122            .map(|node| expand_settings_node(node, constants))
1123            .collect(),
1124    }
1125}
1126
1127fn expand_settings_node(
1128    node: crate::hir::SettingsNode,
1129    constants: &HashMap<String, &Expr>,
1130) -> crate::hir::SettingsNode {
1131    use crate::hir::SettingsNode;
1132    match node {
1133        SettingsNode::Group {
1134            name,
1135            children,
1136            span,
1137        } => {
1138            let children = children
1139                .into_iter()
1140                .map(|child| expand_settings_node(child, constants))
1141                .collect::<Vec<_>>();
1142            let children = if matches!(name.as_str(), "team1" | "team2" | "allTeams") {
1143                children
1144                    .into_iter()
1145                    .flat_map(|child| match child {
1146                        SettingsNode::Group { name, children, .. } if name == "general" => children,
1147                        child => vec![child],
1148                    })
1149                    .collect()
1150            } else {
1151                children
1152            };
1153            SettingsNode::Group {
1154                name,
1155                children,
1156                span,
1157            }
1158        }
1159        SettingsNode::Raw { name, value, span } => constants
1160            .get(&value)
1161            .and_then(|expr| settings_node_from_expr(name.clone(), expr, constants, span))
1162            .unwrap_or(SettingsNode::Raw { name, value, span }),
1163        node => node,
1164    }
1165}
1166
1167fn settings_node_from_expr(
1168    name: String,
1169    expr: &Expr,
1170    constants: &HashMap<String, &Expr>,
1171    span: Option<HirSpan>,
1172) -> Option<crate::hir::SettingsNode> {
1173    use crate::hir::SettingsNode;
1174    match expr {
1175        Expr::Constant { name: value, .. } => constants
1176            .get(value)
1177            .and_then(|expr| settings_node_from_expr(name, expr, constants, span)),
1178        Expr::Dict { entries, .. } => Some(SettingsNode::Group {
1179            name,
1180            children: entries
1181                .iter()
1182                .filter_map(|entry| {
1183                    let Expr::String {
1184                        value: child_name, ..
1185                    } = entry.key.as_ref()
1186                    else {
1187                        return None;
1188                    };
1189                    settings_node_from_expr(child_name.clone(), &entry.value, constants, entry.span)
1190                })
1191                .flat_map(|child| match child {
1192                    SettingsNode::Group { name, children, .. } if name == "general" => children,
1193                    child => vec![child],
1194                })
1195                .collect(),
1196            span,
1197        }),
1198        Expr::Number { value, .. } => Some(SettingsNode::Number {
1199            name,
1200            value: *value,
1201            span,
1202        }),
1203        Expr::Bool { value, .. } => Some(SettingsNode::Bool {
1204            name,
1205            value: *value,
1206            span,
1207        }),
1208        Expr::String { value, .. } => Some(SettingsNode::String {
1209            name,
1210            value: value.clone(),
1211            span,
1212        }),
1213        _ => None,
1214    }
1215}
1216
1217fn convert_settings(settings: crate::hir::Settings) -> workshop_rs::settings::Settings {
1218    workshop_rs::settings::Settings {
1219        span: settings.span.map(convert_settings_span),
1220        children: settings
1221            .children
1222            .into_iter()
1223            .map(convert_settings_node)
1224            .collect(),
1225    }
1226}
1227
1228fn convert_settings_node(node: crate::hir::SettingsNode) -> workshop_rs::settings::SettingsNode {
1229    use crate::hir::SettingsNode as SourceNode;
1230    use workshop_rs::settings::{SettingsListElement, SettingsNode as TargetNode};
1231
1232    match node {
1233        SourceNode::Group {
1234            name,
1235            children,
1236            span,
1237        } if name == "workshop" => TargetNode::Workshop {
1238            children: children.into_iter().map(convert_workshop_node).collect(),
1239            span: span.map(convert_settings_span),
1240        },
1241        SourceNode::Group {
1242            name,
1243            children,
1244            span,
1245        } => TargetNode::Group {
1246            name,
1247            children: children.into_iter().map(convert_settings_node).collect(),
1248            span: span.map(convert_settings_span),
1249        },
1250        SourceNode::Number { name, value, span } => TargetNode::Number {
1251            name,
1252            value,
1253            span: span.map(convert_settings_span),
1254        },
1255        SourceNode::Bool { name, value, span } => TargetNode::Bool {
1256            name,
1257            value,
1258            span: span.map(convert_settings_span),
1259        },
1260        SourceNode::String { name, value, span } => TargetNode::String {
1261            name: name.clone(),
1262            value: if name == "mapRotation" && value == "afterGame" {
1263                "afterAGame".to_string()
1264            } else {
1265                value
1266            },
1267            span: span.map(convert_settings_span),
1268        },
1269        SourceNode::Raw { name, value, span } => TargetNode::Raw {
1270            name,
1271            value,
1272            span: span.map(convert_settings_span),
1273        },
1274        SourceNode::List {
1275            name,
1276            elements,
1277            span,
1278        } => TargetNode::List {
1279            name,
1280            elements: elements
1281                .into_iter()
1282                .map(|element| SettingsListElement {
1283                    value: element.value,
1284                    span: element.span.map(convert_settings_span),
1285                })
1286                .collect(),
1287            span: span.map(convert_settings_span),
1288        },
1289    }
1290}
1291
1292fn convert_workshop_node(node: crate::hir::SettingsNode) -> workshop_rs::settings::SettingsNode {
1293    use crate::hir::SettingsNode as SourceNode;
1294    use workshop_rs::settings::SettingsNode as TargetNode;
1295
1296    match node {
1297        SourceNode::Group {
1298            name,
1299            children,
1300            span,
1301        } => TargetNode::Group {
1302            name,
1303            children: children.into_iter().map(convert_workshop_node).collect(),
1304            span: span.map(convert_settings_span),
1305        },
1306        SourceNode::Number { name, value, span } => TargetNode::Raw {
1307            name,
1308            value: value.to_string(),
1309            span: span.map(convert_settings_span),
1310        },
1311        SourceNode::Bool { name, value, span } => TargetNode::Raw {
1312            name,
1313            value: value.to_string(),
1314            span: span.map(convert_settings_span),
1315        },
1316        SourceNode::String { name, value, span } | SourceNode::Raw { name, value, span } => {
1317            TargetNode::Raw {
1318                name,
1319                value,
1320                span: span.map(convert_settings_span),
1321            }
1322        }
1323        SourceNode::List {
1324            name,
1325            elements,
1326            span,
1327        } => TargetNode::Raw {
1328            name,
1329            value: format!(
1330                "[{}]",
1331                elements
1332                    .into_iter()
1333                    .map(|element| element.value)
1334                    .collect::<Vec<_>>()
1335                    .join(", ")
1336            ),
1337            span: span.map(convert_settings_span),
1338        },
1339    }
1340}
1341
1342fn convert_settings_span(span: HirSpan) -> WorkshopSpan {
1343    WorkshopSpan::new(
1344        workshop_rs::source::FileId::from_index(span.file as usize),
1345        WorkshopPosition::new(span.start.line, span.start.col),
1346        WorkshopPosition::new(span.end.line, span.end.col),
1347    )
1348}
1349
1350struct Lowering<'a> {
1351    compiler: &'a Compiler,
1352    hir: &'a hir::Program,
1353    wir: Program,
1354    files: HashMap<u32, workshop_rs::source::FileId>,
1355    wir_to_hir_files: Vec<u32>,
1356    globals: HashMap<String, wir::GlobalVarId>,
1357    players: HashMap<String, wir::PlayerVarId>,
1358    subroutines: HashMap<String, wir::SubroutineId>,
1359    constants: HashMap<String, &'a Expr>,
1360    defined_subroutines: HashSet<wir::SubroutineId>,
1361    array_bindings: Vec<ArrayBinding>,
1362    current_rule_conditions: Option<Vec<wir::ValueId>>,
1363    visible_labels: Vec<HashSet<String>>,
1364    deferred_gotos: Vec<(wir::ActionId, String, Option<HirSpan>)>,
1365    outer_goto_targets: Vec<String>,
1366}
1367
1368#[derive(Debug, Clone)]
1369struct ArrayBinding {
1370    element: String,
1371    index: Option<String>,
1372}
1373
1374#[derive(Debug, Clone, Copy)]
1375enum BreakTarget {
1376    Loop,
1377    DoWhile,
1378    Switch,
1379}
1380
1381enum DeleteAssignment {
1382    Global(wir::GlobalVarId),
1383    Player {
1384        player: wir::ValueId,
1385        variable: wir::PlayerVarId,
1386    },
1387}
1388
1389type SwitchBreak = (usize, HirSpan);
1390type LoweredSwitchBody = (Vec<wir::ActionId>, Option<SwitchBreak>);
1391type LoweredSwitchArm<'a> = (Option<&'a Expr>, Vec<wir::ActionId>, Option<SwitchBreak>);
1392
1393/// Return the condition path when a statement consists only of a continue.
1394/// An empty path represents an unconditional continue; a non-empty path is
1395/// folded into the canonical `skipIf` condition by the loop lowering.
1396fn pure_continue_conditions(statement: &Stmt) -> Option<Vec<&Expr>> {
1397    match statement {
1398        Stmt::Continue { .. } => Some(Vec::new()),
1399        Stmt::If {
1400            branches,
1401            r#else: None,
1402            ..
1403        } if branches.len() == 1 && branches[0].body.len() == 1 => {
1404            let mut conditions = pure_continue_conditions(&branches[0].body[0])?;
1405            conditions.insert(0, &branches[0].condition);
1406            Some(conditions)
1407        }
1408        _ => None,
1409    }
1410}
1411
1412/// Return the condition path when a statement consists only of a forward
1413/// label jump. The path is folded into a canonical `skipIf` action so jumps
1414/// out of a nested conditional can still target the surrounding sequence.
1415fn pure_goto_conditions(statement: &Stmt) -> Option<(Vec<&Expr>, &str)> {
1416    match statement {
1417        Stmt::Goto {
1418            label: Some(label),
1419            offset: None,
1420            rule_start: false,
1421            ..
1422        } => Some((Vec::new(), label.as_str())),
1423        Stmt::If {
1424            branches,
1425            r#else: None,
1426            ..
1427        } if branches.len() == 1 && branches[0].body.len() == 1 => {
1428            let (mut conditions, label) = pure_goto_conditions(&branches[0].body[0])?;
1429            conditions.insert(0, &branches[0].condition);
1430            Some((conditions, label))
1431        }
1432        _ => None,
1433    }
1434}
1435
1436/// Detect continues belonging to the current loop. Nested loops own their
1437/// continues and are lowered independently by `lower_action`.
1438fn contains_loop_continue(statement: &Stmt) -> bool {
1439    match statement {
1440        Stmt::Continue { .. } => true,
1441        Stmt::If {
1442            branches, r#else, ..
1443        } => {
1444            branches
1445                .iter()
1446                .any(|branch| branch.body.iter().any(contains_loop_continue))
1447                || r#else
1448                    .as_ref()
1449                    .is_some_and(|body| body.iter().any(contains_loop_continue))
1450        }
1451        Stmt::For { .. } | Stmt::While { .. } | Stmt::DoWhile { .. } => false,
1452        _ => false,
1453    }
1454}
1455
1456impl<'a> Lowering<'a> {
1457    fn new(compiler: &'a Compiler, hir: &'a hir::Program) -> Result<Self, IntegrationError> {
1458        Ok(Self {
1459            compiler,
1460            hir,
1461            wir: Program::default(),
1462            files: HashMap::new(),
1463            wir_to_hir_files: Vec::new(),
1464            globals: HashMap::new(),
1465            players: HashMap::new(),
1466            subroutines: HashMap::new(),
1467            constants: HashMap::new(),
1468            defined_subroutines: HashSet::new(),
1469            array_bindings: Vec::new(),
1470            current_rule_conditions: None,
1471            visible_labels: Vec::new(),
1472            deferred_gotos: Vec::new(),
1473            outer_goto_targets: Vec::new(),
1474        })
1475    }
1476
1477    fn copy_files(&mut self) -> Result<(), IntegrationError> {
1478        let settings_constants = self
1479            .hir
1480            .declarations
1481            .iter()
1482            .filter_map(|declaration| match declaration {
1483                hir::Declaration::Constant { name, value, .. } => {
1484                    Some((name.clone(), value.as_ref()))
1485                }
1486                _ => None,
1487            })
1488            .collect();
1489        self.wir.settings = self.hir.settings.clone().map(|settings| {
1490            convert_settings(expand_settings_constants(settings, &settings_constants))
1491        });
1492        for file in &self.hir.files {
1493            if self.files.contains_key(&file.id) {
1494                return Err(IntegrationError::new(
1495                    "source-file",
1496                    format!("duplicate HIR source file id {}", file.id),
1497                    None,
1498                ));
1499            }
1500            let id = self.wir.files.push(SourceFile::new(file.path.clone()));
1501            self.files.insert(file.id, id);
1502            self.wir_to_hir_files.push(file.id);
1503        }
1504        Ok(())
1505    }
1506
1507    fn translation_helper_index(
1508        &self,
1509        implicit_reserved: &HashSet<u32>,
1510    ) -> Result<Option<u32>, IntegrationError> {
1511        if self.hir.preprocessing.translations.is_none() {
1512            return Ok(None);
1513        }
1514        let mut reserved = implicit_reserved.clone();
1515        reserved.extend(
1516            self.hir
1517                .declarations
1518                .iter()
1519                .filter_map(|declaration| match declaration {
1520                    hir::Declaration::GlobalVariable {
1521                        index: Some(index), ..
1522                    } => Some(*index),
1523                    _ => None,
1524                }),
1525        );
1526        (0..=127)
1527            .rev()
1528            .find(|index| !reserved.contains(index))
1529            .map(Some)
1530            .ok_or_else(|| {
1531                IntegrationError::new(
1532                    "index-exhausted",
1533                    "no available global variable index remains for translations",
1534                    self.hir
1535                        .preprocessing
1536                        .translations
1537                        .as_ref()
1538                        .and_then(|value| value.span),
1539                )
1540            })
1541    }
1542
1543    fn lower_declarations(&mut self) -> Result<(), IntegrationError> {
1544        let (implicit_globals, implicit_players) = implicit_default_variables(self.hir);
1545        for declaration in &self.hir.declarations {
1546            if let hir::Declaration::GlobalVariable {
1547                name,
1548                index: Some(index),
1549                span,
1550                ..
1551            } = declaration
1552            {
1553                for (implicit_name, implicit_span) in &implicit_globals {
1554                    if default_var_index(implicit_name) == Some(*index) {
1555                        return Err(IntegrationError::new(
1556                            "index-collision",
1557                            format!(
1558                                "duplicate use of index {index} for global variables '{implicit_name}' and '{name}'"
1559                            ),
1560                            implicit_span.or(*span),
1561                        ));
1562                    }
1563                }
1564            }
1565            if let hir::Declaration::PlayerVariable {
1566                name,
1567                index: Some(index),
1568                span,
1569                ..
1570            } = declaration
1571            {
1572                for (implicit_name, implicit_span) in &implicit_players {
1573                    if default_var_index(implicit_name) == Some(*index) {
1574                        return Err(IntegrationError::new(
1575                            "index-collision",
1576                            format!(
1577                                "duplicate use of index {index} for player variables '{implicit_name}' and '{name}'"
1578                            ),
1579                            implicit_span.or(*span),
1580                        ));
1581                    }
1582                }
1583            }
1584        }
1585
1586        let globals = self
1587            .hir
1588            .declarations
1589            .iter()
1590            .filter_map(|declaration| match declaration {
1591                hir::Declaration::GlobalVariable { index, span, .. } => Some((*index, *span)),
1592                _ => None,
1593            })
1594            .collect::<Vec<_>>();
1595        let players = self
1596            .hir
1597            .declarations
1598            .iter()
1599            .filter_map(|declaration| match declaration {
1600                hir::Declaration::PlayerVariable { index, span, .. } => Some((*index, *span)),
1601                _ => None,
1602            })
1603            .collect::<Vec<_>>();
1604        let subroutines = self
1605            .hir
1606            .declarations
1607            .iter()
1608            .filter_map(|declaration| match declaration {
1609                hir::Declaration::Subroutine { index, span, .. } => Some((*index, *span)),
1610                _ => None,
1611            })
1612            .collect::<Vec<_>>();
1613        let implicit_reserved = implicit_globals
1614            .keys()
1615            .map(|name| default_var_index(name).expect("implicit default variable names resolve"))
1616            .collect::<HashSet<_>>();
1617        let implicit_player_reserved = implicit_players
1618            .keys()
1619            .map(|name| default_var_index(name).expect("implicit default player names resolve"))
1620            .collect::<HashSet<_>>();
1621        let translation_helper_index = self.translation_helper_index(&implicit_reserved)?;
1622        let mut global_reserved = implicit_reserved.clone();
1623        if let Some(index) = translation_helper_index {
1624            global_reserved.insert(index);
1625        }
1626        let empty = HashSet::new();
1627        let global_indices = allocate_indices(&globals, &global_reserved, "global variable")?;
1628        let player_indices =
1629            allocate_indices(&players, &implicit_player_reserved, "player variable")?;
1630        let subroutine_indices = allocate_indices(&subroutines, &empty, "subroutine")?;
1631        let mut global_index = 0;
1632        let mut player_index = 0;
1633        let mut subroutine_index = 0;
1634
1635        // Declared variables in source order (for duplicate detection and
1636        // initializer action order), then merged with the implicit default
1637        // variables and created in Workshop index order so the emitted
1638        // variable tables are reference-compatible.
1639        let mut declared_globals: Vec<(&str, u32, Option<HirSpan>, Option<HirSpan>)> = Vec::new();
1640        let mut global_initializers = Vec::new();
1641        let mut declared_players: Vec<(&str, u32, Option<HirSpan>, Option<HirSpan>)> = Vec::new();
1642        let mut player_initializers = Vec::new();
1643        let mut declared_subroutines: Vec<(&str, u32, Option<HirSpan>, Option<HirSpan>)> =
1644            Vec::new();
1645
1646        for declaration in &self.hir.declarations {
1647            match declaration {
1648                hir::Declaration::GlobalVariable {
1649                    name,
1650                    index: _,
1651                    span,
1652                    name_span,
1653                    initializer,
1654                } => {
1655                    let assigned = global_indices[global_index];
1656                    global_index += 1;
1657                    if declared_globals
1658                        .iter()
1659                        .any(|(existing, ..)| *existing == name)
1660                    {
1661                        return Err(IntegrationError::new(
1662                            "symbol-collision",
1663                            format!("duplicate global variable '{name}'"),
1664                            *span,
1665                        ));
1666                    }
1667                    declared_globals.push((name, assigned, *span, *name_span));
1668                    if let Some(init) = initializer {
1669                        if !is_zero_initializer(init) {
1670                            global_initializers.push((name, init, *span, *name_span));
1671                        }
1672                    }
1673                }
1674                hir::Declaration::PlayerVariable {
1675                    name,
1676                    index: _,
1677                    span,
1678                    name_span,
1679                    initializer,
1680                } => {
1681                    let assigned = player_indices[player_index];
1682                    player_index += 1;
1683                    if declared_players
1684                        .iter()
1685                        .any(|(existing, ..)| *existing == name)
1686                    {
1687                        return Err(IntegrationError::new(
1688                            "symbol-collision",
1689                            format!("duplicate player variable '{name}'"),
1690                            *span,
1691                        ));
1692                    }
1693                    declared_players.push((name, assigned, *span, *name_span));
1694                    if let Some(init) = initializer {
1695                        if !is_zero_initializer(init) {
1696                            player_initializers.push((name, init, *span, *name_span));
1697                        }
1698                    }
1699                }
1700                hir::Declaration::Subroutine {
1701                    name,
1702                    span,
1703                    name_span,
1704                    ..
1705                } => {
1706                    let assigned = subroutine_indices[subroutine_index];
1707                    subroutine_index += 1;
1708                    if declared_subroutines
1709                        .iter()
1710                        .any(|(existing, ..)| *existing == name)
1711                    {
1712                        return Err(IntegrationError::new(
1713                            "symbol-collision",
1714                            format!("duplicate subroutine '{name}'"),
1715                            *span,
1716                        ));
1717                    }
1718                    declared_subroutines.push((name, assigned, *span, *name_span));
1719                }
1720                hir::Declaration::Constant { name, value, span } => {
1721                    if self.constants.insert(name.clone(), value).is_some() {
1722                        return Err(IntegrationError::new(
1723                            "symbol-collision",
1724                            format!("duplicate constant '{name}'"),
1725                            *span,
1726                        ));
1727                    }
1728                }
1729                hir::Declaration::Macro { .. } => {
1730                    // Macro definitions are retained for source tooling; calls
1731                    // are expanded before this WIR lowering pass.
1732                }
1733            }
1734        }
1735
1736        let mut planned_globals: Vec<(String, u32, Option<HirSpan>, Option<HirSpan>)> =
1737            declared_globals
1738                .into_iter()
1739                .map(|(name, index, span, name_span)| (name.to_string(), index, span, name_span))
1740                .collect();
1741        planned_globals.extend(implicit_globals.iter().map(|(name, span)| {
1742            (
1743                name.clone(),
1744                default_var_index(name).expect("implicit default variable names resolve"),
1745                *span,
1746                None,
1747            )
1748        }));
1749        if let Some(index) = translation_helper_index {
1750            planned_globals.push((TRANSLATION_HELPER_NAME.to_string(), index, None, None));
1751        }
1752        planned_globals.sort_by_key(|(_, index, ..)| *index);
1753        for (name, assigned, span, name_span) in planned_globals {
1754            let id = self.wir.global_variables.push(wir::WorkshopVariable {
1755                name: name.clone(),
1756                index: assigned,
1757                span: self.wir_span(span)?,
1758                name_span: self.wir_span(name_span)?,
1759            });
1760            self.globals.insert(name.clone(), id);
1761        }
1762
1763        let mut planned_players: Vec<(String, u32, Option<HirSpan>, Option<HirSpan>)> =
1764            declared_players
1765                .into_iter()
1766                .map(|(name, index, span, name_span)| (name.to_string(), index, span, name_span))
1767                .collect();
1768        planned_players.extend(implicit_players.iter().map(|(name, span)| {
1769            (
1770                name.clone(),
1771                default_var_index(name).expect("implicit default player names resolve"),
1772                *span,
1773                None,
1774            )
1775        }));
1776        planned_players.sort_by_key(|(_, index, ..)| *index);
1777        for (name, assigned, span, name_span) in planned_players {
1778            let id = self.wir.player_variables.push(wir::WorkshopVariable {
1779                name: name.clone(),
1780                index: assigned,
1781                span: self.wir_span(span)?,
1782                name_span: self.wir_span(name_span)?,
1783            });
1784            self.players.insert(name, id);
1785        }
1786
1787        declared_subroutines.sort_by_key(|(_, index, ..)| *index);
1788        for (name, assigned, span, name_span) in declared_subroutines {
1789            let id = self.wir.subroutines.push(wir::WorkshopSubroutine {
1790                name: name.to_string(),
1791                index: assigned,
1792                span: self.wir_span(span)?,
1793                name_span: self.wir_span(name_span)?,
1794            });
1795            self.subroutines.insert(name.to_string(), id);
1796        }
1797
1798        let translation_initializer = self
1799            .hir
1800            .preprocessing
1801            .translations
1802            .as_ref()
1803            .map(|translations| {
1804                let variable = *self
1805                    .globals
1806                    .get(TRANSLATION_HELPER_NAME)
1807                    .expect("translation helper variable is created");
1808                let value = self.lower_translation_helper(translations)?;
1809                Ok(self.wir.actions.push(Action::SetGlobalVariable {
1810                    variable,
1811                    value,
1812                    span: self.wir_span(translations.span)?,
1813                    target_span: None,
1814                }))
1815            })
1816            .transpose()?;
1817
1818        if translation_initializer.is_some() || !global_initializers.is_empty() {
1819            let mut actions = Vec::with_capacity(
1820                global_initializers.len() + usize::from(translation_initializer.is_some()),
1821            );
1822            if let Some(action) = translation_initializer {
1823                actions.push(action);
1824            }
1825            for (name, init_expr, span, target_span) in global_initializers {
1826                let variable = *self.globals.get(name).expect("declared global is created");
1827                let value = self.lower_value(init_expr)?;
1828                actions.push(self.wir.actions.push(Action::SetGlobalVariable {
1829                    variable,
1830                    value,
1831                    span: self.wir_span(span)?,
1832                    target_span: self.wir_span(target_span)?,
1833                }));
1834            }
1835            self.wir.rules.push(wir::Rule {
1836                name: self.global_initializer_rule_name(),
1837                span: None,
1838                name_span: None,
1839                disabled: false,
1840                event: Event::Global,
1841                conditions: Vec::new(),
1842                actions,
1843            });
1844        }
1845
1846        if !player_initializers.is_empty() {
1847            let mut actions = Vec::with_capacity(player_initializers.len());
1848            for (name, init_expr, span, target_span) in player_initializers {
1849                let variable = *self
1850                    .players
1851                    .get(name)
1852                    .expect("declared player variable is created");
1853                let player = self
1854                    .wir
1855                    .values
1856                    .push(ValueNode::new(Value::EventPlayer, None));
1857                let value = self.lower_value(init_expr)?;
1858                actions.push(self.wir.actions.push(Action::SetPlayerVariable {
1859                    player,
1860                    variable,
1861                    value,
1862                    span: self.wir_span(span)?,
1863                    target_span: self.wir_span(target_span)?,
1864                }));
1865            }
1866            self.wir.rules.push(wir::Rule {
1867                name: "Initialize player variables".to_string(),
1868                span: None,
1869                name_span: None,
1870                disabled: false,
1871                event: Event::EachPlayer,
1872                conditions: Vec::new(),
1873                actions,
1874            });
1875        }
1876
1877        Ok(())
1878    }
1879
1880    fn lower_rules(&mut self) -> Result<(), IntegrationError> {
1881        for entry in &self.hir.rules {
1882            match entry {
1883                RuleEntry::Rule(rule) => self.lower_rule(rule)?,
1884                RuleEntry::SubroutineDef {
1885                    name,
1886                    source_name,
1887                    span,
1888                    name_span,
1889                    body,
1890                    annotations,
1891                    ..
1892                } => {
1893                    self.lower_subroutine(name, source_name, *span, *name_span, body, annotations)?
1894                }
1895            }
1896        }
1897        Ok(())
1898    }
1899
1900    fn lower_rule(&mut self, rule: &hir::Rule) -> Result<(), IntegrationError> {
1901        if rule.disabled {
1902            return Ok(());
1903        }
1904        self.reject_rule_metadata(rule)?;
1905        let event = self.lower_event(&rule.event, &rule.annotations)?;
1906        let conditions = rule
1907            .conditions
1908            .iter()
1909            .map(|expr| self.lower_condition(expr))
1910            .collect::<Result<Vec<_>, _>>()?;
1911        let previous_conditions = self.current_rule_conditions.replace(conditions.clone());
1912        let lowered_actions = self.lower_actions(&rule.actions, None);
1913        self.current_rule_conditions = previous_conditions;
1914        let mut actions = Vec::new();
1915        actions.extend(lowered_actions?);
1916        self.wir.rules.push(wir::Rule {
1917            name: rule.name.clone(),
1918            span: self.wir_span(rule.span)?,
1919            name_span: self.wir_span(rule.name_span)?,
1920            disabled: rule.disabled,
1921            event,
1922            conditions,
1923            actions,
1924        });
1925        Ok(())
1926    }
1927
1928    fn lower_subroutine(
1929        &mut self,
1930        name: &str,
1931        source_name: &str,
1932        span: Option<HirSpan>,
1933        name_span: Option<HirSpan>,
1934        body: &[Stmt],
1935        annotations: &[hir::Annotation],
1936    ) -> Result<(), IntegrationError> {
1937        self.reject_subroutine_metadata(annotations)?;
1938        let source_name = if source_name.is_empty() {
1939            name
1940        } else {
1941            source_name
1942        };
1943        let subroutine = *self.subroutines.get(source_name).ok_or_else(|| {
1944            self.unsupported(
1945                format!("subroutine definition '{source_name}' has no declaration"),
1946                name_span.or(span),
1947            )
1948        })?;
1949        if !self.defined_subroutines.insert(subroutine) {
1950            return Err(self.unsupported(
1951                format!("subroutine '{source_name}' has multiple definitions"),
1952                name_span.or(span),
1953            ));
1954        }
1955        let mut actions = Vec::new();
1956        actions.extend(self.lower_actions(body, None)?);
1957        self.wir.rules.push(wir::Rule {
1958            name: self.subroutine_rule_name(name),
1959            span: self.wir_span(span)?,
1960            name_span: self.wir_span(name_span)?,
1961            disabled: false,
1962            event: Event::Subroutine(subroutine),
1963            conditions: Vec::new(),
1964            actions,
1965        });
1966        Ok(())
1967    }
1968
1969    fn reject_rule_metadata(&self, rule: &hir::Rule) -> Result<(), IntegrationError> {
1970        if rule.delimiter {
1971            let span = rule
1972                .annotations
1973                .iter()
1974                .find(|annotation| annotation.name == "Delimiter")
1975                .and_then(|annotation| annotation.span)
1976                .or(rule.span);
1977            return Err(self.unsupported(
1978                "rule delimiter metadata is not representable in canonical WIR",
1979                span,
1980            ));
1981        }
1982        if rule.new_page.is_some() {
1983            let span = rule
1984                .annotations
1985                .iter()
1986                .find(|annotation| annotation.name == "NewPage")
1987                .and_then(|annotation| annotation.span)
1988                .or(rule.span);
1989            return Err(self.unsupported(
1990                "rule new-page metadata is not representable in canonical WIR",
1991                span,
1992            ));
1993        }
1994        for annotation in &rule.annotations {
1995            match annotation.name.as_str() {
1996                "Event" | "Condition" | "Team" | "Slot" | "Hero" | "Disabled"
1997                | "SuppressWarnings" => {}
1998                _ => {
1999                    return Err(self.unsupported(
2000                        format!(
2001                            "rule annotation '{}' is not representable in canonical WIR",
2002                            annotation.name
2003                        ),
2004                        annotation.span.or(rule.span),
2005                    ));
2006                }
2007            }
2008        }
2009        Ok(())
2010    }
2011
2012    fn reject_subroutine_metadata(
2013        &self,
2014        annotations: &[hir::Annotation],
2015    ) -> Result<(), IntegrationError> {
2016        for annotation in annotations {
2017            match annotation.name.as_str() {
2018                "Name" | "SuppressWarnings" => {}
2019                _ => {
2020                    return Err(self.unsupported(
2021                        format!(
2022                            "subroutine annotation '{}' is not representable in canonical WIR",
2023                            annotation.name
2024                        ),
2025                        annotation.span,
2026                    ));
2027                }
2028            }
2029        }
2030        Ok(())
2031    }
2032
2033    fn subroutine_rule_name(&self, generated_name: &str) -> String {
2034        if self.hir.preprocessing.rule_prefix_template.is_some() {
2035            generated_name.to_string()
2036        } else {
2037            format!("Subroutine {generated_name}")
2038        }
2039    }
2040
2041    fn global_initializer_rule_name(&self) -> String {
2042        if self.hir.preprocessing.rule_prefix_template.is_some() {
2043            "[] Initialize global variables".to_string()
2044        } else {
2045            "Initialize global variables".to_string()
2046        }
2047    }
2048
2049    fn lower_event(
2050        &self,
2051        event: &hir::Event,
2052        annotations: &[hir::Annotation],
2053    ) -> Result<Event, IntegrationError> {
2054        if !event.args.is_empty() {
2055            return Err(self.unsupported(
2056                "event arguments are not representable in canonical WIR; use structural event filters",
2057                event.span,
2058            ));
2059        }
2060        let team = self.lower_event_team(annotations)?;
2061        let target = self.lower_event_target(annotations)?;
2062        let has_filters =
2063            !matches!(team, wir::EventTeam::All) || !matches!(target, wir::EventTarget::All);
2064        match event.name.as_str() {
2065            "global" => {
2066                if has_filters {
2067                    return Err(
2068                        self.unsupported("global events cannot have player filters", event.span)
2069                    );
2070                }
2071                Ok(Event::Global)
2072            }
2073            "eachPlayer" => {
2074                if has_filters {
2075                    Ok(Event::EachPlayerWithFilters { team, target })
2076                } else {
2077                    Ok(Event::EachPlayer)
2078                }
2079            }
2080            name => player_event_kind(name).map_or_else(
2081                || {
2082                    Err(self.unsupported(
2083                        format!("event '{name}' is not supported by canonical WIR"),
2084                        event.span,
2085                    ))
2086                },
2087                |kind| Ok(Event::Player { kind, team, target }),
2088            ),
2089        }
2090    }
2091
2092    fn lower_event_team(
2093        &self,
2094        annotations: &[hir::Annotation],
2095    ) -> Result<wir::EventTeam, IntegrationError> {
2096        let team_annotations = annotations
2097            .iter()
2098            .filter(|annotation| annotation.name == "Team")
2099            .collect::<Vec<_>>();
2100        if team_annotations.len() > 1 {
2101            return Err(self.unsupported(
2102                "an event cannot have multiple @Team filters",
2103                team_annotations[1].span.or(team_annotations[0].span),
2104            ));
2105        }
2106        let Some(annotation) = team_annotations.first() else {
2107            return Ok(wir::EventTeam::All);
2108        };
2109        let argument = annotation
2110            .args
2111            .first()
2112            .ok_or_else(|| self.unsupported("@Team requires one filter value", annotation.span))?;
2113        if annotation.args.len() != 1 {
2114            return Err(
2115                self.unsupported("@Team requires exactly one filter value", annotation.span)
2116            );
2117        }
2118        let spelling = match argument.text.as_str() {
2119            "1" => "Team 1",
2120            "2" => "Team 2",
2121            value => value,
2122        };
2123        let (_, member) = self
2124            .compiler
2125            .catalog
2126            .resolve_enum_member("EventTeam", &Locale::new("en-US"), spelling)
2127            .ok_or_else(|| {
2128                self.unsupported(
2129                    format!("unknown EventTeam filter '{spelling}'"),
2130                    argument.span.or(annotation.span),
2131                )
2132            })?;
2133        match member.as_str() {
2134            "ALL" => Ok(wir::EventTeam::All),
2135            "TEAM_1" => Ok(wir::EventTeam::Team1),
2136            "TEAM_2" => Ok(wir::EventTeam::Team2),
2137            _ => Err(self.unsupported(
2138                format!("catalog EventTeam member '{member}' is not supported by canonical WIR"),
2139                argument.span.or(annotation.span),
2140            )),
2141        }
2142    }
2143
2144    fn lower_event_target(
2145        &self,
2146        annotations: &[hir::Annotation],
2147    ) -> Result<wir::EventTarget, IntegrationError> {
2148        let mut filters = Vec::new();
2149        for name in ["Slot", "Hero"] {
2150            let matches = annotations
2151                .iter()
2152                .filter(|annotation| annotation.name == name)
2153                .collect::<Vec<_>>();
2154            if matches.len() > 1 {
2155                return Err(self.unsupported(
2156                    format!("an event cannot have multiple @{name} filters"),
2157                    matches[1].span.or(matches[0].span),
2158                ));
2159            }
2160            filters.extend(matches);
2161        }
2162        if filters.len() > 1 {
2163            return Err(self.unsupported(
2164                "an event cannot combine @Slot and @Hero filters",
2165                filters[1].span.or(filters[0].span),
2166            ));
2167        }
2168        let Some(annotation) = filters.first() else {
2169            return Ok(wir::EventTarget::All);
2170        };
2171        let argument = annotation.args.first().ok_or_else(|| {
2172            self.unsupported(
2173                format!("@{} requires one filter value", annotation.name),
2174                annotation.span,
2175            )
2176        })?;
2177        if annotation.args.len() != 1 {
2178            return Err(self.unsupported(
2179                format!("@{} requires exactly one filter value", annotation.name),
2180                annotation.span,
2181            ));
2182        }
2183        let spelling = if annotation.name == "Slot" {
2184            match argument.text.as_str() {
2185                value if value.parse::<u8>().is_ok() => {
2186                    format!("Slot {}", value.parse::<u8>().unwrap_or_default())
2187                }
2188                value => value.to_string(),
2189            }
2190        } else {
2191            argument.text.clone()
2192        };
2193        let domain = if annotation.name == "Slot" {
2194            "EventPlayer"
2195        } else {
2196            "Hero"
2197        };
2198        let locale = Locale::new("en-US");
2199        let catalog_spelling = match (domain, spelling.as_str()) {
2200            ("Hero", "mccree") => "CASSIDY",
2201            ("Hero", "hammond") => "WRECKING_BALL",
2202            ("Hero", "soldier") => "SOLDIER_76",
2203            ("Hero", "domina") => "JINYU",
2204            ("Hero", "dmon") => "D_MON",
2205            _ => spelling.as_str(),
2206        };
2207        let member = self
2208            .compiler
2209            .catalog
2210            .resolve_enum_member(domain, &locale, catalog_spelling)
2211            .map(|(_, member)| member)
2212            .or_else(|| {
2213                (domain == "Hero")
2214                    .then(|| {
2215                        self.compiler
2216                            .catalog
2217                            .enum_domain(domain)
2218                            .and_then(|domain| {
2219                                domain
2220                                    .members
2221                                    .iter()
2222                                    .find(|member| {
2223                                        member.member.eq_ignore_ascii_case(catalog_spelling)
2224                                            || member.spellings(&locale).iter().any(|candidate| {
2225                                                candidate.eq_ignore_ascii_case(catalog_spelling)
2226                                            })
2227                                            || member
2228                                                .member
2229                                                .chars()
2230                                                .filter(|c| c.is_ascii_alphanumeric())
2231                                                .collect::<String>()
2232                                                .eq_ignore_ascii_case(
2233                                                    &catalog_spelling
2234                                                        .chars()
2235                                                        .filter(|c| c.is_ascii_alphanumeric())
2236                                                        .collect::<String>(),
2237                                                )
2238                                    })
2239                                    .map(|member| member.member.clone())
2240                            })
2241                    })
2242                    .flatten()
2243            })
2244            .ok_or_else(|| {
2245                self.unsupported(
2246                    format!("unknown {domain} filter '{spelling}'"),
2247                    argument.span.or(annotation.span),
2248                )
2249            })?;
2250        if domain == "EventPlayer" {
2251            if member == "ALL" {
2252                Ok(wir::EventTarget::All)
2253            } else if let Some(slot) = member.strip_prefix("SLOT_") {
2254                let slot = slot.parse::<u8>().map_err(|_| {
2255                    self.unsupported(
2256                        format!("catalog EventPlayer member '{member}' is not a slot"),
2257                        argument.span.or(annotation.span),
2258                    )
2259                })?;
2260                Ok(wir::EventTarget::Slot(slot))
2261            } else {
2262                Err(self.unsupported(
2263                    format!(
2264                        "catalog EventPlayer member '{member}' is not supported by canonical WIR"
2265                    ),
2266                    argument.span.or(annotation.span),
2267                ))
2268            }
2269        } else {
2270            Ok(wir::EventTarget::Hero(member))
2271        }
2272    }
2273
2274    fn lower_actions(
2275        &mut self,
2276        statements: &[Stmt],
2277        break_target: Option<BreakTarget>,
2278    ) -> Result<Vec<wir::ActionId>, IntegrationError> {
2279        self.visible_labels.push(
2280            statements
2281                .iter()
2282                .filter_map(|statement| match statement {
2283                    Stmt::Label { name, .. } => Some(name.clone()),
2284                    _ => None,
2285                })
2286                .collect(),
2287        );
2288        let mut actions = Vec::new();
2289        let mut labels = HashMap::new();
2290        let mut gotos = Vec::new();
2291        let mut index = 0;
2292        while index < statements.len() {
2293            let statement = &statements[index];
2294            if let Stmt::If {
2295                branches,
2296                r#else,
2297                span,
2298            } = statement
2299            {
2300                if branches.len() == 1 && r#else.is_none() {
2301                    let branch = &branches[0];
2302                    if let Some((conditions, label)) =
2303                        branch.body.first().and_then(pure_goto_conditions)
2304                    {
2305                        let target = statements[index + 1..]
2306                            .iter()
2307                            .position(|candidate| {
2308                                matches!(candidate, Stmt::Label { name, .. } if name == label)
2309                            });
2310                        if target.is_some() && conditions.is_empty() {
2311                            let condition = self.lower_value(&branch.condition)?;
2312                            let condition = self.push_call("not", vec![condition]);
2313                            let body = self.lower_actions(&branch.body[1..], break_target)?;
2314                            actions.push(self.wir.actions.push(Action::If {
2315                                branches: vec![wir::IfBranch { condition, body }],
2316                                else_body: None,
2317                                span: self.wir_span(*span)?,
2318                            }));
2319                            index += 1;
2320                            continue;
2321                        }
2322                    }
2323                }
2324                let external_jump =
2325                    branches
2326                        .iter()
2327                        .enumerate()
2328                        .find_map(|(branch_index, branch)| {
2329                            let (conditions, label) = pure_goto_conditions(branch.body.last()?)?;
2330                            let target = statements[index + 1..]
2331                                .iter()
2332                                .position(|candidate| {
2333                                    matches!(candidate, Stmt::Label { name, .. } if name == label)
2334                                })
2335                                .map(|offset| index + 1 + offset)
2336                                .or_else(|| {
2337                                    self.outer_goto_targets
2338                                        .last()
2339                                        .is_some_and(|target| target == label)
2340                                        .then_some(statements.len())
2341                                })?;
2342                            Some((
2343                                branch_index,
2344                                conditions.into_iter().cloned().collect::<Vec<_>>(),
2345                                label.to_string(),
2346                                target,
2347                            ))
2348                        });
2349                if let Some((exit_branch, exit_conditions, label, target)) = external_jump {
2350                    self.outer_goto_targets.push(label.clone());
2351                    let middle_result =
2352                        self.lower_actions(&statements[index + 1..target], break_target);
2353                    let middle = middle_result?;
2354                    self.resolve_deferred_gotos(&middle, label.as_str(), middle.len())?;
2355                    let distance = self.canonical_action_width(&middle, *span)?;
2356                    let mut lowered_branches = Vec::with_capacity(branches.len());
2357                    for (branch_index, branch) in branches.iter().enumerate() {
2358                        let body = if branch_index == exit_branch {
2359                            &branch.body[..branch.body.len() - 1]
2360                        } else {
2361                            &branch.body[..]
2362                        };
2363                        lowered_branches.push(wir::IfBranch {
2364                            condition: self.lower_value(&branch.condition)?,
2365                            body: self.lower_actions(body, break_target)?,
2366                        });
2367                    }
2368                    let else_body = r#else
2369                        .as_ref()
2370                        .map(|body| self.lower_actions(body, break_target))
2371                        .transpose()?;
2372                    self.outer_goto_targets.pop();
2373                    actions.push(self.wir.actions.push(Action::If {
2374                        branches: lowered_branches,
2375                        else_body,
2376                        span: self.wir_span(*span)?,
2377                    }));
2378                    let mut condition = self.lower_value(&branches[exit_branch].condition)?;
2379                    for expression in exit_conditions {
2380                        let right = self.lower_value(&expression)?;
2381                        condition = self.push_call("and", vec![condition, right]);
2382                    }
2383                    let distance_value = self.push_number(distance as f64, &distance.to_string());
2384                    actions.push(self.wir.actions.push(Action::Call {
2385                        name: "skipIf".to_string(),
2386                        args: vec![condition, distance_value],
2387                        span: self.wir_span(*span)?,
2388                    }));
2389                    actions.extend(middle);
2390                    index = target + 1;
2391                    continue;
2392                }
2393            }
2394            if let Some((conditions, label)) = pure_goto_conditions(statement) {
2395                let target = statements[index + 1..]
2396                    .iter()
2397                    .position(
2398                        |candidate| matches!(candidate, Stmt::Label { name, .. } if name == label),
2399                    )
2400                    .map(|offset| index + 1 + offset)
2401                    .or_else(|| {
2402                        self.outer_goto_targets
2403                            .last()
2404                            .is_some_and(|target| target == label)
2405                            .then_some(statements.len())
2406                    });
2407                if let Some(target) = target {
2408                    self.outer_goto_targets.push(label.to_string());
2409                    let middle_result =
2410                        self.lower_actions(&statements[index + 1..target], break_target);
2411                    self.outer_goto_targets.pop();
2412                    let middle = middle_result?;
2413                    let span = statement.span().copied();
2414                    self.resolve_deferred_gotos(&middle, label, middle.len())?;
2415                    let distance = self.canonical_action_width(&middle, span)?;
2416                    let mut args = Vec::with_capacity(conditions.len() + 1);
2417                    if let Some((first, rest)) = conditions.split_first() {
2418                        let mut condition = self.lower_value(first)?;
2419                        for expression in rest {
2420                            let right = self.lower_value(expression)?;
2421                            condition = self.push_call("and", vec![condition, right]);
2422                        }
2423                        args.push(condition);
2424                    }
2425                    args.push(self.push_number(distance as f64, &distance.to_string()));
2426                    actions.push(
2427                        self.wir.actions.push(Action::Call {
2428                            name: if conditions.is_empty() {
2429                                "skip"
2430                            } else {
2431                                "skipIf"
2432                            }
2433                            .to_string(),
2434                            args,
2435                            span: self.wir_span(span)?,
2436                        }),
2437                    );
2438                    actions.extend(middle);
2439                    index = target + 1;
2440                    continue;
2441                }
2442            }
2443            match statement {
2444                Stmt::Label { name, .. } => {
2445                    labels.insert(name.clone(), actions.len());
2446                }
2447                Stmt::Goto {
2448                    label,
2449                    offset,
2450                    rule_start,
2451                    span,
2452                } => {
2453                    if *rule_start {
2454                        return Err(self.unsupported(
2455                            "goto RULE_START is not representable in canonical WIR",
2456                            *span,
2457                        ));
2458                    }
2459                    let placeholder = self.push_number(0.0, "0");
2460                    let action = self.wir.actions.push(Action::Call {
2461                        name: "skip".to_string(),
2462                        args: vec![placeholder],
2463                        span: self.wir_span(*span)?,
2464                    });
2465                    let position = actions.len();
2466                    actions.push(action);
2467                    gotos.push((
2468                        action,
2469                        position,
2470                        label.clone(),
2471                        offset.clone(),
2472                        span.map(Into::into),
2473                    ));
2474                }
2475                _ => actions.extend(self.lower_action(statement, break_target)?),
2476            }
2477            index += 1;
2478        }
2479        for (action, position, label, offset, span) in gotos {
2480            let distance = if let Some(offset) = offset {
2481                self.lower_value(&offset)?
2482            } else {
2483                let Some(label) = label else {
2484                    return Err(self.unsupported("goto is missing a label or offset", span));
2485                };
2486                let Some(&target) = labels.get(&label) else {
2487                    if self
2488                        .visible_labels
2489                        .iter()
2490                        .any(|labels| labels.contains(&label))
2491                    {
2492                        self.deferred_gotos.push((action, label, span));
2493                        continue;
2494                    }
2495                    return Err(self.unsupported(format!("unknown goto label '{label}'"), span));
2496                };
2497                if target < position {
2498                    return Err(self
2499                        .unsupported("backward goto is not representable in canonical WIR", span));
2500                }
2501                let width = self.canonical_action_width(&actions[position + 1..target], span)?;
2502                self.push_number(width as f64, &width.to_string())
2503            };
2504            let Some(Action::Call { args, .. }) = self.wir.actions.get_mut(action) else {
2505                unreachable!("goto placeholder must be a call action")
2506            };
2507            args[0] = distance;
2508        }
2509        if self.visible_labels.len() == 1 && !self.deferred_gotos.is_empty() {
2510            let (_, label, span) = self.deferred_gotos.remove(0);
2511            return Err(self.unsupported(format!("unknown goto label '{label}'"), span));
2512        }
2513        self.visible_labels.pop();
2514        Ok(actions)
2515    }
2516
2517    fn resolve_deferred_gotos(
2518        &mut self,
2519        actions: &[wir::ActionId],
2520        label: &str,
2521        target: usize,
2522    ) -> Result<(), IntegrationError> {
2523        let deferred = std::mem::take(&mut self.deferred_gotos);
2524        let mut remaining = Vec::new();
2525        for (action, deferred_label, span) in deferred {
2526            if deferred_label != label {
2527                remaining.push((action, deferred_label, span));
2528                continue;
2529            }
2530            let Some(position) = actions.iter().position(|candidate| *candidate == action) else {
2531                remaining.push((action, deferred_label, span));
2532                continue;
2533            };
2534            if target < position {
2535                return Err(
2536                    self.unsupported("backward goto is not representable in canonical WIR", span)
2537                );
2538            }
2539            let width = self.canonical_action_width(&actions[position + 1..target], span)?;
2540            let distance = self.push_number(width as f64, &width.to_string());
2541            let Some(Action::Call { args, .. }) = self.wir.actions.get_mut(action) else {
2542                unreachable!("deferred goto placeholder must be a call action")
2543            };
2544            args[0] = distance;
2545        }
2546        self.deferred_gotos = remaining;
2547        Ok(())
2548    }
2549
2550    fn lower_action(
2551        &mut self,
2552        stmt: &Stmt,
2553        break_target: Option<BreakTarget>,
2554    ) -> Result<Vec<wir::ActionId>, IntegrationError> {
2555        match stmt {
2556            Stmt::Pass { .. } => Ok(Vec::new()),
2557            Stmt::Assign {
2558                target,
2559                value,
2560                span,
2561            } => self.lower_assign(target, value, *span).map(|action| vec![action]),
2562            Stmt::If {
2563                branches,
2564                r#else,
2565                span,
2566            } => {
2567                let branches = branches
2568                    .iter()
2569                    .map(|branch| {
2570                        Ok(wir::IfBranch {
2571                            condition: self.lower_value(&branch.condition)?,
2572                            body: self.lower_actions(&branch.body, break_target)?,
2573                        })
2574                    })
2575                    .collect::<Result<Vec<_>, IntegrationError>>()?;
2576                let else_body = r#else
2577                    .as_ref()
2578                    .map(|body| self.lower_actions(body, break_target))
2579                    .transpose()?;
2580                Ok(vec![self.wir.actions.push(Action::If {
2581                    branches,
2582                    else_body,
2583                    span: self.wir_span(*span)?,
2584                })])
2585            }
2586            Stmt::For {
2587                variable,
2588                iterable,
2589                body,
2590                span,
2591            } => {
2592                let (start, stop, step) = self.lower_range(iterable)?;
2593                let body = self.lower_loop_body(body)?;
2594                match variable.as_ref() {
2595                    Expr::GlobalVar {
2596                        name,
2597                        span: target_span,
2598                    } => {
2599                        let variable_id = *self.globals.get(name).ok_or_else(|| {
2600                            self.unsupported(
2601                                format!("unknown global variable '{name}'"),
2602                                *target_span,
2603                            )
2604                        })?;
2605                        Ok(vec![self.wir.actions.push(Action::ForGlobalVariable {
2606                            variable: variable_id,
2607                            start,
2608                            stop,
2609                            step,
2610                            body,
2611                            span: self.wir_span(*span)?,
2612                            target_span: self.wir_span(*target_span)?,
2613                        })])
2614                    }
2615                    Expr::PlayerVar {
2616                        player,
2617                        name,
2618                        span: target_span,
2619                        ..
2620                    } => {
2621                        let variable_id = *self.players.get(name).ok_or_else(|| {
2622                            self.unsupported(
2623                                format!("unknown player variable '{name}'"),
2624                                *target_span,
2625                            )
2626                        })?;
2627                        let player = self.lower_value(player)?;
2628                        Ok(vec![self.wir.actions.push(Action::ForPlayerVariable {
2629                            player,
2630                            variable: variable_id,
2631                            start,
2632                            stop,
2633                            step,
2634                            body,
2635                            span: self.wir_span(*span)?,
2636                        })])
2637                    }
2638                    _ => Err(self.unsupported(
2639                        "range loops require a global- or player-variable binder in canonical WIR",
2640                        variable.span().copied(),
2641                    )),
2642                }
2643            }
2644            Stmt::While {
2645                condition,
2646                body,
2647                span,
2648            } => {
2649                let condition = self.lower_value(condition)?;
2650                let body = self.lower_loop_body(body)?;
2651                Ok(vec![self.wir.actions.push(Action::While {
2652                    condition,
2653                    body,
2654                    span: self.wir_span(*span)?,
2655                })])
2656            }
2657            Stmt::DoWhile {
2658                condition,
2659                body,
2660                span,
2661            } => {
2662                let body = self.lower_do_while_body(body)?;
2663                let condition = self.lower_value(condition)?;
2664                let loop_if = self.wir.actions.push(Action::Call {
2665                    name: "loopIf".to_string(),
2666                    args: vec![condition],
2667                    span: self.wir_span(*span)?,
2668                });
2669                // OverPy's pinned lowering expands do/while into its body
2670                // followed by the canonical Loop If action.
2671                let mut actions = body;
2672                actions.push(loop_if);
2673                Ok(actions)
2674            }
2675            Stmt::Switch {
2676                value,
2677                arms,
2678                span,
2679            } => self.lower_switch(value, arms, *span).map(|action| vec![action]),
2680            Stmt::Delete { target, span } => self.lower_delete(target, *span).map(|action| vec![action]),
2681            Stmt::Continue { span } => Err(self.unsupported(
2682                "continue statements are only lowered while constructing a loop body",
2683                *span,
2684            )),
2685            Stmt::Goto { span, .. } => Err(self.unsupported(
2686                "goto statements are not representable in canonical WIR",
2687                *span,
2688            )),
2689            Stmt::Label { span, .. } => Err(self.unsupported(
2690                "labels are not representable in canonical WIR",
2691                *span,
2692            )),
2693            Stmt::Break { span } => match break_target {
2694                Some(BreakTarget::Loop) => Ok(vec![self.wir.actions.push(Action::Call {
2695                    name: "break".to_string(),
2696                    args: Vec::new(),
2697                    span: self.wir_span(*span)?,
2698                })]),
2699                Some(BreakTarget::DoWhile) => Err(self.unsupported(
2700                    "break inside a do-while must be a direct statement or a single conditional break",
2701                    *span,
2702                )),
2703                Some(BreakTarget::Switch) => Err(self.unsupported(
2704                    "break inside a nested conditional cannot be normalized into canonical switch control flow",
2705                    *span,
2706                )),
2707                None => Err(self.unsupported(
2708                    "break has no enclosing canonical loop or switch",
2709                    *span,
2710                )),
2711            },
2712            Stmt::Return { span } => {
2713                let true_value = self.wir.values.push(ValueNode::new(
2714                    Value::Bool(true),
2715                    self.wir_span(*span)?,
2716                ));
2717                Ok(vec![self.wir.actions.push(Action::Call {
2718                    name: "abortIf".to_string(),
2719                    args: vec![true_value],
2720                    span: self.wir_span(*span)?,
2721                })])
2722            }
2723            Stmt::Expr { expr, span } => match expr.as_ref() {
2724                Expr::Call { name, args, .. } => {
2725                    if name == "disableInspector" && args.is_empty() {
2726                        Ok(vec![self.wir.actions.push(Action::Call {
2727                            name: "disableInspector".to_string(),
2728                            args: Vec::new(),
2729                            span: self.wir_span(*span)?,
2730                        })])
2731                    } else if name == "debug" && args.len() == 1 {
2732                        Ok(vec![self.lower_debug(&args[0], *span)?])
2733                    } else if name == "print" && args.len() == 1 {
2734                        Ok(vec![self.lower_print(&args[0], *span)?])
2735                    } else {
2736                        self.lower_action_call(name, args, *span).map(|action| vec![action])
2737                    }
2738                }
2739                Expr::ReceiverCall {
2740                    receiver,
2741                    name,
2742                    args,
2743                    span: call_span,
2744                } => self
2745                    .lower_receiver_action_call(receiver, name, args, *call_span)
2746                    .map(|action| vec![action]),
2747                _ => Err(self.unsupported(
2748                    "only action calls are currently representable as expression statements in canonical WIR",
2749                    *span,
2750                )),
2751            },
2752            Stmt::CallSubroutine { name, span } => {
2753                let subroutine = *self.subroutines.get(name).ok_or_else(|| {
2754                    self.unsupported(format!("unknown subroutine '{name}'"), *span)
2755                })?;
2756                let span = self.wir_span(*span)?;
2757                Ok(vec![self.wir.actions.push(Action::CallSubroutine {
2758                    subroutine,
2759                    span,
2760                    callee_span: span,
2761                })])
2762            }
2763        }
2764    }
2765
2766    /// Lower a loop body while preserving OverPy's continue jump layout.
2767    /// Continue skips the remaining canonical actions in the current body;
2768    /// when it is the sole action of a conditional branch, the conditional
2769    /// jump is lifted to the loop body's action list so its distance reaches
2770    /// the loop continuation label.
2771    fn lower_loop_body(
2772        &mut self,
2773        statements: &[Stmt],
2774    ) -> Result<Vec<wir::ActionId>, IntegrationError> {
2775        self.lower_loop_sequence(statements, &[], 0)
2776    }
2777
2778    /// Lower one sequence nested inside a loop. `after` contains already
2779    /// lowered actions that follow this sequence before the loop continues;
2780    /// `structural_after` counts non-action lines that follow this sequence
2781    /// before those actions. This lets a continue remain inside its authored
2782    /// conditional while still reaching the nearest loop continuation label.
2783    fn lower_loop_sequence(
2784        &mut self,
2785        statements: &[Stmt],
2786        after: &[wir::ActionId],
2787        structural_after: usize,
2788    ) -> Result<Vec<wir::ActionId>, IntegrationError> {
2789        let mut actions = Vec::new();
2790        let mut index = 0;
2791        while index < statements.len() {
2792            let statement = &statements[index];
2793            let tail = &statements[index + 1..];
2794            if let Some(conditions) = pure_continue_conditions(statement) {
2795                let tail = self.lower_loop_sequence(tail, after, structural_after)?;
2796                let distance = self.canonical_action_width(&tail, statement.span().copied())?
2797                    + structural_after
2798                    + self.canonical_action_width(after, statement.span().copied())?;
2799                if distance > 0 {
2800                    let mut args = Vec::with_capacity(conditions.len() + 1);
2801                    if let Some((first, rest)) = conditions.split_first() {
2802                        let mut condition = self.lower_value(first)?;
2803                        for expression in rest {
2804                            let right = self.lower_value(expression)?;
2805                            condition = self.push_call("and", vec![condition, right]);
2806                        }
2807                        args.push(condition);
2808                    }
2809                    let distance = self.wir.values.push(ValueNode::new(
2810                        Value::Number {
2811                            value: distance as f64,
2812                            text: distance.to_string(),
2813                        },
2814                        self.wir_span(statement.span().copied())?,
2815                    ));
2816                    args.push(distance);
2817                    actions.push(
2818                        self.wir.actions.push(Action::Call {
2819                            name: if conditions.is_empty() {
2820                                "skip"
2821                            } else {
2822                                "skipIf"
2823                            }
2824                            .to_string(),
2825                            args,
2826                            span: self.wir_span(statement.span().copied())?,
2827                        }),
2828                    );
2829                }
2830                actions.extend(tail);
2831                return Ok(actions);
2832            }
2833            if contains_loop_continue(statement) {
2834                let tail = self.lower_loop_sequence(tail, after, structural_after)?;
2835                let mut continuation_after = tail.clone();
2836                continuation_after.extend_from_slice(after);
2837                let lowered = self.lower_if_with_loop_continue(
2838                    statement,
2839                    &continuation_after,
2840                    structural_after,
2841                )?;
2842                actions.push(lowered);
2843                actions.extend(tail);
2844                return Ok(actions);
2845            }
2846            if let Some((conditions, label)) = pure_goto_conditions(statement) {
2847                if let Some(target) = statements[index + 1..]
2848                    .iter()
2849                    .position(
2850                        |candidate| matches!(candidate, Stmt::Label { name, .. } if name == label),
2851                    )
2852                    .map(|offset| index + 1 + offset)
2853                {
2854                    let middle = self.lower_loop_sequence(
2855                        &statements[index + 1..target],
2856                        after,
2857                        structural_after,
2858                    )?;
2859                    let suffix = self.lower_loop_sequence(
2860                        &statements[target + 1..],
2861                        after,
2862                        structural_after,
2863                    )?;
2864                    let distance =
2865                        self.canonical_action_width(&middle, statement.span().copied())?;
2866                    let mut args = Vec::with_capacity(conditions.len() + 1);
2867                    if let Some((first, rest)) = conditions.split_first() {
2868                        let mut condition = self.lower_value(first)?;
2869                        for expression in rest {
2870                            let right = self.lower_value(expression)?;
2871                            condition = self.push_call("and", vec![condition, right]);
2872                        }
2873                        args.push(condition);
2874                    }
2875                    args.push(self.push_number(distance as f64, &distance.to_string()));
2876                    actions.push(
2877                        self.wir.actions.push(Action::Call {
2878                            name: if conditions.is_empty() {
2879                                "skip"
2880                            } else {
2881                                "skipIf"
2882                            }
2883                            .to_string(),
2884                            args,
2885                            span: self.wir_span(statement.span().copied())?,
2886                        }),
2887                    );
2888                    actions.extend(middle);
2889                    actions.extend(suffix);
2890                    return Ok(actions);
2891                }
2892            }
2893            if let Some((conditions, label)) = pure_goto_conditions(statement) {
2894                let local_label = statements.iter().any(
2895                    |candidate| matches!(candidate, Stmt::Label { name, .. } if name == label),
2896                );
2897                let outer_label = self
2898                    .visible_labels
2899                    .last()
2900                    .is_some_and(|labels| labels.contains(label));
2901                if !local_label && outer_label {
2902                    let span = statement.span().copied();
2903                    let mut condition = None;
2904                    for expression in conditions {
2905                        let value = self.lower_value(expression)?;
2906                        condition = Some(match condition {
2907                            Some(left) => self.push_call("and", vec![left, value]),
2908                            None => value,
2909                        });
2910                    }
2911                    let break_action = self.wir.actions.push(Action::Call {
2912                        name: "break".to_string(),
2913                        args: Vec::new(),
2914                        span: self.wir_span(span)?,
2915                    });
2916                    actions.push(if let Some(condition) = condition {
2917                        self.wir.actions.push(Action::If {
2918                            branches: vec![wir::IfBranch {
2919                                condition,
2920                                body: vec![break_action],
2921                            }],
2922                            else_body: None,
2923                            span: self.wir_span(span)?,
2924                        })
2925                    } else {
2926                        break_action
2927                    });
2928                    index += 1;
2929                    continue;
2930                }
2931            }
2932            actions.extend(self.lower_action(statement, Some(BreakTarget::Loop))?);
2933            index += 1;
2934        }
2935        Ok(actions)
2936    }
2937
2938    fn lower_if_with_loop_continue(
2939        &mut self,
2940        statement: &Stmt,
2941        after: &[wir::ActionId],
2942        structural_after: usize,
2943    ) -> Result<wir::ActionId, IntegrationError> {
2944        let Stmt::If {
2945            branches,
2946            r#else,
2947            span,
2948        } = statement
2949        else {
2950            unreachable!("continue-containing loop statement must be an if")
2951        };
2952        let mut lowered_branches = Vec::with_capacity(branches.len());
2953        let mut suffix = after.to_vec();
2954        let mut suffix_structural = structural_after + 1;
2955        let mut lowered_else = None;
2956        if let Some(body) = r#else {
2957            let body = self.lower_loop_sequence(body, after, suffix_structural)?;
2958            suffix.splice(0..0, body.iter().copied());
2959            suffix_structural += 1;
2960            lowered_else = Some(body);
2961        }
2962        for index in (0..branches.len()).rev() {
2963            let body =
2964                self.lower_loop_sequence(&branches[index].body, &suffix, suffix_structural)?;
2965            suffix_structural += 1;
2966            suffix.splice(0..0, body.iter().copied());
2967            lowered_branches.push(body);
2968        }
2969        lowered_branches.reverse();
2970        let mut branch_actions = Vec::with_capacity(branches.len());
2971        for (branch, body) in branches.iter().zip(lowered_branches) {
2972            branch_actions.push(wir::IfBranch {
2973                condition: self.lower_value(&branch.condition)?,
2974                body,
2975            });
2976        }
2977        Ok(self.wir.actions.push(Action::If {
2978            branches: branch_actions,
2979            else_body: lowered_else,
2980            span: self.wir_span(*span)?,
2981        }))
2982    }
2983
2984    fn lower_do_while_body(
2985        &mut self,
2986        statements: &[Stmt],
2987    ) -> Result<Vec<wir::ActionId>, IntegrationError> {
2988        let mut actions = Vec::new();
2989        for (index, statement) in statements.iter().enumerate() {
2990            let direct_break = matches!(statement, Stmt::Break { .. });
2991            let conditional_break = match statement {
2992                Stmt::If {
2993                    branches,
2994                    r#else: None,
2995                    ..
2996                } if branches.len() == 1 => {
2997                    matches!(branches[0].body.as_slice(), [Stmt::Break { .. }])
2998                }
2999                _ => false,
3000            };
3001
3002            if direct_break || conditional_break {
3003                let tail = self.lower_do_while_body(&statements[index + 1..])?;
3004                let distance = self.canonical_action_width(&tail, statement.span().copied())? + 1;
3005                let (name, args, span) = if let Stmt::Break { span } = statement {
3006                    ("skip", Vec::new(), *span)
3007                } else if let Stmt::If { branches, span, .. } = statement {
3008                    (
3009                        "skipIf",
3010                        vec![self.lower_value(&branches[0].condition)?],
3011                        *span,
3012                    )
3013                } else {
3014                    unreachable!("break shape was checked above")
3015                };
3016                let distance = self.wir.values.push(ValueNode::new(
3017                    Value::Number {
3018                        value: distance as f64,
3019                        text: distance.to_string(),
3020                    },
3021                    self.wir_span(span)?,
3022                ));
3023                let mut args = args;
3024                args.push(distance);
3025                actions.push(self.wir.actions.push(Action::Call {
3026                    name: name.to_string(),
3027                    args,
3028                    span: self.wir_span(span)?,
3029                }));
3030                actions.extend(tail);
3031                return Ok(actions);
3032            }
3033
3034            actions.extend(self.lower_action(statement, Some(BreakTarget::DoWhile))?);
3035        }
3036        Ok(actions)
3037    }
3038
3039    fn lower_range(
3040        &mut self,
3041        iterable: &Expr,
3042    ) -> Result<(wir::ValueId, wir::ValueId, wir::ValueId), IntegrationError> {
3043        let Expr::Call { name, args, .. } = iterable else {
3044            return Err(self.unsupported(
3045                "range loop iterable must be a range(...) call",
3046                iterable.span().copied(),
3047            ));
3048        };
3049        if name != "range" || !(1..=3).contains(&args.len()) {
3050            return Err(self.unsupported(
3051                "range loop requires one to three arguments",
3052                iterable.span().copied(),
3053            ));
3054        }
3055        let span = iterable.span().copied();
3056        let number = |this: &mut Self, value: f64| -> Result<wir::ValueId, IntegrationError> {
3057            Ok(this.wir.values.push(ValueNode::new(
3058                Value::Number {
3059                    value,
3060                    text: value.to_string(),
3061                },
3062                this.wir_span(span)?,
3063            )))
3064        };
3065        match args.as_slice() {
3066            [stop] => Ok((
3067                number(self, 0.0)?,
3068                self.lower_value(stop)?,
3069                number(self, 1.0)?,
3070            )),
3071            [start, stop] => Ok((
3072                self.lower_value(start)?,
3073                self.lower_value(stop)?,
3074                number(self, 1.0)?,
3075            )),
3076            [start, stop, step] => Ok((
3077                self.lower_value(start)?,
3078                self.lower_value(stop)?,
3079                self.lower_value(step)?,
3080            )),
3081            _ => unreachable!("range arity checked above"),
3082        }
3083    }
3084
3085    fn lower_switch(
3086        &mut self,
3087        value: &Expr,
3088        arms: &[SwitchArm],
3089        span: Option<HirSpan>,
3090    ) -> Result<wir::ActionId, IntegrationError> {
3091        let selector = self.lower_value(value)?;
3092        let mut case_values = Vec::new();
3093        let mut lowered_arms = Vec::with_capacity(arms.len());
3094        let mut has_default = false;
3095        let mut legacy_case_offsets = Vec::new();
3096        let mut legacy_offset = 0;
3097        let mut legacy_default_offset = None;
3098
3099        for arm in arms {
3100            let (value, (body, break_at)) = match arm {
3101                SwitchArm::Case { value, body, .. } => {
3102                    case_values.push(self.lower_value(value)?);
3103                    (Some(value), self.lower_switch_body(body)?)
3104                }
3105                SwitchArm::Default { body, span } => {
3106                    if has_default {
3107                        return Err(
3108                            self.unsupported("a switch may contain at most one default arm", *span)
3109                        );
3110                    }
3111                    has_default = true;
3112                    legacy_default_offset = Some(legacy_offset);
3113                    (None, self.lower_switch_body(body)?)
3114                }
3115            };
3116            if value.is_some() {
3117                legacy_case_offsets.push(legacy_offset);
3118            }
3119            legacy_offset +=
3120                self.canonical_action_width(&body, span)? + usize::from(break_at.is_some());
3121            lowered_arms.push((value.map(Box::as_ref), body, break_at));
3122        }
3123
3124        let break_arms: Vec<_> = lowered_arms
3125            .iter()
3126            .enumerate()
3127            .filter_map(|(index, (_, _, break_at))| break_at.map(|break_at| (index, break_at)))
3128            .collect();
3129        let first_break = break_arms.first().copied();
3130        let has_later_reachable_actions =
3131            first_break.is_some_and(|(break_index, (break_at, _))| {
3132                lowered_arms[break_index].1.len() > break_at
3133                    || lowered_arms
3134                        .iter()
3135                        .skip(break_index + 1)
3136                        .any(|(_, body, _)| !body.is_empty())
3137            });
3138        let use_shared_exit = break_arms.len() > 1 && has_later_reachable_actions;
3139
3140        let case_values = self.lower_array(case_values, span)?;
3141        let value_span = self.wir_span(span)?;
3142        if !use_shared_exit {
3143            let default_offset = legacy_default_offset.unwrap_or(legacy_offset);
3144            let offset_values = std::iter::once(default_offset)
3145                .chain(legacy_case_offsets)
3146                .map(|value| {
3147                    self.wir.values.push(ValueNode::new(
3148                        Value::Number {
3149                            value: value as f64,
3150                            text: value.to_string(),
3151                        },
3152                        value_span,
3153                    ))
3154                })
3155                .collect();
3156            let offsets = self.lower_array(offset_values, span)?;
3157            let skip = self.lower_switch_selector(selector, case_values, offsets, span)?;
3158            let true_value = self
3159                .wir
3160                .values
3161                .push(ValueNode::new(Value::Bool(true), self.wir_span(span)?));
3162            let mut branch_body = vec![skip];
3163            let else_body = if let Some((break_index, (break_at, _))) = first_break {
3164                for (index, (_, body, _)) in lowered_arms.iter().enumerate() {
3165                    if index < break_index {
3166                        branch_body.extend(body.iter().copied());
3167                    } else if index == break_index {
3168                        branch_body.extend(body[..break_at].iter().copied());
3169                    }
3170                }
3171                let mut tail = Vec::new();
3172                tail.extend(lowered_arms[break_index].1[break_at..].iter().copied());
3173                for (_, body, _) in lowered_arms.iter().skip(break_index + 1) {
3174                    tail.extend(body.iter().copied());
3175                }
3176                Some(tail)
3177            } else {
3178                for (_, body, _) in &lowered_arms {
3179                    branch_body.extend(body.iter().copied());
3180                }
3181                None
3182            };
3183            return Ok(self.wir.actions.push(Action::If {
3184                branches: vec![wir::IfBranch {
3185                    condition: true_value,
3186                    body: branch_body,
3187                }],
3188                else_body,
3189                span: self.wir_span(span)?,
3190            }));
3191        }
3192
3193        let offsets = self
3194            .wir
3195            .values
3196            .push(ValueNode::new(Value::Array(Vec::new()), value_span));
3197        let skip = self.lower_switch_selector(selector, case_values, offsets, span)?;
3198        let mut arm_offsets = vec![None; lowered_arms.len()];
3199        let (switch, switch_end) =
3200            self.lower_switch_level(&lowered_arms, 0, Some(skip), 0, &mut arm_offsets, span)?;
3201
3202        let default_offset = lowered_arms
3203            .iter()
3204            .enumerate()
3205            .find_map(|(index, (value, _, _))| value.is_none().then(|| arm_offsets[index].unwrap()))
3206            .unwrap_or(switch_end);
3207        let offset_values = std::iter::once(default_offset)
3208            .chain(
3209                lowered_arms
3210                    .iter()
3211                    .enumerate()
3212                    .filter(|(_, (value, _, _))| value.is_some())
3213                    .map(|(index, _)| arm_offsets[index].unwrap()),
3214            )
3215            .map(|value| {
3216                self.wir.values.push(ValueNode::new(
3217                    Value::Number {
3218                        value: value as f64,
3219                        text: value.to_string(),
3220                    },
3221                    value_span,
3222                ))
3223            })
3224            .collect();
3225        let offset_values = self.lower_array(offset_values, span)?;
3226        let offset_value = self
3227            .wir
3228            .values
3229            .get(offset_values)
3230            .expect("switch offset array must exist")
3231            .value
3232            .clone();
3233        let Some(node) = self.wir.values.get_mut(offsets) else {
3234            unreachable!("switch offset placeholder must exist")
3235        };
3236        node.value = offset_value;
3237
3238        Ok(switch)
3239    }
3240
3241    fn lower_switch_selector(
3242        &mut self,
3243        selector: wir::ValueId,
3244        case_values: wir::ValueId,
3245        offsets: wir::ValueId,
3246        span: Option<HirSpan>,
3247    ) -> Result<wir::ActionId, IntegrationError> {
3248        let one = self.wir.values.push(ValueNode::new(
3249            Value::Number {
3250                value: 1.0,
3251                text: "1".to_string(),
3252            },
3253            self.wir_span(span)?,
3254        ));
3255        let index = self.wir.values.push(ValueNode::new(
3256            Value::Call {
3257                name: "indexOfArrayValue".to_string(),
3258                args: vec![case_values, selector],
3259            },
3260            self.wir_span(span)?,
3261        ));
3262        let case_offset = self.wir.values.push(ValueNode::new(
3263            Value::Call {
3264                name: "add".to_string(),
3265                args: vec![one, index],
3266            },
3267            self.wir_span(span)?,
3268        ));
3269        let skip_condition = self.wir.values.push(ValueNode::new(
3270            Value::Call {
3271                name: "valueInArray".to_string(),
3272                args: vec![offsets, case_offset],
3273            },
3274            self.wir_span(span)?,
3275        ));
3276        Ok(self.wir.actions.push(Action::Call {
3277            name: "skip".to_string(),
3278            args: vec![skip_condition],
3279            span: self.wir_span(span)?,
3280        }))
3281    }
3282
3283    fn lower_switch_level(
3284        &mut self,
3285        arms: &[LoweredSwitchArm<'_>],
3286        start: usize,
3287        selector_skip: Option<wir::ActionId>,
3288        level_offset: usize,
3289        arm_offsets: &mut [Option<usize>],
3290        span: Option<HirSpan>,
3291    ) -> Result<(wir::ActionId, usize), IntegrationError> {
3292        let break_index = (start..arms.len())
3293            .find(|index| arms[*index].2.is_some())
3294            .expect("switch level must contain a break");
3295        let mut branch_body = Vec::new();
3296        if let Some(selector_skip) = selector_skip {
3297            branch_body.push(selector_skip);
3298        }
3299        let mut branch_offset = 0;
3300        for index in start..=break_index {
3301            arm_offsets[index] = Some(if selector_skip.is_some() {
3302                level_offset + branch_offset
3303            } else if index == start {
3304                level_offset
3305            } else {
3306                level_offset + 1 + branch_offset
3307            });
3308            let (_, body, break_at) = &arms[index];
3309            let body = if index == break_index {
3310                &body[..break_at.as_ref().unwrap().0]
3311            } else {
3312                body.as_slice()
3313            };
3314            branch_offset += self.canonical_action_width(body, span)?;
3315            branch_body.extend(body.iter().copied());
3316        }
3317        let branch_width = self.canonical_action_width(&branch_body, span)?;
3318        let (_, break_body, Some((break_at, _))) = &arms[break_index] else {
3319            unreachable!("break index must point to a switch break")
3320        };
3321        let mut else_body = break_body[*break_at..].to_vec();
3322        let tail_width = self.canonical_action_width(&else_body, span)?;
3323        let else_content_start = if selector_skip.is_some() {
3324            level_offset + branch_width + tail_width
3325        } else {
3326            level_offset + branch_width + tail_width + 2
3327        };
3328        let has_next_break = (break_index + 1..arms.len()).any(|index| arms[index].2.is_some());
3329        let end_offset = if has_next_break {
3330            let (child, child_end) = self.lower_switch_level(
3331                arms,
3332                break_index + 1,
3333                None,
3334                else_content_start,
3335                arm_offsets,
3336                span,
3337            )?;
3338            else_body.push(child);
3339            child_end
3340        } else {
3341            let mut offset = else_content_start;
3342            for index in break_index + 1..arms.len() {
3343                arm_offsets[index] = Some(offset);
3344                let (_, body, _) = &arms[index];
3345                offset += self.canonical_action_width(body, span)?;
3346                else_body.extend(body.iter().copied());
3347            }
3348            offset
3349        };
3350        let true_value = self
3351            .wir
3352            .values
3353            .push(ValueNode::new(Value::Bool(true), self.wir_span(span)?));
3354        let switch = self.wir.actions.push(Action::If {
3355            branches: vec![wir::IfBranch {
3356                condition: true_value,
3357                body: branch_body,
3358            }],
3359            else_body: Some(else_body),
3360            span: self.wir_span(span)?,
3361        });
3362        Ok((switch, end_offset))
3363    }
3364
3365    fn lower_switch_body(
3366        &mut self,
3367        statements: &[Stmt],
3368    ) -> Result<LoweredSwitchBody, IntegrationError> {
3369        let mut actions = Vec::new();
3370        let mut break_at = None;
3371        for statement in statements {
3372            if let Stmt::Break { span } = statement {
3373                if break_at.is_some() {
3374                    return Err(self.unsupported(
3375                        "multiple switch breaks in one arm require canonical switch targets",
3376                        *span,
3377                    ));
3378                }
3379                break_at = Some((
3380                    actions.len(),
3381                    span.ok_or_else(|| {
3382                        self.unsupported("switch break is missing source provenance", None)
3383                    })?,
3384                ));
3385                continue;
3386            }
3387            actions.extend(self.lower_action(statement, Some(BreakTarget::Switch))?);
3388        }
3389        Ok((actions, break_at))
3390    }
3391
3392    /// Query the Workshop-owned native action layout for a relative jump.
3393    fn canonical_action_width(
3394        &self,
3395        actions: &[wir::ActionId],
3396        fallback_span: Option<HirSpan>,
3397    ) -> Result<usize, IntegrationError> {
3398        workshop_rs::emitter::action_width(
3399            &self.wir,
3400            &self.compiler.catalog,
3401            &Locale::new("en-US"),
3402            actions,
3403        )
3404        .map(|layout| layout.width)
3405        .map_err(|error| {
3406            let workshop_span = match &error {
3407                workshop_rs::emitter::ActionLayoutError::InvalidWIR(error) => error.span(),
3408                workshop_rs::emitter::ActionLayoutError::Emission(error) => {
3409                    workshop_error_span(error)
3410                }
3411            };
3412            let span = workshop_span
3413                .and_then(|span| self.hir_span_from_workshop(span))
3414                .or(fallback_span);
3415            IntegrationError::new("workshop-action-layout", error.to_string(), span)
3416        })
3417    }
3418
3419    fn lower_array(
3420        &mut self,
3421        elements: Vec<wir::ValueId>,
3422        span: Option<HirSpan>,
3423    ) -> Result<wir::ValueId, IntegrationError> {
3424        let name = if elements.is_empty() {
3425            "emptyArray"
3426        } else {
3427            "array"
3428        };
3429        Ok(self.wir.values.push(ValueNode::new(
3430            Value::Call {
3431                name: name.to_string(),
3432                args: elements,
3433            },
3434            self.wir_span(span)?,
3435        )))
3436    }
3437
3438    fn lower_translation_helper(
3439        &mut self,
3440        translations: &hir::TranslationState,
3441    ) -> Result<wir::ValueId, IntegrationError> {
3442        let translated_white = translations
3443            .languages
3444            .iter()
3445            .map(|language| {
3446                let locale = translation_locale(language).ok_or_else(|| {
3447                    IntegrationError::new(
3448                        "translations-invalid",
3449                        format!("unsupported translation language '{language}'"),
3450                        translations.span,
3451                    )
3452                })?;
3453                self.compiler
3454                    .catalog
3455                    .enum_spelling("Color", &Locale::new(locale), "WHITE")
3456                    .map(str::to_string)
3457                    .ok_or_else(|| {
3458                        IntegrationError::new(
3459                            "translations-invalid",
3460                            format!("catalog has no Color.WHITE spelling for locale '{locale}'"),
3461                            translations.span,
3462                        )
3463                    })
3464            })
3465            .collect::<Result<Vec<_>, _>>()?
3466            .join("0");
3467        let text = self.push_value(Value::String(format!("\u{ec48}0{translated_white}")));
3468        let custom_string = self.push_call("customString", vec![text]);
3469        let null = self.push_value(Value::Null);
3470        let separator = self.push_call("firstOf", vec![null]);
3471        Ok(self.push_call("stringSplit", vec![custom_string, separator]))
3472    }
3473
3474    fn lower_debug(
3475        &mut self,
3476        expr: &Expr,
3477        span: Option<HirSpan>,
3478    ) -> Result<wir::ActionId, IntegrationError> {
3479        macro_rules! call {
3480            ($name:literal $(, $arg:expr)* $(,)?) => {{
3481                let args = vec![$($arg),*];
3482                self.push_call($name, args)
3483            }};
3484        }
3485
3486        let value = self.lower_text_value(expr)?;
3487        let array_text = if self.debug_value_is_array(value) {
3488            self.lower_debug_array_text(value)
3489        } else {
3490            value
3491        };
3492        let debug_label = canonical_debug_text(&debug_expr_text(expr));
3493        let debug_prefix = format!("{debug_label}\u{2028}= {{0}}");
3494        let inline_padding = 128 - debug_prefix.chars().count() - "{1}".chars().count();
3495        let padding_text = self.push_value(Value::String(" ".repeat(170 - inline_padding)));
3496        let padding = self.push_call("customString", vec![padding_text]);
3497        let debug_label = self.push_value(Value::String(format!(
3498            "{debug_prefix}{}{{1}}",
3499            " ".repeat(inline_padding)
3500        )));
3501        let text = self.push_call("customString", vec![debug_label, array_text, padding]);
3502        let all_teams = self.push_value(Value::Enum {
3503            value_type: "Team".to_string(),
3504            value: "ALL".to_string(),
3505        });
3506        let all_players = call!("allPlayers", all_teams);
3507        let null_value = self.push_value(Value::Null);
3508        let null_value_2 = self.push_value(Value::Null);
3509        let null_value_3 = self.push_value(Value::Null);
3510        let null_value_4 = self.push_value(Value::Null);
3511        let hud_position = self.push_value(Value::Enum {
3512            value_type: "HudPosition".to_string(),
3513            value: "LEFT".to_string(),
3514        });
3515        let sort_order = self.push_value(Value::Number {
3516            value: -9999.0,
3517            text: "-9999".to_string(),
3518        });
3519        let color = self.push_value(Value::Enum {
3520            value_type: "Color".to_string(),
3521            value: "WHITE".to_string(),
3522        });
3523        let reevaluation = self.push_value(Value::Enum {
3524            value_type: "HudReeval".to_string(),
3525            value: "VISIBILITY_SORT_ORDER_STRING_AND_COLOR".to_string(),
3526        });
3527        let visibility = self.push_value(Value::Enum {
3528            value_type: "SpecVisibility".to_string(),
3529            value: "DEFAULT".to_string(),
3530        });
3531        Ok(self.wir.actions.push(Action::Call {
3532            name: "createHudText".to_string(),
3533            args: vec![
3534                all_players,
3535                null_value,
3536                text,
3537                null_value_2,
3538                hud_position,
3539                sort_order,
3540                null_value_3,
3541                color,
3542                null_value_4,
3543                reevaluation,
3544                visibility,
3545            ],
3546            span: self.wir_span(span)?,
3547        }))
3548    }
3549
3550    fn lower_print(
3551        &mut self,
3552        expr: &Expr,
3553        span: Option<HirSpan>,
3554    ) -> Result<wir::ActionId, IntegrationError> {
3555        macro_rules! call {
3556            ($name:literal $(, $arg:expr)* $(,)?) => {{
3557                let args = vec![$($arg),*];
3558                self.push_call($name, args)
3559            }};
3560        }
3561
3562        let message = self.lower_text_value(expr)?;
3563        let padding_text = self.push_value(Value::String(" ".repeat(45)));
3564        let padding = self.push_call("customString", vec![padding_text]);
3565        let body_text = self.push_value(Value::String(format!("{}{{0}}", " ".repeat(125))));
3566        let body = self.push_call("customString", vec![body_text, padding]);
3567        let all_teams = self.push_value(Value::Enum {
3568            value_type: "Team".to_string(),
3569            value: "ALL".to_string(),
3570        });
3571        let all_players = call!("allPlayers", all_teams);
3572        let null_value = self.push_value(Value::Null);
3573        let null_value_2 = self.push_value(Value::Null);
3574        let null_value_3 = self.push_value(Value::Null);
3575        let hud_position = self.push_value(Value::Enum {
3576            value_type: "HudPosition".to_string(),
3577            value: "LEFT".to_string(),
3578        });
3579        let sort_order = self.push_value(Value::Number {
3580            value: -9999.0,
3581            text: "-9999".to_string(),
3582        });
3583        let color = self.push_value(Value::Enum {
3584            value_type: "Color".to_string(),
3585            value: "ORANGE".to_string(),
3586        });
3587        let reevaluation = self.push_value(Value::Enum {
3588            value_type: "HudReeval".to_string(),
3589            value: "VISIBILITY_AND_STRING".to_string(),
3590        });
3591        let visibility = self.push_value(Value::Enum {
3592            value_type: "SpecVisibility".to_string(),
3593            value: "DEFAULT".to_string(),
3594        });
3595        Ok(self.wir.actions.push(Action::Call {
3596            name: "createHudText".to_string(),
3597            args: vec![
3598                all_players,
3599                message,
3600                body,
3601                null_value,
3602                hud_position,
3603                sort_order,
3604                color,
3605                null_value_2,
3606                null_value_3,
3607                reevaluation,
3608                visibility,
3609            ],
3610            span: self.wir_span(span)?,
3611        }))
3612    }
3613
3614    fn lower_debug_array_text(&mut self, value: wir::ValueId) -> wir::ValueId {
3615        macro_rules! call {
3616            ($name:literal $(, $arg:expr)* $(,)?) => {{
3617                let args = vec![$($arg),*];
3618                self.push_call($name, args)
3619            }};
3620        }
3621
3622        let current_count = call!("countOf", call!("currentArrayElement"));
3623        let is_single = call!(
3624            "==",
3625            call!("countOf", call!("currentArrayElement")),
3626            self.push_number(1.0, "1")
3627        );
3628        let is_empty = call!("==", call!("currentArrayElement"), call!("emptyArray"));
3629        let not_null = call!(
3630            "!=",
3631            call!("currentArrayElement"),
3632            self.push_value(Value::Null)
3633        );
3634        let has_empty_array = call!("and", is_empty, not_null);
3635        let brackets = call!("or", is_single, has_empty_array);
3636        let first_element = call!(
3637            "customString",
3638            self.push_value(Value::String("[{0}]".to_string())),
3639            call!("currentArrayElement"),
3640        );
3641        let many_elements = call!(
3642            "customString",
3643            self.push_value(Value::String("[{0}, …+{1}]".to_string())),
3644            call!("currentArrayElement"),
3645            call!(
3646                "subtract",
3647                call!("countOf", call!("currentArrayElement")),
3648                self.push_number(1.0, "1"),
3649            ),
3650        );
3651        let element_text = call!(
3652            "ifThenElse",
3653            brackets,
3654            first_element,
3655            call!(
3656                "ifThenElse",
3657                current_count,
3658                many_elements,
3659                call!("currentArrayElement"),
3660            ),
3661        );
3662        let mapped_elements = call!("mappedArray", value, element_text,);
3663        let mapped_input = call!("array", mapped_elements);
3664        let current_array = call!("currentArrayElement");
3665        let actual_array = call!(
3666            "or",
3667            call!("countOf", current_array),
3668            call!(
3669                "and",
3670                call!("==", call!("currentArrayElement"), call!("emptyArray")),
3671                call!(
3672                    "!=",
3673                    call!("currentArrayElement"),
3674                    self.push_value(Value::Null)
3675                ),
3676            ),
3677        );
3678        let empty_length = call!(
3679            "ifThenElse",
3680            call!(
3681                "and",
3682                call!("not", call!("countOf", call!("currentArrayElement"))),
3683                call!("!=", call!("currentArrayElement"), call!("emptyArray"),),
3684            ),
3685            self.push_number(3.0, "3"),
3686            call!(
3687                "multiply",
3688                call!("countOf", call!("currentArrayElement")),
3689                self.push_number(3.0, "3"),
3690            ),
3691        );
3692        let x = call!(
3693            "appendToArray",
3694            call!("appendToArray", actual_array, empty_length),
3695            current_array,
3696        );
3697        let x_input = call!("mappedArray", mapped_input, x);
3698        let x_length = |this: &mut Self| {
3699            let current = this.push_call("currentArrayElement", Vec::new());
3700            let index = this.push_number(1.0, "1");
3701            this.push_call("valueInArray", vec![current, index])
3702        };
3703        let x_value = |this: &mut Self, index: f64| {
3704            let current = this.push_call("currentArrayElement", Vec::new());
3705            let index_value = this.push_number(index, &index.to_string());
3706            this.push_call("valueInArray", vec![current, index_value])
3707        };
3708        let first = call!("firstOf", call!("currentArrayElement"));
3709        let array_tail = call!(
3710            "customString",
3711            self.push_value(Value::String("{0}, {1}, {2}".to_string())),
3712            x_value(self, 4.0),
3713            x_value(self, 5.0),
3714            call!(
3715                "customString",
3716                self.push_value(Value::String("{0}, {1}, …\u{0001}".to_string())),
3717                x_value(self, 6.0),
3718                x_value(self, 7.0),
3719            ),
3720        );
3721        let array_head = call!(
3722            "customString",
3723            self.push_value(Value::String("{0}, {1}, {2}".to_string())),
3724            x_value(self, 2.0),
3725            x_value(self, 3.0),
3726            array_tail,
3727        );
3728        let placeholder = call!(
3729            "customString",
3730            self.push_value(Value::String("0, 0, 0, 0, 0, 0, …\u{0001}".to_string())),
3731        );
3732        let length_for_slice = x_length(self);
3733        let end_length_for_slice = x_length(self);
3734        let slice = call!(
3735            "stringSlice",
3736            placeholder,
3737            call!("add", self.push_number(-2.0, "-2"), length_for_slice),
3738            call!(
3739                "subtract",
3740                self.push_number(22.0, "22"),
3741                end_length_for_slice,
3742            ),
3743        );
3744        let replaced = call!("stringReplace", array_head, slice, call!("emptyArray"),);
3745        let length_for_compare = x_length(self);
3746        let length_for_divide = x_length(self);
3747        let plus = call!(
3748            "ifThenElse",
3749            call!(">", length_for_compare, self.push_number(18.0, "18")),
3750            call!(
3751                "customString",
3752                self.push_value(Value::String("+{0}".to_string())),
3753                call!(
3754                    "subtract",
3755                    call!("divide", length_for_divide, self.push_number(3.0, "3")),
3756                    self.push_number(6.0, "6"),
3757                ),
3758            ),
3759            call!("emptyArray"),
3760        );
3761        let formatted_array = call!(
3762            "customString",
3763            self.push_value(Value::String("[{0}{1}]".to_string())),
3764            replaced,
3765            plus,
3766        );
3767        let current_for_split = call!("currentArrayElement");
3768        let rendered = call!(
3769            "ifThenElse",
3770            first,
3771            formatted_array,
3772            call!(
3773                "stringSplit",
3774                call!(
3775                    "valueInArray",
3776                    current_for_split,
3777                    self.push_number(2.0, "2")
3778                ),
3779                call!("emptyArray"),
3780            ),
3781        );
3782        call!("mappedArray", x_input, rendered)
3783    }
3784
3785    fn lower_text_value(&mut self, expr: &Expr) -> Result<wir::ValueId, IntegrationError> {
3786        let value = self.lower_value(expr)?;
3787        let Value::Call { name, args } = &self
3788            .wir
3789            .values
3790            .get(value)
3791            .expect("lowered text value must exist")
3792            .value
3793        else {
3794            return Ok(value);
3795        };
3796        if name == "customString" && args.len() == 1 {
3797            Ok(args[0])
3798        } else {
3799            Ok(value)
3800        }
3801    }
3802
3803    fn debug_value_is_array(&self, value: wir::ValueId) -> bool {
3804        match &self
3805            .wir
3806            .values
3807            .get(value)
3808            .expect("lowered value must exist")
3809            .value
3810        {
3811            Value::GlobalVariable(_) | Value::Array(_) => true,
3812            Value::Call { name, .. } if matches!(name.as_str(), "array" | "emptyArray") => true,
3813            Value::Call { name, .. } => self
3814                .compiler
3815                .catalog
3816                .entry(Kind::Value, name)
3817                .and_then(|entry| entry.return_type())
3818                .is_some_and(|return_type| {
3819                    return_type.split('|').any(|part| part.trim() == "Array")
3820                }),
3821            _ => false,
3822        }
3823    }
3824
3825    fn value_is_known_player(&self, value: wir::ValueId) -> bool {
3826        match &self
3827            .wir
3828            .values
3829            .get(value)
3830            .expect("lowered value must exist")
3831            .value
3832        {
3833            Value::EventPlayer => true,
3834            Value::Call { name, .. } => self
3835                .compiler
3836                .catalog
3837                .entry(Kind::Value, name)
3838                .and_then(|entry| entry.return_type())
3839                .is_some_and(|return_type| {
3840                    return_type.split('|').any(|part| part.trim() == "Player")
3841                }),
3842            _ => false,
3843        }
3844    }
3845
3846    fn push_value(&mut self, value: Value) -> wir::ValueId {
3847        self.wir.values.push(ValueNode::new(value, None))
3848    }
3849
3850    fn push_call(&mut self, name: &str, args: Vec<wir::ValueId>) -> wir::ValueId {
3851        self.push_value(Value::Call {
3852            name: name.to_string(),
3853            args,
3854        })
3855    }
3856
3857    fn lower_custom_string(
3858        &mut self,
3859        value: String,
3860        span: Option<HirSpan>,
3861    ) -> Result<wir::ValueId, IntegrationError> {
3862        let span = self.wir_span(span)?;
3863        let text = self
3864            .wir
3865            .values
3866            .push(ValueNode::new(Value::String(value), span));
3867        Ok(self.wir.values.push(ValueNode::new(
3868            Value::Call {
3869                name: "customString".to_string(),
3870                args: vec![text],
3871            },
3872            span,
3873        )))
3874    }
3875
3876    fn push_number(&mut self, value: f64, text: &str) -> wir::ValueId {
3877        self.push_value(Value::Number {
3878            value,
3879            text: text.to_string(),
3880        })
3881    }
3882
3883    fn fold_numeric_binary(
3884        &self,
3885        op: &str,
3886        left: wir::ValueId,
3887        right: wir::ValueId,
3888    ) -> Option<f64> {
3889        let number = |id| match self.wir.values.get(id)?.value {
3890            Value::Number { value, .. } => Some(value),
3891            _ => None,
3892        };
3893        let left = number(left)?;
3894        let right = number(right)?;
3895        let value = match op {
3896            "+" => left + right,
3897            "-" => left - right,
3898            "*" => left * right,
3899            "/" if right != 0.0 => left / right,
3900            "%" if right != 0.0 => left % right,
3901            "**" => left.powf(right),
3902            _ => return None,
3903        };
3904        value.is_finite().then_some(value)
3905    }
3906
3907    fn lower_condition(&mut self, expr: &Expr) -> Result<wir::ValueId, IntegrationError> {
3908        let value = self.lower_value(expr)?;
3909        let is_comparison = |expr: &Expr| matches!(expr, Expr::Binary { op, .. } if matches!(op.as_str(), "==" | "!=" | "<" | "<=" | ">" | ">="));
3910        if is_comparison(expr)
3911            || matches!(expr, Expr::Unary { op, operand, .. } if op == "not" && is_comparison(operand))
3912        {
3913            return Ok(value);
3914        }
3915        let true_value = self.wir.values.push(ValueNode::new(
3916            Value::Bool(true),
3917            self.wir_span(expr.span().copied())?,
3918        ));
3919        Ok(self.wir.values.push(ValueNode::new(
3920            Value::Call {
3921                name: "==".to_string(),
3922                args: vec![value, true_value],
3923            },
3924            self.wir_span(expr.span().copied())?,
3925        )))
3926    }
3927
3928    fn lower_delete(
3929        &mut self,
3930        target: &Expr,
3931        span: Option<HirSpan>,
3932    ) -> Result<wir::ActionId, IntegrationError> {
3933        let Expr::Index { array, index, .. } = target else {
3934            return Err(self.unsupported(
3935                "delete statements require an indexed global or player variable",
3936                span,
3937            ));
3938        };
3939        let (root, assignment) = match array.as_ref() {
3940            Expr::GlobalVar {
3941                name,
3942                span: target_span,
3943            } => {
3944                let variable = *self.globals.get(name).ok_or_else(|| {
3945                    self.unsupported(format!("unknown global variable '{name}'"), *target_span)
3946                })?;
3947                let value = self.wir.values.push(ValueNode::new(
3948                    Value::GlobalVariable(variable),
3949                    self.wir_span(*target_span)?,
3950                ));
3951                (value, DeleteAssignment::Global(variable))
3952            }
3953            Expr::PlayerVar {
3954                player,
3955                name,
3956                span: target_span,
3957                ..
3958            } => {
3959                let variable = *self.players.get(name).ok_or_else(|| {
3960                    self.unsupported(format!("unknown player variable '{name}'"), *target_span)
3961                })?;
3962                let player = self.lower_value(player)?;
3963                let value = self.wir.values.push(ValueNode::new(
3964                    Value::PlayerVariable { player, variable },
3965                    self.wir_span(*target_span)?,
3966                ));
3967                (value, DeleteAssignment::Player { player, variable })
3968            }
3969            _ => {
3970                return Err(self.unsupported(
3971                    "delete statements are only representable for global or player variables",
3972                    target.span().copied(),
3973                ));
3974            }
3975        };
3976        let index = self.lower_value(index)?;
3977        let zero = self.push_number(0.0, "0");
3978        let one = self.push_number(1.0, "1");
3979        let end = self.push_call("add", vec![index, one]);
3980        let maximum = self.push_number(999_999_999_999.0, "999999999999");
3981        let prefix = self.push_call("slice", vec![root, zero, index]);
3982        let suffix = self.push_call("slice", vec![root, end, maximum]);
3983        let value = self.push_call("appendToArray", vec![prefix, suffix]);
3984        let span = self.wir_span(span)?;
3985        Ok(match assignment {
3986            DeleteAssignment::Global(variable) => {
3987                self.wir.actions.push(Action::SetGlobalVariable {
3988                    variable,
3989                    value,
3990                    span,
3991                    target_span: span,
3992                })
3993            }
3994            DeleteAssignment::Player { player, variable } => {
3995                self.wir.actions.push(Action::SetPlayerVariable {
3996                    player,
3997                    variable,
3998                    value,
3999                    span,
4000                    target_span: span,
4001                })
4002            }
4003        })
4004    }
4005
4006    fn lower_assign(
4007        &mut self,
4008        target: &Expr,
4009        value: &Expr,
4010        span: Option<HirSpan>,
4011    ) -> Result<wir::ActionId, IntegrationError> {
4012        let mut indices = Vec::new();
4013        if let Some(root) = indexed_target_parts(target, &mut indices) {
4014            if indices.len() > 3 {
4015                return Err(self.unsupported("Cannot assign to 4d array", target.span().copied()));
4016            }
4017            if indices.len() > 1 {
4018                indices.reverse();
4019                return self.lower_nested_indexed_assign(root, &indices, target, value, span);
4020            }
4021        }
4022        match target {
4023            Expr::GlobalVar {
4024                name,
4025                span: target_span,
4026            } => {
4027                let variable = *self.globals.get(name).ok_or_else(|| {
4028                    self.unsupported(format!("unknown global variable '{name}'"), *target_span)
4029                })?;
4030                if let Expr::Binary {
4031                    op, left, right, ..
4032                } = value
4033                {
4034                    if let Expr::GlobalVar {
4035                        name: left_name, ..
4036                    } = left.as_ref()
4037                    {
4038                        if left_name == name {
4039                            if let Some(modify_op) = modify_op_from_str(op) {
4040                                let val = self.lower_value(right)?;
4041                                return Ok(self.wir.actions.push(Action::ModifyGlobalVariable {
4042                                    variable,
4043                                    op: modify_op,
4044                                    value: val,
4045                                    span: self.wir_span(span)?,
4046                                    target_span: self.wir_span(*target_span)?,
4047                                }));
4048                            }
4049                        }
4050                    }
4051                }
4052                let val = self.lower_value(value)?;
4053                Ok(self.wir.actions.push(Action::SetGlobalVariable {
4054                    variable,
4055                    value: val,
4056                    span: self.wir_span(span)?,
4057                    target_span: self.wir_span(*target_span)?,
4058                }))
4059            }
4060            Expr::PlayerVar {
4061                player,
4062                name,
4063                span: target_span,
4064                ..
4065            } => {
4066                let variable = *self.players.get(name).ok_or_else(|| {
4067                    self.unsupported(format!("unknown player variable '{name}'"), *target_span)
4068                })?;
4069                let player_val = self.lower_value(player)?;
4070                if let Expr::Binary {
4071                    op, left, right, ..
4072                } = value
4073                {
4074                    if let Expr::PlayerVar {
4075                        player: left_player,
4076                        name: left_name,
4077                        ..
4078                    } = left.as_ref()
4079                    {
4080                        if left_name == name && left_player.as_ref() == player.as_ref() {
4081                            if let Some(modify_op) = modify_op_from_str(op) {
4082                                let val = self.lower_value(right)?;
4083                                return Ok(self.wir.actions.push(Action::ModifyPlayerVariable {
4084                                    player: player_val,
4085                                    variable,
4086                                    op: modify_op,
4087                                    value: val,
4088                                    span: self.wir_span(span)?,
4089                                    target_span: self.wir_span(*target_span)?,
4090                                }));
4091                            }
4092                        }
4093                    }
4094                }
4095                let val = self.lower_value(value)?;
4096                Ok(self.wir.actions.push(Action::SetPlayerVariable {
4097                    player: player_val,
4098                    variable,
4099                    value: val,
4100                    span: self.wir_span(span)?,
4101                    target_span: self.wir_span(*target_span)?,
4102                }))
4103            }
4104            Expr::Index {
4105                array,
4106                index,
4107                span: target_span,
4108            } => match array.as_ref() {
4109                Expr::GlobalVar {
4110                    name,
4111                    span: arr_span,
4112                } => {
4113                    let variable = *self.globals.get(name).ok_or_else(|| {
4114                        self.unsupported(format!("unknown global variable '{name}'"), *arr_span)
4115                    })?;
4116                    let var_node = self.wir.values.push(ValueNode::new(
4117                        Value::GlobalVariable(variable),
4118                        self.wir_span(*arr_span)?,
4119                    ));
4120                    let index_val = self.lower_value(index)?;
4121                    if let Expr::Binary {
4122                        op, left, right, ..
4123                    } = value
4124                    {
4125                        if let Expr::Index {
4126                            array: left_arr,
4127                            index: left_idx,
4128                            ..
4129                        } = left.as_ref()
4130                        {
4131                            if left_arr.as_ref() == array.as_ref()
4132                                && left_idx.as_ref() == index.as_ref()
4133                            {
4134                                if let Some(op_id) = modify_catalog_name_from_str(op) {
4135                                    let op_node = self.wir.values.push(ValueNode::new(
4136                                        Value::Call {
4137                                            name: op_id.to_string(),
4138                                            args: Vec::new(),
4139                                        },
4140                                        None,
4141                                    ));
4142                                    let right_val = self.lower_value(right)?;
4143                                    return Ok(self.wir.actions.push(Action::Call {
4144                                        name: "modifyGlobalVariableAtIndex".to_string(),
4145                                        args: vec![var_node, index_val, op_node, right_val],
4146                                        span: self.wir_span(span)?,
4147                                    }));
4148                                }
4149                            }
4150                        }
4151                    }
4152                    let val = self.lower_value(value)?;
4153                    Ok(self.wir.actions.push(Action::Call {
4154                        name: "setGlobalVariableAtIndex".to_string(),
4155                        args: vec![var_node, index_val, val],
4156                        span: self.wir_span(span)?,
4157                    }))
4158                }
4159                Expr::PlayerVar {
4160                    player,
4161                    name,
4162                    span: arr_span,
4163                    ..
4164                } => {
4165                    let player_val = self.lower_value(player)?;
4166                    let variable = *self.players.get(name).ok_or_else(|| {
4167                        self.unsupported(format!("unknown player variable '{name}'"), *arr_span)
4168                    })?;
4169                    let var_node = self.wir.values.push(ValueNode::new(
4170                        Value::PlayerVariable {
4171                            player: player_val,
4172                            variable,
4173                        },
4174                        self.wir_span(*arr_span)?,
4175                    ));
4176                    let index_val = self.lower_value(index)?;
4177                    if let Expr::Binary {
4178                        op, left, right, ..
4179                    } = value
4180                    {
4181                        if let Expr::Index {
4182                            array: left_arr,
4183                            index: left_idx,
4184                            ..
4185                        } = left.as_ref()
4186                        {
4187                            if left_arr.as_ref() == array.as_ref()
4188                                && left_idx.as_ref() == index.as_ref()
4189                            {
4190                                if let Some(op_id) = modify_catalog_name_from_str(op) {
4191                                    let op_node = self.wir.values.push(ValueNode::new(
4192                                        Value::Call {
4193                                            name: op_id.to_string(),
4194                                            args: Vec::new(),
4195                                        },
4196                                        None,
4197                                    ));
4198                                    let right_val = self.lower_value(right)?;
4199                                    // The canonical signature takes the
4200                                    // player-variable value node (which
4201                                    // carries the player) as its first
4202                                    // argument.
4203                                    return Ok(self.wir.actions.push(Action::Call {
4204                                        name: "modifyPlayerVariableAtIndex".to_string(),
4205                                        args: vec![var_node, index_val, op_node, right_val],
4206                                        span: self.wir_span(span)?,
4207                                    }));
4208                                }
4209                            }
4210                        }
4211                    }
4212                    let val = self.lower_value(value)?;
4213                    // The canonical signature takes the player-variable
4214                    // value node (which carries the player) as its first
4215                    // argument.
4216                    Ok(self.wir.actions.push(Action::Call {
4217                        name: "setPlayerVariableAtIndex".to_string(),
4218                        args: vec![var_node, index_val, val],
4219                        span: self.wir_span(span)?,
4220                    }))
4221                }
4222                _ => Err(self.unsupported(
4223                    "indexing assignment is only representable for global or player variables",
4224                    *target_span,
4225                )),
4226            },
4227            _ => Err(self.unsupported(
4228                "only global-variable, player-variable, or index assignment is currently representable in canonical WIR",
4229                span,
4230            )),
4231        }
4232    }
4233
4234    fn lower_nested_indexed_assign(
4235        &mut self,
4236        root: &Expr,
4237        indices: &[&Expr],
4238        target: &Expr,
4239        value: &Expr,
4240        span: Option<HirSpan>,
4241    ) -> Result<wir::ActionId, IntegrationError> {
4242        let (action_name, root_value) = match root {
4243            Expr::GlobalVar {
4244                name,
4245                span: target_span,
4246            } => {
4247                let variable = *self.globals.get(name).ok_or_else(|| {
4248                    self.unsupported(format!("unknown global variable '{name}'"), *target_span)
4249                })?;
4250                let root_value = self.wir.values.push(ValueNode::new(
4251                    Value::GlobalVariable(variable),
4252                    self.wir_span(*target_span)?,
4253                ));
4254                ("setGlobalVariableAtIndex", root_value)
4255            }
4256            Expr::PlayerVar {
4257                player,
4258                name,
4259                span: target_span,
4260                ..
4261            } => {
4262                let player_value = self.lower_value(player)?;
4263                let variable = *self.players.get(name).ok_or_else(|| {
4264                    self.unsupported(format!("unknown player variable '{name}'"), *target_span)
4265                })?;
4266                let root_value = self.wir.values.push(ValueNode::new(
4267                    Value::PlayerVariable {
4268                        player: player_value,
4269                        variable,
4270                    },
4271                    self.wir_span(*target_span)?,
4272                ));
4273                ("setPlayerVariableAtIndex", root_value)
4274            }
4275            _ => {
4276                return Err(self.unsupported(
4277                    "indexing assignment is only representable for global or player variables",
4278                    target.span().copied(),
4279                ));
4280            }
4281        };
4282
4283        let outer_index = self.lower_value(indices[0])?;
4284        let outer_array = self.lower_indexed_read(root_value, indices[0], outer_index)?;
4285        let replacement =
4286            self.rebuild_indexed_value(outer_array, &indices[1..], target, value, span)?;
4287        Ok(self.wir.actions.push(Action::Call {
4288            name: action_name.to_string(),
4289            args: vec![root_value, outer_index, replacement],
4290            span: self.wir_span(span)?,
4291        }))
4292    }
4293
4294    fn rebuild_indexed_value(
4295        &mut self,
4296        array: wir::ValueId,
4297        indices: &[&Expr],
4298        target: &Expr,
4299        value: &Expr,
4300        span: Option<HirSpan>,
4301    ) -> Result<wir::ValueId, IntegrationError> {
4302        let index = indices
4303            .first()
4304            .copied()
4305            .expect("nested indexed assignment has an inner index");
4306        let index_value = self.lower_value(index)?;
4307        let replacement = if indices.len() == 1 {
4308            if let Expr::Binary {
4309                op, left, right, ..
4310            } = value
4311                && left.as_ref() == target
4312                && let Some(call_name) = modify_catalog_name_from_str(op)
4313            {
4314                let current = self.lower_indexed_read(array, index, index_value)?;
4315                let right = self.lower_value(right)?;
4316                self.push_call(call_name, vec![current, right])
4317            } else {
4318                self.lower_value(value)?
4319            }
4320        } else {
4321            let child = self.lower_indexed_read(array, index, index_value)?;
4322            self.rebuild_indexed_value(child, &indices[1..], target, value, span)?
4323        };
4324        self.replace_array_element(array, index_value, replacement, span)
4325    }
4326
4327    fn lower_indexed_read(
4328        &mut self,
4329        array: wir::ValueId,
4330        index: &Expr,
4331        index_value: wir::ValueId,
4332    ) -> Result<wir::ValueId, IntegrationError> {
4333        if matches!(index, Expr::Number { value, .. } if *value == 0.0) {
4334            Ok(self.push_call("firstOf", vec![array]))
4335        } else {
4336            Ok(self.push_call("valueInArray", vec![array, index_value]))
4337        }
4338    }
4339
4340    fn replace_array_element(
4341        &mut self,
4342        array: wir::ValueId,
4343        index: wir::ValueId,
4344        replacement: wir::ValueId,
4345        span: Option<HirSpan>,
4346    ) -> Result<wir::ValueId, IntegrationError> {
4347        let zero = self.push_number(0.0, "0");
4348        let one = self.push_number(1.0, "1");
4349        let end = self.push_call("add", vec![index, one]);
4350        let maximum = self.push_number(999_999_999_999.0, "999999999999");
4351        let prefix = self.push_call("slice", vec![array, zero, index]);
4352        let middle = self.lower_array(vec![replacement], span)?;
4353        let suffix = self.push_call("slice", vec![array, end, maximum]);
4354        let with_replacement = self.push_call("appendToArray", vec![prefix, middle]);
4355        Ok(self.push_call("appendToArray", vec![with_replacement, suffix]))
4356    }
4357
4358    fn lower_action_call(
4359        &mut self,
4360        name: &str,
4361        args: &[Expr],
4362        span: Option<HirSpan>,
4363    ) -> Result<wir::ActionId, IntegrationError> {
4364        if args.is_empty() {
4365            if let Some(&subroutine) = self.subroutines.get(name) {
4366                return Ok(self.wir.actions.push(Action::CallSubroutine {
4367                    subroutine,
4368                    span: self.wir_span(span)?,
4369                    callee_span: self.wir_span(span)?,
4370                }));
4371            }
4372        }
4373        if name == "chaseAtRate" {
4374            let args = args
4375                .iter()
4376                .map(|expr| self.lower_value(expr))
4377                .collect::<Result<Vec<_>, _>>()?;
4378            return Ok(self.wir.actions.push(Action::Call {
4379                name: name.to_string(),
4380                args,
4381                span: self.wir_span(span)?,
4382            }));
4383        }
4384        let function = self
4385            .compiler
4386            .manifest
4387            .resolve_function(name)
4388            .ok_or_else(|| self.unsupported(format!("unknown action '{name}'"), span))?;
4389        if !matches!(function.kind, FunctionKind::Action) {
4390            return Err(self.unsupported(format!("'{name}' is not a generic OPY action"), span));
4391        }
4392        if function.id == "async" {
4393            let [subroutine, behavior] = args else {
4394                return Err(
4395                    self.unsupported("async requires a subroutine and an AsyncBehavior", span)
4396                );
4397            };
4398            let subroutine_name = match subroutine {
4399                Expr::Call { name, args, .. } if args.is_empty() => name,
4400                _ => {
4401                    return Err(self.unsupported(
4402                        "async requires a declared subroutine",
4403                        subroutine.span().copied(),
4404                    ));
4405                }
4406            };
4407            let subroutine_id = *self.subroutines.get(subroutine_name).ok_or_else(|| {
4408                self.unsupported(
4409                    format!("unknown subroutine '{subroutine_name}'"),
4410                    subroutine.span().copied(),
4411                )
4412            })?;
4413            let subroutine = self.push_value(Value::Subroutine(subroutine_id));
4414            let behavior = self.lower_value(behavior)?;
4415            return Ok(self.wir.actions.push(Action::Call {
4416                name: "startRule".to_string(),
4417                args: vec![subroutine, behavior],
4418                span: self.wir_span(span)?,
4419            }));
4420        }
4421        if matches!(
4422            function.id.as_str(),
4423            "hudHeader" | "hudSubheader" | "hudSubtext"
4424        ) {
4425            let text_slot = match function.id.as_str() {
4426                "hudHeader" => 1,
4427                "hudSubheader" => 2,
4428                "hudSubtext" => 3,
4429                _ => unreachable!(),
4430            };
4431            return self.lower_hud_text(args, span, text_slot, &function.id);
4432        }
4433        if function.id == "createDummy" && args.len() == 4 {
4434            let mut lowered = args
4435                .iter()
4436                .map(|expr| self.lower_value(expr))
4437                .collect::<Result<Vec<_>, _>>()?;
4438            let mut zero_vector = Vec::with_capacity(3);
4439            for value in [0.0, 0.0, 0.0] {
4440                zero_vector.push(self.push_value(Value::Number {
4441                    value,
4442                    text: "0".to_string(),
4443                }));
4444            }
4445            lowered.push(self.push_call("vector", zero_vector));
4446            return Ok(self.wir.actions.push(Action::Call {
4447                name: "createDummyBot".to_string(),
4448                args: lowered,
4449                span: self.wir_span(span)?,
4450            }));
4451        }
4452        let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
4453            self.unsupported(
4454                format!(
4455                    "action '{}' requires a special lowering not in #46",
4456                    function.id
4457                ),
4458                span,
4459            )
4460        })?;
4461        let args = args
4462            .iter()
4463            .map(|expr| self.lower_value(expr))
4464            .collect::<Result<Vec<_>, _>>()?;
4465        let args = self.normalize_catalog_argument_domains(catalog_id, args);
4466        Ok(self.wir.actions.push(Action::Call {
4467            name: catalog_id.clone(),
4468            args,
4469            span: self.wir_span(span)?,
4470        }))
4471    }
4472
4473    fn normalize_catalog_argument_domains(
4474        &mut self,
4475        catalog_id: &str,
4476        mut args: Vec<wir::ValueId>,
4477    ) -> Vec<wir::ValueId> {
4478        let Some(entry) = self.compiler.catalog.entry(Kind::Action, catalog_id) else {
4479            return args;
4480        };
4481        for (index, argument) in args.iter_mut().enumerate() {
4482            let Some(domain) = entry.param_domain(index) else {
4483                continue;
4484            };
4485            let Some(ValueNode {
4486                value: Value::Enum { value_type, value },
4487                span,
4488            }) = self.wir.values.get(*argument)
4489            else {
4490                continue;
4491            };
4492            if value_type == "Team" && domain == "Color" {
4493                *argument = self.wir.values.push(ValueNode::new(
4494                    Value::Enum {
4495                        value_type: domain.to_string(),
4496                        value: value.clone(),
4497                    },
4498                    *span,
4499                ));
4500            }
4501        }
4502        args
4503    }
4504
4505    fn lower_hud_text(
4506        &mut self,
4507        args: &[Expr],
4508        span: Option<HirSpan>,
4509        text_slot: usize,
4510        function_name: &str,
4511    ) -> Result<wir::ActionId, IntegrationError> {
4512        let [
4513            visible_to,
4514            text,
4515            position,
4516            sort_order,
4517            color,
4518            reevaluation,
4519            spectators,
4520        ] = args
4521        else {
4522            return Err(self.unsupported(
4523                format!("{function_name} requires exactly seven bound arguments"),
4524                span,
4525            ));
4526        };
4527        let visible_to = self.lower_hud_visible_to(visible_to)?;
4528        let mut text_slots = [
4529            self.push_value(Value::Null),
4530            self.push_value(Value::Null),
4531            self.push_value(Value::Null),
4532        ];
4533        let text_value = self.lower_text_value(text)?;
4534        text_slots[text_slot - 1] = self.push_call("customString", vec![text_value]);
4535        let mut colors = [
4536            self.push_value(Value::Null),
4537            self.push_value(Value::Null),
4538            self.push_value(Value::Null),
4539        ];
4540        colors[text_slot - 1] = self.lower_value(color)?;
4541        let args = vec![
4542            visible_to,
4543            text_slots[0],
4544            text_slots[1],
4545            text_slots[2],
4546            self.lower_value(position)?,
4547            self.lower_value(sort_order)?,
4548            colors[0],
4549            colors[1],
4550            colors[2],
4551            self.lower_value(reevaluation)?,
4552            self.lower_value(spectators)?,
4553        ];
4554        Ok(self.wir.actions.push(Action::Call {
4555            name: "createHudText".to_string(),
4556            args,
4557            span: self.wir_span(span)?,
4558        }))
4559    }
4560
4561    fn lower_hud_visible_to(&mut self, expr: &Expr) -> Result<wir::ValueId, IntegrationError> {
4562        if let Expr::Call { name, args, .. } = expr {
4563            if name == "getAllPlayers" && args.is_empty() {
4564                let all_teams = self.push_value(Value::Enum {
4565                    value_type: "Team".to_string(),
4566                    value: "ALL".to_string(),
4567                });
4568                return Ok(self.push_call("allPlayers", vec![all_teams]));
4569            }
4570        }
4571        self.lower_value(expr)
4572    }
4573
4574    fn lower_receiver_action_call(
4575        &mut self,
4576        receiver: &Expr,
4577        name: &str,
4578        args: &[Expr],
4579        span: Option<HirSpan>,
4580    ) -> Result<wir::ActionId, IntegrationError> {
4581        let function = self
4582            .compiler
4583            .manifest
4584            .resolve_member(name)
4585            .ok_or_else(|| self.unsupported(format!("unknown member action '{name}'"), span))?;
4586        if !matches!(function.kind, FunctionKind::MemberAction) {
4587            return Err(self.unsupported(format!("'{name}' is not a member action"), span));
4588        }
4589
4590        // `append` is an OPY mutation, represented by the canonical variable
4591        // modify actions rather than a catalog action call.
4592        if matches!(function.id.as_str(), "append" | "remove") {
4593            let [value] = args else {
4594                return Err(self.unsupported(
4595                    format!("{} requires exactly one argument", function.id),
4596                    span,
4597                ));
4598            };
4599            let value = self.lower_value(value)?;
4600            let op = if function.id == "append" {
4601                wir::ModifyOp::AppendToArray
4602            } else {
4603                wir::ModifyOp::RemoveFromArray
4604            };
4605            return match receiver {
4606                Expr::GlobalVar {
4607                    name,
4608                    span: target_span,
4609                } => {
4610                    let variable = *self.globals.get(name).ok_or_else(|| {
4611                        self.unsupported(format!("unknown global variable '{name}'"), *target_span)
4612                    })?;
4613                    Ok(self.wir.actions.push(Action::ModifyGlobalVariable {
4614                        variable,
4615                        op,
4616                        value,
4617                        span: self.wir_span(span)?,
4618                        target_span: self.wir_span(*target_span)?,
4619                    }))
4620                }
4621                Expr::PlayerVar {
4622                    player,
4623                    name,
4624                    span: target_span,
4625                    ..
4626                } => {
4627                    let variable = *self.players.get(name).ok_or_else(|| {
4628                        self.unsupported(format!("unknown player variable '{name}'"), *target_span)
4629                    })?;
4630                    let player = self.lower_value(player)?;
4631                    Ok(self.wir.actions.push(Action::ModifyPlayerVariable {
4632                        player,
4633                        variable,
4634                        op,
4635                        value,
4636                        span: self.wir_span(span)?,
4637                        target_span: self.wir_span(*target_span)?,
4638                    }))
4639                }
4640                _ => Err(self.unsupported(
4641                    "append requires a global or player variable receiver",
4642                    receiver.span().copied().or(span),
4643                )),
4644            };
4645        }
4646
4647        let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
4648            self.unsupported(
4649                format!(
4650                    "member action '{}' has no canonical catalog identity",
4651                    function.id
4652                ),
4653                span,
4654            )
4655        })?;
4656        let mut lowered = Vec::with_capacity(args.len() + 1);
4657        lowered.push(self.lower_value(receiver)?);
4658        lowered.extend(
4659            args.iter()
4660                .map(|arg| self.lower_value(arg))
4661                .collect::<Result<Vec<_>, _>>()?,
4662        );
4663        Ok(self.wir.actions.push(Action::Call {
4664            name: catalog_id.clone(),
4665            args: lowered,
4666            span: self.wir_span(span)?,
4667        }))
4668    }
4669
4670    fn lower_value(&mut self, expr: &Expr) -> Result<wir::ValueId, IntegrationError> {
4671        let span = expr.span().copied();
4672        let value = match expr {
4673            Expr::Number { value, text, .. } => Value::Number {
4674                value: *value,
4675                text: canonical_number_text(*value, text),
4676            },
4677            Expr::String { value, .. } => {
4678                return self.lower_custom_string(value.clone(), span);
4679            }
4680            Expr::Bool { value, .. } => Value::Bool(*value),
4681            Expr::Null { .. } => Value::Null,
4682            Expr::Local { name, .. } => {
4683                let binding = self.array_bindings.iter().rev().find(|binding| {
4684                    binding.element == *name || binding.index.as_deref() == Some(name)
4685                });
4686                match binding {
4687                    Some(binding) if binding.element == *name => {
4688                        return Ok(self.push_call("currentArrayElement", Vec::new()));
4689                    }
4690                    Some(_) => return Ok(self.push_call("currentArrayIndex", Vec::new())),
4691                    None => {
4692                        return Err(self.unsupported(
4693                            format!("local '{name}' is not inside a supported array callback"),
4694                            span,
4695                        ));
4696                    }
4697                }
4698            }
4699            Expr::Type { .. } => {
4700                return Err(self.unsupported(
4701                    "type expressions are only valid as createWorkshopSetting type arguments",
4702                    span,
4703                ));
4704            }
4705            Expr::GlobalVar { name, .. } => {
4706                let id = *self.globals.get(name).ok_or_else(|| {
4707                    self.unsupported(format!("unknown global variable '{name}'"), span)
4708                })?;
4709                Value::GlobalVariable(id)
4710            }
4711            Expr::PlayerVar { player, name, .. } => {
4712                let player = self.lower_value(player)?;
4713                let id = *self.players.get(name).ok_or_else(|| {
4714                    self.unsupported(format!("unknown player variable '{name}'"), span)
4715                })?;
4716                Value::PlayerVariable {
4717                    player,
4718                    variable: id,
4719                }
4720            }
4721            Expr::EventPlayer { .. } => Value::EventPlayer,
4722            Expr::HostPlayer { .. } => Value::Call {
4723                name: "hostPlayer".to_string(),
4724                args: Vec::new(),
4725            },
4726            Expr::Enum {
4727                value_type, value, ..
4728            } => {
4729                if self
4730                    .compiler
4731                    .catalog
4732                    .enum_spelling(value_type, &Locale::new("en-US"), value)
4733                    .is_none()
4734                {
4735                    return Err(self.unsupported(
4736                        format!("unknown catalog enum member '{value_type}.{value}'"),
4737                        span,
4738                    ));
4739                }
4740                Value::Enum {
4741                    value_type: value_type.clone(),
4742                    value: value.clone(),
4743                }
4744            }
4745            Expr::Array { elements, .. } => {
4746                let elements = elements
4747                    .iter()
4748                    .map(|element| self.lower_value(element))
4749                    .collect::<Result<Vec<_>, _>>()?;
4750                return self.lower_array(elements, span);
4751            }
4752            Expr::Vector { x, y, z, .. } => Value::Call {
4753                name: "vector".to_string(),
4754                args: vec![
4755                    self.lower_value(x)?,
4756                    self.lower_value(y)?,
4757                    self.lower_value(z)?,
4758                ],
4759            },
4760            Expr::Constant { name, .. } => {
4761                let const_expr = *self
4762                    .constants
4763                    .get(name)
4764                    .ok_or_else(|| self.unsupported(format!("unknown constant '{name}'"), span))?;
4765                return self.lower_value(const_expr);
4766            }
4767            Expr::Index { array, index, .. } => {
4768                if let Expr::Dict { entries, .. } = array.as_ref()
4769                    && is_literal_key(index)
4770                    && entries.iter().all(|entry| is_literal_key(&entry.key))
4771                {
4772                    if let Some(value) = entries
4773                        .iter()
4774                        .find(|entry| literal_key_matches(&entry.key, index))
4775                        .map(|entry| &entry.value)
4776                    {
4777                        return self.lower_value(value);
4778                    }
4779                    return Ok(self
4780                        .wir
4781                        .values
4782                        .push(ValueNode::new(Value::Null, self.wir_span(span)?)));
4783                }
4784                // The pinned OverPy oracle lowers a literal zero-index read
4785                // (`arr[0]`, `arr[0.0]`) to `firstOf(arr)`; non-zero indexes
4786                // and indexed writes keep the indexed forms.
4787                if matches!(index.as_ref(), Expr::Number { value, .. } if *value == 0.0) {
4788                    Value::Call {
4789                        name: "firstOf".to_string(),
4790                        args: vec![self.lower_value(array)?],
4791                    }
4792                } else {
4793                    Value::Call {
4794                        name: "valueInArray".to_string(),
4795                        args: vec![self.lower_value(array)?, self.lower_value(index)?],
4796                    }
4797                }
4798            }
4799            Expr::Format { text, args, .. } => {
4800                if let Some(value) = fold_literal_format(text, args) {
4801                    return self.lower_custom_string(value, span);
4802                }
4803                let format_text = canonical_format_text(text);
4804                if args.len() <= 3 {
4805                    let text_node = self.wir.values.push(ValueNode::new(
4806                        Value::String(format_text),
4807                        self.wir_span(span)?,
4808                    ));
4809                    let mut call_args = vec![text_node];
4810                    for arg in args {
4811                        call_args.push(self.lower_value(arg)?);
4812                    }
4813                    Value::Call {
4814                        name: "customString".to_string(),
4815                        args: call_args,
4816                    }
4817                } else {
4818                    let chunks = split_format_chunks(&format_text, args.len()).ok_or_else(|| {
4819                        self.unsupported(
4820                            "format strings with more than three replacements require sequential placeholders",
4821                            span,
4822                        )
4823                    })?;
4824                    let lowered_args = args
4825                        .iter()
4826                        .map(|arg| self.lower_value(arg))
4827                        .collect::<Result<Vec<_>, _>>()?;
4828                    let mut parts = Vec::with_capacity(chunks.len());
4829                    for (chunk, indices) in chunks {
4830                        let text = self
4831                            .wir
4832                            .values
4833                            .push(ValueNode::new(Value::String(chunk), self.wir_span(span)?));
4834                        let mut call_args = vec![text];
4835                        call_args.extend(indices.into_iter().map(|index| lowered_args[index]));
4836                        parts.push(self.push_call("customString", call_args));
4837                    }
4838                    let separator = self.push_value(Value::String("{0}{1}".to_string()));
4839                    let mut value = parts[0];
4840                    for part in parts.into_iter().skip(1) {
4841                        value = self.push_call("customString", vec![separator, value, part]);
4842                    }
4843                    return Ok(value);
4844                }
4845            }
4846            Expr::Conditional {
4847                then_value,
4848                condition,
4849                else_value,
4850                ..
4851            } => Value::Call {
4852                name: "ifThenElse".to_string(),
4853                args: vec![
4854                    self.lower_value(condition)?,
4855                    self.lower_value(then_value)?,
4856                    self.lower_value(else_value)?,
4857                ],
4858            },
4859            Expr::Binary {
4860                op, left, right, ..
4861            } => {
4862                let left = self.lower_value(left)?;
4863                let right = self.lower_value(right)?;
4864                if let Some(value) = self.fold_numeric_binary(op, left, right) {
4865                    Value::Number {
4866                        value,
4867                        text: computed_number_text(value),
4868                    }
4869                } else {
4870                    match op.as_str() {
4871                        "==" | "!=" | "<" | "<=" | ">" | ">=" => Value::Call {
4872                            name: op.clone(),
4873                            args: vec![left, right],
4874                        },
4875                        "+" => Value::Call {
4876                            name: "add".to_string(),
4877                            args: vec![left, right],
4878                        },
4879                        "-" => Value::Call {
4880                            name: "subtract".to_string(),
4881                            args: vec![left, right],
4882                        },
4883                        "*" => Value::Call {
4884                            name: "multiply".to_string(),
4885                            args: vec![left, right],
4886                        },
4887                        "/" => Value::Call {
4888                            name: "divide".to_string(),
4889                            args: vec![left, right],
4890                        },
4891                        "%" => Value::Call {
4892                            name: "modulo".to_string(),
4893                            args: vec![left, right],
4894                        },
4895                        "**" => Value::Call {
4896                            name: "raiseToPower".to_string(),
4897                            args: vec![left, right],
4898                        },
4899                        "and" => Value::Call {
4900                            name: "and".to_string(),
4901                            args: vec![left, right],
4902                        },
4903                        "or" => Value::Call {
4904                            name: "or".to_string(),
4905                            args: vec![left, right],
4906                        },
4907                        "in" => Value::Call {
4908                            name: "arrayContains".to_string(),
4909                            args: vec![right, left],
4910                        },
4911                        "not in" => {
4912                            let wir_span = self.wir_span(span)?;
4913                            let contains = self.wir.values.push(ValueNode::new(
4914                                Value::Call {
4915                                    name: "arrayContains".to_string(),
4916                                    args: vec![right, left],
4917                                },
4918                                wir_span,
4919                            ));
4920                            Value::Call {
4921                                name: "not".to_string(),
4922                                args: vec![contains],
4923                            }
4924                        }
4925                        _ => {
4926                            return Err(self.unsupported(
4927                                format!(
4928                                    "binary operator '{op}' is not currently representable in canonical WIR"
4929                                ),
4930                                span,
4931                            ));
4932                        }
4933                    }
4934                }
4935            }
4936            Expr::Unary { op, operand, .. } => match op.as_str() {
4937                "not" => {
4938                    // The pinned OverPy 9.7.10 oracle lowers `not (a == b)`
4939                    // to the negated comparison (`a != b`), flipping every
4940                    // ordering comparison; `in` membership stays wrapped in
4941                    // `not`. Mirror that observable lowering.
4942                    if let Expr::Binary {
4943                        op: comparison,
4944                        left,
4945                        right,
4946                        ..
4947                    } = operand.as_ref()
4948                    {
4949                        if let Some(negated) = negated_comparison(comparison) {
4950                            Value::Call {
4951                                name: negated.to_string(),
4952                                args: vec![self.lower_value(left)?, self.lower_value(right)?],
4953                            }
4954                        } else {
4955                            Value::Call {
4956                                name: "not".to_string(),
4957                                args: vec![self.lower_value(operand)?],
4958                            }
4959                        }
4960                    } else {
4961                        Value::Call {
4962                            name: "not".to_string(),
4963                            args: vec![self.lower_value(operand)?],
4964                        }
4965                    }
4966                }
4967                "-" => Value::Call {
4968                    name: "-".to_string(),
4969                    args: vec![self.lower_value(operand)?],
4970                },
4971                "+" => return self.lower_value(operand),
4972                _ => {
4973                    return Err(self.unsupported(
4974                        format!(
4975                            "unary operator '{op}' is not currently representable in canonical WIR"
4976                        ),
4977                        span,
4978                    ));
4979                }
4980            },
4981            Expr::Call { name, args, .. } => {
4982                if name == "createWorkshopSetting" {
4983                    return self.lower_workshop_setting(args, span);
4984                }
4985                if matches!(name.as_str(), "attacker" | "victim") && args.is_empty() {
4986                    return Ok(self.push_call(name, Vec::new()));
4987                }
4988                if name == "localPlayer" && args.is_empty() {
4989                    return Ok(self.push_call(name, Vec::new()));
4990                }
4991                if name == "ruleCondition" {
4992                    if !args.is_empty() {
4993                        return Err(
4994                            self.unsupported("ruleCondition does not accept arguments", span)
4995                        );
4996                    }
4997                    let conditions = self.current_rule_conditions.clone().ok_or_else(|| {
4998                        self.unsupported("ruleCondition is only valid inside a rule", span)
4999                    })?;
5000                    let Some((first, rest)) = conditions.split_first() else {
5001                        return Ok(self.push_value(Value::Bool(true)));
5002                    };
5003                    let mut combined = *first;
5004                    for condition in rest {
5005                        combined = self.push_call("and", vec![combined, *condition]);
5006                    }
5007                    return Ok(combined);
5008                }
5009                if name == "vect" && args.len() == 3 {
5010                    Value::Vector {
5011                        x: self.lower_value(&args[0])?,
5012                        y: self.lower_value(&args[1])?,
5013                        z: self.lower_value(&args[2])?,
5014                    }
5015                } else if matches!(
5016                    name.as_str(),
5017                    "createWorkshopSettingBool"
5018                        | "createWorkshopSettingEnum"
5019                        | "createWorkshopSettingInt"
5020                        | "createWorkshopSettingFloat"
5021                ) {
5022                    let mut lowered = args
5023                        .iter()
5024                        .map(|arg| self.lower_value(arg))
5025                        .collect::<Result<Vec<_>, _>>()?;
5026                    if name == "createWorkshopSettingFloat" && lowered.len() == 5 {
5027                        lowered.push(self.push_number(0.0, "0"));
5028                    }
5029                    Value::Call {
5030                        name: match name.as_str() {
5031                            "createWorkshopSettingBool" => "workshopSettingToggle",
5032                            "createWorkshopSettingEnum" => "workshopSettingCombo",
5033                            "createWorkshopSettingInt" => "workshopSettingInteger",
5034                            _ => name,
5035                        }
5036                        .to_string(),
5037                        args: lowered,
5038                    }
5039                } else if matches!(name.as_str(), "all" | "any") {
5040                    let call_name = if name == "all" {
5041                        "isTrueForAll"
5042                    } else {
5043                        "isTrueForAny"
5044                    };
5045                    let [array] = args.as_slice() else {
5046                        return Err(self.unsupported(
5047                            format!("{name} requires exactly one array argument"),
5048                            span,
5049                        ));
5050                    };
5051                    let (array, condition) = match array {
5052                        Expr::Comprehension {
5053                            element,
5054                            variable,
5055                            index,
5056                            iterable,
5057                            ..
5058                        } => {
5059                            if index.is_some() {
5060                                return Err(self.unsupported(
5061                                    format!("{name} does not support an index binder"),
5062                                    span,
5063                                ));
5064                            }
5065                            let iterable = self.lower_value(iterable)?;
5066                            self.array_bindings.push(ArrayBinding {
5067                                element: variable.clone(),
5068                                index: None,
5069                            });
5070                            let condition = self.lower_value(element);
5071                            self.array_bindings.pop();
5072                            (iterable, condition?)
5073                        }
5074                        array => (
5075                            self.lower_value(array)?,
5076                            self.push_call("currentArrayElement", Vec::new()),
5077                        ),
5078                    };
5079                    Value::Call {
5080                        name: call_name.to_string(),
5081                        args: vec![array, condition],
5082                    }
5083                } else if matches!(name.as_str(), "ceil" | "floor" | "round") {
5084                    let [value] = args.as_slice() else {
5085                        return Err(self.unsupported(
5086                            format!("{name} requires exactly one numeric argument"),
5087                            span,
5088                        ));
5089                    };
5090                    let rounding = match name.as_str() {
5091                        "ceil" => "UP",
5092                        "floor" => "DOWN",
5093                        "round" => "NEAREST",
5094                        _ => unreachable!(),
5095                    };
5096                    let rounding = self.push_value(Value::Enum {
5097                        value_type: "Rounding".to_string(),
5098                        value: rounding.to_string(),
5099                    });
5100                    Value::Call {
5101                        name: "roundToInteger".to_string(),
5102                        args: vec![self.lower_value(value)?, rounding],
5103                    }
5104                } else if name == "sorted" {
5105                    let (array, key) = match args.as_slice() {
5106                        [array] => (
5107                            self.lower_value(array)?,
5108                            self.push_call("currentArrayElement", Vec::new()),
5109                        ),
5110                        [
5111                            array,
5112                            Expr::Lambda {
5113                                params, body, span, ..
5114                            },
5115                        ] => {
5116                            let array = self.lower_value(array)?;
5117                            let key = self.lower_array_callback(params, body, *span)?;
5118                            (array, key)
5119                        }
5120                        _ => {
5121                            return Err(self.unsupported(
5122                                "sorted requires an array and an optional lambda key",
5123                                span,
5124                            ));
5125                        }
5126                    };
5127                    Value::Call {
5128                        name: "sortedArray".to_string(),
5129                        args: vec![array, key],
5130                    }
5131                } else {
5132                    let function = self
5133                        .compiler
5134                        .manifest
5135                        .resolve_function(name)
5136                        .ok_or_else(|| self.unsupported(format!("unknown value '{name}'"), span))?;
5137                    if !matches!(function.kind, FunctionKind::Value) {
5138                        return Err(
5139                            self.unsupported(format!("'{name}' is not a generic OPY value"), span)
5140                        );
5141                    }
5142                    let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
5143                        self.unsupported(
5144                            format!(
5145                                "value '{}' requires a special lowering not in #46",
5146                                function.id
5147                            ),
5148                            span,
5149                        )
5150                    })?;
5151                    Value::Call {
5152                        name: catalog_id.clone(),
5153                        args: args
5154                            .iter()
5155                            .map(|arg| self.lower_value(arg))
5156                            .collect::<Result<Vec<_>, _>>()?,
5157                    }
5158                }
5159            }
5160            Expr::ReceiverCall {
5161                receiver,
5162                name,
5163                args,
5164                ..
5165            } => {
5166                if matches!(name.as_str(), "all" | "any") {
5167                    let [
5168                        Expr::Lambda {
5169                            params, body, span, ..
5170                        },
5171                    ] = args.as_slice()
5172                    else {
5173                        return Err(
5174                            self.unsupported(format!("{name} requires one lambda argument"), span)
5175                        );
5176                    };
5177                    let condition = self.lower_array_callback(params, body, *span)?;
5178                    let receiver = self.lower_value(receiver)?;
5179                    return Ok(self.wir.values.push(ValueNode::new(
5180                        Value::Call {
5181                            name: if name == "all" {
5182                                "isTrueForAll"
5183                            } else {
5184                                "isTrueForAny"
5185                            }
5186                            .to_string(),
5187                            args: vec![receiver, condition],
5188                        },
5189                        self.wir_span(*span)?,
5190                    )));
5191                }
5192                let function = self.compiler.manifest.resolve_member(name).ok_or_else(|| {
5193                    self.unsupported(format!("unknown member value '{name}'"), span)
5194                })?;
5195                if !matches!(function.kind, FunctionKind::MemberValue) {
5196                    return Err(self.unsupported(format!("'{name}' is not a member value"), span));
5197                }
5198                if matches!(
5199                    function.id.as_str(),
5200                    "getHitPosition" | "getPlayerHit" | "getNormal"
5201                ) {
5202                    let member_name = function.id.as_str();
5203                    let Expr::Call {
5204                        name: receiver_name,
5205                        args: receiver_args,
5206                        ..
5207                    } = receiver.as_ref()
5208                    else {
5209                        return Err(self.unsupported(
5210                            format!("{member_name} requires a raycast receiver"),
5211                            span,
5212                        ));
5213                    };
5214                    if receiver_name != "raycast" || !args.is_empty() {
5215                        return Err(self.unsupported(
5216                            format!("{member_name} requires raycast(...) with no member arguments"),
5217                            span,
5218                        ));
5219                    }
5220                    let catalog_id = function.catalog_id.clone().ok_or_else(|| {
5221                        self.unsupported(
5222                            format!("{member_name} has no canonical catalog identity"),
5223                            span,
5224                        )
5225                    })?;
5226                    let lowered_args = receiver_args
5227                        .iter()
5228                        .map(|arg| self.lower_value(arg))
5229                        .collect::<Result<Vec<_>, _>>()?;
5230                    return Ok(self.wir.values.push(ValueNode::new(
5231                        Value::Call {
5232                            name: catalog_id,
5233                            args: lowered_args,
5234                        },
5235                        self.wir_span(span)?,
5236                    )));
5237                }
5238                if function.id == "filter" {
5239                    let [
5240                        Expr::Lambda {
5241                            params, body, span, ..
5242                        },
5243                    ] = args.as_slice()
5244                    else {
5245                        return Err(self.unsupported("filter requires one lambda argument", span));
5246                    };
5247                    let condition = self.lower_array_callback(params, body, *span)?;
5248                    Value::Call {
5249                        name: "filteredArray".to_string(),
5250                        args: vec![self.lower_value(receiver)?, condition],
5251                    }
5252                } else if matches!(function.id.as_str(), "concat" | "exclude") {
5253                    let [value] = args.as_slice() else {
5254                        return Err(self.unsupported(
5255                            format!("{} requires exactly one argument", function.id),
5256                            span,
5257                        ));
5258                    };
5259                    Value::Call {
5260                        name: if function.id == "concat" {
5261                            "appendToArray"
5262                        } else {
5263                            "removeFromArray"
5264                        }
5265                        .to_string(),
5266                        args: vec![self.lower_value(receiver)?, self.lower_value(value)?],
5267                    }
5268                } else {
5269                    let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
5270                        self.unsupported(
5271                            format!(
5272                                "member value '{}' has no canonical catalog identity",
5273                                function.id
5274                            ),
5275                            span,
5276                        )
5277                    })?;
5278                    let mut lowered = Vec::with_capacity(args.len() + 1);
5279                    lowered.push(self.lower_value(receiver)?);
5280                    lowered.extend(
5281                        args.iter()
5282                            .map(|arg| self.lower_value(arg))
5283                            .collect::<Result<Vec<_>, _>>()?,
5284                    );
5285                    Value::Call {
5286                        name: catalog_id.clone(),
5287                        args: lowered,
5288                    }
5289                }
5290            }
5291            Expr::Member {
5292                receiver, member, ..
5293            } => {
5294                let receiver = self.lower_value(receiver)?;
5295                if let Some(name) = match member.as_str() {
5296                    "x" => Some("__xComponentOf__"),
5297                    "y" => Some("__yComponentOf__"),
5298                    "z" => Some("__zComponentOf__"),
5299                    _ => None,
5300                } {
5301                    Value::Call {
5302                        name: name.to_string(),
5303                        args: vec![receiver],
5304                    }
5305                } else {
5306                    let member = self.wir.values.push(ValueNode::new(
5307                        Value::String(member.clone()),
5308                        self.wir_span(span)?,
5309                    ));
5310                    Value::Call {
5311                        name: "memberAccess".to_string(),
5312                        args: vec![receiver, member],
5313                    }
5314                }
5315            }
5316            Expr::Comprehension {
5317                element,
5318                variable,
5319                index,
5320                iterable,
5321                condition,
5322                span: comprehension_span,
5323                ..
5324            } => {
5325                if condition.is_some() && index.is_some() {
5326                    return Err(self.unsupported(
5327                        "comprehensions with both a filter and an index binder are not currently representable in canonical WIR",
5328                        *comprehension_span,
5329                    ));
5330                }
5331                let iterable = self.lower_value(iterable)?;
5332                let iterable = if self.value_is_known_player(iterable) {
5333                    self.push_call("array", vec![iterable])
5334                } else {
5335                    iterable
5336                };
5337                let binding = ArrayBinding {
5338                    element: variable.clone(),
5339                    index: index.clone(),
5340                };
5341                self.array_bindings.push(binding);
5342                let predicate = condition
5343                    .as_deref()
5344                    .map(|condition| self.lower_value(condition));
5345                let element = self.lower_value(element);
5346                self.array_bindings.pop();
5347                let element = element?;
5348                let iterable = if let Some(predicate) = predicate {
5349                    let predicate = predicate?;
5350                    self.push_call("filteredArray", vec![iterable, predicate])
5351                } else {
5352                    iterable
5353                };
5354                Value::Call {
5355                    name: "mappedArray".to_string(),
5356                    args: vec![iterable, element],
5357                }
5358            }
5359            Expr::Lambda { span, .. } => {
5360                return Err(self.unsupported(
5361                    "lambda expressions are only representable as supported array operation arguments",
5362                    *span,
5363                ));
5364            }
5365            Expr::StringModifier {
5366                modifier,
5367                value,
5368                span,
5369            } => {
5370                let value = match modifier.as_str() {
5371                    "b" => big_letters(value),
5372                    "c" => case_sensitive(value),
5373                    "w" => fullwidth(value),
5374                    _ => {
5375                        return Err(self.unsupported(
5376                            format!(
5377                                "string modifier '{modifier}' is not currently representable in canonical WIR"
5378                            ),
5379                            *span,
5380                        ));
5381                    }
5382                };
5383                return self.lower_custom_string(value, *span);
5384            }
5385            _ => {
5386                return Err(self.unsupported(
5387                    format!(
5388                        "expression '{}' is not currently representable in canonical WIR",
5389                        expr.kind_name()
5390                    ),
5391                    span,
5392                ));
5393            }
5394        };
5395        Ok(self
5396            .wir
5397            .values
5398            .push(ValueNode::new(value, self.wir_span(span)?)))
5399    }
5400
5401    fn lower_array_callback(
5402        &mut self,
5403        params: &[String],
5404        body: &Expr,
5405        span: Option<HirSpan>,
5406    ) -> Result<wir::ValueId, IntegrationError> {
5407        if !(1..=2).contains(&params.len()) {
5408            return Err(self.unsupported(
5409                "array callbacks require one element parameter and at most one index parameter",
5410                span,
5411            ));
5412        }
5413        if params.windows(2).any(|pair| pair[0] == pair[1]) {
5414            return Err(
5415                self.unsupported("array callback parameters must have distinct names", span)
5416            );
5417        }
5418        self.array_bindings.push(ArrayBinding {
5419            element: params[0].clone(),
5420            index: params.get(1).cloned(),
5421        });
5422        let result = self.lower_value(body);
5423        self.array_bindings.pop();
5424        result
5425    }
5426
5427    fn lower_workshop_setting(
5428        &mut self,
5429        args: &[Expr],
5430        span: Option<HirSpan>,
5431    ) -> Result<wir::ValueId, IntegrationError> {
5432        let [
5433            Expr::Type {
5434                name: setting_type,
5435                args: type_args,
5436                span: type_span,
5437            },
5438            category,
5439            setting_name,
5440            default,
5441            sort_order,
5442        ] = args
5443        else {
5444            return Err(self.unsupported(
5445                "createWorkshopSetting requires a type and four value arguments",
5446                span,
5447            ));
5448        };
5449
5450        let catalog_name = match (setting_type.as_str(), type_args.as_slice()) {
5451            ("bool", []) => "createWorkshopSettingBool",
5452            ("int", [_, _]) => "createWorkshopSettingInt",
5453            ("float", [_, _]) => "createWorkshopSettingFloat",
5454            ("int", []) | ("float", []) => {
5455                return Err(self.unsupported(
5456                    format!("createWorkshopSetting type '{setting_type}' requires a numeric range"),
5457                    type_span.or(span),
5458                ));
5459            }
5460            _ => {
5461                return Err(self.unsupported(
5462                    format!("unsupported createWorkshopSetting type '{setting_type}'"),
5463                    type_span.or(span),
5464                ));
5465            }
5466        };
5467
5468        // OverPy uses an ideographic space for an empty setting category so
5469        // the generated Workshop setting has a non-empty category value.
5470        let category = match category {
5471            Expr::String { value, .. } if value.is_empty() => {
5472                self.push_value(Value::String("\u{3000}".to_string()))
5473            }
5474            _ => self.lower_value(category)?,
5475        };
5476        let mut lowered = vec![
5477            category,
5478            self.lower_value(setting_name)?,
5479            self.lower_value(default)?,
5480        ];
5481        if let [minimum, maximum] = type_args.as_slice() {
5482            lowered.push(self.lower_value(minimum)?);
5483            lowered.push(self.lower_value(maximum)?);
5484        }
5485        lowered.push(self.lower_value(sort_order)?);
5486        Ok(self.push_call(catalog_name, lowered))
5487    }
5488
5489    fn wir_span(&self, span: Option<HirSpan>) -> Result<Option<WorkshopSpan>, IntegrationError> {
5490        let Some(span) = span else {
5491            return Ok(None);
5492        };
5493        let file = *self.files.get(&span.file).ok_or_else(|| {
5494            IntegrationError::new(
5495                "source-file",
5496                format!("HIR span references unknown source file id {}", span.file),
5497                Some(span),
5498            )
5499        })?;
5500        Ok(Some(WorkshopSpan::new(
5501            file,
5502            WorkshopPosition::new(span.start.line, span.start.col),
5503            WorkshopPosition::new(span.end.line, span.end.col),
5504        )))
5505    }
5506
5507    fn hir_span_from_workshop(&self, span: WorkshopSpan) -> Option<HirSpan> {
5508        let file = *self.wir_to_hir_files.get(span.file.index())?;
5509        Some(HirSpan {
5510            file,
5511            start: hir::Position {
5512                line: span.start.line,
5513                col: span.start.col,
5514            },
5515            end: hir::Position {
5516                line: span.end.line,
5517                col: span.end.col,
5518            },
5519        })
5520    }
5521
5522    fn unsupported(&self, message: impl Into<String>, span: Option<HirSpan>) -> IntegrationError {
5523        IntegrationError::new("unsupported-integration-surface", message, span)
5524    }
5525}
5526
5527/// Collect the pinned OverPy implicit default global and player variables.
5528/// Global and player namespaces each have independent fixed Workshop slots;
5529/// only `eventPlayer.<name>` creates an implicit player variable.
5530fn implicit_default_variables(
5531    hir: &hir::Program,
5532) -> (
5533    BTreeMap<String, Option<HirSpan>>,
5534    BTreeMap<String, Option<HirSpan>>,
5535) {
5536    let declared_globals = hir
5537        .declarations
5538        .iter()
5539        .filter_map(|declaration| match declaration {
5540            hir::Declaration::GlobalVariable { name, .. } => Some(name.as_str()),
5541            _ => None,
5542        })
5543        .collect::<HashSet<_>>();
5544    let declared_players = hir
5545        .declarations
5546        .iter()
5547        .filter_map(|declaration| match declaration {
5548            hir::Declaration::PlayerVariable { name, .. } => Some(name.as_str()),
5549            _ => None,
5550        })
5551        .collect::<HashSet<_>>();
5552    let mut globals = BTreeMap::new();
5553    let mut players = BTreeMap::new();
5554    for declaration in &hir.declarations {
5555        let initializer = match declaration {
5556            hir::Declaration::GlobalVariable { initializer, .. }
5557            | hir::Declaration::PlayerVariable { initializer, .. } => initializer.as_ref(),
5558            hir::Declaration::Constant { value, .. } => Some(value),
5559            _ => None,
5560        };
5561        if let Some(expr) = initializer {
5562            collect_implicit_expr(
5563                expr,
5564                &declared_globals,
5565                &declared_players,
5566                &mut globals,
5567                &mut players,
5568            );
5569        }
5570    }
5571    for entry in &hir.rules {
5572        match entry {
5573            RuleEntry::Rule(rule) => {
5574                for condition in &rule.conditions {
5575                    collect_implicit_expr(
5576                        condition,
5577                        &declared_globals,
5578                        &declared_players,
5579                        &mut globals,
5580                        &mut players,
5581                    );
5582                }
5583                collect_implicit_stmts(
5584                    &rule.actions,
5585                    &declared_globals,
5586                    &declared_players,
5587                    &mut globals,
5588                    &mut players,
5589                );
5590            }
5591            RuleEntry::SubroutineDef { body, .. } => collect_implicit_stmts(
5592                body,
5593                &declared_globals,
5594                &declared_players,
5595                &mut globals,
5596                &mut players,
5597            ),
5598        }
5599    }
5600    (globals, players)
5601}
5602
5603fn collect_implicit_stmts(
5604    statements: &[Stmt],
5605    declared_globals: &HashSet<&str>,
5606    declared_players: &HashSet<&str>,
5607    globals: &mut BTreeMap<String, Option<HirSpan>>,
5608    players: &mut BTreeMap<String, Option<HirSpan>>,
5609) {
5610    for statement in statements {
5611        match statement {
5612            Stmt::Expr { expr, .. } => {
5613                collect_implicit_expr(expr, declared_globals, declared_players, globals, players)
5614            }
5615            Stmt::Assign { target, value, .. } => {
5616                collect_implicit_expr(target, declared_globals, declared_players, globals, players);
5617                collect_implicit_expr(value, declared_globals, declared_players, globals, players);
5618            }
5619            Stmt::Delete { target, .. } => {
5620                collect_implicit_expr(target, declared_globals, declared_players, globals, players);
5621            }
5622            Stmt::If {
5623                branches, r#else, ..
5624            } => {
5625                for branch in branches {
5626                    collect_implicit_expr(
5627                        &branch.condition,
5628                        declared_globals,
5629                        declared_players,
5630                        globals,
5631                        players,
5632                    );
5633                    collect_implicit_stmts(
5634                        &branch.body,
5635                        declared_globals,
5636                        declared_players,
5637                        globals,
5638                        players,
5639                    );
5640                }
5641                if let Some(default_body) = r#else {
5642                    collect_implicit_stmts(
5643                        default_body,
5644                        declared_globals,
5645                        declared_players,
5646                        globals,
5647                        players,
5648                    );
5649                }
5650            }
5651            Stmt::For {
5652                variable,
5653                iterable,
5654                body,
5655                ..
5656            } => {
5657                collect_implicit_expr(
5658                    variable,
5659                    declared_globals,
5660                    declared_players,
5661                    globals,
5662                    players,
5663                );
5664                collect_implicit_expr(
5665                    iterable,
5666                    declared_globals,
5667                    declared_players,
5668                    globals,
5669                    players,
5670                );
5671                collect_implicit_stmts(body, declared_globals, declared_players, globals, players);
5672            }
5673            Stmt::While {
5674                condition, body, ..
5675            }
5676            | Stmt::DoWhile {
5677                condition, body, ..
5678            } => {
5679                collect_implicit_expr(
5680                    condition,
5681                    declared_globals,
5682                    declared_players,
5683                    globals,
5684                    players,
5685                );
5686                collect_implicit_stmts(body, declared_globals, declared_players, globals, players);
5687            }
5688            Stmt::Switch { value, arms, .. } => {
5689                collect_implicit_expr(value, declared_globals, declared_players, globals, players);
5690                for arm in arms {
5691                    match arm {
5692                        SwitchArm::Case { value, body, .. } => {
5693                            collect_implicit_expr(
5694                                value,
5695                                declared_globals,
5696                                declared_players,
5697                                globals,
5698                                players,
5699                            );
5700                            collect_implicit_stmts(
5701                                body,
5702                                declared_globals,
5703                                declared_players,
5704                                globals,
5705                                players,
5706                            );
5707                        }
5708                        SwitchArm::Default { body, .. } => {
5709                            collect_implicit_stmts(
5710                                body,
5711                                declared_globals,
5712                                declared_players,
5713                                globals,
5714                                players,
5715                            );
5716                        }
5717                    }
5718                }
5719            }
5720            Stmt::Goto { offset, .. } => {
5721                if let Some(offset) = offset {
5722                    collect_implicit_expr(
5723                        offset,
5724                        declared_globals,
5725                        declared_players,
5726                        globals,
5727                        players,
5728                    );
5729                }
5730            }
5731            Stmt::Break { .. }
5732            | Stmt::Return { .. }
5733            | Stmt::Continue { .. }
5734            | Stmt::Label { .. }
5735            | Stmt::CallSubroutine { .. }
5736            | Stmt::Pass { .. } => {}
5737        }
5738    }
5739}
5740
5741fn collect_implicit_expr(
5742    expr: &Expr,
5743    declared_globals: &HashSet<&str>,
5744    declared_players: &HashSet<&str>,
5745    globals: &mut BTreeMap<String, Option<HirSpan>>,
5746    players: &mut BTreeMap<String, Option<HirSpan>>,
5747) {
5748    match expr {
5749        Expr::GlobalVar { name, span } => {
5750            if !declared_globals.contains(name.as_str()) && default_var_index(name).is_some() {
5751                globals.entry(name.clone()).or_insert(*span);
5752            }
5753        }
5754        Expr::Array { elements, .. } => {
5755            for element in elements {
5756                collect_implicit_expr(
5757                    element,
5758                    declared_globals,
5759                    declared_players,
5760                    globals,
5761                    players,
5762                );
5763            }
5764        }
5765        Expr::Dict { entries, .. } => {
5766            for entry in entries {
5767                collect_implicit_expr(
5768                    &entry.key,
5769                    declared_globals,
5770                    declared_players,
5771                    globals,
5772                    players,
5773                );
5774                collect_implicit_expr(
5775                    &entry.value,
5776                    declared_globals,
5777                    declared_players,
5778                    globals,
5779                    players,
5780                );
5781            }
5782        }
5783        Expr::Comprehension {
5784            element,
5785            iterable,
5786            condition,
5787            ..
5788        } => {
5789            collect_implicit_expr(
5790                element,
5791                declared_globals,
5792                declared_players,
5793                globals,
5794                players,
5795            );
5796            collect_implicit_expr(
5797                iterable,
5798                declared_globals,
5799                declared_players,
5800                globals,
5801                players,
5802            );
5803            if let Some(condition) = condition {
5804                collect_implicit_expr(
5805                    condition,
5806                    declared_globals,
5807                    declared_players,
5808                    globals,
5809                    players,
5810                );
5811            }
5812        }
5813        Expr::Lambda { body, .. } => {
5814            collect_implicit_expr(body, declared_globals, declared_players, globals, players)
5815        }
5816        Expr::Type { args, .. } => {
5817            for arg in args {
5818                collect_implicit_expr(arg, declared_globals, declared_players, globals, players);
5819            }
5820        }
5821        Expr::Vector { x, y, z, .. } => {
5822            collect_implicit_expr(x, declared_globals, declared_players, globals, players);
5823            collect_implicit_expr(y, declared_globals, declared_players, globals, players);
5824            collect_implicit_expr(z, declared_globals, declared_players, globals, players);
5825        }
5826        Expr::PlayerVar {
5827            player,
5828            name,
5829            member_span,
5830            span,
5831        } => {
5832            if !declared_players.contains(name.as_str()) && default_var_index(name).is_some() {
5833                players.entry(name.clone()).or_insert(member_span.or(*span));
5834            }
5835            collect_implicit_expr(player, declared_globals, declared_players, globals, players);
5836        }
5837        Expr::Member {
5838            receiver,
5839            member,
5840            span,
5841            ..
5842        } => {
5843            if !declared_players.contains(member.as_str()) && default_var_index(member).is_some() {
5844                players.entry(member.clone()).or_insert(*span);
5845            }
5846            collect_implicit_expr(
5847                receiver,
5848                declared_globals,
5849                declared_players,
5850                globals,
5851                players,
5852            );
5853        }
5854        Expr::Call { args, .. } | Expr::MacroCall { args, .. } => {
5855            for arg in args {
5856                collect_implicit_expr(arg, declared_globals, declared_players, globals, players);
5857            }
5858        }
5859        Expr::ReceiverCall { receiver, args, .. } => {
5860            collect_implicit_expr(
5861                receiver,
5862                declared_globals,
5863                declared_players,
5864                globals,
5865                players,
5866            );
5867            for arg in args {
5868                collect_implicit_expr(arg, declared_globals, declared_players, globals, players);
5869            }
5870        }
5871        Expr::Binary { left, right, .. } => {
5872            collect_implicit_expr(left, declared_globals, declared_players, globals, players);
5873            collect_implicit_expr(right, declared_globals, declared_players, globals, players);
5874        }
5875        Expr::Conditional {
5876            then_value,
5877            condition,
5878            else_value,
5879            ..
5880        } => {
5881            collect_implicit_expr(
5882                then_value,
5883                declared_globals,
5884                declared_players,
5885                globals,
5886                players,
5887            );
5888            collect_implicit_expr(
5889                condition,
5890                declared_globals,
5891                declared_players,
5892                globals,
5893                players,
5894            );
5895            collect_implicit_expr(
5896                else_value,
5897                declared_globals,
5898                declared_players,
5899                globals,
5900                players,
5901            );
5902        }
5903        Expr::Unary { operand, .. } => collect_implicit_expr(
5904            operand,
5905            declared_globals,
5906            declared_players,
5907            globals,
5908            players,
5909        ),
5910        Expr::Index { array, index, .. } => {
5911            collect_implicit_expr(array, declared_globals, declared_players, globals, players);
5912            collect_implicit_expr(index, declared_globals, declared_players, globals, players);
5913        }
5914        Expr::Format { args, .. } => {
5915            for arg in args {
5916                collect_implicit_expr(arg, declared_globals, declared_players, globals, players);
5917            }
5918        }
5919        Expr::Number { .. }
5920        | Expr::String { .. }
5921        | Expr::Bool { .. }
5922        | Expr::Null { .. }
5923        | Expr::StringModifier { .. }
5924        | Expr::Local { .. }
5925        | Expr::Enum { .. }
5926        | Expr::EventPlayer { .. }
5927        | Expr::HostPlayer { .. }
5928        | Expr::Constant { .. }
5929        | Expr::MacroParam { .. } => {}
5930    }
5931}
5932
5933fn allocate_indices(
5934    entries: &[(Option<u32>, Option<HirSpan>)],
5935    pre_reserved: &HashSet<u32>,
5936    kind: &str,
5937) -> Result<Vec<u32>, IntegrationError> {
5938    let mut reserved = pre_reserved.clone();
5939    for (index, span) in entries {
5940        let Some(index) = index else {
5941            continue;
5942        };
5943        if !reserved.insert(*index) {
5944            return Err(IntegrationError::new(
5945                "index-collision",
5946                format!("duplicate explicit {kind} index {index}"),
5947                *span,
5948            ));
5949        }
5950    }
5951
5952    // The pinned OverPy reference fills the remaining free slots in
5953    // ascending order for auto-allocated entries, regardless of where the
5954    // explicit indices sit in declaration order; an early explicit index
5955    // does not push later auto allocations above it.
5956    let mut next = 0;
5957    let mut allocated = Vec::with_capacity(entries.len());
5958    for (index, span) in entries {
5959        let assigned = if let Some(index) = index {
5960            *index
5961        } else {
5962            while reserved.contains(&next) {
5963                next = next.checked_add(1).ok_or_else(|| {
5964                    IntegrationError::new(
5965                        "index-exhausted",
5966                        format!("no available {kind} index remains"),
5967                        *span,
5968                    )
5969                })?;
5970            }
5971            reserved.insert(next);
5972            let assigned = next;
5973            next = next.checked_add(1).ok_or_else(|| {
5974                IntegrationError::new(
5975                    "index-exhausted",
5976                    format!("no available {kind} index remains"),
5977                    *span,
5978                )
5979            })?;
5980            assigned
5981        };
5982        allocated.push(assigned);
5983    }
5984    Ok(allocated)
5985}
5986
5987fn player_event_kind(name: &str) -> Option<PlayerEventKind> {
5988    Some(match name {
5989        "playerDealtDamage" => PlayerEventKind::DealtDamage,
5990        "playerDealtFinalBlow" => PlayerEventKind::DealtFinalBlow,
5991        "playerDealtHealing" => PlayerEventKind::DealtHealing,
5992        "playerDealtKnockback" => PlayerEventKind::DealtKnockback,
5993        "playerDied" => PlayerEventKind::Died,
5994        "playerEarnedElimination" => PlayerEventKind::EarnedElimination,
5995        "playerJoined" => PlayerEventKind::Joined,
5996        "playerLeft" => PlayerEventKind::Left,
5997        "playerReceivedHealing" => PlayerEventKind::ReceivedHealing,
5998        "playerReceivedKnockback" => PlayerEventKind::ReceivedKnockback,
5999        "playerTookDamage" => PlayerEventKind::TookDamage,
6000        _ => return None,
6001    })
6002}
6003
6004fn is_zero_initializer(expr: &hir::Expr) -> bool {
6005    match expr {
6006        hir::Expr::Number { text, value, .. } => text == "0" && *value == 0.0,
6007        hir::Expr::Null { .. } => true,
6008        _ => false,
6009    }
6010}
6011
6012fn literal_key_matches(left: &hir::Expr, right: &hir::Expr) -> bool {
6013    match (left, right) {
6014        (hir::Expr::Number { value: left, .. }, hir::Expr::Number { value: right, .. }) => {
6015            left == right
6016        }
6017        (hir::Expr::String { value: left, .. }, hir::Expr::String { value: right, .. }) => {
6018            left == right
6019        }
6020        (hir::Expr::Bool { value: left, .. }, hir::Expr::Bool { value: right, .. }) => {
6021            left == right
6022        }
6023        (hir::Expr::Null { .. }, hir::Expr::Null { .. }) => true,
6024        _ => false,
6025    }
6026}
6027
6028fn indexed_target_parts<'a>(
6029    target: &'a hir::Expr,
6030    indices: &mut Vec<&'a hir::Expr>,
6031) -> Option<&'a hir::Expr> {
6032    match target {
6033        hir::Expr::Index { array, index, .. } => {
6034            indices.push(index);
6035            indexed_target_parts(array, indices)
6036        }
6037        hir::Expr::GlobalVar { .. } | hir::Expr::PlayerVar { .. } => Some(target),
6038        _ => None,
6039    }
6040}
6041
6042fn is_literal_key(expr: &hir::Expr) -> bool {
6043    matches!(
6044        expr,
6045        hir::Expr::Number { .. }
6046            | hir::Expr::String { .. }
6047            | hir::Expr::Bool { .. }
6048            | hir::Expr::Null { .. }
6049    )
6050}
6051
6052fn translation_locale(language: &str) -> Option<&'static str> {
6053    Some(match language {
6054        "de" => "de-DE",
6055        "en" => "en-US",
6056        "es" | "es_mx" => "es-MX",
6057        "es_es" => "es-ES",
6058        "fr" => "fr-FR",
6059        "it" => "it-IT",
6060        "ja" => "ja-JP",
6061        "ko" => "ko-KR",
6062        "pl" => "pl-PL",
6063        "pt" => "pt-BR",
6064        "ru" => "ru-RU",
6065        "th" => "th-TH",
6066        "tr" => "tr-TR",
6067        "zh" | "zh_cn" => "zh-CN",
6068        "zh_tw" => "zh-TW",
6069        _ => return None,
6070    })
6071}
6072
6073fn big_letters(value: &str) -> String {
6074    let mut output = String::with_capacity(value.len());
6075    let mut converted = false;
6076    for character in value.chars() {
6077        if !converted {
6078            if let Some(mapped) = big_letter(character) {
6079                output.push(mapped);
6080                converted = true;
6081                continue;
6082            }
6083        }
6084        output.push(character);
6085    }
6086    output
6087}
6088
6089fn big_letter(character: char) -> Option<char> {
6090    Some(match character {
6091        'a' | 'A' => 'Α',
6092        'b' | 'B' => 'Β',
6093        'e' | 'E' => 'Ε',
6094        'h' | 'H' => 'Η',
6095        'i' | 'I' => 'Ι',
6096        'k' | 'K' => 'Κ',
6097        'm' | 'M' => 'Μ',
6098        'n' | 'N' => 'Ν',
6099        'o' | 'O' => 'Ο',
6100        'p' | 'P' => 'Ρ',
6101        't' | 'T' => 'Τ',
6102        'x' | 'X' => 'Χ',
6103        'y' | 'Y' => 'Υ',
6104        'z' | 'Z' => 'Ζ',
6105        '.' => '\u{2024}',
6106        ' ' => '\u{2028}',
6107        _ => return None,
6108    })
6109}
6110
6111fn fullwidth(value: &str) -> String {
6112    value
6113        .chars()
6114        .map(|character| match character {
6115            ' ' => '\u{2001}',
6116            '\u{00a5}' => '\u{ffe5}',
6117            '\u{20a9}' => '\u{ffe6}',
6118            '\u{00a2}' => '\u{ffe0}',
6119            '\u{00a3}' => '\u{ffe1}',
6120            '\u{00af}' => '\u{ffe3}',
6121            '\u{00ac}' => '\u{ffe2}',
6122            '\u{00a6}' => '\u{ffe4}',
6123            character if ('!'..='~').contains(&character) => {
6124                char::from_u32(character as u32 + 65248).unwrap_or(character)
6125            }
6126            _ => character,
6127        })
6128        .collect()
6129}
6130
6131fn case_sensitive(value: &str) -> String {
6132    let mut output = value.replace('æ', "\u{04d5}").replace("nj", "\u{01cc}");
6133    output = output.replace(" a ", " a ");
6134    output
6135        .chars()
6136        .map(|character| match character {
6137            'a' => 'ạ',
6138            'b' => 'ḅ',
6139            'c' => 'ƈ',
6140            'd' => 'ḍ',
6141            'e' => 'ẹ',
6142            'f' => 'ƒ',
6143            'g' => 'ǥ',
6144            'h' => '\u{04bb}',
6145            'i' => 'і',
6146            'j' => 'ј',
6147            'k' => 'ḳ',
6148            'l' => 'I',
6149            'm' => 'ṃ',
6150            'n' => 'ṇ',
6151            'o' => 'ο',
6152            'p' => 'ṗ',
6153            'q' => 'ǫ',
6154            'r' => 'ṛ',
6155            's' => 'ѕ',
6156            't' => 'ṭ',
6157            'u' => 'υ',
6158            'v' => 'ν',
6159            'w' => 'ẉ',
6160            'x' => '\u{04b3}',
6161            'y' => 'ỵ',
6162            'z' => 'ẓ',
6163            _ => character,
6164        })
6165        .collect()
6166}
6167
6168fn canonical_number_text(value: f64, text: &str) -> String {
6169    if text.starts_with("0x") || text.starts_with("0X") {
6170        value.to_string()
6171    } else {
6172        text.to_string()
6173    }
6174}
6175
6176fn computed_number_text(value: f64) -> String {
6177    if value.fract() == 0.0 {
6178        format!("{value:.0}")
6179    } else {
6180        value.to_string()
6181    }
6182}
6183
6184fn canonical_format_text(text: &str) -> String {
6185    let mut output = String::with_capacity(text.len());
6186    let mut chars = text.chars().peekable();
6187    let mut index = 0;
6188    while let Some(character) = chars.next() {
6189        if character == '{' && chars.peek() == Some(&'}') {
6190            chars.next();
6191            output.push('{');
6192            output.push_str(&index.to_string());
6193            output.push('}');
6194            index += 1;
6195        } else {
6196            output.push(character);
6197        }
6198    }
6199    output
6200}
6201
6202fn split_format_chunks(text: &str, arg_count: usize) -> Option<Vec<(String, Vec<usize>)>> {
6203    let mut chunks = Vec::new();
6204    let mut current = String::new();
6205    let mut indices = Vec::new();
6206    let mut pending = String::new();
6207    let mut cursor = 0;
6208    while cursor < text.len() {
6209        let Some(open_rel) = text[cursor..].find('{') else {
6210            pending.push_str(&text[cursor..]);
6211            break;
6212        };
6213        let open = cursor + open_rel;
6214        let Some(close_rel) = text[open + 1..].find('}') else {
6215            pending.push_str(&text[cursor..]);
6216            break;
6217        };
6218        let close = open + 1 + close_rel;
6219        let marker = &text[open + 1..close];
6220        let Ok(index) = marker.parse::<usize>() else {
6221            pending.push_str(&text[cursor..=close]);
6222            cursor = close + 1;
6223            continue;
6224        };
6225        if index >= arg_count {
6226            return None;
6227        }
6228        pending.push_str(&text[cursor..open]);
6229        if indices.len() == 3 && !indices.contains(&index) {
6230            chunks.push((current, indices));
6231            current = String::new();
6232            indices = Vec::new();
6233        }
6234        current.push_str(&pending);
6235        pending.clear();
6236        let local = if let Some(local) = indices.iter().position(|candidate| *candidate == index) {
6237            local
6238        } else {
6239            indices.push(index);
6240            indices.len() - 1
6241        };
6242        current.push('{');
6243        current.push_str(&local.to_string());
6244        current.push('}');
6245        cursor = close + 1;
6246    }
6247    current.push_str(&pending);
6248    if current.is_empty() && chunks.is_empty() {
6249        return Some(vec![(text.to_string(), Vec::new())]);
6250    }
6251    chunks.push((current, indices));
6252    Some(chunks)
6253}
6254
6255fn fold_literal_format(text: &str, args: &[hir::Expr]) -> Option<String> {
6256    let values = args
6257        .iter()
6258        .map(|arg| match arg {
6259            hir::Expr::Number { text, value, .. } => Some(canonical_number_text(*value, text)),
6260            hir::Expr::String { value, .. } => Some(value.clone()),
6261            hir::Expr::Bool { value, .. } => Some(value.to_string()),
6262            hir::Expr::Null { .. } => Some("null".to_string()),
6263            _ => None,
6264        })
6265        .collect::<Option<Vec<_>>>()?;
6266    let mut output = canonical_format_text(text);
6267    for (index, value) in values.iter().enumerate() {
6268        output = output.replace(&format!("{{{index}}}"), value);
6269    }
6270    Some(output)
6271}
6272
6273fn debug_expr_text(expr: &Expr) -> String {
6274    match expr {
6275        Expr::Number { text, .. } => text.clone(),
6276        Expr::String { value, .. } => {
6277            format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
6278        }
6279        Expr::Bool { value, .. } => value.to_string(),
6280        Expr::Null { .. } => "null".to_string(),
6281        Expr::Array { elements, .. } => format!(
6282            "[{}]",
6283            elements
6284                .iter()
6285                .map(debug_expr_text)
6286                .collect::<Vec<_>>()
6287                .join(", ")
6288        ),
6289        Expr::Dict { entries, .. } => format!(
6290            "{{{}}}",
6291            entries
6292                .iter()
6293                .map(|entry| format!(
6294                    "{}: {}",
6295                    debug_expr_text(&entry.key),
6296                    debug_expr_text(&entry.value)
6297                ))
6298                .collect::<Vec<_>>()
6299                .join(", ")
6300        ),
6301        Expr::Comprehension {
6302            element,
6303            variable,
6304            iterable,
6305            condition,
6306            ..
6307        } => {
6308            let condition = condition
6309                .as_deref()
6310                .map(|condition| format!(" if {}", debug_expr_text(condition)))
6311                .unwrap_or_default();
6312            format!(
6313                "[{} for {} in {}{}]",
6314                debug_expr_text(element),
6315                variable,
6316                debug_expr_text(iterable),
6317                condition
6318            )
6319        }
6320        Expr::Lambda { params, body, .. } => {
6321            format!("lambda {}: {}", params.join(", "), debug_expr_text(body))
6322        }
6323        Expr::StringModifier {
6324            modifier, value, ..
6325        } => format!("{}\"{}\"", modifier, value),
6326        Expr::Local { name, .. }
6327        | Expr::GlobalVar { name, .. }
6328        | Expr::Constant { name, .. }
6329        | Expr::MacroParam { name, .. } => name.clone(),
6330        Expr::Type { name, args, .. } => {
6331            if args.is_empty() {
6332                name.clone()
6333            } else {
6334                format!(
6335                    "{}[{}]",
6336                    name,
6337                    args.iter()
6338                        .map(debug_expr_text)
6339                        .collect::<Vec<_>>()
6340                        .join(": ")
6341                )
6342            }
6343        }
6344        Expr::Vector { x, y, z, .. } => format!(
6345            "vect({}, {}, {})",
6346            debug_expr_text(x),
6347            debug_expr_text(y),
6348            debug_expr_text(z)
6349        ),
6350        Expr::Enum {
6351            value_type, value, ..
6352        } => format!("{}.{}", value_type, value),
6353        Expr::PlayerVar { player, name, .. } => {
6354            format!("{}.{}", debug_expr_text(player), name)
6355        }
6356        Expr::Member {
6357            receiver, member, ..
6358        } => format!("{}.{}", debug_expr_text(receiver), member),
6359        Expr::EventPlayer { .. } => "eventPlayer".to_string(),
6360        Expr::HostPlayer { .. } => "hostPlayer".to_string(),
6361        Expr::Call { name, args, .. } if name == "sorted" && args.len() == 2 => {
6362            format!(
6363                "sorted({}, key = {})",
6364                debug_expr_text(&args[0]),
6365                debug_expr_text(&args[1])
6366            )
6367        }
6368        Expr::Call { name, args, .. } | Expr::MacroCall { name, args, .. } => format!(
6369            "{}({})",
6370            name,
6371            args.iter()
6372                .map(debug_expr_text)
6373                .collect::<Vec<_>>()
6374                .join(", ")
6375        ),
6376        Expr::ReceiverCall {
6377            receiver,
6378            name,
6379            args,
6380            ..
6381        } => format!(
6382            "{}.{}({})",
6383            debug_expr_text(receiver),
6384            name,
6385            args.iter()
6386                .map(debug_expr_text)
6387                .collect::<Vec<_>>()
6388                .join(", ")
6389        ),
6390        Expr::Binary {
6391            left, op, right, ..
6392        } => format!(
6393            "{} {} {}",
6394            debug_expr_text(left),
6395            op,
6396            debug_expr_text(right)
6397        ),
6398        Expr::Conditional {
6399            then_value,
6400            condition,
6401            else_value,
6402            ..
6403        } => format!(
6404            "{} if {} else {}",
6405            debug_expr_text(then_value),
6406            debug_expr_text(condition),
6407            debug_expr_text(else_value)
6408        ),
6409        Expr::Unary { op, operand, .. } => format!("{} {}", op, debug_expr_text(operand)),
6410        Expr::Index { array, index, .. } => {
6411            format!("{}[{}]", debug_expr_text(array), debug_expr_text(index))
6412        }
6413        Expr::Format { text, args, .. } => format!(
6414            "\"{}\".format({})",
6415            text,
6416            args.iter()
6417                .map(debug_expr_text)
6418                .collect::<Vec<_>>()
6419                .join(", ")
6420        ),
6421    }
6422}
6423
6424fn canonical_debug_text(text: &str) -> String {
6425    text.chars()
6426        .map(|character| match character {
6427            'a' => 'ạ',
6428            'b' => 'ḅ',
6429            'c' => 'ƈ',
6430            'd' => 'ḍ',
6431            'e' => 'ẹ',
6432            'f' => 'ƒ',
6433            'g' => 'ǥ',
6434            'h' => 'һ',
6435            'i' => 'і',
6436            'j' => 'ј',
6437            'k' => 'ḳ',
6438            'l' => 'I',
6439            'm' => 'ṃ',
6440            'n' => 'ṇ',
6441            'o' => 'ο',
6442            'p' => 'ṗ',
6443            'q' => 'ǫ',
6444            'r' => 'ṛ',
6445            's' => 'ѕ',
6446            't' => 'ṭ',
6447            'u' => 'υ',
6448            'v' => 'ν',
6449            'w' => 'ẉ',
6450            'x' => 'ҳ',
6451            'y' => 'ỵ',
6452            'z' => 'ẓ',
6453            _ => character,
6454        })
6455        .collect()
6456}
6457
6458fn negated_comparison(op: &str) -> Option<&'static str> {
6459    Some(match op {
6460        "==" => "!=",
6461        "!=" => "==",
6462        "<" => ">=",
6463        ">" => "<=",
6464        "<=" => ">",
6465        ">=" => "<",
6466        _ => return None,
6467    })
6468}
6469
6470fn modify_op_from_str(op: &str) -> Option<wir::ModifyOp> {
6471    match op {
6472        "+" => Some(wir::ModifyOp::Add),
6473        "-" => Some(wir::ModifyOp::Subtract),
6474        "*" => Some(wir::ModifyOp::Multiply),
6475        "/" => Some(wir::ModifyOp::Divide),
6476        "%" => Some(wir::ModifyOp::Modulo),
6477        "**" => Some(wir::ModifyOp::RaiseToPower),
6478        _ => None,
6479    }
6480}
6481
6482fn modify_catalog_name_from_str(op: &str) -> Option<&'static str> {
6483    match op {
6484        "+" => Some("add"),
6485        "-" => Some("subtract"),
6486        "*" => Some("multiply"),
6487        "/" => Some("divide"),
6488        "%" => Some("modulo"),
6489        "**" => Some("raiseToPower"),
6490        _ => None,
6491    }
6492}
6493
6494fn hir_span_from_diag(span: crate::diag::Span) -> HirSpan {
6495    HirSpan {
6496        file: span.file,
6497        start: hir::Position {
6498            line: span.start.line,
6499            col: span.start.col,
6500        },
6501        end: hir::Position {
6502            line: span.end.line,
6503            col: span.end.col,
6504        },
6505    }
6506}
6507
6508fn workshop_error_span(error: &workshop_rs::WorkshopError) -> Option<WorkshopSpan> {
6509    match error {
6510        workshop_rs::WorkshopError::Unknown { span, .. }
6511        | workshop_rs::WorkshopError::Malformed { span, .. }
6512        | workshop_rs::WorkshopError::Unsupported { span, .. } => *span,
6513        workshop_rs::WorkshopError::Catalog(_)
6514        | workshop_rs::WorkshopError::MissingMapping { .. } => None,
6515    }
6516}
6517
6518#[cfg(test)]
6519mod tests {
6520    use super::{
6521        COMPILE_SCHEMA_VERSION, CompileFailureClass, CompileStatus, Compiler, WORKSHOP_RS_VERSION,
6522        cross_check_manifest,
6523    };
6524    use crate::manifest::Manifest;
6525    use std::path::Path;
6526    use workshop_rs::catalog::{Catalog, Locale};
6527
6528    #[test]
6529    fn public_contract_is_pinned_and_manifest_links_are_checked() {
6530        let compiler = Compiler::new().expect("released workshop contract must load");
6531        let identity = compiler.catalog_identity();
6532        assert_eq!(identity.implementation_version, WORKSHOP_RS_VERSION);
6533        assert!(compiler.link_report().catalog_ids_checked > 0);
6534        assert!(compiler.link_report().domains_checked > 0);
6535    }
6536
6537    #[test]
6538    fn compile_report_is_versioned_and_contains_reproducibility_identity() {
6539        let compiler = Compiler::new().unwrap();
6540        let report = compiler.compile_source_report_with_locale(
6541            "rule \"report\":\n    @Event global\n    disableInspector()\n",
6542            "report.opy",
6543            Path::new("."),
6544            &Locale::new("en-US"),
6545        );
6546        assert_eq!(report.schema_version, COMPILE_SCHEMA_VERSION);
6547        assert_eq!(report.compiler.name, "opy-rs");
6548        assert_eq!(report.catalog.implementation_version, WORKSHOP_RS_VERSION);
6549        assert_eq!(report.compile.status, CompileStatus::Success);
6550        assert_eq!(report.compile.exit_code, 0);
6551        assert!(report.compile.diagnostics.is_empty());
6552        assert_eq!(
6553            report.compile.workshop,
6554            report
6555                .compile
6556                .workshop_exact
6557                .trim_end_matches('\n')
6558                .to_owned()
6559                + "\n"
6560        );
6561        assert!(serde_json::to_value(report).unwrap()["catalog"]["catalog-version"].is_string());
6562    }
6563
6564    #[test]
6565    fn compile_report_preserves_frontend_failure_class_and_source_path() {
6566        let compiler = Compiler::new().unwrap();
6567        let report = compiler.compile_source_report_with_locale(
6568            "rule \"broken\":\n    @Event global\n    missing()\n",
6569            "broken.opy",
6570            Path::new("."),
6571            &Locale::new("en-US"),
6572        );
6573        assert_eq!(report.compile.status, CompileStatus::Failure);
6574        assert_eq!(
6575            report.compile.failure_class,
6576            Some(CompileFailureClass::Frontend)
6577        );
6578        assert_eq!(report.compile.exit_code, 1);
6579        let diagnostic = &report.compile.diagnostics[0];
6580        assert_eq!(diagnostic.code, "unknown-action");
6581        assert_eq!(diagnostic.span.as_ref().unwrap().path, "broken.opy");
6582    }
6583
6584    #[test]
6585    fn compile_report_preserves_integration_failure_class_and_source_path() {
6586        let compiler = Compiler::new().unwrap();
6587        let report = compiler.compile_source_report_with_locale(
6588            "rule \"broken\":\n    @Event global\n    {\"a\": 1}[\"b\"] = 3\n",
6589            "broken.opy",
6590            Path::new("."),
6591            &Locale::new("en-US"),
6592        );
6593        assert_eq!(report.compile.status, CompileStatus::Failure);
6594        assert_eq!(
6595            report.compile.failure_class,
6596            Some(CompileFailureClass::Integration)
6597        );
6598        assert_eq!(
6599            report.compile.diagnostics[0].span.as_ref().unwrap().path,
6600            "broken.opy"
6601        );
6602    }
6603
6604    #[test]
6605    fn compile_report_preserves_frontend_warnings_on_integration_failure() {
6606        let compiler = Compiler::new().unwrap();
6607        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
6608            .join("../../compatibility/fixtures/synthetic/preprocessing");
6609        let report = compiler.compile_source_report_with_locale(
6610            concat!(
6611                "#!include \"shared.opy\"\n",
6612                "#!include \"shared.opy\"\n",
6613                "rule \"broken\":\n",
6614                "    @Event global\n",
6615                "    {\"a\": 1}[\"b\"] = 3\n",
6616            ),
6617            "broken.opy",
6618            &root,
6619            &Locale::new("en-US"),
6620        );
6621        assert_eq!(report.compile.status, CompileStatus::Failure);
6622        assert_eq!(
6623            report.compile.failure_class,
6624            Some(CompileFailureClass::Integration)
6625        );
6626        assert_eq!(report.compile.diagnostics.len(), 2);
6627        assert_eq!(
6628            report.compile.diagnostics[0].severity,
6629            crate::tooling::DiagnosticSeverity::Warning
6630        );
6631        assert_eq!(report.compile.diagnostics[0].code, "w_already_imported");
6632        assert_eq!(
6633            report.compile.diagnostics[1].severity,
6634            crate::tooling::DiagnosticSeverity::Error
6635        );
6636        assert_eq!(
6637            report.compile.diagnostics[1].span.as_ref().unwrap().path,
6638            "broken.opy"
6639        );
6640    }
6641
6642    #[test]
6643    fn vertical_slice_preserves_source_files_spans_and_emits_workshop() {
6644        let compiler = Compiler::new().unwrap();
6645        let hir = crate::compile(
6646            "globalvar A\nrule \"issue 35 integration\":\n    @Event global\n    A = 1\n    disableInspector()\n",
6647            "issue-35-integration.opy",
6648            Path::new("."),
6649        )
6650        .unwrap();
6651        let artifact = compiler.compile_hir(&hir).unwrap();
6652        assert_eq!(
6653            artifact
6654                .wir
6655                .files
6656                .get(workshop_rs::source::FileId::from_index(0))
6657                .unwrap()
6658                .path,
6659            "issue-35-integration.opy"
6660        );
6661        let rule = artifact
6662            .wir
6663            .rules
6664            .get(workshop_rs::wir::RuleId::from_index(0))
6665            .unwrap();
6666        assert_eq!(rule.span.unwrap().file.index(), 0);
6667        assert_eq!(rule.name_span.unwrap().start.line, 2);
6668        assert!(artifact.emitted.contains("Disable Inspector Recording;"));
6669        assert_eq!(artifact.catalog_identity.implementation_version, "0.1.16");
6670    }
6671
6672    #[test]
6673    fn stale_catalog_links_fail_explicitly() {
6674        let manifest = Manifest::builtin().unwrap().clone();
6675        let mut stale = manifest;
6676        stale.functions[0].catalog_id = Some("missing-catalog-id".to_string());
6677        let error = cross_check_manifest(&stale, &Catalog::builtin().unwrap()).unwrap_err();
6678        assert_eq!(error.diagnostic.code, "catalog-link-missing");
6679    }
6680
6681    #[test]
6682    fn while_lowering_is_source_attributed() {
6683        let compiler = Compiler::new().unwrap();
6684        let hir = crate::compile(
6685            "rule \"while\":\n    @Event global\n    while true:\n        disableInspector()\n",
6686            "while.opy",
6687            Path::new("."),
6688        )
6689        .unwrap();
6690        let artifact = compiler.compile_hir(&hir).unwrap();
6691        let rule = artifact
6692            .wir
6693            .rules
6694            .get(workshop_rs::wir::RuleId::from_index(0))
6695            .unwrap();
6696        assert!(matches!(
6697            artifact.wir.actions.get(rule.actions[0]),
6698            Some(workshop_rs::wir::Action::While { .. })
6699        ));
6700        assert!(artifact.emitted.contains("While(True);"));
6701    }
6702
6703    #[test]
6704    fn structural_subroutines_lower_to_canonical_wir() {
6705        let compiler = Compiler::new().unwrap();
6706        let hir = crate::compile(
6707            "globalvar score\nsubroutine showStatus\ndef showStatus():\n    @Name \"Friendly\"\n    @SuppressWarnings unusedVariable\n    disableInspector()\nrule \"caller\":\n    @Event global\n    showStatus()\n",
6708            "structure.opy",
6709            Path::new("."),
6710        )
6711        .unwrap();
6712        let artifact = compiler.compile_hir(&hir).unwrap();
6713        let subroutine = artifact
6714            .wir
6715            .subroutines
6716            .get(workshop_rs::wir::SubroutineId::from_index(0))
6717            .unwrap();
6718        assert_eq!(subroutine.name, "showStatus");
6719        assert_eq!(subroutine.index, 0);
6720        assert_eq!(subroutine.name_span.unwrap().start.line, 2);
6721        assert_eq!(artifact.wir.rules.len(), 2);
6722        let subroutine_rule = artifact
6723            .wir
6724            .rules
6725            .get(workshop_rs::wir::RuleId::from_index(0))
6726            .unwrap();
6727        let workshop_rs::wir::Event::Subroutine(subroutine_id) = subroutine_rule.event else {
6728            panic!("expected a subroutine event");
6729        };
6730        assert_eq!(
6731            artifact.wir.subroutines.get(subroutine_id).unwrap().name,
6732            "showStatus"
6733        );
6734        assert!(matches!(
6735            artifact
6736                .wir
6737                .actions
6738                .get(workshop_rs::wir::ActionId::from_index(1))
6739                .unwrap(),
6740            workshop_rs::wir::Action::CallSubroutine { .. }
6741        ));
6742        assert!(artifact.emitted.contains("Subroutine Friendly"));
6743    }
6744
6745    #[test]
6746    fn player_event_filters_resolve_through_canonical_catalog() {
6747        let compiler = Compiler::new().unwrap();
6748        let hir = crate::compile(
6749            "rule \"joined\":\n    @Event playerJoined\n    @Team 1\n    @Slot 2\n    disableInspector()\n",
6750            "filters.opy",
6751            Path::new("."),
6752        )
6753        .unwrap();
6754        let artifact = compiler.compile_hir(&hir).unwrap();
6755        assert!(matches!(
6756            &artifact
6757                .wir
6758                .rules
6759                .get(workshop_rs::wir::RuleId::from_index(0))
6760                .unwrap()
6761                .event,
6762            workshop_rs::wir::Event::Player {
6763                kind: workshop_rs::wir::PlayerEventKind::Joined,
6764                team: workshop_rs::wir::EventTeam::Team1,
6765                target: workshop_rs::wir::EventTarget::Slot(2),
6766            }
6767        ));
6768        assert!(artifact.emitted.contains("Player Joined Match;"));
6769    }
6770
6771    #[test]
6772    fn hero_event_filters_accept_legacy_aliases() {
6773        let compiler = Compiler::new().unwrap();
6774        let hir = crate::compile(
6775            "rule \"hero\":\n    @Event eachPlayer\n    @Hero soldier\n    disableInspector()\n",
6776            "hero-filter.opy",
6777            Path::new("."),
6778        )
6779        .unwrap();
6780        let artifact = compiler.compile_hir(&hir).unwrap();
6781        assert!(matches!(
6782            &artifact
6783                .wir
6784                .rules
6785                .get(workshop_rs::wir::RuleId::from_index(0))
6786                .unwrap()
6787                .event,
6788            workshop_rs::wir::Event::EachPlayerWithFilters {
6789                target: workshop_rs::wir::EventTarget::Hero(hero),
6790                ..
6791            } if hero == "SOLDIER_76"
6792        ));
6793    }
6794
6795    #[test]
6796    fn explicit_indices_are_reserved_before_deterministic_allocation() {
6797        let compiler = Compiler::new().unwrap();
6798        let hir = crate::compile(
6799            "globalvar first\nglobalvar reserved 0\nglobalvar next\nrule \"indices\":\n    @Event global\n    disableInspector()\n",
6800            "indices.opy",
6801            Path::new("."),
6802        )
6803        .unwrap();
6804        let artifact = compiler.compile_hir(&hir).unwrap();
6805        let by_name = artifact
6806            .wir
6807            .global_variables
6808            .iter()
6809            .map(|variable| (variable.name.as_str(), variable.index))
6810            .collect::<std::collections::BTreeMap<_, _>>();
6811        assert_eq!(
6812            by_name,
6813            std::collections::BTreeMap::from([("first", 1), ("reserved", 0), ("next", 2)])
6814        );
6815        // Variable tables are emitted in Workshop index order.
6816        let indices = artifact
6817            .wir
6818            .global_variables
6819            .iter()
6820            .map(|variable| variable.index)
6821            .collect::<Vec<_>>();
6822        assert_eq!(indices, vec![0, 1, 2]);
6823    }
6824
6825    #[test]
6826    fn implicit_default_variables_use_reference_fixed_slots() {
6827        let compiler = Compiler::new().unwrap();
6828        let hir = crate::compile(
6829            r#"
6830globalvar timer
6831globalvar extra 5
6832
6833rule "implicit":
6834    @Event global
6835    A = timer + 1
6836    B = A
6837    B += 2
6838    A[0] = 7
6839    DX = B * A
6840"#,
6841            "implicit.opy",
6842            Path::new("."),
6843        )
6844        .unwrap();
6845        let artifact = compiler.compile_hir(&hir).unwrap();
6846        let globals = artifact
6847            .wir
6848            .global_variables
6849            .iter()
6850            .map(|variable| (variable.name.clone(), variable.index))
6851            .collect::<Vec<_>>();
6852        // The implicit A (0), B (1), and DX (127) names keep their fixed
6853        // Workshop slots and reserve them for declared-variable allocation
6854        // (pinned OverPy evidence); `timer` auto-allocates around them and
6855        // `extra` keeps its explicit index.
6856        assert_eq!(
6857            globals,
6858            vec![
6859                ("A".to_string(), 0),
6860                ("B".to_string(), 1),
6861                ("timer".to_string(), 2),
6862                ("extra".to_string(), 5),
6863                ("DX".to_string(), 127),
6864            ]
6865        );
6866        assert!(
6867            artifact
6868                .emitted
6869                .contains("Set Global Variable(A, Add(Global.timer, 1));")
6870        );
6871        assert!(
6872            artifact
6873                .emitted
6874                .contains("Set Global Variable(B, Global.A);")
6875        );
6876        assert!(
6877            artifact
6878                .emitted
6879                .contains("Modify Global Variable(B, Add, 2);")
6880        );
6881        assert!(
6882            artifact
6883                .emitted
6884                .contains("Set Global Variable At Index(A, 0, 7);")
6885        );
6886        assert!(
6887            artifact
6888                .emitted
6889                .contains("Set Global Variable(DX, Multiply(Global.B, Global.A));")
6890        );
6891    }
6892
6893    #[test]
6894    fn implicit_default_variable_slot_collision_is_source_attributed() {
6895        let compiler = Compiler::new().unwrap();
6896        let hir = crate::compile(
6897            "globalvar x 0\nrule \"collision\":\n    @Event global\n    x = 1\n    A = 2\n",
6898            "collision.opy",
6899            Path::new("."),
6900        )
6901        .unwrap();
6902        let error = match compiler.compile_hir(&hir) {
6903            Ok(_) => panic!("slot collision unexpectedly succeeded"),
6904            Err(error) => error,
6905        };
6906        assert_eq!(error.diagnostic.code, "index-collision");
6907        assert_eq!(error.diagnostic.span.unwrap().start.line, 5);
6908        assert!(error.diagnostic.message.contains("'A' and 'x'"));
6909    }
6910
6911    #[test]
6912    fn implicit_default_player_variables_use_independent_reference_slots() {
6913        let compiler = Compiler::new().unwrap();
6914        let hir = crate::compile(
6915            r#"
6916playervar declaredPlayer
6917
6918rule "implicit player variables":
6919    @Event eachPlayer
6920    A = 1
6921    eventPlayer.A = 1
6922    eventPlayer.A += 2
6923    eventPlayer.E = eventPlayer.A
6924    eventPlayer.DX = eventPlayer.E
6925    eventPlayer.declaredPlayer = eventPlayer.A
6926"#,
6927            "implicit-player.opy",
6928            Path::new("."),
6929        )
6930        .unwrap();
6931        let artifact = compiler.compile_hir(&hir).unwrap();
6932        let globals = artifact
6933            .wir
6934            .global_variables
6935            .iter()
6936            .map(|variable| (variable.name.as_str(), variable.index))
6937            .collect::<std::collections::BTreeMap<_, _>>();
6938        let players = artifact
6939            .wir
6940            .player_variables
6941            .iter()
6942            .map(|variable| (variable.name.as_str(), variable.index))
6943            .collect::<std::collections::BTreeMap<_, _>>();
6944        assert_eq!(globals.get("A"), Some(&0));
6945        assert_eq!(players.get("A"), Some(&0));
6946        assert_eq!(players.get("declaredPlayer"), Some(&1));
6947        assert_eq!(players.get("E"), Some(&4));
6948        assert_eq!(players.get("DX"), Some(&127));
6949        assert!(
6950            artifact
6951                .emitted
6952                .contains("Set Player Variable(Event Player, A, 1);")
6953        );
6954        assert!(
6955            artifact
6956                .emitted
6957                .contains("Modify Player Variable(Event Player, A, Add, 2);")
6958        );
6959        assert!(
6960            artifact
6961                .emitted
6962                .contains("Set Player Variable(Event Player, E, (Event Player).A);")
6963        );
6964    }
6965
6966    #[test]
6967    fn implicit_default_player_slot_collision_is_source_attributed() {
6968        let compiler = Compiler::new().unwrap();
6969        let hir = crate::compile(
6970            "playervar declared 0\nrule \"collision\":\n    @Event eachPlayer\n    eventPlayer.A = 1\n",
6971            "player-collision.opy",
6972            Path::new("."),
6973        )
6974        .unwrap();
6975        let error = match compiler.compile_hir(&hir) {
6976            Ok(_) => panic!("player slot collision unexpectedly succeeded"),
6977            Err(error) => error,
6978        };
6979        assert_eq!(error.diagnostic.code, "index-collision");
6980        assert!(
6981            error
6982                .diagnostic
6983                .message
6984                .contains("player variables 'A' and 'declared'")
6985        );
6986        assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
6987    }
6988
6989    #[test]
6990    fn power_augmented_assignment_lowers_from_source() {
6991        let compiler = Compiler::new().unwrap();
6992        let hir = crate::compile(
6993            "globalvar g\nrule \"power\":\n    @Event global\n    g = 2\n    g **= 3\n",
6994            "power.opy",
6995            Path::new("."),
6996        )
6997        .unwrap();
6998        let artifact = compiler.compile_hir(&hir).unwrap();
6999        assert!(artifact.emitted.contains("Set Global Variable(g, 2);"));
7000        assert!(
7001            artifact
7002                .emitted
7003                .contains("Modify Global Variable(g, Raise To Power, 3);")
7004        );
7005    }
7006
7007    #[test]
7008    fn opy_hex_numbers_are_normalized_at_the_wir_boundary() {
7009        let compiler = Compiler::new().unwrap();
7010        let hir = crate::compile(
7011            "globalvar large = 0x124BC\nglobalvar small = 0x124\nglobalvar scientific = 1e10\n",
7012            "numbers.opy",
7013            Path::new("."),
7014        )
7015        .unwrap();
7016        let artifact = compiler.compile_hir(&hir).unwrap();
7017        assert!(
7018            artifact
7019                .emitted
7020                .contains("Set Global Variable(large, 74940);")
7021        );
7022        assert!(
7023            artifact
7024                .emitted
7025                .contains("Set Global Variable(small, 292);")
7026        );
7027        assert!(
7028            artifact
7029                .emitted
7030                .contains("Set Global Variable(scientific, 1e10);")
7031        );
7032        assert!(!artifact.emitted.contains("0x124BC"));
7033        assert!(!artifact.emitted.contains("0x124"));
7034    }
7035
7036    #[test]
7037    fn literal_dict_lookup_lowers_to_the_selected_value() {
7038        let compiler = Compiler::new().unwrap();
7039        let hir = crate::compile(
7040            "globalvar total\nrule \"negative\":\n    @Event global\n    total = {\"a\": 1, \"b\": 2}[\"a\"]\n",
7041            "negative.opy",
7042            Path::new("."),
7043        )
7044        .unwrap();
7045        let artifact = compiler
7046            .compile_hir(&hir)
7047            .expect("literal dict lookup should lower");
7048        assert!(artifact.emitted.contains("Set Global Variable(total, 1);"));
7049    }
7050
7051    #[test]
7052    fn auto_allocation_fills_free_slots_below_early_explicit_indices() {
7053        let compiler = Compiler::new().unwrap();
7054        let hir = crate::compile(
7055            r#"
7056globalvar reserved 5
7057globalvar auto1
7058globalvar auto2
7059
7060rule "allocation":
7061    @Event global
7062    auto1 = 1
7063    auto2 = 2
7064    B = 3
7065"#,
7066            "allocation.opy",
7067            Path::new("."),
7068        )
7069        .unwrap();
7070        let artifact = compiler.compile_hir(&hir).unwrap();
7071        let by_name = artifact
7072            .wir
7073            .global_variables
7074            .iter()
7075            .map(|variable| (variable.name.clone(), variable.index))
7076            .collect::<std::collections::BTreeMap<_, _>>();
7077        // The implicit B keeps its fixed slot 1; the auto-allocated variables
7078        // fill the remaining free slots below the explicit 5 instead of
7079        // jumping past it, matching the pinned OverPy oracle (slot 0 stays
7080        // free here because the implicit A is never used).
7081        assert_eq!(
7082            by_name,
7083            std::collections::BTreeMap::from([
7084                ("B".to_string(), 1),
7085                ("auto1".to_string(), 0),
7086                ("auto2".to_string(), 2),
7087                ("reserved".to_string(), 5),
7088            ])
7089        );
7090    }
7091
7092    #[test]
7093    fn power_expressions_lower_through_the_canonical_contract() {
7094        let compiler = Compiler::new().unwrap();
7095        let hir = crate::compile(
7096            "globalvar a = [2, 4]\nglobalvar out\nrule \"power\":\n    @Event global\n    out = a ** 2\n    a **= 2\n    a[0] **= 2\n",
7097            "power.opy",
7098            Path::new("."),
7099        )
7100        .unwrap();
7101        let artifact = compiler.compile_hir(&hir).unwrap();
7102        assert!(
7103            artifact
7104                .emitted
7105                .contains("Set Global Variable(out, Raise To Power(Global.a, 2));")
7106        );
7107        assert!(
7108            artifact
7109                .emitted
7110                .contains("Modify Global Variable(a, Raise To Power, 2);")
7111        );
7112        assert!(
7113            artifact
7114                .emitted
7115                .contains("Modify Global Variable At Index(a, 0, Raise To Power, 2);")
7116        );
7117    }
7118
7119    #[test]
7120    fn unsupported_rule_metadata_is_explicit_and_source_attributed() {
7121        let compiler = Compiler::new().unwrap();
7122        let hir = crate::compile(
7123            "rule \"metadata\":\n    @Event global\n    @NewPage \"section\"\n    disableInspector()\n",
7124            "metadata.opy",
7125            Path::new("."),
7126        )
7127        .unwrap();
7128        let error = match compiler.compile_hir(&hir) {
7129            Ok(_) => panic!("unsupported metadata unexpectedly succeeded"),
7130            Err(error) => error,
7131        };
7132        assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
7133        assert_eq!(error.diagnostic.span.unwrap().start.line, 3);
7134    }
7135
7136    #[test]
7137    fn issue_40_oracle_fixture_and_wir_lowering_agree() {
7138        let compiler = Compiler::new().unwrap();
7139        let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
7140            .join("../../compatibility/fixtures/synthetic/issue-40-structural");
7141        let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
7142        let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
7143        let artifact = compiler.compile_hir(&hir).unwrap();
7144        let oracle: serde_json::Value =
7145            serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
7146                .unwrap();
7147        let oracle_workshop = oracle["compile"]["workshop"].as_str().unwrap();
7148        let oracle_wir = workshop_rs::parser::parse(
7149            oracle_workshop,
7150            &Catalog::builtin().unwrap(),
7151            &Locale::new("en-US"),
7152        )
7153        .unwrap();
7154        assert!(workshop_rs::roundtrip::equivalent(
7155            &artifact.wir,
7156            &oracle_wir
7157        ));
7158
7159        assert!(oracle_workshop.contains("0: reserved"));
7160        assert!(oracle_workshop.contains("1: first"));
7161        assert!(oracle_workshop.contains("2: explicit"));
7162        assert!(oracle_workshop.contains("3: next"));
7163        assert!(oracle_workshop.contains("0: helper"));
7164        assert!(oracle_workshop.contains("Subroutine;\n        helper;"));
7165        assert!(oracle_workshop.contains("Player Joined Match;\n        Team 1;\n        Slot 2;"));
7166
7167        let indices = artifact
7168            .wir
7169            .global_variables
7170            .iter()
7171            .map(|variable| variable.index)
7172            .collect::<Vec<_>>();
7173        assert_eq!(indices, vec![0, 1, 2, 3]);
7174        assert_eq!(
7175            artifact.wir.subroutines.iter().next().unwrap().name,
7176            "helper"
7177        );
7178        assert!(artifact.emitted.contains("[Source] renamed helper"));
7179        assert!(matches!(
7180            artifact
7181                .wir
7182                .rules
7183                .get(workshop_rs::wir::RuleId::from_index(1))
7184                .unwrap()
7185                .event,
7186            workshop_rs::wir::Event::Player {
7187                kind: workshop_rs::wir::PlayerEventKind::Joined,
7188                team: workshop_rs::wir::EventTeam::Team1,
7189                target: workshop_rs::wir::EventTarget::Slot(2),
7190            }
7191        ));
7192    }
7193
7194    #[test]
7195    fn assignments_and_modifications_lower_to_canonical_wir() {
7196        let compiler = Compiler::new().unwrap();
7197        let hir = crate::compile(
7198            r#"
7199globalvar g1
7200globalvar g2
7201playervar p1
7202playervar p2 = [1, 2, 3]
7203
7204rule "assignments":
7205    @Event eachPlayer
7206    g1 = 10
7207    g1 += 5
7208    g1 -= 2
7209    g1 *= 3
7210    g1 /= 2
7211    g1 %= 4
7212    g2 = [1, 2, 3]
7213    g2[0] = 99
7214    g2[1] += 1
7215    eventPlayer.p1 = 42
7216    eventPlayer.p1 += 8
7217    eventPlayer.p1 *= 2
7218    eventPlayer.p2[2] = 7
7219    eventPlayer.p2[0] -= 3
7220"#,
7221            "assign.opy",
7222            Path::new("."),
7223        )
7224        .unwrap();
7225        let artifact = compiler.compile_hir(&hir).unwrap();
7226        assert!(artifact.emitted.contains("Set Global Variable(g1, 10);"));
7227        assert!(
7228            artifact
7229                .emitted
7230                .contains("Modify Global Variable(g1, Add, 5);")
7231        );
7232        assert!(
7233            artifact
7234                .emitted
7235                .contains("Modify Global Variable(g1, Subtract, 2);")
7236        );
7237        assert!(
7238            artifact
7239                .emitted
7240                .contains("Modify Global Variable(g1, Multiply, 3);")
7241        );
7242        assert!(
7243            artifact
7244                .emitted
7245                .contains("Modify Global Variable(g1, Divide, 2);")
7246        );
7247        assert!(
7248            artifact
7249                .emitted
7250                .contains("Modify Global Variable(g1, Modulo, 4);")
7251        );
7252        assert!(
7253            artifact
7254                .emitted
7255                .contains("Set Global Variable At Index(g2, 0, 99);")
7256        );
7257        assert!(
7258            artifact
7259                .emitted
7260                .contains("Modify Global Variable At Index(g2, 1, Add, 1);")
7261        );
7262        assert!(
7263            artifact
7264                .emitted
7265                .contains("Set Player Variable(Event Player, p1, 42);")
7266        );
7267        assert!(
7268            artifact
7269                .emitted
7270                .contains("Modify Player Variable(Event Player, p1, Add, 8);")
7271        );
7272        assert!(
7273            artifact
7274                .emitted
7275                .contains("Modify Player Variable(Event Player, p1, Multiply, 2);")
7276        );
7277        assert!(
7278            artifact
7279                .emitted
7280                .contains("Set Player Variable At Index((Event Player).p2, 2, 7);")
7281        );
7282        assert!(
7283            artifact
7284                .emitted
7285                .contains("Modify Player Variable At Index((Event Player).p2, 0, Subtract, 3);")
7286        );
7287
7288        // Direct assignments carry both the statement span and the separate
7289        // target-variable span; indexed forms lower to Call actions that
7290        // carry only the statement span.
7291        let rule = artifact
7292            .wir
7293            .rules
7294            .get(workshop_rs::wir::RuleId::from_index(1))
7295            .unwrap();
7296        let direct = artifact.wir.actions.get(rule.actions[0]).unwrap();
7297        match direct {
7298            workshop_rs::wir::Action::SetGlobalVariable {
7299                span,
7300                target_span,
7301                variable,
7302                ..
7303            } => {
7304                assert_eq!(span.unwrap().start.line, 9);
7305                assert_eq!(target_span.unwrap().start.line, 9);
7306                assert_eq!(
7307                    artifact.wir.global_variables.get(*variable).unwrap().name,
7308                    "g1"
7309                );
7310            }
7311            other => panic!("expected a direct global assignment, got {other:?}"),
7312        }
7313        let indexed = artifact.wir.actions.get(rule.actions[7]).unwrap();
7314        match indexed {
7315            workshop_rs::wir::Action::Call { span, .. } => {
7316                assert_eq!(span.unwrap().start.line, 16);
7317            }
7318            other => panic!("expected an indexed assignment call, got {other:?}"),
7319        }
7320    }
7321
7322    #[test]
7323    fn expressions_and_values_lower_to_canonical_wir() {
7324        let compiler = Compiler::new().unwrap();
7325        let hir = crate::compile(
7326            r#"
7327enum Consts:
7328    BASE
7329
7330globalvar total
7331globalvar arr = [1, 2, 3]
7332globalvar pos = vect(1, 2, 3)
7333
7334rule "expressions":
7335    @Event global
7336    @Condition total == 0
7337    @Condition not (pos == vect(0, 0, 0))
7338    @Condition 2 in arr
7339    total = Consts.BASE + arr[1] * 2 - (10 / 2) + (5 % 2)
7340    print("Total: {}".format(total))
7341    debug(pos)
7342"#,
7343            "expr.opy",
7344            Path::new("."),
7345        )
7346        .unwrap();
7347        let artifact = compiler.compile_hir(&hir).unwrap();
7348        assert!(artifact.emitted.contains("Global.total == 0;"));
7349        // `not (pos == vect(0, 0, 0))` lowers to the negated comparison,
7350        // mirroring the pinned OverPy oracle.
7351        assert!(artifact.emitted.contains("Global.pos != Vector(0, 0, 0);"));
7352        assert!(
7353            artifact
7354                .emitted
7355                .contains("Array Contains(Global.arr, 2) == True;")
7356        );
7357        assert!(
7358            artifact
7359                .emitted
7360                .contains("Custom String(\"Total: {0}\", Global.total)")
7361        );
7362    }
7363
7364    #[test]
7365    fn pass_is_supported_as_source_level_noop() {
7366        let compiler = Compiler::new().unwrap();
7367        let hir = crate::compile(
7368            r#"
7369subroutine emptySub
7370
7371def emptySub():
7372    pass
7373
7374rule "empty rule":
7375    @Event global
7376    pass
7377"#,
7378            "pass.opy",
7379            Path::new("."),
7380        )
7381        .unwrap();
7382        let artifact = compiler.compile_hir(&hir).unwrap();
7383        let rule0 = artifact
7384            .wir
7385            .rules
7386            .get(workshop_rs::wir::RuleId::from_index(0))
7387            .unwrap();
7388        assert!(rule0.actions.is_empty());
7389        let rule1 = artifact
7390            .wir
7391            .rules
7392            .get(workshop_rs::wir::RuleId::from_index(1))
7393            .unwrap();
7394        assert!(rule1.actions.is_empty());
7395    }
7396
7397    #[test]
7398    fn variable_initializers_synthesize_initialize_rules() {
7399        let compiler = Compiler::new().unwrap();
7400        let hir = crate::compile(
7401            r#"
7402globalvar j = 5
7403globalvar h = 0
7404globalvar k = 0.0
7405playervar p = 7
7406playervar q = 0
7407
7408rule "main":
7409    @Event global
7410    disableInspector()
7411"#,
7412            "init.opy",
7413            Path::new("."),
7414        )
7415        .unwrap();
7416        let artifact = compiler.compile_hir(&hir).unwrap();
7417        assert_eq!(
7418            artifact
7419                .wir
7420                .rules
7421                .get(workshop_rs::wir::RuleId::from_index(0))
7422                .unwrap()
7423                .name,
7424            "Initialize global variables"
7425        );
7426        assert_eq!(
7427            artifact
7428                .wir
7429                .rules
7430                .get(workshop_rs::wir::RuleId::from_index(1))
7431                .unwrap()
7432                .name,
7433            "Initialize player variables"
7434        );
7435        assert_eq!(
7436            artifact
7437                .wir
7438                .rules
7439                .get(workshop_rs::wir::RuleId::from_index(2))
7440                .unwrap()
7441                .name,
7442            "main"
7443        );
7444        assert!(artifact.emitted.contains("Set Global Variable(j, 5);"));
7445        assert!(artifact.emitted.contains("Set Global Variable(k, 0.0);"));
7446        assert!(!artifact.emitted.contains("Set Global Variable(h,"));
7447        assert!(
7448            artifact
7449                .emitted
7450                .contains("Set Player Variable(Event Player, p, 7);")
7451        );
7452        assert!(
7453            !artifact
7454                .emitted
7455                .contains("Set Player Variable(Event Player, q,")
7456        );
7457    }
7458
7459    #[test]
7460    fn settings_lower_through_workshop_owned_emission() {
7461        let compiler = Compiler::new().unwrap();
7462        let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
7463            .join("../../compatibility/fixtures/synthetic/settings");
7464        let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
7465        let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
7466        let artifact = compiler.compile_hir(&hir).unwrap();
7467        let oracle: serde_json::Value =
7468            serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
7469                .unwrap();
7470        let expected = oracle["compile"]["workshop"]
7471            .as_str()
7472            .unwrap()
7473            .split("\n\nrule")
7474            .next()
7475            .unwrap();
7476        let actual = artifact.emitted.split("\n\nrule").next().unwrap();
7477        let oracle_wir = workshop_rs::parser::parse(
7478            oracle["compile"]["workshop"].as_str().unwrap(),
7479            &Catalog::builtin().unwrap(),
7480            &Locale::new("en-US"),
7481        )
7482        .unwrap();
7483        assert!(workshop_rs::roundtrip::equivalent(
7484            &artifact.wir,
7485            &oracle_wir
7486        ));
7487        assert_eq!(
7488            normalize_workshop_structural_whitespace(actual),
7489            normalize_workshop_structural_whitespace(expected)
7490        );
7491    }
7492
7493    #[test]
7494    fn unsupported_locale_has_no_fabricated_source_span() {
7495        let compiler = Compiler::new().unwrap();
7496        let hir = crate::compile(
7497            "#!translations en\nrule \"r\":\n    @Event global\n    pass\n",
7498            "locale.opy",
7499            Path::new("."),
7500        )
7501        .unwrap();
7502        let error = match compiler.compile_hir_with_locale(&hir, &Locale::new("xx-XX")) {
7503            Ok(_) => panic!("unsupported locale unexpectedly compiled"),
7504            Err(error) => error,
7505        };
7506        assert_eq!(error.diagnostic.code, "locale-unsupported");
7507        assert_eq!(error.diagnostic.span, None);
7508    }
7509
7510    #[test]
7511    fn locale_selection_emits_catalog_localized_workshop() {
7512        let compiler = Compiler::new().unwrap();
7513        let hir = crate::compile(
7514            "rule \"locale\":\n    @Event global\n    disableInspector()\n",
7515            "locale.opy",
7516            Path::new("."),
7517        )
7518        .unwrap();
7519        let artifact = compiler
7520            .compile_hir_with_locale(&hir, &Locale::new("zh-CN"))
7521            .unwrap();
7522        assert!(artifact.emitted.contains("规则 (\"locale\")"));
7523        assert!(artifact.emitted.contains("禁用查看器录制"));
7524    }
7525
7526    #[test]
7527    fn unsupported_backend_directives_fail_at_their_source_anchor() {
7528        let compiler = Compiler::new().unwrap();
7529        let hir = crate::compile(
7530            "#!replace0ByCapturePercentage\nrule \"r\":\n    @Event global\n    pass\n",
7531            "directives.opy",
7532            Path::new("."),
7533        )
7534        .unwrap();
7535        let error = match compiler.compile_hir(&hir) {
7536            Ok(_) => panic!("backend directive unexpectedly compiled"),
7537            Err(error) => error,
7538        };
7539        assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
7540        assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
7541    }
7542
7543    #[test]
7544    fn optimizer_directives_remain_non_blocking_presentation_controls() {
7545        let compiler = Compiler::new().unwrap();
7546        let hir = crate::compile(
7547            "#!disableOptimizations\nrule \"r\":\n    @Event global\n    pass\n",
7548            "optimization.opy",
7549            Path::new("."),
7550        )
7551        .unwrap();
7552        compiler.compile_hir(&hir).unwrap();
7553    }
7554
7555    #[test]
7556    fn replacement_directive_records_are_checked_even_if_final_state_is_restored() {
7557        let compiler = Compiler::new().unwrap();
7558        let mut hir = crate::compile(
7559            "#!replace0ByCapturePercentage\nrule \"r\":\n    @Event global\n    pass\n",
7560            "directives.opy",
7561            Path::new("."),
7562        )
7563        .unwrap();
7564        hir.preprocessing.replacements.clear();
7565        let error = match compiler.compile_hir(&hir) {
7566            Ok(_) => panic!("replacement directive unexpectedly compiled"),
7567            Err(error) => error,
7568        };
7569        assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
7570        assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
7571    }
7572
7573    #[test]
7574    fn active_replacement_state_is_checked_without_directive_history() {
7575        let compiler = Compiler::new().unwrap();
7576        let mut hir = crate::compile(
7577            "#!replace0ByCapturePercentage\nrule \"r\":\n    @Event global\n    pass\n",
7578            "directives.opy",
7579            Path::new("."),
7580        )
7581        .unwrap();
7582        hir.preprocessing.directives.clear();
7583        hir.preprocessing.replacements[0].span = None;
7584        let error = match compiler.compile_hir(&hir) {
7585            Ok(_) => panic!("active replacement state unexpectedly compiled"),
7586            Err(error) => error,
7587        };
7588        assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
7589        assert_eq!(error.diagnostic.span, None);
7590    }
7591
7592    #[test]
7593    fn post_compile_hook_receives_exact_emitted_workshop() {
7594        let compiler = Compiler::new().unwrap();
7595        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
7596        let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n    pass\n";
7597        let artifact = compiler
7598            .compile_source_with_locale(source, "hook.opy", &root, &Locale::new("en-US"))
7599            .unwrap();
7600        assert!(artifact.emitted.contains("rule (\"setup\")"));
7601        assert!(artifact.final_output.contains("rule (\"transformed\")"));
7602        assert_ne!(artifact.final_output, artifact.emitted);
7603    }
7604
7605    #[test]
7606    fn post_compile_hook_failure_keeps_script_provenance_and_directive_anchor() {
7607        let compiler = Compiler::new().unwrap();
7608        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
7609        let source = "#!postCompileHook \"hook-boom.js\"\n\nrule \"setup\":\n    pass\n";
7610        let error = match compiler.compile_source_with_locale(
7611            source,
7612            "hook.opy",
7613            &root,
7614            &Locale::new("en-US"),
7615        ) {
7616            Ok(_) => panic!("failing post-compile hook unexpectedly compiled"),
7617            Err(error) => error,
7618        };
7619        assert_eq!(error.diagnostic.code, "post-compile-hook");
7620        assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
7621        let script = error.diagnostic.script.unwrap();
7622        assert_eq!(script.source_name.as_deref(), Some("hook-boom.js"));
7623        assert_eq!(script.line, Some(1));
7624        assert!(script.stack.unwrap().contains("hook-boom.js:1"));
7625    }
7626
7627    fn normalize_workshop_structural_whitespace(text: &str) -> String {
7628        let mut normalized = String::with_capacity(text.len());
7629        let mut quote = None;
7630        let mut escaped = false;
7631        for character in text.chars() {
7632            if let Some(delimiter) = quote {
7633                normalized.push(character);
7634                if escaped {
7635                    escaped = false;
7636                } else if character == '\\' {
7637                    escaped = true;
7638                } else if character == delimiter {
7639                    quote = None;
7640                }
7641            } else if matches!(character, '\"' | '\'') {
7642                quote = Some(character);
7643                normalized.push(character);
7644            } else if !character.is_whitespace() {
7645                normalized.push(character);
7646            }
7647        }
7648        normalized
7649    }
7650
7651    #[test]
7652    fn settings_whitespace_normalization_preserves_quoted_values() {
7653        assert_ne!(
7654            normalize_workshop_structural_whitespace("Description: \"a b\""),
7655            normalize_workshop_structural_whitespace("Description: \"ab\"")
7656        );
7657    }
7658}