Skip to main content

opy_rs/compiler/
mod.rs

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