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