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, args.len()) {
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 args_len: usize,
1294 ) -> Result<(&'manifest Function, bool), String> {
1295 let expected_action = expected.is_action();
1296 let by_catalog = |entry: &&Function| {
1302 entry.catalog_id.as_deref() == Some(name)
1303 && entry.kind.is_action() == expected_action
1304 && {
1305 let provided = args_len.saturating_sub(usize::from(entry.kind.is_member()));
1306 let required = entry.params.iter().filter(|param| !param.optional).count();
1307 required <= provided && provided <= entry.params.len()
1308 }
1309 };
1310 let (entry, member) = match manifest
1311 .functions
1312 .iter()
1313 .filter(by_catalog)
1314 .min_by_key(|entry| entry.params.len())
1315 {
1316 Some(entry) => (entry, entry.kind.is_member()),
1317 None => match manifest.resolve_function(name) {
1318 Some(entry) => (entry, false),
1319 None => match manifest.resolve_member(name) {
1320 Some(entry) => (entry, true),
1321 None => {
1322 let message = if expected_action {
1323 format!(
1324 "action call '{name}' has no OPY source form on the \
1325 reconstruction surface"
1326 )
1327 } else {
1328 format!(
1329 "value call '{name}' has no OPY source form on the \
1330 reconstruction surface"
1331 )
1332 };
1333 return Err(message);
1334 }
1335 },
1336 },
1337 };
1338 if expected_action != entry.kind.is_action() {
1339 let message = match (expected_action, member) {
1340 (true, true) => format!(
1341 "member value '{name}' cannot be emitted as an action on \
1342 the reconstruction surface"
1343 ),
1344 (true, false) => format!(
1345 "value function '{name}' cannot be emitted as an action on \
1346 the reconstruction surface"
1347 ),
1348 (false, true) => format!(
1349 "member action '{name}' cannot be emitted as a value on \
1350 the reconstruction surface"
1351 ),
1352 (false, false) => format!(
1353 "action function '{name}' cannot be emitted as a value on \
1354 the reconstruction surface"
1355 ),
1356 };
1357 return Err(message);
1358 }
1359 Ok((entry, member))
1360 }
1361
1362 fn emit_manifest_call(
1368 &mut self,
1369 entry: &Function,
1370 args: &[Value],
1371 member: bool,
1372 span: Option<Span>,
1373 argument_spans: &[Option<Span>],
1374 ) {
1375 let (receiver, params) = if member {
1376 match args.split_first() {
1377 Some((receiver, rest)) => (Some(receiver), rest),
1378 None => {
1379 self.issue(
1380 "unsupported-invalid-arity",
1381 format!("member '{}' requires a receiver argument", entry.id),
1382 span,
1383 );
1384 return;
1385 }
1386 }
1387 } else {
1388 (None, args)
1389 };
1390 let name = entry.id.as_str();
1391 if params.len() > entry.params.len() {
1392 self.issue(
1393 "unsupported-invalid-arity",
1394 format!(
1395 "{} '{}' expects at most {} arguments but the Workshop call carries {}",
1396 kind_label(entry.kind),
1397 name,
1398 entry.params.len(),
1399 params.len()
1400 ),
1401 span,
1402 );
1403 return;
1404 }
1405 for (_index, param) in entry.params.iter().enumerate().skip(params.len()) {
1410 if !param.optional {
1411 self.issue(
1412 "unsupported-missing-argument",
1413 format!(
1414 "{} '{}' omits parameter '{}'; the OPY frontend would \
1415 reject or default-fill it and change the resulting Workshop program",
1416 kind_label(entry.kind),
1417 name,
1418 param.name
1419 ),
1420 span,
1421 );
1422 }
1423 }
1424
1425 if let Some(receiver) = receiver {
1426 let receiver_span = argument_spans.first().copied().flatten().or(span);
1427 self.emit_value(receiver, receiver_span);
1428 self.out.push('.');
1429 }
1430 self.out.push_str(name);
1431 self.out.push('(');
1432 if let Some(catalog_id) = &entry.catalog_id {
1438 let expected_kind = match entry.kind {
1439 FunctionKind::Action | FunctionKind::MemberAction => {
1440 workshop_rs::catalog::Kind::Action
1441 }
1442 FunctionKind::Value | FunctionKind::MemberValue => {
1443 workshop_rs::catalog::Kind::Value
1444 }
1445 };
1446 if self
1447 .catalog
1448 .spelling(expected_kind, self.locale, catalog_id)
1449 .is_none()
1450 {
1451 self.issue(
1452 "catalog-error",
1453 format!(
1454 "manifest entry '{}' links catalogId '{catalog_id}' which is \
1455 missing from the Workshop catalog",
1456 entry.id
1457 ),
1458 span,
1459 );
1460 }
1461 }
1462 for (index, arg) in params.iter().enumerate() {
1463 if index > 0 {
1464 self.out.push_str(", ");
1465 }
1466 let argument_index = index + if member { 1 } else { 0 };
1467 let argument_span = argument_spans
1468 .get(argument_index)
1469 .copied()
1470 .flatten()
1471 .or(span);
1472 self.check_param_argument(entry, index, arg, argument_span);
1473 self.emit_value(arg, argument_span);
1474 }
1475 self.out.push(')');
1476 }
1477
1478 fn check_param_argument(
1482 &mut self,
1483 entry: &Function,
1484 index: usize,
1485 arg: &Value,
1486 span: Option<Span>,
1487 ) {
1488 let Some(param) = entry.params.get(index) else {
1489 return;
1490 };
1491 if let Some(domain) = ¶m.domain {
1492 match arg {
1493 Value::Enum { value_type, value } if value_type == domain => {
1494 if !self.enum_member_in_domain(domain, value) {
1495 self.issue(
1496 "unsupported-enum-member",
1497 format!(
1498 "argument {} of '{}' uses enum member '{domain}.{value}' \
1499 which is outside the manifest's declared domain",
1500 index + 1,
1501 entry.id
1502 ),
1503 span,
1504 );
1505 }
1506 }
1507 Value::Enum { value_type, .. } => {
1508 self.issue(
1509 "unsupported-enum-domain-mismatch",
1510 format!(
1511 "argument {} of '{}' expects enum domain '{domain}' but \
1512 the Workshop value carries '{value_type}'",
1513 index + 1,
1514 entry.id
1515 ),
1516 span,
1517 );
1518 }
1519 _ => {
1520 self.issue(
1521 "unsupported-enum-domain-mismatch",
1522 format!(
1523 "argument {} of '{}' expects an enum member of domain \
1524 '{domain}'",
1525 index + 1,
1526 entry.id
1527 ),
1528 span,
1529 );
1530 }
1531 }
1532 }
1533 if param.variable {
1534 let is_variable =
1535 matches!(arg, Value::GlobalVariable(_) | Value::PlayerVariable { .. });
1536 if !is_variable {
1537 self.issue(
1538 "unsupported-invalid-argument",
1539 format!(
1540 "argument {} of '{}' must be a variable reference",
1541 index + 1,
1542 entry.id
1543 ),
1544 span,
1545 );
1546 }
1547 }
1548 }
1549
1550 fn enum_member_in_domain(&self, domain: &str, member: &str) -> bool {
1551 self.catalog.enum_domain(domain).is_some_and(|domain| {
1552 domain
1553 .members
1554 .iter()
1555 .any(|candidate| candidate.member == member)
1556 })
1557 }
1558
1559 fn emit_value(&mut self, value: &Value, span: Option<Span>) {
1562 match value {
1563 Value::Number(value) => {
1564 if !value.is_finite() {
1565 self.issue(
1566 "unsupported-non-finite-number",
1567 format!("non-finite number literal '{value}' has no OPY spelling"),
1568 span,
1569 );
1570 } else if *value < 0.0 {
1571 self.issue(
1572 "unsupported-negative-number",
1573 format!(
1574 "negative number literal '{}' has no OPY literal form \
1575 (the lexer has no negative-number token)",
1576 workshop_rs::format::format_number(*value)
1577 ),
1578 span,
1579 );
1580 } else {
1581 self.out
1582 .push_str(&workshop_rs::format::format_number(*value));
1583 }
1584 }
1585 Value::String(value) => self.emit_string_literal(value),
1586 Value::LocalizedString(value) => {
1587 self.issue(
1588 "unsupported-localized-string",
1589 format!("localized Workshop preset string '{value}' has no OPY source representation"),
1590 span,
1591 );
1592 }
1593 Value::Bool(value) => {
1594 self.out.push_str(if *value { "true" } else { "false" });
1595 }
1596 Value::Null => {
1597 self.out.push_str("None");
1598 }
1599 Value::Array(elements) => {
1600 self.out.push('[');
1601 for (index, element) in elements.iter().enumerate() {
1602 if index > 0 {
1603 self.out.push_str(", ");
1604 }
1605 self.emit_value(element, span);
1606 }
1607 self.out.push(']');
1608 }
1609 Value::Vector { x, y, z } => {
1610 self.out.push_str("vect(");
1611 self.emit_value(x, span);
1612 self.out.push_str(", ");
1613 self.emit_value(y, span);
1614 self.out.push_str(", ");
1615 self.emit_value(z, span);
1616 self.out.push(')');
1617 }
1618 Value::Enum { value_type, value } => {
1619 self.emit_enum(value_type, value, span);
1620 }
1621 Value::GlobalVariable(variable) => {
1622 if !self
1623 .program
1624 .global_variables
1625 .iter()
1626 .any(|declaration| declaration.name == *variable)
1627 {
1628 self.issue(
1629 "unsupported-dangling",
1630 format!("unknown global variable '{variable}'"),
1631 span,
1632 );
1633 return;
1634 }
1635 self.out.push_str(variable);
1636 }
1637 Value::PlayerVariable { player, variable } => {
1638 if !self.is_event_player(player) {
1639 self.issue(
1640 "unsupported-arbitrary-player-target",
1641 "a player-variable access on a non-event-player expression is \
1642 outside the reconstruction surface (only eventPlayer.member \
1643 is representable)",
1644 span,
1645 );
1646 return;
1647 }
1648 if !self
1649 .program
1650 .player_variables
1651 .iter()
1652 .any(|declaration| declaration.name == *variable)
1653 {
1654 self.issue(
1655 "unsupported-dangling",
1656 format!("unknown player variable '{variable}'"),
1657 span,
1658 );
1659 return;
1660 }
1661 self.out.push_str("eventPlayer.");
1662 self.out.push_str(variable);
1663 }
1664 Value::Subroutine(_) => {
1665 self.issue(
1666 "unsupported-subroutine-value",
1667 "subroutine values are outside the OPY reconstruction surface",
1668 span,
1669 );
1670 }
1671 Value::EventPlayer => {
1672 self.out.push_str("eventPlayer");
1673 }
1674 Value::Call { name, args } => {
1675 self.emit_value_call(name, args, span);
1676 }
1677 }
1678 }
1679
1680 fn emit_enum(&mut self, value_type: &str, value: &str, span: Option<Span>) {
1681 let Some(domain) = self.catalog.enum_domain(value_type) else {
1682 self.issue(
1683 "unsupported-enum-domain",
1684 format!(
1685 "enum domain '{value_type}' is outside the manifest's declared \
1686 reconstruction surface"
1687 ),
1688 span,
1689 );
1690 return;
1691 };
1692 if !domain.members.iter().any(|member| member.member == value) {
1693 self.issue(
1694 "unsupported-enum-member",
1695 format!(
1696 "enum member '{value_type}.{value}' is outside the manifest's \
1697 declared domain"
1698 ),
1699 span,
1700 );
1701 return;
1702 }
1703 self.out.push_str(value_type);
1704 self.out.push('.');
1705 self.out.push_str(value);
1706 }
1707
1708 fn emit_value_call(&mut self, name: &str, args: &[Value], span: Option<Span>) {
1709 if name == "localPlayer" && args.is_empty() {
1710 self.out.push_str(name);
1711 return;
1712 }
1713 if BINARY_OPS.contains(&name) && args.len() == 2 {
1715 self.out.push('(');
1716 self.emit_value(&args[0], span);
1717 self.out.push(' ');
1718 self.out.push_str(name);
1719 self.out.push(' ');
1720 self.emit_value(&args[1], span);
1721 self.out.push(')');
1722 return;
1723 }
1724 if name == "not" && args.len() == 1 {
1725 self.out.push_str("(not ");
1726 self.emit_value(&args[0], span);
1727 self.out.push(')');
1728 return;
1729 }
1730 if name == "-" && args.len() == 1 {
1731 self.out.push_str("(-");
1732 self.emit_value(&args[0], span);
1733 self.out.push(')');
1734 return;
1735 }
1736 if name == "format" {
1738 let Some(first) = args.first() else {
1739 self.issue(
1740 "unsupported-value-call",
1741 "format call without a receiver is outside the reconstruction surface",
1742 span,
1743 );
1744 return;
1745 };
1746 let Value::String(text) = first else {
1747 self.issue(
1748 "unsupported-value-call",
1749 "format call without a string receiver is outside the \
1750 reconstruction surface",
1751 span,
1752 );
1753 return;
1754 };
1755 self.emit_string_literal(text);
1756 self.out.push_str(".format(");
1757 for (index, arg) in args.iter().skip(1).enumerate() {
1758 if index > 0 {
1759 self.out.push_str(", ");
1760 }
1761 self.emit_value(arg, span);
1762 }
1763 self.out.push(')');
1764 return;
1765 }
1766 if DEDICATED_VALUE_NAMES.contains(&name) {
1767 self.issue(
1768 "unsupported-value-call",
1769 format!(
1770 "value call '{name}' is lowered to a dedicated Workshop value \
1771 and has no reconstructible call form"
1772 ),
1773 span,
1774 );
1775 return;
1776 }
1777 let (entry, member) =
1778 match Self::resolve_call_entry(self.manifest, name, FunctionKind::Value, args.len()) {
1779 Ok(call) => call,
1780 Err(message) => {
1781 self.issue("unsupported-value-call", message, span);
1782 return;
1783 }
1784 };
1785 if !member && crate::lower::policy::function_context(&entry.id).is_some() {
1786 self.issue(
1787 "unsupported-value-call",
1788 format!(
1789 "value call '{name}' is only valid as a for-loop iterable on \
1790 the OPY surface"
1791 ),
1792 span,
1793 );
1794 return;
1795 }
1796 self.emit_manifest_call(entry, args, member, span, &[]);
1797 }
1798
1799 fn emit_string_literal(&mut self, value: &str) {
1800 self.out.push('"');
1801 for ch in value.chars() {
1802 match ch {
1803 '\\' => self.out.push_str("\\\\"),
1804 '"' => self.out.push_str("\\\""),
1805 '\n' => self.out.push_str("\\n"),
1806 '\t' => self.out.push_str("\\t"),
1807 '\r' => self.out.push_str("\\r"),
1808 other if other.is_control() => {
1809 self.out.push_str(&format!("\\u{:04X}", other as u32));
1810 }
1811 other => self.out.push(other),
1812 }
1813 }
1814 self.out.push('"');
1815 }
1816}
1817
1818fn kind_label(kind: FunctionKind) -> &'static str {
1819 match kind {
1820 FunctionKind::Action => "action",
1821 FunctionKind::Value => "value",
1822 FunctionKind::MemberAction => "member action",
1823 FunctionKind::MemberValue => "member value",
1824 }
1825}
1826
1827#[cfg(test)]
1828mod tests {
1829 use std::path::Path;
1830
1831 use super::reconstruct;
1832 use crate::compiler::Compiler;
1833 use workshop_rs::catalog::{Catalog, Locale};
1834 use workshop_rs::source::{Position, SourceFile, Span};
1835 use workshop_rs::{Action, Event, ModifyOp, Program, Rule, Subroutine, Value, Variable};
1836
1837 fn compile_to_program(source: &str, path: &str) -> Program {
1838 let artifact = Compiler::new()
1839 .expect("compiler loads")
1840 .compile_source_artifact(source, path, Path::new("."))
1841 .expect("reconstructed OPY compiles");
1842 let catalog = Catalog::builtin().expect("catalog loads");
1843 workshop_rs::parser::parse(&artifact.emitted, &catalog, &Locale::new("en-US"))
1844 .expect("compiler output parses through the public Workshop parser")
1845 }
1846
1847 #[test]
1848 fn reconstructs_public_program_control_flow() {
1849 let mut program = Program::new();
1850 program.global_variable(Variable::with_index("score", 0));
1851 program.rule(
1852 Rule::new("main", Event::Global)
1853 .action(Action::If {
1854 condition: Value::call(
1855 "==",
1856 [Value::global_variable("score"), Value::number(0.0)],
1857 ),
1858 })
1859 .action(Action::SetGlobalVariable {
1860 variable: "score".into(),
1861 value: Value::number(1.0),
1862 })
1863 .action(Action::ElseIf {
1864 condition: Value::call(
1865 "<",
1866 [Value::global_variable("score"), Value::number(0.0)],
1867 ),
1868 })
1869 .action(Action::ModifyGlobalVariable {
1870 variable: "score".into(),
1871 op: ModifyOp::Add,
1872 value: Value::number(2.0),
1873 })
1874 .action(Action::Else)
1875 .action(Action::ModifyGlobalVariable {
1876 variable: "score".into(),
1877 op: ModifyOp::Add,
1878 value: Value::number(3.0),
1879 })
1880 .action(Action::End)
1881 .action(Action::While {
1882 condition: Value::call(
1883 "<",
1884 [Value::global_variable("score"), Value::number(10.0)],
1885 ),
1886 })
1887 .action(Action::ModifyGlobalVariable {
1888 variable: "score".into(),
1889 op: ModifyOp::Add,
1890 value: Value::number(1.0),
1891 })
1892 .action(Action::End)
1893 .action(Action::ForGlobalVariable {
1894 variable: "score".into(),
1895 start: Value::number(0.0),
1896 stop: Value::number(2.0),
1897 step: Value::number(1.0),
1898 })
1899 .action(Action::ModifyGlobalVariable {
1900 variable: "score".into(),
1901 op: ModifyOp::Add,
1902 value: Value::number(1.0),
1903 })
1904 .action(Action::End),
1905 );
1906
1907 let source = reconstruct(&program).expect("canonical Program should reconstruct");
1908 assert!(source.contains("if (score == 0):"));
1909 assert!(source.contains("elif (score < 0):"));
1910 assert!(source.contains("else:"));
1911 assert!(source.contains("while (score < 10):"));
1912 assert!(source.contains("for score in range(0, 2, 1):"));
1913
1914 let reparsed = compile_to_program(&source, "reconstructed.opy");
1915 assert!(workshop_rs::roundtrip::equivalent(&program, &reparsed));
1916 }
1917
1918 #[test]
1919 fn initializer_name_with_another_event_remains_an_ordinary_rule() {
1920 let mut program = Program::new();
1921 program.global_variable(Variable::with_index("score", 0));
1922 program.rule(
1923 Rule::new("Initialize global variables", Event::EachPlayer).action(
1924 Action::SetGlobalVariable {
1925 variable: "score".into(),
1926 value: Value::number(1.0),
1927 },
1928 ),
1929 );
1930
1931 let source = reconstruct(&program).expect("ordinary rule should reconstruct");
1932 assert!(source.contains("rule \"Initialize global variables\":"));
1933 assert!(source.contains("@Event eachPlayer"));
1934 let reparsed = compile_to_program(&source, "initializer-name.opy");
1935 assert!(workshop_rs::roundtrip::equivalent(&program, &reparsed));
1936 }
1937
1938 #[test]
1939 fn disabled_initializer_is_not_reconstructed_as_a_declaration_initializer() {
1940 let mut program = Program::new();
1941 program.global_variable(Variable::with_index("score", 0));
1942 let mut initializer = Rule::new("Initialize global variables", Event::Global).action(
1943 Action::SetGlobalVariable {
1944 variable: "score".into(),
1945 value: Value::number(1.0),
1946 },
1947 );
1948 initializer.disabled = true;
1949 program.rule(initializer);
1950
1951 let error = reconstruct(&program).expect_err("disabled initializer cannot be emitted");
1952 assert_eq!(error.issues[0].code, "unsupported-disabled-rule");
1953 }
1954
1955 #[test]
1956 fn disabled_subroutine_rule_is_not_reconstructed_as_an_active_definition() {
1957 let mut program = Program::new();
1958 program.global_variable(Variable::with_index("score", 0));
1959 program.subroutine(Subroutine::with_index("reset", 0));
1960 let mut subroutine = Rule::new("Subroutine reset", Event::Subroutine("reset".into()))
1961 .action(Action::SetGlobalVariable {
1962 variable: "score".into(),
1963 value: Value::number(0.0),
1964 });
1965 subroutine.disabled = true;
1966 program.rule(subroutine);
1967
1968 let error = reconstruct(&program).expect_err("disabled subroutine cannot be emitted");
1969 assert_eq!(error.issues[0].code, "unsupported-disabled-rule");
1970 }
1971
1972 #[test]
1973 fn escaped_rule_names_remain_valid_opy_strings() {
1974 let rule_name = "quoted \"rule\" \\ path\nnext\u{0001}";
1975 let mut program = Program::new();
1976 program.global_variable(Variable::with_index("score", 0));
1977 program.rule(
1978 Rule::new(rule_name, Event::Global).action(Action::SetGlobalVariable {
1979 variable: "score".into(),
1980 value: Value::number(1.0),
1981 }),
1982 );
1983
1984 let source = reconstruct(&program).expect("special characters should be escaped");
1985 let artifact = Compiler::new()
1986 .expect("compiler loads")
1987 .compile_source_artifact(&source, "escaped-name.opy", Path::new("."))
1988 .expect("escaped OPY rule name parses");
1989 assert_eq!(artifact.wir.rules[0].name, rule_name);
1990 }
1991
1992 #[test]
1993 fn reconstructs_public_subroutine_names() {
1994 let mut program = Program::new();
1995 program.global_variable(Variable::with_index("score", 0));
1996 program.subroutine(Subroutine::with_index("reset", 0));
1997 program.rule(
1998 Rule::new("Subroutine reset", Event::Subroutine("reset".into())).action(
1999 Action::SetGlobalVariable {
2000 variable: "score".into(),
2001 value: Value::number(0.0),
2002 },
2003 ),
2004 );
2005 program.rule(
2006 Rule::new("main", Event::Global).action(Action::CallSubroutine {
2007 subroutine: "reset".into(),
2008 }),
2009 );
2010
2011 let source = reconstruct(&program).expect("canonical subroutine should reconstruct");
2012 assert!(source.contains("def reset():"));
2013 assert!(source.contains("reset()"));
2014
2015 let reparsed = compile_to_program(&source, "subroutine.opy");
2016 assert!(workshop_rs::roundtrip::equivalent(&program, &reparsed));
2017 }
2018
2019 #[test]
2020 fn action_argument_diagnostics_use_public_argument_spans() {
2021 let mut program = Program::new();
2022 let file = program.add_file(SourceFile::new("main.opy"));
2023 program.rule(Rule::new("main", Event::Global).action(Action::call(
2024 "chaseAtRate",
2025 [
2026 Value::number(1.0),
2027 Value::number(10.0),
2028 Value::number(1.0),
2029 Value::Enum {
2030 value_type: "ChaseRateReeval".into(),
2031 value: "DESTINATION_AND_RATE".into(),
2032 },
2033 ],
2034 )));
2035 let action_span = Span::new(file, Position::new(1, 1), Position::new(1, 25));
2036 let argument_span = Span::new(file, Position::new(1, 13), Position::new(1, 14));
2037 program.set_action_span(0, 0, Some(action_span)).unwrap();
2038 program
2039 .set_action_argument_span(0, 0, 0, Some(argument_span))
2040 .unwrap();
2041
2042 let error = reconstruct(&program).expect_err("the first parameter must be a variable");
2043 assert_eq!(error.issues[0].code, "unsupported-invalid-argument");
2044 assert_eq!(error.issues[0].span, Some(argument_span));
2045 }
2046
2047 #[test]
2048 fn rejects_public_program_actions_without_an_opy_form() {
2049 let mut program = Program::new();
2050 program.rule(
2051 Rule::new("main", Event::Global).action(Action::AssignMember {
2052 target: Value::EventPlayer,
2053 op: None,
2054 value: Value::number(1.0),
2055 }),
2056 );
2057
2058 let error = reconstruct(&program).expect_err("dynamic member assignment is unsupported");
2059 assert_eq!(error.issues[0].code, "unsupported-member-assignment");
2060 }
2061}