use crate::writer::{Writer, verbatim_lines};
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use zerodds_lang_cst::common::{Doc, Ident, Literal};
use zerodds_lang_cst::lua::{Expr, File, Function, Key, Place, Stat, Table};
#[must_use]
pub fn file(file: &File) -> String {
let mut w = Writer::new(" ");
plain(&mut w, &file.doc);
if !file.doc.is_empty() {
w.blank_line();
}
for statement in &file.body {
if opens_block(statement) {
w.blank_line();
}
render_stat(&mut w, statement);
}
w.blank_line();
w.push("return ");
render_expr(&mut w, &file.returns);
w.newline();
for text in &file.end_text {
verbatim_lines(&mut w, text);
}
w.finish()
}
#[must_use]
pub fn stat(node: &Stat) -> String {
let mut w = Writer::new(" ");
render_stat(&mut w, node);
w.finish()
}
#[must_use]
pub fn expr(node: &Expr) -> String {
let mut w = Writer::new(" ");
render_expr(&mut w, node);
let mut out = w.finish();
if out.ends_with('\n') {
out.pop();
}
out
}
#[must_use]
pub fn ident(name: &Ident) -> String {
name.as_str().to_string()
}
fn opens_block(node: &Stat) -> bool {
match node {
Stat::Local { doc, .. } | Stat::Assign { doc, .. } => !doc.is_empty(),
Stat::Function(function) => !function.doc.is_empty(),
Stat::Comment(doc) | Stat::Annotation(doc) => !doc.is_empty(),
Stat::Verbatim(_) => true,
Stat::Return(_)
| Stat::Perform(_)
| Stat::If { .. }
| Stat::While { .. }
| Stat::NumericFor { .. }
| Stat::Break => false,
}
}
fn render_stat(w: &mut Writer, node: &Stat) {
match node {
Stat::Local { doc, name, value } => {
annotation(w, doc);
w.push("local ");
w.push(name.as_str());
if let Some(value) = value {
w.push(" = ");
render_expr(w, value);
}
w.newline();
}
Stat::Assign { doc, target, value } => {
annotation(w, doc);
render_place(w, target);
w.push(" = ");
render_expr(w, value);
w.newline();
}
Stat::Function(function) => render_function(w, function),
Stat::Return(value) => {
w.push("return");
if let Some(value) = value {
w.push(" ");
render_expr(w, value);
}
w.newline();
}
Stat::Comment(doc) => plain(w, doc),
Stat::Annotation(doc) => annotation(w, doc),
Stat::Verbatim(text) => verbatim_lines(w, text),
Stat::Perform(value) => {
render_expr(w, value);
w.newline();
}
Stat::If {
condition,
then,
otherwise,
} => {
render_if(w, condition, then, otherwise.as_deref(), "if ");
w.line("end");
}
Stat::While { condition, body } => {
w.push("while ");
render_expr(w, condition);
w.push(" do");
w.newline();
w.indent();
for statement in body {
render_stat(w, statement);
}
w.dedent();
w.line("end");
}
Stat::NumericFor {
counter,
from,
to,
body,
} => {
w.push("for ");
w.push(counter.as_str());
w.push(" = ");
render_expr(w, from);
w.push(", ");
render_expr(w, to);
w.push(" do");
w.newline();
w.indent();
for statement in body {
render_stat(w, statement);
}
w.dedent();
w.line("end");
}
Stat::Break => w.line("break"),
}
}
fn render_if(
w: &mut Writer,
condition: &Expr,
then: &[Stat],
otherwise: Option<&[Stat]>,
keyword: &str,
) {
let mut condition = condition;
let mut then = then;
let mut otherwise = otherwise;
let mut keyword = keyword;
loop {
w.push(keyword);
render_expr(w, condition);
w.push(" then");
w.newline();
w.indent();
for statement in then {
render_stat(w, statement);
}
w.dedent();
match otherwise {
None => return,
Some(
[
Stat::If {
condition: next_condition,
then: next_then,
otherwise: next_otherwise,
},
],
) => {
condition = next_condition;
then = next_then;
otherwise = next_otherwise.as_deref();
keyword = "elseif ";
}
Some(rest) => {
w.line("else");
w.indent();
for statement in rest {
render_stat(w, statement);
}
w.dedent();
return;
}
}
}
}
fn render_function(w: &mut Writer, node: &Function) {
annotation(w, &node.doc);
w.push("function ");
render_place(w, &node.target);
w.push("(");
let params: Vec<String> = node
.params
.iter()
.map(|param| param.as_str().to_string())
.collect();
w.push(¶ms.join(", "));
w.push(")");
w.newline();
w.indent();
for statement in &node.body {
render_stat(w, statement);
}
w.dedent();
w.line("end");
}
fn render_place(w: &mut Writer, node: &Place) {
match node {
Place::Name(name) => w.push(name.as_str()),
Place::Field { base, name } => {
render_place(w, base);
w.push(".");
w.push(name.as_str());
}
Place::Index { base, key } => {
render_place(w, base);
w.push("[");
render_expr(w, key);
w.push("]");
}
Place::Paren(inner) => {
w.push("(");
render_expr(w, inner);
w.push(")");
}
}
}
fn render_expr(w: &mut Writer, node: &Expr) {
match node {
Expr::Table(table) if !one_line(table) => render_table(w, table),
Expr::Table(_) | Expr::Nil | Expr::Literal(_) | Expr::Place(_) => w.push(&expr_text(node)),
Expr::Call { func, args } => {
render_place(w, func);
w.push("(");
for (index, arg) in args.iter().enumerate() {
if index > 0 {
w.push(", ");
}
render_expr(w, arg);
}
w.push(")");
}
Expr::Binary { op, lhs, rhs } => {
render_expr(w, lhs);
w.push(" ");
w.push(op);
w.push(" ");
render_expr(w, rhs);
}
Expr::Unary { op, value } => {
w.push(op);
if op.chars().all(|c| c.is_ascii_alphabetic()) {
w.push(" ");
}
render_expr(w, value);
}
Expr::Paren(inner) => {
w.push("(");
render_expr(w, inner);
w.push(")");
}
}
}
fn render_table(w: &mut Writer, table: &Table) {
w.push("{");
w.newline();
w.indent();
for entry in &table.entries {
annotation(w, &entry.doc);
render_key(w, &entry.key);
render_expr(w, &entry.value);
w.push(",");
w.newline();
}
w.dedent();
w.push("}");
}
fn render_key(w: &mut Writer, key: &Key) {
match key {
Key::Positional => {}
Key::Name(name) => {
w.push(name.as_str());
w.push(" = ");
}
Key::Expr(expr) => {
w.push("[");
render_expr(w, expr);
w.push("] = ");
}
}
}
fn one_line(table: &Table) -> bool {
table
.entries
.iter()
.all(|entry| entry.doc.is_empty() && !matches!(entry.value, Expr::Table(_)))
}
fn expr_text(node: &Expr) -> String {
match node {
Expr::Nil => "nil".to_string(),
Expr::Literal(literal) => literal_text(literal),
Expr::Place(place) => place_text(place),
Expr::Table(table) => {
if table.is_empty() {
return "{}".to_string();
}
let mut out = String::from("{ ");
for (index, entry) in table.entries.iter().enumerate() {
if index > 0 {
out.push_str(", ");
}
out.push_str(&key_text(&entry.key));
out.push_str(&expr_text(&entry.value));
}
out.push_str(" }");
out
}
Expr::Call { func, args } => {
let mut out = place_text(func);
out.push('(');
let parts: Vec<String> = args.iter().map(expr_text).collect();
out.push_str(&parts.join(", "));
out.push(')');
out
}
Expr::Binary { op, lhs, rhs } => {
let mut out = expr_text(lhs);
out.push(' ');
out.push_str(op);
out.push(' ');
out.push_str(&expr_text(rhs));
out
}
Expr::Unary { op, value } => {
let mut out = String::from(*op);
if op.chars().all(|c| c.is_ascii_alphabetic()) {
out.push(' ');
}
out.push_str(&expr_text(value));
out
}
Expr::Paren(inner) => {
let mut out = String::from("(");
out.push_str(&expr_text(inner));
out.push(')');
out
}
}
}
fn key_text(key: &Key) -> String {
match key {
Key::Positional => String::new(),
Key::Name(name) => {
let mut out = name.as_str().to_string();
out.push_str(" = ");
out
}
Key::Expr(expr) => {
let mut out = String::from("[");
out.push_str(&expr_text(expr));
out.push_str("] = ");
out
}
}
}
fn place_text(node: &Place) -> String {
match node {
Place::Name(name) => name.as_str().to_string(),
Place::Field { base, name } => {
let mut out = place_text(base);
out.push('.');
out.push_str(name.as_str());
out
}
Place::Index { base, key } => {
let mut out = place_text(base);
out.push('[');
out.push_str(&expr_text(key));
out.push(']');
out
}
Place::Paren(inner) => {
let mut out = String::from("(");
out.push_str(&expr_text(inner));
out.push(')');
out
}
}
}
fn literal_text(literal: &Literal) -> String {
match literal {
Literal::Int(text) | Literal::Float(text) => text.clone(),
Literal::Bool(value) => {
if *value {
"true".to_string()
} else {
"false".to_string()
}
}
Literal::Char(ch) => {
let mut out = String::from("\"");
out.push_str(&escape_string(&alloc::string::String::from(*ch)));
out.push('"');
out
}
Literal::Str(text) => {
let mut out = String::from("\"");
out.push_str(&escape_string(text));
out.push('"');
out
}
Literal::Bytes(bytes) => {
let mut out = String::from("\"");
for byte in bytes {
out.push_str(&alloc::format!("\\{byte:03}"));
}
out.push('"');
out
}
Literal::Null => "nil".to_string(),
}
}
#[must_use]
pub fn escape_string(text: &str) -> String {
let mut out = String::new();
for ch in text.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other if (other as u32) < 0x20 || other as u32 == 0x7f => {
out.push_str(&alloc::format!("\\{:03}", other as u32));
}
other => out.push(other),
}
}
out
}
fn plain(w: &mut Writer, doc: &Doc) {
marked(w, "--", doc);
}
fn annotation(w: &mut Writer, doc: &Doc) {
marked(w, "---", doc);
}
fn marked(w: &mut Writer, marker: &str, doc: &Doc) {
for line in doc.as_lines() {
if line.is_empty() {
w.line(marker);
continue;
}
let mut text = String::from(marker);
if !line.starts_with('@') {
text.push(' ');
}
text.push_str(line);
w.line(&text);
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
use super::*;
use alloc::vec;
use zerodds_lang_cst::lua::{self, Entry, table_key};
fn name(text: &str) -> Place {
Place::name(text)
}
#[test]
fn a_file_ends_with_the_return_its_module_needs() {
let rendered = file(&File {
end_text: Vec::new(),
doc: Doc::line("A header."),
body: vec![Stat::Local {
doc: Doc::none(),
name: Ident::new("M"),
value: Some(Expr::Table(Table::default())),
}],
returns: Expr::place(name("M")),
});
assert_eq!(rendered, "-- A header.\n\nlocal M = {}\n\nreturn M\n");
}
#[test]
fn a_note_takes_two_dashes_and_an_annotation_three() {
assert_eq!(stat(&Stat::Comment(Doc::line("a note"))), "-- a note\n");
assert_eq!(
stat(&Stat::Annotation(Doc::line("@class Pose"))),
"---@class Pose\n"
);
}
#[test]
fn a_luacats_tag_is_written_against_the_dashes_and_prose_is_not() {
assert_eq!(
stat(&Stat::Annotation(Doc::lines(&[
"@field x integer",
"a note"
]))),
"---@field x integer\n--- a note\n"
);
}
#[test]
fn a_commented_statement_starts_a_block_and_an_uncommented_one_does_not() {
let rendered = file(&File {
end_text: Vec::new(),
doc: Doc::none(),
body: vec![
Stat::Assign {
doc: Doc::line("the descriptor"),
target: name("M").field("Pose"),
value: Expr::int(1),
},
Stat::Assign {
doc: Doc::none(),
target: name("M").field("registry"),
value: Expr::int(2),
},
Stat::Assign {
doc: Doc::line("the next block"),
target: name("M").field("Other"),
value: Expr::int(3),
},
],
returns: Expr::place(name("M")),
});
assert_eq!(
rendered,
"--- the descriptor\nM.Pose = 1\nM.registry = 2\n\n\
--- the next block\nM.Other = 3\n\nreturn M\n"
);
}
#[test]
fn an_empty_comment_line_carries_no_trailing_space() {
let rendered = stat(&Stat::Comment(Doc::lines(&["first", "", "third"])));
assert_eq!(rendered, "-- first\n--\n-- third\n");
}
#[test]
fn a_table_of_scalars_is_written_on_one_line() {
let table = Table::of(vec![
Entry::named("name", Expr::string("x")),
Entry::named("bits", Expr::int(32)),
]);
assert_eq!(expr(&Expr::Table(table)), "{ name = \"x\", bits = 32 }");
}
#[test]
fn a_table_holding_a_table_is_written_one_entry_per_line() {
let table = Table::of(vec![Entry::named(
"members",
Expr::Table(Table::of(vec![Entry::positional(Expr::int(1))])),
)]);
assert_eq!(expr(&Expr::Table(table)), "{\n members = { 1 },\n}");
}
#[test]
fn a_nested_table_indents_against_the_statement_that_holds_it() {
let rendered = stat(&Stat::Assign {
doc: Doc::none(),
target: name("M").field("Pose"),
value: Expr::Table(Table::of(vec![Entry::named(
"members",
Expr::Table(Table::of(vec![Entry::positional(Expr::Table(Table::of(
vec![Entry::named("name", Expr::string("x"))],
)))])),
)])),
});
assert_eq!(
rendered,
"M.Pose = {\n members = {\n { name = \"x\" },\n },\n}\n"
);
}
#[test]
fn an_entry_with_a_comment_forces_the_nested_form() {
let table = Table::of(vec![Entry {
doc: Doc::line("why"),
key: table_key("a"),
value: Expr::int(1),
}]);
assert_eq!(expr(&Expr::Table(table)), "{\n --- why\n a = 1,\n}");
}
#[test]
fn a_keyword_key_is_written_in_the_bracket_form() {
let table = Table::of(vec![Entry::named("end", Expr::int(1))]);
assert_eq!(expr(&Expr::Table(table)), "{ [\"end\"] = 1 }");
}
#[test]
fn a_keyword_field_of_a_place_is_written_in_the_bracket_form() {
let place = name("M").field("common").field("end");
assert_eq!(expr(&Expr::place(place)), "M.common[\"end\"]");
}
#[test]
fn a_function_renders_with_its_body_indented() {
let rendered = stat(&Stat::Function(Function {
doc: Doc::line("@return Pose"),
target: name("M").field("Pose").field("new"),
params: Vec::new(),
body: vec![Stat::Return(Some(Expr::Table(Table::of(vec![
Entry::named("x", Expr::int(0)),
]))))],
}));
assert_eq!(
rendered,
"---@return Pose\nfunction M.Pose.new()\n return { x = 0 }\nend\n"
);
}
#[test]
fn a_call_renders_its_arguments_in_order() {
let call = Expr::Call {
func: alloc::boxed::Box::new(name("assert")),
args: vec![
Expr::place(name("M").field("Point")),
Expr::string("not declared"),
],
};
assert_eq!(expr(&call), "assert(M.Point, \"not declared\")");
}
#[test]
fn a_binary_expression_writes_the_operator_it_was_given() {
let shift = Expr::Binary {
op: "<<",
lhs: alloc::boxed::Box::new(Expr::int(1)),
rhs: alloc::boxed::Box::new(Expr::int(4)),
};
assert_eq!(expr(&shift), "1 << 4");
}
#[test]
fn nil_and_the_booleans_are_written_as_lua_spells_them() {
assert_eq!(expr(&Expr::Nil), "nil");
assert_eq!(expr(&Expr::Literal(Literal::Null)), "nil");
assert_eq!(expr(&Expr::bool(true)), "true");
assert_eq!(expr(&Expr::bool(false)), "false");
}
#[test]
fn a_float_keeps_the_form_that_makes_math_type_answer_float() {
assert_eq!(
expr(&Expr::Literal(Literal::Float(lua::float_text("0")))),
"0.0"
);
}
#[test]
fn a_control_character_escape_is_three_digits_wide() {
assert_eq!(escape_string("\u{0}1"), "\\0001");
assert_eq!(escape_string("a\"b"), "a\\\"b");
assert_eq!(escape_string("a\\b"), "a\\\\b");
assert_eq!(escape_string("a\nb"), "a\\nb");
assert_eq!(escape_string("plain"), "plain");
}
#[test]
fn a_non_ascii_character_goes_through_as_its_own_octets() {
assert_eq!(escape_string("Größe"), "Größe");
}
#[test]
fn a_char_literal_becomes_a_one_character_string() {
assert_eq!(expr(&Expr::Literal(Literal::Char('a'))), "\"a\"");
}
#[test]
fn a_byte_string_is_written_as_decimal_escapes() {
assert_eq!(
expr(&Expr::Literal(Literal::Bytes(vec![0, 1, 255]))),
"\"\\000\\001\\255\""
);
}
#[test]
fn an_index_place_renders_its_key_expression() {
let place = name("M").field("_descriptors");
let rendered = expr(&Expr::place(Place::Index {
base: alloc::boxed::Box::new(place),
key: alloc::boxed::Box::new(Expr::string("::Point")),
}));
assert_eq!(rendered, "M._descriptors[\"::Point\"]");
}
#[test]
fn a_bare_return_renders_without_a_value() {
assert_eq!(stat(&Stat::Return(None)), "return\n");
}
#[test]
fn a_local_without_a_value_renders_as_a_bare_declaration() {
assert_eq!(
stat(&Stat::Local {
doc: Doc::none(),
name: Ident::new("x"),
value: None,
}),
"local x\n"
);
}
#[test]
fn an_identifier_renders_exactly_as_it_was_given() {
assert_eq!(ident(&Ident::new("Pose")), "Pose");
}
#[test]
fn a_call_statement_renders_as_the_call() {
let rendered = stat(&Stat::Perform(Expr::Call {
func: alloc::boxed::Box::new(name("zdw").field("write_u32")),
args: vec![Expr::place(name("w")), Expr::int(7)],
}));
assert_eq!(rendered, "zdw.write_u32(w, 7)\n");
}
#[test]
fn an_if_renders_its_two_arms_and_ends_once() {
let rendered = stat(&Stat::If {
condition: Expr::Binary {
op: "==",
lhs: alloc::boxed::Box::new(Expr::place(name("x"))),
rhs: alloc::boxed::Box::new(Expr::int(1)),
},
then: vec![Stat::Return(Some(Expr::int(1)))],
otherwise: Some(vec![Stat::Return(Some(Expr::int(2)))]),
});
assert_eq!(
rendered,
"if x == 1 then\n return 1\nelse\n return 2\nend\n"
);
}
#[test]
fn an_else_holding_one_if_flattens_to_elseif() {
let inner = Stat::If {
condition: Expr::Binary {
op: "==",
lhs: alloc::boxed::Box::new(Expr::place(name("x"))),
rhs: alloc::boxed::Box::new(Expr::int(2)),
},
then: vec![Stat::Break],
otherwise: Some(vec![Stat::Return(None)]),
};
let rendered = stat(&Stat::If {
condition: Expr::Binary {
op: "==",
lhs: alloc::boxed::Box::new(Expr::place(name("x"))),
rhs: alloc::boxed::Box::new(Expr::int(1)),
},
then: vec![Stat::Break],
otherwise: Some(vec![inner]),
});
assert_eq!(
rendered,
"if x == 1 then\n break\nelseif x == 2 then\n break\nelse\n return\nend\n"
);
}
#[test]
fn a_while_and_a_numeric_for_render_their_bodies_indented() {
let rendered = stat(&Stat::While {
condition: Expr::bool(true),
body: vec![Stat::Break],
});
assert_eq!(rendered, "while true do\n break\nend\n");
let rendered = stat(&Stat::NumericFor {
counter: Ident::new("zd_i"),
from: Expr::int(0),
to: Expr::Binary {
op: "-",
lhs: alloc::boxed::Box::new(Expr::place(name("zd_n"))),
rhs: alloc::boxed::Box::new(Expr::int(1)),
},
body: vec![Stat::Break],
});
assert_eq!(rendered, "for zd_i = 0, zd_n - 1 do\n break\nend\n");
}
#[test]
fn the_unary_operators_space_the_word_and_not_the_symbols() {
let value = || alloc::boxed::Box::new(Expr::place(name("v")));
assert_eq!(
expr(&Expr::Unary {
op: "not",
value: value()
}),
"not v"
);
assert_eq!(
expr(&Expr::Unary {
op: "#",
value: value()
}),
"#v"
);
assert_eq!(
expr(&Expr::Unary {
op: "~",
value: value()
}),
"~v"
);
}
#[test]
fn a_deliberate_parenthesis_survives_as_expression_and_as_place_base() {
assert_eq!(
expr(&Expr::Paren(alloc::boxed::Box::new(Expr::int(1)))),
"(1)"
);
let place = Place::Index {
base: alloc::boxed::Box::new(Place::Paren(alloc::boxed::Box::new(Expr::place(name(
"f",
))))),
key: alloc::boxed::Box::new(Expr::int(1)),
};
assert_eq!(expr(&Expr::place(place)), "(f)[1]");
}
}