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::catalog::{Catalog, CatalogIdentity, Kind, Locale, ParamCoercions};
14use workshop_rs::source::{Position as WorkshopPosition, SourceFile, Span as WorkshopSpan};
15use workshop_rs::wir::{self, Action, Event, PlayerEventKind, Program, Value, ValueNode};
16
17pub mod reconstruct;
18
19mod backend;
20mod hooks;
21mod integration;
22mod lowering;
23mod settings;
24
25pub(crate) use backend::MacroExpander;
26pub(super) use backend::{expand_macros, reject_unlowered_directives};
27pub use integration::LinkReport;
28pub(super) use integration::load_compiler_contract;
29pub(super) use lowering::Lowering;
30
31#[cfg(test)]
32mod integration_tests;
33
34pub const WORKSHOP_RS_VERSION: &str = "0.1.19";
36
37const TRANSLATION_HELPER_NAME: &str = "__overpyTranslationHelper__";
38
39pub const COMPILE_SCHEMA_VERSION: u32 = 1;
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
44pub struct CompilerIdentity {
45 pub name: &'static str,
46 pub version: &'static str,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "lowercase")]
52pub enum CompileStatus {
53 Success,
54 Failure,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum CompileFailureClass {
61 Frontend,
62 Integration,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67#[serde(rename_all = "camelCase")]
68pub struct CompileDiagnostic {
69 pub severity: crate::tooling::DiagnosticSeverity,
70 pub code: String,
71 pub message: String,
72 pub span: Option<crate::tooling::SourceLocation>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub script: Option<ScriptDiagnostic>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79#[serde(rename_all = "camelCase")]
80pub struct CompileResult {
81 pub status: CompileStatus,
82 pub exit_code: u8,
83 pub failure_class: Option<CompileFailureClass>,
84 pub diagnostics: Vec<CompileDiagnostic>,
85 pub stdout: String,
86 pub workshop_exact: String,
87 pub workshop: String,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92#[serde(rename_all = "camelCase")]
93pub struct CompileReport {
94 pub schema_version: u32,
95 pub compiler: CompilerIdentity,
96 pub catalog: CatalogIdentity,
97 pub compile: CompileResult,
98}
99
100impl CompilerIdentity {
101 fn current() -> Self {
102 Self {
103 name: "opy-rs",
104 version: env!("CARGO_PKG_VERSION"),
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct IntegrationDiagnostic {
112 pub code: String,
113 pub message: String,
114 pub span: Option<HirSpan>,
115 pub script: Option<Box<ScriptDiagnostic>>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
120#[serde(rename_all = "camelCase")]
121pub struct ScriptDiagnostic {
122 pub source_name: Option<String>,
123 pub line: Option<u32>,
124 pub column: Option<u32>,
125 pub stack: Option<String>,
126}
127
128impl IntegrationDiagnostic {
129 fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
130 Self {
131 code: code.into(),
132 message: message.into(),
133 span,
134 script: None,
135 }
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct IntegrationError {
142 pub diagnostic: IntegrationDiagnostic,
143}
144
145impl IntegrationError {
146 fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
147 Self {
148 diagnostic: IntegrationDiagnostic::new(code, message, span),
149 }
150 }
151
152 fn post_compile_hook(error: crate::macro_js::MacroError, span: Option<HirSpan>) -> Self {
153 let message = error.to_string();
154 let script = match error {
155 crate::macro_js::MacroError::Script(error) => Some(Box::new(ScriptDiagnostic {
156 source_name: error.source_name,
157 line: error.line,
158 column: error.column,
159 stack: error.stack,
160 })),
161 crate::macro_js::MacroError::InvalidResult { .. }
162 | crate::macro_js::MacroError::Internal(_) => None,
163 };
164 Self {
165 diagnostic: IntegrationDiagnostic {
166 code: "post-compile-hook".to_string(),
167 message,
168 span,
169 script,
170 },
171 }
172 }
173}
174
175impl std::fmt::Display for IntegrationError {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 write!(f, "{}: {}", self.diagnostic.code, self.diagnostic.message)
178 }
179}
180
181impl std::error::Error for IntegrationError {}
182
183pub struct Compiler {
186 catalog: Catalog,
187 manifest: &'static Manifest,
188 links: LinkReport,
189}
190
191impl Compiler {
192 pub fn new() -> Result<Self, IntegrationError> {
193 let (catalog, manifest, links) = load_compiler_contract()?;
194 Ok(Self {
195 catalog,
196 manifest,
197 links,
198 })
199 }
200
201 pub fn catalog_identity(&self) -> CatalogIdentity {
202 self.catalog.identity()
203 }
204
205 pub fn link_report(&self) -> LinkReport {
206 self.links
207 }
208
209 pub fn compile_hir(&self, hir: &hir::Program) -> Result<CompilationArtifact, IntegrationError> {
212 self.compile_hir_with_locale(hir, &Locale::new("en-US"))
213 }
214
215 pub fn compile_hir_with_locale(
217 &self,
218 hir: &hir::Program,
219 locale: &Locale,
220 ) -> Result<CompilationArtifact, IntegrationError> {
221 if !self.catalog.supports(locale) {
222 return Err(IntegrationError::new(
223 "locale-unsupported",
224 format!("workshop catalog does not declare locale '{locale}'"),
225 None,
226 ));
227 }
228 reject_unlowered_directives(hir)?;
229 let expanded_hir = expand_macros(hir)?;
230 let mut lowering = Lowering::new(self, &expanded_hir)?;
231 lowering.copy_files()?;
232 lowering.lower_declarations()?;
233 lowering.lower_rules()?;
234
235 lowering.wir.validate().map_err(|error| {
236 let span = error
237 .span()
238 .and_then(|span| lowering.hir_span_from_workshop(span));
239 IntegrationError::new(error.code(), error.message(), span)
240 })?;
241 workshop_rs::validate::validate_canonical_ids(&lowering.wir, &self.catalog).map_err(
242 |error| {
243 let span = workshop_error_span(&error)
244 .and_then(|span| lowering.hir_span_from_workshop(span));
245 IntegrationError::new("catalog-validation", error.to_string(), span)
246 },
247 )?;
248 let emitted =
249 workshop_rs::emitter::emit(&lowering.wir, &self.catalog, locale).map_err(|error| {
250 let span = workshop_error_span(&error)
251 .and_then(|span| lowering.hir_span_from_workshop(span));
252 IntegrationError::new("workshop-emission", error.to_string(), span)
253 })?;
254
255 Ok(CompilationArtifact {
256 wir: lowering.wir,
257 final_output: emitted.clone(),
258 emitted,
259 catalog_identity: self.catalog.identity(),
260 hook_console_output: Vec::new(),
261 })
262 }
263
264 pub fn compile_source(
270 &self,
271 source: &str,
272 main_path: &str,
273 root: &std::path::Path,
274 ) -> Result<CompileOutput, IntegrationError> {
275 self.compile_source_with_language(source, main_path, root, "en-US")
276 }
277
278 pub fn compile_source_with_language(
281 &self,
282 source: &str,
283 main_path: &str,
284 root: &std::path::Path,
285 language: &str,
286 ) -> Result<CompileOutput, IntegrationError> {
287 self.compile_source_with_locale(source, main_path, root, &Locale::new(language))
288 .map(CompilationArtifact::into_output)
289 }
290
291 pub fn compile_source_with_locale(
296 &self,
297 source: &str,
298 main_path: &str,
299 root: &std::path::Path,
300 locale: &Locale,
301 ) -> Result<CompilationArtifact, IntegrationError> {
302 self.compile_source_internal(source, main_path, root, locale)
303 }
304
305 pub fn compile_source_artifact(
308 &self,
309 source: &str,
310 main_path: &str,
311 root: &std::path::Path,
312 ) -> Result<CompilationArtifact, IntegrationError> {
313 self.compile_source_with_locale(source, main_path, root, &Locale::new("en-US"))
314 }
315
316 pub fn compile_source_report(
319 &self,
320 source: &str,
321 main_path: &str,
322 root: &std::path::Path,
323 ) -> CompileReport {
324 self.compile_source_report_with_language(source, main_path, root, "en-US")
325 }
326
327 pub fn compile_source_report_with_language(
330 &self,
331 source: &str,
332 main_path: &str,
333 root: &std::path::Path,
334 language: &str,
335 ) -> CompileReport {
336 self.compile_source_report_with_locale(source, main_path, root, &Locale::new(language))
337 }
338
339 pub fn compile_source_report_with_locale(
342 &self,
343 source: &str,
344 main_path: &str,
345 root: &std::path::Path,
346 locale: &Locale,
347 ) -> CompileReport {
348 let outcome = crate::compile_with_overlay_outcome(
349 source,
350 main_path,
351 root,
352 &std::collections::BTreeMap::new(),
353 );
354 let catalog = self.catalog.identity();
355 let compiler = CompilerIdentity::current();
356 let frontend_diagnostics = outcome
357 .diagnostics
358 .iter()
359 .map(compile_frontend_diagnostic)
360 .collect::<Vec<_>>();
361 let Some(hir) = outcome.hir else {
362 return CompileReport::failure(
363 compiler,
364 catalog,
365 CompileFailureClass::Frontend,
366 frontend_diagnostics,
367 );
368 };
369
370 match self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale) {
371 Ok(artifact) => {
372 CompileReport::success(compiler, catalog, artifact, frontend_diagnostics)
373 }
374 Err(error) => {
375 let mut diagnostics = frontend_diagnostics;
376 diagnostics.push(compile_diagnostic(error, &hir.files));
377 CompileReport::failure(
378 compiler,
379 catalog,
380 CompileFailureClass::Integration,
381 diagnostics,
382 )
383 }
384 }
385 }
386
387 fn compile_source_internal(
388 &self,
389 source: &str,
390 main_path: &str,
391 root: &std::path::Path,
392 locale: &Locale,
393 ) -> Result<CompilationArtifact, IntegrationError> {
394 let outcome = crate::compile_with_overlay_outcome(
395 source,
396 main_path,
397 root,
398 &std::collections::BTreeMap::new(),
399 );
400 let hir = outcome.hir.ok_or_else(|| {
401 let error = outcome
402 .error
403 .expect("failed frontend compile has diagnostic");
404 IntegrationError::new(
405 error.code,
406 error.message,
407 error.span.map(hir_span_from_diag),
408 )
409 })?;
410 self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale)
411 }
412}
413
414impl CompileReport {
415 fn success(
416 compiler: CompilerIdentity,
417 catalog: CatalogIdentity,
418 artifact: CompilationArtifact,
419 diagnostics: Vec<CompileDiagnostic>,
420 ) -> Self {
421 Self {
422 schema_version: COMPILE_SCHEMA_VERSION,
423 compiler,
424 catalog,
425 compile: CompileResult {
426 status: CompileStatus::Success,
427 exit_code: 0,
428 failure_class: None,
429 diagnostics,
430 stdout: String::new(),
431 workshop_exact: artifact.final_output.clone(),
432 workshop: normalize_workshop(&artifact.final_output),
433 },
434 }
435 }
436
437 fn failure(
438 compiler: CompilerIdentity,
439 catalog: CatalogIdentity,
440 failure_class: CompileFailureClass,
441 diagnostics: Vec<CompileDiagnostic>,
442 ) -> Self {
443 Self {
444 schema_version: COMPILE_SCHEMA_VERSION,
445 compiler,
446 catalog,
447 compile: CompileResult {
448 status: CompileStatus::Failure,
449 exit_code: 1,
450 failure_class: Some(failure_class),
451 diagnostics,
452 stdout: String::new(),
453 workshop_exact: String::new(),
454 workshop: String::new(),
455 },
456 }
457 }
458}
459
460fn compile_diagnostic(error: IntegrationError, files: &[hir::SourceFile]) -> CompileDiagnostic {
461 let diagnostic = error.diagnostic;
462 CompileDiagnostic {
463 severity: crate::tooling::DiagnosticSeverity::Error,
464 code: diagnostic.code,
465 message: diagnostic.message,
466 span: diagnostic
467 .span
468 .and_then(|span| source_location_from_hir(span, files)),
469 script: diagnostic.script.map(|script| *script),
470 }
471}
472
473fn compile_frontend_diagnostic(diagnostic: &crate::tooling::Diagnostic) -> CompileDiagnostic {
474 CompileDiagnostic {
475 severity: diagnostic.severity,
476 code: diagnostic.code.clone(),
477 message: diagnostic.message.clone(),
478 span: diagnostic.span.clone(),
479 script: None,
480 }
481}
482
483fn source_location_from_hir(
484 span: HirSpan,
485 files: &[hir::SourceFile],
486) -> Option<crate::tooling::SourceLocation> {
487 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
488 Some(crate::tooling::SourceLocation {
489 file_id: span.file,
490 path,
491 start: crate::diag::Position::new(span.start.line, span.start.col),
492 end: crate::diag::Position::new(span.end.line, span.end.col),
493 })
494}
495
496fn normalize_workshop(text: &str) -> String {
497 if text.is_empty() {
498 return String::new();
499 }
500 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
501 let mut lines = normalized
502 .split('\n')
503 .map(|line| line.trim_end_matches([' ', '\t']).to_owned())
504 .collect::<Vec<_>>();
505 while lines.last().is_some_and(|line| line.is_empty()) {
506 lines.pop();
507 }
508 if lines.is_empty() {
509 String::new()
510 } else {
511 lines.join("\n") + "\n"
512 }
513}
514
515#[derive(Debug, Clone, PartialEq, Eq)]
520pub struct CompileOutput {
521 pub workshop: String,
523 pub emitted_workshop: String,
525 pub hook_console_output: Vec<String>,
527}
528
529pub struct CompilationArtifact {
532 pub wir: Program,
533 pub emitted: String,
534 pub catalog_identity: CatalogIdentity,
535 pub final_output: String,
536 pub hook_console_output: Vec<String>,
537}
538
539impl CompilationArtifact {
540 fn into_output(self) -> CompileOutput {
541 CompileOutput {
542 workshop: self.final_output,
543 emitted_workshop: self.emitted,
544 hook_console_output: self.hook_console_output,
545 }
546 }
547}
548
549fn hir_span_from_diag(span: crate::diag::Span) -> HirSpan {
550 HirSpan {
551 file: span.file,
552 start: hir::Position {
553 line: span.start.line,
554 col: span.start.col,
555 },
556 end: hir::Position {
557 line: span.end.line,
558 col: span.end.col,
559 },
560 }
561}
562
563fn workshop_error_span(error: &workshop_rs::WorkshopError) -> Option<WorkshopSpan> {
564 match error {
565 workshop_rs::WorkshopError::Unknown { span, .. }
566 | workshop_rs::WorkshopError::Malformed { span, .. }
567 | workshop_rs::WorkshopError::Unsupported { span, .. } => *span,
568 workshop_rs::WorkshopError::Catalog(_)
569 | workshop_rs::WorkshopError::MissingMapping { .. } => None,
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::integration::cross_check_manifest;
576 use super::{
577 COMPILE_SCHEMA_VERSION, CompileFailureClass, CompileStatus, Compiler, WORKSHOP_RS_VERSION,
578 };
579 use crate::manifest::Manifest;
580 use std::path::Path;
581 use workshop_rs::catalog::{Catalog, Locale};
582
583 #[test]
584 fn public_contract_is_pinned_and_manifest_links_are_checked() {
585 let compiler = Compiler::new().expect("released workshop contract must load");
586 let identity = compiler.catalog_identity();
587 assert_eq!(identity.implementation_version, WORKSHOP_RS_VERSION);
588 assert!(compiler.link_report().catalog_ids_checked > 0);
589 assert!(compiler.link_report().domains_checked > 0);
590 }
591
592 #[test]
593 fn compile_report_is_versioned_and_contains_reproducibility_identity() {
594 let compiler = Compiler::new().unwrap();
595 let report = compiler.compile_source_report_with_locale(
596 "rule \"report\":\n @Event global\n disableInspector()\n",
597 "report.opy",
598 Path::new("."),
599 &Locale::new("en-US"),
600 );
601 assert_eq!(report.schema_version, COMPILE_SCHEMA_VERSION);
602 assert_eq!(report.compiler.name, "opy-rs");
603 assert_eq!(report.catalog.implementation_version, WORKSHOP_RS_VERSION);
604 assert_eq!(report.compile.status, CompileStatus::Success);
605 assert_eq!(report.compile.exit_code, 0);
606 assert!(report.compile.diagnostics.is_empty());
607 assert_eq!(
608 report.compile.workshop,
609 report
610 .compile
611 .workshop_exact
612 .trim_end_matches('\n')
613 .to_owned()
614 + "\n"
615 );
616 assert!(serde_json::to_value(report).unwrap()["catalog"]["catalog-version"].is_string());
617 }
618
619 #[test]
620 fn compile_report_preserves_frontend_failure_class_and_source_path() {
621 let compiler = Compiler::new().unwrap();
622 let report = compiler.compile_source_report_with_locale(
623 "rule \"broken\":\n @Event global\n missing()\n",
624 "broken.opy",
625 Path::new("."),
626 &Locale::new("en-US"),
627 );
628 assert_eq!(report.compile.status, CompileStatus::Failure);
629 assert_eq!(
630 report.compile.failure_class,
631 Some(CompileFailureClass::Frontend)
632 );
633 assert_eq!(report.compile.exit_code, 1);
634 let diagnostic = &report.compile.diagnostics[0];
635 assert_eq!(diagnostic.code, "unknown-action");
636 assert_eq!(diagnostic.span.as_ref().unwrap().path, "broken.opy");
637 }
638
639 #[test]
640 fn compile_report_preserves_integration_failure_class_and_source_path() {
641 let compiler = Compiler::new().unwrap();
642 let report = compiler.compile_source_report_with_locale(
643 "rule \"broken\":\n @Event global\n {\"a\": 1}[\"b\"] = 3\n",
644 "broken.opy",
645 Path::new("."),
646 &Locale::new("en-US"),
647 );
648 assert_eq!(report.compile.status, CompileStatus::Failure);
649 assert_eq!(
650 report.compile.failure_class,
651 Some(CompileFailureClass::Integration)
652 );
653 assert_eq!(
654 report.compile.diagnostics[0].span.as_ref().unwrap().path,
655 "broken.opy"
656 );
657 }
658
659 #[test]
660 fn compile_report_preserves_frontend_warnings_on_integration_failure() {
661 let compiler = Compiler::new().unwrap();
662 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
663 .join("tests/fixtures/corpus/synthetic/preprocessing");
664 let report = compiler.compile_source_report_with_locale(
665 concat!(
666 "#!include \"shared.opy\"\n",
667 "#!include \"shared.opy\"\n",
668 "rule \"broken\":\n",
669 " @Event global\n",
670 " {\"a\": 1}[\"b\"] = 3\n",
671 ),
672 "broken.opy",
673 &root,
674 &Locale::new("en-US"),
675 );
676 assert_eq!(report.compile.status, CompileStatus::Failure);
677 assert_eq!(
678 report.compile.failure_class,
679 Some(CompileFailureClass::Integration)
680 );
681 assert_eq!(report.compile.diagnostics.len(), 2);
682 assert_eq!(
683 report.compile.diagnostics[0].severity,
684 crate::tooling::DiagnosticSeverity::Warning
685 );
686 assert_eq!(report.compile.diagnostics[0].code, "w_already_imported");
687 assert_eq!(
688 report.compile.diagnostics[1].severity,
689 crate::tooling::DiagnosticSeverity::Error
690 );
691 assert_eq!(
692 report.compile.diagnostics[1].span.as_ref().unwrap().path,
693 "broken.opy"
694 );
695 }
696
697 #[test]
698 fn vertical_slice_preserves_source_files_spans_and_emits_workshop() {
699 let compiler = Compiler::new().unwrap();
700 let hir = crate::compile(
701 "globalvar A\nrule \"issue 35 integration\":\n @Event global\n A = 1\n disableInspector()\n",
702 "compiler-vertical-slice.opy",
703 Path::new("."),
704 )
705 .unwrap();
706 let artifact = compiler.compile_hir(&hir).unwrap();
707 assert_eq!(
708 artifact
709 .wir
710 .files
711 .get(workshop_rs::source::FileId::from_index(0))
712 .unwrap()
713 .path,
714 "compiler-vertical-slice.opy"
715 );
716 let rule = artifact
717 .wir
718 .rules
719 .get(workshop_rs::wir::RuleId::from_index(0))
720 .unwrap();
721 assert_eq!(rule.span.unwrap().file.index(), 0);
722 assert_eq!(rule.name_span.unwrap().start.line, 2);
723 assert!(artifact.emitted.contains("Disable Inspector Recording;"));
724 assert_eq!(artifact.catalog_identity.implementation_version, "0.1.19");
725 }
726
727 #[test]
728 fn stale_catalog_links_fail_explicitly() {
729 let manifest = Manifest::builtin().unwrap().clone();
730 let mut stale = manifest;
731 stale.functions[0].catalog_id = Some("missing-catalog-id".to_string());
732 let error = cross_check_manifest(&stale, &Catalog::builtin().unwrap()).unwrap_err();
733 assert_eq!(error.diagnostic.code, "catalog-link-missing");
734 }
735
736 #[test]
737 fn while_lowering_is_source_attributed() {
738 let compiler = Compiler::new().unwrap();
739 let hir = crate::compile(
740 "rule \"while\":\n @Event global\n while true:\n disableInspector()\n",
741 "while.opy",
742 Path::new("."),
743 )
744 .unwrap();
745 let artifact = compiler.compile_hir(&hir).unwrap();
746 let rule = artifact
747 .wir
748 .rules
749 .get(workshop_rs::wir::RuleId::from_index(0))
750 .unwrap();
751 assert!(matches!(
752 artifact.wir.actions.get(rule.actions[0]),
753 Some(workshop_rs::wir::Action::While { .. })
754 ));
755 assert!(artifact.emitted.contains("While(True);"));
756 }
757
758 #[test]
759 fn structural_subroutines_lower_to_canonical_wir() {
760 let compiler = Compiler::new().unwrap();
761 let hir = crate::compile(
762 "globalvar score\nsubroutine showStatus\ndef showStatus():\n @Name \"Friendly\"\n @SuppressWarnings unusedVariable\n disableInspector()\nrule \"caller\":\n @Event global\n showStatus()\n",
763 "structure.opy",
764 Path::new("."),
765 )
766 .unwrap();
767 let artifact = compiler.compile_hir(&hir).unwrap();
768 let subroutine = artifact
769 .wir
770 .subroutines
771 .get(workshop_rs::wir::SubroutineId::from_index(0))
772 .unwrap();
773 assert_eq!(subroutine.name, "showStatus");
774 assert_eq!(subroutine.index, 0);
775 assert_eq!(subroutine.name_span.unwrap().start.line, 2);
776 assert_eq!(artifact.wir.rules.len(), 2);
777 let subroutine_rule = artifact
778 .wir
779 .rules
780 .get(workshop_rs::wir::RuleId::from_index(0))
781 .unwrap();
782 let workshop_rs::wir::Event::Subroutine(subroutine_id) = subroutine_rule.event else {
783 panic!("expected a subroutine event");
784 };
785 assert_eq!(
786 artifact.wir.subroutines.get(subroutine_id).unwrap().name,
787 "showStatus"
788 );
789 assert!(matches!(
790 artifact
791 .wir
792 .actions
793 .get(workshop_rs::wir::ActionId::from_index(1))
794 .unwrap(),
795 workshop_rs::wir::Action::CallSubroutine { .. }
796 ));
797 assert!(artifact.emitted.contains("Subroutine Friendly"));
798 }
799
800 #[test]
801 fn player_event_filters_resolve_through_canonical_catalog() {
802 let compiler = Compiler::new().unwrap();
803 let hir = crate::compile(
804 "rule \"joined\":\n @Event playerJoined\n @Team 1\n @Slot 2\n disableInspector()\n",
805 "filters.opy",
806 Path::new("."),
807 )
808 .unwrap();
809 let artifact = compiler.compile_hir(&hir).unwrap();
810 assert!(matches!(
811 &artifact
812 .wir
813 .rules
814 .get(workshop_rs::wir::RuleId::from_index(0))
815 .unwrap()
816 .event,
817 workshop_rs::wir::Event::Player {
818 kind: workshop_rs::wir::PlayerEventKind::Joined,
819 team: workshop_rs::wir::EventTeam::Team1,
820 target: workshop_rs::wir::EventTarget::Slot(2),
821 }
822 ));
823 assert!(artifact.emitted.contains("Player Joined Match;"));
824 }
825
826 #[test]
827 fn hero_event_filters_accept_legacy_aliases() {
828 let compiler = Compiler::new().unwrap();
829 let hir = crate::compile(
830 "rule \"hero\":\n @Event eachPlayer\n @Hero soldier\n disableInspector()\n",
831 "hero-filter.opy",
832 Path::new("."),
833 )
834 .unwrap();
835 let artifact = compiler.compile_hir(&hir).unwrap();
836 assert!(matches!(
837 &artifact
838 .wir
839 .rules
840 .get(workshop_rs::wir::RuleId::from_index(0))
841 .unwrap()
842 .event,
843 workshop_rs::wir::Event::EachPlayerWithFilters {
844 target: workshop_rs::wir::EventTarget::Hero(hero),
845 ..
846 } if hero == "SOLDIER_76"
847 ));
848 }
849
850 #[test]
851 fn explicit_indices_are_reserved_before_deterministic_allocation() {
852 let compiler = Compiler::new().unwrap();
853 let hir = crate::compile(
854 "globalvar first\nglobalvar reserved 0\nglobalvar next\nrule \"indices\":\n @Event global\n disableInspector()\n",
855 "indices.opy",
856 Path::new("."),
857 )
858 .unwrap();
859 let artifact = compiler.compile_hir(&hir).unwrap();
860 let by_name = artifact
861 .wir
862 .global_variables
863 .iter()
864 .map(|variable| (variable.name.as_str(), variable.index))
865 .collect::<std::collections::BTreeMap<_, _>>();
866 assert_eq!(
867 by_name,
868 std::collections::BTreeMap::from([("first", 1), ("reserved", 0), ("next", 2)])
869 );
870 let indices = artifact
872 .wir
873 .global_variables
874 .iter()
875 .map(|variable| variable.index)
876 .collect::<Vec<_>>();
877 assert_eq!(indices, vec![0, 1, 2]);
878 }
879
880 #[test]
881 fn implicit_default_variables_use_reference_fixed_slots() {
882 let compiler = Compiler::new().unwrap();
883 let hir = crate::compile(
884 r#"
885globalvar timer
886globalvar extra 5
887
888rule "implicit":
889 @Event global
890 A = timer + 1
891 B = A
892 B += 2
893 A[0] = 7
894 DX = B * A
895"#,
896 "implicit.opy",
897 Path::new("."),
898 )
899 .unwrap();
900 let artifact = compiler.compile_hir(&hir).unwrap();
901 let globals = artifact
902 .wir
903 .global_variables
904 .iter()
905 .map(|variable| (variable.name.clone(), variable.index))
906 .collect::<Vec<_>>();
907 assert_eq!(
912 globals,
913 vec![
914 ("A".to_string(), 0),
915 ("B".to_string(), 1),
916 ("timer".to_string(), 2),
917 ("extra".to_string(), 5),
918 ("DX".to_string(), 127),
919 ]
920 );
921 assert!(
922 artifact
923 .emitted
924 .contains("Set Global Variable(A, Add(Global.timer, 1));")
925 );
926 assert!(
927 artifact
928 .emitted
929 .contains("Set Global Variable(B, Global.A);")
930 );
931 assert!(
932 artifact
933 .emitted
934 .contains("Modify Global Variable(B, Add, 2);")
935 );
936 assert!(
937 artifact
938 .emitted
939 .contains("Set Global Variable At Index(A, 0, 7);")
940 );
941 assert!(
942 artifact
943 .emitted
944 .contains("Set Global Variable(DX, Multiply(Global.B, Global.A));")
945 );
946 }
947
948 #[test]
949 fn implicit_default_variable_slot_collision_is_source_attributed() {
950 let compiler = Compiler::new().unwrap();
951 let hir = crate::compile(
952 "globalvar x 0\nrule \"collision\":\n @Event global\n x = 1\n A = 2\n",
953 "collision.opy",
954 Path::new("."),
955 )
956 .unwrap();
957 let error = match compiler.compile_hir(&hir) {
958 Ok(_) => panic!("slot collision unexpectedly succeeded"),
959 Err(error) => error,
960 };
961 assert_eq!(error.diagnostic.code, "index-collision");
962 assert_eq!(error.diagnostic.span.unwrap().start.line, 5);
963 assert!(error.diagnostic.message.contains("'A' and 'x'"));
964 }
965
966 #[test]
967 fn implicit_default_player_variables_use_independent_reference_slots() {
968 let compiler = Compiler::new().unwrap();
969 let hir = crate::compile(
970 r#"
971playervar declaredPlayer
972
973rule "implicit player variables":
974 @Event eachPlayer
975 A = 1
976 eventPlayer.A = 1
977 eventPlayer.A += 2
978 eventPlayer.E = eventPlayer.A
979 eventPlayer.DX = eventPlayer.E
980 eventPlayer.declaredPlayer = eventPlayer.A
981"#,
982 "implicit-player.opy",
983 Path::new("."),
984 )
985 .unwrap();
986 let artifact = compiler.compile_hir(&hir).unwrap();
987 let globals = artifact
988 .wir
989 .global_variables
990 .iter()
991 .map(|variable| (variable.name.as_str(), variable.index))
992 .collect::<std::collections::BTreeMap<_, _>>();
993 let players = artifact
994 .wir
995 .player_variables
996 .iter()
997 .map(|variable| (variable.name.as_str(), variable.index))
998 .collect::<std::collections::BTreeMap<_, _>>();
999 assert_eq!(globals.get("A"), Some(&0));
1000 assert_eq!(players.get("A"), Some(&0));
1001 assert_eq!(players.get("declaredPlayer"), Some(&1));
1002 assert_eq!(players.get("E"), Some(&4));
1003 assert_eq!(players.get("DX"), Some(&127));
1004 assert!(
1005 artifact
1006 .emitted
1007 .contains("Set Player Variable(Event Player, A, 1);")
1008 );
1009 assert!(
1010 artifact
1011 .emitted
1012 .contains("Modify Player Variable(Event Player, A, Add, 2);")
1013 );
1014 assert!(
1015 artifact
1016 .emitted
1017 .contains("Set Player Variable(Event Player, E, (Event Player).A);")
1018 );
1019 }
1020
1021 #[test]
1022 fn implicit_default_player_slot_collision_is_source_attributed() {
1023 let compiler = Compiler::new().unwrap();
1024 let hir = crate::compile(
1025 "playervar declared 0\nrule \"collision\":\n @Event eachPlayer\n eventPlayer.A = 1\n",
1026 "player-collision.opy",
1027 Path::new("."),
1028 )
1029 .unwrap();
1030 let error = match compiler.compile_hir(&hir) {
1031 Ok(_) => panic!("player slot collision unexpectedly succeeded"),
1032 Err(error) => error,
1033 };
1034 assert_eq!(error.diagnostic.code, "index-collision");
1035 assert!(
1036 error
1037 .diagnostic
1038 .message
1039 .contains("player variables 'A' and 'declared'")
1040 );
1041 assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
1042 }
1043
1044 #[test]
1045 fn power_augmented_assignment_lowers_from_source() {
1046 let compiler = Compiler::new().unwrap();
1047 let hir = crate::compile(
1048 "globalvar g\nrule \"power\":\n @Event global\n g = 2\n g **= 3\n",
1049 "power.opy",
1050 Path::new("."),
1051 )
1052 .unwrap();
1053 let artifact = compiler.compile_hir(&hir).unwrap();
1054 assert!(artifact.emitted.contains("Set Global Variable(g, 2);"));
1055 assert!(
1056 artifact
1057 .emitted
1058 .contains("Modify Global Variable(g, Raise To Power, 3);")
1059 );
1060 }
1061
1062 #[test]
1063 fn opy_hex_numbers_are_normalized_at_the_wir_boundary() {
1064 let compiler = Compiler::new().unwrap();
1065 let hir = crate::compile(
1066 "globalvar large = 0x124BC\nglobalvar small = 0x124\nglobalvar scientific = 1e10\n",
1067 "numbers.opy",
1068 Path::new("."),
1069 )
1070 .unwrap();
1071 let artifact = compiler.compile_hir(&hir).unwrap();
1072 assert!(
1073 artifact
1074 .emitted
1075 .contains("Set Global Variable(large, 74940);")
1076 );
1077 assert!(
1078 artifact
1079 .emitted
1080 .contains("Set Global Variable(small, 292);")
1081 );
1082 assert!(
1083 artifact
1084 .emitted
1085 .contains("Set Global Variable(scientific, 1e10);")
1086 );
1087 assert!(!artifact.emitted.contains("0x124BC"));
1088 assert!(!artifact.emitted.contains("0x124"));
1089 }
1090
1091 #[test]
1092 fn literal_dict_lookup_lowers_to_the_selected_value() {
1093 let compiler = Compiler::new().unwrap();
1094 let hir = crate::compile(
1095 "globalvar total\nrule \"negative\":\n @Event global\n total = {\"a\": 1, \"b\": 2}[\"a\"]\n",
1096 "negative.opy",
1097 Path::new("."),
1098 )
1099 .unwrap();
1100 let artifact = compiler
1101 .compile_hir(&hir)
1102 .expect("literal dict lookup should lower");
1103 assert!(artifact.emitted.contains("Set Global Variable(total, 1);"));
1104 }
1105
1106 #[test]
1107 fn auto_allocation_fills_free_slots_below_early_explicit_indices() {
1108 let compiler = Compiler::new().unwrap();
1109 let hir = crate::compile(
1110 r#"
1111globalvar reserved 5
1112globalvar auto1
1113globalvar auto2
1114
1115rule "allocation":
1116 @Event global
1117 auto1 = 1
1118 auto2 = 2
1119 B = 3
1120"#,
1121 "allocation.opy",
1122 Path::new("."),
1123 )
1124 .unwrap();
1125 let artifact = compiler.compile_hir(&hir).unwrap();
1126 let by_name = artifact
1127 .wir
1128 .global_variables
1129 .iter()
1130 .map(|variable| (variable.name.clone(), variable.index))
1131 .collect::<std::collections::BTreeMap<_, _>>();
1132 assert_eq!(
1137 by_name,
1138 std::collections::BTreeMap::from([
1139 ("B".to_string(), 1),
1140 ("auto1".to_string(), 0),
1141 ("auto2".to_string(), 2),
1142 ("reserved".to_string(), 5),
1143 ])
1144 );
1145 }
1146
1147 #[test]
1148 fn power_expressions_lower_through_the_canonical_contract() {
1149 let compiler = Compiler::new().unwrap();
1150 let hir = crate::compile(
1151 "globalvar a = [2, 4]\nglobalvar out\nrule \"power\":\n @Event global\n out = a ** 2\n a **= 2\n a[0] **= 2\n",
1152 "power.opy",
1153 Path::new("."),
1154 )
1155 .unwrap();
1156 let artifact = compiler.compile_hir(&hir).unwrap();
1157 assert!(
1158 artifact
1159 .emitted
1160 .contains("Set Global Variable(out, Raise To Power(Global.a, 2));")
1161 );
1162 assert!(
1163 artifact
1164 .emitted
1165 .contains("Modify Global Variable(a, Raise To Power, 2);")
1166 );
1167 assert!(
1168 artifact
1169 .emitted
1170 .contains("Modify Global Variable At Index(a, 0, Raise To Power, 2);")
1171 );
1172 }
1173
1174 #[test]
1175 fn unsupported_rule_metadata_is_explicit_and_source_attributed() {
1176 let compiler = Compiler::new().unwrap();
1177 let hir = crate::compile(
1178 "rule \"metadata\":\n @Event global\n @NewPage \"section\"\n disableInspector()\n",
1179 "metadata.opy",
1180 Path::new("."),
1181 )
1182 .unwrap();
1183 let error = match compiler.compile_hir(&hir) {
1184 Ok(_) => panic!("unsupported metadata unexpectedly succeeded"),
1185 Err(error) => error,
1186 };
1187 assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
1188 assert_eq!(error.diagnostic.span.unwrap().start.line, 3);
1189 }
1190
1191 #[test]
1192 fn compiler_structure_matches_the_pinned_oracle() {
1193 let compiler = Compiler::new().unwrap();
1194 let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
1195 .join("tests/fixtures/corpus/synthetic/compiler-structure");
1196 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
1197 let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
1198 let artifact = compiler.compile_hir(&hir).unwrap();
1199 let oracle: serde_json::Value =
1200 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
1201 .unwrap();
1202 let oracle_workshop = oracle["compile"]["workshop"].as_str().unwrap();
1203 let oracle_wir = workshop_rs::parser::parse(
1204 oracle_workshop,
1205 &Catalog::builtin().unwrap(),
1206 &Locale::new("en-US"),
1207 )
1208 .unwrap();
1209 assert!(workshop_rs::roundtrip::equivalent(
1210 &artifact.wir,
1211 &oracle_wir
1212 ));
1213
1214 assert!(oracle_workshop.contains("0: reserved"));
1215 assert!(oracle_workshop.contains("1: first"));
1216 assert!(oracle_workshop.contains("2: explicit"));
1217 assert!(oracle_workshop.contains("3: next"));
1218 assert!(oracle_workshop.contains("0: helper"));
1219 assert!(oracle_workshop.contains("Subroutine;\n helper;"));
1220 assert!(oracle_workshop.contains("Player Joined Match;\n Team 1;\n Slot 2;"));
1221
1222 let indices = artifact
1223 .wir
1224 .global_variables
1225 .iter()
1226 .map(|variable| variable.index)
1227 .collect::<Vec<_>>();
1228 assert_eq!(indices, vec![0, 1, 2, 3]);
1229 assert_eq!(
1230 artifact.wir.subroutines.iter().next().unwrap().name,
1231 "helper"
1232 );
1233 assert!(artifact.emitted.contains("[Source] renamed helper"));
1234 assert!(matches!(
1235 artifact
1236 .wir
1237 .rules
1238 .get(workshop_rs::wir::RuleId::from_index(1))
1239 .unwrap()
1240 .event,
1241 workshop_rs::wir::Event::Player {
1242 kind: workshop_rs::wir::PlayerEventKind::Joined,
1243 team: workshop_rs::wir::EventTeam::Team1,
1244 target: workshop_rs::wir::EventTarget::Slot(2),
1245 }
1246 ));
1247 }
1248
1249 #[test]
1250 fn assignments_and_modifications_lower_to_canonical_wir() {
1251 let compiler = Compiler::new().unwrap();
1252 let hir = crate::compile(
1253 r#"
1254globalvar g1
1255globalvar g2
1256playervar p1
1257playervar p2 = [1, 2, 3]
1258
1259rule "assignments":
1260 @Event eachPlayer
1261 g1 = 10
1262 g1 += 5
1263 g1 -= 2
1264 g1 *= 3
1265 g1 /= 2
1266 g1 %= 4
1267 g2 = [1, 2, 3]
1268 g2[0] = 99
1269 g2[1] += 1
1270 eventPlayer.p1 = 42
1271 eventPlayer.p1 += 8
1272 eventPlayer.p1 *= 2
1273 eventPlayer.p2[2] = 7
1274 eventPlayer.p2[0] -= 3
1275"#,
1276 "assign.opy",
1277 Path::new("."),
1278 )
1279 .unwrap();
1280 let artifact = compiler.compile_hir(&hir).unwrap();
1281 assert!(artifact.emitted.contains("Set Global Variable(g1, 10);"));
1282 assert!(
1283 artifact
1284 .emitted
1285 .contains("Modify Global Variable(g1, Add, 5);")
1286 );
1287 assert!(
1288 artifact
1289 .emitted
1290 .contains("Modify Global Variable(g1, Subtract, 2);")
1291 );
1292 assert!(
1293 artifact
1294 .emitted
1295 .contains("Modify Global Variable(g1, Multiply, 3);")
1296 );
1297 assert!(
1298 artifact
1299 .emitted
1300 .contains("Modify Global Variable(g1, Divide, 2);")
1301 );
1302 assert!(
1303 artifact
1304 .emitted
1305 .contains("Modify Global Variable(g1, Modulo, 4);")
1306 );
1307 assert!(
1308 artifact
1309 .emitted
1310 .contains("Set Global Variable At Index(g2, 0, 99);")
1311 );
1312 assert!(
1313 artifact
1314 .emitted
1315 .contains("Modify Global Variable At Index(g2, 1, Add, 1);")
1316 );
1317 assert!(
1318 artifact
1319 .emitted
1320 .contains("Set Player Variable(Event Player, p1, 42);")
1321 );
1322 assert!(
1323 artifact
1324 .emitted
1325 .contains("Modify Player Variable(Event Player, p1, Add, 8);")
1326 );
1327 assert!(
1328 artifact
1329 .emitted
1330 .contains("Modify Player Variable(Event Player, p1, Multiply, 2);")
1331 );
1332 assert!(
1333 artifact
1334 .emitted
1335 .contains("Set Player Variable At Index((Event Player).p2, 2, 7);")
1336 );
1337 assert!(
1338 artifact
1339 .emitted
1340 .contains("Modify Player Variable At Index((Event Player).p2, 0, Subtract, 3);")
1341 );
1342
1343 let rule = artifact
1347 .wir
1348 .rules
1349 .get(workshop_rs::wir::RuleId::from_index(1))
1350 .unwrap();
1351 let direct = artifact.wir.actions.get(rule.actions[0]).unwrap();
1352 match direct {
1353 workshop_rs::wir::Action::SetGlobalVariable {
1354 span,
1355 target_span,
1356 variable,
1357 ..
1358 } => {
1359 assert_eq!(span.unwrap().start.line, 9);
1360 assert_eq!(target_span.unwrap().start.line, 9);
1361 assert_eq!(
1362 artifact.wir.global_variables.get(*variable).unwrap().name,
1363 "g1"
1364 );
1365 }
1366 other => panic!("expected a direct global assignment, got {other:?}"),
1367 }
1368 let indexed = artifact.wir.actions.get(rule.actions[7]).unwrap();
1369 match indexed {
1370 workshop_rs::wir::Action::Call { span, .. } => {
1371 assert_eq!(span.unwrap().start.line, 16);
1372 }
1373 other => panic!("expected an indexed assignment call, got {other:?}"),
1374 }
1375 }
1376
1377 #[test]
1378 fn expressions_and_values_lower_to_canonical_wir() {
1379 let compiler = Compiler::new().unwrap();
1380 let hir = crate::compile(
1381 r#"
1382enum Consts:
1383 BASE
1384
1385globalvar total
1386globalvar arr = [1, 2, 3]
1387globalvar pos = vect(1, 2, 3)
1388
1389rule "expressions":
1390 @Event global
1391 @Condition total == 0
1392 @Condition not (pos == vect(0, 0, 0))
1393 @Condition 2 in arr
1394 total = Consts.BASE + arr[1] * 2 - (10 / 2) + (5 % 2)
1395 print("Total: {}".format(total))
1396 debug(pos)
1397"#,
1398 "expr.opy",
1399 Path::new("."),
1400 )
1401 .unwrap();
1402 let artifact = compiler.compile_hir(&hir).unwrap();
1403 assert!(artifact.emitted.contains("Global.total == 0;"));
1404 assert!(artifact.emitted.contains("Global.pos != Vector(0, 0, 0);"));
1407 assert!(
1408 artifact
1409 .emitted
1410 .contains("Array Contains(Global.arr, 2) == True;")
1411 );
1412 assert!(
1413 artifact
1414 .emitted
1415 .contains("Custom String(\"Total: {0}\", Global.total)")
1416 );
1417 }
1418
1419 #[test]
1420 fn pass_is_supported_as_source_level_noop() {
1421 let compiler = Compiler::new().unwrap();
1422 let hir = crate::compile(
1423 r#"
1424subroutine emptySub
1425
1426def emptySub():
1427 pass
1428
1429rule "empty rule":
1430 @Event global
1431 pass
1432"#,
1433 "pass.opy",
1434 Path::new("."),
1435 )
1436 .unwrap();
1437 let artifact = compiler.compile_hir(&hir).unwrap();
1438 let rule0 = artifact
1439 .wir
1440 .rules
1441 .get(workshop_rs::wir::RuleId::from_index(0))
1442 .unwrap();
1443 assert!(rule0.actions.is_empty());
1444 let rule1 = artifact
1445 .wir
1446 .rules
1447 .get(workshop_rs::wir::RuleId::from_index(1))
1448 .unwrap();
1449 assert!(rule1.actions.is_empty());
1450 }
1451
1452 #[test]
1453 fn variable_initializers_synthesize_initialize_rules() {
1454 let compiler = Compiler::new().unwrap();
1455 let hir = crate::compile(
1456 r#"
1457globalvar j = 5
1458globalvar h = 0
1459globalvar k = 0.0
1460playervar p = 7
1461playervar q = 0
1462
1463rule "main":
1464 @Event global
1465 disableInspector()
1466"#,
1467 "init.opy",
1468 Path::new("."),
1469 )
1470 .unwrap();
1471 let artifact = compiler.compile_hir(&hir).unwrap();
1472 assert_eq!(
1473 artifact
1474 .wir
1475 .rules
1476 .get(workshop_rs::wir::RuleId::from_index(0))
1477 .unwrap()
1478 .name,
1479 "Initialize global variables"
1480 );
1481 assert_eq!(
1482 artifact
1483 .wir
1484 .rules
1485 .get(workshop_rs::wir::RuleId::from_index(1))
1486 .unwrap()
1487 .name,
1488 "Initialize player variables"
1489 );
1490 assert_eq!(
1491 artifact
1492 .wir
1493 .rules
1494 .get(workshop_rs::wir::RuleId::from_index(2))
1495 .unwrap()
1496 .name,
1497 "main"
1498 );
1499 assert!(artifact.emitted.contains("Set Global Variable(j, 5);"));
1500 assert!(artifact.emitted.contains("Set Global Variable(k, 0.0);"));
1501 assert!(!artifact.emitted.contains("Set Global Variable(h,"));
1502 assert!(
1503 artifact
1504 .emitted
1505 .contains("Set Player Variable(Event Player, p, 7);")
1506 );
1507 assert!(
1508 !artifact
1509 .emitted
1510 .contains("Set Player Variable(Event Player, q,")
1511 );
1512 }
1513
1514 #[test]
1515 fn settings_lower_through_workshop_owned_emission() {
1516 let compiler = Compiler::new().unwrap();
1517 let fixture =
1518 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/corpus/synthetic/settings");
1519 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
1520 let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
1521 let artifact = compiler.compile_hir(&hir).unwrap();
1522 let oracle: serde_json::Value =
1523 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
1524 .unwrap();
1525 let expected = oracle["compile"]["workshop"]
1526 .as_str()
1527 .unwrap()
1528 .split("\n\nrule")
1529 .next()
1530 .unwrap();
1531 let actual = artifact.emitted.split("\n\nrule").next().unwrap();
1532 let oracle_wir = workshop_rs::parser::parse(
1533 oracle["compile"]["workshop"].as_str().unwrap(),
1534 &Catalog::builtin().unwrap(),
1535 &Locale::new("en-US"),
1536 )
1537 .unwrap();
1538 assert!(workshop_rs::roundtrip::equivalent(
1539 &artifact.wir,
1540 &oracle_wir
1541 ));
1542 assert_eq!(
1543 normalize_workshop_structural_whitespace(actual),
1544 normalize_workshop_structural_whitespace(expected)
1545 );
1546 }
1547
1548 #[test]
1549 fn unsupported_locale_has_no_fabricated_source_span() {
1550 let compiler = Compiler::new().unwrap();
1551 let hir = crate::compile(
1552 "#!translations en\nrule \"r\":\n @Event global\n pass\n",
1553 "locale.opy",
1554 Path::new("."),
1555 )
1556 .unwrap();
1557 let error = match compiler.compile_hir_with_locale(&hir, &Locale::new("xx-XX")) {
1558 Ok(_) => panic!("unsupported locale unexpectedly compiled"),
1559 Err(error) => error,
1560 };
1561 assert_eq!(error.diagnostic.code, "locale-unsupported");
1562 assert_eq!(error.diagnostic.span, None);
1563 }
1564
1565 #[test]
1566 fn locale_selection_emits_catalog_localized_workshop() {
1567 let compiler = Compiler::new().unwrap();
1568 let hir = crate::compile(
1569 "rule \"locale\":\n @Event global\n disableInspector()\n",
1570 "locale.opy",
1571 Path::new("."),
1572 )
1573 .unwrap();
1574 let artifact = compiler
1575 .compile_hir_with_locale(&hir, &Locale::new("zh-CN"))
1576 .unwrap();
1577 assert!(artifact.emitted.contains("规则 (\"locale\")"));
1578 assert!(artifact.emitted.contains("禁用查看器录制"));
1579 }
1580
1581 #[test]
1582 fn unsupported_backend_directives_fail_at_their_source_anchor() {
1583 let compiler = Compiler::new().unwrap();
1584 let hir = crate::compile(
1585 "#!replace0ByCapturePercentage\nrule \"r\":\n @Event global\n pass\n",
1586 "directives.opy",
1587 Path::new("."),
1588 )
1589 .unwrap();
1590 let error = match compiler.compile_hir(&hir) {
1591 Ok(_) => panic!("backend directive unexpectedly compiled"),
1592 Err(error) => error,
1593 };
1594 assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
1595 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
1596 }
1597
1598 #[test]
1599 fn optimizer_directives_remain_non_blocking_presentation_controls() {
1600 let compiler = Compiler::new().unwrap();
1601 let hir = crate::compile(
1602 "#!disableOptimizations\nrule \"r\":\n @Event global\n pass\n",
1603 "optimization.opy",
1604 Path::new("."),
1605 )
1606 .unwrap();
1607 compiler.compile_hir(&hir).unwrap();
1608 }
1609
1610 #[test]
1611 fn replacement_directive_records_are_checked_even_if_final_state_is_restored() {
1612 let compiler = Compiler::new().unwrap();
1613 let mut hir = crate::compile(
1614 "#!replace0ByCapturePercentage\nrule \"r\":\n @Event global\n pass\n",
1615 "directives.opy",
1616 Path::new("."),
1617 )
1618 .unwrap();
1619 hir.preprocessing.replacements.clear();
1620 let error = match compiler.compile_hir(&hir) {
1621 Ok(_) => panic!("replacement directive unexpectedly compiled"),
1622 Err(error) => error,
1623 };
1624 assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
1625 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
1626 }
1627
1628 #[test]
1629 fn active_replacement_state_is_checked_without_directive_history() {
1630 let compiler = Compiler::new().unwrap();
1631 let mut hir = crate::compile(
1632 "#!replace0ByCapturePercentage\nrule \"r\":\n @Event global\n pass\n",
1633 "directives.opy",
1634 Path::new("."),
1635 )
1636 .unwrap();
1637 hir.preprocessing.directives.clear();
1638 hir.preprocessing.replacements[0].span = None;
1639 let error = match compiler.compile_hir(&hir) {
1640 Ok(_) => panic!("active replacement state unexpectedly compiled"),
1641 Err(error) => error,
1642 };
1643 assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
1644 assert_eq!(error.diagnostic.span, None);
1645 }
1646
1647 #[test]
1648 fn post_compile_hook_receives_exact_emitted_workshop() {
1649 let compiler = Compiler::new().unwrap();
1650 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
1651 let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n pass\n";
1652 let artifact = compiler
1653 .compile_source_with_locale(source, "hook.opy", &root, &Locale::new("en-US"))
1654 .unwrap();
1655 assert!(artifact.emitted.contains("rule (\"setup\")"));
1656 assert!(artifact.final_output.contains("rule (\"transformed\")"));
1657 assert_ne!(artifact.final_output, artifact.emitted);
1658 }
1659
1660 #[test]
1661 fn post_compile_hook_failure_keeps_script_provenance_and_directive_anchor() {
1662 let compiler = Compiler::new().unwrap();
1663 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
1664 let source = "#!postCompileHook \"hook-boom.js\"\n\nrule \"setup\":\n pass\n";
1665 let error = match compiler.compile_source_with_locale(
1666 source,
1667 "hook.opy",
1668 &root,
1669 &Locale::new("en-US"),
1670 ) {
1671 Ok(_) => panic!("failing post-compile hook unexpectedly compiled"),
1672 Err(error) => error,
1673 };
1674 assert_eq!(error.diagnostic.code, "post-compile-hook");
1675 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
1676 let script = error.diagnostic.script.unwrap();
1677 assert_eq!(script.source_name.as_deref(), Some("hook-boom.js"));
1678 assert_eq!(script.line, Some(1));
1679 assert!(script.stack.unwrap().contains("hook-boom.js:1"));
1680 }
1681
1682 fn normalize_workshop_structural_whitespace(text: &str) -> String {
1683 let mut normalized = String::with_capacity(text.len());
1684 let mut quote = None;
1685 let mut escaped = false;
1686 for character in text.chars() {
1687 if let Some(delimiter) = quote {
1688 normalized.push(character);
1689 if escaped {
1690 escaped = false;
1691 } else if character == '\\' {
1692 escaped = true;
1693 } else if character == delimiter {
1694 quote = None;
1695 }
1696 } else if matches!(character, '\"' | '\'') {
1697 quote = Some(character);
1698 normalized.push(character);
1699 } else if !character.is_whitespace() {
1700 normalized.push(character);
1701 }
1702 }
1703 normalized
1704 }
1705
1706 #[test]
1707 fn settings_whitespace_normalization_preserves_quoted_values() {
1708 assert_ne!(
1709 normalize_workshop_structural_whitespace("Description: \"a b\""),
1710 normalize_workshop_structural_whitespace("Description: \"ab\"")
1711 );
1712 }
1713}