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