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