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;
28pub(crate) mod settings;
29mod size_optimization;
30mod string_format;
31mod value_walk;
32
33pub(crate) use backend::MacroExpander;
34pub(super) use backend::{expand_macros, expand_macros_attributed, reject_unlowered_directives};
35pub use integration::LinkReport;
36pub(super) use integration::load_compiler_contract;
37pub(super) use lowering::Lowering;
38
39#[cfg(test)]
40mod integration_tests;
41
42const TRANSLATION_HELPER_NAME: &str = "__overpyTranslationHelper__";
43
44fn has_directive(hir: &hir::Program, name: &str) -> bool {
45 hir.preprocessing
46 .directives
47 .iter()
48 .any(|directive| directive.name == name)
49}
50
51fn omit_declaration_sections(output: &str, program: &workshop_rs::Program) -> String {
52 let mut markers = Vec::new();
53 for variable in program
54 .global_variables
55 .iter()
56 .chain(program.player_variables.iter())
57 {
58 markers.push(variable.index.map_or_else(
59 || variable.name.clone(),
60 |index| format!("{index}: {}", variable.name),
61 ));
62 }
63 markers.extend(program.subroutines.iter().map(|subroutine| {
64 subroutine.index.map_or_else(
65 || subroutine.name.clone(),
66 |index| format!("{index}: {}", subroutine.name),
67 )
68 }));
69 if markers.is_empty() {
70 return output.to_string();
71 }
72
73 let lines: Vec<&str> = output.split_inclusive('\n').collect();
74 let mut removed = vec![false; lines.len()];
75 for (index, line) in lines.iter().enumerate() {
76 if !markers.iter().any(|marker| line.trim() == marker) {
77 continue;
78 }
79 let Some(start) = (0..=index).rev().find(|candidate| {
80 let trimmed = lines[*candidate].trim_end();
81 !trimmed.starts_with(char::is_whitespace) && trimmed.ends_with('{')
82 }) else {
83 continue;
84 };
85 let mut depth = 0usize;
86 let mut end = start;
87 for (candidate, line) in lines.iter().enumerate().skip(start) {
88 depth += line.matches('{').count();
89 depth = depth.saturating_sub(line.matches('}').count());
90 if depth == 0 {
91 end = candidate;
92 break;
93 }
94 }
95 for removed_line in removed.iter_mut().take(end + 1).skip(start) {
96 *removed_line = true;
97 }
98 if end + 1 < removed.len() && lines[end + 1].trim().is_empty() {
99 removed[end + 1] = true;
100 }
101 }
102
103 lines
104 .into_iter()
105 .enumerate()
106 .filter_map(|(index, line)| (!removed[index]).then_some(line))
107 .collect()
108}
109
110fn emit_debug_element_counts(
111 output: &str,
112 program: &workshop_rs::Program,
113 catalog: &Catalog,
114 hir: &hir::Program,
115) -> Result<String, IntegrationError> {
116 let report = program.element_count(catalog).map_err(|error| {
117 IntegrationError::new(
118 "element-count",
119 format!("cannot compute Workshop element counts: {error}"),
120 None,
121 )
122 })?;
123 let rule_counts: Vec<_> = report.rules.iter().map(debug_rule_count).collect();
124 let total = rule_counts.iter().sum::<usize>();
125 let mut summary = format!("/* Element count: (total {total})\n\n");
126 let mut summary_rules: Vec<_> = report
127 .rules
128 .iter()
129 .zip(&rule_counts)
130 .filter(|(_, count)| **count > 1)
131 .collect();
132 summary_rules.sort_by_key(|(_, count)| std::cmp::Reverse(**count));
133 for (count, rule_count) in summary_rules {
134 let source = count
135 .span
136 .and_then(|span| {
137 hir.files
138 .iter()
139 .find(|file| file.id == span.file.index() as u32)
140 })
141 .map(|file| file.path.as_str())
142 .map(|path| format!(" ({path})"))
143 .unwrap_or_default();
144 summary.push_str(&format!(
145 "{:>5}: rule \"{}\"{source}\n",
146 rule_count, count.name
147 ));
148 }
149 summary.push_str("\n*/\n\n");
150
151 let mut annotated = summary;
152 let mut rule_index = 0;
153 let mut condition_index = 0;
154 let mut conditions = Vec::new();
155 let mut actions = Vec::new();
156 let mut in_actions = false;
157 let mut in_conditions = false;
158 let mut action_index = 0;
159 for line in output.split_inclusive('\n') {
160 let trimmed = line.trim();
161 if line.starts_with("rule (") {
162 if let (Some(rule), Some(rule_count)) =
163 (report.rules.get(rule_index), rule_counts.get(rule_index))
164 {
165 if *rule_count > 1 {
166 annotated.push_str(&format!("//{rule_count} elements\n"));
167 }
168 conditions.clear();
169 collect_element_nodes(
170 &rule.children,
171 workshop_rs::actions::ElementNodeKind::Condition,
172 &mut conditions,
173 );
174 actions.clear();
175 collect_element_nodes(
176 &rule.children,
177 workshop_rs::actions::ElementNodeKind::Action,
178 &mut actions,
179 );
180 }
181 rule_index += 1;
182 condition_index = 0;
183 action_index = 0;
184 in_conditions = false;
185 in_actions = false;
186 } else if trimmed == "conditions {" {
187 in_conditions = true;
188 in_actions = false;
189 } else if trimmed == "actions {" {
190 in_conditions = false;
191 in_actions = true;
192 } else if in_actions && line.starts_with(" }") {
193 in_actions = false;
194 } else if in_conditions && line.starts_with(" }") {
195 in_conditions = false;
196 }
197
198 if in_conditions && line.starts_with(" ") && trimmed.ends_with(';') {
199 if let Some(condition) = conditions.get(condition_index) {
200 let condition_count = debug_condition_count(condition);
201 let suffix = if condition_count == 1 {
202 "element"
203 } else {
204 "elements"
205 };
206 annotated.push_str(line.trim_end_matches('\n'));
207 annotated.push_str(&format!(" // {condition_count} {suffix}\n"));
208 condition_index += 1;
209 continue;
210 }
211 }
212
213 if in_actions && line.starts_with(" ") && trimmed.ends_with(';') {
214 if let Some(action) = actions.get(action_index) {
215 let action_count = debug_action_local_count(action);
216 let suffix = if action_count == 1 {
217 "element"
218 } else {
219 "elements"
220 };
221 annotated.push_str(line.trim_end_matches('\n'));
222 annotated.push_str(&format!(" // {action_count} {suffix}\n"));
223 action_index += 1;
224 continue;
225 }
226 }
227 annotated.push_str(line);
228 }
229 Ok(annotated)
230}
231
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
822pub(crate) fn workshop_error_span(
823 error: &workshop_rs::WorkshopError,
824) -> Option<workshop_rs::source::Span> {
825 match error {
826 workshop_rs::WorkshopError::Unknown { span, .. }
827 | workshop_rs::WorkshopError::Malformed { span, .. }
828 | workshop_rs::WorkshopError::Unsupported { span, .. } => *span,
829 _ => None,
830 }
831}
832
833fn hir_span_from_workshop(span: workshop_rs::source::Span, hir: &hir::Program) -> Option<HirSpan> {
834 let file = span.file.index() as u32;
835 hir.files
836 .iter()
837 .any(|source| source.id == file)
838 .then_some(HirSpan {
839 file,
840 start: hir::Position {
841 line: span.start.line,
842 col: span.start.col,
843 },
844 end: hir::Position {
845 line: span.end.line,
846 col: span.end.col,
847 },
848 })
849}
850
851impl CompileReport {
852 fn success(
853 compiler: CompilerIdentity,
854 catalog: CatalogIdentity,
855 artifact: CompilationArtifact,
856 diagnostics: Vec<CompileDiagnostic>,
857 ) -> Self {
858 let workshop = normalize_workshop(&artifact.final_output);
859 Self {
860 schema_version: COMPILE_SCHEMA_VERSION,
861 compiler,
862 catalog,
863 compile: CompileResult {
864 status: CompileStatus::Success,
865 exit_code: 0,
866 failure_class: None,
867 diagnostics,
868 stdout: String::new(),
869 workshop_exact: artifact.final_output,
870 workshop,
871 },
872 }
873 }
874
875 fn failure(
876 compiler: CompilerIdentity,
877 catalog: CatalogIdentity,
878 failure_class: CompileFailureClass,
879 diagnostics: Vec<CompileDiagnostic>,
880 ) -> Self {
881 Self {
882 schema_version: COMPILE_SCHEMA_VERSION,
883 compiler,
884 catalog,
885 compile: CompileResult {
886 status: CompileStatus::Failure,
887 exit_code: 1,
888 failure_class: Some(failure_class),
889 diagnostics,
890 stdout: String::new(),
891 workshop_exact: String::new(),
892 workshop: String::new(),
893 },
894 }
895 }
896}
897
898fn compile_diagnostic(error: IntegrationError, files: &[hir::SourceFile]) -> CompileDiagnostic {
899 let diagnostic = error.diagnostic;
900 CompileDiagnostic {
901 severity: crate::tooling::DiagnosticSeverity::Error,
902 code: diagnostic.code,
903 message: diagnostic.message,
904 span: diagnostic
905 .span
906 .and_then(|span| source_location_from_hir(span, files)),
907 script: diagnostic.script.map(|script| *script),
908 }
909}
910
911fn integration_failure_report(
912 compiler: CompilerIdentity,
913 catalog: CatalogIdentity,
914 mut diagnostics: Vec<CompileDiagnostic>,
915 error: IntegrationError,
916 files: &[hir::SourceFile],
917) -> CompileReport {
918 diagnostics.push(compile_diagnostic(error, files));
919 CompileReport::failure(
920 compiler,
921 catalog,
922 CompileFailureClass::Integration,
923 diagnostics,
924 )
925}
926
927fn compile_frontend_diagnostic(diagnostic: &crate::tooling::Diagnostic) -> CompileDiagnostic {
928 CompileDiagnostic {
929 severity: diagnostic.severity,
930 code: diagnostic.code.clone(),
931 message: diagnostic.message.clone(),
932 span: diagnostic.span.clone(),
933 script: None,
934 }
935}
936
937fn source_location_from_hir(
938 span: HirSpan,
939 files: &[hir::SourceFile],
940) -> Option<crate::tooling::SourceLocation> {
941 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
942 Some(crate::tooling::SourceLocation {
943 file_id: span.file,
944 path,
945 start: crate::diag::Position::new(span.start.line, span.start.col),
946 end: crate::diag::Position::new(span.end.line, span.end.col),
947 })
948}
949
950fn normalize_workshop(text: &str) -> String {
951 if text.is_empty() {
952 return String::new();
953 }
954 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
955 let mut lines = normalized
956 .split('\n')
957 .map(|line| line.trim_end_matches([' ', '\t']).to_owned())
958 .collect::<Vec<_>>();
959 while lines.last().is_some_and(|line| line.is_empty()) {
960 lines.pop();
961 }
962 if lines.is_empty() {
963 String::new()
964 } else {
965 lines.join("\n") + "\n"
966 }
967}
968
969#[derive(Debug, Clone, PartialEq, Eq)]
974pub struct CompileOutput {
975 pub workshop: String,
977 pub emitted_workshop: String,
979 pub hook_console_output: Vec<String>,
981}
982
983pub struct CompilationArtifact {
986 pub wir: Program,
987 pub emitted: String,
988 pub catalog_identity: CatalogIdentity,
989 pub final_output: String,
990 pub hook_console_output: Vec<String>,
991 pub(crate) translation_files: Vec<(String, String)>,
992}
993
994impl CompilationArtifact {
995 fn into_output(self) -> CompileOutput {
996 CompileOutput {
997 workshop: self.final_output,
998 emitted_workshop: self.emitted,
999 hook_console_output: self.hook_console_output,
1000 }
1001 }
1002}
1003
1004fn hir_span_from_diag(span: crate::diag::Span) -> HirSpan {
1005 HirSpan {
1006 file: span.file,
1007 start: hir::Position {
1008 line: span.start.line,
1009 col: span.start.col,
1010 },
1011 end: hir::Position {
1012 line: span.end.line,
1013 col: span.end.col,
1014 },
1015 }
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020 use super::integration::cross_check_manifest;
1021 use super::{COMPILE_SCHEMA_VERSION, CompileFailureClass, CompileStatus, Compiler};
1022 use crate::manifest::Manifest;
1023 use std::path::Path;
1024 use workshop_rs::catalog::{Catalog, Locale};
1025
1026 #[test]
1027 fn catalog_links_are_checked() {
1028 let compiler = Compiler::new().expect("Workshop contract must load");
1029 let identity = compiler.catalog_identity();
1030 assert!(!identity.implementation_version.is_empty());
1031 assert!(compiler.link_report().catalog_ids_checked > 0);
1032 assert!(compiler.link_report().domains_checked > 0);
1033 }
1034
1035 #[test]
1036 fn compiler_instances_share_the_verified_contract_concurrently() {
1037 let instances = std::thread::scope(|scope| {
1038 let handles = (0..8)
1039 .map(|_| {
1040 scope.spawn(|| {
1041 let compiler = Compiler::new().expect("Workshop contract must load");
1042 (
1043 compiler.catalog as *const Catalog as usize,
1044 compiler.manifest as *const Manifest as usize,
1045 compiler.link_report(),
1046 )
1047 })
1048 })
1049 .collect::<Vec<_>>();
1050 handles
1051 .into_iter()
1052 .map(|handle| handle.join().expect("compiler construction must not panic"))
1053 .collect::<Vec<_>>()
1054 });
1055
1056 let (catalog, manifest, links) = instances[0];
1057 for (instance_catalog, instance_manifest, instance_links) in instances.into_iter().skip(1) {
1058 assert_eq!(instance_catalog, catalog);
1059 assert_eq!(instance_manifest, manifest);
1060 assert_eq!(instance_links, links);
1061 }
1062 }
1063
1064 #[test]
1065 fn compile_report_is_versioned_and_contains_reproducibility_identity() {
1066 let compiler = Compiler::new().unwrap();
1067 let report = compiler.compile_source_report_with_locale(
1068 "rule \"report\":\n @Event global\n disableInspector()\n",
1069 "report.opy",
1070 Path::new("."),
1071 &Locale::new("en-US"),
1072 );
1073 assert_eq!(report.schema_version, COMPILE_SCHEMA_VERSION);
1074 assert_eq!(report.compiler.name, "opy-rs");
1075 assert_eq!(report.catalog, compiler.catalog_identity());
1076 assert_eq!(report.compile.status, CompileStatus::Success);
1077 assert_eq!(report.compile.exit_code, 0);
1078 assert!(report.compile.diagnostics.is_empty());
1079 assert_eq!(
1080 report.compile.workshop,
1081 report
1082 .compile
1083 .workshop_exact
1084 .trim_end_matches('\n')
1085 .to_owned()
1086 + "\n"
1087 );
1088 assert!(serde_json::to_value(report).unwrap()["catalog"]["catalog-version"].is_string());
1089 }
1090
1091 #[test]
1092 fn compile_report_preserves_frontend_failure_class_and_source_path() {
1093 let compiler = Compiler::new().unwrap();
1094 let report = compiler.compile_source_report_with_locale(
1095 "rule \"broken\":\n @Event global\n missing()\n",
1096 "broken.opy",
1097 Path::new("."),
1098 &Locale::new("en-US"),
1099 );
1100 assert_eq!(report.compile.status, CompileStatus::Failure);
1101 assert_eq!(
1102 report.compile.failure_class,
1103 Some(CompileFailureClass::Frontend)
1104 );
1105 assert_eq!(report.compile.exit_code, 1);
1106 let diagnostic = &report.compile.diagnostics[0];
1107 assert_eq!(diagnostic.code, "unknown-action");
1108 assert_eq!(diagnostic.span.as_ref().unwrap().path, "broken.opy");
1109 }
1110
1111 #[test]
1112 fn compile_report_preserves_integration_failure_class_and_source_path() {
1113 let compiler = Compiler::new().unwrap();
1114 let report = compiler.compile_source_report_with_locale(
1115 "rule \"broken\":\n @Event global\n {\"a\": 1}[\"b\"] = 3\n",
1116 "broken.opy",
1117 Path::new("."),
1118 &Locale::new("en-US"),
1119 );
1120 assert_eq!(report.compile.status, CompileStatus::Failure);
1121 assert_eq!(
1122 report.compile.failure_class,
1123 Some(CompileFailureClass::Integration)
1124 );
1125 assert_eq!(
1126 report.compile.diagnostics[0].span.as_ref().unwrap().path,
1127 "broken.opy"
1128 );
1129 }
1130
1131 #[test]
1132 fn compile_report_preserves_frontend_warnings_on_integration_failure() {
1133 let compiler = Compiler::new().unwrap();
1134 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1135 .join("tests/fixtures/corpus/synthetic/preprocessing");
1136 let report = compiler.compile_source_report_with_locale(
1137 concat!(
1138 "#!include \"shared.opy\"\n",
1139 "#!include \"shared.opy\"\n",
1140 "rule \"broken\":\n",
1141 " @Event global\n",
1142 " {\"a\": 1}[\"b\"] = 3\n",
1143 ),
1144 "broken.opy",
1145 &root,
1146 &Locale::new("en-US"),
1147 );
1148 assert_eq!(report.compile.status, CompileStatus::Failure);
1149 assert_eq!(
1150 report.compile.failure_class,
1151 Some(CompileFailureClass::Integration)
1152 );
1153 assert_eq!(report.compile.diagnostics.len(), 2);
1154 assert_eq!(
1155 report.compile.diagnostics[0].severity,
1156 crate::tooling::DiagnosticSeverity::Warning
1157 );
1158 assert_eq!(report.compile.diagnostics[0].code, "w_already_imported");
1159 assert_eq!(
1160 report.compile.diagnostics[1].severity,
1161 crate::tooling::DiagnosticSeverity::Error
1162 );
1163 assert_eq!(
1164 report.compile.diagnostics[1].span.as_ref().unwrap().path,
1165 "broken.opy"
1166 );
1167 }
1168
1169 #[test]
1170 fn suppress_warnings_hides_matching_preprocessing_diagnostics() {
1171 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1172 .join("tests/fixtures/corpus/synthetic/preprocessing");
1173 let outcome = crate::tooling::check(
1174 concat!(
1175 "#!suppressWarnings w_already_imported\n",
1176 "#!include \"shared.opy\"\n",
1177 "#!include \"shared.opy\"\n",
1178 "rule \"r\":\n",
1179 " @Event global\n",
1180 " pass\n",
1181 ),
1182 "main.opy",
1183 &root,
1184 );
1185 assert!(outcome.model.is_some());
1186 assert!(outcome.diagnostics.is_empty());
1187 }
1188
1189 #[test]
1190 fn vertical_slice_preserves_source_files_spans_and_emits_workshop() {
1191 let compiler = Compiler::new().unwrap();
1192 let hir = crate::compile(
1193 "globalvar A\nrule \"issue 35 integration\":\n @Event global\n A = 1\n disableInspector()\n",
1194 "compiler-vertical-slice.opy",
1195 Path::new("."),
1196 )
1197 .unwrap();
1198 let artifact = compiler.compile_hir(&hir).unwrap();
1199 assert!(artifact.emitted.contains("Disable Inspector Recording;"));
1200 assert_eq!(artifact.catalog_identity, compiler.catalog_identity());
1201 }
1202
1203 #[test]
1204 fn stale_catalog_links_fail_explicitly() {
1205 let manifest = Manifest::builtin().unwrap().clone();
1206 let mut stale = manifest;
1207 stale.functions[0].catalog_id = Some("missing-catalog-id".to_string());
1208 let error = cross_check_manifest(&stale, &Catalog::builtin().unwrap()).unwrap_err();
1209 assert_eq!(error.diagnostic.code, "catalog-link-missing");
1210 }
1211
1212 #[test]
1213 fn while_lowering_is_source_attributed() {
1214 let compiler = Compiler::new().unwrap();
1215 let hir = crate::compile(
1216 "rule \"while\":\n @Event global\n while true:\n disableInspector()\n",
1217 "while.opy",
1218 Path::new("."),
1219 )
1220 .unwrap();
1221 let artifact = compiler.compile_hir(&hir).unwrap();
1222 let rule = artifact.wir.rules.first().unwrap();
1223 assert!(matches!(
1224 rule.actions.first(),
1225 Some(workshop_rs::Action::While { .. })
1226 ));
1227 assert!(artifact.emitted.contains("While(True);"));
1228 }
1229
1230 #[test]
1231 fn expanded_control_flow_actions_keep_their_originating_spans() {
1232 let compiler = Compiler::new().unwrap();
1233 let hir = crate::compile(
1234 "globalvar value = 1\nrule \"if\":\n @Event global\n if true:\n wait(1)\n disableInspector()\n",
1235 "control-flow-provenance.opy",
1236 Path::new("."),
1237 )
1238 .unwrap();
1239 let artifact = compiler.compile_hir(&hir).unwrap();
1240
1241 assert_eq!(artifact.wir.action_span(0, 0).unwrap().start.line, 1);
1242 assert_eq!(
1243 artifact
1244 .wir
1245 .action_argument_span(0, 0, 0)
1246 .unwrap()
1247 .start
1248 .line,
1249 1
1250 );
1251 assert_eq!(artifact.wir.rules[1].actions.len(), 4);
1252 assert_eq!(artifact.wir.action_span(1, 0).unwrap().start.line, 4);
1253 assert_eq!(artifact.wir.action_span(1, 1).unwrap().start.line, 5);
1254 assert_eq!(
1255 artifact
1256 .wir
1257 .action_argument_span(1, 1, 0)
1258 .unwrap()
1259 .start
1260 .line,
1261 5
1262 );
1263 assert_eq!(artifact.wir.action_span(1, 2).unwrap().start.line, 4);
1264 }
1265
1266 #[test]
1267 fn range_argument_provenance_uses_canonical_positions() {
1268 let compiler = Compiler::new().unwrap();
1269 let hir = crate::compile(
1270 "globalvar value\nrule \"range\":\n @Event global\n for value in range(3):\n wait(1)\n",
1271 "range-provenance.opy",
1272 Path::new("."),
1273 )
1274 .unwrap();
1275 let artifact = compiler.compile_hir(&hir).unwrap();
1276
1277 assert_eq!(artifact.wir.action_span(0, 0).unwrap().start.line, 4);
1278 assert!(artifact.wir.action_argument_span(0, 0, 0).is_none());
1279 assert_eq!(
1280 artifact
1281 .wir
1282 .action_argument_span(0, 0, 1)
1283 .unwrap()
1284 .start
1285 .line,
1286 4
1287 );
1288 assert!(artifact.wir.action_argument_span(0, 0, 2).is_none());
1289 }
1290
1291 #[test]
1292 fn structural_subroutines_lower_to_canonical_wir() {
1293 let compiler = Compiler::new().unwrap();
1294 let hir = crate::compile(
1295 "globalvar score\nsubroutine showStatus\ndef showStatus():\n @Name \"Friendly\"\n @SuppressWarnings unusedVariable\n disableInspector()\nrule \"caller\":\n @Event global\n showStatus()\n",
1296 "structure.opy",
1297 Path::new("."),
1298 )
1299 .unwrap();
1300 let artifact = compiler.compile_hir(&hir).unwrap();
1301 let subroutine = artifact.wir.subroutines.first().unwrap();
1302 assert_eq!(subroutine.name, "showStatus");
1303 assert_eq!(artifact.wir.rules.len(), 2);
1304 let subroutine_rule = artifact.wir.rules.first().unwrap();
1305 let workshop_rs::Event::Subroutine(subroutine_name) = &subroutine_rule.event else {
1306 panic!("expected a subroutine event");
1307 };
1308 assert_eq!(subroutine_name, "showStatus");
1309 assert!(matches!(
1310 artifact.wir.rules.get(1).unwrap().actions.first(),
1311 Some(workshop_rs::Action::CallSubroutine { .. })
1312 ));
1313 assert!(artifact.emitted.contains("Subroutine Friendly"));
1314 }
1315
1316 #[test]
1317 fn player_event_filters_resolve_through_canonical_catalog() {
1318 let compiler = Compiler::new().unwrap();
1319 let hir = crate::compile(
1320 "rule \"joined\":\n @Event playerJoined\n @Team 1\n @Slot 2\n disableInspector()\n",
1321 "filters.opy",
1322 Path::new("."),
1323 )
1324 .unwrap();
1325 let artifact = compiler.compile_hir(&hir).unwrap();
1326 assert!(matches!(
1327 &artifact.wir.rules.first().unwrap().event,
1328 workshop_rs::Event::Player {
1329 kind: workshop_rs::PlayerEventKind::Joined,
1330 team: workshop_rs::EventTeam::Team1,
1331 target: workshop_rs::EventTarget::Slot(2),
1332 }
1333 ));
1334 assert!(artifact.emitted.contains("Player Joined Match;"));
1335 }
1336
1337 #[test]
1338 fn hero_event_filters_accept_legacy_aliases() {
1339 let compiler = Compiler::new().unwrap();
1340 let hir = crate::compile(
1341 "rule \"hero\":\n @Event eachPlayer\n @Hero soldier\n disableInspector()\n",
1342 "hero-filter.opy",
1343 Path::new("."),
1344 )
1345 .unwrap();
1346 let artifact = compiler.compile_hir(&hir).unwrap();
1347 assert!(matches!(
1348 &artifact.wir.rules.first().unwrap().event,
1349 workshop_rs::Event::EachPlayerWithFilters {
1350 target: workshop_rs::EventTarget::Hero(hero),
1351 ..
1352 } if hero == "SOLDIER_76"
1353 ));
1354 }
1355
1356 #[test]
1357 fn explicit_indices_are_reserved_before_deterministic_allocation() {
1358 let compiler = Compiler::new().unwrap();
1359 let hir = crate::compile(
1360 "globalvar first\nglobalvar reserved 0\nglobalvar next\nrule \"indices\":\n @Event global\n disableInspector()\n",
1361 "indices.opy",
1362 Path::new("."),
1363 )
1364 .unwrap();
1365 let artifact = compiler.compile_hir(&hir).unwrap();
1366 let by_name = artifact
1367 .wir
1368 .global_variables
1369 .iter()
1370 .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1371 .collect::<std::collections::BTreeMap<_, _>>();
1372 assert_eq!(
1373 by_name,
1374 std::collections::BTreeMap::from([("first", 1), ("reserved", 0), ("next", 2)])
1375 );
1376 let indices = artifact
1378 .wir
1379 .global_variables
1380 .iter()
1381 .map(|variable| variable.index.unwrap())
1382 .collect::<Vec<_>>();
1383 assert_eq!(indices, vec![0, 1, 2]);
1384 }
1385
1386 #[test]
1387 fn implicit_default_variables_use_reference_fixed_slots() {
1388 let compiler = Compiler::new().unwrap();
1389 let hir = crate::compile(
1390 r#"
1391globalvar timer
1392globalvar extra 5
1393
1394rule "implicit":
1395 @Event global
1396 A = timer + 1
1397 B = A
1398 B += 2
1399 A[0] = 7
1400 DX = B * A
1401"#,
1402 "implicit.opy",
1403 Path::new("."),
1404 )
1405 .unwrap();
1406 let artifact = compiler.compile_hir(&hir).unwrap();
1407 let globals = artifact
1408 .wir
1409 .global_variables
1410 .iter()
1411 .map(|variable| (variable.name.clone(), variable.index.unwrap()))
1412 .collect::<Vec<_>>();
1413 assert_eq!(
1418 globals,
1419 vec![
1420 ("A".to_string(), 0),
1421 ("B".to_string(), 1),
1422 ("timer".to_string(), 2),
1423 ("extra".to_string(), 5),
1424 ("DX".to_string(), 127),
1425 ]
1426 );
1427 assert!(
1428 artifact
1429 .emitted
1430 .contains("Set Global Variable(A, Add(Global.timer, 1));")
1431 );
1432 assert!(
1433 artifact
1434 .emitted
1435 .contains("Set Global Variable(B, Global.A);")
1436 );
1437 assert!(
1438 artifact
1439 .emitted
1440 .contains("Modify Global Variable(B, Add, 2);")
1441 );
1442 assert!(
1443 artifact
1444 .emitted
1445 .contains("Set Global Variable At Index(A, 0, 7);")
1446 );
1447 assert!(
1448 artifact
1449 .emitted
1450 .contains("Set Global Variable(DX, Multiply(Global.B, Global.A));")
1451 );
1452 }
1453
1454 #[test]
1455 fn implicit_default_variable_slot_collision_is_source_attributed() {
1456 let compiler = Compiler::new().unwrap();
1457 let hir = crate::compile(
1458 "globalvar x 0\nrule \"collision\":\n @Event global\n x = 1\n A = 2\n",
1459 "collision.opy",
1460 Path::new("."),
1461 )
1462 .unwrap();
1463 let error = match compiler.compile_hir(&hir) {
1464 Ok(_) => panic!("slot collision unexpectedly succeeded"),
1465 Err(error) => error,
1466 };
1467 assert_eq!(error.diagnostic.code, "index-collision");
1468 assert_eq!(error.diagnostic.span.unwrap().start.line, 5);
1469 assert!(error.diagnostic.message.contains("'A' and 'x'"));
1470 }
1471
1472 #[test]
1473 fn implicit_default_player_variables_use_independent_reference_slots() {
1474 let compiler = Compiler::new().unwrap();
1475 let hir = crate::compile(
1476 r#"
1477playervar declaredPlayer
1478
1479rule "implicit player variables":
1480 @Event eachPlayer
1481 A = 1
1482 eventPlayer.A = 1
1483 eventPlayer.A += 2
1484 eventPlayer.E = eventPlayer.A
1485 eventPlayer.DX = eventPlayer.E
1486 eventPlayer.declaredPlayer = eventPlayer.A
1487"#,
1488 "implicit-player.opy",
1489 Path::new("."),
1490 )
1491 .unwrap();
1492 let artifact = compiler.compile_hir(&hir).unwrap();
1493 let globals = artifact
1494 .wir
1495 .global_variables
1496 .iter()
1497 .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1498 .collect::<std::collections::BTreeMap<_, _>>();
1499 let players = artifact
1500 .wir
1501 .player_variables
1502 .iter()
1503 .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1504 .collect::<std::collections::BTreeMap<_, _>>();
1505 assert_eq!(globals.get("A"), Some(&0));
1506 assert_eq!(players.get("A"), Some(&0));
1507 assert_eq!(players.get("declaredPlayer"), Some(&1));
1508 assert_eq!(players.get("E"), Some(&4));
1509 assert_eq!(players.get("DX"), Some(&127));
1510 assert!(
1511 artifact
1512 .emitted
1513 .contains("Set Player Variable(Event Player, A, 1);")
1514 );
1515 assert!(
1516 artifact
1517 .emitted
1518 .contains("Modify Player Variable(Event Player, A, Add, 2);")
1519 );
1520 assert!(
1521 artifact
1522 .emitted
1523 .contains("Set Player Variable(Event Player, E, (Event Player).A);")
1524 );
1525 }
1526
1527 #[test]
1528 fn implicit_default_player_slot_collision_is_source_attributed() {
1529 let compiler = Compiler::new().unwrap();
1530 let hir = crate::compile(
1531 "playervar declared 0\nrule \"collision\":\n @Event eachPlayer\n eventPlayer.A = 1\n",
1532 "player-collision.opy",
1533 Path::new("."),
1534 )
1535 .unwrap();
1536 let error = match compiler.compile_hir(&hir) {
1537 Ok(_) => panic!("player slot collision unexpectedly succeeded"),
1538 Err(error) => error,
1539 };
1540 assert_eq!(error.diagnostic.code, "index-collision");
1541 assert!(
1542 error
1543 .diagnostic
1544 .message
1545 .contains("player variables 'A' and 'declared'")
1546 );
1547 assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
1548 }
1549
1550 #[test]
1551 fn power_augmented_assignment_lowers_from_source() {
1552 let compiler = Compiler::new().unwrap();
1553 let hir = crate::compile(
1554 "globalvar g\nrule \"power\":\n @Event global\n g = 2\n g **= 3\n",
1555 "power.opy",
1556 Path::new("."),
1557 )
1558 .unwrap();
1559 let artifact = compiler.compile_hir(&hir).unwrap();
1560 assert!(artifact.emitted.contains("Set Global Variable(g, 2);"));
1561 assert!(
1562 artifact
1563 .emitted
1564 .contains("Modify Global Variable(g, Raise To Power, 3);")
1565 );
1566 }
1567
1568 #[test]
1569 fn opy_hex_numbers_are_normalized_at_the_wir_boundary() {
1570 let compiler = Compiler::new().unwrap();
1571 let hir = crate::compile(
1572 "globalvar large = 0x124BC\nglobalvar small = 0x124\nglobalvar scientific = 1e10\n",
1573 "numbers.opy",
1574 Path::new("."),
1575 )
1576 .unwrap();
1577 let artifact = compiler.compile_hir(&hir).unwrap();
1578 assert!(
1579 artifact
1580 .emitted
1581 .contains("Set Global Variable(large, 74940);")
1582 );
1583 assert!(
1584 artifact
1585 .emitted
1586 .contains("Set Global Variable(small, 292);")
1587 );
1588 assert!(
1589 artifact
1590 .emitted
1591 .contains("Set Global Variable(scientific, 10000000000);")
1592 );
1593 assert!(!artifact.emitted.contains("0x124BC"));
1594 assert!(!artifact.emitted.contains("0x124"));
1595 }
1596
1597 #[test]
1598 fn literal_dict_lookup_lowers_to_the_selected_value() {
1599 let compiler = Compiler::new().unwrap();
1600 let hir = crate::compile(
1601 "globalvar total\nrule \"negative\":\n @Event global\n total = {\"a\": 1, \"b\": 2}[\"a\"]\n",
1602 "negative.opy",
1603 Path::new("."),
1604 )
1605 .unwrap();
1606 let artifact = compiler
1607 .compile_hir(&hir)
1608 .expect("literal dict lookup should lower");
1609 assert!(artifact.emitted.contains("Set Global Variable(total, 1);"));
1610 }
1611
1612 #[test]
1613 fn auto_allocation_fills_free_slots_below_early_explicit_indices() {
1614 let compiler = Compiler::new().unwrap();
1615 let hir = crate::compile(
1616 r#"
1617globalvar reserved 5
1618globalvar auto1
1619globalvar auto2
1620
1621rule "allocation":
1622 @Event global
1623 auto1 = 1
1624 auto2 = 2
1625 B = 3
1626"#,
1627 "allocation.opy",
1628 Path::new("."),
1629 )
1630 .unwrap();
1631 let artifact = compiler.compile_hir(&hir).unwrap();
1632 let by_name = artifact
1633 .wir
1634 .global_variables
1635 .iter()
1636 .map(|variable| (variable.name.clone(), variable.index.unwrap()))
1637 .collect::<std::collections::BTreeMap<_, _>>();
1638 assert_eq!(
1643 by_name,
1644 std::collections::BTreeMap::from([
1645 ("B".to_string(), 1),
1646 ("auto1".to_string(), 0),
1647 ("auto2".to_string(), 2),
1648 ("reserved".to_string(), 5),
1649 ])
1650 );
1651 }
1652
1653 #[test]
1654 fn power_expressions_lower_through_the_canonical_contract() {
1655 let compiler = Compiler::new().unwrap();
1656 let hir = crate::compile(
1657 "globalvar a = [2, 4]\nglobalvar out\nrule \"power\":\n @Event global\n out = a ** 2\n a **= 2\n a[0] **= 2\n",
1658 "power.opy",
1659 Path::new("."),
1660 )
1661 .unwrap();
1662 let artifact = compiler.compile_hir(&hir).unwrap();
1663 assert!(
1664 artifact
1665 .emitted
1666 .contains("Set Global Variable(out, Raise To Power(Global.a, 2));")
1667 );
1668 assert!(
1669 artifact
1670 .emitted
1671 .contains("Modify Global Variable(a, Raise To Power, 2);")
1672 );
1673 assert!(
1674 artifact
1675 .emitted
1676 .contains("Modify Global Variable At Index(a, 0, Raise To Power, 2);")
1677 );
1678 }
1679
1680 #[test]
1681 fn unsupported_rule_metadata_is_explicit_and_source_attributed() {
1682 let compiler = Compiler::new().unwrap();
1683 let hir = crate::compile(
1684 "rule \"metadata\":\n @Event global\n @NewPage \"section\"\n disableInspector()\n",
1685 "metadata.opy",
1686 Path::new("."),
1687 )
1688 .unwrap();
1689 let error = match compiler.compile_hir(&hir) {
1690 Ok(_) => panic!("unsupported metadata unexpectedly succeeded"),
1691 Err(error) => error,
1692 };
1693 assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
1694 assert_eq!(error.diagnostic.span.unwrap().start.line, 3);
1695 }
1696
1697 #[test]
1698 fn compiler_structure_matches_the_pinned_oracle() {
1699 let compiler = Compiler::new().unwrap();
1700 let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
1701 .join("tests/fixtures/corpus/synthetic/compiler-structure");
1702 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
1703 let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
1704 let artifact = compiler.compile_hir(&hir).unwrap();
1705 let oracle: serde_json::Value =
1706 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
1707 .unwrap();
1708 let oracle_workshop = oracle["compile"]["workshop"].as_str().unwrap();
1709 let oracle_wir = workshop_rs::parser::parse(
1710 oracle_workshop,
1711 &Catalog::builtin().unwrap(),
1712 &Locale::new("en-US"),
1713 )
1714 .unwrap();
1715 assert!(workshop_rs::roundtrip::equivalent(
1716 &artifact.wir,
1717 &oracle_wir
1718 ));
1719
1720 assert!(oracle_workshop.contains("0: reserved"));
1721 assert!(oracle_workshop.contains("1: first"));
1722 assert!(oracle_workshop.contains("2: explicit"));
1723 assert!(oracle_workshop.contains("3: next"));
1724 assert!(oracle_workshop.contains("0: helper"));
1725 assert!(oracle_workshop.contains("Subroutine;\n helper;"));
1726 assert!(oracle_workshop.contains("Player Joined Match;\n Team 1;\n Slot 2;"));
1727
1728 let indices = artifact
1729 .wir
1730 .global_variables
1731 .iter()
1732 .map(|variable| variable.index.unwrap())
1733 .collect::<Vec<_>>();
1734 assert_eq!(indices, vec![0, 1, 2, 3]);
1735 assert_eq!(artifact.wir.subroutines.first().unwrap().name, "helper");
1736 assert!(artifact.emitted.contains("[Source] renamed helper"));
1737 assert!(matches!(
1738 artifact.wir.rules.get(1).unwrap().event,
1739 workshop_rs::Event::Player {
1740 kind: workshop_rs::PlayerEventKind::Joined,
1741 team: workshop_rs::EventTeam::Team1,
1742 target: workshop_rs::EventTarget::Slot(2),
1743 }
1744 ));
1745 }
1746
1747 #[test]
1748 fn assignments_and_modifications_lower_to_canonical_wir() {
1749 let compiler = Compiler::new().unwrap();
1750 let hir = crate::compile(
1751 r#"
1752globalvar g1
1753globalvar g2
1754playervar p1
1755playervar p2 = [1, 2, 3]
1756
1757rule "assignments":
1758 @Event eachPlayer
1759 g1 = 10
1760 g1 += 5
1761 g1 -= 2
1762 g1 *= 3
1763 g1 /= 2
1764 g1 %= 4
1765 g2 = [1, 2, 3]
1766 g2[0] = 99
1767 g2[1] += 1
1768 eventPlayer.p1 = 42
1769 eventPlayer.p1 += 8
1770 eventPlayer.p1 *= 2
1771 eventPlayer.p2[2] = 7
1772 eventPlayer.p2[0] -= 3
1773"#,
1774 "assign.opy",
1775 Path::new("."),
1776 )
1777 .unwrap();
1778 let artifact = compiler.compile_hir(&hir).unwrap();
1779 assert!(artifact.emitted.contains("Set Global Variable(g1, 10);"));
1780 assert!(
1781 artifact
1782 .emitted
1783 .contains("Modify Global Variable(g1, Add, 5);")
1784 );
1785 assert!(
1786 artifact
1787 .emitted
1788 .contains("Modify Global Variable(g1, Subtract, 2);")
1789 );
1790 assert!(
1791 artifact
1792 .emitted
1793 .contains("Modify Global Variable(g1, Multiply, 3);")
1794 );
1795 assert!(
1796 artifact
1797 .emitted
1798 .contains("Modify Global Variable(g1, Divide, 2);")
1799 );
1800 assert!(
1801 artifact
1802 .emitted
1803 .contains("Modify Global Variable(g1, Modulo, 4);")
1804 );
1805 assert!(
1806 artifact
1807 .emitted
1808 .contains("Set Global Variable At Index(g2, 0, 99);")
1809 );
1810 assert!(
1811 artifact
1812 .emitted
1813 .contains("Modify Global Variable At Index(g2, 1, Add, 1);")
1814 );
1815 assert!(
1816 artifact
1817 .emitted
1818 .contains("Set Player Variable(Event Player, p1, 42);")
1819 );
1820 assert!(
1821 artifact
1822 .emitted
1823 .contains("Modify Player Variable(Event Player, p1, Add, 8);")
1824 );
1825 assert!(
1826 artifact
1827 .emitted
1828 .contains("Modify Player Variable(Event Player, p1, Multiply, 2);")
1829 );
1830 assert!(artifact.wir.rules[1].actions.iter().any(|action| matches!(
1831 action,
1832 workshop_rs::Action::Call { name, args }
1833 if name == "setPlayerVariableAtIndex"
1834 && matches!(
1835 args.as_slice(),
1836 [
1837 workshop_rs::Value::PlayerVariable { player, variable },
1838 workshop_rs::Value::Number(index),
1839 workshop_rs::Value::Number(value),
1840 ] if matches!(player.as_ref(), workshop_rs::Value::EventPlayer)
1841 && variable == "p2"
1842 && *index == 2.0
1843 && *value == 7.0
1844 )
1845 )));
1846 assert!(artifact.wir.rules[1].actions.iter().any(|action| matches!(
1847 action,
1848 workshop_rs::Action::Call { name, args }
1849 if name == "modifyPlayerVariableAtIndex"
1850 && matches!(
1851 args.as_slice(),
1852 [
1853 workshop_rs::Value::PlayerVariable { player, variable },
1854 workshop_rs::Value::Number(index),
1855 workshop_rs::Value::Call { name: operation, .. },
1856 workshop_rs::Value::Number(value),
1857 ] if matches!(player.as_ref(), workshop_rs::Value::EventPlayer)
1858 && variable == "p2"
1859 && *index == 0.0
1860 && operation == "subtract"
1861 && *value == 3.0
1862 )
1863 )));
1864
1865 let rule = artifact.wir.rules.get(1).unwrap();
1866 assert!(matches!(
1867 rule.actions.first(),
1868 Some(workshop_rs::Action::SetGlobalVariable { variable, .. }) if variable == "g1"
1869 ));
1870 assert!(matches!(
1871 rule.actions.get(7),
1872 Some(workshop_rs::Action::Call { .. })
1873 ));
1874 }
1875
1876 #[test]
1877 fn expressions_and_values_lower_to_canonical_wir() {
1878 let compiler = Compiler::new().unwrap();
1879 let hir = crate::compile(
1880 r#"
1881enum Consts:
1882 BASE
1883
1884globalvar total
1885globalvar arr = [1, 2, 3]
1886globalvar pos = vect(1, 2, 3)
1887
1888rule "expressions":
1889 @Event global
1890 @Condition total == 0
1891 @Condition not (pos == vect(0, 0, 0))
1892 @Condition 2 in arr
1893 total = Consts.BASE + arr[1] * 2 - (10 / 2) + (5 % 2)
1894 print("Total: {}".format(total))
1895 debug(pos)
1896"#,
1897 "expr.opy",
1898 Path::new("."),
1899 )
1900 .unwrap();
1901 let artifact = compiler.compile_hir(&hir).unwrap();
1902 assert!(artifact.emitted.contains("Global.total == 0;"));
1903 assert!(artifact.emitted.contains("Global.pos != Vector(0, 0, 0);"));
1906 assert!(
1907 artifact
1908 .emitted
1909 .contains("Array Contains(Global.arr, 2) == True;")
1910 );
1911 assert!(
1912 artifact
1913 .emitted
1914 .contains("Custom String(\"Total: {0}\", Global.total)")
1915 );
1916 }
1917
1918 #[test]
1919 fn pass_is_supported_as_source_level_noop() {
1920 let compiler = Compiler::new().unwrap();
1921 let hir = crate::compile(
1922 r#"
1923subroutine emptySub
1924
1925def emptySub():
1926 pass
1927
1928rule "empty rule":
1929 @Event global
1930 pass
1931"#,
1932 "pass.opy",
1933 Path::new("."),
1934 )
1935 .unwrap();
1936 let artifact = compiler.compile_hir(&hir).unwrap();
1937 assert!(artifact.wir.rules.is_empty());
1940 }
1941
1942 #[test]
1943 fn optimized_non_subroutine_wait_only_rule_matches_overpy_noop_elision() {
1944 let compiler = Compiler::new().unwrap();
1945 let hir = crate::compile(
1946 r#"
1947rule "wait-only":
1948 @Event eachPlayer
1949 wait(0.032)
1950"#,
1951 "wait-only.opy",
1952 Path::new("."),
1953 )
1954 .unwrap();
1955 let artifact = compiler.compile_hir(&hir).unwrap();
1956 assert!(artifact.wir.rules.is_empty());
1957 }
1958
1959 #[test]
1960 fn variable_initializers_synthesize_initialize_rules() {
1961 let compiler = Compiler::new().unwrap();
1962 let hir = crate::compile(
1963 r#"
1964globalvar j = 5
1965globalvar h = 0
1966globalvar k = 0.0
1967playervar p = 7
1968playervar q = 0
1969
1970rule "main":
1971 @Event global
1972 disableInspector()
1973"#,
1974 "init.opy",
1975 Path::new("."),
1976 )
1977 .unwrap();
1978 let artifact = compiler.compile_hir(&hir).unwrap();
1979 assert_eq!(
1980 artifact.wir.rules.first().unwrap().name,
1981 "Initialize global variables"
1982 );
1983 assert_eq!(
1984 artifact.wir.rules.get(1).unwrap().name,
1985 "Initialize player variables"
1986 );
1987 assert_eq!(artifact.wir.rules.get(2).unwrap().name, "main");
1988 assert!(artifact.emitted.contains("Set Global Variable(j, 5);"));
1989 assert!(artifact.emitted.contains("Set Global Variable(k, 0);"));
1990 assert!(!artifact.emitted.contains("Set Global Variable(h,"));
1991 assert!(
1992 artifact
1993 .emitted
1994 .contains("Set Player Variable(Event Player, p, 7);")
1995 );
1996 assert!(
1997 !artifact
1998 .emitted
1999 .contains("Set Player Variable(Event Player, q,")
2000 );
2001 }
2002
2003 #[test]
2004 fn settings_lower_through_workshop_owned_emission() {
2005 let compiler = Compiler::new().unwrap();
2006 let fixture =
2007 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/corpus/synthetic/settings");
2008 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
2009 let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
2010 let artifact = compiler.compile_hir(&hir).unwrap();
2011 let oracle: serde_json::Value =
2012 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
2013 .unwrap();
2014 let expected = oracle["compile"]["workshop"]
2015 .as_str()
2016 .unwrap()
2017 .split("\n\nrule")
2018 .next()
2019 .unwrap();
2020 let actual = artifact.emitted.split("\n\nrule").next().unwrap();
2021 let oracle_wir = workshop_rs::parser::parse(
2022 oracle["compile"]["workshop"].as_str().unwrap(),
2023 &Catalog::builtin().unwrap(),
2024 &Locale::new("en-US"),
2025 )
2026 .unwrap();
2027 assert!(workshop_rs::roundtrip::equivalent(
2028 &artifact.wir,
2029 &oracle_wir
2030 ));
2031 assert_eq!(
2032 normalize_workshop_structural_whitespace(actual),
2033 normalize_workshop_structural_whitespace(expected)
2034 );
2035 }
2036
2037 #[test]
2038 fn unsupported_locale_has_no_fabricated_source_span() {
2039 let compiler = Compiler::new().unwrap();
2040 let hir = crate::compile(
2041 "#!translations en\nrule \"r\":\n @Event global\n pass\n",
2042 "locale.opy",
2043 Path::new("."),
2044 )
2045 .unwrap();
2046 let error = match compiler.compile_hir_with_locale(&hir, &Locale::new("xx-XX")) {
2047 Ok(_) => panic!("unsupported locale unexpectedly compiled"),
2048 Err(error) => error,
2049 };
2050 assert_eq!(error.diagnostic.code, "locale-unsupported");
2051 assert_eq!(error.diagnostic.span, None);
2052 }
2053
2054 #[test]
2055 fn locale_selection_emits_catalog_localized_workshop() {
2056 let compiler = Compiler::new().unwrap();
2057 let hir = crate::compile(
2058 "rule \"locale\":\n @Event global\n disableInspector()\n",
2059 "locale.opy",
2060 Path::new("."),
2061 )
2062 .unwrap();
2063 let artifact = compiler
2064 .compile_hir_with_locale(&hir, &Locale::new("zh-CN"))
2065 .unwrap();
2066 assert!(artifact.emitted.contains("规则 (\"locale\")"));
2067 assert!(artifact.emitted.contains("禁用查看器录制"));
2068 }
2069
2070 #[test]
2071 fn unsupported_output_directives_fail_at_their_source_anchor() {
2072 let compiler = Compiler::new().unwrap();
2073 let hir = crate::compile(
2074 "#!writeToOutputFile\nrule \"r\":\n @Event global\n pass\n",
2075 "directives.opy",
2076 Path::new("."),
2077 )
2078 .unwrap();
2079 let error = match compiler.compile_hir(&hir) {
2080 Ok(_) => panic!("backend directive unexpectedly compiled"),
2081 Err(error) => error,
2082 };
2083 assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
2084 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
2085 }
2086
2087 #[test]
2088 fn optimizer_directives_remain_non_blocking_presentation_controls() {
2089 let compiler = Compiler::new().unwrap();
2090 let hir = crate::compile(
2091 "#!disableOptimizations\nrule \"r\":\n @Event global\n pass\n",
2092 "optimization.opy",
2093 Path::new("."),
2094 )
2095 .unwrap();
2096 compiler.compile_hir(&hir).unwrap();
2097 }
2098
2099 #[test]
2100 fn strict_optimization_cases_are_preserved_by_native_lowering() {
2101 let compiler = Compiler::new().unwrap();
2102 let hir = crate::compile(
2103 "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",
2104 "strict.opy",
2105 Path::new("."),
2106 )
2107 .unwrap();
2108 let artifact = compiler.compile_hir(&hir).unwrap();
2109 assert!(artifact.emitted.contains("Add(Global.A, 0)"));
2110 assert!(artifact.emitted.contains("Multiply(Global.A, 0)"));
2111 assert!(artifact.emitted.contains("Multiply(Global.A, 1)"));
2112 assert!(
2113 artifact
2114 .emitted
2115 .contains("Compare(Custom String(\"am\"), ==, Custom String(\"**\"))")
2116 );
2117 }
2118
2119 #[test]
2120 fn compression_alphabet_policy_uses_a_shared_global() {
2121 let compiler = Compiler::new().unwrap();
2122 let hir = crate::compile(
2123 "globalvar values = compressed([1, 2, 3])\n\n#!useVariableForCompressionAlphabet\nrule \"compression\":\n @Event global\n print(values)\n",
2124 "compression.opy",
2125 Path::new("."),
2126 )
2127 .unwrap();
2128 let artifact = compiler.compile_hir(&hir).unwrap();
2129 assert!(artifact.emitted.contains("127: __compressionAlphabet__"));
2130 assert!(artifact.emitted.contains("Global.__compressionAlphabet__"));
2131 }
2132
2133 #[test]
2134 fn replacement_directives_lower_when_size_optimization_is_active() {
2135 let compiler = Compiler::new().unwrap();
2136 let hir = crate::compile(
2137 "#!optimizeForSize\n#!replace0ByCapturePercentage\nrule \"r\":\n @Event global\n print(0)\n",
2138 "directives.opy",
2139 Path::new("."),
2140 )
2141 .unwrap();
2142 let artifact = compiler.compile_hir(&hir).unwrap();
2143 assert!(artifact.emitted.contains("Point Capture Percentage"));
2144 }
2145
2146 #[test]
2147 fn debug_element_count_emits_sorted_rule_condition_and_action_comments() {
2148 let compiler = Compiler::new().unwrap();
2149 let hir = crate::compile(
2150 "#!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",
2151 "debug.opy",
2152 Path::new("."),
2153 )
2154 .unwrap();
2155 let artifact = compiler.compile_hir(&hir).unwrap();
2156 let report = artifact.wir.element_count(compiler.catalog).unwrap();
2157 let small = report
2158 .rules
2159 .iter()
2160 .find(|rule| rule.name == "small")
2161 .unwrap();
2162 let large = report
2163 .rules
2164 .iter()
2165 .find(|rule| rule.name == "large")
2166 .unwrap();
2167 let large_summary = artifact.emitted.find(" 32: rule \"large\"").unwrap();
2168 let small_summary = artifact.emitted.find(" 18: rule \"small\"").unwrap();
2169 assert!(large_summary < small_summary);
2170 assert!(
2171 artifact
2172 .emitted
2173 .starts_with("/* Element count: (total 50)\n")
2174 );
2175 assert!(artifact.emitted.contains("//32 elements\nrule (\"large\")"));
2176 assert!(artifact.emitted.contains("//18 elements\nrule (\"small\")"));
2177 assert!(
2178 artifact
2179 .emitted
2180 .contains("Global.value == 1; // 3 elements")
2181 );
2182 assert_eq!(artifact.emitted.matches(" // 3 elements").count(), 2);
2183 assert_eq!(artifact.emitted.matches(" // 14 elements").count(), 3);
2184 let expected_comments = large
2185 .children
2186 .iter()
2187 .chain(&small.children)
2188 .filter(|node| {
2189 matches!(
2190 node.kind,
2191 workshop_rs::actions::ElementNodeKind::Condition
2192 | workshop_rs::actions::ElementNodeKind::Action
2193 )
2194 })
2195 .count();
2196 assert_eq!(artifact.emitted.matches(" // ").count(), expected_comments);
2197 }
2198
2199 #[test]
2200 fn setup_and_initialization_directives_change_forward_output() {
2201 let compiler = Compiler::new().unwrap();
2202 let hir = crate::compile(
2203 "#!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",
2204 "directives.opy",
2205 Path::new("."),
2206 )
2207 .unwrap();
2208 let artifact = compiler.compile_hir(&hir).unwrap();
2209 assert!(artifact.emitted.contains("OverPy <"));
2210 assert!(artifact.emitted.contains("Disable inspector"));
2211 assert!(artifact.emitted.contains("Init globals"));
2212 assert!(artifact.emitted.contains("Init players"));
2213 assert!(artifact.emitted.contains("__holygrail__"));
2214 assert!(
2215 artifact
2216 .emitted
2217 .contains("Custom String(\"{0}fgFF0000FF>ready{0}/fg>\", Global.__holygrail__)")
2218 );
2219 }
2220
2221 #[test]
2222 fn exclude_variables_directive_omits_variable_declarations() {
2223 let compiler = Compiler::new().unwrap();
2224 let hir = crate::compile(
2225 "#!excludeVariablesInCompilation\nglobalvar value 0\nrule \"r\":\n @Event global\n pass\n",
2226 "directives.opy",
2227 Path::new("."),
2228 )
2229 .unwrap();
2230 let artifact = compiler.compile_hir(&hir).unwrap();
2231 assert!(!artifact.wir.global_variables.is_empty());
2232 assert!(
2233 !artifact.emitted.contains("variables {"),
2234 "{}",
2235 artifact.emitted
2236 );
2237 }
2238
2239 #[test]
2240 fn post_compile_hook_receives_exact_emitted_workshop() {
2241 let compiler = Compiler::new().unwrap();
2242 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2243 let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n debug(\"ready\")\n";
2244 let artifact = compiler
2245 .compile_source_with_locale(source, "hook.opy", &root, &Locale::new("en-US"))
2246 .unwrap();
2247 assert!(artifact.emitted.contains("rule (\"setup\")"));
2248 assert!(artifact.final_output.contains("rule (\"transformed\")"));
2249 assert_ne!(artifact.final_output, artifact.emitted);
2250 }
2251
2252 #[test]
2253 fn post_compile_hook_failure_keeps_script_provenance_and_directive_anchor() {
2254 let compiler = Compiler::new().unwrap();
2255 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2256 let source = "#!postCompileHook \"hook-boom.js\"\n\nrule \"setup\":\n pass\n";
2257 let error = match compiler.compile_source_with_locale(
2258 source,
2259 "hook.opy",
2260 &root,
2261 &Locale::new("en-US"),
2262 ) {
2263 Ok(_) => panic!("failing post-compile hook unexpectedly compiled"),
2264 Err(error) => error,
2265 };
2266 assert_eq!(error.diagnostic.code, "post-compile-hook");
2267 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
2268 let script = error.diagnostic.script.unwrap();
2269 assert_eq!(script.source_name.as_deref(), Some("hook-boom.js"));
2270 assert_eq!(script.line, Some(1));
2271 assert!(script.stack.unwrap().contains("hook-boom.js:1"));
2272 }
2273
2274 fn normalize_workshop_structural_whitespace(text: &str) -> String {
2275 let mut normalized = String::with_capacity(text.len());
2276 let mut quote = None;
2277 let mut escaped = false;
2278 for character in text.chars() {
2279 if let Some(delimiter) = quote {
2280 normalized.push(character);
2281 if escaped {
2282 escaped = false;
2283 } else if character == '\\' {
2284 escaped = true;
2285 } else if character == delimiter {
2286 quote = None;
2287 }
2288 } else if matches!(character, '\"' | '\'') {
2289 quote = Some(character);
2290 normalized.push(character);
2291 } else if !character.is_whitespace() {
2292 normalized.push(character);
2293 }
2294 }
2295 normalized
2296 }
2297
2298 #[test]
2299 fn settings_whitespace_normalization_preserves_quoted_values() {
2300 assert_ne!(
2301 normalize_workshop_structural_whitespace("Description: \"a b\""),
2302 normalize_workshop_structural_whitespace("Description: \"ab\"")
2303 );
2304 }
2305}