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