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