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 blizzard_global;
20mod hooks;
21mod integration;
22mod lowering;
23mod settings;
24
25pub(crate) use backend::MacroExpander;
26pub(super) use backend::{expand_macros, reject_unlowered_directives};
27pub use integration::LinkReport;
28pub(super) use integration::load_compiler_contract;
29pub(super) use lowering::Lowering;
30
31#[cfg(test)]
32mod integration_tests;
33
34const TRANSLATION_HELPER_NAME: &str = "__overpyTranslationHelper__";
35
36fn has_directive(hir: &hir::Program, name: &str) -> bool {
37 hir.preprocessing
38 .directives
39 .iter()
40 .any(|directive| directive.name == name)
41}
42
43fn omit_declaration_sections(output: &str, program: &workshop_rs::Program) -> String {
44 let mut markers = Vec::new();
45 for variable in program
46 .global_variables
47 .iter()
48 .chain(program.player_variables.iter())
49 {
50 markers.push(variable.index.map_or_else(
51 || variable.name.clone(),
52 |index| format!("{index}: {}", variable.name),
53 ));
54 }
55 markers.extend(program.subroutines.iter().map(|subroutine| {
56 subroutine.index.map_or_else(
57 || subroutine.name.clone(),
58 |index| format!("{index}: {}", subroutine.name),
59 )
60 }));
61 if markers.is_empty() {
62 return output.to_string();
63 }
64
65 let lines: Vec<&str> = output.split_inclusive('\n').collect();
66 let mut removed = vec![false; lines.len()];
67 for (index, line) in lines.iter().enumerate() {
68 if !markers.iter().any(|marker| line.trim() == marker) {
69 continue;
70 }
71 let Some(start) = (0..=index).rev().find(|candidate| {
72 let trimmed = lines[*candidate].trim_end();
73 !trimmed.starts_with(char::is_whitespace) && trimmed.ends_with('{')
74 }) else {
75 continue;
76 };
77 let mut depth = 0usize;
78 let mut end = start;
79 for (candidate, line) in lines.iter().enumerate().skip(start) {
80 depth += line.matches('{').count();
81 depth = depth.saturating_sub(line.matches('}').count());
82 if depth == 0 {
83 end = candidate;
84 break;
85 }
86 }
87 for removed_line in removed.iter_mut().take(end + 1).skip(start) {
88 *removed_line = true;
89 }
90 if end + 1 < removed.len() && lines[end + 1].trim().is_empty() {
91 removed[end + 1] = true;
92 }
93 }
94
95 lines
96 .into_iter()
97 .enumerate()
98 .filter_map(|(index, line)| (!removed[index]).then_some(line))
99 .collect()
100}
101
102fn emit_debug_element_counts(
103 output: &str,
104 program: &workshop_rs::Program,
105 catalog: &Catalog,
106 hir: &hir::Program,
107) -> Result<String, IntegrationError> {
108 let report = program.element_count(catalog).map_err(|error| {
109 IntegrationError::new(
110 "element-count",
111 format!("cannot compute Workshop element counts: {error}"),
112 None,
113 )
114 })?;
115 let rule_counts: Vec<_> = report.rules.iter().map(debug_rule_count).collect();
116 let total = rule_counts.iter().sum::<usize>();
117 let mut summary = format!("/* Element count: (total {total})\n\n");
118 let mut summary_rules: Vec<_> = report
119 .rules
120 .iter()
121 .zip(&rule_counts)
122 .filter(|(_, count)| **count > 1)
123 .collect();
124 summary_rules.sort_by_key(|(_, count)| std::cmp::Reverse(**count));
125 for (count, rule_count) in summary_rules {
126 let source = count
127 .span
128 .and_then(|span| {
129 hir.files
130 .iter()
131 .find(|file| file.id == span.file.index() as u32)
132 })
133 .map(|file| file.path.as_str())
134 .map(|path| format!(" ({path})"))
135 .unwrap_or_default();
136 summary.push_str(&format!(
137 "{:>5}: rule \"{}\"{source}\n",
138 rule_count, count.name
139 ));
140 }
141 summary.push_str("\n*/\n\n");
142
143 let mut annotated = summary;
144 let mut rule_index = 0;
145 let mut condition_index = 0;
146 let mut conditions = Vec::new();
147 let mut actions = Vec::new();
148 let mut in_actions = false;
149 let mut in_conditions = false;
150 let mut action_index = 0;
151 for line in output.split_inclusive('\n') {
152 let trimmed = line.trim();
153 if line.starts_with("rule (") {
154 if let (Some(rule), Some(rule_count)) =
155 (report.rules.get(rule_index), rule_counts.get(rule_index))
156 {
157 if *rule_count > 1 {
158 let suffix = if *rule_count == 1 {
159 "element"
160 } else {
161 "elements"
162 };
163 annotated.push_str(&format!("//{} {suffix}\n", rule_count));
164 }
165 conditions.clear();
166 collect_element_nodes(
167 &rule.children,
168 workshop_rs::element_count::ElementNodeKind::Condition,
169 &mut conditions,
170 );
171 actions.clear();
172 collect_element_nodes(
173 &rule.children,
174 workshop_rs::element_count::ElementNodeKind::Action,
175 &mut actions,
176 );
177 }
178 rule_index += 1;
179 condition_index = 0;
180 action_index = 0;
181 in_conditions = false;
182 in_actions = false;
183 } else if trimmed == "conditions {" {
184 in_conditions = true;
185 in_actions = false;
186 } else if trimmed == "actions {" {
187 in_conditions = false;
188 in_actions = true;
189 } else if in_actions && line.starts_with(" }") {
190 in_actions = false;
191 } else if in_conditions && line.starts_with(" }") {
192 in_conditions = false;
193 }
194
195 if in_conditions && line.starts_with(" ") && trimmed.ends_with(';') {
196 if let Some(condition) = conditions.get(condition_index) {
197 let condition_count = debug_condition_count(condition);
198 let suffix = if condition_count == 1 {
199 "element"
200 } else {
201 "elements"
202 };
203 annotated.push_str(line.trim_end_matches('\n'));
204 annotated.push_str(&format!(" // {condition_count} {suffix}\n"));
205 condition_index += 1;
206 continue;
207 }
208 }
209
210 if in_actions && line.starts_with(" ") && trimmed.ends_with(';') {
211 if let Some(action) = actions.get(action_index) {
212 let action_count = debug_action_local_count(action);
213 let suffix = if action_count == 1 {
214 "element"
215 } else {
216 "elements"
217 };
218 annotated.push_str(line.trim_end_matches('\n'));
219 annotated.push_str(&format!(" // {action_count} {suffix}\n"));
220 action_index += 1;
221 continue;
222 }
223 }
224 annotated.push_str(line);
225 }
226 Ok(annotated)
227}
228
229fn debug_value_count(node: &workshop_rs::element_count::ElementCountNode) -> usize {
230 let children = node.children.iter().map(debug_value_count).sum::<usize>();
231 match node.name.as_str() {
232 "number" | "global variable" => 2,
233 "localized string" => 2,
234 "customString" => 1 + 4usize.saturating_sub(node.children.len()) + children,
235 "Team" | "Color" => 1 + children.max(1),
236 "array" | "evalOnce" => 2 + children,
237 _ if node.children.is_empty() => 1,
238 _ => 1 + children,
239 }
240}
241
242fn debug_condition_count(node: &workshop_rs::element_count::ElementCountNode) -> usize {
243 let value_count = node.children.iter().map(debug_value_count).sum::<usize>();
244 value_count.saturating_sub(usize::from(node.children.len() > 1))
245}
246
247fn debug_action_local_count(node: &workshop_rs::element_count::ElementCountNode) -> usize {
248 let values = node
249 .children
250 .iter()
251 .filter(|child| child.kind == workshop_rs::element_count::ElementNodeKind::Value)
252 .map(debug_value_count)
253 .sum::<usize>();
254 let value_arguments = node
255 .children
256 .iter()
257 .filter(|child| child.kind == workshop_rs::element_count::ElementNodeKind::Value)
258 .count();
259 1 + values.saturating_sub(value_arguments)
260}
261
262fn debug_action_count(node: &workshop_rs::element_count::ElementCountNode) -> usize {
263 let nested_actions = node
264 .children
265 .iter()
266 .filter(|child| child.kind == workshop_rs::element_count::ElementNodeKind::Action)
267 .map(debug_action_count)
268 .sum::<usize>();
269 debug_action_local_count(node) + nested_actions
270}
271
272fn debug_rule_count(node: &workshop_rs::element_count::ElementCountNode) -> usize {
273 let children = node
274 .children
275 .iter()
276 .map(|child| match child.kind {
277 workshop_rs::element_count::ElementNodeKind::Condition => debug_condition_count(child),
278 workshop_rs::element_count::ElementNodeKind::Action => debug_action_count(child),
279 workshop_rs::element_count::ElementNodeKind::Rule
280 | workshop_rs::element_count::ElementNodeKind::Value => 0,
281 })
282 .sum::<usize>();
283 1 + children
284}
285
286fn collect_element_nodes<'a>(
287 nodes: &'a [workshop_rs::element_count::ElementCountNode],
288 kind: workshop_rs::element_count::ElementNodeKind,
289 collected: &mut Vec<&'a workshop_rs::element_count::ElementCountNode>,
290) {
291 for node in nodes {
292 if node.kind == kind {
293 collected.push(node);
294 }
295 collect_element_nodes(&node.children, kind, collected);
296 }
297}
298
299pub const COMPILE_SCHEMA_VERSION: u32 = 1;
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
304pub struct CompilerIdentity {
305 pub name: &'static str,
306 pub version: &'static str,
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
311#[serde(rename_all = "lowercase")]
312pub enum CompileStatus {
313 Success,
314 Failure,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
319#[serde(rename_all = "kebab-case")]
320pub enum CompileFailureClass {
321 Frontend,
322 Integration,
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
327#[serde(rename_all = "camelCase")]
328pub struct CompileDiagnostic {
329 pub severity: crate::tooling::DiagnosticSeverity,
330 pub code: String,
331 pub message: String,
332 pub span: Option<crate::tooling::SourceLocation>,
333 #[serde(skip_serializing_if = "Option::is_none")]
334 pub script: Option<ScriptDiagnostic>,
335}
336
337#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
339#[serde(rename_all = "camelCase")]
340pub struct CompileResult {
341 pub status: CompileStatus,
342 pub exit_code: u8,
343 pub failure_class: Option<CompileFailureClass>,
344 pub diagnostics: Vec<CompileDiagnostic>,
345 pub stdout: String,
346 pub workshop_exact: String,
347 pub workshop: String,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
352#[serde(rename_all = "camelCase")]
353pub struct CompileReport {
354 pub schema_version: u32,
355 pub compiler: CompilerIdentity,
356 pub catalog: CatalogIdentity,
357 pub compile: CompileResult,
358}
359
360impl CompilerIdentity {
361 fn current() -> Self {
362 Self {
363 name: "opy-rs",
364 version: env!("CARGO_PKG_VERSION"),
365 }
366 }
367}
368
369#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct IntegrationDiagnostic {
372 pub code: String,
373 pub message: String,
374 pub span: Option<HirSpan>,
375 pub script: Option<Box<ScriptDiagnostic>>,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
380#[serde(rename_all = "camelCase")]
381pub struct ScriptDiagnostic {
382 pub source_name: Option<String>,
383 pub line: Option<u32>,
384 pub column: Option<u32>,
385 pub stack: Option<String>,
386}
387
388impl IntegrationDiagnostic {
389 fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
390 Self {
391 code: code.into(),
392 message: message.into(),
393 span,
394 script: None,
395 }
396 }
397}
398
399#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct IntegrationError {
402 pub diagnostic: IntegrationDiagnostic,
403}
404
405impl IntegrationError {
406 fn new(code: impl Into<String>, message: impl Into<String>, span: Option<HirSpan>) -> Self {
407 Self {
408 diagnostic: IntegrationDiagnostic::new(code, message, span),
409 }
410 }
411
412 fn post_compile_hook(error: crate::macro_js::MacroError, span: Option<HirSpan>) -> Self {
413 let message = error.to_string();
414 let script = match error {
415 crate::macro_js::MacroError::Script(error) => Some(Box::new(ScriptDiagnostic {
416 source_name: error.source_name,
417 line: error.line,
418 column: error.column,
419 stack: error.stack,
420 })),
421 crate::macro_js::MacroError::InvalidResult { .. }
422 | crate::macro_js::MacroError::Internal(_) => None,
423 };
424 Self {
425 diagnostic: IntegrationDiagnostic {
426 code: "post-compile-hook".to_string(),
427 message,
428 span,
429 script,
430 },
431 }
432 }
433}
434
435impl std::fmt::Display for IntegrationError {
436 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
437 write!(f, "{}: {}", self.diagnostic.code, self.diagnostic.message)
438 }
439}
440
441impl std::error::Error for IntegrationError {}
442
443pub struct Compiler {
446 catalog: &'static Catalog,
447 manifest: &'static Manifest,
448 links: LinkReport,
449}
450
451impl Compiler {
452 pub fn new() -> Result<Self, IntegrationError> {
453 let contract = load_compiler_contract()?;
454 Ok(Self {
455 catalog: &contract.catalog,
456 manifest: contract.manifest,
457 links: contract.links,
458 })
459 }
460
461 pub fn catalog_identity(&self) -> CatalogIdentity {
462 self.catalog.identity()
463 }
464
465 pub fn link_report(&self) -> LinkReport {
466 self.links
467 }
468
469 pub fn compile_hir(&self, hir: &hir::Program) -> Result<CompilationArtifact, IntegrationError> {
472 self.compile_hir_with_locale(hir, &Locale::new("en-US"))
473 }
474
475 pub fn compile_hir_with_locale(
477 &self,
478 hir: &hir::Program,
479 locale: &Locale,
480 ) -> Result<CompilationArtifact, IntegrationError> {
481 if !self.catalog.supports(locale) {
482 return Err(IntegrationError::new(
483 "locale-unsupported",
484 format!("workshop catalog does not declare locale '{locale}'"),
485 None,
486 ));
487 }
488 reject_unlowered_directives(hir)?;
489 let expanded_hir = expand_macros(hir)?;
490 let mut lowering = Lowering::new(self, &expanded_hir)?;
491 lowering.copy_files()?;
492 lowering.lower_declarations()?;
493 lowering.lower_rules()?;
494 let translation_files = lowering.translation_files();
495
496 let program = lowering.program;
497 program.validate().map_err(|error| {
498 IntegrationError::new(
499 "workshop-validation",
500 error.to_string(),
501 workshop_error_span(&error)
502 .and_then(|span| hir_span_from_workshop(span, &expanded_hir)),
503 )
504 })?;
505 workshop_rs::validate::validate_canonical_ids(&program, self.catalog).map_err(|error| {
506 IntegrationError::new(
507 "catalog-validation",
508 error.to_string(),
509 workshop_error_span(&error)
510 .and_then(|span| hir_span_from_workshop(span, &expanded_hir)),
511 )
512 })?;
513 let exclude_variables = has_directive(&expanded_hir, "excludeVariablesInCompilation");
514 let emitted =
515 workshop_rs::emitter::emit(&program, self.catalog, locale).map_err(|error| {
516 IntegrationError::new(
517 "workshop-emission",
518 error.to_string(),
519 workshop_error_span(&error)
520 .and_then(|span| hir_span_from_workshop(span, &expanded_hir)),
521 )
522 })?;
523 let emitted = if exclude_variables {
524 omit_declaration_sections(&emitted, &program)
525 } else {
526 emitted
527 };
528 let emitted = if has_directive(&expanded_hir, "debugElementCount") {
529 emit_debug_element_counts(&emitted, &program, self.catalog, &expanded_hir)?
530 } else {
531 emitted
532 };
533
534 Ok(CompilationArtifact {
535 wir: program,
536 final_output: emitted.clone(),
537 emitted,
538 catalog_identity: self.catalog.identity(),
539 hook_console_output: Vec::new(),
540 translation_files,
541 })
542 }
543
544 pub fn compile_source(
550 &self,
551 source: &str,
552 main_path: &str,
553 root: &std::path::Path,
554 ) -> Result<CompileOutput, IntegrationError> {
555 self.compile_source_with_language(source, main_path, root, "en-US")
556 }
557
558 pub fn compile_source_with_language(
561 &self,
562 source: &str,
563 main_path: &str,
564 root: &std::path::Path,
565 language: &str,
566 ) -> Result<CompileOutput, IntegrationError> {
567 self.compile_source_with_locale(source, main_path, root, &Locale::new(language))
568 .map(CompilationArtifact::into_output)
569 }
570
571 pub fn compile_source_with_locale(
576 &self,
577 source: &str,
578 main_path: &str,
579 root: &std::path::Path,
580 locale: &Locale,
581 ) -> Result<CompilationArtifact, IntegrationError> {
582 self.compile_source_internal(source, main_path, root, locale)
583 }
584
585 pub fn compile_source_artifact(
588 &self,
589 source: &str,
590 main_path: &str,
591 root: &std::path::Path,
592 ) -> Result<CompilationArtifact, IntegrationError> {
593 self.compile_source_with_locale(source, main_path, root, &Locale::new("en-US"))
594 }
595
596 pub fn compile_source_report(
599 &self,
600 source: &str,
601 main_path: &str,
602 root: &std::path::Path,
603 ) -> CompileReport {
604 self.compile_source_report_with_language(source, main_path, root, "en-US")
605 }
606
607 pub fn compile_source_report_with_language(
610 &self,
611 source: &str,
612 main_path: &str,
613 root: &std::path::Path,
614 language: &str,
615 ) -> CompileReport {
616 self.compile_source_report_with_locale(source, main_path, root, &Locale::new(language))
617 }
618
619 pub fn compile_source_report_with_locale(
622 &self,
623 source: &str,
624 main_path: &str,
625 root: &std::path::Path,
626 locale: &Locale,
627 ) -> CompileReport {
628 let outcome = crate::compile_with_overlay_outcome(
629 source,
630 main_path,
631 root,
632 &std::collections::BTreeMap::new(),
633 );
634 let catalog = self.catalog.identity();
635 let compiler = CompilerIdentity::current();
636 let frontend_diagnostics = outcome
637 .diagnostics
638 .iter()
639 .map(compile_frontend_diagnostic)
640 .collect::<Vec<_>>();
641 let Some(hir) = outcome.hir else {
642 return CompileReport::failure(
643 compiler,
644 catalog,
645 CompileFailureClass::Frontend,
646 frontend_diagnostics,
647 );
648 };
649
650 match self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale) {
651 Ok(artifact) => {
652 match write_translation_files(root, &hir, &artifact.translation_files) {
653 Ok(()) => {
654 CompileReport::success(compiler, catalog, artifact, frontend_diagnostics)
655 }
656 Err(error) => {
657 let mut diagnostics = frontend_diagnostics;
658 diagnostics.push(compile_diagnostic(error, &hir.files));
659 CompileReport::failure(
660 compiler,
661 catalog,
662 CompileFailureClass::Integration,
663 diagnostics,
664 )
665 }
666 }
667 }
668 Err(error) => {
669 let mut diagnostics = frontend_diagnostics;
670 diagnostics.push(compile_diagnostic(error, &hir.files));
671 CompileReport::failure(
672 compiler,
673 catalog,
674 CompileFailureClass::Integration,
675 diagnostics,
676 )
677 }
678 }
679 }
680
681 fn compile_source_internal(
682 &self,
683 source: &str,
684 main_path: &str,
685 root: &std::path::Path,
686 locale: &Locale,
687 ) -> Result<CompilationArtifact, IntegrationError> {
688 let outcome = crate::compile_with_overlay_outcome(
689 source,
690 main_path,
691 root,
692 &std::collections::BTreeMap::new(),
693 );
694 let hir = outcome.hir.ok_or_else(|| {
695 let error = outcome
696 .error
697 .expect("failed frontend compile has diagnostic");
698 IntegrationError::new(
699 error.code,
700 error.message,
701 error.span.map(hir_span_from_diag),
702 )
703 })?;
704 let artifact =
705 self.compile_hir_with_locale_and_hook(&hir, outcome.post_compile_hook, locale)?;
706 write_translation_files(root, &hir, &artifact.translation_files)?;
707 Ok(artifact)
708 }
709}
710
711fn write_translation_files(
712 root: &std::path::Path,
713 hir: &hir::Program,
714 files: &[(String, String)],
715) -> Result<(), IntegrationError> {
716 let Some(source) = hir.files.get(1).or_else(|| hir.files.first()) else {
717 return Ok(());
718 };
719 let base = root.join(
720 std::path::Path::new(&source.path)
721 .file_name()
722 .unwrap_or_else(|| std::ffi::OsStr::new("main.opy")),
723 );
724 for (language, content) in files {
725 let path = base.with_extension(format!("{language}.po"));
726 std::fs::write(&path, content).map_err(|error| {
727 IntegrationError::new(
728 "translations-io",
729 format!(
730 "cannot write translation file '{}': {error}",
731 path.display()
732 ),
733 hir.preprocessing
734 .translations
735 .as_ref()
736 .and_then(|value| value.span),
737 )
738 })?;
739 }
740 Ok(())
741}
742
743fn workshop_error_span(error: &workshop_rs::WorkshopError) -> Option<workshop_rs::source::Span> {
744 match error {
745 workshop_rs::WorkshopError::Unknown { span, .. }
746 | workshop_rs::WorkshopError::Malformed { span, .. }
747 | workshop_rs::WorkshopError::Unsupported { span, .. } => *span,
748 workshop_rs::WorkshopError::Catalog(_)
749 | workshop_rs::WorkshopError::MissingMapping { .. } => None,
750 }
751}
752
753fn hir_span_from_workshop(span: workshop_rs::source::Span, hir: &hir::Program) -> Option<HirSpan> {
754 let file = span.file.index() as u32;
755 hir.files
756 .iter()
757 .any(|source| source.id == file)
758 .then_some(HirSpan {
759 file,
760 start: hir::Position {
761 line: span.start.line,
762 col: span.start.col,
763 },
764 end: hir::Position {
765 line: span.end.line,
766 col: span.end.col,
767 },
768 })
769}
770
771impl CompileReport {
772 fn success(
773 compiler: CompilerIdentity,
774 catalog: CatalogIdentity,
775 artifact: CompilationArtifact,
776 diagnostics: Vec<CompileDiagnostic>,
777 ) -> Self {
778 Self {
779 schema_version: COMPILE_SCHEMA_VERSION,
780 compiler,
781 catalog,
782 compile: CompileResult {
783 status: CompileStatus::Success,
784 exit_code: 0,
785 failure_class: None,
786 diagnostics,
787 stdout: String::new(),
788 workshop_exact: artifact.final_output.clone(),
789 workshop: normalize_workshop(&artifact.final_output),
790 },
791 }
792 }
793
794 fn failure(
795 compiler: CompilerIdentity,
796 catalog: CatalogIdentity,
797 failure_class: CompileFailureClass,
798 diagnostics: Vec<CompileDiagnostic>,
799 ) -> Self {
800 Self {
801 schema_version: COMPILE_SCHEMA_VERSION,
802 compiler,
803 catalog,
804 compile: CompileResult {
805 status: CompileStatus::Failure,
806 exit_code: 1,
807 failure_class: Some(failure_class),
808 diagnostics,
809 stdout: String::new(),
810 workshop_exact: String::new(),
811 workshop: String::new(),
812 },
813 }
814 }
815}
816
817fn compile_diagnostic(error: IntegrationError, files: &[hir::SourceFile]) -> CompileDiagnostic {
818 let diagnostic = error.diagnostic;
819 CompileDiagnostic {
820 severity: crate::tooling::DiagnosticSeverity::Error,
821 code: diagnostic.code,
822 message: diagnostic.message,
823 span: diagnostic
824 .span
825 .and_then(|span| source_location_from_hir(span, files)),
826 script: diagnostic.script.map(|script| *script),
827 }
828}
829
830fn compile_frontend_diagnostic(diagnostic: &crate::tooling::Diagnostic) -> CompileDiagnostic {
831 CompileDiagnostic {
832 severity: diagnostic.severity,
833 code: diagnostic.code.clone(),
834 message: diagnostic.message.clone(),
835 span: diagnostic.span.clone(),
836 script: None,
837 }
838}
839
840fn source_location_from_hir(
841 span: HirSpan,
842 files: &[hir::SourceFile],
843) -> Option<crate::tooling::SourceLocation> {
844 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
845 Some(crate::tooling::SourceLocation {
846 file_id: span.file,
847 path,
848 start: crate::diag::Position::new(span.start.line, span.start.col),
849 end: crate::diag::Position::new(span.end.line, span.end.col),
850 })
851}
852
853fn normalize_workshop(text: &str) -> String {
854 if text.is_empty() {
855 return String::new();
856 }
857 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
858 let mut lines = normalized
859 .split('\n')
860 .map(|line| line.trim_end_matches([' ', '\t']).to_owned())
861 .collect::<Vec<_>>();
862 while lines.last().is_some_and(|line| line.is_empty()) {
863 lines.pop();
864 }
865 if lines.is_empty() {
866 String::new()
867 } else {
868 lines.join("\n") + "\n"
869 }
870}
871
872#[derive(Debug, Clone, PartialEq, Eq)]
877pub struct CompileOutput {
878 pub workshop: String,
880 pub emitted_workshop: String,
882 pub hook_console_output: Vec<String>,
884}
885
886pub struct CompilationArtifact {
889 pub wir: Program,
890 pub emitted: String,
891 pub catalog_identity: CatalogIdentity,
892 pub final_output: String,
893 pub hook_console_output: Vec<String>,
894 pub(crate) translation_files: Vec<(String, String)>,
895}
896
897impl CompilationArtifact {
898 fn into_output(self) -> CompileOutput {
899 CompileOutput {
900 workshop: self.final_output,
901 emitted_workshop: self.emitted,
902 hook_console_output: self.hook_console_output,
903 }
904 }
905}
906
907fn hir_span_from_diag(span: crate::diag::Span) -> HirSpan {
908 HirSpan {
909 file: span.file,
910 start: hir::Position {
911 line: span.start.line,
912 col: span.start.col,
913 },
914 end: hir::Position {
915 line: span.end.line,
916 col: span.end.col,
917 },
918 }
919}
920
921#[cfg(test)]
922mod tests {
923 use super::integration::cross_check_manifest;
924 use super::{COMPILE_SCHEMA_VERSION, CompileFailureClass, CompileStatus, Compiler};
925 use crate::manifest::Manifest;
926 use std::path::Path;
927 use workshop_rs::catalog::{Catalog, Locale};
928
929 #[test]
930 fn catalog_links_are_checked() {
931 let compiler = Compiler::new().expect("Workshop contract must load");
932 let identity = compiler.catalog_identity();
933 assert!(!identity.implementation_version.is_empty());
934 assert!(compiler.link_report().catalog_ids_checked > 0);
935 assert!(compiler.link_report().domains_checked > 0);
936 }
937
938 #[test]
939 fn compiler_instances_share_the_verified_contract_concurrently() {
940 let instances = std::thread::scope(|scope| {
941 let handles = (0..8)
942 .map(|_| {
943 scope.spawn(|| {
944 let compiler = Compiler::new().expect("Workshop contract must load");
945 (
946 compiler.catalog as *const Catalog as usize,
947 compiler.manifest as *const Manifest as usize,
948 compiler.link_report(),
949 )
950 })
951 })
952 .collect::<Vec<_>>();
953 handles
954 .into_iter()
955 .map(|handle| handle.join().expect("compiler construction must not panic"))
956 .collect::<Vec<_>>()
957 });
958
959 let (catalog, manifest, links) = instances[0];
960 for (instance_catalog, instance_manifest, instance_links) in instances.into_iter().skip(1) {
961 assert_eq!(instance_catalog, catalog);
962 assert_eq!(instance_manifest, manifest);
963 assert_eq!(instance_links, links);
964 }
965 }
966
967 #[test]
968 fn compile_report_is_versioned_and_contains_reproducibility_identity() {
969 let compiler = Compiler::new().unwrap();
970 let report = compiler.compile_source_report_with_locale(
971 "rule \"report\":\n @Event global\n disableInspector()\n",
972 "report.opy",
973 Path::new("."),
974 &Locale::new("en-US"),
975 );
976 assert_eq!(report.schema_version, COMPILE_SCHEMA_VERSION);
977 assert_eq!(report.compiler.name, "opy-rs");
978 assert_eq!(report.catalog, compiler.catalog_identity());
979 assert_eq!(report.compile.status, CompileStatus::Success);
980 assert_eq!(report.compile.exit_code, 0);
981 assert!(report.compile.diagnostics.is_empty());
982 assert_eq!(
983 report.compile.workshop,
984 report
985 .compile
986 .workshop_exact
987 .trim_end_matches('\n')
988 .to_owned()
989 + "\n"
990 );
991 assert!(serde_json::to_value(report).unwrap()["catalog"]["catalog-version"].is_string());
992 }
993
994 #[test]
995 fn compile_report_preserves_frontend_failure_class_and_source_path() {
996 let compiler = Compiler::new().unwrap();
997 let report = compiler.compile_source_report_with_locale(
998 "rule \"broken\":\n @Event global\n missing()\n",
999 "broken.opy",
1000 Path::new("."),
1001 &Locale::new("en-US"),
1002 );
1003 assert_eq!(report.compile.status, CompileStatus::Failure);
1004 assert_eq!(
1005 report.compile.failure_class,
1006 Some(CompileFailureClass::Frontend)
1007 );
1008 assert_eq!(report.compile.exit_code, 1);
1009 let diagnostic = &report.compile.diagnostics[0];
1010 assert_eq!(diagnostic.code, "unknown-action");
1011 assert_eq!(diagnostic.span.as_ref().unwrap().path, "broken.opy");
1012 }
1013
1014 #[test]
1015 fn compile_report_preserves_integration_failure_class_and_source_path() {
1016 let compiler = Compiler::new().unwrap();
1017 let report = compiler.compile_source_report_with_locale(
1018 "rule \"broken\":\n @Event global\n {\"a\": 1}[\"b\"] = 3\n",
1019 "broken.opy",
1020 Path::new("."),
1021 &Locale::new("en-US"),
1022 );
1023 assert_eq!(report.compile.status, CompileStatus::Failure);
1024 assert_eq!(
1025 report.compile.failure_class,
1026 Some(CompileFailureClass::Integration)
1027 );
1028 assert_eq!(
1029 report.compile.diagnostics[0].span.as_ref().unwrap().path,
1030 "broken.opy"
1031 );
1032 }
1033
1034 #[test]
1035 fn compile_report_preserves_frontend_warnings_on_integration_failure() {
1036 let compiler = Compiler::new().unwrap();
1037 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1038 .join("tests/fixtures/corpus/synthetic/preprocessing");
1039 let report = compiler.compile_source_report_with_locale(
1040 concat!(
1041 "#!include \"shared.opy\"\n",
1042 "#!include \"shared.opy\"\n",
1043 "rule \"broken\":\n",
1044 " @Event global\n",
1045 " {\"a\": 1}[\"b\"] = 3\n",
1046 ),
1047 "broken.opy",
1048 &root,
1049 &Locale::new("en-US"),
1050 );
1051 assert_eq!(report.compile.status, CompileStatus::Failure);
1052 assert_eq!(
1053 report.compile.failure_class,
1054 Some(CompileFailureClass::Integration)
1055 );
1056 assert_eq!(report.compile.diagnostics.len(), 2);
1057 assert_eq!(
1058 report.compile.diagnostics[0].severity,
1059 crate::tooling::DiagnosticSeverity::Warning
1060 );
1061 assert_eq!(report.compile.diagnostics[0].code, "w_already_imported");
1062 assert_eq!(
1063 report.compile.diagnostics[1].severity,
1064 crate::tooling::DiagnosticSeverity::Error
1065 );
1066 assert_eq!(
1067 report.compile.diagnostics[1].span.as_ref().unwrap().path,
1068 "broken.opy"
1069 );
1070 }
1071
1072 #[test]
1073 fn suppress_warnings_hides_matching_preprocessing_diagnostics() {
1074 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1075 .join("tests/fixtures/corpus/synthetic/preprocessing");
1076 let outcome = crate::tooling::check(
1077 concat!(
1078 "#!suppressWarnings w_already_imported\n",
1079 "#!include \"shared.opy\"\n",
1080 "#!include \"shared.opy\"\n",
1081 "rule \"r\":\n",
1082 " @Event global\n",
1083 " pass\n",
1084 ),
1085 "main.opy",
1086 &root,
1087 );
1088 assert!(outcome.model.is_some());
1089 assert!(outcome.diagnostics.is_empty());
1090 }
1091
1092 #[test]
1093 fn vertical_slice_preserves_source_files_spans_and_emits_workshop() {
1094 let compiler = Compiler::new().unwrap();
1095 let hir = crate::compile(
1096 "globalvar A\nrule \"issue 35 integration\":\n @Event global\n A = 1\n disableInspector()\n",
1097 "compiler-vertical-slice.opy",
1098 Path::new("."),
1099 )
1100 .unwrap();
1101 let artifact = compiler.compile_hir(&hir).unwrap();
1102 assert!(artifact.emitted.contains("Disable Inspector Recording;"));
1103 assert_eq!(artifact.catalog_identity, compiler.catalog_identity());
1104 }
1105
1106 #[test]
1107 fn stale_catalog_links_fail_explicitly() {
1108 let manifest = Manifest::builtin().unwrap().clone();
1109 let mut stale = manifest;
1110 stale.functions[0].catalog_id = Some("missing-catalog-id".to_string());
1111 let error = cross_check_manifest(&stale, &Catalog::builtin().unwrap()).unwrap_err();
1112 assert_eq!(error.diagnostic.code, "catalog-link-missing");
1113 }
1114
1115 #[test]
1116 fn while_lowering_is_source_attributed() {
1117 let compiler = Compiler::new().unwrap();
1118 let hir = crate::compile(
1119 "rule \"while\":\n @Event global\n while true:\n disableInspector()\n",
1120 "while.opy",
1121 Path::new("."),
1122 )
1123 .unwrap();
1124 let artifact = compiler.compile_hir(&hir).unwrap();
1125 let rule = artifact.wir.rules.first().unwrap();
1126 assert!(matches!(
1127 rule.actions.first(),
1128 Some(workshop_rs::Action::While { .. })
1129 ));
1130 assert!(artifact.emitted.contains("While(True);"));
1131 }
1132
1133 #[test]
1134 fn expanded_control_flow_actions_keep_their_originating_spans() {
1135 let compiler = Compiler::new().unwrap();
1136 let hir = crate::compile(
1137 "globalvar value = 1\nrule \"if\":\n @Event global\n if true:\n wait(1)\n",
1138 "control-flow-provenance.opy",
1139 Path::new("."),
1140 )
1141 .unwrap();
1142 let artifact = compiler.compile_hir(&hir).unwrap();
1143
1144 assert_eq!(artifact.wir.action_span(0, 0).unwrap().start.line, 1);
1145 assert_eq!(
1146 artifact
1147 .wir
1148 .action_argument_span(0, 0, 0)
1149 .unwrap()
1150 .start
1151 .line,
1152 1
1153 );
1154 assert_eq!(artifact.wir.rules[1].actions.len(), 3);
1155 assert_eq!(artifact.wir.action_span(1, 0).unwrap().start.line, 4);
1156 assert_eq!(artifact.wir.action_span(1, 1).unwrap().start.line, 5);
1157 assert_eq!(
1158 artifact
1159 .wir
1160 .action_argument_span(1, 1, 0)
1161 .unwrap()
1162 .start
1163 .line,
1164 5
1165 );
1166 assert_eq!(artifact.wir.action_span(1, 2).unwrap().start.line, 4);
1167 }
1168
1169 #[test]
1170 fn range_argument_provenance_uses_canonical_positions() {
1171 let compiler = Compiler::new().unwrap();
1172 let hir = crate::compile(
1173 "globalvar value\nrule \"range\":\n @Event global\n for value in range(3):\n wait(1)\n",
1174 "range-provenance.opy",
1175 Path::new("."),
1176 )
1177 .unwrap();
1178 let artifact = compiler.compile_hir(&hir).unwrap();
1179
1180 assert_eq!(artifact.wir.action_span(0, 0).unwrap().start.line, 4);
1181 assert!(artifact.wir.action_argument_span(0, 0, 0).is_none());
1182 assert_eq!(
1183 artifact
1184 .wir
1185 .action_argument_span(0, 0, 1)
1186 .unwrap()
1187 .start
1188 .line,
1189 4
1190 );
1191 assert!(artifact.wir.action_argument_span(0, 0, 2).is_none());
1192 }
1193
1194 #[test]
1195 fn structural_subroutines_lower_to_canonical_wir() {
1196 let compiler = Compiler::new().unwrap();
1197 let hir = crate::compile(
1198 "globalvar score\nsubroutine showStatus\ndef showStatus():\n @Name \"Friendly\"\n @SuppressWarnings unusedVariable\n disableInspector()\nrule \"caller\":\n @Event global\n showStatus()\n",
1199 "structure.opy",
1200 Path::new("."),
1201 )
1202 .unwrap();
1203 let artifact = compiler.compile_hir(&hir).unwrap();
1204 let subroutine = artifact.wir.subroutines.first().unwrap();
1205 assert_eq!(subroutine.name, "showStatus");
1206 assert_eq!(artifact.wir.rules.len(), 2);
1207 let subroutine_rule = artifact.wir.rules.first().unwrap();
1208 let workshop_rs::Event::Subroutine(subroutine_name) = &subroutine_rule.event else {
1209 panic!("expected a subroutine event");
1210 };
1211 assert_eq!(subroutine_name, "showStatus");
1212 assert!(matches!(
1213 artifact.wir.rules.get(1).unwrap().actions.first(),
1214 Some(workshop_rs::Action::CallSubroutine { .. })
1215 ));
1216 assert!(artifact.emitted.contains("Subroutine Friendly"));
1217 }
1218
1219 #[test]
1220 fn player_event_filters_resolve_through_canonical_catalog() {
1221 let compiler = Compiler::new().unwrap();
1222 let hir = crate::compile(
1223 "rule \"joined\":\n @Event playerJoined\n @Team 1\n @Slot 2\n disableInspector()\n",
1224 "filters.opy",
1225 Path::new("."),
1226 )
1227 .unwrap();
1228 let artifact = compiler.compile_hir(&hir).unwrap();
1229 assert!(matches!(
1230 &artifact.wir.rules.first().unwrap().event,
1231 workshop_rs::Event::Player {
1232 kind: workshop_rs::PlayerEventKind::Joined,
1233 team: workshop_rs::EventTeam::Team1,
1234 target: workshop_rs::EventTarget::Slot(2),
1235 }
1236 ));
1237 assert!(artifact.emitted.contains("Player Joined Match;"));
1238 }
1239
1240 #[test]
1241 fn hero_event_filters_accept_legacy_aliases() {
1242 let compiler = Compiler::new().unwrap();
1243 let hir = crate::compile(
1244 "rule \"hero\":\n @Event eachPlayer\n @Hero soldier\n disableInspector()\n",
1245 "hero-filter.opy",
1246 Path::new("."),
1247 )
1248 .unwrap();
1249 let artifact = compiler.compile_hir(&hir).unwrap();
1250 assert!(matches!(
1251 &artifact.wir.rules.first().unwrap().event,
1252 workshop_rs::Event::EachPlayerWithFilters {
1253 target: workshop_rs::EventTarget::Hero(hero),
1254 ..
1255 } if hero == "SOLDIER_76"
1256 ));
1257 }
1258
1259 #[test]
1260 fn explicit_indices_are_reserved_before_deterministic_allocation() {
1261 let compiler = Compiler::new().unwrap();
1262 let hir = crate::compile(
1263 "globalvar first\nglobalvar reserved 0\nglobalvar next\nrule \"indices\":\n @Event global\n disableInspector()\n",
1264 "indices.opy",
1265 Path::new("."),
1266 )
1267 .unwrap();
1268 let artifact = compiler.compile_hir(&hir).unwrap();
1269 let by_name = artifact
1270 .wir
1271 .global_variables
1272 .iter()
1273 .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1274 .collect::<std::collections::BTreeMap<_, _>>();
1275 assert_eq!(
1276 by_name,
1277 std::collections::BTreeMap::from([("first", 1), ("reserved", 0), ("next", 2)])
1278 );
1279 let indices = artifact
1281 .wir
1282 .global_variables
1283 .iter()
1284 .map(|variable| variable.index.unwrap())
1285 .collect::<Vec<_>>();
1286 assert_eq!(indices, vec![0, 1, 2]);
1287 }
1288
1289 #[test]
1290 fn implicit_default_variables_use_reference_fixed_slots() {
1291 let compiler = Compiler::new().unwrap();
1292 let hir = crate::compile(
1293 r#"
1294globalvar timer
1295globalvar extra 5
1296
1297rule "implicit":
1298 @Event global
1299 A = timer + 1
1300 B = A
1301 B += 2
1302 A[0] = 7
1303 DX = B * A
1304"#,
1305 "implicit.opy",
1306 Path::new("."),
1307 )
1308 .unwrap();
1309 let artifact = compiler.compile_hir(&hir).unwrap();
1310 let globals = artifact
1311 .wir
1312 .global_variables
1313 .iter()
1314 .map(|variable| (variable.name.clone(), variable.index.unwrap()))
1315 .collect::<Vec<_>>();
1316 assert_eq!(
1321 globals,
1322 vec![
1323 ("A".to_string(), 0),
1324 ("B".to_string(), 1),
1325 ("timer".to_string(), 2),
1326 ("extra".to_string(), 5),
1327 ("DX".to_string(), 127),
1328 ]
1329 );
1330 assert!(
1331 artifact
1332 .emitted
1333 .contains("Set Global Variable(A, Add(Global.timer, 1));")
1334 );
1335 assert!(
1336 artifact
1337 .emitted
1338 .contains("Set Global Variable(B, Global.A);")
1339 );
1340 assert!(
1341 artifact
1342 .emitted
1343 .contains("Modify Global Variable(B, Add, 2);")
1344 );
1345 assert!(
1346 artifact
1347 .emitted
1348 .contains("Set Global Variable At Index(A, 0, 7);")
1349 );
1350 assert!(
1351 artifact
1352 .emitted
1353 .contains("Set Global Variable(DX, Multiply(Global.B, Global.A));")
1354 );
1355 }
1356
1357 #[test]
1358 fn implicit_default_variable_slot_collision_is_source_attributed() {
1359 let compiler = Compiler::new().unwrap();
1360 let hir = crate::compile(
1361 "globalvar x 0\nrule \"collision\":\n @Event global\n x = 1\n A = 2\n",
1362 "collision.opy",
1363 Path::new("."),
1364 )
1365 .unwrap();
1366 let error = match compiler.compile_hir(&hir) {
1367 Ok(_) => panic!("slot collision unexpectedly succeeded"),
1368 Err(error) => error,
1369 };
1370 assert_eq!(error.diagnostic.code, "index-collision");
1371 assert_eq!(error.diagnostic.span.unwrap().start.line, 5);
1372 assert!(error.diagnostic.message.contains("'A' and 'x'"));
1373 }
1374
1375 #[test]
1376 fn implicit_default_player_variables_use_independent_reference_slots() {
1377 let compiler = Compiler::new().unwrap();
1378 let hir = crate::compile(
1379 r#"
1380playervar declaredPlayer
1381
1382rule "implicit player variables":
1383 @Event eachPlayer
1384 A = 1
1385 eventPlayer.A = 1
1386 eventPlayer.A += 2
1387 eventPlayer.E = eventPlayer.A
1388 eventPlayer.DX = eventPlayer.E
1389 eventPlayer.declaredPlayer = eventPlayer.A
1390"#,
1391 "implicit-player.opy",
1392 Path::new("."),
1393 )
1394 .unwrap();
1395 let artifact = compiler.compile_hir(&hir).unwrap();
1396 let globals = artifact
1397 .wir
1398 .global_variables
1399 .iter()
1400 .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1401 .collect::<std::collections::BTreeMap<_, _>>();
1402 let players = artifact
1403 .wir
1404 .player_variables
1405 .iter()
1406 .map(|variable| (variable.name.as_str(), variable.index.unwrap()))
1407 .collect::<std::collections::BTreeMap<_, _>>();
1408 assert_eq!(globals.get("A"), Some(&0));
1409 assert_eq!(players.get("A"), Some(&0));
1410 assert_eq!(players.get("declaredPlayer"), Some(&1));
1411 assert_eq!(players.get("E"), Some(&4));
1412 assert_eq!(players.get("DX"), Some(&127));
1413 assert!(
1414 artifact
1415 .emitted
1416 .contains("Set Player Variable(Event Player, A, 1);")
1417 );
1418 assert!(
1419 artifact
1420 .emitted
1421 .contains("Modify Player Variable(Event Player, A, Add, 2);")
1422 );
1423 assert!(
1424 artifact
1425 .emitted
1426 .contains("Set Player Variable(Event Player, E, (Event Player).A);")
1427 );
1428 }
1429
1430 #[test]
1431 fn implicit_default_player_slot_collision_is_source_attributed() {
1432 let compiler = Compiler::new().unwrap();
1433 let hir = crate::compile(
1434 "playervar declared 0\nrule \"collision\":\n @Event eachPlayer\n eventPlayer.A = 1\n",
1435 "player-collision.opy",
1436 Path::new("."),
1437 )
1438 .unwrap();
1439 let error = match compiler.compile_hir(&hir) {
1440 Ok(_) => panic!("player slot collision unexpectedly succeeded"),
1441 Err(error) => error,
1442 };
1443 assert_eq!(error.diagnostic.code, "index-collision");
1444 assert!(
1445 error
1446 .diagnostic
1447 .message
1448 .contains("player variables 'A' and 'declared'")
1449 );
1450 assert_eq!(error.diagnostic.span.unwrap().start.line, 4);
1451 }
1452
1453 #[test]
1454 fn power_augmented_assignment_lowers_from_source() {
1455 let compiler = Compiler::new().unwrap();
1456 let hir = crate::compile(
1457 "globalvar g\nrule \"power\":\n @Event global\n g = 2\n g **= 3\n",
1458 "power.opy",
1459 Path::new("."),
1460 )
1461 .unwrap();
1462 let artifact = compiler.compile_hir(&hir).unwrap();
1463 assert!(artifact.emitted.contains("Set Global Variable(g, 2);"));
1464 assert!(
1465 artifact
1466 .emitted
1467 .contains("Modify Global Variable(g, Raise To Power, 3);")
1468 );
1469 }
1470
1471 #[test]
1472 fn opy_hex_numbers_are_normalized_at_the_wir_boundary() {
1473 let compiler = Compiler::new().unwrap();
1474 let hir = crate::compile(
1475 "globalvar large = 0x124BC\nglobalvar small = 0x124\nglobalvar scientific = 1e10\n",
1476 "numbers.opy",
1477 Path::new("."),
1478 )
1479 .unwrap();
1480 let artifact = compiler.compile_hir(&hir).unwrap();
1481 assert!(
1482 artifact
1483 .emitted
1484 .contains("Set Global Variable(large, 74940);")
1485 );
1486 assert!(
1487 artifact
1488 .emitted
1489 .contains("Set Global Variable(small, 292);")
1490 );
1491 assert!(
1492 artifact
1493 .emitted
1494 .contains("Set Global Variable(scientific, 10000000000);")
1495 );
1496 assert!(!artifact.emitted.contains("0x124BC"));
1497 assert!(!artifact.emitted.contains("0x124"));
1498 }
1499
1500 #[test]
1501 fn literal_dict_lookup_lowers_to_the_selected_value() {
1502 let compiler = Compiler::new().unwrap();
1503 let hir = crate::compile(
1504 "globalvar total\nrule \"negative\":\n @Event global\n total = {\"a\": 1, \"b\": 2}[\"a\"]\n",
1505 "negative.opy",
1506 Path::new("."),
1507 )
1508 .unwrap();
1509 let artifact = compiler
1510 .compile_hir(&hir)
1511 .expect("literal dict lookup should lower");
1512 assert!(artifact.emitted.contains("Set Global Variable(total, 1);"));
1513 }
1514
1515 #[test]
1516 fn auto_allocation_fills_free_slots_below_early_explicit_indices() {
1517 let compiler = Compiler::new().unwrap();
1518 let hir = crate::compile(
1519 r#"
1520globalvar reserved 5
1521globalvar auto1
1522globalvar auto2
1523
1524rule "allocation":
1525 @Event global
1526 auto1 = 1
1527 auto2 = 2
1528 B = 3
1529"#,
1530 "allocation.opy",
1531 Path::new("."),
1532 )
1533 .unwrap();
1534 let artifact = compiler.compile_hir(&hir).unwrap();
1535 let by_name = artifact
1536 .wir
1537 .global_variables
1538 .iter()
1539 .map(|variable| (variable.name.clone(), variable.index.unwrap()))
1540 .collect::<std::collections::BTreeMap<_, _>>();
1541 assert_eq!(
1546 by_name,
1547 std::collections::BTreeMap::from([
1548 ("B".to_string(), 1),
1549 ("auto1".to_string(), 0),
1550 ("auto2".to_string(), 2),
1551 ("reserved".to_string(), 5),
1552 ])
1553 );
1554 }
1555
1556 #[test]
1557 fn power_expressions_lower_through_the_canonical_contract() {
1558 let compiler = Compiler::new().unwrap();
1559 let hir = crate::compile(
1560 "globalvar a = [2, 4]\nglobalvar out\nrule \"power\":\n @Event global\n out = a ** 2\n a **= 2\n a[0] **= 2\n",
1561 "power.opy",
1562 Path::new("."),
1563 )
1564 .unwrap();
1565 let artifact = compiler.compile_hir(&hir).unwrap();
1566 assert!(
1567 artifact
1568 .emitted
1569 .contains("Set Global Variable(out, Raise To Power(Global.a, 2));")
1570 );
1571 assert!(
1572 artifact
1573 .emitted
1574 .contains("Modify Global Variable(a, Raise To Power, 2);")
1575 );
1576 assert!(
1577 artifact
1578 .emitted
1579 .contains("Modify Global Variable At Index(a, 0, Raise To Power, 2);")
1580 );
1581 }
1582
1583 #[test]
1584 fn unsupported_rule_metadata_is_explicit_and_source_attributed() {
1585 let compiler = Compiler::new().unwrap();
1586 let hir = crate::compile(
1587 "rule \"metadata\":\n @Event global\n @NewPage \"section\"\n disableInspector()\n",
1588 "metadata.opy",
1589 Path::new("."),
1590 )
1591 .unwrap();
1592 let error = match compiler.compile_hir(&hir) {
1593 Ok(_) => panic!("unsupported metadata unexpectedly succeeded"),
1594 Err(error) => error,
1595 };
1596 assert_eq!(error.diagnostic.code, "unsupported-integration-surface");
1597 assert_eq!(error.diagnostic.span.unwrap().start.line, 3);
1598 }
1599
1600 #[test]
1601 fn compiler_structure_matches_the_pinned_oracle() {
1602 let compiler = Compiler::new().unwrap();
1603 let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
1604 .join("tests/fixtures/corpus/synthetic/compiler-structure");
1605 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
1606 let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
1607 let artifact = compiler.compile_hir(&hir).unwrap();
1608 let oracle: serde_json::Value =
1609 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
1610 .unwrap();
1611 let oracle_workshop = oracle["compile"]["workshop"].as_str().unwrap();
1612 let oracle_wir = workshop_rs::parser::parse(
1613 oracle_workshop,
1614 &Catalog::builtin().unwrap(),
1615 &Locale::new("en-US"),
1616 )
1617 .unwrap();
1618 assert!(workshop_rs::roundtrip::equivalent(
1619 &artifact.wir,
1620 &oracle_wir
1621 ));
1622
1623 assert!(oracle_workshop.contains("0: reserved"));
1624 assert!(oracle_workshop.contains("1: first"));
1625 assert!(oracle_workshop.contains("2: explicit"));
1626 assert!(oracle_workshop.contains("3: next"));
1627 assert!(oracle_workshop.contains("0: helper"));
1628 assert!(oracle_workshop.contains("Subroutine;\n helper;"));
1629 assert!(oracle_workshop.contains("Player Joined Match;\n Team 1;\n Slot 2;"));
1630
1631 let indices = artifact
1632 .wir
1633 .global_variables
1634 .iter()
1635 .map(|variable| variable.index.unwrap())
1636 .collect::<Vec<_>>();
1637 assert_eq!(indices, vec![0, 1, 2, 3]);
1638 assert_eq!(artifact.wir.subroutines.first().unwrap().name, "helper");
1639 assert!(artifact.emitted.contains("[Source] renamed helper"));
1640 assert!(matches!(
1641 artifact.wir.rules.get(1).unwrap().event,
1642 workshop_rs::Event::Player {
1643 kind: workshop_rs::PlayerEventKind::Joined,
1644 team: workshop_rs::EventTeam::Team1,
1645 target: workshop_rs::EventTarget::Slot(2),
1646 }
1647 ));
1648 }
1649
1650 #[test]
1651 fn assignments_and_modifications_lower_to_canonical_wir() {
1652 let compiler = Compiler::new().unwrap();
1653 let hir = crate::compile(
1654 r#"
1655globalvar g1
1656globalvar g2
1657playervar p1
1658playervar p2 = [1, 2, 3]
1659
1660rule "assignments":
1661 @Event eachPlayer
1662 g1 = 10
1663 g1 += 5
1664 g1 -= 2
1665 g1 *= 3
1666 g1 /= 2
1667 g1 %= 4
1668 g2 = [1, 2, 3]
1669 g2[0] = 99
1670 g2[1] += 1
1671 eventPlayer.p1 = 42
1672 eventPlayer.p1 += 8
1673 eventPlayer.p1 *= 2
1674 eventPlayer.p2[2] = 7
1675 eventPlayer.p2[0] -= 3
1676"#,
1677 "assign.opy",
1678 Path::new("."),
1679 )
1680 .unwrap();
1681 let artifact = compiler.compile_hir(&hir).unwrap();
1682 assert!(artifact.emitted.contains("Set Global Variable(g1, 10);"));
1683 assert!(
1684 artifact
1685 .emitted
1686 .contains("Modify Global Variable(g1, Add, 5);")
1687 );
1688 assert!(
1689 artifact
1690 .emitted
1691 .contains("Modify Global Variable(g1, Subtract, 2);")
1692 );
1693 assert!(
1694 artifact
1695 .emitted
1696 .contains("Modify Global Variable(g1, Multiply, 3);")
1697 );
1698 assert!(
1699 artifact
1700 .emitted
1701 .contains("Modify Global Variable(g1, Divide, 2);")
1702 );
1703 assert!(
1704 artifact
1705 .emitted
1706 .contains("Modify Global Variable(g1, Modulo, 4);")
1707 );
1708 assert!(
1709 artifact
1710 .emitted
1711 .contains("Set Global Variable At Index(g2, 0, 99);")
1712 );
1713 assert!(
1714 artifact
1715 .emitted
1716 .contains("Modify Global Variable At Index(g2, 1, Add, 1);")
1717 );
1718 assert!(
1719 artifact
1720 .emitted
1721 .contains("Set Player Variable(Event Player, p1, 42);")
1722 );
1723 assert!(
1724 artifact
1725 .emitted
1726 .contains("Modify Player Variable(Event Player, p1, Add, 8);")
1727 );
1728 assert!(
1729 artifact
1730 .emitted
1731 .contains("Modify Player Variable(Event Player, p1, Multiply, 2);")
1732 );
1733 assert!(
1734 artifact
1735 .emitted
1736 .contains("Set Player Variable At Index((Event Player).p2, 2, 7);")
1737 );
1738 assert!(
1739 artifact
1740 .emitted
1741 .contains("Modify Player Variable At Index((Event Player).p2, 0, Subtract, 3);")
1742 );
1743
1744 let rule = artifact.wir.rules.get(1).unwrap();
1745 assert!(matches!(
1746 rule.actions.first(),
1747 Some(workshop_rs::Action::SetGlobalVariable { variable, .. }) if variable == "g1"
1748 ));
1749 assert!(matches!(
1750 rule.actions.get(7),
1751 Some(workshop_rs::Action::Call { .. })
1752 ));
1753 }
1754
1755 #[test]
1756 fn expressions_and_values_lower_to_canonical_wir() {
1757 let compiler = Compiler::new().unwrap();
1758 let hir = crate::compile(
1759 r#"
1760enum Consts:
1761 BASE
1762
1763globalvar total
1764globalvar arr = [1, 2, 3]
1765globalvar pos = vect(1, 2, 3)
1766
1767rule "expressions":
1768 @Event global
1769 @Condition total == 0
1770 @Condition not (pos == vect(0, 0, 0))
1771 @Condition 2 in arr
1772 total = Consts.BASE + arr[1] * 2 - (10 / 2) + (5 % 2)
1773 print("Total: {}".format(total))
1774 debug(pos)
1775"#,
1776 "expr.opy",
1777 Path::new("."),
1778 )
1779 .unwrap();
1780 let artifact = compiler.compile_hir(&hir).unwrap();
1781 assert!(artifact.emitted.contains("Global.total == 0;"));
1782 assert!(artifact.emitted.contains("Global.pos != Vector(0, 0, 0);"));
1785 assert!(
1786 artifact
1787 .emitted
1788 .contains("Array Contains(Global.arr, 2) == True;")
1789 );
1790 assert!(
1791 artifact
1792 .emitted
1793 .contains("Custom String(\"Total: {0}\", Global.total)")
1794 );
1795 }
1796
1797 #[test]
1798 fn pass_is_supported_as_source_level_noop() {
1799 let compiler = Compiler::new().unwrap();
1800 let hir = crate::compile(
1801 r#"
1802subroutine emptySub
1803
1804def emptySub():
1805 pass
1806
1807rule "empty rule":
1808 @Event global
1809 pass
1810"#,
1811 "pass.opy",
1812 Path::new("."),
1813 )
1814 .unwrap();
1815 let artifact = compiler.compile_hir(&hir).unwrap();
1816 let rule0 = artifact.wir.rules.first().unwrap();
1817 assert!(rule0.actions.is_empty());
1818 let rule1 = artifact.wir.rules.get(1).unwrap();
1819 assert!(rule1.actions.is_empty());
1820 }
1821
1822 #[test]
1823 fn variable_initializers_synthesize_initialize_rules() {
1824 let compiler = Compiler::new().unwrap();
1825 let hir = crate::compile(
1826 r#"
1827globalvar j = 5
1828globalvar h = 0
1829globalvar k = 0.0
1830playervar p = 7
1831playervar q = 0
1832
1833rule "main":
1834 @Event global
1835 disableInspector()
1836"#,
1837 "init.opy",
1838 Path::new("."),
1839 )
1840 .unwrap();
1841 let artifact = compiler.compile_hir(&hir).unwrap();
1842 assert_eq!(
1843 artifact.wir.rules.first().unwrap().name,
1844 "Initialize global variables"
1845 );
1846 assert_eq!(
1847 artifact.wir.rules.get(1).unwrap().name,
1848 "Initialize player variables"
1849 );
1850 assert_eq!(artifact.wir.rules.get(2).unwrap().name, "main");
1851 assert!(artifact.emitted.contains("Set Global Variable(j, 5);"));
1852 assert!(artifact.emitted.contains("Set Global Variable(k, 0);"));
1853 assert!(!artifact.emitted.contains("Set Global Variable(h,"));
1854 assert!(
1855 artifact
1856 .emitted
1857 .contains("Set Player Variable(Event Player, p, 7);")
1858 );
1859 assert!(
1860 !artifact
1861 .emitted
1862 .contains("Set Player Variable(Event Player, q,")
1863 );
1864 }
1865
1866 #[test]
1867 fn settings_lower_through_workshop_owned_emission() {
1868 let compiler = Compiler::new().unwrap();
1869 let fixture =
1870 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/corpus/synthetic/settings");
1871 let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap();
1872 let hir = crate::compile(&source, "source.opy", &fixture).unwrap();
1873 let artifact = compiler.compile_hir(&hir).unwrap();
1874 let oracle: serde_json::Value =
1875 serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap())
1876 .unwrap();
1877 let expected = oracle["compile"]["workshop"]
1878 .as_str()
1879 .unwrap()
1880 .split("\n\nrule")
1881 .next()
1882 .unwrap();
1883 let actual = artifact.emitted.split("\n\nrule").next().unwrap();
1884 let oracle_wir = workshop_rs::parser::parse(
1885 oracle["compile"]["workshop"].as_str().unwrap(),
1886 &Catalog::builtin().unwrap(),
1887 &Locale::new("en-US"),
1888 )
1889 .unwrap();
1890 assert!(workshop_rs::roundtrip::equivalent(
1891 &artifact.wir,
1892 &oracle_wir
1893 ));
1894 assert_eq!(
1895 normalize_workshop_structural_whitespace(actual),
1896 normalize_workshop_structural_whitespace(expected)
1897 );
1898 }
1899
1900 #[test]
1901 fn unsupported_locale_has_no_fabricated_source_span() {
1902 let compiler = Compiler::new().unwrap();
1903 let hir = crate::compile(
1904 "#!translations en\nrule \"r\":\n @Event global\n pass\n",
1905 "locale.opy",
1906 Path::new("."),
1907 )
1908 .unwrap();
1909 let error = match compiler.compile_hir_with_locale(&hir, &Locale::new("xx-XX")) {
1910 Ok(_) => panic!("unsupported locale unexpectedly compiled"),
1911 Err(error) => error,
1912 };
1913 assert_eq!(error.diagnostic.code, "locale-unsupported");
1914 assert_eq!(error.diagnostic.span, None);
1915 }
1916
1917 #[test]
1918 fn locale_selection_emits_catalog_localized_workshop() {
1919 let compiler = Compiler::new().unwrap();
1920 let hir = crate::compile(
1921 "rule \"locale\":\n @Event global\n disableInspector()\n",
1922 "locale.opy",
1923 Path::new("."),
1924 )
1925 .unwrap();
1926 let artifact = compiler
1927 .compile_hir_with_locale(&hir, &Locale::new("zh-CN"))
1928 .unwrap();
1929 assert!(artifact.emitted.contains("规则 (\"locale\")"));
1930 assert!(artifact.emitted.contains("禁用查看器录制"));
1931 }
1932
1933 #[test]
1934 fn unsupported_output_directives_fail_at_their_source_anchor() {
1935 let compiler = Compiler::new().unwrap();
1936 let hir = crate::compile(
1937 "#!writeToOutputFile\nrule \"r\":\n @Event global\n pass\n",
1938 "directives.opy",
1939 Path::new("."),
1940 )
1941 .unwrap();
1942 let error = match compiler.compile_hir(&hir) {
1943 Ok(_) => panic!("backend directive unexpectedly compiled"),
1944 Err(error) => error,
1945 };
1946 assert_eq!(error.diagnostic.code, "backend-directive-unsupported");
1947 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
1948 }
1949
1950 #[test]
1951 fn optimizer_directives_remain_non_blocking_presentation_controls() {
1952 let compiler = Compiler::new().unwrap();
1953 let hir = crate::compile(
1954 "#!disableOptimizations\nrule \"r\":\n @Event global\n pass\n",
1955 "optimization.opy",
1956 Path::new("."),
1957 )
1958 .unwrap();
1959 compiler.compile_hir(&hir).unwrap();
1960 }
1961
1962 #[test]
1963 fn strict_optimization_cases_are_preserved_by_native_lowering() {
1964 let compiler = Compiler::new().unwrap();
1965 let hir = crate::compile(
1966 "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",
1967 "strict.opy",
1968 Path::new("."),
1969 )
1970 .unwrap();
1971 let artifact = compiler.compile_hir(&hir).unwrap();
1972 assert!(artifact.emitted.contains("Add(Global.A, 0)"));
1973 assert!(artifact.emitted.contains("Multiply(Global.A, 0)"));
1974 assert!(artifact.emitted.contains("Multiply(Global.A, 1)"));
1975 assert!(
1976 artifact
1977 .emitted
1978 .contains("Compare(Custom String(\"am\"), ==, Custom String(\"**\"))")
1979 );
1980 }
1981
1982 #[test]
1983 fn compression_alphabet_policy_uses_a_shared_global() {
1984 let compiler = Compiler::new().unwrap();
1985 let hir = crate::compile(
1986 "globalvar values = compressed([1, 2, 3])\n\n#!useVariableForCompressionAlphabet\nrule \"compression\":\n @Event global\n print(values)\n",
1987 "compression.opy",
1988 Path::new("."),
1989 )
1990 .unwrap();
1991 let artifact = compiler.compile_hir(&hir).unwrap();
1992 assert!(artifact.emitted.contains("127: __compressionAlphabet__"));
1993 assert!(artifact.emitted.contains("Global.__compressionAlphabet__"));
1994 }
1995
1996 #[test]
1997 fn replacement_directives_lower_when_size_optimization_is_active() {
1998 let compiler = Compiler::new().unwrap();
1999 let hir = crate::compile(
2000 "#!optimizeForSize\n#!replace0ByCapturePercentage\nrule \"r\":\n @Event global\n print(0)\n",
2001 "directives.opy",
2002 Path::new("."),
2003 )
2004 .unwrap();
2005 let artifact = compiler.compile_hir(&hir).unwrap();
2006 assert!(artifact.emitted.contains("Point Capture Percentage"));
2007 }
2008
2009 #[test]
2010 fn debug_element_count_emits_sorted_rule_condition_and_action_comments() {
2011 let compiler = Compiler::new().unwrap();
2012 let hir = crate::compile(
2013 "#!debugElementCount\nglobalvar value\nrule \"small\":\n @Event global\n @Condition value == 1\n print(1)\nrule \"large\":\n @Event global\n @Condition value == 1\n print(1)\n print(2)\n",
2014 "debug.opy",
2015 Path::new("."),
2016 )
2017 .unwrap();
2018 let artifact = compiler.compile_hir(&hir).unwrap();
2019 let report = artifact.wir.element_count(compiler.catalog).unwrap();
2020 let small = report
2021 .rules
2022 .iter()
2023 .find(|rule| rule.name == "small")
2024 .unwrap();
2025 let large = report
2026 .rules
2027 .iter()
2028 .find(|rule| rule.name == "large")
2029 .unwrap();
2030 let large_summary = artifact.emitted.find(" 32: rule \"large\"").unwrap();
2031 let small_summary = artifact.emitted.find(" 18: rule \"small\"").unwrap();
2032 assert!(large_summary < small_summary);
2033 assert!(
2034 artifact
2035 .emitted
2036 .starts_with("/* Element count: (total 50)\n")
2037 );
2038 assert!(artifact.emitted.contains("//32 elements\nrule (\"large\")"));
2039 assert!(artifact.emitted.contains("//18 elements\nrule (\"small\")"));
2040 assert!(
2041 artifact
2042 .emitted
2043 .contains("Global.value == 1; // 3 elements")
2044 );
2045 assert_eq!(artifact.emitted.matches(" // 3 elements").count(), 2);
2046 assert_eq!(artifact.emitted.matches(" // 14 elements").count(), 3);
2047 let expected_comments = large
2048 .children
2049 .iter()
2050 .chain(&small.children)
2051 .filter(|node| {
2052 matches!(
2053 node.kind,
2054 workshop_rs::element_count::ElementNodeKind::Condition
2055 | workshop_rs::element_count::ElementNodeKind::Action
2056 )
2057 })
2058 .count();
2059 assert_eq!(artifact.emitted.matches(" // ").count(), expected_comments);
2060 }
2061
2062 #[test]
2063 fn setup_and_initialization_directives_change_forward_output() {
2064 let compiler = Compiler::new().unwrap();
2065 let hir = crate::compile(
2066 "#!setupTx\n#!disableInspector\n#!globalvarInitRuleName \"Init globals\"\n#!playervarInitRuleName \"Init players\"\nglobalvar value = 1\nplayervar playerValue = 1\nrule \"r\":\n @Event global\n print(\"<fgFF0000FF>ready</fg>\")\n",
2067 "directives.opy",
2068 Path::new("."),
2069 )
2070 .unwrap();
2071 let artifact = compiler.compile_hir(&hir).unwrap();
2072 assert!(artifact.emitted.contains("OverPy <"));
2073 assert!(artifact.emitted.contains("Disable inspector"));
2074 assert!(artifact.emitted.contains("Init globals"));
2075 assert!(artifact.emitted.contains("Init players"));
2076 assert!(artifact.emitted.contains("__holygrail__"));
2077 assert!(
2078 artifact
2079 .emitted
2080 .contains("Custom String(\"{0}fgFF0000FF>ready{0}/fg>\", Global.__holygrail__)")
2081 );
2082 }
2083
2084 #[test]
2085 fn exclude_variables_directive_omits_variable_declarations() {
2086 let compiler = Compiler::new().unwrap();
2087 let hir = crate::compile(
2088 "#!excludeVariablesInCompilation\nglobalvar value 0\nrule \"r\":\n @Event global\n pass\n",
2089 "directives.opy",
2090 Path::new("."),
2091 )
2092 .unwrap();
2093 let artifact = compiler.compile_hir(&hir).unwrap();
2094 assert!(!artifact.wir.global_variables.is_empty());
2095 assert!(
2096 !artifact.emitted.contains("variables {"),
2097 "{}",
2098 artifact.emitted
2099 );
2100 }
2101
2102 #[test]
2103 fn post_compile_hook_receives_exact_emitted_workshop() {
2104 let compiler = Compiler::new().unwrap();
2105 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2106 let source = "#!postCompileHook \"hook.js\"\n\nrule \"setup\":\n pass\n";
2107 let artifact = compiler
2108 .compile_source_with_locale(source, "hook.opy", &root, &Locale::new("en-US"))
2109 .unwrap();
2110 assert!(artifact.emitted.contains("rule (\"setup\")"));
2111 assert!(artifact.final_output.contains("rule (\"transformed\")"));
2112 assert_ne!(artifact.final_output, artifact.emitted);
2113 }
2114
2115 #[test]
2116 fn post_compile_hook_failure_keeps_script_provenance_and_directive_anchor() {
2117 let compiler = Compiler::new().unwrap();
2118 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/macros");
2119 let source = "#!postCompileHook \"hook-boom.js\"\n\nrule \"setup\":\n pass\n";
2120 let error = match compiler.compile_source_with_locale(
2121 source,
2122 "hook.opy",
2123 &root,
2124 &Locale::new("en-US"),
2125 ) {
2126 Ok(_) => panic!("failing post-compile hook unexpectedly compiled"),
2127 Err(error) => error,
2128 };
2129 assert_eq!(error.diagnostic.code, "post-compile-hook");
2130 assert_eq!(error.diagnostic.span.unwrap().start.line, 1);
2131 let script = error.diagnostic.script.unwrap();
2132 assert_eq!(script.source_name.as_deref(), Some("hook-boom.js"));
2133 assert_eq!(script.line, Some(1));
2134 assert!(script.stack.unwrap().contains("hook-boom.js:1"));
2135 }
2136
2137 fn normalize_workshop_structural_whitespace(text: &str) -> String {
2138 let mut normalized = String::with_capacity(text.len());
2139 let mut quote = None;
2140 let mut escaped = false;
2141 for character in text.chars() {
2142 if let Some(delimiter) = quote {
2143 normalized.push(character);
2144 if escaped {
2145 escaped = false;
2146 } else if character == '\\' {
2147 escaped = true;
2148 } else if character == delimiter {
2149 quote = None;
2150 }
2151 } else if matches!(character, '\"' | '\'') {
2152 quote = Some(character);
2153 normalized.push(character);
2154 } else if !character.is_whitespace() {
2155 normalized.push(character);
2156 }
2157 }
2158 normalized
2159 }
2160
2161 #[test]
2162 fn settings_whitespace_normalization_preserves_quoted_values() {
2163 assert_ne!(
2164 normalize_workshop_structural_whitespace("Description: \"a b\""),
2165 normalize_workshop_structural_whitespace("Description: \"ab\"")
2166 );
2167 }
2168}