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