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