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