Skip to main content

opy_rs/compiler/
reconstruct.rs

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/// A structured reconstruction diagnostic naming one non-representable
13/// Workshop construct, with its source span when available.
14#[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/// All reconstruction failures for one program. The emitter never returns
22/// partial output: a non-empty issue list means no OPY was produced.
23#[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
46/// Reconstruct a canonical Workshop program into OPY source.
47///
48/// Resolves builtin identities through the built-in OPY semantic manifest
49/// and Workshop catalog (`en-US`). Returns all diagnostics when a construct
50/// cannot be represented in OPY.
51pub 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
81/// The context-sensitive form of [`reconstruct`]: resolves identities through
82/// the supplied manifest and catalog. The locale selects the catalog
83/// spellings used for cross-checks (reconstruction emits OPY, which is
84/// locale-independent; `en-US` is the catalog's declared surface).
85pub 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
102/// OPY names the parser treats as keywords or literals; a Workshop table name
103/// that collides with one of these cannot be referenced or declared faithfully.
104const 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
129/// Binary operator spellings the OPY frontend lowers to `Value::Call`s with
130/// the same name (source operators, not Workshop spellings like `add`).
131const BINARY_OPS: &[&str] = &[
132    "+", "-", "*", "/", "%", "**", "==", "!=", "<", "<=", ">", ">=", "and", "or",
133];
134
135/// Call names the frontend lowers to dedicated Workshop actions or values.
136const 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, for call-vs-subroutine ambiguity checks.
147    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    // ---- diagnostics ----
192
193    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    // ---- table validation ----
210
211    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        // Global table: unique names, valid OPY identifiers, non-decreasing
220        // slot order (the frontend's re-lowering sorts the table by index,
221        // so only slot-ordered input reproduces the same table).
222        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        // Player table: unique names and valid identifiers; player slots are
243        // explicit in the `playervar name <index>` form, so no order rule.
244        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        // Subroutine table: unique names, valid identifiers, and indices
249        // exactly equal to table position (the OPY `subroutine name`
250        // declaration cannot carry an index; the re-lowered index is the
251        // table position).
252        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    /// Whether a name repeats an earlier entry of its table (duplicates
293    /// cannot be declared or referenced faithfully on the OPY surface).
294    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    /// The canonical rule layout: optional leading initializer rules, then
334    /// all subroutine-body rules in subroutine table order, then the normal
335    /// rules. Any other arrangement cannot be reproduced by the frontend's
336    /// deterministic re-lowering and is rejected.
337    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        // Subroutine rules must be in subroutine table order, and each rule
407        // must carry the exact name the re-lowering synthesizes for its def.
408        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    // ---- emission ----
515
516    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        // Declarations.
522        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        // Subroutine bodies.
536        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        // Rules.
560        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    /// Map initializer rule actions onto declaration positions (table order),
648    /// validating the rule's Sets are in table order like the frontend's
649    /// synthesized initializer rule.
650    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    /// A declaration initializer: same value emission, but zero literals are
744    /// spelled `0.0` because the frontend drops integer-`0` initializers
745    /// (matching the reference adapter).
746    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    /// The OPY declaration `globalvar name = value` cannot carry an explicit
757    /// slot, so the frontend re-lowering assigns the lowest free slot. An
758    /// initializer-bearing global is only representable when that slot equals
759    /// its Workshop index; otherwise the reconstructed table would differ.
760    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    /// `x = x <op> v` (or the player form) re-lowers to a Modify action, so a
1144    /// Set whose value matches the pattern cannot be reconstructed as a Set.
1145    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    /// A generic or member action call in statement position.
1247    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        // `name` is a canonical Workshop call identity, so an entry that
1298        // links to it through `catalogId` is the same builtin under its
1299        // upstream source spelling (`id`). Distinct spellings may share one
1300        // canonical id (`getPlayers` aliases `getAllPlayers`, and
1301        // `getRealPlayersInRadius` expands to a filtered `getPlayersInRadius`
1302        // call); prefer the entry whose own id is the canonical name so the
1303        // emitted spelling reproduces the same bare call.
1304        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    /// Emit a manifest function call with explicit full-arity arguments, no
1365    /// indent and no trailing newline (the caller frames the line). The OPY
1366    /// frontend fills declared defaults at recompile time, so any Workshop call
1367    /// that omits a defaulted or required parameter cannot be reconstructed
1368    /// identically and is rejected.
1369    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        // Every parameter beyond the provided arguments must be omittable
1408        // (`optional`). A required parameter (with or without a declared
1409        // default) cannot be omitted: the OPY frontend would reject it or
1410        // fill its default, changing the resulting Workshop program.
1411        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        // Cross-check through the Workshop catalog: a manifest entry with a
1435        // declared `catalogId` must resolve there under the matching kind and
1436        // the reconstruction locale (mirroring the manifest's own catalog
1437        // cross-check test), so the reconstruction identity layer never
1438        // drifts from the catalog.
1439        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    /// Validate a provided argument against its manifest parameter: enum
1481    /// domains are enforced (like the frontend) and variable-required
1482    /// parameters must be variable references. The variable requirement is
1483    /// typed policy (`policy::variable_args`); the manifest's
1484    /// `param.variable` flag records the same fact descriptively and does
1485    /// not select this check (issue #458).
1486    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    // ---- value emission ----
1566
1567    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        // Reconstruction must emit the OverPy source spelling, not the
1710        // canonical catalog id (`Hero.SOLDIER`, `Map.ROUTE66`, `Clip.NONE`;
1711        // issue #466).
1712        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        // Binary and unary operator calls keep their source spelling.
1732        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        // Interpolated strings lower to `customString`; emit the OPY member
1755        // form `"text".format(args...)`.
1756        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        // `getRealPlayersInRadius` shares `getPlayersInRadius`'s catalog id; a
2084        // bare canonical call is the unfiltered builtin, not the macro that
2085        // would recompile to an extra `Filtered Array` wrapper.
2086        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}