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