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