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