Skip to main content

zerodds_lang_render/
node.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Rendering the Node CST. Spec §5.
4//!
5//! Two spaces, the opening brace on the same line, a semicolon on every
6//! statement, `//` comments — the shape `prettier`'s defaults and
7//! `node --check` both accept without complaint. Nothing is decided
8//! here: the tree already carries every choice, down to which operator
9//! is written and whether a value needs parentheses.
10//!
11//! The one JavaScript-specific lexical fact this module owns is
12//! [`string_text`]'s escaping, which is the same set C, Java and every
13//! other column with a `\0` in its string grammar already use — unlike
14//! [`crate::zig`], JavaScript's string literals do accept `\0`
15//! (ECMA-262 §12.9.4's `SingleEscapeCharacter`), so there is no
16//! Zig-shaped exception here.
17
18use crate::writer::{Writer, comment_lines, verbatim_lines};
19use alloc::string::{String, ToString};
20use alloc::vec::Vec;
21use zerodds_lang_cst::common::{Doc, Ident, Literal};
22use zerodds_lang_cst::node::{Expr, File, Function, Stat};
23
24/// Render a whole file.
25#[must_use]
26pub fn file(file: &File) -> String {
27    let mut w = Writer::new("  ");
28    if !file.doc.is_empty() {
29        comment_lines(&mut w, "// ", file.doc.as_lines());
30    }
31    for statement in &file.body {
32        w.blank_line();
33        render_stat(&mut w, statement);
34    }
35    w.finish()
36}
37
38/// Render one statement, for a caller that has only one.
39#[must_use]
40pub fn stat(node: &Stat) -> String {
41    let mut w = Writer::new("  ");
42    render_stat(&mut w, node);
43    w.finish()
44}
45
46/// Render one expression, for a caller that has only one.
47#[must_use]
48pub fn expr(node: &Expr) -> String {
49    expr_text(node)
50}
51
52/// The identifier a name renders as, for a caller assembling one.
53#[must_use]
54pub fn ident(name: &Ident) -> String {
55    name.as_str().to_string()
56}
57
58/// zerodds-lint: recursion-depth 64 (one level per nested block of a
59/// generated body. The codec walk emits a routine per type rather than
60/// inlining one — `lang-rules/src/emit/encode.rs` — so a body nests by
61/// its own framing and by one loop per array dimension, never by the
62/// type graph, whose descent stops at 64 anyway; the else-if chain, one
63/// arm per union case, is walked iteratively and costs no depth.)
64fn render_stat(w: &mut Writer, node: &Stat) {
65    match node {
66        Stat::Declare {
67            doc,
68            kind,
69            name,
70            value,
71        } => {
72            note(w, doc);
73            let mut line = String::from(kind.as_str());
74            line.push(' ');
75            line.push_str(name.as_str());
76            if let Some(value) = value {
77                line.push_str(" = ");
78                line.push_str(&expr_text(value));
79            }
80            line.push(';');
81            w.line(&line);
82        }
83        Stat::Assign { doc, place, value } => {
84            note(w, doc);
85            let mut line = expr_text(place);
86            line.push_str(" = ");
87            line.push_str(&expr_text(value));
88            line.push(';');
89            w.line(&line);
90        }
91        Stat::Function(function) => render_function(w, function),
92        Stat::Return(value) => match value {
93            Some(value) => {
94                let mut line = String::from("return ");
95                line.push_str(&expr_text(value));
96                line.push(';');
97                w.line(&line);
98            }
99            None => w.line("return;"),
100        },
101        Stat::Expr(value) => {
102            let mut line = expr_text(value);
103            line.push(';');
104            w.line(&line);
105        }
106        Stat::If { .. } => render_if(w, node, "if ("),
107        Stat::For {
108            counter,
109            bound,
110            body,
111        } => {
112            let mut head = String::from("for (let ");
113            head.push_str(counter.as_str());
114            head.push_str(" = 0; ");
115            head.push_str(counter.as_str());
116            head.push_str(" < ");
117            head.push_str(&expr_text(bound));
118            head.push_str("; ");
119            head.push_str(counter.as_str());
120            head.push_str(" += 1) {");
121            w.line(&head);
122            w.indent();
123            for statement in body {
124                render_stat(w, statement);
125            }
126            w.dedent();
127            w.line("}");
128        }
129        Stat::While { condition, body } => {
130            let mut head = String::from("while (");
131            head.push_str(&expr_text(condition));
132            head.push_str(") {");
133            w.line(&head);
134            w.indent();
135            for statement in body {
136                render_stat(w, statement);
137            }
138            w.dedent();
139            w.line("}");
140        }
141        Stat::Break => w.line("break;"),
142        Stat::Throw(value) => {
143            let mut line = String::from("throw ");
144            line.push_str(&expr_text(value));
145            line.push(';');
146            w.line(&line);
147        }
148        Stat::Comment(lines) => comment_lines(w, "// ", lines.as_lines()),
149        Stat::Verbatim(text) => verbatim_lines(w, text),
150    }
151}
152
153/// An `if`, with an `else`-block of exactly one `if` collapsed into
154/// `else if`.
155///
156/// The next arm is taken in a loop and not by a call to this function
157/// again. The chain's length is the union's case count, the
158/// enumeration's enumerator count or the `@mutable` aggregate's member
159/// count — `lang-rules/src/emit/encode.rs` builds one `If` per arm, each
160/// in the previous one's `else` — and nothing in the model bounds any of
161/// the three.
162///
163/// zerodds-lint: recursion-depth 64 (block nesting only; the arm chain
164/// is walked iteratively, and what remains is one level per nested block
165/// of a generated body — the codec walk emits a routine per type rather
166/// than inlining, so a body nests by its framing and by one loop per
167/// array dimension, never by the type graph)
168fn render_if(w: &mut Writer, node: &Stat, keyword: &str) {
169    let mut node = node;
170    let mut keyword = keyword;
171    loop {
172        let Stat::If {
173            condition,
174            then,
175            otherwise,
176        } = node
177        else {
178            return;
179        };
180        let mut head = String::from(keyword);
181        head.push_str(&expr_text(condition));
182        head.push_str(") {");
183        w.line(&head);
184        w.indent();
185        for statement in then {
186            render_stat(w, statement);
187        }
188        w.dedent();
189        match otherwise {
190            None => {
191                w.line("}");
192                return;
193            }
194            Some(chain) if chain.len() == 1 && matches!(chain[0], Stat::If { .. }) => {
195                node = &chain[0];
196                keyword = "} else if (";
197            }
198            Some(block) => {
199                w.line("} else {");
200                w.indent();
201                for statement in block {
202                    render_stat(w, statement);
203                }
204                w.dedent();
205                w.line("}");
206                return;
207            }
208        }
209    }
210}
211
212/// zerodds-lint: recursion-depth 64 (one level per nested block of a
213/// generated body. The codec walk emits a routine per type rather than
214/// inlining one — `lang-rules/src/emit/encode.rs` — so a body nests by
215/// its own framing and by one loop per array dimension, never by the
216/// type graph, whose descent stops at 64 anyway; the else-if chain, one
217/// arm per union case, is walked iteratively and costs no depth.)
218fn render_function(w: &mut Writer, node: &Function) {
219    note(w, &node.doc);
220    let mut head = String::from("function ");
221    head.push_str(node.name.as_str());
222    head.push('(');
223    let params: Vec<&str> = node.params.iter().map(Ident::as_str).collect();
224    head.push_str(&params.join(", "));
225    head.push_str(") {");
226    w.line(&head);
227    w.indent();
228    for statement in &node.body {
229        render_stat(w, statement);
230    }
231    w.dedent();
232    w.line("}");
233}
234
235/// zerodds-lint: recursion-depth = the built expression's nesting. A walk
236/// calls a routine per type rather than nesting one, so the one operand
237/// tree that grows with the IDL is an array's default value: one composite
238/// literal per dimension, built in `lang-rules/src/default_node.rs`, and
239/// nothing bounds the dimension count. No number is claimed: this
240/// signature returns no error.
241fn expr_text(node: &Expr) -> String {
242    match node {
243        Expr::Literal(literal) => literal_text(literal),
244        Expr::Undefined => "undefined".to_string(),
245        Expr::Name(name) => name.as_str().to_string(),
246        Expr::Array(elements) => {
247            if elements.is_empty() {
248                return "[]".to_string();
249            }
250            let mut out = String::from("[");
251            let parts: Vec<String> = elements.iter().map(expr_text).collect();
252            out.push_str(&parts.join(", "));
253            out.push(']');
254            out
255        }
256        Expr::Object(fields) => {
257            if fields.is_empty() {
258                return "{}".to_string();
259            }
260            let mut out = String::from("{ ");
261            let parts: Vec<String> = fields
262                .iter()
263                .map(|(name, value)| {
264                    let mut part = String::from(name.as_str());
265                    part.push_str(": ");
266                    part.push_str(&expr_text(value));
267                    part
268                })
269                .collect();
270            out.push_str(&parts.join(", "));
271            out.push_str(" }");
272            out
273        }
274        Expr::New { callee, args } => {
275            let mut out = String::from("new ");
276            out.push_str(callee.as_str());
277            out.push('(');
278            let parts: Vec<String> = args.iter().map(expr_text).collect();
279            out.push_str(&parts.join(", "));
280            out.push(')');
281            out
282        }
283        Expr::Call { callee, args } => {
284            let mut out = expr_text(callee);
285            out.push('(');
286            let parts: Vec<String> = args.iter().map(expr_text).collect();
287            out.push_str(&parts.join(", "));
288            out.push(')');
289            out
290        }
291        Expr::Member { object, name } => {
292            let mut out = expr_text(object);
293            out.push('.');
294            out.push_str(name.as_str());
295            out
296        }
297        Expr::Index { object, index } => {
298            let mut out = expr_text(object);
299            out.push('[');
300            out.push_str(&expr_text(index));
301            out.push(']');
302            out
303        }
304        Expr::Paren(inner) => {
305            let mut out = String::from("(");
306            out.push_str(&expr_text(inner));
307            out.push(')');
308            out
309        }
310        Expr::Binary { op, lhs, rhs } => {
311            let mut out = expr_text(lhs);
312            out.push(' ');
313            out.push_str(op);
314            out.push(' ');
315            out.push_str(&expr_text(rhs));
316            out
317        }
318        Expr::Ternary {
319            condition,
320            then,
321            otherwise,
322        } => {
323            let mut out = expr_text(condition);
324            out.push_str(" ? ");
325            out.push_str(&expr_text(then));
326            out.push_str(" : ");
327            out.push_str(&expr_text(otherwise));
328            out
329        }
330        Expr::Unary { op, value } => {
331            let mut out = String::from(*op);
332            out.push_str(&expr_text(value));
333            out
334        }
335    }
336}
337
338fn literal_text(literal: &Literal) -> String {
339    match literal {
340        Literal::Int(text) | Literal::Float(text) => text.clone(),
341        Literal::Bool(value) => {
342            if *value {
343                "true".to_string()
344            } else {
345                "false".to_string()
346            }
347        }
348        Literal::Char(ch) => {
349            let mut out = String::from("'");
350            out.push_str(&char_text(*ch));
351            out.push('\'');
352            out
353        }
354        Literal::Str(text) => {
355            let mut out = String::from("\"");
356            out.push_str(&string_text(text));
357            out.push('"');
358            out
359        }
360        Literal::Bytes(bytes) => {
361            let mut out = String::from("Uint8Array.from([");
362            let parts: Vec<String> = bytes.iter().map(|b| alloc::format!("0x{b:02x}")).collect();
363            out.push_str(&parts.join(", "));
364            out.push_str("])");
365            out
366        }
367        Literal::Null => "null".to_string(),
368    }
369}
370
371/// Escape a string for a JavaScript double-quoted string literal.
372///
373/// Unlike [`crate::zig::string_text`], JavaScript's grammar has a `\0`
374/// escape (ECMA-262 §12.9.4), so the shared [`crate::writer::escape_string`]
375/// would be the right answer here — this is this module's own only so
376/// every column keeps the same shape of function at the same name, and
377/// so a future divergence (there is none today) has a place already
378/// waiting for it.
379#[must_use]
380pub fn string_text(text: &str) -> String {
381    let mut out = String::new();
382    for ch in text.chars() {
383        push_escaped(&mut out, ch, '"');
384    }
385    out
386}
387
388/// The same, for a character literal.
389#[must_use]
390pub fn char_text(ch: char) -> String {
391    let mut out = String::new();
392    push_escaped(&mut out, ch, '\'');
393    out
394}
395
396fn push_escaped(out: &mut String, ch: char, delimiter: char) {
397    match ch {
398        '\\' => out.push_str("\\\\"),
399        '\0' => out.push_str("\\0"),
400        '\n' => out.push_str("\\n"),
401        '\r' => out.push_str("\\r"),
402        '\t' => out.push_str("\\t"),
403        c if c == delimiter => {
404            out.push('\\');
405            out.push(c);
406        }
407        c if (c as u32) < 0x20 || c as u32 == 0x7F => {
408            out.push_str(&alloc::format!("\\x{:02x}", c as u32));
409        }
410        other => out.push(other),
411    }
412}
413
414fn note(w: &mut Writer, doc: &Doc) {
415    if doc.is_empty() {
416        return;
417    }
418    comment_lines(w, "// ", doc.as_lines());
419}
420
421#[cfg(test)]
422mod tests {
423    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
424    use super::*;
425    use zerodds_lang_cst::node::{VarKind, escape_decl};
426
427    #[test]
428    fn a_const_declaration_ends_in_a_semicolon() {
429        let text = stat(&Stat::Declare {
430            doc: Doc::none(),
431            kind: VarKind::Const,
432            name: Ident::new("READY"),
433            value: Some(Expr::int(4)),
434        });
435        assert_eq!(text, "const READY = 4;\n");
436    }
437
438    #[test]
439    fn an_object_literal_writes_every_key_bare() {
440        let text = expr(&Expr::Object(alloc::vec![
441            (Ident::new("x"), Expr::int(1)),
442            (Ident::new("class"), Expr::int(2)),
443        ]));
444        assert_eq!(text, "{ x: 1, class: 2 }");
445    }
446
447    #[test]
448    fn a_for_loop_counts_from_zero_with_no_off_by_one_correction() {
449        let text = stat(&Stat::For {
450            counter: Ident::new("i"),
451            bound: Expr::name("n"),
452            body: alloc::vec![Stat::Break],
453        });
454        assert_eq!(text, "for (let i = 0; i < n; i += 1) {\n  break;\n}\n");
455    }
456
457    #[test]
458    fn a_function_declaration_has_no_type_annotation_anywhere() {
459        let text = stat(&Stat::Function(Function {
460            doc: Doc::none(),
461            name: Ident::new("zd_enc_p_long"),
462            params: alloc::vec![Ident::new("w"), Ident::new("v")],
463            body: alloc::vec![Stat::Return(None)],
464        }));
465        assert_eq!(text, "function zd_enc_p_long(w, v) {\n  return;\n}\n");
466        assert!(!text.contains(':'), "a type annotation reached the output");
467    }
468
469    #[test]
470    fn a_nul_is_written_as_the_named_escape_because_javascript_has_one() {
471        assert_eq!(string_text("a\0b"), "a\\0b");
472        assert_eq!(string_text("a\"b\\c"), "a\\\"b\\\\c");
473        assert_eq!(string_text("hä"), "hä");
474    }
475
476    #[test]
477    fn a_reserved_word_still_renders_bare_as_a_property_key() {
478        // The renderer never re-decides what the tree already carries:
479        // `escape_decl` is for a binding, not for a property, and this
480        // tree never routes a property key through it.
481        assert_eq!(escape_decl("class"), "class_");
482        let text = expr(&Expr::member(Expr::name("v"), "class"));
483        assert_eq!(text, "v.class");
484    }
485
486    #[test]
487    fn a_delete_expression_keeps_its_trailing_space() {
488        let text = expr(&Expr::Unary {
489            op: "delete ",
490            value: alloc::boxed::Box::new(Expr::member(Expr::name("v"), "m")),
491        });
492        assert_eq!(text, "delete v.m");
493    }
494
495    #[test]
496    fn an_if_else_if_chain_stays_flat() {
497        let text = stat(&Stat::If {
498            condition: Expr::name("a"),
499            then: alloc::vec![Stat::Break],
500            otherwise: Some(alloc::vec![Stat::If {
501                condition: Expr::name("b"),
502                then: alloc::vec![Stat::Break],
503                otherwise: None,
504            }]),
505        });
506        assert_eq!(text, "if (a) {\n  break;\n} else if (b) {\n  break;\n}\n");
507    }
508}