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