Skip to main content

opy_rs/compiler/
mod.rs

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