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