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