Skip to main content

opy_rs/compiler/
mod.rs

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