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 crate::matcher::did_you_mean(
523 error.to_string(),
524 &crate::matcher::settings_member_candidates(
525 expanded_hir.settings.as_ref(),
526 &error,
527 None,
528 ),
529 ),
530 workshop_error_span(&error)
531 .and_then(|span| hir_span_from_workshop(span, &expanded_hir)),
532 )
533 })?;
534 let emitted = if exclude_variables {
535 omit_declaration_sections(&emitted, &program)
536 } else {
537 emitted
538 };
539 let emitted = if has_directive(&expanded_hir, "debugElementCount") {
540 emit_debug_element_counts(&emitted, &program, self.catalog, &expanded_hir)?
541 } else {
542 emitted
543 };
544
545 Ok(CompilationArtifact {
546 wir: program,
547 final_output: emitted.clone(),
548 emitted,
549 catalog_identity: self.catalog.identity(),
550 hook_console_output: Vec::new(),
551 translation_files,
552 })
553 }
554
555 pub fn compile_hir_mapped(
564 &self,
565 hir: &hir::Program,
566 ) -> Result<(CompilationArtifact, MappedText), IntegrationError> {
567 self.compile_hir_mapped_with_locale(hir, &Locale::new("en-US"))
568 }
569
570 pub fn compile_hir_mapped_with_locale(
573 &self,
574 hir: &hir::Program,
575 locale: &Locale,
576 ) -> Result<(CompilationArtifact, MappedText), IntegrationError> {
577 self.compile_hir_mapped_with_hook(hir, None, locale)
578 }
579
580 fn compile_hir_mapped_with_hook(
581 &self,
582 hir: &hir::Program,
583 hook: Option<crate::PostCompileHookRecord>,
584 locale: &Locale,
585 ) -> Result<(CompilationArtifact, MappedText), IntegrationError> {
586 let artifact = self.compile_hir_with_locale_and_hook(hir, hook, locale)?;
587 let mapped = self.mapped_text(hir, &artifact.final_output, locale)?;
588 Ok((artifact, mapped))
589 }
590
591 fn mapped_text(
598 &self,
599 hir: &hir::Program,
600 text: &str,
601 locale: &Locale,
602 ) -> Result<MappedText, IntegrationError> {
603 let expanded = expand_macros_attributed(hir)?;
604 let mut lowering = Lowering::new(self, &expanded);
605 lowering.copy_files()?;
606 lowering.lower_declarations()?;
607 lowering.lower_rules()?;
608 let map = SourceMap::extract(&lowering.program);
609 let mut reparsed = workshop_rs::parser::parse(text, self.catalog, locale)
610 .map_err(|error| IntegrationError::new("source-map-parse", error.to_string(), None))?;
611 map.apply(&mut reparsed)
612 .map_err(|error| IntegrationError::new("source-map-shape", error.to_string(), None))?;
613 Ok(MappedText::new(text.to_owned(), map))
614 }
615
616 pub fn compile_source(
622 &self,
623 source: &str,
624 main_path: &str,
625 root: &std::path::Path,
626 ) -> Result<CompileOutput, IntegrationError> {
627 self.compile_source_with_language(source, main_path, root, "en-US")
628 }
629
630 pub fn compile_source_with_language(
633 &self,
634 source: &str,
635 main_path: &str,
636 root: &std::path::Path,
637 language: &str,
638 ) -> Result<CompileOutput, IntegrationError> {
639 self.compile_source_with_locale(source, main_path, root, &Locale::new(language))
640 .map(CompilationArtifact::into_output)
641 }
642
643 pub fn compile_source_with_locale(
648 &self,
649 source: &str,
650 main_path: &str,
651 root: &std::path::Path,
652 locale: &Locale,
653 ) -> Result<CompilationArtifact, IntegrationError> {
654 self.compile_source_internal(source, main_path, root, locale)
655 }
656
657 pub fn compile_source_artifact(
660 &self,
661 source: &str,
662 main_path: &str,
663 root: &std::path::Path,
664 ) -> Result<CompilationArtifact, IntegrationError> {
665 self.compile_source_with_locale(source, main_path, root, &Locale::new("en-US"))
666 }
667
668 pub fn compile_source_report(
671 &self,
672 source: &str,
673 main_path: &str,
674 root: &std::path::Path,
675 ) -> CompileReport {
676 self.compile_source_report_with_language(source, main_path, root, "en-US")
677 }
678
679 pub fn compile_source_report_with_language(
682 &self,
683 source: &str,
684 main_path: &str,
685 root: &std::path::Path,
686 language: &str,
687 ) -> CompileReport {
688 self.compile_source_report_with_locale(source, main_path, root, &Locale::new(language))
689 }
690
691 pub fn compile_source_report_with_locale(
694 &self,
695 source: &str,
696 main_path: &str,
697 root: &std::path::Path,
698 locale: &Locale,
699 ) -> CompileReport {
700 self.compile_source_report_impl(source, main_path, root, locale, false)
701 .0
702 }
703
704 pub fn compile_source_report_mapped_with_language(
707 &self,
708 source: &str,
709 main_path: &str,
710 root: &std::path::Path,
711 language: &str,
712 ) -> (CompileReport, Option<MappedText>) {
713 self.compile_source_report_impl(source, main_path, root, &Locale::new(language), true)
714 }
715
716 fn compile_source_report_impl(
717 &self,
718 source: &str,
719 main_path: &str,
720 root: &std::path::Path,
721 locale: &Locale,
722 mapped: bool,
723 ) -> (CompileReport, Option<MappedText>) {
724 let outcome = crate::compile_with_overlay_outcome(
725 source,
726 main_path,
727 root,
728 &std::collections::BTreeMap::new(),
729 );
730 let catalog = self.catalog.identity();
731 let compiler = CompilerIdentity::current();
732 let frontend_diagnostics = outcome
733 .diagnostics
734 .iter()
735 .map(compile_frontend_diagnostic)
736 .collect::<Vec<_>>();
737 let Some(hir) = outcome.hir else {
738 return (
739 CompileReport::failure(
740 compiler,
741 catalog,
742 CompileFailureClass::Frontend,
743 frontend_diagnostics,
744 ),
745 None,
746 );
747 };
748
749 let compiled = if mapped {
750 self.compile_hir_mapped_with_hook(&hir, outcome.post_compile_hook, locale)
751 .map(|(artifact, mapped)| (artifact, Some(mapped)))
752 } else {
753 self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale)
754 .map(|artifact| (artifact, None))
755 };
756 match compiled {
757 Ok((artifact, mapped)) => {
758 match write_translation_files(root, &hir, &artifact.translation_files) {
759 Ok(()) => (
760 CompileReport::success(compiler, catalog, artifact, frontend_diagnostics),
761 mapped,
762 ),
763 Err(error) => (
764 integration_failure_report(
765 compiler,
766 catalog,
767 frontend_diagnostics,
768 error,
769 &hir.files,
770 ),
771 None,
772 ),
773 }
774 }
775 Err(error) => (
776 integration_failure_report(
777 compiler,
778 catalog,
779 frontend_diagnostics,
780 error,
781 &hir.files,
782 ),
783 None,
784 ),
785 }
786 }
787
788 fn compile_source_internal(
789 &self,
790 source: &str,
791 main_path: &str,
792 root: &std::path::Path,
793 locale: &Locale,
794 ) -> Result<CompilationArtifact, IntegrationError> {
795 let outcome = crate::compile_with_overlay_outcome(
796 source,
797 main_path,
798 root,
799 &std::collections::BTreeMap::new(),
800 );
801 let hir = outcome.hir.ok_or_else(|| {
802 let error = outcome
803 .error
804 .expect("failed frontend compile has diagnostic");
805 IntegrationError::new(
806 error.code,
807 error.message,
808 error.span.map(hir_span_from_diag),
809 )
810 })?;
811 let artifact =
812 self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale)?;
813 write_translation_files(root, &hir, &artifact.translation_files)?;
814 Ok(artifact)
815 }
816}
817
818fn write_translation_files(
819 root: &std::path::Path,
820 hir: &hir::Program,
821 files: &[(String, String)],
822) -> Result<(), IntegrationError> {
823 let Some(source) = hir.files.get(1).or_else(|| hir.files.first()) else {
824 return Ok(());
825 };
826 let base = root.join(
827 std::path::Path::new(&source.path)
828 .file_name()
829 .unwrap_or_else(|| std::ffi::OsStr::new("main.opy")),
830 );
831 for (language, content) in files {
832 let path = base.with_extension(format!("{language}.po"));
833 std::fs::write(&path, content).map_err(|error| {
834 IntegrationError::new(
835 "translations-io",
836 format!(
837 "cannot write translation file '{}': {error}",
838 path.display()
839 ),
840 hir.preprocessing
841 .translations
842 .as_ref()
843 .and_then(|value| value.span),
844 )
845 })?;
846 }
847 Ok(())
848}
849
850pub(crate) fn workshop_error_span(
851 error: &workshop_rs::WorkshopError,
852) -> Option<workshop_rs::source::Span> {
853 match error {
854 workshop_rs::WorkshopError::Unknown { span, .. }
855 | workshop_rs::WorkshopError::Malformed { span, .. }
856 | workshop_rs::WorkshopError::Unsupported { span, .. } => *span,
857 _ => None,
858 }
859}
860
861fn hir_span_from_workshop(span: workshop_rs::source::Span, hir: &hir::Program) -> Option<HirSpan> {
862 let file = span.file.index() as u32;
863 hir.files
864 .iter()
865 .any(|source| source.id == file)
866 .then_some(HirSpan {
867 file,
868 start: hir::Position {
869 line: span.start.line,
870 col: span.start.col,
871 },
872 end: hir::Position {
873 line: span.end.line,
874 col: span.end.col,
875 },
876 })
877}
878
879impl CompileReport {
880 fn success(
881 compiler: CompilerIdentity,
882 catalog: CatalogIdentity,
883 artifact: CompilationArtifact,
884 diagnostics: Vec<CompileDiagnostic>,
885 ) -> Self {
886 let workshop = normalize_workshop(&artifact.final_output);
887 Self {
888 schema_version: COMPILE_SCHEMA_VERSION,
889 compiler,
890 catalog,
891 compile: CompileResult {
892 status: CompileStatus::Success,
893 exit_code: 0,
894 failure_class: None,
895 diagnostics,
896 stdout: String::new(),
897 workshop_exact: artifact.final_output,
898 workshop,
899 },
900 }
901 }
902
903 fn failure(
904 compiler: CompilerIdentity,
905 catalog: CatalogIdentity,
906 failure_class: CompileFailureClass,
907 diagnostics: Vec<CompileDiagnostic>,
908 ) -> Self {
909 Self {
910 schema_version: COMPILE_SCHEMA_VERSION,
911 compiler,
912 catalog,
913 compile: CompileResult {
914 status: CompileStatus::Failure,
915 exit_code: 1,
916 failure_class: Some(failure_class),
917 diagnostics,
918 stdout: String::new(),
919 workshop_exact: String::new(),
920 workshop: String::new(),
921 },
922 }
923 }
924}
925
926fn compile_diagnostic(error: IntegrationError, files: &[hir::SourceFile]) -> CompileDiagnostic {
927 let diagnostic = error.diagnostic;
928 CompileDiagnostic {
929 severity: crate::tooling::DiagnosticSeverity::Error,
930 code: diagnostic.code,
931 message: diagnostic.message,
932 span: diagnostic
933 .span
934 .and_then(|span| source_location_from_hir(span, files)),
935 script: diagnostic.script.map(|script| *script),
936 }
937}
938
939fn integration_failure_report(
940 compiler: CompilerIdentity,
941 catalog: CatalogIdentity,
942 mut diagnostics: Vec<CompileDiagnostic>,
943 error: IntegrationError,
944 files: &[hir::SourceFile],
945) -> CompileReport {
946 diagnostics.push(compile_diagnostic(error, files));
947 CompileReport::failure(
948 compiler,
949 catalog,
950 CompileFailureClass::Integration,
951 diagnostics,
952 )
953}
954
955fn compile_frontend_diagnostic(diagnostic: &crate::tooling::Diagnostic) -> CompileDiagnostic {
956 CompileDiagnostic {
957 severity: diagnostic.severity,
958 code: diagnostic.code.clone(),
959 message: diagnostic.message.clone(),
960 span: diagnostic.span.clone(),
961 script: None,
962 }
963}
964
965fn source_location_from_hir(
966 span: HirSpan,
967 files: &[hir::SourceFile],
968) -> Option<crate::tooling::SourceLocation> {
969 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
970 Some(crate::tooling::SourceLocation {
971 file_id: span.file,
972 path,
973 start: crate::diag::Position::new(span.start.line, span.start.col),
974 end: crate::diag::Position::new(span.end.line, span.end.col),
975 })
976}
977
978fn normalize_workshop(text: &str) -> String {
979 if text.is_empty() {
980 return String::new();
981 }
982 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
983 let mut lines = normalized
984 .split('\n')
985 .map(|line| line.trim_end_matches([' ', '\t']).to_owned())
986 .collect::<Vec<_>>();
987 while lines.last().is_some_and(|line| line.is_empty()) {
988 lines.pop();
989 }
990 if lines.is_empty() {
991 String::new()
992 } else {
993 lines.join("\n") + "\n"
994 }
995}
996
997#[derive(Debug, Clone, PartialEq, Eq)]
1002pub struct CompileOutput {
1003 pub workshop: String,
1005 pub emitted_workshop: String,
1007 pub hook_console_output: Vec<String>,
1009}
1010
1011pub struct CompilationArtifact {
1014 pub wir: Program,
1015 pub emitted: String,
1016 pub catalog_identity: CatalogIdentity,
1017 pub final_output: String,
1018 pub hook_console_output: Vec<String>,
1019 pub(crate) translation_files: Vec<(String, String)>,
1020}
1021
1022impl CompilationArtifact {
1023 fn into_output(self) -> CompileOutput {
1024 CompileOutput {
1025 workshop: self.final_output,
1026 emitted_workshop: self.emitted,
1027 hook_console_output: self.hook_console_output,
1028 }
1029 }
1030}
1031
1032fn hir_span_from_diag(span: crate::diag::Span) -> HirSpan {
1033 HirSpan {
1034 file: span.file,
1035 start: hir::Position {
1036 line: span.start.line,
1037 col: span.start.col,
1038 },
1039 end: hir::Position {
1040 line: span.end.line,
1041 col: span.end.col,
1042 },
1043 }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048 use super::integration::cross_check_manifest;
1049 use super::{COMPILE_SCHEMA_VERSION, CompileFailureClass, CompileStatus, Compiler};
1050 use crate::manifest::Manifest;
1051 use std::path::Path;
1052 use workshop_rs::catalog::{Catalog, Locale};
1053
1054 #[test]
1055 fn catalog_links_are_checked() {
1056 let compiler = Compiler::new().expect("Workshop contract must load");
1057 let identity = compiler.catalog_identity();
1058 assert!(!identity.implementation_version.is_empty());
1059 assert!(compiler.link_report().catalog_ids_checked > 0);
1060 assert!(compiler.link_report().domains_checked > 0);
1061 }
1062
1063 #[test]
1064 fn compiler_instances_share_the_verified_contract_concurrently() {
1065 let instances = std::thread::scope(|scope| {
1066 let handles = (0..8)
1067 .map(|_| {
1068 scope.spawn(|| {
1069 let compiler = Compiler::new().expect("Workshop contract must load");
1070 (
1071 compiler.catalog as *const Catalog as usize,
1072 compiler.manifest as *const Manifest as usize,
1073 compiler.link_report(),
1074 )
1075 })
1076 })
1077 .collect::<Vec<_>>();
1078 handles
1079 .into_iter()
1080 .map(|handle| handle.join().expect("compiler construction must not panic"))
1081 .collect::<Vec<_>>()
1082 });
1083
1084 let (catalog, manifest, links) = instances[0];
1085 for (instance_catalog, instance_manifest, instance_links) in instances.into_iter().skip(1) {
1086 assert_eq!(instance_catalog, catalog);
1087 assert_eq!(instance_manifest, manifest);
1088 assert_eq!(instance_links, links);
1089 }
1090 }
1091
1092 #[test]
1093 fn compile_report_is_versioned_and_contains_reproducibility_identity() {
1094 let compiler = Compiler::new().unwrap();
1095 let report = compiler.compile_source_report_with_locale(
1096 "rule \"report\":\n @Event global\n disableInspector()\n",
1097 "report.opy",
1098 Path::new("."),
1099 &Locale::new("en-US"),
1100 );
1101 assert_eq!(report.schema_version, COMPILE_SCHEMA_VERSION);
1102 assert_eq!(report.compiler.name, "opy-rs");
1103 assert_eq!(report.catalog, compiler.catalog_identity());
1104 assert_eq!(report.compile.status, CompileStatus::Success);
1105 assert_eq!(report.compile.exit_code, 0);
1106 assert!(report.compile.diagnostics.is_empty());
1107 assert_eq!(
1108 report.compile.workshop,
1109 report
1110 .compile
1111 .workshop_exact
1112 .trim_end_matches('\n')
1113 .to_owned()
1114 + "\n"
1115 );
1116 assert!(serde_json::to_value(report).unwrap()["catalog"]["catalog-version"].is_string());
1117 }
1118
1119 #[test]
1120 fn compile_report_preserves_frontend_failure_class_and_source_path() {
1121 let compiler = Compiler::new().unwrap();
1122 let report = compiler.compile_source_report_with_locale(
1123 "rule \"broken\":\n @Event global\n missing()\n",
1124 "broken.opy",
1125 Path::new("."),
1126 &Locale::new("en-US"),
1127 );
1128 assert_eq!(report.compile.status, CompileStatus::Failure);
1129 assert_eq!(
1130 report.compile.failure_class,
1131 Some(CompileFailureClass::Frontend)
1132 );
1133 assert_eq!(report.compile.exit_code, 1);
1134 let diagnostic = &report.compile.diagnostics[0];
1135 assert_eq!(diagnostic.code, "unknown-action");
1136 assert_eq!(diagnostic.span.as_ref().unwrap().path, "broken.opy");
1137 }
1138
1139 #[test]
1140 fn compile_report_preserves_integration_failure_class_and_source_path() {
1141 let compiler = Compiler::new().unwrap();
1142 let report = compiler.compile_source_report_with_locale(
1143 "rule \"broken\":\n @Event global\n {\"a\": 1}[\"b\"] = 3\n",
1144 "broken.opy",
1145 Path::new("."),
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!(
1154 report.compile.diagnostics[0].span.as_ref().unwrap().path,
1155 "broken.opy"
1156 );
1157 }
1158
1159 #[test]
1160 fn compile_report_preserves_frontend_warnings_on_integration_failure() {
1161 let compiler = Compiler::new().unwrap();
1162 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1163 .join("tests/fixtures/corpus/synthetic/preprocessing");
1164 let report = compiler.compile_source_report_with_locale(
1165 concat!(
1166 "#!include \"shared.opy\"\n",
1167 "#!include \"shared.opy\"\n",
1168 "rule \"broken\":\n",
1169 " @Event global\n",
1170 " {\"a\": 1}[\"b\"] = 3\n",
1171 ),
1172 "broken.opy",
1173 &root,
1174 &Locale::new("en-US"),
1175 );
1176 assert_eq!(report.compile.status, CompileStatus::Failure);
1177 assert_eq!(
1178 report.compile.failure_class,
1179 Some(CompileFailureClass::Integration)
1180 );
1181 assert_eq!(report.compile.diagnostics.len(), 2);
1182 assert_eq!(
1183 report.compile.diagnostics[0].severity,
1184 crate::tooling::DiagnosticSeverity::Warning
1185 );
1186 assert_eq!(report.compile.diagnostics[0].code, "w_already_imported");
1187 assert_eq!(
1188 report.compile.diagnostics[1].severity,
1189 crate::tooling::DiagnosticSeverity::Error
1190 );
1191 assert_eq!(
1192 report.compile.diagnostics[1].span.as_ref().unwrap().path,
1193 "broken.opy"
1194 );
1195 }
1196
1197 #[test]
1198 fn suppress_warnings_hides_matching_preprocessing_diagnostics() {
1199 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1200 .join("tests/fixtures/corpus/synthetic/preprocessing");
1201 let outcome = crate::tooling::check(
1202 concat!(
1203 "#!suppressWarnings w_already_imported\n",
1204 "#!include \"shared.opy\"\n",
1205 "#!include \"shared.opy\"\n",
1206 "rule \"r\":\n",
1207 " @Event global\n",
1208 " pass\n",
1209 ),
1210 "main.opy",
1211 &root,
1212 );
1213 assert!(outcome.model.is_some());
1214 assert!(outcome.diagnostics.is_empty());
1215 }
1216
1217 #[test]
1218 fn vertical_slice_preserves_source_files_spans_and_emits_workshop() {
1219 let compiler = Compiler::new().unwrap();
1220 let hir = crate::compile(
1221 "globalvar A\nrule \"issue 35 integration\":\n @Event global\n A = 1\n disableInspector()\n",
1222 "compiler-vertical-slice.opy",
1223 Path::new("."),
1224 )
1225 .unwrap();
1226 let artifact = compiler.compile_hir(&hir).unwrap();
1227 assert!(artifact.emitted.contains("Disable Inspector Recording;"));
1228 assert_eq!(artifact.catalog_identity, compiler.catalog_identity());
1229 }
1230
1231 #[test]
1232 fn stale_catalog_links_fail_explicitly() {
1233 let mut data: serde_json::Value =
1238 serde_json::from_str(crate::manifest::MANIFEST_DATA).unwrap();
1239 data["functions"][0]["catalogId"] = serde_json::json!("missing-catalog-id");
1240 let stale = Manifest::load(
1241 &serde_json::to_string(&data).unwrap(),
1242 crate::manifest::PROBES_DATA,
1243 )
1244 .unwrap();
1245 let error = cross_check_manifest(&stale, &Catalog::builtin().unwrap()).unwrap_err();
1246 assert_eq!(error.diagnostic.code, "catalog-link-missing");
1247 }
1248
1249 #[test]
1250 fn while_lowering_is_source_attributed() {
1251 let compiler = Compiler::new().unwrap();
1252 let hir = crate::compile(
1253 "rule \"while\":\n @Event global\n while true:\n disableInspector()\n",
1254 "while.opy",
1255 Path::new("."),
1256 )
1257 .unwrap();
1258 let artifact = compiler.compile_hir(&hir).unwrap();
1259 let rule = artifact.wir.rules.first().unwrap();
1260 assert!(matches!(
1261 rule.actions.first(),
1262 Some(workshop_rs::Action::While { .. })
1263 ));
1264 assert!(artifact.emitted.contains("While(True);"));
1265 }
1266
1267 #[test]
1268 fn expanded_control_flow_actions_keep_their_originating_spans() {
1269 let compiler = Compiler::new().unwrap();
1270 let hir = crate::compile(
1271 "globalvar value = 1\nrule \"if\":\n @Event global\n if true:\n wait(1)\n disableInspector()\n",
1272 "control-flow-provenance.opy",
1273 Path::new("."),
1274 )
1275 .unwrap();
1276 let artifact = compiler.compile_hir(&hir).unwrap();
1277
1278 assert_eq!(artifact.wir.action_span(0, 0).unwrap().start.line, 1);
1279 assert_eq!(
1280 artifact
1281 .wir
1282 .action_argument_span(0, 0, 0)
1283 .unwrap()
1284 .start
1285 .line,
1286 1
1287 );
1288 assert_eq!(artifact.wir.rules[1].actions.len(), 4);
1289 assert_eq!(artifact.wir.action_span(1, 0).unwrap().start.line, 4);
1290 assert_eq!(artifact.wir.action_span(1, 1).unwrap().start.line, 5);
1291 assert_eq!(
1292 artifact
1293 .wir
1294 .action_argument_span(1, 1, 0)
1295 .unwrap()
1296 .start
1297 .line,
1298 5
1299 );
1300 assert_eq!(artifact.wir.action_span(1, 2).unwrap().start.line, 4);
1301 }
1302
1303 #[test]
1304 fn range_argument_provenance_uses_canonical_positions() {
1305 let compiler = Compiler::new().unwrap();
1306 let hir = crate::compile(
1307 "globalvar value\nrule \"range\":\n @Event global\n for value in range(3):\n wait(1)\n",
1308 "range-provenance.opy",
1309 Path::new("."),
1310 )
1311 .unwrap();
1312 let artifact = compiler.compile_hir(&hir).unwrap();
1313
1314 assert_eq!(artifact.wir.action_span(0, 0).unwrap().start.line, 4);
1315 assert!(artifact.wir.action_argument_span(0, 0, 0).is_none());
1316 assert_eq!(
1317 artifact
1318 .wir
1319 .action_argument_span(0, 0, 1)
1320 .unwrap()
1321 .start
1322 .line,
1323 4
1324 );
1325 assert!(artifact.wir.action_argument_span(0, 0, 2).is_none());
1326 }
1327
1328 #[test]
1329 fn structural_subroutines_lower_to_canonical_wir() {
1330 let compiler = Compiler::new().unwrap();
1331 let hir = crate::compile(
1332 "globalvar score\nsubroutine showStatus\ndef showStatus():\n @Name \"Friendly\"\n @SuppressWarnings unusedVariable\n disableInspector()\nrule \"caller\":\n @Event global\n showStatus()\n",
1333 "structure.opy",
1334 Path::new("."),
1335 )
1336 .unwrap();
1337 let artifact = compiler.compile_hir(&hir).unwrap();
1338 let subroutine = artifact.wir.subroutines.first().unwrap();
1339 assert_eq!(subroutine.name, "showStatus");
1340 assert_eq!(artifact.wir.rules.len(), 2);
1341 let subroutine_rule = artifact.wir.rules.first().unwrap();
1342 let workshop_rs::Event::Subroutine(subroutine_name) = &subroutine_rule.event else {
1343 panic!("expected a subroutine event");
1344 };
1345 assert_eq!(subroutine_name, "showStatus");
1346 assert!(matches!(
1347 artifact.wir.rules.get(1).unwrap().actions.first(),
1348 Some(workshop_rs::Action::CallSubroutine { .. })
1349 ));
1350 assert!(artifact.emitted.contains("Subroutine Friendly"));
1351 }
1352
1353 #[test]
1354 fn player_event_filters_resolve_through_canonical_catalog() {
1355 let compiler = Compiler::new().unwrap();
1356 let hir = crate::compile(
1357 "rule \"joined\":\n @Event playerJoined\n @Team 1\n @Slot 2\n disableInspector()\n",
1358 "filters.opy",
1359 Path::new("."),
1360 )
1361 .unwrap();
1362 let artifact = compiler.compile_hir(&hir).unwrap();
1363 assert!(matches!(
1364 &artifact.wir.rules.first().unwrap().event,
1365 workshop_rs::Event::Player {
1366 kind: workshop_rs::PlayerEventKind::Joined,
1367 team: workshop_rs::EventTeam::Team1,
1368 target: workshop_rs::EventTarget::Slot(2),
1369 }
1370 ));
1371 assert!(artifact.emitted.contains("Player Joined Match;"));
1372 }
1373
1374 #[test]
1375 fn hero_event_filters_accept_legacy_aliases() {
1376 let compiler = Compiler::new().unwrap();
1377 let hir = crate::compile(
1378 "rule \"hero\":\n @Event eachPlayer\n @Hero soldier\n disableInspector()\n",
1379 "hero-filter.opy",
1380 Path::new("."),
1381 )
1382 .unwrap();
1383 let artifact = compiler.compile_hir(&hir).unwrap();
1384 assert!(matches!(
1385 &artifact.wir.rules.first().unwrap().event,
1386 workshop_rs::Event::EachPlayerWithFilters {
1387 target: workshop_rs::EventTarget::Hero(hero),
1388 ..
1389 } if hero == "SOLDIER_76"
1390 ));
1391 }
1392
1393 #[test]
1394 fn explicit_indices_are_reserved_before_deterministic_allocation() {
1395 let compiler = Compiler::new().unwrap();
1396 let hir = crate::compile(
1397 "globalvar first\nglobalvar reserved 0\nglobalvar next\nrule \"indices\":\n @Event global\n disableInspector()\n",
1398 "indices.opy",
1399 Path::new("."),
1400 )
1401 .unwrap();
1402 let artifact = compiler.compile_hir(&hir).unwrap();
1403 let by_name = artifact
1404 .wir
1405 .global_variables
1406 .iter()
1407 .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1408 .collect::<std::collections::BTreeMap<_, _>>();
1409 assert_eq!(
1410 by_name,
1411 std::collections::BTreeMap::from([("first", 1), ("reserved", 0), ("next", 2)])
1412 );
1413 let indices = artifact
1415 .wir
1416 .global_variables
1417 .iter()
1418 .map(|variable| variable.index.unwrap())
1419 .collect::<Vec<_>>();
1420 assert_eq!(indices, vec![0, 1, 2]);
1421 }
1422
1423 #[test]
1424 fn implicit_default_variables_use_reference_fixed_slots() {
1425 let compiler = Compiler::new().unwrap();
1426 let hir = crate::compile(
1427 r#"
1428globalvar timer
1429globalvar extra 5
1430
1431rule "implicit":
1432 @Event global
1433 A = timer + 1
1434 B = A
1435 B += 2
1436 A[0] = 7
1437 DX = B * A
1438"#,
1439 "implicit.opy",
1440 Path::new("."),
1441 )
1442 .unwrap();
1443 let artifact = compiler.compile_hir(&hir).unwrap();
1444 let globals = artifact
1445 .wir
1446 .global_variables
1447 .iter()
1448 .map(|variable| (variable.name.clone(), variable.index.unwrap()))
1449 .collect::<Vec<_>>();
1450 assert_eq!(
1455 globals,
1456 vec![
1457 ("A".to_string(), 0),
1458 ("B".to_string(), 1),
1459 ("timer".to_string(), 2),
1460 ("extra".to_string(), 5),
1461 ("DX".to_string(), 127),
1462 ]
1463 );
1464 assert!(
1465 artifact
1466 .emitted
1467 .contains("Set Global Variable(A, Add(Global.timer, 1));")
1468 );
1469 assert!(
1470 artifact
1471 .emitted
1472 .contains("Set Global Variable(B, Global.A);")
1473 );
1474 assert!(
1475 artifact
1476 .emitted
1477 .contains("Modify Global Variable(B, Add, 2);")
1478 );
1479 assert!(
1480 artifact
1481 .emitted
1482 .contains("Set Global Variable At Index(A, 0, 7);")
1483 );
1484 assert!(
1485 artifact
1486 .emitted
1487 .contains("Set Global Variable(DX, Multiply(Global.B, Global.A));")
1488 );
1489 }
1490
1491 #[test]
1492 fn implicit_default_variable_slot_collision_is_source_attributed() {
1493 let compiler = Compiler::new().unwrap();
1494 let hir = crate::compile(
1495 "globalvar x 0\nrule \"collision\":\n @Event global\n x = 1\n A = 2\n",
1496 "collision.opy",
1497 Path::new("."),
1498 )
1499 .unwrap();
1500 let error = match compiler.compile_hir(&hir) {
1501 Ok(_) => panic!("slot collision unexpectedly succeeded"),
1502 Err(error) => error,
1503 };
1504 assert_eq!(error.diagnostic.code, "index-collision");
1505 assert_eq!(error.diagnostic.span.unwrap().start.line, 5);
1506 assert!(error.diagnostic.message.contains("'A' and 'x'"));
1507 }
1508
1509 #[test]
1510 fn implicit_default_player_variables_use_independent_reference_slots() {
1511 let compiler = Compiler::new().unwrap();
1512 let hir = crate::compile(
1513 r#"
1514playervar declaredPlayer
1515
1516rule "implicit player variables":
1517 @Event eachPlayer
1518 A = 1
1519 eventPlayer.A = 1
1520 eventPlayer.A += 2
1521 eventPlayer.E = eventPlayer.A
1522 eventPlayer.DX = eventPlayer.E
1523 eventPlayer.declaredPlayer = eventPlayer.A
1524"#,
1525 "implicit-player.opy",
1526 Path::new("."),
1527 )
1528 .unwrap();
1529 let artifact = compiler.compile_hir(&hir).unwrap();
1530 let globals = artifact
1531 .wir
1532 .global_variables
1533 .iter()
1534 .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1535 .collect::<std::collections::BTreeMap<_, _>>();
1536 let players = artifact
1537 .wir
1538 .player_variables
1539 .iter()
1540 .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1541 .collect::<std::collections::BTreeMap<_, _>>();
1542 assert_eq!(globals.get("A"), Some(&0));
1543 assert_eq!(players.get("A"), Some(&0));
1544 assert_eq!(players.get("declaredPlayer"), Some(&1));
1545 assert_eq!(players.get("E"), Some(&4));
1546 assert_eq!(players.get("DX"), Some(&127));
1547 assert!(
1548 artifact
1549 .emitted
1550 .contains("Set Player Variable(Event Player, A, 1);")
1551 );
1552 assert!(
1553 artifact
1554 .emitted
1555 .contains("Modify Player Variable(Event Player, A, Add, 2);")
1556 );
1557 assert!(
1558 artifact
1559 .emitted
1560 .contains("Set Player Variable(Event Player, E, (Event Player).A);")
1561 );
1562 }
1563
1564 #[test]
1565 fn implicit_default_player_slot_collision_is_source_attributed() {
1566 let compiler = Compiler::new().unwrap();
1567 let hir = crate::compile(
1568 "playervar declared 0\nrule \"collision\":\n @Event eachPlayer\n eventPlayer.A = 1\n",
1569 "player-collision.opy",
1570 Path::new("."),
1571 )
1572 .unwrap();
1573 let error = match compiler.compile_hir(&hir) {
1574 Ok(_) => panic!("player slot collision unexpectedly succeeded"),
1575 Err(error) => error,
1576 };
1577 assert_eq!(error.diagnostic.code, "index-collision");
1578 assert!(
1579 error
1580 .diagnostic
1581 .message
1582 .contains("player variables 'A' and 'declared'")
1583 );
1584 assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
1585 }
1586
1587 #[test]
1588 fn power_augmented_assignment_lowers_from_source() {
1589 let compiler = Compiler::new().unwrap();
1590 let hir = crate::compile(
1591 "globalvar g\nrule \"power\":\n @Event global\n g = 2\n g **= 3\n",
1592 "power.opy",
1593 Path::new("."),
1594 )
1595 .unwrap();
1596 let artifact = compiler.compile_hir(&hir).unwrap();
1597 assert!(artifact.emitted.contains("Set Global Variable(g, 2);"));
1598 assert!(
1599 artifact
1600 .emitted
1601 .contains("Modify Global Variable(g, Raise To Power, 3);")
1602 );
1603 }
1604
1605 #[test]
1606 fn opy_hex_numbers_are_normalized_at_the_wir_boundary() {
1607 let compiler = Compiler::new().unwrap();
1608 let hir = crate::compile(
1609 "globalvar large = 0x124BC\nglobalvar small = 0x124\nglobalvar scientific = 1e10\n",
1610 "numbers.opy",
1611 Path::new("."),
1612 )
1613 .unwrap();
1614 let artifact = compiler.compile_hir(&hir).unwrap();
1615 assert!(
1616 artifact
1617 .emitted
1618 .contains("Set Global Variable(large, 74940);")
1619 );
1620 assert!(
1621 artifact
1622 .emitted
1623 .contains("Set Global Variable(small, 292);")
1624 );
1625 assert!(
1626 artifact
1627 .emitted
1628 .contains("Set Global Variable(scientific, 10000000000);")
1629 );
1630 assert!(!artifact.emitted.contains("0x124BC"));
1631 assert!(!artifact.emitted.contains("0x124"));
1632 }
1633
1634 #[test]
1635 fn literal_dict_lookup_lowers_to_the_selected_value() {
1636 let compiler = Compiler::new().unwrap();
1637 let hir = crate::compile(
1638 "globalvar total\nrule \"negative\":\n @Event global\n total = {\"a\": 1, \"b\": 2}[\"a\"]\n",
1639 "negative.opy",
1640 Path::new("."),
1641 )
1642 .unwrap();
1643 let artifact = compiler
1644 .compile_hir(&hir)
1645 .expect("literal dict lookup should lower");
1646 assert!(artifact.emitted.contains("Set Global Variable(total, 1);"));
1647 }
1648
1649 #[test]
1650 fn auto_allocation_fills_free_slots_below_early_explicit_indices() {
1651 let compiler = Compiler::new().unwrap();
1652 let hir = crate::compile(
1653 r#"
1654globalvar reserved 5
1655globalvar auto1
1656globalvar auto2
1657
1658rule "allocation":
1659 @Event global
1660 auto1 = 1
1661 auto2 = 2
1662 B = 3
1663"#,
1664 "allocation.opy",
1665 Path::new("."),
1666 )
1667 .unwrap();
1668 let artifact = compiler.compile_hir(&hir).unwrap();
1669 let by_name = artifact
1670 .wir
1671 .global_variables
1672 .iter()
1673 .map(|variable| (variable.name.clone(), variable.index.unwrap()))
1674 .collect::<std::collections::BTreeMap<_, _>>();
1675 assert_eq!(
1680 by_name,
1681 std::collections::BTreeMap::from([
1682 ("B".to_string(), 1),
1683 ("auto1".to_string(), 0),
1684 ("auto2".to_string(), 2),
1685 ("reserved".to_string(), 5),
1686 ])
1687 );
1688 }
1689
1690 #[test]
1691 fn power_expressions_lower_through_the_canonical_contract() {
1692 let compiler = Compiler::new().unwrap();
1693 let hir = crate::compile(
1694 "globalvar a = [2, 4]\nglobalvar out\nrule \"power\":\n @Event global\n out = a ** 2\n a **= 2\n a[0] **= 2\n",
1695 "power.opy",
1696 Path::new("."),
1697 )
1698 .unwrap();
1699 let artifact = compiler.compile_hir(&hir).unwrap();
1700 assert!(
1701 artifact
1702 .emitted
1703 .contains("Set Global Variable(out, Raise To Power(Global.a, 2));")
1704 );
1705 assert!(
1706 artifact
1707 .emitted
1708 .contains("Modify Global Variable(a, Raise To Power, 2);")
1709 );
1710 assert!(
1711 artifact
1712 .emitted
1713 .contains("Modify Global Variable At Index(a, 0, Raise To Power, 2);")
1714 );
1715 }
1716
1717 #[test]
1718 fn unsupported_rule_metadata_is_explicit_and_source_attributed() {
1719 let compiler = Compiler::new().unwrap();
1720 let hir = crate::compile(
1721 "rule \"metadata\":\n @Event global\n @NewPage \"section\"\n disableInspector()\n",
1722 "metadata.opy",
1723 Path::new("."),
1724 )
1725 .unwrap();
1726 let error = match compiler.compile_hir(&hir) {
1727 Ok(_) => panic!("unsupported metadata unexpectedly succeeded"),
1728 Err(error) => error,
1729 };
1730 assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
1731 assert_eq!(error.diagnostic.span.unwrap().start.line, 3);
1732 }
1733
1734 #[test]
1735 fn compiler_structure_matches_the_pinned_oracle() {
1736 let compiler = Compiler::new().unwrap();
1737 let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
1738 .join("tests/fixtures/corpus/synthetic/compiler-structure");
1739 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
1740 let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
1741 let artifact = compiler.compile_hir(&hir).unwrap();
1742 let oracle: serde_json::Value =
1743 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
1744 .unwrap();
1745 let oracle_workshop = oracle["compile"]["workshop"].as_str().unwrap();
1746 let oracle_wir = workshop_rs::parser::parse(
1747 oracle_workshop,
1748 &Catalog::builtin().unwrap(),
1749 &Locale::new("en-US"),
1750 )
1751 .unwrap();
1752 assert!(workshop_rs::roundtrip::equivalent(
1753 &artifact.wir,
1754 &oracle_wir
1755 ));
1756
1757 assert!(oracle_workshop.contains("0: reserved"));
1758 assert!(oracle_workshop.contains("1: first"));
1759 assert!(oracle_workshop.contains("2: explicit"));
1760 assert!(oracle_workshop.contains("3: next"));
1761 assert!(oracle_workshop.contains("0: helper"));
1762 assert!(oracle_workshop.contains("Subroutine;\n helper;"));
1763 assert!(oracle_workshop.contains("Player Joined Match;\n Team 1;\n Slot 2;"));
1764
1765 let indices = artifact
1766 .wir
1767 .global_variables
1768 .iter()
1769 .map(|variable| variable.index.unwrap())
1770 .collect::<Vec<_>>();
1771 assert_eq!(indices, vec![0, 1, 2, 3]);
1772 assert_eq!(artifact.wir.subroutines.first().unwrap().name, "helper");
1773 assert!(artifact.emitted.contains("[Source] renamed helper"));
1774 assert!(matches!(
1775 artifact.wir.rules.get(1).unwrap().event,
1776 workshop_rs::Event::Player {
1777 kind: workshop_rs::PlayerEventKind::Joined,
1778 team: workshop_rs::EventTeam::Team1,
1779 target: workshop_rs::EventTarget::Slot(2),
1780 }
1781 ));
1782 }
1783
1784 #[test]
1785 fn assignments_and_modifications_lower_to_canonical_wir() {
1786 let compiler = Compiler::new().unwrap();
1787 let hir = crate::compile(
1788 r#"
1789globalvar g1
1790globalvar g2
1791playervar p1
1792playervar p2 = [1, 2, 3]
1793
1794rule "assignments":
1795 @Event eachPlayer
1796 g1 = 10
1797 g1 += 5
1798 g1 -= 2
1799 g1 *= 3
1800 g1 /= 2
1801 g1 %= 4
1802 g2 = [1, 2, 3]
1803 g2[0] = 99
1804 g2[1] += 1
1805 eventPlayer.p1 = 42
1806 eventPlayer.p1 += 8
1807 eventPlayer.p1 *= 2
1808 eventPlayer.p2[2] = 7
1809 eventPlayer.p2[0] -= 3
1810"#,
1811 "assign.opy",
1812 Path::new("."),
1813 )
1814 .unwrap();
1815 let artifact = compiler.compile_hir(&hir).unwrap();
1816 assert!(artifact.emitted.contains("Set Global Variable(g1, 10);"));
1817 assert!(
1818 artifact
1819 .emitted
1820 .contains("Modify Global Variable(g1, Add, 5);")
1821 );
1822 assert!(
1823 artifact
1824 .emitted
1825 .contains("Modify Global Variable(g1, Subtract, 2);")
1826 );
1827 assert!(
1828 artifact
1829 .emitted
1830 .contains("Modify Global Variable(g1, Multiply, 3);")
1831 );
1832 assert!(
1833 artifact
1834 .emitted
1835 .contains("Modify Global Variable(g1, Divide, 2);")
1836 );
1837 assert!(
1838 artifact
1839 .emitted
1840 .contains("Modify Global Variable(g1, Modulo, 4);")
1841 );
1842 assert!(
1843 artifact
1844 .emitted
1845 .contains("Set Global Variable At Index(g2, 0, 99);")
1846 );
1847 assert!(
1848 artifact
1849 .emitted
1850 .contains("Modify Global Variable At Index(g2, 1, Add, 1);")
1851 );
1852 assert!(
1853 artifact
1854 .emitted
1855 .contains("Set Player Variable(Event Player, p1, 42);")
1856 );
1857 assert!(
1858 artifact
1859 .emitted
1860 .contains("Modify Player Variable(Event Player, p1, Add, 8);")
1861 );
1862 assert!(
1863 artifact
1864 .emitted
1865 .contains("Modify Player Variable(Event Player, p1, Multiply, 2);")
1866 );
1867 assert!(artifact.wir.rules[1].actions.iter().any(|action| matches!(
1868 action,
1869 workshop_rs::Action::Call { name, args }
1870 if name == "setPlayerVariableAtIndex"
1871 && matches!(
1872 args.as_slice(),
1873 [
1874 workshop_rs::Value::PlayerVariable { player, variable },
1875 workshop_rs::Value::Number(index),
1876 workshop_rs::Value::Number(value),
1877 ] if matches!(player.as_ref(), workshop_rs::Value::EventPlayer)
1878 && variable == "p2"
1879 && *index == 2.0
1880 && *value == 7.0
1881 )
1882 )));
1883 assert!(artifact.wir.rules[1].actions.iter().any(|action| matches!(
1884 action,
1885 workshop_rs::Action::Call { name, args }
1886 if name == "modifyPlayerVariableAtIndex"
1887 && matches!(
1888 args.as_slice(),
1889 [
1890 workshop_rs::Value::PlayerVariable { player, variable },
1891 workshop_rs::Value::Number(index),
1892 workshop_rs::Value::Call { name: operation, .. },
1893 workshop_rs::Value::Number(value),
1894 ] if matches!(player.as_ref(), workshop_rs::Value::EventPlayer)
1895 && variable == "p2"
1896 && *index == 0.0
1897 && operation == "subtract"
1898 && *value == 3.0
1899 )
1900 )));
1901
1902 let rule = artifact.wir.rules.get(1).unwrap();
1903 assert!(matches!(
1904 rule.actions.first(),
1905 Some(workshop_rs::Action::SetGlobalVariable { variable, .. }) if variable == "g1"
1906 ));
1907 assert!(matches!(
1908 rule.actions.get(7),
1909 Some(workshop_rs::Action::Call { .. })
1910 ));
1911 }
1912
1913 #[test]
1914 fn expressions_and_values_lower_to_canonical_wir() {
1915 let compiler = Compiler::new().unwrap();
1916 let hir = crate::compile(
1917 r#"
1918enum Consts:
1919 BASE
1920
1921globalvar total
1922globalvar arr = [1, 2, 3]
1923globalvar pos = vect(1, 2, 3)
1924
1925rule "expressions":
1926 @Event global
1927 @Condition total == 0
1928 @Condition not (pos == vect(0, 0, 0))
1929 @Condition 2 in arr
1930 total = Consts.BASE + arr[1] * 2 - (10 / 2) + (5 % 2)
1931 print("Total: {}".format(total))
1932 debug(pos)
1933"#,
1934 "expr.opy",
1935 Path::new("."),
1936 )
1937 .unwrap();
1938 let artifact = compiler.compile_hir(&hir).unwrap();
1939 assert!(artifact.emitted.contains("Global.total == 0;"));
1940 assert!(artifact.emitted.contains("Global.pos != Vector(0, 0, 0);"));
1943 assert!(
1944 artifact
1945 .emitted
1946 .contains("Array Contains(Global.arr, 2) == True;")
1947 );
1948 assert!(
1949 artifact
1950 .emitted
1951 .contains("Custom String(\"Total: {0}\", Global.total)")
1952 );
1953 }
1954
1955 #[test]
1956 fn pass_is_supported_as_source_level_noop() {
1957 let compiler = Compiler::new().unwrap();
1958 let hir = crate::compile(
1959 r#"
1960subroutine emptySub
1961
1962def emptySub():
1963 pass
1964
1965rule "empty rule":
1966 @Event global
1967 pass
1968"#,
1969 "pass.opy",
1970 Path::new("."),
1971 )
1972 .unwrap();
1973 let artifact = compiler.compile_hir(&hir).unwrap();
1974 assert!(artifact.wir.rules.is_empty());
1977 }
1978
1979 #[test]
1980 fn optimized_non_subroutine_wait_only_rule_matches_overpy_noop_elision() {
1981 let compiler = Compiler::new().unwrap();
1982 let hir = crate::compile(
1983 r#"
1984rule "wait-only":
1985 @Event eachPlayer
1986 wait(0.032)
1987"#,
1988 "wait-only.opy",
1989 Path::new("."),
1990 )
1991 .unwrap();
1992 let artifact = compiler.compile_hir(&hir).unwrap();
1993 assert!(artifact.wir.rules.is_empty());
1994 }
1995
1996 #[test]
1997 fn variable_initializers_synthesize_initialize_rules() {
1998 let compiler = Compiler::new().unwrap();
1999 let hir = crate::compile(
2000 r#"
2001globalvar j = 5
2002globalvar h = 0
2003globalvar k = 0.0
2004playervar p = 7
2005playervar q = 0
2006
2007rule "main":
2008 @Event global
2009 disableInspector()
2010"#,
2011 "init.opy",
2012 Path::new("."),
2013 )
2014 .unwrap();
2015 let artifact = compiler.compile_hir(&hir).unwrap();
2016 assert_eq!(
2017 artifact.wir.rules.first().unwrap().name,
2018 "Initialize global variables"
2019 );
2020 assert_eq!(
2021 artifact.wir.rules.get(1).unwrap().name,
2022 "Initialize player variables"
2023 );
2024 assert_eq!(artifact.wir.rules.get(2).unwrap().name, "main");
2025 assert!(artifact.emitted.contains("Set Global Variable(j, 5);"));
2026 assert!(artifact.emitted.contains("Set Global Variable(k, 0);"));
2027 assert!(!artifact.emitted.contains("Set Global Variable(h,"));
2028 assert!(
2029 artifact
2030 .emitted
2031 .contains("Set Player Variable(Event Player, p, 7);")
2032 );
2033 assert!(
2034 !artifact
2035 .emitted
2036 .contains("Set Player Variable(Event Player, q,")
2037 );
2038 }
2039
2040 #[test]
2041 fn settings_lower_through_workshop_owned_emission() {
2042 let compiler = Compiler::new().unwrap();
2043 let fixture =
2044 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/corpus/synthetic/settings");
2045 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
2046 let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
2047 let artifact = compiler.compile_hir(&hir).unwrap();
2048 let oracle: serde_json::Value =
2049 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
2050 .unwrap();
2051 let expected = oracle["compile"]["workshop"]
2052 .as_str()
2053 .unwrap()
2054 .split("\n\nrule")
2055 .next()
2056 .unwrap();
2057 let actual = artifact.emitted.split("\n\nrule").next().unwrap();
2058 let oracle_wir = workshop_rs::parser::parse(
2059 oracle["compile"]["workshop"].as_str().unwrap(),
2060 &Catalog::builtin().unwrap(),
2061 &Locale::new("en-US"),
2062 )
2063 .unwrap();
2064 assert!(workshop_rs::roundtrip::equivalent(
2065 &artifact.wir,
2066 &oracle_wir
2067 ));
2068 assert_eq!(
2069 normalize_workshop_structural_whitespace(actual),
2070 normalize_workshop_structural_whitespace(expected)
2071 );
2072 }
2073
2074 #[test]
2075 fn unsupported_locale_has_no_fabricated_source_span() {
2076 let compiler = Compiler::new().unwrap();
2077 let hir = crate::compile(
2078 "#!translations en\nrule \"r\":\n @Event global\n pass\n",
2079 "locale.opy",
2080 Path::new("."),
2081 )
2082 .unwrap();
2083 let error = match compiler.compile_hir_with_locale(&hir, &Locale::new("xx-XX")) {
2084 Ok(_) => panic!("unsupported locale unexpectedly compiled"),
2085 Err(error) => error,
2086 };
2087 assert_eq!(error.diagnostic.code, "locale-unsupported");
2088 assert_eq!(error.diagnostic.span, None);
2089 }
2090
2091 #[test]
2092 fn locale_selection_emits_catalog_localized_workshop() {
2093 let compiler = Compiler::new().unwrap();
2094 let hir = crate::compile(
2095 "rule \"locale\":\n @Event global\n disableInspector()\n",
2096 "locale.opy",
2097 Path::new("."),
2098 )
2099 .unwrap();
2100 let artifact = compiler
2101 .compile_hir_with_locale(&hir, &Locale::new("zh-CN"))
2102 .unwrap();
2103 assert!(artifact.emitted.contains("规则 (\"locale\")"));
2104 assert!(artifact.emitted.contains("禁用查看器录制"));
2105 }
2106
2107 #[test]
2108 fn unsupported_output_directives_fail_at_their_source_anchor() {
2109 let compiler = Compiler::new().unwrap();
2110 let hir = crate::compile(
2111 "#!writeToOutputFile\nrule \"r\":\n @Event global\n pass\n",
2112 "directives.opy",
2113 Path::new("."),
2114 )
2115 .unwrap();
2116 let error = match compiler.compile_hir(&hir) {
2117 Ok(_) => panic!("backend directive unexpectedly compiled"),
2118 Err(error) => error,
2119 };
2120 assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
2121 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
2122 }
2123
2124 #[test]
2125 fn optimizer_directives_remain_non_blocking_presentation_controls() {
2126 let compiler = Compiler::new().unwrap();
2127 let hir = crate::compile(
2128 "#!disableOptimizations\nrule \"r\":\n @Event global\n pass\n",
2129 "optimization.opy",
2130 Path::new("."),
2131 )
2132 .unwrap();
2133 compiler.compile_hir(&hir).unwrap();
2134 }
2135
2136 #[test]
2137 fn strict_optimization_cases_are_preserved_by_native_lowering() {
2138 let compiler = Compiler::new().unwrap();
2139 let hir = crate::compile(
2140 "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",
2141 "strict.opy",
2142 Path::new("."),
2143 )
2144 .unwrap();
2145 let artifact = compiler.compile_hir(&hir).unwrap();
2146 assert!(artifact.emitted.contains("Add(Global.A, 0)"));
2147 assert!(artifact.emitted.contains("Multiply(Global.A, 0)"));
2148 assert!(artifact.emitted.contains("Multiply(Global.A, 1)"));
2149 assert!(
2150 artifact
2151 .emitted
2152 .contains("Compare(Custom String(\"am\"), ==, Custom String(\"**\"))")
2153 );
2154 }
2155
2156 #[test]
2157 fn compression_alphabet_policy_uses_a_shared_global() {
2158 let compiler = Compiler::new().unwrap();
2159 let hir = crate::compile(
2160 "globalvar values = compressed([1, 2, 3])\n\n#!useVariableForCompressionAlphabet\nrule \"compression\":\n @Event global\n print(values)\n",
2161 "compression.opy",
2162 Path::new("."),
2163 )
2164 .unwrap();
2165 let artifact = compiler.compile_hir(&hir).unwrap();
2166 assert!(artifact.emitted.contains("127: __compressionAlphabet__"));
2167 assert!(artifact.emitted.contains("Global.__compressionAlphabet__"));
2168 }
2169
2170 #[test]
2171 fn replacement_directives_lower_when_size_optimization_is_active() {
2172 let compiler = Compiler::new().unwrap();
2173 let hir = crate::compile(
2174 "#!optimizeForSize\n#!replace0ByCapturePercentage\nrule \"r\":\n @Event global\n print(0)\n",
2175 "directives.opy",
2176 Path::new("."),
2177 )
2178 .unwrap();
2179 let artifact = compiler.compile_hir(&hir).unwrap();
2180 assert!(artifact.emitted.contains("Point Capture Percentage"));
2181 }
2182
2183 #[test]
2184 fn debug_element_count_emits_sorted_rule_condition_and_action_comments() {
2185 let compiler = Compiler::new().unwrap();
2186 let hir = crate::compile(
2187 "#!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",
2188 "debug.opy",
2189 Path::new("."),
2190 )
2191 .unwrap();
2192 let artifact = compiler.compile_hir(&hir).unwrap();
2193 let report = artifact.wir.element_count(compiler.catalog).unwrap();
2194 let small = report
2195 .rules
2196 .iter()
2197 .find(|rule| rule.name == "small")
2198 .unwrap();
2199 let large = report
2200 .rules
2201 .iter()
2202 .find(|rule| rule.name == "large")
2203 .unwrap();
2204 let large_summary = artifact.emitted.find(" 32: rule \"large\"").unwrap();
2205 let small_summary = artifact.emitted.find(" 18: rule \"small\"").unwrap();
2206 assert!(large_summary < small_summary);
2207 assert!(
2208 artifact
2209 .emitted
2210 .starts_with("/* Element count: (total 50)\n")
2211 );
2212 assert!(artifact.emitted.contains("//32 elements\nrule (\"large\")"));
2213 assert!(artifact.emitted.contains("//18 elements\nrule (\"small\")"));
2214 assert!(
2215 artifact
2216 .emitted
2217 .contains("Global.value == 1; // 3 elements")
2218 );
2219 assert_eq!(artifact.emitted.matches(" // 3 elements").count(), 2);
2220 assert_eq!(artifact.emitted.matches(" // 14 elements").count(), 3);
2221 let expected_comments = large
2222 .children
2223 .iter()
2224 .chain(&small.children)
2225 .filter(|node| {
2226 matches!(
2227 node.kind,
2228 workshop_rs::actions::ElementNodeKind::Condition
2229 | workshop_rs::actions::ElementNodeKind::Action
2230 )
2231 })
2232 .count();
2233 assert_eq!(artifact.emitted.matches(" // ").count(), expected_comments);
2234 }
2235
2236 #[test]
2237 fn setup_and_initialization_directives_change_forward_output() {
2238 let compiler = Compiler::new().unwrap();
2239 let hir = crate::compile(
2240 "#!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",
2241 "directives.opy",
2242 Path::new("."),
2243 )
2244 .unwrap();
2245 let artifact = compiler.compile_hir(&hir).unwrap();
2246 assert!(artifact.emitted.contains("OverPy <"));
2247 assert!(artifact.emitted.contains("Disable inspector"));
2248 assert!(artifact.emitted.contains("Init globals"));
2249 assert!(artifact.emitted.contains("Init players"));
2250 assert!(artifact.emitted.contains("__holygrail__"));
2251 assert!(
2252 artifact
2253 .emitted
2254 .contains("Custom String(\"{0}fgFF0000FF>ready{0}/fg>\", Global.__holygrail__)")
2255 );
2256 }
2257
2258 #[test]
2259 fn exclude_variables_directive_omits_variable_declarations() {
2260 let compiler = Compiler::new().unwrap();
2261 let hir = crate::compile(
2262 "#!excludeVariablesInCompilation\nglobalvar value 0\nrule \"r\":\n @Event global\n pass\n",
2263 "directives.opy",
2264 Path::new("."),
2265 )
2266 .unwrap();
2267 let artifact = compiler.compile_hir(&hir).unwrap();
2268 assert!(!artifact.wir.global_variables.is_empty());
2269 assert!(
2270 !artifact.emitted.contains("variables {"),
2271 "{}",
2272 artifact.emitted
2273 );
2274 }
2275
2276 #[test]
2277 fn post_compile_hook_receives_exact_emitted_workshop() {
2278 let compiler = Compiler::new().unwrap();
2279 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2280 let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n debug(\"ready\")\n";
2281 let artifact = compiler
2282 .compile_source_with_locale(source, "hook.opy", &root, &Locale::new("en-US"))
2283 .unwrap();
2284 assert!(artifact.emitted.contains("rule (\"setup\")"));
2285 assert!(artifact.final_output.contains("rule (\"transformed\")"));
2286 assert_ne!(artifact.final_output, artifact.emitted);
2287 }
2288
2289 #[test]
2290 fn post_compile_hook_failure_keeps_script_provenance_and_directive_anchor() {
2291 let compiler = Compiler::new().unwrap();
2292 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2293 let source = "#!postCompileHook \"hook-boom.js\"\n\nrule \"setup\":\n pass\n";
2294 let error = match compiler.compile_source_with_locale(
2295 source,
2296 "hook.opy",
2297 &root,
2298 &Locale::new("en-US"),
2299 ) {
2300 Ok(_) => panic!("failing post-compile hook unexpectedly compiled"),
2301 Err(error) => error,
2302 };
2303 assert_eq!(error.diagnostic.code, "post-compile-hook");
2304 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
2305 let script = error.diagnostic.script.unwrap();
2306 assert_eq!(script.source_name.as_deref(), Some("hook-boom.js"));
2307 assert_eq!(script.line, Some(1));
2308 assert!(script.stack.unwrap().contains("hook-boom.js:1"));
2309 }
2310
2311 #[test]
2312 fn shape_preserving_post_compile_output_still_maps() {
2313 let compiler = Compiler::new().unwrap();
2314 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2315 let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n debug(\"ready\")\n";
2316 let (report, mapped) =
2317 compiler.compile_source_report_mapped_with_language(source, "hook.opy", &root, "en-US");
2318 assert_eq!(report.compile.status, CompileStatus::Success);
2319 let mapped = mapped.expect("shape-preserving hook output keeps a proven map");
2320 assert!(mapped.text.contains("rule (\"transformed\")"));
2321 }
2322
2323 #[test]
2324 fn shape_changing_post_compile_output_rejects_mapped_output() {
2325 let compiler = Compiler::new().unwrap();
2326 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2327 let source =
2328 "#!postCompileHook \"hook-shape.js\"\n\nrule \"setup\":\n debug(\"ready\")\n";
2329 let (report, mapped) = compiler.compile_source_report_mapped_with_language(
2330 source,
2331 "hook-shape.opy",
2332 &root,
2333 "en-US",
2334 );
2335 assert!(mapped.is_none());
2336 assert_eq!(report.compile.status, CompileStatus::Failure);
2337 assert!(
2338 report
2339 .compile
2340 .diagnostics
2341 .iter()
2342 .any(|diagnostic| diagnostic.code == "source-map-shape"),
2343 "shape-changing post-compile output must fail the map proof"
2344 );
2345 }
2346
2347 fn normalize_workshop_structural_whitespace(text: &str) -> String {
2348 let mut normalized = String::with_capacity(text.len());
2349 let mut quote = None;
2350 let mut escaped = false;
2351 for character in text.chars() {
2352 if let Some(delimiter) = quote {
2353 normalized.push(character);
2354 if escaped {
2355 escaped = false;
2356 } else if character == '\\' {
2357 escaped = true;
2358 } else if character == delimiter {
2359 quote = None;
2360 }
2361 } else if matches!(character, '\"' | '\'') {
2362 quote = Some(character);
2363 normalized.push(character);
2364 } else if !character.is_whitespace() {
2365 normalized.push(character);
2366 }
2367 }
2368 normalized
2369 }
2370
2371 #[test]
2372 fn settings_whitespace_normalization_preserves_quoted_values() {
2373 assert_ne!(
2374 normalize_workshop_structural_whitespace("Description: \"a b\""),
2375 normalize_workshop_structural_whitespace("Description: \"ab\"")
2376 );
2377 }
2378}