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