1use std::fmt;
2
3use workshop_rs::Program;
4use workshop_rs::catalog::{Catalog, Locale};
5use workshop_rs::program::{Action, Event, EventTarget, EventTeam, ModifyOp, Rule, Value};
6use workshop_rs::source::Span;
7
8use crate::lexer::is_identifier;
9use crate::manifest::{Function, FunctionKind, Manifest};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ReconstructIssue {
15 pub code: &'static str,
16 pub message: String,
17 pub span: Option<Span>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ReconstructError {
24 pub issues: Vec<ReconstructIssue>,
25}
26
27impl fmt::Display for ReconstructError {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 for (index, issue) in self.issues.iter().enumerate() {
30 if index > 0 {
31 writeln!(f)?;
32 }
33 let location = match issue.span {
34 Some(span) => format!(" at {}:{}", span.start.line, span.start.col),
35 None => String::new(),
36 };
37 write!(f, "{}: {}{location}", issue.code, issue.message)?;
38 }
39 Ok(())
40 }
41}
42
43impl std::error::Error for ReconstructError {}
44
45pub fn reconstruct(program: &Program) -> Result<String, ReconstructError> {
51 let manifest = match Manifest::builtin() {
52 Ok(manifest) => manifest,
53 Err(error) => {
54 return Err(ReconstructError {
55 issues: vec![ReconstructIssue {
56 code: "manifest-error",
57 message: format!(
58 "cannot load the OPY semantic compatibility manifest: {error}"
59 ),
60 span: None,
61 }],
62 });
63 }
64 };
65 let catalog = match Catalog::builtin() {
66 Ok(catalog) => catalog,
67 Err(error) => {
68 return Err(ReconstructError {
69 issues: vec![ReconstructIssue {
70 code: "catalog-error",
71 message: format!("cannot load the Workshop catalog: {error}"),
72 span: None,
73 }],
74 });
75 }
76 };
77 reconstruct_with(program, manifest, &catalog, &Locale::new("en-US"))
78}
79
80pub fn reconstruct_with(
85 program: &Program,
86 manifest: &Manifest,
87 catalog: &Catalog,
88 locale: &Locale,
89) -> Result<String, ReconstructError> {
90 let mut emitter = Emitter::new(program, manifest, catalog, locale);
91 emitter.run();
92 if emitter.issues.is_empty() {
93 Ok(emitter.out)
94 } else {
95 Err(ReconstructError {
96 issues: emitter.issues,
97 })
98 }
99}
100
101const RESERVED_NAMES: &[&str] = &[
104 "true",
105 "false",
106 "None",
107 "null",
108 "eventPlayer",
109 "rule",
110 "def",
111 "globalvar",
112 "playervar",
113 "subroutine",
114 "enum",
115 "macro",
116 "if",
117 "for",
118 "while",
119 "pass",
120 "elif",
121 "else",
122 "in",
123 "and",
124 "or",
125 "not",
126];
127
128const BINARY_OPS: &[&str] = &[
131 "+", "-", "*", "/", "%", "**", "==", "!=", "<", "<=", ">", ">=", "and", "or",
132];
133
134const DEDICATED_ACTION_NAMES: &[&str] = &["append"];
136const DEDICATED_VALUE_NAMES: &[&str] = &["vect", "range", "chase"];
137
138struct Emitter<'a> {
139 program: &'a Program,
140 manifest: &'a Manifest,
141 catalog: &'a Catalog,
142 locale: &'a Locale,
143 issues: Vec<ReconstructIssue>,
144 out: String,
145 subroutine_names: std::collections::HashSet<String>,
147}
148
149type IndexedRule<'a> = (usize, &'a Rule);
150
151struct RuleLayout<'a> {
152 global_init: Option<IndexedRule<'a>>,
153 player_init: Option<IndexedRule<'a>>,
154 sub_rules: Vec<IndexedRule<'a>>,
155 normal_rules: Vec<IndexedRule<'a>>,
156}
157
158impl<'a> Emitter<'a> {
159 fn new(
160 program: &'a Program,
161 manifest: &'a Manifest,
162 catalog: &'a Catalog,
163 locale: &'a Locale,
164 ) -> Self {
165 let subroutine_names = program
166 .subroutines
167 .iter()
168 .map(|subroutine| subroutine.name.clone())
169 .collect();
170 Emitter {
171 program,
172 manifest,
173 catalog,
174 locale,
175 issues: Vec::new(),
176 out: String::new(),
177 subroutine_names,
178 }
179 }
180
181 fn run(&mut self) {
182 self.validate_tables();
183 if self.issues.is_empty() {
184 let layout = self.classify_rules();
185 if self.issues.is_empty() {
186 self.emit_program(&layout);
187 }
188 }
189 }
190 fn issue(&mut self, code: &'static str, message: impl Into<String>, span: Option<Span>) {
193 self.issues.push(ReconstructIssue {
194 code,
195 message: message.into(),
196 span,
197 });
198 }
199
200 fn report_disabled_rule(&mut self, rule_index: usize, name: &str) {
201 self.issue(
202 "unsupported-disabled-rule",
203 format!("rule '{name}' is disabled; the OPY surface cannot express it"),
204 self.program.rule_span(rule_index),
205 );
206 }
207
208 fn validate_tables(&mut self) {
211 if self.program.settings.is_some() {
212 self.issue(
213 "unsupported-settings",
214 "custom-game-settings are outside the reconstruction surface",
215 None,
216 );
217 }
218 let mut previous_index: Option<u32> = None;
222 for (position, variable) in self.program.global_variables.iter().enumerate() {
223 let index = variable.index.unwrap_or(position as u32);
224 self.check_variable_name(&variable.name, None, "global variable");
225 self.check_duplicate_name(&variable.name, position, "global variable", None);
226 if let Some(previous) = previous_index {
227 if index < previous {
228 self.issue(
229 "unsupported-global-order",
230 format!(
231 "global variables must be in ascending index order \
232 (slot {} precedes slot {})",
233 previous, index
234 ),
235 None,
236 );
237 }
238 }
239 previous_index = Some(index);
240 }
241 for (position, variable) in self.program.player_variables.iter().enumerate() {
244 self.check_variable_name(&variable.name, None, "player variable");
245 self.check_duplicate_name(&variable.name, position, "player variable", None);
246 }
247 for (position, subroutine) in self.program.subroutines.iter().enumerate() {
252 self.check_variable_name(&subroutine.name, None, "subroutine");
253 self.check_duplicate_name(&subroutine.name, position, "subroutine", None);
254 let index = subroutine.index.unwrap_or(position as u32);
255 if index as usize != position {
256 self.issue(
257 "unsupported-subroutine-index",
258 format!(
259 "subroutine '{}' has index {} but the OPY surface requires \
260 table position {} (subroutine declarations cannot carry an index)",
261 subroutine.name, index, position
262 ),
263 None,
264 );
265 }
266 }
267 }
268
269 fn check_variable_name(&mut self, name: &str, span: Option<Span>, kind: &str) {
270 if !is_identifier(name) {
271 self.issue(
272 "unsupported-name",
273 format!(
274 "{kind} name '{name}' is not a valid OPY identifier on the \
275 reconstruction surface"
276 ),
277 span,
278 );
279 } else if RESERVED_NAMES.contains(&name) {
280 self.issue(
281 "unsupported-name",
282 format!(
283 "{kind} name '{name}' collides with an OPY keyword or literal \
284 and cannot be referenced on the reconstruction surface"
285 ),
286 span,
287 );
288 }
289 }
290
291 fn check_duplicate_name(
294 &mut self,
295 name: &str,
296 position: usize,
297 kind: &str,
298 span: Option<Span>,
299 ) {
300 let duplicate = match kind {
301 "global variable" => self
302 .program
303 .global_variables
304 .iter()
305 .enumerate()
306 .take(position)
307 .any(|(_, other)| other.name == name),
308 "player variable" => self
309 .program
310 .player_variables
311 .iter()
312 .enumerate()
313 .take(position)
314 .any(|(_, other)| other.name == name),
315 _ => self
316 .program
317 .subroutines
318 .iter()
319 .enumerate()
320 .take(position)
321 .any(|(_, other)| other.name == name),
322 };
323 if duplicate {
324 self.issue(
325 "unsupported-duplicate-name",
326 format!("duplicate {kind} name '{name}'"),
327 span,
328 );
329 }
330 }
331
332 fn classify_rules(&mut self) -> RuleLayout<'a> {
337 let rules: Vec<IndexedRule<'a>> = self.program.rules.iter().enumerate().collect();
338 let mut index = 0;
339 let mut global_init = None;
340 let mut player_init = None;
341 if let Some(&(rule_index, rule)) = rules.first() {
342 if rule.name == "Initialize global variables" {
343 let candidate = self.canonical_init(rule_index, rule, true);
344 if candidate.is_some() {
345 global_init = candidate;
346 index = 1;
347 }
348 } else if rule.name == "Initialize player variables" {
349 let candidate = self.canonical_init(rule_index, rule, false);
350 if candidate.is_some() {
351 player_init = candidate;
352 index = 1;
353 }
354 }
355 }
356 if index == 1 {
357 if let Some(&(rule_index, rule)) = rules.get(1) {
358 if rule.name == "Initialize player variables" && global_init.is_some() {
359 let candidate = self.canonical_init(rule_index, rule, false);
360 if candidate.is_some() {
361 player_init = candidate;
362 index = 2;
363 }
364 }
365 }
366 }
367
368 let mut sub_rules = Vec::new();
369 let mut normal_rules = Vec::new();
370 let mut in_sub_rules = true;
371 for (rule_index, rule) in rules.iter().copied().skip(index) {
372 match &rule.event {
373 Event::Subroutine(_) => {
374 if !in_sub_rules {
375 self.issue(
376 "unsupported-rule-order",
377 format!(
378 "subroutine-body rule '{}' appears after a normal rule; \
379 the frontend re-lowering emits subroutine rules first",
380 rule.name
381 ),
382 self.program.rule_span(rule_index),
383 );
384 }
385 if !rule.conditions.is_empty() {
386 self.issue(
387 "unsupported-rule-order",
388 format!(
389 "subroutine-body rule '{}' carries conditions; `def` \
390 bodies cannot express them",
391 rule.name
392 ),
393 self.program.rule_span(rule_index),
394 );
395 }
396 sub_rules.push((rule_index, rule));
397 }
398 _ => {
399 in_sub_rules = false;
400 normal_rules.push((rule_index, rule));
401 }
402 }
403 }
404
405 let mut expected = 0usize;
408 for (rule_index, rule) in &sub_rules {
409 let Event::Subroutine(subroutine) = &rule.event else {
410 continue;
411 };
412 if self
413 .program
414 .subroutines
415 .iter()
416 .position(|definition| definition.name == *subroutine)
417 != Some(expected)
418 {
419 self.issue(
420 "unsupported-rule-order",
421 format!(
422 "subroutine-body rules must appear in subroutine table order; \
423 '{}' is out of order",
424 rule.name
425 ),
426 self.program.rule_span(*rule_index),
427 );
428 }
429 expected += 1;
430 if let Some(definition) = self
431 .program
432 .subroutines
433 .iter()
434 .find(|definition| definition.name == *subroutine)
435 {
436 let expected_name = format!("Subroutine {}", definition.name);
437 if rule.name != expected_name {
438 self.issue(
439 "unsupported-rule-order",
440 format!(
441 "subroutine-body rule name '{}' does not match the def \
442 form '{}' the frontend synthesizes",
443 rule.name, expected_name
444 ),
445 self.program.rule_span(*rule_index),
446 );
447 }
448 }
449 }
450
451 RuleLayout {
452 global_init,
453 player_init,
454 sub_rules,
455 normal_rules,
456 }
457 }
458
459 fn canonical_init(
460 &mut self,
461 rule_index: usize,
462 rule: &'a Rule,
463 global: bool,
464 ) -> Option<IndexedRule<'a>> {
465 if rule.disabled {
466 return None;
467 }
468 let expected_name = if global {
469 "Initialize global variables"
470 } else {
471 "Initialize player variables"
472 };
473 let canonical_event = if global {
474 matches!(&rule.event, Event::Global)
475 } else {
476 matches!(&rule.event, Event::EachPlayer)
477 };
478 if !canonical_event {
479 return None;
480 }
481 if !rule.conditions.is_empty() {
482 self.issue(
483 "unsupported-init-rule",
484 format!(
485 "initializer rule '{expected_name}' carries conditions; the \
486 frontend synthesizes it from declarations with none"
487 ),
488 self.program.rule_span(rule_index),
489 );
490 return None;
491 }
492 for (action_index, action) in rule.actions.iter().enumerate() {
493 let set = matches!(
494 (global, action),
495 (true, Action::SetGlobalVariable { .. })
496 | (false, Action::SetPlayerVariable { .. })
497 );
498 if !set {
499 self.issue(
500 "unsupported-init-rule",
501 format!(
502 "initializer rule '{expected_name}' mixes non-Set actions; \
503 the frontend's synthesized initializer rule is all-Set"
504 ),
505 self.program.action_span(rule_index, action_index),
506 );
507 return None;
508 }
509 }
510 Some((rule_index, rule))
511 }
512
513 fn emit_program(&mut self, layout: &RuleLayout) {
516 let global_initializers = self.collect_initializers(layout.global_init, true);
517 let player_initializers = self.collect_initializers(layout.player_init, false);
518 self.check_initializer_slot(&global_initializers);
519
520 self.emit_variable_declarations(true, &global_initializers);
522 self.emit_variable_declarations(false, &player_initializers);
523 if self.program.subroutines.is_empty() {
524 self.out.push('\n');
525 } else {
526 for subroutine in self.program.subroutines.iter() {
527 self.out.push_str("subroutine ");
528 self.out.push_str(&subroutine.name);
529 self.out.push('\n');
530 }
531 self.out.push('\n');
532 }
533
534 for (rule_index, rule) in &layout.sub_rules {
536 if rule.disabled {
537 self.report_disabled_rule(*rule_index, &rule.name);
538 continue;
539 }
540 let Event::Subroutine(subroutine_name) = &rule.event else {
541 continue;
542 };
543 let Some(definition) = self
544 .program
545 .subroutines
546 .iter()
547 .find(|definition| definition.name == *subroutine_name)
548 else {
549 continue;
550 };
551 self.out.push_str("def ");
552 self.out.push_str(&definition.name);
553 self.out.push_str("():\n");
554 self.emit_actions(*rule_index, &rule.actions, 1);
555 self.out.push('\n');
556 }
557
558 for (rule_index, rule) in &layout.normal_rules {
560 if rule.disabled {
561 self.report_disabled_rule(*rule_index, &rule.name);
562 continue;
563 }
564 if rule.actions.is_empty() {
565 continue;
566 }
567 self.out.push_str("rule ");
568 self.emit_string_literal(&rule.name);
569 self.out.push_str(":\n");
570 match &rule.event {
571 Event::Global => self.out.push_str(" @Event global\n"),
572 Event::EachPlayer
573 | Event::EachPlayerWithFilters {
574 team: EventTeam::All,
575 target: EventTarget::All,
576 } => self.out.push_str(" @Event eachPlayer\n"),
577 Event::EachPlayerWithFilters { .. } | Event::Player { .. } => {
578 self.issue(
579 "unsupported-rule-event",
580 format!("rule '{}' uses an event outside the OPY surface", rule.name),
581 self.program.rule_span(*rule_index),
582 );
583 continue;
584 }
585 Event::Subroutine(_) => {
586 self.issue(
587 "unsupported-rule-order",
588 format!(
589 "rule '{}' has a subroutine event outside the def layout",
590 rule.name
591 ),
592 self.program.rule_span(*rule_index),
593 );
594 continue;
595 }
596 }
597 for (condition_index, condition) in rule.conditions.iter().enumerate() {
598 if condition.disabled {
599 self.issue(
600 "unsupported-disabled-condition",
601 "disabled conditions are outside the OPY reconstruction surface",
602 self.program.condition_span(*rule_index, condition_index),
603 );
604 continue;
605 }
606 self.out.push_str(" @Condition ");
607 self.emit_value(
608 &condition.value,
609 self.program.condition_span(*rule_index, condition_index),
610 );
611 self.out.push('\n');
612 }
613 self.emit_actions(*rule_index, &rule.actions, 1);
614 self.out.push('\n');
615 }
616 }
617
618 fn emit_variable_declarations(
619 &mut self,
620 global: bool,
621 initializers: &std::collections::HashMap<usize, (Value, Option<Span>)>,
622 ) {
623 let (kind, variables) = if global {
624 ("globalvar ", &self.program.global_variables)
625 } else {
626 ("playervar ", &self.program.player_variables)
627 };
628 for (position, variable) in variables.iter().enumerate() {
629 self.out.push_str(kind);
630 self.out.push_str(&variable.name);
631 match initializers.get(&position) {
632 Some(value) => {
633 self.out.push_str(" = ");
634 self.emit_initializer(&value.0, value.1);
635 }
636 None => {
637 self.out.push(' ');
638 self.out
639 .push_str(&variable.index.unwrap_or(position as u32).to_string());
640 }
641 }
642 self.out.push('\n');
643 }
644 }
645
646 fn collect_initializers(
650 &mut self,
651 initializer: Option<IndexedRule<'_>>,
652 global: bool,
653 ) -> std::collections::HashMap<usize, (Value, Option<Span>)> {
654 let mut initializers = std::collections::HashMap::new();
655 let Some((rule_index, rule)) = initializer else {
656 return initializers;
657 };
658 let mut previous: Option<usize> = None;
659 for (action_index, action) in rule.actions.iter().enumerate() {
660 let span = self.program.action_span(rule_index, action_index);
661 let (player, variable, value) = match (global, action) {
662 (true, Action::SetGlobalVariable { variable, value }) => (None, variable, value),
663 (
664 false,
665 Action::SetPlayerVariable {
666 player,
667 variable,
668 value,
669 },
670 ) => (Some(player), variable, value),
671 _ => continue,
672 };
673 if player.is_some_and(|player| !self.is_event_player(player)) {
674 self.issue(
675 "unsupported-init-rule",
676 "player initializer targets a non-event-player expression",
677 span,
678 );
679 }
680 let variables = if global {
681 &self.program.global_variables
682 } else {
683 &self.program.player_variables
684 };
685 let Some(variable_position) = variables
686 .iter()
687 .position(|declaration| declaration.name == *variable)
688 else {
689 self.issue(
690 "unsupported-dangling",
691 format!(
692 "unknown {} variable '{variable}'",
693 if global { "global" } else { "player" }
694 ),
695 span,
696 );
697 continue;
698 };
699 self.record_initializer_position(
700 &mut previous,
701 variable_position,
702 variable,
703 if global { "global" } else { "player" },
704 span,
705 );
706 initializers.insert(
707 variable_position,
708 (
709 value.clone(),
710 self.program.action_argument_span(
711 rule_index,
712 action_index,
713 usize::from(!global),
714 ),
715 ),
716 );
717 }
718 initializers
719 }
720
721 fn record_initializer_position(
722 &mut self,
723 previous: &mut Option<usize>,
724 position: usize,
725 variable: &str,
726 table: &str,
727 span: Option<Span>,
728 ) {
729 if previous.is_some_and(|previous| position <= previous) {
730 self.issue(
731 "unsupported-init-rule",
732 format!(
733 "initializer rule Sets '{variable}' out of {table} table order; \
734 the frontend synthesizes initializers in declaration order"
735 ),
736 span,
737 );
738 }
739 *previous = Some(position);
740 }
741
742 fn emit_initializer(&mut self, value: &Value, span: Option<Span>) {
746 if let Value::Number(number) = value {
747 if *number == 0.0 {
748 self.out.push_str("0.0");
749 return;
750 }
751 }
752 self.emit_value(value, span);
753 }
754
755 fn check_initializer_slot(
760 &mut self,
761 initializers: &std::collections::HashMap<usize, (Value, Option<Span>)>,
762 ) {
763 let mut taken: std::collections::HashSet<u32> = std::collections::HashSet::new();
764 for (position, variable) in self.program.global_variables.iter().enumerate() {
765 if initializers.contains_key(&position) {
766 let mut next_free = 0u32;
767 while taken.contains(&next_free) {
768 next_free += 1;
769 }
770 let index = variable.index.unwrap_or(position as u32);
771 if next_free != index {
772 self.issues.push(ReconstructIssue {
773 code: "unsupported-indexed-initializer",
774 message: format!(
775 "initializer-bearing global '{}' occupies slot {} but the \
776 OPY `globalvar name = value` form assigns the lowest free \
777 slot ({}) on re-lowering",
778 variable.name, index, next_free
779 ),
780 span: None,
781 });
782 }
783 taken.insert(next_free);
784 } else {
785 taken.insert(variable.index.unwrap_or(position as u32));
786 }
787 }
788 }
789
790 fn emit_actions(&mut self, rule_index: usize, actions: &[Action], level: usize) {
791 let mut position = 0;
792 self.emit_action_block(rule_index, actions, &mut position, level);
793 if position < actions.len() {
794 self.issue(
795 "unsupported-control-flow",
796 "unexpected control-flow marker in Workshop action sequence",
797 self.program.action_span(rule_index, position),
798 );
799 }
800 }
801
802 fn emit_action_block(
803 &mut self,
804 rule_index: usize,
805 actions: &[Action],
806 position: &mut usize,
807 level: usize,
808 ) {
809 while let Some(action) = actions.get(*position) {
810 match action {
811 Action::ElseIf { .. } | Action::Else | Action::End => return,
812 Action::If { condition } => {
813 let action_index = *position;
814 let span = self.program.action_span(rule_index, action_index);
815 self.out.push_str(&Self::indent(level));
816 self.out.push_str("if ");
817 self.emit_value(
818 condition,
819 self.program
820 .action_argument_span(rule_index, action_index, 0),
821 );
822 self.out.push_str(":\n");
823 *position += 1;
824 self.emit_action_block(rule_index, actions, position, level + 1);
825 while let Some(Action::ElseIf { condition }) = actions.get(*position) {
826 let action_index = *position;
827 self.out.push_str(&Self::indent(level));
828 self.out.push_str("elif ");
829 self.emit_value(
830 condition,
831 self.program
832 .action_argument_span(rule_index, action_index, 0),
833 );
834 self.out.push_str(":\n");
835 *position += 1;
836 self.emit_action_block(rule_index, actions, position, level + 1);
837 }
838 if matches!(actions.get(*position), Some(Action::Else)) {
839 *position += 1;
840 self.out.push_str(&Self::indent(level));
841 self.out.push_str("else:\n");
842 self.emit_action_block(rule_index, actions, position, level + 1);
843 }
844 if matches!(actions.get(*position), Some(Action::End)) {
845 *position += 1;
846 } else {
847 self.issue("unsupported-control-flow", "if action is missing End", span);
848 }
849 }
850 Action::While { condition } => {
851 let action_index = *position;
852 let span = self.program.action_span(rule_index, action_index);
853 self.out.push_str(&Self::indent(level));
854 self.out.push_str("while ");
855 self.emit_value(
856 condition,
857 self.program
858 .action_argument_span(rule_index, action_index, 0),
859 );
860 self.out.push_str(":\n");
861 *position += 1;
862 self.emit_action_block(rule_index, actions, position, level + 1);
863 self.require_end(rule_index, actions, position, "while", span);
864 }
865 Action::ForGlobalVariable {
866 variable,
867 start,
868 stop,
869 step,
870 } => {
871 let action_index = *position;
872 let span = self.program.action_span(rule_index, action_index);
873 let Some(declaration) = self
874 .program
875 .global_variables
876 .iter()
877 .find(|declaration| declaration.name == *variable)
878 else {
879 self.issue(
880 "unsupported-dangling",
881 format!("unknown loop variable '{variable}'"),
882 span,
883 );
884 *position += 1;
885 self.emit_action_block(rule_index, actions, position, level + 1);
886 self.require_end(rule_index, actions, position, "for", span);
887 continue;
888 };
889 self.out.push_str(&Self::indent(level));
890 self.out.push_str("for ");
891 self.out.push_str(&declaration.name);
892 self.out.push_str(" in range(");
893 for (arg_index, value) in [start, stop, step].into_iter().enumerate() {
894 if arg_index > 0 {
895 self.out.push_str(", ");
896 }
897 self.emit_value(
898 value,
899 self.program
900 .action_argument_span(rule_index, action_index, arg_index),
901 );
902 }
903 self.out.push_str("):\n");
904 *position += 1;
905 self.emit_action_block(rule_index, actions, position, level + 1);
906 self.require_end(rule_index, actions, position, "for", span);
907 }
908 Action::ForPlayerVariable { .. } => {
909 let action_index = *position;
910 let span = self.program.action_span(rule_index, action_index);
911 self.issue(
912 "unsupported-per-player-loop",
913 "For Player Variable is outside the reconstruction surface \
914 (the OPY `for` form binds a global variable)",
915 span,
916 );
917 *position += 1;
918 self.skip_action_block(actions, position);
919 }
920 _ => {
921 let action_index = *position;
922 *position += 1;
923 self.emit_action(rule_index, action_index, action, level);
924 }
925 }
926 }
927 }
928
929 fn require_end(
930 &mut self,
931 rule_index: usize,
932 actions: &[Action],
933 position: &mut usize,
934 kind: &str,
935 span: Option<Span>,
936 ) {
937 if matches!(actions.get(*position), Some(Action::End)) {
938 *position += 1;
939 } else {
940 self.issue(
941 "unsupported-control-flow",
942 format!("{kind} action is missing End"),
943 span.or_else(|| self.program.action_span(rule_index, *position)),
944 );
945 }
946 }
947
948 fn skip_action_block(&self, actions: &[Action], position: &mut usize) {
949 let mut depth = 1usize;
950 while let Some(action) = actions.get(*position) {
951 *position += 1;
952 match action {
953 Action::If { .. }
954 | Action::While { .. }
955 | Action::ForGlobalVariable { .. }
956 | Action::ForPlayerVariable { .. } => depth += 1,
957 Action::End => {
958 depth -= 1;
959 if depth == 0 {
960 break;
961 }
962 }
963 _ => {}
964 }
965 }
966 }
967
968 fn indent(level: usize) -> String {
969 " ".repeat(level)
970 }
971
972 fn emit_action(
973 &mut self,
974 rule_index: usize,
975 action_index: usize,
976 action: &Action,
977 level: usize,
978 ) {
979 let span = self.program.action_span(rule_index, action_index);
980 let indent = Self::indent(level);
981 match action {
982 Action::SetGlobalVariable { variable, value } => {
983 if self.set_has_modify_pattern(value, variable, true) {
984 self.issue(
985 "unsupported-set-binary",
986 format!(
987 "Set Global Variable('{}', <binary over the same variable>) \
988 re-lowers to a Modify action; emit the modify form",
989 variable
990 ),
991 span,
992 );
993 return;
994 }
995 self.out.push_str(&indent);
996 self.out.push_str(variable);
997 self.out.push_str(" = ");
998 self.emit_value(
999 value,
1000 self.program
1001 .action_argument_span(rule_index, action_index, 0),
1002 );
1003 self.out.push('\n');
1004 }
1005 Action::ModifyGlobalVariable {
1006 variable,
1007 op,
1008 value,
1009 } => {
1010 self.emit_modify(
1011 level,
1012 variable,
1013 *op,
1014 value,
1015 self.program
1016 .action_argument_span(rule_index, action_index, 0),
1017 );
1018 }
1019 Action::SetPlayerVariable {
1020 player,
1021 variable,
1022 value,
1023 } => {
1024 if !self.is_event_player(player) {
1025 self.issue(
1026 "unsupported-arbitrary-player-target",
1027 "Set Player Variable targets a non-event-player expression; \
1028 the OPY surface only exposes eventPlayer.member"
1029 .to_string(),
1030 span,
1031 );
1032 return;
1033 }
1034 if self.set_has_modify_pattern(value, variable, false) {
1035 self.issue(
1036 "unsupported-set-binary",
1037 format!(
1038 "Set Player Variable('{}', <binary over the same variable>) \
1039 re-lowers to a Modify action; emit the modify form",
1040 variable
1041 ),
1042 span,
1043 );
1044 return;
1045 }
1046 self.out.push_str(&indent);
1047 self.out.push_str("eventPlayer.");
1048 self.out.push_str(variable);
1049 self.out.push_str(" = ");
1050 self.emit_value(
1051 value,
1052 self.program
1053 .action_argument_span(rule_index, action_index, 1),
1054 );
1055 self.out.push('\n');
1056 }
1057 Action::ModifyPlayerVariable {
1058 player,
1059 variable,
1060 op,
1061 value,
1062 } => {
1063 if !self.is_event_player(player) {
1064 self.issue(
1065 "unsupported-arbitrary-player-target",
1066 "Modify Player Variable targets a non-event-player expression; \
1067 the OPY surface only exposes eventPlayer.member"
1068 .to_string(),
1069 span,
1070 );
1071 return;
1072 }
1073 self.emit_modify(
1074 level,
1075 &format!("eventPlayer.{variable}"),
1076 *op,
1077 value,
1078 self.program
1079 .action_argument_span(rule_index, action_index, 1),
1080 );
1081 }
1082 Action::AssignMember { .. } => {
1083 self.issue(
1084 "unsupported-member-assignment",
1085 "dynamic member assignments are outside the OPY reconstruction surface",
1086 span,
1087 );
1088 }
1089 Action::CallSubroutine { subroutine } => {
1090 if !self.subroutine_names.contains(subroutine) {
1091 self.issue(
1092 "unsupported-dangling",
1093 format!("unknown subroutine '{subroutine}'"),
1094 span,
1095 );
1096 return;
1097 }
1098 self.out.push_str(&indent);
1099 self.out.push_str(subroutine);
1100 self.out.push_str("()\n");
1101 }
1102 Action::If { .. } | Action::ElseIf { .. } | Action::Else | Action::End => self.issue(
1103 "unsupported-control-flow",
1104 "unexpected control-flow marker in Workshop action sequence",
1105 span,
1106 ),
1107 Action::While { .. } => self.issue(
1108 "unsupported-control-flow",
1109 "unexpected while action in Workshop action sequence",
1110 span,
1111 ),
1112 Action::ForGlobalVariable { .. } => self.issue(
1113 "unsupported-control-flow",
1114 "unexpected for action in Workshop action sequence",
1115 span,
1116 ),
1117 Action::ForPlayerVariable { .. } => {
1118 self.issue(
1119 "unsupported-per-player-loop",
1120 "For Player Variable is outside the reconstruction surface \
1121 (the OPY `for` form binds a global variable)",
1122 span,
1123 );
1124 }
1125 Action::Disabled { .. } => self.issue(
1126 "unsupported-disabled-action",
1127 "disabled actions are outside the OPY reconstruction surface",
1128 span,
1129 ),
1130 Action::Call { name, args } => {
1131 let argument_spans = (0..args.len())
1132 .map(|index| {
1133 self.program
1134 .action_argument_span(rule_index, action_index, index)
1135 })
1136 .collect::<Vec<_>>();
1137 self.emit_call_action(name, args, &indent, span, &argument_spans);
1138 }
1139 }
1140 }
1141
1142 fn set_has_modify_pattern(&self, value: &Value, variable: &str, global: bool) -> bool {
1145 let Value::Call { name, args } = value else {
1146 return false;
1147 };
1148 if !matches!(name.as_str(), "+" | "-" | "*" | "/" | "%" | "**") {
1149 return false;
1150 }
1151 args.len() == 2 && args.iter().any(|operand| {
1152 if global {
1153 matches!(operand, Value::GlobalVariable(name) if name == variable)
1154 } else {
1155 matches!(operand, Value::PlayerVariable { variable: name, .. } if name == variable)
1156 }
1157 })
1158 }
1159
1160 fn is_event_player(&self, value: &Value) -> bool {
1161 matches!(value, Value::EventPlayer)
1162 }
1163
1164 fn emit_modify(
1165 &mut self,
1166 level: usize,
1167 name: &str,
1168 op: ModifyOp,
1169 value: &Value,
1170 span: Option<Span>,
1171 ) {
1172 let indent = Self::indent(level);
1173 match op {
1174 ModifyOp::AppendToArray => {
1175 self.out.push_str(&indent);
1176 self.out.push_str(name);
1177 self.out.push_str(".append(");
1178 self.emit_value(value, span);
1179 self.out.push_str(")\n");
1180 }
1181 ModifyOp::RemoveFromArrayByValue => {
1182 self.issue(
1183 "unsupported-modify-op",
1184 "Modify ... Remove From Array is outside the reconstruction surface \
1185 (the OPY surface has no remove-from-array form)",
1186 span,
1187 );
1188 }
1189 ModifyOp::RemoveFromArrayByIndex => {
1190 self.issue(
1191 "unsupported-modify-op",
1192 "Modify ... Remove From Array By Index is outside the reconstruction \
1193 surface (the OPY surface has no indexed remove-from-array form)",
1194 span,
1195 );
1196 }
1197 ModifyOp::Min | ModifyOp::Max => {
1198 self.issue(
1199 "unsupported-modify-op",
1200 format!(
1201 "Modify ... {} is outside the reconstruction surface \
1202 (the OPY surface has no equivalent modification form)",
1203 match op {
1204 ModifyOp::Min => "Min",
1205 ModifyOp::Max => "Max",
1206 _ => unreachable!(),
1207 }
1208 ),
1209 span,
1210 );
1211 }
1212 ModifyOp::Add
1213 | ModifyOp::Subtract
1214 | ModifyOp::Multiply
1215 | ModifyOp::Divide
1216 | ModifyOp::Modulo
1217 | ModifyOp::RaiseToPower => {
1218 let operator = match op {
1219 ModifyOp::Add => "+",
1220 ModifyOp::Subtract => "-",
1221 ModifyOp::Multiply => "*",
1222 ModifyOp::Divide => "/",
1223 ModifyOp::Modulo => "%",
1224 ModifyOp::RaiseToPower => "**",
1225 _ => unreachable!(),
1226 };
1227 self.out.push_str(&indent);
1228 self.out.push_str(name);
1229 self.out.push_str(" = ");
1230 self.out.push_str(name);
1231 self.out.push(' ');
1232 self.out.push_str(operator);
1233 self.out.push(' ');
1234 self.emit_value(value, span);
1235 self.out.push('\n');
1236 }
1237 _ => self.issue(
1238 "unsupported-modify-op",
1239 format!("Workshop modification operation {op:?} has no OPY form"),
1240 span,
1241 ),
1242 }
1243 }
1244
1245 fn emit_call_action(
1247 &mut self,
1248 name: &str,
1249 args: &[Value],
1250 indent: &str,
1251 span: Option<Span>,
1252 argument_spans: &[Option<Span>],
1253 ) {
1254 if DEDICATED_ACTION_NAMES.contains(&name) {
1255 self.issue(
1256 "unsupported-action-call",
1257 format!(
1258 "action call '{name}' is lowered to a dedicated Workshop action \
1259 and has no reconstructible call form"
1260 ),
1261 span,
1262 );
1263 return;
1264 }
1265 let (entry, member) =
1266 match Self::resolve_call_entry(self.manifest, name, FunctionKind::Action) {
1267 Ok(call) => call,
1268 Err(message) => {
1269 self.issue("unsupported-action-call", message, span);
1270 return;
1271 }
1272 };
1273 if !member && args.is_empty() && self.subroutine_names.contains(name) {
1274 self.issue(
1275 "unsupported-action-call",
1276 format!(
1277 "action '{name}' with no arguments is ambiguous with a subroutine \
1278 of the same name on the OPY surface"
1279 ),
1280 span,
1281 );
1282 return;
1283 }
1284 self.out.push_str(indent);
1285 self.emit_manifest_call(entry, args, member, span, argument_spans);
1286 self.out.push('\n');
1287 }
1288
1289 fn resolve_call_entry<'manifest>(
1290 manifest: &'manifest Manifest,
1291 name: &str,
1292 expected: FunctionKind,
1293 ) -> Result<(&'manifest Function, bool), String> {
1294 let expected_action = expected.is_action();
1295 let (entry, member) = match manifest.resolve_function(name) {
1296 Some(entry) => (entry, false),
1297 None => match manifest.resolve_member(name) {
1298 Some(entry) => (entry, true),
1299 None => {
1300 let message = if expected_action {
1301 format!(
1302 "action call '{name}' has no OPY source form on the \
1303 reconstruction surface"
1304 )
1305 } else {
1306 format!(
1307 "value call '{name}' has no OPY source form on the \
1308 reconstruction surface"
1309 )
1310 };
1311 return Err(message);
1312 }
1313 },
1314 };
1315 if expected_action != entry.kind.is_action() {
1316 let message = match (expected_action, member) {
1317 (true, true) => format!(
1318 "member value '{name}' cannot be emitted as an action on \
1319 the reconstruction surface"
1320 ),
1321 (true, false) => format!(
1322 "value function '{name}' cannot be emitted as an action on \
1323 the reconstruction surface"
1324 ),
1325 (false, true) => format!(
1326 "member action '{name}' cannot be emitted as a value on \
1327 the reconstruction surface"
1328 ),
1329 (false, false) => format!(
1330 "action function '{name}' cannot be emitted as a value on \
1331 the reconstruction surface"
1332 ),
1333 };
1334 return Err(message);
1335 }
1336 Ok((entry, member))
1337 }
1338
1339 fn emit_manifest_call(
1345 &mut self,
1346 entry: &Function,
1347 args: &[Value],
1348 member: bool,
1349 span: Option<Span>,
1350 argument_spans: &[Option<Span>],
1351 ) {
1352 let (receiver, params) = if member {
1353 match args.split_first() {
1354 Some((receiver, rest)) => (Some(receiver), rest),
1355 None => {
1356 self.issue(
1357 "unsupported-invalid-arity",
1358 format!("member '{}' requires a receiver argument", entry.id),
1359 span,
1360 );
1361 return;
1362 }
1363 }
1364 } else {
1365 (None, args)
1366 };
1367 let name = entry.id.as_str();
1368 if params.len() > entry.params.len() {
1369 self.issue(
1370 "unsupported-invalid-arity",
1371 format!(
1372 "{} '{}' expects at most {} arguments but the Workshop call carries {}",
1373 kind_label(entry.kind),
1374 name,
1375 entry.params.len(),
1376 params.len()
1377 ),
1378 span,
1379 );
1380 return;
1381 }
1382 for (_index, param) in entry.params.iter().enumerate().skip(params.len()) {
1387 if !param.optional {
1388 self.issue(
1389 "unsupported-missing-argument",
1390 format!(
1391 "{} '{}' omits parameter '{}'; the OPY frontend would \
1392 reject or default-fill it and change the resulting Workshop program",
1393 kind_label(entry.kind),
1394 name,
1395 param.name
1396 ),
1397 span,
1398 );
1399 }
1400 }
1401
1402 if let Some(receiver) = receiver {
1403 let receiver_span = argument_spans.first().copied().flatten().or(span);
1404 self.emit_value(receiver, receiver_span);
1405 self.out.push('.');
1406 }
1407 self.out.push_str(name);
1408 self.out.push('(');
1409 if let Some(catalog_id) = &entry.catalog_id {
1415 let expected_kind = match entry.kind {
1416 FunctionKind::Action | FunctionKind::MemberAction => {
1417 workshop_rs::catalog::Kind::Action
1418 }
1419 FunctionKind::Value | FunctionKind::MemberValue => {
1420 workshop_rs::catalog::Kind::Value
1421 }
1422 };
1423 if self
1424 .catalog
1425 .spelling(expected_kind, self.locale, catalog_id)
1426 .is_none()
1427 {
1428 self.issue(
1429 "catalog-error",
1430 format!(
1431 "manifest entry '{}' links catalogId '{catalog_id}' which is \
1432 missing from the Workshop catalog",
1433 entry.id
1434 ),
1435 span,
1436 );
1437 }
1438 }
1439 for (index, arg) in params.iter().enumerate() {
1440 if index > 0 {
1441 self.out.push_str(", ");
1442 }
1443 let argument_index = index + if member { 1 } else { 0 };
1444 let argument_span = argument_spans
1445 .get(argument_index)
1446 .copied()
1447 .flatten()
1448 .or(span);
1449 self.check_param_argument(entry, index, arg, argument_span);
1450 self.emit_value(arg, argument_span);
1451 }
1452 self.out.push(')');
1453 }
1454
1455 fn check_param_argument(
1459 &mut self,
1460 entry: &Function,
1461 index: usize,
1462 arg: &Value,
1463 span: Option<Span>,
1464 ) {
1465 let Some(param) = entry.params.get(index) else {
1466 return;
1467 };
1468 if let Some(domain) = ¶m.domain {
1469 match arg {
1470 Value::Enum { value_type, value } if value_type == domain => {
1471 if !self.enum_member_in_domain(domain, value) {
1472 self.issue(
1473 "unsupported-enum-member",
1474 format!(
1475 "argument {} of '{}' uses enum member '{domain}.{value}' \
1476 which is outside the manifest's declared domain",
1477 index + 1,
1478 entry.id
1479 ),
1480 span,
1481 );
1482 }
1483 }
1484 Value::Enum { value_type, .. } => {
1485 self.issue(
1486 "unsupported-enum-domain-mismatch",
1487 format!(
1488 "argument {} of '{}' expects enum domain '{domain}' but \
1489 the Workshop value carries '{value_type}'",
1490 index + 1,
1491 entry.id
1492 ),
1493 span,
1494 );
1495 }
1496 _ => {
1497 self.issue(
1498 "unsupported-enum-domain-mismatch",
1499 format!(
1500 "argument {} of '{}' expects an enum member of domain \
1501 '{domain}'",
1502 index + 1,
1503 entry.id
1504 ),
1505 span,
1506 );
1507 }
1508 }
1509 }
1510 if param.variable {
1511 let is_variable =
1512 matches!(arg, Value::GlobalVariable(_) | Value::PlayerVariable { .. });
1513 if !is_variable {
1514 self.issue(
1515 "unsupported-invalid-argument",
1516 format!(
1517 "argument {} of '{}' must be a variable reference",
1518 index + 1,
1519 entry.id
1520 ),
1521 span,
1522 );
1523 }
1524 }
1525 }
1526
1527 fn enum_member_in_domain(&self, domain: &str, member: &str) -> bool {
1528 self.catalog.enum_domain(domain).is_some_and(|domain| {
1529 domain
1530 .members
1531 .iter()
1532 .any(|candidate| candidate.member == member)
1533 })
1534 }
1535
1536 fn emit_value(&mut self, value: &Value, span: Option<Span>) {
1539 match value {
1540 Value::Number(value) => {
1541 if !value.is_finite() {
1542 self.issue(
1543 "unsupported-non-finite-number",
1544 format!("non-finite number literal '{value}' has no OPY spelling"),
1545 span,
1546 );
1547 } else if *value < 0.0 {
1548 self.issue(
1549 "unsupported-negative-number",
1550 format!(
1551 "negative number literal '{}' has no OPY literal form \
1552 (the lexer has no negative-number token)",
1553 workshop_rs::format::format_number(*value)
1554 ),
1555 span,
1556 );
1557 } else {
1558 self.out
1559 .push_str(&workshop_rs::format::format_number(*value));
1560 }
1561 }
1562 Value::String(value) => self.emit_string_literal(value),
1563 Value::LocalizedString(value) => {
1564 self.issue(
1565 "unsupported-localized-string",
1566 format!("localized Workshop preset string '{value}' has no OPY source representation"),
1567 span,
1568 );
1569 }
1570 Value::Bool(value) => {
1571 self.out.push_str(if *value { "true" } else { "false" });
1572 }
1573 Value::Null => {
1574 self.out.push_str("None");
1575 }
1576 Value::Array(elements) => {
1577 self.out.push('[');
1578 for (index, element) in elements.iter().enumerate() {
1579 if index > 0 {
1580 self.out.push_str(", ");
1581 }
1582 self.emit_value(element, span);
1583 }
1584 self.out.push(']');
1585 }
1586 Value::Vector { x, y, z } => {
1587 self.out.push_str("vect(");
1588 self.emit_value(x, span);
1589 self.out.push_str(", ");
1590 self.emit_value(y, span);
1591 self.out.push_str(", ");
1592 self.emit_value(z, span);
1593 self.out.push(')');
1594 }
1595 Value::Enum { value_type, value } => {
1596 self.emit_enum(value_type, value, span);
1597 }
1598 Value::GlobalVariable(variable) => {
1599 if !self
1600 .program
1601 .global_variables
1602 .iter()
1603 .any(|declaration| declaration.name == *variable)
1604 {
1605 self.issue(
1606 "unsupported-dangling",
1607 format!("unknown global variable '{variable}'"),
1608 span,
1609 );
1610 return;
1611 }
1612 self.out.push_str(variable);
1613 }
1614 Value::PlayerVariable { player, variable } => {
1615 if !self.is_event_player(player) {
1616 self.issue(
1617 "unsupported-arbitrary-player-target",
1618 "a player-variable access on a non-event-player expression is \
1619 outside the reconstruction surface (only eventPlayer.member \
1620 is representable)",
1621 span,
1622 );
1623 return;
1624 }
1625 if !self
1626 .program
1627 .player_variables
1628 .iter()
1629 .any(|declaration| declaration.name == *variable)
1630 {
1631 self.issue(
1632 "unsupported-dangling",
1633 format!("unknown player variable '{variable}'"),
1634 span,
1635 );
1636 return;
1637 }
1638 self.out.push_str("eventPlayer.");
1639 self.out.push_str(variable);
1640 }
1641 Value::Subroutine(_) => {
1642 self.issue(
1643 "unsupported-subroutine-value",
1644 "subroutine values are outside the OPY reconstruction surface",
1645 span,
1646 );
1647 }
1648 Value::EventPlayer => {
1649 self.out.push_str("eventPlayer");
1650 }
1651 Value::Call { name, args } => {
1652 self.emit_value_call(name, args, span);
1653 }
1654 }
1655 }
1656
1657 fn emit_enum(&mut self, value_type: &str, value: &str, span: Option<Span>) {
1658 let Some(domain) = self.catalog.enum_domain(value_type) else {
1659 self.issue(
1660 "unsupported-enum-domain",
1661 format!(
1662 "enum domain '{value_type}' is outside the manifest's declared \
1663 reconstruction surface"
1664 ),
1665 span,
1666 );
1667 return;
1668 };
1669 if !domain.members.iter().any(|member| member.member == value) {
1670 self.issue(
1671 "unsupported-enum-member",
1672 format!(
1673 "enum member '{value_type}.{value}' is outside the manifest's \
1674 declared domain"
1675 ),
1676 span,
1677 );
1678 return;
1679 }
1680 self.out.push_str(value_type);
1681 self.out.push('.');
1682 self.out.push_str(value);
1683 }
1684
1685 fn emit_value_call(&mut self, name: &str, args: &[Value], span: Option<Span>) {
1686 if name == "localPlayer" && args.is_empty() {
1687 self.out.push_str(name);
1688 return;
1689 }
1690 if BINARY_OPS.contains(&name) && args.len() == 2 {
1692 self.out.push('(');
1693 self.emit_value(&args[0], span);
1694 self.out.push(' ');
1695 self.out.push_str(name);
1696 self.out.push(' ');
1697 self.emit_value(&args[1], span);
1698 self.out.push(')');
1699 return;
1700 }
1701 if name == "not" && args.len() == 1 {
1702 self.out.push_str("(not ");
1703 self.emit_value(&args[0], span);
1704 self.out.push(')');
1705 return;
1706 }
1707 if name == "-" && args.len() == 1 {
1708 self.out.push_str("(-");
1709 self.emit_value(&args[0], span);
1710 self.out.push(')');
1711 return;
1712 }
1713 if name == "format" {
1715 let Some(first) = args.first() else {
1716 self.issue(
1717 "unsupported-value-call",
1718 "format call without a receiver is outside the reconstruction surface",
1719 span,
1720 );
1721 return;
1722 };
1723 let Value::String(text) = first else {
1724 self.issue(
1725 "unsupported-value-call",
1726 "format call without a string receiver is outside the \
1727 reconstruction surface",
1728 span,
1729 );
1730 return;
1731 };
1732 self.emit_string_literal(text);
1733 self.out.push_str(".format(");
1734 for (index, arg) in args.iter().skip(1).enumerate() {
1735 if index > 0 {
1736 self.out.push_str(", ");
1737 }
1738 self.emit_value(arg, span);
1739 }
1740 self.out.push(')');
1741 return;
1742 }
1743 if DEDICATED_VALUE_NAMES.contains(&name) {
1744 self.issue(
1745 "unsupported-value-call",
1746 format!(
1747 "value call '{name}' is lowered to a dedicated Workshop value \
1748 and has no reconstructible call form"
1749 ),
1750 span,
1751 );
1752 return;
1753 }
1754 let (entry, member) =
1755 match Self::resolve_call_entry(self.manifest, name, FunctionKind::Value) {
1756 Ok(call) => call,
1757 Err(message) => {
1758 self.issue("unsupported-value-call", message, span);
1759 return;
1760 }
1761 };
1762 if !member && crate::lower::policy::function_context(&entry.id).is_some() {
1763 self.issue(
1764 "unsupported-value-call",
1765 format!(
1766 "value call '{name}' is only valid as a for-loop iterable on \
1767 the OPY surface"
1768 ),
1769 span,
1770 );
1771 return;
1772 }
1773 self.emit_manifest_call(entry, args, member, span, &[]);
1774 }
1775
1776 fn emit_string_literal(&mut self, value: &str) {
1777 self.out.push('"');
1778 for ch in value.chars() {
1779 match ch {
1780 '\\' => self.out.push_str("\\\\"),
1781 '"' => self.out.push_str("\\\""),
1782 '\n' => self.out.push_str("\\n"),
1783 '\t' => self.out.push_str("\\t"),
1784 '\r' => self.out.push_str("\\r"),
1785 other if other.is_control() => {
1786 self.out.push_str(&format!("\\u{:04X}", other as u32));
1787 }
1788 other => self.out.push(other),
1789 }
1790 }
1791 self.out.push('"');
1792 }
1793}
1794
1795fn kind_label(kind: FunctionKind) -> &'static str {
1796 match kind {
1797 FunctionKind::Action => "action",
1798 FunctionKind::Value => "value",
1799 FunctionKind::MemberAction => "member action",
1800 FunctionKind::MemberValue => "member value",
1801 }
1802}
1803
1804#[cfg(test)]
1805mod tests {
1806 use std::path::Path;
1807
1808 use super::reconstruct;
1809 use crate::compiler::Compiler;
1810 use workshop_rs::catalog::{Catalog, Locale};
1811 use workshop_rs::source::{Position, SourceFile, Span};
1812 use workshop_rs::{Action, Event, ModifyOp, Program, Rule, Subroutine, Value, Variable};
1813
1814 fn compile_to_program(source: &str, path: &str) -> Program {
1815 let artifact = Compiler::new()
1816 .expect("compiler loads")
1817 .compile_source_artifact(source, path, Path::new("."))
1818 .expect("reconstructed OPY compiles");
1819 let catalog = Catalog::builtin().expect("catalog loads");
1820 workshop_rs::parser::parse(&artifact.emitted, &catalog, &Locale::new("en-US"))
1821 .expect("compiler output parses through the public Workshop parser")
1822 }
1823
1824 #[test]
1825 fn reconstructs_public_program_control_flow() {
1826 let mut program = Program::new();
1827 program.global_variable(Variable::with_index("score", 0));
1828 program.rule(
1829 Rule::new("main", Event::Global)
1830 .action(Action::If {
1831 condition: Value::call(
1832 "==",
1833 [Value::global_variable("score"), Value::number(0.0)],
1834 ),
1835 })
1836 .action(Action::SetGlobalVariable {
1837 variable: "score".into(),
1838 value: Value::number(1.0),
1839 })
1840 .action(Action::ElseIf {
1841 condition: Value::call(
1842 "<",
1843 [Value::global_variable("score"), Value::number(0.0)],
1844 ),
1845 })
1846 .action(Action::ModifyGlobalVariable {
1847 variable: "score".into(),
1848 op: ModifyOp::Add,
1849 value: Value::number(2.0),
1850 })
1851 .action(Action::Else)
1852 .action(Action::ModifyGlobalVariable {
1853 variable: "score".into(),
1854 op: ModifyOp::Add,
1855 value: Value::number(3.0),
1856 })
1857 .action(Action::End)
1858 .action(Action::While {
1859 condition: Value::call(
1860 "<",
1861 [Value::global_variable("score"), Value::number(10.0)],
1862 ),
1863 })
1864 .action(Action::ModifyGlobalVariable {
1865 variable: "score".into(),
1866 op: ModifyOp::Add,
1867 value: Value::number(1.0),
1868 })
1869 .action(Action::End)
1870 .action(Action::ForGlobalVariable {
1871 variable: "score".into(),
1872 start: Value::number(0.0),
1873 stop: Value::number(2.0),
1874 step: Value::number(1.0),
1875 })
1876 .action(Action::ModifyGlobalVariable {
1877 variable: "score".into(),
1878 op: ModifyOp::Add,
1879 value: Value::number(1.0),
1880 })
1881 .action(Action::End),
1882 );
1883
1884 let source = reconstruct(&program).expect("canonical Program should reconstruct");
1885 assert!(source.contains("if (score == 0):"));
1886 assert!(source.contains("elif (score < 0):"));
1887 assert!(source.contains("else:"));
1888 assert!(source.contains("while (score < 10):"));
1889 assert!(source.contains("for score in range(0, 2, 1):"));
1890
1891 let reparsed = compile_to_program(&source, "reconstructed.opy");
1892 assert!(workshop_rs::roundtrip::equivalent(&program, &reparsed));
1893 }
1894
1895 #[test]
1896 fn initializer_name_with_another_event_remains_an_ordinary_rule() {
1897 let mut program = Program::new();
1898 program.global_variable(Variable::with_index("score", 0));
1899 program.rule(
1900 Rule::new("Initialize global variables", Event::EachPlayer).action(
1901 Action::SetGlobalVariable {
1902 variable: "score".into(),
1903 value: Value::number(1.0),
1904 },
1905 ),
1906 );
1907
1908 let source = reconstruct(&program).expect("ordinary rule should reconstruct");
1909 assert!(source.contains("rule \"Initialize global variables\":"));
1910 assert!(source.contains("@Event eachPlayer"));
1911 let reparsed = compile_to_program(&source, "initializer-name.opy");
1912 assert!(workshop_rs::roundtrip::equivalent(&program, &reparsed));
1913 }
1914
1915 #[test]
1916 fn disabled_initializer_is_not_reconstructed_as_a_declaration_initializer() {
1917 let mut program = Program::new();
1918 program.global_variable(Variable::with_index("score", 0));
1919 let mut initializer = Rule::new("Initialize global variables", Event::Global).action(
1920 Action::SetGlobalVariable {
1921 variable: "score".into(),
1922 value: Value::number(1.0),
1923 },
1924 );
1925 initializer.disabled = true;
1926 program.rule(initializer);
1927
1928 let error = reconstruct(&program).expect_err("disabled initializer cannot be emitted");
1929 assert_eq!(error.issues[0].code, "unsupported-disabled-rule");
1930 }
1931
1932 #[test]
1933 fn disabled_subroutine_rule_is_not_reconstructed_as_an_active_definition() {
1934 let mut program = Program::new();
1935 program.global_variable(Variable::with_index("score", 0));
1936 program.subroutine(Subroutine::with_index("reset", 0));
1937 let mut subroutine = Rule::new("Subroutine reset", Event::Subroutine("reset".into()))
1938 .action(Action::SetGlobalVariable {
1939 variable: "score".into(),
1940 value: Value::number(0.0),
1941 });
1942 subroutine.disabled = true;
1943 program.rule(subroutine);
1944
1945 let error = reconstruct(&program).expect_err("disabled subroutine cannot be emitted");
1946 assert_eq!(error.issues[0].code, "unsupported-disabled-rule");
1947 }
1948
1949 #[test]
1950 fn escaped_rule_names_remain_valid_opy_strings() {
1951 let rule_name = "quoted \"rule\" \\ path\nnext\u{0001}";
1952 let mut program = Program::new();
1953 program.global_variable(Variable::with_index("score", 0));
1954 program.rule(
1955 Rule::new(rule_name, Event::Global).action(Action::SetGlobalVariable {
1956 variable: "score".into(),
1957 value: Value::number(1.0),
1958 }),
1959 );
1960
1961 let source = reconstruct(&program).expect("special characters should be escaped");
1962 let artifact = Compiler::new()
1963 .expect("compiler loads")
1964 .compile_source_artifact(&source, "escaped-name.opy", Path::new("."))
1965 .expect("escaped OPY rule name parses");
1966 assert_eq!(artifact.wir.rules[0].name, rule_name);
1967 }
1968
1969 #[test]
1970 fn reconstructs_public_subroutine_names() {
1971 let mut program = Program::new();
1972 program.global_variable(Variable::with_index("score", 0));
1973 program.subroutine(Subroutine::with_index("reset", 0));
1974 program.rule(
1975 Rule::new("Subroutine reset", Event::Subroutine("reset".into())).action(
1976 Action::SetGlobalVariable {
1977 variable: "score".into(),
1978 value: Value::number(0.0),
1979 },
1980 ),
1981 );
1982 program.rule(
1983 Rule::new("main", Event::Global).action(Action::CallSubroutine {
1984 subroutine: "reset".into(),
1985 }),
1986 );
1987
1988 let source = reconstruct(&program).expect("canonical subroutine should reconstruct");
1989 assert!(source.contains("def reset():"));
1990 assert!(source.contains("reset()"));
1991
1992 let reparsed = compile_to_program(&source, "subroutine.opy");
1993 assert!(workshop_rs::roundtrip::equivalent(&program, &reparsed));
1994 }
1995
1996 #[test]
1997 fn action_argument_diagnostics_use_public_argument_spans() {
1998 let mut program = Program::new();
1999 let file = program.add_file(SourceFile::new("main.opy"));
2000 program.rule(Rule::new("main", Event::Global).action(Action::call(
2001 "chaseAtRate",
2002 [
2003 Value::number(1.0),
2004 Value::number(10.0),
2005 Value::number(1.0),
2006 Value::Enum {
2007 value_type: "ChaseRateReeval".into(),
2008 value: "DESTINATION_AND_RATE".into(),
2009 },
2010 ],
2011 )));
2012 let action_span = Span::new(file, Position::new(1, 1), Position::new(1, 25));
2013 let argument_span = Span::new(file, Position::new(1, 13), Position::new(1, 14));
2014 program.set_action_span(0, 0, Some(action_span)).unwrap();
2015 program
2016 .set_action_argument_span(0, 0, 0, Some(argument_span))
2017 .unwrap();
2018
2019 let error = reconstruct(&program).expect_err("the first parameter must be a variable");
2020 assert_eq!(error.issues[0].code, "unsupported-invalid-argument");
2021 assert_eq!(error.issues[0].span, Some(argument_span));
2022 }
2023
2024 #[test]
2025 fn rejects_public_program_actions_without_an_opy_form() {
2026 let mut program = Program::new();
2027 program.rule(
2028 Rule::new("main", Event::Global).action(Action::AssignMember {
2029 target: Value::EventPlayer,
2030 op: None,
2031 value: Value::number(1.0),
2032 }),
2033 );
2034
2035 let error = reconstruct(&program).expect_err("dynamic member assignment is unsupported");
2036 assert_eq!(error.issues[0].code, "unsupported-member-assignment");
2037 }
2038}