Skip to main content

workshop_rs/output/
emitter.rs

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