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