Skip to main content

zerodds_lang_render/
writer.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Indentation and line breaking. Spec §5.
4//!
5//! The writer knows how deep it is and what a line ends with. It knows
6//! nothing about any language: a writer that knew would be a second
7//! place where output is decided.
8
9use alloc::string::{String, ToString};
10
11/// Accumulates rendered text with an indentation level.
12#[derive(Debug, Clone)]
13pub struct Writer {
14    out: String,
15    depth: usize,
16    unit: &'static str,
17    /// Whether the current line has had anything written to it.
18    line_started: bool,
19}
20
21impl Default for Writer {
22    fn default() -> Self {
23        Self::new("    ")
24    }
25}
26
27impl Writer {
28    /// A writer indenting by `unit` per level.
29    #[must_use]
30    pub fn new(unit: &'static str) -> Self {
31        Self {
32            out: String::new(),
33            depth: 0,
34            unit,
35            line_started: false,
36        }
37    }
38
39    /// A writer indenting by four spaces, which is what Rust, C++ and
40    /// Java conventionally use and what the previous emitters wrote.
41    #[must_use]
42    pub fn spaces4() -> Self {
43        Self::new("    ")
44    }
45
46    /// Go one level deeper.
47    pub fn indent(&mut self) {
48        self.depth = self.depth.saturating_add(1);
49    }
50
51    /// Come back one level.
52    pub fn dedent(&mut self) {
53        self.depth = self.depth.saturating_sub(1);
54    }
55
56    /// How deep it currently is, so a caller can assert on balance.
57    #[must_use]
58    pub const fn depth(&self) -> usize {
59        self.depth
60    }
61
62    /// Write text, indenting first when the line is still empty.
63    pub fn push(&mut self, text: &str) {
64        if !self.line_started && !text.is_empty() {
65            for _ in 0..self.depth {
66                self.out.push_str(self.unit);
67            }
68            self.line_started = true;
69        }
70        self.out.push_str(text);
71    }
72
73    /// Write text and end the line.
74    pub fn line(&mut self, text: &str) {
75        self.push(text);
76        self.newline();
77    }
78
79    /// End the line.
80    ///
81    /// A line with nothing on it gets no indentation: trailing
82    /// whitespace on a blank line is what a formatter strips and what a
83    /// byte-comparison then reports as a difference.
84    pub fn newline(&mut self) {
85        self.out.push('\n');
86        self.line_started = false;
87    }
88
89    /// End the line only if something has been written to it.
90    ///
91    /// For a renderer that does not know whether the previous node left
92    /// the line open.
93    pub fn finish_line(&mut self) {
94        if self.line_started {
95            self.newline();
96        }
97    }
98
99    /// An empty line, unless the output is empty or already ends in one.
100    ///
101    /// Idempotent so that a renderer separating two optional sections
102    /// does not have to know whether the first was there.
103    pub fn blank_line(&mut self) {
104        self.finish_line();
105        if self.out.is_empty() || self.out.ends_with("\n\n") {
106            return;
107        }
108        self.out.push('\n');
109    }
110
111    /// Whether nothing has been written.
112    #[must_use]
113    pub fn is_empty(&self) -> bool {
114        self.out.is_empty()
115    }
116
117    /// The text written so far.
118    #[must_use]
119    pub fn as_str(&self) -> &str {
120        &self.out
121    }
122
123    /// The text, with the writer consumed.
124    #[must_use]
125    pub fn finish(mut self) -> String {
126        self.finish_line();
127        self.out
128    }
129}
130
131/// Write `@verbatim` text, one output line per text line.
132///
133/// OMG IDL 4.2 §8.3.5.1: the annotation's `text` is copied into the
134/// generated artifact unchanged — no comment marker, no escaping. The
135/// one transformation is the writer's own indentation, so text placed
136/// inside a class body lines up with that body; at file level the
137/// indentation is zero and the copy is byte-for-byte.
138pub fn verbatim_lines(writer: &mut Writer, text: &str) {
139    for line in text.lines() {
140        writer.line(line);
141    }
142}
143
144/// Write a comment block, one line per entry, each with `marker`.
145///
146/// A line of its own per entry rather than one string: a comment with an
147/// embedded newline would put the indentation of its second line beyond
148/// the writer's reach.
149pub fn comment_lines(writer: &mut Writer, marker: &str, lines: &[String]) {
150    for line in lines {
151        if line.is_empty() {
152            writer.line(marker.trim_end());
153        } else {
154            let mut text = marker.to_string();
155            text.push_str(line);
156            writer.line(&text);
157        }
158    }
159}
160
161/// Escape a string for a C-family string literal.
162///
163/// The four languages agree on these escapes; where they do not — a
164/// Rust raw string, a Java unicode escape — the renderer that needs the
165/// difference does it itself.
166#[must_use]
167pub fn escape_string(text: &str) -> String {
168    let mut out = String::new();
169    for ch in text.chars() {
170        match ch {
171            '"' => out.push_str("\\\""),
172            '\\' => out.push_str("\\\\"),
173            '\n' => out.push_str("\\n"),
174            '\r' => out.push_str("\\r"),
175            '\t' => out.push_str("\\t"),
176            '\0' => out.push_str("\\0"),
177            other => out.push(other),
178        }
179    }
180    out
181}
182
183/// Escape a character for a C-family character literal.
184#[must_use]
185pub fn escape_char(ch: char) -> String {
186    match ch {
187        '\'' => "\\'".to_string(),
188        '\\' => "\\\\".to_string(),
189        '\n' => "\\n".to_string(),
190        '\r' => "\\r".to_string(),
191        '\t' => "\\t".to_string(),
192        '\0' => "\\0".to_string(),
193        other => other.to_string(),
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
200    use super::*;
201
202    #[test]
203    fn indentation_applies_once_per_line_not_per_write() {
204        let mut w = Writer::spaces4();
205        w.indent();
206        w.push("a");
207        w.push("b");
208        w.newline();
209        assert_eq!(w.finish(), "    ab\n");
210    }
211
212    #[test]
213    fn a_blank_line_carries_no_trailing_whitespace() {
214        // Trailing whitespace on a blank line is what a formatter strips
215        // and what a byte comparison then reports as a difference.
216        let mut w = Writer::spaces4();
217        w.indent();
218        w.line("a");
219        w.newline();
220        w.line("b");
221        assert_eq!(w.finish(), "    a\n\n    b\n");
222    }
223
224    #[test]
225    fn a_blank_line_does_not_double_up() {
226        let mut w = Writer::spaces4();
227        w.line("a");
228        w.blank_line();
229        w.blank_line();
230        w.line("b");
231        assert_eq!(w.finish(), "a\n\nb\n");
232    }
233
234    #[test]
235    fn a_blank_line_at_the_start_writes_nothing() {
236        let mut w = Writer::spaces4();
237        w.blank_line();
238        w.line("a");
239        assert_eq!(w.finish(), "a\n");
240    }
241
242    #[test]
243    fn dedent_below_zero_stays_at_zero() {
244        // A renderer with an unbalanced dedent is a defect; underflowing
245        // the depth would turn it into a panic somewhere else entirely.
246        let mut w = Writer::spaces4();
247        w.dedent();
248        w.dedent();
249        assert_eq!(w.depth(), 0);
250        w.line("a");
251        assert_eq!(w.finish(), "a\n");
252    }
253
254    #[test]
255    fn string_escapes_cover_the_ones_a_generated_literal_meets() {
256        assert_eq!(escape_string("a\"b"), "a\\\"b");
257        assert_eq!(escape_string("a\\b"), "a\\\\b");
258        assert_eq!(escape_string("a\nb"), "a\\nb");
259        assert_eq!(escape_string("plain"), "plain");
260    }
261
262    #[test]
263    fn a_comment_block_marks_every_line() {
264        let mut w = Writer::spaces4();
265        comment_lines(
266            &mut w,
267            "/// ",
268            &["first".to_string(), String::new(), "third".to_string()],
269        );
270        assert_eq!(w.finish(), "/// first\n///\n/// third\n");
271    }
272}