Skip to main content

zerodds_lang_render/
nim.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Rendering the Nim CST. Spec §5.
4//!
5//! One style, the one `nimpretty` writes: two spaces per level, a
6//! section keyword on a line of its own, `#` comments.
7//!
8//! ## Indentation is the grammar here
9//!
10//! Nim is indentation-sensitive, so this renderer is closer to
11//! [`crate::python`] than to [`crate::c`]: a wrong indent is a different
12//! program, or none. [`Writer`] owns the depth, every body goes through
13//! the section and declaration functions, and nothing else in this
14//! module writes a newline. Two spaces and not four, and never a tab —
15//! Nim's compiler rejects a tab used for indentation outright (Nim
16//! manual, Lexical Analysis: "Tabulators are not allowed as part of the
17//! indentation").
18//!
19//! ## `#` and not `##`
20//!
21//! Nim's documentation comment is `##`, and it is not a comment the
22//! parser skips: it becomes an AST node, and it is only accepted where a
23//! documentation comment may attach. A `#` comment is dropped by the
24//! lexer wherever it appears at a legal indentation. A generated file
25//! gains nothing from `nim doc` output and loses the whole file if one
26//! `##` lands where the parser will not take it, so every comment this
27//! renderer writes is a `#`.
28//!
29//! ## The output is pure ASCII
30//!
31//! Every code point outside printable ASCII is escaped, in string
32//! literals and comments alike, for the reason the Python renderer
33//! gives: a file that is pure ASCII cannot be changed by an editor, a
34//! terminal or a CI runner that guessed the wrong encoding.
35
36use crate::writer::{Writer, verbatim_lines};
37use alloc::format;
38use alloc::string::{String, ToString};
39use alloc::vec::Vec;
40use zerodds_lang_cst::common::{Doc, Ident, Literal};
41use zerodds_lang_cst::nim::{
42    ConstDecl, EnumField, Expr, Field, File, Import, Param, ProcDecl, Section, Stmt, Type,
43    TypeBody, TypeDecl,
44};
45
46/// The indentation Nim files are written with.
47///
48/// Two spaces, and spaces rather than a tab because Nim refuses a tab in
49/// the indentation of a line.
50const UNIT: &str = "  ";
51
52/// Render a whole file.
53#[must_use]
54pub fn file(node: &File) -> String {
55    let mut w = Writer::new(UNIT);
56    comment(&mut w, &node.doc);
57    if !node.imports.is_empty() {
58        w.blank_line();
59        for import in &node.imports {
60            w.line(&import_text(import));
61        }
62    }
63    for section in &node.sections {
64        w.blank_line();
65        render_section(&mut w, section);
66    }
67    w.finish()
68}
69
70/// Render one section, for a caller that has only one.
71#[must_use]
72pub fn section(node: &Section) -> String {
73    let mut w = Writer::new(UNIT);
74    render_section(&mut w, node);
75    w.finish()
76}
77
78/// Render one type, for a caller that wants its spelling.
79#[must_use]
80pub fn type_(node: &Type) -> String {
81    type_text(node)
82}
83
84/// Render one expression, for a caller that wants its spelling.
85#[must_use]
86pub fn expr(node: &Expr) -> String {
87    expr_text(node)
88}
89
90/// The identifier a name renders as, for a caller assembling one.
91#[must_use]
92pub fn ident(name: &Ident) -> String {
93    name.as_str().to_string()
94}
95
96fn import_text(node: &Import) -> String {
97    format!("import {}", node.path)
98}
99
100/// A section keyword and the declarations under it.
101///
102/// A section with nothing in it writes nothing at all: `type` on a line
103/// with no indented block after it is a parse error, not an empty
104/// section.
105fn render_section(w: &mut Writer, node: &Section) {
106    match node {
107        Section::Type(decls) => {
108            if decls.is_empty() {
109                return;
110            }
111            w.line("type");
112            w.indent();
113            for (index, decl) in decls.iter().enumerate() {
114                if index > 0 {
115                    w.blank_line();
116                }
117                render_type_decl(w, decl);
118            }
119            w.dedent();
120        }
121        Section::Const(decls) => {
122            if decls.is_empty() {
123                return;
124            }
125            w.line("const");
126            w.indent();
127            for decl in decls {
128                render_const(w, decl);
129            }
130            w.dedent();
131        }
132        Section::Comment(doc) => comment(w, doc),
133        Section::Verbatim(text) => verbatim_lines(w, text),
134    }
135}
136
137fn render_type_decl(w: &mut Writer, node: &TypeDecl) {
138    for text in &node.text.before {
139        verbatim_lines(w, text);
140    }
141    render_type_decl_proper(w, node);
142    for text in &node.text.after {
143        verbatim_lines(w, text);
144    }
145}
146
147fn render_type_decl_proper(w: &mut Writer, node: &TypeDecl) {
148    // Above the declaration and never inside the body: an object with no
149    // fields has no body to put a comment in, and a body holding only a
150    // comment is not an object body the parser will take.
151    comment(w, &node.doc);
152    let head = format!("{} = ", name_text(&node.name, node.exported));
153    match &node.body {
154        TypeBody::Object(fields) => {
155            w.line(&format!("{head}object"));
156            let body = &node.text.body;
157            if fields.is_empty() && body.begin.is_empty() && body.end.is_empty() {
158                return;
159            }
160            w.indent();
161            for text in &body.begin {
162                verbatim_lines(w, text);
163            }
164            for field in fields {
165                render_field(w, field);
166            }
167            for text in &body.end {
168                verbatim_lines(w, text);
169            }
170            w.dedent();
171        }
172        TypeBody::Enum(fields) => {
173            w.line(&format!("{head}enum"));
174            w.indent();
175            for field in fields {
176                render_enum_field(w, field);
177            }
178            w.dedent();
179        }
180        TypeBody::Alias(type_) => w.line(&format!("{head}{}", type_text(type_))),
181    }
182}
183
184fn render_field(w: &mut Writer, node: &Field) {
185    comment(w, &node.doc);
186    w.line(&format!(
187        "{}: {}",
188        name_text(&node.name, node.exported),
189        type_text(&node.type_)
190    ));
191}
192
193/// One field of an enumeration, on a line of its own.
194///
195/// No comma: Nim's `enumDecl` makes the separator optional and the
196/// newline does the work, which is the form `nimpretty` writes. No
197/// export marker either — the production takes a `symbol`, and the
198/// fields of an exported enumeration are reachable without one.
199fn render_enum_field(w: &mut Writer, node: &EnumField) {
200    comment(w, &node.doc);
201    let mut line = node.name.as_str().to_string();
202    if let Some(value) = &node.value {
203        line.push_str(" = ");
204        line.push_str(&expr_text(value));
205    }
206    w.line(&line);
207}
208
209fn render_const(w: &mut Writer, node: &ConstDecl) {
210    comment(w, &node.doc);
211    let mut line = name_text(&node.name, node.exported);
212    if let Some(type_) = &node.type_ {
213        line.push_str(": ");
214        line.push_str(&type_text(type_));
215    }
216    line.push_str(" = ");
217    line.push_str(&expr_text(&node.value));
218    w.line(&line);
219}
220
221/// A declared name, with the export marker when it carries one.
222///
223/// The `*` goes after the name and before the colon or the `=`, which is
224/// the `identVis` production: `x*: int32`, `Pose* = object`.
225fn name_text(name: &Ident, exported: bool) -> String {
226    let mut out = name.as_str().to_string();
227    if exported {
228        out.push('*');
229    }
230    out
231}
232
233/// zerodds-lint: recursion-depth 64 (one level per collection, alias or
234/// generic argument of the spelled type; `MAX_TYPE_DEPTH` in
235/// `lang-rules/src/emit/nim.rs` refuses a type that nests deeper)
236fn type_text(node: &Type) -> String {
237    match node {
238        Type::Primitive(primitive) => primitive.as_str().to_string(),
239        Type::Named(name) => name.as_str().to_string(),
240        Type::Seq(element) => format!("seq[{}]", type_text(element)),
241        Type::Array { length, element } => {
242            format!("array[{}, {}]", expr_text(length), type_text(element))
243        }
244        Type::Option(inner) => format!("Option[{}]", type_text(inner)),
245        // A prefix, with no brackets of its own: `ref` binds to the type
246        // expression that follows, so `ref seq[int32]` is a reference to
247        // a sequence and not a sequence of references.
248        Type::Ref(target) => format!("ref {}", type_text(target)),
249        Type::Proc { params, result } => {
250            let parts: Vec<String> = params.iter().map(param_text).collect();
251            let mut out = format!("proc ({})", parts.join(", "));
252            if let Some(result) = result {
253                out.push_str(": ");
254                out.push_str(&type_text(result));
255            }
256            out
257        }
258    }
259}
260
261fn param_text(node: &Param) -> String {
262    format!("{}: {}", node.name.as_str(), type_text(&node.type_))
263}
264
265fn expr_text(node: &Expr) -> String {
266    match node {
267        Expr::Literal(literal) => literal_text(literal),
268        Expr::Name(name) => name.as_str().to_string(),
269        Expr::Binary { op, lhs, rhs } => {
270            format!("{} {op} {}", expr_text(lhs), expr_text(rhs))
271        }
272        Expr::Convert { type_, value } => {
273            format!("{}({})", type_text(type_), expr_text(value))
274        }
275        Expr::Paren(inner) => format!("({})", expr_text(inner)),
276        Expr::Call { func, args } => {
277            let parts: Vec<String> = args.iter().map(expr_text).collect();
278            format!("{}({})", expr_text(func), parts.join(", "))
279        }
280        Expr::Dot { value, name } => format!("{}.{}", expr_text(value), name.as_str()),
281        Expr::Index { value, index } => format!("{}[{}]", expr_text(value), expr_text(index)),
282        // A space and never brackets of its own: `not` is a prefix
283        // operator, and the operand carries its parentheses where the
284        // builder asked for them.
285        Expr::Unary { op, value } => format!("{op} {}", expr_text(value)),
286        Expr::Cast { type_, value } => {
287            format!("cast[{}]({})", type_text(type_), expr_text(value))
288        }
289        Expr::NewSeq { element, length } => {
290            format!("newSeq[{}]({})", type_text(element), expr_text(length))
291        }
292        Expr::Deref(value) => format!("{}[]", expr_text(value)),
293    }
294}
295
296/// Render one procedure, for a caller assembling a codec file.
297#[must_use]
298pub fn proc_decl(node: &ProcDecl) -> String {
299    let mut w = Writer::new(UNIT);
300    render_proc(&mut w, node);
301    w.finish()
302}
303
304/// Render a run of procedures, one blank line between them.
305#[must_use]
306pub fn proc_decls(nodes: &[ProcDecl]) -> String {
307    let mut w = Writer::new(UNIT);
308    for (index, node) in nodes.iter().enumerate() {
309        if index > 0 {
310            w.blank_line();
311        }
312        render_proc(&mut w, node);
313    }
314    w.finish()
315}
316
317fn render_proc(w: &mut Writer, node: &ProcDecl) {
318    comment(w, &node.doc);
319    let params: Vec<String> = node.params.iter().map(param_text).collect();
320    // The export marker goes on the name and only on the definition
321    // Nim reads first; it is written on both the forward declaration
322    // and the body here, which Nim accepts as long as the two agree.
323    let marker = if node.exported { "*" } else { "" };
324    let mut head = format!(
325        "proc {}{}({})",
326        node.name.as_str(),
327        marker,
328        params.join(", ")
329    );
330    if let Some(returns) = &node.returns {
331        head.push_str(": ");
332        head.push_str(&type_text(returns));
333    }
334    let Some(body) = &node.body else {
335        // The forward declaration: the signature with no `=`, which is
336        // what lets two codecs call each other whatever order their
337        // bodies come in.
338        w.line(&head);
339        return;
340    };
341    head.push_str(" =");
342    w.line(&head);
343    w.indent();
344    render_body(w, body);
345    w.dedent();
346}
347
348/// A statement list at the current depth; an empty one renders
349/// `discard`, because a block with nothing indented under it does not
350/// parse.
351///
352/// zerodds-lint: recursion-depth = the generated body's block nesting.
353/// The codec walk emits a routine per type rather than inlining one
354/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
355/// graph. What does grow with the IDL is the dispatch chain: a union's
356/// cases, an enumeration's enumerators and a `@mutable` aggregate's
357/// members each become one `If` per arm, in the previous arm's `else`.
358/// The model bounds none of the three counts, and Nim nests the arms in
359/// its own output too, so they cannot be walked iteratively the way
360/// `c.rs` walks them. No number is claimed: this signature returns no
361/// error, so nothing here could report one.
362fn render_body(w: &mut Writer, body: &[Stmt]) {
363    if body.iter().all(|stmt| matches!(stmt, Stmt::Comment(_))) {
364        for stmt in body {
365            render_stmt(w, stmt);
366        }
367        w.line("discard");
368        return;
369    }
370    for stmt in body {
371        render_stmt(w, stmt);
372    }
373}
374
375/// zerodds-lint: recursion-depth = the generated body's block nesting.
376/// The codec walk emits a routine per type rather than inlining one
377/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
378/// graph. What does grow with the IDL is the dispatch chain: a union's
379/// cases, an enumeration's enumerators and a `@mutable` aggregate's
380/// members each become one `If` per arm, in the previous arm's `else`.
381/// The model bounds none of the three counts, and Nim nests the arms in
382/// its own output too, so they cannot be walked iteratively the way
383/// `c.rs` walks them. No number is claimed: this signature returns no
384/// error, so nothing here could report one.
385fn render_stmt(w: &mut Writer, node: &Stmt) {
386    match node {
387        Stmt::Var { name, type_, value } => {
388            let mut line = format!("var {}", name.as_str());
389            if let Some(type_) = type_ {
390                line.push_str(": ");
391                line.push_str(&type_text(type_));
392            }
393            if let Some(value) = value {
394                line.push_str(" = ");
395                line.push_str(&expr_text(value));
396            }
397            w.line(&line);
398        }
399        Stmt::Assign { place, value } => {
400            w.line(&format!("{} = {}", expr_text(place), expr_text(value)));
401        }
402        Stmt::Expr(value) => w.line(&expr_text(value)),
403        Stmt::Discard(value) => w.line(&format!("discard {}", expr_text(value))),
404        Stmt::Return(value) => match value {
405            Some(value) => w.line(&format!("return {}", expr_text(value))),
406            None => w.line("return"),
407        },
408        Stmt::If {
409            condition,
410            then,
411            otherwise,
412        } => {
413            w.line(&format!("if {}:", expr_text(condition)));
414            w.indent();
415            render_body(w, then);
416            w.dedent();
417            if let Some(otherwise) = otherwise {
418                w.line("else:");
419                w.indent();
420                render_body(w, otherwise);
421                w.dedent();
422            }
423        }
424        Stmt::For {
425            counter,
426            bound,
427            body,
428        } => {
429            // The counter is a `uint32` because every bound the shared
430            // walk hands over is a `uint32` count or a literal, and a
431            // literal converts against the range's type.
432            w.line(&format!(
433                "for {} in 0'u32 ..< {}:",
434                counter.as_str(),
435                expr_text(bound)
436            ));
437            w.indent();
438            render_body(w, body);
439            w.dedent();
440        }
441        Stmt::While { condition, body } => {
442            w.line(&format!("while {}:", expr_text(condition)));
443            w.indent();
444            render_body(w, body);
445            w.dedent();
446        }
447        Stmt::Break => w.line("break"),
448        Stmt::Comment(doc) => comment(w, doc),
449    }
450}
451
452fn literal_text(literal: &Literal) -> String {
453    match literal {
454        Literal::Int(text) | Literal::Float(text) => text.clone(),
455        Literal::Bool(value) => {
456            if *value {
457                "true".to_string()
458            } else {
459                "false".to_string()
460            }
461        }
462        // A Nim character literal is one byte and takes no escape for a
463        // newline: `'\n'` is refused because a newline may be one
464        // character or two, and `'\L'` and `'\x0A'` are what is left.
465        // The numeric form is written for every character outside
466        // printable ASCII, so the arm needs no table of Nim's letter
467        // escapes.
468        Literal::Char(ch) => char_literal(*ch),
469        Literal::Str(text) => string_literal(text),
470        // `@[...]` is a `seq` literal; the `'u8` suffix is what makes
471        // the elements octets rather than the platform's `int`.
472        Literal::Bytes(bytes) => {
473            let parts: Vec<String> = bytes.iter().map(|b| format!("0x{b:02x}'u8")).collect();
474            format!("@[{}]", parts.join(", "))
475        }
476        Literal::Null => "nil".to_string(),
477    }
478}
479
480/// A Nim character literal holding `ch`.
481///
482/// Nim's `char` is one byte, so a code point above 0x7F has no character
483/// literal at all; it is written as the first octet of nothing and would
484/// be wrong. Only the printable ASCII range is written as a quoted
485/// character — everything else takes `'\xNN'`, and a code point that
486/// does not fit in one octet is refused a literal by being written as
487/// its low octet is not: it takes `'\x00'` only when it genuinely is
488/// zero. A caller with a wide character writes an integer instead, which
489/// is what `zerodds_lang_rules::adapter::nim` does.
490#[must_use]
491pub fn char_literal(ch: char) -> String {
492    let point = u32::from(ch);
493    match ch {
494        '\'' => "'\\''".to_string(),
495        '\\' => "'\\\\'".to_string(),
496        _ if (0x20..0x7f).contains(&point) => format!("'{ch}'"),
497        _ if point <= 0xff => format!("'\\x{point:02x}'"),
498        // Not one octet. Written as the escape for the replacement
499        // octet would be a substitution; the code point is written as a
500        // number instead, which is not a `char` and will not typecheck
501        // where one belongs — which is the loud failure.
502        _ => format!("{point}"),
503    }
504}
505
506/// A double-quoted Nim string literal holding `text`, in ASCII.
507///
508/// The escape is `\xHH` per **octet** of the UTF-8 encoding, not per
509/// code point. A Nim `string` is a sequence of bytes with no encoding of
510/// its own, and `\xHH` inserts one byte (Nim manual, Lexical Analysis,
511/// "String literals"), so writing the UTF-8 octets is what puts the same
512/// bytes in the string that the source had. Exactly two hex digits are
513/// read after `\x`, so a literal character that happens to be a hex
514/// digit cannot extend the escape.
515#[must_use]
516pub fn string_literal(text: &str) -> String {
517    let mut out = String::from("\"");
518    out.push_str(&escape_ascii(text));
519    out.push('"');
520    out
521}
522
523/// The body of a Nim string literal: `text`, escaped down to ASCII.
524fn escape_ascii(text: &str) -> String {
525    let mut out = String::new();
526    let mut buffer = [0_u8; 4];
527    for ch in text.chars() {
528        match ch {
529            '"' => out.push_str("\\\""),
530            '\\' => out.push_str("\\\\"),
531            '\n' => out.push_str("\\n"),
532            '\r' => out.push_str("\\r"),
533            '\t' => out.push_str("\\t"),
534            other => {
535                let point = u32::from(other);
536                if (0x20..0x7f).contains(&point) {
537                    out.push(other);
538                } else {
539                    for byte in other.encode_utf8(&mut buffer).as_bytes() {
540                        out.push_str(&format!("\\x{byte:02x}"));
541                    }
542                }
543            }
544        }
545    }
546    out
547}
548
549/// A `#` comment, one line per entry.
550///
551/// The text is reduced to ASCII for the reason the module documentation
552/// gives. A comment carrying a newline would put the indentation of its
553/// second line beyond the writer's reach, which is why [`Doc`] holds
554/// lines and not a string.
555fn comment(w: &mut Writer, doc: &Doc) {
556    for line in doc.as_lines() {
557        if line.is_empty() {
558            w.line("#");
559        } else {
560            w.line(&format!("# {}", escape_ascii(line)));
561        }
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
568    use super::*;
569    use alloc::boxed::Box;
570    use alloc::vec;
571    use zerodds_lang_cst::nim::Primitive;
572
573    fn field(name: &str, type_: Type) -> Field {
574        Field {
575            doc: Doc::none(),
576            name: Ident::new(name),
577            exported: true,
578            type_,
579        }
580    }
581
582    #[test]
583    fn an_object_indents_its_fields_by_two_under_the_type_section() {
584        // Two levels: one for the declaration inside `type`, one for the
585        // fields inside the object. Nim has no closing delimiter, so the
586        // dedent is the end of the block.
587        let text = section(&Section::Type(vec![TypeDecl {
588            text: zerodds_lang_cst::nim::TypeText::default(),
589            doc: Doc::none(),
590            name: Ident::new("Pose"),
591            exported: true,
592            body: TypeBody::Object(vec![
593                field("x", Type::Primitive(Primitive::Int32)),
594                field("y", Type::Primitive(Primitive::Float64)),
595            ]),
596        }]));
597        assert_eq!(
598            text,
599            "type\n  Pose* = object\n    x*: int32\n    y*: float64\n"
600        );
601    }
602
603    #[test]
604    fn an_object_with_no_fields_is_a_bare_object() {
605        // Legal Nim, so no refusal is needed for a struct with no
606        // members. A body holding only the comment would not be.
607        let text = section(&Section::Type(vec![TypeDecl {
608            text: zerodds_lang_cst::nim::TypeText::default(),
609            doc: Doc::line("Empty."),
610            name: Ident::new("E"),
611            exported: true,
612            body: TypeBody::Object(Vec::new()),
613        }]));
614        assert_eq!(text, "type\n  # Empty.\n  E* = object\n");
615    }
616
617    #[test]
618    fn an_enumeration_writes_one_field_per_line_with_no_export_marker() {
619        let text = section(&Section::Type(vec![TypeDecl {
620            text: zerodds_lang_cst::nim::TypeText::default(),
621            doc: Doc::none(),
622            name: Ident::new("Mode"),
623            exported: true,
624            body: TypeBody::Enum(vec![
625                EnumField {
626                    doc: Doc::none(),
627                    name: Ident::new("IDLE"),
628                    value: Some(Expr::int(0)),
629                },
630                EnumField {
631                    doc: Doc::none(),
632                    name: Ident::new("ACTIVE"),
633                    value: Some(Expr::int(4)),
634                },
635            ]),
636        }]));
637        assert_eq!(text, "type\n  Mode* = enum\n    IDLE = 0\n    ACTIVE = 4\n");
638    }
639
640    #[test]
641    fn an_empty_section_writes_nothing_because_the_keyword_alone_is_a_syntax_error() {
642        assert_eq!(section(&Section::Type(Vec::new())), "");
643        assert_eq!(section(&Section::Const(Vec::new())), "");
644    }
645
646    #[test]
647    fn a_const_carries_its_declared_type_before_the_value() {
648        let text = section(&Section::Const(vec![ConstDecl {
649            doc: Doc::none(),
650            name: Ident::new("LIMIT"),
651            exported: true,
652            type_: Some(Type::Primitive(Primitive::UInt8)),
653            value: Expr::int(8),
654        }]));
655        assert_eq!(text, "const\n  LIMIT*: uint8 = 8\n");
656    }
657
658    #[test]
659    fn a_bitmask_constant_shifts_in_the_masks_own_width() {
660        // `Flags(1) shl 63` is an unsigned shift; `1 shl 63` is an `int`
661        // shift and overflows into a negative number before the
662        // conversion could happen.
663        let text = expr(&Expr::Binary {
664            op: "shl",
665            lhs: Box::new(Expr::Convert {
666                type_: Box::new(Type::named("Flags")),
667                value: Box::new(Expr::int(1)),
668            }),
669            rhs: Box::new(Expr::int(63)),
670        });
671        assert_eq!(text, "Flags(1) shl 63");
672    }
673
674    #[test]
675    fn every_collection_writes_the_bracket_form_nim_reads() {
676        assert_eq!(
677            type_(&Type::seq(Type::Primitive(Primitive::Int32))),
678            "seq[int32]"
679        );
680        assert_eq!(
681            type_(&Type::array(
682                2,
683                Type::array(3, Type::Primitive(Primitive::Int32))
684            )),
685            "array[2, array[3, int32]]"
686        );
687        assert_eq!(
688            type_(&Type::option(Type::Primitive(Primitive::UInt8))),
689            "Option[uint8]"
690        );
691    }
692
693    #[test]
694    fn a_reference_is_a_prefix_and_takes_no_brackets_of_its_own() {
695        // `@optional @external Node next` is an `Option` outside a
696        // `ref`: the reference is what makes the declaration finite and
697        // the `Option` is what says the member may be absent.
698        assert_eq!(
699            type_(&Type::option(Type::reference(Type::named("Node")))),
700            "Option[ref Node]"
701        );
702        // `ref` binds to the whole type expression after it, so this is
703        // a reference to a sequence and not a sequence of references.
704        assert_eq!(
705            type_(&Type::reference(Type::seq(Type::Primitive(
706                Primitive::Int32
707            )))),
708            "ref seq[int32]"
709        );
710    }
711
712    #[test]
713    fn a_procedural_type_writes_its_parameters_and_its_result() {
714        assert_eq!(
715            type_(&Type::Proc {
716                params: vec![Param {
717                    name: Ident::new("target"),
718                    type_: Type::named("Pose"),
719                }],
720                result: None,
721            }),
722            "proc (target: Pose)"
723        );
724        assert_eq!(
725            type_(&Type::Proc {
726                params: Vec::new(),
727                result: Some(Box::new(Type::Primitive(Primitive::Int32))),
728            }),
729            "proc (): int32"
730        );
731    }
732
733    #[test]
734    fn a_string_literal_leaves_the_file_pure_ascii_one_escape_per_octet() {
735        assert_eq!(string_literal("Z"), "\"Z\"");
736        // U+00E4 is two octets in UTF-8, and a Nim `\xHH` escape inserts
737        // one byte — so it takes two escapes and not one.
738        assert_eq!(string_literal("\u{e4}"), "\"\\xc3\\xa4\"");
739        assert_eq!(string_literal("a\"b\\c"), "\"a\\\"b\\\\c\"");
740        assert_eq!(string_literal("a\nb"), "\"a\\nb\"");
741    }
742
743    #[test]
744    fn a_character_literal_takes_the_hex_escape_where_nim_has_no_letter_one() {
745        assert_eq!(char_literal('a'), "'a'");
746        assert_eq!(char_literal('\''), "'\\''");
747        // Nim refuses `'\n'`: a newline may be one character or two.
748        assert_eq!(char_literal('\n'), "'\\x0a'");
749    }
750
751    #[test]
752    fn a_file_puts_its_imports_above_the_sections_and_below_the_comment() {
753        let text = file(&File {
754            doc: Doc::line("Generated."),
755            imports: vec![Import::new("std/options")],
756            sections: vec![Section::Type(vec![TypeDecl {
757                text: zerodds_lang_cst::nim::TypeText::default(),
758                doc: Doc::none(),
759                name: Ident::new("S"),
760                exported: true,
761                body: TypeBody::Object(vec![field(
762                    "a",
763                    Type::option(Type::Primitive(Primitive::Int32)),
764                )]),
765            }])],
766        });
767        assert_eq!(
768            text,
769            "# Generated.\n\nimport std/options\n\ntype\n  S* = object\n    a*: Option[int32]\n"
770        );
771    }
772
773    #[test]
774    fn every_expression_form_writes_the_nim_spelling() {
775        assert_eq!(
776            expr(&Expr::call(
777                Expr::dot(Expr::name("w"), "writeU32"),
778                vec![Expr::dot(Expr::name("v"), "x")],
779            )),
780            "w.writeU32(v.x)"
781        );
782        assert_eq!(
783            expr(&Expr::Index {
784                value: Box::new(Expr::name("v")),
785                index: Box::new(Expr::name("i")),
786            }),
787            "v[i]"
788        );
789        assert_eq!(
790            expr(&Expr::Unary {
791                op: "not",
792                value: Box::new(Expr::name("zd_sent")),
793            }),
794            "not zd_sent"
795        );
796        // `cast` reinterprets where a conversion would range-check:
797        // `int8(200'u8)` raises and `cast[int8](200'u8)` is `-56`.
798        assert_eq!(
799            expr(&Expr::Cast {
800                type_: Box::new(Type::Primitive(Primitive::Int8)),
801                value: Box::new(Expr::name("k")),
802            }),
803            "cast[int8](k)"
804        );
805        assert_eq!(
806            expr(&Expr::NewSeq {
807                element: Box::new(Type::Primitive(Primitive::Int32)),
808                length: Box::new(Expr::name("zd_n")),
809            }),
810            "newSeq[int32](zd_n)"
811        );
812        assert_eq!(expr(&Expr::Deref(Box::new(Expr::name("p")))), "p[]");
813    }
814
815    #[test]
816    fn a_proc_indents_its_body_and_a_forward_declaration_has_no_equals() {
817        let node = ProcDecl {
818            doc: Doc::none(),
819            exported: false,
820            name: Ident::new("zd_enc_S"),
821            params: vec![
822                Param {
823                    name: Ident::new("w"),
824                    type_: Type::named("zdwire.Writer"),
825                },
826                Param {
827                    name: Ident::new("v"),
828                    type_: Type::named("S"),
829                },
830            ],
831            returns: None,
832            body: Some(vec![Stmt::Expr(Expr::call(
833                Expr::dot(Expr::name("w"), "writeBool"),
834                vec![Expr::dot(Expr::name("v"), "a")],
835            ))]),
836        };
837        assert_eq!(
838            proc_decl(&node),
839            "proc zd_enc_S(w: zdwire.Writer, v: S) =\n  w.writeBool(v.a)\n"
840        );
841        let forward = ProcDecl { body: None, ..node };
842        // No `=`: Nim resolves a module-scope name only after its
843        // declaration, so this is what lets mutually recursive codecs
844        // compile in either order.
845        assert_eq!(
846            proc_decl(&forward),
847            "proc zd_enc_S(w: zdwire.Writer, v: S)\n"
848        );
849    }
850
851    #[test]
852    fn a_proc_with_a_result_writes_it_and_an_empty_body_is_discard() {
853        let node = ProcDecl {
854            doc: Doc::line("Table 9."),
855            exported: false,
856            name: Ident::new("zd_def_p_long"),
857            params: Vec::new(),
858            returns: Some(Type::Primitive(Primitive::Int32)),
859            body: Some(vec![Stmt::Return(Some(Expr::int(0)))]),
860        };
861        assert_eq!(
862            proc_decl(&node),
863            "# Table 9.\nproc zd_def_p_long(): int32 =\n  return 0\n"
864        );
865        let empty = ProcDecl {
866            doc: Doc::none(),
867            exported: false,
868            name: Ident::new("zd_noop"),
869            params: Vec::new(),
870            returns: None,
871            body: Some(Vec::new()),
872        };
873        // A bare `=` with nothing under it does not parse; `discard` is
874        // Nim's statement that does nothing.
875        assert_eq!(proc_decl(&empty), "proc zd_noop() =\n  discard\n");
876    }
877
878    #[test]
879    fn every_statement_form_indents_the_way_nim_reads() {
880        let body = vec![
881            Stmt::Var {
882                name: Ident::new("zd_n"),
883                type_: Some(Type::Primitive(Primitive::UInt32)),
884                value: Some(Expr::call(
885                    Expr::dot(Expr::name("r"), "readU32"),
886                    Vec::new(),
887                )),
888            },
889            Stmt::For {
890                counter: Ident::new("zd_i"),
891                bound: Box::new(Expr::name("zd_n")),
892                body: vec![Stmt::Assign {
893                    place: Expr::Index {
894                        value: Box::new(Expr::name("v")),
895                        index: Box::new(Expr::Convert {
896                            type_: Box::new(Type::named("int")),
897                            value: Box::new(Expr::name("zd_i")),
898                        }),
899                    },
900                    value: Expr::int(0),
901                }],
902            },
903            Stmt::While {
904                condition: Expr::Literal(Literal::Bool(true)),
905                body: vec![Stmt::Break],
906            },
907            Stmt::If {
908                condition: Expr::name("zd_over"),
909                then: vec![Stmt::Discard(Expr::name("zd_tc"))],
910                otherwise: Some(vec![Stmt::Return(None)]),
911            },
912            Stmt::Comment(Doc::line("done")),
913        ];
914        let node = ProcDecl {
915            doc: Doc::none(),
916            exported: false,
917            name: Ident::new("f"),
918            params: Vec::new(),
919            returns: None,
920            body: Some(body),
921        };
922        assert_eq!(
923            proc_decl(&node),
924            "proc f() =\n\
925             \x20 var zd_n: uint32 = r.readU32()\n\
926             \x20 for zd_i in 0'u32 ..< zd_n:\n\
927             \x20   v[int(zd_i)] = 0\n\
928             \x20 while true:\n\
929             \x20   break\n\
930             \x20 if zd_over:\n\
931             \x20   discard zd_tc\n\
932             \x20 else:\n\
933             \x20   return\n\
934             \x20 # done\n"
935        );
936    }
937
938    #[test]
939    fn no_line_carries_trailing_whitespace_or_a_tab() {
940        // Nim refuses a tab in the indentation of a line outright, and
941        // trailing whitespace is what a formatter strips and a byte
942        // comparison then reports as a difference.
943        let text = file(&File {
944            doc: Doc::lines(&["one", "", "three"]),
945            imports: Vec::new(),
946            sections: vec![
947                Section::Type(vec![TypeDecl {
948                    text: zerodds_lang_cst::nim::TypeText::default(),
949                    doc: Doc::none(),
950                    name: Ident::new("A"),
951                    exported: true,
952                    body: TypeBody::Object(vec![field("a", Type::Primitive(Primitive::Bool))]),
953                }]),
954                Section::Const(vec![ConstDecl {
955                    doc: Doc::none(),
956                    name: Ident::new("N"),
957                    exported: true,
958                    type_: None,
959                    value: Expr::int(1),
960                }]),
961            ],
962        });
963        for line in text.lines() {
964            assert_eq!(line, line.trim_end(), "trailing whitespace: {line:?}");
965            assert!(!line.contains('\t'), "a tab reached the output: {line:?}");
966        }
967    }
968}