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