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 mut data: serde_json::Value =
1210 serde_json::from_str(crate::manifest::MANIFEST_DATA).unwrap();
1211 data["functions"][0]["catalogId"] = serde_json::json!("missing-catalog-id");
1212 let stale = Manifest::load(
1213 &serde_json::to_string(&data).unwrap(),
1214 crate::manifest::PROBES_DATA,
1215 )
1216 .unwrap();
1217 let error = cross_check_manifest(&stale, &Catalog::builtin().unwrap()).unwrap_err();
1218 assert_eq!(error.diagnostic.code, "catalog-link-missing");
1219 }
1220
1221 #[test]
1222 fn while_lowering_is_source_attributed() {
1223 let compiler = Compiler::new().unwrap();
1224 let hir = crate::compile(
1225 "rule \"while\":\n @Event global\n while true:\n disableInspector()\n",
1226 "while.opy",
1227 Path::new("."),
1228 )
1229 .unwrap();
1230 let artifact = compiler.compile_hir(&hir).unwrap();
1231 let rule = artifact.wir.rules.first().unwrap();
1232 assert!(matches!(
1233 rule.actions.first(),
1234 Some(workshop_rs::Action::While { .. })
1235 ));
1236 assert!(artifact.emitted.contains("While(True);"));
1237 }
1238
1239 #[test]
1240 fn expanded_control_flow_actions_keep_their_originating_spans() {
1241 let compiler = Compiler::new().unwrap();
1242 let hir = crate::compile(
1243 "globalvar value = 1\nrule \"if\":\n @Event global\n if true:\n wait(1)\n disableInspector()\n",
1244 "control-flow-provenance.opy",
1245 Path::new("."),
1246 )
1247 .unwrap();
1248 let artifact = compiler.compile_hir(&hir).unwrap();
1249
1250 assert_eq!(artifact.wir.action_span(0, 0).unwrap().start.line, 1);
1251 assert_eq!(
1252 artifact
1253 .wir
1254 .action_argument_span(0, 0, 0)
1255 .unwrap()
1256 .start
1257 .line,
1258 1
1259 );
1260 assert_eq!(artifact.wir.rules[1].actions.len(), 4);
1261 assert_eq!(artifact.wir.action_span(1, 0).unwrap().start.line, 4);
1262 assert_eq!(artifact.wir.action_span(1, 1).unwrap().start.line, 5);
1263 assert_eq!(
1264 artifact
1265 .wir
1266 .action_argument_span(1, 1, 0)
1267 .unwrap()
1268 .start
1269 .line,
1270 5
1271 );
1272 assert_eq!(artifact.wir.action_span(1, 2).unwrap().start.line, 4);
1273 }
1274
1275 #[test]
1276 fn range_argument_provenance_uses_canonical_positions() {
1277 let compiler = Compiler::new().unwrap();
1278 let hir = crate::compile(
1279 "globalvar value\nrule \"range\":\n @Event global\n for value in range(3):\n wait(1)\n",
1280 "range-provenance.opy",
1281 Path::new("."),
1282 )
1283 .unwrap();
1284 let artifact = compiler.compile_hir(&hir).unwrap();
1285
1286 assert_eq!(artifact.wir.action_span(0, 0).unwrap().start.line, 4);
1287 assert!(artifact.wir.action_argument_span(0, 0, 0).is_none());
1288 assert_eq!(
1289 artifact
1290 .wir
1291 .action_argument_span(0, 0, 1)
1292 .unwrap()
1293 .start
1294 .line,
1295 4
1296 );
1297 assert!(artifact.wir.action_argument_span(0, 0, 2).is_none());
1298 }
1299
1300 #[test]
1301 fn structural_subroutines_lower_to_canonical_wir() {
1302 let compiler = Compiler::new().unwrap();
1303 let hir = crate::compile(
1304 "globalvar score\nsubroutine showStatus\ndef showStatus():\n @Name \"Friendly\"\n @SuppressWarnings unusedVariable\n disableInspector()\nrule \"caller\":\n @Event global\n showStatus()\n",
1305 "structure.opy",
1306 Path::new("."),
1307 )
1308 .unwrap();
1309 let artifact = compiler.compile_hir(&hir).unwrap();
1310 let subroutine = artifact.wir.subroutines.first().unwrap();
1311 assert_eq!(subroutine.name, "showStatus");
1312 assert_eq!(artifact.wir.rules.len(), 2);
1313 let subroutine_rule = artifact.wir.rules.first().unwrap();
1314 let workshop_rs::Event::Subroutine(subroutine_name) = &subroutine_rule.event else {
1315 panic!("expected a subroutine event");
1316 };
1317 assert_eq!(subroutine_name, "showStatus");
1318 assert!(matches!(
1319 artifact.wir.rules.get(1).unwrap().actions.first(),
1320 Some(workshop_rs::Action::CallSubroutine { .. })
1321 ));
1322 assert!(artifact.emitted.contains("Subroutine Friendly"));
1323 }
1324
1325 #[test]
1326 fn player_event_filters_resolve_through_canonical_catalog() {
1327 let compiler = Compiler::new().unwrap();
1328 let hir = crate::compile(
1329 "rule \"joined\":\n @Event playerJoined\n @Team 1\n @Slot 2\n disableInspector()\n",
1330 "filters.opy",
1331 Path::new("."),
1332 )
1333 .unwrap();
1334 let artifact = compiler.compile_hir(&hir).unwrap();
1335 assert!(matches!(
1336 &artifact.wir.rules.first().unwrap().event,
1337 workshop_rs::Event::Player {
1338 kind: workshop_rs::PlayerEventKind::Joined,
1339 team: workshop_rs::EventTeam::Team1,
1340 target: workshop_rs::EventTarget::Slot(2),
1341 }
1342 ));
1343 assert!(artifact.emitted.contains("Player Joined Match;"));
1344 }
1345
1346 #[test]
1347 fn hero_event_filters_accept_legacy_aliases() {
1348 let compiler = Compiler::new().unwrap();
1349 let hir = crate::compile(
1350 "rule \"hero\":\n @Event eachPlayer\n @Hero soldier\n disableInspector()\n",
1351 "hero-filter.opy",
1352 Path::new("."),
1353 )
1354 .unwrap();
1355 let artifact = compiler.compile_hir(&hir).unwrap();
1356 assert!(matches!(
1357 &artifact.wir.rules.first().unwrap().event,
1358 workshop_rs::Event::EachPlayerWithFilters {
1359 target: workshop_rs::EventTarget::Hero(hero),
1360 ..
1361 } if hero == "SOLDIER_76"
1362 ));
1363 }
1364
1365 #[test]
1366 fn explicit_indices_are_reserved_before_deterministic_allocation() {
1367 let compiler = Compiler::new().unwrap();
1368 let hir = crate::compile(
1369 "globalvar first\nglobalvar reserved 0\nglobalvar next\nrule \"indices\":\n @Event global\n disableInspector()\n",
1370 "indices.opy",
1371 Path::new("."),
1372 )
1373 .unwrap();
1374 let artifact = compiler.compile_hir(&hir).unwrap();
1375 let by_name = artifact
1376 .wir
1377 .global_variables
1378 .iter()
1379 .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1380 .collect::<std::collections::BTreeMap<_, _>>();
1381 assert_eq!(
1382 by_name,
1383 std::collections::BTreeMap::from([("first", 1), ("reserved", 0), ("next", 2)])
1384 );
1385 let indices = artifact
1387 .wir
1388 .global_variables
1389 .iter()
1390 .map(|variable| variable.index.unwrap())
1391 .collect::<Vec<_>>();
1392 assert_eq!(indices, vec![0, 1, 2]);
1393 }
1394
1395 #[test]
1396 fn implicit_default_variables_use_reference_fixed_slots() {
1397 let compiler = Compiler::new().unwrap();
1398 let hir = crate::compile(
1399 r#"
1400globalvar timer
1401globalvar extra 5
1402
1403rule "implicit":
1404 @Event global
1405 A = timer + 1
1406 B = A
1407 B += 2
1408 A[0] = 7
1409 DX = B * A
1410"#,
1411 "implicit.opy",
1412 Path::new("."),
1413 )
1414 .unwrap();
1415 let artifact = compiler.compile_hir(&hir).unwrap();
1416 let globals = artifact
1417 .wir
1418 .global_variables
1419 .iter()
1420 .map(|variable| (variable.name.clone(), variable.index.unwrap()))
1421 .collect::<Vec<_>>();
1422 assert_eq!(
1427 globals,
1428 vec![
1429 ("A".to_string(), 0),
1430 ("B".to_string(), 1),
1431 ("timer".to_string(), 2),
1432 ("extra".to_string(), 5),
1433 ("DX".to_string(), 127),
1434 ]
1435 );
1436 assert!(
1437 artifact
1438 .emitted
1439 .contains("Set Global Variable(A, Add(Global.timer, 1));")
1440 );
1441 assert!(
1442 artifact
1443 .emitted
1444 .contains("Set Global Variable(B, Global.A);")
1445 );
1446 assert!(
1447 artifact
1448 .emitted
1449 .contains("Modify Global Variable(B, Add, 2);")
1450 );
1451 assert!(
1452 artifact
1453 .emitted
1454 .contains("Set Global Variable At Index(A, 0, 7);")
1455 );
1456 assert!(
1457 artifact
1458 .emitted
1459 .contains("Set Global Variable(DX, Multiply(Global.B, Global.A));")
1460 );
1461 }
1462
1463 #[test]
1464 fn implicit_default_variable_slot_collision_is_source_attributed() {
1465 let compiler = Compiler::new().unwrap();
1466 let hir = crate::compile(
1467 "globalvar x 0\nrule \"collision\":\n @Event global\n x = 1\n A = 2\n",
1468 "collision.opy",
1469 Path::new("."),
1470 )
1471 .unwrap();
1472 let error = match compiler.compile_hir(&hir) {
1473 Ok(_) => panic!("slot collision unexpectedly succeeded"),
1474 Err(error) => error,
1475 };
1476 assert_eq!(error.diagnostic.code, "index-collision");
1477 assert_eq!(error.diagnostic.span.unwrap().start.line, 5);
1478 assert!(error.diagnostic.message.contains("'A' and 'x'"));
1479 }
1480
1481 #[test]
1482 fn implicit_default_player_variables_use_independent_reference_slots() {
1483 let compiler = Compiler::new().unwrap();
1484 let hir = crate::compile(
1485 r#"
1486playervar declaredPlayer
1487
1488rule "implicit player variables":
1489 @Event eachPlayer
1490 A = 1
1491 eventPlayer.A = 1
1492 eventPlayer.A += 2
1493 eventPlayer.E = eventPlayer.A
1494 eventPlayer.DX = eventPlayer.E
1495 eventPlayer.declaredPlayer = eventPlayer.A
1496"#,
1497 "implicit-player.opy",
1498 Path::new("."),
1499 )
1500 .unwrap();
1501 let artifact = compiler.compile_hir(&hir).unwrap();
1502 let globals = artifact
1503 .wir
1504 .global_variables
1505 .iter()
1506 .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1507 .collect::<std::collections::BTreeMap<_, _>>();
1508 let players = artifact
1509 .wir
1510 .player_variables
1511 .iter()
1512 .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1513 .collect::<std::collections::BTreeMap<_, _>>();
1514 assert_eq!(globals.get("A"), Some(&0));
1515 assert_eq!(players.get("A"), Some(&0));
1516 assert_eq!(players.get("declaredPlayer"), Some(&1));
1517 assert_eq!(players.get("E"), Some(&4));
1518 assert_eq!(players.get("DX"), Some(&127));
1519 assert!(
1520 artifact
1521 .emitted
1522 .contains("Set Player Variable(Event Player, A, 1);")
1523 );
1524 assert!(
1525 artifact
1526 .emitted
1527 .contains("Modify Player Variable(Event Player, A, Add, 2);")
1528 );
1529 assert!(
1530 artifact
1531 .emitted
1532 .contains("Set Player Variable(Event Player, E, (Event Player).A);")
1533 );
1534 }
1535
1536 #[test]
1537 fn implicit_default_player_slot_collision_is_source_attributed() {
1538 let compiler = Compiler::new().unwrap();
1539 let hir = crate::compile(
1540 "playervar declared 0\nrule \"collision\":\n @Event eachPlayer\n eventPlayer.A = 1\n",
1541 "player-collision.opy",
1542 Path::new("."),
1543 )
1544 .unwrap();
1545 let error = match compiler.compile_hir(&hir) {
1546 Ok(_) => panic!("player slot collision unexpectedly succeeded"),
1547 Err(error) => error,
1548 };
1549 assert_eq!(error.diagnostic.code, "index-collision");
1550 assert!(
1551 error
1552 .diagnostic
1553 .message
1554 .contains("player variables 'A' and 'declared'")
1555 );
1556 assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
1557 }
1558
1559 #[test]
1560 fn power_augmented_assignment_lowers_from_source() {
1561 let compiler = Compiler::new().unwrap();
1562 let hir = crate::compile(
1563 "globalvar g\nrule \"power\":\n @Event global\n g = 2\n g **= 3\n",
1564 "power.opy",
1565 Path::new("."),
1566 )
1567 .unwrap();
1568 let artifact = compiler.compile_hir(&hir).unwrap();
1569 assert!(artifact.emitted.contains("Set Global Variable(g, 2);"));
1570 assert!(
1571 artifact
1572 .emitted
1573 .contains("Modify Global Variable(g, Raise To Power, 3);")
1574 );
1575 }
1576
1577 #[test]
1578 fn opy_hex_numbers_are_normalized_at_the_wir_boundary() {
1579 let compiler = Compiler::new().unwrap();
1580 let hir = crate::compile(
1581 "globalvar large = 0x124BC\nglobalvar small = 0x124\nglobalvar scientific = 1e10\n",
1582 "numbers.opy",
1583 Path::new("."),
1584 )
1585 .unwrap();
1586 let artifact = compiler.compile_hir(&hir).unwrap();
1587 assert!(
1588 artifact
1589 .emitted
1590 .contains("Set Global Variable(large, 74940);")
1591 );
1592 assert!(
1593 artifact
1594 .emitted
1595 .contains("Set Global Variable(small, 292);")
1596 );
1597 assert!(
1598 artifact
1599 .emitted
1600 .contains("Set Global Variable(scientific, 10000000000);")
1601 );
1602 assert!(!artifact.emitted.contains("0x124BC"));
1603 assert!(!artifact.emitted.contains("0x124"));
1604 }
1605
1606 #[test]
1607 fn literal_dict_lookup_lowers_to_the_selected_value() {
1608 let compiler = Compiler::new().unwrap();
1609 let hir = crate::compile(
1610 "globalvar total\nrule \"negative\":\n @Event global\n total = {\"a\": 1, \"b\": 2}[\"a\"]\n",
1611 "negative.opy",
1612 Path::new("."),
1613 )
1614 .unwrap();
1615 let artifact = compiler
1616 .compile_hir(&hir)
1617 .expect("literal dict lookup should lower");
1618 assert!(artifact.emitted.contains("Set Global Variable(total, 1);"));
1619 }
1620
1621 #[test]
1622 fn auto_allocation_fills_free_slots_below_early_explicit_indices() {
1623 let compiler = Compiler::new().unwrap();
1624 let hir = crate::compile(
1625 r#"
1626globalvar reserved 5
1627globalvar auto1
1628globalvar auto2
1629
1630rule "allocation":
1631 @Event global
1632 auto1 = 1
1633 auto2 = 2
1634 B = 3
1635"#,
1636 "allocation.opy",
1637 Path::new("."),
1638 )
1639 .unwrap();
1640 let artifact = compiler.compile_hir(&hir).unwrap();
1641 let by_name = artifact
1642 .wir
1643 .global_variables
1644 .iter()
1645 .map(|variable| (variable.name.clone(), variable.index.unwrap()))
1646 .collect::<std::collections::BTreeMap<_, _>>();
1647 assert_eq!(
1652 by_name,
1653 std::collections::BTreeMap::from([
1654 ("B".to_string(), 1),
1655 ("auto1".to_string(), 0),
1656 ("auto2".to_string(), 2),
1657 ("reserved".to_string(), 5),
1658 ])
1659 );
1660 }
1661
1662 #[test]
1663 fn power_expressions_lower_through_the_canonical_contract() {
1664 let compiler = Compiler::new().unwrap();
1665 let hir = crate::compile(
1666 "globalvar a = [2, 4]\nglobalvar out\nrule \"power\":\n @Event global\n out = a ** 2\n a **= 2\n a[0] **= 2\n",
1667 "power.opy",
1668 Path::new("."),
1669 )
1670 .unwrap();
1671 let artifact = compiler.compile_hir(&hir).unwrap();
1672 assert!(
1673 artifact
1674 .emitted
1675 .contains("Set Global Variable(out, Raise To Power(Global.a, 2));")
1676 );
1677 assert!(
1678 artifact
1679 .emitted
1680 .contains("Modify Global Variable(a, Raise To Power, 2);")
1681 );
1682 assert!(
1683 artifact
1684 .emitted
1685 .contains("Modify Global Variable At Index(a, 0, Raise To Power, 2);")
1686 );
1687 }
1688
1689 #[test]
1690 fn unsupported_rule_metadata_is_explicit_and_source_attributed() {
1691 let compiler = Compiler::new().unwrap();
1692 let hir = crate::compile(
1693 "rule \"metadata\":\n @Event global\n @NewPage \"section\"\n disableInspector()\n",
1694 "metadata.opy",
1695 Path::new("."),
1696 )
1697 .unwrap();
1698 let error = match compiler.compile_hir(&hir) {
1699 Ok(_) => panic!("unsupported metadata unexpectedly succeeded"),
1700 Err(error) => error,
1701 };
1702 assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
1703 assert_eq!(error.diagnostic.span.unwrap().start.line, 3);
1704 }
1705
1706 #[test]
1707 fn compiler_structure_matches_the_pinned_oracle() {
1708 let compiler = Compiler::new().unwrap();
1709 let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
1710 .join("tests/fixtures/corpus/synthetic/compiler-structure");
1711 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
1712 let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
1713 let artifact = compiler.compile_hir(&hir).unwrap();
1714 let oracle: serde_json::Value =
1715 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
1716 .unwrap();
1717 let oracle_workshop = oracle["compile"]["workshop"].as_str().unwrap();
1718 let oracle_wir = workshop_rs::parser::parse(
1719 oracle_workshop,
1720 &Catalog::builtin().unwrap(),
1721 &Locale::new("en-US"),
1722 )
1723 .unwrap();
1724 assert!(workshop_rs::roundtrip::equivalent(
1725 &artifact.wir,
1726 &oracle_wir
1727 ));
1728
1729 assert!(oracle_workshop.contains("0: reserved"));
1730 assert!(oracle_workshop.contains("1: first"));
1731 assert!(oracle_workshop.contains("2: explicit"));
1732 assert!(oracle_workshop.contains("3: next"));
1733 assert!(oracle_workshop.contains("0: helper"));
1734 assert!(oracle_workshop.contains("Subroutine;\n helper;"));
1735 assert!(oracle_workshop.contains("Player Joined Match;\n Team 1;\n Slot 2;"));
1736
1737 let indices = artifact
1738 .wir
1739 .global_variables
1740 .iter()
1741 .map(|variable| variable.index.unwrap())
1742 .collect::<Vec<_>>();
1743 assert_eq!(indices, vec![0, 1, 2, 3]);
1744 assert_eq!(artifact.wir.subroutines.first().unwrap().name, "helper");
1745 assert!(artifact.emitted.contains("[Source] renamed helper"));
1746 assert!(matches!(
1747 artifact.wir.rules.get(1).unwrap().event,
1748 workshop_rs::Event::Player {
1749 kind: workshop_rs::PlayerEventKind::Joined,
1750 team: workshop_rs::EventTeam::Team1,
1751 target: workshop_rs::EventTarget::Slot(2),
1752 }
1753 ));
1754 }
1755
1756 #[test]
1757 fn assignments_and_modifications_lower_to_canonical_wir() {
1758 let compiler = Compiler::new().unwrap();
1759 let hir = crate::compile(
1760 r#"
1761globalvar g1
1762globalvar g2
1763playervar p1
1764playervar p2 = [1, 2, 3]
1765
1766rule "assignments":
1767 @Event eachPlayer
1768 g1 = 10
1769 g1 += 5
1770 g1 -= 2
1771 g1 *= 3
1772 g1 /= 2
1773 g1 %= 4
1774 g2 = [1, 2, 3]
1775 g2[0] = 99
1776 g2[1] += 1
1777 eventPlayer.p1 = 42
1778 eventPlayer.p1 += 8
1779 eventPlayer.p1 *= 2
1780 eventPlayer.p2[2] = 7
1781 eventPlayer.p2[0] -= 3
1782"#,
1783 "assign.opy",
1784 Path::new("."),
1785 )
1786 .unwrap();
1787 let artifact = compiler.compile_hir(&hir).unwrap();
1788 assert!(artifact.emitted.contains("Set Global Variable(g1, 10);"));
1789 assert!(
1790 artifact
1791 .emitted
1792 .contains("Modify Global Variable(g1, Add, 5);")
1793 );
1794 assert!(
1795 artifact
1796 .emitted
1797 .contains("Modify Global Variable(g1, Subtract, 2);")
1798 );
1799 assert!(
1800 artifact
1801 .emitted
1802 .contains("Modify Global Variable(g1, Multiply, 3);")
1803 );
1804 assert!(
1805 artifact
1806 .emitted
1807 .contains("Modify Global Variable(g1, Divide, 2);")
1808 );
1809 assert!(
1810 artifact
1811 .emitted
1812 .contains("Modify Global Variable(g1, Modulo, 4);")
1813 );
1814 assert!(
1815 artifact
1816 .emitted
1817 .contains("Set Global Variable At Index(g2, 0, 99);")
1818 );
1819 assert!(
1820 artifact
1821 .emitted
1822 .contains("Modify Global Variable At Index(g2, 1, Add, 1);")
1823 );
1824 assert!(
1825 artifact
1826 .emitted
1827 .contains("Set Player Variable(Event Player, p1, 42);")
1828 );
1829 assert!(
1830 artifact
1831 .emitted
1832 .contains("Modify Player Variable(Event Player, p1, Add, 8);")
1833 );
1834 assert!(
1835 artifact
1836 .emitted
1837 .contains("Modify Player Variable(Event Player, p1, Multiply, 2);")
1838 );
1839 assert!(artifact.wir.rules[1].actions.iter().any(|action| matches!(
1840 action,
1841 workshop_rs::Action::Call { name, args }
1842 if name == "setPlayerVariableAtIndex"
1843 && matches!(
1844 args.as_slice(),
1845 [
1846 workshop_rs::Value::PlayerVariable { player, variable },
1847 workshop_rs::Value::Number(index),
1848 workshop_rs::Value::Number(value),
1849 ] if matches!(player.as_ref(), workshop_rs::Value::EventPlayer)
1850 && variable == "p2"
1851 && *index == 2.0
1852 && *value == 7.0
1853 )
1854 )));
1855 assert!(artifact.wir.rules[1].actions.iter().any(|action| matches!(
1856 action,
1857 workshop_rs::Action::Call { name, args }
1858 if name == "modifyPlayerVariableAtIndex"
1859 && matches!(
1860 args.as_slice(),
1861 [
1862 workshop_rs::Value::PlayerVariable { player, variable },
1863 workshop_rs::Value::Number(index),
1864 workshop_rs::Value::Call { name: operation, .. },
1865 workshop_rs::Value::Number(value),
1866 ] if matches!(player.as_ref(), workshop_rs::Value::EventPlayer)
1867 && variable == "p2"
1868 && *index == 0.0
1869 && operation == "subtract"
1870 && *value == 3.0
1871 )
1872 )));
1873
1874 let rule = artifact.wir.rules.get(1).unwrap();
1875 assert!(matches!(
1876 rule.actions.first(),
1877 Some(workshop_rs::Action::SetGlobalVariable { variable, .. }) if variable == "g1"
1878 ));
1879 assert!(matches!(
1880 rule.actions.get(7),
1881 Some(workshop_rs::Action::Call { .. })
1882 ));
1883 }
1884
1885 #[test]
1886 fn expressions_and_values_lower_to_canonical_wir() {
1887 let compiler = Compiler::new().unwrap();
1888 let hir = crate::compile(
1889 r#"
1890enum Consts:
1891 BASE
1892
1893globalvar total
1894globalvar arr = [1, 2, 3]
1895globalvar pos = vect(1, 2, 3)
1896
1897rule "expressions":
1898 @Event global
1899 @Condition total == 0
1900 @Condition not (pos == vect(0, 0, 0))
1901 @Condition 2 in arr
1902 total = Consts.BASE + arr[1] * 2 - (10 / 2) + (5 % 2)
1903 print("Total: {}".format(total))
1904 debug(pos)
1905"#,
1906 "expr.opy",
1907 Path::new("."),
1908 )
1909 .unwrap();
1910 let artifact = compiler.compile_hir(&hir).unwrap();
1911 assert!(artifact.emitted.contains("Global.total == 0;"));
1912 assert!(artifact.emitted.contains("Global.pos != Vector(0, 0, 0);"));
1915 assert!(
1916 artifact
1917 .emitted
1918 .contains("Array Contains(Global.arr, 2) == True;")
1919 );
1920 assert!(
1921 artifact
1922 .emitted
1923 .contains("Custom String(\"Total: {0}\", Global.total)")
1924 );
1925 }
1926
1927 #[test]
1928 fn pass_is_supported_as_source_level_noop() {
1929 let compiler = Compiler::new().unwrap();
1930 let hir = crate::compile(
1931 r#"
1932subroutine emptySub
1933
1934def emptySub():
1935 pass
1936
1937rule "empty rule":
1938 @Event global
1939 pass
1940"#,
1941 "pass.opy",
1942 Path::new("."),
1943 )
1944 .unwrap();
1945 let artifact = compiler.compile_hir(&hir).unwrap();
1946 assert!(artifact.wir.rules.is_empty());
1949 }
1950
1951 #[test]
1952 fn optimized_non_subroutine_wait_only_rule_matches_overpy_noop_elision() {
1953 let compiler = Compiler::new().unwrap();
1954 let hir = crate::compile(
1955 r#"
1956rule "wait-only":
1957 @Event eachPlayer
1958 wait(0.032)
1959"#,
1960 "wait-only.opy",
1961 Path::new("."),
1962 )
1963 .unwrap();
1964 let artifact = compiler.compile_hir(&hir).unwrap();
1965 assert!(artifact.wir.rules.is_empty());
1966 }
1967
1968 #[test]
1969 fn variable_initializers_synthesize_initialize_rules() {
1970 let compiler = Compiler::new().unwrap();
1971 let hir = crate::compile(
1972 r#"
1973globalvar j = 5
1974globalvar h = 0
1975globalvar k = 0.0
1976playervar p = 7
1977playervar q = 0
1978
1979rule "main":
1980 @Event global
1981 disableInspector()
1982"#,
1983 "init.opy",
1984 Path::new("."),
1985 )
1986 .unwrap();
1987 let artifact = compiler.compile_hir(&hir).unwrap();
1988 assert_eq!(
1989 artifact.wir.rules.first().unwrap().name,
1990 "Initialize global variables"
1991 );
1992 assert_eq!(
1993 artifact.wir.rules.get(1).unwrap().name,
1994 "Initialize player variables"
1995 );
1996 assert_eq!(artifact.wir.rules.get(2).unwrap().name, "main");
1997 assert!(artifact.emitted.contains("Set Global Variable(j, 5);"));
1998 assert!(artifact.emitted.contains("Set Global Variable(k, 0);"));
1999 assert!(!artifact.emitted.contains("Set Global Variable(h,"));
2000 assert!(
2001 artifact
2002 .emitted
2003 .contains("Set Player Variable(Event Player, p, 7);")
2004 );
2005 assert!(
2006 !artifact
2007 .emitted
2008 .contains("Set Player Variable(Event Player, q,")
2009 );
2010 }
2011
2012 #[test]
2013 fn settings_lower_through_workshop_owned_emission() {
2014 let compiler = Compiler::new().unwrap();
2015 let fixture =
2016 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/corpus/synthetic/settings");
2017 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
2018 let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
2019 let artifact = compiler.compile_hir(&hir).unwrap();
2020 let oracle: serde_json::Value =
2021 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
2022 .unwrap();
2023 let expected = oracle["compile"]["workshop"]
2024 .as_str()
2025 .unwrap()
2026 .split("\n\nrule")
2027 .next()
2028 .unwrap();
2029 let actual = artifact.emitted.split("\n\nrule").next().unwrap();
2030 let oracle_wir = workshop_rs::parser::parse(
2031 oracle["compile"]["workshop"].as_str().unwrap(),
2032 &Catalog::builtin().unwrap(),
2033 &Locale::new("en-US"),
2034 )
2035 .unwrap();
2036 assert!(workshop_rs::roundtrip::equivalent(
2037 &artifact.wir,
2038 &oracle_wir
2039 ));
2040 assert_eq!(
2041 normalize_workshop_structural_whitespace(actual),
2042 normalize_workshop_structural_whitespace(expected)
2043 );
2044 }
2045
2046 #[test]
2047 fn unsupported_locale_has_no_fabricated_source_span() {
2048 let compiler = Compiler::new().unwrap();
2049 let hir = crate::compile(
2050 "#!translations en\nrule \"r\":\n @Event global\n pass\n",
2051 "locale.opy",
2052 Path::new("."),
2053 )
2054 .unwrap();
2055 let error = match compiler.compile_hir_with_locale(&hir, &Locale::new("xx-XX")) {
2056 Ok(_) => panic!("unsupported locale unexpectedly compiled"),
2057 Err(error) => error,
2058 };
2059 assert_eq!(error.diagnostic.code, "locale-unsupported");
2060 assert_eq!(error.diagnostic.span, None);
2061 }
2062
2063 #[test]
2064 fn locale_selection_emits_catalog_localized_workshop() {
2065 let compiler = Compiler::new().unwrap();
2066 let hir = crate::compile(
2067 "rule \"locale\":\n @Event global\n disableInspector()\n",
2068 "locale.opy",
2069 Path::new("."),
2070 )
2071 .unwrap();
2072 let artifact = compiler
2073 .compile_hir_with_locale(&hir, &Locale::new("zh-CN"))
2074 .unwrap();
2075 assert!(artifact.emitted.contains("规则 (\"locale\")"));
2076 assert!(artifact.emitted.contains("禁用查看器录制"));
2077 }
2078
2079 #[test]
2080 fn unsupported_output_directives_fail_at_their_source_anchor() {
2081 let compiler = Compiler::new().unwrap();
2082 let hir = crate::compile(
2083 "#!writeToOutputFile\nrule \"r\":\n @Event global\n pass\n",
2084 "directives.opy",
2085 Path::new("."),
2086 )
2087 .unwrap();
2088 let error = match compiler.compile_hir(&hir) {
2089 Ok(_) => panic!("backend directive unexpectedly compiled"),
2090 Err(error) => error,
2091 };
2092 assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
2093 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
2094 }
2095
2096 #[test]
2097 fn optimizer_directives_remain_non_blocking_presentation_controls() {
2098 let compiler = Compiler::new().unwrap();
2099 let hir = crate::compile(
2100 "#!disableOptimizations\nrule \"r\":\n @Event global\n pass\n",
2101 "optimization.opy",
2102 Path::new("."),
2103 )
2104 .unwrap();
2105 compiler.compile_hir(&hir).unwrap();
2106 }
2107
2108 #[test]
2109 fn strict_optimization_cases_are_preserved_by_native_lowering() {
2110 let compiler = Compiler::new().unwrap();
2111 let hir = crate::compile(
2112 "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",
2113 "strict.opy",
2114 Path::new("."),
2115 )
2116 .unwrap();
2117 let artifact = compiler.compile_hir(&hir).unwrap();
2118 assert!(artifact.emitted.contains("Add(Global.A, 0)"));
2119 assert!(artifact.emitted.contains("Multiply(Global.A, 0)"));
2120 assert!(artifact.emitted.contains("Multiply(Global.A, 1)"));
2121 assert!(
2122 artifact
2123 .emitted
2124 .contains("Compare(Custom String(\"am\"), ==, Custom String(\"**\"))")
2125 );
2126 }
2127
2128 #[test]
2129 fn compression_alphabet_policy_uses_a_shared_global() {
2130 let compiler = Compiler::new().unwrap();
2131 let hir = crate::compile(
2132 "globalvar values = compressed([1, 2, 3])\n\n#!useVariableForCompressionAlphabet\nrule \"compression\":\n @Event global\n print(values)\n",
2133 "compression.opy",
2134 Path::new("."),
2135 )
2136 .unwrap();
2137 let artifact = compiler.compile_hir(&hir).unwrap();
2138 assert!(artifact.emitted.contains("127: __compressionAlphabet__"));
2139 assert!(artifact.emitted.contains("Global.__compressionAlphabet__"));
2140 }
2141
2142 #[test]
2143 fn replacement_directives_lower_when_size_optimization_is_active() {
2144 let compiler = Compiler::new().unwrap();
2145 let hir = crate::compile(
2146 "#!optimizeForSize\n#!replace0ByCapturePercentage\nrule \"r\":\n @Event global\n print(0)\n",
2147 "directives.opy",
2148 Path::new("."),
2149 )
2150 .unwrap();
2151 let artifact = compiler.compile_hir(&hir).unwrap();
2152 assert!(artifact.emitted.contains("Point Capture Percentage"));
2153 }
2154
2155 #[test]
2156 fn debug_element_count_emits_sorted_rule_condition_and_action_comments() {
2157 let compiler = Compiler::new().unwrap();
2158 let hir = crate::compile(
2159 "#!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",
2160 "debug.opy",
2161 Path::new("."),
2162 )
2163 .unwrap();
2164 let artifact = compiler.compile_hir(&hir).unwrap();
2165 let report = artifact.wir.element_count(compiler.catalog).unwrap();
2166 let small = report
2167 .rules
2168 .iter()
2169 .find(|rule| rule.name == "small")
2170 .unwrap();
2171 let large = report
2172 .rules
2173 .iter()
2174 .find(|rule| rule.name == "large")
2175 .unwrap();
2176 let large_summary = artifact.emitted.find(" 32: rule \"large\"").unwrap();
2177 let small_summary = artifact.emitted.find(" 18: rule \"small\"").unwrap();
2178 assert!(large_summary < small_summary);
2179 assert!(
2180 artifact
2181 .emitted
2182 .starts_with("/* Element count: (total 50)\n")
2183 );
2184 assert!(artifact.emitted.contains("//32 elements\nrule (\"large\")"));
2185 assert!(artifact.emitted.contains("//18 elements\nrule (\"small\")"));
2186 assert!(
2187 artifact
2188 .emitted
2189 .contains("Global.value == 1; // 3 elements")
2190 );
2191 assert_eq!(artifact.emitted.matches(" // 3 elements").count(), 2);
2192 assert_eq!(artifact.emitted.matches(" // 14 elements").count(), 3);
2193 let expected_comments = large
2194 .children
2195 .iter()
2196 .chain(&small.children)
2197 .filter(|node| {
2198 matches!(
2199 node.kind,
2200 workshop_rs::actions::ElementNodeKind::Condition
2201 | workshop_rs::actions::ElementNodeKind::Action
2202 )
2203 })
2204 .count();
2205 assert_eq!(artifact.emitted.matches(" // ").count(), expected_comments);
2206 }
2207
2208 #[test]
2209 fn setup_and_initialization_directives_change_forward_output() {
2210 let compiler = Compiler::new().unwrap();
2211 let hir = crate::compile(
2212 "#!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",
2213 "directives.opy",
2214 Path::new("."),
2215 )
2216 .unwrap();
2217 let artifact = compiler.compile_hir(&hir).unwrap();
2218 assert!(artifact.emitted.contains("OverPy <"));
2219 assert!(artifact.emitted.contains("Disable inspector"));
2220 assert!(artifact.emitted.contains("Init globals"));
2221 assert!(artifact.emitted.contains("Init players"));
2222 assert!(artifact.emitted.contains("__holygrail__"));
2223 assert!(
2224 artifact
2225 .emitted
2226 .contains("Custom String(\"{0}fgFF0000FF>ready{0}/fg>\", Global.__holygrail__)")
2227 );
2228 }
2229
2230 #[test]
2231 fn exclude_variables_directive_omits_variable_declarations() {
2232 let compiler = Compiler::new().unwrap();
2233 let hir = crate::compile(
2234 "#!excludeVariablesInCompilation\nglobalvar value 0\nrule \"r\":\n @Event global\n pass\n",
2235 "directives.opy",
2236 Path::new("."),
2237 )
2238 .unwrap();
2239 let artifact = compiler.compile_hir(&hir).unwrap();
2240 assert!(!artifact.wir.global_variables.is_empty());
2241 assert!(
2242 !artifact.emitted.contains("variables {"),
2243 "{}",
2244 artifact.emitted
2245 );
2246 }
2247
2248 #[test]
2249 fn post_compile_hook_receives_exact_emitted_workshop() {
2250 let compiler = Compiler::new().unwrap();
2251 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2252 let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n debug(\"ready\")\n";
2253 let artifact = compiler
2254 .compile_source_with_locale(source, "hook.opy", &root, &Locale::new("en-US"))
2255 .unwrap();
2256 assert!(artifact.emitted.contains("rule (\"setup\")"));
2257 assert!(artifact.final_output.contains("rule (\"transformed\")"));
2258 assert_ne!(artifact.final_output, artifact.emitted);
2259 }
2260
2261 #[test]
2262 fn post_compile_hook_failure_keeps_script_provenance_and_directive_anchor() {
2263 let compiler = Compiler::new().unwrap();
2264 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2265 let source = "#!postCompileHook \"hook-boom.js\"\n\nrule \"setup\":\n pass\n";
2266 let error = match compiler.compile_source_with_locale(
2267 source,
2268 "hook.opy",
2269 &root,
2270 &Locale::new("en-US"),
2271 ) {
2272 Ok(_) => panic!("failing post-compile hook unexpectedly compiled"),
2273 Err(error) => error,
2274 };
2275 assert_eq!(error.diagnostic.code, "post-compile-hook");
2276 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
2277 let script = error.diagnostic.script.unwrap();
2278 assert_eq!(script.source_name.as_deref(), Some("hook-boom.js"));
2279 assert_eq!(script.line, Some(1));
2280 assert!(script.stack.unwrap().contains("hook-boom.js:1"));
2281 }
2282
2283 fn normalize_workshop_structural_whitespace(text: &str) -> String {
2284 let mut normalized = String::with_capacity(text.len());
2285 let mut quote = None;
2286 let mut escaped = false;
2287 for character in text.chars() {
2288 if let Some(delimiter) = quote {
2289 normalized.push(character);
2290 if escaped {
2291 escaped = false;
2292 } else if character == '\\' {
2293 escaped = true;
2294 } else if character == delimiter {
2295 quote = None;
2296 }
2297 } else if matches!(character, '\"' | '\'') {
2298 quote = Some(character);
2299 normalized.push(character);
2300 } else if !character.is_whitespace() {
2301 normalized.push(character);
2302 }
2303 }
2304 normalized
2305 }
2306
2307 #[test]
2308 fn settings_whitespace_normalization_preserves_quoted_values() {
2309 assert_ne!(
2310 normalize_workshop_structural_whitespace("Description: \"a b\""),
2311 normalize_workshop_structural_whitespace("Description: \"ab\"")
2312 );
2313 }
2314}