Skip to main content

opy_compiler/
lib.rs

1//! The first OPY-to-Workshop integration boundary.
2//!
3//! `opy-rs` remains a standalone OPY/HIR producer. This crate is the
4//! consumer-owned compiler layer: it pins the released `workshop-rs` v0.1.11
5//! contract, checks the OPY manifest links against the canonical catalog, and
6//! lowers the supported OPY program structure into canonical WIR before
7//! validation and deterministic Workshop emission.
8
9use std::collections::{BTreeMap, HashMap, HashSet};
10
11use opy_rs::hir::{self, Expr, RuleEntry, Span as HirSpan, Stmt, SwitchArm, default_var_index};
12use opy_rs::manifest::{FunctionKind, Manifest};
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/// The exact released dependency contract consumed by this crate.
20pub const WORKSHOP_RS_VERSION: &str = "0.1.11";
21
22/// A source-attributed integration diagnostic.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct IntegrationDiagnostic {
25    pub code: String,
26    pub message: String,
27    pub span: Option<HirSpan>,
28    pub script: Option<Box<ScriptDiagnostic>>,
29}
30
31/// Script-runtime provenance retained alongside the OPY directive anchor.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ScriptDiagnostic {
34    pub source_name: Option<String>,
35    pub line: Option<u32>,
36    pub column: Option<u32>,
37    pub stack: Option<String>,
38}
39
40impl IntegrationDiagnostic {
41    fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
42        Self {
43            code: code.into(),
44            message: message.into(),
45            span,
46            script: None,
47        }
48    }
49}
50
51/// An integration boundary failure.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct IntegrationError {
54    pub diagnostic: IntegrationDiagnostic,
55}
56
57impl IntegrationError {
58    fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
59        Self {
60            diagnostic: IntegrationDiagnostic::new(code, message, span),
61        }
62    }
63
64    fn post_compile_hook(error: opy_macro_js::MacroError, span: Option<HirSpan>) -> Self {
65        let message = error.to_string();
66        let script = match error {
67            opy_macro_js::MacroError::Script(error) => Some(Box::new(ScriptDiagnostic {
68                source_name: error.source_name,
69                line: error.line,
70                column: error.column,
71                stack: error.stack,
72            })),
73            opy_macro_js::MacroError::InvalidResult { .. }
74            | opy_macro_js::MacroError::Internal(_) => None,
75        };
76        Self {
77            diagnostic: IntegrationDiagnostic {
78                code: "post-compile-hook".to_string(),
79                message,
80                span,
81                script,
82            },
83        }
84    }
85}
86
87impl std::fmt::Display for IntegrationError {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(f, "{}: {}", self.diagnostic.code, self.diagnostic.message)
90    }
91}
92
93impl std::error::Error for IntegrationError {}
94
95/// Results of the manifest-to-catalog cross-check.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub struct LinkReport {
98    pub catalog_ids_checked: usize,
99    pub domains_checked: usize,
100}
101
102/// Cross-check every OPY manifest `catalogId` and domain identity against the
103/// canonical Workshop catalog. No local catalog copy or spelling allowlist is
104/// involved.
105pub fn cross_check_manifest(
106    manifest: &Manifest,
107    catalog: &Catalog,
108) -> Result<LinkReport, IntegrationError> {
109    let mut catalog_ids_checked = 0;
110    let mut domains_checked = 0;
111
112    for function in &manifest.functions {
113        if let Some(catalog_id) = &function.catalog_id {
114            let kind = match function.kind {
115                FunctionKind::Action | FunctionKind::MemberAction => Kind::Action,
116                FunctionKind::Value | FunctionKind::MemberValue => Kind::Value,
117            };
118            catalog_ids_checked += 1;
119            if catalog.entry(kind, catalog_id).is_none() {
120                return Err(IntegrationError::new(
121                    "catalog-link-missing",
122                    format!(
123                        "manifest function '{}' links to missing {:?} catalog id '{}'",
124                        function.id, kind, catalog_id
125                    ),
126                    None,
127                ));
128            }
129        }
130
131        for parameter in &function.params {
132            let Some(domain) = &parameter.domain else {
133                continue;
134            };
135            let contextual = function
136                .contextual_domain
137                .as_ref()
138                .is_some_and(|context| context.domain == *domain);
139            if contextual {
140                continue;
141            }
142            domains_checked += 1;
143            if catalog.enum_domain(domain).is_none() {
144                return Err(IntegrationError::new(
145                    "domain-link-missing",
146                    format!(
147                        "manifest function '{}' parameter '{}' links to missing enum domain '{}',",
148                        function.id, parameter.name, domain
149                    ),
150                    None,
151                ));
152            }
153        }
154
155        if let Some(contextual) = &function.contextual_domain {
156            for option in contextual.options.values() {
157                domains_checked += 1;
158                if catalog.enum_domain(&option.domain).is_none() {
159                    return Err(IntegrationError::new(
160                        "domain-link-missing",
161                        format!(
162                            "manifest function '{}' contextual option links to missing enum domain '{}'",
163                            function.id, option.domain
164                        ),
165                        None,
166                    ));
167                }
168            }
169        }
170    }
171
172    Ok(LinkReport {
173        catalog_ids_checked,
174        domains_checked,
175    })
176}
177
178/// The compiler-facing integration object. Construction validates the public
179/// manifest/catalog contract once and exposes the pinned catalog identity.
180pub struct Compiler {
181    catalog: Catalog,
182    manifest: &'static Manifest,
183    links: LinkReport,
184}
185
186impl Compiler {
187    pub fn new() -> Result<Self, IntegrationError> {
188        let catalog = Catalog::builtin()
189            .map_err(|error| IntegrationError::new("catalog-load", error.to_string(), None))?;
190        let manifest = Manifest::builtin()
191            .map_err(|error| IntegrationError::new("manifest-load", error.to_string(), None))?;
192        let links = cross_check_manifest(manifest, &catalog)?;
193        let identity = catalog.identity();
194        if identity.implementation_version != WORKSHOP_RS_VERSION {
195            return Err(IntegrationError::new(
196                "workshop-contract-version",
197                format!(
198                    "expected workshop-rs {}, loaded {}",
199                    WORKSHOP_RS_VERSION, identity.implementation_version
200                ),
201                None,
202            ));
203        }
204        Ok(Self {
205            catalog,
206            manifest,
207            links,
208        })
209    }
210
211    pub fn catalog_identity(&self) -> CatalogIdentity {
212        self.catalog.identity()
213    }
214
215    pub fn link_report(&self) -> LinkReport {
216        self.links
217    }
218
219    /// Lower a resolved OPY HIR program into canonical WIR, validate it
220    /// against the canonical catalog, and emit deterministic en-US Workshop.
221    pub fn compile_hir(&self, hir: &hir::Program) -> Result<CompilationArtifact, IntegrationError> {
222        self.compile_hir_with_locale(hir, &Locale::new("en-US"))
223    }
224
225    /// Lower and emit using a locale declared by the canonical catalog.
226    pub fn compile_hir_with_locale(
227        &self,
228        hir: &hir::Program,
229        locale: &Locale,
230    ) -> Result<CompilationArtifact, IntegrationError> {
231        if !self.catalog.supports(locale) {
232            return Err(IntegrationError::new(
233                "locale-unsupported",
234                format!("workshop catalog does not declare locale '{locale}'"),
235                None,
236            ));
237        }
238        reject_unlowered_directives(hir)?;
239        let mut lowering = Lowering::new(self, hir)?;
240        lowering.copy_files()?;
241        lowering.lower_declarations()?;
242        lowering.lower_rules()?;
243
244        lowering.wir.validate().map_err(|error| {
245            let span = error
246                .span()
247                .and_then(|span| lowering.hir_span_from_workshop(span));
248            IntegrationError::new(error.code(), error.message(), span)
249        })?;
250        workshop_rs::validate::validate_canonical_ids(&lowering.wir, &self.catalog).map_err(
251            |error| {
252                let span = workshop_error_span(&error)
253                    .and_then(|span| lowering.hir_span_from_workshop(span));
254                IntegrationError::new("catalog-validation", error.to_string(), span)
255            },
256        )?;
257        let emitted =
258            workshop_rs::emitter::emit(&lowering.wir, &self.catalog, locale).map_err(|error| {
259                let span = workshop_error_span(&error)
260                    .and_then(|span| lowering.hir_span_from_workshop(span));
261                IntegrationError::new("workshop-emission", error.to_string(), span)
262            })?;
263
264        Ok(CompilationArtifact {
265            wir: lowering.wir,
266            final_output: emitted.clone(),
267            emitted,
268            catalog_identity: self.catalog.identity(),
269            hook_console_output: Vec::new(),
270        })
271    }
272
273    /// Compile source and run a declared post-compile hook only after the
274    /// final Workshop emission has succeeded.
275    pub fn compile_source(
276        &self,
277        source: &str,
278        main_path: &str,
279        root: &std::path::Path,
280        locale: &Locale,
281    ) -> Result<CompilationArtifact, IntegrationError> {
282        let outcome = opy_rs::compile_with_overlay_outcome(
283            source,
284            main_path,
285            root,
286            &std::collections::BTreeMap::new(),
287        );
288        let hir = outcome.hir.ok_or_else(|| {
289            let error = outcome
290                .error
291                .expect("failed frontend compile has diagnostic");
292            IntegrationError::new(
293                error.code,
294                error.message,
295                error.span.map(hir_span_from_diag),
296            )
297        })?;
298        let hook = outcome.post_compile_hook;
299        let mut artifact = self.compile_hir_with_locale(&hir, locale)?;
300        if let Some(hook) = hook {
301            let runtime = opy_macro_js::MacroRuntime::new(opy_macro_js::Limits::default());
302            let result = runtime
303                .run_hook(&hook.source, &artifact.emitted, &hook.script)
304                .map_err(|error| {
305                    IntegrationError::post_compile_hook(error, hook.span.map(hir_span_from_diag))
306                })?;
307            artifact.final_output = result.text;
308            artifact.hook_console_output = result.console_output;
309        }
310        Ok(artifact)
311    }
312}
313
314fn reject_unlowered_directives(hir: &hir::Program) -> Result<(), IntegrationError> {
315    if let Some(replacement) = hir.preprocessing.replacements.first() {
316        let span = hir
317            .preprocessing
318            .directives
319            .iter()
320            .find(|directive| directive.name.starts_with("replace"))
321            .and_then(|directive| directive.span)
322            .or(replacement.span);
323        return Err(IntegrationError::new(
324            "backend-directive-unsupported",
325            format!(
326                "replacement directive '{}' has no canonical workshop-rs lowering",
327                replacement.value
328            ),
329            span,
330        ));
331    }
332    if let Some(replacement) = hir
333        .preprocessing
334        .directives
335        .iter()
336        .find(|directive| directive.name.starts_with("replace"))
337    {
338        return Err(IntegrationError::new(
339            "backend-directive-unsupported",
340            format!(
341                "replacement directive '{}' has no canonical workshop-rs lowering",
342                replacement.name
343            ),
344            replacement.span,
345        ));
346    }
347    Ok(())
348}
349
350/// A validated WIR program and its emitted Workshop artifact.
351pub struct CompilationArtifact {
352    pub wir: Program,
353    pub emitted: String,
354    pub catalog_identity: CatalogIdentity,
355    pub final_output: String,
356    pub hook_console_output: Vec<String>,
357}
358
359fn convert_settings(settings: opy_rs::hir::Settings) -> workshop_rs::settings::Settings {
360    workshop_rs::settings::Settings {
361        span: settings.span.map(convert_settings_span),
362        children: settings
363            .children
364            .into_iter()
365            .map(convert_settings_node)
366            .collect(),
367    }
368}
369
370fn convert_settings_node(node: opy_rs::hir::SettingsNode) -> workshop_rs::settings::SettingsNode {
371    use opy_rs::hir::SettingsNode as SourceNode;
372    use workshop_rs::settings::{SettingsListElement, SettingsNode as TargetNode};
373
374    match node {
375        SourceNode::Group {
376            name,
377            children,
378            span,
379        } => TargetNode::Group {
380            name,
381            children: children.into_iter().map(convert_settings_node).collect(),
382            span: span.map(convert_settings_span),
383        },
384        SourceNode::Number { name, value, span } => TargetNode::Number {
385            name,
386            value,
387            span: span.map(convert_settings_span),
388        },
389        SourceNode::Bool { name, value, span } => TargetNode::Bool {
390            name,
391            value,
392            span: span.map(convert_settings_span),
393        },
394        SourceNode::String { name, value, span } => TargetNode::String {
395            name,
396            value,
397            span: span.map(convert_settings_span),
398        },
399        SourceNode::List {
400            name,
401            elements,
402            span,
403        } => TargetNode::List {
404            name,
405            elements: elements
406                .into_iter()
407                .map(|element| SettingsListElement {
408                    value: element.value,
409                    span: element.span.map(convert_settings_span),
410                })
411                .collect(),
412            span: span.map(convert_settings_span),
413        },
414    }
415}
416
417fn convert_settings_span(span: HirSpan) -> WorkshopSpan {
418    WorkshopSpan::new(
419        workshop_rs::source::FileId::from_index(span.file as usize),
420        WorkshopPosition::new(span.start.line, span.start.col),
421        WorkshopPosition::new(span.end.line, span.end.col),
422    )
423}
424
425struct Lowering<'a> {
426    compiler: &'a Compiler,
427    hir: &'a hir::Program,
428    wir: Program,
429    files: HashMap<u32, workshop_rs::source::FileId>,
430    wir_to_hir_files: Vec<u32>,
431    globals: HashMap<String, wir::GlobalVarId>,
432    players: HashMap<String, wir::PlayerVarId>,
433    subroutines: HashMap<String, wir::SubroutineId>,
434    constants: HashMap<String, &'a Expr>,
435    defined_subroutines: HashSet<wir::SubroutineId>,
436}
437
438#[derive(Debug, Clone, Copy)]
439enum BreakTarget {
440    Loop,
441    DoWhile,
442    Switch,
443}
444
445type SwitchBreak = (usize, HirSpan);
446type LoweredSwitchBody = (Vec<wir::ActionId>, Option<SwitchBreak>);
447
448impl<'a> Lowering<'a> {
449    fn new(compiler: &'a Compiler, hir: &'a hir::Program) -> Result<Self, IntegrationError> {
450        Ok(Self {
451            compiler,
452            hir,
453            wir: Program::default(),
454            files: HashMap::new(),
455            wir_to_hir_files: Vec::new(),
456            globals: HashMap::new(),
457            players: HashMap::new(),
458            subroutines: HashMap::new(),
459            constants: HashMap::new(),
460            defined_subroutines: HashSet::new(),
461        })
462    }
463
464    fn copy_files(&mut self) -> Result<(), IntegrationError> {
465        self.wir.settings = self.hir.settings.clone().map(convert_settings);
466        for file in &self.hir.files {
467            if self.files.contains_key(&file.id) {
468                return Err(IntegrationError::new(
469                    "source-file",
470                    format!("duplicate HIR source file id {}", file.id),
471                    None,
472                ));
473            }
474            let id = self.wir.files.push(SourceFile::new(file.path.clone()));
475            self.files.insert(file.id, id);
476            self.wir_to_hir_files.push(file.id);
477        }
478        Ok(())
479    }
480
481    fn lower_declarations(&mut self) -> Result<(), IntegrationError> {
482        let (implicit_globals, implicit_players) = implicit_default_variables(self.hir);
483        for declaration in &self.hir.declarations {
484            if let hir::Declaration::GlobalVariable {
485                name,
486                index: Some(index),
487                span,
488                ..
489            } = declaration
490            {
491                for (implicit_name, implicit_span) in &implicit_globals {
492                    if default_var_index(implicit_name) == Some(*index) {
493                        return Err(IntegrationError::new(
494                            "index-collision",
495                            format!(
496                                "duplicate use of index {index} for global variables '{implicit_name}' and '{name}'"
497                            ),
498                            implicit_span.or(*span),
499                        ));
500                    }
501                }
502            }
503            if let hir::Declaration::PlayerVariable {
504                name,
505                index: Some(index),
506                span,
507                ..
508            } = declaration
509            {
510                for (implicit_name, implicit_span) in &implicit_players {
511                    if default_var_index(implicit_name) == Some(*index) {
512                        return Err(IntegrationError::new(
513                            "index-collision",
514                            format!(
515                                "duplicate use of index {index} for player variables '{implicit_name}' and '{name}'"
516                            ),
517                            implicit_span.or(*span),
518                        ));
519                    }
520                }
521            }
522        }
523
524        let globals = self
525            .hir
526            .declarations
527            .iter()
528            .filter_map(|declaration| match declaration {
529                hir::Declaration::GlobalVariable { index, span, .. } => Some((*index, *span)),
530                _ => None,
531            })
532            .collect::<Vec<_>>();
533        let players = self
534            .hir
535            .declarations
536            .iter()
537            .filter_map(|declaration| match declaration {
538                hir::Declaration::PlayerVariable { index, span, .. } => Some((*index, *span)),
539                _ => None,
540            })
541            .collect::<Vec<_>>();
542        let subroutines = self
543            .hir
544            .declarations
545            .iter()
546            .filter_map(|declaration| match declaration {
547                hir::Declaration::Subroutine { index, span, .. } => Some((*index, *span)),
548                _ => None,
549            })
550            .collect::<Vec<_>>();
551        let implicit_reserved = implicit_globals
552            .keys()
553            .map(|name| default_var_index(name).expect("implicit default variable names resolve"))
554            .collect::<HashSet<_>>();
555        let implicit_player_reserved = implicit_players
556            .keys()
557            .map(|name| default_var_index(name).expect("implicit default player names resolve"))
558            .collect::<HashSet<_>>();
559        let empty = HashSet::new();
560        let global_indices = allocate_indices(&globals, &implicit_reserved, "global variable")?;
561        let player_indices =
562            allocate_indices(&players, &implicit_player_reserved, "player variable")?;
563        let subroutine_indices = allocate_indices(&subroutines, &empty, "subroutine")?;
564        let mut global_index = 0;
565        let mut player_index = 0;
566        let mut subroutine_index = 0;
567
568        // Declared variables in source order (for duplicate detection and
569        // initializer action order), then merged with the implicit default
570        // variables and created in Workshop index order so the emitted
571        // variable tables are reference-compatible.
572        let mut declared_globals: Vec<(&str, u32, Option<HirSpan>, Option<HirSpan>)> = Vec::new();
573        let mut global_initializers = Vec::new();
574        let mut declared_players: Vec<(&str, u32, Option<HirSpan>, Option<HirSpan>)> = Vec::new();
575        let mut player_initializers = Vec::new();
576        let mut declared_subroutines: Vec<(&str, u32, Option<HirSpan>, Option<HirSpan>)> =
577            Vec::new();
578
579        for declaration in &self.hir.declarations {
580            match declaration {
581                hir::Declaration::GlobalVariable {
582                    name,
583                    index: _,
584                    span,
585                    name_span,
586                    initializer,
587                } => {
588                    let assigned = global_indices[global_index];
589                    global_index += 1;
590                    if declared_globals
591                        .iter()
592                        .any(|(existing, ..)| *existing == name)
593                    {
594                        return Err(IntegrationError::new(
595                            "symbol-collision",
596                            format!("duplicate global variable '{name}'"),
597                            *span,
598                        ));
599                    }
600                    declared_globals.push((name, assigned, *span, *name_span));
601                    if let Some(init) = initializer {
602                        if !is_zero_initializer(init) {
603                            global_initializers.push((name, init, *span, *name_span));
604                        }
605                    }
606                }
607                hir::Declaration::PlayerVariable {
608                    name,
609                    index: _,
610                    span,
611                    name_span,
612                    initializer,
613                } => {
614                    let assigned = player_indices[player_index];
615                    player_index += 1;
616                    if declared_players
617                        .iter()
618                        .any(|(existing, ..)| *existing == name)
619                    {
620                        return Err(IntegrationError::new(
621                            "symbol-collision",
622                            format!("duplicate player variable '{name}'"),
623                            *span,
624                        ));
625                    }
626                    declared_players.push((name, assigned, *span, *name_span));
627                    if let Some(init) = initializer {
628                        if !is_zero_initializer(init) {
629                            player_initializers.push((name, init, *span, *name_span));
630                        }
631                    }
632                }
633                hir::Declaration::Subroutine {
634                    name,
635                    span,
636                    name_span,
637                    ..
638                } => {
639                    let assigned = subroutine_indices[subroutine_index];
640                    subroutine_index += 1;
641                    if declared_subroutines
642                        .iter()
643                        .any(|(existing, ..)| *existing == name)
644                    {
645                        return Err(IntegrationError::new(
646                            "symbol-collision",
647                            format!("duplicate subroutine '{name}'"),
648                            *span,
649                        ));
650                    }
651                    declared_subroutines.push((name, assigned, *span, *name_span));
652                }
653                hir::Declaration::Constant { name, value, span } => {
654                    if self.constants.insert(name.clone(), value).is_some() {
655                        return Err(IntegrationError::new(
656                            "symbol-collision",
657                            format!("duplicate constant '{name}'"),
658                            *span,
659                        ));
660                    }
661                }
662                hir::Declaration::Macro { .. } => {
663                    // Macro definitions are expanded by the source implementation; ignored during WIR lowering.
664                }
665            }
666        }
667
668        let mut planned_globals: Vec<(String, u32, Option<HirSpan>, Option<HirSpan>)> =
669            declared_globals
670                .into_iter()
671                .map(|(name, index, span, name_span)| (name.to_string(), index, span, name_span))
672                .collect();
673        planned_globals.extend(implicit_globals.iter().map(|(name, span)| {
674            (
675                name.clone(),
676                default_var_index(name).expect("implicit default variable names resolve"),
677                *span,
678                None,
679            )
680        }));
681        planned_globals.sort_by_key(|(_, index, ..)| *index);
682        for (name, assigned, span, name_span) in planned_globals {
683            let id = self.wir.global_variables.push(wir::WorkshopVariable {
684                name: name.clone(),
685                index: assigned,
686                span: self.wir_span(span)?,
687                name_span: self.wir_span(name_span)?,
688            });
689            self.globals.insert(name.clone(), id);
690        }
691
692        let mut planned_players: Vec<(String, u32, Option<HirSpan>, Option<HirSpan>)> =
693            declared_players
694                .into_iter()
695                .map(|(name, index, span, name_span)| (name.to_string(), index, span, name_span))
696                .collect();
697        planned_players.extend(implicit_players.iter().map(|(name, span)| {
698            (
699                name.clone(),
700                default_var_index(name).expect("implicit default player names resolve"),
701                *span,
702                None,
703            )
704        }));
705        planned_players.sort_by_key(|(_, index, ..)| *index);
706        for (name, assigned, span, name_span) in planned_players {
707            let id = self.wir.player_variables.push(wir::WorkshopVariable {
708                name: name.clone(),
709                index: assigned,
710                span: self.wir_span(span)?,
711                name_span: self.wir_span(name_span)?,
712            });
713            self.players.insert(name, id);
714        }
715
716        declared_subroutines.sort_by_key(|(_, index, ..)| *index);
717        for (name, assigned, span, name_span) in declared_subroutines {
718            let id = self.wir.subroutines.push(wir::WorkshopSubroutine {
719                name: name.to_string(),
720                index: assigned,
721                span: self.wir_span(span)?,
722                name_span: self.wir_span(name_span)?,
723            });
724            self.subroutines.insert(name.to_string(), id);
725        }
726
727        if !global_initializers.is_empty() {
728            let mut actions = Vec::with_capacity(global_initializers.len());
729            for (name, init_expr, span, target_span) in global_initializers {
730                let variable = *self.globals.get(name).expect("declared global is created");
731                let value = self.lower_value(init_expr)?;
732                actions.push(self.wir.actions.push(Action::SetGlobalVariable {
733                    variable,
734                    value,
735                    span: self.wir_span(span)?,
736                    target_span: self.wir_span(target_span)?,
737                }));
738            }
739            self.wir.rules.push(wir::Rule {
740                name: "Initialize global variables".to_string(),
741                span: None,
742                name_span: None,
743                disabled: false,
744                event: Event::Global,
745                conditions: Vec::new(),
746                actions,
747            });
748        }
749
750        if !player_initializers.is_empty() {
751            let mut actions = Vec::with_capacity(player_initializers.len());
752            for (name, init_expr, span, target_span) in player_initializers {
753                let variable = *self
754                    .players
755                    .get(name)
756                    .expect("declared player variable is created");
757                let player = self
758                    .wir
759                    .values
760                    .push(ValueNode::new(Value::EventPlayer, None));
761                let value = self.lower_value(init_expr)?;
762                actions.push(self.wir.actions.push(Action::SetPlayerVariable {
763                    player,
764                    variable,
765                    value,
766                    span: self.wir_span(span)?,
767                    target_span: self.wir_span(target_span)?,
768                }));
769            }
770            self.wir.rules.push(wir::Rule {
771                name: "Initialize player variables".to_string(),
772                span: None,
773                name_span: None,
774                disabled: false,
775                event: Event::EachPlayer,
776                conditions: Vec::new(),
777                actions,
778            });
779        }
780
781        Ok(())
782    }
783
784    fn lower_rules(&mut self) -> Result<(), IntegrationError> {
785        for entry in &self.hir.rules {
786            match entry {
787                RuleEntry::Rule(rule) => self.lower_rule(rule)?,
788                RuleEntry::SubroutineDef {
789                    name,
790                    source_name,
791                    span,
792                    name_span,
793                    body,
794                    annotations,
795                    ..
796                } => {
797                    self.lower_subroutine(name, source_name, *span, *name_span, body, annotations)?
798                }
799            }
800        }
801        Ok(())
802    }
803
804    fn lower_rule(&mut self, rule: &hir::Rule) -> Result<(), IntegrationError> {
805        self.reject_rule_metadata(rule)?;
806        let event = self.lower_event(&rule.event, &rule.annotations)?;
807        let conditions = rule
808            .conditions
809            .iter()
810            .map(|expr| self.lower_value(expr))
811            .collect::<Result<Vec<_>, _>>()?;
812        let mut actions = Vec::new();
813        actions.extend(self.lower_actions(&rule.actions, None)?);
814        self.wir.rules.push(wir::Rule {
815            name: rule.name.clone(),
816            span: self.wir_span(rule.span)?,
817            name_span: self.wir_span(rule.name_span)?,
818            disabled: rule.disabled,
819            event,
820            conditions,
821            actions,
822        });
823        Ok(())
824    }
825
826    fn lower_subroutine(
827        &mut self,
828        name: &str,
829        source_name: &str,
830        span: Option<HirSpan>,
831        name_span: Option<HirSpan>,
832        body: &[Stmt],
833        annotations: &[hir::Annotation],
834    ) -> Result<(), IntegrationError> {
835        self.reject_subroutine_metadata(annotations)?;
836        let source_name = if source_name.is_empty() {
837            name
838        } else {
839            source_name
840        };
841        let subroutine = *self.subroutines.get(source_name).ok_or_else(|| {
842            self.unsupported(
843                format!("subroutine definition '{source_name}' has no declaration"),
844                name_span.or(span),
845            )
846        })?;
847        if !self.defined_subroutines.insert(subroutine) {
848            return Err(self.unsupported(
849                format!("subroutine '{source_name}' has multiple definitions"),
850                name_span.or(span),
851            ));
852        }
853        let mut actions = Vec::new();
854        actions.extend(self.lower_actions(body, None)?);
855        self.wir.rules.push(wir::Rule {
856            name: self.subroutine_rule_name(name),
857            span: self.wir_span(span)?,
858            name_span: self.wir_span(name_span)?,
859            disabled: false,
860            event: Event::Subroutine(subroutine),
861            conditions: Vec::new(),
862            actions,
863        });
864        Ok(())
865    }
866
867    fn reject_rule_metadata(&self, rule: &hir::Rule) -> Result<(), IntegrationError> {
868        if rule.delimiter {
869            let span = rule
870                .annotations
871                .iter()
872                .find(|annotation| annotation.name == "Delimiter")
873                .and_then(|annotation| annotation.span)
874                .or(rule.span);
875            return Err(self.unsupported(
876                "rule delimiter metadata is not representable in canonical WIR",
877                span,
878            ));
879        }
880        if rule.new_page.is_some() {
881            let span = rule
882                .annotations
883                .iter()
884                .find(|annotation| annotation.name == "NewPage")
885                .and_then(|annotation| annotation.span)
886                .or(rule.span);
887            return Err(self.unsupported(
888                "rule new-page metadata is not representable in canonical WIR",
889                span,
890            ));
891        }
892        for annotation in &rule.annotations {
893            match annotation.name.as_str() {
894                "Event" | "Condition" | "Team" | "Slot" | "Hero" | "Disabled"
895                | "SuppressWarnings" => {}
896                _ => {
897                    return Err(self.unsupported(
898                        format!(
899                            "rule annotation '{}' is not representable in canonical WIR",
900                            annotation.name
901                        ),
902                        annotation.span.or(rule.span),
903                    ));
904                }
905            }
906        }
907        Ok(())
908    }
909
910    fn reject_subroutine_metadata(
911        &self,
912        annotations: &[hir::Annotation],
913    ) -> Result<(), IntegrationError> {
914        for annotation in annotations {
915            match annotation.name.as_str() {
916                "Name" | "SuppressWarnings" => {}
917                _ => {
918                    return Err(self.unsupported(
919                        format!(
920                            "subroutine annotation '{}' is not representable in canonical WIR",
921                            annotation.name
922                        ),
923                        annotation.span,
924                    ));
925                }
926            }
927        }
928        Ok(())
929    }
930
931    fn subroutine_rule_name(&self, generated_name: &str) -> String {
932        if self.hir.preprocessing.rule_prefix_template.is_some() {
933            generated_name.to_string()
934        } else {
935            format!("Subroutine {generated_name}")
936        }
937    }
938
939    fn lower_event(
940        &self,
941        event: &hir::Event,
942        annotations: &[hir::Annotation],
943    ) -> Result<Event, IntegrationError> {
944        if !event.args.is_empty() {
945            return Err(self.unsupported(
946                "event arguments are not representable in canonical WIR; use structural event filters",
947                event.span,
948            ));
949        }
950        let team = self.lower_event_team(annotations)?;
951        let target = self.lower_event_target(annotations)?;
952        let has_filters =
953            !matches!(team, wir::EventTeam::All) || !matches!(target, wir::EventTarget::All);
954        match event.name.as_str() {
955            "global" => {
956                if has_filters {
957                    return Err(
958                        self.unsupported("global events cannot have player filters", event.span)
959                    );
960                }
961                Ok(Event::Global)
962            }
963            "eachPlayer" => {
964                if has_filters {
965                    Ok(Event::EachPlayerWithFilters { team, target })
966                } else {
967                    Ok(Event::EachPlayer)
968                }
969            }
970            name => player_event_kind(name).map_or_else(
971                || {
972                    Err(self.unsupported(
973                        format!("event '{name}' is not supported by canonical WIR"),
974                        event.span,
975                    ))
976                },
977                |kind| Ok(Event::Player { kind, team, target }),
978            ),
979        }
980    }
981
982    fn lower_event_team(
983        &self,
984        annotations: &[hir::Annotation],
985    ) -> Result<wir::EventTeam, IntegrationError> {
986        let team_annotations = annotations
987            .iter()
988            .filter(|annotation| annotation.name == "Team")
989            .collect::<Vec<_>>();
990        if team_annotations.len() > 1 {
991            return Err(self.unsupported(
992                "an event cannot have multiple @Team filters",
993                team_annotations[1].span.or(team_annotations[0].span),
994            ));
995        }
996        let Some(annotation) = team_annotations.first() else {
997            return Ok(wir::EventTeam::All);
998        };
999        let argument = annotation
1000            .args
1001            .first()
1002            .ok_or_else(|| self.unsupported("@Team requires one filter value", annotation.span))?;
1003        if annotation.args.len() != 1 {
1004            return Err(
1005                self.unsupported("@Team requires exactly one filter value", annotation.span)
1006            );
1007        }
1008        let spelling = match argument.text.as_str() {
1009            "1" => "Team 1",
1010            "2" => "Team 2",
1011            value => value,
1012        };
1013        let (_, member) = self
1014            .compiler
1015            .catalog
1016            .resolve_enum_member("EventTeam", &Locale::new("en-US"), spelling)
1017            .ok_or_else(|| {
1018                self.unsupported(
1019                    format!("unknown EventTeam filter '{spelling}'"),
1020                    argument.span.or(annotation.span),
1021                )
1022            })?;
1023        match member.as_str() {
1024            "ALL" => Ok(wir::EventTeam::All),
1025            "TEAM_1" => Ok(wir::EventTeam::Team1),
1026            "TEAM_2" => Ok(wir::EventTeam::Team2),
1027            _ => Err(self.unsupported(
1028                format!("catalog EventTeam member '{member}' is not supported by canonical WIR"),
1029                argument.span.or(annotation.span),
1030            )),
1031        }
1032    }
1033
1034    fn lower_event_target(
1035        &self,
1036        annotations: &[hir::Annotation],
1037    ) -> Result<wir::EventTarget, IntegrationError> {
1038        let mut filters = Vec::new();
1039        for name in ["Slot", "Hero"] {
1040            let matches = annotations
1041                .iter()
1042                .filter(|annotation| annotation.name == name)
1043                .collect::<Vec<_>>();
1044            if matches.len() > 1 {
1045                return Err(self.unsupported(
1046                    format!("an event cannot have multiple @{name} filters"),
1047                    matches[1].span.or(matches[0].span),
1048                ));
1049            }
1050            filters.extend(matches);
1051        }
1052        if filters.len() > 1 {
1053            return Err(self.unsupported(
1054                "an event cannot combine @Slot and @Hero filters",
1055                filters[1].span.or(filters[0].span),
1056            ));
1057        }
1058        let Some(annotation) = filters.first() else {
1059            return Ok(wir::EventTarget::All);
1060        };
1061        let argument = annotation.args.first().ok_or_else(|| {
1062            self.unsupported(
1063                format!("@{} requires one filter value", annotation.name),
1064                annotation.span,
1065            )
1066        })?;
1067        if annotation.args.len() != 1 {
1068            return Err(self.unsupported(
1069                format!("@{} requires exactly one filter value", annotation.name),
1070                annotation.span,
1071            ));
1072        }
1073        let spelling = if annotation.name == "Slot" {
1074            match argument.text.as_str() {
1075                value if value.parse::<u8>().is_ok() => {
1076                    format!("Slot {}", value.parse::<u8>().unwrap_or_default())
1077                }
1078                value => value.to_string(),
1079            }
1080        } else {
1081            argument.text.clone()
1082        };
1083        let domain = if annotation.name == "Slot" {
1084            "EventPlayer"
1085        } else {
1086            "Hero"
1087        };
1088        let (_, member) = self
1089            .compiler
1090            .catalog
1091            .resolve_enum_member(domain, &Locale::new("en-US"), &spelling)
1092            .ok_or_else(|| {
1093                self.unsupported(
1094                    format!("unknown {domain} filter '{spelling}'"),
1095                    argument.span.or(annotation.span),
1096                )
1097            })?;
1098        if domain == "EventPlayer" {
1099            if member == "ALL" {
1100                Ok(wir::EventTarget::All)
1101            } else if let Some(slot) = member.strip_prefix("SLOT_") {
1102                let slot = slot.parse::<u8>().map_err(|_| {
1103                    self.unsupported(
1104                        format!("catalog EventPlayer member '{member}' is not a slot"),
1105                        argument.span.or(annotation.span),
1106                    )
1107                })?;
1108                Ok(wir::EventTarget::Slot(slot))
1109            } else {
1110                Err(self.unsupported(
1111                    format!(
1112                        "catalog EventPlayer member '{member}' is not supported by canonical WIR"
1113                    ),
1114                    argument.span.or(annotation.span),
1115                ))
1116            }
1117        } else {
1118            Ok(wir::EventTarget::Hero(member))
1119        }
1120    }
1121
1122    fn lower_actions(
1123        &mut self,
1124        statements: &[Stmt],
1125        break_target: Option<BreakTarget>,
1126    ) -> Result<Vec<wir::ActionId>, IntegrationError> {
1127        let mut actions = Vec::new();
1128        for statement in statements {
1129            actions.extend(self.lower_action(statement, break_target)?);
1130        }
1131        Ok(actions)
1132    }
1133
1134    fn lower_action(
1135        &mut self,
1136        stmt: &Stmt,
1137        break_target: Option<BreakTarget>,
1138    ) -> Result<Vec<wir::ActionId>, IntegrationError> {
1139        match stmt {
1140            Stmt::Pass { .. } => Ok(Vec::new()),
1141            Stmt::Assign {
1142                target,
1143                value,
1144                span,
1145            } => self.lower_assign(target, value, *span).map(|action| vec![action]),
1146            Stmt::If {
1147                branches,
1148                r#else,
1149                span,
1150            } => {
1151                let branches = branches
1152                    .iter()
1153                    .map(|branch| {
1154                        Ok(wir::IfBranch {
1155                            condition: self.lower_value(&branch.condition)?,
1156                            body: self.lower_actions(&branch.body, break_target)?,
1157                        })
1158                    })
1159                    .collect::<Result<Vec<_>, IntegrationError>>()?;
1160                let else_body = r#else
1161                    .as_ref()
1162                    .map(|body| self.lower_actions(body, break_target))
1163                    .transpose()?;
1164                Ok(vec![self.wir.actions.push(Action::If {
1165                    branches,
1166                    else_body,
1167                    span: self.wir_span(*span)?,
1168                })])
1169            }
1170            Stmt::For {
1171                variable,
1172                iterable,
1173                body,
1174                span,
1175            } => {
1176                let Expr::GlobalVar {
1177                    name,
1178                    span: target_span,
1179                } = variable.as_ref()
1180                else {
1181                    return Err(self.unsupported(
1182                        "range loops require a global-variable binder in canonical WIR",
1183                        variable.span().copied(),
1184                    ));
1185                };
1186                let variable_id = *self.globals.get(name).ok_or_else(|| {
1187                    self.unsupported(format!("unknown global variable '{name}'"), *target_span)
1188                })?;
1189                let (start, stop, step) = self.lower_range(iterable)?;
1190                let body = self.lower_actions(body, Some(BreakTarget::Loop))?;
1191                Ok(vec![self.wir.actions.push(Action::ForGlobalVariable {
1192                    variable: variable_id,
1193                    start,
1194                    stop,
1195                    step,
1196                    body,
1197                    span: self.wir_span(*span)?,
1198                    target_span: self.wir_span(*target_span)?,
1199                })])
1200            }
1201            Stmt::While {
1202                condition,
1203                body,
1204                span,
1205            } => {
1206                let condition = self.lower_value(condition)?;
1207                let body = self.lower_actions(body, Some(BreakTarget::Loop))?;
1208                Ok(vec![self.wir.actions.push(Action::While {
1209                    condition,
1210                    body,
1211                    span: self.wir_span(*span)?,
1212                })])
1213            }
1214            Stmt::DoWhile {
1215                condition,
1216                body,
1217                span,
1218            } => {
1219                let body = self.lower_do_while_body(body)?;
1220                let condition = self.lower_value(condition)?;
1221                let loop_if = self.wir.actions.push(Action::Call {
1222                    name: "loopIf".to_string(),
1223                    args: vec![condition],
1224                    span: self.wir_span(*span)?,
1225                });
1226                // OverPy's pinned lowering expands do/while into its body
1227                // followed by the canonical Loop If action.
1228                let mut actions = body;
1229                actions.push(loop_if);
1230                Ok(actions)
1231            }
1232            Stmt::Switch {
1233                value,
1234                arms,
1235                span,
1236            } => self.lower_switch(value, arms, *span).map(|action| vec![action]),
1237            Stmt::Break { span } => match break_target {
1238                Some(BreakTarget::Loop) => Ok(vec![self.wir.actions.push(Action::Call {
1239                    name: "break".to_string(),
1240                    args: Vec::new(),
1241                    span: self.wir_span(*span)?,
1242                })]),
1243                Some(BreakTarget::DoWhile) => Err(self.unsupported(
1244                    "break inside a do-while must be a direct statement or a single conditional break",
1245                    *span,
1246                )),
1247                Some(BreakTarget::Switch) => Err(self.unsupported(
1248                    "break inside a nested conditional cannot be normalized into canonical switch control flow",
1249                    *span,
1250                )),
1251                None => Err(self.unsupported(
1252                    "break has no enclosing canonical loop or switch",
1253                    *span,
1254                )),
1255            },
1256            Stmt::Expr { expr, span } => match expr.as_ref() {
1257                Expr::Call { name, args, .. } => {
1258                    if name == "disableInspector" && args.is_empty() {
1259                        Ok(vec![self.wir.actions.push(Action::Call {
1260                            name: "disableInspector".to_string(),
1261                            args: Vec::new(),
1262                            span: self.wir_span(*span)?,
1263                        })])
1264                    } else if name == "debug" && args.len() == 1 {
1265                        let val = self.lower_value(&args[0])?;
1266                        Ok(vec![self.wir.actions.push(Action::Debug {
1267                            value: val,
1268                            span: self.wir_span(*span)?,
1269                        })])
1270                    } else if name == "print" && args.len() == 1 {
1271                        let msg = self.lower_value(&args[0])?;
1272                        Ok(vec![self.wir.actions.push(Action::Print {
1273                            message: msg,
1274                            span: self.wir_span(*span)?,
1275                        })])
1276                    } else {
1277                        self.lower_action_call(name, args, *span).map(|action| vec![action])
1278                    }
1279                }
1280                Expr::ReceiverCall {
1281                    receiver,
1282                    name,
1283                    args,
1284                    span: call_span,
1285                } => self
1286                    .lower_receiver_action_call(receiver, name, args, *call_span)
1287                    .map(|action| vec![action]),
1288                _ => Err(self.unsupported(
1289                    "only action calls are currently representable as expression statements in canonical WIR",
1290                    *span,
1291                )),
1292            },
1293            Stmt::CallSubroutine { name, span } => {
1294                let subroutine = *self.subroutines.get(name).ok_or_else(|| {
1295                    self.unsupported(format!("unknown subroutine '{name}'"), *span)
1296                })?;
1297                let span = self.wir_span(*span)?;
1298                Ok(vec![self.wir.actions.push(Action::CallSubroutine {
1299                    subroutine,
1300                    span,
1301                    callee_span: span,
1302                })])
1303            }
1304        }
1305    }
1306
1307    fn lower_do_while_body(
1308        &mut self,
1309        statements: &[Stmt],
1310    ) -> Result<Vec<wir::ActionId>, IntegrationError> {
1311        let mut actions = Vec::new();
1312        for (index, statement) in statements.iter().enumerate() {
1313            let direct_break = matches!(statement, Stmt::Break { .. });
1314            let conditional_break = match statement {
1315                Stmt::If {
1316                    branches,
1317                    r#else: None,
1318                    ..
1319                } if branches.len() == 1 => {
1320                    matches!(branches[0].body.as_slice(), [Stmt::Break { .. }])
1321                }
1322                _ => false,
1323            };
1324
1325            if direct_break || conditional_break {
1326                let tail = self.lower_do_while_body(&statements[index + 1..])?;
1327                let distance = self.canonical_action_width(&tail, statement.span().copied())? + 1;
1328                let (name, args, span) = if let Stmt::Break { span } = statement {
1329                    ("skip", Vec::new(), *span)
1330                } else if let Stmt::If { branches, span, .. } = statement {
1331                    (
1332                        "skipIf",
1333                        vec![self.lower_value(&branches[0].condition)?],
1334                        *span,
1335                    )
1336                } else {
1337                    unreachable!("break shape was checked above")
1338                };
1339                let distance = self.wir.values.push(ValueNode::new(
1340                    Value::Number {
1341                        value: distance as f64,
1342                        text: distance.to_string(),
1343                    },
1344                    self.wir_span(span)?,
1345                ));
1346                let mut args = args;
1347                args.push(distance);
1348                actions.push(self.wir.actions.push(Action::Call {
1349                    name: name.to_string(),
1350                    args,
1351                    span: self.wir_span(span)?,
1352                }));
1353                actions.extend(tail);
1354                return Ok(actions);
1355            }
1356
1357            actions.extend(self.lower_action(statement, Some(BreakTarget::DoWhile))?);
1358        }
1359        Ok(actions)
1360    }
1361
1362    fn lower_range(
1363        &mut self,
1364        iterable: &Expr,
1365    ) -> Result<(wir::ValueId, wir::ValueId, wir::ValueId), IntegrationError> {
1366        let Expr::Call { name, args, .. } = iterable else {
1367            return Err(self.unsupported(
1368                "range loop iterable must be a range(...) call",
1369                iterable.span().copied(),
1370            ));
1371        };
1372        if name != "range" || !(1..=3).contains(&args.len()) {
1373            return Err(self.unsupported(
1374                "range loop requires one to three arguments",
1375                iterable.span().copied(),
1376            ));
1377        }
1378        let span = iterable.span().copied();
1379        let number = |this: &mut Self, value: f64| -> Result<wir::ValueId, IntegrationError> {
1380            Ok(this.wir.values.push(ValueNode::new(
1381                Value::Number {
1382                    value,
1383                    text: value.to_string(),
1384                },
1385                this.wir_span(span)?,
1386            )))
1387        };
1388        match args.as_slice() {
1389            [stop] => Ok((
1390                number(self, 0.0)?,
1391                self.lower_value(stop)?,
1392                number(self, 1.0)?,
1393            )),
1394            [start, stop] => Ok((
1395                self.lower_value(start)?,
1396                self.lower_value(stop)?,
1397                number(self, 1.0)?,
1398            )),
1399            [start, stop, step] => Ok((
1400                self.lower_value(start)?,
1401                self.lower_value(stop)?,
1402                self.lower_value(step)?,
1403            )),
1404            _ => unreachable!("range arity checked above"),
1405        }
1406    }
1407
1408    fn lower_switch(
1409        &mut self,
1410        value: &Expr,
1411        arms: &[SwitchArm],
1412        span: Option<HirSpan>,
1413    ) -> Result<wir::ActionId, IntegrationError> {
1414        let selector = self.lower_value(value)?;
1415        let mut case_values = Vec::new();
1416        let mut lowered_arms = Vec::with_capacity(arms.len());
1417        let mut case_offsets = Vec::new();
1418        let mut offset = 0usize;
1419        let mut default_offset = None;
1420
1421        for arm in arms {
1422            let (value, (body, break_at)) = match arm {
1423                SwitchArm::Case { value, body, .. } => {
1424                    case_values.push(self.lower_value(value)?);
1425                    (Some(value), self.lower_switch_body(body)?)
1426                }
1427                SwitchArm::Default { body, span } => {
1428                    if default_offset.is_some() {
1429                        return Err(
1430                            self.unsupported("a switch may contain at most one default arm", *span)
1431                        );
1432                    }
1433                    default_offset = Some(offset);
1434                    (None, self.lower_switch_body(body)?)
1435                }
1436            };
1437            if value.is_some() {
1438                case_offsets.push(offset);
1439            }
1440            offset += self.canonical_action_width(&body, span)? + usize::from(break_at.is_some());
1441            lowered_arms.push((value, body, break_at));
1442        }
1443        let default_offset = default_offset.unwrap_or(offset);
1444
1445        let break_arms: Vec<_> = lowered_arms
1446            .iter()
1447            .enumerate()
1448            .filter_map(|(index, (_, _, break_at))| break_at.map(|break_at| (index, break_at)))
1449            .collect();
1450        if break_arms.len() > 1 {
1451            let (first_index, first_break) = break_arms[0];
1452            let has_actions_after_first = lowered_arms[first_index].1.len() > first_break.0
1453                || lowered_arms
1454                    .iter()
1455                    .skip(first_index + 1)
1456                    .any(|(_, body, _)| !body.is_empty());
1457            if has_actions_after_first {
1458                return Err(self.unsupported(
1459                    "multiple switch breaks with later reachable actions require canonical switch targets",
1460                    Some(break_arms[1].1.1),
1461                ));
1462            }
1463        }
1464
1465        let case_values = self.wir.values.push(ValueNode::new(
1466            Value::Array(case_values),
1467            self.wir_span(span)?,
1468        ));
1469        let value_span = self.wir_span(span)?;
1470        let offset_values = std::iter::once(default_offset)
1471            .chain(case_offsets)
1472            .map(|value| {
1473                self.wir.values.push(ValueNode::new(
1474                    Value::Number {
1475                        value: value as f64,
1476                        text: value.to_string(),
1477                    },
1478                    value_span,
1479                ))
1480            })
1481            .collect();
1482        let offsets = self
1483            .wir
1484            .values
1485            .push(ValueNode::new(Value::Array(offset_values), value_span));
1486        let one = self.wir.values.push(ValueNode::new(
1487            Value::Number {
1488                value: 1.0,
1489                text: "1".to_string(),
1490            },
1491            self.wir_span(span)?,
1492        ));
1493        let index = self.wir.values.push(ValueNode::new(
1494            Value::Call {
1495                name: "indexOfArrayValue".to_string(),
1496                args: vec![case_values, selector],
1497            },
1498            self.wir_span(span)?,
1499        ));
1500        let case_offset = self.wir.values.push(ValueNode::new(
1501            Value::Call {
1502                name: "add".to_string(),
1503                args: vec![one, index],
1504            },
1505            self.wir_span(span)?,
1506        ));
1507        let skip_condition = self.wir.values.push(ValueNode::new(
1508            Value::Call {
1509                name: "valueInArray".to_string(),
1510                args: vec![offsets, case_offset],
1511            },
1512            self.wir_span(span)?,
1513        ));
1514        let skip = self.wir.actions.push(Action::Call {
1515            name: "skip".to_string(),
1516            args: vec![skip_condition],
1517            span: self.wir_span(span)?,
1518        });
1519        let true_value = self
1520            .wir
1521            .values
1522            .push(ValueNode::new(Value::Bool(true), self.wir_span(span)?));
1523
1524        let first_break = break_arms.first().copied();
1525        let mut branch_body = vec![skip];
1526        let else_body = if let Some((break_index, (break_at, _))) = first_break {
1527            for (index, (_, body, _)) in lowered_arms.iter().enumerate() {
1528                if index < break_index {
1529                    branch_body.extend(body.iter().copied());
1530                } else if index == break_index {
1531                    branch_body.extend(body[..break_at].iter().copied());
1532                }
1533            }
1534            let mut tail = Vec::new();
1535            tail.extend(lowered_arms[break_index].1[break_at..].iter().copied());
1536            for (_, body, _) in lowered_arms.iter().skip(break_index + 1) {
1537                tail.extend(body.iter().copied());
1538            }
1539            Some(tail)
1540        } else {
1541            for (_, body, _) in &lowered_arms {
1542                branch_body.extend(body.iter().copied());
1543            }
1544            None
1545        };
1546
1547        Ok(self.wir.actions.push(Action::If {
1548            branches: vec![wir::IfBranch {
1549                condition: true_value,
1550                body: branch_body,
1551            }],
1552            else_body,
1553            span: self.wir_span(span)?,
1554        }))
1555    }
1556
1557    fn lower_switch_body(
1558        &mut self,
1559        statements: &[Stmt],
1560    ) -> Result<LoweredSwitchBody, IntegrationError> {
1561        let mut actions = Vec::new();
1562        let mut break_at = None;
1563        for statement in statements {
1564            if let Stmt::Break { span } = statement {
1565                if break_at.is_some() {
1566                    return Err(self.unsupported(
1567                        "multiple switch breaks in one arm require canonical switch targets",
1568                        *span,
1569                    ));
1570                }
1571                break_at = Some((
1572                    actions.len(),
1573                    span.ok_or_else(|| {
1574                        self.unsupported("switch break is missing source provenance", None)
1575                    })?,
1576                ));
1577                continue;
1578            }
1579            actions.extend(self.lower_action(statement, Some(BreakTarget::Switch))?);
1580        }
1581        Ok((actions, break_at))
1582    }
1583
1584    /// Query the Workshop-owned native action layout for a relative jump.
1585    fn canonical_action_width(
1586        &self,
1587        actions: &[wir::ActionId],
1588        fallback_span: Option<HirSpan>,
1589    ) -> Result<usize, IntegrationError> {
1590        workshop_rs::emitter::action_width(
1591            &self.wir,
1592            &self.compiler.catalog,
1593            &Locale::new("en-US"),
1594            actions,
1595        )
1596        .map(|layout| layout.width)
1597        .map_err(|error| {
1598            let workshop_span = match &error {
1599                workshop_rs::emitter::ActionLayoutError::InvalidWIR(error) => error.span(),
1600                workshop_rs::emitter::ActionLayoutError::Emission(error) => {
1601                    workshop_error_span(error)
1602                }
1603            };
1604            let span = workshop_span
1605                .and_then(|span| self.hir_span_from_workshop(span))
1606                .or(fallback_span);
1607            IntegrationError::new("workshop-action-layout", error.to_string(), span)
1608        })
1609    }
1610
1611    fn lower_assign(
1612        &mut self,
1613        target: &Expr,
1614        value: &Expr,
1615        span: Option<HirSpan>,
1616    ) -> Result<wir::ActionId, IntegrationError> {
1617        match target {
1618            Expr::GlobalVar {
1619                name,
1620                span: target_span,
1621            } => {
1622                let variable = *self.globals.get(name).ok_or_else(|| {
1623                    self.unsupported(format!("unknown global variable '{name}'"), *target_span)
1624                })?;
1625                if let Expr::Binary {
1626                    op, left, right, ..
1627                } = value
1628                {
1629                    if let Expr::GlobalVar {
1630                        name: left_name, ..
1631                    } = left.as_ref()
1632                    {
1633                        if left_name == name {
1634                            if let Some(modify_op) = modify_op_from_str(op) {
1635                                let val = self.lower_value(right)?;
1636                                return Ok(self.wir.actions.push(Action::ModifyGlobalVariable {
1637                                    variable,
1638                                    op: modify_op,
1639                                    value: val,
1640                                    span: self.wir_span(span)?,
1641                                    target_span: self.wir_span(*target_span)?,
1642                                }));
1643                            }
1644                        }
1645                    }
1646                }
1647                let val = self.lower_value(value)?;
1648                Ok(self.wir.actions.push(Action::SetGlobalVariable {
1649                    variable,
1650                    value: val,
1651                    span: self.wir_span(span)?,
1652                    target_span: self.wir_span(*target_span)?,
1653                }))
1654            }
1655            Expr::PlayerVar {
1656                player,
1657                name,
1658                span: target_span,
1659            } => {
1660                let variable = *self.players.get(name).ok_or_else(|| {
1661                    self.unsupported(format!("unknown player variable '{name}'"), *target_span)
1662                })?;
1663                let player_val = self.lower_value(player)?;
1664                if let Expr::Binary {
1665                    op, left, right, ..
1666                } = value
1667                {
1668                    if let Expr::PlayerVar {
1669                        player: left_player,
1670                        name: left_name,
1671                        ..
1672                    } = left.as_ref()
1673                    {
1674                        if left_name == name && left_player.as_ref() == player.as_ref() {
1675                            if let Some(modify_op) = modify_op_from_str(op) {
1676                                let val = self.lower_value(right)?;
1677                                return Ok(self.wir.actions.push(Action::ModifyPlayerVariable {
1678                                    player: player_val,
1679                                    variable,
1680                                    op: modify_op,
1681                                    value: val,
1682                                    span: self.wir_span(span)?,
1683                                    target_span: self.wir_span(*target_span)?,
1684                                }));
1685                            }
1686                        }
1687                    }
1688                }
1689                let val = self.lower_value(value)?;
1690                Ok(self.wir.actions.push(Action::SetPlayerVariable {
1691                    player: player_val,
1692                    variable,
1693                    value: val,
1694                    span: self.wir_span(span)?,
1695                    target_span: self.wir_span(*target_span)?,
1696                }))
1697            }
1698            Expr::Index {
1699                array,
1700                index,
1701                span: target_span,
1702            } => match array.as_ref() {
1703                Expr::GlobalVar {
1704                    name,
1705                    span: arr_span,
1706                } => {
1707                    let variable = *self.globals.get(name).ok_or_else(|| {
1708                        self.unsupported(format!("unknown global variable '{name}'"), *arr_span)
1709                    })?;
1710                    let var_node = self.wir.values.push(ValueNode::new(
1711                        Value::GlobalVariable(variable),
1712                        self.wir_span(*arr_span)?,
1713                    ));
1714                    let index_val = self.lower_value(index)?;
1715                    if let Expr::Binary {
1716                        op, left, right, ..
1717                    } = value
1718                    {
1719                        if let Expr::Index {
1720                            array: left_arr,
1721                            index: left_idx,
1722                            ..
1723                        } = left.as_ref()
1724                        {
1725                            if left_arr.as_ref() == array.as_ref()
1726                                && left_idx.as_ref() == index.as_ref()
1727                            {
1728                                if let Some(op_id) = modify_catalog_name_from_str(op) {
1729                                    let op_node = self.wir.values.push(ValueNode::new(
1730                                        Value::Call {
1731                                            name: op_id.to_string(),
1732                                            args: Vec::new(),
1733                                        },
1734                                        None,
1735                                    ));
1736                                    let right_val = self.lower_value(right)?;
1737                                    return Ok(self.wir.actions.push(Action::Call {
1738                                        name: "modifyGlobalVariableAtIndex".to_string(),
1739                                        args: vec![var_node, index_val, op_node, right_val],
1740                                        span: self.wir_span(span)?,
1741                                    }));
1742                                }
1743                            }
1744                        }
1745                    }
1746                    let val = self.lower_value(value)?;
1747                    Ok(self.wir.actions.push(Action::Call {
1748                        name: "setGlobalVariableAtIndex".to_string(),
1749                        args: vec![var_node, index_val, val],
1750                        span: self.wir_span(span)?,
1751                    }))
1752                }
1753                Expr::PlayerVar {
1754                    player,
1755                    name,
1756                    span: arr_span,
1757                } => {
1758                    let player_val = self.lower_value(player)?;
1759                    let variable = *self.players.get(name).ok_or_else(|| {
1760                        self.unsupported(format!("unknown player variable '{name}'"), *arr_span)
1761                    })?;
1762                    let var_node = self.wir.values.push(ValueNode::new(
1763                        Value::PlayerVariable {
1764                            player: player_val,
1765                            variable,
1766                        },
1767                        self.wir_span(*arr_span)?,
1768                    ));
1769                    let index_val = self.lower_value(index)?;
1770                    if let Expr::Binary {
1771                        op, left, right, ..
1772                    } = value
1773                    {
1774                        if let Expr::Index {
1775                            array: left_arr,
1776                            index: left_idx,
1777                            ..
1778                        } = left.as_ref()
1779                        {
1780                            if left_arr.as_ref() == array.as_ref()
1781                                && left_idx.as_ref() == index.as_ref()
1782                            {
1783                                if let Some(op_id) = modify_catalog_name_from_str(op) {
1784                                    let op_node = self.wir.values.push(ValueNode::new(
1785                                        Value::Call {
1786                                            name: op_id.to_string(),
1787                                            args: Vec::new(),
1788                                        },
1789                                        None,
1790                                    ));
1791                                    let right_val = self.lower_value(right)?;
1792                                    // The canonical signature takes the
1793                                    // player-variable value node (which
1794                                    // carries the player) as its first
1795                                    // argument.
1796                                    return Ok(self.wir.actions.push(Action::Call {
1797                                        name: "modifyPlayerVariableAtIndex".to_string(),
1798                                        args: vec![var_node, index_val, op_node, right_val],
1799                                        span: self.wir_span(span)?,
1800                                    }));
1801                                }
1802                            }
1803                        }
1804                    }
1805                    let val = self.lower_value(value)?;
1806                    // The canonical signature takes the player-variable
1807                    // value node (which carries the player) as its first
1808                    // argument.
1809                    Ok(self.wir.actions.push(Action::Call {
1810                        name: "setPlayerVariableAtIndex".to_string(),
1811                        args: vec![var_node, index_val, val],
1812                        span: self.wir_span(span)?,
1813                    }))
1814                }
1815                _ => Err(self.unsupported(
1816                    "indexing assignment is only representable for global or player variables",
1817                    *target_span,
1818                )),
1819            },
1820            _ => Err(self.unsupported(
1821                "only global-variable, player-variable, or index assignment is currently representable in canonical WIR",
1822                span,
1823            )),
1824        }
1825    }
1826
1827    fn lower_action_call(
1828        &mut self,
1829        name: &str,
1830        args: &[Expr],
1831        span: Option<HirSpan>,
1832    ) -> Result<wir::ActionId, IntegrationError> {
1833        let function = self
1834            .compiler
1835            .manifest
1836            .resolve_function(name)
1837            .ok_or_else(|| self.unsupported(format!("unknown action '{name}'"), span))?;
1838        if !matches!(function.kind, FunctionKind::Action) {
1839            return Err(self.unsupported(format!("'{name}' is not a generic OPY action"), span));
1840        }
1841        let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
1842            self.unsupported(
1843                format!(
1844                    "action '{}' requires a special lowering not in #46",
1845                    function.id
1846                ),
1847                span,
1848            )
1849        })?;
1850        let args = args
1851            .iter()
1852            .map(|expr| self.lower_value(expr))
1853            .collect::<Result<Vec<_>, _>>()?;
1854        Ok(self.wir.actions.push(Action::Call {
1855            name: catalog_id.clone(),
1856            args,
1857            span: self.wir_span(span)?,
1858        }))
1859    }
1860
1861    fn lower_receiver_action_call(
1862        &mut self,
1863        receiver: &Expr,
1864        name: &str,
1865        args: &[Expr],
1866        span: Option<HirSpan>,
1867    ) -> Result<wir::ActionId, IntegrationError> {
1868        let function = self
1869            .compiler
1870            .manifest
1871            .resolve_member(name)
1872            .ok_or_else(|| self.unsupported(format!("unknown member action '{name}'"), span))?;
1873        if !matches!(function.kind, FunctionKind::MemberAction) {
1874            return Err(self.unsupported(format!("'{name}' is not a member action"), span));
1875        }
1876
1877        // `append` is an OPY mutation, represented by the canonical variable
1878        // modify actions rather than a catalog action call.
1879        if function.id == "append" {
1880            let [value] = args else {
1881                return Err(self.unsupported("append requires exactly one argument", span));
1882            };
1883            let value = self.lower_value(value)?;
1884            return match receiver {
1885                Expr::GlobalVar {
1886                    name,
1887                    span: target_span,
1888                } => {
1889                    let variable = *self.globals.get(name).ok_or_else(|| {
1890                        self.unsupported(format!("unknown global variable '{name}'"), *target_span)
1891                    })?;
1892                    Ok(self.wir.actions.push(Action::ModifyGlobalVariable {
1893                        variable,
1894                        op: wir::ModifyOp::AppendToArray,
1895                        value,
1896                        span: self.wir_span(span)?,
1897                        target_span: self.wir_span(*target_span)?,
1898                    }))
1899                }
1900                Expr::PlayerVar {
1901                    player,
1902                    name,
1903                    span: target_span,
1904                } => {
1905                    let variable = *self.players.get(name).ok_or_else(|| {
1906                        self.unsupported(format!("unknown player variable '{name}'"), *target_span)
1907                    })?;
1908                    let player = self.lower_value(player)?;
1909                    Ok(self.wir.actions.push(Action::ModifyPlayerVariable {
1910                        player,
1911                        variable,
1912                        op: wir::ModifyOp::AppendToArray,
1913                        value,
1914                        span: self.wir_span(span)?,
1915                        target_span: self.wir_span(*target_span)?,
1916                    }))
1917                }
1918                _ => Err(self.unsupported(
1919                    "append requires a global or player variable receiver",
1920                    receiver.span().copied().or(span),
1921                )),
1922            };
1923        }
1924
1925        let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
1926            self.unsupported(
1927                format!(
1928                    "member action '{}' has no canonical catalog identity",
1929                    function.id
1930                ),
1931                span,
1932            )
1933        })?;
1934        let mut lowered = Vec::with_capacity(args.len() + 1);
1935        lowered.push(self.lower_value(receiver)?);
1936        lowered.extend(
1937            args.iter()
1938                .map(|arg| self.lower_value(arg))
1939                .collect::<Result<Vec<_>, _>>()?,
1940        );
1941        Ok(self.wir.actions.push(Action::Call {
1942            name: catalog_id.clone(),
1943            args: lowered,
1944            span: self.wir_span(span)?,
1945        }))
1946    }
1947
1948    fn lower_value(&mut self, expr: &Expr) -> Result<wir::ValueId, IntegrationError> {
1949        let span = expr.span().copied();
1950        let value = match expr {
1951            Expr::Number { value, text, .. } => Value::Number {
1952                value: *value,
1953                text: canonical_number_text(*value, text),
1954            },
1955            Expr::String { value, .. } => Value::String(value.clone()),
1956            Expr::Bool { value, .. } => Value::Bool(*value),
1957            Expr::Null { .. } => Value::Null,
1958            Expr::GlobalVar { name, .. } => {
1959                let id = *self.globals.get(name).ok_or_else(|| {
1960                    self.unsupported(format!("unknown global variable '{name}'"), span)
1961                })?;
1962                Value::GlobalVariable(id)
1963            }
1964            Expr::PlayerVar { player, name, .. } => {
1965                let player = self.lower_value(player)?;
1966                let id = *self.players.get(name).ok_or_else(|| {
1967                    self.unsupported(format!("unknown player variable '{name}'"), span)
1968                })?;
1969                Value::PlayerVariable {
1970                    player,
1971                    variable: id,
1972                }
1973            }
1974            Expr::EventPlayer { .. } => Value::EventPlayer,
1975            Expr::Enum {
1976                value_type, value, ..
1977            } => {
1978                if self
1979                    .compiler
1980                    .catalog
1981                    .enum_spelling(value_type, &Locale::new("en-US"), value)
1982                    .is_none()
1983                {
1984                    return Err(self.unsupported(
1985                        format!("unknown catalog enum member '{value_type}.{value}'"),
1986                        span,
1987                    ));
1988                }
1989                Value::Enum {
1990                    value_type: value_type.clone(),
1991                    value: value.clone(),
1992                }
1993            }
1994            Expr::Array { elements, .. } => Value::Array(
1995                elements
1996                    .iter()
1997                    .map(|element| self.lower_value(element))
1998                    .collect::<Result<Vec<_>, _>>()?,
1999            ),
2000            Expr::Vector { x, y, z, .. } => Value::Vector {
2001                x: self.lower_value(x)?,
2002                y: self.lower_value(y)?,
2003                z: self.lower_value(z)?,
2004            },
2005            Expr::Constant { name, .. } => {
2006                let const_expr = *self
2007                    .constants
2008                    .get(name)
2009                    .ok_or_else(|| self.unsupported(format!("unknown constant '{name}'"), span))?;
2010                return self.lower_value(const_expr);
2011            }
2012            Expr::Index { array, index, .. } => {
2013                // The pinned OverPy oracle lowers a literal zero-index read
2014                // (`arr[0]`, `arr[0.0]`) to `firstOf(arr)`; non-zero indexes
2015                // and indexed writes keep the indexed forms.
2016                if matches!(index.as_ref(), Expr::Number { value, .. } if *value == 0.0) {
2017                    Value::Call {
2018                        name: "firstOf".to_string(),
2019                        args: vec![self.lower_value(array)?],
2020                    }
2021                } else {
2022                    Value::Call {
2023                        name: "valueInArray".to_string(),
2024                        args: vec![self.lower_value(array)?, self.lower_value(index)?],
2025                    }
2026                }
2027            }
2028            Expr::Format { text, args, .. } => {
2029                let text_node = self.wir.values.push(ValueNode::new(
2030                    Value::String(text.clone()),
2031                    self.wir_span(span)?,
2032                ));
2033                let mut call_args = vec![text_node];
2034                for arg in args {
2035                    call_args.push(self.lower_value(arg)?);
2036                }
2037                Value::Call {
2038                    name: "customString".to_string(),
2039                    args: call_args,
2040                }
2041            }
2042            Expr::Binary {
2043                op, left, right, ..
2044            } => match op.as_str() {
2045                "==" | "!=" | "<" | "<=" | ">" | ">=" => Value::Call {
2046                    name: op.clone(),
2047                    args: vec![self.lower_value(left)?, self.lower_value(right)?],
2048                },
2049                "+" => Value::Call {
2050                    name: "add".to_string(),
2051                    args: vec![self.lower_value(left)?, self.lower_value(right)?],
2052                },
2053                "-" => Value::Call {
2054                    name: "subtract".to_string(),
2055                    args: vec![self.lower_value(left)?, self.lower_value(right)?],
2056                },
2057                "*" => Value::Call {
2058                    name: "multiply".to_string(),
2059                    args: vec![self.lower_value(left)?, self.lower_value(right)?],
2060                },
2061                "/" => Value::Call {
2062                    name: "divide".to_string(),
2063                    args: vec![self.lower_value(left)?, self.lower_value(right)?],
2064                },
2065                "%" => Value::Call {
2066                    name: "modulo".to_string(),
2067                    args: vec![self.lower_value(left)?, self.lower_value(right)?],
2068                },
2069                "**" => Value::Call {
2070                    name: "raiseToPower".to_string(),
2071                    args: vec![self.lower_value(left)?, self.lower_value(right)?],
2072                },
2073                "and" => Value::Call {
2074                    name: "and".to_string(),
2075                    args: vec![self.lower_value(left)?, self.lower_value(right)?],
2076                },
2077                "or" => Value::Call {
2078                    name: "or".to_string(),
2079                    args: vec![self.lower_value(left)?, self.lower_value(right)?],
2080                },
2081                "in" => Value::Call {
2082                    name: "arrayContains".to_string(),
2083                    args: vec![self.lower_value(right)?, self.lower_value(left)?],
2084                },
2085                "not in" => {
2086                    let right_val = self.lower_value(right)?;
2087                    let left_val = self.lower_value(left)?;
2088                    let wir_span = self.wir_span(span)?;
2089                    let contains = self.wir.values.push(ValueNode::new(
2090                        Value::Call {
2091                            name: "arrayContains".to_string(),
2092                            args: vec![right_val, left_val],
2093                        },
2094                        wir_span,
2095                    ));
2096                    Value::Call {
2097                        name: "not".to_string(),
2098                        args: vec![contains],
2099                    }
2100                }
2101                _ => {
2102                    return Err(self.unsupported(
2103                        format!(
2104                            "binary operator '{op}' is not currently representable in canonical WIR"
2105                        ),
2106                        span,
2107                    ));
2108                }
2109            },
2110            Expr::Unary { op, operand, .. } => match op.as_str() {
2111                "not" => {
2112                    // The pinned OverPy 9.7.10 oracle lowers `not (a == b)`
2113                    // to the negated comparison (`a != b`), flipping every
2114                    // ordering comparison; `in` membership stays wrapped in
2115                    // `not`. Mirror that observable lowering.
2116                    if let Expr::Binary {
2117                        op: comparison,
2118                        left,
2119                        right,
2120                        ..
2121                    } = operand.as_ref()
2122                    {
2123                        if let Some(negated) = negated_comparison(comparison) {
2124                            Value::Call {
2125                                name: negated.to_string(),
2126                                args: vec![self.lower_value(left)?, self.lower_value(right)?],
2127                            }
2128                        } else {
2129                            Value::Call {
2130                                name: "not".to_string(),
2131                                args: vec![self.lower_value(operand)?],
2132                            }
2133                        }
2134                    } else {
2135                        Value::Call {
2136                            name: "not".to_string(),
2137                            args: vec![self.lower_value(operand)?],
2138                        }
2139                    }
2140                }
2141                "-" => Value::Call {
2142                    name: "-".to_string(),
2143                    args: vec![self.lower_value(operand)?],
2144                },
2145                "+" => return self.lower_value(operand),
2146                _ => {
2147                    return Err(self.unsupported(
2148                        format!(
2149                            "unary operator '{op}' is not currently representable in canonical WIR"
2150                        ),
2151                        span,
2152                    ));
2153                }
2154            },
2155            Expr::Call { name, args, .. } => {
2156                if name == "vect" && args.len() == 3 {
2157                    Value::Vector {
2158                        x: self.lower_value(&args[0])?,
2159                        y: self.lower_value(&args[1])?,
2160                        z: self.lower_value(&args[2])?,
2161                    }
2162                } else {
2163                    let function = self
2164                        .compiler
2165                        .manifest
2166                        .resolve_function(name)
2167                        .ok_or_else(|| self.unsupported(format!("unknown value '{name}'"), span))?;
2168                    if !matches!(function.kind, FunctionKind::Value) {
2169                        return Err(
2170                            self.unsupported(format!("'{name}' is not a generic OPY value"), span)
2171                        );
2172                    }
2173                    let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
2174                        self.unsupported(
2175                            format!(
2176                                "value '{}' requires a special lowering not in #46",
2177                                function.id
2178                            ),
2179                            span,
2180                        )
2181                    })?;
2182                    Value::Call {
2183                        name: catalog_id.clone(),
2184                        args: args
2185                            .iter()
2186                            .map(|arg| self.lower_value(arg))
2187                            .collect::<Result<Vec<_>, _>>()?,
2188                    }
2189                }
2190            }
2191            Expr::ReceiverCall {
2192                receiver,
2193                name,
2194                args,
2195                ..
2196            } => {
2197                let function = self.compiler.manifest.resolve_member(name).ok_or_else(|| {
2198                    self.unsupported(format!("unknown member value '{name}'"), span)
2199                })?;
2200                if !matches!(function.kind, FunctionKind::MemberValue) {
2201                    return Err(self.unsupported(format!("'{name}' is not a member value"), span));
2202                }
2203                let catalog_id = function.catalog_id.as_ref().ok_or_else(|| {
2204                    self.unsupported(
2205                        format!(
2206                            "member value '{}' has no canonical catalog identity",
2207                            function.id
2208                        ),
2209                        span,
2210                    )
2211                })?;
2212                let mut lowered = Vec::with_capacity(args.len() + 1);
2213                lowered.push(self.lower_value(receiver)?);
2214                lowered.extend(
2215                    args.iter()
2216                        .map(|arg| self.lower_value(arg))
2217                        .collect::<Result<Vec<_>, _>>()?,
2218                );
2219                Value::Call {
2220                    name: catalog_id.clone(),
2221                    args: lowered,
2222                }
2223            }
2224            Expr::Member {
2225                receiver, member, ..
2226            } => {
2227                let receiver = self.lower_value(receiver)?;
2228                let member = self.wir.values.push(ValueNode::new(
2229                    Value::String(member.clone()),
2230                    self.wir_span(span)?,
2231                ));
2232                Value::Call {
2233                    name: "memberAccess".to_string(),
2234                    args: vec![receiver, member],
2235                }
2236            }
2237            _ => {
2238                return Err(self.unsupported(
2239                    format!(
2240                        "expression '{}' is not currently representable in canonical WIR",
2241                        expr.kind_name()
2242                    ),
2243                    span,
2244                ));
2245            }
2246        };
2247        Ok(self
2248            .wir
2249            .values
2250            .push(ValueNode::new(value, self.wir_span(span)?)))
2251    }
2252
2253    fn wir_span(&self, span: Option<HirSpan>) -> Result<Option<WorkshopSpan>, IntegrationError> {
2254        let Some(span) = span else {
2255            return Ok(None);
2256        };
2257        let file = *self.files.get(&span.file).ok_or_else(|| {
2258            IntegrationError::new(
2259                "source-file",
2260                format!("HIR span references unknown source file id {}", span.file),
2261                Some(span),
2262            )
2263        })?;
2264        Ok(Some(WorkshopSpan::new(
2265            file,
2266            WorkshopPosition::new(span.start.line, span.start.col),
2267            WorkshopPosition::new(span.end.line, span.end.col),
2268        )))
2269    }
2270
2271    fn hir_span_from_workshop(&self, span: WorkshopSpan) -> Option<HirSpan> {
2272        let file = *self.wir_to_hir_files.get(span.file.index())?;
2273        Some(HirSpan {
2274            file,
2275            start: hir::Position {
2276                line: span.start.line,
2277                col: span.start.col,
2278            },
2279            end: hir::Position {
2280                line: span.end.line,
2281                col: span.end.col,
2282            },
2283        })
2284    }
2285
2286    fn unsupported(&self, message: impl Into<String>, span: Option<HirSpan>) -> IntegrationError {
2287        IntegrationError::new("unsupported-integration-surface", message, span)
2288    }
2289}
2290
2291/// Collect the pinned OverPy implicit default global and player variables.
2292/// Global and player namespaces each have independent fixed Workshop slots;
2293/// only `eventPlayer.<name>` creates an implicit player variable.
2294fn implicit_default_variables(
2295    hir: &hir::Program,
2296) -> (
2297    BTreeMap<String, Option<HirSpan>>,
2298    BTreeMap<String, Option<HirSpan>>,
2299) {
2300    let declared_globals = hir
2301        .declarations
2302        .iter()
2303        .filter_map(|declaration| match declaration {
2304            hir::Declaration::GlobalVariable { name, .. } => Some(name.as_str()),
2305            _ => None,
2306        })
2307        .collect::<HashSet<_>>();
2308    let declared_players = hir
2309        .declarations
2310        .iter()
2311        .filter_map(|declaration| match declaration {
2312            hir::Declaration::PlayerVariable { name, .. } => Some(name.as_str()),
2313            _ => None,
2314        })
2315        .collect::<HashSet<_>>();
2316    let mut globals = BTreeMap::new();
2317    let mut players = BTreeMap::new();
2318    for declaration in &hir.declarations {
2319        let initializer = match declaration {
2320            hir::Declaration::GlobalVariable { initializer, .. }
2321            | hir::Declaration::PlayerVariable { initializer, .. } => initializer.as_ref(),
2322            hir::Declaration::Constant { value, .. } => Some(value),
2323            _ => None,
2324        };
2325        if let Some(expr) = initializer {
2326            collect_implicit_expr(
2327                expr,
2328                &declared_globals,
2329                &declared_players,
2330                &mut globals,
2331                &mut players,
2332            );
2333        }
2334    }
2335    for entry in &hir.rules {
2336        match entry {
2337            RuleEntry::Rule(rule) => {
2338                for condition in &rule.conditions {
2339                    collect_implicit_expr(
2340                        condition,
2341                        &declared_globals,
2342                        &declared_players,
2343                        &mut globals,
2344                        &mut players,
2345                    );
2346                }
2347                collect_implicit_stmts(
2348                    &rule.actions,
2349                    &declared_globals,
2350                    &declared_players,
2351                    &mut globals,
2352                    &mut players,
2353                );
2354            }
2355            RuleEntry::SubroutineDef { body, .. } => collect_implicit_stmts(
2356                body,
2357                &declared_globals,
2358                &declared_players,
2359                &mut globals,
2360                &mut players,
2361            ),
2362        }
2363    }
2364    (globals, players)
2365}
2366
2367fn collect_implicit_stmts(
2368    statements: &[Stmt],
2369    declared_globals: &HashSet<&str>,
2370    declared_players: &HashSet<&str>,
2371    globals: &mut BTreeMap<String, Option<HirSpan>>,
2372    players: &mut BTreeMap<String, Option<HirSpan>>,
2373) {
2374    for statement in statements {
2375        match statement {
2376            Stmt::Expr { expr, .. } => {
2377                collect_implicit_expr(expr, declared_globals, declared_players, globals, players)
2378            }
2379            Stmt::Assign { target, value, .. } => {
2380                collect_implicit_expr(target, declared_globals, declared_players, globals, players);
2381                collect_implicit_expr(value, declared_globals, declared_players, globals, players);
2382            }
2383            Stmt::If {
2384                branches, r#else, ..
2385            } => {
2386                for branch in branches {
2387                    collect_implicit_expr(
2388                        &branch.condition,
2389                        declared_globals,
2390                        declared_players,
2391                        globals,
2392                        players,
2393                    );
2394                    collect_implicit_stmts(
2395                        &branch.body,
2396                        declared_globals,
2397                        declared_players,
2398                        globals,
2399                        players,
2400                    );
2401                }
2402                if let Some(default_body) = r#else {
2403                    collect_implicit_stmts(
2404                        default_body,
2405                        declared_globals,
2406                        declared_players,
2407                        globals,
2408                        players,
2409                    );
2410                }
2411            }
2412            Stmt::For {
2413                variable,
2414                iterable,
2415                body,
2416                ..
2417            } => {
2418                collect_implicit_expr(
2419                    variable,
2420                    declared_globals,
2421                    declared_players,
2422                    globals,
2423                    players,
2424                );
2425                collect_implicit_expr(
2426                    iterable,
2427                    declared_globals,
2428                    declared_players,
2429                    globals,
2430                    players,
2431                );
2432                collect_implicit_stmts(body, declared_globals, declared_players, globals, players);
2433            }
2434            Stmt::While {
2435                condition, body, ..
2436            }
2437            | Stmt::DoWhile {
2438                condition, body, ..
2439            } => {
2440                collect_implicit_expr(
2441                    condition,
2442                    declared_globals,
2443                    declared_players,
2444                    globals,
2445                    players,
2446                );
2447                collect_implicit_stmts(body, declared_globals, declared_players, globals, players);
2448            }
2449            Stmt::Switch { value, arms, .. } => {
2450                collect_implicit_expr(value, declared_globals, declared_players, globals, players);
2451                for arm in arms {
2452                    match arm {
2453                        SwitchArm::Case { value, body, .. } => {
2454                            collect_implicit_expr(
2455                                value,
2456                                declared_globals,
2457                                declared_players,
2458                                globals,
2459                                players,
2460                            );
2461                            collect_implicit_stmts(
2462                                body,
2463                                declared_globals,
2464                                declared_players,
2465                                globals,
2466                                players,
2467                            );
2468                        }
2469                        SwitchArm::Default { body, .. } => {
2470                            collect_implicit_stmts(
2471                                body,
2472                                declared_globals,
2473                                declared_players,
2474                                globals,
2475                                players,
2476                            );
2477                        }
2478                    }
2479                }
2480            }
2481            Stmt::Break { .. } | Stmt::CallSubroutine { .. } | Stmt::Pass { .. } => {}
2482        }
2483    }
2484}
2485
2486fn collect_implicit_expr(
2487    expr: &Expr,
2488    declared_globals: &HashSet<&str>,
2489    declared_players: &HashSet<&str>,
2490    globals: &mut BTreeMap<String, Option<HirSpan>>,
2491    players: &mut BTreeMap<String, Option<HirSpan>>,
2492) {
2493    match expr {
2494        Expr::GlobalVar { name, span } => {
2495            if !declared_globals.contains(name.as_str()) && default_var_index(name).is_some() {
2496                globals.entry(name.clone()).or_insert(*span);
2497            }
2498        }
2499        Expr::Array { elements, .. } => {
2500            for element in elements {
2501                collect_implicit_expr(
2502                    element,
2503                    declared_globals,
2504                    declared_players,
2505                    globals,
2506                    players,
2507                );
2508            }
2509        }
2510        Expr::Dict { entries, .. } => {
2511            for entry in entries {
2512                collect_implicit_expr(
2513                    &entry.key,
2514                    declared_globals,
2515                    declared_players,
2516                    globals,
2517                    players,
2518                );
2519                collect_implicit_expr(
2520                    &entry.value,
2521                    declared_globals,
2522                    declared_players,
2523                    globals,
2524                    players,
2525                );
2526            }
2527        }
2528        Expr::Comprehension {
2529            element,
2530            iterable,
2531            condition,
2532            ..
2533        } => {
2534            collect_implicit_expr(
2535                element,
2536                declared_globals,
2537                declared_players,
2538                globals,
2539                players,
2540            );
2541            collect_implicit_expr(
2542                iterable,
2543                declared_globals,
2544                declared_players,
2545                globals,
2546                players,
2547            );
2548            if let Some(condition) = condition {
2549                collect_implicit_expr(
2550                    condition,
2551                    declared_globals,
2552                    declared_players,
2553                    globals,
2554                    players,
2555                );
2556            }
2557        }
2558        Expr::Lambda { body, .. } => {
2559            collect_implicit_expr(body, declared_globals, declared_players, globals, players)
2560        }
2561        Expr::Vector { x, y, z, .. } => {
2562            collect_implicit_expr(x, declared_globals, declared_players, globals, players);
2563            collect_implicit_expr(y, declared_globals, declared_players, globals, players);
2564            collect_implicit_expr(z, declared_globals, declared_players, globals, players);
2565        }
2566        Expr::PlayerVar { player, name, span } => {
2567            if matches!(player.as_ref(), Expr::EventPlayer { .. })
2568                && !declared_players.contains(name.as_str())
2569                && default_var_index(name).is_some()
2570            {
2571                players.entry(name.clone()).or_insert(*span);
2572            }
2573            collect_implicit_expr(player, declared_globals, declared_players, globals, players);
2574        }
2575        Expr::Member { receiver, .. } => collect_implicit_expr(
2576            receiver,
2577            declared_globals,
2578            declared_players,
2579            globals,
2580            players,
2581        ),
2582        Expr::Call { args, .. } | Expr::MacroCall { args, .. } => {
2583            for arg in args {
2584                collect_implicit_expr(arg, declared_globals, declared_players, globals, players);
2585            }
2586        }
2587        Expr::ReceiverCall { receiver, args, .. } => {
2588            collect_implicit_expr(
2589                receiver,
2590                declared_globals,
2591                declared_players,
2592                globals,
2593                players,
2594            );
2595            for arg in args {
2596                collect_implicit_expr(arg, declared_globals, declared_players, globals, players);
2597            }
2598        }
2599        Expr::Binary { left, right, .. } => {
2600            collect_implicit_expr(left, declared_globals, declared_players, globals, players);
2601            collect_implicit_expr(right, declared_globals, declared_players, globals, players);
2602        }
2603        Expr::Unary { operand, .. } => collect_implicit_expr(
2604            operand,
2605            declared_globals,
2606            declared_players,
2607            globals,
2608            players,
2609        ),
2610        Expr::Index { array, index, .. } => {
2611            collect_implicit_expr(array, declared_globals, declared_players, globals, players);
2612            collect_implicit_expr(index, declared_globals, declared_players, globals, players);
2613        }
2614        Expr::Format { args, .. } => {
2615            for arg in args {
2616                collect_implicit_expr(arg, declared_globals, declared_players, globals, players);
2617            }
2618        }
2619        Expr::Number { .. }
2620        | Expr::String { .. }
2621        | Expr::Bool { .. }
2622        | Expr::Null { .. }
2623        | Expr::StringModifier { .. }
2624        | Expr::Local { .. }
2625        | Expr::Enum { .. }
2626        | Expr::EventPlayer { .. }
2627        | Expr::Constant { .. }
2628        | Expr::MacroParam { .. } => {}
2629    }
2630}
2631
2632fn allocate_indices(
2633    entries: &[(Option<u32>, Option<HirSpan>)],
2634    pre_reserved: &HashSet<u32>,
2635    kind: &str,
2636) -> Result<Vec<u32>, IntegrationError> {
2637    let mut reserved = pre_reserved.clone();
2638    for (index, span) in entries {
2639        let Some(index) = index else {
2640            continue;
2641        };
2642        if !reserved.insert(*index) {
2643            return Err(IntegrationError::new(
2644                "index-collision",
2645                format!("duplicate explicit {kind} index {index}"),
2646                *span,
2647            ));
2648        }
2649    }
2650
2651    // The pinned OverPy reference fills the remaining free slots in
2652    // ascending order for auto-allocated entries, regardless of where the
2653    // explicit indices sit in declaration order; an early explicit index
2654    // does not push later auto allocations above it.
2655    let mut next = 0;
2656    let mut allocated = Vec::with_capacity(entries.len());
2657    for (index, span) in entries {
2658        let assigned = if let Some(index) = index {
2659            *index
2660        } else {
2661            while reserved.contains(&next) {
2662                next = next.checked_add(1).ok_or_else(|| {
2663                    IntegrationError::new(
2664                        "index-exhausted",
2665                        format!("no available {kind} index remains"),
2666                        *span,
2667                    )
2668                })?;
2669            }
2670            reserved.insert(next);
2671            let assigned = next;
2672            next = next.checked_add(1).ok_or_else(|| {
2673                IntegrationError::new(
2674                    "index-exhausted",
2675                    format!("no available {kind} index remains"),
2676                    *span,
2677                )
2678            })?;
2679            assigned
2680        };
2681        allocated.push(assigned);
2682    }
2683    Ok(allocated)
2684}
2685
2686fn player_event_kind(name: &str) -> Option<PlayerEventKind> {
2687    Some(match name {
2688        "playerDealtDamage" => PlayerEventKind::DealtDamage,
2689        "playerDealtFinalBlow" => PlayerEventKind::DealtFinalBlow,
2690        "playerDealtHealing" => PlayerEventKind::DealtHealing,
2691        "playerDied" => PlayerEventKind::Died,
2692        "playerEarnedElimination" => PlayerEventKind::EarnedElimination,
2693        "playerJoined" => PlayerEventKind::Joined,
2694        "playerLeft" => PlayerEventKind::Left,
2695        "playerReceivedHealing" => PlayerEventKind::ReceivedHealing,
2696        "playerTookDamage" => PlayerEventKind::TookDamage,
2697        _ => return None,
2698    })
2699}
2700
2701fn is_zero_initializer(expr: &hir::Expr) -> bool {
2702    match expr {
2703        hir::Expr::Number { text, value, .. } => text == "0" && *value == 0.0,
2704        _ => false,
2705    }
2706}
2707
2708fn canonical_number_text(value: f64, text: &str) -> String {
2709    if text.starts_with("0x") || text.starts_with("0X") {
2710        value.to_string()
2711    } else {
2712        text.to_string()
2713    }
2714}
2715
2716fn negated_comparison(op: &str) -> Option<&'static str> {
2717    Some(match op {
2718        "==" => "!=",
2719        "!=" => "==",
2720        "<" => ">=",
2721        ">" => "<=",
2722        "<=" => ">",
2723        ">=" => "<",
2724        _ => return None,
2725    })
2726}
2727
2728fn modify_op_from_str(op: &str) -> Option<wir::ModifyOp> {
2729    match op {
2730        "+" => Some(wir::ModifyOp::Add),
2731        "-" => Some(wir::ModifyOp::Subtract),
2732        "*" => Some(wir::ModifyOp::Multiply),
2733        "/" => Some(wir::ModifyOp::Divide),
2734        "%" => Some(wir::ModifyOp::Modulo),
2735        "**" => Some(wir::ModifyOp::RaiseToPower),
2736        _ => None,
2737    }
2738}
2739
2740fn modify_catalog_name_from_str(op: &str) -> Option<&'static str> {
2741    match op {
2742        "+" => Some("add"),
2743        "-" => Some("subtract"),
2744        "*" => Some("multiply"),
2745        "/" => Some("divide"),
2746        "%" => Some("modulo"),
2747        "**" => Some("raiseToPower"),
2748        _ => None,
2749    }
2750}
2751
2752fn hir_span_from_diag(span: opy_rs::diag::Span) -> HirSpan {
2753    HirSpan {
2754        file: span.file,
2755        start: hir::Position {
2756            line: span.start.line,
2757            col: span.start.col,
2758        },
2759        end: hir::Position {
2760            line: span.end.line,
2761            col: span.end.col,
2762        },
2763    }
2764}
2765
2766fn workshop_error_span(error: &workshop_rs::WorkshopError) -> Option<WorkshopSpan> {
2767    match error {
2768        workshop_rs::WorkshopError::Unknown { span, .. }
2769        | workshop_rs::WorkshopError::Malformed { span, .. }
2770        | workshop_rs::WorkshopError::Unsupported { span, .. } => *span,
2771        workshop_rs::WorkshopError::Catalog(_)
2772        | workshop_rs::WorkshopError::MissingMapping { .. } => None,
2773    }
2774}
2775
2776#[cfg(test)]
2777mod tests {
2778    use super::{Compiler, WORKSHOP_RS_VERSION, cross_check_manifest};
2779    use opy_rs::manifest::Manifest;
2780    use std::path::Path;
2781    use workshop_rs::catalog::{Catalog, Locale};
2782
2783    #[test]
2784    fn public_contract_is_pinned_and_manifest_links_are_checked() {
2785        let compiler = Compiler::new().expect("released workshop contract must load");
2786        let identity = compiler.catalog_identity();
2787        assert_eq!(identity.implementation_version, WORKSHOP_RS_VERSION);
2788        assert!(compiler.link_report().catalog_ids_checked > 0);
2789        assert!(compiler.link_report().domains_checked > 0);
2790    }
2791
2792    #[test]
2793    fn vertical_slice_preserves_source_files_spans_and_emits_workshop() {
2794        let compiler = Compiler::new().unwrap();
2795        let hir = opy_rs::compile(
2796            "globalvar A\nrule \"issue 35 integration\":\n    @Event global\n    A = 1\n    disableInspector()\n",
2797            "issue-35-integration.opy",
2798            Path::new("."),
2799        )
2800        .unwrap();
2801        let artifact = compiler.compile_hir(&hir).unwrap();
2802        assert_eq!(
2803            artifact
2804                .wir
2805                .files
2806                .get(workshop_rs::source::FileId::from_index(0))
2807                .unwrap()
2808                .path,
2809            "issue-35-integration.opy"
2810        );
2811        let rule = artifact
2812            .wir
2813            .rules
2814            .get(workshop_rs::wir::RuleId::from_index(0))
2815            .unwrap();
2816        assert_eq!(rule.span.unwrap().file.index(), 0);
2817        assert_eq!(rule.name_span.unwrap().start.line, 2);
2818        assert!(artifact.emitted.contains("Disable Inspector Recording;"));
2819        assert_eq!(artifact.catalog_identity.implementation_version, "0.1.11");
2820    }
2821
2822    #[test]
2823    fn stale_catalog_links_fail_explicitly() {
2824        let manifest = Manifest::builtin().unwrap().clone();
2825        let mut stale = manifest;
2826        stale.functions[0].catalog_id = Some("missing-catalog-id".to_string());
2827        let error = cross_check_manifest(&stale, &Catalog::builtin().unwrap()).unwrap_err();
2828        assert_eq!(error.diagnostic.code, "catalog-link-missing");
2829    }
2830
2831    #[test]
2832    fn while_lowering_is_source_attributed() {
2833        let compiler = Compiler::new().unwrap();
2834        let hir = opy_rs::compile(
2835            "rule \"while\":\n    @Event global\n    while true:\n        disableInspector()\n",
2836            "while.opy",
2837            Path::new("."),
2838        )
2839        .unwrap();
2840        let artifact = compiler.compile_hir(&hir).unwrap();
2841        let rule = artifact
2842            .wir
2843            .rules
2844            .get(workshop_rs::wir::RuleId::from_index(0))
2845            .unwrap();
2846        assert!(matches!(
2847            artifact.wir.actions.get(rule.actions[0]),
2848            Some(workshop_rs::wir::Action::While { .. })
2849        ));
2850        assert!(artifact.emitted.contains("While(True);"));
2851    }
2852
2853    #[test]
2854    fn structural_subroutines_lower_to_canonical_wir() {
2855        let compiler = Compiler::new().unwrap();
2856        let hir = opy_rs::compile(
2857            "globalvar score\nsubroutine showStatus\ndef showStatus():\n    @Name \"Friendly\"\n    @SuppressWarnings unusedVariable\n    disableInspector()\nrule \"caller\":\n    @Event global\n    showStatus()\n",
2858            "structure.opy",
2859            Path::new("."),
2860        )
2861        .unwrap();
2862        let artifact = compiler.compile_hir(&hir).unwrap();
2863        let subroutine = artifact
2864            .wir
2865            .subroutines
2866            .get(workshop_rs::wir::SubroutineId::from_index(0))
2867            .unwrap();
2868        assert_eq!(subroutine.name, "showStatus");
2869        assert_eq!(subroutine.index, 0);
2870        assert_eq!(subroutine.name_span.unwrap().start.line, 2);
2871        assert_eq!(artifact.wir.rules.len(), 2);
2872        let subroutine_rule = artifact
2873            .wir
2874            .rules
2875            .get(workshop_rs::wir::RuleId::from_index(0))
2876            .unwrap();
2877        let workshop_rs::wir::Event::Subroutine(subroutine_id) = subroutine_rule.event else {
2878            panic!("expected a subroutine event");
2879        };
2880        assert_eq!(
2881            artifact.wir.subroutines.get(subroutine_id).unwrap().name,
2882            "showStatus"
2883        );
2884        assert!(matches!(
2885            artifact
2886                .wir
2887                .actions
2888                .get(workshop_rs::wir::ActionId::from_index(1))
2889                .unwrap(),
2890            workshop_rs::wir::Action::CallSubroutine { .. }
2891        ));
2892        assert!(artifact.emitted.contains("Subroutine Friendly"));
2893    }
2894
2895    #[test]
2896    fn player_event_filters_resolve_through_canonical_catalog() {
2897        let compiler = Compiler::new().unwrap();
2898        let hir = opy_rs::compile(
2899            "rule \"joined\":\n    @Event playerJoined\n    @Team 1\n    @Slot 2\n    disableInspector()\n",
2900            "filters.opy",
2901            Path::new("."),
2902        )
2903        .unwrap();
2904        let artifact = compiler.compile_hir(&hir).unwrap();
2905        assert!(matches!(
2906            &artifact
2907                .wir
2908                .rules
2909                .get(workshop_rs::wir::RuleId::from_index(0))
2910                .unwrap()
2911                .event,
2912            workshop_rs::wir::Event::Player {
2913                kind: workshop_rs::wir::PlayerEventKind::Joined,
2914                team: workshop_rs::wir::EventTeam::Team1,
2915                target: workshop_rs::wir::EventTarget::Slot(2),
2916            }
2917        ));
2918        assert!(artifact.emitted.contains("Player Joined Match;"));
2919    }
2920
2921    #[test]
2922    fn explicit_indices_are_reserved_before_deterministic_allocation() {
2923        let compiler = Compiler::new().unwrap();
2924        let hir = opy_rs::compile(
2925            "globalvar first\nglobalvar reserved 0\nglobalvar next\nrule \"indices\":\n    @Event global\n    disableInspector()\n",
2926            "indices.opy",
2927            Path::new("."),
2928        )
2929        .unwrap();
2930        let artifact = compiler.compile_hir(&hir).unwrap();
2931        let by_name = artifact
2932            .wir
2933            .global_variables
2934            .iter()
2935            .map(|variable| (variable.name.as_str(), variable.index))
2936            .collect::<std::collections::BTreeMap<_, _>>();
2937        assert_eq!(
2938            by_name,
2939            std::collections::BTreeMap::from([("first", 1), ("reserved", 0), ("next", 2)])
2940        );
2941        // Variable tables are emitted in Workshop index order.
2942        let indices = artifact
2943            .wir
2944            .global_variables
2945            .iter()
2946            .map(|variable| variable.index)
2947            .collect::<Vec<_>>();
2948        assert_eq!(indices, vec![0, 1, 2]);
2949    }
2950
2951    #[test]
2952    fn implicit_default_variables_use_reference_fixed_slots() {
2953        let compiler = Compiler::new().unwrap();
2954        let hir = opy_rs::compile(
2955            r#"
2956globalvar timer
2957globalvar extra 5
2958
2959rule "implicit":
2960    @Event global
2961    A = timer + 1
2962    B = A
2963    B += 2
2964    A[0] = 7
2965    DX = B * A
2966"#,
2967            "implicit.opy",
2968            Path::new("."),
2969        )
2970        .unwrap();
2971        let artifact = compiler.compile_hir(&hir).unwrap();
2972        let globals = artifact
2973            .wir
2974            .global_variables
2975            .iter()
2976            .map(|variable| (variable.name.clone(), variable.index))
2977            .collect::<Vec<_>>();
2978        // The implicit A (0), B (1), and DX (127) names keep their fixed
2979        // Workshop slots and reserve them for declared-variable allocation
2980        // (pinned OverPy evidence); `timer` auto-allocates around them and
2981        // `extra` keeps its explicit index.
2982        assert_eq!(
2983            globals,
2984            vec![
2985                ("A".to_string(), 0),
2986                ("B".to_string(), 1),
2987                ("timer".to_string(), 2),
2988                ("extra".to_string(), 5),
2989                ("DX".to_string(), 127),
2990            ]
2991        );
2992        assert!(
2993            artifact
2994                .emitted
2995                .contains("Set Global Variable(A, Add(Global.timer, 1));")
2996        );
2997        assert!(
2998            artifact
2999                .emitted
3000                .contains("Set Global Variable(B, Global.A);")
3001        );
3002        assert!(
3003            artifact
3004                .emitted
3005                .contains("Modify Global Variable(B, Add, 2);")
3006        );
3007        assert!(
3008            artifact
3009                .emitted
3010                .contains("Set Global Variable At Index(A, 0, 7);")
3011        );
3012        assert!(
3013            artifact
3014                .emitted
3015                .contains("Set Global Variable(DX, Multiply(Global.B, Global.A));")
3016        );
3017    }
3018
3019    #[test]
3020    fn implicit_default_variable_slot_collision_is_source_attributed() {
3021        let compiler = Compiler::new().unwrap();
3022        let hir = opy_rs::compile(
3023            "globalvar x 0\nrule \"collision\":\n    @Event global\n    x = 1\n    A = 2\n",
3024            "collision.opy",
3025            Path::new("."),
3026        )
3027        .unwrap();
3028        let error = match compiler.compile_hir(&hir) {
3029            Ok(_) => panic!("slot collision unexpectedly succeeded"),
3030            Err(error) => error,
3031        };
3032        assert_eq!(error.diagnostic.code, "index-collision");
3033        assert_eq!(error.diagnostic.span.unwrap().start.line, 5);
3034        assert!(error.diagnostic.message.contains("'A' and 'x'"));
3035    }
3036
3037    #[test]
3038    fn implicit_default_player_variables_use_independent_reference_slots() {
3039        let compiler = Compiler::new().unwrap();
3040        let hir = opy_rs::compile(
3041            r#"
3042playervar declaredPlayer
3043
3044rule "implicit player variables":
3045    @Event eachPlayer
3046    A = 1
3047    eventPlayer.A = 1
3048    eventPlayer.A += 2
3049    eventPlayer.E = eventPlayer.A
3050    eventPlayer.DX = eventPlayer.E
3051    eventPlayer.declaredPlayer = eventPlayer.A
3052"#,
3053            "implicit-player.opy",
3054            Path::new("."),
3055        )
3056        .unwrap();
3057        let artifact = compiler.compile_hir(&hir).unwrap();
3058        let globals = artifact
3059            .wir
3060            .global_variables
3061            .iter()
3062            .map(|variable| (variable.name.as_str(), variable.index))
3063            .collect::<std::collections::BTreeMap<_, _>>();
3064        let players = artifact
3065            .wir
3066            .player_variables
3067            .iter()
3068            .map(|variable| (variable.name.as_str(), variable.index))
3069            .collect::<std::collections::BTreeMap<_, _>>();
3070        assert_eq!(globals.get("A"), Some(&0));
3071        assert_eq!(players.get("A"), Some(&0));
3072        assert_eq!(players.get("declaredPlayer"), Some(&1));
3073        assert_eq!(players.get("E"), Some(&4));
3074        assert_eq!(players.get("DX"), Some(&127));
3075        assert!(
3076            artifact
3077                .emitted
3078                .contains("Set Player Variable(Event Player, A, 1);")
3079        );
3080        assert!(
3081            artifact
3082                .emitted
3083                .contains("Modify Player Variable(Event Player, A, Add, 2);")
3084        );
3085        assert!(
3086            artifact
3087                .emitted
3088                .contains("Set Player Variable(Event Player, E, (Event Player).A);")
3089        );
3090    }
3091
3092    #[test]
3093    fn implicit_default_player_slot_collision_is_source_attributed() {
3094        let compiler = Compiler::new().unwrap();
3095        let hir = opy_rs::compile(
3096            "playervar declared 0\nrule \"collision\":\n    @Event eachPlayer\n    eventPlayer.A = 1\n",
3097            "player-collision.opy",
3098            Path::new("."),
3099        )
3100        .unwrap();
3101        let error = match compiler.compile_hir(&hir) {
3102            Ok(_) => panic!("player slot collision unexpectedly succeeded"),
3103            Err(error) => error,
3104        };
3105        assert_eq!(error.diagnostic.code, "index-collision");
3106        assert!(
3107            error
3108                .diagnostic
3109                .message
3110                .contains("player variables 'A' and 'declared'")
3111        );
3112        assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
3113    }
3114
3115    #[test]
3116    fn power_augmented_assignment_lowers_from_source() {
3117        let compiler = Compiler::new().unwrap();
3118        let hir = opy_rs::compile(
3119            "globalvar g\nrule \"power\":\n    @Event global\n    g = 2\n    g **= 3\n",
3120            "power.opy",
3121            Path::new("."),
3122        )
3123        .unwrap();
3124        let artifact = compiler.compile_hir(&hir).unwrap();
3125        assert!(artifact.emitted.contains("Set Global Variable(g, 2);"));
3126        assert!(
3127            artifact
3128                .emitted
3129                .contains("Modify Global Variable(g, Raise To Power, 3);")
3130        );
3131    }
3132
3133    #[test]
3134    fn opy_hex_numbers_are_normalized_at_the_wir_boundary() {
3135        let compiler = Compiler::new().unwrap();
3136        let hir = opy_rs::compile(
3137            "globalvar large = 0x124BC\nglobalvar small = 0x124\nglobalvar scientific = 1e10\n",
3138            "numbers.opy",
3139            Path::new("."),
3140        )
3141        .unwrap();
3142        let artifact = compiler.compile_hir(&hir).unwrap();
3143        assert!(
3144            artifact
3145                .emitted
3146                .contains("Set Global Variable(large, 74940);")
3147        );
3148        assert!(
3149            artifact
3150                .emitted
3151                .contains("Set Global Variable(small, 292);")
3152        );
3153        assert!(
3154            artifact
3155                .emitted
3156                .contains("Set Global Variable(scientific, 1e10);")
3157        );
3158        assert!(!artifact.emitted.contains("0x124BC"));
3159        assert!(!artifact.emitted.contains("0x124"));
3160    }
3161
3162    #[test]
3163    fn unsupported_primitive_lowering_is_stable_and_source_attributed() {
3164        let compiler = Compiler::new().unwrap();
3165        let hir = opy_rs::compile(
3166            "globalvar total\nrule \"negative\":\n    @Event global\n    total = {\"a\": 1, \"b\": 2}[\"a\"]\n",
3167            "negative.opy",
3168            Path::new("."),
3169        )
3170        .unwrap();
3171        let error = match compiler.compile_hir(&hir) {
3172            Ok(_) => panic!("dict primitive lowering unexpectedly succeeded"),
3173            Err(error) => error,
3174        };
3175        assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
3176        assert!(error.diagnostic.message.contains("dict"));
3177        assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
3178    }
3179
3180    #[test]
3181    fn auto_allocation_fills_free_slots_below_early_explicit_indices() {
3182        let compiler = Compiler::new().unwrap();
3183        let hir = opy_rs::compile(
3184            r#"
3185globalvar reserved 5
3186globalvar auto1
3187globalvar auto2
3188
3189rule "allocation":
3190    @Event global
3191    auto1 = 1
3192    auto2 = 2
3193    B = 3
3194"#,
3195            "allocation.opy",
3196            Path::new("."),
3197        )
3198        .unwrap();
3199        let artifact = compiler.compile_hir(&hir).unwrap();
3200        let by_name = artifact
3201            .wir
3202            .global_variables
3203            .iter()
3204            .map(|variable| (variable.name.clone(), variable.index))
3205            .collect::<std::collections::BTreeMap<_, _>>();
3206        // The implicit B keeps its fixed slot 1; the auto-allocated variables
3207        // fill the remaining free slots below the explicit 5 instead of
3208        // jumping past it, matching the pinned OverPy oracle (slot 0 stays
3209        // free here because the implicit A is never used).
3210        assert_eq!(
3211            by_name,
3212            std::collections::BTreeMap::from([
3213                ("B".to_string(), 1),
3214                ("auto1".to_string(), 0),
3215                ("auto2".to_string(), 2),
3216                ("reserved".to_string(), 5),
3217            ])
3218        );
3219    }
3220
3221    #[test]
3222    fn power_expressions_lower_through_the_canonical_contract() {
3223        let compiler = Compiler::new().unwrap();
3224        let hir = opy_rs::compile(
3225            "globalvar a = [2, 4]\nglobalvar out\nrule \"power\":\n    @Event global\n    out = a ** 2\n    a **= 2\n    a[0] **= 2\n",
3226            "power.opy",
3227            Path::new("."),
3228        )
3229        .unwrap();
3230        let artifact = compiler.compile_hir(&hir).unwrap();
3231        assert!(
3232            artifact
3233                .emitted
3234                .contains("Set Global Variable(out, Raise To Power(Global.a, 2));")
3235        );
3236        assert!(
3237            artifact
3238                .emitted
3239                .contains("Modify Global Variable(a, Raise To Power, 2);")
3240        );
3241        assert!(
3242            artifact
3243                .emitted
3244                .contains("Modify Global Variable At Index(a, 0, Raise To Power, 2);")
3245        );
3246    }
3247
3248    #[test]
3249    fn unsupported_rule_metadata_is_explicit_and_source_attributed() {
3250        let compiler = Compiler::new().unwrap();
3251        let hir = opy_rs::compile(
3252            "rule \"metadata\":\n    @Event global\n    @NewPage \"section\"\n    disableInspector()\n",
3253            "metadata.opy",
3254            Path::new("."),
3255        )
3256        .unwrap();
3257        let error = match compiler.compile_hir(&hir) {
3258            Ok(_) => panic!("unsupported metadata unexpectedly succeeded"),
3259            Err(error) => error,
3260        };
3261        assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
3262        assert_eq!(error.diagnostic.span.unwrap().start.line, 3);
3263    }
3264
3265    #[test]
3266    fn issue_40_oracle_fixture_and_wir_lowering_agree() {
3267        let compiler = Compiler::new().unwrap();
3268        let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
3269            .join("../../compatibility/fixtures/synthetic/issue-40-structural");
3270        let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
3271        let hir = opy_rs::compile(&source, "source.opy", &fixture).unwrap();
3272        let artifact = compiler.compile_hir(&hir).unwrap();
3273        let oracle: serde_json::Value =
3274            serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
3275                .unwrap();
3276        let oracle_workshop = oracle["compile"]["workshop"].as_str().unwrap();
3277
3278        assert!(oracle_workshop.contains("0: reserved"));
3279        assert!(oracle_workshop.contains("1: first"));
3280        assert!(oracle_workshop.contains("2: explicit"));
3281        assert!(oracle_workshop.contains("3: next"));
3282        assert!(oracle_workshop.contains("0: helper"));
3283        assert!(oracle_workshop.contains("Subroutine;\n        helper;"));
3284        assert!(oracle_workshop.contains("Player Joined Match;\n        Team 1;\n        Slot 2;"));
3285
3286        let indices = artifact
3287            .wir
3288            .global_variables
3289            .iter()
3290            .map(|variable| variable.index)
3291            .collect::<Vec<_>>();
3292        assert_eq!(indices, vec![0, 1, 2, 3]);
3293        assert_eq!(
3294            artifact.wir.subroutines.iter().next().unwrap().name,
3295            "helper"
3296        );
3297        assert!(artifact.emitted.contains("[Source] renamed helper"));
3298        assert!(matches!(
3299            artifact
3300                .wir
3301                .rules
3302                .get(workshop_rs::wir::RuleId::from_index(1))
3303                .unwrap()
3304                .event,
3305            workshop_rs::wir::Event::Player {
3306                kind: workshop_rs::wir::PlayerEventKind::Joined,
3307                team: workshop_rs::wir::EventTeam::Team1,
3308                target: workshop_rs::wir::EventTarget::Slot(2),
3309            }
3310        ));
3311    }
3312
3313    #[test]
3314    fn assignments_and_modifications_lower_to_canonical_wir() {
3315        let compiler = Compiler::new().unwrap();
3316        let hir = opy_rs::compile(
3317            r#"
3318globalvar g1
3319globalvar g2
3320playervar p1
3321playervar p2 = [1, 2, 3]
3322
3323rule "assignments":
3324    @Event eachPlayer
3325    g1 = 10
3326    g1 += 5
3327    g1 -= 2
3328    g1 *= 3
3329    g1 /= 2
3330    g1 %= 4
3331    g2 = [1, 2, 3]
3332    g2[0] = 99
3333    g2[1] += 1
3334    eventPlayer.p1 = 42
3335    eventPlayer.p1 += 8
3336    eventPlayer.p1 *= 2
3337    eventPlayer.p2[2] = 7
3338    eventPlayer.p2[0] -= 3
3339"#,
3340            "assign.opy",
3341            Path::new("."),
3342        )
3343        .unwrap();
3344        let artifact = compiler.compile_hir(&hir).unwrap();
3345        assert!(artifact.emitted.contains("Set Global Variable(g1, 10);"));
3346        assert!(
3347            artifact
3348                .emitted
3349                .contains("Modify Global Variable(g1, Add, 5);")
3350        );
3351        assert!(
3352            artifact
3353                .emitted
3354                .contains("Modify Global Variable(g1, Subtract, 2);")
3355        );
3356        assert!(
3357            artifact
3358                .emitted
3359                .contains("Modify Global Variable(g1, Multiply, 3);")
3360        );
3361        assert!(
3362            artifact
3363                .emitted
3364                .contains("Modify Global Variable(g1, Divide, 2);")
3365        );
3366        assert!(
3367            artifact
3368                .emitted
3369                .contains("Modify Global Variable(g1, Modulo, 4);")
3370        );
3371        assert!(
3372            artifact
3373                .emitted
3374                .contains("Set Global Variable At Index(g2, 0, 99);")
3375        );
3376        assert!(
3377            artifact
3378                .emitted
3379                .contains("Modify Global Variable At Index(g2, 1, Add, 1);")
3380        );
3381        assert!(
3382            artifact
3383                .emitted
3384                .contains("Set Player Variable(Event Player, p1, 42);")
3385        );
3386        assert!(
3387            artifact
3388                .emitted
3389                .contains("Modify Player Variable(Event Player, p1, Add, 8);")
3390        );
3391        assert!(
3392            artifact
3393                .emitted
3394                .contains("Modify Player Variable(Event Player, p1, Multiply, 2);")
3395        );
3396        assert!(
3397            artifact
3398                .emitted
3399                .contains("Set Player Variable At Index((Event Player).p2, 2, 7);")
3400        );
3401        assert!(
3402            artifact
3403                .emitted
3404                .contains("Modify Player Variable At Index((Event Player).p2, 0, Subtract, 3);")
3405        );
3406
3407        // Direct assignments carry both the statement span and the separate
3408        // target-variable span; indexed forms lower to Call actions that
3409        // carry only the statement span.
3410        let rule = artifact
3411            .wir
3412            .rules
3413            .get(workshop_rs::wir::RuleId::from_index(1))
3414            .unwrap();
3415        let direct = artifact.wir.actions.get(rule.actions[0]).unwrap();
3416        match direct {
3417            workshop_rs::wir::Action::SetGlobalVariable {
3418                span,
3419                target_span,
3420                variable,
3421                ..
3422            } => {
3423                assert_eq!(span.unwrap().start.line, 9);
3424                assert_eq!(target_span.unwrap().start.line, 9);
3425                assert_eq!(
3426                    artifact.wir.global_variables.get(*variable).unwrap().name,
3427                    "g1"
3428                );
3429            }
3430            other => panic!("expected a direct global assignment, got {other:?}"),
3431        }
3432        let indexed = artifact.wir.actions.get(rule.actions[7]).unwrap();
3433        match indexed {
3434            workshop_rs::wir::Action::Call { span, .. } => {
3435                assert_eq!(span.unwrap().start.line, 16);
3436            }
3437            other => panic!("expected an indexed assignment call, got {other:?}"),
3438        }
3439    }
3440
3441    #[test]
3442    fn expressions_and_values_lower_to_canonical_wir() {
3443        let compiler = Compiler::new().unwrap();
3444        let hir = opy_rs::compile(
3445            r#"
3446enum Consts:
3447    BASE
3448
3449globalvar total
3450globalvar arr = [1, 2, 3]
3451globalvar pos = vect(1, 2, 3)
3452
3453rule "expressions":
3454    @Event global
3455    @Condition total == 0
3456    @Condition not (pos == vect(0, 0, 0))
3457    @Condition 2 in arr
3458    total = Consts.BASE + arr[1] * 2 - (10 / 2) + (5 % 2)
3459    print("Total: {}".format(total))
3460    debug(pos)
3461"#,
3462            "expr.opy",
3463            Path::new("."),
3464        )
3465        .unwrap();
3466        let artifact = compiler.compile_hir(&hir).unwrap();
3467        assert!(artifact.emitted.contains("Global.total == 0;"));
3468        // `not (pos == vect(0, 0, 0))` lowers to the negated comparison,
3469        // mirroring the pinned OverPy oracle.
3470        assert!(artifact.emitted.contains("Global.pos != Vector(0, 0, 0);"));
3471        assert!(
3472            artifact
3473                .emitted
3474                .contains("Array Contains(Global.arr, 2) == True;")
3475        );
3476        assert!(
3477            artifact
3478                .emitted
3479                .contains("Custom String(\"Total: {0}\", Global.total)")
3480        );
3481    }
3482
3483    #[test]
3484    fn pass_is_supported_as_source_level_noop() {
3485        let compiler = Compiler::new().unwrap();
3486        let hir = opy_rs::compile(
3487            r#"
3488subroutine emptySub
3489
3490def emptySub():
3491    pass
3492
3493rule "empty rule":
3494    @Event global
3495    pass
3496"#,
3497            "pass.opy",
3498            Path::new("."),
3499        )
3500        .unwrap();
3501        let artifact = compiler.compile_hir(&hir).unwrap();
3502        let rule0 = artifact
3503            .wir
3504            .rules
3505            .get(workshop_rs::wir::RuleId::from_index(0))
3506            .unwrap();
3507        assert!(rule0.actions.is_empty());
3508        let rule1 = artifact
3509            .wir
3510            .rules
3511            .get(workshop_rs::wir::RuleId::from_index(1))
3512            .unwrap();
3513        assert!(rule1.actions.is_empty());
3514    }
3515
3516    #[test]
3517    fn variable_initializers_synthesize_initialize_rules() {
3518        let compiler = Compiler::new().unwrap();
3519        let hir = opy_rs::compile(
3520            r#"
3521globalvar j = 5
3522globalvar h = 0
3523globalvar k = 0.0
3524playervar p = 7
3525playervar q = 0
3526
3527rule "main":
3528    @Event global
3529    disableInspector()
3530"#,
3531            "init.opy",
3532            Path::new("."),
3533        )
3534        .unwrap();
3535        let artifact = compiler.compile_hir(&hir).unwrap();
3536        assert_eq!(
3537            artifact
3538                .wir
3539                .rules
3540                .get(workshop_rs::wir::RuleId::from_index(0))
3541                .unwrap()
3542                .name,
3543            "Initialize global variables"
3544        );
3545        assert_eq!(
3546            artifact
3547                .wir
3548                .rules
3549                .get(workshop_rs::wir::RuleId::from_index(1))
3550                .unwrap()
3551                .name,
3552            "Initialize player variables"
3553        );
3554        assert_eq!(
3555            artifact
3556                .wir
3557                .rules
3558                .get(workshop_rs::wir::RuleId::from_index(2))
3559                .unwrap()
3560                .name,
3561            "main"
3562        );
3563        assert!(artifact.emitted.contains("Set Global Variable(j, 5);"));
3564        assert!(artifact.emitted.contains("Set Global Variable(k, 0.0);"));
3565        assert!(!artifact.emitted.contains("Set Global Variable(h,"));
3566        assert!(
3567            artifact
3568                .emitted
3569                .contains("Set Player Variable(Event Player, p, 7);")
3570        );
3571        assert!(
3572            !artifact
3573                .emitted
3574                .contains("Set Player Variable(Event Player, q,")
3575        );
3576    }
3577
3578    #[test]
3579    fn settings_lower_through_workshop_owned_emission() {
3580        let compiler = Compiler::new().unwrap();
3581        let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
3582            .join("../../compatibility/fixtures/synthetic/settings");
3583        let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
3584        let hir = opy_rs::compile(&source, "source.opy", &fixture).unwrap();
3585        let artifact = compiler.compile_hir(&hir).unwrap();
3586        let oracle: serde_json::Value =
3587            serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
3588                .unwrap();
3589        let expected = oracle["compile"]["workshop"]
3590            .as_str()
3591            .unwrap()
3592            .split("\n\nrule")
3593            .next()
3594            .unwrap();
3595        let actual = artifact.emitted.split("\n\nrule").next().unwrap();
3596        assert_eq!(
3597            normalize_workshop_structural_whitespace(actual),
3598            normalize_workshop_structural_whitespace(expected)
3599        );
3600    }
3601
3602    #[test]
3603    fn unsupported_locale_has_no_fabricated_source_span() {
3604        let compiler = Compiler::new().unwrap();
3605        let hir = opy_rs::compile(
3606            "#!translations en\nrule \"r\":\n    @Event global\n    pass\n",
3607            "locale.opy",
3608            Path::new("."),
3609        )
3610        .unwrap();
3611        let error = match compiler.compile_hir_with_locale(&hir, &Locale::new("xx-XX")) {
3612            Ok(_) => panic!("unsupported locale unexpectedly compiled"),
3613            Err(error) => error,
3614        };
3615        assert_eq!(error.diagnostic.code, "locale-unsupported");
3616        assert_eq!(error.diagnostic.span, None);
3617    }
3618
3619    #[test]
3620    fn locale_selection_emits_catalog_localized_workshop() {
3621        let compiler = Compiler::new().unwrap();
3622        let hir = opy_rs::compile(
3623            "rule \"locale\":\n    @Event global\n    disableInspector()\n",
3624            "locale.opy",
3625            Path::new("."),
3626        )
3627        .unwrap();
3628        let artifact = compiler
3629            .compile_hir_with_locale(&hir, &Locale::new("zh-CN"))
3630            .unwrap();
3631        assert!(artifact.emitted.contains("规则 (\"locale\")"));
3632        assert!(artifact.emitted.contains("禁用查看器录制"));
3633    }
3634
3635    #[test]
3636    fn unsupported_backend_directives_fail_at_their_source_anchor() {
3637        let compiler = Compiler::new().unwrap();
3638        let hir = opy_rs::compile(
3639            "#!replace0ByCapturePercentage\nrule \"r\":\n    @Event global\n    pass\n",
3640            "directives.opy",
3641            Path::new("."),
3642        )
3643        .unwrap();
3644        let error = match compiler.compile_hir(&hir) {
3645            Ok(_) => panic!("backend directive unexpectedly compiled"),
3646            Err(error) => error,
3647        };
3648        assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
3649        assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
3650    }
3651
3652    #[test]
3653    fn optimizer_directives_remain_non_blocking_presentation_controls() {
3654        let compiler = Compiler::new().unwrap();
3655        let hir = opy_rs::compile(
3656            "#!disableOptimizations\nrule \"r\":\n    @Event global\n    pass\n",
3657            "optimization.opy",
3658            Path::new("."),
3659        )
3660        .unwrap();
3661        compiler.compile_hir(&hir).unwrap();
3662    }
3663
3664    #[test]
3665    fn replacement_directive_records_are_checked_even_if_final_state_is_restored() {
3666        let compiler = Compiler::new().unwrap();
3667        let mut hir = opy_rs::compile(
3668            "#!replace0ByCapturePercentage\nrule \"r\":\n    @Event global\n    pass\n",
3669            "directives.opy",
3670            Path::new("."),
3671        )
3672        .unwrap();
3673        hir.preprocessing.replacements.clear();
3674        let error = match compiler.compile_hir(&hir) {
3675            Ok(_) => panic!("replacement directive unexpectedly compiled"),
3676            Err(error) => error,
3677        };
3678        assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
3679        assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
3680    }
3681
3682    #[test]
3683    fn active_replacement_state_is_checked_without_directive_history() {
3684        let compiler = Compiler::new().unwrap();
3685        let mut hir = opy_rs::compile(
3686            "#!replace0ByCapturePercentage\nrule \"r\":\n    @Event global\n    pass\n",
3687            "directives.opy",
3688            Path::new("."),
3689        )
3690        .unwrap();
3691        hir.preprocessing.directives.clear();
3692        hir.preprocessing.replacements[0].span = None;
3693        let error = match compiler.compile_hir(&hir) {
3694            Ok(_) => panic!("active replacement state unexpectedly compiled"),
3695            Err(error) => error,
3696        };
3697        assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
3698        assert_eq!(error.diagnostic.span, None);
3699    }
3700
3701    #[test]
3702    fn post_compile_hook_receives_exact_emitted_workshop() {
3703        let compiler = Compiler::new().unwrap();
3704        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../opy-rs/tests/fixtures/macros");
3705        let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n    pass\n";
3706        let artifact = compiler
3707            .compile_source(source, "hook.opy", &root, &Locale::new("en-US"))
3708            .unwrap();
3709        assert!(artifact.emitted.contains("rule (\"setup\")"));
3710        assert!(artifact.final_output.contains("rule (\"transformed\")"));
3711        assert_ne!(artifact.final_output, artifact.emitted);
3712    }
3713
3714    #[test]
3715    fn post_compile_hook_failure_keeps_script_provenance_and_directive_anchor() {
3716        let compiler = Compiler::new().unwrap();
3717        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../opy-rs/tests/fixtures/macros");
3718        let source = "#!postCompileHook \"hook-boom.js\"\n\nrule \"setup\":\n    pass\n";
3719        let error = match compiler.compile_source(source, "hook.opy", &root, &Locale::new("en-US"))
3720        {
3721            Ok(_) => panic!("failing post-compile hook unexpectedly compiled"),
3722            Err(error) => error,
3723        };
3724        assert_eq!(error.diagnostic.code, "post-compile-hook");
3725        assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
3726        let script = error.diagnostic.script.unwrap();
3727        assert_eq!(script.source_name.as_deref(), Some("hook-boom.js"));
3728        assert_eq!(script.line, Some(1));
3729        assert!(script.stack.unwrap().contains("hook-boom.js:1"));
3730    }
3731
3732    fn normalize_workshop_structural_whitespace(text: &str) -> String {
3733        let mut normalized = String::with_capacity(text.len());
3734        let mut quote = None;
3735        let mut escaped = false;
3736        for character in text.chars() {
3737            if let Some(delimiter) = quote {
3738                normalized.push(character);
3739                if escaped {
3740                    escaped = false;
3741                } else if character == '\\' {
3742                    escaped = true;
3743                } else if character == delimiter {
3744                    quote = None;
3745                }
3746            } else if matches!(character, '\"' | '\'') {
3747                quote = Some(character);
3748                normalized.push(character);
3749            } else if !character.is_whitespace() {
3750                normalized.push(character);
3751            }
3752        }
3753        normalized
3754    }
3755
3756    #[test]
3757    fn settings_whitespace_normalization_preserves_quoted_values() {
3758        assert_ne!(
3759            normalize_workshop_structural_whitespace("Description: \"a b\""),
3760            normalize_workshop_structural_whitespace("Description: \"ab\"")
3761        );
3762    }
3763}