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