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