Skip to main content

zerodds_lang_render/
lua.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Rendering the Lua CST. Spec §5.
4//!
5//! Two spaces per level. Lua has no `gofmt` and no formatter a generated
6//! file has to agree with, so the width is chosen for the content this
7//! column produces: a type descriptor is a table of tables of tables, and
8//! at four spaces the innermost member sits past column sixty before its
9//! first key.
10//!
11//! Nothing is decided here. There is one layout rule and it reads off
12//! the node in hand: **a table is written on one line when no entry
13//! carries a comment and no entry's value is itself a table.** Nothing is
14//! lost either way — the same entries in the same order — and a renderer
15//! that had to be told which form to use would be carrying a decision
16//! the tree could not show.
17//!
18//! ## Two comment markers, and they are not interchangeable
19//!
20//! `--` is read by nothing. `---` is read by `lua-language-server`, which
21//! attaches the block to the declaration that follows it. So
22//! [`zerodds_lang_cst::lua::Stat::Comment`] renders with `--` and
23//! [`zerodds_lang_cst::lua::Stat::Annotation`] with `---`, and the file
24//! header renders with `--` because there is no declaration it is about.
25//! Neither is read by the Lua runtime.
26
27use crate::writer::{Writer, verbatim_lines};
28use alloc::string::{String, ToString};
29use alloc::vec::Vec;
30use zerodds_lang_cst::common::{Doc, Ident, Literal};
31use zerodds_lang_cst::lua::{Expr, File, Function, Key, Place, Stat, Table};
32
33/// Render a whole file.
34#[must_use]
35pub fn file(file: &File) -> String {
36    let mut w = Writer::new("  ");
37    plain(&mut w, &file.doc);
38    if !file.doc.is_empty() {
39        w.blank_line();
40    }
41    for statement in &file.body {
42        // A statement that carries a comment starts a block; one that
43        // does not belongs with what came before it. That is the second
44        // and last layout rule in this file, and like the first it reads
45        // off the node: `M.Pose = { … }` and the registry line under it
46        // are one thing, and the next `---` block is another.
47        if opens_block(statement) {
48            w.blank_line();
49        }
50        render_stat(&mut w, statement);
51    }
52    w.blank_line();
53    // The chunk's own `return`, which is what `require` binds.
54    w.push("return ");
55    render_expr(&mut w, &file.returns);
56    w.newline();
57    for text in &file.end_text {
58        verbatim_lines(&mut w, text);
59    }
60    w.finish()
61}
62
63/// Render one statement, for a caller that has only one.
64#[must_use]
65pub fn stat(node: &Stat) -> String {
66    let mut w = Writer::new("  ");
67    render_stat(&mut w, node);
68    w.finish()
69}
70
71/// Render one expression, for a caller that has only one.
72///
73/// Without the line break [`Writer::finish`] ends the open line with: a
74/// statement is a line and an expression is not, and a caller splicing
75/// this into one would get a break in the middle of it.
76#[must_use]
77pub fn expr(node: &Expr) -> String {
78    let mut w = Writer::new("  ");
79    render_expr(&mut w, node);
80    let mut out = w.finish();
81    if out.ends_with('\n') {
82        out.pop();
83    }
84    out
85}
86
87/// The identifier a name renders as, for a caller assembling one.
88#[must_use]
89pub fn ident(name: &Ident) -> String {
90    name.as_str().to_string()
91}
92
93/// Whether a statement begins a block of its own.
94fn opens_block(node: &Stat) -> bool {
95    match node {
96        Stat::Local { doc, .. } | Stat::Assign { doc, .. } => !doc.is_empty(),
97        Stat::Function(function) => !function.doc.is_empty(),
98        Stat::Comment(doc) | Stat::Annotation(doc) => !doc.is_empty(),
99        Stat::Verbatim(_) => true,
100        Stat::Return(_)
101        | Stat::Perform(_)
102        | Stat::If { .. }
103        | Stat::While { .. }
104        | Stat::NumericFor { .. }
105        | Stat::Break => false,
106    }
107}
108
109/// zerodds-lint: recursion-depth 64 (one level per nested block of a
110/// generated body. The codec walk emits a routine per type rather than
111/// inlining one — `lang-rules/src/emit/encode.rs` — so a body nests by
112/// its own framing and by one loop per array dimension, never by the
113/// type graph, whose descent stops at 64 anyway; the else-if chain, one
114/// arm per union case, is walked iteratively and costs no depth.)
115fn render_stat(w: &mut Writer, node: &Stat) {
116    match node {
117        Stat::Local { doc, name, value } => {
118            annotation(w, doc);
119            w.push("local ");
120            w.push(name.as_str());
121            if let Some(value) = value {
122                w.push(" = ");
123                render_expr(w, value);
124            }
125            w.newline();
126        }
127        Stat::Assign { doc, target, value } => {
128            annotation(w, doc);
129            render_place(w, target);
130            w.push(" = ");
131            render_expr(w, value);
132            w.newline();
133        }
134        Stat::Function(function) => render_function(w, function),
135        Stat::Return(value) => {
136            w.push("return");
137            if let Some(value) = value {
138                w.push(" ");
139                render_expr(w, value);
140            }
141            w.newline();
142        }
143        Stat::Comment(doc) => plain(w, doc),
144        Stat::Annotation(doc) => annotation(w, doc),
145        Stat::Verbatim(text) => verbatim_lines(w, text),
146        Stat::Perform(value) => {
147            render_expr(w, value);
148            w.newline();
149        }
150        Stat::If {
151            condition,
152            then,
153            otherwise,
154        } => {
155            render_if(w, condition, then, otherwise.as_deref(), "if ");
156            w.line("end");
157        }
158        Stat::While { condition, body } => {
159            w.push("while ");
160            render_expr(w, condition);
161            w.push(" do");
162            w.newline();
163            w.indent();
164            for statement in body {
165                render_stat(w, statement);
166            }
167            w.dedent();
168            w.line("end");
169        }
170        Stat::NumericFor {
171            counter,
172            from,
173            to,
174            body,
175        } => {
176            w.push("for ");
177            w.push(counter.as_str());
178            w.push(" = ");
179            render_expr(w, from);
180            w.push(", ");
181            render_expr(w, to);
182            w.push(" do");
183            w.newline();
184            w.indent();
185            for statement in body {
186                render_stat(w, statement);
187            }
188            w.dedent();
189            w.line("end");
190        }
191        Stat::Break => w.line("break"),
192    }
193}
194
195/// One `if`/`elseif` arm, and whatever follows it.
196///
197/// An `otherwise` that holds exactly one further [`Stat::If`] renders as
198/// `elseif` rather than as a nested block. The flattening changes no
199/// meaning — Lua defines `elseif` as exactly that nesting — and it is
200/// what keeps a `@mutable` aggregate's member dispatch from indenting
201/// once per member: the walk builds one `If` per member, each in the
202/// previous one's `else`, and a hundred members would otherwise nest a
203/// hundred levels toward the parser's own depth limit.
204///
205/// The next arm is taken in a loop and not by a call to this function
206/// again: the member count that would have nested the output a hundred
207/// levels would equally have been the renderer's own stack depth, and
208/// nothing in the model bounds a union's case count, an enumeration's
209/// enumerator count or a `@mutable` aggregate's member count.
210///
211/// zerodds-lint: recursion-depth 64 (block nesting only; the arm chain
212/// is walked iteratively, and what remains is one level per nested block
213/// of a generated body — the codec walk emits a routine per type rather
214/// than inlining, so a body nests by its framing and by one loop per
215/// array dimension, never by the type graph)
216fn render_if(
217    w: &mut Writer,
218    condition: &Expr,
219    then: &[Stat],
220    otherwise: Option<&[Stat]>,
221    keyword: &str,
222) {
223    let mut condition = condition;
224    let mut then = then;
225    let mut otherwise = otherwise;
226    let mut keyword = keyword;
227    loop {
228        w.push(keyword);
229        render_expr(w, condition);
230        w.push(" then");
231        w.newline();
232        w.indent();
233        for statement in then {
234            render_stat(w, statement);
235        }
236        w.dedent();
237        match otherwise {
238            None => return,
239            Some(
240                [
241                    Stat::If {
242                        condition: next_condition,
243                        then: next_then,
244                        otherwise: next_otherwise,
245                    },
246                ],
247            ) => {
248                condition = next_condition;
249                then = next_then;
250                otherwise = next_otherwise.as_deref();
251                keyword = "elseif ";
252            }
253            Some(rest) => {
254                w.line("else");
255                w.indent();
256                for statement in rest {
257                    render_stat(w, statement);
258                }
259                w.dedent();
260                return;
261            }
262        }
263    }
264}
265
266/// zerodds-lint: recursion-depth 64 (one level per nested block of a
267/// generated body. The codec walk emits a routine per type rather than
268/// inlining one — `lang-rules/src/emit/encode.rs` — so a body nests by
269/// its own framing and by one loop per array dimension, never by the
270/// type graph, whose descent stops at 64 anyway; the else-if chain, one
271/// arm per union case, is walked iteratively and costs no depth.)
272fn render_function(w: &mut Writer, node: &Function) {
273    annotation(w, &node.doc);
274    w.push("function ");
275    render_place(w, &node.target);
276    w.push("(");
277    let params: Vec<String> = node
278        .params
279        .iter()
280        .map(|param| param.as_str().to_string())
281        .collect();
282    w.push(&params.join(", "));
283    w.push(")");
284    w.newline();
285    w.indent();
286    for statement in &node.body {
287        render_stat(w, statement);
288    }
289    w.dedent();
290    w.line("end");
291}
292
293/// zerodds-lint: recursion-depth = the built expression's nesting. A walk
294/// calls a routine per type rather than nesting one, so the one operand
295/// tree that grows with the IDL is an array's default value: one composite
296/// literal per dimension, built in `lang-rules/src/default_lua.rs`, and
297/// nothing bounds the dimension count. No number is claimed: this
298/// signature returns no error.
299fn render_place(w: &mut Writer, node: &Place) {
300    match node {
301        Place::Name(name) => w.push(name.as_str()),
302        Place::Field { base, name } => {
303            render_place(w, base);
304            w.push(".");
305            w.push(name.as_str());
306        }
307        Place::Index { base, key } => {
308            render_place(w, base);
309            w.push("[");
310            render_expr(w, key);
311            w.push("]");
312        }
313        Place::Paren(inner) => {
314            w.push("(");
315            render_expr(w, inner);
316            w.push(")");
317        }
318    }
319}
320
321/// zerodds-lint: recursion-depth = the built expression's nesting. A walk
322/// calls a routine per type rather than nesting one, so the one operand
323/// tree that grows with the IDL is an array's default value: one composite
324/// literal per dimension, built in `lang-rules/src/default_lua.rs`, and
325/// nothing bounds the dimension count. No number is claimed: this
326/// signature returns no error.
327fn render_expr(w: &mut Writer, node: &Expr) {
328    match node {
329        Expr::Table(table) if !one_line(table) => render_table(w, table),
330        Expr::Table(_) | Expr::Nil | Expr::Literal(_) | Expr::Place(_) => w.push(&expr_text(node)),
331        Expr::Call { func, args } => {
332            render_place(w, func);
333            w.push("(");
334            for (index, arg) in args.iter().enumerate() {
335                if index > 0 {
336                    w.push(", ");
337                }
338                render_expr(w, arg);
339            }
340            w.push(")");
341        }
342        Expr::Binary { op, lhs, rhs } => {
343            render_expr(w, lhs);
344            w.push(" ");
345            w.push(op);
346            w.push(" ");
347            render_expr(w, rhs);
348        }
349        Expr::Unary { op, value } => {
350            w.push(op);
351            if op.chars().all(|c| c.is_ascii_alphabetic()) {
352                w.push(" ");
353            }
354            render_expr(w, value);
355        }
356        Expr::Paren(inner) => {
357            w.push("(");
358            render_expr(w, inner);
359            w.push(")");
360        }
361    }
362}
363
364/// A table written one entry per line.
365///
366/// The separator goes after the last entry too. Lua's grammar allows it
367/// (reference §3.4.9, `fieldlist`), and it makes appending an entry a
368/// one-line diff rather than a two-line one.
369///
370/// zerodds-lint: recursion-depth = the built expression's nesting. A walk
371/// calls a routine per type rather than nesting one, so the one operand
372/// tree that grows with the IDL is an array's default value: one composite
373/// literal per dimension, built in `lang-rules/src/default_lua.rs`, and
374/// nothing bounds the dimension count. No number is claimed: this
375/// signature returns no error.
376fn render_table(w: &mut Writer, table: &Table) {
377    w.push("{");
378    w.newline();
379    w.indent();
380    for entry in &table.entries {
381        annotation(w, &entry.doc);
382        render_key(w, &entry.key);
383        render_expr(w, &entry.value);
384        w.push(",");
385        w.newline();
386    }
387    w.dedent();
388    w.push("}");
389}
390
391fn render_key(w: &mut Writer, key: &Key) {
392    match key {
393        Key::Positional => {}
394        Key::Name(name) => {
395            w.push(name.as_str());
396            w.push(" = ");
397        }
398        Key::Expr(expr) => {
399            w.push("[");
400            render_expr(w, expr);
401            w.push("] = ");
402        }
403    }
404}
405
406/// Whether a table fits on one line.
407///
408/// The only layout decision in this file, and it reads off the node: a
409/// table goes on one line when no entry carries a comment there would be
410/// nowhere to put, and no entry's value is a table of its own. A
411/// descriptor holding a member list therefore nests, and a member
412/// holding only scalars does not.
413fn one_line(table: &Table) -> bool {
414    table
415        .entries
416        .iter()
417        .all(|entry| entry.doc.is_empty() && !matches!(entry.value, Expr::Table(_)))
418}
419
420/// One expression on one line.
421///
422/// Reached for a table only through [`one_line`]; a caller that wants
423/// the nested form goes through [`render_expr`], which can indent it
424/// against the statement.
425///
426/// zerodds-lint: recursion-depth = the built expression's nesting. A walk
427/// calls a routine per type rather than nesting one, so the one operand
428/// tree that grows with the IDL is an array's default value: one composite
429/// literal per dimension, built in `lang-rules/src/default_lua.rs`, and
430/// nothing bounds the dimension count. No number is claimed: this
431/// signature returns no error.
432fn expr_text(node: &Expr) -> String {
433    match node {
434        Expr::Nil => "nil".to_string(),
435        Expr::Literal(literal) => literal_text(literal),
436        Expr::Place(place) => place_text(place),
437        Expr::Table(table) => {
438            if table.is_empty() {
439                return "{}".to_string();
440            }
441            let mut out = String::from("{ ");
442            for (index, entry) in table.entries.iter().enumerate() {
443                if index > 0 {
444                    out.push_str(", ");
445                }
446                out.push_str(&key_text(&entry.key));
447                out.push_str(&expr_text(&entry.value));
448            }
449            out.push_str(" }");
450            out
451        }
452        Expr::Call { func, args } => {
453            let mut out = place_text(func);
454            out.push('(');
455            let parts: Vec<String> = args.iter().map(expr_text).collect();
456            out.push_str(&parts.join(", "));
457            out.push(')');
458            out
459        }
460        Expr::Binary { op, lhs, rhs } => {
461            let mut out = expr_text(lhs);
462            out.push(' ');
463            out.push_str(op);
464            out.push(' ');
465            out.push_str(&expr_text(rhs));
466            out
467        }
468        Expr::Unary { op, value } => {
469            let mut out = String::from(*op);
470            if op.chars().all(|c| c.is_ascii_alphabetic()) {
471                out.push(' ');
472            }
473            out.push_str(&expr_text(value));
474            out
475        }
476        Expr::Paren(inner) => {
477            let mut out = String::from("(");
478            out.push_str(&expr_text(inner));
479            out.push(')');
480            out
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_lua.rs`, and
489/// nothing bounds the dimension count. No number is claimed: this
490/// signature returns no error.
491fn key_text(key: &Key) -> String {
492    match key {
493        Key::Positional => String::new(),
494        Key::Name(name) => {
495            let mut out = name.as_str().to_string();
496            out.push_str(" = ");
497            out
498        }
499        Key::Expr(expr) => {
500            let mut out = String::from("[");
501            out.push_str(&expr_text(expr));
502            out.push_str("] = ");
503            out
504        }
505    }
506}
507
508/// zerodds-lint: recursion-depth = the built expression's nesting. A walk
509/// calls a routine per type rather than nesting one, so the one operand
510/// tree that grows with the IDL is an array's default value: one composite
511/// literal per dimension, built in `lang-rules/src/default_lua.rs`, and
512/// nothing bounds the dimension count. No number is claimed: this
513/// signature returns no error.
514fn place_text(node: &Place) -> String {
515    match node {
516        Place::Name(name) => name.as_str().to_string(),
517        Place::Field { base, name } => {
518            let mut out = place_text(base);
519            out.push('.');
520            out.push_str(name.as_str());
521            out
522        }
523        Place::Index { base, key } => {
524            let mut out = place_text(base);
525            out.push('[');
526            out.push_str(&expr_text(key));
527            out.push(']');
528            out
529        }
530        Place::Paren(inner) => {
531            let mut out = String::from("(");
532            out.push_str(&expr_text(inner));
533            out.push(')');
534            out
535        }
536    }
537}
538
539fn literal_text(literal: &Literal) -> String {
540    match literal {
541        Literal::Int(text) | Literal::Float(text) => text.clone(),
542        Literal::Bool(value) => {
543            if *value {
544                "true".to_string()
545            } else {
546                "false".to_string()
547            }
548        }
549        // A Lua string is a run of octets, so a character is written as
550        // a one-character string rather than as a form of its own: Lua
551        // has no character type at all.
552        Literal::Char(ch) => {
553            let mut out = String::from("\"");
554            out.push_str(&escape_string(&alloc::string::String::from(*ch)));
555            out.push('"');
556            out
557        }
558        Literal::Str(text) => {
559            let mut out = String::from("\"");
560            out.push_str(&escape_string(text));
561            out.push('"');
562            out
563        }
564        // Lua has no byte-string form distinct from a string: both are a
565        // run of octets. The octets are written as decimal escapes, so
566        // the literal is ASCII whatever it holds.
567        Literal::Bytes(bytes) => {
568            let mut out = String::from("\"");
569            for byte in bytes {
570                out.push_str(&alloc::format!("\\{byte:03}"));
571            }
572            out.push('"');
573            out
574        }
575        Literal::Null => "nil".to_string(),
576    }
577}
578
579/// Escape a string for a Lua double-quoted literal.
580///
581/// Not [`crate::writer::escape_string`], and the difference is a defect
582/// that form would produce here. A Lua decimal escape reads **up to
583/// three** digits (Lua 5.4 reference §3.1), so `\0` followed by the
584/// character `1` is `\01`, which is the octet 1 and not two octets. Every
585/// escape written here is therefore three digits wide, and the ambiguity
586/// cannot arise.
587///
588/// Everything outside the control range goes through unchanged, the
589/// non-ASCII included: a Lua string is a sequence of octets with no
590/// encoding of its own, so the UTF-8 of a `wstring` holder is written as
591/// itself.
592#[must_use]
593pub fn escape_string(text: &str) -> String {
594    let mut out = String::new();
595    for ch in text.chars() {
596        match ch {
597            '"' => out.push_str("\\\""),
598            '\\' => out.push_str("\\\\"),
599            '\n' => out.push_str("\\n"),
600            '\r' => out.push_str("\\r"),
601            '\t' => out.push_str("\\t"),
602            other if (other as u32) < 0x20 || other as u32 == 0x7f => {
603                out.push_str(&alloc::format!("\\{:03}", other as u32));
604            }
605            other => out.push(other),
606        }
607    }
608    out
609}
610
611/// A `--` block: a note read by nothing.
612fn plain(w: &mut Writer, doc: &Doc) {
613    marked(w, "--", doc);
614}
615
616/// A `---` block: what `lua-language-server` attaches to the declaration
617/// below it.
618fn annotation(w: &mut Writer, doc: &Doc) {
619    marked(w, "---", doc);
620}
621
622/// A comment block, one line per entry, each with `marker`.
623///
624/// The space between the marker and the text is left out where the line
625/// begins with `@`. A LuaCATS tag is read immediately after the dashes —
626/// `---@class Pose` — and the space is not part of that spelling; a
627/// prose line takes it, so that `--- A note` reads as prose.
628fn marked(w: &mut Writer, marker: &str, doc: &Doc) {
629    for line in doc.as_lines() {
630        if line.is_empty() {
631            w.line(marker);
632            continue;
633        }
634        let mut text = String::from(marker);
635        if !line.starts_with('@') {
636            text.push(' ');
637        }
638        text.push_str(line);
639        w.line(&text);
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
646    use super::*;
647    use alloc::vec;
648    use zerodds_lang_cst::lua::{self, Entry, table_key};
649
650    fn name(text: &str) -> Place {
651        Place::name(text)
652    }
653
654    #[test]
655    fn a_file_ends_with_the_return_its_module_needs() {
656        let rendered = file(&File {
657            end_text: Vec::new(),
658            doc: Doc::line("A header."),
659            body: vec![Stat::Local {
660                doc: Doc::none(),
661                name: Ident::new("M"),
662                value: Some(Expr::Table(Table::default())),
663            }],
664            returns: Expr::place(name("M")),
665        });
666        assert_eq!(rendered, "-- A header.\n\nlocal M = {}\n\nreturn M\n");
667    }
668
669    #[test]
670    fn a_note_takes_two_dashes_and_an_annotation_three() {
671        // `lua-language-server` attaches a `---` block to the declaration
672        // below it, so a plain note written with `---` would become
673        // documentation for something nobody wrote it about.
674        assert_eq!(stat(&Stat::Comment(Doc::line("a note"))), "-- a note\n");
675        assert_eq!(
676            stat(&Stat::Annotation(Doc::line("@class Pose"))),
677            "---@class Pose\n"
678        );
679    }
680
681    #[test]
682    fn a_luacats_tag_is_written_against_the_dashes_and_prose_is_not() {
683        // `---@class Pose` is the spelling LuaCATS reads; the space is
684        // not part of it. A prose line takes the space so that it reads
685        // as prose.
686        assert_eq!(
687            stat(&Stat::Annotation(Doc::lines(&[
688                "@field x integer",
689                "a note"
690            ]))),
691            "---@field x integer\n--- a note\n"
692        );
693    }
694
695    #[test]
696    fn a_commented_statement_starts_a_block_and_an_uncommented_one_does_not() {
697        // `M.Pose = { … }` and the registry line under it are one thing.
698        let rendered = file(&File {
699            end_text: Vec::new(),
700            doc: Doc::none(),
701            body: vec![
702                Stat::Assign {
703                    doc: Doc::line("the descriptor"),
704                    target: name("M").field("Pose"),
705                    value: Expr::int(1),
706                },
707                Stat::Assign {
708                    doc: Doc::none(),
709                    target: name("M").field("registry"),
710                    value: Expr::int(2),
711                },
712                Stat::Assign {
713                    doc: Doc::line("the next block"),
714                    target: name("M").field("Other"),
715                    value: Expr::int(3),
716                },
717            ],
718            returns: Expr::place(name("M")),
719        });
720        assert_eq!(
721            rendered,
722            "--- the descriptor\nM.Pose = 1\nM.registry = 2\n\n\
723             --- the next block\nM.Other = 3\n\nreturn M\n"
724        );
725    }
726
727    #[test]
728    fn an_empty_comment_line_carries_no_trailing_space() {
729        let rendered = stat(&Stat::Comment(Doc::lines(&["first", "", "third"])));
730        assert_eq!(rendered, "-- first\n--\n-- third\n");
731    }
732
733    #[test]
734    fn a_table_of_scalars_is_written_on_one_line() {
735        let table = Table::of(vec![
736            Entry::named("name", Expr::string("x")),
737            Entry::named("bits", Expr::int(32)),
738        ]);
739        assert_eq!(expr(&Expr::Table(table)), "{ name = \"x\", bits = 32 }");
740    }
741
742    #[test]
743    fn a_table_holding_a_table_is_written_one_entry_per_line() {
744        let table = Table::of(vec![Entry::named(
745            "members",
746            Expr::Table(Table::of(vec![Entry::positional(Expr::int(1))])),
747        )]);
748        // The outer table nests because an entry's value is a table; the
749        // inner one does not, because its own entries are scalars.
750        assert_eq!(expr(&Expr::Table(table)), "{\n  members = { 1 },\n}");
751    }
752
753    #[test]
754    fn a_nested_table_indents_against_the_statement_that_holds_it() {
755        // The reason expressions render into the writer rather than into
756        // a string: a continuation line has to know how deep it is.
757        let rendered = stat(&Stat::Assign {
758            doc: Doc::none(),
759            target: name("M").field("Pose"),
760            value: Expr::Table(Table::of(vec![Entry::named(
761                "members",
762                Expr::Table(Table::of(vec![Entry::positional(Expr::Table(Table::of(
763                    vec![Entry::named("name", Expr::string("x"))],
764                )))])),
765            )])),
766        });
767        assert_eq!(
768            rendered,
769            "M.Pose = {\n  members = {\n    { name = \"x\" },\n  },\n}\n"
770        );
771    }
772
773    #[test]
774    fn an_entry_with_a_comment_forces_the_nested_form() {
775        // There is nowhere to put a comment on a one-line table, and
776        // dropping it would be the silent loss this project is arranged
777        // against.
778        let table = Table::of(vec![Entry {
779            doc: Doc::line("why"),
780            key: table_key("a"),
781            value: Expr::int(1),
782        }]);
783        assert_eq!(expr(&Expr::Table(table)), "{\n  --- why\n  a = 1,\n}");
784    }
785
786    #[test]
787    fn a_keyword_key_is_written_in_the_bracket_form() {
788        let table = Table::of(vec![Entry::named("end", Expr::int(1))]);
789        assert_eq!(expr(&Expr::Table(table)), "{ [\"end\"] = 1 }");
790    }
791
792    #[test]
793    fn a_keyword_field_of_a_place_is_written_in_the_bracket_form() {
794        let place = name("M").field("common").field("end");
795        assert_eq!(expr(&Expr::place(place)), "M.common[\"end\"]");
796    }
797
798    #[test]
799    fn a_function_renders_with_its_body_indented() {
800        let rendered = stat(&Stat::Function(Function {
801            doc: Doc::line("@return Pose"),
802            target: name("M").field("Pose").field("new"),
803            params: Vec::new(),
804            body: vec![Stat::Return(Some(Expr::Table(Table::of(vec![
805                Entry::named("x", Expr::int(0)),
806            ]))))],
807        }));
808        assert_eq!(
809            rendered,
810            "---@return Pose\nfunction M.Pose.new()\n  return { x = 0 }\nend\n"
811        );
812    }
813
814    #[test]
815    fn a_call_renders_its_arguments_in_order() {
816        let call = Expr::Call {
817            func: alloc::boxed::Box::new(name("assert")),
818            args: vec![
819                Expr::place(name("M").field("Point")),
820                Expr::string("not declared"),
821            ],
822        };
823        assert_eq!(expr(&call), "assert(M.Point, \"not declared\")");
824    }
825
826    #[test]
827    fn a_binary_expression_writes_the_operator_it_was_given() {
828        let shift = Expr::Binary {
829            op: "<<",
830            lhs: alloc::boxed::Box::new(Expr::int(1)),
831            rhs: alloc::boxed::Box::new(Expr::int(4)),
832        };
833        assert_eq!(expr(&shift), "1 << 4");
834    }
835
836    #[test]
837    fn nil_and_the_booleans_are_written_as_lua_spells_them() {
838        assert_eq!(expr(&Expr::Nil), "nil");
839        assert_eq!(expr(&Expr::Literal(Literal::Null)), "nil");
840        assert_eq!(expr(&Expr::bool(true)), "true");
841        assert_eq!(expr(&Expr::bool(false)), "false");
842    }
843
844    #[test]
845    fn a_float_keeps_the_form_that_makes_math_type_answer_float() {
846        assert_eq!(
847            expr(&Expr::Literal(Literal::Float(lua::float_text("0")))),
848            "0.0"
849        );
850    }
851
852    #[test]
853    fn a_control_character_escape_is_three_digits_wide() {
854        // A Lua decimal escape reads up to three digits, so `"\0" .. "1"`
855        // written as `"\01"` would be one octet and not two.
856        assert_eq!(escape_string("\u{0}1"), "\\0001");
857        assert_eq!(escape_string("a\"b"), "a\\\"b");
858        assert_eq!(escape_string("a\\b"), "a\\\\b");
859        assert_eq!(escape_string("a\nb"), "a\\nb");
860        assert_eq!(escape_string("plain"), "plain");
861    }
862
863    #[test]
864    fn a_non_ascii_character_goes_through_as_its_own_octets() {
865        // A Lua string has no encoding of its own, so the UTF-8 a
866        // `wstring` holder carries is written as itself.
867        assert_eq!(escape_string("Größe"), "Größe");
868    }
869
870    #[test]
871    fn a_char_literal_becomes_a_one_character_string() {
872        assert_eq!(expr(&Expr::Literal(Literal::Char('a'))), "\"a\"");
873    }
874
875    #[test]
876    fn a_byte_string_is_written_as_decimal_escapes() {
877        assert_eq!(
878            expr(&Expr::Literal(Literal::Bytes(vec![0, 1, 255]))),
879            "\"\\000\\001\\255\""
880        );
881    }
882
883    #[test]
884    fn an_index_place_renders_its_key_expression() {
885        let place = name("M").field("_descriptors");
886        let rendered = expr(&Expr::place(Place::Index {
887            base: alloc::boxed::Box::new(place),
888            key: alloc::boxed::Box::new(Expr::string("::Point")),
889        }));
890        assert_eq!(rendered, "M._descriptors[\"::Point\"]");
891    }
892
893    #[test]
894    fn a_bare_return_renders_without_a_value() {
895        assert_eq!(stat(&Stat::Return(None)), "return\n");
896    }
897
898    #[test]
899    fn a_local_without_a_value_renders_as_a_bare_declaration() {
900        assert_eq!(
901            stat(&Stat::Local {
902                doc: Doc::none(),
903                name: Ident::new("x"),
904                value: None,
905            }),
906            "local x\n"
907        );
908    }
909
910    #[test]
911    fn an_identifier_renders_exactly_as_it_was_given() {
912        assert_eq!(ident(&Ident::new("Pose")), "Pose");
913    }
914
915    #[test]
916    fn a_call_statement_renders_as_the_call() {
917        // Lua's grammar admits a functioncall as a statement and nothing
918        // else expression-shaped; the tree's `Perform` is that.
919        let rendered = stat(&Stat::Perform(Expr::Call {
920            func: alloc::boxed::Box::new(name("zdw").field("write_u32")),
921            args: vec![Expr::place(name("w")), Expr::int(7)],
922        }));
923        assert_eq!(rendered, "zdw.write_u32(w, 7)\n");
924    }
925
926    #[test]
927    fn an_if_renders_its_two_arms_and_ends_once() {
928        let rendered = stat(&Stat::If {
929            condition: Expr::Binary {
930                op: "==",
931                lhs: alloc::boxed::Box::new(Expr::place(name("x"))),
932                rhs: alloc::boxed::Box::new(Expr::int(1)),
933            },
934            then: vec![Stat::Return(Some(Expr::int(1)))],
935            otherwise: Some(vec![Stat::Return(Some(Expr::int(2)))]),
936        });
937        assert_eq!(
938            rendered,
939            "if x == 1 then\n  return 1\nelse\n  return 2\nend\n"
940        );
941    }
942
943    #[test]
944    fn an_else_holding_one_if_flattens_to_elseif() {
945        // The walk builds one `If` per member, each in the previous
946        // one's `else`; a hundred members must not nest a hundred deep.
947        let inner = Stat::If {
948            condition: Expr::Binary {
949                op: "==",
950                lhs: alloc::boxed::Box::new(Expr::place(name("x"))),
951                rhs: alloc::boxed::Box::new(Expr::int(2)),
952            },
953            then: vec![Stat::Break],
954            otherwise: Some(vec![Stat::Return(None)]),
955        };
956        let rendered = stat(&Stat::If {
957            condition: Expr::Binary {
958                op: "==",
959                lhs: alloc::boxed::Box::new(Expr::place(name("x"))),
960                rhs: alloc::boxed::Box::new(Expr::int(1)),
961            },
962            then: vec![Stat::Break],
963            otherwise: Some(vec![inner]),
964        });
965        assert_eq!(
966            rendered,
967            "if x == 1 then\n  break\nelseif x == 2 then\n  break\nelse\n  return\nend\n"
968        );
969    }
970
971    #[test]
972    fn a_while_and_a_numeric_for_render_their_bodies_indented() {
973        let rendered = stat(&Stat::While {
974            condition: Expr::bool(true),
975            body: vec![Stat::Break],
976        });
977        assert_eq!(rendered, "while true do\n  break\nend\n");
978        let rendered = stat(&Stat::NumericFor {
979            counter: Ident::new("zd_i"),
980            from: Expr::int(0),
981            to: Expr::Binary {
982                op: "-",
983                lhs: alloc::boxed::Box::new(Expr::place(name("zd_n"))),
984                rhs: alloc::boxed::Box::new(Expr::int(1)),
985            },
986            body: vec![Stat::Break],
987        });
988        assert_eq!(rendered, "for zd_i = 0, zd_n - 1 do\n  break\nend\n");
989    }
990
991    #[test]
992    fn the_unary_operators_space_the_word_and_not_the_symbols() {
993        // `notx` is an identifier; `#v` and `~v` take no space.
994        let value = || alloc::boxed::Box::new(Expr::place(name("v")));
995        assert_eq!(
996            expr(&Expr::Unary {
997                op: "not",
998                value: value()
999            }),
1000            "not v"
1001        );
1002        assert_eq!(
1003            expr(&Expr::Unary {
1004                op: "#",
1005                value: value()
1006            }),
1007            "#v"
1008        );
1009        assert_eq!(
1010            expr(&Expr::Unary {
1011                op: "~",
1012                value: value()
1013            }),
1014            "~v"
1015        );
1016    }
1017
1018    #[test]
1019    fn a_deliberate_parenthesis_survives_as_expression_and_as_place_base() {
1020        assert_eq!(
1021            expr(&Expr::Paren(alloc::boxed::Box::new(Expr::int(1)))),
1022            "(1)"
1023        );
1024        let place = Place::Index {
1025            base: alloc::boxed::Box::new(Place::Paren(alloc::boxed::Box::new(Expr::place(name(
1026                "f",
1027            ))))),
1028            key: alloc::boxed::Box::new(Expr::int(1)),
1029        };
1030        assert_eq!(expr(&Expr::place(place)), "(f)[1]");
1031    }
1032}