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