Skip to main content

workshop_rs/output/
emitter.rs

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