Skip to main content

zerodds_lang_render/
elixir.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Rendering the Elixir CST. Spec §5.
4//!
5//! One style, `mix format`'s: two spaces per level, `do`/`end` blocks,
6//! `#` comments, `@moduledoc` as a heredoc. Two spaces and not four
7//! because `mix format` is not configurable in that respect, and a
8//! generated file that changes under the formatter every consumer runs
9//! is a diff nobody asked for.
10//!
11//! Nothing is decided here. Three spellings are the language's rather
12//! than a choice, and each is written down where it happens:
13//!
14//! - a float literal must have a digit, a point and a digit before any
15//!   exponent — `1e10` does not parse (measured, Elixir 1.20.2), while
16//!   C, C++, Java and Rust all take it, so the shared
17//!   `zerodds_lang_rules::adapter::constant::float_text` cannot be
18//!   handed straight through;
19//! - `#{` inside a double-quoted string is interpolation, so `#` is
20//!   escaped;
21//! - the keyword pairs of a list come last, which is why
22//!   [`zerodds_lang_cst::elixir::Field`] always carries a default.
23
24use crate::writer::{Writer, comment_lines, verbatim_lines};
25use alloc::format;
26use alloc::string::{String, ToString};
27use alloc::vec::Vec;
28use zerodds_lang_cst::common::{Doc, Ident, Literal, Path};
29use zerodds_lang_cst::elixir::{
30    Access, Attribute, CodecFunction, Expr, Field, File, Function, Item, Module, Param, Signature,
31    Statement, TopLevel, Type, TypeAlias,
32};
33
34/// Render a whole file.
35#[must_use]
36pub fn file(file: &File) -> String {
37    let mut w = Writer::new("  ");
38    if !file.doc.is_empty() {
39        comment_lines(&mut w, "# ", file.doc.as_lines());
40    }
41    for entry in &file.modules {
42        w.blank_line();
43        render_top_level(&mut w, entry);
44    }
45    w.finish()
46}
47
48/// Render one top-level entry, for a caller that has only one.
49#[must_use]
50pub fn top_level(node: &TopLevel) -> String {
51    let mut w = Writer::new("  ");
52    render_top_level(&mut w, node);
53    w.finish()
54}
55
56fn render_top_level(w: &mut Writer, node: &TopLevel) {
57    match node {
58        TopLevel::Module(module) => render_module(w, module),
59        TopLevel::Verbatim(text) => verbatim_lines(w, text),
60    }
61}
62
63/// Render one module, for a caller that has only one.
64#[must_use]
65pub fn module(node: &Module) -> String {
66    let mut w = Writer::new("  ");
67    render_module(&mut w, node);
68    w.finish()
69}
70
71/// Render one item, for a caller that has only one.
72#[must_use]
73pub fn item(node: &Item) -> String {
74    let mut w = Writer::new("  ");
75    render_item(&mut w, node);
76    w.finish()
77}
78
79/// Render one type, for a caller that has only one.
80#[must_use]
81pub fn type_(node: &Type) -> String {
82    type_text(node)
83}
84
85/// Render one expression, for a caller that has only one.
86#[must_use]
87pub fn expr(node: &Expr) -> String {
88    expr_text(node)
89}
90
91/// The identifier a name renders as, for a caller assembling one.
92#[must_use]
93pub fn ident(name: &Ident) -> String {
94    name.as_str().to_string()
95}
96
97fn render_module(w: &mut Writer, node: &Module) {
98    let mut head = String::from("defmodule ");
99    head.push_str(&path_text(&node.name));
100    head.push_str(" do");
101    w.line(&head);
102    w.indent();
103    render_moduledoc(w, &node.doc);
104    let mut previous: Option<&Item> = None;
105    for (index, item) in node.body.iter().enumerate() {
106        let separated = match previous {
107            None => !node.doc.is_empty(),
108            Some(previous) => !belong_together(previous, item),
109        };
110        if separated && (index > 0 || !node.doc.is_empty()) {
111            w.blank_line();
112        }
113        render_item(w, item);
114        previous = Some(item);
115    }
116    w.dedent();
117    w.line("end");
118}
119
120/// `@moduledoc """ … """`.
121///
122/// A heredoc rather than a single-quoted line, because the text is
123/// several lines and a `\n` inside a one-line literal is a documentation
124/// string nobody can read in the source. The closing delimiter's
125/// indentation is what Elixir strips from every line, so both are
126/// written at the writer's current depth.
127fn render_moduledoc(w: &mut Writer, doc: &Doc) {
128    if doc.is_empty() {
129        return;
130    }
131    w.line("@moduledoc \"\"\"");
132    for line in doc.as_lines() {
133        w.line(&escape_heredoc(line));
134    }
135    w.line("\"\"\"");
136}
137
138/// Whether two adjacent items are written with no blank line between
139/// them.
140///
141/// The clauses of one function are one definition and Elixir requires
142/// them to be adjacent — it warns "clauses with the same name should be
143/// grouped together" otherwise — so a blank line between them would
144/// suggest they could be moved apart. A `@spec` belongs to the clauses
145/// that follow it for the same reason, and the `@callback`s of a
146/// behaviour are one declaration read together. Two definitions that
147/// take no parameters are a table of constants — one enumerator or one
148/// bit each — and a blank line between every row of a table is noise.
149fn belong_together(previous: &Item, next: &Item) -> bool {
150    match (previous, next) {
151        (Item::Function(before), Item::Function(after)) => {
152            before.name == after.name || (before.params.is_empty() && after.params.is_empty())
153        }
154        (Item::Spec(before), Item::Function(after)) => before.name == after.name,
155        (Item::Callback(_), Item::Callback(_)) => true,
156        _ => false,
157    }
158}
159
160fn render_item(w: &mut Writer, node: &Item) {
161    match node {
162        Item::Attribute(attribute) => render_attribute(w, attribute),
163        Item::Defstruct(fields) => render_defstruct(w, fields),
164        Item::TypeAlias(alias) => render_type_alias(w, alias),
165        Item::Spec(signature) => render_signature(w, "@spec ", signature),
166        Item::Callback(signature) => render_signature(w, "@callback ", signature),
167        Item::Function(function) => render_function(w, function),
168        Item::Verbatim(text) => verbatim_lines(w, text),
169    }
170}
171
172fn render_attribute(w: &mut Writer, node: &Attribute) {
173    doc_comment(w, &node.doc);
174    let mut head = String::from("@");
175    head.push_str(node.name.as_str());
176    head.push(' ');
177    render_value(w, &head, &node.value);
178}
179
180/// `defstruct [ … ]`.
181///
182/// The bracketed form always, including for the empty case:
183/// `defstruct []` is a struct with no fields, which is legal and is what
184/// an IDL aggregate with no members becomes.
185fn render_defstruct(w: &mut Writer, fields: &[Field]) {
186    if fields.is_empty() {
187        w.line("defstruct []");
188        return;
189    }
190    let pairs: Vec<(Ident, Expr)> = fields
191        .iter()
192        .map(|field| (field.name.clone(), field.default.clone()))
193        .collect();
194    render_value(w, "defstruct ", &Expr::Keyword(pairs));
195}
196
197fn render_type_alias(w: &mut Writer, node: &TypeAlias) {
198    doc_comment(w, &node.doc);
199    let mut head = String::from("@type ");
200    head.push_str(node.name.as_str());
201    head.push_str(" :: ");
202    render_type_value(w, &head, &node.type_);
203}
204
205fn render_signature(w: &mut Writer, marker: &str, node: &Signature) {
206    doc_comment(w, &node.doc);
207    let mut line = String::from(marker);
208    line.push_str(node.name.as_str());
209    line.push('(');
210    let params: Vec<String> = node.params.iter().map(param_text).collect();
211    line.push_str(&params.join(", "));
212    line.push_str(") :: ");
213    line.push_str(&type_text(&node.result));
214    w.line(&line);
215}
216
217fn param_text(param: &Param) -> String {
218    let mut out = String::new();
219    if let Some(name) = &param.name {
220        out.push_str(name.as_str());
221        out.push_str(" :: ");
222    }
223    out.push_str(&type_text(&param.type_));
224    out
225}
226
227/// `def name(patterns), do: body`.
228///
229/// The one-line body form, because every function this column writes has
230/// a body of one expression: a `do`/`end` block for `do: @limit` would
231/// be three lines saying the same thing.
232fn render_function(w: &mut Writer, node: &Function) {
233    doc_comment(w, &node.doc);
234    let mut line = String::from("def ");
235    line.push_str(node.name.as_str());
236    if !node.params.is_empty() {
237        line.push('(');
238        let params: Vec<String> = node.params.iter().map(expr_text).collect();
239        line.push_str(&params.join(", "));
240        line.push(')');
241    }
242    line.push_str(", do: ");
243    line.push_str(&expr_text(&node.body));
244    w.line(&line);
245}
246
247/// The column a line is broken at.
248///
249/// `mix format`'s own default. A member list of twenty entries on one
250/// line is legal and unreadable, and the attribute is the part of a
251/// generated file a reader goes to: it is where every width, bound and
252/// IDL spelling is written down.
253const LINE_LENGTH: usize = 98;
254
255/// Whether `head` and `text` fit on one line at the writer's depth.
256fn fits(w: &Writer, head: &str, text: &str) -> bool {
257    w.depth()
258        .saturating_mul(2)
259        .saturating_add(head.len())
260        .saturating_add(text.len())
261        <= LINE_LENGTH
262}
263
264/// A value written after `head`, broken over lines when it does not fit.
265fn render_value(w: &mut Writer, head: &str, value: &Expr) {
266    if fits(w, head, &expr_text(value)) {
267        let mut line = String::from(head);
268        line.push_str(&expr_text(value));
269        w.line(&line);
270        return;
271    }
272    match value {
273        Expr::List(items) if items.len() > 1 => {
274            let mut line = String::from(head);
275            line.push('[');
276            w.line(&line);
277            w.indent();
278            for (index, item) in items.iter().enumerate() {
279                let mut text = expr_text(item);
280                if index + 1 < items.len() {
281                    text.push(',');
282                }
283                w.line(&text);
284            }
285            w.dedent();
286            w.line("]");
287        }
288        Expr::Keyword(pairs) if pairs.len() > 1 => {
289            let mut line = String::from(head);
290            line.push('[');
291            w.line(&line);
292            w.indent();
293            for (index, (key, item)) in pairs.iter().enumerate() {
294                let mut text = keyword_text(key, item);
295                if index + 1 < pairs.len() {
296                    text.push(',');
297                }
298                w.line(&text);
299            }
300            w.dedent();
301            w.line("]");
302        }
303        other => {
304            let mut line = String::from(head);
305            line.push_str(&expr_text(other));
306            w.line(&line);
307        }
308    }
309}
310
311/// A type written after `head`, broken over lines when it does not fit.
312fn render_type_value(w: &mut Writer, head: &str, type_: &Type) {
313    if fits(w, head, &type_text(type_)) {
314        let mut line = String::from(head);
315        line.push_str(&type_text(type_));
316        w.line(&line);
317        return;
318    }
319    match type_ {
320        Type::Struct(fields) if fields.len() > 1 => {
321            let mut line = String::from(head);
322            line.push_str("%__MODULE__{");
323            w.line(&line);
324            w.indent();
325            for (index, (key, field)) in fields.iter().enumerate() {
326                let mut text = String::from(key.as_str());
327                text.push_str(": ");
328                text.push_str(&type_text(field));
329                if index + 1 < fields.len() {
330                    text.push(',');
331                }
332                w.line(&text);
333            }
334            w.dedent();
335            w.line("}");
336        }
337        other => {
338            let mut line = String::from(head);
339            line.push_str(&type_text(other));
340            w.line(&line);
341        }
342    }
343}
344
345/// zerodds-lint: recursion-depth 64 (one level per collection, alias or
346/// generic argument of the spelled type; `MAX_TYPE_DEPTH` in
347/// `lang-rules/src/adapter/elixir.rs` refuses a type that nests deeper)
348fn type_text(node: &Type) -> String {
349    match node {
350        Type::Basic(basic) => basic.as_str().to_string(),
351        Type::Atom(name) => atom_text(name),
352        Type::Local(name) => {
353            let mut out = String::from(name.as_str());
354            out.push_str("()");
355            out
356        }
357        Type::Remote { module, name } => {
358            let mut out = path_text(module);
359            out.push('.');
360            out.push_str(name.as_str());
361            out.push_str("()");
362            out
363        }
364        Type::List(element) => {
365            let mut out = String::from("list(");
366            out.push_str(&type_text(element));
367            out.push(')');
368            out
369        }
370        Type::Tuple(items) => {
371            let parts: Vec<String> = items.iter().map(type_text).collect();
372            let mut out = String::from("{");
373            out.push_str(&parts.join(", "));
374            out.push('}');
375            out
376        }
377        Type::Struct(fields) => {
378            let parts: Vec<String> = fields
379                .iter()
380                .map(|(key, field)| {
381                    let mut out = String::from(key.as_str());
382                    out.push_str(": ");
383                    out.push_str(&type_text(field));
384                    out
385                })
386                .collect();
387            let mut out = String::from("%__MODULE__{");
388            out.push_str(&parts.join(", "));
389            out.push('}');
390            out
391        }
392        Type::Union(arms) => {
393            let parts: Vec<String> = arms.iter().map(type_text).collect();
394            parts.join(" | ")
395        }
396    }
397}
398
399/// zerodds-lint: recursion-depth = the built expression's nesting. A walk
400/// calls a routine per type rather than nesting one, so the one operand
401/// tree that grows with the IDL is an array's default value: one composite
402/// literal per dimension, built in `lang-rules/src/default_elixir.rs`, and
403/// nothing bounds the dimension count. No number is claimed: this
404/// signature returns no error.
405fn expr_text(node: &Expr) -> String {
406    match node {
407        Expr::Literal(literal) => literal_text(literal),
408        Expr::Atom(name) => atom_text(name),
409        Expr::Var(name) => name.as_str().to_string(),
410        Expr::Attr(name) => {
411            let mut out = String::from("@");
412            out.push_str(name.as_str());
413            out
414        }
415        Expr::Module(path) => path_text(path),
416        Expr::List(items) => {
417            let parts: Vec<String> = items.iter().map(expr_text).collect();
418            let mut out = String::from("[");
419            out.push_str(&parts.join(", "));
420            out.push(']');
421            out
422        }
423        Expr::Tuple(items) => {
424            let parts: Vec<String> = items.iter().map(expr_text).collect();
425            let mut out = String::from("{");
426            out.push_str(&parts.join(", "));
427            out.push('}');
428            out
429        }
430        Expr::Keyword(pairs) => {
431            let parts: Vec<String> = pairs
432                .iter()
433                .map(|(key, value)| keyword_text(key, value))
434                .collect();
435            let mut out = String::from("[");
436            out.push_str(&parts.join(", "));
437            out.push(']');
438            out
439        }
440        Expr::Call { func, args } => {
441            let parts: Vec<String> = args.iter().map(expr_text).collect();
442            let mut out = path_text(func);
443            out.push('(');
444            out.push_str(&parts.join(", "));
445            out.push(')');
446            out
447        }
448        Expr::Binary { op, lhs, rhs } => {
449            format!("{} {op} {}", expr_text(lhs), expr_text(rhs))
450        }
451        Expr::Unary { op, value } => {
452            let mut out = String::from(*op);
453            out.push_str(&expr_text(value));
454            out
455        }
456        Expr::Paren(value) => format!("({})", expr_text(value)),
457        Expr::Field { value, name } => format!("{}.{}", expr_text(value), name.as_str()),
458        // `Enum.at` and not the `Access` bracket: a generated list is
459        // dense — the codec sized it — so a `nil` from a short list is a
460        // generator defect that shows up as a refused write, which is
461        // louder than an exception here would be quiet.
462        Expr::Index { value, index } => {
463            format!("Enum.at({}, {})", expr_text(value), expr_text(index))
464        }
465        Expr::IfElse {
466            condition,
467            then,
468            otherwise,
469        } => format!(
470            "(if {}, do: {}, else: {})",
471            expr_text(condition),
472            expr_text(then),
473            expr_text(otherwise)
474        ),
475        Expr::StructLiteral { module, fields } => {
476            let parts: Vec<String> = fields
477                .iter()
478                .map(|(key, value)| keyword_text(key, value))
479                .collect();
480            format!("%{}{{{}}}", path_text(module), parts.join(", "))
481        }
482    }
483}
484
485/// zerodds-lint: recursion-depth = the built expression's nesting. A walk
486/// calls a routine per type rather than nesting one, so the one operand
487/// tree that grows with the IDL is an array's default value: one composite
488/// literal per dimension, built in `lang-rules/src/default_elixir.rs`, and
489/// nothing bounds the dimension count. No number is claimed: this
490/// signature returns no error.
491fn keyword_text(key: &Ident, value: &Expr) -> String {
492    let mut out = String::new();
493    out.push_str(key.as_str());
494    out.push_str(": ");
495    out.push_str(&expr_text(value));
496    out
497}
498
499fn literal_text(literal: &Literal) -> String {
500    match literal {
501        Literal::Int(text) => text.clone(),
502        Literal::Float(text) => float_text(text),
503        Literal::Bool(value) => {
504            if *value {
505                "true".to_string()
506            } else {
507                "false".to_string()
508            }
509        }
510        // The code point as a number. An IDL `char` is one octet and an
511        // IDL `wchar` two, and Elixir's `?a` is a code point — the
512        // conversion the member's width asks for is then explicit in the
513        // attribute rather than implicit in a literal that happens to
514        // fit.
515        Literal::Char(ch) => alloc::format!("{}", u32::from(*ch)),
516        Literal::Str(text) => {
517            let mut out = String::from("\"");
518            out.push_str(&escape_elixir(text));
519            out.push('"');
520            out
521        }
522        // `<<1, 2>>` — a binary, which is what a run of octets is in
523        // Elixir. A charlist `[1, 2]` would be a list of integers and
524        // would compare unequal to the binary the wire carries.
525        Literal::Bytes(bytes) => {
526            let parts: Vec<String> = bytes.iter().map(|b| alloc::format!("{b}")).collect();
527            let mut out = String::from("<<");
528            out.push_str(&parts.join(", "));
529            out.push_str(">>");
530            out
531        }
532        Literal::Null => "nil".to_string(),
533    }
534}
535
536/// A float literal Elixir reads as the number the source wrote.
537///
538/// Elixir's float syntax requires a digit, a point and a digit before
539/// any exponent: `1e10` is rejected with "invalid character e after
540/// number 1" (measured, Elixir 1.20.2), where C, C++, Java and Rust all
541/// accept it. The value is never changed here — a point and a zero are
542/// added where the spelling needs them, and nothing else.
543fn float_text(text: &str) -> String {
544    let (mantissa, exponent) = match text.find(['e', 'E']) {
545        Some(at) => (&text[..at], Some(&text[at + 1..])),
546        None => (text, None),
547    };
548    let mut out = String::new();
549    if mantissa.starts_with('.') {
550        out.push('0');
551    } else if let Some(rest) = mantissa.strip_prefix('-').filter(|r| r.starts_with('.')) {
552        out.push_str("-0");
553        out.push_str(rest);
554        return with_exponent(out, exponent);
555    }
556    out.push_str(mantissa);
557    if out.ends_with('.') {
558        out.push('0');
559    }
560    if !out.contains('.') {
561        out.push_str(".0");
562    }
563    with_exponent(out, exponent)
564}
565
566fn with_exponent(mut text: String, exponent: Option<&str>) -> String {
567    if let Some(exponent) = exponent {
568        text.push('e');
569        text.push_str(exponent);
570    }
571    text
572}
573
574/// An atom, quoted only where it has to be.
575///
576/// Every atom this column writes comes from
577/// `zerodds_lang_cst::elixir::atom_name` and needs no quotes, but the
578/// renderer takes what it is given: an atom whose text is not a plain
579/// identifier is written `:"…"` rather than produced as text that does
580/// not parse.
581fn atom_text(name: &str) -> String {
582    let plain = !name.is_empty()
583        && name
584            .chars()
585            .next()
586            .is_some_and(|first| first.is_ascii_lowercase() || first == '_')
587        && name
588            .chars()
589            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '?' || ch == '!');
590    let mut out = String::from(":");
591    if plain {
592        out.push_str(name);
593    } else {
594        out.push('"');
595        out.push_str(&escape_elixir(name));
596        out.push('"');
597    }
598    out
599}
600
601fn path_text(path: &Path) -> String {
602    path.segments()
603        .iter()
604        .map(|segment| segment.as_str())
605        .collect::<Vec<&str>>()
606        .join(".")
607}
608
609/// Escape a string for a double-quoted Elixir literal.
610///
611/// The C-family escapes, and one more: `#` becomes `\#`. `"a#{b}"` is
612/// interpolation and `"a#{}"` compiles to `"a"` (measured, Elixir
613/// 1.20.2), so a `const string` whose value contains `#{` would
614/// otherwise put a different string in the generated file than the IDL
615/// wrote — or fail to compile, if the text between the braces is not an
616/// expression.
617fn escape_elixir(text: &str) -> String {
618    let mut out = String::new();
619    for ch in text.chars() {
620        match ch {
621            '"' => out.push_str("\\\""),
622            '\\' => out.push_str("\\\\"),
623            '\n' => out.push_str("\\n"),
624            '\r' => out.push_str("\\r"),
625            '\t' => out.push_str("\\t"),
626            '\0' => out.push_str("\\0"),
627            '#' => out.push_str("\\#"),
628            other => out.push(other),
629        }
630    }
631    out
632}
633
634/// Escape one line of a heredoc.
635///
636/// A heredoc interpolates exactly as a double-quoted string does, so `#`
637/// is escaped for the same reason. The quotes are not: a `"` inside a
638/// heredoc is an ordinary character, and only a line that is `"""` on
639/// its own would close it.
640fn escape_heredoc(line: &str) -> String {
641    line.replace('#', "\\#")
642}
643
644fn doc_comment(w: &mut Writer, doc: &Doc) {
645    if doc.is_empty() {
646        return;
647    }
648    comment_lines(w, "# ", doc.as_lines());
649}
650
651/// A double-quoted Elixir string literal, escaped.
652///
653/// Public for the probe generator, which writes harness text rather
654/// than a tree and still has to spell strings the way this renderer
655/// does — `#` included, because `#{` interpolates.
656#[must_use]
657pub fn string_literal(text: &str) -> String {
658    let mut out = String::from("\"");
659    out.push_str(&escape_elixir(text));
660    out.push('"');
661    out
662}
663
664// -- the codec lowering ---------------------------------------------------
665//
666// The codec statement layer of `zerodds_lang_cst::elixir` is imperative
667// and Elixir is not: a rebinding inside an `if` branch or an
668// `Enum.reduce` body does not survive the block. The lowering here is
669// what makes the two meet, and it rests on one computable fact — every
670// binding a statement makes is named on the statement — so the renderer
671// can thread exactly the changed bindings out of each block as a tuple.
672//
673// Three rules, each measured against Elixir 1.20.2:
674//
675// 1. **A block threads what it rebinds and was bound before it.** A
676//    variable first bound inside a branch is branch-local; one bound
677//    before and rebound inside comes out through the accumulator tuple,
678//    which both branches of an `if` and the tail of every loop body
679//    answer with. A variable read at the end of its block this way is
680//    never "unused", so the generated file compiles without warnings.
681// 2. **`break` and early `return` are `throw`.** A `catch` clause that
682//    does not match re-raises, so the innermost loop's
683//    `{:zd_break, acc}` handler and the routine's `{:zd_return, value}`
684//    handler compose without inspecting each other.
685// 3. **A counted loop ranges `0..(n - 1)//1`.** Without the step,
686//    `0..-1` is a *descending* two-element range and a zero-bound loop
687//    would run twice.
688
689/// Rendering state one routine's lowering carries.
690struct Lowering {
691    /// How many `While` loops have been named, for `zd_loop<n>`.
692    loops_named: usize,
693    /// The accumulator tuple of each enclosing loop, innermost last —
694    /// what a `Break` throws.
695    loop_accs: Vec<Vec<String>>,
696    /// Every name an enclosing context reads after the statements at
697    /// hand — the routine's result variables, and each enclosing
698    /// block's accumulator names, which its trailing tuple reads. What
699    /// decides whether a block's outer binding of a threaded name is a
700    /// binding or an `_`-prefixed one; a name nothing reads again and
701    /// bound anyway is a warning, and warnings are failures in this
702    /// column's compile gates.
703    outer_reads: Vec<String>,
704}
705
706/// Render one codec routine.
707#[must_use]
708pub fn codec_function(node: &CodecFunction) -> String {
709    let mut w = Writer::new("  ");
710    doc_comment(&mut w, &node.doc);
711    let mut head = String::from("def ");
712    head.push_str(node.name.as_str());
713    if !node.params.is_empty() {
714        head.push('(');
715        let params: Vec<&str> = node.params.iter().map(Ident::as_str).collect();
716        head.push_str(&params.join(", "));
717        head.push(')');
718    }
719    head.push_str(" do");
720    w.line(&head);
721    w.indent();
722    let needs_try = contains_return(&node.body);
723    if needs_try {
724        w.line("try do");
725        w.indent();
726    }
727    let mut bound: Vec<String> = node
728        .params
729        .iter()
730        .map(|param| param.as_str().to_string())
731        .collect();
732    let mut result_reads = Vec::new();
733    expr_reads(&node.result, &mut result_reads);
734    let mut lowering = Lowering {
735        loops_named: 0,
736        loop_accs: Vec::new(),
737        outer_reads: result_reads,
738    };
739    let body = flatten(&node.body);
740    render_statements(&mut w, &body, &mut bound, &mut lowering);
741    w.line(&expr_text(&node.result));
742    if needs_try {
743        w.dedent();
744        w.line("catch");
745        w.indent();
746        w.line(":throw, {:zd_return, zd_ret} -> zd_ret");
747        w.dedent();
748        w.line("end");
749    }
750    w.dedent();
751    w.line("end");
752    w.finish()
753}
754
755/// Every name the statements bind, in first-assignment order.
756///
757/// zerodds-lint: recursion-depth = the generated body's block nesting.
758/// The codec walk emits a routine per type rather than inlining one
759/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
760/// graph. What does grow with the IDL is the dispatch chain: a union's
761/// cases, an enumeration's enumerators and a `@mutable` aggregate's
762/// members each become one `If` per arm, in the previous arm's `else`.
763/// The model bounds none of the three counts, and Elixir nests the arms in
764/// its own output too, so they cannot be walked iteratively the way
765/// `c.rs` walks them. No number is claimed: this signature returns no
766/// error, so nothing here could report one.
767fn assigned(statements: &[Statement], out: &mut Vec<String>) {
768    let push = |name: &Ident, out: &mut Vec<String>| {
769        let text = name.as_str();
770        if text != "_" && !out.iter().any(|seen| seen == text) {
771            out.push(text.to_string());
772        }
773    };
774    for statement in statements {
775        match statement {
776            Statement::Bind { name, .. } => push(name, out),
777            Statement::BindTuple { names, .. } => {
778                for name in names {
779                    push(name, out);
780                }
781            }
782            Statement::Update { base, .. } => push(base, out),
783            Statement::If {
784                then, otherwise, ..
785            } => {
786                assigned(then, out);
787                assigned(otherwise, out);
788            }
789            // The counter is scoped to the body and never threads.
790            Statement::For { body, .. } | Statement::While { body, .. } => assigned(body, out),
791            Statement::Seq(inner) => assigned(inner, out),
792            Statement::Break | Statement::Return(_) | Statement::Comment(_) => {}
793        }
794    }
795}
796
797/// The names a block rebinds *of those already bound*, in the order they
798/// were first bound — the accumulator tuple, and a deterministic one.
799fn threaded(statements: &[Statement], bound: &[String]) -> Vec<String> {
800    let mut inside = Vec::new();
801    assigned(statements, &mut inside);
802    bound
803        .iter()
804        .filter(|name| inside.iter().any(|assigned| assigned == *name))
805        .cloned()
806        .collect()
807}
808
809/// Whether a `Break` sits in these statements outside any nested loop.
810///
811/// zerodds-lint: recursion-depth = the generated body's block nesting.
812/// The codec walk emits a routine per type rather than inlining one
813/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
814/// graph. What does grow with the IDL is the dispatch chain: a union's
815/// cases, an enumeration's enumerators and a `@mutable` aggregate's
816/// members each become one `If` per arm, in the previous arm's `else`.
817/// The model bounds none of the three counts, and Elixir nests the arms in
818/// its own output too, so they cannot be walked iteratively the way
819/// `c.rs` walks them. No number is claimed: this signature returns no
820/// error, so nothing here could report one.
821fn breaks_at_this_level(statements: &[Statement]) -> bool {
822    statements.iter().any(|statement| match statement {
823        Statement::Break => true,
824        Statement::If {
825            then, otherwise, ..
826        } => breaks_at_this_level(then) || breaks_at_this_level(otherwise),
827        Statement::Seq(inner) => breaks_at_this_level(inner),
828        // A nested loop catches its own breaks.
829        Statement::For { .. } | Statement::While { .. } => false,
830        _ => false,
831    })
832}
833
834/// Whether any `Return` appears anywhere, loops included.
835///
836/// zerodds-lint: recursion-depth = the generated body's block nesting.
837/// The codec walk emits a routine per type rather than inlining one
838/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
839/// graph. What does grow with the IDL is the dispatch chain: a union's
840/// cases, an enumeration's enumerators and a `@mutable` aggregate's
841/// members each become one `If` per arm, in the previous arm's `else`.
842/// The model bounds none of the three counts, and Elixir nests the arms in
843/// its own output too, so they cannot be walked iteratively the way
844/// `c.rs` walks them. No number is claimed: this signature returns no
845/// error, so nothing here could report one.
846fn contains_return(statements: &[Statement]) -> bool {
847    statements.iter().any(|statement| match statement {
848        Statement::Return(_) => true,
849        Statement::If {
850            then, otherwise, ..
851        } => contains_return(then) || contains_return(otherwise),
852        Statement::For { body, .. } | Statement::While { body, .. } => contains_return(body),
853        Statement::Seq(inner) => contains_return(inner),
854        _ => false,
855    })
856}
857
858/// The statements with every [`Statement::Seq`] spliced into its
859/// parts, recursively — what makes "the statements after this one" a
860/// plain slice for the used-later analysis.
861///
862/// zerodds-lint: recursion-depth = the generated body's block nesting.
863/// The codec walk emits a routine per type rather than inlining one
864/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
865/// graph. What does grow with the IDL is the dispatch chain: a union's
866/// cases, an enumeration's enumerators and a `@mutable` aggregate's
867/// members each become one `If` per arm, in the previous arm's `else`.
868/// The model bounds none of the three counts, and Elixir nests the arms in
869/// its own output too, so they cannot be walked iteratively the way
870/// `c.rs` walks them. No number is claimed: this signature returns no
871/// error, so nothing here could report one.
872fn flatten(statements: &[Statement]) -> Vec<Statement> {
873    let mut out = Vec::new();
874    for statement in statements {
875        match statement {
876            Statement::Seq(inner) => out.extend(flatten(inner)),
877            Statement::If {
878                condition,
879                then,
880                otherwise,
881            } => out.push(Statement::If {
882                condition: condition.clone(),
883                then: flatten(then),
884                otherwise: flatten(otherwise),
885            }),
886            Statement::For {
887                counter,
888                bound,
889                body,
890            } => out.push(Statement::For {
891                counter: counter.clone(),
892                bound: bound.clone(),
893                body: flatten(body),
894            }),
895            Statement::While { condition, body } => out.push(Statement::While {
896                condition: condition.clone(),
897                body: flatten(body),
898            }),
899            other => out.push(other.clone()),
900        }
901    }
902    out
903}
904
905/// Every variable `expr` reads, appended to `out`.
906///
907/// zerodds-lint: recursion-depth = the built expression's nesting. A walk
908/// calls a routine per type rather than nesting one, so the one operand
909/// tree that grows with the IDL is an array's default value: one composite
910/// literal per dimension, built in `lang-rules/src/default_elixir.rs`, and
911/// nothing bounds the dimension count. No number is claimed: this
912/// signature returns no error.
913fn expr_reads(expr: &Expr, out: &mut Vec<String>) {
914    match expr {
915        Expr::Var(name) => {
916            if !out.iter().any(|seen| seen == name.as_str()) {
917                out.push(name.as_str().to_string());
918            }
919        }
920        Expr::Call { args, .. } => {
921            for argument in args {
922                expr_reads(argument, out);
923            }
924        }
925        Expr::Binary { lhs, rhs, .. } => {
926            expr_reads(lhs, out);
927            expr_reads(rhs, out);
928        }
929        Expr::Unary { value, .. } | Expr::Paren(value) => expr_reads(value, out),
930        Expr::Field { value, .. } => expr_reads(value, out),
931        Expr::Index { value, index } => {
932            expr_reads(value, out);
933            expr_reads(index, out);
934        }
935        Expr::IfElse {
936            condition,
937            then,
938            otherwise,
939        } => {
940            expr_reads(condition, out);
941            expr_reads(then, out);
942            expr_reads(otherwise, out);
943        }
944        Expr::List(items) | Expr::Tuple(items) => {
945            for item in items {
946                expr_reads(item, out);
947            }
948        }
949        Expr::Keyword(pairs) => {
950            for (_, value) in pairs {
951                expr_reads(value, out);
952            }
953        }
954        Expr::StructLiteral { fields, .. } => {
955            for (_, value) in fields {
956                expr_reads(value, out);
957            }
958        }
959        Expr::Literal(_) | Expr::Atom(_) | Expr::Attr(_) | Expr::Module(_) => {}
960    }
961}
962
963fn expr_mentions(expr: &Expr, name: &str) -> bool {
964    let mut reads = Vec::new();
965    expr_reads(expr, &mut reads);
966    reads.iter().any(|read| read == name)
967}
968
969/// Whether the statements read `name`.
970///
971/// An assignment inside a *nested block* counts as a read, because the
972/// block's own lowering reads the binding into its accumulator; a plain
973/// rebinding at this level does not.
974///
975/// zerodds-lint: recursion-depth = the generated body's block nesting.
976/// The codec walk emits a routine per type rather than inlining one
977/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
978/// graph. What does grow with the IDL is the dispatch chain: a union's
979/// cases, an enumeration's enumerators and a `@mutable` aggregate's
980/// members each become one `If` per arm, in the previous arm's `else`.
981/// The model bounds none of the three counts, and Elixir nests the arms in
982/// its own output too, so they cannot be walked iteratively the way
983/// `c.rs` walks them. No number is claimed: this signature returns no
984/// error, so nothing here could report one.
985fn statements_read(statements: &[Statement], name: &str) -> bool {
986    statements.iter().any(|statement| match statement {
987        Statement::Bind { value, .. } | Statement::BindTuple { value, .. } => {
988            expr_mentions(value, name)
989        }
990        Statement::Update { base, path, value } => {
991            base.as_str() == name
992                || expr_mentions(value, name)
993                || path.iter().any(|step| match step {
994                    Access::At(index) => expr_mentions(index, name),
995                    Access::Key(_) => false,
996                })
997        }
998        Statement::If {
999            condition,
1000            then,
1001            otherwise,
1002        } => {
1003            let mut inside = Vec::new();
1004            assigned(then, &mut inside);
1005            assigned(otherwise, &mut inside);
1006            expr_mentions(condition, name)
1007                || inside.iter().any(|bound| bound == name)
1008                || statements_read(then, name)
1009                || statements_read(otherwise, name)
1010        }
1011        Statement::For { bound, body, .. } => {
1012            let mut inside = Vec::new();
1013            assigned(body, &mut inside);
1014            expr_mentions(bound, name)
1015                || inside.iter().any(|assigned| assigned == name)
1016                || statements_read(body, name)
1017        }
1018        Statement::While { condition, body } => {
1019            let mut inside = Vec::new();
1020            assigned(body, &mut inside);
1021            expr_mentions(condition, name)
1022                || inside.iter().any(|assigned| assigned == name)
1023                || statements_read(body, name)
1024        }
1025        Statement::Return(value) => expr_mentions(value, name),
1026        Statement::Seq(inner) => statements_read(inner, name),
1027        Statement::Break | Statement::Comment(_) => false,
1028    })
1029}
1030
1031/// The outer binding pattern of a block's threaded names: each one
1032/// bound plainly where something still reads it — the following
1033/// statements, an enclosing block's trailing tuple, the routine's
1034/// result — and `_`-prefixed where nothing does.
1035fn binding_pattern(names: &[String], lowering: &Lowering, tail: &[Statement]) -> String {
1036    let parts: Vec<String> = names
1037        .iter()
1038        .map(|name| {
1039            if statements_read(tail, name) || lowering.outer_reads.iter().any(|read| read == name) {
1040                name.clone()
1041            } else {
1042                format!("_{name}")
1043            }
1044        })
1045        .collect();
1046    format!("{{{}}}", parts.join(", "))
1047}
1048
1049/// Enclosing-context reads gain this block's accumulator names while
1050/// its body renders: the trailing tuple reads every one of them.
1051fn push_outer_reads(lowering: &mut Lowering, names: &[String]) -> usize {
1052    let mut pushed = 0;
1053    for name in names {
1054        if !lowering.outer_reads.iter().any(|read| read == name) {
1055            lowering.outer_reads.push(name.clone());
1056            pushed += 1;
1057        }
1058    }
1059    pushed
1060}
1061
1062fn pop_outer_reads(lowering: &mut Lowering, pushed: usize) {
1063    for _ in 0..pushed {
1064        lowering.outer_reads.pop();
1065    }
1066}
1067
1068/// `{a, b}` over the threaded names, `{}` over none.
1069fn acc_tuple(names: &[String]) -> String {
1070    let mut out = String::from("{");
1071    out.push_str(
1072        &names
1073            .iter()
1074            .map(String::as_str)
1075            .collect::<Vec<&str>>()
1076            .join(", "),
1077    );
1078    out.push('}');
1079    out
1080}
1081
1082fn bind_name(bound: &mut Vec<String>, name: &Ident) {
1083    let text = name.as_str();
1084    if text != "_" && !bound.iter().any(|seen| seen == text) {
1085        bound.push(text.to_string());
1086    }
1087}
1088
1089/// zerodds-lint: recursion-depth = the generated body's block nesting.
1090/// The codec walk emits a routine per type rather than inlining one
1091/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
1092/// graph. What does grow with the IDL is the dispatch chain: a union's
1093/// cases, an enumeration's enumerators and a `@mutable` aggregate's
1094/// members each become one `If` per arm, in the previous arm's `else`.
1095/// The model bounds none of the three counts, and Elixir nests the arms in
1096/// its own output too, so they cannot be walked iteratively the way
1097/// `c.rs` walks them. No number is claimed: this signature returns no
1098/// error, so nothing here could report one.
1099#[allow(clippy::too_many_lines)]
1100fn render_statements(
1101    w: &mut Writer,
1102    statements: &[Statement],
1103    bound: &mut Vec<String>,
1104    lowering: &mut Lowering,
1105) {
1106    for (index, statement) in statements.iter().enumerate() {
1107        let tail = &statements[index + 1..];
1108        match statement {
1109            // Flattened before rendering; unreachable rather than
1110            // wrong, and rendered as its parts if it ever arrives.
1111            Statement::Seq(inner) => render_statements(w, inner, bound, lowering),
1112            Statement::Comment(doc) => doc_comment(w, doc),
1113            Statement::Bind { name, value } => {
1114                w.line(&format!("{} = {}", name.as_str(), expr_text(value)));
1115                bind_name(bound, name);
1116            }
1117            Statement::BindTuple { names, value } => {
1118                let pattern: Vec<&str> = names.iter().map(Ident::as_str).collect();
1119                w.line(&format!(
1120                    "{{{}}} = {}",
1121                    pattern.join(", "),
1122                    expr_text(value)
1123                ));
1124                for name in names {
1125                    bind_name(bound, name);
1126                }
1127            }
1128            Statement::Update { base, path, value } => {
1129                render_update(w, base, path, value);
1130                bind_name(bound, base);
1131            }
1132            Statement::If {
1133                condition,
1134                then,
1135                otherwise,
1136            } => render_if(w, condition, then, otherwise, bound, lowering, tail),
1137            Statement::For {
1138                counter,
1139                bound: limit,
1140                body,
1141            } => render_for(w, counter, limit, body, bound, lowering, tail),
1142            Statement::While { condition, body } => {
1143                render_while(w, condition, body, bound, lowering, tail);
1144            }
1145            Statement::Break => {
1146                let acc = lowering
1147                    .loop_accs
1148                    .last()
1149                    .map_or_else(|| String::from("{}"), |names| acc_tuple(names));
1150                w.line(&format!("throw({{:zd_break, {acc}}})"));
1151            }
1152            Statement::Return(value) => {
1153                w.line(&format!("throw({{:zd_return, {}}})", expr_text(value)));
1154            }
1155        }
1156    }
1157}
1158
1159/// `base = %{base | f: v}` for one field, `put_in/3` for a deeper path.
1160fn render_update(w: &mut Writer, base: &Ident, path: &[Access], value: &Expr) {
1161    if let [Access::Key(field)] = path {
1162        w.line(&format!(
1163            "{base} = %{{{base} | {field}: {value}}}",
1164            base = base.as_str(),
1165            field = field.as_str(),
1166            value = expr_text(value)
1167        ));
1168        return;
1169    }
1170    let steps: Vec<String> = path
1171        .iter()
1172        .map(|step| match step {
1173            // `Access.key!`, which reaches into a struct; bare
1174            // `Access.key` answers a default for a missing key, and a
1175            // missing key here is a generator defect that must raise.
1176            Access::Key(field) => format!("Access.key!(:{})", field.as_str()),
1177            Access::At(index) => format!("Access.at({})", expr_text(index)),
1178        })
1179        .collect();
1180    w.line(&format!(
1181        "{base} = put_in({base}, [{path}], {value})",
1182        base = base.as_str(),
1183        path = steps.join(", "),
1184        value = expr_text(value)
1185    ));
1186}
1187
1188/// zerodds-lint: recursion-depth = the generated body's block nesting.
1189/// The codec walk emits a routine per type rather than inlining one
1190/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
1191/// graph. What does grow with the IDL is the dispatch chain: a union's
1192/// cases, an enumeration's enumerators and a `@mutable` aggregate's
1193/// members each become one `If` per arm, in the previous arm's `else`.
1194/// The model bounds none of the three counts, and Elixir nests the arms in
1195/// its own output too, so they cannot be walked iteratively the way
1196/// `c.rs` walks them. No number is claimed: this signature returns no
1197/// error, so nothing here could report one.
1198#[allow(clippy::too_many_arguments)]
1199fn render_if(
1200    w: &mut Writer,
1201    condition: &Expr,
1202    then: &[Statement],
1203    otherwise: &[Statement],
1204    bound: &[String],
1205    lowering: &mut Lowering,
1206    tail: &[Statement],
1207) {
1208    let mut both: Vec<Statement> = then.to_vec();
1209    both.extend(otherwise.iter().cloned());
1210    let names = threaded(&both, bound);
1211    if names.is_empty() {
1212        if then.is_empty() && otherwise.is_empty() {
1213            return;
1214        }
1215        w.line(&format!("if {} do", expr_text(condition)));
1216        w.indent();
1217        if then.is_empty() {
1218            // An empty suite is not Elixir; `nil` is the value it means.
1219            w.line("nil");
1220        } else {
1221            let mut branch_bound = bound.to_vec();
1222            render_statements(w, then, &mut branch_bound, lowering);
1223        }
1224        w.dedent();
1225        if !otherwise.is_empty() {
1226            w.line("else");
1227            w.indent();
1228            let mut branch_bound = bound.to_vec();
1229            render_statements(w, otherwise, &mut branch_bound, lowering);
1230            w.dedent();
1231        }
1232        w.line("end");
1233        return;
1234    }
1235    let acc = acc_tuple(&names);
1236    w.line(&format!("{} =", binding_pattern(&names, lowering, tail)));
1237    w.indent();
1238    w.line(&format!("if {} do", expr_text(condition)));
1239    w.indent();
1240    let pushed = push_outer_reads(lowering, &names);
1241    let mut branch_bound = bound.to_vec();
1242    render_statements(w, then, &mut branch_bound, lowering);
1243    w.line(&acc);
1244    w.dedent();
1245    w.line("else");
1246    w.indent();
1247    let mut branch_bound = bound.to_vec();
1248    render_statements(w, otherwise, &mut branch_bound, lowering);
1249    w.line(&acc);
1250    w.dedent();
1251    w.line("end");
1252    w.dedent();
1253    pop_outer_reads(lowering, pushed);
1254}
1255
1256/// zerodds-lint: recursion-depth = the generated body's block nesting.
1257/// The codec walk emits a routine per type rather than inlining one
1258/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
1259/// graph. What does grow with the IDL is the dispatch chain: a union's
1260/// cases, an enumeration's enumerators and a `@mutable` aggregate's
1261/// members each become one `If` per arm, in the previous arm's `else`.
1262/// The model bounds none of the three counts, and Elixir nests the arms in
1263/// its own output too, so they cannot be walked iteratively the way
1264/// `c.rs` walks them. No number is claimed: this signature returns no
1265/// error, so nothing here could report one.
1266#[allow(clippy::too_many_arguments)]
1267fn render_for(
1268    w: &mut Writer,
1269    counter: &Ident,
1270    limit: &Expr,
1271    body: &[Statement],
1272    bound: &[String],
1273    lowering: &mut Lowering,
1274    tail: &[Statement],
1275) {
1276    let names = threaded(body, bound);
1277    let breaks = breaks_at_this_level(body);
1278    let range = format!("0..({} - 1)//1", expr_text(limit));
1279    if names.is_empty() && !breaks {
1280        w.line(&format!("Enum.each({range}, fn {} ->", counter.as_str()));
1281        w.indent();
1282        if body.is_empty() {
1283            w.line("nil");
1284        } else {
1285            let mut body_bound = bound.to_vec();
1286            body_bound.push(counter.as_str().to_string());
1287            lowering.loop_accs.push(Vec::new());
1288            render_statements(w, body, &mut body_bound, lowering);
1289            lowering.loop_accs.pop();
1290        }
1291        w.dedent();
1292        w.line("end)");
1293        return;
1294    }
1295    let acc = acc_tuple(&names);
1296    if !names.is_empty() {
1297        w.line(&format!("{} =", binding_pattern(&names, lowering, tail)));
1298        w.indent();
1299    }
1300    if breaks {
1301        w.line("try do");
1302        w.indent();
1303    }
1304    w.line(&format!(
1305        "Enum.reduce({range}, {acc}, fn {}, {acc} ->",
1306        counter.as_str()
1307    ));
1308    w.indent();
1309    let pushed = push_outer_reads(lowering, &names);
1310    let mut body_bound = bound.to_vec();
1311    body_bound.push(counter.as_str().to_string());
1312    lowering.loop_accs.push(names.clone());
1313    render_statements(w, body, &mut body_bound, lowering);
1314    lowering.loop_accs.pop();
1315    pop_outer_reads(lowering, pushed);
1316    w.line(&acc);
1317    w.dedent();
1318    w.line("end)");
1319    if breaks {
1320        w.dedent();
1321        w.line("catch");
1322        w.indent();
1323        w.line(":throw, {:zd_break, zd_brk} -> zd_brk");
1324        w.dedent();
1325        w.line("end");
1326    }
1327    if !names.is_empty() {
1328        w.dedent();
1329    }
1330}
1331
1332/// zerodds-lint: recursion-depth = the generated body's block nesting.
1333/// The codec walk emits a routine per type rather than inlining one
1334/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
1335/// graph. What does grow with the IDL is the dispatch chain: a union's
1336/// cases, an enumeration's enumerators and a `@mutable` aggregate's
1337/// members each become one `If` per arm, in the previous arm's `else`.
1338/// The model bounds none of the three counts, and Elixir nests the arms in
1339/// its own output too, so they cannot be walked iteratively the way
1340/// `c.rs` walks them. No number is claimed: this signature returns no
1341/// error, so nothing here could report one.
1342#[allow(clippy::too_many_arguments)]
1343fn render_while(
1344    w: &mut Writer,
1345    condition: &Expr,
1346    body: &[Statement],
1347    bound: &[String],
1348    lowering: &mut Lowering,
1349    tail: &[Statement],
1350) {
1351    let names = threaded(body, bound);
1352    let breaks = breaks_at_this_level(body);
1353    let acc = acc_tuple(&names);
1354    let loop_name = format!("zd_loop{}", lowering.loops_named);
1355    lowering.loops_named += 1;
1356    w.line(&format!("{loop_name} = fn {loop_name}, {acc} ->"));
1357    w.indent();
1358    w.line(&format!("if {} do", expr_text(condition)));
1359    w.indent();
1360    let pushed = push_outer_reads(lowering, &names);
1361    let mut body_bound = bound.to_vec();
1362    lowering.loop_accs.push(names.clone());
1363    render_statements(w, body, &mut body_bound, lowering);
1364    lowering.loop_accs.pop();
1365    pop_outer_reads(lowering, pushed);
1366    w.line(&format!("{loop_name}.({loop_name}, {acc})"));
1367    w.dedent();
1368    w.line("else");
1369    w.indent();
1370    w.line(&acc);
1371    w.dedent();
1372    w.line("end");
1373    w.dedent();
1374    w.line("end");
1375    let call = format!("{loop_name}.({loop_name}, {acc})");
1376    let pattern = binding_pattern(&names, lowering, tail);
1377    if breaks {
1378        if !names.is_empty() {
1379            w.line(&format!("{pattern} ="));
1380            w.indent();
1381        }
1382        w.line("try do");
1383        w.indent();
1384        w.line(&call);
1385        w.dedent();
1386        w.line("catch");
1387        w.indent();
1388        w.line(":throw, {:zd_break, zd_brk} -> zd_brk");
1389        w.dedent();
1390        w.line("end");
1391        if !names.is_empty() {
1392            w.dedent();
1393        }
1394    } else if names.is_empty() {
1395        w.line(&call);
1396    } else {
1397        w.line(&format!("{pattern} = {call}"));
1398    }
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
1404    use super::*;
1405    use alloc::vec;
1406    use zerodds_lang_cst::elixir::{Basic, Field};
1407
1408    fn module_of(body: Vec<Item>) -> Module {
1409        Module {
1410            doc: Doc::none(),
1411            name: Path::single("Probe"),
1412            body,
1413        }
1414    }
1415
1416    #[test]
1417    fn a_module_is_a_do_end_block_indented_by_two_spaces() {
1418        let text = module(&module_of(vec![Item::Defstruct(vec![Field {
1419            name: Ident::new("x"),
1420            default: Expr::int(0),
1421        }])]));
1422        assert_eq!(text, "defmodule Probe do\n  defstruct [x: 0]\nend\n");
1423    }
1424
1425    #[test]
1426    fn a_module_name_joins_its_segments_with_dots() {
1427        let text = module(&Module {
1428            doc: Doc::none(),
1429            name: Path::new(&["Common", "Util", "Location"]),
1430            body: Vec::new(),
1431        });
1432        assert!(
1433            text.starts_with("defmodule Common.Util.Location do"),
1434            "{text}"
1435        );
1436    }
1437
1438    #[test]
1439    fn a_moduledoc_is_a_heredoc_at_the_top_of_the_body() {
1440        let text = module(&Module {
1441            doc: Doc::lines(&["first", "second"]),
1442            name: Path::single("Probe"),
1443            body: Vec::new(),
1444        });
1445        assert_eq!(
1446            text,
1447            "defmodule Probe do\n  @moduledoc \"\"\"\n  first\n  second\n  \"\"\"\nend\n"
1448        );
1449    }
1450
1451    #[test]
1452    fn a_struct_with_no_fields_is_defstruct_with_an_empty_list() {
1453        let text = item(&Item::Defstruct(Vec::new()));
1454        assert_eq!(text, "defstruct []\n");
1455    }
1456
1457    #[test]
1458    fn a_struct_of_several_short_fields_stays_on_one_line() {
1459        let text = item(&Item::Defstruct(vec![
1460            Field {
1461                name: Ident::new("x"),
1462                default: Expr::int(0),
1463            },
1464            Field {
1465                name: Ident::new("pose"),
1466                default: Expr::nil(),
1467            },
1468        ]));
1469        assert_eq!(text, "defstruct [x: 0, pose: nil]\n");
1470    }
1471
1472    #[test]
1473    fn a_struct_too_wide_for_a_line_takes_one_field_each() {
1474        let text = item(&Item::Defstruct(vec![
1475            Field {
1476                name: Ident::new("a_rather_long_member_name"),
1477                default: Expr::string("a default long enough to push this past the column"),
1478            },
1479            Field {
1480                name: Ident::new("another_long_member_name"),
1481                default: Expr::nil(),
1482            },
1483        ]));
1484        assert_eq!(
1485            text,
1486            "defstruct [\n  a_rather_long_member_name: \"a default long enough to push \
1487             this past the column\",\n  another_long_member_name: nil\n]\n"
1488        );
1489    }
1490
1491    #[test]
1492    fn a_struct_type_too_wide_for_a_line_takes_one_field_each() {
1493        let text = item(&Item::TypeAlias(TypeAlias {
1494            doc: Doc::none(),
1495            name: Ident::new("t"),
1496            type_: Type::Struct(vec![
1497                (
1498                    Ident::new("a_rather_long_member_name"),
1499                    Type::Basic(Basic::Integer),
1500                ),
1501                (
1502                    Ident::new("another_long_member_name"),
1503                    Type::Basic(Basic::Float),
1504                ),
1505                (
1506                    Ident::new("a_third_long_member_name"),
1507                    Type::Basic(Basic::Binary),
1508                ),
1509            ]),
1510        }));
1511        assert_eq!(
1512            text,
1513            "@type t :: %__MODULE__{\n  a_rather_long_member_name: integer(),\n  \
1514             another_long_member_name: float(),\n  a_third_long_member_name: binary()\n}\n"
1515        );
1516    }
1517
1518    #[test]
1519    fn a_struct_type_that_fits_stays_on_its_line() {
1520        let text = item(&Item::TypeAlias(TypeAlias {
1521            doc: Doc::none(),
1522            name: Ident::new("t"),
1523            type_: Type::Struct(vec![
1524                (Ident::new("x"), Type::Basic(Basic::Integer)),
1525                (Ident::new("y"), Type::Basic(Basic::Float)),
1526            ]),
1527        }));
1528        assert_eq!(text, "@type t :: %__MODULE__{x: integer(), y: float()}\n");
1529    }
1530
1531    #[test]
1532    fn every_type_form_writes_its_own_spelling() {
1533        assert_eq!(type_(&Type::Basic(Basic::Integer)), "integer()");
1534        assert_eq!(type_(&Type::Basic(Basic::Nil)), "nil");
1535        assert_eq!(type_(&Type::Atom("idle".to_string())), ":idle");
1536        assert_eq!(type_(&Type::Local(Ident::new("t"))), "t()");
1537        assert_eq!(
1538            type_(&Type::Remote {
1539                module: Path::new(&["Common", "Pose"]),
1540                name: Ident::new("t"),
1541            }),
1542            "Common.Pose.t()"
1543        );
1544        assert_eq!(
1545            type_(&Type::list(Type::Basic(Basic::Integer))),
1546            "list(integer())"
1547        );
1548        assert_eq!(
1549            type_(&Type::Tuple(vec![
1550                Type::Basic(Basic::Integer),
1551                Type::Basic(Basic::Binary)
1552            ])),
1553            "{integer(), binary()}"
1554        );
1555        assert_eq!(
1556            type_(&Type::Basic(Basic::Integer).or_nil()),
1557            "integer() | nil"
1558        );
1559    }
1560
1561    #[test]
1562    fn an_attribute_of_a_long_list_takes_one_line_per_entry() {
1563        let text = item(&Item::Attribute(Attribute {
1564            doc: Doc::none(),
1565            name: Ident::new("zd_members"),
1566            value: Expr::List(vec![
1567                Expr::Tuple(vec![
1568                    Expr::Atom("x".to_string()),
1569                    Expr::Keyword(vec![
1570                        (Ident::new("idl"), Expr::string("x")),
1571                        (Ident::new("kind"), Expr::Atom("int32".to_string())),
1572                        (Ident::new("bits"), Expr::int(32)),
1573                    ]),
1574                ]),
1575                Expr::Tuple(vec![
1576                    Expr::Atom("y".to_string()),
1577                    Expr::Keyword(vec![
1578                        (Ident::new("idl"), Expr::string("y")),
1579                        (Ident::new("kind"), Expr::Atom("float64".to_string())),
1580                        (Ident::new("bits"), Expr::int(64)),
1581                        (Ident::new("signed"), Expr::Literal(Literal::Bool(true))),
1582                    ]),
1583                ]),
1584            ]),
1585        }));
1586        assert_eq!(
1587            text,
1588            "@zd_members [\n  {:x, [idl: \"x\", kind: :int32, bits: 32]},\n  \
1589             {:y, [idl: \"y\", kind: :float64, bits: 64, signed: true]}\n]\n"
1590        );
1591    }
1592
1593    #[test]
1594    fn a_function_writes_its_body_on_the_same_line() {
1595        let text = item(&Item::Function(Function {
1596            doc: Doc::none(),
1597            name: Ident::new("limit"),
1598            params: Vec::new(),
1599            body: Expr::Attr(Ident::new("limit")),
1600        }));
1601        assert_eq!(text, "def limit, do: @limit\n");
1602    }
1603
1604    #[test]
1605    fn a_function_with_patterns_writes_them_in_parentheses() {
1606        let text = item(&Item::Function(Function {
1607            doc: Doc::none(),
1608            name: Ident::new("from_value"),
1609            params: vec![Expr::int(0)],
1610            body: Expr::Tuple(vec![
1611                Expr::Atom("ok".to_string()),
1612                Expr::Atom("idle".to_string()),
1613            ]),
1614        }));
1615        assert_eq!(text, "def from_value(0), do: {:ok, :idle}\n");
1616    }
1617
1618    #[test]
1619    fn a_spec_and_a_callback_differ_only_in_their_marker() {
1620        let signature = Signature {
1621            doc: Doc::none(),
1622            name: Ident::new("status"),
1623            params: vec![Param {
1624                name: Some(Ident::new("target")),
1625                type_: Type::Basic(Basic::Binary),
1626            }],
1627            result: Type::Basic(Basic::Integer),
1628        };
1629        assert_eq!(
1630            item(&Item::Spec(signature.clone())),
1631            "@spec status(target :: binary()) :: integer()\n"
1632        );
1633        assert_eq!(
1634            item(&Item::Callback(signature)),
1635            "@callback status(target :: binary()) :: integer()\n"
1636        );
1637    }
1638
1639    #[test]
1640    fn a_float_literal_gets_the_point_elixir_requires() {
1641        // `1e10` is rejected by Elixir and accepted by C, C++, Java and
1642        // Rust, so the shared spelling cannot be handed straight
1643        // through. The value does not change.
1644        assert_eq!(float_text("1e10"), "1.0e10");
1645        assert_eq!(float_text("1"), "1.0");
1646        assert_eq!(float_text("1.5"), "1.5");
1647        assert_eq!(float_text("1.5e-3"), "1.5e-3");
1648        assert_eq!(float_text(".5"), "0.5");
1649        assert_eq!(float_text("-.5"), "-0.5");
1650        assert_eq!(float_text("2."), "2.0");
1651    }
1652
1653    #[test]
1654    fn a_string_literal_escapes_the_interpolation_marker() {
1655        // `"a#{}"` compiles to `"a"`, so an unescaped `#` would put a
1656        // different string in the file than the IDL wrote.
1657        assert_eq!(expr(&Expr::string("a#{b}c")), "\"a\\#{b}c\"".to_string());
1658        assert_eq!(expr(&Expr::string("plain")), "\"plain\"");
1659        assert_eq!(expr(&Expr::string("a\"b")), "\"a\\\"b\"");
1660    }
1661
1662    #[test]
1663    fn an_atom_that_is_not_an_identifier_is_quoted() {
1664        assert_eq!(expr(&Expr::Atom("idle".to_string())), ":idle");
1665        assert_eq!(expr(&Expr::Atom("has space".to_string())), ":\"has space\"");
1666        assert_eq!(expr(&Expr::Atom("Upper".to_string())), ":\"Upper\"");
1667    }
1668
1669    #[test]
1670    fn a_module_name_in_a_value_is_written_as_the_alias() {
1671        // The alias evaluates to `:"Elixir.Common.Pose"`; writing the
1672        // alias is what a reader recognises and is the same value.
1673        assert_eq!(
1674            expr(&Expr::Module(Path::new(&["Common", "Pose"]))),
1675            "Common.Pose"
1676        );
1677    }
1678
1679    #[test]
1680    fn a_byte_string_is_a_binary_and_not_a_charlist() {
1681        assert_eq!(
1682            expr(&Expr::Literal(Literal::Bytes(vec![1, 255]))),
1683            "<<1, 255>>"
1684        );
1685    }
1686
1687    #[test]
1688    fn a_char_literal_is_written_as_its_code_unit() {
1689        assert_eq!(expr(&Expr::Literal(Literal::Char('A'))), "65");
1690    }
1691
1692    #[test]
1693    fn a_file_writes_its_comment_then_its_modules() {
1694        let text = file(&File {
1695            doc: Doc::line("Generated."),
1696            modules: vec![TopLevel::Module(module_of(Vec::new()))],
1697        });
1698        assert_eq!(text, "# Generated.\n\ndefmodule Probe do\nend\n");
1699    }
1700}