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