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                    crate::matcher::did_you_mean(
523                        error.to_string(),
524                        &crate::matcher::settings_member_candidates(
525                            expanded_hir.settings.as_ref(),
526                            &error,
527                            None,
528                        ),
529                    ),
530                    workshop_error_span(&error)
531                        .and_then(|span| hir_span_from_workshop(span, &expanded_hir)),
532                )
533            })?;
534        let emitted = if exclude_variables {
535            omit_declaration_sections(&emitted, &program)
536        } else {
537            emitted
538        };
539        let emitted = if has_directive(&expanded_hir, "debugElementCount") {
540            emit_debug_element_counts(&emitted, &program, self.catalog, &expanded_hir)?
541        } else {
542            emitted
543        };
544
545        Ok(CompilationArtifact {
546            wir: program,
547            final_output: emitted.clone(),
548            emitted,
549            catalog_identity: self.catalog.identity(),
550            hook_console_output: Vec::new(),
551            translation_files,
552        })
553    }
554
555    /// Lower, validate, and emit `hir`, then pair the emitted Workshop text
556    /// with the source map of its authored origin (`workshop-rs/mapped-text-v1`)
557    /// in the same operation.
558    ///
559    /// The map is built from this call's own compilation, so the artifact and
560    /// its map can never come from different compiles. Diagnostics on the
561    /// artifact keep macro definition provenance while the map attributes
562    /// macro-expanded code to its invocation site.
563    pub fn compile_hir_mapped(
564        &self,
565        hir: &hir::Program,
566    ) -> Result<(CompilationArtifact, MappedText), IntegrationError> {
567        self.compile_hir_mapped_with_locale(hir, &Locale::new("en-US"))
568    }
569
570    /// [`Self::compile_hir_mapped`] using a locale declared by the canonical
571    /// catalog.
572    pub fn compile_hir_mapped_with_locale(
573        &self,
574        hir: &hir::Program,
575        locale: &Locale,
576    ) -> Result<(CompilationArtifact, MappedText), IntegrationError> {
577        self.compile_hir_mapped_with_hook(hir, None, locale)
578    }
579
580    fn compile_hir_mapped_with_hook(
581        &self,
582        hir: &hir::Program,
583        hook: Option<crate::PostCompileHookRecord>,
584        locale: &Locale,
585    ) -> Result<(CompilationArtifact, MappedText), IntegrationError> {
586        let artifact = self.compile_hir_with_locale_and_hook(hir, hook, locale)?;
587        let mapped = self.mapped_text(hir, &artifact.final_output, locale)?;
588        Ok((artifact, mapped))
589    }
590
591    /// Pair emitted Workshop `text` with the source map of its authored origin.
592    /// `hir` is the program `text` was emitted from in this operation. Spans
593    /// come from a second lowering that attributes macro-expanded code to its
594    /// invocation site, so diagnostics keep their spans.
595    ///
596    /// When `text` is still this program's verbatim emission the map applies
597    /// by construction, so the proof is discharged without re-parsing. Text
598    /// rewritten by post-compile directives or hooks is instead proven against
599    /// its re-parse, so a program shape that differs is reported instead of
600    /// yielding displaced spans.
601    fn mapped_text(
602        &self,
603        hir: &hir::Program,
604        text: &str,
605        locale: &Locale,
606    ) -> Result<MappedText, IntegrationError> {
607        let expanded = expand_macros_attributed(hir)?;
608        let mut lowering = Lowering::new(self, &expanded);
609        lowering.copy_files()?;
610        lowering.lower_declarations()?;
611        lowering.lower_rules()?;
612        let map = SourceMap::extract(&lowering.program);
613        let verbatim_emission = workshop_rs::emitter::emit(&lowering.program, self.catalog, locale)
614            .is_ok_and(|emitted| emitted == text);
615        if !verbatim_emission {
616            let mut reparsed =
617                workshop_rs::parser::parse(text, self.catalog, locale).map_err(|error| {
618                    IntegrationError::new("source-map-parse", error.to_string(), None)
619                })?;
620            map.apply(&mut reparsed).map_err(|error| {
621                IntegrationError::new("source-map-shape", error.to_string(), None)
622            })?;
623        }
624        Ok(MappedText::new(text.to_owned(), map))
625    }
626
627    /// Compile source using the default `en-US` catalog locale.
628    ///
629    /// This is the ordinary embedding API. It returns Workshop text and does
630    /// not require callers to construct a `workshop-rs` locale or understand
631    /// canonical Workshop types.
632    pub fn compile_source(
633        &self,
634        source: &str,
635        main_path: &str,
636        root: &std::path::Path,
637    ) -> Result<CompileOutput, IntegrationError> {
638        self.compile_source_with_language(source, main_path, root, "en-US")
639    }
640
641    /// Compile source using a catalog locale name without exposing the
642    /// `workshop-rs` locale type to ordinary embedding callers.
643    pub fn compile_source_with_language(
644        &self,
645        source: &str,
646        main_path: &str,
647        root: &std::path::Path,
648        language: &str,
649    ) -> Result<CompileOutput, IntegrationError> {
650        self.compile_source_with_locale(source, main_path, root, &Locale::new(language))
651            .map(CompilationArtifact::into_output)
652    }
653
654    /// Compile source with an explicit canonical Workshop locale.
655    ///
656    /// This is an advanced integration API. Use [`Self::compile_source`] or
657    /// [`Self::compile_source_with_language`] for ordinary embedding.
658    pub fn compile_source_with_locale(
659        &self,
660        source: &str,
661        main_path: &str,
662        root: &std::path::Path,
663        locale: &Locale,
664    ) -> Result<CompilationArtifact, IntegrationError> {
665        self.compile_source_internal(source, main_path, root, locale)
666    }
667
668    /// Compile source and return the canonical Workshop artifact for advanced
669    /// integrations.
670    pub fn compile_source_artifact(
671        &self,
672        source: &str,
673        main_path: &str,
674        root: &std::path::Path,
675    ) -> Result<CompilationArtifact, IntegrationError> {
676        self.compile_source_with_locale(source, main_path, root, &Locale::new("en-US"))
677    }
678
679    /// Compile source into the versioned machine-readable result contract
680    /// using the default `en-US` catalog locale.
681    pub fn compile_source_report(
682        &self,
683        source: &str,
684        main_path: &str,
685        root: &std::path::Path,
686    ) -> CompileReport {
687        self.compile_source_report_with_language(source, main_path, root, "en-US")
688    }
689
690    /// Compile source into the versioned machine-readable result contract
691    /// using a catalog locale name.
692    pub fn compile_source_report_with_language(
693        &self,
694        source: &str,
695        main_path: &str,
696        root: &std::path::Path,
697        language: &str,
698    ) -> CompileReport {
699        self.compile_source_report_with_locale(source, main_path, root, &Locale::new(language))
700    }
701
702    /// Compile source into the report contract with an explicit canonical
703    /// Workshop locale. This is an advanced integration API.
704    pub fn compile_source_report_with_locale(
705        &self,
706        source: &str,
707        main_path: &str,
708        root: &std::path::Path,
709        locale: &Locale,
710    ) -> CompileReport {
711        self.compile_source_report_impl(source, main_path, root, locale, false)
712            .0
713    }
714
715    /// Like [`Compiler::compile_source_report_with_language`], and on success
716    /// also returns the mapped Workshop text of the same compile.
717    pub fn compile_source_report_mapped_with_language(
718        &self,
719        source: &str,
720        main_path: &str,
721        root: &std::path::Path,
722        language: &str,
723    ) -> (CompileReport, Option<MappedText>) {
724        self.compile_source_report_impl(source, main_path, root, &Locale::new(language), true)
725    }
726
727    /// Compile a resolved [`crate::CompileOutcome`] into the report contract
728    /// using a catalog locale name, reusing the caller's frontend result
729    /// instead of re-running preprocessing, parsing, and lowering. This is an
730    /// advanced integration API for hosts that already ran the frontend.
731    pub fn compile_outcome_report_with_language(
732        &self,
733        outcome: crate::CompileOutcome,
734        root: &std::path::Path,
735        language: &str,
736    ) -> CompileReport {
737        self.compile_outcome_report_impl(outcome, root, &Locale::new(language), false)
738            .0
739    }
740
741    /// Like [`Self::compile_outcome_report_with_language`], and on success also
742    /// returns the mapped Workshop text of the same compile.
743    pub fn compile_outcome_report_mapped_with_language(
744        &self,
745        outcome: crate::CompileOutcome,
746        root: &std::path::Path,
747        language: &str,
748    ) -> (CompileReport, Option<MappedText>) {
749        self.compile_outcome_report_impl(outcome, root, &Locale::new(language), true)
750    }
751
752    fn compile_source_report_impl(
753        &self,
754        source: &str,
755        main_path: &str,
756        root: &std::path::Path,
757        locale: &Locale,
758        mapped: bool,
759    ) -> (CompileReport, Option<MappedText>) {
760        let outcome = crate::compile_with_overlay_outcome(
761            source,
762            main_path,
763            root,
764            &std::collections::BTreeMap::new(),
765        );
766        self.compile_outcome_report_impl(outcome, root, locale, mapped)
767    }
768
769    fn compile_outcome_report_impl(
770        &self,
771        outcome: crate::CompileOutcome,
772        root: &std::path::Path,
773        locale: &Locale,
774        mapped: bool,
775    ) -> (CompileReport, Option<MappedText>) {
776        let catalog = self.catalog.identity();
777        let compiler = CompilerIdentity::current();
778        let frontend_diagnostics = outcome
779            .diagnostics
780            .iter()
781            .map(compile_frontend_diagnostic)
782            .collect::<Vec<_>>();
783        let Some(hir) = outcome.hir else {
784            return (
785                CompileReport::failure(
786                    compiler,
787                    catalog,
788                    CompileFailureClass::Frontend,
789                    frontend_diagnostics,
790                ),
791                None,
792            );
793        };
794
795        let compiled = if mapped {
796            self.compile_hir_mapped_with_hook(&hir, outcome.post_compile_hook, locale)
797                .map(|(artifact, mapped)| (artifact, Some(mapped)))
798        } else {
799            self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale)
800                .map(|artifact| (artifact, None))
801        };
802        match compiled {
803            Ok((artifact, mapped)) => {
804                match write_translation_files(root, &hir, &artifact.translation_files) {
805                    Ok(()) => (
806                        CompileReport::success(compiler, catalog, artifact, frontend_diagnostics),
807                        mapped,
808                    ),
809                    Err(error) => (
810                        integration_failure_report(
811                            compiler,
812                            catalog,
813                            frontend_diagnostics,
814                            error,
815                            &hir.files,
816                        ),
817                        None,
818                    ),
819                }
820            }
821            Err(error) => (
822                integration_failure_report(
823                    compiler,
824                    catalog,
825                    frontend_diagnostics,
826                    error,
827                    &hir.files,
828                ),
829                None,
830            ),
831        }
832    }
833
834    fn compile_source_internal(
835        &self,
836        source: &str,
837        main_path: &str,
838        root: &std::path::Path,
839        locale: &Locale,
840    ) -> Result<CompilationArtifact, IntegrationError> {
841        let outcome = crate::compile_with_overlay_outcome(
842            source,
843            main_path,
844            root,
845            &std::collections::BTreeMap::new(),
846        );
847        let hir = outcome.hir.ok_or_else(|| {
848            let error = outcome
849                .error
850                .expect("failed frontend compile has diagnostic");
851            IntegrationError::new(
852                error.code,
853                error.message,
854                error.span.map(hir_span_from_diag),
855            )
856        })?;
857        let artifact =
858            self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale)?;
859        write_translation_files(root, &hir, &artifact.translation_files)?;
860        Ok(artifact)
861    }
862}
863
864fn write_translation_files(
865    root: &std::path::Path,
866    hir: &hir::Program,
867    files: &[(String, String)],
868) -> Result<(), IntegrationError> {
869    let Some(source) = hir.files.get(1).or_else(|| hir.files.first()) else {
870        return Ok(());
871    };
872    let base = root.join(
873        std::path::Path::new(&source.path)
874            .file_name()
875            .unwrap_or_else(|| std::ffi::OsStr::new("main.opy")),
876    );
877    for (language, content) in files {
878        let path = base.with_extension(format!("{language}.po"));
879        std::fs::write(&path, content).map_err(|error| {
880            IntegrationError::new(
881                "translations-io",
882                format!(
883                    "cannot write translation file '{}': {error}",
884                    path.display()
885                ),
886                hir.preprocessing
887                    .translations
888                    .as_ref()
889                    .and_then(|value| value.span),
890            )
891        })?;
892    }
893    Ok(())
894}
895
896pub(crate) fn workshop_error_span(
897    error: &workshop_rs::WorkshopError,
898) -> Option<workshop_rs::source::Span> {
899    match error {
900        workshop_rs::WorkshopError::Unknown { span, .. }
901        | workshop_rs::WorkshopError::Malformed { span, .. }
902        | workshop_rs::WorkshopError::Unsupported { span, .. } => *span,
903        _ => None,
904    }
905}
906
907fn hir_span_from_workshop(span: workshop_rs::source::Span, hir: &hir::Program) -> Option<HirSpan> {
908    let file = span.file.index() as u32;
909    hir.files
910        .iter()
911        .any(|source| source.id == file)
912        .then_some(HirSpan {
913            file,
914            start: hir::Position {
915                line: span.start.line,
916                col: span.start.col,
917            },
918            end: hir::Position {
919                line: span.end.line,
920                col: span.end.col,
921            },
922        })
923}
924
925impl CompileReport {
926    fn success(
927        compiler: CompilerIdentity,
928        catalog: CatalogIdentity,
929        artifact: CompilationArtifact,
930        diagnostics: Vec<CompileDiagnostic>,
931    ) -> Self {
932        let workshop = normalize_workshop(&artifact.final_output);
933        Self {
934            schema_version: COMPILE_SCHEMA_VERSION,
935            compiler,
936            catalog,
937            compile: CompileResult {
938                status: CompileStatus::Success,
939                exit_code: 0,
940                failure_class: None,
941                diagnostics,
942                stdout: String::new(),
943                workshop_exact: artifact.final_output,
944                workshop,
945            },
946        }
947    }
948
949    fn failure(
950        compiler: CompilerIdentity,
951        catalog: CatalogIdentity,
952        failure_class: CompileFailureClass,
953        diagnostics: Vec<CompileDiagnostic>,
954    ) -> Self {
955        Self {
956            schema_version: COMPILE_SCHEMA_VERSION,
957            compiler,
958            catalog,
959            compile: CompileResult {
960                status: CompileStatus::Failure,
961                exit_code: 1,
962                failure_class: Some(failure_class),
963                diagnostics,
964                stdout: String::new(),
965                workshop_exact: String::new(),
966                workshop: String::new(),
967            },
968        }
969    }
970}
971
972fn compile_diagnostic(error: IntegrationError, files: &[hir::SourceFile]) -> CompileDiagnostic {
973    let diagnostic = error.diagnostic;
974    CompileDiagnostic {
975        severity: crate::tooling::DiagnosticSeverity::Error,
976        code: diagnostic.code,
977        message: diagnostic.message,
978        span: diagnostic
979            .span
980            .and_then(|span| source_location_from_hir(span, files)),
981        script: diagnostic.script.map(|script| *script),
982    }
983}
984
985fn integration_failure_report(
986    compiler: CompilerIdentity,
987    catalog: CatalogIdentity,
988    mut diagnostics: Vec<CompileDiagnostic>,
989    error: IntegrationError,
990    files: &[hir::SourceFile],
991) -> CompileReport {
992    diagnostics.push(compile_diagnostic(error, files));
993    CompileReport::failure(
994        compiler,
995        catalog,
996        CompileFailureClass::Integration,
997        diagnostics,
998    )
999}
1000
1001fn compile_frontend_diagnostic(diagnostic: &crate::tooling::Diagnostic) -> CompileDiagnostic {
1002    CompileDiagnostic {
1003        severity: diagnostic.severity,
1004        code: diagnostic.code.clone(),
1005        message: diagnostic.message.clone(),
1006        span: diagnostic.span.clone(),
1007        script: None,
1008    }
1009}
1010
1011fn source_location_from_hir(
1012    span: HirSpan,
1013    files: &[hir::SourceFile],
1014) -> Option<crate::tooling::SourceLocation> {
1015    let path = files.iter().find(|file| file.id == span.file)?.path.clone();
1016    Some(crate::tooling::SourceLocation {
1017        file_id: span.file,
1018        path,
1019        start: crate::diag::Position::new(span.start.line, span.start.col),
1020        end: crate::diag::Position::new(span.end.line, span.end.col),
1021    })
1022}
1023
1024fn normalize_workshop(text: &str) -> String {
1025    if text.is_empty() {
1026        return String::new();
1027    }
1028    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
1029    let mut lines = normalized
1030        .split('\n')
1031        .map(|line| line.trim_end_matches([' ', '\t']).to_owned())
1032        .collect::<Vec<_>>();
1033    while lines.last().is_some_and(|line| line.is_empty()) {
1034        lines.pop();
1035    }
1036    if lines.is_empty() {
1037        String::new()
1038    } else {
1039        lines.join("\n") + "\n"
1040    }
1041}
1042
1043/// A source compile result for ordinary embedding callers.
1044///
1045/// The result contains only emitted text and hook output. Callers that need
1046/// the canonical Workshop `Program` should use the explicit advanced artifact APIs instead.
1047#[derive(Debug, Clone, PartialEq, Eq)]
1048pub struct CompileOutput {
1049    /// Workshop text after a declared post-compile hook, if any.
1050    pub workshop: String,
1051    /// Workshop text emitted before a declared post-compile hook.
1052    pub emitted_workshop: String,
1053    /// Console lines captured while running a declared post-compile hook.
1054    pub hook_console_output: Vec<String>,
1055}
1056
1057/// A validated canonical Workshop `Program` and its emitted Workshop artifact for advanced
1058/// integrations.
1059pub struct CompilationArtifact {
1060    pub wir: Program,
1061    pub emitted: String,
1062    pub catalog_identity: CatalogIdentity,
1063    pub final_output: String,
1064    pub hook_console_output: Vec<String>,
1065    pub(crate) translation_files: Vec<(String, String)>,
1066}
1067
1068impl CompilationArtifact {
1069    fn into_output(self) -> CompileOutput {
1070        CompileOutput {
1071            workshop: self.final_output,
1072            emitted_workshop: self.emitted,
1073            hook_console_output: self.hook_console_output,
1074        }
1075    }
1076}
1077
1078fn hir_span_from_diag(span: crate::diag::Span) -> HirSpan {
1079    HirSpan {
1080        file: span.file,
1081        start: hir::Position {
1082            line: span.start.line,
1083            col: span.start.col,
1084        },
1085        end: hir::Position {
1086            line: span.end.line,
1087            col: span.end.col,
1088        },
1089    }
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::integration::cross_check_manifest;
1095    use super::{COMPILE_SCHEMA_VERSION, CompileFailureClass, CompileStatus, Compiler};
1096    use crate::manifest::Manifest;
1097    use std::path::Path;
1098    use workshop_rs::catalog::{Catalog, Locale};
1099
1100    #[test]
1101    fn catalog_links_are_checked() {
1102        let compiler = Compiler::new().expect("Workshop contract must load");
1103        let identity = compiler.catalog_identity();
1104        assert!(!identity.implementation_version.is_empty());
1105        assert!(compiler.link_report().catalog_ids_checked > 0);
1106        assert!(compiler.link_report().domains_checked > 0);
1107    }
1108
1109    #[test]
1110    fn compiler_instances_share_the_verified_contract_concurrently() {
1111        let instances = std::thread::scope(|scope| {
1112            let handles = (0..8)
1113                .map(|_| {
1114                    scope.spawn(|| {
1115                        let compiler = Compiler::new().expect("Workshop contract must load");
1116                        (
1117                            compiler.catalog as *const Catalog as usize,
1118                            compiler.manifest as *const Manifest as usize,
1119                            compiler.link_report(),
1120                        )
1121                    })
1122                })
1123                .collect::<Vec<_>>();
1124            handles
1125                .into_iter()
1126                .map(|handle| handle.join().expect("compiler construction must not panic"))
1127                .collect::<Vec<_>>()
1128        });
1129
1130        let (catalog, manifest, links) = instances[0];
1131        for (instance_catalog, instance_manifest, instance_links) in instances.into_iter().skip(1) {
1132            assert_eq!(instance_catalog, catalog);
1133            assert_eq!(instance_manifest, manifest);
1134            assert_eq!(instance_links, links);
1135        }
1136    }
1137
1138    #[test]
1139    fn compile_report_is_versioned_and_contains_reproducibility_identity() {
1140        let compiler = Compiler::new().unwrap();
1141        let report = compiler.compile_source_report_with_locale(
1142            "rule \"report\":\n    @Event global\n    disableInspector()\n",
1143            "report.opy",
1144            Path::new("."),
1145            &Locale::new("en-US"),
1146        );
1147        assert_eq!(report.schema_version, COMPILE_SCHEMA_VERSION);
1148        assert_eq!(report.compiler.name, "opy-rs");
1149        assert_eq!(report.catalog, compiler.catalog_identity());
1150        assert_eq!(report.compile.status, CompileStatus::Success);
1151        assert_eq!(report.compile.exit_code, 0);
1152        assert!(report.compile.diagnostics.is_empty());
1153        assert_eq!(
1154            report.compile.workshop,
1155            report
1156                .compile
1157                .workshop_exact
1158                .trim_end_matches('\n')
1159                .to_owned()
1160                + "\n"
1161        );
1162        assert!(serde_json::to_value(report).unwrap()["catalog"]["catalog-version"].is_string());
1163    }
1164
1165    #[test]
1166    fn compile_report_preserves_frontend_failure_class_and_source_path() {
1167        let compiler = Compiler::new().unwrap();
1168        let report = compiler.compile_source_report_with_locale(
1169            "rule \"broken\":\n    @Event global\n    missing()\n",
1170            "broken.opy",
1171            Path::new("."),
1172            &Locale::new("en-US"),
1173        );
1174        assert_eq!(report.compile.status, CompileStatus::Failure);
1175        assert_eq!(
1176            report.compile.failure_class,
1177            Some(CompileFailureClass::Frontend)
1178        );
1179        assert_eq!(report.compile.exit_code, 1);
1180        let diagnostic = &report.compile.diagnostics[0];
1181        assert_eq!(diagnostic.code, "unknown-action");
1182        assert_eq!(diagnostic.span.as_ref().unwrap().path, "broken.opy");
1183    }
1184
1185    #[test]
1186    fn compile_report_preserves_integration_failure_class_and_source_path() {
1187        let compiler = Compiler::new().unwrap();
1188        let report = compiler.compile_source_report_with_locale(
1189            "rule \"broken\":\n    @Event global\n    {\"a\": 1}[\"b\"] = 3\n",
1190            "broken.opy",
1191            Path::new("."),
1192            &Locale::new("en-US"),
1193        );
1194        assert_eq!(report.compile.status, CompileStatus::Failure);
1195        assert_eq!(
1196            report.compile.failure_class,
1197            Some(CompileFailureClass::Integration)
1198        );
1199        assert_eq!(
1200            report.compile.diagnostics[0].span.as_ref().unwrap().path,
1201            "broken.opy"
1202        );
1203    }
1204
1205    #[test]
1206    fn compile_report_preserves_frontend_warnings_on_integration_failure() {
1207        let compiler = Compiler::new().unwrap();
1208        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1209            .join("tests/fixtures/corpus/synthetic/preprocessing");
1210        let report = compiler.compile_source_report_with_locale(
1211            concat!(
1212                "#!include \"shared.opy\"\n",
1213                "#!include \"shared.opy\"\n",
1214                "rule \"broken\":\n",
1215                "    @Event global\n",
1216                "    {\"a\": 1}[\"b\"] = 3\n",
1217            ),
1218            "broken.opy",
1219            &root,
1220            &Locale::new("en-US"),
1221        );
1222        assert_eq!(report.compile.status, CompileStatus::Failure);
1223        assert_eq!(
1224            report.compile.failure_class,
1225            Some(CompileFailureClass::Integration)
1226        );
1227        assert_eq!(report.compile.diagnostics.len(), 2);
1228        assert_eq!(
1229            report.compile.diagnostics[0].severity,
1230            crate::tooling::DiagnosticSeverity::Warning
1231        );
1232        assert_eq!(report.compile.diagnostics[0].code, "w_already_imported");
1233        assert_eq!(
1234            report.compile.diagnostics[1].severity,
1235            crate::tooling::DiagnosticSeverity::Error
1236        );
1237        assert_eq!(
1238            report.compile.diagnostics[1].span.as_ref().unwrap().path,
1239            "broken.opy"
1240        );
1241    }
1242
1243    #[test]
1244    fn suppress_warnings_hides_matching_preprocessing_diagnostics() {
1245        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1246            .join("tests/fixtures/corpus/synthetic/preprocessing");
1247        let outcome = crate::tooling::check(
1248            concat!(
1249                "#!suppressWarnings w_already_imported\n",
1250                "#!include \"shared.opy\"\n",
1251                "#!include \"shared.opy\"\n",
1252                "rule \"r\":\n",
1253                "    @Event global\n",
1254                "    pass\n",
1255            ),
1256            "main.opy",
1257            &root,
1258        );
1259        assert!(outcome.model.is_some());
1260        assert!(outcome.diagnostics.is_empty());
1261    }
1262
1263    #[test]
1264    fn vertical_slice_preserves_source_files_spans_and_emits_workshop() {
1265        let compiler = Compiler::new().unwrap();
1266        let hir = crate::compile(
1267            "globalvar A\nrule \"issue 35 integration\":\n    @Event global\n    A = 1\n    disableInspector()\n",
1268            "compiler-vertical-slice.opy",
1269            Path::new("."),
1270        )
1271        .unwrap();
1272        let artifact = compiler.compile_hir(&hir).unwrap();
1273        assert!(artifact.emitted.contains("Disable Inspector Recording;"));
1274        assert_eq!(artifact.catalog_identity, compiler.catalog_identity());
1275    }
1276
1277    #[test]
1278    fn stale_catalog_links_fail_explicitly() {
1279        // A manifest whose entry links to a catalog id the catalog does not
1280        // declare is invalid input; construct it before `Manifest::load`, the
1281        // supported custom-manifest boundary, rather than mutating validated
1282        // state (#455).
1283        let mut data: serde_json::Value =
1284            serde_json::from_str(crate::manifest::MANIFEST_DATA).unwrap();
1285        data["functions"][0]["catalogId"] = serde_json::json!("missing-catalog-id");
1286        let stale = Manifest::load(
1287            &serde_json::to_string(&data).unwrap(),
1288            crate::manifest::PROBES_DATA,
1289        )
1290        .unwrap();
1291        let error = cross_check_manifest(&stale, &Catalog::builtin().unwrap()).unwrap_err();
1292        assert_eq!(error.diagnostic.code, "catalog-link-missing");
1293    }
1294
1295    #[test]
1296    fn while_lowering_is_source_attributed() {
1297        let compiler = Compiler::new().unwrap();
1298        let hir = crate::compile(
1299            "rule \"while\":\n    @Event global\n    while true:\n        disableInspector()\n",
1300            "while.opy",
1301            Path::new("."),
1302        )
1303        .unwrap();
1304        let artifact = compiler.compile_hir(&hir).unwrap();
1305        let rule = artifact.wir.rules.first().unwrap();
1306        assert!(matches!(
1307            rule.actions.first(),
1308            Some(workshop_rs::Action::While { .. })
1309        ));
1310        assert!(artifact.emitted.contains("While(True);"));
1311    }
1312
1313    #[test]
1314    fn expanded_control_flow_actions_keep_their_originating_spans() {
1315        let compiler = Compiler::new().unwrap();
1316        let hir = crate::compile(
1317            "globalvar value = 1\nrule \"if\":\n    @Event global\n    if true:\n        wait(1)\n    disableInspector()\n",
1318            "control-flow-provenance.opy",
1319            Path::new("."),
1320        )
1321        .unwrap();
1322        let artifact = compiler.compile_hir(&hir).unwrap();
1323
1324        assert_eq!(artifact.wir.action_span(0, 0).unwrap().start.line, 1);
1325        assert_eq!(
1326            artifact
1327                .wir
1328                .action_argument_span(0, 0, 0)
1329                .unwrap()
1330                .start
1331                .line,
1332            1
1333        );
1334        assert_eq!(artifact.wir.rules[1].actions.len(), 4);
1335        assert_eq!(artifact.wir.action_span(1, 0).unwrap().start.line, 4);
1336        assert_eq!(artifact.wir.action_span(1, 1).unwrap().start.line, 5);
1337        assert_eq!(
1338            artifact
1339                .wir
1340                .action_argument_span(1, 1, 0)
1341                .unwrap()
1342                .start
1343                .line,
1344            5
1345        );
1346        assert_eq!(artifact.wir.action_span(1, 2).unwrap().start.line, 4);
1347    }
1348
1349    #[test]
1350    fn range_argument_provenance_uses_canonical_positions() {
1351        let compiler = Compiler::new().unwrap();
1352        let hir = crate::compile(
1353            "globalvar value\nrule \"range\":\n    @Event global\n    for value in range(3):\n        wait(1)\n",
1354            "range-provenance.opy",
1355            Path::new("."),
1356        )
1357        .unwrap();
1358        let artifact = compiler.compile_hir(&hir).unwrap();
1359
1360        assert_eq!(artifact.wir.action_span(0, 0).unwrap().start.line, 4);
1361        assert!(artifact.wir.action_argument_span(0, 0, 0).is_none());
1362        assert_eq!(
1363            artifact
1364                .wir
1365                .action_argument_span(0, 0, 1)
1366                .unwrap()
1367                .start
1368                .line,
1369            4
1370        );
1371        assert!(artifact.wir.action_argument_span(0, 0, 2).is_none());
1372    }
1373
1374    #[test]
1375    fn structural_subroutines_lower_to_canonical_wir() {
1376        let compiler = Compiler::new().unwrap();
1377        let hir = crate::compile(
1378            "globalvar score\nsubroutine showStatus\ndef showStatus():\n    @Name \"Friendly\"\n    @SuppressWarnings unusedVariable\n    disableInspector()\nrule \"caller\":\n    @Event global\n    showStatus()\n",
1379            "structure.opy",
1380            Path::new("."),
1381        )
1382        .unwrap();
1383        let artifact = compiler.compile_hir(&hir).unwrap();
1384        let subroutine = artifact.wir.subroutines.first().unwrap();
1385        assert_eq!(subroutine.name, "showStatus");
1386        assert_eq!(artifact.wir.rules.len(), 2);
1387        let subroutine_rule = artifact.wir.rules.first().unwrap();
1388        let workshop_rs::Event::Subroutine(subroutine_name) = &subroutine_rule.event else {
1389            panic!("expected a subroutine event");
1390        };
1391        assert_eq!(subroutine_name, "showStatus");
1392        assert!(matches!(
1393            artifact.wir.rules.get(1).unwrap().actions.first(),
1394            Some(workshop_rs::Action::CallSubroutine { .. })
1395        ));
1396        assert!(artifact.emitted.contains("Subroutine Friendly"));
1397    }
1398
1399    #[test]
1400    fn player_event_filters_resolve_through_canonical_catalog() {
1401        let compiler = Compiler::new().unwrap();
1402        let hir = crate::compile(
1403            "rule \"joined\":\n    @Event playerJoined\n    @Team 1\n    @Slot 2\n    disableInspector()\n",
1404            "filters.opy",
1405            Path::new("."),
1406        )
1407        .unwrap();
1408        let artifact = compiler.compile_hir(&hir).unwrap();
1409        assert!(matches!(
1410            &artifact.wir.rules.first().unwrap().event,
1411            workshop_rs::Event::Player {
1412                kind: workshop_rs::PlayerEventKind::Joined,
1413                team: workshop_rs::EventTeam::Team1,
1414                target: workshop_rs::EventTarget::Slot(2),
1415            }
1416        ));
1417        assert!(artifact.emitted.contains("Player Joined Match;"));
1418    }
1419
1420    #[test]
1421    fn hero_event_filters_accept_legacy_aliases() {
1422        let compiler = Compiler::new().unwrap();
1423        let hir = crate::compile(
1424            "rule \"hero\":\n    @Event eachPlayer\n    @Hero soldier\n    disableInspector()\n",
1425            "hero-filter.opy",
1426            Path::new("."),
1427        )
1428        .unwrap();
1429        let artifact = compiler.compile_hir(&hir).unwrap();
1430        assert!(matches!(
1431            &artifact.wir.rules.first().unwrap().event,
1432            workshop_rs::Event::EachPlayerWithFilters {
1433                target: workshop_rs::EventTarget::Hero(hero),
1434                ..
1435            } if hero == "SOLDIER_76"
1436        ));
1437    }
1438
1439    #[test]
1440    fn explicit_indices_are_reserved_before_deterministic_allocation() {
1441        let compiler = Compiler::new().unwrap();
1442        let hir = crate::compile(
1443            "globalvar first\nglobalvar reserved 0\nglobalvar next\nrule \"indices\":\n    @Event global\n    disableInspector()\n",
1444            "indices.opy",
1445            Path::new("."),
1446        )
1447        .unwrap();
1448        let artifact = compiler.compile_hir(&hir).unwrap();
1449        let by_name = artifact
1450            .wir
1451            .global_variables
1452            .iter()
1453            .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1454            .collect::<std::collections::BTreeMap<_, _>>();
1455        assert_eq!(
1456            by_name,
1457            std::collections::BTreeMap::from([("first", 1), ("reserved", 0), ("next", 2)])
1458        );
1459        // Variable tables are emitted in Workshop index order.
1460        let indices = artifact
1461            .wir
1462            .global_variables
1463            .iter()
1464            .map(|variable| variable.index.unwrap())
1465            .collect::<Vec<_>>();
1466        assert_eq!(indices, vec![0, 1, 2]);
1467    }
1468
1469    #[test]
1470    fn implicit_default_variables_use_reference_fixed_slots() {
1471        let compiler = Compiler::new().unwrap();
1472        let hir = crate::compile(
1473            r#"
1474globalvar timer
1475globalvar extra 5
1476
1477rule "implicit":
1478    @Event global
1479    A = timer + 1
1480    B = A
1481    B += 2
1482    A[0] = 7
1483    DX = B * A
1484"#,
1485            "implicit.opy",
1486            Path::new("."),
1487        )
1488        .unwrap();
1489        let artifact = compiler.compile_hir(&hir).unwrap();
1490        let globals = artifact
1491            .wir
1492            .global_variables
1493            .iter()
1494            .map(|variable| (variable.name.clone(), variable.index.unwrap()))
1495            .collect::<Vec<_>>();
1496        // The implicit A (0), B (1), and DX (127) names keep their fixed
1497        // Workshop slots and reserve them for declared-variable allocation
1498        // (pinned OverPy evidence); `timer` auto-allocates around them and
1499        // `extra` keeps its explicit index.
1500        assert_eq!(
1501            globals,
1502            vec![
1503                ("A".to_string(), 0),
1504                ("B".to_string(), 1),
1505                ("timer".to_string(), 2),
1506                ("extra".to_string(), 5),
1507                ("DX".to_string(), 127),
1508            ]
1509        );
1510        assert!(
1511            artifact
1512                .emitted
1513                .contains("Set Global Variable(A, Add(Global.timer, 1));")
1514        );
1515        assert!(
1516            artifact
1517                .emitted
1518                .contains("Set Global Variable(B, Global.A);")
1519        );
1520        assert!(
1521            artifact
1522                .emitted
1523                .contains("Modify Global Variable(B, Add, 2);")
1524        );
1525        assert!(
1526            artifact
1527                .emitted
1528                .contains("Set Global Variable At Index(A, 0, 7);")
1529        );
1530        assert!(
1531            artifact
1532                .emitted
1533                .contains("Set Global Variable(DX, Multiply(Global.B, Global.A));")
1534        );
1535    }
1536
1537    #[test]
1538    fn implicit_default_variable_slot_collision_is_source_attributed() {
1539        let compiler = Compiler::new().unwrap();
1540        let hir = crate::compile(
1541            "globalvar x 0\nrule \"collision\":\n    @Event global\n    x = 1\n    A = 2\n",
1542            "collision.opy",
1543            Path::new("."),
1544        )
1545        .unwrap();
1546        let error = match compiler.compile_hir(&hir) {
1547            Ok(_) => panic!("slot collision unexpectedly succeeded"),
1548            Err(error) => error,
1549        };
1550        assert_eq!(error.diagnostic.code, "index-collision");
1551        assert_eq!(error.diagnostic.span.unwrap().start.line, 5);
1552        assert!(error.diagnostic.message.contains("'A' and 'x'"));
1553    }
1554
1555    #[test]
1556    fn implicit_default_player_variables_use_independent_reference_slots() {
1557        let compiler = Compiler::new().unwrap();
1558        let hir = crate::compile(
1559            r#"
1560playervar declaredPlayer
1561
1562rule "implicit player variables":
1563    @Event eachPlayer
1564    A = 1
1565    eventPlayer.A = 1
1566    eventPlayer.A += 2
1567    eventPlayer.E = eventPlayer.A
1568    eventPlayer.DX = eventPlayer.E
1569    eventPlayer.declaredPlayer = eventPlayer.A
1570"#,
1571            "implicit-player.opy",
1572            Path::new("."),
1573        )
1574        .unwrap();
1575        let artifact = compiler.compile_hir(&hir).unwrap();
1576        let globals = artifact
1577            .wir
1578            .global_variables
1579            .iter()
1580            .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1581            .collect::<std::collections::BTreeMap<_, _>>();
1582        let players = artifact
1583            .wir
1584            .player_variables
1585            .iter()
1586            .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1587            .collect::<std::collections::BTreeMap<_, _>>();
1588        assert_eq!(globals.get("A"), Some(&0));
1589        assert_eq!(players.get("A"), Some(&0));
1590        assert_eq!(players.get("declaredPlayer"), Some(&1));
1591        assert_eq!(players.get("E"), Some(&4));
1592        assert_eq!(players.get("DX"), Some(&127));
1593        assert!(
1594            artifact
1595                .emitted
1596                .contains("Set Player Variable(Event Player, A, 1);")
1597        );
1598        assert!(
1599            artifact
1600                .emitted
1601                .contains("Modify Player Variable(Event Player, A, Add, 2);")
1602        );
1603        assert!(
1604            artifact
1605                .emitted
1606                .contains("Set Player Variable(Event Player, E, (Event Player).A);")
1607        );
1608    }
1609
1610    #[test]
1611    fn implicit_default_player_slot_collision_is_source_attributed() {
1612        let compiler = Compiler::new().unwrap();
1613        let hir = crate::compile(
1614            "playervar declared 0\nrule \"collision\":\n    @Event eachPlayer\n    eventPlayer.A = 1\n",
1615            "player-collision.opy",
1616            Path::new("."),
1617        )
1618        .unwrap();
1619        let error = match compiler.compile_hir(&hir) {
1620            Ok(_) => panic!("player slot collision unexpectedly succeeded"),
1621            Err(error) => error,
1622        };
1623        assert_eq!(error.diagnostic.code, "index-collision");
1624        assert!(
1625            error
1626                .diagnostic
1627                .message
1628                .contains("player variables 'A' and 'declared'")
1629        );
1630        assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
1631    }
1632
1633    #[test]
1634    fn power_augmented_assignment_lowers_from_source() {
1635        let compiler = Compiler::new().unwrap();
1636        let hir = crate::compile(
1637            "globalvar g\nrule \"power\":\n    @Event global\n    g = 2\n    g **= 3\n",
1638            "power.opy",
1639            Path::new("."),
1640        )
1641        .unwrap();
1642        let artifact = compiler.compile_hir(&hir).unwrap();
1643        assert!(artifact.emitted.contains("Set Global Variable(g, 2);"));
1644        assert!(
1645            artifact
1646                .emitted
1647                .contains("Modify Global Variable(g, Raise To Power, 3);")
1648        );
1649    }
1650
1651    #[test]
1652    fn opy_hex_numbers_are_normalized_at_the_wir_boundary() {
1653        let compiler = Compiler::new().unwrap();
1654        let hir = crate::compile(
1655            "globalvar large = 0x124BC\nglobalvar small = 0x124\nglobalvar scientific = 1e10\n",
1656            "numbers.opy",
1657            Path::new("."),
1658        )
1659        .unwrap();
1660        let artifact = compiler.compile_hir(&hir).unwrap();
1661        assert!(
1662            artifact
1663                .emitted
1664                .contains("Set Global Variable(large, 74940);")
1665        );
1666        assert!(
1667            artifact
1668                .emitted
1669                .contains("Set Global Variable(small, 292);")
1670        );
1671        assert!(
1672            artifact
1673                .emitted
1674                .contains("Set Global Variable(scientific, 10000000000);")
1675        );
1676        assert!(!artifact.emitted.contains("0x124BC"));
1677        assert!(!artifact.emitted.contains("0x124"));
1678    }
1679
1680    #[test]
1681    fn literal_dict_lookup_lowers_to_the_selected_value() {
1682        let compiler = Compiler::new().unwrap();
1683        let hir = crate::compile(
1684            "globalvar total\nrule \"negative\":\n    @Event global\n    total = {\"a\": 1, \"b\": 2}[\"a\"]\n",
1685            "negative.opy",
1686            Path::new("."),
1687        )
1688        .unwrap();
1689        let artifact = compiler
1690            .compile_hir(&hir)
1691            .expect("literal dict lookup should lower");
1692        assert!(artifact.emitted.contains("Set Global Variable(total, 1);"));
1693    }
1694
1695    #[test]
1696    fn auto_allocation_fills_free_slots_below_early_explicit_indices() {
1697        let compiler = Compiler::new().unwrap();
1698        let hir = crate::compile(
1699            r#"
1700globalvar reserved 5
1701globalvar auto1
1702globalvar auto2
1703
1704rule "allocation":
1705    @Event global
1706    auto1 = 1
1707    auto2 = 2
1708    B = 3
1709"#,
1710            "allocation.opy",
1711            Path::new("."),
1712        )
1713        .unwrap();
1714        let artifact = compiler.compile_hir(&hir).unwrap();
1715        let by_name = artifact
1716            .wir
1717            .global_variables
1718            .iter()
1719            .map(|variable| (variable.name.clone(), variable.index.unwrap()))
1720            .collect::<std::collections::BTreeMap<_, _>>();
1721        // The implicit B keeps its fixed slot 1; the auto-allocated variables
1722        // fill the remaining free slots below the explicit 5 instead of
1723        // jumping past it, matching the pinned OverPy oracle (slot 0 stays
1724        // free here because the implicit A is never used).
1725        assert_eq!(
1726            by_name,
1727            std::collections::BTreeMap::from([
1728                ("B".to_string(), 1),
1729                ("auto1".to_string(), 0),
1730                ("auto2".to_string(), 2),
1731                ("reserved".to_string(), 5),
1732            ])
1733        );
1734    }
1735
1736    #[test]
1737    fn power_expressions_lower_through_the_canonical_contract() {
1738        let compiler = Compiler::new().unwrap();
1739        let hir = crate::compile(
1740            "globalvar a = [2, 4]\nglobalvar out\nrule \"power\":\n    @Event global\n    out = a ** 2\n    a **= 2\n    a[0] **= 2\n",
1741            "power.opy",
1742            Path::new("."),
1743        )
1744        .unwrap();
1745        let artifact = compiler.compile_hir(&hir).unwrap();
1746        assert!(
1747            artifact
1748                .emitted
1749                .contains("Set Global Variable(out, Raise To Power(Global.a, 2));")
1750        );
1751        assert!(
1752            artifact
1753                .emitted
1754                .contains("Modify Global Variable(a, Raise To Power, 2);")
1755        );
1756        assert!(
1757            artifact
1758                .emitted
1759                .contains("Modify Global Variable At Index(a, 0, Raise To Power, 2);")
1760        );
1761    }
1762
1763    #[test]
1764    fn unsupported_rule_metadata_is_explicit_and_source_attributed() {
1765        let compiler = Compiler::new().unwrap();
1766        let hir = crate::compile(
1767            "rule \"metadata\":\n    @Event global\n    @NewPage \"section\"\n    disableInspector()\n",
1768            "metadata.opy",
1769            Path::new("."),
1770        )
1771        .unwrap();
1772        let error = match compiler.compile_hir(&hir) {
1773            Ok(_) => panic!("unsupported metadata unexpectedly succeeded"),
1774            Err(error) => error,
1775        };
1776        assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
1777        assert_eq!(error.diagnostic.span.unwrap().start.line, 3);
1778    }
1779
1780    #[test]
1781    fn compiler_structure_matches_the_pinned_oracle() {
1782        let compiler = Compiler::new().unwrap();
1783        let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
1784            .join("tests/fixtures/corpus/synthetic/compiler-structure");
1785        let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
1786        let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
1787        let artifact = compiler.compile_hir(&hir).unwrap();
1788        let oracle: serde_json::Value =
1789            serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
1790                .unwrap();
1791        let oracle_workshop = oracle["compile"]["workshop"].as_str().unwrap();
1792        let oracle_wir = workshop_rs::parser::parse(
1793            oracle_workshop,
1794            &Catalog::builtin().unwrap(),
1795            &Locale::new("en-US"),
1796        )
1797        .unwrap();
1798        assert!(workshop_rs::roundtrip::equivalent(
1799            &artifact.wir,
1800            &oracle_wir
1801        ));
1802
1803        assert!(oracle_workshop.contains("0: reserved"));
1804        assert!(oracle_workshop.contains("1: first"));
1805        assert!(oracle_workshop.contains("2: explicit"));
1806        assert!(oracle_workshop.contains("3: next"));
1807        assert!(oracle_workshop.contains("0: helper"));
1808        assert!(oracle_workshop.contains("Subroutine;\n        helper;"));
1809        assert!(oracle_workshop.contains("Player Joined Match;\n        Team 1;\n        Slot 2;"));
1810
1811        let indices = artifact
1812            .wir
1813            .global_variables
1814            .iter()
1815            .map(|variable| variable.index.unwrap())
1816            .collect::<Vec<_>>();
1817        assert_eq!(indices, vec![0, 1, 2, 3]);
1818        assert_eq!(artifact.wir.subroutines.first().unwrap().name, "helper");
1819        assert!(artifact.emitted.contains("[Source] renamed helper"));
1820        assert!(matches!(
1821            artifact.wir.rules.get(1).unwrap().event,
1822            workshop_rs::Event::Player {
1823                kind: workshop_rs::PlayerEventKind::Joined,
1824                team: workshop_rs::EventTeam::Team1,
1825                target: workshop_rs::EventTarget::Slot(2),
1826            }
1827        ));
1828    }
1829
1830    #[test]
1831    fn assignments_and_modifications_lower_to_canonical_wir() {
1832        let compiler = Compiler::new().unwrap();
1833        let hir = crate::compile(
1834            r#"
1835globalvar g1
1836globalvar g2
1837playervar p1
1838playervar p2 = [1, 2, 3]
1839
1840rule "assignments":
1841    @Event eachPlayer
1842    g1 = 10
1843    g1 += 5
1844    g1 -= 2
1845    g1 *= 3
1846    g1 /= 2
1847    g1 %= 4
1848    g2 = [1, 2, 3]
1849    g2[0] = 99
1850    g2[1] += 1
1851    eventPlayer.p1 = 42
1852    eventPlayer.p1 += 8
1853    eventPlayer.p1 *= 2
1854    eventPlayer.p2[2] = 7
1855    eventPlayer.p2[0] -= 3
1856"#,
1857            "assign.opy",
1858            Path::new("."),
1859        )
1860        .unwrap();
1861        let artifact = compiler.compile_hir(&hir).unwrap();
1862        assert!(artifact.emitted.contains("Set Global Variable(g1, 10);"));
1863        assert!(
1864            artifact
1865                .emitted
1866                .contains("Modify Global Variable(g1, Add, 5);")
1867        );
1868        assert!(
1869            artifact
1870                .emitted
1871                .contains("Modify Global Variable(g1, Subtract, 2);")
1872        );
1873        assert!(
1874            artifact
1875                .emitted
1876                .contains("Modify Global Variable(g1, Multiply, 3);")
1877        );
1878        assert!(
1879            artifact
1880                .emitted
1881                .contains("Modify Global Variable(g1, Divide, 2);")
1882        );
1883        assert!(
1884            artifact
1885                .emitted
1886                .contains("Modify Global Variable(g1, Modulo, 4);")
1887        );
1888        assert!(
1889            artifact
1890                .emitted
1891                .contains("Set Global Variable At Index(g2, 0, 99);")
1892        );
1893        assert!(
1894            artifact
1895                .emitted
1896                .contains("Modify Global Variable At Index(g2, 1, Add, 1);")
1897        );
1898        assert!(
1899            artifact
1900                .emitted
1901                .contains("Set Player Variable(Event Player, p1, 42);")
1902        );
1903        assert!(
1904            artifact
1905                .emitted
1906                .contains("Modify Player Variable(Event Player, p1, Add, 8);")
1907        );
1908        assert!(
1909            artifact
1910                .emitted
1911                .contains("Modify Player Variable(Event Player, p1, Multiply, 2);")
1912        );
1913        assert!(artifact.wir.rules[1].actions.iter().any(|action| matches!(
1914            action,
1915            workshop_rs::Action::Call { name, args }
1916                if name == "setPlayerVariableAtIndex"
1917                    && matches!(
1918                        args.as_slice(),
1919                        [
1920                            workshop_rs::Value::PlayerVariable { player, variable },
1921                            workshop_rs::Value::Number(index),
1922                            workshop_rs::Value::Number(value),
1923                        ] if matches!(player.as_ref(), workshop_rs::Value::EventPlayer)
1924                            && variable == "p2"
1925                            && *index == 2.0
1926                            && *value == 7.0
1927                    )
1928        )));
1929        assert!(artifact.wir.rules[1].actions.iter().any(|action| matches!(
1930            action,
1931            workshop_rs::Action::Call { name, args }
1932                if name == "modifyPlayerVariableAtIndex"
1933                    && matches!(
1934                        args.as_slice(),
1935                        [
1936                            workshop_rs::Value::PlayerVariable { player, variable },
1937                            workshop_rs::Value::Number(index),
1938                            workshop_rs::Value::Call { name: operation, .. },
1939                            workshop_rs::Value::Number(value),
1940                        ] if matches!(player.as_ref(), workshop_rs::Value::EventPlayer)
1941                            && variable == "p2"
1942                            && *index == 0.0
1943                            && operation == "subtract"
1944                            && *value == 3.0
1945                    )
1946        )));
1947
1948        let rule = artifact.wir.rules.get(1).unwrap();
1949        assert!(matches!(
1950            rule.actions.first(),
1951            Some(workshop_rs::Action::SetGlobalVariable { variable, .. }) if variable == "g1"
1952        ));
1953        assert!(matches!(
1954            rule.actions.get(7),
1955            Some(workshop_rs::Action::Call { .. })
1956        ));
1957    }
1958
1959    #[test]
1960    fn expressions_and_values_lower_to_canonical_wir() {
1961        let compiler = Compiler::new().unwrap();
1962        let hir = crate::compile(
1963            r#"
1964enum Consts:
1965    BASE
1966
1967globalvar total
1968globalvar arr = [1, 2, 3]
1969globalvar pos = vect(1, 2, 3)
1970
1971rule "expressions":
1972    @Event global
1973    @Condition total == 0
1974    @Condition not (pos == vect(0, 0, 0))
1975    @Condition 2 in arr
1976    total = Consts.BASE + arr[1] * 2 - (10 / 2) + (5 % 2)
1977    print("Total: {}".format(total))
1978    debug(pos)
1979"#,
1980            "expr.opy",
1981            Path::new("."),
1982        )
1983        .unwrap();
1984        let artifact = compiler.compile_hir(&hir).unwrap();
1985        assert!(artifact.emitted.contains("Global.total == 0;"));
1986        // `not (pos == vect(0, 0, 0))` lowers to the negated comparison,
1987        // mirroring the pinned OverPy oracle.
1988        assert!(artifact.emitted.contains("Global.pos != Vector(0, 0, 0);"));
1989        assert!(
1990            artifact
1991                .emitted
1992                .contains("Array Contains(Global.arr, 2) == True;")
1993        );
1994        assert!(
1995            artifact
1996                .emitted
1997                .contains("Custom String(\"Total: {0}\", Global.total)")
1998        );
1999    }
2000
2001    #[test]
2002    fn pass_is_supported_as_source_level_noop() {
2003        let compiler = Compiler::new().unwrap();
2004        let hir = crate::compile(
2005            r#"
2006subroutine emptySub
2007
2008def emptySub():
2009    pass
2010
2011rule "empty rule":
2012    @Event global
2013    pass
2014"#,
2015            "pass.opy",
2016            Path::new("."),
2017        )
2018        .unwrap();
2019        let artifact = compiler.compile_hir(&hir).unwrap();
2020        // Rules and subroutines with nothing to run are dropped, as the
2021        // pinned OverPy does.
2022        assert!(artifact.wir.rules.is_empty());
2023    }
2024
2025    #[test]
2026    fn optimized_non_subroutine_wait_only_rule_matches_overpy_noop_elision() {
2027        let compiler = Compiler::new().unwrap();
2028        let hir = crate::compile(
2029            r#"
2030rule "wait-only":
2031    @Event eachPlayer
2032    wait(0.032)
2033"#,
2034            "wait-only.opy",
2035            Path::new("."),
2036        )
2037        .unwrap();
2038        let artifact = compiler.compile_hir(&hir).unwrap();
2039        assert!(artifact.wir.rules.is_empty());
2040    }
2041
2042    #[test]
2043    fn variable_initializers_synthesize_initialize_rules() {
2044        let compiler = Compiler::new().unwrap();
2045        let hir = crate::compile(
2046            r#"
2047globalvar j = 5
2048globalvar h = 0
2049globalvar k = 0.0
2050playervar p = 7
2051playervar q = 0
2052
2053rule "main":
2054    @Event global
2055    disableInspector()
2056"#,
2057            "init.opy",
2058            Path::new("."),
2059        )
2060        .unwrap();
2061        let artifact = compiler.compile_hir(&hir).unwrap();
2062        assert_eq!(
2063            artifact.wir.rules.first().unwrap().name,
2064            "Initialize global variables"
2065        );
2066        assert_eq!(
2067            artifact.wir.rules.get(1).unwrap().name,
2068            "Initialize player variables"
2069        );
2070        assert_eq!(artifact.wir.rules.get(2).unwrap().name, "main");
2071        assert!(artifact.emitted.contains("Set Global Variable(j, 5);"));
2072        assert!(artifact.emitted.contains("Set Global Variable(k, 0);"));
2073        assert!(!artifact.emitted.contains("Set Global Variable(h,"));
2074        assert!(
2075            artifact
2076                .emitted
2077                .contains("Set Player Variable(Event Player, p, 7);")
2078        );
2079        assert!(
2080            !artifact
2081                .emitted
2082                .contains("Set Player Variable(Event Player, q,")
2083        );
2084    }
2085
2086    #[test]
2087    fn settings_lower_through_workshop_owned_emission() {
2088        let compiler = Compiler::new().unwrap();
2089        let fixture =
2090            Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/corpus/synthetic/settings");
2091        let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
2092        let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
2093        let artifact = compiler.compile_hir(&hir).unwrap();
2094        let oracle: serde_json::Value =
2095            serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
2096                .unwrap();
2097        let expected = oracle["compile"]["workshop"]
2098            .as_str()
2099            .unwrap()
2100            .split("\n\nrule")
2101            .next()
2102            .unwrap();
2103        let actual = artifact.emitted.split("\n\nrule").next().unwrap();
2104        let oracle_wir = workshop_rs::parser::parse(
2105            oracle["compile"]["workshop"].as_str().unwrap(),
2106            &Catalog::builtin().unwrap(),
2107            &Locale::new("en-US"),
2108        )
2109        .unwrap();
2110        assert!(workshop_rs::roundtrip::equivalent(
2111            &artifact.wir,
2112            &oracle_wir
2113        ));
2114        assert_eq!(
2115            normalize_workshop_structural_whitespace(actual),
2116            normalize_workshop_structural_whitespace(expected)
2117        );
2118    }
2119
2120    #[test]
2121    fn unsupported_locale_has_no_fabricated_source_span() {
2122        let compiler = Compiler::new().unwrap();
2123        let hir = crate::compile(
2124            "#!translations en\nrule \"r\":\n    @Event global\n    pass\n",
2125            "locale.opy",
2126            Path::new("."),
2127        )
2128        .unwrap();
2129        let error = match compiler.compile_hir_with_locale(&hir, &Locale::new("xx-XX")) {
2130            Ok(_) => panic!("unsupported locale unexpectedly compiled"),
2131            Err(error) => error,
2132        };
2133        assert_eq!(error.diagnostic.code, "locale-unsupported");
2134        assert_eq!(error.diagnostic.span, None);
2135    }
2136
2137    #[test]
2138    fn locale_selection_emits_catalog_localized_workshop() {
2139        let compiler = Compiler::new().unwrap();
2140        let hir = crate::compile(
2141            "rule \"locale\":\n    @Event global\n    disableInspector()\n",
2142            "locale.opy",
2143            Path::new("."),
2144        )
2145        .unwrap();
2146        let artifact = compiler
2147            .compile_hir_with_locale(&hir, &Locale::new("zh-CN"))
2148            .unwrap();
2149        assert!(artifact.emitted.contains("规则 (\"locale\")"));
2150        assert!(artifact.emitted.contains("禁用查看器录制"));
2151    }
2152
2153    #[test]
2154    fn unsupported_output_directives_fail_at_their_source_anchor() {
2155        let compiler = Compiler::new().unwrap();
2156        let hir = crate::compile(
2157            "#!writeToOutputFile\nrule \"r\":\n    @Event global\n    pass\n",
2158            "directives.opy",
2159            Path::new("."),
2160        )
2161        .unwrap();
2162        let error = match compiler.compile_hir(&hir) {
2163            Ok(_) => panic!("backend directive unexpectedly compiled"),
2164            Err(error) => error,
2165        };
2166        assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
2167        assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
2168    }
2169
2170    #[test]
2171    fn optimizer_directives_remain_non_blocking_presentation_controls() {
2172        let compiler = Compiler::new().unwrap();
2173        let hir = crate::compile(
2174            "#!disableOptimizations\nrule \"r\":\n    @Event global\n    pass\n",
2175            "optimization.opy",
2176            Path::new("."),
2177        )
2178        .unwrap();
2179        compiler.compile_hir(&hir).unwrap();
2180    }
2181
2182    #[test]
2183    fn strict_optimization_cases_are_preserved_by_native_lowering() {
2184        let compiler = Compiler::new().unwrap();
2185        let hir = crate::compile(
2186            "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",
2187            "strict.opy",
2188            Path::new("."),
2189        )
2190        .unwrap();
2191        let artifact = compiler.compile_hir(&hir).unwrap();
2192        assert!(artifact.emitted.contains("Add(Global.A, 0)"));
2193        assert!(artifact.emitted.contains("Multiply(Global.A, 0)"));
2194        assert!(artifact.emitted.contains("Multiply(Global.A, 1)"));
2195        assert!(
2196            artifact
2197                .emitted
2198                .contains("Compare(Custom String(\"am\"), ==, Custom String(\"**\"))")
2199        );
2200    }
2201
2202    #[test]
2203    fn compression_alphabet_policy_uses_a_shared_global() {
2204        let compiler = Compiler::new().unwrap();
2205        let hir = crate::compile(
2206            "globalvar values = compressed([1, 2, 3])\n\n#!useVariableForCompressionAlphabet\nrule \"compression\":\n    @Event global\n    print(values)\n",
2207            "compression.opy",
2208            Path::new("."),
2209        )
2210        .unwrap();
2211        let artifact = compiler.compile_hir(&hir).unwrap();
2212        assert!(artifact.emitted.contains("127: __compressionAlphabet__"));
2213        assert!(artifact.emitted.contains("Global.__compressionAlphabet__"));
2214    }
2215
2216    #[test]
2217    fn replacement_directives_lower_when_size_optimization_is_active() {
2218        let compiler = Compiler::new().unwrap();
2219        let hir = crate::compile(
2220            "#!optimizeForSize\n#!replace0ByCapturePercentage\nrule \"r\":\n    @Event global\n    print(0)\n",
2221            "directives.opy",
2222            Path::new("."),
2223        )
2224        .unwrap();
2225        let artifact = compiler.compile_hir(&hir).unwrap();
2226        assert!(artifact.emitted.contains("Point Capture Percentage"));
2227    }
2228
2229    #[test]
2230    fn debug_element_count_emits_sorted_rule_condition_and_action_comments() {
2231        let compiler = Compiler::new().unwrap();
2232        let hir = crate::compile(
2233            "#!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",
2234            "debug.opy",
2235            Path::new("."),
2236        )
2237        .unwrap();
2238        let artifact = compiler.compile_hir(&hir).unwrap();
2239        let report = artifact.wir.element_count(compiler.catalog).unwrap();
2240        let small = report
2241            .rules
2242            .iter()
2243            .find(|rule| rule.name == "small")
2244            .unwrap();
2245        let large = report
2246            .rules
2247            .iter()
2248            .find(|rule| rule.name == "large")
2249            .unwrap();
2250        let large_summary = artifact.emitted.find("   32: rule \"large\"").unwrap();
2251        let small_summary = artifact.emitted.find("   18: rule \"small\"").unwrap();
2252        assert!(large_summary < small_summary);
2253        assert!(
2254            artifact
2255                .emitted
2256                .starts_with("/* Element count: (total 50)\n")
2257        );
2258        assert!(artifact.emitted.contains("//32 elements\nrule (\"large\")"));
2259        assert!(artifact.emitted.contains("//18 elements\nrule (\"small\")"));
2260        assert!(
2261            artifact
2262                .emitted
2263                .contains("Global.value == 1; // 3 elements")
2264        );
2265        assert_eq!(artifact.emitted.matches(" // 3 elements").count(), 2);
2266        assert_eq!(artifact.emitted.matches(" // 14 elements").count(), 3);
2267        let expected_comments = large
2268            .children
2269            .iter()
2270            .chain(&small.children)
2271            .filter(|node| {
2272                matches!(
2273                    node.kind,
2274                    workshop_rs::actions::ElementNodeKind::Condition
2275                        | workshop_rs::actions::ElementNodeKind::Action
2276                )
2277            })
2278            .count();
2279        assert_eq!(artifact.emitted.matches(" // ").count(), expected_comments);
2280    }
2281
2282    #[test]
2283    fn setup_and_initialization_directives_change_forward_output() {
2284        let compiler = Compiler::new().unwrap();
2285        let hir = crate::compile(
2286            "#!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",
2287            "directives.opy",
2288            Path::new("."),
2289        )
2290        .unwrap();
2291        let artifact = compiler.compile_hir(&hir).unwrap();
2292        assert!(artifact.emitted.contains("OverPy <"));
2293        assert!(artifact.emitted.contains("Disable inspector"));
2294        assert!(artifact.emitted.contains("Init globals"));
2295        assert!(artifact.emitted.contains("Init players"));
2296        assert!(artifact.emitted.contains("__holygrail__"));
2297        assert!(
2298            artifact
2299                .emitted
2300                .contains("Custom String(\"{0}fgFF0000FF>ready{0}/fg>\", Global.__holygrail__)")
2301        );
2302    }
2303
2304    #[test]
2305    fn exclude_variables_directive_omits_variable_declarations() {
2306        let compiler = Compiler::new().unwrap();
2307        let hir = crate::compile(
2308            "#!excludeVariablesInCompilation\nglobalvar value 0\nrule \"r\":\n    @Event global\n    pass\n",
2309            "directives.opy",
2310            Path::new("."),
2311        )
2312        .unwrap();
2313        let artifact = compiler.compile_hir(&hir).unwrap();
2314        assert!(!artifact.wir.global_variables.is_empty());
2315        assert!(
2316            !artifact.emitted.contains("variables {"),
2317            "{}",
2318            artifact.emitted
2319        );
2320    }
2321
2322    #[test]
2323    fn post_compile_hook_receives_exact_emitted_workshop() {
2324        let compiler = Compiler::new().unwrap();
2325        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2326        let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n    debug(\"ready\")\n";
2327        let artifact = compiler
2328            .compile_source_with_locale(source, "hook.opy", &root, &Locale::new("en-US"))
2329            .unwrap();
2330        assert!(artifact.emitted.contains("rule (\"setup\")"));
2331        assert!(artifact.final_output.contains("rule (\"transformed\")"));
2332        assert_ne!(artifact.final_output, artifact.emitted);
2333    }
2334
2335    #[test]
2336    fn post_compile_hook_failure_keeps_script_provenance_and_directive_anchor() {
2337        let compiler = Compiler::new().unwrap();
2338        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2339        let source = "#!postCompileHook \"hook-boom.js\"\n\nrule \"setup\":\n    pass\n";
2340        let error = match compiler.compile_source_with_locale(
2341            source,
2342            "hook.opy",
2343            &root,
2344            &Locale::new("en-US"),
2345        ) {
2346            Ok(_) => panic!("failing post-compile hook unexpectedly compiled"),
2347            Err(error) => error,
2348        };
2349        assert_eq!(error.diagnostic.code, "post-compile-hook");
2350        assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
2351        let script = error.diagnostic.script.unwrap();
2352        assert_eq!(script.source_name.as_deref(), Some("hook-boom.js"));
2353        assert_eq!(script.line, Some(1));
2354        assert!(script.stack.unwrap().contains("hook-boom.js:1"));
2355    }
2356
2357    #[test]
2358    fn shape_preserving_post_compile_output_still_maps() {
2359        let compiler = Compiler::new().unwrap();
2360        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2361        let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n    debug(\"ready\")\n";
2362        let (report, mapped) =
2363            compiler.compile_source_report_mapped_with_language(source, "hook.opy", &root, "en-US");
2364        assert_eq!(report.compile.status, CompileStatus::Success);
2365        let mapped = mapped.expect("shape-preserving hook output keeps a proven map");
2366        assert!(mapped.text.contains("rule (\"transformed\")"));
2367    }
2368
2369    #[test]
2370    fn shape_changing_post_compile_output_rejects_mapped_output() {
2371        let compiler = Compiler::new().unwrap();
2372        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2373        let source =
2374            "#!postCompileHook \"hook-shape.js\"\n\nrule \"setup\":\n    debug(\"ready\")\n";
2375        let (report, mapped) = compiler.compile_source_report_mapped_with_language(
2376            source,
2377            "hook-shape.opy",
2378            &root,
2379            "en-US",
2380        );
2381        assert!(mapped.is_none());
2382        assert_eq!(report.compile.status, CompileStatus::Failure);
2383        assert!(
2384            report
2385                .compile
2386                .diagnostics
2387                .iter()
2388                .any(|diagnostic| diagnostic.code == "source-map-shape"),
2389            "shape-changing post-compile output must fail the map proof"
2390        );
2391    }
2392
2393    fn normalize_workshop_structural_whitespace(text: &str) -> String {
2394        let mut normalized = String::with_capacity(text.len());
2395        let mut quote = None;
2396        let mut escaped = false;
2397        for character in text.chars() {
2398            if let Some(delimiter) = quote {
2399                normalized.push(character);
2400                if escaped {
2401                    escaped = false;
2402                } else if character == '\\' {
2403                    escaped = true;
2404                } else if character == delimiter {
2405                    quote = None;
2406                }
2407            } else if matches!(character, '\"' | '\'') {
2408                quote = Some(character);
2409                normalized.push(character);
2410            } else if !character.is_whitespace() {
2411                normalized.push(character);
2412            }
2413        }
2414        normalized
2415    }
2416
2417    #[test]
2418    fn settings_whitespace_normalization_preserves_quoted_values() {
2419        assert_ne!(
2420            normalize_workshop_structural_whitespace("Description: \"a b\""),
2421            normalize_workshop_structural_whitespace("Description: \"ab\"")
2422        );
2423    }
2424}