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