From c0ffee603e85969abfb8f9044d2a0bb9b0ac9a16 Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Mon, 27 Oct 2025 17:07:48 +0100 Subject: [PATCH 01/20] feat: scaffold check rust --- Cargo.lock | 15 + Cargo.toml | 3 +- crates/bulloak/Cargo.toml | 1 + crates/bulloak/src/check.rs | 65 +++++ crates/bulloak/src/scaffold.rs | 48 ++- crates/bulloak/tests/check_rust.rs | 98 +++++++ crates/bulloak/tests/scaffold_rust.rs | 87 ++++++ crates/bulloak/tests/scaffold_rust/basic.tree | 6 + .../bulloak/tests/scaffold_rust/basic_test.rs | 43 +++ .../tests/scaffold_rust/no_helpers.tree | 3 + .../tests/scaffold_rust/no_helpers_test.rs | 23 ++ .../tests/scaffold_rust/with_panic.tree | 5 + .../tests/scaffold_rust/with_panic_test.rs | 38 +++ crates/rust/Cargo.toml | 29 ++ crates/rust/README.md | 7 + crates/rust/src/check/mod.rs | 45 +++ crates/rust/src/check/rules/mod.rs | 5 + .../rust/src/check/rules/structural_match.rs | 148 ++++++++++ crates/rust/src/check/violation.rs | 96 ++++++ crates/rust/src/config.rs | 20 ++ crates/rust/src/constants.rs | 22 ++ crates/rust/src/hir/hir.rs | 147 ++++++++++ crates/rust/src/hir/mod.rs | 11 + crates/rust/src/hir/translator.rs | 273 ++++++++++++++++++ crates/rust/src/hir/visitor.rs | 41 +++ crates/rust/src/lib.rs | 19 ++ crates/rust/src/rust/mod.rs | 5 + crates/rust/src/rust/parser.rs | 178 ++++++++++++ crates/rust/src/scaffold/comment.rs | 37 +++ crates/rust/src/scaffold/emitter.rs | 221 ++++++++++++++ crates/rust/src/scaffold/mod.rs | 27 ++ 31 files changed, 1754 insertions(+), 12 deletions(-) create mode 100644 crates/bulloak/tests/check_rust.rs create mode 100644 crates/bulloak/tests/scaffold_rust.rs create mode 100644 crates/bulloak/tests/scaffold_rust/basic.tree create mode 100644 crates/bulloak/tests/scaffold_rust/basic_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/no_helpers.tree create mode 100644 crates/bulloak/tests/scaffold_rust/no_helpers_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/with_panic.tree create mode 100644 crates/bulloak/tests/scaffold_rust/with_panic_test.rs create mode 100644 crates/rust/Cargo.toml create mode 100644 crates/rust/README.md create mode 100644 crates/rust/src/check/mod.rs create mode 100644 crates/rust/src/check/rules/mod.rs create mode 100644 crates/rust/src/check/rules/structural_match.rs create mode 100644 crates/rust/src/check/violation.rs create mode 100644 crates/rust/src/config.rs create mode 100644 crates/rust/src/constants.rs create mode 100644 crates/rust/src/hir/hir.rs create mode 100644 crates/rust/src/hir/mod.rs create mode 100644 crates/rust/src/hir/translator.rs create mode 100644 crates/rust/src/hir/visitor.rs create mode 100644 crates/rust/src/lib.rs create mode 100644 crates/rust/src/rust/mod.rs create mode 100644 crates/rust/src/rust/parser.rs create mode 100644 crates/rust/src/scaffold/comment.rs create mode 100644 crates/rust/src/scaffold/emitter.rs create mode 100644 crates/rust/src/scaffold/mod.rs diff --git a/Cargo.lock b/Cargo.lock index a3345db..286a65f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,6 +334,7 @@ version = "0.9.1" dependencies = [ "anyhow", "bulloak-foundry", + "bulloak-rust", "bulloak-syntax", "clap", "criterion", @@ -362,6 +363,20 @@ dependencies = [ "thiserror", ] +[[package]] +name = "bulloak-rust" +version = "0.9.1" +dependencies = [ + "anyhow", + "bulloak-syntax", + "indoc", + "pretty_assertions", + "proc-macro2", + "quote", + "syn 2.0.66", + "thiserror", +] + [[package]] name = "bulloak-syntax" version = "0.9.1" diff --git a/Cargo.toml b/Cargo.toml index c95a1be..6628d86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/bulloak", "crates/foundry", "crates/syntax"] +members = ["crates/bulloak", "crates/foundry", "crates/rust", "crates/syntax"] [workspace.package] authors = ["Alexander Gonzalez "] @@ -35,6 +35,7 @@ all = "warn" [workspace.dependencies] bulloak-syntax = { path = "crates/syntax", version = "0.9.0" } bulloak-foundry = { path = "crates/foundry", version = "0.9.0" } +bulloak-rust = { path = "crates/rust", version = "0.9.0" } anyhow = "1.0.75" clap = { version = "4.3.19", features = ["derive"] } diff --git a/crates/bulloak/Cargo.toml b/crates/bulloak/Cargo.toml index 455003b..eef6d06 100644 --- a/crates/bulloak/Cargo.toml +++ b/crates/bulloak/Cargo.toml @@ -15,6 +15,7 @@ categories.workspace = true [dependencies] bulloak-syntax.workspace = true bulloak-foundry.workspace = true +bulloak-rust.workspace = true anyhow.workspace = true clap.workspace = true diff --git a/crates/bulloak/src/check.rs b/crates/bulloak/src/check.rs index cc2bef8..d3f0ec1 100644 --- a/crates/bulloak/src/check.rs +++ b/crates/bulloak/src/check.rs @@ -41,6 +41,9 @@ pub struct Check { /// Whether to capitalize and punctuate branch descriptions. #[arg(long = "format-descriptions", default_value_t = false)] pub format_descriptions: bool, + /// Check Rust tests instead of Solidity tests. + #[arg(short = 'r', long = "rust", default_value_t = false)] + pub rust: bool, } impl Default for Check { @@ -54,6 +57,10 @@ impl Check { /// /// Note that we don't deal with `solang_parser` errors at all. pub(crate) fn run(&self, cfg: &Cli) { + if self.rust { + return self.run_rust_check(); + } + let mut specs = Vec::new(); for pattern in &self.files { match expand_glob(pattern.clone()) { @@ -163,6 +170,64 @@ impl Check { eprintln!("{}: {e}", "warn".yellow()); } } + + /// Run check for Rust tests. + fn run_rust_check(&self) { + let mut specs = Vec::new(); + for pattern in &self.files { + match expand_glob(pattern.clone()) { + Ok(iter) => specs.extend(iter), + Err(e) => eprintln!( + "{}: could not expand {}: {}", + "warn".yellow(), + pattern.display(), + e + ), + } + } + + let rust_cfg = bulloak_rust::Config { + files: self.files.iter().map(|p| p.display().to_string()).collect(), + skip_helpers: self.skip_modifiers, + format_descriptions: self.format_descriptions, + }; + + let mut all_violations = Vec::new(); + for tree_path in specs { + match bulloak_rust::check::check(&tree_path, &rust_cfg) { + Ok(violations) => { + for violation in &violations { + eprintln!("{}", violation); + } + all_violations.extend(violations); + } + Err(e) => { + eprintln!( + "{}: Failed to check {}: {}", + "error".red(), + tree_path.display(), + e + ); + } + } + } + + if all_violations.is_empty() { + println!( + "{}", + "All checks completed successfully! No issues found.".green() + ); + } else { + let check_literal = pluralize(all_violations.len(), "check", "checks"); + eprintln!( + "\n{}: {} {} failed", + "warn".bold().yellow(), + all_violations.len(), + check_literal + ); + std::process::exit(1); + } + } } fn exit(violations: &[Violation]) { diff --git a/crates/bulloak/src/scaffold.rs b/crates/bulloak/src/scaffold.rs index dad34e8..b6c7864 100644 --- a/crates/bulloak/src/scaffold.rs +++ b/crates/bulloak/src/scaffold.rs @@ -53,6 +53,9 @@ pub struct Scaffold { /// Whether to capitalize and punctuate branch descriptions. #[arg(short = 'F', long = "format-descriptions", default_value_t = false)] pub format_descriptions: bool, + /// Generate Rust tests instead of Solidity tests. + #[arg(short = 'r', long = "rust", default_value_t = false)] + pub rust: bool, } impl Default for Scaffold { @@ -101,21 +104,44 @@ impl Scaffold { /// Processes a single input file. /// - /// This method reads the input file, scaffolds the Solidity code, formats + /// This method reads the input file, scaffolds the Solidity/Rust code, formats /// it, and either writes it to a file or prints it to stdout. fn process_file(&self, file: &Path, cfg: &Cli) -> anyhow::Result<()> { let text = fs::read_to_string(file)?; - let emitted = scaffold(&text, &cfg.into())?; - let formatted = fmt(&emitted).unwrap_or_else(|err| { - eprintln!("{}: {}", "WARN".yellow(), err); - emitted - }); - - if self.write_files { - let file = file.with_extension("t.sol"); - self.write_file(&formatted, &file); + + if self.rust { + // Rust backend + let ast = bulloak_syntax::parse_one(&text)?; + let rust_cfg = bulloak_rust::Config { + files: self.files.iter().map(|p| p.display().to_string()).collect(), + skip_helpers: self.skip_modifiers, + format_descriptions: self.format_descriptions, + }; + let emitted = bulloak_rust::scaffold(&ast, &rust_cfg)?; + + if self.write_files { + let output_file = file.with_file_name(format!( + "{}_test.rs", + file.file_stem().unwrap().to_str().unwrap() + )); + self.write_file(&emitted, &output_file); + } else { + println!("{emitted}"); + } } else { - println!("{formatted}"); + // Solidity backend (original) + let emitted = scaffold(&text, &cfg.into())?; + let formatted = fmt(&emitted).unwrap_or_else(|err| { + eprintln!("{}: {}", "WARN".yellow(), err); + emitted + }); + + if self.write_files { + let file = file.with_extension("t.sol"); + self.write_file(&formatted, &file); + } else { + println!("{formatted}"); + } } Ok(()) diff --git a/crates/bulloak/tests/check_rust.rs b/crates/bulloak/tests/check_rust.rs new file mode 100644 index 0000000..3bbeb10 --- /dev/null +++ b/crates/bulloak/tests/check_rust.rs @@ -0,0 +1,98 @@ +#![allow(missing_docs)] +use std::{env, fs}; + +use common::{cmd, get_binary_path}; + +mod common; + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_rust_passes_when_correct() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("scaffold_rust"); + let tree_name = "basic.tree"; + + let tree_path = tests_path.join(tree_name); + let output = cmd(&binary_path, "check", &tree_path, &["--rust"]); + + // Should pass with no violations + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("All checks completed successfully")); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_rust_fails_when_missing_file() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("scaffold_rust"); + + // Create a temporary tree file without corresponding test file + let temp_tree = tests_path.join("temp_missing.tree"); + fs::write( + &temp_tree, + "test_func\n└── It should work.", + ) + .unwrap(); + + let output = cmd(&binary_path, "check", &temp_tree, &["--rust"]); + + // Should fail + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Rust test file is missing")); + + // Clean up + fs::remove_file(temp_tree).ok(); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_rust_fails_when_missing_test_function() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("scaffold_rust"); + + // Create a tree file + let temp_tree = tests_path.join("temp_incomplete.tree"); + fs::write( + &temp_tree, + "test_func\n├── It should work.\n└── It should also work differently.", + ) + .unwrap(); + + // Create an incomplete test file (missing one test) + let temp_test = tests_path.join("temp_incomplete_test.rs"); + fs::write( + &temp_test, + r#" +#[derive(Default)] +struct TestContext {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_should_work() { + // It should work. + } + // Missing: test_should_also_work_differently +} +"#, + ) + .unwrap(); + + let output = cmd(&binary_path, "check", &temp_tree, &["--rust"]); + + // Should fail + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Test function") && stderr.contains("is missing")); + + // Clean up + fs::remove_file(temp_tree).ok(); + fs::remove_file(temp_test).ok(); +} diff --git a/crates/bulloak/tests/scaffold_rust.rs b/crates/bulloak/tests/scaffold_rust.rs new file mode 100644 index 0000000..83dd33b --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust.rs @@ -0,0 +1,87 @@ +#![allow(missing_docs)] +use std::{env, fs}; + +use common::{cmd, get_binary_path}; +use pretty_assertions::assert_eq; + +mod common; + +#[cfg(not(target_os = "windows"))] +#[test] +fn scaffolds_rust_trees() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("scaffold_rust"); + let trees = ["basic.tree", "with_panic.tree", "no_helpers.tree"]; + + for tree_name in trees { + let tree_path = tests_path.join(tree_name); + let output = cmd(&binary_path, "scaffold", &tree_path, &["--rust"]); + let actual = String::from_utf8(output.stdout).unwrap(); + + let mut output_file = tree_path.clone(); + output_file.set_extension(""); + let mut output_file_str = output_file.into_os_string(); + output_file_str.push("_test.rs"); + let output_file: std::path::PathBuf = output_file_str.into(); + + let expected = fs::read_to_string(&output_file).unwrap_or_else(|_| { + panic!( + "Failed to read expected output file: {}", + output_file.display() + ) + }); + + // We trim here because we don't care about ending newlines. + assert_eq!( + expected.trim(), + actual.trim(), + "Mismatch for {}", + tree_name + ); + } +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn scaffolds_rust_trees_skip_helpers() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("scaffold_rust"); + let tree_name = "basic.tree"; + + let tree_path = tests_path.join(tree_name); + let output = cmd(&binary_path, "scaffold", &tree_path, &["--rust", "-m"]); + let actual = String::from_utf8(output.stdout).unwrap(); + + // Should not contain helper functions + assert!(!actual.contains("fn first_arg_is_smaller_than_second_arg")); + assert!(!actual.contains("fn first_arg_is_bigger_than_second_arg")); + + // Should still contain test module and context + assert!(actual.contains("#[cfg(test)]")); + assert!(actual.contains("mod tests")); + assert!(actual.contains("struct TestContext")); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn scaffolds_rust_trees_format_descriptions() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("scaffold_rust"); + let tree_name = "basic.tree"; + + let tree_path = tests_path.join(tree_name); + let output = cmd( + &binary_path, + "scaffold", + &tree_path, + &["--rust", "--format-descriptions"], + ); + let actual = String::from_utf8(output.stdout).unwrap(); + + // Comments should be capitalized and have periods + assert!(actual.contains("// It should match the result of hash(a, b).")); + assert!(actual.contains("// It should match the result of hash(b, a).")); +} diff --git a/crates/bulloak/tests/scaffold_rust/basic.tree b/crates/bulloak/tests/scaffold_rust/basic.tree new file mode 100644 index 0000000..1f07838 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/basic.tree @@ -0,0 +1,6 @@ +hash_pair +├── It should never panic. +├── When first arg is smaller than second arg +│ └── It should match the result of hash(a, b). +└── When first arg is bigger than second arg + └── It should match the result of hash(b, a). diff --git a/crates/bulloak/tests/scaffold_rust/basic_test.rs b/crates/bulloak/tests/scaffold_rust/basic_test.rs new file mode 100644 index 0000000..0b48059 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/basic_test.rs @@ -0,0 +1,43 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext { + // Add fields as needed +} + +/// Helper: When first arg is smaller than second arg +fn first_arg_is_smaller_than_second_arg(mut ctx: TestContext) -> TestContext { + // TODO: Set up condition + ctx +} + +/// Helper: When first arg is bigger than second arg +fn first_arg_is_bigger_than_second_arg(mut ctx: TestContext) -> TestContext { + // TODO: Set up condition + ctx +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[should_panic] + fn test_should_never_panic() { + // It should never panic. + } + + #[test] + fn test_first_arg_is_smaller_than_second_arg_should_match_the_result_of_hashab() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + // It should match the result of hash(a, b). + } + + #[test] + fn test_first_arg_is_bigger_than_second_arg_should_match_the_result_of_hashba() { + let _ctx = first_arg_is_bigger_than_second_arg(TestContext::default()); + // It should match the result of hash(b, a). + } + +} diff --git a/crates/bulloak/tests/scaffold_rust/no_helpers.tree b/crates/bulloak/tests/scaffold_rust/no_helpers.tree new file mode 100644 index 0000000..8f47c03 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/no_helpers.tree @@ -0,0 +1,3 @@ +simple_function +├── It should return true for valid input. +└── It should return false for invalid input. diff --git a/crates/bulloak/tests/scaffold_rust/no_helpers_test.rs b/crates/bulloak/tests/scaffold_rust/no_helpers_test.rs new file mode 100644 index 0000000..f52b85f --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/no_helpers_test.rs @@ -0,0 +1,23 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext { + // Add fields as needed +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_should_return_true_for_valid_input() { + // It should return true for valid input. + } + + #[test] + fn test_should_return_false_for_invalid_input() { + // It should return false for invalid input. + } + +} diff --git a/crates/bulloak/tests/scaffold_rust/with_panic.tree b/crates/bulloak/tests/scaffold_rust/with_panic.tree new file mode 100644 index 0000000..3a31f3c --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/with_panic.tree @@ -0,0 +1,5 @@ +divide +├── When divisor is zero +│ └── It should panic with division by zero. +└── When divisor is non-zero + └── It should return the quotient. diff --git a/crates/bulloak/tests/scaffold_rust/with_panic_test.rs b/crates/bulloak/tests/scaffold_rust/with_panic_test.rs new file mode 100644 index 0000000..7618681 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/with_panic_test.rs @@ -0,0 +1,38 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext { + // Add fields as needed +} + +/// Helper: When divisor is zero +fn divisor_is_zero(mut ctx: TestContext) -> TestContext { + // TODO: Set up condition + ctx +} + +/// Helper: When divisor is non_zero +fn divisor_is_nonzero(mut ctx: TestContext) -> TestContext { + // TODO: Set up condition + ctx +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[should_panic] + fn test_divisor_is_zero_should_panic_with_division_by_zero() { + let _ctx = divisor_is_zero(TestContext::default()); + // It should panic with division by zero. + } + + #[test] + fn test_divisor_is_nonzero_should_return_the_quotient() { + let _ctx = divisor_is_nonzero(TestContext::default()); + // It should return the quotient. + } + +} diff --git a/crates/rust/Cargo.toml b/crates/rust/Cargo.toml new file mode 100644 index 0000000..09c1d2c --- /dev/null +++ b/crates/rust/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "bulloak-rust" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +readme = "./README.md" +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +description.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +bulloak-syntax.workspace = true + +anyhow.workspace = true +thiserror.workspace = true +syn = { version = "2.0", features = ["full", "parsing", "visit"] } +quote = "1.0" +proc-macro2 = "1.0" + +[dev-dependencies] +pretty_assertions.workspace = true +indoc = "2.0.5" + +[lints] +workspace = true diff --git a/crates/rust/README.md b/crates/rust/README.md new file mode 100644 index 0000000..c25cd24 --- /dev/null +++ b/crates/rust/README.md @@ -0,0 +1,7 @@ +# bulloak-rust + +A backend for `bulloak` that generates Rust test files. + +This crate provides an implementation of turning a `bulloak-syntax` AST into a `_test.rs` file containing scaffolded Rust tests based on the Branching Tree Technique. + +It also includes validation functionality to check that Rust test files correspond to their `.tree` specifications. diff --git a/crates/rust/src/check/mod.rs b/crates/rust/src/check/mod.rs new file mode 100644 index 0000000..6344233 --- /dev/null +++ b/crates/rust/src/check/mod.rs @@ -0,0 +1,45 @@ +//! Check module for validating Rust test files against specs. + +pub mod rules; +pub mod violation; + +pub use violation::{Violation, ViolationKind}; + +use crate::config::Config; +use anyhow::{Context, Result}; +use std::path::Path; + +/// Check that a Rust test file matches its tree specification. +/// +/// # Errors +/// +/// Returns an error if checking fails. +pub fn check(tree_path: &Path, cfg: &Config) -> Result> { + // Read tree file + let tree_source = std::fs::read_to_string(tree_path) + .with_context(|| format!("Failed to read tree file: {}", tree_path.display()))?; + + // Parse tree + let ast = bulloak_syntax::parse_one(&tree_source)?; + + // Determine Rust file path (replace .tree with _test.rs) + let file_stem = tree_path.file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("Invalid file name"))?; + let rust_path = tree_path.with_file_name(format!("{}_test.rs", file_stem)); + + // Check if Rust file exists + if !rust_path.exists() { + return Ok(vec![Violation::new( + ViolationKind::RustFileMissing, + rust_path.display().to_string(), + )]); + } + + // Read Rust file + let rust_source = std::fs::read_to_string(&rust_path) + .with_context(|| format!("Failed to read Rust file: {}", rust_path.display()))?; + + // Run structural match rule + rules::check_structural_match(&ast, &rust_source, &rust_path.display().to_string(), cfg) +} diff --git a/crates/rust/src/check/rules/mod.rs b/crates/rust/src/check/rules/mod.rs new file mode 100644 index 0000000..aa1c72d --- /dev/null +++ b/crates/rust/src/check/rules/mod.rs @@ -0,0 +1,5 @@ +//! Validation rules for checking Rust test files. + +pub mod structural_match; + +pub use structural_match::check_structural_match; diff --git a/crates/rust/src/check/rules/structural_match.rs b/crates/rust/src/check/rules/structural_match.rs new file mode 100644 index 0000000..b751c58 --- /dev/null +++ b/crates/rust/src/check/rules/structural_match.rs @@ -0,0 +1,148 @@ +//! Structural matching rule that checks if Rust code matches the spec. + +use crate::{ + check::violation::{Violation, ViolationKind}, + config::Config, + hir::Translator, + rust::ParsedRustFile, +}; +use anyhow::Result; +use bulloak_syntax::Ast; +use std::collections::HashSet; + +/// Check that the Rust file structurally matches the spec. +/// +/// # Errors +/// +/// Returns an error if checking fails. +pub fn check_structural_match( + ast: &Ast, + rust_source: &str, + file_path: &str, + cfg: &Config, +) -> Result> { + let mut violations = Vec::new(); + + // Parse the Rust file + let parsed = match ParsedRustFile::parse(rust_source) { + Ok(p) => p, + Err(e) => { + violations.push(Violation::new( + ViolationKind::RustFileInvalid(e.to_string()), + file_path.to_string(), + )); + return Ok(violations); + } + }; + + // Check test module exists + if parsed.find_test_module().is_none() { + violations.push(Violation::new( + ViolationKind::TestModuleMissing, + file_path.to_string(), + )); + return Ok(violations); + } + + // Generate expected HIR from AST + let translator = Translator::new(cfg.format_descriptions, cfg.skip_helpers); + let hir = translator.translate(ast)?; + + // Extract expected test functions from HIR + let mut expected_tests = Vec::new(); + let mut expected_helpers = HashSet::new(); + + if let crate::hir::Hir::Root(root) = &hir { + for child in &root.children { + if let crate::hir::Hir::Helper(helper) = child { + expected_helpers.insert(helper.name.clone()); + } else if let crate::hir::Hir::TestModule(module) = child { + for test_child in &module.children { + if let crate::hir::Hir::TestFunction(func) = test_child { + expected_tests.push(func.clone()); + } + } + } + } + } + + // Check helpers (if not skipped) + if !cfg.skip_helpers { + let found_helpers: HashSet = parsed + .find_helper_functions() + .iter() + .map(|f| f.sig.ident.to_string()) + .collect(); + + for expected_helper in &expected_helpers { + if !found_helpers.contains(expected_helper) { + violations.push(Violation::new( + ViolationKind::HelperFunctionMissing(expected_helper.clone()), + file_path.to_string(), + )); + } + } + } + + // Check test functions + let found_tests = parsed.find_test_functions(); + let found_test_names: HashSet = + found_tests.iter().map(|f| f.sig.ident.to_string()).collect(); + + for expected_test in &expected_tests { + if !found_test_names.contains(&expected_test.name) { + violations.push(Violation::new( + ViolationKind::TestFunctionMissing(expected_test.name.clone()), + file_path.to_string(), + )); + } else { + // Check attributes + let found_fn = found_tests + .iter() + .find(|f| f.sig.ident == expected_test.name) + .unwrap(); + + let has_should_panic = ParsedRustFile::has_should_panic(found_fn); + let expects_should_panic = expected_test + .attributes + .iter() + .any(|a| matches!(a, crate::hir::Attribute::ShouldPanic)); + + if expects_should_panic && !has_should_panic { + violations.push(Violation::new( + ViolationKind::TestAttributeIncorrect { + function: expected_test.name.clone(), + expected: "#[should_panic]".to_string(), + found: "none".to_string(), + }, + file_path.to_string(), + )); + } + } + } + + // Check order + let expected_order: Vec<&str> = expected_tests.iter().map(|t| t.name.as_str()).collect(); + let found_order: Vec<&str> = found_tests + .iter() + .map(|f| f.sig.ident.to_string()) + .map(|s| Box::leak(s.into_boxed_str()) as &str) + .collect(); + + // Check if the order matches (found may have extra tests, but expected ones should be in order) + let mut expected_idx = 0; + for found_name in &found_order { + if expected_idx < expected_order.len() && *found_name == expected_order[expected_idx] { + expected_idx += 1; + } + } + + if expected_idx != expected_order.len() { + violations.push(Violation::new( + ViolationKind::TestOrderIncorrect, + file_path.to_string(), + )); + } + + Ok(violations) +} diff --git a/crates/rust/src/check/violation.rs b/crates/rust/src/check/violation.rs new file mode 100644 index 0000000..0b1c306 --- /dev/null +++ b/crates/rust/src/check/violation.rs @@ -0,0 +1,96 @@ +//! Violation types for check command. + +use std::fmt; + +/// A violation found during checking. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Violation { + /// The kind of violation. + pub kind: ViolationKind, + /// The file path where the violation occurred. + pub file_path: String, + /// Optional line number. + pub line: Option, +} + +impl Violation { + /// Create a new violation. + #[must_use] + pub fn new(kind: ViolationKind, file_path: String) -> Self { + Self { + kind, + file_path, + line: None, + } + } + + /// Create a new violation with a line number. + #[must_use] + pub fn with_line(kind: ViolationKind, file_path: String, line: usize) -> Self { + Self { + kind, + file_path, + line: Some(line), + } + } +} + +/// The kind of violation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ViolationKind { + /// The Rust file is missing. + RustFileMissing, + /// The Rust file could not be parsed. + RustFileInvalid(String), + /// The test module is missing. + TestModuleMissing, + /// A test function is missing. + TestFunctionMissing(String), + /// A helper function is missing. + HelperFunctionMissing(String), + /// A test function has incorrect attributes. + TestAttributeIncorrect { + /// The function name. + function: String, + /// The expected attribute. + expected: String, + /// The found attribute. + found: String, + }, + /// Test function order does not match spec. + TestOrderIncorrect, +} + +impl fmt::Display for ViolationKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RustFileMissing => write!(f, "Rust test file is missing"), + Self::RustFileInvalid(err) => write!(f, "Rust file could not be parsed: {}", err), + Self::TestModuleMissing => write!(f, "Test module (#[cfg(test)] mod tests) is missing"), + Self::TestFunctionMissing(name) => write!(f, "Test function '{}' is missing", name), + Self::HelperFunctionMissing(name) => write!(f, "Helper function '{}' is missing", name), + Self::TestAttributeIncorrect { + function, + expected, + found, + } => write!( + f, + "Test function '{}' has incorrect attributes: expected {}, found {}", + function, expected, found + ), + Self::TestOrderIncorrect => { + write!(f, "Test function order does not match spec order") + } + } + } +} + +impl fmt::Display for Violation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(line) = self.line { + write!(f, "{}:{}: {}", self.file_path, line, self.kind) + } else { + write!(f, "{}: {}", self.file_path, self.kind) + } + } +} diff --git a/crates/rust/src/config.rs b/crates/rust/src/config.rs new file mode 100644 index 0000000..43c4145 --- /dev/null +++ b/crates/rust/src/config.rs @@ -0,0 +1,20 @@ +//! Configuration for the Rust backend. + +/// Configuration for the Rust backend. +#[derive(Debug, Clone, Default)] +pub struct Config { + /// List of files to process. + pub files: Vec, + /// Whether to skip emitting helper functions. + pub skip_helpers: bool, + /// Whether to format/capitalize branch descriptions. + pub format_descriptions: bool, +} + +impl Config { + /// Create a new configuration with default values. + #[must_use] + pub fn new() -> Self { + Self::default() + } +} diff --git a/crates/rust/src/constants.rs b/crates/rust/src/constants.rs new file mode 100644 index 0000000..560a9be --- /dev/null +++ b/crates/rust/src/constants.rs @@ -0,0 +1,22 @@ +//! Constants used in the Rust backend. + +/// Default indentation for generated code. +pub(crate) const DEFAULT_INDENTATION: usize = 4; + +/// Keywords that indicate a test should panic. +pub(crate) const PANIC_KEYWORDS: &[&str] = &[ + "panic", + "panics", + "revert", + "reverts", + "error", + "errors", + "fail", + "fails", +]; + +/// Name of the test context struct. +pub(crate) const CONTEXT_STRUCT_NAME: &str = "TestContext"; + +/// Name of the test module. +pub(crate) const TEST_MODULE_NAME: &str = "tests"; diff --git a/crates/rust/src/hir/hir.rs b/crates/rust/src/hir/hir.rs new file mode 100644 index 0000000..fa94425 --- /dev/null +++ b/crates/rust/src/hir/hir.rs @@ -0,0 +1,147 @@ +//! Defines a high-level intermediate representation (HIR) for Rust tests. + +use bulloak_syntax::Span; + +/// A high-level intermediate representation (HIR) that describes +/// the semantic structure of a Rust test file as emitted by `bulloak`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Hir { + /// An abstract root node that does not correspond + /// to any concrete Rust construct. + /// + /// This represents the file boundary. + Root(Root), + /// A context struct for passing test state. + Context(ContextStruct), + /// A helper function (corresponds to conditions). + Helper(HelperFunction), + /// A test module (#[cfg(test)] mod tests). + TestModule(TestModule), + /// A test function. + TestFunction(TestFunction), + /// A comment. + Comment(Comment), +} + +impl Hir { + /// Whether this HIR node is a root. + #[must_use] + pub fn is_root(&self) -> bool { + matches!(self, Self::Root(_)) + } + + /// Whether this HIR node is a test module. + #[must_use] + pub fn is_test_module(&self) -> bool { + matches!(self, Self::TestModule(_)) + } + + /// Whether this HIR node is a test function. + #[must_use] + pub fn is_test_function(&self) -> bool { + matches!(self, Self::TestFunction(_)) + } + + /// Whether this HIR node is a helper function. + #[must_use] + pub fn is_helper(&self) -> bool { + matches!(self, Self::Helper(_)) + } +} + +impl Default for Hir { + fn default() -> Self { + Self::Root(Root::default()) + } +} + +type Identifier = String; + +/// The root HIR node. +/// +/// There can only be one root node in any HIR. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Root { + /// The children HIR nodes of this node. + pub children: Vec, +} + +/// A context struct for passing test state between helpers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextStruct { + /// The struct name (typically "TestContext"). + pub name: Identifier, + /// Optional documentation comment. + pub doc: Option, +} + +impl Default for ContextStruct { + fn default() -> Self { + Self { + name: "TestContext".to_string(), + doc: Some("Context for test conditions".to_string()), + } + } +} + +/// A helper function that sets up test conditions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HelperFunction { + /// The function name. + pub name: Identifier, + /// Optional documentation comment. + pub doc: Option, + /// The span of the original tree node. + pub span: Option, +} + +/// A test module containing test functions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TestModule { + /// The module name (typically "tests"). + pub name: Identifier, + /// The test functions in this module. + pub children: Vec, +} + +impl Default for TestModule { + fn default() -> Self { + Self { + name: "tests".to_string(), + children: Vec::new(), + } + } +} + +/// An attribute for a test function. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Attribute { + /// #[test] + Test, + /// #[should_panic] + ShouldPanic, +} + +/// A test function. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TestFunction { + /// The function name. + pub name: Identifier, + /// Attributes (e.g., #[test], #[should_panic]). + pub attributes: Vec, + /// Names of helper functions to call. + pub helpers: Vec, + /// Comments in the function body. + pub children: Vec, + /// The span of the original tree node. + pub span: Option, +} + +/// A comment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Comment { + /// The comment text. + pub text: String, + /// Whether this comment should be formatted (capitalized/punctuated). + pub format: bool, +} diff --git a/crates/rust/src/hir/mod.rs b/crates/rust/src/hir/mod.rs new file mode 100644 index 0000000..c375980 --- /dev/null +++ b/crates/rust/src/hir/mod.rs @@ -0,0 +1,11 @@ +//! High-level intermediate representation for Rust tests. + +pub mod hir; +pub mod translator; +pub mod visitor; + +pub use hir::{ + Attribute, Comment, ContextStruct, HelperFunction, Hir, Root, TestFunction, TestModule, +}; +pub use translator::Translator; +pub use visitor::Visitor; diff --git a/crates/rust/src/hir/translator.rs b/crates/rust/src/hir/translator.rs new file mode 100644 index 0000000..54f98e7 --- /dev/null +++ b/crates/rust/src/hir/translator.rs @@ -0,0 +1,273 @@ +//! Translates a `bulloak-syntax` AST into a Rust HIR. + +use bulloak_syntax::{Action, Ast, Condition}; + +use super::hir::{ + Attribute, Comment, ContextStruct, HelperFunction, Hir, Root, TestFunction, TestModule, +}; +use crate::constants::{PANIC_KEYWORDS, TEST_MODULE_NAME}; + +/// Translates a `bulloak-syntax` AST into a Rust HIR. +pub struct Translator { + /// Whether to format/capitalize descriptions. + format_descriptions: bool, + /// Whether to skip helper functions. + skip_helpers: bool, +} + +impl Translator { + /// Create a new translator. + #[must_use] + pub fn new(format_descriptions: bool, skip_helpers: bool) -> Self { + Self { + format_descriptions, + skip_helpers, + } + } + + /// Translate an AST into a HIR. + /// + /// # Errors + /// + /// Returns an error if translation fails. + pub fn translate(&self, ast: &Ast) -> anyhow::Result { + let mut root = Root::default(); + + // Add context struct + root.children.push(Hir::Context(ContextStruct::default())); + + // Get the root node's children + let ast_root = match ast { + Ast::Root(r) => r, + _ => anyhow::bail!("Expected Root node"), + }; + + // Collect all unique conditions as helper functions + if !self.skip_helpers { + let helpers = self.collect_helpers(&ast_root.children); + for helper in helpers { + root.children.push(Hir::Helper(helper)); + } + } + + // Create test module + let test_module = self.translate_test_module(&ast_root.children)?; + root.children.push(Hir::TestModule(test_module)); + + Ok(Hir::Root(root)) + } + + /// Collect all unique conditions as helper functions. + fn collect_helpers(&self, children: &[Ast]) -> Vec { + let mut helpers = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + self.collect_helpers_recursive(children, &mut helpers, &mut seen); + + helpers + } + + /// Recursively collect helpers from the AST tree. + fn collect_helpers_recursive( + &self, + children: &[Ast], + helpers: &mut Vec, + seen: &mut std::collections::HashSet, + ) { + for child in children { + if let Ast::Condition(condition) = child { + let name = self.condition_to_helper_name(condition); + if !seen.contains(&name) { + seen.insert(name.clone()); + helpers.push(HelperFunction { + name: name.clone(), + doc: Some(condition.title.clone()), + span: Some(condition.span.clone()), + }); + } + // Recursively collect from nested conditions + self.collect_helpers_recursive(&condition.children, helpers, seen); + } + } + } + + /// Translate the AST into a test module. + fn translate_test_module(&self, children: &[Ast]) -> anyhow::Result { + let mut module = TestModule { + name: TEST_MODULE_NAME.to_string(), + children: Vec::new(), + }; + + // Process all children to generate test functions + self.process_children(children, &[], &mut module.children)?; + + Ok(module) + } + + /// Process AST children recursively to generate test functions. + fn process_children( + &self, + children: &[Ast], + parent_helpers: &[String], + output: &mut Vec, + ) -> anyhow::Result<()> { + for child in children { + match child { + Ast::Condition(condition) => { + let helper_name = self.condition_to_helper_name(condition); + let mut new_helpers = parent_helpers.to_vec(); + new_helpers.push(helper_name); + self.process_children(&condition.children, &new_helpers, output)?; + } + Ast::Action(action) => { + let test_fn = self.translate_action(action, parent_helpers)?; + output.push(Hir::TestFunction(test_fn)); + } + _ => {} + } + } + Ok(()) + } + + /// Translate an action into a test function. + fn translate_action( + &self, + action: &Action, + helpers: &[String], + ) -> anyhow::Result { + let name = self.action_to_test_name(action, helpers); + let should_panic = self.should_panic(&action.title); + + let mut attributes = vec![Attribute::Test]; + if should_panic { + attributes.push(Attribute::ShouldPanic); + } + + let mut children = Vec::new(); + + // Add action title as comment + children.push(Hir::Comment(Comment { + text: action.title.clone(), + format: self.format_descriptions, + })); + + // Add descriptions as comments + for desc_ast in &action.children { + if let Ast::ActionDescription(desc) = desc_ast { + children.push(Hir::Comment(Comment { + text: desc.text.clone(), + format: self.format_descriptions, + })); + } + } + + Ok(TestFunction { + name, + attributes, + helpers: helpers.to_vec(), + children, + span: Some(action.span.clone()), + }) + } + + /// Convert a condition title to a helper function name. + fn condition_to_helper_name(&self, condition: &Condition) -> String { + self.to_snake_case(&condition.title) + } + + /// Convert an action to a test function name. + fn action_to_test_name(&self, action: &Action, helpers: &[String]) -> String { + let action_part = self.to_snake_case(&action.title); + + if helpers.is_empty() { + format!("test_{}", action_part) + } else { + // Include the last helper in the name for context + let last_helper = &helpers[helpers.len() - 1]; + format!("test_{}_{}", last_helper, action_part) + } + } + + /// Convert a string to snake_case. + fn to_snake_case(&self, s: &str) -> String { + // Remove "when", "given", "it" prefixes (case-insensitive) + let s = s.trim(); + let s = s + .strip_prefix("when ") + .or_else(|| s.strip_prefix("When ")) + .or_else(|| s.strip_prefix("WHEN ")) + .or_else(|| s.strip_prefix("given ")) + .or_else(|| s.strip_prefix("Given ")) + .or_else(|| s.strip_prefix("GIVEN ")) + .or_else(|| s.strip_prefix("it ")) + .or_else(|| s.strip_prefix("It ")) + .or_else(|| s.strip_prefix("IT ")) + .unwrap_or(s); + + // Convert to snake_case + let mut result = String::new(); + let mut prev_is_alphanumeric = false; + + for c in s.chars() { + if c.is_alphanumeric() { + if c.is_uppercase() && prev_is_alphanumeric && !result.is_empty() { + result.push('_'); + } + result.push(c.to_ascii_lowercase()); + prev_is_alphanumeric = true; + } else if c.is_whitespace() || c == '-' { + if prev_is_alphanumeric { + result.push('_'); + prev_is_alphanumeric = false; + } + } else { + // Skip other characters + prev_is_alphanumeric = false; + } + } + + // Remove trailing underscores + result.trim_end_matches('_').to_string() + } + + /// Check if an action title indicates the test should panic. + fn should_panic(&self, title: &str) -> bool { + let title_lower = title.to_lowercase(); + PANIC_KEYWORDS + .iter() + .any(|keyword| title_lower.contains(keyword)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_snake_case() { + let translator = Translator::new(false, false); + + assert_eq!( + translator.to_snake_case("when first arg is smaller"), + "first_arg_is_smaller" + ); + assert_eq!( + translator.to_snake_case("It should return the sum"), + "should_return_the_sum" + ); + assert_eq!( + translator.to_snake_case("Given a valid input"), + "a_valid_input" + ); + } + + #[test] + fn test_should_panic() { + let translator = Translator::new(false, false); + + assert!(translator.should_panic("It should panic")); + assert!(translator.should_panic("It should revert")); + assert!(translator.should_panic("It should fail with error")); + assert!(!translator.should_panic("It should return a value")); + } +} diff --git a/crates/rust/src/hir/visitor.rs b/crates/rust/src/hir/visitor.rs new file mode 100644 index 0000000..fc2463d --- /dev/null +++ b/crates/rust/src/hir/visitor.rs @@ -0,0 +1,41 @@ +//! Visitor pattern for traversing the HIR. + +use super::hir::{ + Comment, ContextStruct, HelperFunction, Hir, Root, TestFunction, TestModule, +}; + +/// A visitor trait for traversing the HIR. +pub trait Visitor: Sized { + /// The result type of visiting a node. + type Output; + + /// Visit a HIR node. + fn visit(&mut self, hir: &Hir) -> Self::Output { + match hir { + Hir::Root(root) => self.visit_root(root), + Hir::Context(context) => self.visit_context(context), + Hir::Helper(helper) => self.visit_helper(helper), + Hir::TestModule(module) => self.visit_test_module(module), + Hir::TestFunction(func) => self.visit_test_function(func), + Hir::Comment(comment) => self.visit_comment(comment), + } + } + + /// Visit a root node. + fn visit_root(&mut self, root: &Root) -> Self::Output; + + /// Visit a context struct. + fn visit_context(&mut self, context: &ContextStruct) -> Self::Output; + + /// Visit a helper function. + fn visit_helper(&mut self, helper: &HelperFunction) -> Self::Output; + + /// Visit a test module. + fn visit_test_module(&mut self, module: &TestModule) -> Self::Output; + + /// Visit a test function. + fn visit_test_function(&mut self, func: &TestFunction) -> Self::Output; + + /// Visit a comment. + fn visit_comment(&mut self, comment: &Comment) -> Self::Output; +} diff --git a/crates/rust/src/lib.rs b/crates/rust/src/lib.rs new file mode 100644 index 0000000..1213f8d --- /dev/null +++ b/crates/rust/src/lib.rs @@ -0,0 +1,19 @@ +//! A `bulloak` backend for Rust tests. +//! +//! `bulloak-rust` provides an implementation of turning a `bulloak-syntax` +//! AST into a `_test.rs` file containing scaffolded Rust tests based on the +//! Branching Tree Technique. +//! +//! It also includes validation functionality to check that Rust test files +//! correspond to their `.tree` specifications. + +pub mod check; +pub mod config; +pub mod constants; +pub mod hir; +pub mod rust; +pub mod scaffold; + +pub use check::{Violation, ViolationKind}; +pub use config::Config; +pub use scaffold::scaffold; diff --git a/crates/rust/src/rust/mod.rs b/crates/rust/src/rust/mod.rs new file mode 100644 index 0000000..73b6120 --- /dev/null +++ b/crates/rust/src/rust/mod.rs @@ -0,0 +1,5 @@ +//! Rust code parsing and analysis. + +pub mod parser; + +pub use parser::ParsedRustFile; diff --git a/crates/rust/src/rust/parser.rs b/crates/rust/src/rust/parser.rs new file mode 100644 index 0000000..7d12e67 --- /dev/null +++ b/crates/rust/src/rust/parser.rs @@ -0,0 +1,178 @@ +//! Rust code parser using syn. + +use anyhow::{Context, Result}; +use syn::{File, Item, ItemFn, ItemMod, ItemStruct}; + +/// Parsed Rust test file. +pub struct ParsedRustFile { + /// The parsed syntax tree. + pub syntax: File, +} + +impl ParsedRustFile { + /// Parse a Rust file from source code. + /// + /// # Errors + /// + /// Returns an error if parsing fails. + pub fn parse(source: &str) -> Result { + let syntax = syn::parse_file(source).context("Failed to parse Rust file")?; + Ok(Self { syntax }) + } + + /// Find the test module in the file. + #[must_use] + pub fn find_test_module(&self) -> Option<&ItemMod> { + for item in &self.syntax.items { + if let Item::Mod(module) = item { + // Check if it has #[cfg(test)] attribute + if Self::has_cfg_test(&module.attrs) { + return Some(module); + } + } + } + None + } + + /// Find all test functions in the file. + #[must_use] + pub fn find_test_functions(&self) -> Vec<&ItemFn> { + let mut functions = Vec::new(); + + // Check in test module + if let Some(test_module) = self.find_test_module() { + if let Some((_, items)) = &test_module.content { + for item in items { + if let Item::Fn(func) = item { + if Self::has_test_attr(&func.attrs) { + functions.push(func); + } + } + } + } + } + + functions + } + + /// Find all helper functions (non-test functions at module level). + #[must_use] + pub fn find_helper_functions(&self) -> Vec<&ItemFn> { + let mut functions = Vec::new(); + + for item in &self.syntax.items { + if let Item::Fn(func) = item { + // Not a test function + if !Self::has_test_attr(&func.attrs) { + functions.push(func); + } + } + } + + functions + } + + /// Find the context struct. + #[must_use] + pub fn find_context_struct(&self) -> Option<&ItemStruct> { + for item in &self.syntax.items { + if let Item::Struct(s) = item { + // Look for a struct with "Context" in the name + if s.ident.to_string().contains("Context") { + return Some(s); + } + } + } + None + } + + /// Check if a function has #[test] attribute. + fn has_test_attr(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|attr| attr.path().is_ident("test")) + } + + /// Check if an item has #[cfg(test)] attribute. + fn has_cfg_test(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|attr| { + if attr.path().is_ident("cfg") { + if let Ok(meta_list) = attr.meta.require_list() { + return meta_list.tokens.to_string().contains("test"); + } + } + false + }) + } + + /// Check if a function has #[should_panic] attribute. + #[must_use] + pub fn has_should_panic(func: &ItemFn) -> bool { + func.attrs + .iter() + .any(|attr| attr.path().is_ident("should_panic")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_file() { + let source = r#" + #[cfg(test)] + mod tests { + #[test] + fn test_something() {} + } + "#; + + let parsed = ParsedRustFile::parse(source).unwrap(); + assert!(parsed.find_test_module().is_some()); + + let test_fns = parsed.find_test_functions(); + assert_eq!(test_fns.len(), 1); + assert_eq!(test_fns[0].sig.ident.to_string(), "test_something"); + } + + #[test] + fn test_find_helper_functions() { + let source = r#" + fn helper_function() {} + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_something() {} + } + "#; + + let parsed = ParsedRustFile::parse(source).unwrap(); + let helpers = parsed.find_helper_functions(); + assert_eq!(helpers.len(), 1); + assert_eq!(helpers[0].sig.ident.to_string(), "helper_function"); + } + + #[test] + fn test_has_should_panic() { + let source = r#" + #[cfg(test)] + mod tests { + #[test] + #[should_panic] + fn test_panics() {} + + #[test] + fn test_normal() {} + } + "#; + + let parsed = ParsedRustFile::parse(source).unwrap(); + let test_fns = parsed.find_test_functions(); + + assert_eq!(test_fns.len(), 2); + assert!(ParsedRustFile::has_should_panic(test_fns[0])); + assert!(!ParsedRustFile::has_should_panic(test_fns[1])); + } +} diff --git a/crates/rust/src/scaffold/comment.rs b/crates/rust/src/scaffold/comment.rs new file mode 100644 index 0000000..9329f70 --- /dev/null +++ b/crates/rust/src/scaffold/comment.rs @@ -0,0 +1,37 @@ +//! Comment formatting utilities. + +/// Format a comment by capitalizing the first letter and ensuring it ends with a period. +pub(crate) fn format_comment(text: &str) -> String { + let trimmed = text.trim(); + if trimmed.is_empty() { + return String::new(); + } + + let mut chars = trimmed.chars(); + let first = chars.next().unwrap(); + let rest: String = chars.collect(); + + let capitalized = format!("{}{}", first.to_uppercase(), rest); + + if capitalized.ends_with('.') || capitalized.ends_with('!') || capitalized.ends_with('?') { + capitalized + } else { + format!("{}.", capitalized) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_comment() { + assert_eq!(format_comment("should return sum"), "Should return sum."); + assert_eq!( + format_comment("Should return sum."), + "Should return sum." + ); + assert_eq!(format_comment("should panic!"), "Should panic!"); + assert_eq!(format_comment(""), ""); + } +} diff --git a/crates/rust/src/scaffold/emitter.rs b/crates/rust/src/scaffold/emitter.rs new file mode 100644 index 0000000..b61d1cc --- /dev/null +++ b/crates/rust/src/scaffold/emitter.rs @@ -0,0 +1,221 @@ +//! Emits Rust code from a HIR. + +use crate::{ + constants::{CONTEXT_STRUCT_NAME, DEFAULT_INDENTATION}, + hir::{ + visitor::Visitor, Attribute, Comment, ContextStruct, HelperFunction, Hir, Root, + TestFunction, TestModule, + }, + scaffold::comment, +}; + +/// Emits Rust code from a HIR. +pub struct Emitter { + /// Current indentation level. + indent: usize, + /// Whether to format descriptions. + format_descriptions: bool, +} + +impl Emitter { + /// Create a new emitter. + #[must_use] + pub fn new(format_descriptions: bool) -> Self { + Self { + indent: 0, + format_descriptions, + } + } + + /// Emit Rust code from the given HIR. + #[must_use] + pub fn emit(mut self, hir: &Hir) -> String { + self.visit(hir) + } + + /// Get the current indentation string. + fn indent(&self) -> String { + " ".repeat(self.indent) + } + + /// Increase indentation. + fn push_indent(&mut self) { + self.indent += DEFAULT_INDENTATION; + } + + /// Decrease indentation. + fn pop_indent(&mut self) { + self.indent = self.indent.saturating_sub(DEFAULT_INDENTATION); + } + + /// Emit a comment line. + fn emit_comment(&self, text: &str, should_format: bool) -> String { + let text = if should_format || self.format_descriptions { + comment::format_comment(text) + } else { + text.to_string() + }; + format!("{}// {}", self.indent(), text) + } +} + +impl Visitor for Emitter { + type Output = String; + + fn visit_root(&mut self, root: &Root) -> String { + let mut parts = vec!["// Generated by bulloak".to_string()]; + parts.push(String::new()); + + for child in &root.children { + let code = self.visit(child); + if !code.is_empty() { + parts.push(code); + parts.push(String::new()); // Blank line between top-level items + } + } + + parts.join("\n") + } + + fn visit_context(&mut self, context: &ContextStruct) -> String { + let mut parts = Vec::new(); + + if let Some(doc) = &context.doc { + parts.push(format!("/// {}", doc)); + } + parts.push("#[derive(Default)]".to_string()); + parts.push(format!("struct {} {{", context.name)); + parts.push(" // Add fields as needed".to_string()); + parts.push("}".to_string()); + + parts.join("\n") + } + + fn visit_helper(&mut self, helper: &HelperFunction) -> String { + let mut parts = Vec::new(); + + if let Some(doc) = &helper.doc { + parts.push(format!("/// Helper: {}", doc)); + } + + parts.push(format!( + "fn {}(mut ctx: {}) -> {} {{", + helper.name, CONTEXT_STRUCT_NAME, CONTEXT_STRUCT_NAME + )); + parts.push(" // TODO: Set up condition".to_string()); + parts.push(" ctx".to_string()); + parts.push("}".to_string()); + + parts.join("\n") + } + + fn visit_test_module(&mut self, module: &TestModule) -> String { + let mut parts = vec![ + "#[cfg(test)]".to_string(), + format!("mod {} {{", module.name), + ]; + + // Add use super::*; + parts.push(" use super::*;".to_string()); + parts.push(String::new()); + + self.push_indent(); + + for child in &module.children { + let code = self.visit(child); + if !code.is_empty() { + parts.push(code); + parts.push(String::new()); + } + } + + self.pop_indent(); + + parts.push("}".to_string()); + + parts.join("\n") + } + + fn visit_test_function(&mut self, func: &TestFunction) -> String { + let mut parts = Vec::new(); + + // Emit attributes + for attr in &func.attributes { + let attr_str = match attr { + Attribute::Test => "#[test]", + Attribute::ShouldPanic => "#[should_panic]", + }; + parts.push(format!("{}{}", self.indent(), attr_str)); + } + + // Function signature + parts.push(format!("{}fn {}() {{", self.indent(), func.name)); + + self.push_indent(); + + // Call helpers + if !func.helpers.is_empty() { + let helpers_chain = if func.helpers.len() == 1 { + format!( + "let _ctx = {}({}::default());", + func.helpers[0], CONTEXT_STRUCT_NAME + ) + } else { + let mut chain = format!("{}::default()", CONTEXT_STRUCT_NAME); + for helper in &func.helpers { + chain = format!("{}({})", helper, chain); + } + format!("let _ctx = {};", chain) + }; + parts.push(format!("{}{}", self.indent(), helpers_chain)); + } + + // Emit comments + for child in &func.children { + let code = self.visit(child); + if !code.is_empty() { + parts.push(code); + } + } + + self.pop_indent(); + + parts.push(format!("{}}}", self.indent())); + + parts.join("\n") + } + + fn visit_comment(&mut self, comment: &Comment) -> String { + self.emit_comment(&comment.text, comment.format) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_emit_context() { + let mut emitter = Emitter::new(false); + let context = ContextStruct::default(); + let output = emitter.visit_context(&context); + + assert!(output.contains("struct TestContext")); + assert!(output.contains("#[derive(Default)]")); + } + + #[test] + fn test_emit_helper() { + let mut emitter = Emitter::new(false); + let helper = HelperFunction { + name: "when_x_is_greater".to_string(), + doc: Some("When x is greater".to_string()), + span: None, + }; + let output = emitter.visit_helper(&helper); + + assert!(output.contains("fn when_x_is_greater")); + assert!(output.contains("mut ctx: TestContext")); + assert!(output.contains("/// Helper: When x is greater")); + } +} diff --git a/crates/rust/src/scaffold/mod.rs b/crates/rust/src/scaffold/mod.rs new file mode 100644 index 0000000..23ee9d4 --- /dev/null +++ b/crates/rust/src/scaffold/mod.rs @@ -0,0 +1,27 @@ +//! Scaffold module for generating Rust test code. + +pub mod comment; +pub mod emitter; + +pub use emitter::Emitter; + +use crate::{config::Config, hir::Translator}; +use anyhow::Result; +use bulloak_syntax::Ast; + +/// Scaffold Rust test code from an AST. +/// +/// # Errors +/// +/// Returns an error if scaffolding fails. +pub fn scaffold(ast: &Ast, cfg: &Config) -> Result { + // Translate AST to HIR + let translator = Translator::new(cfg.format_descriptions, cfg.skip_helpers); + let hir = translator.translate(ast)?; + + // Emit Rust code from HIR + let emitter = Emitter::new(cfg.format_descriptions); + let code = emitter.emit(&hir); + + Ok(code) +} From c0ffee74a63da1351e383a45d9b6680c038f849a Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Mon, 27 Oct 2025 18:15:17 +0100 Subject: [PATCH 02/20] fix: use quote macro --- Cargo.lock | 47 ++- crates/bulloak/src/check.rs | 13 +- crates/bulloak/src/cli.rs | 13 +- crates/bulloak/src/scaffold.rs | 77 ++-- crates/bulloak/tests/check_rust.rs | 6 +- crates/bulloak/tests/scaffold_rust.rs | 6 +- .../bulloak/tests/scaffold_rust/basic_test.rs | 13 +- .../tests/scaffold_rust/no_helpers_test.rs | 8 +- .../tests/scaffold_rust/with_panic_test.rs | 12 +- crates/rust/Cargo.toml | 1 + .../rust/src/check/rules/structural_match.rs | 146 +++++--- crates/rust/src/constants.rs | 6 - crates/rust/src/hir/hir.rs | 147 -------- crates/rust/src/hir/mod.rs | 11 - crates/rust/src/hir/translator.rs | 273 -------------- crates/rust/src/hir/visitor.rs | 41 -- crates/rust/src/lib.rs | 2 +- crates/rust/src/scaffold/emitter.rs | 221 ----------- crates/rust/src/scaffold/generator.rs | 350 ++++++++++++++++++ crates/rust/src/scaffold/mod.rs | 17 +- crates/rust/src/utils.rs | 62 ++++ 21 files changed, 612 insertions(+), 860 deletions(-) delete mode 100644 crates/rust/src/hir/hir.rs delete mode 100644 crates/rust/src/hir/mod.rs delete mode 100644 crates/rust/src/hir/translator.rs delete mode 100644 crates/rust/src/hir/visitor.rs delete mode 100644 crates/rust/src/scaffold/emitter.rs create mode 100644 crates/rust/src/scaffold/generator.rs create mode 100644 crates/rust/src/utils.rs diff --git a/Cargo.lock b/Cargo.lock index 286a65f..be65967 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,7 +116,7 @@ checksum = "c0391754c09fab4eae3404d19d0d297aa1c670c1775ab51d8a5312afeca23157" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -371,9 +371,10 @@ dependencies = [ "bulloak-syntax", "indoc", "pretty_assertions", + "prettyplease", "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", "thiserror", ] @@ -542,7 +543,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -882,7 +883,7 @@ checksum = "c2ad8cef1d801a4686bfd8919f0b30eac4c8e48968c437a6405ded4fb5272d2b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -1202,7 +1203,7 @@ checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -1798,7 +1799,7 @@ dependencies = [ "proc-macro-crate 2.0.0", "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -1963,7 +1964,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -2012,7 +2013,7 @@ dependencies = [ "phf_shared 0.11.2", "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -2117,6 +2118,16 @@ dependencies = [ "yansi 0.5.1", ] +[[package]] +name = "prettyplease" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +dependencies = [ + "proc-macro2", + "syn 2.0.87", +] + [[package]] name = "primitive-types" version = "0.12.2" @@ -2191,7 +2202,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", "version_check", "yansi 1.0.0-rc.1", ] @@ -2649,7 +2660,7 @@ checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -2860,7 +2871,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -2915,9 +2926,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.66" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", @@ -2992,7 +3003,7 @@ checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -3184,7 +3195,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -3358,7 +3369,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", "wasm-bindgen-shared", ] @@ -3392,7 +3403,7 @@ checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3655,7 +3666,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] diff --git a/crates/bulloak/src/check.rs b/crates/bulloak/src/check.rs index d3f0ec1..a2f9cd1 100644 --- a/crates/bulloak/src/check.rs +++ b/crates/bulloak/src/check.rs @@ -18,7 +18,7 @@ use clap::Parser; use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; -use crate::{cli::Cli, glob::expand_glob}; +use crate::{cli::{Backend, Cli}, glob::expand_glob}; /// Check that the tests match the spec. #[doc(hidden)] @@ -41,9 +41,9 @@ pub struct Check { /// Whether to capitalize and punctuate branch descriptions. #[arg(long = "format-descriptions", default_value_t = false)] pub format_descriptions: bool, - /// Check Rust tests instead of Solidity tests. - #[arg(short = 'r', long = "rust", default_value_t = false)] - pub rust: bool, + /// The target backend/language for checking. + #[arg(short = 'b', long = "backend", value_enum, default_value_t = Backend::Solidity)] + pub backend: Backend, } impl Default for Check { @@ -57,8 +57,9 @@ impl Check { /// /// Note that we don't deal with `solang_parser` errors at all. pub(crate) fn run(&self, cfg: &Cli) { - if self.rust { - return self.run_rust_check(); + match self.backend { + Backend::Rust => return self.run_rust_check(), + Backend::Solidity => {} // Continue with Solidity check below } let mut specs = Vec::new(); diff --git a/crates/bulloak/src/cli.rs b/crates/bulloak/src/cli.rs index 7953f17..f502e5c 100644 --- a/crates/bulloak/src/cli.rs +++ b/crates/bulloak/src/cli.rs @@ -1,8 +1,19 @@ //! `bulloak`'s CLI config. -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use figment::{providers::Serialized, Figment}; use serde::{Deserialize, Serialize}; +/// The target backend/language for code generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Backend { + /// Solidity (Foundry) backend. + #[default] + Solidity, + /// Rust backend. + Rust, +} + /// `bulloak`'s configuration. #[derive(Parser, Debug, Clone, Default, Serialize, Deserialize)] #[command(author, version, about, long_about = None)] // Read from `Cargo.toml` diff --git a/crates/bulloak/src/scaffold.rs b/crates/bulloak/src/scaffold.rs index b6c7864..89c7440 100644 --- a/crates/bulloak/src/scaffold.rs +++ b/crates/bulloak/src/scaffold.rs @@ -13,9 +13,9 @@ use forge_fmt::fmt; use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; -use crate::{cli::Cli, glob::expand_glob}; +use crate::{cli::{Backend, Cli}, glob::expand_glob}; -/// Generate Solidity tests based on your spec. +/// Generate test files based on your spec. #[doc(hidden)] #[derive(Parser, Debug, Clone, Serialize, Deserialize)] pub struct Scaffold { @@ -53,9 +53,9 @@ pub struct Scaffold { /// Whether to capitalize and punctuate branch descriptions. #[arg(short = 'F', long = "format-descriptions", default_value_t = false)] pub format_descriptions: bool, - /// Generate Rust tests instead of Solidity tests. - #[arg(short = 'r', long = "rust", default_value_t = false)] - pub rust: bool, + /// The target backend/language for code generation. + #[arg(short = 'b', long = "backend", value_enum, default_value_t = Backend::Solidity)] + pub backend: Backend, } impl Default for Scaffold { @@ -104,43 +104,46 @@ impl Scaffold { /// Processes a single input file. /// - /// This method reads the input file, scaffolds the Solidity/Rust code, formats + /// This method reads the input file, scaffolds the code, formats /// it, and either writes it to a file or prints it to stdout. fn process_file(&self, file: &Path, cfg: &Cli) -> anyhow::Result<()> { let text = fs::read_to_string(file)?; - if self.rust { - // Rust backend - let ast = bulloak_syntax::parse_one(&text)?; - let rust_cfg = bulloak_rust::Config { - files: self.files.iter().map(|p| p.display().to_string()).collect(), - skip_helpers: self.skip_modifiers, - format_descriptions: self.format_descriptions, - }; - let emitted = bulloak_rust::scaffold(&ast, &rust_cfg)?; - - if self.write_files { - let output_file = file.with_file_name(format!( - "{}_test.rs", - file.file_stem().unwrap().to_str().unwrap() - )); - self.write_file(&emitted, &output_file); - } else { - println!("{emitted}"); + match self.backend { + Backend::Rust => { + // Rust backend + let ast = bulloak_syntax::parse_one(&text)?; + let rust_cfg = bulloak_rust::Config { + files: self.files.iter().map(|p| p.display().to_string()).collect(), + skip_helpers: self.skip_modifiers, + format_descriptions: self.format_descriptions, + }; + let emitted = bulloak_rust::scaffold(&ast, &rust_cfg)?; + + if self.write_files { + let output_file = file.with_file_name(format!( + "{}_test.rs", + file.file_stem().unwrap().to_str().unwrap() + )); + self.write_file(&emitted, &output_file); + } else { + println!("{emitted}"); + } } - } else { - // Solidity backend (original) - let emitted = scaffold(&text, &cfg.into())?; - let formatted = fmt(&emitted).unwrap_or_else(|err| { - eprintln!("{}: {}", "WARN".yellow(), err); - emitted - }); - - if self.write_files { - let file = file.with_extension("t.sol"); - self.write_file(&formatted, &file); - } else { - println!("{formatted}"); + Backend::Solidity => { + // Solidity backend + let emitted = scaffold(&text, &cfg.into())?; + let formatted = fmt(&emitted).unwrap_or_else(|err| { + eprintln!("{}: {}", "WARN".yellow(), err); + emitted + }); + + if self.write_files { + let file = file.with_extension("t.sol"); + self.write_file(&formatted, &file); + } else { + println!("{formatted}"); + } } } diff --git a/crates/bulloak/tests/check_rust.rs b/crates/bulloak/tests/check_rust.rs index 3bbeb10..b4b9a40 100644 --- a/crates/bulloak/tests/check_rust.rs +++ b/crates/bulloak/tests/check_rust.rs @@ -14,7 +14,7 @@ fn check_rust_passes_when_correct() { let tree_name = "basic.tree"; let tree_path = tests_path.join(tree_name); - let output = cmd(&binary_path, "check", &tree_path, &["--rust"]); + let output = cmd(&binary_path, "check", &tree_path, &["--backend", "rust"]); // Should pass with no violations assert!(output.status.success()); @@ -37,7 +37,7 @@ fn check_rust_fails_when_missing_file() { ) .unwrap(); - let output = cmd(&binary_path, "check", &temp_tree, &["--rust"]); + let output = cmd(&binary_path, "check", &temp_tree, &["--backend", "rust"]); // Should fail assert!(!output.status.success()); @@ -85,7 +85,7 @@ mod tests { ) .unwrap(); - let output = cmd(&binary_path, "check", &temp_tree, &["--rust"]); + let output = cmd(&binary_path, "check", &temp_tree, &["--backend", "rust"]); // Should fail assert!(!output.status.success()); diff --git a/crates/bulloak/tests/scaffold_rust.rs b/crates/bulloak/tests/scaffold_rust.rs index 83dd33b..bf5418d 100644 --- a/crates/bulloak/tests/scaffold_rust.rs +++ b/crates/bulloak/tests/scaffold_rust.rs @@ -16,7 +16,7 @@ fn scaffolds_rust_trees() { for tree_name in trees { let tree_path = tests_path.join(tree_name); - let output = cmd(&binary_path, "scaffold", &tree_path, &["--rust"]); + let output = cmd(&binary_path, "scaffold", &tree_path, &["--backend", "rust"]); let actual = String::from_utf8(output.stdout).unwrap(); let mut output_file = tree_path.clone(); @@ -51,7 +51,7 @@ fn scaffolds_rust_trees_skip_helpers() { let tree_name = "basic.tree"; let tree_path = tests_path.join(tree_name); - let output = cmd(&binary_path, "scaffold", &tree_path, &["--rust", "-m"]); + let output = cmd(&binary_path, "scaffold", &tree_path, &["--backend", "rust", "-m"]); let actual = String::from_utf8(output.stdout).unwrap(); // Should not contain helper functions @@ -77,7 +77,7 @@ fn scaffolds_rust_trees_format_descriptions() { &binary_path, "scaffold", &tree_path, - &["--rust", "--format-descriptions"], + &["--backend", "rust", "--format-descriptions"], ); let actual = String::from_utf8(output.stdout).unwrap(); diff --git a/crates/bulloak/tests/scaffold_rust/basic_test.rs b/crates/bulloak/tests/scaffold_rust/basic_test.rs index 0b48059..2c57aac 100644 --- a/crates/bulloak/tests/scaffold_rust/basic_test.rs +++ b/crates/bulloak/tests/scaffold_rust/basic_test.rs @@ -2,42 +2,31 @@ /// Context for test conditions #[derive(Default)] -struct TestContext { - // Add fields as needed -} - +struct TestContext {} /// Helper: When first arg is smaller than second arg fn first_arg_is_smaller_than_second_arg(mut ctx: TestContext) -> TestContext { - // TODO: Set up condition ctx } - /// Helper: When first arg is bigger than second arg fn first_arg_is_bigger_than_second_arg(mut ctx: TestContext) -> TestContext { - // TODO: Set up condition ctx } - #[cfg(test)] mod tests { use super::*; - #[test] #[should_panic] fn test_should_never_panic() { // It should never panic. } - #[test] fn test_first_arg_is_smaller_than_second_arg_should_match_the_result_of_hashab() { let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); // It should match the result of hash(a, b). } - #[test] fn test_first_arg_is_bigger_than_second_arg_should_match_the_result_of_hashba() { let _ctx = first_arg_is_bigger_than_second_arg(TestContext::default()); // It should match the result of hash(b, a). } - } diff --git a/crates/bulloak/tests/scaffold_rust/no_helpers_test.rs b/crates/bulloak/tests/scaffold_rust/no_helpers_test.rs index f52b85f..44bf708 100644 --- a/crates/bulloak/tests/scaffold_rust/no_helpers_test.rs +++ b/crates/bulloak/tests/scaffold_rust/no_helpers_test.rs @@ -2,22 +2,16 @@ /// Context for test conditions #[derive(Default)] -struct TestContext { - // Add fields as needed -} - +struct TestContext {} #[cfg(test)] mod tests { use super::*; - #[test] fn test_should_return_true_for_valid_input() { // It should return true for valid input. } - #[test] fn test_should_return_false_for_invalid_input() { // It should return false for invalid input. } - } diff --git a/crates/bulloak/tests/scaffold_rust/with_panic_test.rs b/crates/bulloak/tests/scaffold_rust/with_panic_test.rs index 7618681..24cbbe2 100644 --- a/crates/bulloak/tests/scaffold_rust/with_panic_test.rs +++ b/crates/bulloak/tests/scaffold_rust/with_panic_test.rs @@ -2,37 +2,27 @@ /// Context for test conditions #[derive(Default)] -struct TestContext { - // Add fields as needed -} - +struct TestContext {} /// Helper: When divisor is zero fn divisor_is_zero(mut ctx: TestContext) -> TestContext { - // TODO: Set up condition ctx } - /// Helper: When divisor is non_zero fn divisor_is_nonzero(mut ctx: TestContext) -> TestContext { - // TODO: Set up condition ctx } - #[cfg(test)] mod tests { use super::*; - #[test] #[should_panic] fn test_divisor_is_zero_should_panic_with_division_by_zero() { let _ctx = divisor_is_zero(TestContext::default()); // It should panic with division by zero. } - #[test] fn test_divisor_is_nonzero_should_return_the_quotient() { let _ctx = divisor_is_nonzero(TestContext::default()); // It should return the quotient. } - } diff --git a/crates/rust/Cargo.toml b/crates/rust/Cargo.toml index 09c1d2c..753e29e 100644 --- a/crates/rust/Cargo.toml +++ b/crates/rust/Cargo.toml @@ -20,6 +20,7 @@ thiserror.workspace = true syn = { version = "2.0", features = ["full", "parsing", "visit"] } quote = "1.0" proc-macro2 = "1.0" +prettyplease = "0.2" [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/rust/src/check/rules/structural_match.rs b/crates/rust/src/check/rules/structural_match.rs index b751c58..2888d4e 100644 --- a/crates/rust/src/check/rules/structural_match.rs +++ b/crates/rust/src/check/rules/structural_match.rs @@ -3,13 +3,25 @@ use crate::{ check::violation::{Violation, ViolationKind}, config::Config, - hir::Translator, rust::ParsedRustFile, + scaffold::Generator, + utils::to_snake_case, }; use anyhow::Result; use bulloak_syntax::Ast; use std::collections::HashSet; +/// Expected test structure extracted from AST. +struct ExpectedTests { + helpers: HashSet, + test_functions: Vec, +} + +struct TestInfo { + name: String, + should_panic: bool, +} + /// Check that the Rust file structurally matches the spec. /// /// # Errors @@ -44,27 +56,8 @@ pub fn check_structural_match( return Ok(violations); } - // Generate expected HIR from AST - let translator = Translator::new(cfg.format_descriptions, cfg.skip_helpers); - let hir = translator.translate(ast)?; - - // Extract expected test functions from HIR - let mut expected_tests = Vec::new(); - let mut expected_helpers = HashSet::new(); - - if let crate::hir::Hir::Root(root) = &hir { - for child in &root.children { - if let crate::hir::Hir::Helper(helper) = child { - expected_helpers.insert(helper.name.clone()); - } else if let crate::hir::Hir::TestModule(module) = child { - for test_child in &module.children { - if let crate::hir::Hir::TestFunction(func) = test_child { - expected_tests.push(func.clone()); - } - } - } - } - } + // Extract expected structure from AST + let expected = extract_expected_structure(ast, cfg)?; // Check helpers (if not skipped) if !cfg.skip_helpers { @@ -74,7 +67,7 @@ pub fn check_structural_match( .map(|f| f.sig.ident.to_string()) .collect(); - for expected_helper in &expected_helpers { + for expected_helper in &expected.helpers { if !found_helpers.contains(expected_helper) { violations.push(Violation::new( ViolationKind::HelperFunctionMissing(expected_helper.clone()), @@ -89,7 +82,7 @@ pub fn check_structural_match( let found_test_names: HashSet = found_tests.iter().map(|f| f.sig.ident.to_string()).collect(); - for expected_test in &expected_tests { + for expected_test in &expected.test_functions { if !found_test_names.contains(&expected_test.name) { violations.push(Violation::new( ViolationKind::TestFunctionMissing(expected_test.name.clone()), @@ -99,16 +92,12 @@ pub fn check_structural_match( // Check attributes let found_fn = found_tests .iter() - .find(|f| f.sig.ident == expected_test.name) + .find(|f| f.sig.ident.to_string() == expected_test.name) .unwrap(); let has_should_panic = ParsedRustFile::has_should_panic(found_fn); - let expects_should_panic = expected_test - .attributes - .iter() - .any(|a| matches!(a, crate::hir::Attribute::ShouldPanic)); - if expects_should_panic && !has_should_panic { + if expected_test.should_panic && !has_should_panic { violations.push(Violation::new( ViolationKind::TestAttributeIncorrect { function: expected_test.name.clone(), @@ -121,28 +110,85 @@ pub fn check_structural_match( } } - // Check order - let expected_order: Vec<&str> = expected_tests.iter().map(|t| t.name.as_str()).collect(); - let found_order: Vec<&str> = found_tests - .iter() - .map(|f| f.sig.ident.to_string()) - .map(|s| Box::leak(s.into_boxed_str()) as &str) - .collect(); - - // Check if the order matches (found may have extra tests, but expected ones should be in order) - let mut expected_idx = 0; - for found_name in &found_order { - if expected_idx < expected_order.len() && *found_name == expected_order[expected_idx] { - expected_idx += 1; - } + Ok(violations) +} + +/// Extract expected test structure from AST. +fn extract_expected_structure(ast: &Ast, cfg: &Config) -> Result { + let generator = Generator::new(cfg); + + let ast_root = match ast { + Ast::Root(r) => r, + _ => anyhow::bail!("Expected Root node"), + }; + + let mut helpers = HashSet::new(); + let mut test_functions = Vec::new(); + + // Collect helpers + if !cfg.skip_helpers { + collect_helpers_recursive(&ast_root.children, &mut helpers, &generator); } - if expected_idx != expected_order.len() { - violations.push(Violation::new( - ViolationKind::TestOrderIncorrect, - file_path.to_string(), - )); + // Collect test functions + collect_tests_recursive(&ast_root.children, &[], &mut test_functions, &generator); + + Ok(ExpectedTests { + helpers, + test_functions, + }) +} + +/// Recursively collect helper function names. +fn collect_helpers_recursive( + children: &[Ast], + helpers: &mut HashSet, + generator: &Generator, +) { + for child in children { + if let Ast::Condition(condition) = child { + let name = to_snake_case(&condition.title); + helpers.insert(name); + collect_helpers_recursive(&condition.children, helpers, generator); + } } +} - Ok(violations) +/// Recursively collect test function info. +fn collect_tests_recursive( + children: &[Ast], + parent_helpers: &[String], + tests: &mut Vec, + generator: &Generator, +) { + for child in children { + match child { + Ast::Condition(condition) => { + let helper_name = to_snake_case(&condition.title); + let mut new_helpers = parent_helpers.to_vec(); + new_helpers.push(helper_name); + collect_tests_recursive(&condition.children, &new_helpers, tests, generator); + } + Ast::Action(action) => { + let action_part = to_snake_case(&action.title); + let test_name = if parent_helpers.is_empty() { + format!("test_{}", action_part) + } else { + let last_helper = &parent_helpers[parent_helpers.len() - 1]; + format!("test_{}_{}", last_helper, action_part) + }; + + let should_panic = action.title.to_lowercase() + .split_whitespace() + .any(|w| matches!(w, "panic" | "panics" | "revert" | "reverts" | "error" | "errors" | "fail" | "fails")); + + tests.push(TestInfo { + name: test_name, + should_panic, + }); + } + _ => {} + } + } } + diff --git a/crates/rust/src/constants.rs b/crates/rust/src/constants.rs index 560a9be..4f1367d 100644 --- a/crates/rust/src/constants.rs +++ b/crates/rust/src/constants.rs @@ -1,8 +1,5 @@ //! Constants used in the Rust backend. -/// Default indentation for generated code. -pub(crate) const DEFAULT_INDENTATION: usize = 4; - /// Keywords that indicate a test should panic. pub(crate) const PANIC_KEYWORDS: &[&str] = &[ "panic", @@ -17,6 +14,3 @@ pub(crate) const PANIC_KEYWORDS: &[&str] = &[ /// Name of the test context struct. pub(crate) const CONTEXT_STRUCT_NAME: &str = "TestContext"; - -/// Name of the test module. -pub(crate) const TEST_MODULE_NAME: &str = "tests"; diff --git a/crates/rust/src/hir/hir.rs b/crates/rust/src/hir/hir.rs deleted file mode 100644 index fa94425..0000000 --- a/crates/rust/src/hir/hir.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Defines a high-level intermediate representation (HIR) for Rust tests. - -use bulloak_syntax::Span; - -/// A high-level intermediate representation (HIR) that describes -/// the semantic structure of a Rust test file as emitted by `bulloak`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Hir { - /// An abstract root node that does not correspond - /// to any concrete Rust construct. - /// - /// This represents the file boundary. - Root(Root), - /// A context struct for passing test state. - Context(ContextStruct), - /// A helper function (corresponds to conditions). - Helper(HelperFunction), - /// A test module (#[cfg(test)] mod tests). - TestModule(TestModule), - /// A test function. - TestFunction(TestFunction), - /// A comment. - Comment(Comment), -} - -impl Hir { - /// Whether this HIR node is a root. - #[must_use] - pub fn is_root(&self) -> bool { - matches!(self, Self::Root(_)) - } - - /// Whether this HIR node is a test module. - #[must_use] - pub fn is_test_module(&self) -> bool { - matches!(self, Self::TestModule(_)) - } - - /// Whether this HIR node is a test function. - #[must_use] - pub fn is_test_function(&self) -> bool { - matches!(self, Self::TestFunction(_)) - } - - /// Whether this HIR node is a helper function. - #[must_use] - pub fn is_helper(&self) -> bool { - matches!(self, Self::Helper(_)) - } -} - -impl Default for Hir { - fn default() -> Self { - Self::Root(Root::default()) - } -} - -type Identifier = String; - -/// The root HIR node. -/// -/// There can only be one root node in any HIR. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct Root { - /// The children HIR nodes of this node. - pub children: Vec, -} - -/// A context struct for passing test state between helpers. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ContextStruct { - /// The struct name (typically "TestContext"). - pub name: Identifier, - /// Optional documentation comment. - pub doc: Option, -} - -impl Default for ContextStruct { - fn default() -> Self { - Self { - name: "TestContext".to_string(), - doc: Some("Context for test conditions".to_string()), - } - } -} - -/// A helper function that sets up test conditions. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HelperFunction { - /// The function name. - pub name: Identifier, - /// Optional documentation comment. - pub doc: Option, - /// The span of the original tree node. - pub span: Option, -} - -/// A test module containing test functions. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TestModule { - /// The module name (typically "tests"). - pub name: Identifier, - /// The test functions in this module. - pub children: Vec, -} - -impl Default for TestModule { - fn default() -> Self { - Self { - name: "tests".to_string(), - children: Vec::new(), - } - } -} - -/// An attribute for a test function. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Attribute { - /// #[test] - Test, - /// #[should_panic] - ShouldPanic, -} - -/// A test function. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TestFunction { - /// The function name. - pub name: Identifier, - /// Attributes (e.g., #[test], #[should_panic]). - pub attributes: Vec, - /// Names of helper functions to call. - pub helpers: Vec, - /// Comments in the function body. - pub children: Vec, - /// The span of the original tree node. - pub span: Option, -} - -/// A comment. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Comment { - /// The comment text. - pub text: String, - /// Whether this comment should be formatted (capitalized/punctuated). - pub format: bool, -} diff --git a/crates/rust/src/hir/mod.rs b/crates/rust/src/hir/mod.rs deleted file mode 100644 index c375980..0000000 --- a/crates/rust/src/hir/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! High-level intermediate representation for Rust tests. - -pub mod hir; -pub mod translator; -pub mod visitor; - -pub use hir::{ - Attribute, Comment, ContextStruct, HelperFunction, Hir, Root, TestFunction, TestModule, -}; -pub use translator::Translator; -pub use visitor::Visitor; diff --git a/crates/rust/src/hir/translator.rs b/crates/rust/src/hir/translator.rs deleted file mode 100644 index 54f98e7..0000000 --- a/crates/rust/src/hir/translator.rs +++ /dev/null @@ -1,273 +0,0 @@ -//! Translates a `bulloak-syntax` AST into a Rust HIR. - -use bulloak_syntax::{Action, Ast, Condition}; - -use super::hir::{ - Attribute, Comment, ContextStruct, HelperFunction, Hir, Root, TestFunction, TestModule, -}; -use crate::constants::{PANIC_KEYWORDS, TEST_MODULE_NAME}; - -/// Translates a `bulloak-syntax` AST into a Rust HIR. -pub struct Translator { - /// Whether to format/capitalize descriptions. - format_descriptions: bool, - /// Whether to skip helper functions. - skip_helpers: bool, -} - -impl Translator { - /// Create a new translator. - #[must_use] - pub fn new(format_descriptions: bool, skip_helpers: bool) -> Self { - Self { - format_descriptions, - skip_helpers, - } - } - - /// Translate an AST into a HIR. - /// - /// # Errors - /// - /// Returns an error if translation fails. - pub fn translate(&self, ast: &Ast) -> anyhow::Result { - let mut root = Root::default(); - - // Add context struct - root.children.push(Hir::Context(ContextStruct::default())); - - // Get the root node's children - let ast_root = match ast { - Ast::Root(r) => r, - _ => anyhow::bail!("Expected Root node"), - }; - - // Collect all unique conditions as helper functions - if !self.skip_helpers { - let helpers = self.collect_helpers(&ast_root.children); - for helper in helpers { - root.children.push(Hir::Helper(helper)); - } - } - - // Create test module - let test_module = self.translate_test_module(&ast_root.children)?; - root.children.push(Hir::TestModule(test_module)); - - Ok(Hir::Root(root)) - } - - /// Collect all unique conditions as helper functions. - fn collect_helpers(&self, children: &[Ast]) -> Vec { - let mut helpers = Vec::new(); - let mut seen = std::collections::HashSet::new(); - - self.collect_helpers_recursive(children, &mut helpers, &mut seen); - - helpers - } - - /// Recursively collect helpers from the AST tree. - fn collect_helpers_recursive( - &self, - children: &[Ast], - helpers: &mut Vec, - seen: &mut std::collections::HashSet, - ) { - for child in children { - if let Ast::Condition(condition) = child { - let name = self.condition_to_helper_name(condition); - if !seen.contains(&name) { - seen.insert(name.clone()); - helpers.push(HelperFunction { - name: name.clone(), - doc: Some(condition.title.clone()), - span: Some(condition.span.clone()), - }); - } - // Recursively collect from nested conditions - self.collect_helpers_recursive(&condition.children, helpers, seen); - } - } - } - - /// Translate the AST into a test module. - fn translate_test_module(&self, children: &[Ast]) -> anyhow::Result { - let mut module = TestModule { - name: TEST_MODULE_NAME.to_string(), - children: Vec::new(), - }; - - // Process all children to generate test functions - self.process_children(children, &[], &mut module.children)?; - - Ok(module) - } - - /// Process AST children recursively to generate test functions. - fn process_children( - &self, - children: &[Ast], - parent_helpers: &[String], - output: &mut Vec, - ) -> anyhow::Result<()> { - for child in children { - match child { - Ast::Condition(condition) => { - let helper_name = self.condition_to_helper_name(condition); - let mut new_helpers = parent_helpers.to_vec(); - new_helpers.push(helper_name); - self.process_children(&condition.children, &new_helpers, output)?; - } - Ast::Action(action) => { - let test_fn = self.translate_action(action, parent_helpers)?; - output.push(Hir::TestFunction(test_fn)); - } - _ => {} - } - } - Ok(()) - } - - /// Translate an action into a test function. - fn translate_action( - &self, - action: &Action, - helpers: &[String], - ) -> anyhow::Result { - let name = self.action_to_test_name(action, helpers); - let should_panic = self.should_panic(&action.title); - - let mut attributes = vec![Attribute::Test]; - if should_panic { - attributes.push(Attribute::ShouldPanic); - } - - let mut children = Vec::new(); - - // Add action title as comment - children.push(Hir::Comment(Comment { - text: action.title.clone(), - format: self.format_descriptions, - })); - - // Add descriptions as comments - for desc_ast in &action.children { - if let Ast::ActionDescription(desc) = desc_ast { - children.push(Hir::Comment(Comment { - text: desc.text.clone(), - format: self.format_descriptions, - })); - } - } - - Ok(TestFunction { - name, - attributes, - helpers: helpers.to_vec(), - children, - span: Some(action.span.clone()), - }) - } - - /// Convert a condition title to a helper function name. - fn condition_to_helper_name(&self, condition: &Condition) -> String { - self.to_snake_case(&condition.title) - } - - /// Convert an action to a test function name. - fn action_to_test_name(&self, action: &Action, helpers: &[String]) -> String { - let action_part = self.to_snake_case(&action.title); - - if helpers.is_empty() { - format!("test_{}", action_part) - } else { - // Include the last helper in the name for context - let last_helper = &helpers[helpers.len() - 1]; - format!("test_{}_{}", last_helper, action_part) - } - } - - /// Convert a string to snake_case. - fn to_snake_case(&self, s: &str) -> String { - // Remove "when", "given", "it" prefixes (case-insensitive) - let s = s.trim(); - let s = s - .strip_prefix("when ") - .or_else(|| s.strip_prefix("When ")) - .or_else(|| s.strip_prefix("WHEN ")) - .or_else(|| s.strip_prefix("given ")) - .or_else(|| s.strip_prefix("Given ")) - .or_else(|| s.strip_prefix("GIVEN ")) - .or_else(|| s.strip_prefix("it ")) - .or_else(|| s.strip_prefix("It ")) - .or_else(|| s.strip_prefix("IT ")) - .unwrap_or(s); - - // Convert to snake_case - let mut result = String::new(); - let mut prev_is_alphanumeric = false; - - for c in s.chars() { - if c.is_alphanumeric() { - if c.is_uppercase() && prev_is_alphanumeric && !result.is_empty() { - result.push('_'); - } - result.push(c.to_ascii_lowercase()); - prev_is_alphanumeric = true; - } else if c.is_whitespace() || c == '-' { - if prev_is_alphanumeric { - result.push('_'); - prev_is_alphanumeric = false; - } - } else { - // Skip other characters - prev_is_alphanumeric = false; - } - } - - // Remove trailing underscores - result.trim_end_matches('_').to_string() - } - - /// Check if an action title indicates the test should panic. - fn should_panic(&self, title: &str) -> bool { - let title_lower = title.to_lowercase(); - PANIC_KEYWORDS - .iter() - .any(|keyword| title_lower.contains(keyword)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_to_snake_case() { - let translator = Translator::new(false, false); - - assert_eq!( - translator.to_snake_case("when first arg is smaller"), - "first_arg_is_smaller" - ); - assert_eq!( - translator.to_snake_case("It should return the sum"), - "should_return_the_sum" - ); - assert_eq!( - translator.to_snake_case("Given a valid input"), - "a_valid_input" - ); - } - - #[test] - fn test_should_panic() { - let translator = Translator::new(false, false); - - assert!(translator.should_panic("It should panic")); - assert!(translator.should_panic("It should revert")); - assert!(translator.should_panic("It should fail with error")); - assert!(!translator.should_panic("It should return a value")); - } -} diff --git a/crates/rust/src/hir/visitor.rs b/crates/rust/src/hir/visitor.rs deleted file mode 100644 index fc2463d..0000000 --- a/crates/rust/src/hir/visitor.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Visitor pattern for traversing the HIR. - -use super::hir::{ - Comment, ContextStruct, HelperFunction, Hir, Root, TestFunction, TestModule, -}; - -/// A visitor trait for traversing the HIR. -pub trait Visitor: Sized { - /// The result type of visiting a node. - type Output; - - /// Visit a HIR node. - fn visit(&mut self, hir: &Hir) -> Self::Output { - match hir { - Hir::Root(root) => self.visit_root(root), - Hir::Context(context) => self.visit_context(context), - Hir::Helper(helper) => self.visit_helper(helper), - Hir::TestModule(module) => self.visit_test_module(module), - Hir::TestFunction(func) => self.visit_test_function(func), - Hir::Comment(comment) => self.visit_comment(comment), - } - } - - /// Visit a root node. - fn visit_root(&mut self, root: &Root) -> Self::Output; - - /// Visit a context struct. - fn visit_context(&mut self, context: &ContextStruct) -> Self::Output; - - /// Visit a helper function. - fn visit_helper(&mut self, helper: &HelperFunction) -> Self::Output; - - /// Visit a test module. - fn visit_test_module(&mut self, module: &TestModule) -> Self::Output; - - /// Visit a test function. - fn visit_test_function(&mut self, func: &TestFunction) -> Self::Output; - - /// Visit a comment. - fn visit_comment(&mut self, comment: &Comment) -> Self::Output; -} diff --git a/crates/rust/src/lib.rs b/crates/rust/src/lib.rs index 1213f8d..77a9d5a 100644 --- a/crates/rust/src/lib.rs +++ b/crates/rust/src/lib.rs @@ -10,9 +10,9 @@ pub mod check; pub mod config; pub mod constants; -pub mod hir; pub mod rust; pub mod scaffold; +mod utils; pub use check::{Violation, ViolationKind}; pub use config::Config; diff --git a/crates/rust/src/scaffold/emitter.rs b/crates/rust/src/scaffold/emitter.rs deleted file mode 100644 index b61d1cc..0000000 --- a/crates/rust/src/scaffold/emitter.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! Emits Rust code from a HIR. - -use crate::{ - constants::{CONTEXT_STRUCT_NAME, DEFAULT_INDENTATION}, - hir::{ - visitor::Visitor, Attribute, Comment, ContextStruct, HelperFunction, Hir, Root, - TestFunction, TestModule, - }, - scaffold::comment, -}; - -/// Emits Rust code from a HIR. -pub struct Emitter { - /// Current indentation level. - indent: usize, - /// Whether to format descriptions. - format_descriptions: bool, -} - -impl Emitter { - /// Create a new emitter. - #[must_use] - pub fn new(format_descriptions: bool) -> Self { - Self { - indent: 0, - format_descriptions, - } - } - - /// Emit Rust code from the given HIR. - #[must_use] - pub fn emit(mut self, hir: &Hir) -> String { - self.visit(hir) - } - - /// Get the current indentation string. - fn indent(&self) -> String { - " ".repeat(self.indent) - } - - /// Increase indentation. - fn push_indent(&mut self) { - self.indent += DEFAULT_INDENTATION; - } - - /// Decrease indentation. - fn pop_indent(&mut self) { - self.indent = self.indent.saturating_sub(DEFAULT_INDENTATION); - } - - /// Emit a comment line. - fn emit_comment(&self, text: &str, should_format: bool) -> String { - let text = if should_format || self.format_descriptions { - comment::format_comment(text) - } else { - text.to_string() - }; - format!("{}// {}", self.indent(), text) - } -} - -impl Visitor for Emitter { - type Output = String; - - fn visit_root(&mut self, root: &Root) -> String { - let mut parts = vec!["// Generated by bulloak".to_string()]; - parts.push(String::new()); - - for child in &root.children { - let code = self.visit(child); - if !code.is_empty() { - parts.push(code); - parts.push(String::new()); // Blank line between top-level items - } - } - - parts.join("\n") - } - - fn visit_context(&mut self, context: &ContextStruct) -> String { - let mut parts = Vec::new(); - - if let Some(doc) = &context.doc { - parts.push(format!("/// {}", doc)); - } - parts.push("#[derive(Default)]".to_string()); - parts.push(format!("struct {} {{", context.name)); - parts.push(" // Add fields as needed".to_string()); - parts.push("}".to_string()); - - parts.join("\n") - } - - fn visit_helper(&mut self, helper: &HelperFunction) -> String { - let mut parts = Vec::new(); - - if let Some(doc) = &helper.doc { - parts.push(format!("/// Helper: {}", doc)); - } - - parts.push(format!( - "fn {}(mut ctx: {}) -> {} {{", - helper.name, CONTEXT_STRUCT_NAME, CONTEXT_STRUCT_NAME - )); - parts.push(" // TODO: Set up condition".to_string()); - parts.push(" ctx".to_string()); - parts.push("}".to_string()); - - parts.join("\n") - } - - fn visit_test_module(&mut self, module: &TestModule) -> String { - let mut parts = vec![ - "#[cfg(test)]".to_string(), - format!("mod {} {{", module.name), - ]; - - // Add use super::*; - parts.push(" use super::*;".to_string()); - parts.push(String::new()); - - self.push_indent(); - - for child in &module.children { - let code = self.visit(child); - if !code.is_empty() { - parts.push(code); - parts.push(String::new()); - } - } - - self.pop_indent(); - - parts.push("}".to_string()); - - parts.join("\n") - } - - fn visit_test_function(&mut self, func: &TestFunction) -> String { - let mut parts = Vec::new(); - - // Emit attributes - for attr in &func.attributes { - let attr_str = match attr { - Attribute::Test => "#[test]", - Attribute::ShouldPanic => "#[should_panic]", - }; - parts.push(format!("{}{}", self.indent(), attr_str)); - } - - // Function signature - parts.push(format!("{}fn {}() {{", self.indent(), func.name)); - - self.push_indent(); - - // Call helpers - if !func.helpers.is_empty() { - let helpers_chain = if func.helpers.len() == 1 { - format!( - "let _ctx = {}({}::default());", - func.helpers[0], CONTEXT_STRUCT_NAME - ) - } else { - let mut chain = format!("{}::default()", CONTEXT_STRUCT_NAME); - for helper in &func.helpers { - chain = format!("{}({})", helper, chain); - } - format!("let _ctx = {};", chain) - }; - parts.push(format!("{}{}", self.indent(), helpers_chain)); - } - - // Emit comments - for child in &func.children { - let code = self.visit(child); - if !code.is_empty() { - parts.push(code); - } - } - - self.pop_indent(); - - parts.push(format!("{}}}", self.indent())); - - parts.join("\n") - } - - fn visit_comment(&mut self, comment: &Comment) -> String { - self.emit_comment(&comment.text, comment.format) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_emit_context() { - let mut emitter = Emitter::new(false); - let context = ContextStruct::default(); - let output = emitter.visit_context(&context); - - assert!(output.contains("struct TestContext")); - assert!(output.contains("#[derive(Default)]")); - } - - #[test] - fn test_emit_helper() { - let mut emitter = Emitter::new(false); - let helper = HelperFunction { - name: "when_x_is_greater".to_string(), - doc: Some("When x is greater".to_string()), - span: None, - }; - let output = emitter.visit_helper(&helper); - - assert!(output.contains("fn when_x_is_greater")); - assert!(output.contains("mut ctx: TestContext")); - assert!(output.contains("/// Helper: When x is greater")); - } -} diff --git a/crates/rust/src/scaffold/generator.rs b/crates/rust/src/scaffold/generator.rs new file mode 100644 index 0000000..d8b422f --- /dev/null +++ b/crates/rust/src/scaffold/generator.rs @@ -0,0 +1,350 @@ +//! Direct code generation using quote! macro. + +use bulloak_syntax::{Action, Ast}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use std::collections::HashSet; + +use crate::{ + config::Config, + constants::{CONTEXT_STRUCT_NAME, PANIC_KEYWORDS}, + scaffold::comment, + utils::to_snake_case, +}; + +/// Generate Rust test code from an AST using quote! macro. +pub struct Generator { + /// Whether to format descriptions. + format_descriptions: bool, + /// Whether to skip helper functions. + skip_helpers: bool, +} + +impl Generator { + /// Create a new generator. + #[must_use] + pub fn new(cfg: &Config) -> Self { + Self { + format_descriptions: cfg.format_descriptions, + skip_helpers: cfg.skip_helpers, + } + } + + /// Generate complete Rust test file from AST. + pub fn generate(&self, ast: &Ast) -> anyhow::Result { + let ast_root = match ast { + Ast::Root(r) => r, + _ => anyhow::bail!("Expected Root node"), + }; + + // Generate all parts + let context_struct = self.generate_context_struct(); + let helpers = if self.skip_helpers { + TokenStream::new() + } else { + self.generate_helpers(&ast_root.children) + }; + let test_module = self.generate_test_module(&ast_root.children)?; + + // Combine everything + let tokens = quote! { + #context_struct + + #helpers + + #test_module + }; + + // Format the output using prettyplease + let syntax_tree = syn::parse2(tokens)?; + let mut formatted = prettyplease::unparse(&syntax_tree); + + // Post-process: add header comment + formatted = format!("// Generated by bulloak\n\n{}", formatted); + + // Post-process: fix doc comment formatting (add space after ///) + formatted = formatted.replace("///Helper:", "/// Helper:"); + + // Post-process: add action comments to test function bodies + formatted = self.add_test_body_comments(formatted, &ast_root.children); + + Ok(formatted) + } + + /// Add comments to test function bodies based on action titles. + fn add_test_body_comments(&self, formatted: String, children: &[Ast]) -> String { + let mut test_comments = Vec::new(); + self.collect_test_comments(children, &[], &mut test_comments); + + let mut result = formatted; + for (test_name, comment) in test_comments { + // Find the test function and add the comment + let pattern = format!("fn {}() {{", test_name); + if let Some(pos) = result.find(&pattern) { + let closing_brace_pos = pos + pattern.len(); + // Check if it's an empty body (just whitespace before }) + if let Some(next_brace) = result[closing_brace_pos..].find('}') { + let body = &result[closing_brace_pos..closing_brace_pos + next_brace]; + // Only add comment if body is empty or just contains helper setup + if body.trim().is_empty() { + // Empty body - just add comment with proper indentation + let comment_str = format!("\n {}\n ", comment); + let insertion_pos = closing_brace_pos + next_brace; + result.insert_str(insertion_pos, &comment_str); + } else if !body.contains("//") && body.contains("let _ctx") { + // Has helper call - add comment after it by replacing trailing whitespace + let trimmed_body = body.trim_end(); + let chars_to_remove = body.len() - trimmed_body.len(); + result.replace_range( + closing_brace_pos + next_brace - chars_to_remove..closing_brace_pos + next_brace, + &format!("\n {}\n ", comment) + ); + } + } + } + } + + result + } + + /// Collect test function names and their comments. + fn collect_test_comments( + &self, + children: &[Ast], + parent_helpers: &[String], + comments: &mut Vec<(String, String)>, + ) { + for child in children { + match child { + Ast::Condition(condition) => { + let helper_name = to_snake_case(&condition.title); + let mut new_helpers = parent_helpers.to_vec(); + new_helpers.push(helper_name); + self.collect_test_comments(&condition.children, &new_helpers, comments); + } + Ast::Action(action) => { + let test_name = self.action_to_test_name(action, parent_helpers); + let comment = format!("// {}", self.format_comment(&action.title)); + comments.push((test_name, comment)); + } + _ => {} + } + } + } + + /// Generate the TestContext struct. + fn generate_context_struct(&self) -> TokenStream { + let context_name = format_ident!("{}", CONTEXT_STRUCT_NAME); + quote! { + /// Context for test conditions + #[derive(Default)] + struct #context_name { + // Add fields as needed + } + } + } + + /// Generate all helper functions from conditions. + fn generate_helpers(&self, children: &[Ast]) -> TokenStream { + let mut helpers = Vec::new(); + let mut seen = HashSet::new(); + + self.collect_helpers_recursive(children, &mut helpers, &mut seen); + + let helper_fns: Vec<_> = helpers + .iter() + .map(|(name, doc)| self.generate_helper(name, doc)) + .collect(); + + quote! { + #(#helper_fns)* + } + } + + /// Recursively collect unique helper functions. + fn collect_helpers_recursive( + &self, + children: &[Ast], + helpers: &mut Vec<(String, String)>, + seen: &mut HashSet, + ) { + for child in children { + if let Ast::Condition(condition) = child { + let name = to_snake_case(&condition.title); + if !seen.contains(&name) { + seen.insert(name.clone()); + helpers.push((name, condition.title.clone())); + } + self.collect_helpers_recursive(&condition.children, helpers, seen); + } + } + } + + /// Generate a single helper function. + fn generate_helper(&self, name: &str, doc: &str) -> TokenStream { + let fn_name = format_ident!("{}", name); + let context_ty = format_ident!("{}", CONTEXT_STRUCT_NAME); + let doc_comment = format!("Helper: {}", doc); + + quote! { + #[doc = #doc_comment] + fn #fn_name(mut ctx: #context_ty) -> #context_ty { + // TODO: Set up condition + ctx + } + } + } + + /// Generate the test module. + fn generate_test_module(&self, children: &[Ast]) -> anyhow::Result { + let test_fns = self.process_children(children, &[])?; + + Ok(quote! { + #[cfg(test)] + mod tests { + use super::*; + + #(#test_fns)* + } + }) + } + + /// Process AST children to generate test functions. + fn process_children( + &self, + children: &[Ast], + parent_helpers: &[String], + ) -> anyhow::Result> { + let mut test_fns = Vec::new(); + + for child in children { + match child { + Ast::Condition(condition) => { + let helper_name = to_snake_case(&condition.title); + let mut new_helpers = parent_helpers.to_vec(); + new_helpers.push(helper_name); + test_fns.extend(self.process_children(&condition.children, &new_helpers)?); + } + Ast::Action(action) => { + test_fns.push(self.generate_test_function(action, parent_helpers)?); + } + _ => {} + } + } + + Ok(test_fns) + } + + /// Generate a test function from an action. + fn generate_test_function( + &self, + action: &Action, + helpers: &[String], + ) -> anyhow::Result { + let test_name = self.action_to_test_name(action, helpers); + let test_fn_name = format_ident!("{}", test_name); + let should_panic = self.should_panic(&action.title); + + // Collect comments as strings for the body + let mut comment_lines = vec![format!("// {}", self.format_comment(&action.title))]; + for desc_ast in &action.children { + if let Ast::ActionDescription(desc) = desc_ast { + comment_lines.push(format!("// {}", self.format_comment(&desc.text))); + } + } + let body_comments = comment_lines.join("\n "); + + // Generate helper calls + let helper_calls = if helpers.is_empty() { + String::new() + } else if helpers.len() == 1 { + format!("let _ctx = {}({}::default());", &helpers[0], CONTEXT_STRUCT_NAME) + } else { + // Chain multiple helpers + let mut chain = format!("{}::default()", CONTEXT_STRUCT_NAME); + for helper in helpers { + chain = format!("{}({})", helper, chain); + } + format!("let _ctx = {};", chain) + }; + + // Build complete function body as a string + let body_str = if helper_calls.is_empty() { + body_comments + } else { + format!("{}\n {}", helper_calls, body_comments) + }; + + // Parse the body as tokens + let body_tokens: TokenStream = body_str.parse().unwrap_or_else(|_| { + // Fallback to just comments if parsing fails + quote! {} + }); + + // Build test function + let test_fn = if should_panic { + quote! { + #[test] + #[should_panic] + fn #test_fn_name() { + #body_tokens + } + } + } else { + quote! { + #[test] + fn #test_fn_name() { + #body_tokens + } + } + }; + + Ok(test_fn) + } + + /// Convert action to test name. + fn action_to_test_name(&self, action: &Action, helpers: &[String]) -> String { + let action_part = to_snake_case(&action.title); + + if helpers.is_empty() { + format!("test_{}", action_part) + } else { + let last_helper = &helpers[helpers.len() - 1]; + format!("test_{}_{}", last_helper, action_part) + } + } + + + /// Check if action should panic. + fn should_panic(&self, title: &str) -> bool { + let title_lower = title.to_lowercase(); + PANIC_KEYWORDS + .iter() + .any(|keyword| title_lower.contains(keyword)) + } + + /// Format a comment string. + fn format_comment(&self, text: &str) -> String { + if self.format_descriptions { + comment::format_comment(text) + } else { + text.to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + + #[test] + fn test_should_panic() { + let cfg = Config::default(); + let gen = Generator::new(&cfg); + + assert!(gen.should_panic("It should panic")); + assert!(gen.should_panic("It should revert")); + assert!(!gen.should_panic("It should return a value")); + } +} diff --git a/crates/rust/src/scaffold/mod.rs b/crates/rust/src/scaffold/mod.rs index 23ee9d4..56c3f05 100644 --- a/crates/rust/src/scaffold/mod.rs +++ b/crates/rust/src/scaffold/mod.rs @@ -1,11 +1,11 @@ //! Scaffold module for generating Rust test code. pub mod comment; -pub mod emitter; +pub mod generator; -pub use emitter::Emitter; +pub use generator::Generator; -use crate::{config::Config, hir::Translator}; +use crate::config::Config; use anyhow::Result; use bulloak_syntax::Ast; @@ -15,13 +15,6 @@ use bulloak_syntax::Ast; /// /// Returns an error if scaffolding fails. pub fn scaffold(ast: &Ast, cfg: &Config) -> Result { - // Translate AST to HIR - let translator = Translator::new(cfg.format_descriptions, cfg.skip_helpers); - let hir = translator.translate(ast)?; - - // Emit Rust code from HIR - let emitter = Emitter::new(cfg.format_descriptions); - let code = emitter.emit(&hir); - - Ok(code) + let generator = Generator::new(cfg); + generator.generate(ast) } diff --git a/crates/rust/src/utils.rs b/crates/rust/src/utils.rs new file mode 100644 index 0000000..44bca21 --- /dev/null +++ b/crates/rust/src/utils.rs @@ -0,0 +1,62 @@ +//! Utility functions for the Rust backend. + +/// Convert string to snake_case. +/// +/// Strips common BDD prefixes (when, given, it) and converts to snake_case. +pub(crate) fn to_snake_case(s: &str) -> String { + let s = s.trim(); + let s = s + .strip_prefix("when ") + .or_else(|| s.strip_prefix("When ")) + .or_else(|| s.strip_prefix("WHEN ")) + .or_else(|| s.strip_prefix("given ")) + .or_else(|| s.strip_prefix("Given ")) + .or_else(|| s.strip_prefix("GIVEN ")) + .or_else(|| s.strip_prefix("it ")) + .or_else(|| s.strip_prefix("It ")) + .or_else(|| s.strip_prefix("IT ")) + .unwrap_or(s); + + let mut result = String::new(); + let mut prev_is_alphanumeric = false; + + for c in s.chars() { + if c.is_alphanumeric() { + if c.is_uppercase() && prev_is_alphanumeric && !result.is_empty() { + result.push('_'); + } + result.push(c.to_ascii_lowercase()); + prev_is_alphanumeric = true; + } else if c.is_whitespace() || c == '-' { + if prev_is_alphanumeric { + result.push('_'); + prev_is_alphanumeric = false; + } + } else { + prev_is_alphanumeric = false; + } + } + + result.trim_end_matches('_').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_snake_case() { + assert_eq!( + to_snake_case("when first arg is smaller"), + "first_arg_is_smaller" + ); + assert_eq!( + to_snake_case("It should return the sum"), + "should_return_the_sum" + ); + assert_eq!( + to_snake_case("given a valid input"), + "a_valid_input" + ); + } +} From c0ffeeabbde4abbd669444ff17b59640c72f2f4d Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Mon, 27 Oct 2025 18:41:21 +0100 Subject: [PATCH 03/20] fix: test format --- crates/bulloak/tests/scaffold_rust.rs | 9 +- crates/bulloak/tests/scaffold_rust/basic.tree | 2 +- .../bulloak/tests/scaffold_rust/basic_test.rs | 9 +- .../tests/scaffold_rust/deeply_nested.tree | 18 +++ .../tests/scaffold_rust/deeply_nested_test.rs | 112 +++++++++++++++ .../tests/scaffold_rust/multiple_actions.tree | 8 ++ .../scaffold_rust/multiple_actions_test.rs | 30 ++++ .../bulloak/tests/scaffold_rust/nested.tree | 13 ++ .../tests/scaffold_rust/nested_test.rs | 64 +++++++++ .../tests/scaffold_rust/with_panic_test.rs | 4 +- .../rust/src/check/rules/structural_match.rs | 60 +++++--- crates/rust/src/scaffold/generator.rs | 132 +++++++++++++----- 12 files changed, 402 insertions(+), 59 deletions(-) create mode 100644 crates/bulloak/tests/scaffold_rust/deeply_nested.tree create mode 100644 crates/bulloak/tests/scaffold_rust/deeply_nested_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/multiple_actions.tree create mode 100644 crates/bulloak/tests/scaffold_rust/multiple_actions_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/nested.tree create mode 100644 crates/bulloak/tests/scaffold_rust/nested_test.rs diff --git a/crates/bulloak/tests/scaffold_rust.rs b/crates/bulloak/tests/scaffold_rust.rs index bf5418d..97c092e 100644 --- a/crates/bulloak/tests/scaffold_rust.rs +++ b/crates/bulloak/tests/scaffold_rust.rs @@ -12,7 +12,14 @@ fn scaffolds_rust_trees() { let cwd = env::current_dir().unwrap(); let binary_path = get_binary_path(); let tests_path = cwd.join("tests").join("scaffold_rust"); - let trees = ["basic.tree", "with_panic.tree", "no_helpers.tree"]; + let trees = [ + "basic.tree", + "with_panic.tree", + "no_helpers.tree", + "nested.tree", + "deeply_nested.tree", + "multiple_actions.tree", + ]; for tree_name in trees { let tree_path = tests_path.join(tree_name); diff --git a/crates/bulloak/tests/scaffold_rust/basic.tree b/crates/bulloak/tests/scaffold_rust/basic.tree index 1f07838..c2dac26 100644 --- a/crates/bulloak/tests/scaffold_rust/basic.tree +++ b/crates/bulloak/tests/scaffold_rust/basic.tree @@ -1,5 +1,5 @@ hash_pair -├── It should never panic. +├── It should always work. ├── When first arg is smaller than second arg │ └── It should match the result of hash(a, b). └── When first arg is bigger than second arg diff --git a/crates/bulloak/tests/scaffold_rust/basic_test.rs b/crates/bulloak/tests/scaffold_rust/basic_test.rs index 2c57aac..3fd6640 100644 --- a/crates/bulloak/tests/scaffold_rust/basic_test.rs +++ b/crates/bulloak/tests/scaffold_rust/basic_test.rs @@ -15,17 +15,16 @@ fn first_arg_is_bigger_than_second_arg(mut ctx: TestContext) -> TestContext { mod tests { use super::*; #[test] - #[should_panic] - fn test_should_never_panic() { - // It should never panic. + fn test_should_always_work() { + // It should always work. } #[test] - fn test_first_arg_is_smaller_than_second_arg_should_match_the_result_of_hashab() { + fn test_when_first_arg_is_smaller_than_second_arg() { let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); // It should match the result of hash(a, b). } #[test] - fn test_first_arg_is_bigger_than_second_arg_should_match_the_result_of_hashba() { + fn test_when_first_arg_is_bigger_than_second_arg() { let _ctx = first_arg_is_bigger_than_second_arg(TestContext::default()); // It should match the result of hash(b, a). } diff --git a/crates/bulloak/tests/scaffold_rust/deeply_nested.tree b/crates/bulloak/tests/scaffold_rust/deeply_nested.tree new file mode 100644 index 0000000..ccd1544 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/deeply_nested.tree @@ -0,0 +1,18 @@ +validate_config +├── when config is null +│ └── it should revert with null config error +└── when config is not null + ├── given version is outdated + │ └── it should revert with version error + └── given version is current + ├── when permissions are empty + │ └── it should revert with permissions error + └── when permissions are set + ├── given user is not authorized + │ ├── when user is banned + │ │ └── it should revert with banned error + │ └── when user is unknown + │ └── it should revert with unauthorized error + └── given user is authorized + ├── it should validate successfully + └── it should return config data diff --git a/crates/bulloak/tests/scaffold_rust/deeply_nested_test.rs b/crates/bulloak/tests/scaffold_rust/deeply_nested_test.rs new file mode 100644 index 0000000..5b41618 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/deeply_nested_test.rs @@ -0,0 +1,112 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: when config is null +fn config_is_null(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when config is not null +fn config_is_not_null(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given version is outdated +fn version_is_outdated(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given version is current +fn version_is_current(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when permissions are empty +fn permissions_are_empty(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when permissions are set +fn permissions_are_set(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given user is not authorized +fn user_is_not_authorized(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when user is banned +fn user_is_banned(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when user is unknown +fn user_is_unknown(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given user is authorized +fn user_is_authorized(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[should_panic] + fn test_when_config_is_null() { + let _ctx = config_is_null(TestContext::default()); + // it should revert with null config error + } + #[test] + #[should_panic] + fn test_when_version_is_outdated() { + let _ctx = version_is_outdated(config_is_not_null(TestContext::default())); + // it should revert with version error + } + #[test] + #[should_panic] + fn test_when_permissions_are_empty() { + let _ctx = permissions_are_empty( + version_is_current(config_is_not_null(TestContext::default())), + ); + // it should revert with permissions error + } + #[test] + #[should_panic] + fn test_when_user_is_banned() { + let _ctx = user_is_banned( + user_is_not_authorized( + permissions_are_set( + version_is_current(config_is_not_null(TestContext::default())), + ), + ), + ); + // it should revert with banned error + } + #[test] + #[should_panic] + fn test_when_user_is_unknown() { + let _ctx = user_is_unknown( + user_is_not_authorized( + permissions_are_set( + version_is_current(config_is_not_null(TestContext::default())), + ), + ), + ); + // it should revert with unauthorized error + } + #[test] + fn test_when_user_is_authorized() { + let _ctx = user_is_authorized( + permissions_are_set( + version_is_current(config_is_not_null(TestContext::default())), + ), + ); + // it should validate successfully + // it should return config data + } + #[test] + fn test_when_user_is_authorized() { + let _ctx = user_is_authorized( + permissions_are_set( + version_is_current(config_is_not_null(TestContext::default())), + ), + ); + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/multiple_actions.tree b/crates/bulloak/tests/scaffold_rust/multiple_actions.tree new file mode 100644 index 0000000..edc0ba0 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/multiple_actions.tree @@ -0,0 +1,8 @@ +process_payment +├── it should validate input +├── it should check balance +└── when balance sufficient + ├── it should deduct amount + ├── it should update ledger + ├── it should emit event + └── it should return receipt diff --git a/crates/bulloak/tests/scaffold_rust/multiple_actions_test.rs b/crates/bulloak/tests/scaffold_rust/multiple_actions_test.rs new file mode 100644 index 0000000..01f43d8 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/multiple_actions_test.rs @@ -0,0 +1,30 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: when balance sufficient +fn balance_sufficient(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_should_validate_input() { + // it should validate input + } + #[test] + fn test_should_check_balance() { + // it should check balance + } + #[test] + fn test_when_balance_sufficient() { + let _ctx = balance_sufficient(TestContext::default()); + // it should deduct amount + // it should update ledger + // it should emit event + // it should return receipt + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/nested.tree b/crates/bulloak/tests/scaffold_rust/nested.tree new file mode 100644 index 0000000..02778ee --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/nested.tree @@ -0,0 +1,13 @@ +transfer +├── when amount is zero +│ └── it should revert +└── when amount is not zero + ├── given sender has insufficient balance + │ └── it should revert + └── given sender has sufficient balance + ├── when recipient is the sender + │ └── it should succeed without transfer + └── when recipient is different + ├── it should transfer the amount + ├── it should update balances + └── it should emit a Transfer event diff --git a/crates/bulloak/tests/scaffold_rust/nested_test.rs b/crates/bulloak/tests/scaffold_rust/nested_test.rs new file mode 100644 index 0000000..f63f9f1 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/nested_test.rs @@ -0,0 +1,64 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: when amount is zero +fn amount_is_zero(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when amount is not zero +fn amount_is_not_zero(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given sender has insufficient balance +fn sender_has_insufficient_balance(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given sender has sufficient balance +fn sender_has_sufficient_balance(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when recipient is the sender +fn recipient_is_the_sender(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when recipient is different +fn recipient_is_different(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[should_panic] + fn test_when_amount_is_zero() { + let _ctx = amount_is_zero(TestContext::default()); + // it should revert + } + #[test] + #[should_panic] + fn test_when_sender_has_insufficient_balance() { + let _ctx = sender_has_insufficient_balance( + amount_is_not_zero(TestContext::default()), + ); + // it should revert + } + #[test] + fn test_when_recipient_is_the_sender() { + let _ctx = recipient_is_the_sender( + sender_has_sufficient_balance(amount_is_not_zero(TestContext::default())), + ); + // it should succeed without transfer + } + #[test] + fn test_when_recipient_is_different() { + let _ctx = recipient_is_different( + sender_has_sufficient_balance(amount_is_not_zero(TestContext::default())), + ); + // it should transfer the amount + // it should update balances + // it should emit a Transfer event + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/with_panic_test.rs b/crates/bulloak/tests/scaffold_rust/with_panic_test.rs index 24cbbe2..8c9076d 100644 --- a/crates/bulloak/tests/scaffold_rust/with_panic_test.rs +++ b/crates/bulloak/tests/scaffold_rust/with_panic_test.rs @@ -16,12 +16,12 @@ mod tests { use super::*; #[test] #[should_panic] - fn test_divisor_is_zero_should_panic_with_division_by_zero() { + fn test_when_divisor_is_zero() { let _ctx = divisor_is_zero(TestContext::default()); // It should panic with division by zero. } #[test] - fn test_divisor_is_nonzero_should_return_the_quotient() { + fn test_when_divisor_is_nonzero() { let _ctx = divisor_is_nonzero(TestContext::default()); // It should return the quotient. } diff --git a/crates/rust/src/check/rules/structural_match.rs b/crates/rust/src/check/rules/structural_match.rs index 2888d4e..4cdccb9 100644 --- a/crates/rust/src/check/rules/structural_match.rs +++ b/crates/rust/src/check/rules/structural_match.rs @@ -167,25 +167,53 @@ fn collect_tests_recursive( let helper_name = to_snake_case(&condition.title); let mut new_helpers = parent_helpers.to_vec(); new_helpers.push(helper_name); + + // Collect all direct action children of this condition + let actions: Vec<&bulloak_syntax::Action> = condition.children.iter() + .filter_map(|c| if let Ast::Action(a) = c { Some(a) } else { None }) + .collect(); + + if !actions.is_empty() { + // Generate a single test for all actions under this condition + let test_name = if new_helpers.is_empty() { + let action_part = to_snake_case(&actions[0].title); + format!("test_{}", action_part) + } else { + let last_helper = &new_helpers[new_helpers.len() - 1]; + format!("test_when_{}", last_helper) + }; + + // Check if any action should panic + let should_panic = actions.iter().any(|action| { + action.title.to_lowercase() + .split_whitespace() + .any(|w| matches!(w, "panic" | "panics" | "revert" | "reverts" | "error" | "errors" | "fail" | "fails")) + }); + + tests.push(TestInfo { + name: test_name, + should_panic, + }); + } + + // Process nested conditions collect_tests_recursive(&condition.children, &new_helpers, tests, generator); } Ast::Action(action) => { - let action_part = to_snake_case(&action.title); - let test_name = if parent_helpers.is_empty() { - format!("test_{}", action_part) - } else { - let last_helper = &parent_helpers[parent_helpers.len() - 1]; - format!("test_{}_{}", last_helper, action_part) - }; - - let should_panic = action.title.to_lowercase() - .split_whitespace() - .any(|w| matches!(w, "panic" | "panics" | "revert" | "reverts" | "error" | "errors" | "fail" | "fails")); - - tests.push(TestInfo { - name: test_name, - should_panic, - }); + // Root-level action (no condition) + if parent_helpers.is_empty() { + let action_part = to_snake_case(&action.title); + let test_name = format!("test_{}", action_part); + + let should_panic = action.title.to_lowercase() + .split_whitespace() + .any(|w| matches!(w, "panic" | "panics" | "revert" | "reverts" | "error" | "errors" | "fail" | "fails")); + + tests.push(TestInfo { + name: test_name, + should_panic, + }); + } } _ => {} } diff --git a/crates/rust/src/scaffold/generator.rs b/crates/rust/src/scaffold/generator.rs index d8b422f..e738181 100644 --- a/crates/rust/src/scaffold/generator.rs +++ b/crates/rust/src/scaffold/generator.rs @@ -77,27 +77,29 @@ impl Generator { self.collect_test_comments(children, &[], &mut test_comments); let mut result = formatted; - for (test_name, comment) in test_comments { - // Find the test function and add the comment + for (test_name, comments) in test_comments { + // Find the test function and add the comments let pattern = format!("fn {}() {{", test_name); if let Some(pos) = result.find(&pattern) { let closing_brace_pos = pos + pattern.len(); // Check if it's an empty body (just whitespace before }) if let Some(next_brace) = result[closing_brace_pos..].find('}') { let body = &result[closing_brace_pos..closing_brace_pos + next_brace]; - // Only add comment if body is empty or just contains helper setup + // Only add comments if body is empty or just contains helper setup if body.trim().is_empty() { - // Empty body - just add comment with proper indentation - let comment_str = format!("\n {}\n ", comment); + // Empty body - just add comments with proper indentation + let all_comments = comments.join("\n "); + let comment_str = format!("\n {}\n ", all_comments); let insertion_pos = closing_brace_pos + next_brace; result.insert_str(insertion_pos, &comment_str); } else if !body.contains("//") && body.contains("let _ctx") { - // Has helper call - add comment after it by replacing trailing whitespace + // Has helper call - add comments after it + let all_comments = comments.join("\n "); let trimmed_body = body.trim_end(); let chars_to_remove = body.len() - trimmed_body.len(); result.replace_range( closing_brace_pos + next_brace - chars_to_remove..closing_brace_pos + next_brace, - &format!("\n {}\n ", comment) + &format!("\n {}\n ", all_comments) ); } } @@ -107,12 +109,12 @@ impl Generator { result } - /// Collect test function names and their comments. + /// Collect test function names and their comments (grouped by test function). fn collect_test_comments( &self, children: &[Ast], parent_helpers: &[String], - comments: &mut Vec<(String, String)>, + comments: &mut Vec<(String, Vec)>, ) { for child in children { match child { @@ -120,12 +122,35 @@ impl Generator { let helper_name = to_snake_case(&condition.title); let mut new_helpers = parent_helpers.to_vec(); new_helpers.push(helper_name); + + // Collect all action comments under this condition + let action_comments: Vec = condition.children.iter() + .filter_map(|c| if let Ast::Action(a) = c { Some(a) } else { None }) + .map(|action| format!("// {}", self.format_comment(&action.title))) + .collect(); + + if !action_comments.is_empty() { + let test_name = if new_helpers.is_empty() { + let action_part = to_snake_case(&condition.title); + format!("test_{}", action_part) + } else { + let last_helper = &new_helpers[new_helpers.len() - 1]; + format!("test_when_{}", last_helper) + }; + comments.push((test_name, action_comments)); + } + + // Process nested conditions self.collect_test_comments(&condition.children, &new_helpers, comments); } Ast::Action(action) => { - let test_name = self.action_to_test_name(action, parent_helpers); - let comment = format!("// {}", self.format_comment(&action.title)); - comments.push((test_name, comment)); + // Root-level action (no condition) + if parent_helpers.is_empty() { + let action_part = to_snake_case(&action.title); + let test_name = format!("test_{}", action_part); + let comment = format!("// {}", self.format_comment(&action.title)); + comments.push((test_name, vec![comment])); + } } _ => {} } @@ -223,10 +248,34 @@ impl Generator { let helper_name = to_snake_case(&condition.title); let mut new_helpers = parent_helpers.to_vec(); new_helpers.push(helper_name); - test_fns.extend(self.process_children(&condition.children, &new_helpers)?); + + // Collect all direct action children of this condition + let actions: Vec<&Action> = condition.children.iter() + .filter_map(|c| if let Ast::Action(a) = c { Some(a) } else { None }) + .collect(); + + if !actions.is_empty() { + // Generate a single test function for all actions under this condition + test_fns.push(self.generate_test_function_for_condition(&actions, &new_helpers)?); + } + + // Process only nested conditions (not actions, as they were already processed above) + let nested_conditions: Vec<&Ast> = condition.children.iter() + .filter(|c| !matches!(c, Ast::Action(_))) + .collect(); + + for nested_child in nested_conditions { + if let Ast::Condition(nested_cond) = nested_child { + let nested_helper_name = to_snake_case(&nested_cond.title); + let mut nested_helpers = new_helpers.clone(); + nested_helpers.push(nested_helper_name); + test_fns.extend(self.process_children(&nested_cond.children, &nested_helpers)?); + } + } } Ast::Action(action) => { - test_fns.push(self.generate_test_function(action, parent_helpers)?); + // Action at root level (no condition) + test_fns.push(self.generate_test_function(&[action], parent_helpers)?); } _ => {} } @@ -235,21 +284,47 @@ impl Generator { Ok(test_fns) } - /// Generate a test function from an action. + /// Generate a test function for a condition with multiple actions. + fn generate_test_function_for_condition( + &self, + actions: &[&Action], + helpers: &[String], + ) -> anyhow::Result { + self.generate_test_function(actions, helpers) + } + + /// Generate a test function from one or more actions. fn generate_test_function( &self, - action: &Action, + actions: &[&Action], helpers: &[String], ) -> anyhow::Result { - let test_name = self.action_to_test_name(action, helpers); + if actions.is_empty() { + anyhow::bail!("Cannot generate test function with no actions"); + } + + // Use the last helper (condition) for the test name if helpers exist + let test_name = if helpers.is_empty() { + let action_part = to_snake_case(&actions[0].title); + format!("test_{}", action_part) + } else { + let last_helper = &helpers[helpers.len() - 1]; + format!("test_when_{}", last_helper) + }; + let test_fn_name = format_ident!("{}", test_name); - let should_panic = self.should_panic(&action.title); - // Collect comments as strings for the body - let mut comment_lines = vec![format!("// {}", self.format_comment(&action.title))]; - for desc_ast in &action.children { - if let Ast::ActionDescription(desc) = desc_ast { - comment_lines.push(format!("// {}", self.format_comment(&desc.text))); + // Check if any action should panic + let should_panic = actions.iter().any(|a| self.should_panic(&a.title)); + + // Collect comments from all actions + let mut comment_lines = Vec::new(); + for action in actions { + comment_lines.push(format!("// {}", self.format_comment(&action.title))); + for desc_ast in &action.children { + if let Ast::ActionDescription(desc) = desc_ast { + comment_lines.push(format!("// {}", self.format_comment(&desc.text))); + } } } let body_comments = comment_lines.join("\n "); @@ -302,17 +377,6 @@ impl Generator { Ok(test_fn) } - /// Convert action to test name. - fn action_to_test_name(&self, action: &Action, helpers: &[String]) -> String { - let action_part = to_snake_case(&action.title); - - if helpers.is_empty() { - format!("test_{}", action_part) - } else { - let last_helper = &helpers[helpers.len() - 1]; - format!("test_{}_{}", last_helper, action_part) - } - } /// Check if action should panic. From c0ffee6fb72197242f59e457a07d22fa394d3376 Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Mon, 27 Oct 2025 18:57:04 +0100 Subject: [PATCH 04/20] chore: refactor --- crates/bulloak/src/check.rs | 6 +- crates/bulloak/src/scaffold.rs | 11 ++-- .../rust/src/check/rules/structural_match.rs | 13 ++-- crates/rust/src/scaffold/generator.rs | 62 ++++++++++--------- 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/crates/bulloak/src/check.rs b/crates/bulloak/src/check.rs index a2f9cd1..7cb97b2 100644 --- a/crates/bulloak/src/check.rs +++ b/crates/bulloak/src/check.rs @@ -57,11 +57,11 @@ impl Check { /// /// Note that we don't deal with `solang_parser` errors at all. pub(crate) fn run(&self, cfg: &Cli) { - match self.backend { - Backend::Rust => return self.run_rust_check(), - Backend::Solidity => {} // Continue with Solidity check below + if self.backend == Backend::Rust { + return self.run_rust_check(); } + // Solidity check let mut specs = Vec::new(); for pattern in &self.files { match expand_glob(pattern.clone()) { diff --git a/crates/bulloak/src/scaffold.rs b/crates/bulloak/src/scaffold.rs index 89c7440..61e855d 100644 --- a/crates/bulloak/src/scaffold.rs +++ b/crates/bulloak/src/scaffold.rs @@ -111,7 +111,6 @@ impl Scaffold { match self.backend { Backend::Rust => { - // Rust backend let ast = bulloak_syntax::parse_one(&text)?; let rust_cfg = bulloak_rust::Config { files: self.files.iter().map(|p| p.display().to_string()).collect(), @@ -121,17 +120,17 @@ impl Scaffold { let emitted = bulloak_rust::scaffold(&ast, &rust_cfg)?; if self.write_files { - let output_file = file.with_file_name(format!( - "{}_test.rs", - file.file_stem().unwrap().to_str().unwrap() - )); + let file_stem = file + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("Invalid file name: {}", file.display()))?; + let output_file = file.with_file_name(format!("{}_test.rs", file_stem)); self.write_file(&emitted, &output_file); } else { println!("{emitted}"); } } Backend::Solidity => { - // Solidity backend let emitted = scaffold(&text, &cfg.into())?; let formatted = fmt(&emitted).unwrap_or_else(|err| { eprintln!("{}: {}", "WARN".yellow(), err); diff --git a/crates/rust/src/check/rules/structural_match.rs b/crates/rust/src/check/rules/structural_match.rs index 4cdccb9..aebd59d 100644 --- a/crates/rust/src/check/rules/structural_match.rs +++ b/crates/rust/src/check/rules/structural_match.rs @@ -4,7 +4,6 @@ use crate::{ check::violation::{Violation, ViolationKind}, config::Config, rust::ParsedRustFile, - scaffold::Generator, utils::to_snake_case, }; use anyhow::Result; @@ -115,8 +114,6 @@ pub fn check_structural_match( /// Extract expected test structure from AST. fn extract_expected_structure(ast: &Ast, cfg: &Config) -> Result { - let generator = Generator::new(cfg); - let ast_root = match ast { Ast::Root(r) => r, _ => anyhow::bail!("Expected Root node"), @@ -127,11 +124,11 @@ fn extract_expected_structure(ast: &Ast, cfg: &Config) -> Result // Collect helpers if !cfg.skip_helpers { - collect_helpers_recursive(&ast_root.children, &mut helpers, &generator); + collect_helpers_recursive(&ast_root.children, &mut helpers); } // Collect test functions - collect_tests_recursive(&ast_root.children, &[], &mut test_functions, &generator); + collect_tests_recursive(&ast_root.children, &[], &mut test_functions); Ok(ExpectedTests { helpers, @@ -143,13 +140,12 @@ fn extract_expected_structure(ast: &Ast, cfg: &Config) -> Result fn collect_helpers_recursive( children: &[Ast], helpers: &mut HashSet, - generator: &Generator, ) { for child in children { if let Ast::Condition(condition) = child { let name = to_snake_case(&condition.title); helpers.insert(name); - collect_helpers_recursive(&condition.children, helpers, generator); + collect_helpers_recursive(&condition.children, helpers); } } } @@ -159,7 +155,6 @@ fn collect_tests_recursive( children: &[Ast], parent_helpers: &[String], tests: &mut Vec, - generator: &Generator, ) { for child in children { match child { @@ -197,7 +192,7 @@ fn collect_tests_recursive( } // Process nested conditions - collect_tests_recursive(&condition.children, &new_helpers, tests, generator); + collect_tests_recursive(&condition.children, &new_helpers, tests); } Ast::Action(action) => { // Root-level action (no condition) diff --git a/crates/rust/src/scaffold/generator.rs b/crates/rust/src/scaffold/generator.rs index e738181..f7e84e4 100644 --- a/crates/rust/src/scaffold/generator.rs +++ b/crates/rust/src/scaffold/generator.rs @@ -78,37 +78,43 @@ impl Generator { let mut result = formatted; for (test_name, comments) in test_comments { - // Find the test function and add the comments - let pattern = format!("fn {}() {{", test_name); - if let Some(pos) = result.find(&pattern) { - let closing_brace_pos = pos + pattern.len(); - // Check if it's an empty body (just whitespace before }) - if let Some(next_brace) = result[closing_brace_pos..].find('}') { - let body = &result[closing_brace_pos..closing_brace_pos + next_brace]; - // Only add comments if body is empty or just contains helper setup - if body.trim().is_empty() { - // Empty body - just add comments with proper indentation - let all_comments = comments.join("\n "); - let comment_str = format!("\n {}\n ", all_comments); - let insertion_pos = closing_brace_pos + next_brace; - result.insert_str(insertion_pos, &comment_str); - } else if !body.contains("//") && body.contains("let _ctx") { - // Has helper call - add comments after it - let all_comments = comments.join("\n "); - let trimmed_body = body.trim_end(); - let chars_to_remove = body.len() - trimmed_body.len(); - result.replace_range( - closing_brace_pos + next_brace - chars_to_remove..closing_brace_pos + next_brace, - &format!("\n {}\n ", all_comments) - ); - } - } - } + self.insert_comments_for_test(&mut result, &test_name, &comments); } result } + /// Insert comments into a specific test function body. + fn insert_comments_for_test(&self, result: &mut String, test_name: &str, comments: &[String]) { + let pattern = format!("fn {}() {{", test_name); + let Some(pos) = result.find(&pattern) else { + return; + }; + + let closing_brace_pos = pos + pattern.len(); + let Some(next_brace) = result[closing_brace_pos..].find('}') else { + return; + }; + + let body = &result[closing_brace_pos..closing_brace_pos + next_brace]; + let all_comments = comments.join("\n "); + + if body.trim().is_empty() { + // Empty body - just add comments with proper indentation + let comment_str = format!("\n {}\n ", all_comments); + let insertion_pos = closing_brace_pos + next_brace; + result.insert_str(insertion_pos, &comment_str); + } else if !body.contains("//") && body.contains("let _ctx") { + // Has helper call - add comments after it + let trimmed_body = body.trim_end(); + let chars_to_remove = body.len() - trimmed_body.len(); + result.replace_range( + closing_brace_pos + next_brace - chars_to_remove..closing_brace_pos + next_brace, + &format!("\n {}\n ", all_comments) + ); + } + } + /// Collect test function names and their comments (grouped by test function). fn collect_test_comments( &self, @@ -196,8 +202,8 @@ impl Generator { for child in children { if let Ast::Condition(condition) = child { let name = to_snake_case(&condition.title); - if !seen.contains(&name) { - seen.insert(name.clone()); + if seen.insert(name.clone()) { + // insert returns true if the value was newly inserted helpers.push((name, condition.title.clone())); } self.collect_helpers_recursive(&condition.children, helpers, seen); From c0ffeebb4ac77c39738faa3ba26e1cd8e063c2a9 Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Mon, 27 Oct 2025 21:58:07 +0100 Subject: [PATCH 05/20] feat: noir init --- Cargo.lock | 150 ++++++++- Cargo.toml | 3 +- README.md | 85 ++++- crates/bulloak/Cargo.toml | 2 + crates/bulloak/src/check.rs | 62 ++++ crates/bulloak/src/cli.rs | 19 ++ crates/bulloak/src/scaffold.rs | 16 + crates/bulloak/tests/scaffold_noir.rs | 75 +++++ crates/bulloak/tests/scaffold_noir/basic.tree | 6 + .../bulloak/tests/scaffold_noir/basic_test.nr | 30 ++ .../bulloak/tests/scaffold_noir/nested.tree | 11 + .../tests/scaffold_noir/nested_test.nr | 62 ++++ .../tests/scaffold_noir/no_helpers.tree | 2 + .../tests/scaffold_noir/no_helpers_test.nr | 6 + .../tests/scaffold_noir/with_panic.tree | 5 + .../tests/scaffold_noir/with_panic_test.nr | 25 ++ crates/noir/Cargo.toml | 29 ++ .../bulloak/tests/scaffold_noir/basic_test.nr | 0 .../tests/scaffold_noir/nested_test.nr | 0 .../tests/scaffold_noir/no_helpers_test.nr | 0 .../tests/scaffold_noir/with_panic_test.nr | 0 crates/noir/src/check/mod.rs | 19 ++ crates/noir/src/check/rules/mod.rs | 3 + .../noir/src/check/rules/structural_match.rs | 292 ++++++++++++++++++ crates/noir/src/check/violation.rs | 56 ++++ crates/noir/src/config.rs | 12 + crates/noir/src/constants.rs | 10 + crates/noir/src/lib.rs | 29 ++ crates/noir/src/noir/mod.rs | 5 + crates/noir/src/noir/parser.rs | 234 ++++++++++++++ crates/noir/src/scaffold/generator.rs | 246 +++++++++++++++ crates/noir/src/scaffold/mod.rs | 17 + crates/noir/src/utils.rs | 61 ++++ crates/noir/tests/debug_parser.rs | 52 ++++ 34 files changed, 1605 insertions(+), 19 deletions(-) create mode 100644 crates/bulloak/tests/scaffold_noir.rs create mode 100644 crates/bulloak/tests/scaffold_noir/basic.tree create mode 100644 crates/bulloak/tests/scaffold_noir/basic_test.nr create mode 100644 crates/bulloak/tests/scaffold_noir/nested.tree create mode 100644 crates/bulloak/tests/scaffold_noir/nested_test.nr create mode 100644 crates/bulloak/tests/scaffold_noir/no_helpers.tree create mode 100644 crates/bulloak/tests/scaffold_noir/no_helpers_test.nr create mode 100644 crates/bulloak/tests/scaffold_noir/with_panic.tree create mode 100644 crates/bulloak/tests/scaffold_noir/with_panic_test.nr create mode 100644 crates/noir/Cargo.toml create mode 100644 crates/noir/crates/bulloak/tests/scaffold_noir/basic_test.nr create mode 100644 crates/noir/crates/bulloak/tests/scaffold_noir/nested_test.nr create mode 100644 crates/noir/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr create mode 100644 crates/noir/crates/bulloak/tests/scaffold_noir/with_panic_test.nr create mode 100644 crates/noir/src/check/mod.rs create mode 100644 crates/noir/src/check/rules/mod.rs create mode 100644 crates/noir/src/check/rules/structural_match.rs create mode 100644 crates/noir/src/check/violation.rs create mode 100644 crates/noir/src/config.rs create mode 100644 crates/noir/src/constants.rs create mode 100644 crates/noir/src/lib.rs create mode 100644 crates/noir/src/noir/mod.rs create mode 100644 crates/noir/src/noir/parser.rs create mode 100644 crates/noir/src/scaffold/generator.rs create mode 100644 crates/noir/src/scaffold/mod.rs create mode 100644 crates/noir/src/utils.rs create mode 100644 crates/noir/tests/debug_parser.rs diff --git a/Cargo.lock b/Cargo.lock index be65967..d60f318 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,22 @@ dependencies = [ "term", ] +[[package]] +name = "assert_cmd" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd389a4b2970a01282ee455294913c0a43724daedcd1a24c3eb0ec1c1320b66" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "atomic" version = "0.6.0" @@ -319,6 +335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "542f33a8835a0884b006a0c3df3dadd99c0c3f296ed26c2fdc8028e01ad6230c" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -333,7 +350,9 @@ name = "bulloak" version = "0.9.1" dependencies = [ "anyhow", + "assert_cmd", "bulloak-foundry", + "bulloak-noir", "bulloak-rust", "bulloak-syntax", "clap", @@ -363,6 +382,20 @@ dependencies = [ "thiserror", ] +[[package]] +name = "bulloak-noir" +version = "0.9.1" +dependencies = [ + "anyhow", + "bulloak-syntax", + "indoc", + "pretty_assertions", + "tempfile", + "thiserror", + "tree-sitter", + "tree-sitter-noir", +] + [[package]] name = "bulloak-rust" version = "0.9.1" @@ -452,12 +485,14 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.0.83" +version = "1.2.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2" dependencies = [ + "find-msvc-tools", "jobserver", "libc", + "shlex", ] [[package]] @@ -758,6 +793,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -812,6 +853,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + [[package]] name = "dunce" version = "1.0.4" @@ -1068,6 +1115,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + [[package]] name = "fixed-hash" version = "0.8.0" @@ -1290,7 +1343,7 @@ dependencies = [ "bstr", "log", "regex-automata", - "regex-syntax 0.8.2", + "regex-syntax 0.8.8", ] [[package]] @@ -1581,10 +1634,11 @@ checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" [[package]] name = "jobserver" -version = "0.1.27" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ + "getrandom 0.3.2", "libc", ] @@ -2108,6 +2162,33 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_assertions" version = "1.4.0" @@ -2219,7 +2300,7 @@ dependencies = [ "rand", "rand_chacha", "rand_xorshift", - "regex-syntax 0.8.2", + "regex-syntax 0.8.8", "unarray", ] @@ -2325,25 +2406,25 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.2" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.2", + "regex-syntax 0.8.8", ] [[package]] name = "regex-automata" -version = "0.4.3" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.2", + "regex-syntax 0.8.8", ] [[package]] @@ -2354,9 +2435,9 @@ checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "regex-syntax" -version = "0.8.2" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" @@ -2737,6 +2818,12 @@ dependencies = [ "keccak", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -2986,6 +3073,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.61" @@ -3207,6 +3300,26 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree-sitter" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e747b1f9b7b931ed39a548c1fae149101497de3c1fc8d9e18c62c1a66c683d3d" +dependencies = [ + "cc", + "regex", +] + +[[package]] +name = "tree-sitter-noir" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09a0f7c33f9ea1e0ca3e3469e6660fac2c7d9539ea37e5cc1b22d98f91a374a" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "try-lock" version = "0.2.4" @@ -3314,6 +3427,15 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.4.0" diff --git a/Cargo.toml b/Cargo.toml index 6628d86..c797c58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/bulloak", "crates/foundry", "crates/rust", "crates/syntax"] +members = ["crates/bulloak", "crates/foundry", "crates/rust", "crates/syntax", "crates/noir"] [workspace.package] authors = ["Alexander Gonzalez "] @@ -36,6 +36,7 @@ all = "warn" bulloak-syntax = { path = "crates/syntax", version = "0.9.0" } bulloak-foundry = { path = "crates/foundry", version = "0.9.0" } bulloak-rust = { path = "crates/rust", version = "0.9.0" } +bulloak-noir = { path = "crates/noir", version = "0.9.0" } anyhow = "1.0.75" clap = { version = "4.3.19", features = ["derive"] } diff --git a/README.md b/README.md index 7f4b371..cf9faee 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,14 @@ # bulloak -A Solidity test generator based on the +A test generator based on the [Branching Tree Technique](https://twitter.com/PaulRBerg/status/1682346315806539776). +**Supported backends:** +- **Solidity (Foundry)**: Generates `.t.sol` files with modifiers for conditions +- **Rust**: Generates `_test.rs` files with helper functions for conditions +- **Noir**: Generates `_test.nr` files with helper functions for conditions + - [Installation](#installation) - [VSCode](#vscode) - [Usage](#usage) @@ -60,7 +65,11 @@ better user experience: - `bulloak scaffold` - `bulloak check` -### Scaffold Solidity Files +### Scaffold Test Files + +By default, `bulloak scaffold` generates Solidity test files. You can specify a different backend using the `--backend` (or `-b`) flag. + +#### Solidity (default) Say you have a `foo.tree` file with the following contents: @@ -130,10 +139,78 @@ When enabled, bulloak capitalizes the first letter of each branch description and ensures it ends with a dot, so you don't need to touch the `.tree` file to get consistent sentence casing in the scaffolded test bodies. +#### Rust + +To generate Rust test files, use `--backend rust`: + +```bash +$ bulloak scaffold --backend rust foo.tree +``` + +This will generate a `foo_test.rs` file with helper functions for conditions and `#[test]` functions for actions. The generated file will use `#[should_panic]` for actions containing panic keywords like "panic", "revert", "error", or "fail". + +```rust +// $ bulloak scaffold --backend rust foo.tree +// Generated by bulloak + +/// Helper function for condition +fn stuff_is_called() { +// TODO: Implement condition +} + +/// Helper function for condition +fn a_condition_is_met() { +// TODO: Implement condition +} + +#[test] +#[should_panic] +fn test_when_a_condition_is_met() { + stuff_is_called(); + a_condition_is_met(); + // It should revert. +} +``` + +#### Noir + +To generate Noir test files, use `--backend noir`: + +```bash +$ bulloak scaffold --backend noir foo.tree +``` + +This will generate a `foo_test.nr` file with helper functions for conditions and `#[test]` functions for actions. The generated file will use `#[test(should_fail)]` for actions containing panic keywords. + +```rust +// $ bulloak scaffold --backend noir foo.tree +// Generated by bulloak + +/// Helper function for condition +fn stuff_is_called() { +// TODO: Implement condition +} + +/// Helper function for condition +fn a_condition_is_met() { +// TODO: Implement condition +} + +#[test(should_fail)] +fn test_when_a_condition_is_met() { + stuff_is_called(); + a_condition_is_met(); + // It should revert. +} +``` + +**Note:** The `-m` (skip helpers) and `-F` (format descriptions) flags work for all backends. + ### Check That Your Code And Spec Match -You can use `bulloak check` to make sure that your Solidity files match your -spec. For example, any missing tests will be reported to you. +You can use `bulloak check` to make sure that your test files match your +spec. For example, any missing tests will be reported to you. The `--backend` +flag works the same way as in `scaffold`. Say you have the following spec: diff --git a/crates/bulloak/Cargo.toml b/crates/bulloak/Cargo.toml index eef6d06..cb2fc99 100644 --- a/crates/bulloak/Cargo.toml +++ b/crates/bulloak/Cargo.toml @@ -16,6 +16,7 @@ categories.workspace = true bulloak-syntax.workspace = true bulloak-foundry.workspace = true bulloak-rust.workspace = true +bulloak-noir.workspace = true anyhow.workspace = true clap.workspace = true @@ -28,6 +29,7 @@ glob = "0.3.2" [dev-dependencies] pretty_assertions.workspace = true criterion.workspace = true +assert_cmd = "2.0" [[bench]] name = "emit" diff --git a/crates/bulloak/src/check.rs b/crates/bulloak/src/check.rs index 7cb97b2..af3f2d1 100644 --- a/crates/bulloak/src/check.rs +++ b/crates/bulloak/src/check.rs @@ -61,6 +61,10 @@ impl Check { return self.run_rust_check(); } + if self.backend == Backend::Noir { + return self.run_noir_check(); + } + // Solidity check let mut specs = Vec::new(); for pattern in &self.files { @@ -229,6 +233,64 @@ impl Check { std::process::exit(1); } } + + /// Run check for Noir tests. + fn run_noir_check(&self) { + let mut specs = Vec::new(); + for pattern in &self.files { + match expand_glob(pattern.clone()) { + Ok(iter) => specs.extend(iter), + Err(e) => eprintln!( + "{}: could not expand {}: {}", + "warn".yellow(), + pattern.display(), + e + ), + } + } + + let noir_cfg = bulloak_noir::Config { + files: self.files.iter().map(|p| p.display().to_string()).collect(), + skip_helpers: self.skip_modifiers, + format_descriptions: self.format_descriptions, + }; + + let mut all_violations = Vec::new(); + for tree_path in specs { + match bulloak_noir::check::check(&tree_path, &noir_cfg) { + Ok(violations) => { + for violation in &violations { + eprintln!("{}", violation); + } + all_violations.extend(violations); + } + Err(e) => { + eprintln!( + "{}: Failed to check {}: {}", + "error".red(), + tree_path.display(), + e + ); + } + } + } + + if all_violations.is_empty() { + println!( + "{}", + "All checks completed successfully! No issues found.".green() + ); + } else { + let check_literal = pluralize(all_violations.len(), "check", "checks"); + eprintln!( + "\n{}: {} {} failed", + "warn".bold().yellow(), + all_violations.len(), + check_literal + ); + std::process::exit(1); + } + } } fn exit(violations: &[Violation]) { diff --git a/crates/bulloak/src/cli.rs b/crates/bulloak/src/cli.rs index f502e5c..a3aea5d 100644 --- a/crates/bulloak/src/cli.rs +++ b/crates/bulloak/src/cli.rs @@ -12,6 +12,8 @@ pub enum Backend { Solidity, /// Rust backend. Rust, + /// Noir backend. + Noir, } /// `bulloak`'s configuration. @@ -61,6 +63,23 @@ impl From<&Cli> for bulloak_foundry::config::Config { } } +impl From<&Cli> for bulloak_noir::Config { + fn from(cli: &Cli) -> Self { + match &cli.command { + Commands::Scaffold(cmd) => Self { + files: cmd.files.iter().map(|p| p.display().to_string()).collect(), + skip_helpers: cmd.skip_modifiers, + format_descriptions: cmd.format_descriptions, + }, + Commands::Check(cmd) => Self { + files: cmd.files.iter().map(|p| p.display().to_string()).collect(), + skip_helpers: cmd.skip_modifiers, + format_descriptions: cmd.format_descriptions, + }, + } + } +} + /// Main entrypoint of `bulloak`'s execution. pub(crate) fn run() -> anyhow::Result<()> { let config: Cli = diff --git a/crates/bulloak/src/scaffold.rs b/crates/bulloak/src/scaffold.rs index 61e855d..f51f329 100644 --- a/crates/bulloak/src/scaffold.rs +++ b/crates/bulloak/src/scaffold.rs @@ -130,6 +130,22 @@ impl Scaffold { println!("{emitted}"); } } + Backend::Noir => { + let ast = bulloak_syntax::parse_one(&text)?; + let noir_cfg: bulloak_noir::Config = cfg.into(); + let emitted = bulloak_noir::scaffold(&ast, &noir_cfg)?; + + if self.write_files { + let file_stem = file + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("Invalid file name: {}", file.display()))?; + let output_file = file.with_file_name(format!("{}_test.nr", file_stem)); + self.write_file(&emitted, &output_file); + } else { + println!("{emitted}"); + } + } Backend::Solidity => { let emitted = scaffold(&text, &cfg.into())?; let formatted = fmt(&emitted).unwrap_or_else(|err| { diff --git a/crates/bulloak/tests/scaffold_noir.rs b/crates/bulloak/tests/scaffold_noir.rs new file mode 100644 index 0000000..5e095e4 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir.rs @@ -0,0 +1,75 @@ +//! Integration tests for Noir scaffolding. + +use std::{fs, path::PathBuf, process::Command}; + +fn bulloak_binary() -> PathBuf { + assert_cmd::cargo::cargo_bin("bulloak") +} + +fn tests_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/scaffold_noir") +} + +fn cmd(binary: &PathBuf, command: &str, tree_path: &PathBuf, extra_args: &[&str]) -> std::process::Output { + let mut cmd = Command::new(binary); + cmd.arg(command); + cmd.args(extra_args); + cmd.arg(tree_path); + cmd.output().expect("Failed to execute bulloak") +} + +#[test] +fn test_scaffold_noir_basic() { + let binary_path = bulloak_binary(); + let tests_path = tests_path(); + let tree_path = tests_path.join("basic.tree"); + + let output = cmd(&binary_path, "scaffold", &tree_path, &["--backend", "noir"]); + let actual = String::from_utf8(output.stdout).unwrap(); + + let expected = fs::read_to_string(tests_path.join("basic_test.nr")).unwrap(); + + assert_eq!(expected.trim(), actual.trim(), "Basic scaffold output should match expected"); +} + +#[test] +fn test_scaffold_noir_with_panic() { + let binary_path = bulloak_binary(); + let tests_path = tests_path(); + let tree_path = tests_path.join("with_panic.tree"); + + let output = cmd(&binary_path, "scaffold", &tree_path, &["--backend", "noir"]); + let actual = String::from_utf8(output.stdout).unwrap(); + + let expected = fs::read_to_string(tests_path.join("with_panic_test.nr")).unwrap(); + + assert_eq!(expected.trim(), actual.trim(), "Should generate #[test(should_fail)] for panic cases"); +} + +#[test] +fn test_scaffold_noir_no_helpers() { + let binary_path = bulloak_binary(); + let tests_path = tests_path(); + let tree_path = tests_path.join("no_helpers.tree"); + + let output = cmd(&binary_path, "scaffold", &tree_path, &["--backend", "noir"]); + let actual = String::from_utf8(output.stdout).unwrap(); + + let expected = fs::read_to_string(tests_path.join("no_helpers_test.nr")).unwrap(); + + assert_eq!(expected.trim(), actual.trim(), "Simple test should work without helpers"); +} + +#[test] +fn test_scaffold_noir_nested() { + let binary_path = bulloak_binary(); + let tests_path = tests_path(); + let tree_path = tests_path.join("nested.tree"); + + let output = cmd(&binary_path, "scaffold", &tree_path, &["--backend", "noir"]); + let actual = String::from_utf8(output.stdout).unwrap(); + + let expected = fs::read_to_string(tests_path.join("nested_test.nr")).unwrap(); + + assert_eq!(expected.trim(), actual.trim(), "Nested conditions should be handled correctly"); +} diff --git a/crates/bulloak/tests/scaffold_noir/basic.tree b/crates/bulloak/tests/scaffold_noir/basic.tree new file mode 100644 index 0000000..c2dac26 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/basic.tree @@ -0,0 +1,6 @@ +hash_pair +├── It should always work. +├── When first arg is smaller than second arg +│ └── It should match the result of hash(a, b). +└── When first arg is bigger than second arg + └── It should match the result of hash(b, a). diff --git a/crates/bulloak/tests/scaffold_noir/basic_test.nr b/crates/bulloak/tests/scaffold_noir/basic_test.nr new file mode 100644 index 0000000..40854a2 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/basic_test.nr @@ -0,0 +1,30 @@ +// Generated by bulloak + +/// Helper function for condition +fn first_arg_is_bigger_than_second_arg() { +// TODO: Implement condition +} + +/// Helper function for condition +fn first_arg_is_smaller_than_second_arg() { +// TODO: Implement condition +} + +#[test] +fn test_should_always_work() { + // It should always work. +} + +#[test] +fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of hash(a, b). +} + +#[test] +fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the result of hash(b, a). +} + + diff --git a/crates/bulloak/tests/scaffold_noir/nested.tree b/crates/bulloak/tests/scaffold_noir/nested.tree new file mode 100644 index 0000000..000495c --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/nested.tree @@ -0,0 +1,11 @@ +transfer +├── When amount is zero +│ └── It should revert. +└── When amount is not zero + ├── Given sender has insufficient balance + │ └── It should revert. + └── Given sender has sufficient balance + ├── When recipient is the sender + │ └── It should succeed without transfer. + └── When recipient is different + └── It should transfer the amount. diff --git a/crates/bulloak/tests/scaffold_noir/nested_test.nr b/crates/bulloak/tests/scaffold_noir/nested_test.nr new file mode 100644 index 0000000..28d4ba3 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/nested_test.nr @@ -0,0 +1,62 @@ +// Generated by bulloak + +/// Helper function for condition +fn amount_is_not_zero() { +// TODO: Implement condition +} + +/// Helper function for condition +fn amount_is_zero() { +// TODO: Implement condition +} + +/// Helper function for condition +fn recipient_is_different() { +// TODO: Implement condition +} + +/// Helper function for condition +fn recipient_is_the_sender() { +// TODO: Implement condition +} + +/// Helper function for condition +fn sender_has_insufficient_balance() { +// TODO: Implement condition +} + +/// Helper function for condition +fn sender_has_sufficient_balance() { +// TODO: Implement condition +} + +#[test(should_fail)] +fn test_when_amount_is_zero() { + amount_is_zero(); + // It should revert. +} + +#[test(should_fail)] +fn test_when_sender_has_insufficient_balance() { + amount_is_not_zero(); + sender_has_insufficient_balance(); + // It should revert. +} + +#[test] +fn test_when_recipient_is_the_sender() { + amount_is_not_zero(); + sender_has_sufficient_balance(); + recipient_is_the_sender(); + // It should succeed without transfer. +} + +#[test] +fn test_when_recipient_is_different() { + amount_is_not_zero(); + sender_has_sufficient_balance(); + recipient_is_different(); + // It should transfer the amount. +} + + diff --git a/crates/bulloak/tests/scaffold_noir/no_helpers.tree b/crates/bulloak/tests/scaffold_noir/no_helpers.tree new file mode 100644 index 0000000..5229cab --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/no_helpers.tree @@ -0,0 +1,2 @@ +simple_test +└── It should work correctly. diff --git a/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr b/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr new file mode 100644 index 0000000..f2d3cb9 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr @@ -0,0 +1,6 @@ +// Generated by bulloak + +#[test] +fn test_should_work_correctly() { + // It should work correctly. +} diff --git a/crates/bulloak/tests/scaffold_noir/with_panic.tree b/crates/bulloak/tests/scaffold_noir/with_panic.tree new file mode 100644 index 0000000..3a31f3c --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/with_panic.tree @@ -0,0 +1,5 @@ +divide +├── When divisor is zero +│ └── It should panic with division by zero. +└── When divisor is non-zero + └── It should return the quotient. diff --git a/crates/bulloak/tests/scaffold_noir/with_panic_test.nr b/crates/bulloak/tests/scaffold_noir/with_panic_test.nr new file mode 100644 index 0000000..d1b6e7e --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/with_panic_test.nr @@ -0,0 +1,25 @@ +// Generated by bulloak + +/// Helper function for condition +fn divisor_is_nonzero() { +// TODO: Implement condition +} + +/// Helper function for condition +fn divisor_is_zero() { +// TODO: Implement condition +} + +#[test(should_fail)] +fn test_when_divisor_is_zero() { + divisor_is_zero(); + // It should panic with division by zero. +} + +#[test] +fn test_when_divisor_is_nonzero() { + divisor_is_nonzero(); + // It should return the quotient. +} + + diff --git a/crates/noir/Cargo.toml b/crates/noir/Cargo.toml new file mode 100644 index 0000000..24320cd --- /dev/null +++ b/crates/noir/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "bulloak-noir" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +readme = "./README.md" +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +description.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +bulloak-syntax.workspace = true + +anyhow.workspace = true +thiserror.workspace = true +tree-sitter = "0.20" +tree-sitter-noir = "0.0.1" + +[dev-dependencies] +pretty_assertions.workspace = true +indoc = "2.0.5" +tempfile = "3.8" + +[lints] +workspace = true diff --git a/crates/noir/crates/bulloak/tests/scaffold_noir/basic_test.nr b/crates/noir/crates/bulloak/tests/scaffold_noir/basic_test.nr new file mode 100644 index 0000000..e69de29 diff --git a/crates/noir/crates/bulloak/tests/scaffold_noir/nested_test.nr b/crates/noir/crates/bulloak/tests/scaffold_noir/nested_test.nr new file mode 100644 index 0000000..e69de29 diff --git a/crates/noir/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr b/crates/noir/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr new file mode 100644 index 0000000..e69de29 diff --git a/crates/noir/crates/bulloak/tests/scaffold_noir/with_panic_test.nr b/crates/noir/crates/bulloak/tests/scaffold_noir/with_panic_test.nr new file mode 100644 index 0000000..e69de29 diff --git a/crates/noir/src/check/mod.rs b/crates/noir/src/check/mod.rs new file mode 100644 index 0000000..ece25ad --- /dev/null +++ b/crates/noir/src/check/mod.rs @@ -0,0 +1,19 @@ +//! Validation rules for Noir tests. + +pub mod rules; +pub mod violation; + +use anyhow::Result; +use std::path::Path; + +use crate::Config; +pub use violation::Violation; + +/// Check that a Noir test file matches its tree specification. +/// +/// # Errors +/// +/// Returns an error if checking fails. +pub fn check(tree_path: &Path, cfg: &Config) -> Result> { + rules::structural_match::check(tree_path, cfg) +} diff --git a/crates/noir/src/check/rules/mod.rs b/crates/noir/src/check/rules/mod.rs new file mode 100644 index 0000000..f8c8f7c --- /dev/null +++ b/crates/noir/src/check/rules/mod.rs @@ -0,0 +1,3 @@ +//! Validation rules for Noir tests. + +pub mod structural_match; diff --git a/crates/noir/src/check/rules/structural_match.rs b/crates/noir/src/check/rules/structural_match.rs new file mode 100644 index 0000000..24d6738 --- /dev/null +++ b/crates/noir/src/check/rules/structural_match.rs @@ -0,0 +1,292 @@ +//! Structural matching rule for Noir tests. + +use anyhow::Result; +use bulloak_syntax::Ast; +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +use crate::{ + check::violation::{Violation, ViolationKind}, + noir::ParsedNoirFile, + utils::to_snake_case, + Config, +}; + +/// Expected test structure extracted from AST. +struct ExpectedTests { + helpers: HashSet, + test_functions: Vec, +} + +struct TestInfo { + name: String, + should_fail: bool, +} + +/// Check that a Noir test file matches its tree specification. +/// +/// # Errors +/// +/// Returns an error if checking fails. +pub fn check(tree_path: &Path, cfg: &Config) -> Result> { + let mut violations = Vec::new(); + + // Read the tree file + let tree_text = fs::read_to_string(tree_path)?; + let ast = bulloak_syntax::parse_one(&tree_text)?; + + // Find corresponding Noir test file + let test_file = tree_path.with_file_name(format!( + "{}_test.nr", + tree_path.file_stem().unwrap().to_str().unwrap() + )); + + if !test_file.exists() { + violations.push(Violation::new( + ViolationKind::NoirFileInvalid(format!("File not found: {}", test_file.display())), + test_file.display().to_string(), + )); + return Ok(violations); + } + + let noir_source = fs::read_to_string(&test_file)?; + + // Parse the Noir file + let parsed = match ParsedNoirFile::parse(&noir_source) { + Ok(p) => p, + Err(e) => { + violations.push(Violation::new( + ViolationKind::NoirFileInvalid(e.to_string()), + test_file.display().to_string(), + )); + return Ok(violations); + } + }; + + // Extract expected structure from AST + let expected = extract_expected_structure(&ast, cfg)?; + + // Check helpers (if not skipped) + if !cfg.skip_helpers { + let found_helpers = parsed.find_helper_functions(); + let found_helper_set: HashSet = found_helpers.into_iter().collect(); + + for expected_helper in &expected.helpers { + if !found_helper_set.contains(expected_helper) { + violations.push(Violation::new( + ViolationKind::HelperFunctionMissing(expected_helper.clone()), + test_file.display().to_string(), + )); + } + } + } + + // Check test functions + let found_tests = parsed.find_test_functions(); + let found_test_map: std::collections::HashMap = found_tests + .iter() + .map(|t| (t.name.clone(), t.has_should_fail)) + .collect(); + + for expected_test in &expected.test_functions { + if let Some(&has_should_fail) = found_test_map.get(&expected_test.name) { + // Test exists - check attributes + if expected_test.should_fail && !has_should_fail { + violations.push(Violation::new( + ViolationKind::ShouldFailMissing(expected_test.name.clone()), + test_file.display().to_string(), + )); + } + } else { + // Test is missing + violations.push(Violation::new( + ViolationKind::TestFunctionMissing(expected_test.name.clone()), + test_file.display().to_string(), + )); + } + } + + Ok(violations) +} + +/// Extract expected test structure from AST. +fn extract_expected_structure(ast: &Ast, cfg: &Config) -> Result { + let ast_root = match ast { + Ast::Root(r) => r, + _ => anyhow::bail!("Expected Root node"), + }; + + let mut helpers = HashSet::new(); + let mut test_functions = Vec::new(); + + if !cfg.skip_helpers { + collect_helpers_recursive(&ast_root.children, &mut helpers); + } + + collect_tests(&ast_root.children, &[], &mut test_functions, cfg); + + Ok(ExpectedTests { + helpers, + test_functions, + }) +} + +/// Recursively collect helper names from conditions. +fn collect_helpers_recursive(children: &[Ast], helpers: &mut HashSet) { + for child in children { + if let Ast::Condition(condition) = child { + helpers.insert(to_snake_case(&condition.title)); + collect_helpers_recursive(&condition.children, helpers); + } + } +} + +/// Collect expected test functions. +fn collect_tests( + children: &[Ast], + parent_helpers: &[String], + tests: &mut Vec, + cfg: &Config, +) { + for child in children { + match child { + Ast::Condition(condition) => { + let mut helpers = parent_helpers.to_vec(); + if !cfg.skip_helpers { + helpers.push(to_snake_case(&condition.title)); + } + + // Collect all direct Action children + let actions: Vec<_> = condition + .children + .iter() + .filter_map(|c| match c { + Ast::Action(a) => Some(a), + _ => None, + }) + .collect(); + + // One test function for all actions under this condition + if !actions.is_empty() { + let test_name = if helpers.is_empty() { + // Root level action (shouldn't really happen with a Condition parent, + // but handle it just in case) + format!("test_{}", to_snake_case(&actions[0].title)) + } else { + // Under conditions: use the last helper name, NOT the action name + format!("test_when_{}", helpers.last().unwrap()) + }; + + let should_fail = actions + .iter() + .any(|a| has_panic_keyword(&a.title)); + + tests.push(TestInfo { + name: test_name, + should_fail, + }); + } + + // Recursively process only nested Condition children (not actions!) + for child in &condition.children { + if matches!(child, Ast::Condition(_)) { + collect_tests(std::slice::from_ref(child), &helpers, tests, cfg); + } + } + } + Ast::Action(action) => { + // Root-level action + let test_name = format!("test_{}", to_snake_case(&action.title)); + let should_fail = has_panic_keyword(&action.title); + tests.push(TestInfo { + name: test_name, + should_fail, + }); + } + _ => {} + } + } +} + +/// Check if a title contains panic keywords. +fn has_panic_keyword(title: &str) -> bool { + let lower = title.to_lowercase(); + crate::constants::PANIC_KEYWORDS + .iter() + .any(|keyword| lower.contains(keyword)) +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_check_passes_when_correct() { + let tree_content = indoc! {r#" + hash_pair + └── It should work. + "#}; + + let noir_content = indoc! {r#" + // Generated by bulloak + + #[test] + fn test_should_work() { + // It should work. + } + "#}; + + let mut tree_file = NamedTempFile::new().unwrap(); + tree_file.write_all(tree_content.as_bytes()).unwrap(); + tree_file.flush().unwrap(); + + let test_path = tree_file.path().with_file_name(format!( + "{}_test.nr", + tree_file.path().file_stem().unwrap().to_str().unwrap() + )); + fs::write(&test_path, noir_content).unwrap(); + + let cfg = Config::default(); + let violations = check(tree_file.path(), &cfg).unwrap(); + + assert_eq!(violations.len(), 0); + + // Cleanup + let _ = fs::remove_file(test_path); + } + + #[test] + fn test_check_fails_when_missing_test() { + let tree_content = indoc! {r#" + test_root + └── It should work. + "#}; + + let noir_content = "// Generated by bulloak\n\n"; + + let mut tree_file = NamedTempFile::new().unwrap(); + tree_file.write_all(tree_content.as_bytes()).unwrap(); + tree_file.flush().unwrap(); + + let test_path = tree_file.path().with_file_name(format!( + "{}_test.nr", + tree_file.path().file_stem().unwrap().to_str().unwrap() + )); + fs::write(&test_path, noir_content).unwrap(); + + let cfg = Config::default(); + let violations = check(tree_file.path(), &cfg).unwrap(); + + assert!(violations.len() > 0); + assert!(violations + .iter() + .any(|v| matches!(v.kind, ViolationKind::TestFunctionMissing(_)))); + + // Cleanup + let _ = fs::remove_file(test_path); + } +} diff --git a/crates/noir/src/check/violation.rs b/crates/noir/src/check/violation.rs new file mode 100644 index 0000000..d67054a --- /dev/null +++ b/crates/noir/src/check/violation.rs @@ -0,0 +1,56 @@ +//! Violation types for Noir test checking. + +use std::fmt; + +/// A violation found when checking a Noir test file. +#[derive(Debug, Clone)] +pub struct Violation { + /// The kind of violation. + pub kind: ViolationKind, + /// The file where the violation occurred. + pub file: String, +} + +/// The kind of violation. +#[derive(Debug, Clone)] +pub enum ViolationKind { + /// The Noir file could not be parsed. + NoirFileInvalid(String), + /// A test function is missing. + TestFunctionMissing(String), + /// A helper function is missing. + HelperFunctionMissing(String), + /// A test should have `#[test(should_fail)]` but doesn't. + ShouldFailMissing(String), +} + +impl Violation { + /// Create a new violation. + #[must_use] + pub fn new(kind: ViolationKind, file: String) -> Self { + Self { kind, file } + } +} + +impl fmt::Display for Violation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match &self.kind { + ViolationKind::NoirFileInvalid(err) => { + write!(f, "Failed to parse Noir file {}: {}", self.file, err) + } + ViolationKind::TestFunctionMissing(name) => { + write!(f, "Missing test function '{}' in {}", name, self.file) + } + ViolationKind::HelperFunctionMissing(name) => { + write!(f, "Missing helper function '{}' in {}", name, self.file) + } + ViolationKind::ShouldFailMissing(name) => { + write!( + f, + "Test '{}' should have #[test(should_fail)] in {}", + name, self.file + ) + } + } + } +} diff --git a/crates/noir/src/config.rs b/crates/noir/src/config.rs new file mode 100644 index 0000000..3279b93 --- /dev/null +++ b/crates/noir/src/config.rs @@ -0,0 +1,12 @@ +//! Configuration for Noir backend. + +/// Configuration for Noir test generation and checking. +#[derive(Debug, Clone, Default)] +pub struct Config { + /// List of files being processed. + pub files: Vec, + /// Skip generation of helper functions for conditions. + pub skip_helpers: bool, + /// Format action descriptions (capitalize, etc). + pub format_descriptions: bool, +} diff --git a/crates/noir/src/constants.rs b/crates/noir/src/constants.rs new file mode 100644 index 0000000..d9482af --- /dev/null +++ b/crates/noir/src/constants.rs @@ -0,0 +1,10 @@ +//! Constants for Noir code generation. + +/// Keywords that indicate a test should have the `#[test(should_fail)]` attribute. +pub(crate) const PANIC_KEYWORDS: &[&str] = &["panic", "revert", "error", "fail", "assert_fail"]; + +/// Prefix for test functions. +pub(crate) const TEST_PREFIX: &str = "test"; + +/// File extension for Noir files. +pub(crate) const FILE_EXTENSION: &str = "nr"; diff --git a/crates/noir/src/lib.rs b/crates/noir/src/lib.rs new file mode 100644 index 0000000..c2d3039 --- /dev/null +++ b/crates/noir/src/lib.rs @@ -0,0 +1,29 @@ +//! Noir backend for bulloak. +//! +//! This crate provides Noir test generation and validation for bulloak, +//! converting `.tree` specifications into Noir test files with `#[test]` attributes. + +#![warn(missing_docs)] +#![warn(unreachable_pub)] + +pub mod check; +pub mod config; +pub mod noir; +pub mod scaffold; + +mod constants; +mod utils; + +pub use config::Config; + +use anyhow::Result; +use bulloak_syntax::Ast; + +/// Generate Noir test code from an AST. +/// +/// # Errors +/// +/// Returns an error if code generation fails. +pub fn scaffold(ast: &Ast, cfg: &Config) -> Result { + scaffold::generate(ast, cfg) +} diff --git a/crates/noir/src/noir/mod.rs b/crates/noir/src/noir/mod.rs new file mode 100644 index 0000000..a724eb4 --- /dev/null +++ b/crates/noir/src/noir/mod.rs @@ -0,0 +1,5 @@ +//! Noir code parsing using tree-sitter. + +mod parser; + +pub use parser::ParsedNoirFile; diff --git a/crates/noir/src/noir/parser.rs b/crates/noir/src/noir/parser.rs new file mode 100644 index 0000000..0a24c78 --- /dev/null +++ b/crates/noir/src/noir/parser.rs @@ -0,0 +1,234 @@ +//! Noir code parser using tree-sitter. + +use anyhow::{Context, Result}; +use tree_sitter::{Node, Parser}; + +/// Parsed Noir test file. +pub struct ParsedNoirFile { + /// The source code. + source: String, + /// The parsed syntax tree. + tree: tree_sitter::Tree, +} + +/// Information about a test function. +#[derive(Debug, Clone)] +pub struct TestFunction { + /// The function name. + pub name: String, + /// Whether the function has `#[test(should_fail)]` attribute. + pub has_should_fail: bool, +} + +impl ParsedNoirFile { + /// Parse a Noir file from source code. + /// + /// # Errors + /// + /// Returns an error if parsing fails. + pub fn parse(source: &str) -> Result { + let mut parser = Parser::new(); + parser + .set_language(tree_sitter_noir::language()) + .context("Failed to load Noir grammar")?; + + let tree = parser + .parse(source, None) + .context("Failed to parse Noir file")?; + + Ok(Self { + source: source.to_string(), + tree, + }) + } + + /// Find all test functions in the file. + #[must_use] + pub fn find_test_functions(&self) -> Vec { + let mut functions = Vec::new(); + let root_node = self.tree.root_node(); + + self.find_test_functions_recursive(root_node, &mut functions); + functions + } + + /// Recursively find test functions in a node and its children. + fn find_test_functions_recursive<'a>(&self, node: Node<'a>, functions: &mut Vec) { + // Check if this node is a function with #[test] attribute + if node.kind() == "function_definition" { + if let Some(test_fn) = self.extract_test_function(node) { + functions.push(test_fn); + } + } + + // Recursively check children + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + self.find_test_functions_recursive(child, functions); + } + } + + /// Extract test function information from a function node. + fn extract_test_function<'a>(&self, node: Node<'a>) -> Option { + // Look for #[test] attribute + let has_test_attr = self.has_test_attribute(node); + if !has_test_attr { + return None; + } + + // Extract function name + let name = self.get_function_name(node)?; + + // Check for should_fail + let has_should_fail = self.has_should_fail_attribute(node); + + Some(TestFunction { + name, + has_should_fail, + }) + } + + /// Check if a function has #[test] attribute. + fn has_test_attribute<'a>(&self, node: Node<'a>) -> bool { + self.find_attribute(node, "test").is_some() + } + + /// Check if a function has #[test(should_fail)] attribute. + fn has_should_fail_attribute<'a>(&self, node: Node<'a>) -> bool { + if let Some(attr_node) = self.find_attribute(node, "test") { + // Check if the attribute contains "should_fail" + let attr_text = self.node_text(attr_node); + return attr_text.contains("should_fail"); + } + false + } + + /// Find a macro/attribute node by name (Noir uses "macro" for attributes). + fn find_attribute<'a>(&self, node: Node<'a>, attr_name: &str) -> Option> { + // Look for macro nodes before the function + let mut sibling = node.prev_sibling(); + while let Some(s) = sibling { + if s.kind() == "macro" { + let text = self.node_text(s); + if text.contains(attr_name) { + return Some(s); + } + } else if s.kind() != "comment" && s.kind() != "line_comment" { + // Stop if we hit something that's not a macro or comment + break; + } + sibling = s.prev_sibling(); + } + + None + } + + /// Extract function name from a function node. + fn get_function_name<'a>(&self, node: Node<'a>) -> Option { + let mut cursor = node.walk(); + // Find the identifier after "fn" keyword + let mut found_fn = false; + for child in node.children(&mut cursor) { + if child.kind() == "fn" { + found_fn = true; + } else if found_fn && child.kind() == "identifier" { + return Some(self.node_text(child)); + } + } + None + } + + /// Find all helper functions (functions without #[test] attribute). + #[must_use] + pub fn find_helper_functions(&self) -> Vec { + let mut functions = Vec::new(); + let root_node = self.tree.root_node(); + + self.find_helper_functions_recursive(root_node, &mut functions); + functions + } + + /// Recursively find helper functions in a node and its children. + fn find_helper_functions_recursive<'a>(&self, node: Node<'a>, functions: &mut Vec) { + if node.kind() == "function_definition" { + // Check if it has #[test] attribute + if !self.has_test_attribute(node) { + if let Some(name) = self.get_function_name(node) { + functions.push(name); + } + } + } + + // Recursively check children + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + self.find_helper_functions_recursive(child, functions); + } + } + + /// Get text content of a node. + fn node_text<'a>(&self, node: Node<'a>) -> String { + node.utf8_text(self.source.as_bytes()) + .unwrap_or("") + .to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_test() { + let source = r#" + #[test] + fn test_something() { + assert(true); + } + "#; + + let parsed = ParsedNoirFile::parse(source).unwrap(); + let test_fns = parsed.find_test_functions(); + + assert_eq!(test_fns.len(), 1); + assert_eq!(test_fns[0].name, "test_something"); + assert!(!test_fns[0].has_should_fail); + } + + #[test] + fn test_parse_should_fail() { + let source = r#" + #[test(should_fail)] + fn test_panics() { + assert(false); + } + "#; + + let parsed = ParsedNoirFile::parse(source).unwrap(); + let test_fns = parsed.find_test_functions(); + + assert_eq!(test_fns.len(), 1); + assert_eq!(test_fns[0].name, "test_panics"); + assert!(test_fns[0].has_should_fail); + } + + #[test] + fn test_find_helper_functions() { + let source = r#" + fn helper_function() { + // helper + } + + #[test] + fn test_something() { + helper_function(); + } + "#; + + let parsed = ParsedNoirFile::parse(source).unwrap(); + let helpers = parsed.find_helper_functions(); + + assert!(!helpers.is_empty()); + assert!(helpers.contains(&"helper_function".to_string())); + } +} diff --git a/crates/noir/src/scaffold/generator.rs b/crates/noir/src/scaffold/generator.rs new file mode 100644 index 0000000..7fb90b5 --- /dev/null +++ b/crates/noir/src/scaffold/generator.rs @@ -0,0 +1,246 @@ +//! Noir test code generation. + +use anyhow::Result; +use bulloak_syntax::{Action, Ast}; +use std::collections::HashSet; + +use crate::{ + config::Config, + constants::{PANIC_KEYWORDS, TEST_PREFIX}, + utils::to_snake_case, +}; + +/// Generate Noir test code from an AST. +/// +/// # Errors +/// +/// Returns an error if code generation fails. +pub(super) fn generate(ast: &Ast, cfg: &Config) -> Result { + let ast_root = match ast { + Ast::Root(r) => r, + _ => anyhow::bail!("Expected Root node"), + }; + + let mut output = String::from("// Generated by bulloak\n\n"); + + // Generate helper functions (if not skipped) + if !cfg.skip_helpers { + let helpers = collect_helpers(&ast_root.children); + for helper in helpers { + output.push_str(&generate_helper_function(&helper)); + output.push('\n'); + } + } + + // Generate test functions + let tests = generate_tests(&ast_root.children, &[], cfg); + for test in tests { + output.push_str(&test); + } + + Ok(output) +} + +/// Collect all unique helper names from conditions. +fn collect_helpers(children: &[Ast]) -> Vec { + let mut helpers = HashSet::new(); + collect_helpers_recursive(children, &mut helpers); + let mut sorted: Vec = helpers.into_iter().collect(); + sorted.sort(); // Sort alphabetically for deterministic output + sorted +} + +/// Recursively collect helper names. +fn collect_helpers_recursive(children: &[Ast], helpers: &mut HashSet) { + for child in children { + if let Ast::Condition(condition) = child { + helpers.insert(to_snake_case(&condition.title)); + collect_helpers_recursive(&condition.children, helpers); + } + } +} + +/// Generate a helper function. +fn generate_helper_function(name: &str) -> String { + format!( + "/// Helper function for condition\n\ + fn {}() {{\n\ + // TODO: Implement condition\n\ + }}\n", + name + ) +} + +/// Generate test functions from AST. +fn generate_tests(children: &[Ast], parent_helpers: &[String], cfg: &Config) -> Vec { + let mut tests = Vec::new(); + + for child in children { + match child { + Ast::Condition(condition) => { + let mut helpers = parent_helpers.to_vec(); + if !cfg.skip_helpers { + helpers.push(to_snake_case(&condition.title)); + } + + // Collect all direct Action children + let actions: Vec<&Action> = condition + .children + .iter() + .filter_map(|c| match c { + Ast::Action(a) => Some(a), + _ => None, + }) + .collect(); + + // Generate ONE test function for all actions under this condition + if !actions.is_empty() { + tests.push(generate_test_function(&actions, &helpers, cfg)); + } + + // Process only nested Condition children (not actions!) recursively + // We need to collect into a Vec first, then pass a slice + let nested_conditions: Vec<_> = condition + .children + .iter() + .filter(|c| matches!(c, Ast::Condition(_))) + .collect(); + + for nested_cond in nested_conditions { + tests.extend(generate_tests(std::slice::from_ref(nested_cond), &helpers, cfg)); + } + } + Ast::Action(action) => { + // Root-level action + tests.push(generate_test_function(&[action], parent_helpers, cfg)); + } + _ => {} + } + } + + tests +} + +/// Generate a single test function for one or more actions. +fn generate_test_function(actions: &[&Action], helpers: &[String], cfg: &Config) -> String { + // Determine test name + let test_name = if helpers.is_empty() { + // Root level: test_{action_name} + format!("{}_{}", TEST_PREFIX, to_snake_case(&actions[0].title)) + } else { + // Under condition: test_when_{last_helper} + format!("{}_when_{}", TEST_PREFIX, helpers.last().unwrap()) + }; + + // Check if any action contains panic keywords + let has_panic = actions + .iter() + .any(|action| has_panic_keyword(&action.title)); + + // Generate attribute + let attr = if has_panic { + "#[test(should_fail)]\n" + } else { + "#[test]\n" + }; + + // Generate function body + let mut body = String::new(); + + // Call helpers in order + if !cfg.skip_helpers { + for helper in helpers { + body.push_str(&format!(" {}();\n", helper)); + } + } + + // Add action comments + for action in actions { + let comment = format_action_comment(&action.title, cfg.format_descriptions); + body.push_str(&format!(" // {}\n", comment)); + } + + format!("{}fn {}() {{\n{}}}\n\n", attr, test_name, body) +} + +/// Format an action comment. +fn format_action_comment(title: &str, format_descriptions: bool) -> String { + if format_descriptions { + // Capitalize first letter + let mut chars = title.chars(); + match chars.next() { + Some(f) => f.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + } else { + title.to_string() + } +} + +/// Check if a title contains panic keywords. +fn has_panic_keyword(title: &str) -> bool { + let lower = title.to_lowercase(); + PANIC_KEYWORDS + .iter() + .any(|keyword| lower.contains(keyword)) +} + +#[cfg(test)] +mod tests { + use super::*; + use bulloak_syntax::parse_one; + + #[test] + fn test_generate_basic() { + let tree = r#" +hash_pair +├── It should always work. +└── When first arg is smaller + └── It should match result. +"#; + + let ast = parse_one(tree).unwrap(); + let cfg = Config::default(); + let output = generate(&ast, &cfg).unwrap(); + + assert!(output.contains("// Generated by bulloak")); + assert!(output.contains("fn first_arg_is_smaller()")); + assert!(output.contains("#[test]\nfn test_should_always_work()")); + assert!(output.contains("#[test]\nfn test_when_first_arg_is_smaller()")); + } + + #[test] + fn test_generate_with_panic() { + let tree = r#" +divide +└── When divisor is zero + └── It should panic with division by zero. +"#; + + let ast = parse_one(tree).unwrap(); + let cfg = Config::default(); + let output = generate(&ast, &cfg).unwrap(); + + assert!(output.contains("#[test(should_fail)]")); + assert!(output.contains("fn test_when_divisor_is_zero()")); + } + + #[test] + fn test_skip_helpers() { + let tree = r#" +test_root +└── When condition + └── It should work. +"#; + + let ast = parse_one(tree).unwrap(); + let cfg = Config { + skip_helpers: true, + ..Default::default() + }; + let output = generate(&ast, &cfg).unwrap(); + + assert!(!output.contains("fn condition()")); + assert!(output.contains("#[test]\nfn test_should_work()")); + } +} diff --git a/crates/noir/src/scaffold/mod.rs b/crates/noir/src/scaffold/mod.rs new file mode 100644 index 0000000..616db77 --- /dev/null +++ b/crates/noir/src/scaffold/mod.rs @@ -0,0 +1,17 @@ +//! Noir test scaffolding. + +mod generator; + +use anyhow::Result; +use bulloak_syntax::Ast; + +use crate::Config; + +/// Generate Noir test code from an AST. +/// +/// # Errors +/// +/// Returns an error if code generation fails. +pub fn generate(ast: &Ast, cfg: &Config) -> Result { + generator::generate(ast, cfg) +} diff --git a/crates/noir/src/utils.rs b/crates/noir/src/utils.rs new file mode 100644 index 0000000..c2b4743 --- /dev/null +++ b/crates/noir/src/utils.rs @@ -0,0 +1,61 @@ +//! Utility functions for Noir code generation. + +/// Convert a title to snake_case, stripping BDD prefixes. +/// +/// # Examples +/// +/// ```ignore +/// assert_eq!(to_snake_case("When user is logged in"), "user_is_logged_in"); +/// assert_eq!(to_snake_case("It should return true"), "should_return_true"); +/// ``` +pub(crate) fn to_snake_case(title: &str) -> String { + // Strip BDD prefixes + let stripped = title + .trim() + .trim_start_matches("when ") + .trim_start_matches("given ") + .trim_start_matches("it ") + .trim_start_matches("When ") + .trim_start_matches("Given ") + .trim_start_matches("It "); + + // Convert to snake_case + stripped + .chars() + .filter_map(|c| { + if c.is_alphanumeric() { + Some(c.to_ascii_lowercase()) + } else if c.is_whitespace() { + Some('_') + } else { + None + } + }) + .collect::() + .split('_') + .filter(|s| !s.is_empty()) + .collect::>() + .join("_") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_snake_case() { + assert_eq!(to_snake_case("When user is logged in"), "user_is_logged_in"); + assert_eq!(to_snake_case("It should return true"), "should_return_true"); + assert_eq!(to_snake_case("given amount is zero"), "amount_is_zero"); + assert_eq!( + to_snake_case("When first arg is bigger than second arg"), + "first_arg_is_bigger_than_second_arg" + ); + } + + #[test] + fn test_to_snake_case_with_special_chars() { + assert_eq!(to_snake_case("It's working!"), "its_working"); + assert_eq!(to_snake_case("value > 100"), "value_100"); + } +} diff --git a/crates/noir/tests/debug_parser.rs b/crates/noir/tests/debug_parser.rs new file mode 100644 index 0000000..5a307fd --- /dev/null +++ b/crates/noir/tests/debug_parser.rs @@ -0,0 +1,52 @@ +use tree_sitter::Parser; + +fn print_tree(node: tree_sitter::Node, source: &str, depth: usize) { + let indent = " ".repeat(depth); + let text = node.utf8_text(source.as_bytes()).unwrap_or(""); + let text_preview = if text.len() > 50 { + format!("{}...", &text[..50]) + } else { + text.to_string() + }; + + println!("{}{} [{}]", indent, node.kind(), text_preview.replace('\n', "\\n")); + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + print_tree(child, source, depth + 1); + } +} + +#[test] +fn debug_noir_ast() { + let source = r#" +#[test] +fn test_something() { + assert(true); +} +"#; + + let mut parser = Parser::new(); + parser.set_language(tree_sitter_noir::language()).unwrap(); + let tree = parser.parse(source, None).unwrap(); + + println!("\n=== AST STRUCTURE ==="); + print_tree(tree.root_node(), source, 0); +} + +#[test] +fn debug_noir_should_fail() { + let source = r#" +#[test(should_fail)] +fn test_panics() { + assert(false); +} +"#; + + let mut parser = Parser::new(); + parser.set_language(tree_sitter_noir::language()).unwrap(); + let tree = parser.parse(source, None).unwrap(); + + println!("\n=== AST STRUCTURE (should_fail) ==="); + print_tree(tree.root_node(), source, 0); +} From c0ffee9a5b9ba3aa99d025fbb68c65b1943b6d84 Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 08:29:45 +0100 Subject: [PATCH 06/20] chore: test fmt --- README.md | 4 +--- crates/bulloak/tests/scaffold_noir/basic_test.nr | 8 +++----- crates/bulloak/tests/scaffold_noir/nested_test.nr | 14 ++++---------- .../bulloak/tests/scaffold_noir/no_helpers_test.nr | 4 +++- .../bulloak/tests/scaffold_noir/with_panic_test.nr | 6 ++---- crates/noir/src/scaffold/generator.rs | 11 +++++------ 6 files changed, 18 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index cf9faee..2bda12f 100644 --- a/README.md +++ b/README.md @@ -188,16 +188,14 @@ This will generate a `foo_test.nr` file with helper functions for conditions and /// Helper function for condition fn stuff_is_called() { -// TODO: Implement condition } /// Helper function for condition fn a_condition_is_met() { -// TODO: Implement condition } #[test(should_fail)] -fn test_when_a_condition_is_met() { +unconstrained fn test_when_a_condition_is_met() { stuff_is_called(); a_condition_is_met(); // It should revert. diff --git a/crates/bulloak/tests/scaffold_noir/basic_test.nr b/crates/bulloak/tests/scaffold_noir/basic_test.nr index 40854a2..804e63a 100644 --- a/crates/bulloak/tests/scaffold_noir/basic_test.nr +++ b/crates/bulloak/tests/scaffold_noir/basic_test.nr @@ -2,27 +2,25 @@ /// Helper function for condition fn first_arg_is_bigger_than_second_arg() { -// TODO: Implement condition } /// Helper function for condition fn first_arg_is_smaller_than_second_arg() { -// TODO: Implement condition } #[test] -fn test_should_always_work() { +unconstrained fn test_should_always_work() { // It should always work. } #[test] -fn test_when_first_arg_is_smaller_than_second_arg() { +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { first_arg_is_smaller_than_second_arg(); // It should match the result of hash(a, b). } #[test] -fn test_when_first_arg_is_bigger_than_second_arg() { +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { first_arg_is_bigger_than_second_arg(); // It should match the result of hash(b, a). } diff --git a/crates/bulloak/tests/scaffold_noir/nested_test.nr b/crates/bulloak/tests/scaffold_noir/nested_test.nr index 28d4ba3..678559c 100644 --- a/crates/bulloak/tests/scaffold_noir/nested_test.nr +++ b/crates/bulloak/tests/scaffold_noir/nested_test.nr @@ -2,49 +2,43 @@ /// Helper function for condition fn amount_is_not_zero() { -// TODO: Implement condition } /// Helper function for condition fn amount_is_zero() { -// TODO: Implement condition } /// Helper function for condition fn recipient_is_different() { -// TODO: Implement condition } /// Helper function for condition fn recipient_is_the_sender() { -// TODO: Implement condition } /// Helper function for condition fn sender_has_insufficient_balance() { -// TODO: Implement condition } /// Helper function for condition fn sender_has_sufficient_balance() { -// TODO: Implement condition } #[test(should_fail)] -fn test_when_amount_is_zero() { +unconstrained fn test_when_amount_is_zero() { amount_is_zero(); // It should revert. } #[test(should_fail)] -fn test_when_sender_has_insufficient_balance() { +unconstrained fn test_when_sender_has_insufficient_balance() { amount_is_not_zero(); sender_has_insufficient_balance(); // It should revert. } #[test] -fn test_when_recipient_is_the_sender() { +unconstrained fn test_when_recipient_is_the_sender() { amount_is_not_zero(); sender_has_sufficient_balance(); recipient_is_the_sender(); @@ -52,7 +46,7 @@ fn test_when_recipient_is_the_sender() { } #[test] -fn test_when_recipient_is_different() { +unconstrained fn test_when_recipient_is_different() { amount_is_not_zero(); sender_has_sufficient_balance(); recipient_is_different(); diff --git a/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr b/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr index f2d3cb9..c1968ee 100644 --- a/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr +++ b/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr @@ -1,6 +1,8 @@ // Generated by bulloak #[test] -fn test_should_work_correctly() { +unconstrained fn test_should_work_correctly() { // It should work correctly. } + + diff --git a/crates/bulloak/tests/scaffold_noir/with_panic_test.nr b/crates/bulloak/tests/scaffold_noir/with_panic_test.nr index d1b6e7e..347b3a4 100644 --- a/crates/bulloak/tests/scaffold_noir/with_panic_test.nr +++ b/crates/bulloak/tests/scaffold_noir/with_panic_test.nr @@ -2,22 +2,20 @@ /// Helper function for condition fn divisor_is_nonzero() { -// TODO: Implement condition } /// Helper function for condition fn divisor_is_zero() { -// TODO: Implement condition } #[test(should_fail)] -fn test_when_divisor_is_zero() { +unconstrained fn test_when_divisor_is_zero() { divisor_is_zero(); // It should panic with division by zero. } #[test] -fn test_when_divisor_is_nonzero() { +unconstrained fn test_when_divisor_is_nonzero() { divisor_is_nonzero(); // It should return the quotient. } diff --git a/crates/noir/src/scaffold/generator.rs b/crates/noir/src/scaffold/generator.rs index 7fb90b5..bbfd3fb 100644 --- a/crates/noir/src/scaffold/generator.rs +++ b/crates/noir/src/scaffold/generator.rs @@ -65,7 +65,6 @@ fn generate_helper_function(name: &str) -> String { format!( "/// Helper function for condition\n\ fn {}() {{\n\ - // TODO: Implement condition\n\ }}\n", name ) @@ -160,7 +159,7 @@ fn generate_test_function(actions: &[&Action], helpers: &[String], cfg: &Config) body.push_str(&format!(" // {}\n", comment)); } - format!("{}fn {}() {{\n{}}}\n\n", attr, test_name, body) + format!("{}unconstrained fn {}() {{\n{}}}\n\n", attr, test_name, body) } /// Format an action comment. @@ -205,8 +204,8 @@ hash_pair assert!(output.contains("// Generated by bulloak")); assert!(output.contains("fn first_arg_is_smaller()")); - assert!(output.contains("#[test]\nfn test_should_always_work()")); - assert!(output.contains("#[test]\nfn test_when_first_arg_is_smaller()")); + assert!(output.contains("#[test]\nunconstrained fn test_should_always_work()")); + assert!(output.contains("#[test]\nunconstrained fn test_when_first_arg_is_smaller()")); } #[test] @@ -222,7 +221,7 @@ divide let output = generate(&ast, &cfg).unwrap(); assert!(output.contains("#[test(should_fail)]")); - assert!(output.contains("fn test_when_divisor_is_zero()")); + assert!(output.contains("unconstrained fn test_when_divisor_is_zero()")); } #[test] @@ -241,6 +240,6 @@ test_root let output = generate(&ast, &cfg).unwrap(); assert!(!output.contains("fn condition()")); - assert!(output.contains("#[test]\nfn test_should_work()")); + assert!(output.contains("#[test]\nunconstrained fn test_should_work()")); } } From c0ffeeecd1b0957377ceb5e8cc8035857ca2f347 Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 08:52:27 +0100 Subject: [PATCH 07/20] fix: better cli flag --- README.md | 20 ++++++++++---------- crates/bulloak/src/check.rs | 4 ++-- crates/bulloak/src/scaffold.rs | 4 ++-- crates/bulloak/tests/check_rust.rs | 6 +++--- crates/bulloak/tests/scaffold_noir.rs | 8 ++++---- crates/bulloak/tests/scaffold_rust.rs | 6 +++--- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 2bda12f..91dc461 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A test generator based on the [Branching Tree Technique](https://twitter.com/PaulRBerg/status/1682346315806539776). -**Supported backends:** +**Supported languages:** - **Solidity (Foundry)**: Generates `.t.sol` files with modifiers for conditions - **Rust**: Generates `_test.rs` files with helper functions for conditions - **Noir**: Generates `_test.nr` files with helper functions for conditions @@ -67,7 +67,7 @@ better user experience: ### Scaffold Test Files -By default, `bulloak scaffold` generates Solidity test files. You can specify a different backend using the `--backend` (or `-b`) flag. +By default, `bulloak scaffold` generates Solidity test files. You can specify a different language using the `--lang` (or `-l`) flag. #### Solidity (default) @@ -141,16 +141,16 @@ get consistent sentence casing in the scaffolded test bodies. #### Rust -To generate Rust test files, use `--backend rust`: +To generate Rust test files, use `--lang rust`: ```bash -$ bulloak scaffold --backend rust foo.tree +$ bulloak scaffold --lang rust foo.tree ``` This will generate a `foo_test.rs` file with helper functions for conditions and `#[test]` functions for actions. The generated file will use `#[should_panic]` for actions containing panic keywords like "panic", "revert", "error", or "fail". ```rust -// $ bulloak scaffold --backend rust foo.tree +// $ bulloak scaffold --lang rust foo.tree // Generated by bulloak /// Helper function for condition @@ -174,16 +174,16 @@ fn test_when_a_condition_is_met() { #### Noir -To generate Noir test files, use `--backend noir`: +To generate Noir test files, use `--lang noir`: ```bash -$ bulloak scaffold --backend noir foo.tree +$ bulloak scaffold --lang noir foo.tree ``` This will generate a `foo_test.nr` file with helper functions for conditions and `#[test]` functions for actions. The generated file will use `#[test(should_fail)]` for actions containing panic keywords. ```rust -// $ bulloak scaffold --backend noir foo.tree +// $ bulloak scaffold --lang noir foo.tree // Generated by bulloak /// Helper function for condition @@ -202,12 +202,12 @@ unconstrained fn test_when_a_condition_is_met() { } ``` -**Note:** The `-m` (skip helpers) and `-F` (format descriptions) flags work for all backends. +**Note:** The `-m` (skip helpers) and `-F` (format descriptions) flags work for all languages. ### Check That Your Code And Spec Match You can use `bulloak check` to make sure that your test files match your -spec. For example, any missing tests will be reported to you. The `--backend` +spec. For example, any missing tests will be reported to you. The `--lang` flag works the same way as in `scaffold`. Say you have the following spec: diff --git a/crates/bulloak/src/check.rs b/crates/bulloak/src/check.rs index af3f2d1..f34b973 100644 --- a/crates/bulloak/src/check.rs +++ b/crates/bulloak/src/check.rs @@ -41,8 +41,8 @@ pub struct Check { /// Whether to capitalize and punctuate branch descriptions. #[arg(long = "format-descriptions", default_value_t = false)] pub format_descriptions: bool, - /// The target backend/language for checking. - #[arg(short = 'b', long = "backend", value_enum, default_value_t = Backend::Solidity)] + /// The target language for checking. + #[arg(short = 'l', long = "lang", value_enum, default_value_t = Backend::Solidity)] pub backend: Backend, } diff --git a/crates/bulloak/src/scaffold.rs b/crates/bulloak/src/scaffold.rs index f51f329..05e701f 100644 --- a/crates/bulloak/src/scaffold.rs +++ b/crates/bulloak/src/scaffold.rs @@ -53,8 +53,8 @@ pub struct Scaffold { /// Whether to capitalize and punctuate branch descriptions. #[arg(short = 'F', long = "format-descriptions", default_value_t = false)] pub format_descriptions: bool, - /// The target backend/language for code generation. - #[arg(short = 'b', long = "backend", value_enum, default_value_t = Backend::Solidity)] + /// The target language for code generation. + #[arg(short = 'l', long = "lang", value_enum, default_value_t = Backend::Solidity)] pub backend: Backend, } diff --git a/crates/bulloak/tests/check_rust.rs b/crates/bulloak/tests/check_rust.rs index b4b9a40..26978f3 100644 --- a/crates/bulloak/tests/check_rust.rs +++ b/crates/bulloak/tests/check_rust.rs @@ -14,7 +14,7 @@ fn check_rust_passes_when_correct() { let tree_name = "basic.tree"; let tree_path = tests_path.join(tree_name); - let output = cmd(&binary_path, "check", &tree_path, &["--backend", "rust"]); + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "rust"]); // Should pass with no violations assert!(output.status.success()); @@ -37,7 +37,7 @@ fn check_rust_fails_when_missing_file() { ) .unwrap(); - let output = cmd(&binary_path, "check", &temp_tree, &["--backend", "rust"]); + let output = cmd(&binary_path, "check", &temp_tree, &["--lang", "rust"]); // Should fail assert!(!output.status.success()); @@ -85,7 +85,7 @@ mod tests { ) .unwrap(); - let output = cmd(&binary_path, "check", &temp_tree, &["--backend", "rust"]); + let output = cmd(&binary_path, "check", &temp_tree, &["--lang", "rust"]); // Should fail assert!(!output.status.success()); diff --git a/crates/bulloak/tests/scaffold_noir.rs b/crates/bulloak/tests/scaffold_noir.rs index 5e095e4..103994a 100644 --- a/crates/bulloak/tests/scaffold_noir.rs +++ b/crates/bulloak/tests/scaffold_noir.rs @@ -24,7 +24,7 @@ fn test_scaffold_noir_basic() { let tests_path = tests_path(); let tree_path = tests_path.join("basic.tree"); - let output = cmd(&binary_path, "scaffold", &tree_path, &["--backend", "noir"]); + let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir"]); let actual = String::from_utf8(output.stdout).unwrap(); let expected = fs::read_to_string(tests_path.join("basic_test.nr")).unwrap(); @@ -38,7 +38,7 @@ fn test_scaffold_noir_with_panic() { let tests_path = tests_path(); let tree_path = tests_path.join("with_panic.tree"); - let output = cmd(&binary_path, "scaffold", &tree_path, &["--backend", "noir"]); + let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir"]); let actual = String::from_utf8(output.stdout).unwrap(); let expected = fs::read_to_string(tests_path.join("with_panic_test.nr")).unwrap(); @@ -52,7 +52,7 @@ fn test_scaffold_noir_no_helpers() { let tests_path = tests_path(); let tree_path = tests_path.join("no_helpers.tree"); - let output = cmd(&binary_path, "scaffold", &tree_path, &["--backend", "noir"]); + let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir"]); let actual = String::from_utf8(output.stdout).unwrap(); let expected = fs::read_to_string(tests_path.join("no_helpers_test.nr")).unwrap(); @@ -66,7 +66,7 @@ fn test_scaffold_noir_nested() { let tests_path = tests_path(); let tree_path = tests_path.join("nested.tree"); - let output = cmd(&binary_path, "scaffold", &tree_path, &["--backend", "noir"]); + let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir"]); let actual = String::from_utf8(output.stdout).unwrap(); let expected = fs::read_to_string(tests_path.join("nested_test.nr")).unwrap(); diff --git a/crates/bulloak/tests/scaffold_rust.rs b/crates/bulloak/tests/scaffold_rust.rs index 97c092e..ed0bdfd 100644 --- a/crates/bulloak/tests/scaffold_rust.rs +++ b/crates/bulloak/tests/scaffold_rust.rs @@ -23,7 +23,7 @@ fn scaffolds_rust_trees() { for tree_name in trees { let tree_path = tests_path.join(tree_name); - let output = cmd(&binary_path, "scaffold", &tree_path, &["--backend", "rust"]); + let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "rust"]); let actual = String::from_utf8(output.stdout).unwrap(); let mut output_file = tree_path.clone(); @@ -58,7 +58,7 @@ fn scaffolds_rust_trees_skip_helpers() { let tree_name = "basic.tree"; let tree_path = tests_path.join(tree_name); - let output = cmd(&binary_path, "scaffold", &tree_path, &["--backend", "rust", "-m"]); + let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "rust", "-m"]); let actual = String::from_utf8(output.stdout).unwrap(); // Should not contain helper functions @@ -84,7 +84,7 @@ fn scaffolds_rust_trees_format_descriptions() { &binary_path, "scaffold", &tree_path, - &["--backend", "rust", "--format-descriptions"], + &["--lang", "rust", "--format-descriptions"], ); let actual = String::from_utf8(output.stdout).unwrap(); From c0ffeec04528c8ed36c5836b75661df0fc897acf Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 08:57:22 +0100 Subject: [PATCH 08/20] fix: dead constant --- crates/noir/src/constants.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/noir/src/constants.rs b/crates/noir/src/constants.rs index d9482af..07ad777 100644 --- a/crates/noir/src/constants.rs +++ b/crates/noir/src/constants.rs @@ -1,10 +1,9 @@ //! Constants for Noir code generation. -/// Keywords that indicate a test should have the `#[test(should_fail)]` attribute. -pub(crate) const PANIC_KEYWORDS: &[&str] = &["panic", "revert", "error", "fail", "assert_fail"]; +/// Keywords that indicate a test should have the `#[test(should_fail)]` +/// attribute. +pub(crate) const PANIC_KEYWORDS: &[&str] = + &["panic", "revert", "error", "fail", "assert_fail"]; /// Prefix for test functions. pub(crate) const TEST_PREFIX: &str = "test"; - -/// File extension for Noir files. -pub(crate) const FILE_EXTENSION: &str = "nr"; From c0ffee673cbff6eedae3bf675116ec4e93d1c3f0 Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:08:06 +0100 Subject: [PATCH 09/20] fix: check with unconstrained kw --- .../noir/src/check/rules/structural_match.rs | 2 +- crates/noir/src/noir/parser.rs | 35 ++++++++++++++++++- crates/noir/tests/debug_parser.rs | 17 +++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/noir/src/check/rules/structural_match.rs b/crates/noir/src/check/rules/structural_match.rs index 24d6738..ae98eee 100644 --- a/crates/noir/src/check/rules/structural_match.rs +++ b/crates/noir/src/check/rules/structural_match.rs @@ -235,7 +235,7 @@ mod tests { // Generated by bulloak #[test] - fn test_should_work() { + unconstrained fn test_should_work() { // It should work. } "#}; diff --git a/crates/noir/src/noir/parser.rs b/crates/noir/src/noir/parser.rs index 0a24c78..3bbc3d8 100644 --- a/crates/noir/src/noir/parser.rs +++ b/crates/noir/src/noir/parser.rs @@ -113,8 +113,17 @@ impl ParsedNoirFile { if text.contains(attr_name) { return Some(s); } + } else if s.kind() == "identifier" { + // Skip "unconstrained" or other modifiers + let text = self.node_text(s); + if text == "unconstrained" || text == "pub" { + sibling = s.prev_sibling(); + continue; + } + // Stop if we hit an identifier that's not a known modifier + break; } else if s.kind() != "comment" && s.kind() != "line_comment" { - // Stop if we hit something that's not a macro or comment + // Stop if we hit something that's not a macro, comment, or known modifier break; } sibling = s.prev_sibling(); @@ -212,6 +221,30 @@ mod tests { assert!(test_fns[0].has_should_fail); } + #[test] + fn test_parse_unconstrained() { + let source = r#" + #[test] + unconstrained fn test_something() { + assert(true); + } + + #[test(should_fail)] + unconstrained fn test_panics() { + assert(false); + } + "#; + + let parsed = ParsedNoirFile::parse(source).unwrap(); + let test_fns = parsed.find_test_functions(); + + assert_eq!(test_fns.len(), 2); + assert_eq!(test_fns[0].name, "test_something"); + assert!(!test_fns[0].has_should_fail); + assert_eq!(test_fns[1].name, "test_panics"); + assert!(test_fns[1].has_should_fail); + } + #[test] fn test_find_helper_functions() { let source = r#" diff --git a/crates/noir/tests/debug_parser.rs b/crates/noir/tests/debug_parser.rs index 5a307fd..4bdb804 100644 --- a/crates/noir/tests/debug_parser.rs +++ b/crates/noir/tests/debug_parser.rs @@ -50,3 +50,20 @@ fn test_panics() { println!("\n=== AST STRUCTURE (should_fail) ==="); print_tree(tree.root_node(), source, 0); } + +#[test] +fn debug_noir_unconstrained() { + let source = r#" +#[test] +unconstrained fn test_something() { + assert(true); +} +"#; + + let mut parser = Parser::new(); + parser.set_language(tree_sitter_noir::language()).unwrap(); + let tree = parser.parse(source, None).unwrap(); + + println!("\n=== AST STRUCTURE (unconstrained) ==="); + print_tree(tree.root_node(), source, 0); +} From c0ffeee8e9e9e0d9aff4b6c4240d0f11913359a7 Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 10:56:54 +0100 Subject: [PATCH 10/20] chore: refactor --- .../noir/src/check/rules/structural_match.rs | 10 +-- crates/noir/src/scaffold/generator.rs | 25 +++---- crates/noir/tests/debug_parser.rs | 69 ------------------- 3 files changed, 19 insertions(+), 85 deletions(-) delete mode 100644 crates/noir/tests/debug_parser.rs diff --git a/crates/noir/src/check/rules/structural_match.rs b/crates/noir/src/check/rules/structural_match.rs index ae98eee..7d07368 100644 --- a/crates/noir/src/check/rules/structural_match.rs +++ b/crates/noir/src/check/rules/structural_match.rs @@ -37,10 +37,12 @@ pub fn check(tree_path: &Path, cfg: &Config) -> Result> { let ast = bulloak_syntax::parse_one(&tree_text)?; // Find corresponding Noir test file - let test_file = tree_path.with_file_name(format!( - "{}_test.nr", - tree_path.file_stem().unwrap().to_str().unwrap() - )); + let file_stem = tree_path + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("Invalid tree file name: {}", tree_path.display()))?; + + let test_file = tree_path.with_file_name(format!("{file_stem}_test.nr")); if !test_file.exists() { violations.push(Violation::new( diff --git a/crates/noir/src/scaffold/generator.rs b/crates/noir/src/scaffold/generator.rs index bbfd3fb..db17554 100644 --- a/crates/noir/src/scaffold/generator.rs +++ b/crates/noir/src/scaffold/generator.rs @@ -64,9 +64,8 @@ fn collect_helpers_recursive(children: &[Ast], helpers: &mut HashSet) { fn generate_helper_function(name: &str) -> String { format!( "/// Helper function for condition\n\ - fn {}() {{\n\ - }}\n", - name + fn {name}() {{\n\ + }}\n" ) } @@ -149,17 +148,19 @@ fn generate_test_function(actions: &[&Action], helpers: &[String], cfg: &Config) // Call helpers in order if !cfg.skip_helpers { for helper in helpers { - body.push_str(&format!(" {}();\n", helper)); + use std::fmt::Write; + let _ = writeln!(body, " {helper}();"); } } // Add action comments for action in actions { let comment = format_action_comment(&action.title, cfg.format_descriptions); - body.push_str(&format!(" // {}\n", comment)); + use std::fmt::Write; + let _ = writeln!(body, " // {comment}"); } - format!("{}unconstrained fn {}() {{\n{}}}\n\n", attr, test_name, body) + format!("{attr}unconstrained fn {test_name}() {{\n{body}}}\n\n") } /// Format an action comment. @@ -191,12 +192,12 @@ mod tests { #[test] fn test_generate_basic() { - let tree = r#" + let tree = r" hash_pair ├── It should always work. └── When first arg is smaller └── It should match result. -"#; +"; let ast = parse_one(tree).unwrap(); let cfg = Config::default(); @@ -210,11 +211,11 @@ hash_pair #[test] fn test_generate_with_panic() { - let tree = r#" + let tree = r" divide └── When divisor is zero └── It should panic with division by zero. -"#; +"; let ast = parse_one(tree).unwrap(); let cfg = Config::default(); @@ -226,11 +227,11 @@ divide #[test] fn test_skip_helpers() { - let tree = r#" + let tree = r" test_root └── When condition └── It should work. -"#; +"; let ast = parse_one(tree).unwrap(); let cfg = Config { diff --git a/crates/noir/tests/debug_parser.rs b/crates/noir/tests/debug_parser.rs deleted file mode 100644 index 4bdb804..0000000 --- a/crates/noir/tests/debug_parser.rs +++ /dev/null @@ -1,69 +0,0 @@ -use tree_sitter::Parser; - -fn print_tree(node: tree_sitter::Node, source: &str, depth: usize) { - let indent = " ".repeat(depth); - let text = node.utf8_text(source.as_bytes()).unwrap_or(""); - let text_preview = if text.len() > 50 { - format!("{}...", &text[..50]) - } else { - text.to_string() - }; - - println!("{}{} [{}]", indent, node.kind(), text_preview.replace('\n', "\\n")); - - let mut cursor = node.walk(); - for child in node.children(&mut cursor) { - print_tree(child, source, depth + 1); - } -} - -#[test] -fn debug_noir_ast() { - let source = r#" -#[test] -fn test_something() { - assert(true); -} -"#; - - let mut parser = Parser::new(); - parser.set_language(tree_sitter_noir::language()).unwrap(); - let tree = parser.parse(source, None).unwrap(); - - println!("\n=== AST STRUCTURE ==="); - print_tree(tree.root_node(), source, 0); -} - -#[test] -fn debug_noir_should_fail() { - let source = r#" -#[test(should_fail)] -fn test_panics() { - assert(false); -} -"#; - - let mut parser = Parser::new(); - parser.set_language(tree_sitter_noir::language()).unwrap(); - let tree = parser.parse(source, None).unwrap(); - - println!("\n=== AST STRUCTURE (should_fail) ==="); - print_tree(tree.root_node(), source, 0); -} - -#[test] -fn debug_noir_unconstrained() { - let source = r#" -#[test] -unconstrained fn test_something() { - assert(true); -} -"#; - - let mut parser = Parser::new(); - parser.set_language(tree_sitter_noir::language()).unwrap(); - let tree = parser.parse(source, None).unwrap(); - - println!("\n=== AST STRUCTURE (unconstrained) ==="); - print_tree(tree.root_node(), source, 0); -} From c0ffee37bfcc06a59d44776594e6377040b0ecaa Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 11:03:37 +0100 Subject: [PATCH 11/20] chore: refactor --- crates/noir/crates/bulloak/tests/scaffold_noir/basic_test.nr | 0 crates/noir/crates/bulloak/tests/scaffold_noir/nested_test.nr | 0 crates/noir/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr | 0 crates/noir/crates/bulloak/tests/scaffold_noir/with_panic_test.nr | 0 4 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 crates/noir/crates/bulloak/tests/scaffold_noir/basic_test.nr delete mode 100644 crates/noir/crates/bulloak/tests/scaffold_noir/nested_test.nr delete mode 100644 crates/noir/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr delete mode 100644 crates/noir/crates/bulloak/tests/scaffold_noir/with_panic_test.nr diff --git a/crates/noir/crates/bulloak/tests/scaffold_noir/basic_test.nr b/crates/noir/crates/bulloak/tests/scaffold_noir/basic_test.nr deleted file mode 100644 index e69de29..0000000 diff --git a/crates/noir/crates/bulloak/tests/scaffold_noir/nested_test.nr b/crates/noir/crates/bulloak/tests/scaffold_noir/nested_test.nr deleted file mode 100644 index e69de29..0000000 diff --git a/crates/noir/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr b/crates/noir/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr deleted file mode 100644 index e69de29..0000000 diff --git a/crates/noir/crates/bulloak/tests/scaffold_noir/with_panic_test.nr b/crates/noir/crates/bulloak/tests/scaffold_noir/with_panic_test.nr deleted file mode 100644 index e69de29..0000000 From c0ffee08f2925595852df6c82a4537b86351f741 Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 11:56:58 +0100 Subject: [PATCH 12/20] chore: test refactor --- crates/bulloak/Cargo.toml | 8 + crates/bulloak/benches/emit_noir.rs | 19 + crates/bulloak/benches/emit_rust.rs | 19 + crates/bulloak/tests/check_noir.rs | 85 ++ .../{scaffold_noir => check_noir}/basic.tree | 1 - crates/bulloak/tests/check_noir/basic_test.nr | 21 + .../tests/check_noir/missing_helper.tree | 3 + .../tests/check_noir/missing_helper_test.nr | 9 + .../tests/check_noir/missing_test.tree | 3 + .../tests/check_noir/missing_test_test.nr | 7 + .../no_helpers.tree | 0 .../tests/check_noir/no_helpers_test.nr | 11 + crates/bulloak/tests/check_rust.rs | 85 +- .../{scaffold_rust => check_rust}/basic.tree | 1 - crates/bulloak/tests/check_rust/basic_test.rs | 28 + .../tests/check_rust/missing_helper.tree | 3 + .../tests/check_rust/missing_helper_test.rs | 16 + .../tests/check_rust/missing_test.tree | 3 + .../tests/check_rust/missing_test_test.rs | 14 + .../bulloak/tests/check_rust/no_helpers.tree | 3 + .../no_helpers_test.rs | 1 + crates/bulloak/tests/scaffold_noir.rs | 135 ++-- .../bulloak/tests/scaffold_noir/basic_test.nr | 21 +- .../tests/scaffold_noir/complex_test.nr | 422 ++++++++++ .../scaffold_noir/disambiguation_test.nr | 47 ++ .../duplicated_condition_test.nr | 25 + .../duplicated_top_action_test.nr | 0 .../bulloak/tests/scaffold_noir/empty_test.nr | 0 .../scaffold_noir/format_descriptions_test.nr | 14 + .../tests/scaffold_noir/hash_pair_test.nr | 0 .../bulloak/tests/scaffold_noir/nested.tree | 11 - .../tests/scaffold_noir/nested_test.nr | 56 -- .../tests/scaffold_noir/no_helpers.tree | 2 - .../tests/scaffold_noir/no_helpers_test.nr | 8 - .../removes_invalid_title_chars_test.nr | 8 + .../tests/scaffold_noir/revert_when_test.nr | 18 + .../scaffold_noir/skip_modifiers_test.nr | 39 + .../scaffold_noir/spurious_comments_test.nr | 28 + .../tests/scaffold_noir/with_panic.tree | 5 - .../tests/scaffold_noir/with_panic_test.nr | 23 - crates/bulloak/tests/scaffold_rust.rs | 41 +- .../bulloak/tests/scaffold_rust/basic_test.rs | 21 +- .../tests/scaffold_rust/complex_test.rs | 739 ++++++++++++++++++ .../tests/scaffold_rust/deeply_nested.tree | 18 - .../tests/scaffold_rust/deeply_nested_test.rs | 112 --- .../scaffold_rust/disambiguation_test.rs | 46 ++ .../duplicated_condition_test.rs | 27 + .../duplicated_top_action_test.rs | 0 .../bulloak/tests/scaffold_rust/empty_test.rs | 0 .../scaffold_rust/format_descriptions_test.rs | 20 + .../tests/scaffold_rust/hash_pair_test.rs | 0 .../tests/scaffold_rust/multiple_actions.tree | 8 - .../scaffold_rust/multiple_actions_test.rs | 30 - .../bulloak/tests/scaffold_rust/nested.tree | 13 - .../tests/scaffold_rust/nested_test.rs | 64 -- .../removes_invalid_title_chars_test.rs | 14 + .../tests/scaffold_rust/revert_when_test.rs | 24 + .../scaffold_rust/skip_modifiers_test.rs | 44 ++ .../scaffold_rust/spurious_comments_test.rs | 33 + .../tests/scaffold_rust/with_panic.tree | 5 - .../tests/scaffold_rust/with_panic_test.rs | 28 - 61 files changed, 1968 insertions(+), 521 deletions(-) create mode 100644 crates/bulloak/benches/emit_noir.rs create mode 100644 crates/bulloak/benches/emit_rust.rs create mode 100644 crates/bulloak/tests/check_noir.rs rename crates/bulloak/tests/{scaffold_noir => check_noir}/basic.tree (87%) create mode 100644 crates/bulloak/tests/check_noir/basic_test.nr create mode 100644 crates/bulloak/tests/check_noir/missing_helper.tree create mode 100644 crates/bulloak/tests/check_noir/missing_helper_test.nr create mode 100644 crates/bulloak/tests/check_noir/missing_test.tree create mode 100644 crates/bulloak/tests/check_noir/missing_test_test.nr rename crates/bulloak/tests/{scaffold_rust => check_noir}/no_helpers.tree (100%) create mode 100644 crates/bulloak/tests/check_noir/no_helpers_test.nr rename crates/bulloak/tests/{scaffold_rust => check_rust}/basic.tree (87%) create mode 100644 crates/bulloak/tests/check_rust/basic_test.rs create mode 100644 crates/bulloak/tests/check_rust/missing_helper.tree create mode 100644 crates/bulloak/tests/check_rust/missing_helper_test.rs create mode 100644 crates/bulloak/tests/check_rust/missing_test.tree create mode 100644 crates/bulloak/tests/check_rust/missing_test_test.rs create mode 100644 crates/bulloak/tests/check_rust/no_helpers.tree rename crates/bulloak/tests/{scaffold_rust => check_rust}/no_helpers_test.rs (99%) create mode 100644 crates/bulloak/tests/scaffold_noir/complex_test.nr create mode 100644 crates/bulloak/tests/scaffold_noir/disambiguation_test.nr create mode 100644 crates/bulloak/tests/scaffold_noir/duplicated_condition_test.nr create mode 100644 crates/bulloak/tests/scaffold_noir/duplicated_top_action_test.nr create mode 100644 crates/bulloak/tests/scaffold_noir/empty_test.nr create mode 100644 crates/bulloak/tests/scaffold_noir/format_descriptions_test.nr create mode 100644 crates/bulloak/tests/scaffold_noir/hash_pair_test.nr delete mode 100644 crates/bulloak/tests/scaffold_noir/nested.tree delete mode 100644 crates/bulloak/tests/scaffold_noir/nested_test.nr delete mode 100644 crates/bulloak/tests/scaffold_noir/no_helpers.tree delete mode 100644 crates/bulloak/tests/scaffold_noir/no_helpers_test.nr create mode 100644 crates/bulloak/tests/scaffold_noir/removes_invalid_title_chars_test.nr create mode 100644 crates/bulloak/tests/scaffold_noir/revert_when_test.nr create mode 100644 crates/bulloak/tests/scaffold_noir/skip_modifiers_test.nr create mode 100644 crates/bulloak/tests/scaffold_noir/spurious_comments_test.nr delete mode 100644 crates/bulloak/tests/scaffold_noir/with_panic.tree delete mode 100644 crates/bulloak/tests/scaffold_noir/with_panic_test.nr create mode 100644 crates/bulloak/tests/scaffold_rust/complex_test.rs delete mode 100644 crates/bulloak/tests/scaffold_rust/deeply_nested.tree delete mode 100644 crates/bulloak/tests/scaffold_rust/deeply_nested_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/disambiguation_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/duplicated_condition_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/duplicated_top_action_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/empty_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/format_descriptions_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/hash_pair_test.rs delete mode 100644 crates/bulloak/tests/scaffold_rust/multiple_actions.tree delete mode 100644 crates/bulloak/tests/scaffold_rust/multiple_actions_test.rs delete mode 100644 crates/bulloak/tests/scaffold_rust/nested.tree delete mode 100644 crates/bulloak/tests/scaffold_rust/nested_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/removes_invalid_title_chars_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/revert_when_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/skip_modifiers_test.rs create mode 100644 crates/bulloak/tests/scaffold_rust/spurious_comments_test.rs delete mode 100644 crates/bulloak/tests/scaffold_rust/with_panic.tree delete mode 100644 crates/bulloak/tests/scaffold_rust/with_panic_test.rs diff --git a/crates/bulloak/Cargo.toml b/crates/bulloak/Cargo.toml index cb2fc99..c29d63c 100644 --- a/crates/bulloak/Cargo.toml +++ b/crates/bulloak/Cargo.toml @@ -35,5 +35,13 @@ assert_cmd = "2.0" name = "emit" harness = false +[[bench]] +name = "emit_rust" +harness = false + +[[bench]] +name = "emit_noir" +harness = false + [lints] workspace = true diff --git a/crates/bulloak/benches/emit_noir.rs b/crates/bulloak/benches/emit_noir.rs new file mode 100644 index 0000000..81986d4 --- /dev/null +++ b/crates/bulloak/benches/emit_noir.rs @@ -0,0 +1,19 @@ +#![allow(missing_docs)] +use bulloak_noir::scaffold; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn emit_big_tree_noir(c: &mut Criterion) { + let tree = + std::fs::read_to_string("benches/bench_data/cancel.tree").unwrap(); + let ast = bulloak_syntax::parse_one(&tree).unwrap(); + + let cfg = Default::default(); + let mut group = c.benchmark_group("sample-size-10"); + group.bench_function("emit-big-tree-noir", |b| { + b.iter(|| scaffold::generate(black_box(&ast), &cfg)) + }); + group.finish(); +} + +criterion_group!(benches, emit_big_tree_noir); +criterion_main!(benches); diff --git a/crates/bulloak/benches/emit_rust.rs b/crates/bulloak/benches/emit_rust.rs new file mode 100644 index 0000000..d0a3db4 --- /dev/null +++ b/crates/bulloak/benches/emit_rust.rs @@ -0,0 +1,19 @@ +#![allow(missing_docs)] +use bulloak_rust::scaffold; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn emit_big_tree_rust(c: &mut Criterion) { + let tree = + std::fs::read_to_string("benches/bench_data/cancel.tree").unwrap(); + let ast = bulloak_syntax::parse_one(&tree).unwrap(); + + let cfg = Default::default(); + let mut group = c.benchmark_group("sample-size-10"); + group.bench_function("emit-big-tree-rust", |b| { + b.iter(|| scaffold::scaffold(black_box(&ast), &cfg)) + }); + group.finish(); +} + +criterion_group!(benches, emit_big_tree_rust); +criterion_main!(benches); diff --git a/crates/bulloak/tests/check_noir.rs b/crates/bulloak/tests/check_noir.rs new file mode 100644 index 0000000..de1a357 --- /dev/null +++ b/crates/bulloak/tests/check_noir.rs @@ -0,0 +1,85 @@ +#![allow(missing_docs)] +use std::{env, fs}; + +use common::{cmd, get_binary_path}; + +mod common; + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_noir_passes_when_correct() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_noir"); + let tree_path = tests_path.join("basic.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "noir"]); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("All checks completed successfully")); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_noir_fails_when_missing_file() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_noir"); + + let temp_tree = tests_path.join("temp_missing.tree"); + fs::write(&temp_tree, "test_func\n└── It should work.").unwrap(); + + let output = cmd(&binary_path, "check", &temp_tree, &["--lang", "noir"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("File not found")); + + fs::remove_file(temp_tree).ok(); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_noir_fails_when_missing_test_function() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_noir"); + let tree_path = tests_path.join("missing_test.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "noir"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Missing test function") || stderr.contains("is missing")); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_noir_fails_when_missing_helper() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_noir"); + let tree_path = tests_path.join("missing_helper.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "noir"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Missing helper function") || stderr.contains("is missing")); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_noir_passes_with_skip_helpers() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_noir"); + let tree_path = tests_path.join("no_helpers.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "noir", "-m"]); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("All checks completed successfully")); +} diff --git a/crates/bulloak/tests/scaffold_noir/basic.tree b/crates/bulloak/tests/check_noir/basic.tree similarity index 87% rename from crates/bulloak/tests/scaffold_noir/basic.tree rename to crates/bulloak/tests/check_noir/basic.tree index c2dac26..4d25919 100644 --- a/crates/bulloak/tests/scaffold_noir/basic.tree +++ b/crates/bulloak/tests/check_noir/basic.tree @@ -1,5 +1,4 @@ hash_pair -├── It should always work. ├── When first arg is smaller than second arg │ └── It should match the result of hash(a, b). └── When first arg is bigger than second arg diff --git a/crates/bulloak/tests/check_noir/basic_test.nr b/crates/bulloak/tests/check_noir/basic_test.nr new file mode 100644 index 0000000..f98c2c5 --- /dev/null +++ b/crates/bulloak/tests/check_noir/basic_test.nr @@ -0,0 +1,21 @@ +// Generated by bulloak + +/// Helper function for condition +fn first_arg_is_bigger_than_second_arg() { +} + +/// Helper function for condition +fn first_arg_is_smaller_than_second_arg() { +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of hash(a, b). +} + +#[test] +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the result of hash(b, a). +} diff --git a/crates/bulloak/tests/check_noir/missing_helper.tree b/crates/bulloak/tests/check_noir/missing_helper.tree new file mode 100644 index 0000000..73a5eda --- /dev/null +++ b/crates/bulloak/tests/check_noir/missing_helper.tree @@ -0,0 +1,3 @@ +test_func +└── When condition is met + └── It should work. diff --git a/crates/bulloak/tests/check_noir/missing_helper_test.nr b/crates/bulloak/tests/check_noir/missing_helper_test.nr new file mode 100644 index 0000000..2e2364a --- /dev/null +++ b/crates/bulloak/tests/check_noir/missing_helper_test.nr @@ -0,0 +1,9 @@ +// Generated by bulloak + +// Missing: fn condition_is_met() {} + +#[test] +unconstrained fn test_when_condition_is_met() { + condition_is_met(); + // It should work. +} diff --git a/crates/bulloak/tests/check_noir/missing_test.tree b/crates/bulloak/tests/check_noir/missing_test.tree new file mode 100644 index 0000000..3c5314b --- /dev/null +++ b/crates/bulloak/tests/check_noir/missing_test.tree @@ -0,0 +1,3 @@ +test_func +├── It should work. +└── It should also work differently. diff --git a/crates/bulloak/tests/check_noir/missing_test_test.nr b/crates/bulloak/tests/check_noir/missing_test_test.nr new file mode 100644 index 0000000..e2097c9 --- /dev/null +++ b/crates/bulloak/tests/check_noir/missing_test_test.nr @@ -0,0 +1,7 @@ +// Generated by bulloak + +#[test] +unconstrained fn test_should_work() { + // It should work. +} +// Missing: test_should_also_work_differently diff --git a/crates/bulloak/tests/scaffold_rust/no_helpers.tree b/crates/bulloak/tests/check_noir/no_helpers.tree similarity index 100% rename from crates/bulloak/tests/scaffold_rust/no_helpers.tree rename to crates/bulloak/tests/check_noir/no_helpers.tree diff --git a/crates/bulloak/tests/check_noir/no_helpers_test.nr b/crates/bulloak/tests/check_noir/no_helpers_test.nr new file mode 100644 index 0000000..c2643dd --- /dev/null +++ b/crates/bulloak/tests/check_noir/no_helpers_test.nr @@ -0,0 +1,11 @@ +// Generated by bulloak + +#[test] +unconstrained fn test_should_return_true_for_valid_input() { + // It should return true for valid input. +} + +#[test] +unconstrained fn test_should_return_false_for_invalid_input() { + // It should return false for invalid input. +} diff --git a/crates/bulloak/tests/check_rust.rs b/crates/bulloak/tests/check_rust.rs index 26978f3..bd9806f 100644 --- a/crates/bulloak/tests/check_rust.rs +++ b/crates/bulloak/tests/check_rust.rs @@ -10,13 +10,11 @@ mod common; fn check_rust_passes_when_correct() { let cwd = env::current_dir().unwrap(); let binary_path = get_binary_path(); - let tests_path = cwd.join("tests").join("scaffold_rust"); - let tree_name = "basic.tree"; + let tests_path = cwd.join("tests").join("check_rust"); + let tree_path = tests_path.join("basic.tree"); - let tree_path = tests_path.join(tree_name); let output = cmd(&binary_path, "check", &tree_path, &["--lang", "rust"]); - // Should pass with no violations assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("All checks completed successfully")); @@ -27,24 +25,17 @@ fn check_rust_passes_when_correct() { fn check_rust_fails_when_missing_file() { let cwd = env::current_dir().unwrap(); let binary_path = get_binary_path(); - let tests_path = cwd.join("tests").join("scaffold_rust"); + let tests_path = cwd.join("tests").join("check_rust"); - // Create a temporary tree file without corresponding test file let temp_tree = tests_path.join("temp_missing.tree"); - fs::write( - &temp_tree, - "test_func\n└── It should work.", - ) - .unwrap(); + fs::write(&temp_tree, "test_func\n└── It should work.").unwrap(); let output = cmd(&binary_path, "check", &temp_tree, &["--lang", "rust"]); - // Should fail assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); assert!(stderr.contains("Rust test file is missing")); - // Clean up fs::remove_file(temp_tree).ok(); } @@ -53,46 +44,42 @@ fn check_rust_fails_when_missing_file() { fn check_rust_fails_when_missing_test_function() { let cwd = env::current_dir().unwrap(); let binary_path = get_binary_path(); - let tests_path = cwd.join("tests").join("scaffold_rust"); - - // Create a tree file - let temp_tree = tests_path.join("temp_incomplete.tree"); - fs::write( - &temp_tree, - "test_func\n├── It should work.\n└── It should also work differently.", - ) - .unwrap(); - - // Create an incomplete test file (missing one test) - let temp_test = tests_path.join("temp_incomplete_test.rs"); - fs::write( - &temp_test, - r#" -#[derive(Default)] -struct TestContext {} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_should_work() { - // It should work. - } - // Missing: test_should_also_work_differently -} -"#, - ) - .unwrap(); + let tests_path = cwd.join("tests").join("check_rust"); + let tree_path = tests_path.join("missing_test.tree"); - let output = cmd(&binary_path, "check", &temp_tree, &["--lang", "rust"]); + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "rust"]); - // Should fail assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); assert!(stderr.contains("Test function") && stderr.contains("is missing")); +} - // Clean up - fs::remove_file(temp_tree).ok(); - fs::remove_file(temp_test).ok(); +#[cfg(not(target_os = "windows"))] +#[test] +fn check_rust_fails_when_missing_helper() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_rust"); + let tree_path = tests_path.join("missing_helper.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "rust"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Helper function") && stderr.contains("is missing")); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_rust_passes_with_skip_helpers() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_rust"); + let tree_path = tests_path.join("no_helpers.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "rust", "-m"]); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("All checks completed successfully")); } diff --git a/crates/bulloak/tests/scaffold_rust/basic.tree b/crates/bulloak/tests/check_rust/basic.tree similarity index 87% rename from crates/bulloak/tests/scaffold_rust/basic.tree rename to crates/bulloak/tests/check_rust/basic.tree index c2dac26..4d25919 100644 --- a/crates/bulloak/tests/scaffold_rust/basic.tree +++ b/crates/bulloak/tests/check_rust/basic.tree @@ -1,5 +1,4 @@ hash_pair -├── It should always work. ├── When first arg is smaller than second arg │ └── It should match the result of hash(a, b). └── When first arg is bigger than second arg diff --git a/crates/bulloak/tests/check_rust/basic_test.rs b/crates/bulloak/tests/check_rust/basic_test.rs new file mode 100644 index 0000000..c755f5f --- /dev/null +++ b/crates/bulloak/tests/check_rust/basic_test.rs @@ -0,0 +1,28 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: When first arg is smaller than second arg +fn first_arg_is_smaller_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: When first arg is bigger than second arg +fn first_arg_is_bigger_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_when_first_arg_is_smaller_than_second_arg() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + // It should match the result of hash(a, b). + } + #[test] + fn test_when_first_arg_is_bigger_than_second_arg() { + let _ctx = first_arg_is_bigger_than_second_arg(TestContext::default()); + // It should match the result of hash(b, a). + } +} + diff --git a/crates/bulloak/tests/check_rust/missing_helper.tree b/crates/bulloak/tests/check_rust/missing_helper.tree new file mode 100644 index 0000000..73a5eda --- /dev/null +++ b/crates/bulloak/tests/check_rust/missing_helper.tree @@ -0,0 +1,3 @@ +test_func +└── When condition is met + └── It should work. diff --git a/crates/bulloak/tests/check_rust/missing_helper_test.rs b/crates/bulloak/tests/check_rust/missing_helper_test.rs new file mode 100644 index 0000000..45188e7 --- /dev/null +++ b/crates/bulloak/tests/check_rust/missing_helper_test.rs @@ -0,0 +1,16 @@ +// Generated by bulloak + +struct TestContext {} + +// Missing: fn condition_is_met(mut ctx: TestContext) -> TestContext {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_when_condition_is_met() { + let _ctx = condition_is_met(TestContext::default()); + // It should work. + } +} diff --git a/crates/bulloak/tests/check_rust/missing_test.tree b/crates/bulloak/tests/check_rust/missing_test.tree new file mode 100644 index 0000000..3c5314b --- /dev/null +++ b/crates/bulloak/tests/check_rust/missing_test.tree @@ -0,0 +1,3 @@ +test_func +├── It should work. +└── It should also work differently. diff --git a/crates/bulloak/tests/check_rust/missing_test_test.rs b/crates/bulloak/tests/check_rust/missing_test_test.rs new file mode 100644 index 0000000..7659fd1 --- /dev/null +++ b/crates/bulloak/tests/check_rust/missing_test_test.rs @@ -0,0 +1,14 @@ +// Generated by bulloak + +struct TestContext {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_should_work() { + // It should work. + } + // Missing: test_should_also_work_differently +} diff --git a/crates/bulloak/tests/check_rust/no_helpers.tree b/crates/bulloak/tests/check_rust/no_helpers.tree new file mode 100644 index 0000000..8f47c03 --- /dev/null +++ b/crates/bulloak/tests/check_rust/no_helpers.tree @@ -0,0 +1,3 @@ +simple_function +├── It should return true for valid input. +└── It should return false for invalid input. diff --git a/crates/bulloak/tests/scaffold_rust/no_helpers_test.rs b/crates/bulloak/tests/check_rust/no_helpers_test.rs similarity index 99% rename from crates/bulloak/tests/scaffold_rust/no_helpers_test.rs rename to crates/bulloak/tests/check_rust/no_helpers_test.rs index 44bf708..ee63dcd 100644 --- a/crates/bulloak/tests/scaffold_rust/no_helpers_test.rs +++ b/crates/bulloak/tests/check_rust/no_helpers_test.rs @@ -15,3 +15,4 @@ mod tests { // It should return false for invalid input. } } + diff --git a/crates/bulloak/tests/scaffold_noir.rs b/crates/bulloak/tests/scaffold_noir.rs index 103994a..87f9e7b 100644 --- a/crates/bulloak/tests/scaffold_noir.rs +++ b/crates/bulloak/tests/scaffold_noir.rs @@ -1,75 +1,94 @@ -//! Integration tests for Noir scaffolding. +#![allow(missing_docs)] +use std::{env, fs}; -use std::{fs, path::PathBuf, process::Command}; +use common::{cmd, get_binary_path}; +use pretty_assertions::assert_eq; -fn bulloak_binary() -> PathBuf { - assert_cmd::cargo::cargo_bin("bulloak") -} - -fn tests_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/scaffold_noir") -} - -fn cmd(binary: &PathBuf, command: &str, tree_path: &PathBuf, extra_args: &[&str]) -> std::process::Output { - let mut cmd = Command::new(binary); - cmd.arg(command); - cmd.args(extra_args); - cmd.arg(tree_path); - cmd.output().expect("Failed to execute bulloak") -} - -#[test] -fn test_scaffold_noir_basic() { - let binary_path = bulloak_binary(); - let tests_path = tests_path(); - let tree_path = tests_path.join("basic.tree"); - - let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir"]); - let actual = String::from_utf8(output.stdout).unwrap(); - - let expected = fs::read_to_string(tests_path.join("basic_test.nr")).unwrap(); - - assert_eq!(expected.trim(), actual.trim(), "Basic scaffold output should match expected"); -} +mod common; +#[cfg(not(target_os = "windows"))] #[test] -fn test_scaffold_noir_with_panic() { - let binary_path = bulloak_binary(); - let tests_path = tests_path(); - let tree_path = tests_path.join("with_panic.tree"); - - let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir"]); - let actual = String::from_utf8(output.stdout).unwrap(); - - let expected = fs::read_to_string(tests_path.join("with_panic_test.nr")).unwrap(); - - assert_eq!(expected.trim(), actual.trim(), "Should generate #[test(should_fail)] for panic cases"); +fn scaffolds_noir_trees() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let trees_path = cwd.join("tests").join("scaffold"); + let outputs_path = cwd.join("tests").join("scaffold_noir"); + let trees = [ + "basic.tree", + "complex.tree", + "disambiguation.tree", + "duplicated_condition.tree", + "duplicated_top_action.tree", + "empty.tree", + "format_descriptions.tree", + "hash_pair.tree", + "removes_invalid_title_chars.tree", + "revert_when.tree", + "skip_modifiers.tree", + "spurious_comments.tree", + ]; + + for tree_name in trees { + let tree_path = trees_path.join(tree_name); + let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir"]); + let actual = String::from_utf8(output.stdout).unwrap(); + + let output_file = outputs_path.join(tree_name.replace(".tree", "_test.nr")); + + let expected = fs::read_to_string(&output_file).unwrap_or_else(|_| { + panic!( + "Failed to read expected output file: {}", + output_file.display() + ) + }); + + // We trim here because we don't care about ending newlines. + assert_eq!( + expected.trim(), + actual.trim(), + "Mismatch for {}", + tree_name + ); + } } +#[cfg(not(target_os = "windows"))] #[test] -fn test_scaffold_noir_no_helpers() { - let binary_path = bulloak_binary(); - let tests_path = tests_path(); - let tree_path = tests_path.join("no_helpers.tree"); +fn scaffolds_noir_trees_skip_helpers() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let trees_path = cwd.join("tests").join("scaffold"); + let tree_path = trees_path.join("basic.tree"); - let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir"]); + let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir", "-m"]); let actual = String::from_utf8(output.stdout).unwrap(); - let expected = fs::read_to_string(tests_path.join("no_helpers_test.nr")).unwrap(); + // Should not contain helper functions + assert!(!actual.contains("fn first_arg_is_smaller_than_second_arg")); + assert!(!actual.contains("fn first_arg_is_bigger_than_second_arg")); - assert_eq!(expected.trim(), actual.trim(), "Simple test should work without helpers"); + // Should still contain test functions + assert!(actual.contains("#[test]")); + assert!(actual.contains("unconstrained fn")); } +#[cfg(not(target_os = "windows"))] #[test] -fn test_scaffold_noir_nested() { - let binary_path = bulloak_binary(); - let tests_path = tests_path(); - let tree_path = tests_path.join("nested.tree"); - - let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir"]); +fn scaffolds_noir_trees_format_descriptions() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let trees_path = cwd.join("tests").join("scaffold"); + let tree_path = trees_path.join("basic.tree"); + + let output = cmd( + &binary_path, + "scaffold", + &tree_path, + &["--lang", "noir", "--format-descriptions"], + ); let actual = String::from_utf8(output.stdout).unwrap(); - let expected = fs::read_to_string(tests_path.join("nested_test.nr")).unwrap(); - - assert_eq!(expected.trim(), actual.trim(), "Nested conditions should be handled correctly"); + // Comments should be capitalized and have periods + assert!(actual.contains("// It should match the result of `keccak256(abi.encodePacked(a,b))`.")); + assert!(actual.contains("// It should match the result of `keccak256(abi.encodePacked(b,a))`.")); } diff --git a/crates/bulloak/tests/scaffold_noir/basic_test.nr b/crates/bulloak/tests/scaffold_noir/basic_test.nr index 804e63a..8fb806b 100644 --- a/crates/bulloak/tests/scaffold_noir/basic_test.nr +++ b/crates/bulloak/tests/scaffold_noir/basic_test.nr @@ -8,21 +8,32 @@ fn first_arg_is_bigger_than_second_arg() { fn first_arg_is_smaller_than_second_arg() { } -#[test] -unconstrained fn test_should_always_work() { - // It should always work. +/// Helper function for condition +fn first_arg_is_zero() { +} + +#[test(should_fail)] +unconstrained fn test_should_never_revert() { + // It should never revert. } #[test] unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { first_arg_is_smaller_than_second_arg(); - // It should match the result of hash(a, b). + // It should match the result of `keccak256(abi.encodePacked(a,b))`. +} + +#[test] +unconstrained fn test_when_first_arg_is_zero() { + first_arg_is_smaller_than_second_arg(); + first_arg_is_zero(); + // It should do something. } #[test] unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { first_arg_is_bigger_than_second_arg(); - // It should match the result of hash(b, a). + // It should match the result of `keccak256(abi.encodePacked(b,a))`. } diff --git a/crates/bulloak/tests/scaffold_noir/complex_test.nr b/crates/bulloak/tests/scaffold_noir/complex_test.nr new file mode 100644 index 0000000..093f61a --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/complex_test.nr @@ -0,0 +1,422 @@ +// Generated by bulloak + +/// Helper function for condition +fn delegate_called() { +} + +/// Helper function for condition +fn not_delegate_called() { +} + +/// Helper function for condition +fn the_caller_is_a_former_recipient() { +} + +/// Helper function for condition +fn the_caller_is_a_malicious_third_party() { +} + +/// Helper function for condition +fn the_caller_is_an_approved_third_party() { +} + +/// Helper function for condition +fn the_caller_is_authorized() { +} + +/// Helper function for condition +fn the_caller_is_the_recipient() { +} + +/// Helper function for condition +fn the_caller_is_the_sender() { +} + +/// Helper function for condition +fn the_caller_is_unauthorized() { +} + +/// Helper function for condition +fn the_id_does_not_reference_a_null_stream() { +} + +/// Helper function for condition +fn the_id_references_a_null_stream() { +} + +/// Helper function for condition +fn the_recipient_does_not_implement_the_hook() { +} + +/// Helper function for condition +fn the_recipient_does_not_revert() { +} + +/// Helper function for condition +fn the_recipient_implements_the_hook() { +} + +/// Helper function for condition +fn the_recipient_is_a_contract() { +} + +/// Helper function for condition +fn the_recipient_is_not_a_contract() { +} + +/// Helper function for condition +fn the_recipient_reverts() { +} + +/// Helper function for condition +fn the_sender_does_not_implement_the_hook() { +} + +/// Helper function for condition +fn the_sender_does_not_revert() { +} + +/// Helper function for condition +fn the_sender_implements_the_hook() { +} + +/// Helper function for condition +fn the_sender_is_a_contract() { +} + +/// Helper function for condition +fn the_sender_is_not_a_contract() { +} + +/// Helper function for condition +fn the_sender_reverts() { +} + +/// Helper function for condition +fn the_stream_is_cancelable() { +} + +/// Helper function for condition +fn the_stream_is_cold() { +} + +/// Helper function for condition +fn the_stream_is_not_cancelable() { +} + +/// Helper function for condition +fn the_stream_is_warm() { +} + +/// Helper function for condition +fn the_streams_status_is_canceled() { +} + +/// Helper function for condition +fn the_streams_status_is_depleted() { +} + +/// Helper function for condition +fn the_streams_status_is_pending() { +} + +/// Helper function for condition +fn the_streams_status_is_settled() { +} + +/// Helper function for condition +fn the_streams_status_is_streaming() { +} + +/// Helper function for condition +fn there_is_no_reentrancy_1() { +} + +/// Helper function for condition +fn there_is_no_reentrancy_2() { +} + +/// Helper function for condition +fn there_is_reentrancy_1() { +} + +/// Helper function for condition +fn there_is_reentrancy_2() { +} + +#[test(should_fail)] +unconstrained fn test_when_delegate_called() { + delegate_called(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_id_references_a_null_stream() { + not_delegate_called(); + the_id_references_a_null_stream(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_streams_status_is_depleted() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_cold(); + the_streams_status_is_depleted(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_streams_status_is_canceled() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_cold(); + the_streams_status_is_canceled(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_streams_status_is_settled() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_cold(); + the_streams_status_is_settled(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_caller_is_a_malicious_third_party() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_unauthorized(); + the_caller_is_a_malicious_third_party(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_caller_is_an_approved_third_party() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_unauthorized(); + the_caller_is_an_approved_third_party(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_caller_is_a_former_recipient() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_unauthorized(); + the_caller_is_a_former_recipient(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_stream_is_not_cancelable() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_not_cancelable(); + // it should revert +} + +#[test] +unconstrained fn test_when_the_streams_status_is_pending() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_pending(); + // it should cancel the stream + // it should mark the stream as depleted + // it should make the stream not cancelable +} + +#[test] +unconstrained fn test_when_the_recipient_is_not_a_contract() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_sender(); + the_recipient_is_not_a_contract(); + // it should cancel the stream + // it should mark the stream as canceled +} + +#[test(should_fail)] +unconstrained fn test_when_the_recipient_does_not_implement_the_hook() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_sender(); + the_recipient_is_a_contract(); + the_recipient_does_not_implement_the_hook(); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the recipient hook + // it should ignore the revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_recipient_reverts() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_sender(); + the_recipient_is_a_contract(); + the_recipient_implements_the_hook(); + the_recipient_reverts(); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the recipient hook + // it should ignore the revert +} + +#[test(should_fail)] +unconstrained fn test_when_there_is_reentrancy_1() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_sender(); + the_recipient_is_a_contract(); + the_recipient_implements_the_hook(); + the_recipient_does_not_revert(); + there_is_reentrancy_1(); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the recipient hook + // it should ignore the revert +} + +#[test] +unconstrained fn test_when_there_is_no_reentrancy_1() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_sender(); + the_recipient_is_a_contract(); + the_recipient_implements_the_hook(); + the_recipient_does_not_revert(); + there_is_no_reentrancy_1(); + // it should cancel the stream + // it should mark the stream as canceled + // it should make the stream not cancelable + // it should update the refunded amount + // it should refund the sender + // it should call the recipient hook + // it should emit a {CancelLockupStream} event + // it should emit a {MetadataUpdate} event +} + +#[test] +unconstrained fn test_when_the_sender_is_not_a_contract() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_recipient(); + the_sender_is_not_a_contract(); + // it should cancel the stream + // it should mark the stream as canceled +} + +#[test(should_fail)] +unconstrained fn test_when_the_sender_does_not_implement_the_hook() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_recipient(); + the_sender_is_a_contract(); + the_sender_does_not_implement_the_hook(); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the sender hook + // it should ignore the revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_sender_reverts() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_recipient(); + the_sender_is_a_contract(); + the_sender_implements_the_hook(); + the_sender_reverts(); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the sender hook + // it should ignore the revert +} + +#[test(should_fail)] +unconstrained fn test_when_there_is_reentrancy_2() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_recipient(); + the_sender_is_a_contract(); + the_sender_implements_the_hook(); + the_sender_does_not_revert(); + there_is_reentrancy_2(); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the sender hook + // it should ignore the revert +} + +#[test] +unconstrained fn test_when_there_is_no_reentrancy_2() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_recipient(); + the_sender_is_a_contract(); + the_sender_implements_the_hook(); + the_sender_does_not_revert(); + there_is_no_reentrancy_2(); + // it should cancel the stream + // it should mark the stream as canceled + // it should make the stream not cancelable + // it should update the refunded amount + // it should refund the sender + // it should call the sender hook + // it should emit a {MetadataUpdate} event + // it should emit a {CancelLockupStream} event +} + + diff --git a/crates/bulloak/tests/scaffold_noir/disambiguation_test.nr b/crates/bulloak/tests/scaffold_noir/disambiguation_test.nr new file mode 100644 index 0000000..b777c18 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/disambiguation_test.nr @@ -0,0 +1,47 @@ +// Generated by bulloak + +/// Helper function for condition +fn a_is_even() { +} + +/// Helper function for condition +fn b_is_even() { +} + +/// Helper function for condition +fn not_zero() { +} + +/// Helper function for condition +fn zero() { +} + +#[test(should_fail)] +unconstrained fn test_when_zero() { + a_is_even(); + zero(); + // it should revert +} + +#[test] +unconstrained fn test_when_not_zero() { + a_is_even(); + not_zero(); + // it should work +} + +#[test(should_fail)] +unconstrained fn test_when_zero() { + b_is_even(); + zero(); + // it should revert +} + +#[test] +unconstrained fn test_when_not_zero() { + b_is_even(); + not_zero(); + // it should work +} + + diff --git a/crates/bulloak/tests/scaffold_noir/duplicated_condition_test.nr b/crates/bulloak/tests/scaffold_noir/duplicated_condition_test.nr new file mode 100644 index 0000000..408f92c --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/duplicated_condition_test.nr @@ -0,0 +1,25 @@ +// Generated by bulloak + +/// Helper function for condition +fn first_arg_is_smaller_than_second_arg() { +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. +} + + diff --git a/crates/bulloak/tests/scaffold_noir/duplicated_top_action_test.nr b/crates/bulloak/tests/scaffold_noir/duplicated_top_action_test.nr new file mode 100644 index 0000000..e69de29 diff --git a/crates/bulloak/tests/scaffold_noir/empty_test.nr b/crates/bulloak/tests/scaffold_noir/empty_test.nr new file mode 100644 index 0000000..e69de29 diff --git a/crates/bulloak/tests/scaffold_noir/format_descriptions_test.nr b/crates/bulloak/tests/scaffold_noir/format_descriptions_test.nr new file mode 100644 index 0000000..031001a --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/format_descriptions_test.nr @@ -0,0 +1,14 @@ +// Generated by bulloak + +/// Helper function for condition +fn formatting_toggled() { +} + +#[test] +unconstrained fn test_when_formatting_toggled() { + formatting_toggled(); + // it should reformat comment + // it should handle question? +} + + diff --git a/crates/bulloak/tests/scaffold_noir/hash_pair_test.nr b/crates/bulloak/tests/scaffold_noir/hash_pair_test.nr new file mode 100644 index 0000000..e69de29 diff --git a/crates/bulloak/tests/scaffold_noir/nested.tree b/crates/bulloak/tests/scaffold_noir/nested.tree deleted file mode 100644 index 000495c..0000000 --- a/crates/bulloak/tests/scaffold_noir/nested.tree +++ /dev/null @@ -1,11 +0,0 @@ -transfer -├── When amount is zero -│ └── It should revert. -└── When amount is not zero - ├── Given sender has insufficient balance - │ └── It should revert. - └── Given sender has sufficient balance - ├── When recipient is the sender - │ └── It should succeed without transfer. - └── When recipient is different - └── It should transfer the amount. diff --git a/crates/bulloak/tests/scaffold_noir/nested_test.nr b/crates/bulloak/tests/scaffold_noir/nested_test.nr deleted file mode 100644 index 678559c..0000000 --- a/crates/bulloak/tests/scaffold_noir/nested_test.nr +++ /dev/null @@ -1,56 +0,0 @@ -// Generated by bulloak - -/// Helper function for condition -fn amount_is_not_zero() { -} - -/// Helper function for condition -fn amount_is_zero() { -} - -/// Helper function for condition -fn recipient_is_different() { -} - -/// Helper function for condition -fn recipient_is_the_sender() { -} - -/// Helper function for condition -fn sender_has_insufficient_balance() { -} - -/// Helper function for condition -fn sender_has_sufficient_balance() { -} - -#[test(should_fail)] -unconstrained fn test_when_amount_is_zero() { - amount_is_zero(); - // It should revert. -} - -#[test(should_fail)] -unconstrained fn test_when_sender_has_insufficient_balance() { - amount_is_not_zero(); - sender_has_insufficient_balance(); - // It should revert. -} - -#[test] -unconstrained fn test_when_recipient_is_the_sender() { - amount_is_not_zero(); - sender_has_sufficient_balance(); - recipient_is_the_sender(); - // It should succeed without transfer. -} - -#[test] -unconstrained fn test_when_recipient_is_different() { - amount_is_not_zero(); - sender_has_sufficient_balance(); - recipient_is_different(); - // It should transfer the amount. -} - - diff --git a/crates/bulloak/tests/scaffold_noir/no_helpers.tree b/crates/bulloak/tests/scaffold_noir/no_helpers.tree deleted file mode 100644 index 5229cab..0000000 --- a/crates/bulloak/tests/scaffold_noir/no_helpers.tree +++ /dev/null @@ -1,2 +0,0 @@ -simple_test -└── It should work correctly. diff --git a/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr b/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr deleted file mode 100644 index c1968ee..0000000 --- a/crates/bulloak/tests/scaffold_noir/no_helpers_test.nr +++ /dev/null @@ -1,8 +0,0 @@ -// Generated by bulloak - -#[test] -unconstrained fn test_should_work_correctly() { - // It should work correctly. -} - - diff --git a/crates/bulloak/tests/scaffold_noir/removes_invalid_title_chars_test.nr b/crates/bulloak/tests/scaffold_noir/removes_invalid_title_chars_test.nr new file mode 100644 index 0000000..fc8eab0 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/removes_invalid_title_chars_test.nr @@ -0,0 +1,8 @@ +// Generated by bulloak + +#[test] +unconstrained fn test_cant_do_x() { + // It can’t do, X. +} + + diff --git a/crates/bulloak/tests/scaffold_noir/revert_when_test.nr b/crates/bulloak/tests/scaffold_noir/revert_when_test.nr new file mode 100644 index 0000000..14cbf75 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/revert_when_test.nr @@ -0,0 +1,18 @@ +// Generated by bulloak + +/// Helper function for condition +fn a_condition_is_met() { +} + +/// Helper function for condition +fn stuff_is_called() { +} + +#[test(should_fail)] +unconstrained fn test_when_a_condition_is_met() { + stuff_is_called(); + a_condition_is_met(); + // It should revert. +} + + diff --git a/crates/bulloak/tests/scaffold_noir/skip_modifiers_test.nr b/crates/bulloak/tests/scaffold_noir/skip_modifiers_test.nr new file mode 100644 index 0000000..8fb806b --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/skip_modifiers_test.nr @@ -0,0 +1,39 @@ +// Generated by bulloak + +/// Helper function for condition +fn first_arg_is_bigger_than_second_arg() { +} + +/// Helper function for condition +fn first_arg_is_smaller_than_second_arg() { +} + +/// Helper function for condition +fn first_arg_is_zero() { +} + +#[test(should_fail)] +unconstrained fn test_should_never_revert() { + // It should never revert. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. +} + +#[test] +unconstrained fn test_when_first_arg_is_zero() { + first_arg_is_smaller_than_second_arg(); + first_arg_is_zero(); + // It should do something. +} + +#[test] +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. +} + + diff --git a/crates/bulloak/tests/scaffold_noir/spurious_comments_test.nr b/crates/bulloak/tests/scaffold_noir/spurious_comments_test.nr new file mode 100644 index 0000000..2a568e8 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/spurious_comments_test.nr @@ -0,0 +1,28 @@ +// Generated by bulloak + +/// Helper function for condition +fn first_arg_is_bigger_than_second_arg() { +} + +/// Helper function for condition +fn first_arg_is_smaller_than_second_arg() { +} + +#[test(should_fail)] +unconstrained fn test_should_never_revert() { + // It should never revert. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. +} + +#[test] +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. +} + + diff --git a/crates/bulloak/tests/scaffold_noir/with_panic.tree b/crates/bulloak/tests/scaffold_noir/with_panic.tree deleted file mode 100644 index 3a31f3c..0000000 --- a/crates/bulloak/tests/scaffold_noir/with_panic.tree +++ /dev/null @@ -1,5 +0,0 @@ -divide -├── When divisor is zero -│ └── It should panic with division by zero. -└── When divisor is non-zero - └── It should return the quotient. diff --git a/crates/bulloak/tests/scaffold_noir/with_panic_test.nr b/crates/bulloak/tests/scaffold_noir/with_panic_test.nr deleted file mode 100644 index 347b3a4..0000000 --- a/crates/bulloak/tests/scaffold_noir/with_panic_test.nr +++ /dev/null @@ -1,23 +0,0 @@ -// Generated by bulloak - -/// Helper function for condition -fn divisor_is_nonzero() { -} - -/// Helper function for condition -fn divisor_is_zero() { -} - -#[test(should_fail)] -unconstrained fn test_when_divisor_is_zero() { - divisor_is_zero(); - // It should panic with division by zero. -} - -#[test] -unconstrained fn test_when_divisor_is_nonzero() { - divisor_is_nonzero(); - // It should return the quotient. -} - - diff --git a/crates/bulloak/tests/scaffold_rust.rs b/crates/bulloak/tests/scaffold_rust.rs index ed0bdfd..ab93392 100644 --- a/crates/bulloak/tests/scaffold_rust.rs +++ b/crates/bulloak/tests/scaffold_rust.rs @@ -11,26 +11,29 @@ mod common; fn scaffolds_rust_trees() { let cwd = env::current_dir().unwrap(); let binary_path = get_binary_path(); - let tests_path = cwd.join("tests").join("scaffold_rust"); + let trees_path = cwd.join("tests").join("scaffold"); + let outputs_path = cwd.join("tests").join("scaffold_rust"); let trees = [ "basic.tree", - "with_panic.tree", - "no_helpers.tree", - "nested.tree", - "deeply_nested.tree", - "multiple_actions.tree", + "complex.tree", + "disambiguation.tree", + "duplicated_condition.tree", + "duplicated_top_action.tree", + "empty.tree", + "format_descriptions.tree", + "hash_pair.tree", + "removes_invalid_title_chars.tree", + "revert_when.tree", + "skip_modifiers.tree", + "spurious_comments.tree", ]; for tree_name in trees { - let tree_path = tests_path.join(tree_name); + let tree_path = trees_path.join(tree_name); let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "rust"]); let actual = String::from_utf8(output.stdout).unwrap(); - let mut output_file = tree_path.clone(); - output_file.set_extension(""); - let mut output_file_str = output_file.into_os_string(); - output_file_str.push("_test.rs"); - let output_file: std::path::PathBuf = output_file_str.into(); + let output_file = outputs_path.join(tree_name.replace(".tree", "_test.rs")); let expected = fs::read_to_string(&output_file).unwrap_or_else(|_| { panic!( @@ -54,10 +57,9 @@ fn scaffolds_rust_trees() { fn scaffolds_rust_trees_skip_helpers() { let cwd = env::current_dir().unwrap(); let binary_path = get_binary_path(); - let tests_path = cwd.join("tests").join("scaffold_rust"); - let tree_name = "basic.tree"; + let trees_path = cwd.join("tests").join("scaffold"); + let tree_path = trees_path.join("basic.tree"); - let tree_path = tests_path.join(tree_name); let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "rust", "-m"]); let actual = String::from_utf8(output.stdout).unwrap(); @@ -76,10 +78,9 @@ fn scaffolds_rust_trees_skip_helpers() { fn scaffolds_rust_trees_format_descriptions() { let cwd = env::current_dir().unwrap(); let binary_path = get_binary_path(); - let tests_path = cwd.join("tests").join("scaffold_rust"); - let tree_name = "basic.tree"; + let trees_path = cwd.join("tests").join("scaffold"); + let tree_path = trees_path.join("basic.tree"); - let tree_path = tests_path.join(tree_name); let output = cmd( &binary_path, "scaffold", @@ -89,6 +90,6 @@ fn scaffolds_rust_trees_format_descriptions() { let actual = String::from_utf8(output.stdout).unwrap(); // Comments should be capitalized and have periods - assert!(actual.contains("// It should match the result of hash(a, b).")); - assert!(actual.contains("// It should match the result of hash(b, a).")); + assert!(actual.contains("// It should match the result of `keccak256(abi.encodePacked(a,b))`.")); + assert!(actual.contains("// It should match the result of `keccak256(abi.encodePacked(b,a))`.")); } diff --git a/crates/bulloak/tests/scaffold_rust/basic_test.rs b/crates/bulloak/tests/scaffold_rust/basic_test.rs index 3fd6640..254c750 100644 --- a/crates/bulloak/tests/scaffold_rust/basic_test.rs +++ b/crates/bulloak/tests/scaffold_rust/basic_test.rs @@ -7,6 +7,10 @@ struct TestContext {} fn first_arg_is_smaller_than_second_arg(mut ctx: TestContext) -> TestContext { ctx } +/// Helper: When first arg is zero +fn first_arg_is_zero(mut ctx: TestContext) -> TestContext { + ctx +} /// Helper: When first arg is bigger than second arg fn first_arg_is_bigger_than_second_arg(mut ctx: TestContext) -> TestContext { ctx @@ -15,17 +19,26 @@ fn first_arg_is_bigger_than_second_arg(mut ctx: TestContext) -> TestContext { mod tests { use super::*; #[test] - fn test_should_always_work() { - // It should always work. + #[should_panic] + fn test_should_never_revert() { + // It should never revert. } #[test] fn test_when_first_arg_is_smaller_than_second_arg() { let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); - // It should match the result of hash(a, b). + // It should match the result of `keccak256(abi.encodePacked(a,b))`. + } + #[test] + fn test_when_first_arg_is_zero() { + let _ctx = first_arg_is_zero( + first_arg_is_smaller_than_second_arg(TestContext::default()), + ); + // It should do something. } #[test] fn test_when_first_arg_is_bigger_than_second_arg() { let _ctx = first_arg_is_bigger_than_second_arg(TestContext::default()); - // It should match the result of hash(b, a). + // It should match the result of `keccak256(abi.encodePacked(b,a))`. } } + diff --git a/crates/bulloak/tests/scaffold_rust/complex_test.rs b/crates/bulloak/tests/scaffold_rust/complex_test.rs new file mode 100644 index 0000000..beae80c --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/complex_test.rs @@ -0,0 +1,739 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: when delegate called +fn delegate_called(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when not delegate called +fn not_delegate_called(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the id references a null stream +fn the_id_references_a_null_stream(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the id does not reference a null stream +fn the_id_does_not_reference_a_null_stream(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the stream is cold +fn the_stream_is_cold(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the streams status is DEPLETED +fn the_streams_status_is_d_e_p_l_e_t_e_d(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the streams status is CANCELED +fn the_streams_status_is_c_a_n_c_e_l_e_d(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the streams status is SETTLED +fn the_streams_status_is_s_e_t_t_l_e_d(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the stream is warm +fn the_stream_is_warm(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is unauthorized +fn the_caller_is_unauthorized(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is a malicious third party +fn the_caller_is_a_malicious_third_party(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is an approved third party +fn the_caller_is_an_approved_third_party(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is a former recipient +fn the_caller_is_a_former_recipient(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is authorized +fn the_caller_is_authorized(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the stream is not cancelable +fn the_stream_is_not_cancelable(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the stream is cancelable +fn the_stream_is_cancelable(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the streams status is PENDING +fn the_streams_status_is_p_e_n_d_i_n_g(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the streams status is STREAMING +fn the_streams_status_is_s_t_r_e_a_m_i_n_g(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is the sender +fn the_caller_is_the_sender(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the recipient is not a contract +fn the_recipient_is_not_a_contract(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the recipient is a contract +fn the_recipient_is_a_contract(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the recipient does not implement the hook +fn the_recipient_does_not_implement_the_hook(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the recipient implements the hook +fn the_recipient_implements_the_hook(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the recipient reverts +fn the_recipient_reverts(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the recipient does not revert +fn the_recipient_does_not_revert(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when there is reentrancy 1 +fn there_is_reentrancy_1(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when there is no reentrancy 1 +fn there_is_no_reentrancy_1(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is the recipient +fn the_caller_is_the_recipient(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the sender is not a contract +fn the_sender_is_not_a_contract(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the sender is a contract +fn the_sender_is_a_contract(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the sender does not implement the hook +fn the_sender_does_not_implement_the_hook(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the sender implements the hook +fn the_sender_implements_the_hook(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the sender reverts +fn the_sender_reverts(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the sender does not revert +fn the_sender_does_not_revert(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when there is reentrancy 2 +fn there_is_reentrancy_2(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when there is no reentrancy 2 +fn there_is_no_reentrancy_2(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[should_panic] + fn test_when_delegate_called() { + let _ctx = delegate_called(TestContext::default()); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_id_references_a_null_stream() { + let _ctx = the_id_references_a_null_stream( + not_delegate_called(TestContext::default()), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_streams_status_is_d_e_p_l_e_t_e_d() { + let _ctx = the_streams_status_is_d_e_p_l_e_t_e_d( + the_stream_is_cold( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_streams_status_is_c_a_n_c_e_l_e_d() { + let _ctx = the_streams_status_is_c_a_n_c_e_l_e_d( + the_stream_is_cold( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_streams_status_is_s_e_t_t_l_e_d() { + let _ctx = the_streams_status_is_s_e_t_t_l_e_d( + the_stream_is_cold( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_caller_is_a_malicious_third_party() { + let _ctx = the_caller_is_a_malicious_third_party( + the_caller_is_unauthorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_caller_is_an_approved_third_party() { + let _ctx = the_caller_is_an_approved_third_party( + the_caller_is_unauthorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_caller_is_a_former_recipient() { + let _ctx = the_caller_is_a_former_recipient( + the_caller_is_unauthorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_stream_is_not_cancelable() { + let _ctx = the_stream_is_not_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ); + // it should revert + } + #[test] + fn test_when_the_streams_status_is_p_e_n_d_i_n_g() { + let _ctx = the_streams_status_is_p_e_n_d_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as depleted + // it should make the stream not cancelable + } + #[test] + fn test_when_the_streams_status_is_p_e_n_d_i_n_g() { + let _ctx = the_streams_status_is_p_e_n_d_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ); + } + #[test] + fn test_when_the_streams_status_is_p_e_n_d_i_n_g() { + let _ctx = the_streams_status_is_p_e_n_d_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ); + } + #[test] + fn test_when_the_recipient_is_not_a_contract() { + let _ctx = the_recipient_is_not_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + } + #[test] + fn test_when_the_recipient_is_not_a_contract() { + let _ctx = the_recipient_is_not_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + #[should_panic] + fn test_when_the_recipient_does_not_implement_the_hook() { + let _ctx = the_recipient_does_not_implement_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the recipient hook + // it should ignore the revert + } + #[test] + fn test_when_the_recipient_reverts() { + let _ctx = the_recipient_reverts( + the_recipient_implements_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the recipient hook + // it should ignore the revert + } + #[test] + fn test_when_the_recipient_reverts() { + let _ctx = the_recipient_reverts( + the_recipient_implements_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + fn test_when_the_recipient_reverts() { + let _ctx = the_recipient_reverts( + the_recipient_implements_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + #[should_panic] + fn test_when_the_recipient_reverts() { + let _ctx = the_recipient_reverts( + the_recipient_implements_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + #[should_panic] + fn test_when_there_is_reentrancy_1() { + let _ctx = there_is_reentrancy_1( + the_recipient_does_not_revert( + the_recipient_implements_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the recipient hook + // it should ignore the revert + } + #[test] + fn test_when_there_is_no_reentrancy_1() { + let _ctx = there_is_no_reentrancy_1( + the_recipient_does_not_revert( + the_recipient_implements_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should make the stream not cancelable + // it should update the refunded amount + // it should refund the sender + // it should call the recipient hook + // it should emit a {CancelLockupStream} event + // it should emit a {MetadataUpdate} event + } + #[test] + fn test_when_the_sender_is_not_a_contract() { + let _ctx = the_sender_is_not_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + } + #[test] + fn test_when_the_sender_is_not_a_contract() { + let _ctx = the_sender_is_not_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + #[should_panic] + fn test_when_the_sender_does_not_implement_the_hook() { + let _ctx = the_sender_does_not_implement_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the sender hook + // it should ignore the revert + } + #[test] + fn test_when_the_sender_reverts() { + let _ctx = the_sender_reverts( + the_sender_implements_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the sender hook + // it should ignore the revert + } + #[test] + fn test_when_the_sender_reverts() { + let _ctx = the_sender_reverts( + the_sender_implements_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + fn test_when_the_sender_reverts() { + let _ctx = the_sender_reverts( + the_sender_implements_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + #[should_panic] + fn test_when_the_sender_reverts() { + let _ctx = the_sender_reverts( + the_sender_implements_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + #[should_panic] + fn test_when_there_is_reentrancy_2() { + let _ctx = there_is_reentrancy_2( + the_sender_does_not_revert( + the_sender_implements_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the sender hook + // it should ignore the revert + } + #[test] + fn test_when_there_is_no_reentrancy_2() { + let _ctx = there_is_no_reentrancy_2( + the_sender_does_not_revert( + the_sender_implements_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should make the stream not cancelable + // it should update the refunded amount + // it should refund the sender + // it should call the sender hook + // it should emit a {MetadataUpdate} event + // it should emit a {CancelLockupStream} event + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/deeply_nested.tree b/crates/bulloak/tests/scaffold_rust/deeply_nested.tree deleted file mode 100644 index ccd1544..0000000 --- a/crates/bulloak/tests/scaffold_rust/deeply_nested.tree +++ /dev/null @@ -1,18 +0,0 @@ -validate_config -├── when config is null -│ └── it should revert with null config error -└── when config is not null - ├── given version is outdated - │ └── it should revert with version error - └── given version is current - ├── when permissions are empty - │ └── it should revert with permissions error - └── when permissions are set - ├── given user is not authorized - │ ├── when user is banned - │ │ └── it should revert with banned error - │ └── when user is unknown - │ └── it should revert with unauthorized error - └── given user is authorized - ├── it should validate successfully - └── it should return config data diff --git a/crates/bulloak/tests/scaffold_rust/deeply_nested_test.rs b/crates/bulloak/tests/scaffold_rust/deeply_nested_test.rs deleted file mode 100644 index 5b41618..0000000 --- a/crates/bulloak/tests/scaffold_rust/deeply_nested_test.rs +++ /dev/null @@ -1,112 +0,0 @@ -// Generated by bulloak - -/// Context for test conditions -#[derive(Default)] -struct TestContext {} -/// Helper: when config is null -fn config_is_null(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: when config is not null -fn config_is_not_null(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: given version is outdated -fn version_is_outdated(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: given version is current -fn version_is_current(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: when permissions are empty -fn permissions_are_empty(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: when permissions are set -fn permissions_are_set(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: given user is not authorized -fn user_is_not_authorized(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: when user is banned -fn user_is_banned(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: when user is unknown -fn user_is_unknown(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: given user is authorized -fn user_is_authorized(mut ctx: TestContext) -> TestContext { - ctx -} -#[cfg(test)] -mod tests { - use super::*; - #[test] - #[should_panic] - fn test_when_config_is_null() { - let _ctx = config_is_null(TestContext::default()); - // it should revert with null config error - } - #[test] - #[should_panic] - fn test_when_version_is_outdated() { - let _ctx = version_is_outdated(config_is_not_null(TestContext::default())); - // it should revert with version error - } - #[test] - #[should_panic] - fn test_when_permissions_are_empty() { - let _ctx = permissions_are_empty( - version_is_current(config_is_not_null(TestContext::default())), - ); - // it should revert with permissions error - } - #[test] - #[should_panic] - fn test_when_user_is_banned() { - let _ctx = user_is_banned( - user_is_not_authorized( - permissions_are_set( - version_is_current(config_is_not_null(TestContext::default())), - ), - ), - ); - // it should revert with banned error - } - #[test] - #[should_panic] - fn test_when_user_is_unknown() { - let _ctx = user_is_unknown( - user_is_not_authorized( - permissions_are_set( - version_is_current(config_is_not_null(TestContext::default())), - ), - ), - ); - // it should revert with unauthorized error - } - #[test] - fn test_when_user_is_authorized() { - let _ctx = user_is_authorized( - permissions_are_set( - version_is_current(config_is_not_null(TestContext::default())), - ), - ); - // it should validate successfully - // it should return config data - } - #[test] - fn test_when_user_is_authorized() { - let _ctx = user_is_authorized( - permissions_are_set( - version_is_current(config_is_not_null(TestContext::default())), - ), - ); - } -} - diff --git a/crates/bulloak/tests/scaffold_rust/disambiguation_test.rs b/crates/bulloak/tests/scaffold_rust/disambiguation_test.rs new file mode 100644 index 0000000..cd2f4d5 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/disambiguation_test.rs @@ -0,0 +1,46 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: when a is even +fn a_is_even(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given zero +fn zero(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given not zero +fn not_zero(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when b is even +fn b_is_even(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[should_panic] + fn test_when_zero() { + let _ctx = zero(a_is_even(TestContext::default())); + // it should revert + } + #[test] + fn test_when_not_zero() { + let _ctx = not_zero(a_is_even(TestContext::default())); + // it should work + } + #[test] + #[should_panic] + fn test_when_zero() { + let _ctx = zero(b_is_even(TestContext::default())); + } + #[test] + fn test_when_not_zero() { + let _ctx = not_zero(b_is_even(TestContext::default())); + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/duplicated_condition_test.rs b/crates/bulloak/tests/scaffold_rust/duplicated_condition_test.rs new file mode 100644 index 0000000..4859115 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/duplicated_condition_test.rs @@ -0,0 +1,27 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: When first arg is smaller than second arg +fn first_arg_is_smaller_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_when_first_arg_is_smaller_than_second_arg() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. + } + #[test] + fn test_when_first_arg_is_smaller_than_second_arg() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + } + #[test] + fn test_when_first_arg_is_smaller_than_second_arg() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/duplicated_top_action_test.rs b/crates/bulloak/tests/scaffold_rust/duplicated_top_action_test.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/bulloak/tests/scaffold_rust/empty_test.rs b/crates/bulloak/tests/scaffold_rust/empty_test.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/bulloak/tests/scaffold_rust/format_descriptions_test.rs b/crates/bulloak/tests/scaffold_rust/format_descriptions_test.rs new file mode 100644 index 0000000..8fcd3d5 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/format_descriptions_test.rs @@ -0,0 +1,20 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: when formatting toggled +fn formatting_toggled(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_when_formatting_toggled() { + let _ctx = formatting_toggled(TestContext::default()); + // it should reformat comment + // it should handle question? + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/hash_pair_test.rs b/crates/bulloak/tests/scaffold_rust/hash_pair_test.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/bulloak/tests/scaffold_rust/multiple_actions.tree b/crates/bulloak/tests/scaffold_rust/multiple_actions.tree deleted file mode 100644 index edc0ba0..0000000 --- a/crates/bulloak/tests/scaffold_rust/multiple_actions.tree +++ /dev/null @@ -1,8 +0,0 @@ -process_payment -├── it should validate input -├── it should check balance -└── when balance sufficient - ├── it should deduct amount - ├── it should update ledger - ├── it should emit event - └── it should return receipt diff --git a/crates/bulloak/tests/scaffold_rust/multiple_actions_test.rs b/crates/bulloak/tests/scaffold_rust/multiple_actions_test.rs deleted file mode 100644 index 01f43d8..0000000 --- a/crates/bulloak/tests/scaffold_rust/multiple_actions_test.rs +++ /dev/null @@ -1,30 +0,0 @@ -// Generated by bulloak - -/// Context for test conditions -#[derive(Default)] -struct TestContext {} -/// Helper: when balance sufficient -fn balance_sufficient(mut ctx: TestContext) -> TestContext { - ctx -} -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn test_should_validate_input() { - // it should validate input - } - #[test] - fn test_should_check_balance() { - // it should check balance - } - #[test] - fn test_when_balance_sufficient() { - let _ctx = balance_sufficient(TestContext::default()); - // it should deduct amount - // it should update ledger - // it should emit event - // it should return receipt - } -} - diff --git a/crates/bulloak/tests/scaffold_rust/nested.tree b/crates/bulloak/tests/scaffold_rust/nested.tree deleted file mode 100644 index 02778ee..0000000 --- a/crates/bulloak/tests/scaffold_rust/nested.tree +++ /dev/null @@ -1,13 +0,0 @@ -transfer -├── when amount is zero -│ └── it should revert -└── when amount is not zero - ├── given sender has insufficient balance - │ └── it should revert - └── given sender has sufficient balance - ├── when recipient is the sender - │ └── it should succeed without transfer - └── when recipient is different - ├── it should transfer the amount - ├── it should update balances - └── it should emit a Transfer event diff --git a/crates/bulloak/tests/scaffold_rust/nested_test.rs b/crates/bulloak/tests/scaffold_rust/nested_test.rs deleted file mode 100644 index f63f9f1..0000000 --- a/crates/bulloak/tests/scaffold_rust/nested_test.rs +++ /dev/null @@ -1,64 +0,0 @@ -// Generated by bulloak - -/// Context for test conditions -#[derive(Default)] -struct TestContext {} -/// Helper: when amount is zero -fn amount_is_zero(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: when amount is not zero -fn amount_is_not_zero(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: given sender has insufficient balance -fn sender_has_insufficient_balance(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: given sender has sufficient balance -fn sender_has_sufficient_balance(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: when recipient is the sender -fn recipient_is_the_sender(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: when recipient is different -fn recipient_is_different(mut ctx: TestContext) -> TestContext { - ctx -} -#[cfg(test)] -mod tests { - use super::*; - #[test] - #[should_panic] - fn test_when_amount_is_zero() { - let _ctx = amount_is_zero(TestContext::default()); - // it should revert - } - #[test] - #[should_panic] - fn test_when_sender_has_insufficient_balance() { - let _ctx = sender_has_insufficient_balance( - amount_is_not_zero(TestContext::default()), - ); - // it should revert - } - #[test] - fn test_when_recipient_is_the_sender() { - let _ctx = recipient_is_the_sender( - sender_has_sufficient_balance(amount_is_not_zero(TestContext::default())), - ); - // it should succeed without transfer - } - #[test] - fn test_when_recipient_is_different() { - let _ctx = recipient_is_different( - sender_has_sufficient_balance(amount_is_not_zero(TestContext::default())), - ); - // it should transfer the amount - // it should update balances - // it should emit a Transfer event - } -} - diff --git a/crates/bulloak/tests/scaffold_rust/removes_invalid_title_chars_test.rs b/crates/bulloak/tests/scaffold_rust/removes_invalid_title_chars_test.rs new file mode 100644 index 0000000..245fdb8 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/removes_invalid_title_chars_test.rs @@ -0,0 +1,14 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_cant_dox() { + // It can’t do, X. + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/revert_when_test.rs b/crates/bulloak/tests/scaffold_rust/revert_when_test.rs new file mode 100644 index 0000000..a1f40d1 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/revert_when_test.rs @@ -0,0 +1,24 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: When stuff is called +fn stuff_is_called(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: When a condition is met +fn a_condition_is_met(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[should_panic] + fn test_when_a_condition_is_met() { + let _ctx = a_condition_is_met(stuff_is_called(TestContext::default())); + // It should revert. + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/skip_modifiers_test.rs b/crates/bulloak/tests/scaffold_rust/skip_modifiers_test.rs new file mode 100644 index 0000000..254c750 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/skip_modifiers_test.rs @@ -0,0 +1,44 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: When first arg is smaller than second arg +fn first_arg_is_smaller_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: When first arg is zero +fn first_arg_is_zero(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: When first arg is bigger than second arg +fn first_arg_is_bigger_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[should_panic] + fn test_should_never_revert() { + // It should never revert. + } + #[test] + fn test_when_first_arg_is_smaller_than_second_arg() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. + } + #[test] + fn test_when_first_arg_is_zero() { + let _ctx = first_arg_is_zero( + first_arg_is_smaller_than_second_arg(TestContext::default()), + ); + // It should do something. + } + #[test] + fn test_when_first_arg_is_bigger_than_second_arg() { + let _ctx = first_arg_is_bigger_than_second_arg(TestContext::default()); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/spurious_comments_test.rs b/crates/bulloak/tests/scaffold_rust/spurious_comments_test.rs new file mode 100644 index 0000000..e0e03e7 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/spurious_comments_test.rs @@ -0,0 +1,33 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: When first arg is smaller than second arg +fn first_arg_is_smaller_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: When first arg is bigger than second arg +fn first_arg_is_bigger_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[should_panic] + fn test_should_never_revert() { + // It should never revert. + } + #[test] + fn test_when_first_arg_is_smaller_than_second_arg() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. + } + #[test] + fn test_when_first_arg_is_bigger_than_second_arg() { + let _ctx = first_arg_is_bigger_than_second_arg(TestContext::default()); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/with_panic.tree b/crates/bulloak/tests/scaffold_rust/with_panic.tree deleted file mode 100644 index 3a31f3c..0000000 --- a/crates/bulloak/tests/scaffold_rust/with_panic.tree +++ /dev/null @@ -1,5 +0,0 @@ -divide -├── When divisor is zero -│ └── It should panic with division by zero. -└── When divisor is non-zero - └── It should return the quotient. diff --git a/crates/bulloak/tests/scaffold_rust/with_panic_test.rs b/crates/bulloak/tests/scaffold_rust/with_panic_test.rs deleted file mode 100644 index 8c9076d..0000000 --- a/crates/bulloak/tests/scaffold_rust/with_panic_test.rs +++ /dev/null @@ -1,28 +0,0 @@ -// Generated by bulloak - -/// Context for test conditions -#[derive(Default)] -struct TestContext {} -/// Helper: When divisor is zero -fn divisor_is_zero(mut ctx: TestContext) -> TestContext { - ctx -} -/// Helper: When divisor is non_zero -fn divisor_is_nonzero(mut ctx: TestContext) -> TestContext { - ctx -} -#[cfg(test)] -mod tests { - use super::*; - #[test] - #[should_panic] - fn test_when_divisor_is_zero() { - let _ctx = divisor_is_zero(TestContext::default()); - // It should panic with division by zero. - } - #[test] - fn test_when_divisor_is_nonzero() { - let _ctx = divisor_is_nonzero(TestContext::default()); - // It should return the quotient. - } -} From c0ffee8475134afd3e8ab4c3ef0730ff5039cee5 Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:02:22 +0100 Subject: [PATCH 13/20] chore: typo --- README.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 91dc461..45c5fa3 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A test generator based on the [Branching Tree Technique](https://twitter.com/PaulRBerg/status/1682346315806539776). **Supported languages:** + - **Solidity (Foundry)**: Generates `.t.sol` files with modifiers for conditions - **Rust**: Generates `_test.rs` files with helper functions for conditions - **Noir**: Generates `_test.nr` files with helper functions for conditions @@ -107,7 +108,7 @@ Say we have a bunch of `.tree` files in the current working directory. If we run the following: ```text -$ bulloak scaffold -w ./**/*.tree +bulloak scaffold -w ./**/*.tree ``` `bulloak` will create a `.t.sol` file per `.tree` file and write the generated @@ -119,7 +120,7 @@ behavior with the `-f` flag. This will force `bulloak` to overwrite the contents of the file. ```text -$ bulloak scaffold -wf ./**/*.tree +bulloak scaffold -wf ./**/*.tree ``` Note all tests are showing as passing when their body is empty. To prevent this, @@ -144,7 +145,7 @@ get consistent sentence casing in the scaffolded test bodies. To generate Rust test files, use `--lang rust`: ```bash -$ bulloak scaffold --lang rust foo.tree +bulloak scaffold --lang rust foo.tree ``` This will generate a `foo_test.rs` file with helper functions for conditions and `#[test]` functions for actions. The generated file will use `#[should_panic]` for actions containing panic keywords like "panic", "revert", "error", or "fail". @@ -155,12 +156,10 @@ This will generate a `foo_test.rs` file with helper functions for conditions and /// Helper function for condition fn stuff_is_called() { -// TODO: Implement condition } /// Helper function for condition fn a_condition_is_met() { -// TODO: Implement condition } #[test] @@ -177,7 +176,7 @@ fn test_when_a_condition_is_met() { To generate Noir test files, use `--lang noir`: ```bash -$ bulloak scaffold --lang noir foo.tree +bulloak scaffold --lang noir foo.tree ``` This will generate a `foo_test.nr` file with helper functions for conditions and `#[test]` functions for actions. The generated file will use `#[test(should_fail)]` for actions containing panic keywords. @@ -528,6 +527,6 @@ This project has been possible thanks to the support of: This project is licensed under either of: - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or - https://www.apache.org/licenses/LICENSE-2.0). + ). - MIT license ([LICENSE-MIT](LICENSE-MIT) or - https://opensource.org/licenses/MIT). + ). From c0ffeecfef169b1cc574d8c453c241d64ad0f33a Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:08:41 +0100 Subject: [PATCH 14/20] chore: refactor dry --- crates/bulloak/src/check.rs | 90 +++++++++++++++---------------------- 1 file changed, 35 insertions(+), 55 deletions(-) diff --git a/crates/bulloak/src/check.rs b/crates/bulloak/src/check.rs index f34b973..854eecd 100644 --- a/crates/bulloak/src/check.rs +++ b/crates/bulloak/src/check.rs @@ -176,8 +176,8 @@ impl Check { } } - /// Run check for Rust tests. - fn run_rust_check(&self) { + /// Expand glob patterns into file paths. + fn expand_specs(&self) -> Vec { let mut specs = Vec::new(); for pattern in &self.files { match expand_glob(pattern.clone()) { @@ -190,74 +190,50 @@ impl Check { ), } } + specs + } - let rust_cfg = bulloak_rust::Config { + /// Run check for Rust tests. + fn run_rust_check(&self) { + let specs = self.expand_specs(); + let cfg = bulloak_rust::Config { files: self.files.iter().map(|p| p.display().to_string()).collect(), skip_helpers: self.skip_modifiers, format_descriptions: self.format_descriptions, }; - let mut all_violations = Vec::new(); - for tree_path in specs { - match bulloak_rust::check::check(&tree_path, &rust_cfg) { - Ok(violations) => { - for violation in &violations { - eprintln!("{}", violation); - } - all_violations.extend(violations); - } - Err(e) => { - eprintln!( - "{}: Failed to check {}: {}", - "error".red(), - tree_path.display(), - e - ); - } - } - } + let violations = self.collect_violations(&specs, |path| { + bulloak_rust::check::check(path, &cfg) + }); - if all_violations.is_empty() { - println!( - "{}", - "All checks completed successfully! No issues found.".green() - ); - } else { - let check_literal = pluralize(all_violations.len(), "check", "checks"); - eprintln!( - "\n{}: {} {} failed", - "warn".bold().yellow(), - all_violations.len(), - check_literal - ); - std::process::exit(1); - } + self.report_violations(&violations); } /// Run check for Noir tests. fn run_noir_check(&self) { - let mut specs = Vec::new(); - for pattern in &self.files { - match expand_glob(pattern.clone()) { - Ok(iter) => specs.extend(iter), - Err(e) => eprintln!( - "{}: could not expand {}: {}", - "warn".yellow(), - pattern.display(), - e - ), - } - } - - let noir_cfg = bulloak_noir::Config { + let specs = self.expand_specs(); + let cfg = bulloak_noir::Config { files: self.files.iter().map(|p| p.display().to_string()).collect(), skip_helpers: self.skip_modifiers, format_descriptions: self.format_descriptions, }; + let violations = self.collect_violations(&specs, |path| { + bulloak_noir::check::check(path, &cfg) + }); + + self.report_violations(&violations); + } + + /// Collect violations from checking multiple tree files. + fn collect_violations(&self, specs: &[PathBuf], check_fn: F) -> Vec + where + F: Fn(&PathBuf) -> anyhow::Result>, + V: std::fmt::Display, + { let mut all_violations = Vec::new(); for tree_path in specs { - match bulloak_noir::check::check(&tree_path, &noir_cfg) { + match check_fn(tree_path) { Ok(violations) => { for violation in &violations { eprintln!("{}", violation); @@ -274,18 +250,22 @@ impl Check { } } } + all_violations + } - if all_violations.is_empty() { + /// Report violations and exit if necessary. + fn report_violations(&self, violations: &[V]) { + if violations.is_empty() { println!( "{}", "All checks completed successfully! No issues found.".green() ); } else { - let check_literal = pluralize(all_violations.len(), "check", "checks"); + let check_literal = pluralize(violations.len(), "check", "checks"); eprintln!( "\n{}: {} {} failed", "warn".bold().yellow(), - all_violations.len(), + violations.len(), check_literal ); std::process::exit(1); From c0ffeedfb0995bedf471e7832b1a49be5b645ddb Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:12:30 +0100 Subject: [PATCH 15/20] chore: refactor dry bis --- crates/bulloak/src/scaffold.rs | 58 ++++++++++++++++------------------ 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/crates/bulloak/src/scaffold.rs b/crates/bulloak/src/scaffold.rs index 05e701f..7a4de8e 100644 --- a/crates/bulloak/src/scaffold.rs +++ b/crates/bulloak/src/scaffold.rs @@ -109,7 +109,7 @@ impl Scaffold { fn process_file(&self, file: &Path, cfg: &Cli) -> anyhow::Result<()> { let text = fs::read_to_string(file)?; - match self.backend { + let (emitted, output_file) = match self.backend { Backend::Rust => { let ast = bulloak_syntax::parse_one(&text)?; let rust_cfg = bulloak_rust::Config { @@ -118,33 +118,15 @@ impl Scaffold { format_descriptions: self.format_descriptions, }; let emitted = bulloak_rust::scaffold(&ast, &rust_cfg)?; - - if self.write_files { - let file_stem = file - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| anyhow::anyhow!("Invalid file name: {}", file.display()))?; - let output_file = file.with_file_name(format!("{}_test.rs", file_stem)); - self.write_file(&emitted, &output_file); - } else { - println!("{emitted}"); - } + let output_file = Self::build_output_path(file, "_test.rs")?; + (emitted, output_file) } Backend::Noir => { let ast = bulloak_syntax::parse_one(&text)?; let noir_cfg: bulloak_noir::Config = cfg.into(); let emitted = bulloak_noir::scaffold(&ast, &noir_cfg)?; - - if self.write_files { - let file_stem = file - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| anyhow::anyhow!("Invalid file name: {}", file.display()))?; - let output_file = file.with_file_name(format!("{}_test.nr", file_stem)); - self.write_file(&emitted, &output_file); - } else { - println!("{emitted}"); - } + let output_file = Self::build_output_path(file, "_test.nr")?; + (emitted, output_file) } Backend::Solidity => { let emitted = scaffold(&text, &cfg.into())?; @@ -152,19 +134,33 @@ impl Scaffold { eprintln!("{}: {}", "WARN".yellow(), err); emitted }); - - if self.write_files { - let file = file.with_extension("t.sol"); - self.write_file(&formatted, &file); - } else { - println!("{formatted}"); - } + let output_file = file.with_extension("t.sol"); + (formatted, output_file) } - } + }; + self.output(&emitted, &output_file); Ok(()) } + /// Builds the output file path for a given input file. + fn build_output_path(file: &Path, suffix: &str) -> anyhow::Result { + let file_stem = file + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("Invalid file name: {}", file.display()))?; + Ok(file.with_file_name(format!("{file_stem}{suffix}"))) + } + + /// Outputs the scaffolded text either to stdout or to a file. + fn output(&self, text: &str, file: &PathBuf) { + if self.write_files { + self.write_file(text, file); + } else { + println!("{text}"); + } + } + /// Writes the provided `text` to `file`. /// /// If the file doesn't exist it will create it. If it exists, From c0ffee10ce4fb8ecb593ce7cf258c76e9a09bee1 Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:15:18 +0100 Subject: [PATCH 16/20] chore: fmt --- crates/bulloak/src/check.rs | 5 +- crates/bulloak/src/cli.rs | 24 +++- crates/bulloak/src/scaffold.rs | 19 ++- crates/bulloak/tests/check_noir.rs | 13 +- crates/bulloak/tests/check_rust.rs | 7 +- crates/bulloak/tests/scaffold_noir.rs | 17 ++- crates/bulloak/tests/scaffold_rust.rs | 17 ++- crates/noir/src/check/mod.rs | 5 +- .../noir/src/check/rules/structural_match.rs | 84 +++++++----- crates/noir/src/lib.rs | 6 +- crates/noir/src/noir/parser.rs | 47 ++++--- crates/noir/src/scaffold/generator.rs | 68 ++++++---- crates/noir/src/utils.rs | 10 +- crates/rust/src/check/mod.rs | 27 ++-- .../rust/src/check/rules/structural_match.rs | 98 +++++++++----- crates/rust/src/check/violation.rs | 18 +-- crates/rust/src/constants.rs | 9 +- crates/rust/src/rust/parser.rs | 7 +- crates/rust/src/scaffold/comment.rs | 13 +- crates/rust/src/scaffold/generator.rs | 126 +++++++++++++----- crates/rust/src/scaffold/mod.rs | 4 +- crates/rust/src/utils.rs | 5 +- 22 files changed, 406 insertions(+), 223 deletions(-) diff --git a/crates/bulloak/src/check.rs b/crates/bulloak/src/check.rs index 854eecd..ac3caf8 100644 --- a/crates/bulloak/src/check.rs +++ b/crates/bulloak/src/check.rs @@ -18,7 +18,10 @@ use clap::Parser; use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; -use crate::{cli::{Backend, Cli}, glob::expand_glob}; +use crate::{ + cli::{Backend, Cli}, + glob::expand_glob, +}; /// Check that the tests match the spec. #[doc(hidden)] diff --git a/crates/bulloak/src/cli.rs b/crates/bulloak/src/cli.rs index a3aea5d..f649d03 100644 --- a/crates/bulloak/src/cli.rs +++ b/crates/bulloak/src/cli.rs @@ -4,7 +4,17 @@ use figment::{providers::Serialized, Figment}; use serde::{Deserialize, Serialize}; /// The target backend/language for code generation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize, Deserialize)] +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Default, + ValueEnum, + Serialize, + Deserialize, +)] #[serde(rename_all = "lowercase")] pub enum Backend { /// Solidity (Foundry) backend. @@ -67,12 +77,20 @@ impl From<&Cli> for bulloak_noir::Config { fn from(cli: &Cli) -> Self { match &cli.command { Commands::Scaffold(cmd) => Self { - files: cmd.files.iter().map(|p| p.display().to_string()).collect(), + files: cmd + .files + .iter() + .map(|p| p.display().to_string()) + .collect(), skip_helpers: cmd.skip_modifiers, format_descriptions: cmd.format_descriptions, }, Commands::Check(cmd) => Self { - files: cmd.files.iter().map(|p| p.display().to_string()).collect(), + files: cmd + .files + .iter() + .map(|p| p.display().to_string()) + .collect(), skip_helpers: cmd.skip_modifiers, format_descriptions: cmd.format_descriptions, }, diff --git a/crates/bulloak/src/scaffold.rs b/crates/bulloak/src/scaffold.rs index 7a4de8e..525d36b 100644 --- a/crates/bulloak/src/scaffold.rs +++ b/crates/bulloak/src/scaffold.rs @@ -13,7 +13,10 @@ use forge_fmt::fmt; use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; -use crate::{cli::{Backend, Cli}, glob::expand_glob}; +use crate::{ + cli::{Backend, Cli}, + glob::expand_glob, +}; /// Generate test files based on your spec. #[doc(hidden)] @@ -113,7 +116,11 @@ impl Scaffold { Backend::Rust => { let ast = bulloak_syntax::parse_one(&text)?; let rust_cfg = bulloak_rust::Config { - files: self.files.iter().map(|p| p.display().to_string()).collect(), + files: self + .files + .iter() + .map(|p| p.display().to_string()) + .collect(), skip_helpers: self.skip_modifiers, format_descriptions: self.format_descriptions, }; @@ -145,10 +152,10 @@ impl Scaffold { /// Builds the output file path for a given input file. fn build_output_path(file: &Path, suffix: &str) -> anyhow::Result { - let file_stem = file - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| anyhow::anyhow!("Invalid file name: {}", file.display()))?; + let file_stem = + file.file_stem().and_then(|s| s.to_str()).ok_or_else(|| { + anyhow::anyhow!("Invalid file name: {}", file.display()) + })?; Ok(file.with_file_name(format!("{file_stem}{suffix}"))) } diff --git a/crates/bulloak/tests/check_noir.rs b/crates/bulloak/tests/check_noir.rs index de1a357..be3f452 100644 --- a/crates/bulloak/tests/check_noir.rs +++ b/crates/bulloak/tests/check_noir.rs @@ -51,7 +51,10 @@ fn check_noir_fails_when_missing_test_function() { assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("Missing test function") || stderr.contains("is missing")); + assert!( + stderr.contains("Missing test function") + || stderr.contains("is missing") + ); } #[cfg(not(target_os = "windows"))] @@ -66,7 +69,10 @@ fn check_noir_fails_when_missing_helper() { assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("Missing helper function") || stderr.contains("is missing")); + assert!( + stderr.contains("Missing helper function") + || stderr.contains("is missing") + ); } #[cfg(not(target_os = "windows"))] @@ -77,7 +83,8 @@ fn check_noir_passes_with_skip_helpers() { let tests_path = cwd.join("tests").join("check_noir"); let tree_path = tests_path.join("no_helpers.tree"); - let output = cmd(&binary_path, "check", &tree_path, &["--lang", "noir", "-m"]); + let output = + cmd(&binary_path, "check", &tree_path, &["--lang", "noir", "-m"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); diff --git a/crates/bulloak/tests/check_rust.rs b/crates/bulloak/tests/check_rust.rs index bd9806f..f992f86 100644 --- a/crates/bulloak/tests/check_rust.rs +++ b/crates/bulloak/tests/check_rust.rs @@ -66,7 +66,9 @@ fn check_rust_fails_when_missing_helper() { assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("Helper function") && stderr.contains("is missing")); + assert!( + stderr.contains("Helper function") && stderr.contains("is missing") + ); } #[cfg(not(target_os = "windows"))] @@ -77,7 +79,8 @@ fn check_rust_passes_with_skip_helpers() { let tests_path = cwd.join("tests").join("check_rust"); let tree_path = tests_path.join("no_helpers.tree"); - let output = cmd(&binary_path, "check", &tree_path, &["--lang", "rust", "-m"]); + let output = + cmd(&binary_path, "check", &tree_path, &["--lang", "rust", "-m"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); diff --git a/crates/bulloak/tests/scaffold_noir.rs b/crates/bulloak/tests/scaffold_noir.rs index 87f9e7b..0975f61 100644 --- a/crates/bulloak/tests/scaffold_noir.rs +++ b/crates/bulloak/tests/scaffold_noir.rs @@ -30,10 +30,12 @@ fn scaffolds_noir_trees() { for tree_name in trees { let tree_path = trees_path.join(tree_name); - let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir"]); + let output = + cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir"]); let actual = String::from_utf8(output.stdout).unwrap(); - let output_file = outputs_path.join(tree_name.replace(".tree", "_test.nr")); + let output_file = + outputs_path.join(tree_name.replace(".tree", "_test.nr")); let expected = fs::read_to_string(&output_file).unwrap_or_else(|_| { panic!( @@ -60,7 +62,8 @@ fn scaffolds_noir_trees_skip_helpers() { let trees_path = cwd.join("tests").join("scaffold"); let tree_path = trees_path.join("basic.tree"); - let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir", "-m"]); + let output = + cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir", "-m"]); let actual = String::from_utf8(output.stdout).unwrap(); // Should not contain helper functions @@ -89,6 +92,10 @@ fn scaffolds_noir_trees_format_descriptions() { let actual = String::from_utf8(output.stdout).unwrap(); // Comments should be capitalized and have periods - assert!(actual.contains("// It should match the result of `keccak256(abi.encodePacked(a,b))`.")); - assert!(actual.contains("// It should match the result of `keccak256(abi.encodePacked(b,a))`.")); + assert!(actual.contains( + "// It should match the result of `keccak256(abi.encodePacked(a,b))`." + )); + assert!(actual.contains( + "// It should match the result of `keccak256(abi.encodePacked(b,a))`." + )); } diff --git a/crates/bulloak/tests/scaffold_rust.rs b/crates/bulloak/tests/scaffold_rust.rs index ab93392..4b08441 100644 --- a/crates/bulloak/tests/scaffold_rust.rs +++ b/crates/bulloak/tests/scaffold_rust.rs @@ -30,10 +30,12 @@ fn scaffolds_rust_trees() { for tree_name in trees { let tree_path = trees_path.join(tree_name); - let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "rust"]); + let output = + cmd(&binary_path, "scaffold", &tree_path, &["--lang", "rust"]); let actual = String::from_utf8(output.stdout).unwrap(); - let output_file = outputs_path.join(tree_name.replace(".tree", "_test.rs")); + let output_file = + outputs_path.join(tree_name.replace(".tree", "_test.rs")); let expected = fs::read_to_string(&output_file).unwrap_or_else(|_| { panic!( @@ -60,7 +62,8 @@ fn scaffolds_rust_trees_skip_helpers() { let trees_path = cwd.join("tests").join("scaffold"); let tree_path = trees_path.join("basic.tree"); - let output = cmd(&binary_path, "scaffold", &tree_path, &["--lang", "rust", "-m"]); + let output = + cmd(&binary_path, "scaffold", &tree_path, &["--lang", "rust", "-m"]); let actual = String::from_utf8(output.stdout).unwrap(); // Should not contain helper functions @@ -90,6 +93,10 @@ fn scaffolds_rust_trees_format_descriptions() { let actual = String::from_utf8(output.stdout).unwrap(); // Comments should be capitalized and have periods - assert!(actual.contains("// It should match the result of `keccak256(abi.encodePacked(a,b))`.")); - assert!(actual.contains("// It should match the result of `keccak256(abi.encodePacked(b,a))`.")); + assert!(actual.contains( + "// It should match the result of `keccak256(abi.encodePacked(a,b))`." + )); + assert!(actual.contains( + "// It should match the result of `keccak256(abi.encodePacked(b,a))`." + )); } diff --git a/crates/noir/src/check/mod.rs b/crates/noir/src/check/mod.rs index ece25ad..fdfba64 100644 --- a/crates/noir/src/check/mod.rs +++ b/crates/noir/src/check/mod.rs @@ -3,12 +3,13 @@ pub mod rules; pub mod violation; -use anyhow::Result; use std::path::Path; -use crate::Config; +use anyhow::Result; pub use violation::Violation; +use crate::Config; + /// Check that a Noir test file matches its tree specification. /// /// # Errors diff --git a/crates/noir/src/check/rules/structural_match.rs b/crates/noir/src/check/rules/structural_match.rs index 7d07368..04313e9 100644 --- a/crates/noir/src/check/rules/structural_match.rs +++ b/crates/noir/src/check/rules/structural_match.rs @@ -1,10 +1,9 @@ //! Structural matching rule for Noir tests. +use std::{collections::HashSet, fs, path::Path}; + use anyhow::Result; use bulloak_syntax::Ast; -use std::collections::HashSet; -use std::fs; -use std::path::Path; use crate::{ check::violation::{Violation, ViolationKind}, @@ -37,16 +36,19 @@ pub fn check(tree_path: &Path, cfg: &Config) -> Result> { let ast = bulloak_syntax::parse_one(&tree_text)?; // Find corresponding Noir test file - let file_stem = tree_path - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| anyhow::anyhow!("Invalid tree file name: {}", tree_path.display()))?; + let file_stem = + tree_path.file_stem().and_then(|s| s.to_str()).ok_or_else(|| { + anyhow::anyhow!("Invalid tree file name: {}", tree_path.display()) + })?; let test_file = tree_path.with_file_name(format!("{file_stem}_test.nr")); if !test_file.exists() { violations.push(Violation::new( - ViolationKind::NoirFileInvalid(format!("File not found: {}", test_file.display())), + ViolationKind::NoirFileInvalid(format!( + "File not found: {}", + test_file.display() + )), test_file.display().to_string(), )); return Ok(violations); @@ -72,12 +74,15 @@ pub fn check(tree_path: &Path, cfg: &Config) -> Result> { // Check helpers (if not skipped) if !cfg.skip_helpers { let found_helpers = parsed.find_helper_functions(); - let found_helper_set: HashSet = found_helpers.into_iter().collect(); + let found_helper_set: HashSet = + found_helpers.into_iter().collect(); for expected_helper in &expected.helpers { if !found_helper_set.contains(expected_helper) { violations.push(Violation::new( - ViolationKind::HelperFunctionMissing(expected_helper.clone()), + ViolationKind::HelperFunctionMissing( + expected_helper.clone(), + ), test_file.display().to_string(), )); } @@ -92,11 +97,14 @@ pub fn check(tree_path: &Path, cfg: &Config) -> Result> { .collect(); for expected_test in &expected.test_functions { - if let Some(&has_should_fail) = found_test_map.get(&expected_test.name) { + if let Some(&has_should_fail) = found_test_map.get(&expected_test.name) + { // Test exists - check attributes if expected_test.should_fail && !has_should_fail { violations.push(Violation::new( - ViolationKind::ShouldFailMissing(expected_test.name.clone()), + ViolationKind::ShouldFailMissing( + expected_test.name.clone(), + ), test_file.display().to_string(), )); } @@ -113,7 +121,10 @@ pub fn check(tree_path: &Path, cfg: &Config) -> Result> { } /// Extract expected test structure from AST. -fn extract_expected_structure(ast: &Ast, cfg: &Config) -> Result { +fn extract_expected_structure( + ast: &Ast, + cfg: &Config, +) -> Result { let ast_root = match ast { Ast::Root(r) => r, _ => anyhow::bail!("Expected Root node"), @@ -128,10 +139,7 @@ fn extract_expected_structure(ast: &Ast, cfg: &Config) -> Result collect_tests(&ast_root.children, &[], &mut test_functions, cfg); - Ok(ExpectedTests { - helpers, - test_functions, - }) + Ok(ExpectedTests { helpers, test_functions }) } /// Recursively collect helper names from conditions. @@ -172,39 +180,41 @@ fn collect_tests( // One test function for all actions under this condition if !actions.is_empty() { let test_name = if helpers.is_empty() { - // Root level action (shouldn't really happen with a Condition parent, - // but handle it just in case) + // Root level action (shouldn't really happen with a + // Condition parent, but handle + // it just in case) format!("test_{}", to_snake_case(&actions[0].title)) } else { - // Under conditions: use the last helper name, NOT the action name + // Under conditions: use the last helper name, NOT the + // action name format!("test_when_{}", helpers.last().unwrap()) }; - let should_fail = actions - .iter() - .any(|a| has_panic_keyword(&a.title)); + let should_fail = + actions.iter().any(|a| has_panic_keyword(&a.title)); - tests.push(TestInfo { - name: test_name, - should_fail, - }); + tests.push(TestInfo { name: test_name, should_fail }); } - // Recursively process only nested Condition children (not actions!) + // Recursively process only nested Condition children (not + // actions!) for child in &condition.children { if matches!(child, Ast::Condition(_)) { - collect_tests(std::slice::from_ref(child), &helpers, tests, cfg); + collect_tests( + std::slice::from_ref(child), + &helpers, + tests, + cfg, + ); } } } Ast::Action(action) => { // Root-level action - let test_name = format!("test_{}", to_snake_case(&action.title)); + let test_name = + format!("test_{}", to_snake_case(&action.title)); let should_fail = has_panic_keyword(&action.title); - tests.push(TestInfo { - name: test_name, - should_fail, - }); + tests.push(TestInfo { name: test_name, should_fail }); } _ => {} } @@ -221,11 +231,13 @@ fn has_panic_keyword(title: &str) -> bool { #[cfg(test)] mod tests { - use super::*; - use indoc::indoc; use std::io::Write; + + use indoc::indoc; use tempfile::NamedTempFile; + use super::*; + #[test] fn test_check_passes_when_correct() { let tree_content = indoc! {r#" diff --git a/crates/noir/src/lib.rs b/crates/noir/src/lib.rs index c2d3039..2889af4 100644 --- a/crates/noir/src/lib.rs +++ b/crates/noir/src/lib.rs @@ -1,7 +1,8 @@ //! Noir backend for bulloak. //! //! This crate provides Noir test generation and validation for bulloak, -//! converting `.tree` specifications into Noir test files with `#[test]` attributes. +//! converting `.tree` specifications into Noir test files with `#[test]` +//! attributes. #![warn(missing_docs)] #![warn(unreachable_pub)] @@ -14,10 +15,9 @@ pub mod scaffold; mod constants; mod utils; -pub use config::Config; - use anyhow::Result; use bulloak_syntax::Ast; +pub use config::Config; /// Generate Noir test code from an AST. /// diff --git a/crates/noir/src/noir/parser.rs b/crates/noir/src/noir/parser.rs index 3bbc3d8..4887449 100644 --- a/crates/noir/src/noir/parser.rs +++ b/crates/noir/src/noir/parser.rs @@ -32,14 +32,10 @@ impl ParsedNoirFile { .set_language(tree_sitter_noir::language()) .context("Failed to load Noir grammar")?; - let tree = parser - .parse(source, None) - .context("Failed to parse Noir file")?; - - Ok(Self { - source: source.to_string(), - tree, - }) + let tree = + parser.parse(source, None).context("Failed to parse Noir file")?; + + Ok(Self { source: source.to_string(), tree }) } /// Find all test functions in the file. @@ -53,7 +49,11 @@ impl ParsedNoirFile { } /// Recursively find test functions in a node and its children. - fn find_test_functions_recursive<'a>(&self, node: Node<'a>, functions: &mut Vec) { + fn find_test_functions_recursive<'a>( + &self, + node: Node<'a>, + functions: &mut Vec, + ) { // Check if this node is a function with #[test] attribute if node.kind() == "function_definition" { if let Some(test_fn) = self.extract_test_function(node) { @@ -69,7 +69,10 @@ impl ParsedNoirFile { } /// Extract test function information from a function node. - fn extract_test_function<'a>(&self, node: Node<'a>) -> Option { + fn extract_test_function<'a>( + &self, + node: Node<'a>, + ) -> Option { // Look for #[test] attribute let has_test_attr = self.has_test_attribute(node); if !has_test_attr { @@ -82,10 +85,7 @@ impl ParsedNoirFile { // Check for should_fail let has_should_fail = self.has_should_fail_attribute(node); - Some(TestFunction { - name, - has_should_fail, - }) + Some(TestFunction { name, has_should_fail }) } /// Check if a function has #[test] attribute. @@ -104,7 +104,11 @@ impl ParsedNoirFile { } /// Find a macro/attribute node by name (Noir uses "macro" for attributes). - fn find_attribute<'a>(&self, node: Node<'a>, attr_name: &str) -> Option> { + fn find_attribute<'a>( + &self, + node: Node<'a>, + attr_name: &str, + ) -> Option> { // Look for macro nodes before the function let mut sibling = node.prev_sibling(); while let Some(s) = sibling { @@ -123,7 +127,8 @@ impl ParsedNoirFile { // Stop if we hit an identifier that's not a known modifier break; } else if s.kind() != "comment" && s.kind() != "line_comment" { - // Stop if we hit something that's not a macro, comment, or known modifier + // Stop if we hit something that's not a macro, comment, or + // known modifier break; } sibling = s.prev_sibling(); @@ -158,7 +163,11 @@ impl ParsedNoirFile { } /// Recursively find helper functions in a node and its children. - fn find_helper_functions_recursive<'a>(&self, node: Node<'a>, functions: &mut Vec) { + fn find_helper_functions_recursive<'a>( + &self, + node: Node<'a>, + functions: &mut Vec, + ) { if node.kind() == "function_definition" { // Check if it has #[test] attribute if !self.has_test_attribute(node) { @@ -177,9 +186,7 @@ impl ParsedNoirFile { /// Get text content of a node. fn node_text<'a>(&self, node: Node<'a>) -> String { - node.utf8_text(self.source.as_bytes()) - .unwrap_or("") - .to_string() + node.utf8_text(self.source.as_bytes()).unwrap_or("").to_string() } } diff --git a/crates/noir/src/scaffold/generator.rs b/crates/noir/src/scaffold/generator.rs index db17554..3389c82 100644 --- a/crates/noir/src/scaffold/generator.rs +++ b/crates/noir/src/scaffold/generator.rs @@ -1,8 +1,9 @@ //! Noir test code generation. +use std::collections::HashSet; + use anyhow::Result; use bulloak_syntax::{Action, Ast}; -use std::collections::HashSet; use crate::{ config::Config, @@ -70,7 +71,11 @@ fn generate_helper_function(name: &str) -> String { } /// Generate test functions from AST. -fn generate_tests(children: &[Ast], parent_helpers: &[String], cfg: &Config) -> Vec { +fn generate_tests( + children: &[Ast], + parent_helpers: &[String], + cfg: &Config, +) -> Vec { let mut tests = Vec::new(); for child in children { @@ -91,13 +96,15 @@ fn generate_tests(children: &[Ast], parent_helpers: &[String], cfg: &Config) -> }) .collect(); - // Generate ONE test function for all actions under this condition + // Generate ONE test function for all actions under this + // condition if !actions.is_empty() { tests.push(generate_test_function(&actions, &helpers, cfg)); } - // Process only nested Condition children (not actions!) recursively - // We need to collect into a Vec first, then pass a slice + // Process only nested Condition children (not actions!) + // recursively We need to collect into a Vec + // first, then pass a slice let nested_conditions: Vec<_> = condition .children .iter() @@ -105,12 +112,20 @@ fn generate_tests(children: &[Ast], parent_helpers: &[String], cfg: &Config) -> .collect(); for nested_cond in nested_conditions { - tests.extend(generate_tests(std::slice::from_ref(nested_cond), &helpers, cfg)); + tests.extend(generate_tests( + std::slice::from_ref(nested_cond), + &helpers, + cfg, + )); } } Ast::Action(action) => { // Root-level action - tests.push(generate_test_function(&[action], parent_helpers, cfg)); + tests.push(generate_test_function( + &[action], + parent_helpers, + cfg, + )); } _ => {} } @@ -120,7 +135,11 @@ fn generate_tests(children: &[Ast], parent_helpers: &[String], cfg: &Config) -> } /// Generate a single test function for one or more actions. -fn generate_test_function(actions: &[&Action], helpers: &[String], cfg: &Config) -> String { +fn generate_test_function( + actions: &[&Action], + helpers: &[String], + cfg: &Config, +) -> String { // Determine test name let test_name = if helpers.is_empty() { // Root level: test_{action_name} @@ -131,16 +150,11 @@ fn generate_test_function(actions: &[&Action], helpers: &[String], cfg: &Config) }; // Check if any action contains panic keywords - let has_panic = actions - .iter() - .any(|action| has_panic_keyword(&action.title)); + let has_panic = + actions.iter().any(|action| has_panic_keyword(&action.title)); // Generate attribute - let attr = if has_panic { - "#[test(should_fail)]\n" - } else { - "#[test]\n" - }; + let attr = if has_panic { "#[test(should_fail)]\n" } else { "#[test]\n" }; // Generate function body let mut body = String::new(); @@ -155,7 +169,8 @@ fn generate_test_function(actions: &[&Action], helpers: &[String], cfg: &Config) // Add action comments for action in actions { - let comment = format_action_comment(&action.title, cfg.format_descriptions); + let comment = + format_action_comment(&action.title, cfg.format_descriptions); use std::fmt::Write; let _ = writeln!(body, " // {comment}"); } @@ -180,16 +195,15 @@ fn format_action_comment(title: &str, format_descriptions: bool) -> String { /// Check if a title contains panic keywords. fn has_panic_keyword(title: &str) -> bool { let lower = title.to_lowercase(); - PANIC_KEYWORDS - .iter() - .any(|keyword| lower.contains(keyword)) + PANIC_KEYWORDS.iter().any(|keyword| lower.contains(keyword)) } #[cfg(test)] mod tests { - use super::*; use bulloak_syntax::parse_one; + use super::*; + #[test] fn test_generate_basic() { let tree = r" @@ -205,8 +219,11 @@ hash_pair assert!(output.contains("// Generated by bulloak")); assert!(output.contains("fn first_arg_is_smaller()")); - assert!(output.contains("#[test]\nunconstrained fn test_should_always_work()")); - assert!(output.contains("#[test]\nunconstrained fn test_when_first_arg_is_smaller()")); + assert!(output + .contains("#[test]\nunconstrained fn test_should_always_work()")); + assert!(output.contains( + "#[test]\nunconstrained fn test_when_first_arg_is_smaller()" + )); } #[test] @@ -234,10 +251,7 @@ test_root "; let ast = parse_one(tree).unwrap(); - let cfg = Config { - skip_helpers: true, - ..Default::default() - }; + let cfg = Config { skip_helpers: true, ..Default::default() }; let output = generate(&ast, &cfg).unwrap(); assert!(!output.contains("fn condition()")); diff --git a/crates/noir/src/utils.rs b/crates/noir/src/utils.rs index c2b4743..24433d4 100644 --- a/crates/noir/src/utils.rs +++ b/crates/noir/src/utils.rs @@ -44,8 +44,14 @@ mod tests { #[test] fn test_to_snake_case() { - assert_eq!(to_snake_case("When user is logged in"), "user_is_logged_in"); - assert_eq!(to_snake_case("It should return true"), "should_return_true"); + assert_eq!( + to_snake_case("When user is logged in"), + "user_is_logged_in" + ); + assert_eq!( + to_snake_case("It should return true"), + "should_return_true" + ); assert_eq!(to_snake_case("given amount is zero"), "amount_is_zero"); assert_eq!( to_snake_case("When first arg is bigger than second arg"), diff --git a/crates/rust/src/check/mod.rs b/crates/rust/src/check/mod.rs index 6344233..b3284fe 100644 --- a/crates/rust/src/check/mod.rs +++ b/crates/rust/src/check/mod.rs @@ -3,11 +3,12 @@ pub mod rules; pub mod violation; +use std::path::Path; + +use anyhow::{Context, Result}; pub use violation::{Violation, ViolationKind}; use crate::config::Config; -use anyhow::{Context, Result}; -use std::path::Path; /// Check that a Rust test file matches its tree specification. /// @@ -16,14 +17,17 @@ use std::path::Path; /// Returns an error if checking fails. pub fn check(tree_path: &Path, cfg: &Config) -> Result> { // Read tree file - let tree_source = std::fs::read_to_string(tree_path) - .with_context(|| format!("Failed to read tree file: {}", tree_path.display()))?; + let tree_source = + std::fs::read_to_string(tree_path).with_context(|| { + format!("Failed to read tree file: {}", tree_path.display()) + })?; // Parse tree let ast = bulloak_syntax::parse_one(&tree_source)?; // Determine Rust file path (replace .tree with _test.rs) - let file_stem = tree_path.file_stem() + let file_stem = tree_path + .file_stem() .and_then(|s| s.to_str()) .ok_or_else(|| anyhow::anyhow!("Invalid file name"))?; let rust_path = tree_path.with_file_name(format!("{}_test.rs", file_stem)); @@ -37,9 +41,16 @@ pub fn check(tree_path: &Path, cfg: &Config) -> Result> { } // Read Rust file - let rust_source = std::fs::read_to_string(&rust_path) - .with_context(|| format!("Failed to read Rust file: {}", rust_path.display()))?; + let rust_source = + std::fs::read_to_string(&rust_path).with_context(|| { + format!("Failed to read Rust file: {}", rust_path.display()) + })?; // Run structural match rule - rules::check_structural_match(&ast, &rust_source, &rust_path.display().to_string(), cfg) + rules::check_structural_match( + &ast, + &rust_source, + &rust_path.display().to_string(), + cfg, + ) } diff --git a/crates/rust/src/check/rules/structural_match.rs b/crates/rust/src/check/rules/structural_match.rs index aebd59d..465c00f 100644 --- a/crates/rust/src/check/rules/structural_match.rs +++ b/crates/rust/src/check/rules/structural_match.rs @@ -1,14 +1,16 @@ //! Structural matching rule that checks if Rust code matches the spec. +use std::collections::HashSet; + +use anyhow::Result; +use bulloak_syntax::Ast; + use crate::{ check::violation::{Violation, ViolationKind}, config::Config, rust::ParsedRustFile, utils::to_snake_case, }; -use anyhow::Result; -use bulloak_syntax::Ast; -use std::collections::HashSet; /// Expected test structure extracted from AST. struct ExpectedTests { @@ -69,7 +71,9 @@ pub fn check_structural_match( for expected_helper in &expected.helpers { if !found_helpers.contains(expected_helper) { violations.push(Violation::new( - ViolationKind::HelperFunctionMissing(expected_helper.clone()), + ViolationKind::HelperFunctionMissing( + expected_helper.clone(), + ), file_path.to_string(), )); } @@ -113,7 +117,10 @@ pub fn check_structural_match( } /// Extract expected test structure from AST. -fn extract_expected_structure(ast: &Ast, cfg: &Config) -> Result { +fn extract_expected_structure( + ast: &Ast, + cfg: &Config, +) -> Result { let ast_root = match ast { Ast::Root(r) => r, _ => anyhow::bail!("Expected Root node"), @@ -130,17 +137,11 @@ fn extract_expected_structure(ast: &Ast, cfg: &Config) -> Result // Collect test functions collect_tests_recursive(&ast_root.children, &[], &mut test_functions); - Ok(ExpectedTests { - helpers, - test_functions, - }) + Ok(ExpectedTests { helpers, test_functions }) } /// Recursively collect helper function names. -fn collect_helpers_recursive( - children: &[Ast], - helpers: &mut HashSet, -) { +fn collect_helpers_recursive(children: &[Ast], helpers: &mut HashSet) { for child in children { if let Ast::Condition(condition) = child { let name = to_snake_case(&condition.title); @@ -164,12 +165,21 @@ fn collect_tests_recursive( new_helpers.push(helper_name); // Collect all direct action children of this condition - let actions: Vec<&bulloak_syntax::Action> = condition.children.iter() - .filter_map(|c| if let Ast::Action(a) = c { Some(a) } else { None }) + let actions: Vec<&bulloak_syntax::Action> = condition + .children + .iter() + .filter_map(|c| { + if let Ast::Action(a) = c { + Some(a) + } else { + None + } + }) .collect(); if !actions.is_empty() { - // Generate a single test for all actions under this condition + // Generate a single test for all actions under this + // condition let test_name = if new_helpers.is_empty() { let action_part = to_snake_case(&actions[0].title); format!("test_{}", action_part) @@ -180,19 +190,32 @@ fn collect_tests_recursive( // Check if any action should panic let should_panic = actions.iter().any(|action| { - action.title.to_lowercase() - .split_whitespace() - .any(|w| matches!(w, "panic" | "panics" | "revert" | "reverts" | "error" | "errors" | "fail" | "fails")) + action.title.to_lowercase().split_whitespace().any( + |w| { + matches!( + w, + "panic" + | "panics" + | "revert" + | "reverts" + | "error" + | "errors" + | "fail" + | "fails" + ) + }, + ) }); - tests.push(TestInfo { - name: test_name, - should_panic, - }); + tests.push(TestInfo { name: test_name, should_panic }); } // Process nested conditions - collect_tests_recursive(&condition.children, &new_helpers, tests); + collect_tests_recursive( + &condition.children, + &new_helpers, + tests, + ); } Ast::Action(action) => { // Root-level action (no condition) @@ -200,18 +223,27 @@ fn collect_tests_recursive( let action_part = to_snake_case(&action.title); let test_name = format!("test_{}", action_part); - let should_panic = action.title.to_lowercase() - .split_whitespace() - .any(|w| matches!(w, "panic" | "panics" | "revert" | "reverts" | "error" | "errors" | "fail" | "fails")); - - tests.push(TestInfo { - name: test_name, - should_panic, - }); + let should_panic = + action.title.to_lowercase().split_whitespace().any( + |w| { + matches!( + w, + "panic" + | "panics" + | "revert" + | "reverts" + | "error" + | "errors" + | "fail" + | "fails" + ) + }, + ); + + tests.push(TestInfo { name: test_name, should_panic }); } } _ => {} } } } - diff --git a/crates/rust/src/check/violation.rs b/crates/rust/src/check/violation.rs index 0b1c306..bac1e44 100644 --- a/crates/rust/src/check/violation.rs +++ b/crates/rust/src/check/violation.rs @@ -17,21 +17,17 @@ impl Violation { /// Create a new violation. #[must_use] pub fn new(kind: ViolationKind, file_path: String) -> Self { - Self { - kind, - file_path, - line: None, - } + Self { kind, file_path, line: None } } /// Create a new violation with a line number. #[must_use] - pub fn with_line(kind: ViolationKind, file_path: String, line: usize) -> Self { - Self { - kind, - file_path, - line: Some(line), - } + pub fn with_line( + kind: ViolationKind, + file_path: String, + line: usize, + ) -> Self { + Self { kind, file_path, line: Some(line) } } } diff --git a/crates/rust/src/constants.rs b/crates/rust/src/constants.rs index 4f1367d..ff1c8e0 100644 --- a/crates/rust/src/constants.rs +++ b/crates/rust/src/constants.rs @@ -2,14 +2,7 @@ /// Keywords that indicate a test should panic. pub(crate) const PANIC_KEYWORDS: &[&str] = &[ - "panic", - "panics", - "revert", - "reverts", - "error", - "errors", - "fail", - "fails", + "panic", "panics", "revert", "reverts", "error", "errors", "fail", "fails", ]; /// Name of the test context struct. diff --git a/crates/rust/src/rust/parser.rs b/crates/rust/src/rust/parser.rs index 7d12e67..bcbe2fa 100644 --- a/crates/rust/src/rust/parser.rs +++ b/crates/rust/src/rust/parser.rs @@ -16,7 +16,8 @@ impl ParsedRustFile { /// /// Returns an error if parsing fails. pub fn parse(source: &str) -> Result { - let syntax = syn::parse_file(source).context("Failed to parse Rust file")?; + let syntax = + syn::parse_file(source).context("Failed to parse Rust file")?; Ok(Self { syntax }) } @@ -106,9 +107,7 @@ impl ParsedRustFile { /// Check if a function has #[should_panic] attribute. #[must_use] pub fn has_should_panic(func: &ItemFn) -> bool { - func.attrs - .iter() - .any(|attr| attr.path().is_ident("should_panic")) + func.attrs.iter().any(|attr| attr.path().is_ident("should_panic")) } } diff --git a/crates/rust/src/scaffold/comment.rs b/crates/rust/src/scaffold/comment.rs index 9329f70..ffd87a1 100644 --- a/crates/rust/src/scaffold/comment.rs +++ b/crates/rust/src/scaffold/comment.rs @@ -1,6 +1,7 @@ //! Comment formatting utilities. -/// Format a comment by capitalizing the first letter and ensuring it ends with a period. +/// Format a comment by capitalizing the first letter and ensuring it ends with +/// a period. pub(crate) fn format_comment(text: &str) -> String { let trimmed = text.trim(); if trimmed.is_empty() { @@ -13,7 +14,10 @@ pub(crate) fn format_comment(text: &str) -> String { let capitalized = format!("{}{}", first.to_uppercase(), rest); - if capitalized.ends_with('.') || capitalized.ends_with('!') || capitalized.ends_with('?') { + if capitalized.ends_with('.') + || capitalized.ends_with('!') + || capitalized.ends_with('?') + { capitalized } else { format!("{}.", capitalized) @@ -27,10 +31,7 @@ mod tests { #[test] fn test_format_comment() { assert_eq!(format_comment("should return sum"), "Should return sum."); - assert_eq!( - format_comment("Should return sum."), - "Should return sum." - ); + assert_eq!(format_comment("Should return sum."), "Should return sum."); assert_eq!(format_comment("should panic!"), "Should panic!"); assert_eq!(format_comment(""), ""); } diff --git a/crates/rust/src/scaffold/generator.rs b/crates/rust/src/scaffold/generator.rs index f7e84e4..b01c5b1 100644 --- a/crates/rust/src/scaffold/generator.rs +++ b/crates/rust/src/scaffold/generator.rs @@ -1,9 +1,10 @@ //! Direct code generation using quote! macro. +use std::collections::HashSet; + use bulloak_syntax::{Action, Ast}; use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use std::collections::HashSet; use crate::{ config::Config, @@ -72,7 +73,11 @@ impl Generator { } /// Add comments to test function bodies based on action titles. - fn add_test_body_comments(&self, formatted: String, children: &[Ast]) -> String { + fn add_test_body_comments( + &self, + formatted: String, + children: &[Ast], + ) -> String { let mut test_comments = Vec::new(); self.collect_test_comments(children, &[], &mut test_comments); @@ -85,7 +90,12 @@ impl Generator { } /// Insert comments into a specific test function body. - fn insert_comments_for_test(&self, result: &mut String, test_name: &str, comments: &[String]) { + fn insert_comments_for_test( + &self, + result: &mut String, + test_name: &str, + comments: &[String], + ) { let pattern = format!("fn {}() {{", test_name); let Some(pos) = result.find(&pattern) else { return; @@ -109,13 +119,15 @@ impl Generator { let trimmed_body = body.trim_end(); let chars_to_remove = body.len() - trimmed_body.len(); result.replace_range( - closing_brace_pos + next_brace - chars_to_remove..closing_brace_pos + next_brace, - &format!("\n {}\n ", all_comments) + closing_brace_pos + next_brace - chars_to_remove + ..closing_brace_pos + next_brace, + &format!("\n {}\n ", all_comments), ); } } - /// Collect test function names and their comments (grouped by test function). + /// Collect test function names and their comments (grouped by test + /// function). fn collect_test_comments( &self, children: &[Ast], @@ -130,9 +142,19 @@ impl Generator { new_helpers.push(helper_name); // Collect all action comments under this condition - let action_comments: Vec = condition.children.iter() - .filter_map(|c| if let Ast::Action(a) = c { Some(a) } else { None }) - .map(|action| format!("// {}", self.format_comment(&action.title))) + let action_comments: Vec = condition + .children + .iter() + .filter_map(|c| { + if let Ast::Action(a) = c { + Some(a) + } else { + None + } + }) + .map(|action| { + format!("// {}", self.format_comment(&action.title)) + }) .collect(); if !action_comments.is_empty() { @@ -140,21 +162,29 @@ impl Generator { let action_part = to_snake_case(&condition.title); format!("test_{}", action_part) } else { - let last_helper = &new_helpers[new_helpers.len() - 1]; + let last_helper = + &new_helpers[new_helpers.len() - 1]; format!("test_when_{}", last_helper) }; comments.push((test_name, action_comments)); } // Process nested conditions - self.collect_test_comments(&condition.children, &new_helpers, comments); + self.collect_test_comments( + &condition.children, + &new_helpers, + comments, + ); } Ast::Action(action) => { // Root-level action (no condition) if parent_helpers.is_empty() { let action_part = to_snake_case(&action.title); let test_name = format!("test_{}", action_part); - let comment = format!("// {}", self.format_comment(&action.title)); + let comment = format!( + "// {}", + self.format_comment(&action.title) + ); comments.push((test_name, vec![comment])); } } @@ -206,7 +236,11 @@ impl Generator { // insert returns true if the value was newly inserted helpers.push((name, condition.title.clone())); } - self.collect_helpers_recursive(&condition.children, helpers, seen); + self.collect_helpers_recursive( + &condition.children, + helpers, + seen, + ); } } } @@ -227,7 +261,10 @@ impl Generator { } /// Generate the test module. - fn generate_test_module(&self, children: &[Ast]) -> anyhow::Result { + fn generate_test_module( + &self, + children: &[Ast], + ) -> anyhow::Result { let test_fns = self.process_children(children, &[])?; Ok(quote! { @@ -256,32 +293,55 @@ impl Generator { new_helpers.push(helper_name); // Collect all direct action children of this condition - let actions: Vec<&Action> = condition.children.iter() - .filter_map(|c| if let Ast::Action(a) = c { Some(a) } else { None }) + let actions: Vec<&Action> = condition + .children + .iter() + .filter_map(|c| { + if let Ast::Action(a) = c { + Some(a) + } else { + None + } + }) .collect(); if !actions.is_empty() { - // Generate a single test function for all actions under this condition - test_fns.push(self.generate_test_function_for_condition(&actions, &new_helpers)?); + // Generate a single test function for all actions under + // this condition + test_fns.push( + self.generate_test_function_for_condition( + &actions, + &new_helpers, + )?, + ); } - // Process only nested conditions (not actions, as they were already processed above) - let nested_conditions: Vec<&Ast> = condition.children.iter() + // Process only nested conditions (not actions, as they were + // already processed above) + let nested_conditions: Vec<&Ast> = condition + .children + .iter() .filter(|c| !matches!(c, Ast::Action(_))) .collect(); for nested_child in nested_conditions { if let Ast::Condition(nested_cond) = nested_child { - let nested_helper_name = to_snake_case(&nested_cond.title); + let nested_helper_name = + to_snake_case(&nested_cond.title); let mut nested_helpers = new_helpers.clone(); nested_helpers.push(nested_helper_name); - test_fns.extend(self.process_children(&nested_cond.children, &nested_helpers)?); + test_fns.extend(self.process_children( + &nested_cond.children, + &nested_helpers, + )?); } } } Ast::Action(action) => { // Action at root level (no condition) - test_fns.push(self.generate_test_function(&[action], parent_helpers)?); + test_fns.push( + self.generate_test_function(&[action], parent_helpers)?, + ); } _ => {} } @@ -326,10 +386,14 @@ impl Generator { // Collect comments from all actions let mut comment_lines = Vec::new(); for action in actions { - comment_lines.push(format!("// {}", self.format_comment(&action.title))); + comment_lines + .push(format!("// {}", self.format_comment(&action.title))); for desc_ast in &action.children { if let Ast::ActionDescription(desc) = desc_ast { - comment_lines.push(format!("// {}", self.format_comment(&desc.text))); + comment_lines.push(format!( + "// {}", + self.format_comment(&desc.text) + )); } } } @@ -339,7 +403,10 @@ impl Generator { let helper_calls = if helpers.is_empty() { String::new() } else if helpers.len() == 1 { - format!("let _ctx = {}({}::default());", &helpers[0], CONTEXT_STRUCT_NAME) + format!( + "let _ctx = {}({}::default());", + &helpers[0], CONTEXT_STRUCT_NAME + ) } else { // Chain multiple helpers let mut chain = format!("{}::default()", CONTEXT_STRUCT_NAME); @@ -383,14 +450,10 @@ impl Generator { Ok(test_fn) } - - /// Check if action should panic. fn should_panic(&self, title: &str) -> bool { let title_lower = title.to_lowercase(); - PANIC_KEYWORDS - .iter() - .any(|keyword| title_lower.contains(keyword)) + PANIC_KEYWORDS.iter().any(|keyword| title_lower.contains(keyword)) } /// Format a comment string. @@ -407,7 +470,6 @@ impl Generator { mod tests { use super::*; - #[test] fn test_should_panic() { let cfg = Config::default(); diff --git a/crates/rust/src/scaffold/mod.rs b/crates/rust/src/scaffold/mod.rs index 56c3f05..26cf4a0 100644 --- a/crates/rust/src/scaffold/mod.rs +++ b/crates/rust/src/scaffold/mod.rs @@ -3,11 +3,11 @@ pub mod comment; pub mod generator; +use anyhow::Result; +use bulloak_syntax::Ast; pub use generator::Generator; use crate::config::Config; -use anyhow::Result; -use bulloak_syntax::Ast; /// Scaffold Rust test code from an AST. /// diff --git a/crates/rust/src/utils.rs b/crates/rust/src/utils.rs index 44bca21..06abf4e 100644 --- a/crates/rust/src/utils.rs +++ b/crates/rust/src/utils.rs @@ -54,9 +54,6 @@ mod tests { to_snake_case("It should return the sum"), "should_return_the_sum" ); - assert_eq!( - to_snake_case("given a valid input"), - "a_valid_input" - ); + assert_eq!(to_snake_case("given a valid input"), "a_valid_input"); } } From c0ffee6eb93fab2b881c42498c07013cccc3863f Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:28:29 +0100 Subject: [PATCH 17/20] feat: add violation for wrong shouldfail --- crates/bulloak/tests/check_noir.rs | 15 +++++++++++++++ .../check_noir/unexpected_should_fail.tree | 2 ++ .../check_noir/unexpected_should_fail_test.nr | 6 ++++++ crates/bulloak/tests/check_rust.rs | 18 ++++++++++++++++++ .../check_rust/unexpected_should_panic.tree | 2 ++ .../check_rust/unexpected_should_panic_test.rs | 14 ++++++++++++++ .../noir/src/check/rules/structural_match.rs | 7 +++++++ crates/noir/src/check/violation.rs | 9 +++++++++ .../rust/src/check/rules/structural_match.rs | 9 +++++++++ 9 files changed, 82 insertions(+) create mode 100644 crates/bulloak/tests/check_noir/unexpected_should_fail.tree create mode 100644 crates/bulloak/tests/check_noir/unexpected_should_fail_test.nr create mode 100644 crates/bulloak/tests/check_rust/unexpected_should_panic.tree create mode 100644 crates/bulloak/tests/check_rust/unexpected_should_panic_test.rs diff --git a/crates/bulloak/tests/check_noir.rs b/crates/bulloak/tests/check_noir.rs index be3f452..c1d8777 100644 --- a/crates/bulloak/tests/check_noir.rs +++ b/crates/bulloak/tests/check_noir.rs @@ -90,3 +90,18 @@ fn check_noir_passes_with_skip_helpers() { let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("All checks completed successfully")); } + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_noir_fails_when_unexpected_should_fail() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_noir"); + let tree_path = tests_path.join("unexpected_should_fail.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "noir"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("has #[test(should_fail)] but shouldn't")); +} diff --git a/crates/bulloak/tests/check_noir/unexpected_should_fail.tree b/crates/bulloak/tests/check_noir/unexpected_should_fail.tree new file mode 100644 index 0000000..880b5e7 --- /dev/null +++ b/crates/bulloak/tests/check_noir/unexpected_should_fail.tree @@ -0,0 +1,2 @@ +test_func +└── It should work normally. diff --git a/crates/bulloak/tests/check_noir/unexpected_should_fail_test.nr b/crates/bulloak/tests/check_noir/unexpected_should_fail_test.nr new file mode 100644 index 0000000..9d7210b --- /dev/null +++ b/crates/bulloak/tests/check_noir/unexpected_should_fail_test.nr @@ -0,0 +1,6 @@ +// Generated by bulloak + +#[test(should_fail)] +unconstrained fn test_should_work_normally() { + // It should work normally. +} diff --git a/crates/bulloak/tests/check_rust.rs b/crates/bulloak/tests/check_rust.rs index f992f86..b7eeb2e 100644 --- a/crates/bulloak/tests/check_rust.rs +++ b/crates/bulloak/tests/check_rust.rs @@ -86,3 +86,21 @@ fn check_rust_passes_with_skip_helpers() { let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("All checks completed successfully")); } + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_rust_fails_when_unexpected_should_panic() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_rust"); + let tree_path = tests_path.join("unexpected_should_panic.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "rust"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("expected none, found #[should_panic]") + || stderr.contains("has incorrect attributes") + ); +} diff --git a/crates/bulloak/tests/check_rust/unexpected_should_panic.tree b/crates/bulloak/tests/check_rust/unexpected_should_panic.tree new file mode 100644 index 0000000..880b5e7 --- /dev/null +++ b/crates/bulloak/tests/check_rust/unexpected_should_panic.tree @@ -0,0 +1,2 @@ +test_func +└── It should work normally. diff --git a/crates/bulloak/tests/check_rust/unexpected_should_panic_test.rs b/crates/bulloak/tests/check_rust/unexpected_should_panic_test.rs new file mode 100644 index 0000000..a12675b --- /dev/null +++ b/crates/bulloak/tests/check_rust/unexpected_should_panic_test.rs @@ -0,0 +1,14 @@ +// Generated by bulloak + +struct TestContext {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[should_panic] + fn test_should_work_normally() { + // It should work normally. + } +} diff --git a/crates/noir/src/check/rules/structural_match.rs b/crates/noir/src/check/rules/structural_match.rs index 04313e9..376a9be 100644 --- a/crates/noir/src/check/rules/structural_match.rs +++ b/crates/noir/src/check/rules/structural_match.rs @@ -107,6 +107,13 @@ pub fn check(tree_path: &Path, cfg: &Config) -> Result> { ), test_file.display().to_string(), )); + } else if !expected_test.should_fail && has_should_fail { + violations.push(Violation::new( + ViolationKind::ShouldFailUnexpected( + expected_test.name.clone(), + ), + test_file.display().to_string(), + )); } } else { // Test is missing diff --git a/crates/noir/src/check/violation.rs b/crates/noir/src/check/violation.rs index d67054a..acf589d 100644 --- a/crates/noir/src/check/violation.rs +++ b/crates/noir/src/check/violation.rs @@ -22,6 +22,8 @@ pub enum ViolationKind { HelperFunctionMissing(String), /// A test should have `#[test(should_fail)]` but doesn't. ShouldFailMissing(String), + /// A test has `#[test(should_fail)]` but shouldn't. + ShouldFailUnexpected(String), } impl Violation { @@ -51,6 +53,13 @@ impl fmt::Display for Violation { name, self.file ) } + ViolationKind::ShouldFailUnexpected(name) => { + write!( + f, + "Test '{}' has #[test(should_fail)] but shouldn't in {}", + name, self.file + ) + } } } } diff --git a/crates/rust/src/check/rules/structural_match.rs b/crates/rust/src/check/rules/structural_match.rs index 465c00f..2edc558 100644 --- a/crates/rust/src/check/rules/structural_match.rs +++ b/crates/rust/src/check/rules/structural_match.rs @@ -109,6 +109,15 @@ pub fn check_structural_match( }, file_path.to_string(), )); + } else if !expected_test.should_panic && has_should_panic { + violations.push(Violation::new( + ViolationKind::TestAttributeIncorrect { + function: expected_test.name.clone(), + expected: "none".to_string(), + found: "#[should_panic]".to_string(), + }, + file_path.to_string(), + )); } } } From c0ffee10c3420a7834d4dfa4753b8f21bb906f1e Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:20:19 +0100 Subject: [PATCH 18/20] chore: refactor trim --- crates/noir/src/utils.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/noir/src/utils.rs b/crates/noir/src/utils.rs index 24433d4..74c59a7 100644 --- a/crates/noir/src/utils.rs +++ b/crates/noir/src/utils.rs @@ -9,15 +9,16 @@ /// assert_eq!(to_snake_case("It should return true"), "should_return_true"); /// ``` pub(crate) fn to_snake_case(title: &str) -> String { - // Strip BDD prefixes + // Strip BDD prefixes (case-insensitive) + let title = title.trim(); let stripped = title - .trim() - .trim_start_matches("when ") - .trim_start_matches("given ") - .trim_start_matches("it ") - .trim_start_matches("When ") - .trim_start_matches("Given ") - .trim_start_matches("It "); + .strip_prefix("when ") + .or_else(|| title.strip_prefix("When ")) + .or_else(|| title.strip_prefix("given ")) + .or_else(|| title.strip_prefix("Given ")) + .or_else(|| title.strip_prefix("it ")) + .or_else(|| title.strip_prefix("It ")) + .unwrap_or(title); // Convert to snake_case stripped From c0ffee4284b375733f5e11b17125efbcaebf486f Mon Sep 17 00:00:00 2001 From: drgorillamd <83670532+drgorillamd@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:54:39 +0100 Subject: [PATCH 19/20] chore: more refactor --- crates/rust/src/scaffold/generator.rs | 263 ++++++++++++++++++-------- 1 file changed, 182 insertions(+), 81 deletions(-) diff --git a/crates/rust/src/scaffold/generator.rs b/crates/rust/src/scaffold/generator.rs index b01c5b1..bec2ef2 100644 --- a/crates/rust/src/scaffold/generator.rs +++ b/crates/rust/src/scaffold/generator.rs @@ -142,30 +142,17 @@ impl Generator { new_helpers.push(helper_name); // Collect all action comments under this condition - let action_comments: Vec = condition - .children - .iter() - .filter_map(|c| { - if let Ast::Action(a) = c { - Some(a) - } else { - None - } - }) - .map(|action| { - format!("// {}", self.format_comment(&action.title)) - }) - .collect(); + let action_comments: Vec = + Self::collect_actions(&condition.children) + .iter() + .map(|action| { + format!("// {}", self.format_comment(&action.title)) + }) + .collect(); if !action_comments.is_empty() { - let test_name = if new_helpers.is_empty() { - let action_part = to_snake_case(&condition.title); - format!("test_{}", action_part) - } else { - let last_helper = - &new_helpers[new_helpers.len() - 1]; - format!("test_when_{}", last_helper) - }; + let test_name = + Self::generate_test_name(&condition.title, &new_helpers); comments.push((test_name, action_comments)); } @@ -179,8 +166,8 @@ impl Generator { Ast::Action(action) => { // Root-level action (no condition) if parent_helpers.is_empty() { - let action_part = to_snake_case(&action.title); - let test_name = format!("test_{}", action_part); + let test_name = + Self::generate_test_name(&action.title, parent_helpers); let comment = format!( "// {}", self.format_comment(&action.title) @@ -293,26 +280,13 @@ impl Generator { new_helpers.push(helper_name); // Collect all direct action children of this condition - let actions: Vec<&Action> = condition - .children - .iter() - .filter_map(|c| { - if let Ast::Action(a) = c { - Some(a) - } else { - None - } - }) - .collect(); + let actions = Self::collect_actions(&condition.children); if !actions.is_empty() { // Generate a single test function for all actions under // this condition test_fns.push( - self.generate_test_function_for_condition( - &actions, - &new_helpers, - )?, + self.generate_test_function(&actions, &new_helpers)?, ); } @@ -325,16 +299,17 @@ impl Generator { .collect(); for nested_child in nested_conditions { - if let Ast::Condition(nested_cond) = nested_child { - let nested_helper_name = - to_snake_case(&nested_cond.title); - let mut nested_helpers = new_helpers.clone(); - nested_helpers.push(nested_helper_name); - test_fns.extend(self.process_children( - &nested_cond.children, - &nested_helpers, - )?); - } + let Ast::Condition(nested_cond) = nested_child else { + continue; + }; + + let nested_helper_name = to_snake_case(&nested_cond.title); + let mut nested_helpers = new_helpers.clone(); + nested_helpers.push(nested_helper_name); + test_fns.extend(self.process_children( + &nested_cond.children, + &nested_helpers, + )?); } } Ast::Action(action) => { @@ -350,15 +325,6 @@ impl Generator { Ok(test_fns) } - /// Generate a test function for a condition with multiple actions. - fn generate_test_function_for_condition( - &self, - actions: &[&Action], - helpers: &[String], - ) -> anyhow::Result { - self.generate_test_function(actions, helpers) - } - /// Generate a test function from one or more actions. fn generate_test_function( &self, @@ -370,14 +336,7 @@ impl Generator { } // Use the last helper (condition) for the test name if helpers exist - let test_name = if helpers.is_empty() { - let action_part = to_snake_case(&actions[0].title); - format!("test_{}", action_part) - } else { - let last_helper = &helpers[helpers.len() - 1]; - format!("test_when_{}", last_helper) - }; - + let test_name = Self::generate_test_name(&actions[0].title, helpers); let test_fn_name = format_ident!("{}", test_name); // Check if any action should panic @@ -400,21 +359,7 @@ impl Generator { let body_comments = comment_lines.join("\n "); // Generate helper calls - let helper_calls = if helpers.is_empty() { - String::new() - } else if helpers.len() == 1 { - format!( - "let _ctx = {}({}::default());", - &helpers[0], CONTEXT_STRUCT_NAME - ) - } else { - // Chain multiple helpers - let mut chain = format!("{}::default()", CONTEXT_STRUCT_NAME); - for helper in helpers { - chain = format!("{}({})", helper, chain); - } - format!("let _ctx = {};", chain) - }; + let helper_calls = Self::build_helper_chain(helpers); // Build complete function body as a string let body_str = if helper_calls.is_empty() { @@ -464,11 +409,55 @@ impl Generator { text.to_string() } } + + /// Generate test function name from action title and helpers. + fn generate_test_name(action_title: &str, helpers: &[String]) -> String { + if helpers.is_empty() { + format!("test_{}", to_snake_case(action_title)) + } else { + format!("test_when_{}", helpers.last().unwrap()) + } + } + + /// Collect all direct action children from AST nodes. + fn collect_actions(children: &[Ast]) -> Vec<&Action> { + children + .iter() + .filter_map(|c| { + if let Ast::Action(a) = c { + Some(a) + } else { + None + } + }) + .collect() + } + + /// Build helper function call chain. + fn build_helper_chain(helpers: &[String]) -> String { + if helpers.is_empty() { + return String::new(); + } + + if helpers.len() == 1 { + format!( + "let _ctx = {}({}::default());", + &helpers[0], CONTEXT_STRUCT_NAME + ) + } else { + let mut chain = format!("{}::default()", CONTEXT_STRUCT_NAME); + for helper in helpers { + chain = format!("{}({})", helper, chain); + } + format!("let _ctx = {};", chain) + } + } } #[cfg(test)] mod tests { use super::*; + use bulloak_syntax::{Action, Condition}; #[test] fn test_should_panic() { @@ -479,4 +468,116 @@ mod tests { assert!(gen.should_panic("It should revert")); assert!(!gen.should_panic("It should return a value")); } + + #[test] + fn test_generate_test_name_without_helpers() { + let result = Generator::generate_test_name("It should work", &[]); + assert_eq!(result, "test_should_work"); + } + + #[test] + fn test_generate_test_name_with_one_helper() { + let helpers = vec!["user_is_logged_in".to_string()]; + let result = Generator::generate_test_name("It should succeed", &helpers); + assert_eq!(result, "test_when_user_is_logged_in"); + } + + #[test] + fn test_generate_test_name_with_multiple_helpers() { + let helpers = vec![ + "user_is_logged_in".to_string(), + "balance_is_zero".to_string(), + ]; + let result = Generator::generate_test_name("It should fail", &helpers); + assert_eq!(result, "test_when_balance_is_zero"); + } + + #[test] + fn test_collect_actions_empty() { + let children: Vec = vec![]; + let actions = Generator::collect_actions(&children); + assert!(actions.is_empty()); + } + + #[test] + fn test_collect_actions_mixed() { + let children = vec![ + Ast::Action(Action { + title: "action1".to_string(), + children: vec![], + span: Default::default(), + }), + Ast::Condition(Condition { + title: "condition1".to_string(), + children: vec![], + span: Default::default(), + }), + Ast::Action(Action { + title: "action2".to_string(), + children: vec![], + span: Default::default(), + }), + ]; + + let actions = Generator::collect_actions(&children); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].title, "action1"); + assert_eq!(actions[1].title, "action2"); + } + + #[test] + fn test_collect_actions_only_conditions() { + let children = vec![ + Ast::Condition(Condition { + title: "condition1".to_string(), + children: vec![], + span: Default::default(), + }), + Ast::Condition(Condition { + title: "condition2".to_string(), + children: vec![], + span: Default::default(), + }), + ]; + + let actions = Generator::collect_actions(&children); + assert!(actions.is_empty()); + } + + #[test] + fn test_build_helper_chain_empty() { + let result = Generator::build_helper_chain(&[]); + assert_eq!(result, ""); + } + + #[test] + fn test_build_helper_chain_single() { + let helpers = vec!["helper1".to_string()]; + let result = Generator::build_helper_chain(&helpers); + assert_eq!(result, "let _ctx = helper1(TestContext::default());"); + } + + #[test] + fn test_build_helper_chain_multiple() { + let helpers = vec!["helper1".to_string(), "helper2".to_string()]; + let result = Generator::build_helper_chain(&helpers); + assert_eq!( + result, + "let _ctx = helper2(helper1(TestContext::default()));" + ); + } + + #[test] + fn test_build_helper_chain_three() { + let helpers = vec![ + "helper1".to_string(), + "helper2".to_string(), + "helper3".to_string(), + ]; + let result = Generator::build_helper_chain(&helpers); + assert_eq!( + result, + "let _ctx = helper3(helper2(helper1(TestContext::default())));" + ); + } } From d4373199bed5c57fcfcac4594268a30193685055 Mon Sep 17 00:00:00 2001 From: teddy Date: Thu, 6 Nov 2025 12:03:45 -0300 Subject: [PATCH 20/20] poc: parse treefiles with multiple files for bulloak --- crates/bulloak/src/scaffold.rs | 4 +- .../tests/scaffold_noir/hash_pair_test.nr | 60 +++++++++++++++++++ crates/noir/src/lib.rs | 4 +- crates/noir/src/scaffold/generator.rs | 45 +++++++------- crates/noir/src/scaffold/mod.rs | 4 +- 5 files changed, 89 insertions(+), 28 deletions(-) diff --git a/crates/bulloak/src/scaffold.rs b/crates/bulloak/src/scaffold.rs index 525d36b..ece981f 100644 --- a/crates/bulloak/src/scaffold.rs +++ b/crates/bulloak/src/scaffold.rs @@ -129,9 +129,9 @@ impl Scaffold { (emitted, output_file) } Backend::Noir => { - let ast = bulloak_syntax::parse_one(&text)?; + let forest = bulloak_syntax::parse(&text)?; let noir_cfg: bulloak_noir::Config = cfg.into(); - let emitted = bulloak_noir::scaffold(&ast, &noir_cfg)?; + let emitted = bulloak_noir::scaffold(&forest, &noir_cfg)?; let output_file = Self::build_output_path(file, "_test.nr")?; (emitted, output_file) } diff --git a/crates/bulloak/tests/scaffold_noir/hash_pair_test.nr b/crates/bulloak/tests/scaffold_noir/hash_pair_test.nr index e69de29..fc7e0c6 100644 --- a/crates/bulloak/tests/scaffold_noir/hash_pair_test.nr +++ b/crates/bulloak/tests/scaffold_noir/hash_pair_test.nr @@ -0,0 +1,60 @@ +// Generated by bulloak + +/// Helper function for condition +fn first_arg_is_bigger_than_second_arg() { +} + +/// Helper function for condition +fn first_arg_is_smaller_than_second_arg() { +} + +#[test(should_fail)] +unconstrained fn test_should_never_revert() { + // It should never revert. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. +} + +#[test] +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. +} + +#[test(should_fail)] +unconstrained fn test_should_never_revert() { + // It should never revert. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the value of `a`. +} + +#[test] +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the value of `b`. +} + +#[test(should_fail)] +unconstrained fn test_should_never_revert() { + // It should never revert. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the value of `b`. +} + +#[test] +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the value of `a`. +} diff --git a/crates/noir/src/lib.rs b/crates/noir/src/lib.rs index 2889af4..8fed8b6 100644 --- a/crates/noir/src/lib.rs +++ b/crates/noir/src/lib.rs @@ -24,6 +24,6 @@ pub use config::Config; /// # Errors /// /// Returns an error if code generation fails. -pub fn scaffold(ast: &Ast, cfg: &Config) -> Result { - scaffold::generate(ast, cfg) +pub fn scaffold(forest: &Vec, cfg: &Config) -> Result { + scaffold::generate(forest, cfg) } diff --git a/crates/noir/src/scaffold/generator.rs b/crates/noir/src/scaffold/generator.rs index 3389c82..0a6173c 100644 --- a/crates/noir/src/scaffold/generator.rs +++ b/crates/noir/src/scaffold/generator.rs @@ -16,27 +16,28 @@ use crate::{ /// # Errors /// /// Returns an error if code generation fails. -pub(super) fn generate(ast: &Ast, cfg: &Config) -> Result { - let ast_root = match ast { - Ast::Root(r) => r, - _ => anyhow::bail!("Expected Root node"), - }; - +pub(super) fn generate(forest: &Vec, cfg: &Config) -> Result { let mut output = String::from("// Generated by bulloak\n\n"); - - // Generate helper functions (if not skipped) - if !cfg.skip_helpers { - let helpers = collect_helpers(&ast_root.children); - for helper in helpers { - output.push_str(&generate_helper_function(&helper)); - output.push('\n'); + for ast in forest { + let ast_root = match ast { + Ast::Root(r) => r, + _ => anyhow::bail!("Expected Root node"), + }; + + // Generate helper functions (if not skipped) + if !cfg.skip_helpers { + let helpers = collect_helpers(&ast_root.children); + for helper in helpers { + output.push_str(&generate_helper_function(&helper)); + output.push('\n'); + } } - } - // Generate test functions - let tests = generate_tests(&ast_root.children, &[], cfg); - for test in tests { - output.push_str(&test); + // Generate test functions + let tests = generate_tests(&ast_root.children, &[], cfg); + for test in tests { + output.push_str(&test); + } } Ok(output) @@ -200,7 +201,7 @@ fn has_panic_keyword(title: &str) -> bool { #[cfg(test)] mod tests { - use bulloak_syntax::parse_one; + use bulloak_syntax::parse; use super::*; @@ -213,7 +214,7 @@ hash_pair └── It should match result. "; - let ast = parse_one(tree).unwrap(); + let ast = parse(tree).unwrap(); let cfg = Config::default(); let output = generate(&ast, &cfg).unwrap(); @@ -234,7 +235,7 @@ divide └── It should panic with division by zero. "; - let ast = parse_one(tree).unwrap(); + let ast = parse(tree).unwrap(); let cfg = Config::default(); let output = generate(&ast, &cfg).unwrap(); @@ -250,7 +251,7 @@ test_root └── It should work. "; - let ast = parse_one(tree).unwrap(); + let ast = parse(tree).unwrap(); let cfg = Config { skip_helpers: true, ..Default::default() }; let output = generate(&ast, &cfg).unwrap(); diff --git a/crates/noir/src/scaffold/mod.rs b/crates/noir/src/scaffold/mod.rs index 616db77..2d8b6e9 100644 --- a/crates/noir/src/scaffold/mod.rs +++ b/crates/noir/src/scaffold/mod.rs @@ -12,6 +12,6 @@ use crate::Config; /// # Errors /// /// Returns an error if code generation fails. -pub fn generate(ast: &Ast, cfg: &Config) -> Result { - generator::generate(ast, cfg) +pub fn generate(forest: &Vec, cfg: &Config) -> Result { + generator::generate(forest, cfg) }