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