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