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