Skip to main content

opy_rs/compiler/
mod.rs

1//! OPY-to-Workshop integration, kept behind the `opy-rs` library boundary.
2//!
3//! This module consumes the `workshop-rs` 0.3 contract, checks the OPY
4//! manifest links against the canonical catalog, and lowers the supported OPY
5//! program structure into canonical WIR before validation and deterministic
6//! Workshop emission.
7
8use std::collections::{BTreeMap, HashMap, HashSet};
9
10use crate::hir::{self, Expr, RuleEntry, Span as HirSpan, Stmt, SwitchArm, default_var_index};
11use crate::manifest::{FunctionKind, Manifest};
12use serde::Serialize;
13use workshop_rs::catalog::{Catalog, CatalogIdentity, Kind, Locale, ParamCoercions};
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
19mod backend;
20mod hooks;
21mod integration;
22mod lowering;
23mod settings;
24
25pub(crate) use backend::MacroExpander;
26pub(super) use backend::{expand_macros, reject_unlowered_directives};
27pub use integration::LinkReport;
28pub(super) use integration::load_compiler_contract;
29pub(super) use lowering::Lowering;
30
31#[cfg(test)]
32mod integration_tests;
33
34const TRANSLATION_HELPER_NAME: &str = "__overpyTranslationHelper__";
35
36/// Version of the machine-readable compile report contract.
37pub const COMPILE_SCHEMA_VERSION: u32 = 1;
38
39/// Stable identity of the compiler that produced a compile report.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41pub struct CompilerIdentity {
42    pub name: &'static str,
43    pub version: &'static str,
44}
45
46/// Whether compilation produced a valid Workshop artifact.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
48#[serde(rename_all = "lowercase")]
49pub enum CompileStatus {
50    Success,
51    Failure,
52}
53
54/// Stable classification for a compile failure.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
56#[serde(rename_all = "kebab-case")]
57pub enum CompileFailureClass {
58    Frontend,
59    Integration,
60}
61
62/// A versioned, source-attributed diagnostic exposed by the compile API.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct CompileDiagnostic {
66    pub severity: crate::tooling::DiagnosticSeverity,
67    pub code: String,
68    pub message: String,
69    pub span: Option<crate::tooling::SourceLocation>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub script: Option<ScriptDiagnostic>,
72}
73
74/// The machine-readable result for one compile operation.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "camelCase")]
77pub struct CompileResult {
78    pub status: CompileStatus,
79    pub exit_code: u8,
80    pub failure_class: Option<CompileFailureClass>,
81    pub diagnostics: Vec<CompileDiagnostic>,
82    pub stdout: String,
83    pub workshop_exact: String,
84    pub workshop: String,
85}
86
87/// Complete versioned compile report for CLI, CI, and embedding consumers.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub struct CompileReport {
91    pub schema_version: u32,
92    pub compiler: CompilerIdentity,
93    pub catalog: CatalogIdentity,
94    pub compile: CompileResult,
95}
96
97impl CompilerIdentity {
98    fn current() -> Self {
99        Self {
100            name: "opy-rs",
101            version: env!("CARGO_PKG_VERSION"),
102        }
103    }
104}
105
106/// A source-attributed integration diagnostic.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct IntegrationDiagnostic {
109    pub code: String,
110    pub message: String,
111    pub span: Option<HirSpan>,
112    pub script: Option<Box<ScriptDiagnostic>>,
113}
114
115/// Script-runtime provenance retained alongside the OPY directive anchor.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub struct ScriptDiagnostic {
119    pub source_name: Option<String>,
120    pub line: Option<u32>,
121    pub column: Option<u32>,
122    pub stack: Option<String>,
123}
124
125impl IntegrationDiagnostic {
126    fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
127        Self {
128            code: code.into(),
129            message: message.into(),
130            span,
131            script: None,
132        }
133    }
134}
135
136/// An integration boundary failure.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct IntegrationError {
139    pub diagnostic: IntegrationDiagnostic,
140}
141
142impl IntegrationError {
143    fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
144        Self {
145            diagnostic: IntegrationDiagnostic::new(code, message, span),
146        }
147    }
148
149    fn post_compile_hook(error: crate::macro_js::MacroError, span: Option<HirSpan>) -> Self {
150        let message = error.to_string();
151        let script = match error {
152            crate::macro_js::MacroError::Script(error) => Some(Box::new(ScriptDiagnostic {
153                source_name: error.source_name,
154                line: error.line,
155                column: error.column,
156                stack: error.stack,
157            })),
158            crate::macro_js::MacroError::InvalidResult { .. }
159            | crate::macro_js::MacroError::Internal(_) => None,
160        };
161        Self {
162            diagnostic: IntegrationDiagnostic {
163                code: "post-compile-hook".to_string(),
164                message,
165                span,
166                script,
167            },
168        }
169    }
170}
171
172impl std::fmt::Display for IntegrationError {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        write!(f, "{}: {}", self.diagnostic.code, self.diagnostic.message)
175    }
176}
177
178impl std::error::Error for IntegrationError {}
179
180/// The compiler-facing integration object. Construction validates the public
181/// manifest/catalog contract once and exposes the pinned catalog identity.
182pub struct Compiler {
183    catalog: Catalog,
184    manifest: &'static Manifest,
185    links: LinkReport,
186}
187
188impl Compiler {
189    pub fn new() -> Result<Self, IntegrationError> {
190        let (catalog, manifest, links) = load_compiler_contract()?;
191        Ok(Self {
192            catalog,
193            manifest,
194            links,
195        })
196    }
197
198    pub fn catalog_identity(&self) -> CatalogIdentity {
199        self.catalog.identity()
200    }
201
202    pub fn link_report(&self) -> LinkReport {
203        self.links
204    }
205
206    /// Lower a resolved OPY HIR program into canonical WIR, validate it
207    /// against the canonical catalog, and emit deterministic en-US Workshop.
208    pub fn compile_hir(&self, hir: &hir::Program) -> Result<CompilationArtifact, IntegrationError> {
209        self.compile_hir_with_locale(hir, &Locale::new("en-US"))
210    }
211
212    /// Lower and emit using a locale declared by the canonical catalog.
213    pub fn compile_hir_with_locale(
214        &self,
215        hir: &hir::Program,
216        locale: &Locale,
217    ) -> Result<CompilationArtifact, IntegrationError> {
218        if !self.catalog.supports(locale) {
219            return Err(IntegrationError::new(
220                "locale-unsupported",
221                format!("workshop catalog does not declare locale '{locale}'"),
222                None,
223            ));
224        }
225        reject_unlowered_directives(hir)?;
226        let expanded_hir = expand_macros(hir)?;
227        let mut lowering = Lowering::new(self, &expanded_hir)?;
228        lowering.copy_files()?;
229        lowering.lower_declarations()?;
230        lowering.lower_rules()?;
231
232        lowering.wir.validate().map_err(|error| {
233            let span = error
234                .span()
235                .and_then(|span| lowering.hir_span_from_workshop(span));
236            IntegrationError::new(error.code(), error.message(), span)
237        })?;
238        workshop_rs::validate::validate_canonical_ids(&lowering.wir, &self.catalog).map_err(
239            |error| {
240                let span = workshop_error_span(&error)
241                    .and_then(|span| lowering.hir_span_from_workshop(span));
242                IntegrationError::new("catalog-validation", error.to_string(), span)
243            },
244        )?;
245        let emitted =
246            workshop_rs::emitter::emit(&lowering.wir, &self.catalog, locale).map_err(|error| {
247                let span = workshop_error_span(&error)
248                    .and_then(|span| lowering.hir_span_from_workshop(span));
249                IntegrationError::new("workshop-emission", error.to_string(), span)
250            })?;
251
252        Ok(CompilationArtifact {
253            wir: lowering.wir,
254            final_output: emitted.clone(),
255            emitted,
256            catalog_identity: self.catalog.identity(),
257            hook_console_output: Vec::new(),
258        })
259    }
260
261    /// Compile source using the default `en-US` catalog locale.
262    ///
263    /// This is the ordinary embedding API. It returns Workshop text and does
264    /// not require callers to construct a `workshop-rs` locale or understand
265    /// canonical WIR types.
266    pub fn compile_source(
267        &self,
268        source: &str,
269        main_path: &str,
270        root: &std::path::Path,
271    ) -> Result<CompileOutput, IntegrationError> {
272        self.compile_source_with_language(source, main_path, root, "en-US")
273    }
274
275    /// Compile source using a catalog locale name without exposing the
276    /// `workshop-rs` locale type to ordinary embedding callers.
277    pub fn compile_source_with_language(
278        &self,
279        source: &str,
280        main_path: &str,
281        root: &std::path::Path,
282        language: &str,
283    ) -> Result<CompileOutput, IntegrationError> {
284        self.compile_source_with_locale(source, main_path, root, &Locale::new(language))
285            .map(CompilationArtifact::into_output)
286    }
287
288    /// Compile source with an explicit canonical Workshop locale.
289    ///
290    /// This is an advanced integration API. Use [`Self::compile_source`] or
291    /// [`Self::compile_source_with_language`] for ordinary embedding.
292    pub fn compile_source_with_locale(
293        &self,
294        source: &str,
295        main_path: &str,
296        root: &std::path::Path,
297        locale: &Locale,
298    ) -> Result<CompilationArtifact, IntegrationError> {
299        self.compile_source_internal(source, main_path, root, locale)
300    }
301
302    /// Compile source and return the canonical WIR artifact for advanced
303    /// integrations.
304    pub fn compile_source_artifact(
305        &self,
306        source: &str,
307        main_path: &str,
308        root: &std::path::Path,
309    ) -> Result<CompilationArtifact, IntegrationError> {
310        self.compile_source_with_locale(source, main_path, root, &Locale::new("en-US"))
311    }
312
313    /// Compile source into the versioned machine-readable result contract
314    /// using the default `en-US` catalog locale.
315    pub fn compile_source_report(
316        &self,
317        source: &str,
318        main_path: &str,
319        root: &std::path::Path,
320    ) -> CompileReport {
321        self.compile_source_report_with_language(source, main_path, root, "en-US")
322    }
323
324    /// Compile source into the versioned machine-readable result contract
325    /// using a catalog locale name.
326    pub fn compile_source_report_with_language(
327        &self,
328        source: &str,
329        main_path: &str,
330        root: &std::path::Path,
331        language: &str,
332    ) -> CompileReport {
333        self.compile_source_report_with_locale(source, main_path, root, &Locale::new(language))
334    }
335
336    /// Compile source into the report contract with an explicit canonical
337    /// Workshop locale. This is an advanced integration API.
338    pub fn compile_source_report_with_locale(
339        &self,
340        source: &str,
341        main_path: &str,
342        root: &std::path::Path,
343        locale: &Locale,
344    ) -> CompileReport {
345        let outcome = crate::compile_with_overlay_outcome(
346            source,
347            main_path,
348            root,
349            &std::collections::BTreeMap::new(),
350        );
351        let catalog = self.catalog.identity();
352        let compiler = CompilerIdentity::current();
353        let frontend_diagnostics = outcome
354            .diagnostics
355            .iter()
356            .map(compile_frontend_diagnostic)
357            .collect::<Vec<_>>();
358        let Some(hir) = outcome.hir else {
359            return CompileReport::failure(
360                compiler,
361                catalog,
362                CompileFailureClass::Frontend,
363                frontend_diagnostics,
364            );
365        };
366
367        match self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale) {
368            Ok(artifact) => {
369                CompileReport::success(compiler, catalog, artifact, frontend_diagnostics)
370            }
371            Err(error) => {
372                let mut diagnostics = frontend_diagnostics;
373                diagnostics.push(compile_diagnostic(error, &hir.files));
374                CompileReport::failure(
375                    compiler,
376                    catalog,
377                    CompileFailureClass::Integration,
378                    diagnostics,
379                )
380            }
381        }
382    }
383
384    fn compile_source_internal(
385        &self,
386        source: &str,
387        main_path: &str,
388        root: &std::path::Path,
389        locale: &Locale,
390    ) -> Result<CompilationArtifact, IntegrationError> {
391        let outcome = crate::compile_with_overlay_outcome(
392            source,
393            main_path,
394            root,
395            &std::collections::BTreeMap::new(),
396        );
397        let hir = outcome.hir.ok_or_else(|| {
398            let error = outcome
399                .error
400                .expect("failed frontend compile has diagnostic");
401            IntegrationError::new(
402                error.code,
403                error.message,
404                error.span.map(hir_span_from_diag),
405            )
406        })?;
407        self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale)
408    }
409}
410
411impl CompileReport {
412    fn success(
413        compiler: CompilerIdentity,
414        catalog: CatalogIdentity,
415        artifact: CompilationArtifact,
416        diagnostics: Vec<CompileDiagnostic>,
417    ) -> Self {
418        Self {
419            schema_version: COMPILE_SCHEMA_VERSION,
420            compiler,
421            catalog,
422            compile: CompileResult {
423                status: CompileStatus::Success,
424                exit_code: 0,
425                failure_class: None,
426                diagnostics,
427                stdout: String::new(),
428                workshop_exact: artifact.final_output.clone(),
429                workshop: normalize_workshop(&artifact.final_output),
430            },
431        }
432    }
433
434    fn failure(
435        compiler: CompilerIdentity,
436        catalog: CatalogIdentity,
437        failure_class: CompileFailureClass,
438        diagnostics: Vec<CompileDiagnostic>,
439    ) -> Self {
440        Self {
441            schema_version: COMPILE_SCHEMA_VERSION,
442            compiler,
443            catalog,
444            compile: CompileResult {
445                status: CompileStatus::Failure,
446                exit_code: 1,
447                failure_class: Some(failure_class),
448                diagnostics,
449                stdout: String::new(),
450                workshop_exact: String::new(),
451                workshop: String::new(),
452            },
453        }
454    }
455}
456
457fn compile_diagnostic(error: IntegrationError, files: &[hir::SourceFile]) -> CompileDiagnostic {
458    let diagnostic = error.diagnostic;
459    CompileDiagnostic {
460        severity: crate::tooling::DiagnosticSeverity::Error,
461        code: diagnostic.code,
462        message: diagnostic.message,
463        span: diagnostic
464            .span
465            .and_then(|span| source_location_from_hir(span, files)),
466        script: diagnostic.script.map(|script| *script),
467    }
468}
469
470fn compile_frontend_diagnostic(diagnostic: &crate::tooling::Diagnostic) -> CompileDiagnostic {
471    CompileDiagnostic {
472        severity: diagnostic.severity,
473        code: diagnostic.code.clone(),
474        message: diagnostic.message.clone(),
475        span: diagnostic.span.clone(),
476        script: None,
477    }
478}
479
480fn source_location_from_hir(
481    span: HirSpan,
482    files: &[hir::SourceFile],
483) -> Option<crate::tooling::SourceLocation> {
484    let path = files.iter().find(|file| file.id == span.file)?.path.clone();
485    Some(crate::tooling::SourceLocation {
486        file_id: span.file,
487        path,
488        start: crate::diag::Position::new(span.start.line, span.start.col),
489        end: crate::diag::Position::new(span.end.line, span.end.col),
490    })
491}
492
493fn normalize_workshop(text: &str) -> String {
494    if text.is_empty() {
495        return String::new();
496    }
497    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
498    let mut lines = normalized
499        .split('\n')
500        .map(|line| line.trim_end_matches([' ', '\t']).to_owned())
501        .collect::<Vec<_>>();
502    while lines.last().is_some_and(|line| line.is_empty()) {
503        lines.pop();
504    }
505    if lines.is_empty() {
506        String::new()
507    } else {
508        lines.join("\n") + "\n"
509    }
510}
511
512/// A source compile result for ordinary embedding callers.
513///
514/// The result contains only emitted text and hook output. Callers that need
515/// canonical WIR should use the explicit advanced artifact APIs instead.
516#[derive(Debug, Clone, PartialEq, Eq)]
517pub struct CompileOutput {
518    /// Workshop text after a declared post-compile hook, if any.
519    pub workshop: String,
520    /// Workshop text emitted before a declared post-compile hook.
521    pub emitted_workshop: String,
522    /// Console lines captured while running a declared post-compile hook.
523    pub hook_console_output: Vec<String>,
524}
525
526/// A validated WIR program and its emitted Workshop artifact for advanced
527/// integrations.
528pub struct CompilationArtifact {
529    pub wir: Program,
530    pub emitted: String,
531    pub catalog_identity: CatalogIdentity,
532    pub final_output: String,
533    pub hook_console_output: Vec<String>,
534}
535
536impl CompilationArtifact {
537    fn into_output(self) -> CompileOutput {
538        CompileOutput {
539            workshop: self.final_output,
540            emitted_workshop: self.emitted,
541            hook_console_output: self.hook_console_output,
542        }
543    }
544}
545
546fn hir_span_from_diag(span: crate::diag::Span) -> HirSpan {
547    HirSpan {
548        file: span.file,
549        start: hir::Position {
550            line: span.start.line,
551            col: span.start.col,
552        },
553        end: hir::Position {
554            line: span.end.line,
555            col: span.end.col,
556        },
557    }
558}
559
560fn workshop_error_span(error: &workshop_rs::WorkshopError) -> Option<WorkshopSpan> {
561    match error {
562        workshop_rs::WorkshopError::Unknown { span, .. }
563        | workshop_rs::WorkshopError::Malformed { span, .. }
564        | workshop_rs::WorkshopError::Unsupported { span, .. } => *span,
565        workshop_rs::WorkshopError::Catalog(_)
566        | workshop_rs::WorkshopError::MissingMapping { .. } => None,
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::integration::cross_check_manifest;
573    use super::{COMPILE_SCHEMA_VERSION, CompileFailureClass, CompileStatus, Compiler};
574    use crate::manifest::Manifest;
575    use std::path::Path;
576    use workshop_rs::catalog::{Catalog, Locale};
577
578    #[test]
579    fn catalog_links_are_checked() {
580        let compiler = Compiler::new().expect("Workshop contract must load");
581        let identity = compiler.catalog_identity();
582        assert!(!identity.implementation_version.is_empty());
583        assert!(compiler.link_report().catalog_ids_checked > 0);
584        assert!(compiler.link_report().domains_checked > 0);
585    }
586
587    #[test]
588    fn compile_report_is_versioned_and_contains_reproducibility_identity() {
589        let compiler = Compiler::new().unwrap();
590        let report = compiler.compile_source_report_with_locale(
591            "rule \"report\":\n    @Event global\n    disableInspector()\n",
592            "report.opy",
593            Path::new("."),
594            &Locale::new("en-US"),
595        );
596        assert_eq!(report.schema_version, COMPILE_SCHEMA_VERSION);
597        assert_eq!(report.compiler.name, "opy-rs");
598        assert_eq!(report.catalog, compiler.catalog_identity());
599        assert_eq!(report.compile.status, CompileStatus::Success);
600        assert_eq!(report.compile.exit_code, 0);
601        assert!(report.compile.diagnostics.is_empty());
602        assert_eq!(
603            report.compile.workshop,
604            report
605                .compile
606                .workshop_exact
607                .trim_end_matches('\n')
608                .to_owned()
609                + "\n"
610        );
611        assert!(serde_json::to_value(report).unwrap()["catalog"]["catalog-version"].is_string());
612    }
613
614    #[test]
615    fn compile_report_preserves_frontend_failure_class_and_source_path() {
616        let compiler = Compiler::new().unwrap();
617        let report = compiler.compile_source_report_with_locale(
618            "rule \"broken\":\n    @Event global\n    missing()\n",
619            "broken.opy",
620            Path::new("."),
621            &Locale::new("en-US"),
622        );
623        assert_eq!(report.compile.status, CompileStatus::Failure);
624        assert_eq!(
625            report.compile.failure_class,
626            Some(CompileFailureClass::Frontend)
627        );
628        assert_eq!(report.compile.exit_code, 1);
629        let diagnostic = &report.compile.diagnostics[0];
630        assert_eq!(diagnostic.code, "unknown-action");
631        assert_eq!(diagnostic.span.as_ref().unwrap().path, "broken.opy");
632    }
633
634    #[test]
635    fn compile_report_preserves_integration_failure_class_and_source_path() {
636        let compiler = Compiler::new().unwrap();
637        let report = compiler.compile_source_report_with_locale(
638            "rule \"broken\":\n    @Event global\n    {\"a\": 1}[\"b\"] = 3\n",
639            "broken.opy",
640            Path::new("."),
641            &Locale::new("en-US"),
642        );
643        assert_eq!(report.compile.status, CompileStatus::Failure);
644        assert_eq!(
645            report.compile.failure_class,
646            Some(CompileFailureClass::Integration)
647        );
648        assert_eq!(
649            report.compile.diagnostics[0].span.as_ref().unwrap().path,
650            "broken.opy"
651        );
652    }
653
654    #[test]
655    fn compile_report_preserves_frontend_warnings_on_integration_failure() {
656        let compiler = Compiler::new().unwrap();
657        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
658            .join("tests/fixtures/corpus/synthetic/preprocessing");
659        let report = compiler.compile_source_report_with_locale(
660            concat!(
661                "#!include \"shared.opy\"\n",
662                "#!include \"shared.opy\"\n",
663                "rule \"broken\":\n",
664                "    @Event global\n",
665                "    {\"a\": 1}[\"b\"] = 3\n",
666            ),
667            "broken.opy",
668            &root,
669            &Locale::new("en-US"),
670        );
671        assert_eq!(report.compile.status, CompileStatus::Failure);
672        assert_eq!(
673            report.compile.failure_class,
674            Some(CompileFailureClass::Integration)
675        );
676        assert_eq!(report.compile.diagnostics.len(), 2);
677        assert_eq!(
678            report.compile.diagnostics[0].severity,
679            crate::tooling::DiagnosticSeverity::Warning
680        );
681        assert_eq!(report.compile.diagnostics[0].code, "w_already_imported");
682        assert_eq!(
683            report.compile.diagnostics[1].severity,
684            crate::tooling::DiagnosticSeverity::Error
685        );
686        assert_eq!(
687            report.compile.diagnostics[1].span.as_ref().unwrap().path,
688            "broken.opy"
689        );
690    }
691
692    #[test]
693    fn vertical_slice_preserves_source_files_spans_and_emits_workshop() {
694        let compiler = Compiler::new().unwrap();
695        let hir = crate::compile(
696            "globalvar A\nrule \"issue 35 integration\":\n    @Event global\n    A = 1\n    disableInspector()\n",
697            "compiler-vertical-slice.opy",
698            Path::new("."),
699        )
700        .unwrap();
701        let artifact = compiler.compile_hir(&hir).unwrap();
702        assert_eq!(
703            artifact
704                .wir
705                .files
706                .get(workshop_rs::source::FileId::from_index(0))
707                .unwrap()
708                .path,
709            "compiler-vertical-slice.opy"
710        );
711        let rule = artifact
712            .wir
713            .rules
714            .get(workshop_rs::wir::RuleId::from_index(0))
715            .unwrap();
716        assert_eq!(rule.span.unwrap().file.index(), 0);
717        assert_eq!(rule.name_span.unwrap().start.line, 2);
718        assert!(artifact.emitted.contains("Disable Inspector Recording;"));
719        assert_eq!(artifact.catalog_identity, compiler.catalog_identity());
720    }
721
722    #[test]
723    fn stale_catalog_links_fail_explicitly() {
724        let manifest = Manifest::builtin().unwrap().clone();
725        let mut stale = manifest;
726        stale.functions[0].catalog_id = Some("missing-catalog-id".to_string());
727        let error = cross_check_manifest(&stale, &Catalog::builtin().unwrap()).unwrap_err();
728        assert_eq!(error.diagnostic.code, "catalog-link-missing");
729    }
730
731    #[test]
732    fn while_lowering_is_source_attributed() {
733        let compiler = Compiler::new().unwrap();
734        let hir = crate::compile(
735            "rule \"while\":\n    @Event global\n    while true:\n        disableInspector()\n",
736            "while.opy",
737            Path::new("."),
738        )
739        .unwrap();
740        let artifact = compiler.compile_hir(&hir).unwrap();
741        let rule = artifact
742            .wir
743            .rules
744            .get(workshop_rs::wir::RuleId::from_index(0))
745            .unwrap();
746        assert!(matches!(
747            artifact.wir.actions.get(rule.actions[0]),
748            Some(workshop_rs::wir::Action::While { .. })
749        ));
750        assert!(artifact.emitted.contains("While(True);"));
751    }
752
753    #[test]
754    fn structural_subroutines_lower_to_canonical_wir() {
755        let compiler = Compiler::new().unwrap();
756        let hir = crate::compile(
757            "globalvar score\nsubroutine showStatus\ndef showStatus():\n    @Name \"Friendly\"\n    @SuppressWarnings unusedVariable\n    disableInspector()\nrule \"caller\":\n    @Event global\n    showStatus()\n",
758            "structure.opy",
759            Path::new("."),
760        )
761        .unwrap();
762        let artifact = compiler.compile_hir(&hir).unwrap();
763        let subroutine = artifact
764            .wir
765            .subroutines
766            .get(workshop_rs::wir::SubroutineId::from_index(0))
767            .unwrap();
768        assert_eq!(subroutine.name, "showStatus");
769        assert_eq!(subroutine.index, 0);
770        assert_eq!(subroutine.name_span.unwrap().start.line, 2);
771        assert_eq!(artifact.wir.rules.len(), 2);
772        let subroutine_rule = artifact
773            .wir
774            .rules
775            .get(workshop_rs::wir::RuleId::from_index(0))
776            .unwrap();
777        let workshop_rs::wir::Event::Subroutine(subroutine_id) = subroutine_rule.event else {
778            panic!("expected a subroutine event");
779        };
780        assert_eq!(
781            artifact.wir.subroutines.get(subroutine_id).unwrap().name,
782            "showStatus"
783        );
784        assert!(matches!(
785            artifact
786                .wir
787                .actions
788                .get(workshop_rs::wir::ActionId::from_index(1))
789                .unwrap(),
790            workshop_rs::wir::Action::CallSubroutine { .. }
791        ));
792        assert!(artifact.emitted.contains("Subroutine Friendly"));
793    }
794
795    #[test]
796    fn player_event_filters_resolve_through_canonical_catalog() {
797        let compiler = Compiler::new().unwrap();
798        let hir = crate::compile(
799            "rule \"joined\":\n    @Event playerJoined\n    @Team 1\n    @Slot 2\n    disableInspector()\n",
800            "filters.opy",
801            Path::new("."),
802        )
803        .unwrap();
804        let artifact = compiler.compile_hir(&hir).unwrap();
805        assert!(matches!(
806            &artifact
807                .wir
808                .rules
809                .get(workshop_rs::wir::RuleId::from_index(0))
810                .unwrap()
811                .event,
812            workshop_rs::wir::Event::Player {
813                kind: workshop_rs::wir::PlayerEventKind::Joined,
814                team: workshop_rs::wir::EventTeam::Team1,
815                target: workshop_rs::wir::EventTarget::Slot(2),
816            }
817        ));
818        assert!(artifact.emitted.contains("Player Joined Match;"));
819    }
820
821    #[test]
822    fn hero_event_filters_accept_legacy_aliases() {
823        let compiler = Compiler::new().unwrap();
824        let hir = crate::compile(
825            "rule \"hero\":\n    @Event eachPlayer\n    @Hero soldier\n    disableInspector()\n",
826            "hero-filter.opy",
827            Path::new("."),
828        )
829        .unwrap();
830        let artifact = compiler.compile_hir(&hir).unwrap();
831        assert!(matches!(
832            &artifact
833                .wir
834                .rules
835                .get(workshop_rs::wir::RuleId::from_index(0))
836                .unwrap()
837                .event,
838            workshop_rs::wir::Event::EachPlayerWithFilters {
839                target: workshop_rs::wir::EventTarget::Hero(hero),
840                ..
841            } if hero == "SOLDIER_76"
842        ));
843    }
844
845    #[test]
846    fn explicit_indices_are_reserved_before_deterministic_allocation() {
847        let compiler = Compiler::new().unwrap();
848        let hir = crate::compile(
849            "globalvar first\nglobalvar reserved 0\nglobalvar next\nrule \"indices\":\n    @Event global\n    disableInspector()\n",
850            "indices.opy",
851            Path::new("."),
852        )
853        .unwrap();
854        let artifact = compiler.compile_hir(&hir).unwrap();
855        let by_name = artifact
856            .wir
857            .global_variables
858            .iter()
859            .map(|variable| (variable.name.as_str(), variable.index))
860            .collect::<std::collections::BTreeMap<_, _>>();
861        assert_eq!(
862            by_name,
863            std::collections::BTreeMap::from([("first", 1), ("reserved", 0), ("next", 2)])
864        );
865        // Variable tables are emitted in Workshop index order.
866        let indices = artifact
867            .wir
868            .global_variables
869            .iter()
870            .map(|variable| variable.index)
871            .collect::<Vec<_>>();
872        assert_eq!(indices, vec![0, 1, 2]);
873    }
874
875    #[test]
876    fn implicit_default_variables_use_reference_fixed_slots() {
877        let compiler = Compiler::new().unwrap();
878        let hir = crate::compile(
879            r#"
880globalvar timer
881globalvar extra 5
882
883rule "implicit":
884    @Event global
885    A = timer + 1
886    B = A
887    B += 2
888    A[0] = 7
889    DX = B * A
890"#,
891            "implicit.opy",
892            Path::new("."),
893        )
894        .unwrap();
895        let artifact = compiler.compile_hir(&hir).unwrap();
896        let globals = artifact
897            .wir
898            .global_variables
899            .iter()
900            .map(|variable| (variable.name.clone(), variable.index))
901            .collect::<Vec<_>>();
902        // The implicit A (0), B (1), and DX (127) names keep their fixed
903        // Workshop slots and reserve them for declared-variable allocation
904        // (pinned OverPy evidence); `timer` auto-allocates around them and
905        // `extra` keeps its explicit index.
906        assert_eq!(
907            globals,
908            vec![
909                ("A".to_string(), 0),
910                ("B".to_string(), 1),
911                ("timer".to_string(), 2),
912                ("extra".to_string(), 5),
913                ("DX".to_string(), 127),
914            ]
915        );
916        assert!(
917            artifact
918                .emitted
919                .contains("Set Global Variable(A, Add(Global.timer, 1));")
920        );
921        assert!(
922            artifact
923                .emitted
924                .contains("Set Global Variable(B, Global.A);")
925        );
926        assert!(
927            artifact
928                .emitted
929                .contains("Modify Global Variable(B, Add, 2);")
930        );
931        assert!(
932            artifact
933                .emitted
934                .contains("Set Global Variable At Index(A, 0, 7);")
935        );
936        assert!(
937            artifact
938                .emitted
939                .contains("Set Global Variable(DX, Multiply(Global.B, Global.A));")
940        );
941    }
942
943    #[test]
944    fn implicit_default_variable_slot_collision_is_source_attributed() {
945        let compiler = Compiler::new().unwrap();
946        let hir = crate::compile(
947            "globalvar x 0\nrule \"collision\":\n    @Event global\n    x = 1\n    A = 2\n",
948            "collision.opy",
949            Path::new("."),
950        )
951        .unwrap();
952        let error = match compiler.compile_hir(&hir) {
953            Ok(_) => panic!("slot collision unexpectedly succeeded"),
954            Err(error) => error,
955        };
956        assert_eq!(error.diagnostic.code, "index-collision");
957        assert_eq!(error.diagnostic.span.unwrap().start.line, 5);
958        assert!(error.diagnostic.message.contains("'A' and 'x'"));
959    }
960
961    #[test]
962    fn implicit_default_player_variables_use_independent_reference_slots() {
963        let compiler = Compiler::new().unwrap();
964        let hir = crate::compile(
965            r#"
966playervar declaredPlayer
967
968rule "implicit player variables":
969    @Event eachPlayer
970    A = 1
971    eventPlayer.A = 1
972    eventPlayer.A += 2
973    eventPlayer.E = eventPlayer.A
974    eventPlayer.DX = eventPlayer.E
975    eventPlayer.declaredPlayer = eventPlayer.A
976"#,
977            "implicit-player.opy",
978            Path::new("."),
979        )
980        .unwrap();
981        let artifact = compiler.compile_hir(&hir).unwrap();
982        let globals = artifact
983            .wir
984            .global_variables
985            .iter()
986            .map(|variable| (variable.name.as_str(), variable.index))
987            .collect::<std::collections::BTreeMap<_, _>>();
988        let players = artifact
989            .wir
990            .player_variables
991            .iter()
992            .map(|variable| (variable.name.as_str(), variable.index))
993            .collect::<std::collections::BTreeMap<_, _>>();
994        assert_eq!(globals.get("A"), Some(&0));
995        assert_eq!(players.get("A"), Some(&0));
996        assert_eq!(players.get("declaredPlayer"), Some(&1));
997        assert_eq!(players.get("E"), Some(&4));
998        assert_eq!(players.get("DX"), Some(&127));
999        assert!(
1000            artifact
1001                .emitted
1002                .contains("Set Player Variable(Event Player, A, 1);")
1003        );
1004        assert!(
1005            artifact
1006                .emitted
1007                .contains("Modify Player Variable(Event Player, A, Add, 2);")
1008        );
1009        assert!(
1010            artifact
1011                .emitted
1012                .contains("Set Player Variable(Event Player, E, (Event Player).A);")
1013        );
1014    }
1015
1016    #[test]
1017    fn implicit_default_player_slot_collision_is_source_attributed() {
1018        let compiler = Compiler::new().unwrap();
1019        let hir = crate::compile(
1020            "playervar declared 0\nrule \"collision\":\n    @Event eachPlayer\n    eventPlayer.A = 1\n",
1021            "player-collision.opy",
1022            Path::new("."),
1023        )
1024        .unwrap();
1025        let error = match compiler.compile_hir(&hir) {
1026            Ok(_) => panic!("player slot collision unexpectedly succeeded"),
1027            Err(error) => error,
1028        };
1029        assert_eq!(error.diagnostic.code, "index-collision");
1030        assert!(
1031            error
1032                .diagnostic
1033                .message
1034                .contains("player variables 'A' and 'declared'")
1035        );
1036        assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
1037    }
1038
1039    #[test]
1040    fn power_augmented_assignment_lowers_from_source() {
1041        let compiler = Compiler::new().unwrap();
1042        let hir = crate::compile(
1043            "globalvar g\nrule \"power\":\n    @Event global\n    g = 2\n    g **= 3\n",
1044            "power.opy",
1045            Path::new("."),
1046        )
1047        .unwrap();
1048        let artifact = compiler.compile_hir(&hir).unwrap();
1049        assert!(artifact.emitted.contains("Set Global Variable(g, 2);"));
1050        assert!(
1051            artifact
1052                .emitted
1053                .contains("Modify Global Variable(g, Raise To Power, 3);")
1054        );
1055    }
1056
1057    #[test]
1058    fn opy_hex_numbers_are_normalized_at_the_wir_boundary() {
1059        let compiler = Compiler::new().unwrap();
1060        let hir = crate::compile(
1061            "globalvar large = 0x124BC\nglobalvar small = 0x124\nglobalvar scientific = 1e10\n",
1062            "numbers.opy",
1063            Path::new("."),
1064        )
1065        .unwrap();
1066        let artifact = compiler.compile_hir(&hir).unwrap();
1067        assert!(
1068            artifact
1069                .emitted
1070                .contains("Set Global Variable(large, 74940);")
1071        );
1072        assert!(
1073            artifact
1074                .emitted
1075                .contains("Set Global Variable(small, 292);")
1076        );
1077        assert!(
1078            artifact
1079                .emitted
1080                .contains("Set Global Variable(scientific, 1e10);")
1081        );
1082        assert!(!artifact.emitted.contains("0x124BC"));
1083        assert!(!artifact.emitted.contains("0x124"));
1084    }
1085
1086    #[test]
1087    fn literal_dict_lookup_lowers_to_the_selected_value() {
1088        let compiler = Compiler::new().unwrap();
1089        let hir = crate::compile(
1090            "globalvar total\nrule \"negative\":\n    @Event global\n    total = {\"a\": 1, \"b\": 2}[\"a\"]\n",
1091            "negative.opy",
1092            Path::new("."),
1093        )
1094        .unwrap();
1095        let artifact = compiler
1096            .compile_hir(&hir)
1097            .expect("literal dict lookup should lower");
1098        assert!(artifact.emitted.contains("Set Global Variable(total, 1);"));
1099    }
1100
1101    #[test]
1102    fn auto_allocation_fills_free_slots_below_early_explicit_indices() {
1103        let compiler = Compiler::new().unwrap();
1104        let hir = crate::compile(
1105            r#"
1106globalvar reserved 5
1107globalvar auto1
1108globalvar auto2
1109
1110rule "allocation":
1111    @Event global
1112    auto1 = 1
1113    auto2 = 2
1114    B = 3
1115"#,
1116            "allocation.opy",
1117            Path::new("."),
1118        )
1119        .unwrap();
1120        let artifact = compiler.compile_hir(&hir).unwrap();
1121        let by_name = artifact
1122            .wir
1123            .global_variables
1124            .iter()
1125            .map(|variable| (variable.name.clone(), variable.index))
1126            .collect::<std::collections::BTreeMap<_, _>>();
1127        // The implicit B keeps its fixed slot 1; the auto-allocated variables
1128        // fill the remaining free slots below the explicit 5 instead of
1129        // jumping past it, matching the pinned OverPy oracle (slot 0 stays
1130        // free here because the implicit A is never used).
1131        assert_eq!(
1132            by_name,
1133            std::collections::BTreeMap::from([
1134                ("B".to_string(), 1),
1135                ("auto1".to_string(), 0),
1136                ("auto2".to_string(), 2),
1137                ("reserved".to_string(), 5),
1138            ])
1139        );
1140    }
1141
1142    #[test]
1143    fn power_expressions_lower_through_the_canonical_contract() {
1144        let compiler = Compiler::new().unwrap();
1145        let hir = crate::compile(
1146            "globalvar a = [2, 4]\nglobalvar out\nrule \"power\":\n    @Event global\n    out = a ** 2\n    a **= 2\n    a[0] **= 2\n",
1147            "power.opy",
1148            Path::new("."),
1149        )
1150        .unwrap();
1151        let artifact = compiler.compile_hir(&hir).unwrap();
1152        assert!(
1153            artifact
1154                .emitted
1155                .contains("Set Global Variable(out, Raise To Power(Global.a, 2));")
1156        );
1157        assert!(
1158            artifact
1159                .emitted
1160                .contains("Modify Global Variable(a, Raise To Power, 2);")
1161        );
1162        assert!(
1163            artifact
1164                .emitted
1165                .contains("Modify Global Variable At Index(a, 0, Raise To Power, 2);")
1166        );
1167    }
1168
1169    #[test]
1170    fn unsupported_rule_metadata_is_explicit_and_source_attributed() {
1171        let compiler = Compiler::new().unwrap();
1172        let hir = crate::compile(
1173            "rule \"metadata\":\n    @Event global\n    @NewPage \"section\"\n    disableInspector()\n",
1174            "metadata.opy",
1175            Path::new("."),
1176        )
1177        .unwrap();
1178        let error = match compiler.compile_hir(&hir) {
1179            Ok(_) => panic!("unsupported metadata unexpectedly succeeded"),
1180            Err(error) => error,
1181        };
1182        assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
1183        assert_eq!(error.diagnostic.span.unwrap().start.line, 3);
1184    }
1185
1186    #[test]
1187    fn compiler_structure_matches_the_pinned_oracle() {
1188        let compiler = Compiler::new().unwrap();
1189        let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
1190            .join("tests/fixtures/corpus/synthetic/compiler-structure");
1191        let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
1192        let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
1193        let artifact = compiler.compile_hir(&hir).unwrap();
1194        let oracle: serde_json::Value =
1195            serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
1196                .unwrap();
1197        let oracle_workshop = oracle["compile"]["workshop"].as_str().unwrap();
1198        let oracle_wir = workshop_rs::parser::parse(
1199            oracle_workshop,
1200            &Catalog::builtin().unwrap(),
1201            &Locale::new("en-US"),
1202        )
1203        .unwrap();
1204        assert!(workshop_rs::roundtrip::equivalent(
1205            &artifact.wir,
1206            &oracle_wir
1207        ));
1208
1209        assert!(oracle_workshop.contains("0: reserved"));
1210        assert!(oracle_workshop.contains("1: first"));
1211        assert!(oracle_workshop.contains("2: explicit"));
1212        assert!(oracle_workshop.contains("3: next"));
1213        assert!(oracle_workshop.contains("0: helper"));
1214        assert!(oracle_workshop.contains("Subroutine;\n        helper;"));
1215        assert!(oracle_workshop.contains("Player Joined Match;\n        Team 1;\n        Slot 2;"));
1216
1217        let indices = artifact
1218            .wir
1219            .global_variables
1220            .iter()
1221            .map(|variable| variable.index)
1222            .collect::<Vec<_>>();
1223        assert_eq!(indices, vec![0, 1, 2, 3]);
1224        assert_eq!(
1225            artifact.wir.subroutines.iter().next().unwrap().name,
1226            "helper"
1227        );
1228        assert!(artifact.emitted.contains("[Source] renamed helper"));
1229        assert!(matches!(
1230            artifact
1231                .wir
1232                .rules
1233                .get(workshop_rs::wir::RuleId::from_index(1))
1234                .unwrap()
1235                .event,
1236            workshop_rs::wir::Event::Player {
1237                kind: workshop_rs::wir::PlayerEventKind::Joined,
1238                team: workshop_rs::wir::EventTeam::Team1,
1239                target: workshop_rs::wir::EventTarget::Slot(2),
1240            }
1241        ));
1242    }
1243
1244    #[test]
1245    fn assignments_and_modifications_lower_to_canonical_wir() {
1246        let compiler = Compiler::new().unwrap();
1247        let hir = crate::compile(
1248            r#"
1249globalvar g1
1250globalvar g2
1251playervar p1
1252playervar p2 = [1, 2, 3]
1253
1254rule "assignments":
1255    @Event eachPlayer
1256    g1 = 10
1257    g1 += 5
1258    g1 -= 2
1259    g1 *= 3
1260    g1 /= 2
1261    g1 %= 4
1262    g2 = [1, 2, 3]
1263    g2[0] = 99
1264    g2[1] += 1
1265    eventPlayer.p1 = 42
1266    eventPlayer.p1 += 8
1267    eventPlayer.p1 *= 2
1268    eventPlayer.p2[2] = 7
1269    eventPlayer.p2[0] -= 3
1270"#,
1271            "assign.opy",
1272            Path::new("."),
1273        )
1274        .unwrap();
1275        let artifact = compiler.compile_hir(&hir).unwrap();
1276        assert!(artifact.emitted.contains("Set Global Variable(g1, 10);"));
1277        assert!(
1278            artifact
1279                .emitted
1280                .contains("Modify Global Variable(g1, Add, 5);")
1281        );
1282        assert!(
1283            artifact
1284                .emitted
1285                .contains("Modify Global Variable(g1, Subtract, 2);")
1286        );
1287        assert!(
1288            artifact
1289                .emitted
1290                .contains("Modify Global Variable(g1, Multiply, 3);")
1291        );
1292        assert!(
1293            artifact
1294                .emitted
1295                .contains("Modify Global Variable(g1, Divide, 2);")
1296        );
1297        assert!(
1298            artifact
1299                .emitted
1300                .contains("Modify Global Variable(g1, Modulo, 4);")
1301        );
1302        assert!(
1303            artifact
1304                .emitted
1305                .contains("Set Global Variable At Index(g2, 0, 99);")
1306        );
1307        assert!(
1308            artifact
1309                .emitted
1310                .contains("Modify Global Variable At Index(g2, 1, Add, 1);")
1311        );
1312        assert!(
1313            artifact
1314                .emitted
1315                .contains("Set Player Variable(Event Player, p1, 42);")
1316        );
1317        assert!(
1318            artifact
1319                .emitted
1320                .contains("Modify Player Variable(Event Player, p1, Add, 8);")
1321        );
1322        assert!(
1323            artifact
1324                .emitted
1325                .contains("Modify Player Variable(Event Player, p1, Multiply, 2);")
1326        );
1327        assert!(
1328            artifact
1329                .emitted
1330                .contains("Set Player Variable At Index((Event Player).p2, 2, 7);")
1331        );
1332        assert!(
1333            artifact
1334                .emitted
1335                .contains("Modify Player Variable At Index((Event Player).p2, 0, Subtract, 3);")
1336        );
1337
1338        // Direct assignments carry both the statement span and the separate
1339        // target-variable span; indexed forms lower to Call actions that
1340        // carry only the statement span.
1341        let rule = artifact
1342            .wir
1343            .rules
1344            .get(workshop_rs::wir::RuleId::from_index(1))
1345            .unwrap();
1346        let direct = artifact.wir.actions.get(rule.actions[0]).unwrap();
1347        match direct {
1348            workshop_rs::wir::Action::SetGlobalVariable {
1349                span,
1350                target_span,
1351                variable,
1352                ..
1353            } => {
1354                assert_eq!(span.unwrap().start.line, 9);
1355                assert_eq!(target_span.unwrap().start.line, 9);
1356                assert_eq!(
1357                    artifact.wir.global_variables.get(*variable).unwrap().name,
1358                    "g1"
1359                );
1360            }
1361            other => panic!("expected a direct global assignment, got {other:?}"),
1362        }
1363        let indexed = artifact.wir.actions.get(rule.actions[7]).unwrap();
1364        match indexed {
1365            workshop_rs::wir::Action::Call { span, .. } => {
1366                assert_eq!(span.unwrap().start.line, 16);
1367            }
1368            other => panic!("expected an indexed assignment call, got {other:?}"),
1369        }
1370    }
1371
1372    #[test]
1373    fn expressions_and_values_lower_to_canonical_wir() {
1374        let compiler = Compiler::new().unwrap();
1375        let hir = crate::compile(
1376            r#"
1377enum Consts:
1378    BASE
1379
1380globalvar total
1381globalvar arr = [1, 2, 3]
1382globalvar pos = vect(1, 2, 3)
1383
1384rule "expressions":
1385    @Event global
1386    @Condition total == 0
1387    @Condition not (pos == vect(0, 0, 0))
1388    @Condition 2 in arr
1389    total = Consts.BASE + arr[1] * 2 - (10 / 2) + (5 % 2)
1390    print("Total: {}".format(total))
1391    debug(pos)
1392"#,
1393            "expr.opy",
1394            Path::new("."),
1395        )
1396        .unwrap();
1397        let artifact = compiler.compile_hir(&hir).unwrap();
1398        assert!(artifact.emitted.contains("Global.total == 0;"));
1399        // `not (pos == vect(0, 0, 0))` lowers to the negated comparison,
1400        // mirroring the pinned OverPy oracle.
1401        assert!(artifact.emitted.contains("Global.pos != Vector(0, 0, 0);"));
1402        assert!(
1403            artifact
1404                .emitted
1405                .contains("Array Contains(Global.arr, 2) == True;")
1406        );
1407        assert!(
1408            artifact
1409                .emitted
1410                .contains("Custom String(\"Total: {0}\", Global.total)")
1411        );
1412    }
1413
1414    #[test]
1415    fn pass_is_supported_as_source_level_noop() {
1416        let compiler = Compiler::new().unwrap();
1417        let hir = crate::compile(
1418            r#"
1419subroutine emptySub
1420
1421def emptySub():
1422    pass
1423
1424rule "empty rule":
1425    @Event global
1426    pass
1427"#,
1428            "pass.opy",
1429            Path::new("."),
1430        )
1431        .unwrap();
1432        let artifact = compiler.compile_hir(&hir).unwrap();
1433        let rule0 = artifact
1434            .wir
1435            .rules
1436            .get(workshop_rs::wir::RuleId::from_index(0))
1437            .unwrap();
1438        assert!(rule0.actions.is_empty());
1439        let rule1 = artifact
1440            .wir
1441            .rules
1442            .get(workshop_rs::wir::RuleId::from_index(1))
1443            .unwrap();
1444        assert!(rule1.actions.is_empty());
1445    }
1446
1447    #[test]
1448    fn variable_initializers_synthesize_initialize_rules() {
1449        let compiler = Compiler::new().unwrap();
1450        let hir = crate::compile(
1451            r#"
1452globalvar j = 5
1453globalvar h = 0
1454globalvar k = 0.0
1455playervar p = 7
1456playervar q = 0
1457
1458rule "main":
1459    @Event global
1460    disableInspector()
1461"#,
1462            "init.opy",
1463            Path::new("."),
1464        )
1465        .unwrap();
1466        let artifact = compiler.compile_hir(&hir).unwrap();
1467        assert_eq!(
1468            artifact
1469                .wir
1470                .rules
1471                .get(workshop_rs::wir::RuleId::from_index(0))
1472                .unwrap()
1473                .name,
1474            "Initialize global variables"
1475        );
1476        assert_eq!(
1477            artifact
1478                .wir
1479                .rules
1480                .get(workshop_rs::wir::RuleId::from_index(1))
1481                .unwrap()
1482                .name,
1483            "Initialize player variables"
1484        );
1485        assert_eq!(
1486            artifact
1487                .wir
1488                .rules
1489                .get(workshop_rs::wir::RuleId::from_index(2))
1490                .unwrap()
1491                .name,
1492            "main"
1493        );
1494        assert!(artifact.emitted.contains("Set Global Variable(j, 5);"));
1495        assert!(artifact.emitted.contains("Set Global Variable(k, 0.0);"));
1496        assert!(!artifact.emitted.contains("Set Global Variable(h,"));
1497        assert!(
1498            artifact
1499                .emitted
1500                .contains("Set Player Variable(Event Player, p, 7);")
1501        );
1502        assert!(
1503            !artifact
1504                .emitted
1505                .contains("Set Player Variable(Event Player, q,")
1506        );
1507    }
1508
1509    #[test]
1510    fn settings_lower_through_workshop_owned_emission() {
1511        let compiler = Compiler::new().unwrap();
1512        let fixture =
1513            Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/corpus/synthetic/settings");
1514        let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
1515        let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
1516        let artifact = compiler.compile_hir(&hir).unwrap();
1517        let oracle: serde_json::Value =
1518            serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
1519                .unwrap();
1520        let expected = oracle["compile"]["workshop"]
1521            .as_str()
1522            .unwrap()
1523            .split("\n\nrule")
1524            .next()
1525            .unwrap();
1526        let actual = artifact.emitted.split("\n\nrule").next().unwrap();
1527        let oracle_wir = workshop_rs::parser::parse(
1528            oracle["compile"]["workshop"].as_str().unwrap(),
1529            &Catalog::builtin().unwrap(),
1530            &Locale::new("en-US"),
1531        )
1532        .unwrap();
1533        assert!(workshop_rs::roundtrip::equivalent(
1534            &artifact.wir,
1535            &oracle_wir
1536        ));
1537        assert_eq!(
1538            normalize_workshop_structural_whitespace(actual),
1539            normalize_workshop_structural_whitespace(expected)
1540        );
1541    }
1542
1543    #[test]
1544    fn unsupported_locale_has_no_fabricated_source_span() {
1545        let compiler = Compiler::new().unwrap();
1546        let hir = crate::compile(
1547            "#!translations en\nrule \"r\":\n    @Event global\n    pass\n",
1548            "locale.opy",
1549            Path::new("."),
1550        )
1551        .unwrap();
1552        let error = match compiler.compile_hir_with_locale(&hir, &Locale::new("xx-XX")) {
1553            Ok(_) => panic!("unsupported locale unexpectedly compiled"),
1554            Err(error) => error,
1555        };
1556        assert_eq!(error.diagnostic.code, "locale-unsupported");
1557        assert_eq!(error.diagnostic.span, None);
1558    }
1559
1560    #[test]
1561    fn locale_selection_emits_catalog_localized_workshop() {
1562        let compiler = Compiler::new().unwrap();
1563        let hir = crate::compile(
1564            "rule \"locale\":\n    @Event global\n    disableInspector()\n",
1565            "locale.opy",
1566            Path::new("."),
1567        )
1568        .unwrap();
1569        let artifact = compiler
1570            .compile_hir_with_locale(&hir, &Locale::new("zh-CN"))
1571            .unwrap();
1572        assert!(artifact.emitted.contains("规则 (\"locale\")"));
1573        assert!(artifact.emitted.contains("禁用查看器录制"));
1574    }
1575
1576    #[test]
1577    fn unsupported_backend_directives_fail_at_their_source_anchor() {
1578        let compiler = Compiler::new().unwrap();
1579        let hir = crate::compile(
1580            "#!replace0ByCapturePercentage\nrule \"r\":\n    @Event global\n    pass\n",
1581            "directives.opy",
1582            Path::new("."),
1583        )
1584        .unwrap();
1585        let error = match compiler.compile_hir(&hir) {
1586            Ok(_) => panic!("backend directive unexpectedly compiled"),
1587            Err(error) => error,
1588        };
1589        assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
1590        assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
1591    }
1592
1593    #[test]
1594    fn optimizer_directives_remain_non_blocking_presentation_controls() {
1595        let compiler = Compiler::new().unwrap();
1596        let hir = crate::compile(
1597            "#!disableOptimizations\nrule \"r\":\n    @Event global\n    pass\n",
1598            "optimization.opy",
1599            Path::new("."),
1600        )
1601        .unwrap();
1602        compiler.compile_hir(&hir).unwrap();
1603    }
1604
1605    #[test]
1606    fn replacement_directive_records_are_checked_even_if_final_state_is_restored() {
1607        let compiler = Compiler::new().unwrap();
1608        let mut hir = crate::compile(
1609            "#!replace0ByCapturePercentage\nrule \"r\":\n    @Event global\n    pass\n",
1610            "directives.opy",
1611            Path::new("."),
1612        )
1613        .unwrap();
1614        hir.preprocessing.replacements.clear();
1615        let error = match compiler.compile_hir(&hir) {
1616            Ok(_) => panic!("replacement directive unexpectedly compiled"),
1617            Err(error) => error,
1618        };
1619        assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
1620        assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
1621    }
1622
1623    #[test]
1624    fn active_replacement_state_is_checked_without_directive_history() {
1625        let compiler = Compiler::new().unwrap();
1626        let mut hir = crate::compile(
1627            "#!replace0ByCapturePercentage\nrule \"r\":\n    @Event global\n    pass\n",
1628            "directives.opy",
1629            Path::new("."),
1630        )
1631        .unwrap();
1632        hir.preprocessing.directives.clear();
1633        hir.preprocessing.replacements[0].span = None;
1634        let error = match compiler.compile_hir(&hir) {
1635            Ok(_) => panic!("active replacement state unexpectedly compiled"),
1636            Err(error) => error,
1637        };
1638        assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
1639        assert_eq!(error.diagnostic.span, None);
1640    }
1641
1642    #[test]
1643    fn post_compile_hook_receives_exact_emitted_workshop() {
1644        let compiler = Compiler::new().unwrap();
1645        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
1646        let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n    pass\n";
1647        let artifact = compiler
1648            .compile_source_with_locale(source, "hook.opy", &root, &Locale::new("en-US"))
1649            .unwrap();
1650        assert!(artifact.emitted.contains("rule (\"setup\")"));
1651        assert!(artifact.final_output.contains("rule (\"transformed\")"));
1652        assert_ne!(artifact.final_output, artifact.emitted);
1653    }
1654
1655    #[test]
1656    fn post_compile_hook_failure_keeps_script_provenance_and_directive_anchor() {
1657        let compiler = Compiler::new().unwrap();
1658        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
1659        let source = "#!postCompileHook \"hook-boom.js\"\n\nrule \"setup\":\n    pass\n";
1660        let error = match compiler.compile_source_with_locale(
1661            source,
1662            "hook.opy",
1663            &root,
1664            &Locale::new("en-US"),
1665        ) {
1666            Ok(_) => panic!("failing post-compile hook unexpectedly compiled"),
1667            Err(error) => error,
1668        };
1669        assert_eq!(error.diagnostic.code, "post-compile-hook");
1670        assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
1671        let script = error.diagnostic.script.unwrap();
1672        assert_eq!(script.source_name.as_deref(), Some("hook-boom.js"));
1673        assert_eq!(script.line, Some(1));
1674        assert!(script.stack.unwrap().contains("hook-boom.js:1"));
1675    }
1676
1677    fn normalize_workshop_structural_whitespace(text: &str) -> String {
1678        let mut normalized = String::with_capacity(text.len());
1679        let mut quote = None;
1680        let mut escaped = false;
1681        for character in text.chars() {
1682            if let Some(delimiter) = quote {
1683                normalized.push(character);
1684                if escaped {
1685                    escaped = false;
1686                } else if character == '\\' {
1687                    escaped = true;
1688                } else if character == delimiter {
1689                    quote = None;
1690                }
1691            } else if matches!(character, '\"' | '\'') {
1692                quote = Some(character);
1693                normalized.push(character);
1694            } else if !character.is_whitespace() {
1695                normalized.push(character);
1696            }
1697        }
1698        normalized
1699    }
1700
1701    #[test]
1702    fn settings_whitespace_normalization_preserves_quoted_values() {
1703        assert_ne!(
1704            normalize_workshop_structural_whitespace("Description: \"a b\""),
1705            normalize_workshop_structural_whitespace("Description: \"ab\"")
1706        );
1707    }
1708}