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 public `workshop-rs` contract, checks OPY manifest
4//! links against the canonical catalog, and lowers supported OPY program
5//! structure into the canonical Workshop `Program` before validation and
6//! deterministic 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 blizzard_global;
20mod hooks;
21mod integration;
22mod lowering;
23mod operator_optimization;
24mod settings;
25mod size_optimization;
26mod string_format;
27
28pub(crate) use backend::MacroExpander;
29pub(super) use backend::{expand_macros, reject_unlowered_directives};
30pub use integration::LinkReport;
31pub(super) use integration::load_compiler_contract;
32pub(super) use lowering::Lowering;
33
34#[cfg(test)]
35mod integration_tests;
36
37const TRANSLATION_HELPER_NAME: &str = "__overpyTranslationHelper__";
38
39fn has_directive(hir: &hir::Program, name: &str) -> bool {
40    hir.preprocessing
41        .directives
42        .iter()
43        .any(|directive| directive.name == name)
44}
45
46fn omit_declaration_sections(output: &str, program: &workshop_rs::Program) -> String {
47    let mut markers = Vec::new();
48    for variable in program
49        .global_variables
50        .iter()
51        .chain(program.player_variables.iter())
52    {
53        markers.push(variable.index.map_or_else(
54            || variable.name.clone(),
55            |index| format!("{index}: {}", variable.name),
56        ));
57    }
58    markers.extend(program.subroutines.iter().map(|subroutine| {
59        subroutine.index.map_or_else(
60            || subroutine.name.clone(),
61            |index| format!("{index}: {}", subroutine.name),
62        )
63    }));
64    if markers.is_empty() {
65        return output.to_string();
66    }
67
68    let lines: Vec<&str> = output.split_inclusive('\n').collect();
69    let mut removed = vec![false; lines.len()];
70    for (index, line) in lines.iter().enumerate() {
71        if !markers.iter().any(|marker| line.trim() == marker) {
72            continue;
73        }
74        let Some(start) = (0..=index).rev().find(|candidate| {
75            let trimmed = lines[*candidate].trim_end();
76            !trimmed.starts_with(char::is_whitespace) && trimmed.ends_with('{')
77        }) else {
78            continue;
79        };
80        let mut depth = 0usize;
81        let mut end = start;
82        for (candidate, line) in lines.iter().enumerate().skip(start) {
83            depth += line.matches('{').count();
84            depth = depth.saturating_sub(line.matches('}').count());
85            if depth == 0 {
86                end = candidate;
87                break;
88            }
89        }
90        for removed_line in removed.iter_mut().take(end + 1).skip(start) {
91            *removed_line = true;
92        }
93        if end + 1 < removed.len() && lines[end + 1].trim().is_empty() {
94            removed[end + 1] = true;
95        }
96    }
97
98    lines
99        .into_iter()
100        .enumerate()
101        .filter_map(|(index, line)| (!removed[index]).then_some(line))
102        .collect()
103}
104
105fn emit_debug_element_counts(
106    output: &str,
107    program: &workshop_rs::Program,
108    catalog: &Catalog,
109    hir: &hir::Program,
110) -> Result<String, IntegrationError> {
111    let report = program.element_count(catalog).map_err(|error| {
112        IntegrationError::new(
113            "element-count",
114            format!("cannot compute Workshop element counts: {error}"),
115            None,
116        )
117    })?;
118    let rule_counts: Vec<_> = report.rules.iter().map(debug_rule_count).collect();
119    let total = rule_counts.iter().sum::<usize>();
120    let mut summary = format!("/* Element count: (total {total})\n\n");
121    let mut summary_rules: Vec<_> = report
122        .rules
123        .iter()
124        .zip(&rule_counts)
125        .filter(|(_, count)| **count > 1)
126        .collect();
127    summary_rules.sort_by_key(|(_, count)| std::cmp::Reverse(**count));
128    for (count, rule_count) in summary_rules {
129        let source = count
130            .span
131            .and_then(|span| {
132                hir.files
133                    .iter()
134                    .find(|file| file.id == span.file.index() as u32)
135            })
136            .map(|file| file.path.as_str())
137            .map(|path| format!(" ({path})"))
138            .unwrap_or_default();
139        summary.push_str(&format!(
140            "{:>5}: rule \"{}\"{source}\n",
141            rule_count, count.name
142        ));
143    }
144    summary.push_str("\n*/\n\n");
145
146    let mut annotated = summary;
147    let mut rule_index = 0;
148    let mut condition_index = 0;
149    let mut conditions = Vec::new();
150    let mut actions = Vec::new();
151    let mut in_actions = false;
152    let mut in_conditions = false;
153    let mut action_index = 0;
154    for line in output.split_inclusive('\n') {
155        let trimmed = line.trim();
156        if line.starts_with("rule (") {
157            if let (Some(rule), Some(rule_count)) =
158                (report.rules.get(rule_index), rule_counts.get(rule_index))
159            {
160                if *rule_count > 1 {
161                    let suffix = if *rule_count == 1 {
162                        "element"
163                    } else {
164                        "elements"
165                    };
166                    annotated.push_str(&format!("//{} {suffix}\n", rule_count));
167                }
168                conditions.clear();
169                collect_element_nodes(
170                    &rule.children,
171                    workshop_rs::actions::ElementNodeKind::Condition,
172                    &mut conditions,
173                );
174                actions.clear();
175                collect_element_nodes(
176                    &rule.children,
177                    workshop_rs::actions::ElementNodeKind::Action,
178                    &mut actions,
179                );
180            }
181            rule_index += 1;
182            condition_index = 0;
183            action_index = 0;
184            in_conditions = false;
185            in_actions = false;
186        } else if trimmed == "conditions {" {
187            in_conditions = true;
188            in_actions = false;
189        } else if trimmed == "actions {" {
190            in_conditions = false;
191            in_actions = true;
192        } else if in_actions && line.starts_with("    }") {
193            in_actions = false;
194        } else if in_conditions && line.starts_with("    }") {
195            in_conditions = false;
196        }
197
198        if in_conditions && line.starts_with("        ") && trimmed.ends_with(';') {
199            if let Some(condition) = conditions.get(condition_index) {
200                let condition_count = debug_condition_count(condition);
201                let suffix = if condition_count == 1 {
202                    "element"
203                } else {
204                    "elements"
205                };
206                annotated.push_str(line.trim_end_matches('\n'));
207                annotated.push_str(&format!(" // {condition_count} {suffix}\n"));
208                condition_index += 1;
209                continue;
210            }
211        }
212
213        if in_actions && line.starts_with("        ") && trimmed.ends_with(';') {
214            if let Some(action) = actions.get(action_index) {
215                let action_count = debug_action_local_count(action);
216                let suffix = if action_count == 1 {
217                    "element"
218                } else {
219                    "elements"
220                };
221                annotated.push_str(line.trim_end_matches('\n'));
222                annotated.push_str(&format!(" // {action_count} {suffix}\n"));
223                action_index += 1;
224                continue;
225            }
226        }
227        annotated.push_str(line);
228    }
229    Ok(annotated)
230}
231
232// Keep OPY's source-visible debugElementCount contract; Workshop's canonical
233// ElementCountNode counts differ from these OverPy-compatible display counts.
234fn debug_value_count(node: &workshop_rs::actions::ElementCountNode) -> usize {
235    let children = node.children.iter().map(debug_value_count).sum::<usize>();
236    match node.name.as_str() {
237        "number" | "global variable" => 2,
238        "localized string" => 2,
239        "customString" => 1 + 4usize.saturating_sub(node.children.len()) + children,
240        "Team" | "Color" => 1 + children.max(1),
241        "array" | "evalOnce" => 2 + children,
242        _ if node.children.is_empty() => 1,
243        _ => 1 + children,
244    }
245}
246
247fn debug_condition_count(node: &workshop_rs::actions::ElementCountNode) -> usize {
248    let value_count = node.children.iter().map(debug_value_count).sum::<usize>();
249    value_count.saturating_sub(usize::from(node.children.len() > 1))
250}
251
252fn debug_action_local_count(node: &workshop_rs::actions::ElementCountNode) -> usize {
253    let values = node
254        .children
255        .iter()
256        .filter(|child| child.kind == workshop_rs::actions::ElementNodeKind::Value)
257        .map(debug_value_count)
258        .sum::<usize>();
259    let value_arguments = node
260        .children
261        .iter()
262        .filter(|child| child.kind == workshop_rs::actions::ElementNodeKind::Value)
263        .count();
264    1 + values.saturating_sub(value_arguments)
265}
266
267fn debug_action_count(node: &workshop_rs::actions::ElementCountNode) -> usize {
268    let nested_actions = node
269        .children
270        .iter()
271        .filter(|child| child.kind == workshop_rs::actions::ElementNodeKind::Action)
272        .map(debug_action_count)
273        .sum::<usize>();
274    debug_action_local_count(node) + nested_actions
275}
276
277fn debug_rule_count(node: &workshop_rs::actions::ElementCountNode) -> usize {
278    let children = node
279        .children
280        .iter()
281        .map(|child| match child.kind {
282            workshop_rs::actions::ElementNodeKind::Condition => debug_condition_count(child),
283            workshop_rs::actions::ElementNodeKind::Action => debug_action_count(child),
284            workshop_rs::actions::ElementNodeKind::Rule
285            | workshop_rs::actions::ElementNodeKind::Value => 0,
286        })
287        .sum::<usize>();
288    1 + children
289}
290
291fn collect_element_nodes<'a>(
292    nodes: &'a [workshop_rs::actions::ElementCountNode],
293    kind: workshop_rs::actions::ElementNodeKind,
294    collected: &mut Vec<&'a workshop_rs::actions::ElementCountNode>,
295) {
296    for node in nodes {
297        if node.kind == kind {
298            collected.push(node);
299        }
300        collect_element_nodes(&node.children, kind, collected);
301    }
302}
303
304/// Version of the machine-readable compile report contract.
305pub const COMPILE_SCHEMA_VERSION: u32 = 1;
306
307/// Stable identity of the compiler that produced a compile report.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
309pub struct CompilerIdentity {
310    pub name: &'static str,
311    pub version: &'static str,
312}
313
314/// Whether compilation produced a valid Workshop artifact.
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
316#[serde(rename_all = "lowercase")]
317pub enum CompileStatus {
318    Success,
319    Failure,
320}
321
322/// Stable classification for a compile failure.
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
324#[serde(rename_all = "kebab-case")]
325pub enum CompileFailureClass {
326    Frontend,
327    Integration,
328}
329
330/// A versioned, source-attributed diagnostic exposed by the compile API.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
332#[serde(rename_all = "camelCase")]
333pub struct CompileDiagnostic {
334    pub severity: crate::tooling::DiagnosticSeverity,
335    pub code: String,
336    pub message: String,
337    pub span: Option<crate::tooling::SourceLocation>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub script: Option<ScriptDiagnostic>,
340}
341
342/// The machine-readable result for one compile operation.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
344#[serde(rename_all = "camelCase")]
345pub struct CompileResult {
346    pub status: CompileStatus,
347    pub exit_code: u8,
348    pub failure_class: Option<CompileFailureClass>,
349    pub diagnostics: Vec<CompileDiagnostic>,
350    pub stdout: String,
351    pub workshop_exact: String,
352    pub workshop: String,
353}
354
355/// Complete versioned compile report for CLI, CI, and embedding consumers.
356#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
357#[serde(rename_all = "camelCase")]
358pub struct CompileReport {
359    pub schema_version: u32,
360    pub compiler: CompilerIdentity,
361    pub catalog: CatalogIdentity,
362    pub compile: CompileResult,
363}
364
365impl CompilerIdentity {
366    fn current() -> Self {
367        Self {
368            name: "opy-rs",
369            version: env!("CARGO_PKG_VERSION"),
370        }
371    }
372}
373
374/// A source-attributed integration diagnostic.
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct IntegrationDiagnostic {
377    pub code: String,
378    pub message: String,
379    pub span: Option<HirSpan>,
380    pub script: Option<Box<ScriptDiagnostic>>,
381}
382
383/// Script-runtime provenance retained alongside the OPY directive anchor.
384#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
385#[serde(rename_all = "camelCase")]
386pub struct ScriptDiagnostic {
387    pub source_name: Option<String>,
388    pub line: Option<u32>,
389    pub column: Option<u32>,
390    pub stack: Option<String>,
391}
392
393impl IntegrationDiagnostic {
394    fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
395        Self {
396            code: code.into(),
397            message: message.into(),
398            span,
399            script: None,
400        }
401    }
402}
403
404/// An integration boundary failure.
405#[derive(Debug, Clone, PartialEq, Eq)]
406pub struct IntegrationError {
407    pub diagnostic: IntegrationDiagnostic,
408}
409
410impl IntegrationError {
411    fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
412        Self {
413            diagnostic: IntegrationDiagnostic::new(code, message, span),
414        }
415    }
416
417    fn post_compile_hook(error: crate::macro_js::MacroError, span: Option<HirSpan>) -> Self {
418        let message = error.to_string();
419        let script = match error {
420            crate::macro_js::MacroError::Script(error) => Some(Box::new(ScriptDiagnostic {
421                source_name: error.source_name,
422                line: error.line,
423                column: error.column,
424                stack: error.stack,
425            })),
426            crate::macro_js::MacroError::InvalidResult { .. }
427            | crate::macro_js::MacroError::Internal(_) => None,
428        };
429        Self {
430            diagnostic: IntegrationDiagnostic {
431                code: "post-compile-hook".to_string(),
432                message,
433                span,
434                script,
435            },
436        }
437    }
438}
439
440impl std::fmt::Display for IntegrationError {
441    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442        write!(f, "{}: {}", self.diagnostic.code, self.diagnostic.message)
443    }
444}
445
446impl std::error::Error for IntegrationError {}
447
448/// The compiler-facing integration object. Construction validates the public
449/// manifest/catalog contract once and exposes the pinned catalog identity.
450pub struct Compiler {
451    catalog: &'static Catalog,
452    manifest: &'static Manifest,
453    links: LinkReport,
454}
455
456impl Compiler {
457    pub fn new() -> Result<Self, IntegrationError> {
458        let contract = load_compiler_contract()?;
459        Ok(Self {
460            catalog: &contract.catalog,
461            manifest: contract.manifest,
462            links: contract.links,
463        })
464    }
465
466    pub fn catalog_identity(&self) -> CatalogIdentity {
467        self.catalog.identity()
468    }
469
470    pub fn link_report(&self) -> LinkReport {
471        self.links
472    }
473
474    /// Lower a resolved OPY HIR program into the canonical Workshop `Program`, validate it
475    /// against the canonical catalog, and emit deterministic en-US Workshop.
476    pub fn compile_hir(&self, hir: &hir::Program) -> Result<CompilationArtifact, IntegrationError> {
477        self.compile_hir_with_locale(hir, &Locale::new("en-US"))
478    }
479
480    /// Lower and emit using a locale declared by the canonical catalog.
481    pub fn compile_hir_with_locale(
482        &self,
483        hir: &hir::Program,
484        locale: &Locale,
485    ) -> Result<CompilationArtifact, IntegrationError> {
486        if !self.catalog.supports(locale) {
487            return Err(IntegrationError::new(
488                "locale-unsupported",
489                format!("workshop catalog does not declare locale '{locale}'"),
490                None,
491            ));
492        }
493        reject_unlowered_directives(hir)?;
494        let expanded_hir = expand_macros(hir)?;
495        let mut lowering = Lowering::new(self, &expanded_hir)?;
496        lowering.copy_files()?;
497        lowering.lower_declarations()?;
498        lowering.lower_rules()?;
499        let translation_files = lowering.translation_files();
500
501        let program = lowering.program;
502        program.validate().map_err(|error| {
503            IntegrationError::new(
504                "workshop-validation",
505                error.to_string(),
506                workshop_error_span(&error)
507                    .and_then(|span| hir_span_from_workshop(span, &expanded_hir)),
508            )
509        })?;
510        workshop_rs::validate::validate_canonical_ids(&program, self.catalog).map_err(|error| {
511            IntegrationError::new(
512                "catalog-validation",
513                error.to_string(),
514                workshop_error_span(&error)
515                    .and_then(|span| hir_span_from_workshop(span, &expanded_hir)),
516            )
517        })?;
518        let exclude_variables = has_directive(&expanded_hir, "excludeVariablesInCompilation");
519        let emitted =
520            workshop_rs::emitter::emit(&program, self.catalog, locale).map_err(|error| {
521                IntegrationError::new(
522                    "workshop-emission",
523                    error.to_string(),
524                    workshop_error_span(&error)
525                        .and_then(|span| hir_span_from_workshop(span, &expanded_hir)),
526                )
527            })?;
528        let emitted = if exclude_variables {
529            omit_declaration_sections(&emitted, &program)
530        } else {
531            emitted
532        };
533        let emitted = if has_directive(&expanded_hir, "debugElementCount") {
534            emit_debug_element_counts(&emitted, &program, self.catalog, &expanded_hir)?
535        } else {
536            emitted
537        };
538
539        Ok(CompilationArtifact {
540            wir: program,
541            final_output: emitted.clone(),
542            emitted,
543            catalog_identity: self.catalog.identity(),
544            hook_console_output: Vec::new(),
545            translation_files,
546        })
547    }
548
549    /// Compile source using the default `en-US` catalog locale.
550    ///
551    /// This is the ordinary embedding API. It returns Workshop text and does
552    /// not require callers to construct a `workshop-rs` locale or understand
553    /// canonical Workshop types.
554    pub fn compile_source(
555        &self,
556        source: &str,
557        main_path: &str,
558        root: &std::path::Path,
559    ) -> Result<CompileOutput, IntegrationError> {
560        self.compile_source_with_language(source, main_path, root, "en-US")
561    }
562
563    /// Compile source using a catalog locale name without exposing the
564    /// `workshop-rs` locale type to ordinary embedding callers.
565    pub fn compile_source_with_language(
566        &self,
567        source: &str,
568        main_path: &str,
569        root: &std::path::Path,
570        language: &str,
571    ) -> Result<CompileOutput, IntegrationError> {
572        self.compile_source_with_locale(source, main_path, root, &Locale::new(language))
573            .map(CompilationArtifact::into_output)
574    }
575
576    /// Compile source with an explicit canonical Workshop locale.
577    ///
578    /// This is an advanced integration API. Use [`Self::compile_source`] or
579    /// [`Self::compile_source_with_language`] for ordinary embedding.
580    pub fn compile_source_with_locale(
581        &self,
582        source: &str,
583        main_path: &str,
584        root: &std::path::Path,
585        locale: &Locale,
586    ) -> Result<CompilationArtifact, IntegrationError> {
587        self.compile_source_internal(source, main_path, root, locale)
588    }
589
590    /// Compile source and return the canonical Workshop artifact for advanced
591    /// integrations.
592    pub fn compile_source_artifact(
593        &self,
594        source: &str,
595        main_path: &str,
596        root: &std::path::Path,
597    ) -> Result<CompilationArtifact, IntegrationError> {
598        self.compile_source_with_locale(source, main_path, root, &Locale::new("en-US"))
599    }
600
601    /// Compile source into the versioned machine-readable result contract
602    /// using the default `en-US` catalog locale.
603    pub fn compile_source_report(
604        &self,
605        source: &str,
606        main_path: &str,
607        root: &std::path::Path,
608    ) -> CompileReport {
609        self.compile_source_report_with_language(source, main_path, root, "en-US")
610    }
611
612    /// Compile source into the versioned machine-readable result contract
613    /// using a catalog locale name.
614    pub fn compile_source_report_with_language(
615        &self,
616        source: &str,
617        main_path: &str,
618        root: &std::path::Path,
619        language: &str,
620    ) -> CompileReport {
621        self.compile_source_report_with_locale(source, main_path, root, &Locale::new(language))
622    }
623
624    /// Compile source into the report contract with an explicit canonical
625    /// Workshop locale. This is an advanced integration API.
626    pub fn compile_source_report_with_locale(
627        &self,
628        source: &str,
629        main_path: &str,
630        root: &std::path::Path,
631        locale: &Locale,
632    ) -> CompileReport {
633        let outcome = crate::compile_with_overlay_outcome(
634            source,
635            main_path,
636            root,
637            &std::collections::BTreeMap::new(),
638        );
639        let catalog = self.catalog.identity();
640        let compiler = CompilerIdentity::current();
641        let frontend_diagnostics = outcome
642            .diagnostics
643            .iter()
644            .map(compile_frontend_diagnostic)
645            .collect::<Vec<_>>();
646        let Some(hir) = outcome.hir else {
647            return CompileReport::failure(
648                compiler,
649                catalog,
650                CompileFailureClass::Frontend,
651                frontend_diagnostics,
652            );
653        };
654
655        match self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale) {
656            Ok(artifact) => {
657                match write_translation_files(root, &hir, &artifact.translation_files) {
658                    Ok(()) => {
659                        CompileReport::success(compiler, catalog, artifact, frontend_diagnostics)
660                    }
661                    Err(error) => {
662                        let mut diagnostics = frontend_diagnostics;
663                        diagnostics.push(compile_diagnostic(error, &hir.files));
664                        CompileReport::failure(
665                            compiler,
666                            catalog,
667                            CompileFailureClass::Integration,
668                            diagnostics,
669                        )
670                    }
671                }
672            }
673            Err(error) => {
674                let mut diagnostics = frontend_diagnostics;
675                diagnostics.push(compile_diagnostic(error, &hir.files));
676                CompileReport::failure(
677                    compiler,
678                    catalog,
679                    CompileFailureClass::Integration,
680                    diagnostics,
681                )
682            }
683        }
684    }
685
686    fn compile_source_internal(
687        &self,
688        source: &str,
689        main_path: &str,
690        root: &std::path::Path,
691        locale: &Locale,
692    ) -> Result<CompilationArtifact, IntegrationError> {
693        let outcome = crate::compile_with_overlay_outcome(
694            source,
695            main_path,
696            root,
697            &std::collections::BTreeMap::new(),
698        );
699        let hir = outcome.hir.ok_or_else(|| {
700            let error = outcome
701                .error
702                .expect("failed frontend compile has diagnostic");
703            IntegrationError::new(
704                error.code,
705                error.message,
706                error.span.map(hir_span_from_diag),
707            )
708        })?;
709        let artifact =
710            self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale)?;
711        write_translation_files(root, &hir, &artifact.translation_files)?;
712        Ok(artifact)
713    }
714}
715
716fn write_translation_files(
717    root: &std::path::Path,
718    hir: &hir::Program,
719    files: &[(String, String)],
720) -> Result<(), IntegrationError> {
721    let Some(source) = hir.files.get(1).or_else(|| hir.files.first()) else {
722        return Ok(());
723    };
724    let base = root.join(
725        std::path::Path::new(&source.path)
726            .file_name()
727            .unwrap_or_else(|| std::ffi::OsStr::new("main.opy")),
728    );
729    for (language, content) in files {
730        let path = base.with_extension(format!("{language}.po"));
731        std::fs::write(&path, content).map_err(|error| {
732            IntegrationError::new(
733                "translations-io",
734                format!(
735                    "cannot write translation file '{}': {error}",
736                    path.display()
737                ),
738                hir.preprocessing
739                    .translations
740                    .as_ref()
741                    .and_then(|value| value.span),
742            )
743        })?;
744    }
745    Ok(())
746}
747
748fn workshop_error_span(error: &workshop_rs::WorkshopError) -> Option<workshop_rs::source::Span> {
749    match error {
750        workshop_rs::WorkshopError::Unknown { span, .. }
751        | workshop_rs::WorkshopError::Malformed { span, .. }
752        | workshop_rs::WorkshopError::Unsupported { span, .. } => *span,
753        workshop_rs::WorkshopError::Catalog(_)
754        | workshop_rs::WorkshopError::MissingMapping { .. } => None,
755    }
756}
757
758fn hir_span_from_workshop(span: workshop_rs::source::Span, hir: &hir::Program) -> Option<HirSpan> {
759    let file = span.file.index() as u32;
760    hir.files
761        .iter()
762        .any(|source| source.id == file)
763        .then_some(HirSpan {
764            file,
765            start: hir::Position {
766                line: span.start.line,
767                col: span.start.col,
768            },
769            end: hir::Position {
770                line: span.end.line,
771                col: span.end.col,
772            },
773        })
774}
775
776impl CompileReport {
777    fn success(
778        compiler: CompilerIdentity,
779        catalog: CatalogIdentity,
780        artifact: CompilationArtifact,
781        diagnostics: Vec<CompileDiagnostic>,
782    ) -> Self {
783        Self {
784            schema_version: COMPILE_SCHEMA_VERSION,
785            compiler,
786            catalog,
787            compile: CompileResult {
788                status: CompileStatus::Success,
789                exit_code: 0,
790                failure_class: None,
791                diagnostics,
792                stdout: String::new(),
793                workshop_exact: artifact.final_output.clone(),
794                workshop: normalize_workshop(&artifact.final_output),
795            },
796        }
797    }
798
799    fn failure(
800        compiler: CompilerIdentity,
801        catalog: CatalogIdentity,
802        failure_class: CompileFailureClass,
803        diagnostics: Vec<CompileDiagnostic>,
804    ) -> Self {
805        Self {
806            schema_version: COMPILE_SCHEMA_VERSION,
807            compiler,
808            catalog,
809            compile: CompileResult {
810                status: CompileStatus::Failure,
811                exit_code: 1,
812                failure_class: Some(failure_class),
813                diagnostics,
814                stdout: String::new(),
815                workshop_exact: String::new(),
816                workshop: String::new(),
817            },
818        }
819    }
820}
821
822fn compile_diagnostic(error: IntegrationError, files: &[hir::SourceFile]) -> CompileDiagnostic {
823    let diagnostic = error.diagnostic;
824    CompileDiagnostic {
825        severity: crate::tooling::DiagnosticSeverity::Error,
826        code: diagnostic.code,
827        message: diagnostic.message,
828        span: diagnostic
829            .span
830            .and_then(|span| source_location_from_hir(span, files)),
831        script: diagnostic.script.map(|script| *script),
832    }
833}
834
835fn compile_frontend_diagnostic(diagnostic: &crate::tooling::Diagnostic) -> CompileDiagnostic {
836    CompileDiagnostic {
837        severity: diagnostic.severity,
838        code: diagnostic.code.clone(),
839        message: diagnostic.message.clone(),
840        span: diagnostic.span.clone(),
841        script: None,
842    }
843}
844
845fn source_location_from_hir(
846    span: HirSpan,
847    files: &[hir::SourceFile],
848) -> Option<crate::tooling::SourceLocation> {
849    let path = files.iter().find(|file| file.id == span.file)?.path.clone();
850    Some(crate::tooling::SourceLocation {
851        file_id: span.file,
852        path,
853        start: crate::diag::Position::new(span.start.line, span.start.col),
854        end: crate::diag::Position::new(span.end.line, span.end.col),
855    })
856}
857
858fn normalize_workshop(text: &str) -> String {
859    if text.is_empty() {
860        return String::new();
861    }
862    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
863    let mut lines = normalized
864        .split('\n')
865        .map(|line| line.trim_end_matches([' ', '\t']).to_owned())
866        .collect::<Vec<_>>();
867    while lines.last().is_some_and(|line| line.is_empty()) {
868        lines.pop();
869    }
870    if lines.is_empty() {
871        String::new()
872    } else {
873        lines.join("\n") + "\n"
874    }
875}
876
877/// A source compile result for ordinary embedding callers.
878///
879/// The result contains only emitted text and hook output. Callers that need
880/// the canonical Workshop `Program` should use the explicit advanced artifact APIs instead.
881#[derive(Debug, Clone, PartialEq, Eq)]
882pub struct CompileOutput {
883    /// Workshop text after a declared post-compile hook, if any.
884    pub workshop: String,
885    /// Workshop text emitted before a declared post-compile hook.
886    pub emitted_workshop: String,
887    /// Console lines captured while running a declared post-compile hook.
888    pub hook_console_output: Vec<String>,
889}
890
891/// A validated canonical Workshop `Program` and its emitted Workshop artifact for advanced
892/// integrations.
893pub struct CompilationArtifact {
894    pub wir: Program,
895    pub emitted: String,
896    pub catalog_identity: CatalogIdentity,
897    pub final_output: String,
898    pub hook_console_output: Vec<String>,
899    pub(crate) translation_files: Vec<(String, String)>,
900}
901
902impl CompilationArtifact {
903    fn into_output(self) -> CompileOutput {
904        CompileOutput {
905            workshop: self.final_output,
906            emitted_workshop: self.emitted,
907            hook_console_output: self.hook_console_output,
908        }
909    }
910}
911
912fn hir_span_from_diag(span: crate::diag::Span) -> HirSpan {
913    HirSpan {
914        file: span.file,
915        start: hir::Position {
916            line: span.start.line,
917            col: span.start.col,
918        },
919        end: hir::Position {
920            line: span.end.line,
921            col: span.end.col,
922        },
923    }
924}
925
926#[cfg(test)]
927mod tests {
928    use super::integration::cross_check_manifest;
929    use super::{COMPILE_SCHEMA_VERSION, CompileFailureClass, CompileStatus, Compiler};
930    use crate::manifest::Manifest;
931    use std::path::Path;
932    use workshop_rs::catalog::{Catalog, Locale};
933
934    #[test]
935    fn catalog_links_are_checked() {
936        let compiler = Compiler::new().expect("Workshop contract must load");
937        let identity = compiler.catalog_identity();
938        assert!(!identity.implementation_version.is_empty());
939        assert!(compiler.link_report().catalog_ids_checked > 0);
940        assert!(compiler.link_report().domains_checked > 0);
941    }
942
943    #[test]
944    fn compiler_instances_share_the_verified_contract_concurrently() {
945        let instances = std::thread::scope(|scope| {
946            let handles = (0..8)
947                .map(|_| {
948                    scope.spawn(|| {
949                        let compiler = Compiler::new().expect("Workshop contract must load");
950                        (
951                            compiler.catalog as *const Catalog as usize,
952                            compiler.manifest as *const Manifest as usize,
953                            compiler.link_report(),
954                        )
955                    })
956                })
957                .collect::<Vec<_>>();
958            handles
959                .into_iter()
960                .map(|handle| handle.join().expect("compiler construction must not panic"))
961                .collect::<Vec<_>>()
962        });
963
964        let (catalog, manifest, links) = instances[0];
965        for (instance_catalog, instance_manifest, instance_links) in instances.into_iter().skip(1) {
966            assert_eq!(instance_catalog, catalog);
967            assert_eq!(instance_manifest, manifest);
968            assert_eq!(instance_links, links);
969        }
970    }
971
972    #[test]
973    fn compile_report_is_versioned_and_contains_reproducibility_identity() {
974        let compiler = Compiler::new().unwrap();
975        let report = compiler.compile_source_report_with_locale(
976            "rule \"report\":\n    @Event global\n    disableInspector()\n",
977            "report.opy",
978            Path::new("."),
979            &Locale::new("en-US"),
980        );
981        assert_eq!(report.schema_version, COMPILE_SCHEMA_VERSION);
982        assert_eq!(report.compiler.name, "opy-rs");
983        assert_eq!(report.catalog, compiler.catalog_identity());
984        assert_eq!(report.compile.status, CompileStatus::Success);
985        assert_eq!(report.compile.exit_code, 0);
986        assert!(report.compile.diagnostics.is_empty());
987        assert_eq!(
988            report.compile.workshop,
989            report
990                .compile
991                .workshop_exact
992                .trim_end_matches('\n')
993                .to_owned()
994                + "\n"
995        );
996        assert!(serde_json::to_value(report).unwrap()["catalog"]["catalog-version"].is_string());
997    }
998
999    #[test]
1000    fn compile_report_preserves_frontend_failure_class_and_source_path() {
1001        let compiler = Compiler::new().unwrap();
1002        let report = compiler.compile_source_report_with_locale(
1003            "rule \"broken\":\n    @Event global\n    missing()\n",
1004            "broken.opy",
1005            Path::new("."),
1006            &Locale::new("en-US"),
1007        );
1008        assert_eq!(report.compile.status, CompileStatus::Failure);
1009        assert_eq!(
1010            report.compile.failure_class,
1011            Some(CompileFailureClass::Frontend)
1012        );
1013        assert_eq!(report.compile.exit_code, 1);
1014        let diagnostic = &report.compile.diagnostics[0];
1015        assert_eq!(diagnostic.code, "unknown-action");
1016        assert_eq!(diagnostic.span.as_ref().unwrap().path, "broken.opy");
1017    }
1018
1019    #[test]
1020    fn compile_report_preserves_integration_failure_class_and_source_path() {
1021        let compiler = Compiler::new().unwrap();
1022        let report = compiler.compile_source_report_with_locale(
1023            "rule \"broken\":\n    @Event global\n    {\"a\": 1}[\"b\"] = 3\n",
1024            "broken.opy",
1025            Path::new("."),
1026            &Locale::new("en-US"),
1027        );
1028        assert_eq!(report.compile.status, CompileStatus::Failure);
1029        assert_eq!(
1030            report.compile.failure_class,
1031            Some(CompileFailureClass::Integration)
1032        );
1033        assert_eq!(
1034            report.compile.diagnostics[0].span.as_ref().unwrap().path,
1035            "broken.opy"
1036        );
1037    }
1038
1039    #[test]
1040    fn compile_report_preserves_frontend_warnings_on_integration_failure() {
1041        let compiler = Compiler::new().unwrap();
1042        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1043            .join("tests/fixtures/corpus/synthetic/preprocessing");
1044        let report = compiler.compile_source_report_with_locale(
1045            concat!(
1046                "#!include \"shared.opy\"\n",
1047                "#!include \"shared.opy\"\n",
1048                "rule \"broken\":\n",
1049                "    @Event global\n",
1050                "    {\"a\": 1}[\"b\"] = 3\n",
1051            ),
1052            "broken.opy",
1053            &root,
1054            &Locale::new("en-US"),
1055        );
1056        assert_eq!(report.compile.status, CompileStatus::Failure);
1057        assert_eq!(
1058            report.compile.failure_class,
1059            Some(CompileFailureClass::Integration)
1060        );
1061        assert_eq!(report.compile.diagnostics.len(), 2);
1062        assert_eq!(
1063            report.compile.diagnostics[0].severity,
1064            crate::tooling::DiagnosticSeverity::Warning
1065        );
1066        assert_eq!(report.compile.diagnostics[0].code, "w_already_imported");
1067        assert_eq!(
1068            report.compile.diagnostics[1].severity,
1069            crate::tooling::DiagnosticSeverity::Error
1070        );
1071        assert_eq!(
1072            report.compile.diagnostics[1].span.as_ref().unwrap().path,
1073            "broken.opy"
1074        );
1075    }
1076
1077    #[test]
1078    fn suppress_warnings_hides_matching_preprocessing_diagnostics() {
1079        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1080            .join("tests/fixtures/corpus/synthetic/preprocessing");
1081        let outcome = crate::tooling::check(
1082            concat!(
1083                "#!suppressWarnings w_already_imported\n",
1084                "#!include \"shared.opy\"\n",
1085                "#!include \"shared.opy\"\n",
1086                "rule \"r\":\n",
1087                "    @Event global\n",
1088                "    pass\n",
1089            ),
1090            "main.opy",
1091            &root,
1092        );
1093        assert!(outcome.model.is_some());
1094        assert!(outcome.diagnostics.is_empty());
1095    }
1096
1097    #[test]
1098    fn vertical_slice_preserves_source_files_spans_and_emits_workshop() {
1099        let compiler = Compiler::new().unwrap();
1100        let hir = crate::compile(
1101            "globalvar A\nrule \"issue 35 integration\":\n    @Event global\n    A = 1\n    disableInspector()\n",
1102            "compiler-vertical-slice.opy",
1103            Path::new("."),
1104        )
1105        .unwrap();
1106        let artifact = compiler.compile_hir(&hir).unwrap();
1107        assert!(artifact.emitted.contains("Disable Inspector Recording;"));
1108        assert_eq!(artifact.catalog_identity, compiler.catalog_identity());
1109    }
1110
1111    #[test]
1112    fn stale_catalog_links_fail_explicitly() {
1113        let manifest = Manifest::builtin().unwrap().clone();
1114        let mut stale = manifest;
1115        stale.functions[0].catalog_id = Some("missing-catalog-id".to_string());
1116        let error = cross_check_manifest(&stale, &Catalog::builtin().unwrap()).unwrap_err();
1117        assert_eq!(error.diagnostic.code, "catalog-link-missing");
1118    }
1119
1120    #[test]
1121    fn while_lowering_is_source_attributed() {
1122        let compiler = Compiler::new().unwrap();
1123        let hir = crate::compile(
1124            "rule \"while\":\n    @Event global\n    while true:\n        disableInspector()\n",
1125            "while.opy",
1126            Path::new("."),
1127        )
1128        .unwrap();
1129        let artifact = compiler.compile_hir(&hir).unwrap();
1130        let rule = artifact.wir.rules.first().unwrap();
1131        assert!(matches!(
1132            rule.actions.first(),
1133            Some(workshop_rs::Action::While { .. })
1134        ));
1135        assert!(artifact.emitted.contains("While(True);"));
1136    }
1137
1138    #[test]
1139    fn expanded_control_flow_actions_keep_their_originating_spans() {
1140        let compiler = Compiler::new().unwrap();
1141        let hir = crate::compile(
1142            "globalvar value = 1\nrule \"if\":\n    @Event global\n    if true:\n        wait(1)\n    disableInspector()\n",
1143            "control-flow-provenance.opy",
1144            Path::new("."),
1145        )
1146        .unwrap();
1147        let artifact = compiler.compile_hir(&hir).unwrap();
1148
1149        assert_eq!(artifact.wir.action_span(0, 0).unwrap().start.line, 1);
1150        assert_eq!(
1151            artifact
1152                .wir
1153                .action_argument_span(0, 0, 0)
1154                .unwrap()
1155                .start
1156                .line,
1157            1
1158        );
1159        assert_eq!(artifact.wir.rules[1].actions.len(), 4);
1160        assert_eq!(artifact.wir.action_span(1, 0).unwrap().start.line, 4);
1161        assert_eq!(artifact.wir.action_span(1, 1).unwrap().start.line, 5);
1162        assert_eq!(
1163            artifact
1164                .wir
1165                .action_argument_span(1, 1, 0)
1166                .unwrap()
1167                .start
1168                .line,
1169            5
1170        );
1171        assert_eq!(artifact.wir.action_span(1, 2).unwrap().start.line, 4);
1172    }
1173
1174    #[test]
1175    fn range_argument_provenance_uses_canonical_positions() {
1176        let compiler = Compiler::new().unwrap();
1177        let hir = crate::compile(
1178            "globalvar value\nrule \"range\":\n    @Event global\n    for value in range(3):\n        wait(1)\n",
1179            "range-provenance.opy",
1180            Path::new("."),
1181        )
1182        .unwrap();
1183        let artifact = compiler.compile_hir(&hir).unwrap();
1184
1185        assert_eq!(artifact.wir.action_span(0, 0).unwrap().start.line, 4);
1186        assert!(artifact.wir.action_argument_span(0, 0, 0).is_none());
1187        assert_eq!(
1188            artifact
1189                .wir
1190                .action_argument_span(0, 0, 1)
1191                .unwrap()
1192                .start
1193                .line,
1194            4
1195        );
1196        assert!(artifact.wir.action_argument_span(0, 0, 2).is_none());
1197    }
1198
1199    #[test]
1200    fn structural_subroutines_lower_to_canonical_wir() {
1201        let compiler = Compiler::new().unwrap();
1202        let hir = crate::compile(
1203            "globalvar score\nsubroutine showStatus\ndef showStatus():\n    @Name \"Friendly\"\n    @SuppressWarnings unusedVariable\n    disableInspector()\nrule \"caller\":\n    @Event global\n    showStatus()\n",
1204            "structure.opy",
1205            Path::new("."),
1206        )
1207        .unwrap();
1208        let artifact = compiler.compile_hir(&hir).unwrap();
1209        let subroutine = artifact.wir.subroutines.first().unwrap();
1210        assert_eq!(subroutine.name, "showStatus");
1211        assert_eq!(artifact.wir.rules.len(), 2);
1212        let subroutine_rule = artifact.wir.rules.first().unwrap();
1213        let workshop_rs::Event::Subroutine(subroutine_name) = &subroutine_rule.event else {
1214            panic!("expected a subroutine event");
1215        };
1216        assert_eq!(subroutine_name, "showStatus");
1217        assert!(matches!(
1218            artifact.wir.rules.get(1).unwrap().actions.first(),
1219            Some(workshop_rs::Action::CallSubroutine { .. })
1220        ));
1221        assert!(artifact.emitted.contains("Subroutine Friendly"));
1222    }
1223
1224    #[test]
1225    fn player_event_filters_resolve_through_canonical_catalog() {
1226        let compiler = Compiler::new().unwrap();
1227        let hir = crate::compile(
1228            "rule \"joined\":\n    @Event playerJoined\n    @Team 1\n    @Slot 2\n    disableInspector()\n",
1229            "filters.opy",
1230            Path::new("."),
1231        )
1232        .unwrap();
1233        let artifact = compiler.compile_hir(&hir).unwrap();
1234        assert!(matches!(
1235            &artifact.wir.rules.first().unwrap().event,
1236            workshop_rs::Event::Player {
1237                kind: workshop_rs::PlayerEventKind::Joined,
1238                team: workshop_rs::EventTeam::Team1,
1239                target: workshop_rs::EventTarget::Slot(2),
1240            }
1241        ));
1242        assert!(artifact.emitted.contains("Player Joined Match;"));
1243    }
1244
1245    #[test]
1246    fn hero_event_filters_accept_legacy_aliases() {
1247        let compiler = Compiler::new().unwrap();
1248        let hir = crate::compile(
1249            "rule \"hero\":\n    @Event eachPlayer\n    @Hero soldier\n    disableInspector()\n",
1250            "hero-filter.opy",
1251            Path::new("."),
1252        )
1253        .unwrap();
1254        let artifact = compiler.compile_hir(&hir).unwrap();
1255        assert!(matches!(
1256            &artifact.wir.rules.first().unwrap().event,
1257            workshop_rs::Event::EachPlayerWithFilters {
1258                target: workshop_rs::EventTarget::Hero(hero),
1259                ..
1260            } if hero == "SOLDIER_76"
1261        ));
1262    }
1263
1264    #[test]
1265    fn explicit_indices_are_reserved_before_deterministic_allocation() {
1266        let compiler = Compiler::new().unwrap();
1267        let hir = crate::compile(
1268            "globalvar first\nglobalvar reserved 0\nglobalvar next\nrule \"indices\":\n    @Event global\n    disableInspector()\n",
1269            "indices.opy",
1270            Path::new("."),
1271        )
1272        .unwrap();
1273        let artifact = compiler.compile_hir(&hir).unwrap();
1274        let by_name = artifact
1275            .wir
1276            .global_variables
1277            .iter()
1278            .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1279            .collect::<std::collections::BTreeMap<_, _>>();
1280        assert_eq!(
1281            by_name,
1282            std::collections::BTreeMap::from([("first", 1), ("reserved", 0), ("next", 2)])
1283        );
1284        // Variable tables are emitted in Workshop index order.
1285        let indices = artifact
1286            .wir
1287            .global_variables
1288            .iter()
1289            .map(|variable| variable.index.unwrap())
1290            .collect::<Vec<_>>();
1291        assert_eq!(indices, vec![0, 1, 2]);
1292    }
1293
1294    #[test]
1295    fn implicit_default_variables_use_reference_fixed_slots() {
1296        let compiler = Compiler::new().unwrap();
1297        let hir = crate::compile(
1298            r#"
1299globalvar timer
1300globalvar extra 5
1301
1302rule "implicit":
1303    @Event global
1304    A = timer + 1
1305    B = A
1306    B += 2
1307    A[0] = 7
1308    DX = B * A
1309"#,
1310            "implicit.opy",
1311            Path::new("."),
1312        )
1313        .unwrap();
1314        let artifact = compiler.compile_hir(&hir).unwrap();
1315        let globals = artifact
1316            .wir
1317            .global_variables
1318            .iter()
1319            .map(|variable| (variable.name.clone(), variable.index.unwrap()))
1320            .collect::<Vec<_>>();
1321        // The implicit A (0), B (1), and DX (127) names keep their fixed
1322        // Workshop slots and reserve them for declared-variable allocation
1323        // (pinned OverPy evidence); `timer` auto-allocates around them and
1324        // `extra` keeps its explicit index.
1325        assert_eq!(
1326            globals,
1327            vec![
1328                ("A".to_string(), 0),
1329                ("B".to_string(), 1),
1330                ("timer".to_string(), 2),
1331                ("extra".to_string(), 5),
1332                ("DX".to_string(), 127),
1333            ]
1334        );
1335        assert!(
1336            artifact
1337                .emitted
1338                .contains("Set Global Variable(A, Add(Global.timer, 1));")
1339        );
1340        assert!(
1341            artifact
1342                .emitted
1343                .contains("Set Global Variable(B, Global.A);")
1344        );
1345        assert!(
1346            artifact
1347                .emitted
1348                .contains("Modify Global Variable(B, Add, 2);")
1349        );
1350        assert!(
1351            artifact
1352                .emitted
1353                .contains("Set Global Variable At Index(A, 0, 7);")
1354        );
1355        assert!(
1356            artifact
1357                .emitted
1358                .contains("Set Global Variable(DX, Multiply(Global.B, Global.A));")
1359        );
1360    }
1361
1362    #[test]
1363    fn implicit_default_variable_slot_collision_is_source_attributed() {
1364        let compiler = Compiler::new().unwrap();
1365        let hir = crate::compile(
1366            "globalvar x 0\nrule \"collision\":\n    @Event global\n    x = 1\n    A = 2\n",
1367            "collision.opy",
1368            Path::new("."),
1369        )
1370        .unwrap();
1371        let error = match compiler.compile_hir(&hir) {
1372            Ok(_) => panic!("slot collision unexpectedly succeeded"),
1373            Err(error) => error,
1374        };
1375        assert_eq!(error.diagnostic.code, "index-collision");
1376        assert_eq!(error.diagnostic.span.unwrap().start.line, 5);
1377        assert!(error.diagnostic.message.contains("'A' and 'x'"));
1378    }
1379
1380    #[test]
1381    fn implicit_default_player_variables_use_independent_reference_slots() {
1382        let compiler = Compiler::new().unwrap();
1383        let hir = crate::compile(
1384            r#"
1385playervar declaredPlayer
1386
1387rule "implicit player variables":
1388    @Event eachPlayer
1389    A = 1
1390    eventPlayer.A = 1
1391    eventPlayer.A += 2
1392    eventPlayer.E = eventPlayer.A
1393    eventPlayer.DX = eventPlayer.E
1394    eventPlayer.declaredPlayer = eventPlayer.A
1395"#,
1396            "implicit-player.opy",
1397            Path::new("."),
1398        )
1399        .unwrap();
1400        let artifact = compiler.compile_hir(&hir).unwrap();
1401        let globals = artifact
1402            .wir
1403            .global_variables
1404            .iter()
1405            .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1406            .collect::<std::collections::BTreeMap<_, _>>();
1407        let players = artifact
1408            .wir
1409            .player_variables
1410            .iter()
1411            .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1412            .collect::<std::collections::BTreeMap<_, _>>();
1413        assert_eq!(globals.get("A"), Some(&0));
1414        assert_eq!(players.get("A"), Some(&0));
1415        assert_eq!(players.get("declaredPlayer"), Some(&1));
1416        assert_eq!(players.get("E"), Some(&4));
1417        assert_eq!(players.get("DX"), Some(&127));
1418        assert!(
1419            artifact
1420                .emitted
1421                .contains("Set Player Variable(Event Player, A, 1);")
1422        );
1423        assert!(
1424            artifact
1425                .emitted
1426                .contains("Modify Player Variable(Event Player, A, Add, 2);")
1427        );
1428        assert!(
1429            artifact
1430                .emitted
1431                .contains("Set Player Variable(Event Player, E, (Event Player).A);")
1432        );
1433    }
1434
1435    #[test]
1436    fn implicit_default_player_slot_collision_is_source_attributed() {
1437        let compiler = Compiler::new().unwrap();
1438        let hir = crate::compile(
1439            "playervar declared 0\nrule \"collision\":\n    @Event eachPlayer\n    eventPlayer.A = 1\n",
1440            "player-collision.opy",
1441            Path::new("."),
1442        )
1443        .unwrap();
1444        let error = match compiler.compile_hir(&hir) {
1445            Ok(_) => panic!("player slot collision unexpectedly succeeded"),
1446            Err(error) => error,
1447        };
1448        assert_eq!(error.diagnostic.code, "index-collision");
1449        assert!(
1450            error
1451                .diagnostic
1452                .message
1453                .contains("player variables 'A' and 'declared'")
1454        );
1455        assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
1456    }
1457
1458    #[test]
1459    fn power_augmented_assignment_lowers_from_source() {
1460        let compiler = Compiler::new().unwrap();
1461        let hir = crate::compile(
1462            "globalvar g\nrule \"power\":\n    @Event global\n    g = 2\n    g **= 3\n",
1463            "power.opy",
1464            Path::new("."),
1465        )
1466        .unwrap();
1467        let artifact = compiler.compile_hir(&hir).unwrap();
1468        assert!(artifact.emitted.contains("Set Global Variable(g, 2);"));
1469        assert!(
1470            artifact
1471                .emitted
1472                .contains("Modify Global Variable(g, Raise To Power, 3);")
1473        );
1474    }
1475
1476    #[test]
1477    fn opy_hex_numbers_are_normalized_at_the_wir_boundary() {
1478        let compiler = Compiler::new().unwrap();
1479        let hir = crate::compile(
1480            "globalvar large = 0x124BC\nglobalvar small = 0x124\nglobalvar scientific = 1e10\n",
1481            "numbers.opy",
1482            Path::new("."),
1483        )
1484        .unwrap();
1485        let artifact = compiler.compile_hir(&hir).unwrap();
1486        assert!(
1487            artifact
1488                .emitted
1489                .contains("Set Global Variable(large, 74940);")
1490        );
1491        assert!(
1492            artifact
1493                .emitted
1494                .contains("Set Global Variable(small, 292);")
1495        );
1496        assert!(
1497            artifact
1498                .emitted
1499                .contains("Set Global Variable(scientific, 10000000000);")
1500        );
1501        assert!(!artifact.emitted.contains("0x124BC"));
1502        assert!(!artifact.emitted.contains("0x124"));
1503    }
1504
1505    #[test]
1506    fn literal_dict_lookup_lowers_to_the_selected_value() {
1507        let compiler = Compiler::new().unwrap();
1508        let hir = crate::compile(
1509            "globalvar total\nrule \"negative\":\n    @Event global\n    total = {\"a\": 1, \"b\": 2}[\"a\"]\n",
1510            "negative.opy",
1511            Path::new("."),
1512        )
1513        .unwrap();
1514        let artifact = compiler
1515            .compile_hir(&hir)
1516            .expect("literal dict lookup should lower");
1517        assert!(artifact.emitted.contains("Set Global Variable(total, 1);"));
1518    }
1519
1520    #[test]
1521    fn auto_allocation_fills_free_slots_below_early_explicit_indices() {
1522        let compiler = Compiler::new().unwrap();
1523        let hir = crate::compile(
1524            r#"
1525globalvar reserved 5
1526globalvar auto1
1527globalvar auto2
1528
1529rule "allocation":
1530    @Event global
1531    auto1 = 1
1532    auto2 = 2
1533    B = 3
1534"#,
1535            "allocation.opy",
1536            Path::new("."),
1537        )
1538        .unwrap();
1539        let artifact = compiler.compile_hir(&hir).unwrap();
1540        let by_name = artifact
1541            .wir
1542            .global_variables
1543            .iter()
1544            .map(|variable| (variable.name.clone(), variable.index.unwrap()))
1545            .collect::<std::collections::BTreeMap<_, _>>();
1546        // The implicit B keeps its fixed slot 1; the auto-allocated variables
1547        // fill the remaining free slots below the explicit 5 instead of
1548        // jumping past it, matching the pinned OverPy oracle (slot 0 stays
1549        // free here because the implicit A is never used).
1550        assert_eq!(
1551            by_name,
1552            std::collections::BTreeMap::from([
1553                ("B".to_string(), 1),
1554                ("auto1".to_string(), 0),
1555                ("auto2".to_string(), 2),
1556                ("reserved".to_string(), 5),
1557            ])
1558        );
1559    }
1560
1561    #[test]
1562    fn power_expressions_lower_through_the_canonical_contract() {
1563        let compiler = Compiler::new().unwrap();
1564        let hir = crate::compile(
1565            "globalvar a = [2, 4]\nglobalvar out\nrule \"power\":\n    @Event global\n    out = a ** 2\n    a **= 2\n    a[0] **= 2\n",
1566            "power.opy",
1567            Path::new("."),
1568        )
1569        .unwrap();
1570        let artifact = compiler.compile_hir(&hir).unwrap();
1571        assert!(
1572            artifact
1573                .emitted
1574                .contains("Set Global Variable(out, Raise To Power(Global.a, 2));")
1575        );
1576        assert!(
1577            artifact
1578                .emitted
1579                .contains("Modify Global Variable(a, Raise To Power, 2);")
1580        );
1581        assert!(
1582            artifact
1583                .emitted
1584                .contains("Modify Global Variable At Index(a, 0, Raise To Power, 2);")
1585        );
1586    }
1587
1588    #[test]
1589    fn unsupported_rule_metadata_is_explicit_and_source_attributed() {
1590        let compiler = Compiler::new().unwrap();
1591        let hir = crate::compile(
1592            "rule \"metadata\":\n    @Event global\n    @NewPage \"section\"\n    disableInspector()\n",
1593            "metadata.opy",
1594            Path::new("."),
1595        )
1596        .unwrap();
1597        let error = match compiler.compile_hir(&hir) {
1598            Ok(_) => panic!("unsupported metadata unexpectedly succeeded"),
1599            Err(error) => error,
1600        };
1601        assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
1602        assert_eq!(error.diagnostic.span.unwrap().start.line, 3);
1603    }
1604
1605    #[test]
1606    fn compiler_structure_matches_the_pinned_oracle() {
1607        let compiler = Compiler::new().unwrap();
1608        let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
1609            .join("tests/fixtures/corpus/synthetic/compiler-structure");
1610        let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
1611        let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
1612        let artifact = compiler.compile_hir(&hir).unwrap();
1613        let oracle: serde_json::Value =
1614            serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
1615                .unwrap();
1616        let oracle_workshop = oracle["compile"]["workshop"].as_str().unwrap();
1617        let oracle_wir = workshop_rs::parser::parse(
1618            oracle_workshop,
1619            &Catalog::builtin().unwrap(),
1620            &Locale::new("en-US"),
1621        )
1622        .unwrap();
1623        assert!(workshop_rs::roundtrip::equivalent(
1624            &artifact.wir,
1625            &oracle_wir
1626        ));
1627
1628        assert!(oracle_workshop.contains("0: reserved"));
1629        assert!(oracle_workshop.contains("1: first"));
1630        assert!(oracle_workshop.contains("2: explicit"));
1631        assert!(oracle_workshop.contains("3: next"));
1632        assert!(oracle_workshop.contains("0: helper"));
1633        assert!(oracle_workshop.contains("Subroutine;\n        helper;"));
1634        assert!(oracle_workshop.contains("Player Joined Match;\n        Team 1;\n        Slot 2;"));
1635
1636        let indices = artifact
1637            .wir
1638            .global_variables
1639            .iter()
1640            .map(|variable| variable.index.unwrap())
1641            .collect::<Vec<_>>();
1642        assert_eq!(indices, vec![0, 1, 2, 3]);
1643        assert_eq!(artifact.wir.subroutines.first().unwrap().name, "helper");
1644        assert!(artifact.emitted.contains("[Source] renamed helper"));
1645        assert!(matches!(
1646            artifact.wir.rules.get(1).unwrap().event,
1647            workshop_rs::Event::Player {
1648                kind: workshop_rs::PlayerEventKind::Joined,
1649                team: workshop_rs::EventTeam::Team1,
1650                target: workshop_rs::EventTarget::Slot(2),
1651            }
1652        ));
1653    }
1654
1655    #[test]
1656    fn assignments_and_modifications_lower_to_canonical_wir() {
1657        let compiler = Compiler::new().unwrap();
1658        let hir = crate::compile(
1659            r#"
1660globalvar g1
1661globalvar g2
1662playervar p1
1663playervar p2 = [1, 2, 3]
1664
1665rule "assignments":
1666    @Event eachPlayer
1667    g1 = 10
1668    g1 += 5
1669    g1 -= 2
1670    g1 *= 3
1671    g1 /= 2
1672    g1 %= 4
1673    g2 = [1, 2, 3]
1674    g2[0] = 99
1675    g2[1] += 1
1676    eventPlayer.p1 = 42
1677    eventPlayer.p1 += 8
1678    eventPlayer.p1 *= 2
1679    eventPlayer.p2[2] = 7
1680    eventPlayer.p2[0] -= 3
1681"#,
1682            "assign.opy",
1683            Path::new("."),
1684        )
1685        .unwrap();
1686        let artifact = compiler.compile_hir(&hir).unwrap();
1687        assert!(artifact.emitted.contains("Set Global Variable(g1, 10);"));
1688        assert!(
1689            artifact
1690                .emitted
1691                .contains("Modify Global Variable(g1, Add, 5);")
1692        );
1693        assert!(
1694            artifact
1695                .emitted
1696                .contains("Modify Global Variable(g1, Subtract, 2);")
1697        );
1698        assert!(
1699            artifact
1700                .emitted
1701                .contains("Modify Global Variable(g1, Multiply, 3);")
1702        );
1703        assert!(
1704            artifact
1705                .emitted
1706                .contains("Modify Global Variable(g1, Divide, 2);")
1707        );
1708        assert!(
1709            artifact
1710                .emitted
1711                .contains("Modify Global Variable(g1, Modulo, 4);")
1712        );
1713        assert!(
1714            artifact
1715                .emitted
1716                .contains("Set Global Variable At Index(g2, 0, 99);")
1717        );
1718        assert!(
1719            artifact
1720                .emitted
1721                .contains("Modify Global Variable At Index(g2, 1, Add, 1);")
1722        );
1723        assert!(
1724            artifact
1725                .emitted
1726                .contains("Set Player Variable(Event Player, p1, 42);")
1727        );
1728        assert!(
1729            artifact
1730                .emitted
1731                .contains("Modify Player Variable(Event Player, p1, Add, 8);")
1732        );
1733        assert!(
1734            artifact
1735                .emitted
1736                .contains("Modify Player Variable(Event Player, p1, Multiply, 2);")
1737        );
1738        assert!(
1739            artifact
1740                .emitted
1741                .contains("Set Player Variable At Index((Event Player).p2, 2, 7);")
1742        );
1743        assert!(
1744            artifact
1745                .emitted
1746                .contains("Modify Player Variable At Index((Event Player).p2, 0, Subtract, 3);")
1747        );
1748
1749        let rule = artifact.wir.rules.get(1).unwrap();
1750        assert!(matches!(
1751            rule.actions.first(),
1752            Some(workshop_rs::Action::SetGlobalVariable { variable, .. }) if variable == "g1"
1753        ));
1754        assert!(matches!(
1755            rule.actions.get(7),
1756            Some(workshop_rs::Action::Call { .. })
1757        ));
1758    }
1759
1760    #[test]
1761    fn expressions_and_values_lower_to_canonical_wir() {
1762        let compiler = Compiler::new().unwrap();
1763        let hir = crate::compile(
1764            r#"
1765enum Consts:
1766    BASE
1767
1768globalvar total
1769globalvar arr = [1, 2, 3]
1770globalvar pos = vect(1, 2, 3)
1771
1772rule "expressions":
1773    @Event global
1774    @Condition total == 0
1775    @Condition not (pos == vect(0, 0, 0))
1776    @Condition 2 in arr
1777    total = Consts.BASE + arr[1] * 2 - (10 / 2) + (5 % 2)
1778    print("Total: {}".format(total))
1779    debug(pos)
1780"#,
1781            "expr.opy",
1782            Path::new("."),
1783        )
1784        .unwrap();
1785        let artifact = compiler.compile_hir(&hir).unwrap();
1786        assert!(artifact.emitted.contains("Global.total == 0;"));
1787        // `not (pos == vect(0, 0, 0))` lowers to the negated comparison,
1788        // mirroring the pinned OverPy oracle.
1789        assert!(artifact.emitted.contains("Global.pos != Vector(0, 0, 0);"));
1790        assert!(
1791            artifact
1792                .emitted
1793                .contains("Array Contains(Global.arr, 2) == True;")
1794        );
1795        assert!(
1796            artifact
1797                .emitted
1798                .contains("Custom String(\"Total: {0}\", Global.total)")
1799        );
1800    }
1801
1802    #[test]
1803    fn pass_is_supported_as_source_level_noop() {
1804        let compiler = Compiler::new().unwrap();
1805        let hir = crate::compile(
1806            r#"
1807subroutine emptySub
1808
1809def emptySub():
1810    pass
1811
1812rule "empty rule":
1813    @Event global
1814    pass
1815"#,
1816            "pass.opy",
1817            Path::new("."),
1818        )
1819        .unwrap();
1820        let artifact = compiler.compile_hir(&hir).unwrap();
1821        // Rules and subroutines with nothing to run are dropped, as the
1822        // pinned OverPy does.
1823        assert!(artifact.wir.rules.is_empty());
1824    }
1825
1826    #[test]
1827    fn optimized_non_subroutine_wait_only_rule_matches_overpy_noop_elision() {
1828        let compiler = Compiler::new().unwrap();
1829        let hir = crate::compile(
1830            r#"
1831rule "wait-only":
1832    @Event eachPlayer
1833    wait(0.032)
1834"#,
1835            "wait-only.opy",
1836            Path::new("."),
1837        )
1838        .unwrap();
1839        let artifact = compiler.compile_hir(&hir).unwrap();
1840        assert!(artifact.wir.rules.is_empty());
1841    }
1842
1843    #[test]
1844    fn variable_initializers_synthesize_initialize_rules() {
1845        let compiler = Compiler::new().unwrap();
1846        let hir = crate::compile(
1847            r#"
1848globalvar j = 5
1849globalvar h = 0
1850globalvar k = 0.0
1851playervar p = 7
1852playervar q = 0
1853
1854rule "main":
1855    @Event global
1856    disableInspector()
1857"#,
1858            "init.opy",
1859            Path::new("."),
1860        )
1861        .unwrap();
1862        let artifact = compiler.compile_hir(&hir).unwrap();
1863        assert_eq!(
1864            artifact.wir.rules.first().unwrap().name,
1865            "Initialize global variables"
1866        );
1867        assert_eq!(
1868            artifact.wir.rules.get(1).unwrap().name,
1869            "Initialize player variables"
1870        );
1871        assert_eq!(artifact.wir.rules.get(2).unwrap().name, "main");
1872        assert!(artifact.emitted.contains("Set Global Variable(j, 5);"));
1873        assert!(artifact.emitted.contains("Set Global Variable(k, 0);"));
1874        assert!(!artifact.emitted.contains("Set Global Variable(h,"));
1875        assert!(
1876            artifact
1877                .emitted
1878                .contains("Set Player Variable(Event Player, p, 7);")
1879        );
1880        assert!(
1881            !artifact
1882                .emitted
1883                .contains("Set Player Variable(Event Player, q,")
1884        );
1885    }
1886
1887    #[test]
1888    fn settings_lower_through_workshop_owned_emission() {
1889        let compiler = Compiler::new().unwrap();
1890        let fixture =
1891            Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/corpus/synthetic/settings");
1892        let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
1893        let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
1894        let artifact = compiler.compile_hir(&hir).unwrap();
1895        let oracle: serde_json::Value =
1896            serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
1897                .unwrap();
1898        let expected = oracle["compile"]["workshop"]
1899            .as_str()
1900            .unwrap()
1901            .split("\n\nrule")
1902            .next()
1903            .unwrap();
1904        let actual = artifact.emitted.split("\n\nrule").next().unwrap();
1905        let oracle_wir = workshop_rs::parser::parse(
1906            oracle["compile"]["workshop"].as_str().unwrap(),
1907            &Catalog::builtin().unwrap(),
1908            &Locale::new("en-US"),
1909        )
1910        .unwrap();
1911        assert!(workshop_rs::roundtrip::equivalent(
1912            &artifact.wir,
1913            &oracle_wir
1914        ));
1915        assert_eq!(
1916            normalize_workshop_structural_whitespace(actual),
1917            normalize_workshop_structural_whitespace(expected)
1918        );
1919    }
1920
1921    #[test]
1922    fn unsupported_locale_has_no_fabricated_source_span() {
1923        let compiler = Compiler::new().unwrap();
1924        let hir = crate::compile(
1925            "#!translations en\nrule \"r\":\n    @Event global\n    pass\n",
1926            "locale.opy",
1927            Path::new("."),
1928        )
1929        .unwrap();
1930        let error = match compiler.compile_hir_with_locale(&hir, &Locale::new("xx-XX")) {
1931            Ok(_) => panic!("unsupported locale unexpectedly compiled"),
1932            Err(error) => error,
1933        };
1934        assert_eq!(error.diagnostic.code, "locale-unsupported");
1935        assert_eq!(error.diagnostic.span, None);
1936    }
1937
1938    #[test]
1939    fn locale_selection_emits_catalog_localized_workshop() {
1940        let compiler = Compiler::new().unwrap();
1941        let hir = crate::compile(
1942            "rule \"locale\":\n    @Event global\n    disableInspector()\n",
1943            "locale.opy",
1944            Path::new("."),
1945        )
1946        .unwrap();
1947        let artifact = compiler
1948            .compile_hir_with_locale(&hir, &Locale::new("zh-CN"))
1949            .unwrap();
1950        assert!(artifact.emitted.contains("规则 (\"locale\")"));
1951        assert!(artifact.emitted.contains("禁用查看器录制"));
1952    }
1953
1954    #[test]
1955    fn unsupported_output_directives_fail_at_their_source_anchor() {
1956        let compiler = Compiler::new().unwrap();
1957        let hir = crate::compile(
1958            "#!writeToOutputFile\nrule \"r\":\n    @Event global\n    pass\n",
1959            "directives.opy",
1960            Path::new("."),
1961        )
1962        .unwrap();
1963        let error = match compiler.compile_hir(&hir) {
1964            Ok(_) => panic!("backend directive unexpectedly compiled"),
1965            Err(error) => error,
1966        };
1967        assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
1968        assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
1969    }
1970
1971    #[test]
1972    fn optimizer_directives_remain_non_blocking_presentation_controls() {
1973        let compiler = Compiler::new().unwrap();
1974        let hir = crate::compile(
1975            "#!disableOptimizations\nrule \"r\":\n    @Event global\n    pass\n",
1976            "optimization.opy",
1977            Path::new("."),
1978        )
1979        .unwrap();
1980        compiler.compile_hir(&hir).unwrap();
1981    }
1982
1983    #[test]
1984    fn strict_optimization_cases_are_preserved_by_native_lowering() {
1985        let compiler = Compiler::new().unwrap();
1986        let hir = crate::compile(
1987            "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",
1988            "strict.opy",
1989            Path::new("."),
1990        )
1991        .unwrap();
1992        let artifact = compiler.compile_hir(&hir).unwrap();
1993        assert!(artifact.emitted.contains("Add(Global.A, 0)"));
1994        assert!(artifact.emitted.contains("Multiply(Global.A, 0)"));
1995        assert!(artifact.emitted.contains("Multiply(Global.A, 1)"));
1996        assert!(
1997            artifact
1998                .emitted
1999                .contains("Compare(Custom String(\"am\"), ==, Custom String(\"**\"))")
2000        );
2001    }
2002
2003    #[test]
2004    fn compression_alphabet_policy_uses_a_shared_global() {
2005        let compiler = Compiler::new().unwrap();
2006        let hir = crate::compile(
2007            "globalvar values = compressed([1, 2, 3])\n\n#!useVariableForCompressionAlphabet\nrule \"compression\":\n    @Event global\n    print(values)\n",
2008            "compression.opy",
2009            Path::new("."),
2010        )
2011        .unwrap();
2012        let artifact = compiler.compile_hir(&hir).unwrap();
2013        assert!(artifact.emitted.contains("127: __compressionAlphabet__"));
2014        assert!(artifact.emitted.contains("Global.__compressionAlphabet__"));
2015    }
2016
2017    #[test]
2018    fn replacement_directives_lower_when_size_optimization_is_active() {
2019        let compiler = Compiler::new().unwrap();
2020        let hir = crate::compile(
2021            "#!optimizeForSize\n#!replace0ByCapturePercentage\nrule \"r\":\n    @Event global\n    print(0)\n",
2022            "directives.opy",
2023            Path::new("."),
2024        )
2025        .unwrap();
2026        let artifact = compiler.compile_hir(&hir).unwrap();
2027        assert!(artifact.emitted.contains("Point Capture Percentage"));
2028    }
2029
2030    #[test]
2031    fn debug_element_count_emits_sorted_rule_condition_and_action_comments() {
2032        let compiler = Compiler::new().unwrap();
2033        let hir = crate::compile(
2034            "#!debugElementCount\nglobalvar value\nrule \"small\":\n    @Event global\n    @Condition value == 1\n    print(1)\nrule \"large\":\n    @Event global\n    @Condition value == 1\n    print(1)\n    print(2)\n",
2035            "debug.opy",
2036            Path::new("."),
2037        )
2038        .unwrap();
2039        let artifact = compiler.compile_hir(&hir).unwrap();
2040        let report = artifact.wir.element_count(compiler.catalog).unwrap();
2041        let small = report
2042            .rules
2043            .iter()
2044            .find(|rule| rule.name == "small")
2045            .unwrap();
2046        let large = report
2047            .rules
2048            .iter()
2049            .find(|rule| rule.name == "large")
2050            .unwrap();
2051        let large_summary = artifact.emitted.find("   32: rule \"large\"").unwrap();
2052        let small_summary = artifact.emitted.find("   18: rule \"small\"").unwrap();
2053        assert!(large_summary < small_summary);
2054        assert!(
2055            artifact
2056                .emitted
2057                .starts_with("/* Element count: (total 50)\n")
2058        );
2059        assert!(artifact.emitted.contains("//32 elements\nrule (\"large\")"));
2060        assert!(artifact.emitted.contains("//18 elements\nrule (\"small\")"));
2061        assert!(
2062            artifact
2063                .emitted
2064                .contains("Global.value == 1; // 3 elements")
2065        );
2066        assert_eq!(artifact.emitted.matches(" // 3 elements").count(), 2);
2067        assert_eq!(artifact.emitted.matches(" // 14 elements").count(), 3);
2068        let expected_comments = large
2069            .children
2070            .iter()
2071            .chain(&small.children)
2072            .filter(|node| {
2073                matches!(
2074                    node.kind,
2075                    workshop_rs::actions::ElementNodeKind::Condition
2076                        | workshop_rs::actions::ElementNodeKind::Action
2077                )
2078            })
2079            .count();
2080        assert_eq!(artifact.emitted.matches(" // ").count(), expected_comments);
2081    }
2082
2083    #[test]
2084    fn setup_and_initialization_directives_change_forward_output() {
2085        let compiler = Compiler::new().unwrap();
2086        let hir = crate::compile(
2087            "#!setupTx\n#!disableInspector\n#!globalvarInitRuleName \"Init globals\"\n#!playervarInitRuleName \"Init players\"\nglobalvar value = 1\nplayervar playerValue = 1\nrule \"r\":\n    @Event global\n    print(\"<fgFF0000FF>ready</fg>\")\n",
2088            "directives.opy",
2089            Path::new("."),
2090        )
2091        .unwrap();
2092        let artifact = compiler.compile_hir(&hir).unwrap();
2093        assert!(artifact.emitted.contains("OverPy <"));
2094        assert!(artifact.emitted.contains("Disable inspector"));
2095        assert!(artifact.emitted.contains("Init globals"));
2096        assert!(artifact.emitted.contains("Init players"));
2097        assert!(artifact.emitted.contains("__holygrail__"));
2098        assert!(
2099            artifact
2100                .emitted
2101                .contains("Custom String(\"{0}fgFF0000FF>ready{0}/fg>\", Global.__holygrail__)")
2102        );
2103    }
2104
2105    #[test]
2106    fn exclude_variables_directive_omits_variable_declarations() {
2107        let compiler = Compiler::new().unwrap();
2108        let hir = crate::compile(
2109            "#!excludeVariablesInCompilation\nglobalvar value 0\nrule \"r\":\n    @Event global\n    pass\n",
2110            "directives.opy",
2111            Path::new("."),
2112        )
2113        .unwrap();
2114        let artifact = compiler.compile_hir(&hir).unwrap();
2115        assert!(!artifact.wir.global_variables.is_empty());
2116        assert!(
2117            !artifact.emitted.contains("variables {"),
2118            "{}",
2119            artifact.emitted
2120        );
2121    }
2122
2123    #[test]
2124    fn post_compile_hook_receives_exact_emitted_workshop() {
2125        let compiler = Compiler::new().unwrap();
2126        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2127        let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n    debug(\"ready\")\n";
2128        let artifact = compiler
2129            .compile_source_with_locale(source, "hook.opy", &root, &Locale::new("en-US"))
2130            .unwrap();
2131        assert!(artifact.emitted.contains("rule (\"setup\")"));
2132        assert!(artifact.final_output.contains("rule (\"transformed\")"));
2133        assert_ne!(artifact.final_output, artifact.emitted);
2134    }
2135
2136    #[test]
2137    fn post_compile_hook_failure_keeps_script_provenance_and_directive_anchor() {
2138        let compiler = Compiler::new().unwrap();
2139        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2140        let source = "#!postCompileHook \"hook-boom.js\"\n\nrule \"setup\":\n    pass\n";
2141        let error = match compiler.compile_source_with_locale(
2142            source,
2143            "hook.opy",
2144            &root,
2145            &Locale::new("en-US"),
2146        ) {
2147            Ok(_) => panic!("failing post-compile hook unexpectedly compiled"),
2148            Err(error) => error,
2149        };
2150        assert_eq!(error.diagnostic.code, "post-compile-hook");
2151        assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
2152        let script = error.diagnostic.script.unwrap();
2153        assert_eq!(script.source_name.as_deref(), Some("hook-boom.js"));
2154        assert_eq!(script.line, Some(1));
2155        assert!(script.stack.unwrap().contains("hook-boom.js:1"));
2156    }
2157
2158    fn normalize_workshop_structural_whitespace(text: &str) -> String {
2159        let mut normalized = String::with_capacity(text.len());
2160        let mut quote = None;
2161        let mut escaped = false;
2162        for character in text.chars() {
2163            if let Some(delimiter) = quote {
2164                normalized.push(character);
2165                if escaped {
2166                    escaped = false;
2167                } else if character == '\\' {
2168                    escaped = true;
2169                } else if character == delimiter {
2170                    quote = None;
2171                }
2172            } else if matches!(character, '\"' | '\'') {
2173                quote = Some(character);
2174                normalized.push(character);
2175            } else if !character.is_whitespace() {
2176                normalized.push(character);
2177            }
2178        }
2179        normalized
2180    }
2181
2182    #[test]
2183    fn settings_whitespace_normalization_preserves_quoted_values() {
2184        assert_ne!(
2185            normalize_workshop_structural_whitespace("Description: \"a b\""),
2186            normalize_workshop_structural_whitespace("Description: \"ab\"")
2187        );
2188    }
2189}