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