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