feat: Add AST emitter and comment support

Introduces a new AST emitter module responsible for serializing AST
nodes back into Myc source code. This emitter preserves leading comments
and whitespace, enabling a round-trip guarantee for the parser and
emitter.

The documentation in `docs/BNF.md` has been updated to reflect the new
syntax rules for comments, including single-line comments (`;`) and
documentation comments (`;;`). The rules clarify that comments must be
whole lines and that inline comments are disallowed.

The lexer and AST node structures have been updated to accommodate
`CommentLine` variants, allowing for different types of comments
(regular, documentation, and blank lines) to be stored and processed.

A new test suite `tests/comment_roundtrip.rs` has been added to verify
the round-trip property of the parser and emitter across all example
`.myc` files. This test ensures that parsing source code, emitting it,
and then parsing the emitted code again results in the same output
string.

The `ast.rs` binary now includes a `--emit` flag that uses the new
emitter to output the parsed source code, facilitating debugging and
testing of the emitter itself.
This commit is contained in:
2026-03-25 19:15:40 +01:00
parent ee89d2330d
commit e757b453a4
10 changed files with 501 additions and 32 deletions
+258
View File
@@ -0,0 +1,258 @@
use crate::ast::nodes::{NodeKind, SyntaxNode};
use crate::ast::types::{CommentLine, Value};
use std::rc::Rc;
/// Emits a [`SyntaxNode`] back to Myc source code, preserving leading comments.
///
/// Round-trip guarantee: `emit(parse(emit(parse(source)))) == emit(parse(source))`.
/// In other words, the emitter is idempotent after the first pass.
pub fn emit(node: &SyntaxNode) -> String {
let mut out = String::new();
// The top-level node is always at block level (owns its own line).
emit_block(node, 0, &mut out);
out
}
// ── Block-level emission ────────────────────────────────────────────────────
/// Emits a node in **block-level** context: writes leading comments and
/// the appropriate indentation prefix, then the code.
///
/// Callers must only write a `\n` separator before calling this — never `\n + pad`.
fn emit_block(node: &SyntaxNode, indent: usize, out: &mut String) {
emit_comments(&node.comments, indent, out);
out.push_str(&" ".repeat(indent));
emit_kind(node, indent, out);
}
/// Emits a node in **inline** context: no indentation, no comments.
/// Used for sub-expressions that are part of a larger expression on the same line.
fn emit_inline(node: &SyntaxNode, indent: usize, out: &mut String) {
emit_kind(node, indent, out);
}
// ── Comment emission ────────────────────────────────────────────────────────
fn emit_comments(comments: &Rc<[CommentLine]>, indent: usize, out: &mut String) {
if comments.is_empty() {
return;
}
let pad = " ".repeat(indent);
for line in comments.iter() {
match line {
CommentLine::Comment(text) => {
out.push_str(&pad);
out.push(';');
if !text.is_empty() {
out.push(' ');
out.push_str(text);
}
out.push('\n');
}
CommentLine::Doc(text) => {
out.push_str(&pad);
out.push_str(";;");
if !text.is_empty() {
out.push(' ');
out.push_str(text);
}
out.push('\n');
}
CommentLine::Blank => {
out.push('\n');
}
}
}
}
// ── Kind emission ───────────────────────────────────────────────────────────
fn emit_kind(node: &SyntaxNode, indent: usize, out: &mut String) {
match &node.kind {
NodeKind::Nop => out.push_str("..."),
NodeKind::Constant(val) => emit_value(val, out),
NodeKind::Identifier { symbol, .. } => out.push_str(&symbol.name),
NodeKind::FieldAccessor(k) => {
out.push('.');
out.push_str(&k.name());
}
NodeKind::If { cond, then_br, else_br } => {
out.push_str("(if ");
emit_inline(cond, indent + 1, out);
out.push('\n');
emit_block(then_br, indent + 1, out);
if let Some(else_node) = else_br {
out.push('\n');
emit_block(else_node, indent + 1, out);
}
out.push(')');
}
NodeKind::Def { pattern, value, .. } => {
out.push_str("(def ");
emit_inline(pattern, indent, out);
out.push('\n');
emit_block(value, indent + 1, out);
out.push(')');
}
NodeKind::Assign { target, value, .. } => {
out.push_str("(assign ");
emit_inline(target, indent, out);
out.push(' ');
emit_inline(value, indent + 1, out);
out.push(')');
}
NodeKind::Lambda { params, body, .. } => {
out.push_str("(fn ");
emit_inline(params, indent, out);
out.push('\n');
emit_block(body, indent + 1, out);
out.push(')');
}
NodeKind::Call { callee, args } => {
out.push('(');
emit_inline(callee, indent, out);
if let NodeKind::Tuple { elements } = &args.kind {
for arg in elements {
out.push(' ');
emit_inline(arg, indent + 1, out);
}
}
out.push(')');
}
NodeKind::Again { args } => {
out.push_str("(again");
if let NodeKind::Tuple { elements } = &args.kind {
for arg in elements {
out.push(' ');
emit_inline(arg, indent + 1, out);
}
}
out.push(')');
}
NodeKind::Block { exprs } => {
out.push_str("(do");
for expr in exprs {
out.push('\n');
emit_block(expr, indent + 1, out);
}
out.push(')');
}
NodeKind::Tuple { elements } => {
out.push('[');
for (i, el) in elements.iter().enumerate() {
if i > 0 {
out.push(' ');
}
emit_inline(el, indent, out);
}
out.push(']');
}
NodeKind::Record { fields, .. } => {
if fields.is_empty() {
out.push_str("{}");
return;
}
// Always multi-line so that any leading comments on keys are valid.
out.push('{');
for (key, val) in fields.iter() {
out.push('\n');
emit_block(key, indent + 1, out);
out.push(' ');
emit_inline(val, indent + 1, out);
}
out.push('\n');
out.push_str(&" ".repeat(indent));
out.push('}');
}
NodeKind::MacroDecl { name, params, body } => {
out.push_str("(macro ");
out.push_str(&name.name);
out.push(' ');
emit_inline(params, indent, out);
out.push('\n');
emit_block(body, indent + 1, out);
out.push(')');
}
NodeKind::Template(inner) => {
out.push('`');
emit_inline(inner, indent, out);
}
NodeKind::Placeholder(inner) => {
out.push('~');
emit_inline(inner, indent, out);
}
NodeKind::Splice(inner) => {
out.push_str("~@");
emit_inline(inner, indent, out);
}
NodeKind::GetField { rec, field } => {
out.push_str("(.");
out.push_str(&field.name());
out.push(' ');
emit_inline(rec, indent, out);
out.push(')');
}
NodeKind::Expansion { original_call, .. } => {
// Round-trip uses the original pre-expansion form.
emit_inline(original_call, indent, out);
}
NodeKind::Error | NodeKind::Extension(_) => {
out.push_str("<error>");
}
}
}
fn emit_value(val: &Value, out: &mut String) {
match val {
Value::Int(n) => out.push_str(&n.to_string()),
Value::Float(f) => {
let s = format!("{}", f);
// Ensure a decimal point is present so it round-trips as a float.
if s.contains('.') || s.contains('e') {
out.push_str(&s);
} else {
out.push_str(&s);
out.push_str(".0");
}
}
Value::Text(t) => {
out.push('"');
for c in t.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out.push('"');
}
Value::Keyword(k) => {
out.push(':');
out.push_str(&k.name());
}
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
other => out.push_str(&other.to_string()),
}
}
+1 -1
View File
@@ -624,7 +624,7 @@ mod tests {
use crate::ast::diagnostics::Diagnostics;
use crate::ast::nodes::{Address, VirtualId};
use crate::ast::parser::Parser;
use crate::ast::types::{NodeIdentity, Object, Purity, SourceLocation, StaticType, Value};
use crate::ast::types::{NodeIdentity, Purity, SourceLocation, StaticType, Value};
struct SimpleEvaluator;
impl MacroEvaluator for SimpleEvaluator {
+1
View File
@@ -2,6 +2,7 @@ pub mod analyzer;
pub mod binder;
pub mod captures;
pub mod dumper;
pub mod emitter;
pub mod lambda_collector;
pub mod macros;
pub mod optimizer;