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