Skip to main content

opy_rs/compiler/
mod.rs

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