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