diff --git a/Cargo.lock b/Cargo.lock index 4da953b..79931a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,12 @@ dependencies = [ "yansi", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "backtrace" version = "0.3.76" @@ -132,6 +138,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.1" @@ -167,7 +188,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -432,7 +453,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -737,6 +758,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -794,6 +824,7 @@ dependencies = [ "hashbrown 0.17.1", "lexer", "memchr", + "proptest", "smallvec", "thiserror", "tracing", @@ -826,6 +857,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -855,6 +895,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -876,6 +941,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.1" @@ -884,7 +959,26 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -893,6 +987,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "regex" version = "1.12.3" @@ -956,6 +1059,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -1229,6 +1344,12 @@ dependencies = [ "rustc-hash", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unic-langid" version = "0.9.6" @@ -1333,7 +1454,7 @@ dependencies = [ "libc", "nix", "pretty_assertions", - "rand", + "rand 0.10.1", "regex", "rlimit", "tempfile", @@ -1353,6 +1474,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" diff --git a/parser/Cargo.toml b/parser/Cargo.toml index 958e0f0..1c95a46 100644 --- a/parser/Cargo.toml +++ b/parser/Cargo.toml @@ -17,5 +17,8 @@ allocator-api2.workspace = true ahash.workspace = true ariadne = "0.6.0" +[dev-dependencies] +proptest = "1.6.0" + [lints] workspace = true diff --git a/parser/src/lib.rs b/parser/src/lib.rs index f4b27b6..cff08eb 100644 --- a/parser/src/lib.rs +++ b/parser/src/lib.rs @@ -8,8 +8,12 @@ mod diagnostics; mod idempotency; mod lex; mod pratt; +#[cfg(test)] +mod prop_tests; mod sexpr; #[cfg(test)] +mod testing; +#[cfg(test)] mod tests; use std::{fmt::Debug, mem::replace}; @@ -738,7 +742,7 @@ impl<'a> Parser<'a> { } impl<'a> Ast<'a> { - fn new(arena: &'a Bump) -> Self { + pub(crate) fn new(arena: &'a Bump) -> Self { Self { loads: Vec::new_in(arena), begin: Vec::new_in(arena), diff --git a/parser/src/prop_tests.rs b/parser/src/prop_tests.rs new file mode 100644 index 0000000..6701db1 --- /dev/null +++ b/parser/src/prop_tests.rs @@ -0,0 +1,107 @@ +// This file is part of the uutils awk package. +// +// For the full copyright and license information, please view the LICENSE +// files that was distributed with this source code. + +use std::fmt::Write; + +use bumpalo::Bump; +use proptest::prelude::*; + +use crate::testing::{ + ast_gen::{self, GenAtom, GenBody, GenExpr, GenPattern, GenProgram, GenRule, GenStatement}, + roundtrip_ast, roundtrip_source, +}; + +proptest! { + #![proptest_config(ProptestConfig { + cases: 128, + .. ProptestConfig::default() + })] + + #[test] + fn roundtrip_generated_program(program in ast_gen::gen_program()) { + let arena = Bump::new(); + let ast = ast_gen::materialize(&program, &arena); + roundtrip_ast(&ast).map_err(|e| TestCaseError::fail(e))?; + } + + #[test] + fn roundtrip_generated_source(program in ast_gen::gen_program()) { + let arena = Bump::new(); + let ast = ast_gen::materialize(&program, &arena); + let mut source = String::new(); + write!(source, "{ast}").map_err(|e| TestCaseError::fail(format!("display failed: {e}")))?; + roundtrip_source(&source).map_err(|e| TestCaseError::fail(e))?; + } +} + +#[test] +fn roundtrip_smoke_cases() { + let cases = [ + "{ print 1 + a }", + "BEGIN { print 1 }", + "{ if (a) print; else print 0 }", + "{ while (a < 10) a++ }", + "{ a = 1; b = a + 2 }", + "/pat/ { print $1, $2 }", + "BEGIN { print \"hi\" }", + ]; + for source in cases { + roundtrip_source(source) + .unwrap_or_else(|e| panic!("round-trip failed for {source:?}: {e}")); + } +} + +#[test] +fn roundtrip_handwritten_ast() { + let program = GenProgram { + rules: vec![ + GenRule { + pattern: Some(GenPattern::Regex(0)), + body: GenBody { + statements: vec![GenStatement::Print(vec![GenExpr::Atom(GenAtom::Var(0))])], + }, + }, + GenRule { + pattern: None, + body: GenBody { + statements: vec![ + GenStatement::If { + condition: GenExpr::Binary( + crate::ast::BinaryOperator::Lt, + Box::new(GenExpr::Atom(GenAtom::Var(0))), + Box::new(GenExpr::Atom(GenAtom::SmallInt(10))), + ), + then_body: GenBody { + statements: vec![GenStatement::Print(vec![GenExpr::Atom( + GenAtom::SmallInt(1), + )])], + }, + else_body: None, + }, + GenStatement::Print(vec![GenExpr::Binary( + crate::ast::BinaryOperator::Add, + Box::new(GenExpr::Atom(GenAtom::SmallInt(1))), + Box::new(GenExpr::Atom(GenAtom::Var(0))), + )]), + GenStatement::Expr(GenExpr::Binary( + crate::ast::BinaryOperator::Eq, + Box::new(GenExpr::Atom(GenAtom::SmallInt(0))), + Box::new(GenExpr::Atom(GenAtom::SmallInt(1))), + )), + ], + }, + }, + ], + begin: Some(GenBody { + statements: vec![GenStatement::Print(vec![GenExpr::Atom(GenAtom::SmallInt( + 0, + ))])], + }), + }; + + let arena = Bump::new(); + let ast = ast_gen::materialize(&program, &arena); + roundtrip_ast(&ast).expect("hand-written AST should round-trip"); +} diff --git a/parser/src/testing/ast_gen.rs b/parser/src/testing/ast_gen.rs new file mode 100644 index 0000000..28ef146 --- /dev/null +++ b/parser/src/testing/ast_gen.rs @@ -0,0 +1,316 @@ +// This file is part of the uutils awk package. +// +// For the full copyright and license information, please view the LICENSE +// files that was distributed with this source code. + +use std::vec::Vec; + +use bumpalo::{Bump, collections::CollectIn}; +use proptest::prelude::*; + +use crate::{ + Ast, Body, Place, Rule, + ast::{ + Atom, BinaryOperator, BinaryPlaceOperator, Command, Expr, Identifier, RulePattern, + SimpleStatement, Statement, UnaryOperator, Variable, + }, +}; + +/// Owned, lifetime-free description of a program fragment for property testing. +#[derive(Clone, Debug)] +pub struct GenProgram { + pub rules: Vec, + pub begin: Option, +} + +#[derive(Clone, Debug)] +pub struct GenRule { + pub pattern: Option, + pub body: GenBody, +} + +#[derive(Clone, Debug)] +pub enum GenPattern { + Expr(GenExpr), + Regex(u8), +} + +#[derive(Clone, Debug)] +pub struct GenBody { + pub statements: Vec, +} + +#[derive(Clone, Debug)] +pub enum GenStatement { + Print(Vec), + Expr(GenExpr), + If { condition: GenExpr, then_body: GenBody, else_body: Option }, + While { condition: GenExpr, body: GenBody }, +} + +#[derive(Clone, Debug)] +pub enum GenExpr { + Atom(GenAtom), + Unary(UnaryOperator, Box), + Binary(BinaryOperator, Box, Box), + Assign(GenPlace, Box), + Index(u8, Box), +} + +#[derive(Clone, Debug)] +pub enum GenAtom { + SmallInt(i8), + Var(u8), + String(u8), +} + +#[derive(Clone, Debug)] +pub enum GenPlace { + Var(u8), +} + +pub fn gen_program() -> impl Strategy { + ( + prop::option::of(gen_body()), + prop::collection::vec(gen_rule(), 1..=3), + ) + .prop_map(|(begin, rules)| GenProgram { rules, begin }) +} + +fn gen_rule() -> impl Strategy { + (prop::option::of(gen_pattern()), gen_body()) + .prop_map(|(pattern, body)| GenRule { pattern, body }) +} + +fn gen_pattern() -> impl Strategy { + prop_oneof![ + 2 => gen_expr().prop_map(GenPattern::Expr), + 1 => (0u8..4).prop_map(GenPattern::Regex), + ] +} + +fn gen_body() -> impl Strategy { + prop::collection::vec(gen_statement(), 1..=4).prop_map(|statements| GenBody { statements }) +} + +fn gen_simple_body() -> impl Strategy { + prop::collection::vec(gen_simple_statement(), 1..=3) + .prop_map(|statements| GenBody { statements }) +} + +fn gen_simple_statement() -> impl Strategy { + prop_oneof![ + 2 => prop::collection::vec(gen_leaf(), 0..=3).prop_map(GenStatement::Print), + 1 => gen_expr().prop_map(GenStatement::Expr), + ] +} + +fn gen_statement() -> impl Strategy { + prop_oneof![ + 3 => prop::collection::vec(gen_leaf(), 0..=3).prop_map(GenStatement::Print), + 2 => gen_expr().prop_map(GenStatement::Expr), + 1 => ( + gen_expr(), + gen_simple_body(), + prop::option::of(gen_simple_body()), + ) + .prop_map(|(condition, then_body, else_body)| GenStatement::If { + condition, + then_body, + else_body, + }), + 1 => (gen_expr(), gen_simple_body()) + .prop_map(|(condition, body)| GenStatement::While { condition, body }), + ] +} + +fn gen_leaf() -> impl Strategy { + prop_oneof![ + (0i8..=127) + .prop_map(GenAtom::SmallInt) + .prop_map(GenExpr::Atom), + (0u8..4).prop_map(GenAtom::Var).prop_map(GenExpr::Atom), + (0u8..3).prop_map(GenAtom::String).prop_map(GenExpr::Atom), + ] +} + +fn gen_arith_binary_operator() -> impl Strategy { + prop_oneof![ + Just(BinaryOperator::Multiply), + Just(BinaryOperator::Add), + Just(BinaryOperator::Subtract), + Just(BinaryOperator::And), + Just(BinaryOperator::Or), + ] +} + +fn gen_cmp_binary_operator() -> impl Strategy { + prop_oneof![ + Just(BinaryOperator::Eq), + Just(BinaryOperator::NEq), + Just(BinaryOperator::Lt), + Just(BinaryOperator::Gt), + Just(BinaryOperator::LtE), + Just(BinaryOperator::GtE), + ] +} + +fn gen_arith_expr() -> impl Strategy { + gen_leaf().prop_recursive(3, 32, 8, |inner| { + prop_oneof![ + 2 => inner + .clone() + .prop_map(|e| GenExpr::Unary(UnaryOperator::Negation, Box::new(e))), + 2 => (gen_arith_binary_operator(), inner.clone(), inner.clone()) + .prop_map(|(op, a, b)| GenExpr::Binary(op, Box::new(a), Box::new(b))), + 1 => (gen_place(), inner.clone()) + .prop_map(|(place, rhs)| GenExpr::Assign(place, Box::new(rhs))), + 1 => (0u8..4, inner.clone()) + .prop_map(|(var, idx)| GenExpr::Index(var, Box::new(idx))), + ] + }) +} + +fn gen_expr() -> impl Strategy { + prop_oneof![ + 4 => gen_arith_expr(), + 1 => ( + gen_cmp_binary_operator(), + gen_arith_expr(), + gen_arith_expr(), + ) + .prop_map(|(op, a, b)| GenExpr::Binary(op, Box::new(a), Box::new(b))), + ] +} + +fn gen_place() -> impl Strategy { + (0u8..4).prop_map(GenPlace::Var) +} + +pub fn materialize<'a>(program: &GenProgram, arena: &'a Bump) -> Ast<'a> { + let mut ast = Ast::new(arena); + if let Some(body) = &program.begin { + ast.begin.push(materialize_body(body, arena)); + } + for rule in &program.rules { + ast.rules.push(materialize_rule(rule, arena)); + } + ast +} + +fn materialize_rule<'a>(rule: &GenRule, arena: &'a Bump) -> Rule<'a> { + Rule { + pattern: rule + .pattern + .as_ref() + .map(|pat| materialize_pattern(pat, arena)), + actions: Some(materialize_body(&rule.body, arena)), + } +} + +fn materialize_pattern<'a>(pattern: &GenPattern, arena: &'a Bump) -> RulePattern<'a> { + match pattern { + GenPattern::Expr(expr) => RulePattern::Expression(materialize_expr(expr, arena)), + GenPattern::Regex(n) => { + RulePattern::Expression(Expr::leaf(Atom::Regex(regex_slice(arena, *n)))) + } + } +} + +fn materialize_body<'a>(body: &GenBody, arena: &'a Bump) -> Body<'a> { + Body( + body.statements + .iter() + .map(|s| materialize_statement(s, arena)) + .collect_in(arena), + ) +} + +fn materialize_statement<'a>(stmnt: &GenStatement, arena: &'a Bump) -> Statement<'a> { + match stmnt { + GenStatement::Print(args) => Statement::Simple(SimpleStatement::Command { + name: Command::Print, + args: args + .iter() + .map(|e| materialize_expr(e, arena)) + .collect_in(arena), + redirection: None, + }), + GenStatement::Expr(expr) => { + Statement::Simple(SimpleStatement::Expression(materialize_expr(expr, arena))) + } + GenStatement::If { condition, then_body, else_body } => Statement::If { + condition: materialize_expr(condition, arena), + then_body: materialize_body(then_body, arena), + else_body: else_body.as_ref().map(|body| materialize_body(body, arena)), + }, + GenStatement::While { condition, body } => Statement::While { + condition: materialize_expr(condition, arena), + then_body: materialize_body(body, arena), + }, + } +} + +fn materialize_expr<'a>(expr: &GenExpr, arena: &'a Bump) -> Expr<'a> { + match expr { + GenExpr::Atom(atom) => Expr::leaf(materialize_atom(atom, arena)), + GenExpr::Unary(op, inner) => Expr::node(op.expr(materialize_expr(inner, arena)), arena), + GenExpr::Binary(op, a, b) => Expr::node( + op.expr(materialize_expr(a, arena), materialize_expr(b, arena)), + arena, + ), + GenExpr::Assign(place, rhs) => Expr::node( + BinaryPlaceOperator::Assignment.expr( + materialize_place(place, arena), + materialize_expr(rhs, arena), + ), + arena, + ), + GenExpr::Index(var, idx) => Expr::node( + crate::ast::ArrayOperator::Index.expr( + materialize_var(*var, arena), + bumpalo::vec![in arena; materialize_expr(idx, arena)], + ), + arena, + ), + } +} + +fn materialize_place<'a>(place: &GenPlace, arena: &'a Bump) -> Place<'a> { + match place { + GenPlace::Var(v) => Place::Variable(materialize_var(*v, arena)), + } +} + +fn materialize_atom<'a>(atom: &GenAtom, arena: &'a Bump) -> Atom<'a> { + match atom { + GenAtom::SmallInt(n) => Atom::Integer(i32::from(*n)), + GenAtom::Var(v) => Atom::Variable(materialize_var(*v, arena)), + GenAtom::String(n) => Atom::String(text_slice(arena, &format!("s{n}"))), + } +} + +fn materialize_var(index: u8, arena: &Bump) -> Variable<'_> { + Variable::User(ident(arena, index)) +} + +fn ident(arena: &Bump, index: u8) -> Identifier<'_> { + let literal = match index % 4 { + 0 => "a", + 1 => "b", + 2 => "c", + _ => "d", + }; + Identifier { + namespace: "awk", + literal: arena.alloc_str(literal), + } +} + +fn text_slice<'a>(arena: &'a Bump, content: &str) -> lexer::Slice<'a> { + arena.alloc_str(content).as_bytes().into() +} + +fn regex_slice<'a>(arena: &'a Bump, index: u8) -> lexer::Slice<'a> { + text_slice(arena, &format!("p{index}")) +} diff --git a/parser/src/testing/mod.rs b/parser/src/testing/mod.rs new file mode 100644 index 0000000..f998056 --- /dev/null +++ b/parser/src/testing/mod.rs @@ -0,0 +1,75 @@ +// This file is part of the uutils awk package. +// +// For the full copyright and license information, please view the LICENSE +// files that was distributed with this source code. + +pub mod ast_gen; + +use std::fmt::Write; + +use bumpalo::Bump; + +use crate::{Ast, Lexer, Parser, Result}; + +/// Canonical `Debug` fingerprint used to compare ASTs across round-trips. +pub fn ast_signature(ast: &Ast<'_>) -> String { + let mut out = String::new(); + for load in &ast.loads { + let _ = writeln!(out, "load:{load:?}"); + } + for body in &ast.begin { + let _ = writeln!(out, "begin:{body:?}"); + } + for body in &ast.begin_file { + let _ = writeln!(out, "begin_file:{body:?}"); + } + for rule in &ast.rules { + let pattern = rule.pattern.as_ref().map(|p| format!("{p:?}")); + let actions = rule.actions.as_ref().map(|a| format!("{a:?}")); + let _ = writeln!(out, "rule:({pattern:?},{actions:?})"); + } + for rule in &ast.concurrent { + let pattern = rule.pattern.as_ref().map(|p| format!("{p:?}")); + let actions = rule.actions.as_ref().map(|a| format!("{a:?}")); + let _ = writeln!(out, "concurrent:({pattern:?},{actions:?})"); + } + for body in &ast.end_file { + let _ = writeln!(out, "end_file:{body:?}"); + } + for body in &ast.end { + let _ = writeln!(out, "end:{body:?}"); + } + for (name, fun) in ast.functions.iter() { + let _ = writeln!(out, "function:{name:?}:{fun:?}"); + } + out +} + +pub fn parse_top<'a>(source: &'a str, arena: &'a Bump) -> Result<&'a Ast<'a>> { + let parser = arena.alloc(Parser::new(arena)); + parser.parse_top(&mut Lexer::new(source.as_bytes(), arena), true) +} + +/// Pretty-print `ast`, parse the result, and require both signatures to match. +pub fn roundtrip_ast(ast: &Ast<'_>) -> Result<(), String> { + let mut printed = String::new(); + write!(printed, "{ast}").map_err(|e| format!("display failed: {e}"))?; + + let arena = Bump::new(); + let reparsed = parse_top(&printed, &arena).map_err(|e| format!("re-parse failed: {e}"))?; + + let before = ast_signature(ast); + let after = ast_signature(reparsed); + if before != after { + return Err(format!( + "AST mismatch after round-trip\nprinted:\n{printed}\nbefore:\n{before}\nafter:\n{after}" + )); + } + Ok(()) +} + +pub fn roundtrip_source(source: &str) -> Result<(), String> { + let arena = Bump::new(); + let ast = parse_top(source, &arena).map_err(|e| format!("initial parse failed: {e}"))?; + roundtrip_ast(ast) +}