Skip to main content

zerodds_lang_render/
julia.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Rendering the Julia CST. Spec §5.
4//!
5//! One style: four spaces per level, `#` comments, one blank line between
6//! declarations. Four spaces because that is what the manual's own style
7//! guide and every formatter for the language use, and because Julia has
8//! no `gofmt` to make the question moot.
9//!
10//! Nothing is decided here. A Julia type reads left to right and carries
11//! its own length and element type, so unlike [`crate::c`]
12//! there is no declarator to split around a name: a field is the name,
13//! `::`, and the type.
14//!
15//! ## `#` and not a docstring
16//!
17//! Julia's documentation form is a string literal above the declaration,
18//! which the parser attaches to the binding. It is an expression that
19//! runs when the file is loaded and it can only be attached to a binding
20//! Julia has a docsystem entry for — not, for instance, to one field of a
21//! struct. A `#` comment carries the same text, is attached to nothing,
22//! and cannot fail at load time. For a file whose whole content is type
23//! declarations, that is the honest form.
24//!
25//! ## `$` is escaped in a string literal
26//!
27//! Julia interpolates `$name` inside `"…"` (manual, "Strings"), so a
28//! `const string PROMPT = "$HOME";` written through the C-family escape
29//! alone would be a file that either fails to load — `UndefVarError:
30//! HOME` — or, worse, loads with whatever `HOME` happens to name. It is
31//! escaped here, in the one place a Julia string literal is written.
32
33use crate::writer::{Writer, comment_lines, escape_string, verbatim_lines};
34use alloc::string::{String, ToString};
35use alloc::vec::Vec;
36use zerodds_lang_cst::common::{Doc, Ident, Literal};
37use zerodds_lang_cst::julia::{
38    AbstractDecl, ConstDecl, Decl, EnumDecl, Enumerator, Expr, Field, File, FunctionDecl,
39    FunctionDef, Statement, StructDecl, Type,
40};
41
42/// Render a whole file.
43#[must_use]
44pub fn file(file: &File) -> String {
45    let mut w = Writer::spaces4();
46    doc(&mut w, &file.doc);
47    for decl in &file.decls {
48        w.blank_line();
49        render_decl(&mut w, decl);
50    }
51    w.finish()
52}
53
54/// Render one declaration, for a caller that has only one.
55#[must_use]
56pub fn decl(node: &Decl) -> String {
57    let mut w = Writer::spaces4();
58    render_decl(&mut w, node);
59    w.finish()
60}
61
62/// Render one type, for a caller that has only one.
63#[must_use]
64pub fn type_(node: &Type) -> String {
65    type_text(node)
66}
67
68/// The identifier a name renders as, for a caller assembling one.
69#[must_use]
70pub fn ident(name: &Ident) -> String {
71    name.as_str().to_string()
72}
73
74/// Render one statement, for a caller that has only one.
75#[must_use]
76pub fn statement(node: &Statement) -> String {
77    let mut w = Writer::spaces4();
78    render_statement(&mut w, node);
79    w.finish()
80}
81
82/// Render one expression, for a caller assembling source by hand.
83#[must_use]
84pub fn expr(node: &Expr) -> String {
85    expr_text(node)
86}
87
88/// A Julia string literal, quotes and escapes included.
89///
90/// The same escapes a [`Literal::Str`] gets — the C-family set and `$`,
91/// which Julia interpolates inside `"…"`.
92#[must_use]
93pub fn string_literal(text: &str) -> String {
94    let mut out = String::from("\"");
95    out.push_str(&julia_string(text));
96    out.push('"');
97    out
98}
99
100fn render_decl(w: &mut Writer, node: &Decl) {
101    match node {
102        Decl::Abstract(decl) => render_abstract(w, decl),
103        Decl::Struct(decl) => render_struct(w, decl),
104        Decl::Enum(decl) => render_enum(w, decl),
105        Decl::Const(decl) => render_const(w, decl),
106        Decl::Function(decl) => render_function(w, decl),
107        Decl::FunctionDef(decl) => render_function_def(w, decl),
108        Decl::Comment(lines) => doc(w, lines),
109        Decl::Verbatim(text) => verbatim_lines(w, text),
110    }
111}
112
113/// `function name(a::T, b)[::R]`, the body, `end`.
114fn render_function_def(w: &mut Writer, node: &FunctionDef) {
115    doc(w, &node.doc);
116    let mut head = String::from("function ");
117    head.push_str(node.name.as_str());
118    head.push('(');
119    let params: Vec<String> = node
120        .params
121        .iter()
122        .map(|param| match &param.type_ {
123            Some(type_) => {
124                let mut out = String::from(param.name.as_str());
125                out.push_str("::");
126                out.push_str(&type_text(type_));
127                out
128            }
129            None => param.name.as_str().to_string(),
130        })
131        .collect();
132    head.push_str(&params.join(", "));
133    head.push(')');
134    if let Some(returns) = &node.returns {
135        head.push_str("::");
136        head.push_str(&type_text(returns));
137    }
138    w.line(&head);
139    w.indent();
140    for statement in &node.body {
141        render_statement(w, statement);
142    }
143    w.dedent();
144    w.line("end");
145}
146
147/// zerodds-lint: recursion-depth = the generated body's block nesting.
148/// The codec walk emits a routine per type rather than inlining one
149/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
150/// graph. What does grow with the IDL is the dispatch chain: a union's
151/// cases, an enumeration's enumerators and a `@mutable` aggregate's
152/// members each become one `If` per arm, in the previous arm's `else`.
153/// The model bounds none of the three counts, and Julia nests the arms in
154/// its own output too, so they cannot be walked iteratively the way
155/// `c.rs` walks them. No number is claimed: this signature returns no
156/// error, so nothing here could report one.
157fn render_statement(w: &mut Writer, node: &Statement) {
158    match node {
159        Statement::Local { name, type_, value } => {
160            let mut line = String::from("local ");
161            line.push_str(name.as_str());
162            if let Some(type_) = type_ {
163                line.push_str("::");
164                line.push_str(&type_text(type_));
165            }
166            if let Some(value) = value {
167                line.push_str(" = ");
168                line.push_str(&expr_text(value));
169            }
170            w.line(&line);
171        }
172        Statement::Assign { place, value } => {
173            let mut line = expr_text(place);
174            line.push_str(" = ");
175            line.push_str(&expr_text(value));
176            w.line(&line);
177        }
178        Statement::Expr(value) => w.line(&expr_text(value)),
179        Statement::If {
180            condition,
181            then,
182            otherwise,
183        } => {
184            let mut head = String::from("if ");
185            head.push_str(&expr_text(condition));
186            w.line(&head);
187            w.indent();
188            for statement in then {
189                render_statement(w, statement);
190            }
191            w.dedent();
192            if let Some(otherwise) = otherwise {
193                w.line("else");
194                w.indent();
195                for statement in otherwise {
196                    render_statement(w, statement);
197                }
198                w.dedent();
199            }
200            w.line("end");
201        }
202        Statement::For {
203            counter,
204            iter,
205            body,
206        } => {
207            let mut head = String::from("for ");
208            head.push_str(counter.as_str());
209            head.push_str(" in ");
210            head.push_str(&expr_text(iter));
211            w.line(&head);
212            w.indent();
213            for statement in body {
214                render_statement(w, statement);
215            }
216            w.dedent();
217            w.line("end");
218        }
219        Statement::While { condition, body } => {
220            let mut head = String::from("while ");
221            head.push_str(&expr_text(condition));
222            w.line(&head);
223            w.indent();
224            for statement in body {
225                render_statement(w, statement);
226            }
227            w.dedent();
228            w.line("end");
229        }
230        Statement::Break => w.line("break"),
231        Statement::Return(value) => match value {
232            Some(value) => {
233                let mut line = String::from("return ");
234                line.push_str(&expr_text(value));
235                w.line(&line);
236            }
237            None => w.line("return"),
238        },
239        Statement::Comment(lines) => doc(w, lines),
240    }
241}
242
243fn render_abstract(w: &mut Writer, node: &AbstractDecl) {
244    doc(w, &node.doc);
245    let mut line = String::from("abstract type ");
246    line.push_str(node.name.as_str());
247    line.push_str(" end");
248    w.line(&line);
249}
250
251/// `struct Name [<: Super]`, the fields, `end`.
252///
253/// `struct` and never `mutable struct`: the tree has no arm for it, and
254/// the materialiser's documentation carries why.
255fn render_struct(w: &mut Writer, node: &StructDecl) {
256    doc(w, &node.doc);
257    let mut head = String::from("struct ");
258    head.push_str(node.name.as_str());
259    if let Some(supertype) = &node.supertype {
260        head.push_str(" <: ");
261        head.push_str(supertype.as_str());
262    }
263    w.line(&head);
264    w.indent();
265    for text in &node.body_text.begin {
266        verbatim_lines(w, text);
267    }
268    for field in &node.fields {
269        render_field(w, field);
270    }
271    for text in &node.body_text.end {
272        verbatim_lines(w, text);
273    }
274    w.dedent();
275    w.line("end");
276}
277
278fn render_field(w: &mut Writer, node: &Field) {
279    doc(w, &node.doc);
280    let mut line = String::from(node.name.as_str());
281    line.push_str("::");
282    line.push_str(&type_text(&node.type_));
283    w.line(&line);
284}
285
286/// `@enum Name::Base begin … end`.
287///
288/// The block form and not the one-line one, because a one-line `@enum`
289/// with a value per member runs past any width a reader scans, and the
290/// two forms are the same macro call.
291fn render_enum(w: &mut Writer, node: &EnumDecl) {
292    doc(w, &node.doc);
293    let mut head = String::from("@enum ");
294    head.push_str(node.name.as_str());
295    head.push_str("::");
296    head.push_str(node.base.as_str());
297    head.push_str(" begin");
298    w.line(&head);
299    w.indent();
300    for member in &node.members {
301        render_enumerator(w, member);
302    }
303    w.dedent();
304    w.line("end");
305}
306
307fn render_enumerator(w: &mut Writer, node: &Enumerator) {
308    doc(w, &node.doc);
309    let mut line = String::from(node.name.as_str());
310    line.push_str(" = ");
311    line.push_str(&expr_text(&node.value));
312    w.line(&line);
313}
314
315fn render_const(w: &mut Writer, node: &ConstDecl) {
316    doc(w, &node.doc);
317    let mut line = String::from("const ");
318    line.push_str(node.name.as_str());
319    line.push_str(" = ");
320    line.push_str(&expr_text(&node.value));
321    w.line(&line);
322}
323
324fn render_function(w: &mut Writer, node: &FunctionDecl) {
325    doc(w, &node.doc);
326    let mut line = String::from("function ");
327    line.push_str(node.name.as_str());
328    line.push_str(" end");
329    w.line(&line);
330}
331
332/// zerodds-lint: recursion-depth 64 (one level per collection, alias or
333/// generic argument of the spelled type; `MAX_TYPE_DEPTH` in
334/// `lang-rules/src/emit/julia.rs` refuses a type that nests deeper)
335fn type_text(node: &Type) -> String {
336    match node {
337        Type::Primitive(primitive) => primitive.as_str().to_string(),
338        Type::Named(name) => name.as_str().to_string(),
339        Type::Vector(element) => {
340            let mut out = String::from("Vector{");
341            out.push_str(&type_text(element));
342            out.push('}');
343            out
344        }
345        Type::NTuple { length, element } => {
346            let mut out = String::from("NTuple{");
347            out.push_str(&expr_text(length));
348            out.push_str(", ");
349            out.push_str(&type_text(element));
350            out.push('}');
351            out
352        }
353        Type::Union(arms) => {
354            let mut out = String::from("Union{");
355            let parts: Vec<String> = arms.iter().map(type_text).collect();
356            out.push_str(&parts.join(", "));
357            out.push('}');
358            out
359        }
360    }
361}
362
363/// zerodds-lint: recursion-depth = the built expression's nesting. A walk
364/// calls a routine per type rather than nesting one, so the one operand
365/// tree that grows with the IDL is an array's default value: one composite
366/// literal per dimension, built in `lang-rules/src/default_julia.rs`, and
367/// nothing bounds the dimension count. No number is claimed: this
368/// signature returns no error.
369fn expr_text(node: &Expr) -> String {
370    match node {
371        Expr::Literal(literal) => literal_text(literal),
372        Expr::Name(name) => name.as_str().to_string(),
373        Expr::Type(type_) => type_text(type_),
374        Expr::Call { func, args } => {
375            let mut out = type_text(func);
376            out.push('(');
377            let parts: Vec<String> = args.iter().map(expr_text).collect();
378            out.push_str(&parts.join(", "));
379            out.push(')');
380            out
381        }
382        Expr::Binary { op, lhs, rhs } => {
383            let mut out = expr_text(lhs);
384            out.push(' ');
385            out.push_str(op);
386            out.push(' ');
387            out.push_str(&expr_text(rhs));
388            out
389        }
390        Expr::Paren(inner) => {
391            let mut out = String::from("(");
392            out.push_str(&expr_text(inner));
393            out.push(')');
394            out
395        }
396        Expr::FreeCall { func, args } => {
397            let mut out = String::from(func.as_str());
398            out.push('(');
399            let parts: Vec<String> = args.iter().map(expr_text).collect();
400            out.push_str(&parts.join(", "));
401            out.push(')');
402            out
403        }
404        Expr::Field { value, name } => {
405            let mut out = expr_text(value);
406            out.push('.');
407            out.push_str(name.as_str());
408            out
409        }
410        Expr::Index { value, index } => {
411            let mut out = expr_text(value);
412            out.push('[');
413            out.push_str(&expr_text(index));
414            out.push(']');
415            out
416        }
417        // No spaces: `0 : n` parses but reads as three tokens, and a
418        // range inside a subscript — `v[1:n]` — is the form every Julia
419        // reader knows.
420        Expr::Range { start, stop } => {
421            let mut out = expr_text(start);
422            out.push(':');
423            out.push_str(&expr_text(stop));
424            out
425        }
426        Expr::Unary { op, value } => {
427            let mut out = String::from(*op);
428            out.push_str(&expr_text(value));
429            out
430        }
431        Expr::AnyVector(items) => {
432            let mut out = String::from("Any[");
433            let parts: Vec<String> = items.iter().map(expr_text).collect();
434            out.push_str(&parts.join(", "));
435            out.push(']');
436            out
437        }
438    }
439}
440
441fn literal_text(literal: &Literal) -> String {
442    match literal {
443        Literal::Int(text) | Literal::Float(text) => text.clone(),
444        Literal::Bool(value) => {
445            if *value {
446                "true".to_string()
447            } else {
448                "false".to_string()
449            }
450        }
451        // The code point as a number. A Julia `Char` is a 32-bit code
452        // point and an IDL `char` is one octet, so the conversion the
453        // member's type asks for is explicit at the declaration rather
454        // than implicit in a literal that happens to fit.
455        Literal::Char(ch) => alloc::format!("{}", u32::from(*ch)),
456        Literal::Str(text) => {
457            let mut out = String::from("\"");
458            out.push_str(&julia_string(text));
459            out.push('"');
460            out
461        }
462        Literal::Bytes(bytes) => {
463            let mut out = String::from("UInt8[");
464            let parts: Vec<String> = bytes.iter().map(|b| alloc::format!("0x{b:02x}")).collect();
465            out.push_str(&parts.join(", "));
466            out.push(']');
467            out
468        }
469        Literal::Null => "nothing".to_string(),
470    }
471}
472
473/// A string literal's body: the C-family escapes, and `$`.
474///
475/// `$` is Julia's interpolation marker inside `"…"`, and no other column
476/// has to escape it.
477fn julia_string(text: &str) -> String {
478    let mut out = String::new();
479    for ch in escape_string(text).chars() {
480        if ch == '$' {
481            out.push('\\');
482        }
483        out.push(ch);
484    }
485    out
486}
487
488fn doc(w: &mut Writer, doc: &Doc) {
489    if doc.is_empty() {
490        return;
491    }
492    comment_lines(w, "# ", doc.as_lines());
493}
494
495#[cfg(test)]
496mod tests {
497    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
498    use super::*;
499    use alloc::vec;
500    use zerodds_lang_cst::julia::Primitive;
501
502    fn field(name: &str, type_: Type) -> Field {
503        Field {
504            doc: Doc::none(),
505            name: Ident::new(name),
506            type_,
507        }
508    }
509
510    #[test]
511    fn a_struct_writes_its_fields_with_a_double_colon() {
512        let text = decl(&Decl::Struct(StructDecl {
513            body_text: zerodds_lang_cst::common::BodyText::none(),
514            doc: Doc::none(),
515            name: Ident::new("Pose"),
516            supertype: None,
517            fields: vec![
518                field("x", Type::Primitive(Primitive::Int32)),
519                field("y", Type::Primitive(Primitive::Float64)),
520            ],
521        }));
522        assert_eq!(text, "struct Pose\n    x::Int32\n    y::Float64\nend\n");
523    }
524
525    #[test]
526    fn an_empty_struct_is_still_a_struct() {
527        // A singleton type, which is what an IDL aggregate with no
528        // members is. No refusal is needed: Julia accepts it.
529        let text = decl(&Decl::Struct(StructDecl {
530            body_text: zerodds_lang_cst::common::BodyText::none(),
531            doc: Doc::none(),
532            name: Ident::new("Empty"),
533            supertype: None,
534            fields: Vec::new(),
535        }));
536        assert_eq!(text, "struct Empty\nend\n");
537    }
538
539    #[test]
540    fn a_struct_with_a_supertype_writes_the_subtype_operator() {
541        let text = decl(&Decl::Struct(StructDecl {
542            body_text: zerodds_lang_cst::common::BodyText::none(),
543            doc: Doc::none(),
544            name: Ident::new("B"),
545            supertype: Some(Ident::new("ZdFwd_B")),
546            fields: vec![field("x", Type::Primitive(Primitive::Int32))],
547        }));
548        assert_eq!(text, "struct B <: ZdFwd_B\n    x::Int32\nend\n");
549    }
550
551    #[test]
552    fn an_abstract_type_is_one_line() {
553        let text = decl(&Decl::Abstract(AbstractDecl {
554            doc: Doc::none(),
555            name: Ident::new("Control"),
556        }));
557        assert_eq!(text, "abstract type Control end\n");
558    }
559
560    #[test]
561    fn an_enum_writes_its_base_and_every_value() {
562        let text = decl(&Decl::Enum(EnumDecl {
563            doc: Doc::none(),
564            name: Ident::new("Mode"),
565            base: Primitive::Int32,
566            members: vec![
567                Enumerator {
568                    doc: Doc::none(),
569                    name: Ident::new("IDLE"),
570                    value: Expr::int(0),
571                },
572                Enumerator {
573                    doc: Doc::none(),
574                    name: Ident::new("ACTIVE"),
575                    value: Expr::int(4),
576                },
577            ],
578        }));
579        assert_eq!(
580            text,
581            "@enum Mode::Int32 begin\n    IDLE = 0\n    ACTIVE = 4\nend\n"
582        );
583    }
584
585    #[test]
586    fn a_const_writes_a_type_as_readily_as_a_value() {
587        let alias = decl(&Decl::Const(ConstDecl {
588            doc: Doc::none(),
589            name: Ident::new("Distance"),
590            value: Expr::Type(alloc::boxed::Box::new(Type::Primitive(Primitive::Int32))),
591        }));
592        assert_eq!(alias, "const Distance = Int32\n");
593
594        let value = decl(&Decl::Const(ConstDecl {
595            doc: Doc::none(),
596            name: Ident::new("LIMIT"),
597            value: Expr::convert(Type::Primitive(Primitive::UInt8), Expr::int(8)),
598        }));
599        assert_eq!(value, "const LIMIT = UInt8(8)\n");
600    }
601
602    #[test]
603    fn a_bitmask_constant_is_a_shift_of_a_converted_one() {
604        let text = decl(&Decl::Const(ConstDecl {
605            doc: Doc::none(),
606            name: Ident::new("MOVING"),
607            value: Expr::Binary {
608                op: "<<",
609                lhs: alloc::boxed::Box::new(Expr::convert(Type::named("Flags"), Expr::int(1))),
610                rhs: alloc::boxed::Box::new(Expr::int(4)),
611            },
612        }));
613        assert_eq!(text, "const MOVING = Flags(1) << 4\n");
614    }
615
616    #[test]
617    fn a_function_stub_binds_the_name_and_adds_no_method() {
618        let text = decl(&Decl::Function(FunctionDecl {
619            doc: Doc::line("go(target::Pose)::Nothing"),
620            name: Ident::new("go"),
621        }));
622        assert_eq!(text, "# go(target::Pose)::Nothing\nfunction go end\n");
623    }
624
625    #[test]
626    fn a_comment_declaration_writes_only_comment_lines() {
627        let text = decl(&Decl::Comment(Doc::lines(&["first", "second"])));
628        assert_eq!(text, "# first\n# second\n");
629    }
630
631    #[test]
632    fn every_collection_writes_its_element_inside_braces() {
633        assert_eq!(
634            type_(&Type::vector(Type::Primitive(Primitive::Int32))),
635            "Vector{Int32}"
636        );
637        assert_eq!(
638            type_(&Type::ntuple(
639                2,
640                Type::ntuple(3, Type::Primitive(Primitive::Int32))
641            )),
642            "NTuple{2, NTuple{3, Int32}}"
643        );
644        assert_eq!(
645            type_(&Type::optional(Type::named("Pose"))),
646            "Union{Pose, Nothing}"
647        );
648    }
649
650    #[test]
651    fn a_string_literal_escapes_the_interpolation_marker() {
652        // `"$HOME"` in Julia is an interpolation, not four characters.
653        // A generated constant carrying it would either fail to load with
654        // `UndefVarError` or load with whatever the name happens to hold.
655        let text = decl(&Decl::Const(ConstDecl {
656            doc: Doc::none(),
657            name: Ident::new("PROMPT"),
658            value: Expr::Literal(Literal::string("$HOME\"x\"")),
659        }));
660        assert_eq!(text, "const PROMPT = \"\\$HOME\\\"x\\\"\"\n");
661    }
662
663    #[test]
664    fn the_other_literal_forms_write_what_julia_reads() {
665        assert_eq!(expr_text(&Expr::Literal(Literal::Bool(true))), "true");
666        assert_eq!(expr_text(&Expr::Literal(Literal::Null)), "nothing");
667        assert_eq!(expr_text(&Expr::Literal(Literal::Char('A'))), "65");
668        assert_eq!(
669            expr_text(&Expr::Literal(Literal::Float("1.5".into()))),
670            "1.5"
671        );
672        assert_eq!(
673            expr_text(&Expr::Literal(Literal::Bytes(alloc::vec![1, 255]))),
674            "UInt8[0x01, 0xff]"
675        );
676        assert_eq!(
677            expr_text(&Expr::Paren(alloc::boxed::Box::new(Expr::Name(
678                Ident::new("x")
679            )))),
680            "(x)"
681        );
682    }
683
684    #[test]
685    fn a_file_separates_its_declarations_with_one_blank_line() {
686        let text = file(&File {
687            doc: Doc::line("Generated."),
688            decls: vec![
689                Decl::Abstract(AbstractDecl {
690                    doc: Doc::none(),
691                    name: Ident::new("A"),
692                }),
693                Decl::Abstract(AbstractDecl {
694                    doc: Doc::none(),
695                    name: Ident::new("B"),
696                }),
697            ],
698        });
699        assert_eq!(
700            text,
701            "# Generated.\n\nabstract type A end\n\nabstract type B end\n"
702        );
703    }
704
705    #[test]
706    fn a_named_identifier_renders_as_itself() {
707        assert_eq!(ident(&Ident::new("Pose")), "Pose");
708    }
709
710    #[test]
711    fn the_codec_expressions_render_what_julia_reads() {
712        assert_eq!(
713            expr(&Expr::free_call(
714                "write_u32",
715                vec![Expr::name("w"), Expr::field(Expr::name("v"), "x")]
716            )),
717            "write_u32(w, v.x)"
718        );
719        assert_eq!(
720            expr(&Expr::index(
721                Expr::name("v"),
722                Expr::Binary {
723                    op: "+",
724                    lhs: alloc::boxed::Box::new(Expr::Paren(alloc::boxed::Box::new(Expr::name(
725                        "zd_i"
726                    )))),
727                    rhs: alloc::boxed::Box::new(Expr::int(1)),
728                }
729            )),
730            "v[(zd_i) + 1]"
731        );
732        assert_eq!(
733            expr(&Expr::range(
734                Expr::int(0),
735                Expr::Paren(alloc::boxed::Box::new(Expr::name("zd_n")))
736            )),
737            "0:(zd_n)"
738        );
739        assert_eq!(
740            expr(&Expr::Unary {
741                op: "!",
742                value: alloc::boxed::Box::new(Expr::name("zd_sent")),
743            }),
744            "!zd_sent"
745        );
746        assert_eq!(
747            expr(&Expr::AnyVector(vec![
748                Expr::Literal(Literal::Null),
749                Expr::int(2)
750            ])),
751            "Any[nothing, 2]"
752        );
753    }
754
755    #[test]
756    fn a_function_definition_renders_params_returns_and_body() {
757        let text = decl(&Decl::FunctionDef(FunctionDef {
758            doc: Doc::none(),
759            name: Ident::new("zd_enc_Pose"),
760            params: vec![
761                zerodds_lang_cst::julia::Param {
762                    name: Ident::new("w"),
763                    type_: Some(Type::named("ZdWriter")),
764                },
765                zerodds_lang_cst::julia::Param {
766                    name: Ident::new("v"),
767                    type_: None,
768                },
769            ],
770            returns: None,
771            body: vec![Statement::Expr(Expr::free_call(
772                "write_u32",
773                vec![Expr::name("w"), Expr::field(Expr::name("v"), "x")],
774            ))],
775        }));
776        assert_eq!(
777            text,
778            "function zd_enc_Pose(w::ZdWriter, v)\n    write_u32(w, v.x)\nend\n"
779        );
780    }
781
782    #[test]
783    fn the_statement_forms_render_their_julia_shapes() {
784        let local = statement(&Statement::Local {
785            name: Ident::new("zd_n"),
786            type_: Some(Type::Primitive(Primitive::UInt32)),
787            value: Some(Expr::free_call("read_u32", vec![Expr::name("r")])),
788        });
789        assert_eq!(local, "local zd_n::UInt32 = read_u32(r)\n");
790
791        let bare = statement(&Statement::Local {
792            name: Ident::new("zd_h"),
793            type_: None,
794            value: None,
795        });
796        assert_eq!(bare, "local zd_h\n");
797
798        let chain = statement(&Statement::If {
799            condition: Expr::name("a"),
800            then: vec![Statement::Break],
801            otherwise: Some(vec![Statement::Return(Some(Expr::name("v")))]),
802        });
803        assert_eq!(chain, "if a\n    break\nelse\n    return v\nend\n");
804
805        let counted = statement(&Statement::For {
806            counter: Ident::new("zd_i"),
807            iter: Expr::range(Expr::int(0), Expr::int(2)),
808            body: vec![Statement::Assign {
809                place: Expr::name("x"),
810                value: Expr::name("zd_i"),
811            }],
812        });
813        assert_eq!(counted, "for zd_i in 0:2\n    x = zd_i\nend\n");
814
815        let looped = statement(&Statement::While {
816            condition: Expr::name("ok"),
817            body: vec![Statement::Expr(Expr::name("x"))],
818        });
819        assert_eq!(looped, "while ok\n    x\nend\n");
820    }
821
822    #[test]
823    fn a_string_literal_helper_matches_the_literal_form() {
824        assert_eq!(string_literal("$HOME\"x\""), "\"\\$HOME\\\"x\\\"\"");
825    }
826
827    #[test]
828    fn an_empty_if_body_still_renders_a_closed_block() {
829        // Julia accepts an empty `if` body, so no `pass` counterpart is
830        // needed and none is invented.
831        let text = statement(&Statement::If {
832            condition: Expr::name("a"),
833            then: Vec::new(),
834            otherwise: None,
835        });
836        assert_eq!(text, "if a\nend\n");
837    }
838}