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