Skip to main content

workshop_rs/output/
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
16pub(crate) use std::fmt::Write;
17
18pub(crate) use crate::catalog::{Catalog, Kind, Locale};
19pub(crate) use crate::core::error::{Result, WorkshopError};
20pub(crate) use crate::core::format::format_number;
21pub(crate) use crate::settings::table::{self, KeyKind, PathPart};
22pub(crate) use crate::settings::{Settings as SettingsTree, SettingsNode};
23pub(crate) use crate::wir;
24
25/// Emission options: opt-in fallback for missing target-locale mappings.
26#[derive(Debug, Clone, Default, PartialEq, Eq)]
27pub struct EmitOptions {
28    /// When a canonical identity has no spelling for the target locale, its
29    /// spelling in this declared locale is used instead. `None` (the default)
30    /// keeps missing mappings failing explicitly. The fallback choice is
31    /// visible in [`EmitOutput::fallback_ids`].
32    pub fallback_locale: Option<Locale>,
33}
34
35/// The result of a localized emission.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct EmitOutput {
38    /// The emitted localized Workshop text.
39    pub text: String,
40    /// Canonical identities (and the `settings` marker) whose spelling came
41    /// from the opt-in fallback locale instead of the target locale. Empty
42    /// when no fallback occurred.
43    pub fallback_ids: Vec<String>,
44}
45
46/// Emit a Workshop IR program as localized Workshop text, failing explicitly
47/// on any missing target-locale mapping (no fallback).
48pub fn emit(program: &wir::Program, catalog: &Catalog, locale: &Locale) -> Result<String> {
49    emit_with_options(program, catalog, locale, &EmitOptions::default()).map(|out| out.text)
50}
51
52/// Emit a Workshop IR program as localized Workshop text with emission
53/// options (opt-in fallback locale).
54pub fn emit_with_options(
55    program: &wir::Program,
56    catalog: &Catalog,
57    locale: &Locale,
58    options: &EmitOptions,
59) -> Result<EmitOutput> {
60    emit_with_options_inner(program, catalog, locale, options, false)
61}
62
63pub(crate) fn emit_with_options_for_conversion(
64    program: &wir::Program,
65    catalog: &Catalog,
66    locale: &Locale,
67    options: &EmitOptions,
68) -> Result<EmitOutput> {
69    emit_with_options_inner(program, catalog, locale, options, true)
70}
71
72fn emit_with_options_inner(
73    program: &wir::Program,
74    catalog: &Catalog,
75    locale: &Locale,
76    options: &EmitOptions,
77    force_hero_constructors: bool,
78) -> Result<EmitOutput> {
79    let mut emitter = EmitContext {
80        program,
81        catalog,
82        locale: locale.clone(),
83        fallback: options.fallback_locale.clone(),
84        force_hero_constructors,
85        fallback_ids: Vec::new(),
86        out: String::new(),
87        line_count: 0,
88    };
89    emitter.run()?;
90    Ok(EmitOutput {
91        text: emitter.out,
92        fallback_ids: emitter.fallback_ids,
93    })
94}
95
96pub(crate) struct EmitContext<'a> {
97    pub(crate) program: &'a wir::Program,
98    pub(crate) catalog: &'a Catalog,
99    pub(crate) locale: Locale,
100    /// The opt-in fallback locale for missing target-locale mappings.
101    pub(crate) fallback: Option<Locale>,
102    /// Canonical ids emitted with a fallback-locale spelling.
103    pub(crate) fallback_ids: Vec<String>,
104    pub(crate) force_hero_constructors: bool,
105    pub(crate) out: String,
106    pub(crate) line_count: usize,
107}
108
109impl EmitContext<'_> {
110    pub(crate) fn run(&mut self) -> Result<()> {
111        // Section order: settings, variables, subroutines, rules.
112        if let Some(settings) = &self.program.settings {
113            self.emit_settings(settings)?;
114            self.out.push('\n');
115        }
116        if !self.program.global_variables.is_empty() || !self.program.player_variables.is_empty() {
117            let variables = self.structural("variables")?;
118            self.line(0, &format!("{variables} {{"))?;
119            if !self.program.global_variables.is_empty() {
120                let global = self.structural("global")?;
121                self.line(1, &format!("{global}:"))?;
122                for variable in self.program.global_variables.iter() {
123                    self.line(2, &format!("{}: {}", variable.index, variable.name))?;
124                }
125            }
126            if !self.program.player_variables.is_empty() {
127                let player = self.structural("player")?;
128                self.line(1, &format!("{player}:"))?;
129                for variable in self.program.player_variables.iter() {
130                    self.line(2, &format!("{}: {}", variable.index, variable.name))?;
131                }
132            }
133            self.line(0, "}")?;
134            self.out.push('\n');
135        }
136        if !self.program.subroutines.is_empty() {
137            let subroutines = self.structural("subroutines")?;
138            self.line(0, &format!("{subroutines} {{"))?;
139            for subroutine in self.program.subroutines.iter() {
140                self.line(1, &format!("{}: {}", subroutine.index, subroutine.name))?;
141            }
142            self.line(0, "}")?;
143            self.out.push('\n');
144        }
145        for (emitted_rules, rule) in self.program.rules.iter().enumerate() {
146            if emitted_rules > 0 {
147                self.out.push('\n');
148            }
149            self.rule(rule)?;
150        }
151        // The oracle's raw artifact ends with a trailing blank line (the
152        // committed snapshots strip it via the acquisition normalizer; the
153        // pinned oracle's own output keeps it).
154        if !self.out.is_empty() && !self.out.ends_with("\n\n") {
155            self.out.push('\n');
156        }
157        Ok(())
158    }
159
160    pub(crate) fn malformed(&self, message: impl Into<String>) -> WorkshopError {
161        WorkshopError::Malformed {
162            message: message.into(),
163            span: None,
164        }
165    }
166
167    pub(crate) fn line(&mut self, level: usize, text: &str) -> Result<()> {
168        for _ in 0..level {
169            self.out.push_str("    ");
170        }
171        self.out.push_str(text);
172        self.out.push('\n');
173        self.line_count += 1;
174        Ok(())
175    }
176}
177
178/// Format a float like the reference frontend: integers print without a
179/// decimal point, and non-integers print the shortest round-trip
180/// representation truncated to 16 significant digits (OverPy behavior;
181/// evidence: the pinned oracle snapshots).
182pub(crate) fn is_comparison_operator(name: &str) -> bool {
183    matches!(name, "==" | "!=" | "<" | "<=" | ">" | ">=")
184}
185
186pub(crate) fn escape_string(value: &str) -> String {
187    value.replace('"', "\\\"")
188}
189
190/// Re-escape a decoded value string the way the pinned oracle does (#87):
191/// `\`, `"`, newline, and carriage return re-escape; tabs pass through raw
192/// (byte-measured oracle behavior: `a\tb` emits a real tab, `a\nb` emits the
193/// literal two-character `\n`).
194pub(crate) fn escape_value_string(value: &str) -> String {
195    let mut out = String::with_capacity(value.len());
196    for ch in value.chars() {
197        match ch {
198            '\\' => out.push_str("\\\\"),
199            '"' => out.push_str("\\\""),
200            '\n' => out.push_str("\\n"),
201            '\r' => out.push_str("\\r"),
202            other => out.push(other),
203        }
204    }
205    out
206}
207
208/// Split a decoded string per the oracle's long-string rule (#87): when the
209/// decoded length exceeds the Workshop 128-char limit, non-final segments
210/// hold exactly 125 decoded chars and are emitted with a `{0}` continuation
211/// placeholder (128 total text chars), chained as nested `Custom String`
212/// arguments; the final segment holds the remainder without a placeholder.
213/// Segment texts are re-escaped. Byte-measured basis: chunk sizes are
214/// counted on the decoded string (70 escaped newlines — 140 escaped chars,
215/// 70 decoded — emit unsplit; 129 decoded newlines split at 125 decoded).
216pub(crate) fn split_string(value: &str) -> Vec<String> {
217    if value.chars().count() <= 128 {
218        return vec![escape_value_string(value)];
219    }
220    let mut segments = Vec::new();
221    let mut rest = value;
222    while rest.chars().count() > 125 {
223        let chunk: String = rest.chars().take(125).collect();
224        let mut text = escape_value_string(&chunk);
225        text.push_str("{0}");
226        segments.push(text);
227        rest = &rest[chunk.len()..];
228    }
229    if !rest.is_empty() {
230        segments.push(escape_value_string(rest));
231    }
232    segments
233}
234
235/// Escape a settings string value the way the pinned oracle does: every
236/// decode the JSONC parser performed is re-escaped, so decoded values
237/// round-trip to the oracle's spelling. Evidence: the inputhud description
238/// (`\n` in the source block) is emitted by the oracle as the literal
239/// two-character sequence `\n` in the Workshop settings section.
240pub(crate) fn escape_settings_string(value: &str) -> String {
241    let mut out = String::with_capacity(value.len());
242    for ch in value.chars() {
243        match ch {
244            '\\' => out.push_str("\\\\"),
245            '"' => out.push_str("\\\""),
246            '\n' => out.push_str("\\n"),
247            '\t' => out.push_str("\\t"),
248            '\r' => out.push_str("\\r"),
249            other => out.push(other),
250        }
251    }
252    out
253}
254
255/// Emit the nested continuation chain
256/// `Custom String(seg0, Custom String(seg1, ...))`; segment texts are
257/// pre-escaped, non-final segments carry the `{0}` placeholder. Iterative:
258/// every segment except the first opens a `Custom String` level, then all
259/// levels close.
260pub(crate) fn emit_string_chain(spelling: &str, segments: &[String], out: &mut String) {
261    let Some((first, rest)) = segments.split_first() else {
262        return;
263    };
264    out.push_str(spelling);
265    out.push('(');
266    write!(out, "\"{first}\"").unwrap();
267    for segment in rest {
268        out.push_str(", ");
269        out.push_str(spelling);
270        out.push('(');
271        write!(out, "\"{segment}\"").unwrap();
272    }
273    for _ in 0..=rest.len() {
274        out.push(')');
275    }
276}
277
278/// Render a constant format argument the way the oracle folds it: integers
279/// without decimals, non-integers with exactly two decimals (JS `toFixed(2)`
280/// rounding: `0.5` -> `0.50`, `0.125` -> `0.13`, #87).
281pub(crate) fn fold_number(value: f64) -> String {
282    if value.fract() == 0.0 && value.abs() < 1e15 {
283        format!("{}", value as i64)
284    } else {
285        let scaled = (value * 100.0).round();
286        let sign = if scaled < 0.0 { "-" } else { "" };
287        let scaled = scaled.abs() as i64;
288        format!("{sign}{}.{:02}", scaled / 100, scaled % 100)
289    }
290}