Skip to main content

zerodds_lang_render/
zig.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Rendering the Zig CST. Spec §5.
4//!
5//! One style, `zig fmt`'s: four spaces, the opening brace on the same
6//! line, a trailing comma on every member of a multi-line container,
7//! `//` and `///` comments. Not a preference — `zig fmt` ships with the
8//! compiler and every reader of a generated file runs it, so a file that
9//! moves under it is a diff nobody asked for. The layout here is checked
10//! against the real formatter by
11//! `zerodds-lang-rules/tests/zig_end_to_end.rs`.
12//!
13//! Nothing is decided here. A Zig type reads left to right and carries
14//! its own length, optionality and pointer depth, so unlike
15//! [`crate::c`] there is no declarator to split around a name:
16//! a field is the name, a colon, and the type.
17//!
18//! ## Three places Zig's lexical rules differ from the C family
19//!
20//! 1. **There is no `\0` escape.** Zig's string escapes are `\n`, `\r`,
21//!    `\t`, `\\`, `\'`, `\"`, `\xNN` and `\u{…}` (Zig language
22//!    reference, "String Literals and Unicode Code Point Literals"). The
23//!    shared [`crate::writer::escape_string`] writes `\0`, which no Zig
24//!    compiler reads, so [`string_text`] is this module's own.
25//! 2. **A documentation comment must be attached.** `///` with no
26//!    declaration under it is `error: unattached documentation comment`,
27//!    and one above a `test` block is refused as well. The file's own
28//!    comment is therefore `//!` — the container form, which is only
29//!    legal at the top — and [`zerodds_lang_cst::zig::AnalyseAll`]'s is
30//!    a plain `//`.
31//! 3. **A container's members end in a comma, not a semicolon.** A
32//!    struct field, an enum variant and a function parameter are all
33//!    comma-separated; only a declaration ends in `;`.
34
35use crate::writer::{Writer, comment_lines, verbatim_lines};
36use alloc::string::{String, ToString};
37use alloc::vec::Vec;
38use zerodds_lang_cst::common::{BodyText, Doc, Ident, Literal};
39use zerodds_lang_cst::zig::{
40    AnalyseAll, ConstDecl, Decl, Expr, Field, File, FuncDecl, Param, Stmt, Type, Variant,
41};
42
43/// Render a whole file.
44#[must_use]
45pub fn file(file: &File) -> String {
46    let mut w = Writer::spaces4();
47    if !file.doc.is_empty() {
48        comment_lines(&mut w, "//! ", file.doc.as_lines());
49    }
50    for decl in &file.decls {
51        w.blank_line();
52        render_decl(&mut w, decl);
53    }
54    w.finish()
55}
56
57/// Render one declaration, for a caller that has only one.
58#[must_use]
59pub fn decl(node: &Decl) -> String {
60    let mut w = Writer::spaces4();
61    render_decl(&mut w, node);
62    w.finish()
63}
64
65/// Render one type, for a caller that has only one.
66#[must_use]
67pub fn type_(node: &Type) -> String {
68    type_text(node)
69}
70
71/// The identifier a name renders as, for a caller assembling one.
72#[must_use]
73pub fn ident(name: &Ident) -> String {
74    name.as_str().to_string()
75}
76
77fn render_decl(w: &mut Writer, node: &Decl) {
78    match node {
79        Decl::Const(decl) => render_const(w, decl),
80        Decl::Func(decl) => render_func(w, decl),
81        Decl::Comment(lines) => comment_lines(w, "// ", lines.as_lines()),
82        Decl::Verbatim(text) => verbatim_lines(w, text),
83        Decl::AnalyseAll(block) => render_analyse_all(w, block),
84    }
85}
86
87/// `pub fn name(a: T, b: U) R { body }`.
88fn render_func(w: &mut Writer, node: &FuncDecl) {
89    doc(w, &node.doc);
90    let mut head = String::new();
91    if node.is_pub {
92        head.push_str("pub ");
93    }
94    head.push_str("fn ");
95    head.push_str(node.name.as_str());
96    head.push('(');
97    let params: Vec<String> = node
98        .params
99        .iter()
100        .map(|param| {
101            let mut out = String::from(param.name.as_str());
102            out.push_str(": ");
103            out.push_str(&type_text(&param.type_));
104            out
105        })
106        .collect();
107    head.push_str(&params.join(", "));
108    head.push_str(") ");
109    head.push_str(&type_text(&node.returns));
110    head.push_str(" {");
111    w.line(&head);
112    w.indent();
113    for statement in &node.body {
114        render_stmt(w, statement);
115    }
116    w.dedent();
117    w.line("}");
118}
119
120/// zerodds-lint: recursion-depth 64 (one level per nested block of a
121/// generated body. The codec walk emits a routine per type rather than
122/// inlining one — `lang-rules/src/emit/encode.rs` — so a body nests by
123/// its own framing and by one loop per array dimension, never by the
124/// type graph, whose descent stops at 64 anyway; the else-if chain, one
125/// arm per union case, is walked iteratively and costs no depth.)
126fn render_stmt(w: &mut Writer, node: &Stmt) {
127    match node {
128        Stmt::Var {
129            name,
130            type_,
131            value,
132            mutable,
133        } => {
134            let mut line = String::from(if *mutable { "var " } else { "const " });
135            line.push_str(name.as_str());
136            if let Some(type_) = type_ {
137                line.push_str(": ");
138                line.push_str(&type_text(type_));
139            }
140            line.push_str(" = ");
141            match value {
142                Some(value) => line.push_str(&expr_text(value)),
143                None => line.push_str("undefined"),
144            }
145            line.push(';');
146            w.line(&line);
147        }
148        Stmt::Assign { place, value } => {
149            let mut line = expr_text(place);
150            line.push_str(" = ");
151            line.push_str(&expr_text(value));
152            line.push(';');
153            w.line(&line);
154        }
155        Stmt::Expr(value) => {
156            let mut line = expr_text(value);
157            line.push(';');
158            w.line(&line);
159        }
160        Stmt::DiscardExpr(value) => {
161            let mut line = String::from("_ = ");
162            line.push_str(&expr_text(value));
163            line.push(';');
164            w.line(&line);
165        }
166        Stmt::Discard(name) => {
167            let mut line = String::from("_ = ");
168            line.push_str(name.as_str());
169            line.push(';');
170            w.line(&line);
171        }
172        Stmt::Return(value) => match value {
173            Some(value) => {
174                let mut line = String::from("return ");
175                line.push_str(&expr_text(value));
176                line.push(';');
177                w.line(&line);
178            }
179            None => w.line("return;"),
180        },
181        Stmt::If { .. } => render_if(w, node, "if ("),
182        Stmt::CountUp {
183            counter,
184            type_,
185            bound,
186            body,
187        } => {
188            // The outer block scopes the counter, so two sequential
189            // loops may share a name without Zig's shadowing error.
190            w.line("{");
191            w.indent();
192            let mut line = String::from("var ");
193            line.push_str(counter.as_str());
194            line.push_str(": ");
195            line.push_str(&type_text(type_));
196            line.push_str(" = 0;");
197            w.line(&line);
198            let mut head = String::from("while (");
199            head.push_str(counter.as_str());
200            head.push_str(" < ");
201            head.push_str(&expr_text(bound));
202            head.push_str(") : (");
203            head.push_str(counter.as_str());
204            head.push_str(" += 1) {");
205            w.line(&head);
206            w.indent();
207            for statement in body {
208                render_stmt(w, statement);
209            }
210            w.dedent();
211            w.line("}");
212            w.dedent();
213            w.line("}");
214        }
215        Stmt::While { condition, body } => {
216            let mut head = String::from("while (");
217            head.push_str(&expr_text(condition));
218            head.push_str(") {");
219            w.line(&head);
220            w.indent();
221            for statement in body {
222                render_stmt(w, statement);
223            }
224            w.dedent();
225            w.line("}");
226        }
227        Stmt::Break => w.line("break;"),
228        Stmt::Comment(lines) => comment_lines(w, "// ", lines.as_lines()),
229    }
230}
231
232/// An `if`, with an `else`-block of exactly one `if` collapsed into
233/// `else if` — the shape a long dispatch chain stays readable in, and a
234/// shape `zig fmt` leaves exactly as written.
235///
236/// The next arm is taken in a loop and not by a call to this function
237/// again. The chain's length is the union's case count, the
238/// enumeration's enumerator count or the `@mutable` aggregate's member
239/// count — `lang-rules/src/emit/encode.rs` builds one `If` per arm, each
240/// in the previous one's `else` — and nothing in the model bounds any of
241/// the three.
242///
243/// zerodds-lint: recursion-depth 64 (block nesting only; the arm chain
244/// is walked iteratively, and what remains is one level per nested block
245/// of a generated body — the codec walk emits a routine per type rather
246/// than inlining, so a body nests by its framing and by one loop per
247/// array dimension, never by the type graph)
248fn render_if(w: &mut Writer, node: &Stmt, keyword: &str) {
249    let mut node = node;
250    let mut keyword = keyword;
251    loop {
252        let Stmt::If {
253            condition,
254            then,
255            otherwise,
256        } = node
257        else {
258            return;
259        };
260        let mut head = String::from(keyword);
261        head.push_str(&expr_text(condition));
262        head.push_str(") {");
263        w.line(&head);
264        w.indent();
265        for statement in then {
266            render_stmt(w, statement);
267        }
268        w.dedent();
269        match otherwise {
270            None => {
271                w.line("}");
272                return;
273            }
274            Some(chain) if chain.len() == 1 && matches!(chain[0], Stmt::If { .. }) => {
275                node = &chain[0];
276                keyword = "} else if (";
277            }
278            Some(block) => {
279                w.line("} else {");
280                w.indent();
281                for statement in block {
282                    render_stmt(w, statement);
283                }
284                w.dedent();
285                w.line("}");
286                return;
287            }
288        }
289    }
290}
291
292/// `pub const Name: T = value;`
293///
294/// A value that is a container — a struct or an enum with members — is
295/// written across lines, so its fields can be indented against the
296/// declaration and can carry documentation of their own. Every other
297/// value fits on the line.
298fn render_const(w: &mut Writer, node: &ConstDecl) {
299    doc(w, &node.doc);
300    let mut head = String::new();
301    if node.is_pub {
302        head.push_str("pub ");
303    }
304    head.push_str("const ");
305    head.push_str(node.name.as_str());
306    if let Some(type_) = &node.type_ {
307        head.push_str(": ");
308        head.push_str(&type_text(type_));
309    }
310    head.push_str(" = ");
311    match &node.value {
312        Expr::Type(type_) => render_container(w, head, type_, &node.body_text),
313        other => {
314            head.push_str(&expr_text(other));
315            head.push(';');
316            w.line(&head);
317        }
318    }
319}
320
321/// A type on the right-hand side of a declaration, opened out where it
322/// has members or `@verbatim` text.
323fn render_container(w: &mut Writer, mut head: String, node: &Type, body_text: &BodyText) {
324    let has_text = !body_text.begin.is_empty() || !body_text.end.is_empty();
325    match node {
326        Type::Struct(fields) if !fields.is_empty() || has_text => {
327            head.push_str("struct {");
328            w.line(&head);
329            w.indent();
330            for text in &body_text.begin {
331                verbatim_lines(w, text);
332            }
333            for field in fields {
334                render_field(w, field);
335            }
336            for text in &body_text.end {
337                verbatim_lines(w, text);
338            }
339            w.dedent();
340            w.line("};");
341        }
342        Type::Enum { tag, variants } if !variants.is_empty() => {
343            head.push_str("enum(");
344            head.push_str(&type_text(tag));
345            head.push_str(") {");
346            w.line(&head);
347            w.indent();
348            for variant in variants {
349                render_variant(w, variant);
350            }
351            w.dedent();
352            w.line("};");
353        }
354        other => {
355            head.push_str(&type_text(other));
356            head.push(';');
357            w.line(&head);
358        }
359    }
360}
361
362fn render_field(w: &mut Writer, node: &Field) {
363    doc(w, &node.doc);
364    let mut line = String::from(node.name.as_str());
365    line.push_str(": ");
366    line.push_str(&type_text(&node.type_));
367    if let Some(default) = &node.default {
368        line.push_str(" = ");
369        line.push_str(&expr_text(default));
370    }
371    line.push(',');
372    w.line(&line);
373}
374
375fn render_variant(w: &mut Writer, node: &Variant) {
376    doc(w, &node.doc);
377    let mut line = String::from(node.name.as_str());
378    if let Some(value) = &node.value {
379        line.push_str(" = ");
380        line.push_str(&expr_text(value));
381    }
382    line.push(',');
383    w.line(&line);
384}
385
386/// The `test` block that makes Zig analyse every declaration.
387///
388/// Its comment is `//` and not `///`: Zig refuses a documentation
389/// comment attached to a test block.
390fn render_analyse_all(w: &mut Writer, node: &AnalyseAll) {
391    if !node.doc.is_empty() {
392        comment_lines(w, "// ", node.doc.as_lines());
393    }
394    // The walk is written out rather than called from `std.testing`.
395    // `refAllDeclsRecursive` was removed in Zig 0.16; what remains is
396    // `refAllDecls`, which stops at the top level. Using that instead
397    // would leave every nested container unanalysed — and a nested
398    // container is exactly where a generated codec puts a union's arms
399    // and a map's entry type, so the block would still be there and no
400    // longer do what it is for.
401    w.line("test {");
402    w.indent();
403    w.line("const zd_walk = struct {");
404    w.indent();
405    // `zd_` on the parameter too: a single-letter name would shadow a
406    // generated declaration, and an IDL is free to declare `struct T`.
407    // Zig rejects the shadowing outright, so the escape is not a matter
408    // of taste — the file would not compile.
409    w.line("fn all(comptime zd_T: type) void {");
410    w.indent();
411    w.line("inline for (comptime @import(\"std\").meta.declarations(zd_T)) |zd_decl| {");
412    w.indent();
413    w.line("const zd_field = @field(zd_T, zd_decl.name);");
414    w.line("if (@TypeOf(zd_field) == type) {");
415    w.indent();
416    w.line("switch (@typeInfo(zd_field)) {");
417    w.indent();
418    w.line(".@\"struct\", .@\"enum\", .@\"union\", .@\"opaque\" => all(zd_field),");
419    w.line("else => {},");
420    w.dedent();
421    w.line("}");
422    w.dedent();
423    w.line("}");
424    w.line("_ = &zd_field;");
425    w.dedent();
426    w.line("}");
427    w.dedent();
428    w.line("}");
429    w.dedent();
430    w.line("}.all;");
431    w.line("zd_walk(@This());");
432    w.dedent();
433    w.line("}");
434}
435
436/// A struct or enum written inline, for a member whose type is one.
437///
438/// Reached only through [`type_text`]; a top-level container is written
439/// by [`render_container`], which can indent it against the
440/// declaration.
441fn container_text(node: &Type) -> String {
442    let mut w = Writer::spaces4();
443    render_container(&mut w, String::new(), node, &BodyText::none());
444    // `render_container` closes a declaration, so the `;` it wrote is
445    // not part of the type. An inline container is a type expression and
446    // the member's own comma follows it.
447    w.finish().trim_end().trim_end_matches(';').to_string()
448}
449
450/// zerodds-lint: recursion-depth 64 (one level per collection, alias or
451/// generic argument of the spelled type; `MAX_TYPE_DEPTH` in
452/// `lang-rules/src/emit/zig.rs` refuses a type that nests deeper)
453fn type_text(node: &Type) -> String {
454    match node {
455        Type::Primitive(primitive) => primitive.as_str().to_string(),
456        Type::Named(name) => name.as_str().to_string(),
457        Type::Slice { is_const, element } => {
458            let mut out = String::from("[]");
459            if *is_const {
460                out.push_str("const ");
461            }
462            out.push_str(&type_text(element));
463            out
464        }
465        Type::Array { length, element } => {
466            let mut out = String::from("[");
467            out.push_str(&expr_text(length));
468            out.push(']');
469            out.push_str(&type_text(element));
470            out
471        }
472        Type::Optional(inner) => {
473            let mut out = String::from("?");
474            out.push_str(&type_text(inner));
475            out
476        }
477        Type::Pointer { is_const, target } => {
478            let mut out = String::from("*");
479            if *is_const {
480                out.push_str("const ");
481            }
482            out.push_str(&type_text(target));
483            out
484        }
485        Type::Struct(fields) => {
486            if fields.is_empty() {
487                return "struct {}".to_string();
488            }
489            container_text(node)
490        }
491        Type::Enum { tag, variants } => {
492            if variants.is_empty() {
493                let mut out = String::from("enum(");
494                out.push_str(&type_text(tag));
495                out.push_str(") {}");
496                return out;
497            }
498            container_text(node)
499        }
500        // `fn (a: T) R`, with the space `zig fmt` puts after `fn`.
501        Type::Fn { params, returns } => {
502            let mut out = String::from("fn (");
503            let parts: Vec<String> = params.iter().map(param_text).collect();
504            out.push_str(&parts.join(", "));
505            out.push_str(") ");
506            out.push_str(&type_text(returns));
507            out
508        }
509        Type::ErrorUnion { error_set, payload } => {
510            let mut out = type_text(error_set);
511            out.push('!');
512            out.push_str(&type_text(payload));
513            out
514        }
515    }
516}
517
518fn param_text(param: &Param) -> String {
519    let mut out = String::new();
520    if let Some(name) = &param.name {
521        out.push_str(name.as_str());
522        out.push_str(": ");
523    }
524    out.push_str(&type_text(&param.type_));
525    out
526}
527
528/// zerodds-lint: recursion-depth = the built expression's nesting. A walk
529/// calls a routine per type rather than nesting one, so the one operand
530/// tree that grows with the IDL is an array's default value: one composite
531/// literal per dimension, built in `lang-rules/src/default_zig.rs`, and
532/// nothing bounds the dimension count. No number is claimed: this
533/// signature returns no error.
534fn expr_text(node: &Expr) -> String {
535    match node {
536        Expr::Literal(literal) => literal_text(literal),
537        Expr::Name(name) => name.as_str().to_string(),
538        Expr::Binary { op, lhs, rhs } => {
539            let mut out = expr_text(lhs);
540            out.push(' ');
541            out.push_str(op);
542            out.push(' ');
543            out.push_str(&expr_text(rhs));
544            out
545        }
546        Expr::Paren(inner) => {
547            let mut out = String::from("(");
548            out.push_str(&expr_text(inner));
549            out.push(')');
550            out
551        }
552        Expr::Type(type_) => type_text(type_),
553        Expr::Call { func, args } => {
554            let mut out = expr_text(func);
555            out.push('(');
556            let parts: Vec<String> = args.iter().map(expr_text).collect();
557            out.push_str(&parts.join(", "));
558            out.push(')');
559            out
560        }
561        Expr::Dot { value, name } => {
562            let mut out = expr_text(value);
563            out.push('.');
564            out.push_str(name.as_str());
565            out
566        }
567        Expr::Index { value, index } => {
568            let mut out = expr_text(value);
569            out.push('[');
570            out.push_str(&expr_text(index));
571            out.push(']');
572            out
573        }
574        Expr::Unary { op, value } => {
575            let mut out = String::from(*op);
576            out.push_str(&expr_text(value));
577            out
578        }
579        Expr::Try(value) => {
580            let mut out = String::from("try ");
581            out.push_str(&expr_text(value));
582            out
583        }
584        Expr::StructLit(fields) => {
585            if fields.is_empty() {
586                return ".{}".to_string();
587            }
588            let mut out = String::from(".{ ");
589            let parts: Vec<String> = fields
590                .iter()
591                .map(|(name, value)| {
592                    let mut part = String::from(".");
593                    part.push_str(name.as_str());
594                    part.push_str(" = ");
595                    part.push_str(&expr_text(value));
596                    part
597                })
598                .collect();
599            out.push_str(&parts.join(", "));
600            out.push_str(" }");
601            out
602        }
603        Expr::ListLit(elements) => {
604            if elements.is_empty() {
605                return ".{}".to_string();
606            }
607            let mut out = String::from(".{ ");
608            let parts: Vec<String> = elements.iter().map(expr_text).collect();
609            out.push_str(&parts.join(", "));
610            out.push_str(" }");
611            out
612        }
613    }
614}
615
616fn literal_text(literal: &Literal) -> String {
617    match literal {
618        Literal::Int(text) | Literal::Float(text) => text.clone(),
619        Literal::Bool(value) => {
620            if *value {
621                "true".to_string()
622            } else {
623                "false".to_string()
624            }
625        }
626        Literal::Char(ch) => {
627            let mut out = String::from("'");
628            out.push_str(&char_text(*ch));
629            out.push('\'');
630            out
631        }
632        Literal::Str(text) => {
633            let mut out = String::from("\"");
634            out.push_str(&string_text(text));
635            out.push('"');
636            out
637        }
638        // `&[_]u8{ … }` and not `[_]u8{ … }`: a byte string used where a
639        // `[]const u8` belongs has to be a slice, and taking the address
640        // of the array literal is what makes it one.
641        Literal::Bytes(bytes) => {
642            let mut out = String::from("&[_]u8{ ");
643            let parts: Vec<String> = bytes.iter().map(|b| alloc::format!("0x{b:02x}")).collect();
644            out.push_str(&parts.join(", "));
645            out.push_str(" }");
646            out
647        }
648        Literal::Null => "null".to_string(),
649    }
650}
651
652/// Escape a string for a Zig string literal.
653///
654/// This module's own rather than [`crate::writer::escape_string`],
655/// because that one writes `\0` for a NUL and Zig has no `\0` escape:
656/// its escapes are `\n`, `\r`, `\t`, `\\`, `\'`, `\"`, `\xNN` and
657/// `\u{…}` (Zig language reference, "String Literals and Unicode Code
658/// Point Literals"). A NUL in an IDL string constant is legal — IDL 4.2
659/// puts no restriction on a `string`'s contents — and would otherwise
660/// produce a literal no Zig compiler reads.
661///
662/// Everything outside the printable ASCII range that is not one of the
663/// named escapes is written `\xNN` if it is an octet and passed through
664/// otherwise: Zig source is UTF-8, so a character above the ASCII range
665/// is already what the file should contain.
666#[must_use]
667pub fn string_text(text: &str) -> String {
668    let mut out = String::new();
669    for ch in text.chars() {
670        push_escaped(&mut out, ch, '"');
671    }
672    out
673}
674
675/// The same, for a character literal.
676#[must_use]
677pub fn char_text(ch: char) -> String {
678    let mut out = String::new();
679    push_escaped(&mut out, ch, '\'');
680    out
681}
682
683fn push_escaped(out: &mut String, ch: char, delimiter: char) {
684    match ch {
685        '\\' => out.push_str("\\\\"),
686        '\n' => out.push_str("\\n"),
687        '\r' => out.push_str("\\r"),
688        '\t' => out.push_str("\\t"),
689        c if c == delimiter => {
690            out.push('\\');
691            out.push(c);
692        }
693        c if (c as u32) < 0x20 || c as u32 == 0x7F => {
694            out.push_str(&alloc::format!("\\x{:02x}", c as u32));
695        }
696        other => out.push(other),
697    }
698}
699
700fn doc(w: &mut Writer, doc: &Doc) {
701    if doc.is_empty() {
702        return;
703    }
704    comment_lines(w, "/// ", doc.as_lines());
705}
706
707#[cfg(test)]
708mod tests {
709    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
710    use super::*;
711    use zerodds_lang_cst::zig::Primitive;
712
713    fn struct_decl(fields: Vec<Field>) -> Decl {
714        Decl::Const(ConstDecl {
715            body_text: zerodds_lang_cst::common::BodyText::none(),
716            doc: Doc::none(),
717            is_pub: true,
718            name: Ident::new("S"),
719            type_: None,
720            value: Expr::type_(Type::Struct(fields)),
721        })
722    }
723
724    fn field(name: &str, type_: Type) -> Field {
725        Field {
726            doc: Doc::none(),
727            name: Ident::new(name),
728            type_,
729            default: None,
730        }
731    }
732
733    #[test]
734    fn a_struct_declaration_indents_by_four_and_ends_every_field_in_a_comma() {
735        let text = decl(&struct_decl(alloc::vec![
736            field("x", Type::Primitive(Primitive::I32)),
737            field("y", Type::Primitive(Primitive::F64)),
738        ]));
739        assert_eq!(
740            text,
741            "pub const S = struct {\n    x: i32,\n    y: f64,\n};\n"
742        );
743    }
744
745    #[test]
746    fn an_empty_struct_stays_on_one_line() {
747        assert_eq!(decl(&struct_decl(Vec::new())), "pub const S = struct {};\n");
748    }
749
750    #[test]
751    fn a_type_reads_left_to_right_with_no_declarator_to_split() {
752        assert_eq!(
753            type_(&Type::array(
754                2,
755                Type::array(3, Type::Primitive(Primitive::I32))
756            )),
757            "[2][3]i32"
758        );
759        assert_eq!(type_(&Type::text()), "[]const u8");
760        assert_eq!(type_(&Type::slice(Type::named("Entry"))), "[]Entry");
761        assert_eq!(
762            type_(&Type::optional(Type::Primitive(Primitive::U8))),
763            "?u8"
764        );
765        assert_eq!(
766            type_(&Type::pointer(Type::Primitive(Primitive::AnyOpaque))),
767            "*anyopaque"
768        );
769    }
770
771    #[test]
772    fn a_function_pointer_carries_its_parameter_names() {
773        let type_ = Type::const_pointer(Type::Fn {
774            params: alloc::vec![Param {
775                name: Some(Ident::new("target")),
776                type_: Type::named("Pose"),
777            }],
778            returns: alloc::boxed::Box::new(Type::Primitive(Primitive::Void)),
779        });
780        assert_eq!(super::type_(&type_), "*const fn (target: Pose) void");
781    }
782
783    #[test]
784    fn an_optional_field_writes_its_absence_as_the_default() {
785        let mut optional = field("b", Type::optional(Type::Primitive(Primitive::U8)));
786        optional.default = Some(Expr::Literal(Literal::Null));
787        let text = decl(&struct_decl(alloc::vec![optional]));
788        assert!(text.contains("b: ?u8 = null,"), "{text}");
789    }
790
791    #[test]
792    fn an_enum_writes_its_tag_type_and_every_value() {
793        let text = decl(&Decl::Const(ConstDecl {
794            body_text: zerodds_lang_cst::common::BodyText::none(),
795            doc: Doc::none(),
796            is_pub: true,
797            name: Ident::new("Mode"),
798            type_: None,
799            value: Expr::type_(Type::Enum {
800                tag: alloc::boxed::Box::new(Type::Primitive(Primitive::I32)),
801                variants: alloc::vec![
802                    Variant {
803                        doc: Doc::none(),
804                        name: Ident::new("IDLE"),
805                        value: Some(Expr::int(0)),
806                    },
807                    Variant {
808                        doc: Doc::none(),
809                        name: Ident::new("ACTIVE"),
810                        value: Some(Expr::int(4)),
811                    },
812                ],
813            }),
814        }));
815        assert_eq!(
816            text,
817            "pub const Mode = enum(i32) {\n    IDLE = 0,\n    ACTIVE = 4,\n};\n"
818        );
819    }
820
821    #[test]
822    fn a_constant_writes_its_ascription_before_its_value() {
823        let text = decl(&Decl::Const(ConstDecl {
824            body_text: zerodds_lang_cst::common::BodyText::none(),
825            doc: Doc::none(),
826            is_pub: true,
827            name: Ident::new("READY"),
828            type_: Some(Type::named("Flags")),
829            value: Expr::Binary {
830                op: "<<",
831                lhs: alloc::boxed::Box::new(Expr::int(1)),
832                rhs: alloc::boxed::Box::new(Expr::int(4)),
833            },
834        }));
835        assert_eq!(text, "pub const READY: Flags = 1 << 4;\n");
836    }
837
838    #[test]
839    fn a_nul_is_written_as_a_hex_escape_because_zig_has_no_backslash_zero() {
840        // The shared `escape_string` writes `\0`, which no Zig compiler
841        // reads. A NUL in an IDL string constant is legal.
842        assert_eq!(string_text("a\0b"), "a\\x00b");
843        assert_eq!(string_text("a\"b\\c"), "a\\\"b\\\\c");
844        assert_eq!(string_text("line\nnext\ttab"), "line\\nnext\\ttab");
845        // Zig source is UTF-8, so a character above ASCII is already
846        // what the file should contain.
847        assert_eq!(string_text("hä"), "hä");
848    }
849
850    #[test]
851    fn the_file_comment_is_the_container_form_and_the_test_block_has_none() {
852        // `///` with nothing under it is an unattached documentation
853        // comment, and Zig refuses one above a `test` block outright.
854        let text = file(&File {
855            doc: Doc::lines(&["Generated.", "", "Do not edit."]),
856            decls: alloc::vec![Decl::AnalyseAll(AnalyseAll {
857                doc: Doc::line("Why this block is here."),
858            })],
859        });
860        assert!(
861            text.starts_with("//! Generated.\n//!\n//! Do not edit.\n"),
862            "{text}"
863        );
864        assert!(text.contains("// Why this block is here.\n"), "{text}");
865        assert!(text.contains("test {\n"), "{text}");
866        assert!(
867            !text.contains("/// Why"),
868            "a documentation comment was attached to a test block\n{text}"
869        );
870    }
871
872    #[test]
873    fn a_doc_comment_sits_above_the_field_it_belongs_to() {
874        let mut documented = field("type_", Type::Primitive(Primitive::I32));
875        documented.doc = Doc::line("IDL: `type`.");
876        let text = decl(&struct_decl(alloc::vec![documented]));
877        assert_eq!(
878            text,
879            "pub const S = struct {\n    /// IDL: `type`.\n    type_: i32,\n};\n"
880        );
881    }
882}