Skip to main content

workshop_rs/
emitter.rs

1//! Deterministic localized Workshop emitter.
2//!
3//! Serializes validated Workshop IR into localized Workshop text with a
4//! selectable output locale. Canonical catalog identities resolve to
5//! locale-specific spellings; missing target-locale mappings fail explicitly
6//! with a [`WorkshopError::MissingMapping`] diagnostic — never a guess, never
7//! a silent passthrough of another locale's spelling. Fallback to another
8//! declared locale is opt-in ([`EmitOptions`]) and every fell-back identity
9//! is recorded in [`EmitOutput::fallback_ids`]. The formatting is fixed and
10//! presentation-canonical, so the same WIR/config emits byte-stable text that
11//! reparses to equivalent WIR — except for the `settings` section:
12//! settings-bearing emissions are deliberately rejected by the Workshop
13//! parser (a `.ws` decompiler is a non-goal). Settings names are resolved from
14//! the generated locale corpus, with an explicit `en-US` fallback when needed.
15
16use std::fmt::Write;
17
18use crate::catalog::{Catalog, Kind, Locale};
19use crate::error::{Result, WorkshopError};
20use crate::format::format_number;
21use crate::settings::table::{self, KeyKind, PathPart};
22use crate::settings::{Settings as SettingsTree, SettingsNode};
23use crate::wir;
24
25/// The number of native Workshop actions emitted by a canonical WIR action
26/// sequence.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct ActionLayout {
29    /// The action count in the canonical native action stream.
30    pub width: usize,
31}
32
33/// Errors returned while querying canonical native action layout.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum ActionLayoutError {
36    /// The WIR does not satisfy its structural invariants.
37    InvalidWIR(crate::wir::error::IrError),
38    /// Canonical emission could not expand the requested actions.
39    Emission(WorkshopError),
40}
41
42impl std::fmt::Display for ActionLayoutError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::InvalidWIR(error) => write!(f, "invalid WIR: {error}"),
46            Self::Emission(error) => write!(f, "action layout emission failed: {error}"),
47        }
48    }
49}
50
51impl std::error::Error for ActionLayoutError {}
52
53/// Query the native Workshop action width of a validated WIR action sequence.
54///
55/// The sequence is expanded using the same recursive action implementation as
56/// [`emit`]. Every action in the sequence is treated as non-rule-final, which
57/// is the canonical stream contract needed for relative action offsets. The
58/// returned width counts native Workshop action lines, including structural
59/// headers and terminators.
60pub fn action_width(
61    program: &wir::Program,
62    catalog: &Catalog,
63    locale: &Locale,
64    actions: &[wir::ActionId],
65) -> std::result::Result<ActionLayout, ActionLayoutError> {
66    program.validate().map_err(ActionLayoutError::InvalidWIR)?;
67    let mut emitter = Emitter {
68        program,
69        catalog,
70        locale: locale.clone(),
71        fallback: None,
72        fallback_ids: Vec::new(),
73        force_hero_constructors: false,
74        out: String::new(),
75        line_count: 0,
76    };
77    for action in actions {
78        emitter
79            .action(*action, 0, false)
80            .map_err(ActionLayoutError::Emission)?;
81    }
82    Ok(ActionLayout {
83        width: emitter.line_count,
84    })
85}
86
87/// Emission options: opt-in fallback for missing target-locale mappings.
88#[derive(Debug, Clone, Default, PartialEq, Eq)]
89pub struct EmitOptions {
90    /// When a canonical identity has no spelling for the target locale, its
91    /// spelling in this declared locale is used instead. `None` (the default)
92    /// keeps missing mappings failing explicitly. The fallback choice is
93    /// visible in [`EmitOutput::fallback_ids`].
94    pub fallback_locale: Option<Locale>,
95}
96
97/// The result of a localized emission.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct EmitOutput {
100    /// The emitted localized Workshop text.
101    pub text: String,
102    /// Canonical identities (and the `settings` marker) whose spelling came
103    /// from the opt-in fallback locale instead of the target locale. Empty
104    /// when no fallback occurred.
105    pub fallback_ids: Vec<String>,
106}
107
108/// Emit a Workshop IR program as localized Workshop text, failing explicitly
109/// on any missing target-locale mapping (no fallback).
110pub fn emit(program: &wir::Program, catalog: &Catalog, locale: &Locale) -> Result<String> {
111    emit_with_options(program, catalog, locale, &EmitOptions::default()).map(|out| out.text)
112}
113
114/// Emit a Workshop IR program as localized Workshop text with emission
115/// options (opt-in fallback locale).
116pub fn emit_with_options(
117    program: &wir::Program,
118    catalog: &Catalog,
119    locale: &Locale,
120    options: &EmitOptions,
121) -> Result<EmitOutput> {
122    emit_with_options_inner(program, catalog, locale, options, false)
123}
124
125pub(crate) fn emit_with_options_for_conversion(
126    program: &wir::Program,
127    catalog: &Catalog,
128    locale: &Locale,
129    options: &EmitOptions,
130) -> Result<EmitOutput> {
131    emit_with_options_inner(program, catalog, locale, options, true)
132}
133
134fn emit_with_options_inner(
135    program: &wir::Program,
136    catalog: &Catalog,
137    locale: &Locale,
138    options: &EmitOptions,
139    force_hero_constructors: bool,
140) -> Result<EmitOutput> {
141    let mut emitter = Emitter {
142        program,
143        catalog,
144        locale: locale.clone(),
145        fallback: options.fallback_locale.clone(),
146        force_hero_constructors,
147        fallback_ids: Vec::new(),
148        out: String::new(),
149        line_count: 0,
150    };
151    emitter.run()?;
152    Ok(EmitOutput {
153        text: emitter.out,
154        fallback_ids: emitter.fallback_ids,
155    })
156}
157
158struct Emitter<'a> {
159    program: &'a wir::Program,
160    catalog: &'a Catalog,
161    locale: Locale,
162    /// The opt-in fallback locale for missing target-locale mappings.
163    fallback: Option<Locale>,
164    /// Canonical ids emitted with a fallback-locale spelling.
165    fallback_ids: Vec<String>,
166    force_hero_constructors: bool,
167    out: String,
168    line_count: usize,
169}
170
171impl Emitter<'_> {
172    fn run(&mut self) -> Result<()> {
173        // Section order: settings, variables, subroutines, rules.
174        if let Some(settings) = &self.program.settings {
175            self.emit_settings(settings)?;
176            self.out.push('\n');
177        }
178        if !self.program.global_variables.is_empty() || !self.program.player_variables.is_empty() {
179            let variables = self.structural("variables")?;
180            self.line(0, &format!("{variables} {{"))?;
181            if !self.program.global_variables.is_empty() {
182                let global = self.structural("global")?;
183                self.line(1, &format!("{global}:"))?;
184                for variable in self.program.global_variables.iter() {
185                    self.line(2, &format!("{}: {}", variable.index, variable.name))?;
186                }
187            }
188            if !self.program.player_variables.is_empty() {
189                let player = self.structural("player")?;
190                self.line(1, &format!("{player}:"))?;
191                for variable in self.program.player_variables.iter() {
192                    self.line(2, &format!("{}: {}", variable.index, variable.name))?;
193                }
194            }
195            self.line(0, "}")?;
196            self.out.push('\n');
197        }
198        if !self.program.subroutines.is_empty() {
199            let subroutines = self.structural("subroutines")?;
200            self.line(0, &format!("{subroutines} {{"))?;
201            for subroutine in self.program.subroutines.iter() {
202                self.line(1, &format!("{}: {}", subroutine.index, subroutine.name))?;
203            }
204            self.line(0, "}")?;
205            self.out.push('\n');
206        }
207        for (emitted_rules, rule) in self.program.rules.iter().enumerate() {
208            if emitted_rules > 0 {
209                self.out.push('\n');
210            }
211            self.rule(rule)?;
212        }
213        // The oracle's raw artifact ends with a trailing blank line (the
214        // committed snapshots strip it via the acquisition normalizer; the
215        // pinned oracle's own output keeps it).
216        if !self.out.is_empty() && !self.out.ends_with("\n\n") {
217            self.out.push('\n');
218        }
219        Ok(())
220    }
221
222    /// Emit the `settings { ... }` section from the validated settings
223    /// carrier, table-driven (fixture-evidenced names). Only runs on
224    /// validated programs, so unknown keys cannot reach this point.
225    fn emit_settings(&mut self, settings: &SettingsTree) -> Result<()> {
226        let settings_keyword = self.structural("settings")?;
227        self.line(0, &format!("{settings_keyword} {{"))?;
228        for child in &settings.children {
229            if let SettingsNode::Workshop { children, .. } = child {
230                self.emit_workshop_settings(children, 1)?;
231                continue;
232            }
233            let SettingsNode::Group { name, children, .. } = child else {
234                return Err(self.malformed("settings block children must be groups"));
235            };
236            match name.as_str() {
237                "main" | "lobby" => {
238                    self.line(1, &format!("{name} {{"))?;
239                    for member in children {
240                        self.settings_member(member, 2, &[PathPart::Part(name)], None)?;
241                    }
242                    self.line(1, "}")?;
243                }
244                "gamemodes" => self.emit_modes(children)?,
245                "heroes" => self.emit_heroes(children)?,
246                "extensions" => {
247                    self.line(1, "extensions {")?;
248                    for member in children {
249                        self.settings_member(member, 2, &[PathPart::Part("extensions")], None)?;
250                    }
251                    self.line(1, "}")?;
252                }
253                _ => self.emit_opaque_group(children, name, 1)?,
254            }
255        }
256        self.line(0, "}")?;
257        Ok(())
258    }
259
260    fn emit_workshop_settings(&mut self, children: &[SettingsNode], level: usize) -> Result<()> {
261        let workshop = self.structural("workshop")?;
262        self.line(level, &format!("{workshop} {{"))?;
263        for child in children {
264            self.emit_workshop_node(child, level + 1)?;
265        }
266        self.line(level, "}")?;
267        Ok(())
268    }
269
270    fn emit_workshop_node(&mut self, node: &SettingsNode, level: usize) -> Result<()> {
271        match node {
272            SettingsNode::Group { name, children, .. } => {
273                self.line(level, &format!("{name} {{"))?;
274                for child in children {
275                    self.emit_workshop_node(child, level + 1)?;
276                }
277                self.line(level, "}")?;
278                Ok(())
279            }
280            SettingsNode::Workshop { children, .. } => self.emit_workshop_settings(children, level),
281            SettingsNode::Raw { name, value, .. } => {
282                if value.is_empty() {
283                    self.line(level, name)
284                } else {
285                    self.line(level, &format!("{name}: {value}"))
286                }
287            }
288            _ => Err(self.malformed("settings.workshop contains a typed builtin setting")),
289        }
290    }
291
292    /// Emit the `modes { <Mode> { ... } }` block of a gamemodes group.
293    fn emit_modes(&mut self, modes: &[SettingsNode]) -> Result<()> {
294        self.line(1, "modes {")?;
295        for mode in modes {
296            let SettingsNode::Group { name, children, .. } = mode else {
297                return Err(self.malformed("mode entries must be groups"));
298            };
299            let display = match table::mode_name(name) {
300                Some(english) => self.setting_name("modes", english, &format!("mode.{name}"))?,
301                None => name.clone(),
302            };
303            // `enabled: false` prefixes the mode header; true renders with no
304            // prefix (only false is evidenced in the corpus, #86).
305            let disabled = children.iter().any(|member| {
306                matches!(
307                    member,
308                    SettingsNode::Bool { name: n, value: false, .. } if n == "enabled"
309                )
310            });
311            let header = if disabled {
312                let disabled_name = self.setting_name("tokens", "disabled", "token.disabled")?;
313                format!("{disabled_name} {display}")
314            } else {
315                display
316            };
317            self.line(2, &format!("{header} {{"))?;
318            for member in children {
319                if matches!(member, SettingsNode::Bool { name: n, .. } if n == "enabled") {
320                    continue;
321                }
322                self.settings_member(
323                    member,
324                    3,
325                    &[PathPart::Part("gamemodes"), PathPart::Part(name)],
326                    None,
327                )?;
328            }
329            self.line(2, "}")?;
330        }
331        self.line(1, "}")?;
332        Ok(())
333    }
334
335    /// Emit the `heroes { <Team> { ... } }` block of a heroes group.
336    fn emit_heroes(&mut self, teams: &[SettingsNode]) -> Result<()> {
337        self.line(1, "heroes {")?;
338        for team in teams {
339            let SettingsNode::Group { name, children, .. } = team else {
340                return Err(self.malformed("team entries must be groups"));
341            };
342            let english = table::team_name(name)
343                .ok_or_else(|| self.malformed(format!("unknown team '{name}'")))?;
344            let display = self.setting_name("teams", english, &format!("team.{name}"))?;
345            self.line(2, &format!("{display} {{"))?;
346            for member in children {
347                match member {
348                    SettingsNode::Group { name, children, .. } => {
349                        let english = table::hero_name(name)
350                            .ok_or_else(|| self.malformed(format!("unknown hero '{name}'")))?;
351                        let hero = self.setting_name("heroes", english, &format!("hero.{name}"))?;
352                        self.line(3, &format!("{hero} {{"))?;
353                        for inner in children {
354                            self.settings_member(
355                                inner,
356                                4,
357                                &[PathPart::Part("heroes"), PathPart::Team, PathPart::Hero],
358                                Some(name),
359                            )?;
360                        }
361                        self.line(3, "}")?;
362                    }
363                    other => self.settings_member(
364                        other,
365                        3,
366                        &[PathPart::Part("heroes"), PathPart::Team],
367                        None,
368                    )?,
369                }
370            }
371            self.line(2, "}")?;
372        }
373        self.line(1, "}")?;
374        Ok(())
375    }
376
377    /// Emit one leaf-level settings member (`Name: value`, lists as blocks).
378    fn settings_member(
379        &mut self,
380        node: &SettingsNode,
381        level: usize,
382        path: &[PathPart],
383        hero: Option<&str>,
384    ) -> Result<()> {
385        if let SettingsNode::Raw { name, value, .. } = node {
386            if value.is_empty() {
387                self.line(level, name)?;
388            } else {
389                self.line(level, &format!("{name}: {value}"))?;
390            }
391            return Ok(());
392        }
393        let name = node.name();
394        let mut full = path.to_vec();
395        full.push(PathPart::Part(name));
396        let entry = table::lookup(&full).ok_or_else(|| {
397            self.malformed(format!(
398                "settings key '{}' is outside the emission table",
399                table::path_string(&full)
400            ))
401        })?;
402        let display_name = if let (Some(hero), Some(key)) = (
403            hero,
404            full.last().and_then(|part| match part {
405                PathPart::Part(key) => Some(*key),
406                _ => None,
407            }),
408        ) {
409            if let Some(name) = table::hero_setting_name(hero, key, self.locale.as_str()) {
410                name.to_string()
411            } else if !matches!(
412                key,
413                "enableAbility1" | "enableAbility2" | "enableAbility3" | "enableSecondaryFire"
414            ) {
415                if let Some(slot) = table::ability_slot_for_path(&full) {
416                    self.gameplay_setting_name(hero, slot, &table::path_string(&full))?
417                } else {
418                    self.setting_name("labels", entry.workshop_name, &table::path_string(&full))?
419                }
420            } else {
421                self.setting_name("labels", entry.workshop_name, &table::path_string(&full))?
422            }
423        } else if let (Some(hero), Some(slot)) = (hero, table::ability_slot_for_path(&full)) {
424            self.gameplay_setting_name(hero, slot, &table::path_string(&full))?
425        } else {
426            self.setting_name("labels", entry.workshop_name, &table::path_string(&full))?
427        };
428        match (node, &entry.kind) {
429            (SettingsNode::Flag { .. }, KeyKind::Flag) => {
430                self.line(level, &display_name)?;
431            }
432            (SettingsNode::String { value, .. }, KeyKind::String) => {
433                self.line(
434                    level,
435                    &format!("{}: \"{}\"", display_name, escape_settings_string(value)),
436                )?;
437            }
438            (SettingsNode::String { value, .. }, KeyKind::Enum(domain)) => {
439                let english = table::enum_name(domain, value).ok_or_else(|| {
440                    self.malformed(format!("unknown value '{value}' for settings key '{name}'"))
441                })?;
442                let display =
443                    self.setting_name("enums", english, &format!("enum.{domain}.{value}"))?;
444                self.line(level, &format!("{display_name}: {display}"))?;
445            }
446            (SettingsNode::Number { value, .. }, KeyKind::Number) => {
447                self.line(level, &format!("{display_name}: {}", format_number(*value)))?;
448            }
449            (SettingsNode::Number { value, .. }, KeyKind::Percent) => {
450                self.line(
451                    level,
452                    &format!("{display_name}: {}%", format_number(*value)),
453                )?;
454            }
455            (SettingsNode::Bool { value, .. }, KeyKind::Bool) => {
456                let rendered = self.setting_name(
457                    "tokens",
458                    if *value { "On" } else { "Off" },
459                    if *value { "token.on" } else { "token.off" },
460                )?;
461                self.line(level, &format!("{display_name}: {rendered}"))?;
462            }
463            (SettingsNode::Bool { value, .. }, KeyKind::BoolEnum(domain)) => {
464                if !*value {
465                    return Err(self
466                        .malformed(format!("unsupported false value for settings key '{name}'")));
467                }
468                let english = table::enum_name(domain, "enabled").ok_or_else(|| {
469                    self.malformed(format!("unknown value 'enabled' for settings key '{name}'"))
470                })?;
471                let rendered =
472                    self.setting_name("enums", english, &format!("enum.{domain}.enabled"))?;
473                self.line(level, &format!("{display_name}: {rendered}"))?;
474            }
475            (SettingsNode::List { elements, .. }, KeyKind::ListMap) => {
476                self.line(level, &format!("{display_name} {{"))?;
477                for element in elements {
478                    let english = table::map_name(&element.value).ok_or_else(|| {
479                        self.malformed(format!(
480                            "unknown map '{}' in settings list '{name}'",
481                            element.value
482                        ))
483                    })?;
484                    let display =
485                        self.setting_name("maps", english, &format!("map.{}.name", element.value))?;
486                    self.line(level + 1, &display)?;
487                }
488                self.line(level, "}")?;
489            }
490            (SettingsNode::List { elements, .. }, KeyKind::ListHero) => {
491                self.line(level, &format!("{display_name} {{"))?;
492                for element in elements {
493                    let english = table::hero_name(&element.value).ok_or_else(|| {
494                        self.malformed(format!(
495                            "unknown hero '{}' in settings list '{name}'",
496                            element.value
497                        ))
498                    })?;
499                    let display = self.setting_name(
500                        "heroes",
501                        english,
502                        &format!("hero.{}.name", element.value),
503                    )?;
504                    self.line(level + 1, &display)?;
505                }
506                self.line(level, "}")?;
507            }
508            _ => {
509                return Err(self.malformed(format!(
510                    "settings key '{name}' does not match its table kind"
511                )));
512            }
513        }
514        Ok(())
515    }
516
517    fn emit_opaque_group(
518        &mut self,
519        children: &[SettingsNode],
520        name: &str,
521        level: usize,
522    ) -> Result<()> {
523        self.line(level, &format!("{name} {{"))?;
524        for child in children {
525            match child {
526                SettingsNode::Group { name, children, .. } => {
527                    self.emit_opaque_group(children, name, level + 1)?;
528                }
529                _ => self.settings_member(child, level + 1, &[], None)?,
530            }
531        }
532        self.line(level, "}")?;
533        Ok(())
534    }
535
536    /// Resolve a settings spelling from the generated locale corpus. The
537    /// English table remains the explicit fallback only when the caller opts
538    /// into `en-US`, matching the catalog's missing-mapping contract.
539    fn gameplay_setting_name(&mut self, hero: &str, slot: &str, id: &str) -> Result<String> {
540        let resolve = |locale: &Locale| {
541            crate::gameplay_data::builtin().ok().and_then(|catalog| {
542                catalog
543                    .query()
544                    .ability_name(hero, slot, None, locale.as_str())
545                    .ok()
546                    .map(str::to_string)
547            })
548        };
549        if let Some(name) = resolve(&self.locale) {
550            return Ok(name);
551        }
552        if let Some(fallback) = &self.fallback {
553            if let Some(name) = resolve(fallback) {
554                if !self.fallback_ids.iter().any(|value| value == "settings") {
555                    self.fallback_ids.push("settings".to_string());
556                }
557                return Ok(name);
558            }
559        }
560        Err(WorkshopError::MissingMapping {
561            kind: "setting",
562            id: id.to_string(),
563            locale: self.locale.clone(),
564        })
565    }
566
567    fn setting_name(&mut self, section: &str, english: &str, id: &str) -> Result<String> {
568        let en_us = Locale::new("en-US");
569        if self.locale == en_us {
570            return Ok(english.to_string());
571        }
572        if let Some(spelling) = table::localized_name(self.locale.as_str(), section, english) {
573            return Ok(spelling.to_string());
574        }
575        if let Some(fallback) = &self.fallback {
576            if *fallback == en_us {
577                if !self.fallback_ids.iter().any(|value| value == "settings") {
578                    self.fallback_ids.push("settings".to_string());
579                }
580                return Ok(english.to_string());
581            }
582        }
583        Err(WorkshopError::MissingMapping {
584            kind: "setting",
585            id: id.to_string(),
586            locale: self.locale.clone(),
587        })
588    }
589
590    fn malformed(&self, message: impl Into<String>) -> WorkshopError {
591        WorkshopError::Malformed {
592            message: message.into(),
593            span: None,
594        }
595    }
596
597    fn rule(&mut self, rule: &wir::Rule) -> Result<()> {
598        let disabled = if rule.disabled {
599            format!("{} ", self.structural("disabled")?)
600        } else {
601            String::new()
602        };
603        let rule_keyword = self.structural("rule")?;
604        self.line(
605            0,
606            &format!(
607                "{disabled}{rule_keyword} (\"{}\") {{",
608                escape_string(&rule.name)
609            ),
610        )?;
611        let event = self.structural("event")?;
612        self.line(1, &format!("{event} {{"))?;
613        match &rule.event {
614            wir::Event::Global => {
615                let spelling = self.spelling(Kind::Event, "global")?;
616                self.line(2, &format!("{spelling};"))?;
617            }
618            wir::Event::EachPlayer => {
619                let spelling = self.spelling(Kind::Event, "eachPlayer")?;
620                self.line(2, &format!("{spelling};"))?;
621                self.event_filters(wir::EventTeam::All, &wir::EventTarget::All)?;
622            }
623            wir::Event::EachPlayerWithFilters { team, target } => {
624                let spelling = self.spelling(Kind::Event, "eachPlayer")?;
625                self.line(2, &format!("{spelling};"))?;
626                self.event_filters(*team, target)?;
627            }
628            wir::Event::Player { kind, team, target } => {
629                let spelling = self.spelling(Kind::Event, kind.catalog_id())?;
630                self.line(2, &format!("{spelling};"))?;
631                self.event_filters(*team, target)?;
632            }
633            wir::Event::Subroutine(subroutine) => {
634                let spelling = self.spelling(Kind::Event, "subroutine")?;
635                self.line(2, &format!("{spelling};"))?;
636                let name = self
637                    .program
638                    .subroutines
639                    .get(*subroutine)
640                    .map(|s| s.name.clone())
641                    .unwrap_or_else(|| "<dangling>".to_string());
642                self.line(2, &format!("{name};"))?;
643            }
644        }
645        self.line(1, "}")?;
646        if !rule.conditions.is_empty() {
647            let conditions = self.structural("conditions")?;
648            self.line(1, &format!("{conditions} {{"))?;
649            for condition in &rule.conditions {
650                let mut text = String::new();
651                // Reference normalization: comparison conditions render
652                // infix; other conditions render as `value == True`.
653                if let Some(wir::Value::Call { name, args }) =
654                    self.program.values.get(*condition).map(|node| &node.value)
655                {
656                    if is_comparison_operator(name) && args.len() == 2 {
657                        self.value(args[0], &mut text)?;
658                        write!(text, " {name} ").unwrap();
659                        self.value(args[1], &mut text)?;
660                    } else {
661                        self.value(*condition, &mut text)?;
662                        text.push_str(" == True");
663                    }
664                } else {
665                    self.value(*condition, &mut text)?;
666                    text.push_str(" == True");
667                }
668                self.line(2, &format!("{text};"))?;
669            }
670            self.line(1, "}")?;
671        }
672        if !rule.actions.is_empty() {
673            let actions = self.structural("actions")?;
674            self.line(1, &format!("{actions} {{"))?;
675            for (index, action) in rule.actions.iter().enumerate() {
676                let rule_final = index + 1 == rule.actions.len();
677                self.action(*action, 2, rule_final)?;
678            }
679            self.line(1, "}")?;
680        }
681        self.line(0, "}")?;
682        Ok(())
683    }
684
685    fn event_filters(&mut self, team: wir::EventTeam, target: &wir::EventTarget) -> Result<()> {
686        let team = match team {
687            wir::EventTeam::All => "ALL",
688            wir::EventTeam::Team1 => "TEAM_1",
689            wir::EventTeam::Team2 => "TEAM_2",
690        };
691        let team = self.enum_spelling("EventTeam", team)?;
692        self.line(2, &format!("{team};"))?;
693        let target = match target {
694            wir::EventTarget::All => self.enum_spelling("EventPlayer", "ALL")?,
695            wir::EventTarget::Slot(slot) => {
696                self.enum_spelling("EventPlayer", &format!("SLOT_{slot}"))?
697            }
698            wir::EventTarget::Hero(hero) => self.enum_spelling("Hero", hero)?,
699        };
700        self.line(2, &format!("{target};"))?;
701        Ok(())
702    }
703
704    /// Emit one rule action; `rule_final` marks the last action of the rule,
705    /// for which an `if`/`if-else` closes without the trailing `End;`
706    /// (the pinned oracle's spelling, #87).
707    fn action(&mut self, id: wir::ActionId, level: usize, rule_final: bool) -> Result<()> {
708        let Some(action) = self.program.actions.get(id) else {
709            return Err(WorkshopError::Malformed {
710                message: format!("dangling action {id}"),
711                span: None,
712            });
713        };
714        match action {
715            wir::Action::SetGlobalVariable {
716                variable, value, ..
717            } => {
718                let name = self.global_name(*variable)?;
719                let mut value_text = String::new();
720                self.value(*value, &mut value_text)?;
721                let keyword = self.spelling(Kind::Structural, "setGlobalVariable")?;
722                self.line(level, &format!("{keyword}({name}, {value_text});"))?;
723            }
724            wir::Action::ModifyGlobalVariable {
725                variable,
726                op,
727                value,
728                ..
729            } => {
730                let name = self.global_name(*variable)?;
731                let op = self.modify_op_spelling(*op)?;
732                let mut value_text = String::new();
733                self.value(*value, &mut value_text)?;
734                let keyword = self.spelling(Kind::Structural, "modifyGlobalVariable")?;
735                self.line(level, &format!("{keyword}({name}, {op}, {value_text});"))?;
736            }
737            wir::Action::SetPlayerVariable {
738                player,
739                variable,
740                value,
741                ..
742            } => {
743                let mut player_text = String::new();
744                self.value(*player, &mut player_text)?;
745                let name = self.player_name(*variable)?;
746                let mut value_text = String::new();
747                self.value(*value, &mut value_text)?;
748                let keyword = self.spelling(Kind::Structural, "setPlayerVariable")?;
749                self.line(
750                    level,
751                    &format!("{keyword}({player_text}, {name}, {value_text});"),
752                )?;
753            }
754            wir::Action::ModifyPlayerVariable {
755                player,
756                variable,
757                op,
758                value,
759                ..
760            } => {
761                let mut player_text = String::new();
762                self.value(*player, &mut player_text)?;
763                let name = self.player_name(*variable)?;
764                let op = self.modify_op_spelling(*op)?;
765                let mut value_text = String::new();
766                self.value(*value, &mut value_text)?;
767                let keyword = self.spelling(Kind::Structural, "modifyPlayerVariable")?;
768                self.line(
769                    level,
770                    &format!("{keyword}({player_text}, {name}, {op}, {value_text});"),
771                )?;
772            }
773            wir::Action::CallSubroutine { subroutine, .. } => {
774                let name = self
775                    .program
776                    .subroutines
777                    .get(*subroutine)
778                    .map(|s| s.name.clone())
779                    .ok_or_else(|| WorkshopError::Unknown {
780                        kind: "subroutine",
781                        spelling: format!("<{subroutine}>"),
782                        locale: self.locale.clone(),
783                        span: None,
784                    })?;
785                let keyword = self.spelling(Kind::Structural, "callSubroutine")?;
786                self.line(level, &format!("{keyword}({name});"))?;
787            }
788            wir::Action::If {
789                branches,
790                else_body,
791                ..
792            } => {
793                for (index, branch) in branches.iter().enumerate() {
794                    let mut condition = String::new();
795                    self.value(branch.condition, &mut condition)?;
796                    let keyword =
797                        self.spelling(Kind::Structural, if index == 0 { "if" } else { "elseIf" })?;
798                    self.line(level, &format!("{keyword}({condition});"))?;
799                    for action in &branch.body {
800                        self.action(*action, level + 1, false)?;
801                    }
802                }
803                if let Some(else_body) = else_body {
804                    let keyword = self.spelling(Kind::Structural, "else")?;
805                    self.line(level, &format!("{keyword};"))?;
806                    for action in else_body {
807                        self.action(*action, level + 1, false)?;
808                    }
809                }
810                // A rule-final if closes the rule without `End;` (oracle
811                // spelling); nested and middle-of-rule ifs keep it.
812                if !rule_final {
813                    let keyword = self.spelling(Kind::Structural, "end")?;
814                    self.line(level, &format!("{keyword};"))?;
815                }
816            }
817            wir::Action::While {
818                condition, body, ..
819            } => {
820                let mut text = String::new();
821                self.value(*condition, &mut text)?;
822                let keyword = self.spelling(Kind::Structural, "while")?;
823                self.line(level, &format!("{keyword}({text});"))?;
824                for action in body {
825                    self.action(*action, level + 1, false)?;
826                }
827                let end = self.spelling(Kind::Structural, "end")?;
828                self.line(level, &format!("{end};"))?;
829            }
830            wir::Action::ForGlobalVariable {
831                variable,
832                start,
833                stop,
834                step,
835                body,
836                ..
837            } => {
838                let name = self.global_name(*variable)?;
839                let mut start_text = String::new();
840                let mut stop_text = String::new();
841                let mut step_text = String::new();
842                self.value(*start, &mut start_text)?;
843                self.value(*stop, &mut stop_text)?;
844                self.value(*step, &mut step_text)?;
845                let keyword = self.spelling(Kind::Structural, "forGlobalVariable")?;
846                self.line(
847                    level,
848                    &format!("{keyword}({name}, {start_text}, {stop_text}, {step_text});"),
849                )?;
850                for action in body {
851                    self.action(*action, level + 1, false)?;
852                }
853                let end = self.spelling(Kind::Structural, "end")?;
854                self.line(level, &format!("{end};"))?;
855            }
856            wir::Action::ForPlayerVariable {
857                player,
858                variable,
859                start,
860                stop,
861                step,
862                body,
863                ..
864            } => {
865                let keyword = self.structural("forPlayerVariable")?;
866                let mut player_text = String::new();
867                let mut start_text = String::new();
868                let mut stop_text = String::new();
869                let mut step_text = String::new();
870                self.value(*player, &mut player_text)?;
871                self.value(*start, &mut start_text)?;
872                self.value(*stop, &mut stop_text)?;
873                self.value(*step, &mut step_text)?;
874                let name = self.player_name(*variable)?;
875                self.line(
876                    level,
877                    &format!(
878                        "{}({player_text}, {name}, {start_text}, {stop_text}, {step_text});",
879                        keyword
880                    ),
881                )?;
882                for action in body {
883                    self.action(*action, level + 1, false)?;
884                }
885                let end = self.spelling(Kind::Structural, "end")?;
886                self.line(level, &format!("{end};"))?;
887            }
888            wir::Action::AssignMember {
889                target, op, value, ..
890            } => {
891                let mut target_text = String::new();
892                let mut value_text = String::new();
893                self.value(*target, &mut target_text)?;
894                self.value(*value, &mut value_text)?;
895                let operator = match op {
896                    None => "=".to_string(),
897                    Some(op) => {
898                        let token = match op {
899                            wir::ModifyOp::Add => "+",
900                            wir::ModifyOp::Subtract => "-",
901                            wir::ModifyOp::Multiply => "*",
902                            wir::ModifyOp::Divide => "/",
903                            wir::ModifyOp::Modulo => "%",
904                            wir::ModifyOp::Min => "min",
905                            wir::ModifyOp::Max => "max",
906                            _ => {
907                                return Err(WorkshopError::Unsupported {
908                                    message: format!(
909                                        "unsupported member assignment operator {op:?}"
910                                    ),
911                                    span: None,
912                                });
913                            }
914                        };
915                        format!("{token}=")
916                    }
917                };
918                self.line(level, &format!("{target_text} {operator} {value_text};"))?;
919            }
920            wir::Action::Call { name, args, .. } => {
921                // The chase family dispatches on the first argument's
922                // variable kind, mirroring the pinned reference: a global
923                // variable emits the global form with the argument list
924                // unchanged; a player variable emits the player form with
925                // the receiver split into `player, name` leading arguments
926                // (the frontend guarantees a variable first argument,
927                // issue #110).
928                if matches!(name.as_str(), "chaseAtRate" | "chaseOverTime") {
929                    let player_var = args.first().and_then(|id| {
930                        self.program
931                            .values
932                            .get(*id)
933                            .and_then(|node| match &node.value {
934                                wir::Value::PlayerVariable { player, variable } => {
935                                    Some((*player, *variable))
936                                }
937                                _ => None,
938                            })
939                    });
940                    let spelling = if let Some((player, variable)) = player_var {
941                        let id = if name == "chaseAtRate" {
942                            "chasePlayerVariableAtRate"
943                        } else {
944                            "chasePlayerVariableOverTime"
945                        };
946                        let spelling = self.spelling(Kind::Action, id)?;
947                        // `Chase Player Variable At Rate(player, name, …)`:
948                        // the receiver splits into `player, name` leading
949                        // arguments (the pinned oracle's spelling).
950                        let mut text = String::new();
951                        self.value(player, &mut text)?;
952                        let mut parts = vec![text, self.player_name(variable)?];
953                        for arg in args.iter().skip(1) {
954                            let mut part = String::new();
955                            self.value(*arg, &mut part)?;
956                            parts.push(part);
957                        }
958                        return self.line(level, &format!("{spelling}({});", parts.join(", ")));
959                    } else {
960                        self.spelling(Kind::Action, name)?
961                    };
962                    let mut args_text = String::new();
963                    self.args(args, &mut args_text)?;
964                    return self.line(level, &format!("{spelling}({args_text});"));
965                }
966                if name == "stopChasingPlayerVariable" {
967                    let Some((player, variable)) = args.first().and_then(|id| {
968                        self.program
969                            .values
970                            .get(*id)
971                            .and_then(|node| match &node.value {
972                                wir::Value::PlayerVariable { player, variable } => {
973                                    Some((*player, *variable))
974                                }
975                                _ => None,
976                            })
977                    }) else {
978                        return Err(WorkshopError::Malformed {
979                            message: "Stop Chasing Player Variable requires a player variable"
980                                .into(),
981                            span: None,
982                        });
983                    };
984                    let spelling = self.spelling(Kind::Action, name)?;
985                    let mut player_text = String::new();
986                    self.value(player, &mut player_text)?;
987                    return self.line(
988                        level,
989                        &format!(
990                            "{spelling}({player_text}, {});",
991                            self.player_name(variable)?
992                        ),
993                    );
994                }
995                // Native `.opy` action names map to canonical catalog ids at
996                // emission (presentation concern).
997                let canonical = match name.as_str() {
998                    "createBeam" => Some("createBeamEffect"),
999                    _ => None,
1000                };
1001                let spelling = if let Some(canonical) = canonical {
1002                    self.spelling(Kind::Action, canonical)?
1003                } else {
1004                    self.spelling(Kind::Action, name)?
1005                };
1006                if args.is_empty() {
1007                    self.line(level, &format!("{spelling};"))?;
1008                } else {
1009                    let mut args_text = String::new();
1010                    for (index, arg) in args.iter().enumerate() {
1011                        if index > 0 {
1012                            args_text.push_str(", ");
1013                        }
1014                        let variable_position = match name.as_str() {
1015                            "setGlobalVariableAtIndex" | "modifyGlobalVariableAtIndex" => {
1016                                index == 0
1017                            }
1018                            "setPlayerVariableAtIndex" | "modifyPlayerVariableAtIndex" => {
1019                                index == 1
1020                            }
1021                            _ => false,
1022                        };
1023                        if variable_position {
1024                            if let Some(node) = self.program.values.get(*arg) {
1025                                match &node.value {
1026                                    wir::Value::GlobalVariable(variable) => {
1027                                        args_text.push_str(&self.global_name(*variable)?);
1028                                        continue;
1029                                    }
1030                                    wir::Value::PlayerVariable { variable, .. } => {
1031                                        args_text.push_str(&self.player_name(*variable)?);
1032                                        continue;
1033                                    }
1034                                    _ => {}
1035                                }
1036                            }
1037                        }
1038                        self.value(*arg, &mut args_text)?;
1039                    }
1040                    self.line(level, &format!("{spelling}({args_text});"))?;
1041                }
1042            }
1043        }
1044        Ok(())
1045    }
1046
1047    fn args(&mut self, args: &[wir::ValueId], out: &mut String) -> Result<()> {
1048        for (index, arg) in args.iter().enumerate() {
1049            if index > 0 {
1050                out.push_str(", ");
1051            }
1052            self.value(*arg, out)?;
1053        }
1054        Ok(())
1055    }
1056
1057    fn value(&mut self, id: wir::ValueId, out: &mut String) -> Result<()> {
1058        let Some(node) = self.program.values.get(id) else {
1059            return Err(WorkshopError::Malformed {
1060                message: format!("dangling value {id}"),
1061                span: None,
1062            });
1063        };
1064        match &node.value {
1065            wir::Value::Number { text, .. } => {
1066                // Literal spellings carry through (the oracle preserves the
1067                // source spelling, e.g. `0.0`; computed values carry the
1068                // formatted spelling, #87).
1069                out.push_str(text);
1070            }
1071            wir::Value::String(value) => {
1072                // Value-position strings wrap in `Custom String("...")` with
1073                // re-escaped content and long-string splitting, the pinned
1074                // oracle's spelling (evidence: array elements, initializers,
1075                // assignments, call arguments, comparisons — #87). The only
1076                // bare string value is the `Custom String` text argument,
1077                // handled in the call arm below.
1078                self.emit_string_value(value, out)?;
1079            }
1080            wir::Value::LocalizedString(id) => {
1081                out.push_str(&self.spelling(Kind::Value, "string")?);
1082                out.push('(');
1083                let spelling = self.localized_string_spelling(id)?;
1084                write!(out, "\"{}\"", escape_value_string(&spelling)).unwrap();
1085                out.push(')');
1086            }
1087            wir::Value::Bool(true) => out.push_str("True"),
1088            wir::Value::Bool(false) => out.push_str("False"),
1089            wir::Value::Null => out.push_str("Null"),
1090            wir::Value::Array(elements) => {
1091                if elements.is_empty() {
1092                    // The canonical empty-array constant (reference emission).
1093                    out.push_str(&self.spelling(Kind::Value, "emptyArray")?);
1094                } else {
1095                    out.push_str(&self.spelling(Kind::Value, "array")?);
1096                    out.push('(');
1097                    self.args(elements, out)?;
1098                    out.push(')');
1099                }
1100            }
1101            wir::Value::Vector { x, y, z } => {
1102                out.push_str(&self.spelling(Kind::Value, "vector")?);
1103                out.push('(');
1104                self.value(*x, out)?;
1105                out.push_str(", ");
1106                self.value(*y, out)?;
1107                out.push_str(", ");
1108                self.value(*z, out)?;
1109                out.push(')');
1110            }
1111            wir::Value::Enum { value_type, value } => {
1112                let spelling = self.enum_spelling(value_type, value)?;
1113                // Color, Team, and Hero values use the constructor form;
1114                // other domains use bare member spellings (the canonical
1115                // corpus form). The
1116                // Team/Color spelling collision (`Team 2` is both a Team and
1117                // a Team color) is the one ambiguity unpinned by the
1118                // catalog's paramDomains, so Team members qualify with the
1119                // constructor form and the emitted text reparses
1120                // deterministically (round-trip contract; pinned P4
1121                // evidence).
1122                if matches!(value_type.as_str(), "Color" | "Map" | "Team")
1123                    || value_type == "Hero"
1124                        && (spelling.contains('.')
1125                            || self.locale != *self.catalog.primary_locale()
1126                            || (self.force_hero_constructors
1127                                && self
1128                                    .program
1129                                    .global_variables
1130                                    .iter()
1131                                    .any(|variable| variable.name == spelling)))
1132                {
1133                    let domain = self
1134                        .catalog
1135                        .enum_domain(value_type)
1136                        .and_then(|entry| entry.spelling(&self.locale))
1137                        .unwrap_or(value_type);
1138                    write!(out, "{domain}({spelling})").unwrap();
1139                } else {
1140                    out.push_str(&spelling);
1141                }
1142            }
1143            wir::Value::GlobalVariable(variable) => {
1144                let name = self.global_name(*variable)?;
1145                write!(out, "Global.{name}").unwrap();
1146            }
1147            wir::Value::PlayerVariable { player, variable } => {
1148                // The oracle's spelling parenthesizes the receiver:
1149                // `Set Global Variable(g, (Event Player).p)` (#87).
1150                out.push('(');
1151                self.value(*player, out)?;
1152                out.push(')');
1153                let name = self.player_name(*variable)?;
1154                write!(out, ".{name}").unwrap();
1155            }
1156            wir::Value::Subroutine(subroutine) => {
1157                let name = self
1158                    .program
1159                    .subroutines
1160                    .get(*subroutine)
1161                    .map(|value| value.name.clone())
1162                    .ok_or_else(|| WorkshopError::Malformed {
1163                        message: format!("dangling subroutine value {subroutine}"),
1164                        span: None,
1165                    })?;
1166                out.push_str(&name);
1167            }
1168            wir::Value::EventPlayer => out.push_str(&self.spelling(Kind::Value, "eventPlayer")?),
1169            wir::Value::Call { name, args } => {
1170                if name == "memberAccess" {
1171                    if args.len() < 2 || args.len() > 3 {
1172                        return Err(WorkshopError::Malformed {
1173                            message: "memberAccess expects two or three arguments".to_string(),
1174                            span: node.span,
1175                        });
1176                    }
1177                    let Some(wir::ValueNode {
1178                        value: wir::Value::String(member),
1179                        ..
1180                    }) = self.program.values.get(args[1])
1181                    else {
1182                        return Err(WorkshopError::Malformed {
1183                            message: "memberAccess member must be a string".to_string(),
1184                            span: node.span,
1185                        });
1186                    };
1187                    let bare_event_player = self
1188                        .program
1189                        .values
1190                        .get(args[0])
1191                        .is_some_and(|node| matches!(node.value, wir::Value::EventPlayer));
1192                    if !bare_event_player {
1193                        out.push('(');
1194                    }
1195                    self.value(args[0], out)?;
1196                    if !bare_event_player {
1197                        out.push(')');
1198                    }
1199                    write!(out, ".{member}").unwrap();
1200                    if let Some(index) = args.get(2) {
1201                        out.push('[');
1202                        self.value(*index, out)?;
1203                        out.push(']');
1204                    }
1205                    return Ok(());
1206                }
1207                if is_comparison_operator(name) {
1208                    // Canonical form: Compare(a, op, b).
1209                    if args.len() != 2 {
1210                        return Err(WorkshopError::Malformed {
1211                            message: format!("comparison call '{name}' must have 2 args"),
1212                            span: None,
1213                        });
1214                    }
1215                    out.push_str(&self.spelling(Kind::Value, "compare")?);
1216                    out.push('(');
1217                    self.value(args[0], out)?;
1218                    write!(out, ", {name}, ").unwrap();
1219                    self.value(args[1], out)?;
1220                    out.push(')');
1221                    return Ok(());
1222                }
1223                // Unary minus renders as Multiply(-1, x); the reference folds
1224                // literal negation, handled by the compat constant-fold pass.
1225                if name == "-" && args.len() == 1 {
1226                    out.push_str(&self.spelling(Kind::Value, "multiply")?);
1227                    out.push_str("(-1, ");
1228                    self.value(args[0], out)?;
1229                    out.push(')');
1230                    return Ok(());
1231                }
1232                // `getAllPlayers()` is OverPy's All Players(All Teams).
1233                if name == "getAllPlayers" && args.is_empty() {
1234                    out.push_str(&self.spelling(Kind::Value, "allPlayers")?);
1235                    out.push('(');
1236                    out.push_str(&self.enum_spelling("Team", "ALL")?);
1237                    out.push(')');
1238                    return Ok(());
1239                }
1240                // Binary arithmetic operators and native `.opy` source names
1241                // map to canonical catalog ids at emission (presentation
1242                // concern; the compat pass folds constants to match the
1243                // reference exactly).
1244                let canonical = match name.as_str() {
1245                    "+" => Some("add"),
1246                    "-" => Some("subtract"),
1247                    "*" => Some("multiply"),
1248                    "/" => Some("divide"),
1249                    "len" => Some("countOf"),
1250                    "abs" => Some("absoluteValue"),
1251                    "sqrt" => Some("squareRoot"),
1252                    "createBeam" => Some("createBeamEffect"),
1253                    "random.uniform" => Some("randomReal"),
1254                    "random.choice" => Some("randomValueInArray"),
1255                    "format" => Some("customString"),
1256                    _ => None,
1257                };
1258                let spelling = if let Some(canonical) = canonical {
1259                    self.spelling(Kind::Value, canonical)?
1260                } else {
1261                    self.spelling(Kind::Value, name)?
1262                };
1263                // `format` (frontend) and `customString` (parsed ws text) are
1264                // the same node.
1265                let is_custom_string = canonical == Some("customString") || name == "customString";
1266                if name == "string" {
1267                    out.push_str(&spelling);
1268                    out.push('(');
1269                    if let Some(first) = args.first() {
1270                        self.localized_string_value(*first, out)?;
1271                        if args.len() > 1 {
1272                            out.push_str(", ");
1273                            self.args(&args[1..], out)?;
1274                        }
1275                    }
1276                    out.push(')');
1277                } else if args.is_empty() {
1278                    // Constants (e.g. Empty Array) emit as bare spellings.
1279                    out.push_str(&spelling);
1280                } else if is_custom_string {
1281                    // `.format()` calls canonicalize: constant numeric
1282                    // arguments fold into the substituted text, implicit
1283                    // `{}` placeholders renumber to the oracle's explicit
1284                    // form, and remaining variable arguments wrap (the
1285                    // oracle spelling, #87). The canonical text feeds the
1286                    // value-string path (re-escaping/splitting) when no
1287                    // arguments remain.
1288                    match self.canonicalize_format_call(args)? {
1289                        Some((text, variable_args)) => {
1290                            if variable_args.is_empty() {
1291                                self.emit_string_value(&text, out)?;
1292                            } else {
1293                                out.push_str(&spelling);
1294                                out.push('(');
1295                                write!(out, "\"{}\"", escape_value_string(&text)).unwrap();
1296                                if !variable_args.is_empty() {
1297                                    out.push_str(", ");
1298                                }
1299                                self.args(&variable_args, out)?;
1300                                out.push(')');
1301                            }
1302                        }
1303                        None => {
1304                            // The `Custom String` text argument stays bare
1305                            // (the oracle spelling); the remaining arguments
1306                            // are values and wrap (#87).
1307                            out.push_str(&spelling);
1308                            out.push('(');
1309                            self.bare_string_value(args[0], out)?;
1310                            if args.len() > 1 {
1311                                out.push_str(", ");
1312                            }
1313                            self.args(&args[1..], out)?;
1314                            out.push(')');
1315                        }
1316                    }
1317                } else {
1318                    out.push_str(&spelling);
1319                    out.push('(');
1320                    self.args(args, out)?;
1321                    out.push(')');
1322                }
1323            }
1324        }
1325        Ok(())
1326    }
1327
1328    fn localized_string_value(&mut self, id: wir::ValueId, out: &mut String) -> Result<()> {
1329        let Some(node) = self.program.values.get(id) else {
1330            return Err(WorkshopError::Malformed {
1331                message: format!("dangling value {id}"),
1332                span: None,
1333            });
1334        };
1335        let wir::Value::LocalizedString(id) = &node.value else {
1336            return Err(WorkshopError::Unsupported {
1337                message: "value 'string' argument 1 must be localized string text".to_string(),
1338                span: node.span,
1339            });
1340        };
1341        let spelling = self.localized_string_spelling(id)?;
1342        write!(out, "\"{}\"", escape_value_string(&spelling)).unwrap();
1343        Ok(())
1344    }
1345
1346    /// Fold a `Custom String` call whose text argument and constant numeric
1347    /// arguments are all literals into the substituted text (the oracle's
1348    /// Canonicalize a `Custom String`/`.format()` call (#87): constant
1349    /// numeric arguments fold into the substituted text (the oracle's
1350    /// spelling), implicit `{}` placeholders renumber positionally to the
1351    /// explicit `{N}` form, and the remaining variable arguments are
1352    /// returned in placeholder order. Returns `None` (rendered unchanged)
1353    /// when nothing canonicalizes: explicit-only texts without constants,
1354    /// texts mixing implicit and explicit placeholders (the oracle rejects
1355    /// those), out-of-range placeholders, or non-String text arguments.
1356    fn canonicalize_format_call(
1357        &self,
1358        args: &[wir::ValueId],
1359    ) -> Result<Option<(String, Vec<wir::ValueId>)>> {
1360        if args.len() < 2 {
1361            return Ok(None);
1362        }
1363        let Some(text) = self.program.values.get(args[0]) else {
1364            return Ok(None);
1365        };
1366        let wir::Value::String(text) = &text.value else {
1367            return Ok(None);
1368        };
1369        let format_args = &args[1..];
1370        // Classify the placeholders: implicit `{}` consumes the next
1371        // argument, explicit `{N}` references argument N.
1372        let mut has_implicit = false;
1373        let mut has_explicit = false;
1374        let mut out_of_range = false;
1375        let mut cursor = 0usize;
1376        let mut chars = text.chars().peekable();
1377        while let Some(ch) = chars.next() {
1378            if ch == '{' {
1379                let mut inner = String::new();
1380                let mut closed = false;
1381                for next in chars.by_ref() {
1382                    if next == '}' {
1383                        closed = true;
1384                        break;
1385                    }
1386                    inner.push(next);
1387                }
1388                if !closed {
1389                    break; // unterminated brace: literal text
1390                }
1391                if inner.is_empty() {
1392                    if cursor >= format_args.len() {
1393                        out_of_range = true;
1394                    }
1395                    cursor += 1;
1396                    has_implicit = true;
1397                } else if inner.chars().all(|c| c.is_ascii_digit()) {
1398                    match inner.parse::<usize>() {
1399                        Ok(index) if index < format_args.len() => has_explicit = true,
1400                        _ => out_of_range = true,
1401                    }
1402                } else {
1403                    out_of_range = true;
1404                }
1405            }
1406        }
1407        if out_of_range || (has_implicit && has_explicit) {
1408            return Ok(None);
1409        }
1410        let mut any_constant = false;
1411        for id in format_args {
1412            let Some(node) = self.program.values.get(*id) else {
1413                return Ok(None);
1414            };
1415            if matches!(node.value, wir::Value::Number { .. }) {
1416                any_constant = true;
1417            }
1418        }
1419        if !has_implicit && !any_constant {
1420            return Ok(None);
1421        }
1422        // Canonicalize: fold constants inline at their placeholder, renumber
1423        // variable placeholders positionally, keep variable arguments in
1424        // placeholder order.
1425        let mut canonical = String::with_capacity(text.len());
1426        let mut variable_args = Vec::new();
1427        let mut variable_index = 0usize;
1428        let mut cursor = 0usize;
1429        let mut chars = text.chars().peekable();
1430        while let Some(ch) = chars.next() {
1431            if ch == '{' {
1432                let mut inner = String::new();
1433                let mut closed = false;
1434                for next in chars.by_ref() {
1435                    if next == '}' {
1436                        closed = true;
1437                        break;
1438                    }
1439                    inner.push(next);
1440                }
1441                if !closed {
1442                    canonical.push('{');
1443                    canonical.push_str(&inner);
1444                    break;
1445                }
1446                let index = if inner.is_empty() {
1447                    let index = cursor;
1448                    cursor += 1;
1449                    index
1450                } else {
1451                    match inner.parse::<usize>() {
1452                        Ok(index) => index,
1453                        Err(_) => {
1454                            canonical.push('{');
1455                            canonical.push_str(&inner);
1456                            canonical.push('}');
1457                            continue;
1458                        }
1459                    }
1460                };
1461                let Some(arg) = format_args.get(index).copied() else {
1462                    canonical.push('{');
1463                    canonical.push_str(&inner);
1464                    canonical.push('}');
1465                    continue;
1466                };
1467                let node = self.program.values.get(arg);
1468                if let Some(wir::Value::Number { value, .. }) = node.map(|node| &node.value) {
1469                    canonical.push_str(&fold_number(*value));
1470                } else {
1471                    write!(canonical, "{{{variable_index}}}").unwrap();
1472                    variable_index += 1;
1473                    variable_args.push(arg);
1474                }
1475            } else {
1476                canonical.push(ch);
1477            }
1478        }
1479        Ok(Some((canonical, variable_args)))
1480    }
1481
1482    /// The localized spelling of a modify operator, resolved through the
1483    /// catalog (fallback-aware).
1484    fn modify_op_spelling(&mut self, op: wir::ModifyOp) -> Result<String> {
1485        self.spelling(Kind::Operator, op.catalog_id())
1486    }
1487
1488    /// The localized spelling of a canonical builtin id, resolving through
1489    /// the catalog: a dangling id is `Unknown`, an id without a target-locale
1490    /// mapping is `MissingMapping` unless an opt-in fallback locale declares
1491    /// one (recorded in [`Emitter::fallback_ids`]).
1492    fn spelling(&mut self, kind: Kind, id: &str) -> Result<String> {
1493        let Some(entry) = self.catalog.entry(kind, id) else {
1494            return Err(WorkshopError::Unknown {
1495                kind: kind.as_str(),
1496                spelling: id.to_string(),
1497                locale: self.locale.clone(),
1498                span: None,
1499            });
1500        };
1501        if let Some(spelling) = entry.spelling(&self.locale) {
1502            return Ok(spelling.to_string());
1503        }
1504        if let Some(fallback) = &self.fallback {
1505            if let Some(spelling) = entry.spelling(fallback) {
1506                self.fallback_ids.push(id.to_string());
1507                return Ok(spelling.to_string());
1508            }
1509        }
1510        Err(WorkshopError::MissingMapping {
1511            kind: kind.as_str(),
1512            id: id.to_string(),
1513            locale: self.locale.clone(),
1514        })
1515    }
1516
1517    fn localized_string_spelling(&mut self, id: &str) -> Result<String> {
1518        if let Some(spelling) = self.catalog.localized_string_spelling(&self.locale, id) {
1519            return Ok(spelling.to_string());
1520        }
1521        if let Some(fallback) = &self.fallback {
1522            if let Some(spelling) = self.catalog.localized_string_spelling(fallback, id) {
1523                self.fallback_ids.push(format!("localizedString.{id}"));
1524                return Ok(spelling.to_string());
1525            }
1526        }
1527        if self.catalog.localized_strings().any(|entry| entry.id == id) {
1528            return Err(WorkshopError::MissingMapping {
1529                kind: "localized string",
1530                id: id.to_string(),
1531                locale: self.locale.clone(),
1532            });
1533        }
1534        Err(WorkshopError::Unknown {
1535            kind: "localized string",
1536            spelling: id.to_string(),
1537            locale: self.locale.clone(),
1538            span: None,
1539        })
1540    }
1541
1542    fn structural(&mut self, id: &str) -> Result<String> {
1543        self.spelling(Kind::Structural, id)
1544    }
1545
1546    /// The localized spelling of a canonical enum member, resolving through
1547    /// the catalog (fallback-aware; see [`Emitter::spelling`]).
1548    fn enum_spelling(&mut self, domain: &str, member: &str) -> Result<String> {
1549        let Some(domain_entry) = self.catalog.enum_domain(domain) else {
1550            return Err(WorkshopError::Unknown {
1551                kind: "enum domain",
1552                spelling: domain.to_string(),
1553                locale: self.locale.clone(),
1554                span: None,
1555            });
1556        };
1557        let Some(member_entry) = domain_entry.members.iter().find(|m| m.member == member) else {
1558            return Err(WorkshopError::Unknown {
1559                kind: "enum member",
1560                spelling: format!("{domain}.{member}"),
1561                locale: self.locale.clone(),
1562                span: None,
1563            });
1564        };
1565        if let Some(spelling) = member_entry.spelling(&self.locale) {
1566            return Ok(spelling.to_string());
1567        }
1568        if let Some(fallback) = &self.fallback {
1569            if let Some(spelling) = member_entry.spelling(fallback) {
1570                self.fallback_ids.push(format!("{domain}.{member}"));
1571                return Ok(spelling.to_string());
1572            }
1573        }
1574        Err(WorkshopError::MissingMapping {
1575            kind: "enum member",
1576            id: format!("{domain}.{member}"),
1577            locale: self.locale.clone(),
1578        })
1579    }
1580
1581    /// Render a value that must stay a bare string (the `Custom String` text
1582    /// argument). Any non-string value falls back to the normal renderer.
1583    fn bare_string_value(&mut self, id: wir::ValueId, out: &mut String) -> Result<()> {
1584        let Some(node) = self.program.values.get(id) else {
1585            return Err(WorkshopError::Malformed {
1586                message: format!("dangling value {id}"),
1587                span: None,
1588            });
1589        };
1590        if let wir::Value::String(value) = &node.value {
1591            write!(out, "\"{}\"", escape_value_string(value)).unwrap();
1592            return Ok(());
1593        }
1594        self.value(id, out)
1595    }
1596
1597    /// Emit a value-position string as `Custom String("...")`, splitting it
1598    /// into a continuation chain when it exceeds the Workshop 128-char limit.
1599    fn emit_string_value(&mut self, value: &str, out: &mut String) -> Result<()> {
1600        let spelling = self.spelling(Kind::Value, "customString")?;
1601        let segments = split_string(value);
1602        emit_string_chain(&spelling, &segments, out);
1603        Ok(())
1604    }
1605
1606    fn global_name(&self, id: wir::GlobalVarId) -> Result<String> {
1607        self.program
1608            .global_variables
1609            .get(id)
1610            .map(|variable| variable.name.clone())
1611            .ok_or_else(|| WorkshopError::Unknown {
1612                kind: "global variable",
1613                spelling: format!("<{id}>"),
1614                locale: self.locale.clone(),
1615                span: None,
1616            })
1617    }
1618
1619    fn player_name(&self, id: wir::PlayerVarId) -> Result<String> {
1620        self.program
1621            .player_variables
1622            .get(id)
1623            .map(|variable| variable.name.clone())
1624            .ok_or_else(|| WorkshopError::Unknown {
1625                kind: "player variable",
1626                spelling: format!("<{id}>"),
1627                locale: self.locale.clone(),
1628                span: None,
1629            })
1630    }
1631
1632    fn line(&mut self, level: usize, text: &str) -> Result<()> {
1633        for _ in 0..level {
1634            self.out.push_str("    ");
1635        }
1636        self.out.push_str(text);
1637        self.out.push('\n');
1638        self.line_count += 1;
1639        Ok(())
1640    }
1641}
1642
1643/// Format a float like the reference frontend: integers print without a
1644/// decimal point, and non-integers print the shortest round-trip
1645/// representation truncated to 16 significant digits (OverPy behavior;
1646/// evidence: the pinned oracle snapshots).
1647fn is_comparison_operator(name: &str) -> bool {
1648    matches!(name, "==" | "!=" | "<" | "<=" | ">" | ">=")
1649}
1650
1651fn escape_string(value: &str) -> String {
1652    value.replace('"', "\\\"")
1653}
1654
1655/// Re-escape a decoded value string the way the pinned oracle does (#87):
1656/// `\`, `"`, newline, and carriage return re-escape; tabs pass through raw
1657/// (byte-measured oracle behavior: `a\tb` emits a real tab, `a\nb` emits the
1658/// literal two-character `\n`).
1659fn escape_value_string(value: &str) -> String {
1660    let mut out = String::with_capacity(value.len());
1661    for ch in value.chars() {
1662        match ch {
1663            '\\' => out.push_str("\\\\"),
1664            '"' => out.push_str("\\\""),
1665            '\n' => out.push_str("\\n"),
1666            '\r' => out.push_str("\\r"),
1667            other => out.push(other),
1668        }
1669    }
1670    out
1671}
1672
1673/// Split a decoded string per the oracle's long-string rule (#87): when the
1674/// decoded length exceeds the Workshop 128-char limit, non-final segments
1675/// hold exactly 125 decoded chars and are emitted with a `{0}` continuation
1676/// placeholder (128 total text chars), chained as nested `Custom String`
1677/// arguments; the final segment holds the remainder without a placeholder.
1678/// Segment texts are re-escaped. Byte-measured basis: chunk sizes are
1679/// counted on the decoded string (70 escaped newlines — 140 escaped chars,
1680/// 70 decoded — emit unsplit; 129 decoded newlines split at 125 decoded).
1681fn split_string(value: &str) -> Vec<String> {
1682    if value.chars().count() <= 128 {
1683        return vec![escape_value_string(value)];
1684    }
1685    let mut segments = Vec::new();
1686    let mut rest = value;
1687    while rest.chars().count() > 125 {
1688        let chunk: String = rest.chars().take(125).collect();
1689        let mut text = escape_value_string(&chunk);
1690        text.push_str("{0}");
1691        segments.push(text);
1692        rest = &rest[chunk.len()..];
1693    }
1694    if !rest.is_empty() {
1695        segments.push(escape_value_string(rest));
1696    }
1697    segments
1698}
1699
1700/// Escape a settings string value the way the pinned oracle does: every
1701/// decode the JSONC parser performed is re-escaped, so decoded values
1702/// round-trip to the oracle's spelling. Evidence: the inputhud description
1703/// (`\n` in the source block) is emitted by the oracle as the literal
1704/// two-character sequence `\n` in the Workshop settings section.
1705fn escape_settings_string(value: &str) -> String {
1706    let mut out = String::with_capacity(value.len());
1707    for ch in value.chars() {
1708        match ch {
1709            '\\' => out.push_str("\\\\"),
1710            '"' => out.push_str("\\\""),
1711            '\n' => out.push_str("\\n"),
1712            '\t' => out.push_str("\\t"),
1713            '\r' => out.push_str("\\r"),
1714            other => out.push(other),
1715        }
1716    }
1717    out
1718}
1719
1720/// Emit the nested continuation chain
1721/// `Custom String(seg0, Custom String(seg1, ...))`; segment texts are
1722/// pre-escaped, non-final segments carry the `{0}` placeholder. Iterative:
1723/// every segment except the first opens a `Custom String` level, then all
1724/// levels close.
1725fn emit_string_chain(spelling: &str, segments: &[String], out: &mut String) {
1726    let Some((first, rest)) = segments.split_first() else {
1727        return;
1728    };
1729    out.push_str(spelling);
1730    out.push('(');
1731    write!(out, "\"{first}\"").unwrap();
1732    for segment in rest {
1733        out.push_str(", ");
1734        out.push_str(spelling);
1735        out.push('(');
1736        write!(out, "\"{segment}\"").unwrap();
1737    }
1738    for _ in 0..=rest.len() {
1739        out.push(')');
1740    }
1741}
1742
1743/// Render a constant format argument the way the oracle folds it: integers
1744/// without decimals, non-integers with exactly two decimals (JS `toFixed(2)`
1745/// rounding: `0.5` -> `0.50`, `0.125` -> `0.13`, #87).
1746fn fold_number(value: f64) -> String {
1747    if value.fract() == 0.0 && value.abs() < 1e15 {
1748        format!("{}", value as i64)
1749    } else {
1750        let scaled = (value * 100.0).round();
1751        let sign = if scaled < 0.0 { "-" } else { "" };
1752        let scaled = scaled.abs() as i64;
1753        format!("{sign}{}.{:02}", scaled / 100, scaled % 100)
1754    }
1755}