├── .husky └── pre-commit ├── crates ├── bulloak │ ├── tests │ │ ├── scaffold │ │ │ ├── empty.tree │ │ │ ├── duplicated_top_action.tree │ │ │ ├── removes_invalid_title_chars.tree │ │ │ ├── contract_name_missing_multiple_roots.tree │ │ │ ├── contract_names_mismatch_multiple_roots.tree │ │ │ ├── revert_when.tree │ │ │ ├── format_descriptions.tree │ │ │ ├── removes_invalid_title_chars.t.sol │ │ │ ├── removes_invalid_title_chars_vm_skip.t.sol │ │ │ ├── disambiguation.tree │ │ │ ├── multiple_roots.tree │ │ │ ├── format_descriptions.t.sol │ │ │ ├── format_descriptions_formatted.t.sol │ │ │ ├── revert_when.t.sol │ │ │ ├── basic.tree │ │ │ ├── skip_modifiers.tree │ │ │ ├── duplicated_condition.tree │ │ │ ├── spurious_comments.tree │ │ │ ├── spurious_comments.t.sol │ │ │ ├── multiple_roots.t.sol │ │ │ ├── disambiguation.t.sol │ │ │ ├── skip_modifiers.t.sol │ │ │ ├── multiple_roots_vm_skip.t.sol │ │ │ ├── hash_pair.tree │ │ │ ├── basic.t.sol │ │ │ ├── basic_vm_skip.t.sol │ │ │ ├── hash_pair.t.sol │ │ │ ├── complex.tree │ │ │ └── complex.t.sol │ │ ├── check │ │ │ ├── missing_contract.t.sol │ │ │ ├── no_matching_sol.tree │ │ │ ├── empty_contract.t.sol │ │ │ ├── missing_contract.tree │ │ │ ├── contract_names_mismatch.tree │ │ │ ├── empty_contract.tree │ │ │ ├── fix_extra_fn_plus_order.tree │ │ │ ├── missing_contract_identifier.tree │ │ │ ├── contract_names_mismatch_multiple_roots.tree │ │ │ ├── contract_names_mismatch.t.sol │ │ │ ├── invalid.tree │ │ │ ├── extra_codegen_sol.tree │ │ │ ├── fix_extra_fn_plus_order.t.sol │ │ │ ├── unsorted.tree │ │ │ ├── missing_contract_identifier.t.sol │ │ │ ├── skip_modifiers.tree │ │ │ ├── issue_81.tree │ │ │ ├── extra_codegen_tree.tree │ │ │ ├── contract_names_mismatch_multiple_roots.t.sol │ │ │ ├── skip_modifiers.t.sol │ │ │ ├── extra_codegen_tree.t.sol │ │ │ ├── unsorted.t.sol │ │ │ ├── extra_codegen_sol.t.sol │ │ │ ├── issue_81.t.sol │ │ │ ├── invalid_sol_structure.tree │ │ │ └── invalid_sol_structure.t.sol │ │ ├── common │ │ │ └── mod.rs │ │ ├── glob_cmd.rs │ │ └── scaffold.rs │ ├── src │ │ ├── main.rs │ │ ├── cli.rs │ │ ├── glob.rs │ │ ├── scaffold.rs │ │ └── check.rs │ ├── benches │ │ ├── emit.rs │ │ └── bench_data │ │ │ └── cancel.tree │ └── Cargo.toml ├── foundry │ ├── src │ │ ├── check │ │ │ ├── mod.rs │ │ │ ├── rules │ │ │ │ └── mod.rs │ │ │ ├── location.rs │ │ │ ├── utils.rs │ │ │ ├── pretty.rs │ │ │ └── violation.rs │ │ ├── constants.rs │ │ ├── lib.rs │ │ ├── scaffold │ │ │ ├── mod.rs │ │ │ ├── comment.rs │ │ │ └── modifiers.rs │ │ ├── config.rs │ │ ├── hir │ │ │ ├── mod.rs │ │ │ ├── visitor.rs │ │ │ └── hir.rs │ │ └── sol │ │ │ ├── visitor.rs │ │ │ └── mod.rs │ ├── Cargo.toml │ └── README.md └── syntax │ ├── benches │ ├── bench_data │ │ ├── small.tree │ │ ├── medium.tree │ │ └── large.tree │ └── syntax.rs │ ├── src │ ├── test_utils.rs │ ├── char.rs │ ├── lib.rs │ ├── visitor.rs │ ├── ast.rs │ ├── splitter.rs │ ├── span.rs │ ├── utils.rs │ └── error.rs │ ├── Cargo.toml │ └── README.md ├── docs ├── .eslintrc.json ├── src │ ├── app │ │ ├── favicon.ico │ │ ├── opengraph-image.png │ │ ├── fonts │ │ │ ├── Satoshi-Variable.woff2 │ │ │ ├── CommitMono-VariableFont.woff2 │ │ │ └── index.ts │ │ ├── sitemap.ts │ │ ├── robots.ts │ │ ├── globals.css │ │ └── layout.tsx │ └── components │ │ ├── icons │ │ └── index.tsx │ │ └── TreesAnimation.tsx ├── next.config.mjs ├── postcss.config.mjs ├── tailwind.config.ts ├── tsconfig.json ├── package.json ├── public │ └── logo.svg └── README.md ├── .prettierrc ├── tea.yaml ├── .lintstagedrc.yml ├── .prettierignore ├── .rustfmt.toml ├── funding.json ├── .editorconfig ├── .github ├── codecov.yml └── workflows │ ├── release.yml │ ├── test.yml │ └── ci.yml ├── package.json ├── .gitignore ├── LICENSE-MIT ├── CONTRIBUTING.md ├── Cargo.toml └── cliff.toml /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged 2 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/empty.tree: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/missing_contract.t.sol: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /docs/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexfertel/bulloak/HEAD/docs/src/app/favicon.ico -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "trailingComma": "all", 4 | "proseWrap": "always" 5 | } 6 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/no_matching_sol.tree: -------------------------------------------------------------------------------- 1 | NoMatchingSol 2 | └── It should not find the solidity file. 3 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/empty_contract.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.0; 2 | 3 | contract EmptyContract { 4 | } 5 | -------------------------------------------------------------------------------- /docs/src/app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexfertel/bulloak/HEAD/docs/src/app/opengraph-image.png -------------------------------------------------------------------------------- /tea.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1.0.0 3 | codeOwners: 4 | - "0x0654DcE3B33Fd3b611Cd8f514253AB168FF27ee4" 5 | quorum: 1 6 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/missing_contract.tree: -------------------------------------------------------------------------------- 1 | MissingContract 2 | └── It should not find a contract in the Solidity file. 3 | -------------------------------------------------------------------------------- /docs/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/contract_names_mismatch.tree: -------------------------------------------------------------------------------- 1 | ContractName 2 | └── It should have a name mismatch in the contracts. 3 | -------------------------------------------------------------------------------- /.lintstagedrc.yml: -------------------------------------------------------------------------------- 1 | "*.{json,md,tsx,yml}": "prettier --write --ignore-path .prettierignore" 2 | "*.rs": "sh -c 'cargo +nightly fmt'" 3 | -------------------------------------------------------------------------------- /docs/src/app/fonts/Satoshi-Variable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexfertel/bulloak/HEAD/docs/src/app/fonts/Satoshi-Variable.woff2 -------------------------------------------------------------------------------- /crates/bulloak/tests/check/empty_contract.tree: -------------------------------------------------------------------------------- 1 | EmptyContract 2 | ├── It should never revert. 3 | └── It should not find the solidity file. 4 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/duplicated_top_action.tree: -------------------------------------------------------------------------------- 1 | HashPairTest 2 | ├── It should, match the result. 3 | └── It should' match the result. 4 | -------------------------------------------------------------------------------- /docs/src/app/fonts/CommitMono-VariableFont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexfertel/bulloak/HEAD/docs/src/app/fonts/CommitMono-VariableFont.woff2 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # directories 2 | .vscode 3 | node_modules 4 | target 5 | 6 | # files 7 | bun.lock 8 | LICENSE-* 9 | Cargo.lock 10 | pnpm-lock.yaml 11 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/fix_extra_fn_plus_order.tree: -------------------------------------------------------------------------------- 1 | // https://github.com/alexfertel/bulloak/issues/56 2 | Foo 3 | ├── when b 4 | │ └── it Y 5 | └── when a 6 | └── it X 7 | -------------------------------------------------------------------------------- /docs/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/missing_contract_identifier.tree: -------------------------------------------------------------------------------- 1 | ContractName::func1 2 | └── It should find a contract identifier. 3 | 4 | ::orphanedFunction 5 | └── It should not find a contract identifier. 6 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/removes_invalid_title_chars.tree: -------------------------------------------------------------------------------- 1 | // https://github.com/alexfertel/bulloak/issues/52 2 | // https://github.com/alexfertel/bulloak/issues/58 3 | Foo 4 | └── It can’t do, X. 5 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/contract_name_missing_multiple_roots.tree: -------------------------------------------------------------------------------- 1 | ::function1 2 | └── It should have a name mismatch in the contracts. 3 | 4 | ContractName::function2 5 | └── It should have a name mismatch in the contracts. -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/contract_names_mismatch_multiple_roots.tree: -------------------------------------------------------------------------------- 1 | ContractName::function1 2 | └── It should have a name mismatch in the contracts. 3 | 4 | ::function2 5 | └── It should have a name mismatch in the contracts. -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/revert_when.tree: -------------------------------------------------------------------------------- 1 | FooTest 2 | └── When stuff is called // Comments are supported. 3 | └── When a condition is met 4 | └── It should revert. 5 | └── Because we shouldn't allow it. 6 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/format_descriptions.tree: -------------------------------------------------------------------------------- 1 | FormatDescriptions 2 | └── when formatting toggled 3 | ├── it should reformat comment 4 | │ └── ensures trailing punctuation is added 5 | └── it should handle question? 6 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/contract_names_mismatch_multiple_roots.tree: -------------------------------------------------------------------------------- 1 | ContractName::func1 2 | └── It should have a name mismatch in the contracts. 3 | 4 | MismatchedContractName::func2 5 | └── It should have a name mismatch in the contracts. 6 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/removes_invalid_title_chars.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract Foo { 5 | function test_CantDoX() external { 6 | // It can’t do, X. 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 80 2 | format_macro_matchers = true 3 | group_imports = "StdExternalCrate" 4 | imports_granularity = "Crate" 5 | reorder_impl_items = true 6 | use_field_init_shorthand = true 7 | use_small_heuristics = "Max" 8 | wrap_comments = true 9 | -------------------------------------------------------------------------------- /funding.json: -------------------------------------------------------------------------------- 1 | { 2 | "opRetro": { 3 | "projectId": "0x740bebbc731ec56dcc17c7d429656220a73960022c3809edf324bd8120285722" 4 | }, 5 | "drips": { 6 | "ethereum": { 7 | "ownedBy": "0x05A13d11D3f1dC99f6819Ceec3Ec4cf611fB7199" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/src/app/fonts/index.ts: -------------------------------------------------------------------------------- 1 | import localFont from "next/font/local"; 2 | 3 | export const body = localFont({ 4 | src: "./Satoshi-Variable.woff2", 5 | }); 6 | 7 | export const mono = localFont({ 8 | variable: "--font-mono", 9 | src: "./CommitMono-VariableFont.woff2", 10 | }); 11 | -------------------------------------------------------------------------------- /crates/bulloak/src/main.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | use std::process; 3 | 4 | mod check; 5 | mod cli; 6 | mod glob; 7 | mod scaffold; 8 | 9 | fn main() { 10 | if let Err(e) = crate::cli::run() { 11 | eprintln!("Error: {e:?}"); 12 | process::exit(1); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/contract_names_mismatch.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract ADifferentName { 5 | function test_ShouldHaveANameMismatchInTheContracts() external { 6 | // It should match the result of `keccak256(abi.encodePacked(a,b))`. 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/src/app/sitemap.ts: -------------------------------------------------------------------------------- 1 | export default async function sitemap() { 2 | const url = process.env.NEXT_PUBLIC_SITE_URL; 3 | 4 | let routes = [""].map((route) => ({ 5 | url: `${url}${route}`, 6 | lastModified: new Date().toISOString().split("T")[0], 7 | })); 8 | 9 | return [...routes]; 10 | } 11 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/invalid.tree: -------------------------------------------------------------------------------- 1 | HashPairTest 2 | ├── 3 | ├── When first arg is smaller than second arg 4 | │ └── It should match the result of `keccak256(abi.encodePacked(a,b))`. 5 | └── When first arg is bigger than second arg 6 | └── It should match the result of `keccak256(abi.encodePacked(b,a))`. 7 | -------------------------------------------------------------------------------- /crates/foundry/src/check/mod.rs: -------------------------------------------------------------------------------- 1 | //! Defines the `bulloak check` command. 2 | //! 3 | //! This command performs checks on the relationship between a bulloak tree and 4 | //! a Solidity file. 5 | 6 | pub mod context; 7 | pub mod location; 8 | pub mod pretty; 9 | pub mod rules; 10 | pub mod utils; 11 | pub mod violation; 12 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/removes_invalid_title_chars_vm_skip.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | 6 | contract Foo is Test { 7 | function test_CantDoX() external { 8 | // It can’t do, X. 9 | vm.skip(true); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/disambiguation.tree: -------------------------------------------------------------------------------- 1 | Disambiguation 2 | ├── when a is even 3 | │ ├── given zero 4 | │ │ └── it should revert 5 | │ └── given not zero 6 | │ └── it should work 7 | └── when b is even 8 | ├── given zero 9 | │ └── it should revert 10 | └── given not zero 11 | └── it should work 12 | 13 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/extra_codegen_sol.tree: -------------------------------------------------------------------------------- 1 | HashPairTest 2 | ├── It should never revert. 3 | ├── When first arg is smaller than second arg 4 | │ └── It should match the result of `keccak256(abi.encodePacked(a,b))`. 5 | └── When first arg is bigger than second arg 6 | └── It should match the result of `keccak256(abi.encodePacked(b,a))`. 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # All files 7 | [*.{json,md,tsx,yml}] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_size = 2 11 | indent_style = space 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | [*.sol] 16 | indent_size = 4 17 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/multiple_roots.tree: -------------------------------------------------------------------------------- 1 | MultipleRootsTreeTest::function1 2 | ├── It should never revert. 3 | └── When first arg is bigger than second arg 4 | └── It is all good 5 | 6 | MultipleRootsTreeTest::function2 7 | ├── when stuff does not happen 8 | │ └── it should revert 9 | └── when stuff happens 10 | └── it should do something simple -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/format_descriptions.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract FormatDescriptions { 5 | function test_WhenFormattingToggled() external { 6 | // it should reformat comment 7 | // ensures trailing punctuation is added 8 | // it should handle question? 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/src/app/robots.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from "next"; 2 | 3 | export default function robots(): MetadataRoute.Robots { 4 | const url = process.env.NEXT_PUBLIC_SITE_URL; 5 | return { 6 | rules: [ 7 | { 8 | userAgent: "*", 9 | allow: "/", 10 | }, 11 | ], 12 | sitemap: `${url}/sitemap.xml`, 13 | host: url, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/format_descriptions_formatted.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract FormatDescriptions { 5 | function test_WhenFormattingToggled() external { 6 | // It should reformat comment. 7 | // Ensures trailing punctuation is added. 8 | // It should handle question. 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/revert_when.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract FooTest { 5 | modifier whenStuffIsCalled() { 6 | _; 7 | } 8 | 9 | function test_RevertWhen_AConditionIsMet() external whenStuffIsCalled { 10 | // It should revert. 11 | // Because we shouldn't allow it. 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/fix_extra_fn_plus_order.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract Foo { 5 | function test_WhenA() external { 6 | // it X 7 | } 8 | 9 | function test_WhenB() external { 10 | // it Y 11 | } 12 | 13 | function test_WhenTheMethodIsCalledASecondTime() external { 14 | // it Z 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/unsorted.tree: -------------------------------------------------------------------------------- 1 | HashPairTest 2 | ├── It should never revert. 3 | ├── When first arg is smaller than second arg 4 | │ ├── When first arg is zero 5 | │ │ └── It should do something. 6 | │ └── It should match the result of `keccak256(abi.encodePacked(a,b))`. 7 | └── When first arg is bigger than second arg 8 | └── It should match the result of `keccak256(abi.encodePacked(b,a))`. 9 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/basic.tree: -------------------------------------------------------------------------------- 1 | HashPairTest.Sanitize 2 | ├── It should never revert. 3 | ├── When first arg is smaller than second arg 4 | │ ├── When first arg is zero 5 | │ │ └── It should do something. 6 | │ └── It should match the result of `keccak256(abi.encodePacked(a,b))`. 7 | └── When first arg is bigger than second arg 8 | └── It should match the result of `keccak256(abi.encodePacked(b,a))`. 9 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | # ref: https://docs.codecov.com/docs/codecovyml-reference 2 | coverage: 3 | range: 85..100 4 | round: down 5 | precision: 1 6 | status: 7 | # ref: https://docs.codecov.com/docs/commit-status 8 | project: 9 | default: 10 | # Avoid false negatives 11 | threshold: 1% 12 | ignore: 13 | - "tests" 14 | comment: 15 | layout: "files" 16 | require_changes: true 17 | -------------------------------------------------------------------------------- /crates/syntax/benches/bench_data/small.tree: -------------------------------------------------------------------------------- 1 | HashPairTest.Sanitize 2 | ├── It should never revert. 3 | ├── When first arg is smaller than second arg 4 | │ ├── When first arg is zero 5 | │ │ └── It should do something. 6 | │ └── It should match the result of `keccak256(abi.encodePacked(a,b))`. 7 | └── When first arg is bigger than second arg 8 | └── It should match the result of `keccak256(abi.encodePacked(b,a))`. 9 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/missing_contract_identifier.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract ContractName { 5 | function test_ShouldFindAContractIdentifier() external { 6 | // It should find a contract identifier. 7 | } 8 | 9 | function test_ShouldNotFindAContractIdentifier() external { 10 | // It should not find a contract identifier. 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/skip_modifiers.tree: -------------------------------------------------------------------------------- 1 | HashPairTest.Sanitize 2 | ├── It should never revert. 3 | ├── When first arg is smaller than second arg 4 | │ ├── When first arg is zero 5 | │ │ └── It should do something. 6 | │ └── It should match the result of `keccak256(abi.encodePacked(a,b))`. 7 | └── When first arg is bigger than second arg 8 | └── It should match the result of `keccak256(abi.encodePacked(b,a))`. 9 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/skip_modifiers.tree: -------------------------------------------------------------------------------- 1 | HashPairTest.Sanitize 2 | ├── It should never revert. 3 | ├── When first arg is smaller than second arg 4 | │ ├── When first arg is zero 5 | │ │ └── It should do something. 6 | │ └── It should match the result of `keccak256(abi.encodePacked(a,b))`. 7 | └── When first arg is bigger than second arg 8 | └── It should match the result of `keccak256(abi.encodePacked(b,a))`. 9 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/issue_81.tree: -------------------------------------------------------------------------------- 1 | RecentAmountOf_Integration_Concrete_Test 2 | ├── given null 3 | │ └── it should revert 4 | └── given not null 5 | ├── given paused 6 | │ └── it should return zero 7 | └── given not paused 8 | ├── when last updated time in present 9 | │ └── it should return zero 10 | └── when last updated time in past 11 | └── it should return the correct recent amount 12 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/duplicated_condition.tree: -------------------------------------------------------------------------------- 1 | HashPairTest 2 | ├── When first arg is smaller than second arg 3 | │ └── It should match the result of `keccak256(abi.encodePacked(a,b))`. 4 | ├── When first arg is smaller than second arg 5 | │ └── It should match the result of `keccak256(abi.encodePacked(a,b))`. 6 | └── When first arg is smaller than second arg 7 | └── It should match the result of `keccak256(abi.encodePacked(b,a))`. 8 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/extra_codegen_tree.tree: -------------------------------------------------------------------------------- 1 | HashPairTest 2 | ├── It should never revert. 3 | ├── When first arg is smaller than second arg 4 | │ └── It should match the result of `keccak256(abi.encodePacked(a,b))`. 5 | ├── When first arg is smaller than third arg 6 | │ └── It should match the result of `keccak256(abi.encodePacked(a,b))`. 7 | └── When first arg is bigger than second arg 8 | └── It should match the result of `keccak256(abi.encodePacked(b,a))`. 9 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/contract_names_mismatch_multiple_roots.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract ContractName { 5 | function test_ShouldHaveANameMismatchInTheContracts1() external { 6 | // It should match the result of `keccak256(abi.encodePacked(a,b))`. 7 | } 8 | 9 | function test_ShouldHaveANameMismatchInTheContracts2() external { 10 | // It should match the result of `keccak256(abi.encodePacked(a,b))`. 11 | } 12 | } -------------------------------------------------------------------------------- /crates/syntax/src/test_utils.rs: -------------------------------------------------------------------------------- 1 | #![allow(missing_docs, unreachable_pub, unused)] 2 | use crate::span::{Position, Span}; 3 | 4 | #[derive(Clone, Debug)] 5 | pub(crate) struct TestError { 6 | pub(crate) span: Span, 7 | pub(crate) kind: K, 8 | } 9 | 10 | pub(crate) fn p(offset: usize, line: usize, column: usize) -> Position { 11 | Position::new(offset, line, column) 12 | } 13 | 14 | pub(crate) fn s(start: Position, end: Position) -> Span { 15 | Span::new(start, end) 16 | } 17 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/spurious_comments.tree: -------------------------------------------------------------------------------- 1 | // This comment doesn't work 2 | 3 | // This comment works 4 | HashPairTest 5 | ├── It should never revert. // This comment also works 6 | ├── When first arg is smaller than second arg 7 | │ └── It should match the result of `keccak256(abi.encodePacked(a,b))`. 8 | └── When first arg is bigger than second arg 9 | └── It should match the result of `keccak256(abi.encodePacked(b,a))`. 10 | // This comment works 11 | 12 | // This comment doesn't work 13 | -------------------------------------------------------------------------------- /docs/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-rgb: 245, 245, 220; 8 | } 9 | 10 | body { 11 | color: rgb(var(--foreground-rgb)); 12 | background: linear-gradient( 13 | to bottom, 14 | transparent, 15 | rgb(var(--background-end-rgb)) 16 | ) 17 | rgb(var(--background-start-rgb)); 18 | } 19 | 20 | @layer utilities { 21 | .text-balance { 22 | text-wrap: balance; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /crates/foundry/src/constants.rs: -------------------------------------------------------------------------------- 1 | //! Constants. 2 | 3 | /// Default indentation used internally. 4 | pub(crate) const INTERNAL_DEFAULT_INDENTATION: usize = 2; 5 | /// Default solidity version used internally. 6 | pub const DEFAULT_SOL_VERSION: &str = "0.8.0"; 7 | /// The separator used between contract name and function name when parsing 8 | /// `.tree` files with multiple trees. 9 | pub const CONTRACT_IDENTIFIER_SEPARATOR: &str = "::"; 10 | /// The separator used between trees when parsing `.tree` files with multiple 11 | /// trees. 12 | pub const TREES_SEPARATOR: &str = "\n\n"; 13 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/spurious_comments.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract HashPairTest { 5 | function test_ShouldNeverRevert() external { 6 | // It should never revert. 7 | } 8 | 9 | function test_WhenFirstArgIsSmallerThanSecondArg() external { 10 | // It should match the result of `keccak256(abi.encodePacked(a,b))`. 11 | } 12 | 13 | function test_WhenFirstArgIsBiggerThanSecondArg() external { 14 | // It should match the result of `keccak256(abi.encodePacked(b,a))`. 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /crates/syntax/src/char.rs: -------------------------------------------------------------------------------- 1 | pub(crate) trait CharExt { 2 | /// Checks whether a character can appear in an identifier. 3 | /// 4 | /// Valid identifiers are those which can be used as a variable name 5 | /// plus `-`, which will be converted to `_` in the generated code. 6 | fn is_valid_identifier(&self) -> bool; 7 | } 8 | 9 | impl CharExt for char { 10 | fn is_valid_identifier(&self) -> bool { 11 | self.is_alphanumeric() 12 | || *self == '_' 13 | || *self == '-' 14 | || *self == '\'' 15 | || *self == '"' 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docs/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | }; 19 | export default config; 20 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/multiple_roots.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract MultipleRootsTreeTest { 5 | function test_Function1_ShouldNeverRevert() external { 6 | // It should never revert. 7 | } 8 | 9 | function test_Function1_WhenFirstArgIsBiggerThanSecondArg() external { 10 | // It is all good 11 | } 12 | 13 | function test_Function2_RevertWhen_StuffDoesNotHappen() external { 14 | // it should revert 15 | } 16 | 17 | function test_Function2_WhenStuffHappens() external { 18 | // it should do something simple 19 | } 20 | } -------------------------------------------------------------------------------- /crates/foundry/src/check/rules/mod.rs: -------------------------------------------------------------------------------- 1 | //! Defines rules that Solidity contracts must follow in order to 2 | //! be considered spec compliant. 3 | //! 4 | //! These rules are checked with the `bulloak check` command. 5 | 6 | use super::{context::Context, violation::Violation}; 7 | 8 | pub mod structural_match; 9 | pub use structural_match::StructuralMatcher; 10 | 11 | /// Trait definition for a rule checker object. 12 | /// 13 | /// All children modules must export an implementor of this trait. 14 | pub trait Checker { 15 | /// Defines a rule to be checked by the `bulloak check` command. 16 | fn check(ctx: &Context) -> Vec; 17 | } 18 | -------------------------------------------------------------------------------- /crates/bulloak/benches/emit.rs: -------------------------------------------------------------------------------- 1 | #![allow(missing_docs)] 2 | use bulloak_foundry::scaffold; 3 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 4 | 5 | fn emit_big_tree(c: &mut Criterion) { 6 | let tree = 7 | std::fs::read_to_string("benches/bench_data/cancel.tree").unwrap(); 8 | 9 | let cfg = Default::default(); 10 | let mut group = c.benchmark_group("sample-size-10"); 11 | group.bench_function("emit-big-tree", |b| { 12 | b.iter(|| scaffold::scaffold(black_box(&tree), &cfg)) 13 | }); 14 | group.finish(); 15 | } 16 | 17 | criterion_group!(benches, emit_big_tree); 18 | criterion_main!(benches); 19 | -------------------------------------------------------------------------------- /crates/foundry/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A `bulloak` backend for Foundry tests. 2 | //! 3 | //! `bulloak-foundry` provides an implementation of turning a `bulloak-syntax` 4 | //! AST into a `.t.sol` file containing scaffolded and ready-to-run Foundry 5 | //! tests. 6 | //! 7 | //! It also includes the implementation of a system to check that tests 8 | //! correspond to a spec in the form of a `.tree`. This implementation allows 9 | //! for defining rules to be checked, which may be automatically fixed. 10 | 11 | pub mod check; 12 | pub mod config; 13 | pub mod constants; 14 | pub mod hir; 15 | pub mod scaffold; 16 | pub mod sol; 17 | 18 | pub use check::violation::{self, Violation, ViolationKind}; 19 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@vercel/analytics": "^1.1.2", 13 | "@vercel/speed-insights": "^1.0.8", 14 | "react": "^18", 15 | "react-dom": "^18", 16 | "next": "14.2.26" 17 | }, 18 | "devDependencies": { 19 | "typescript": "^5", 20 | "@types/node": "^20", 21 | "@types/react": "^18", 22 | "@types/react-dom": "^18", 23 | "postcss": "^8", 24 | "tailwindcss": "^3.4.1", 25 | "eslint": "^8", 26 | "eslint-config-next": "14.2.6" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /crates/syntax/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bulloak-syntax" 3 | version.workspace = true 4 | authors.workspace = true 5 | license.workspace = true 6 | edition.workspace = true 7 | readme = "./README.md" 8 | repository.workspace = true 9 | homepage.workspace = true 10 | documentation.workspace = true 11 | description.workspace = true 12 | keywords.workspace = true 13 | categories.workspace = true 14 | 15 | [dependencies] 16 | anyhow.workspace = true 17 | thiserror.workspace = true 18 | unicode-xid.workspace = true 19 | 20 | [dev-dependencies] 21 | indoc = "2.0.5" 22 | pretty_assertions.workspace = true 23 | criterion.workspace = true 24 | 25 | [[bench]] 26 | name = "syntax" 27 | harness = false 28 | 29 | [lints] 30 | workspace = true 31 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/disambiguation.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract Disambiguation { 5 | modifier whenAIsEven() { 6 | _; 7 | } 8 | 9 | function test_RevertGiven_Zero() external whenAIsEven { 10 | // it should revert 11 | } 12 | 13 | function test_GivenNotZero() external whenAIsEven { 14 | // it should work 15 | } 16 | 17 | modifier whenBIsEven() { 18 | _; 19 | } 20 | 21 | function test_RevertGiven_ZeroWhenBIsEven() external whenBIsEven { 22 | // it should revert 23 | } 24 | 25 | function test_GivenNotZero_WhenBIsEven() external whenBIsEven { 26 | // it should work 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/skip_modifiers.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract HashPairTestSanitize { 5 | function test_ShouldNeverRevert() external { 6 | // It should never revert. 7 | } 8 | 9 | function test_WhenFirstArgIsSmallerThanSecondArg() external whenFirstArgIsSmallerThanSecondArg { 10 | // It should match the result of `keccak256(abi.encodePacked(a,b))`. 11 | } 12 | 13 | function test_WhenFirstArgIsZero() external whenFirstArgIsSmallerThanSecondArg { 14 | // It should do something. 15 | } 16 | 17 | function test_WhenFirstArgIsBiggerThanSecondArg() external { 18 | // It should match the result of `keccak256(abi.encodePacked(b,a))`. 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/skip_modifiers.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract HashPairTestSanitize { 5 | function test_ShouldNeverRevert() external { 6 | // It should never revert. 7 | } 8 | 9 | function test_WhenFirstArgIsSmallerThanSecondArg() external whenFirstArgIsSmallerThanSecondArg { 10 | // It should match the result of `keccak256(abi.encodePacked(a,b))`. 11 | } 12 | 13 | function test_WhenFirstArgIsZero() external whenFirstArgIsSmallerThanSecondArg { 14 | // It should do something. 15 | } 16 | 17 | function test_WhenFirstArgIsBiggerThanSecondArg() external { 18 | // It should match the result of `keccak256(abi.encodePacked(b,a))`. 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/multiple_roots_vm_skip.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | 6 | contract MultipleRootsTreeTest is Test { 7 | function test_Function1_ShouldNeverRevert() external { 8 | // It should never revert. 9 | vm.skip(true); 10 | } 11 | 12 | function test_Function1_WhenFirstArgIsBiggerThanSecondArg() external { 13 | // It is all good 14 | vm.skip(true); 15 | } 16 | 17 | function test_Function2_RevertWhen_StuffDoesNotHappen() external { 18 | // it should revert 19 | vm.skip(true); 20 | } 21 | 22 | function test_Function2_WhenStuffHappens() external { 23 | // it should do something simple 24 | vm.skip(true); 25 | } 26 | } -------------------------------------------------------------------------------- /crates/bulloak/tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | path::PathBuf, 4 | process::{Command, Output}, 5 | }; 6 | 7 | pub(crate) fn get_binary_path() -> PathBuf { 8 | let root = env::current_exe() 9 | .unwrap() 10 | .parent() 11 | .expect("should be in the executable's directory") 12 | .to_path_buf(); 13 | root.join("../bulloak") 14 | } 15 | 16 | /// Runs a command with the specified args. 17 | #[allow(dead_code)] // Not used in all test crates. 18 | pub(crate) fn cmd( 19 | binary_path: &PathBuf, 20 | command: &str, 21 | tree_path: &PathBuf, 22 | args: &[&str], 23 | ) -> Output { 24 | Command::new(binary_path) 25 | .arg(command) 26 | .arg(tree_path) 27 | .args(args) 28 | .output() 29 | .expect("should execute the command") 30 | } 31 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/hash_pair.tree: -------------------------------------------------------------------------------- 1 | Utils::hashPair 2 | ├── It should never revert. 3 | ├── When first arg is smaller than second arg 4 | │ └── It should match the result of `keccak256(abi.encodePacked(a,b))`. 5 | └── When first arg is bigger than second arg 6 | └── It should match the result of `keccak256(abi.encodePacked(b,a))`. 7 | 8 | 9 | Utils::min 10 | ├── It should never revert. 11 | ├── When first arg is smaller than second arg 12 | │ └── It should match the value of `a`. 13 | └── When first arg is bigger than second arg 14 | └── It should match the value of `b`. 15 | 16 | 17 | Utils::max 18 | ├── It should never revert. 19 | ├── When first arg is smaller than second arg 20 | │ └── It should match the value of `b`. 21 | └── When first arg is bigger than second arg 22 | └── It should match the value of `a`. 23 | -------------------------------------------------------------------------------- /crates/foundry/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bulloak-foundry" 3 | version.workspace = true 4 | authors.workspace = true 5 | license.workspace = true 6 | edition.workspace = true 7 | readme = "./README.md" 8 | repository.workspace = true 9 | homepage.workspace = true 10 | documentation.workspace = true 11 | description.workspace = true 12 | keywords.workspace = true 13 | categories.workspace = true 14 | 15 | [dependencies] 16 | bulloak-syntax.workspace = true 17 | 18 | anyhow.workspace = true 19 | forge-fmt.workspace = true 20 | indexmap.workspace = true 21 | once_cell.workspace = true 22 | owo-colors.workspace = true 23 | regex.workspace = true 24 | solang-parser.workspace = true 25 | thiserror.workspace = true 26 | 27 | [dev-dependencies] 28 | pretty_assertions.workspace = true 29 | tempfile = "3.19.1" 30 | 31 | [lints] 32 | workspace = true 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@alexfertel/bulloak", 3 | "description": "Generate tests based on the Branching Tree Technique", 4 | "license": "(MIT OR APACHE-2.0)", 5 | "version": "0.8.1", 6 | "author": { 7 | "name": "Alex Fertel", 8 | "url": "https://www.bulloak.dev/" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/alexfertel/bulloak/issues/new" 12 | }, 13 | "devDependencies": { 14 | "husky": "^9.1.7", 15 | "lint-staged": "^16.1.5", 16 | "prettier": "^3.6.2" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/alexfertel/bulloak.git" 21 | }, 22 | "scripts": { 23 | "prepare": "husky install", 24 | "prettier:check": "prettier --check \"**/*.{json,md,tsx,yml}\"", 25 | "prettier:write": "prettier --write \"**/*.{json,md,tsx,yml}\"" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/extra_codegen_tree.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract HashPairTest { 5 | modifier whenFirstArgIsSmallerThanSecondArg() { 6 | _; 7 | } 8 | 9 | function test_WhenFirstArgIsSmallerThanSecondArg() 10 | external 11 | whenFirstArgIsSmallerThanSecondArg 12 | { 13 | // It should match the result of `keccak256(abi.encodePacked(a,b))`. 14 | } 15 | 16 | modifier whenFirstArgIsBiggerThanSecondArg() { 17 | _; 18 | } 19 | 20 | function thisIsAnotherExtraFunction() external { 21 | // It has a random comment inside. 22 | } 23 | 24 | function test_WhenFirstArgIsBiggerThanSecondArg() 25 | external 26 | whenFirstArgIsBiggerThanSecondArg 27 | { 28 | // It should match the result of `keccak256(abi.encodePacked(b,a))`. 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/basic.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract HashPairTestSanitize { 5 | function test_ShouldNeverRevert() external { 6 | // It should never revert. 7 | } 8 | 9 | modifier whenFirstArgIsSmallerThanSecondArg() { 10 | _; 11 | } 12 | 13 | function test_WhenFirstArgIsSmallerThanSecondArg() external whenFirstArgIsSmallerThanSecondArg { 14 | // It should match the result of `keccak256(abi.encodePacked(a,b))`. 15 | } 16 | 17 | function test_WhenFirstArgIsZero() external whenFirstArgIsSmallerThanSecondArg { 18 | // It should do something. 19 | } 20 | 21 | function test_WhenFirstArgIsBiggerThanSecondArg() external { 22 | // It should match the result of `keccak256(abi.encodePacked(b,a))`. 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /crates/bulloak/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bulloak" 3 | version.workspace = true 4 | authors.workspace = true 5 | license.workspace = true 6 | edition.workspace = true 7 | readme = "./README.md" 8 | repository.workspace = true 9 | homepage.workspace = true 10 | documentation.workspace = true 11 | description.workspace = true 12 | keywords.workspace = true 13 | categories.workspace = true 14 | 15 | [dependencies] 16 | bulloak-syntax.workspace = true 17 | bulloak-foundry.workspace = true 18 | 19 | anyhow.workspace = true 20 | clap.workspace = true 21 | figment.workspace = true 22 | forge-fmt.workspace = true 23 | owo-colors.workspace = true 24 | serde.workspace = true 25 | glob = "0.3.2" 26 | 27 | [dev-dependencies] 28 | pretty_assertions.workspace = true 29 | criterion.workspace = true 30 | 31 | [[bench]] 32 | name = "emit" 33 | harness = false 34 | 35 | [lints] 36 | workspace = true 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # will have compiled files and executables 2 | target/ 3 | 4 | # These are backup files generated by rustfmt 5 | **/*.rs.bk 6 | 7 | # VSCode configuration 8 | .vscode/ 9 | 10 | # Sublime Text configuration 11 | *.sublime-* 12 | 13 | # IntelliJ IDE configuration 14 | .idea/ 15 | /*.iml 16 | 17 | # Vim swap files 18 | *.swp 19 | 20 | # Emacs 21 | *~ 22 | \#*\# 23 | 24 | **/.DS_Store 25 | 26 | # dependencies 27 | docs/node_modules 28 | docs/.pnp 29 | docs/.pnp.js 30 | docs/.yarn/install-state.gz 31 | 32 | # testing 33 | docs/coverage 34 | 35 | # next.js 36 | docs/.next/ 37 | docs/out/ 38 | 39 | # production 40 | docs/build 41 | 42 | # misc 43 | *.pem 44 | 45 | # debug 46 | docs/npm-debug.log* 47 | docs/yarn-debug.log* 48 | docs/yarn-error.log* 49 | 50 | # local env files 51 | docs/.env*.local 52 | 53 | # vercel 54 | docs/.vercel 55 | 56 | # typescript 57 | docs/*.tsbuildinfo 58 | docs/next-env.d.ts 59 | -------------------------------------------------------------------------------- /crates/foundry/src/check/location.rs: -------------------------------------------------------------------------------- 1 | //! Location utilities. 2 | use std::fmt; 3 | 4 | type Filename = String; 5 | type Line = usize; 6 | 7 | /// A code location. 8 | #[derive(Clone, Debug, Eq, PartialEq)] 9 | pub enum Location { 10 | /// A code location inside a file. 11 | Code(Filename, Line), 12 | /// A file name. 13 | File(Filename), 14 | } 15 | 16 | impl Location { 17 | /// Returns the filename of this code location. 18 | pub fn file(&self) -> String { 19 | match self { 20 | Location::Code(file, _) | Location::File(file) => file.clone(), 21 | } 22 | } 23 | } 24 | 25 | impl fmt::Display for Location { 26 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 27 | match self { 28 | Location::Code(filename, line) => write!(f, "{filename}:{line}"), 29 | Location::File(name) => write!(f, "{name}"), 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /crates/foundry/src/check/utils.rs: -------------------------------------------------------------------------------- 1 | //! Check module utilities. 2 | 3 | /// Converts the start offset of a `Loc` to `(line, col)`. Modified from 4 | pub fn offset_to_line_column(content: &str, start: usize) -> (usize, usize) { 5 | debug_assert!(content.len() > start); 6 | 7 | // first line is `1` 8 | let mut line_counter = 1; 9 | for (offset, c) in content.chars().enumerate() { 10 | if c == '\n' { 11 | line_counter += 1; 12 | } 13 | if offset > start { 14 | return (line_counter, offset - start); 15 | } 16 | } 17 | 18 | unreachable!("content.len() > start") 19 | } 20 | 21 | /// Returns the line where a byte offset is found. 22 | pub fn offset_to_line(content: &str, start: usize) -> usize { 23 | offset_to_line_column(content, start).0 24 | } 25 | -------------------------------------------------------------------------------- /crates/foundry/src/scaffold/mod.rs: -------------------------------------------------------------------------------- 1 | //! Defines the `bulloak scaffold` command. 2 | //! 3 | //! This command scaffolds a Solidity file from a spec `.tree` file. 4 | 5 | use forge_fmt::fmt; 6 | 7 | use crate::{config::Config, hir::translate, sol}; 8 | 9 | pub mod comment; 10 | pub mod emitter; 11 | pub mod modifiers; 12 | 13 | /// Generates Solidity code from a `.tree` file. 14 | /// 15 | /// This function takes the content of a `.tree` file and a configuration, 16 | /// translates it to an intermediate representation, then to Solidity, and 17 | /// finally formats the resulting Solidity code. 18 | pub fn scaffold(text: &str, cfg: &Config) -> anyhow::Result { 19 | let hir = translate(text, cfg)?; 20 | let pt = sol::Translator::new(cfg).translate(&hir); 21 | let source = sol::Formatter::new().emit(pt); 22 | let formatted = 23 | fmt(&source).expect("should format the emitted solidity code"); 24 | 25 | Ok(formatted) 26 | } 27 | -------------------------------------------------------------------------------- /docs/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/unsorted.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract HashPairTest { 5 | function test_ShouldNeverRevert() external { 6 | // It should never revert. 7 | } 8 | 9 | function an_extra_function() external { 10 | // That may have some content. 11 | } 12 | 13 | function test_WhenFirstArgIsSmallerThanSecondArg() external whenFirstArgIsSmallerThanSecondArg { 14 | // It should match the result of `keccak256(abi.encodePacked(a,b))`. 15 | } 16 | 17 | // A comment. 18 | function test_WhenFirstArgIsZero() external whenFirstArgIsSmallerThanSecondArg { 19 | // It should do something. 20 | } 21 | 22 | modifier whenFirstArgIsSmallerThanSecondArg() { 23 | _; 24 | } 25 | 26 | function test_WhenFirstArgIsBiggerThanSecondArg() external { 27 | // It should match the result of `keccak256(abi.encodePacked(b,a))`. 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/basic_vm_skip.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | 6 | contract HashPairTestSanitize is Test { 7 | function test_ShouldNeverRevert() external { 8 | // It should never revert. 9 | vm.skip(true); 10 | } 11 | 12 | modifier whenFirstArgIsSmallerThanSecondArg() { 13 | _; 14 | } 15 | 16 | function test_WhenFirstArgIsSmallerThanSecondArg() external whenFirstArgIsSmallerThanSecondArg { 17 | // It should match the result of `keccak256(abi.encodePacked(a,b))`. 18 | vm.skip(true); 19 | } 20 | 21 | function test_WhenFirstArgIsZero() external whenFirstArgIsSmallerThanSecondArg { 22 | // It should do something. 23 | vm.skip(true); 24 | } 25 | 26 | function test_WhenFirstArgIsBiggerThanSecondArg() external { 27 | // It should match the result of `keccak256(abi.encodePacked(b,a))`. 28 | vm.skip(true); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/extra_codegen_sol.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract HashPairTest { 5 | function test_ShouldNeverRevert() external { 6 | // It should never revert. 7 | } 8 | 9 | modifier thisisAnExtraModifier() { 10 | // It has a random comment inside. 11 | _; 12 | } 13 | 14 | modifier whenFirstArgIsSmallerThanSecondArg() { 15 | _; 16 | } 17 | 18 | function thisIsAnExtraFunction() { 19 | // It has a random comment inside. 20 | } 21 | 22 | function test_WhenFirstArgIsSmallerThanSecondArg() 23 | external 24 | whenFirstArgIsSmallerThanSecondArg 25 | { 26 | // It should match the result of `keccak256(abi.encodePacked(a,b))`. 27 | } 28 | 29 | modifier whenFirstArgIsBiggerThanSecondArg() { 30 | _; 31 | } 32 | 33 | function thisIsAnotherExtraFunction() { 34 | // It has a random comment inside. 35 | } 36 | 37 | function test_WhenFirstArgIsBiggerThanSecondArg() 38 | external 39 | whenFirstArgIsBiggerThanSecondArg 40 | { 41 | // It should match the result of `keccak256(abi.encodePacked(b,a))`. 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docs/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Analytics } from "@vercel/analytics/react"; 3 | import { SpeedInsights } from "@vercel/speed-insights/next"; 4 | import { mono } from "./fonts"; 5 | import "./globals.css"; 6 | 7 | const title = "bulloak - Test Generator using Branching Tree Technique"; 8 | const description = 9 | "bulloak is a powerful test generator that implements the Branching Tree Technique (BTT) for comprehensive smart contract testing."; 10 | 11 | export const metadata: Metadata = { 12 | metadataBase: new URL("https://www.bulloak.dev"), 13 | alternates: { 14 | canonical: "/", 15 | }, 16 | title, 17 | description, 18 | openGraph: { 19 | title, 20 | description, 21 | images: `/opengraph-image.png`, 22 | }, 23 | }; 24 | 25 | export default function RootLayout({ 26 | children, 27 | }: Readonly<{ 28 | children: React.ReactNode; 29 | }>) { 30 | return ( 31 | 32 | 33 | {children} 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /crates/syntax/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! The syntax parser module. 2 | //! 3 | //! This module includes everything necessary to convert from a tree 4 | //! in string form to an AST. It also includes a semantic analyzer. 5 | 6 | mod ast; 7 | mod char; 8 | mod error; 9 | pub mod parser; 10 | pub mod semantics; 11 | mod span; 12 | mod splitter; 13 | mod test_utils; 14 | pub mod tokenizer; 15 | pub mod utils; 16 | mod visitor; 17 | 18 | pub use ast::{Action, Ast, Condition, Description, Root}; 19 | pub use error::FrontendError; 20 | pub use span::{Position, Span}; 21 | pub use tokenizer::{Token, TokenKind}; 22 | pub use visitor::Visitor; 23 | 24 | /// Parses a string containing trees into ASTs. 25 | pub fn parse(text: &str) -> anyhow::Result> { 26 | splitter::split_trees(text).map(parse_one).collect() 27 | } 28 | 29 | /// Parses a string containing a single tree into an AST. 30 | pub fn parse_one(text: &str) -> anyhow::Result { 31 | let tokens = tokenizer::Tokenizer::new().tokenize(text)?; 32 | let ast = parser::Parser::new().parse(text, &tokens)?; 33 | let mut analyzer = semantics::SemanticAnalyzer::new(text); 34 | analyzer.analyze(&ast)?; 35 | 36 | Ok(ast) 37 | } 38 | -------------------------------------------------------------------------------- /crates/foundry/src/config.rs: -------------------------------------------------------------------------------- 1 | //! `bulloak-core`'s configuration. 2 | 3 | use std::path::PathBuf; 4 | 5 | use crate::constants::DEFAULT_SOL_VERSION; 6 | 7 | /// `bulloak-core`'s configuration. 8 | /// 9 | /// Note that configuration coming from the command line is aggregated to this 10 | /// struct only if it makes sense. For example, the `--fix` flag, doesn't make 11 | /// sense in the context of `bulloak-core`. 12 | #[derive(Debug, Clone)] 13 | pub struct Config { 14 | /// The set of tree files to work on. 15 | pub files: Vec, 16 | /// Whether to emit modifiers. 17 | pub skip_modifiers: bool, 18 | /// Sets a Solidity version for the test contracts. 19 | pub solidity_version: String, 20 | /// Whether to add `vm.skip(true)` at the beginning of each test. 21 | pub emit_vm_skip: bool, 22 | /// Whether to capitalize and punctuate branch descriptions. 23 | pub format_descriptions: bool, 24 | } 25 | 26 | impl Default for Config { 27 | fn default() -> Self { 28 | Self { 29 | files: vec![], 30 | solidity_version: DEFAULT_SOL_VERSION.to_owned(), 31 | emit_vm_skip: false, 32 | skip_modifiers: false, 33 | format_descriptions: false, 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /crates/bulloak/tests/glob_cmd.rs: -------------------------------------------------------------------------------- 1 | #![allow(missing_docs)] 2 | use std::env; 3 | 4 | use common::cmd; 5 | 6 | mod common; 7 | 8 | #[test] 9 | fn scaffold_expands_glob_internally() { 10 | let cwd = env::current_dir().unwrap(); 11 | let bin = common::get_binary_path(); 12 | 13 | // Build the pattern via PathBuf so it uses "\" on Windows. 14 | let glob_pattern = cwd.join("tests").join("scaffold").join("*.tree"); 15 | let out = cmd(&bin, "scaffold", &glob_pattern, &[]); 16 | assert!(!out.status.success()); 17 | 18 | let stdout = String::from_utf8_lossy(&out.stdout); 19 | assert!(stdout.contains("contract HashPair"),); 20 | assert!(stdout.contains("contract CancelTest"),); 21 | } 22 | 23 | #[cfg(not(target_os = "windows"))] 24 | #[test] 25 | fn check_expands_glob_internally() { 26 | let cwd = env::current_dir().unwrap(); 27 | let bin = common::get_binary_path(); 28 | 29 | let glob_pattern = cwd.join("tests").join("check").join("*.tree"); 30 | let out = cmd(&bin, "check", &glob_pattern, &[]); 31 | assert!(!out.status.success()); 32 | 33 | let stderr = String::from_utf8_lossy(&out.stderr); 34 | assert!(stderr.contains("contract name mismatch")); 35 | assert!(stderr.contains("contract name missing at tree root")); 36 | } 37 | -------------------------------------------------------------------------------- /crates/syntax/src/visitor.rs: -------------------------------------------------------------------------------- 1 | //! Defines a trait for visiting a bulloak tree AST in depth-first order. 2 | 3 | use crate::ast; 4 | 5 | /// A trait for visiting a tree AST in depth-first order. 6 | /// 7 | /// All implementors of `Visitor` must provide a `visit_root` implementation. 8 | /// This is usually the entry point of the visitor, though it is best if this 9 | /// assumption is not held. 10 | pub trait Visitor { 11 | /// The result of visiting the AST. 12 | type Output; 13 | /// An error that might occur when visiting the AST. 14 | type Error; 15 | 16 | /// This method is called on a root node. 17 | fn visit_root( 18 | &mut self, 19 | root: &ast::Root, 20 | ) -> Result; 21 | /// This method is called on a condition node. 22 | fn visit_condition( 23 | &mut self, 24 | condition: &ast::Condition, 25 | ) -> Result; 26 | /// This method is called on an action node. 27 | fn visit_action( 28 | &mut self, 29 | action: &ast::Action, 30 | ) -> Result; 31 | /// This method is called on an action description node. 32 | fn visit_description( 33 | &mut self, 34 | description: &ast::Description, 35 | ) -> Result; 36 | } 37 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/hash_pair.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract Utils { 5 | function test_HashPair_ShouldNeverRevert() external { 6 | // It should never revert. 7 | } 8 | 9 | function test_HashPair_WhenFirstArgIsSmallerThanSecondArg() external { 10 | // It should match the result of `keccak256(abi.encodePacked(a,b))`. 11 | } 12 | 13 | function test_HashPair_WhenFirstArgIsBiggerThanSecondArg() external { 14 | // It should match the result of `keccak256(abi.encodePacked(b,a))`. 15 | } 16 | 17 | function test_Min_ShouldNeverRevert() external { 18 | // It should never revert. 19 | } 20 | 21 | function test_Min_WhenFirstArgIsSmallerThanSecondArg() external { 22 | // It should match the value of `a`. 23 | } 24 | 25 | function test_Min_WhenFirstArgIsBiggerThanSecondArg() external { 26 | // It should match the value of `b`. 27 | } 28 | 29 | function test_Max_ShouldNeverRevert() external { 30 | // It should never revert. 31 | } 32 | 33 | function test_Max_WhenFirstArgIsSmallerThanSecondArg() external { 34 | // It should match the value of `b`. 35 | } 36 | 37 | function test_Max_WhenFirstArgIsBiggerThanSecondArg() external { 38 | // It should match the value of `a`. 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Bulloak Contribution Guidelines 2 | 3 | Thank you for contributing to this project! We expect all contributors to have 4 | read this file before submitting PRs. 5 | 6 | ## Pre-Requisites 7 | 8 | Ensure you have the following software installed and configured on your machine: 9 | 10 | - [Node.js](https://nodejs.org) (v20+) 11 | - [Rust (Nightly)](https://rust-lang.org/tools/install) 12 | 13 | In addition, familiarity with [Solidity](https://soliditylang.org) is requisite. 14 | 15 | ## Pull Requests 16 | 17 | To make changes to `bulloak`, please submit a PR to the `main` branch. We'll 18 | review them and either merge or request changes. We have a basic CI setup that 19 | runs: 20 | 21 | ```rust 22 | cargo check 23 | cargo test 24 | rustup run nightly cargo fmt --all -- --check 25 | ``` 26 | 27 | ## Testing 28 | 29 | PRs without tests when appropriate won't be merged. 30 | 31 | To run the tests: 32 | 33 | ```bash 34 | cargo test 35 | ``` 36 | 37 | ## Formatting 38 | 39 | ### Rust Code 40 | 41 | We adhere to the standard rules of formatting in rust. Please, make sure that 42 | your changes follow them too by running: 43 | 44 | ```bash 45 | rustup run nightly cargo fmt 46 | ``` 47 | 48 | ### Markdown Files 49 | 50 | We use Prettier to enforce consistent formatting across Markdown files. 51 | 52 | ```bash 53 | # Check formatting 54 | npm run prettier:check 55 | 56 | # Fix formatting issues 57 | npm run prettier:write 58 | ``` 59 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | This directory holds the `bulloak` documentation site. 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the 18 | result. 19 | 20 | You can start editing the page by modifying `src/app/page.tsx`. The page 21 | auto-updates as you edit the file. 22 | 23 | This project uses 24 | [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to 25 | automatically optimize and load Inter, a custom Google Font. 26 | 27 | ## Learn More 28 | 29 | To learn more about Next.js, take a look at the following resources: 30 | 31 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js 32 | features and API. 33 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 34 | 35 | You can check out 36 | [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your 37 | feedback and contributions are welcome! 38 | 39 | ## Deploy on Vercel 40 | 41 | The easiest way to deploy your Next.js app is to use the 42 | [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) 43 | from the creators of Next.js. 44 | 45 | Check out our 46 | [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more 47 | details. 48 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/issue_81.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import {Integration_Test} from "../../Integration.t.sol"; 5 | 6 | contract RecentAmountOf_Integration_Concrete_Test is Integration_Test { 7 | function test_RevertGiven_Null() external { 8 | bytes memory callData = abi.encodeCall( 9 | flow.recentAmountOf, 10 | nullStreamId 11 | ); 12 | expectRevert_Null(callData); 13 | } 14 | 15 | function test_GivenPaused() external givenNotNull { 16 | flow.pause(defaultStreamId); 17 | 18 | // It should return zero. 19 | uint128 recentAmount = flow.recentAmountOf(defaultStreamId); 20 | assertEq(recentAmount, 0, "recent amount"); 21 | } 22 | 23 | function test_WhenLastUpdatedTimeInPresent() 24 | external 25 | givenNotNull 26 | givenNotPaused 27 | { 28 | // Update the last time to the current block timestamp. 29 | updateLastTimeToBlockTimestamp(defaultStreamId); 30 | 31 | // It should return zero. 32 | uint128 recentAmount = flow.recentAmountOf(defaultStreamId); 33 | assertEq(recentAmount, 0, "recent amount"); 34 | } 35 | 36 | function test_BlahBlahBlah() external view givenNotNull givenNotPaused { 37 | // It should return the correct recent amount. 38 | uint128 recentAmount = flow.recentAmountOf(defaultStreamId); 39 | assertEq(recentAmount, ONE_MONTH_STREAMED_AMOUNT, "recent amount"); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["crates/bulloak", "crates/foundry", "crates/syntax"] 4 | 5 | [workspace.package] 6 | authors = ["Alexander Gonzalez "] 7 | version = "0.9.1" 8 | license = "MIT OR Apache-2.0" 9 | edition = "2021" 10 | readme = "README.md" 11 | repository = "https://github.com/alexfertel/bulloak" 12 | homepage = "https://github.com/alexfertel/bulloak" 13 | documentation = "https://github.com/alexfertel/bulloak" 14 | description = """ 15 | A Solidity test generator based on the Branching Tree Technique. 16 | """ 17 | keywords = ["solidity", "testing", "compiler", "cli", "tree"] 18 | categories = [ 19 | "development-tools::testing", 20 | "command-line-utilities", 21 | "compilers", 22 | "parsing", 23 | ] 24 | exclude = ["/.github/*"] 25 | 26 | [workspace.lints.rust] 27 | missing_docs = "warn" 28 | unreachable_pub = "warn" 29 | rust_2021_compatibility = { level = "warn", priority = -1 } 30 | 31 | [workspace.lints.clippy] 32 | pedantic = "warn" 33 | all = "warn" 34 | 35 | [workspace.dependencies] 36 | bulloak-syntax = { path = "crates/syntax", version = "0.9.0" } 37 | bulloak-foundry = { path = "crates/foundry", version = "0.9.0" } 38 | 39 | anyhow = "1.0.75" 40 | clap = { version = "4.3.19", features = ["derive"] } 41 | criterion = "0.5.1" 42 | figment = "0.10.19" 43 | forge-fmt = "0.2.0" 44 | indexmap = "2.0.0" 45 | once_cell = "1.18.0" 46 | owo-colors = "3.5.0" 47 | pretty_assertions = { version = "1.4.0" } 48 | regex = "1.10.2" 49 | serde = "1.0.203" 50 | solang-parser = "0.3.2" 51 | thiserror = "1.0.61" 52 | unicode-xid = "0.2.4" 53 | -------------------------------------------------------------------------------- /crates/syntax/README.md: -------------------------------------------------------------------------------- 1 | # bulloak-syntax 2 | 3 | ## Overview 4 | 5 | `bulloak-syntax` is a Rust crate that provides a syntax parser for converting 6 | tree-like structures in string form into Abstract Syntax Trees (ASTs). It also 7 | includes a semantic analyzer for further processing of the parsed structures. 8 | 9 | ## Features 10 | 11 | - Parse strings containing tree-like structures into ASTs. 12 | - Tokenize input strings. 13 | - Perform semantic analysis on parsed ASTs (e.g., ensure the tree has content, 14 | top‑level actions are unique). Duplicate condition titles are allowed; only 15 | duplicate top‑level actions are rejected. 16 | - Support for parsing both single and multiple trees. 17 | - Error handling with custom `FrontendError` type. 18 | 19 | ## Usage 20 | 21 | To use bulloak-syntax in your project, add it to your `Cargo.toml`: 22 | 23 | ```toml 24 | [dependencies] 25 | bulloak-syntax = "0.1.0" # Replace with the actual version 26 | ``` 27 | 28 | And then parse the input: 29 | 30 | ```rust 31 | use bulloak_syntax::parse; 32 | 33 | fn main() -> anyhow::Result<()> { 34 | let input = "your tree-like structure here"; 35 | let asts = parse(input)?; 36 | 37 | // Process the ASTs as needed 38 | for ast in asts { 39 | // ... 40 | } 41 | 42 | Ok(()) 43 | } 44 | ``` 45 | 46 | ## License 47 | 48 | This project is licensed under either of: 49 | 50 | - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or 51 | https://www.apache.org/licenses/LICENSE-2.0). 52 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or 53 | https://opensource.org/licenses/MIT). 54 | -------------------------------------------------------------------------------- /crates/foundry/src/hir/mod.rs: -------------------------------------------------------------------------------- 1 | //! Defines a high-level intermediate representation (HIR) and translation 2 | //! functions that convert abstract syntax trees (ASTs) to their corresponding 3 | //! HIR. 4 | 5 | pub mod combiner; 6 | #[allow(clippy::module_inception)] 7 | pub mod hir; 8 | pub use hir::*; 9 | pub mod translator; 10 | pub mod visitor; 11 | 12 | use bulloak_syntax::Ast; 13 | 14 | use crate::{config::Config, scaffold::modifiers::ModifierDiscoverer}; 15 | 16 | /// Translates the contents of a `.tree` file into a HIR. 17 | /// 18 | /// # Arguments 19 | /// 20 | /// * `text` - The contents of the `.tree` file. 21 | /// * `cfg` - The configuration for the translation process. 22 | /// 23 | /// # Returns 24 | /// 25 | /// Returns a `Result` containing the translated `Hir` or a `TranslationError`. 26 | pub fn translate(text: &str, cfg: &Config) -> anyhow::Result { 27 | let asts = bulloak_syntax::parse(text)?; 28 | 29 | if asts.len() == 1 { 30 | return Ok(translate_one(&asts[0], cfg)); 31 | } 32 | 33 | let hirs = asts.into_iter().map(|ast| translate_one(&ast, cfg)); 34 | Ok(combiner::Combiner::new().combine(text, hirs)?) 35 | } 36 | 37 | /// Generates the HIR for a single AST. 38 | /// 39 | /// # Arguments 40 | /// 41 | /// * `ast` - The Abstract Syntax Tree to translate. 42 | /// * `cfg` - The configuration for the translation process. 43 | /// 44 | /// # Returns 45 | /// 46 | /// Returns the translated `Hir`. 47 | #[must_use] 48 | pub fn translate_one(ast: &Ast, cfg: &Config) -> Hir { 49 | let mut discoverer = ModifierDiscoverer::new(); 50 | let modifiers = discoverer.discover(ast); 51 | translator::Translator::new().translate(ast, modifiers, cfg) 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - "**[0-9]+.[0-9]+.[0-9]+*" 10 | workflow_dispatch: 11 | inputs: 12 | tag: 13 | description: "Release tag (must match vMAJOR.MINOR.PATCH)" 14 | required: true 15 | release: 16 | types: [created] 17 | 18 | jobs: 19 | create-release: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: taiki-e/create-gh-release-action@v1 24 | with: 25 | changelog: CHANGELOG.md 26 | draft: true 27 | token: ${{ secrets.GITHUB_TOKEN }} 28 | ref: 29 | ${{ github.event_name == 'workflow_dispatch' && 30 | format('refs/tags/{0}', github.event.inputs.tag) || github.ref }} 31 | upload-assets: 32 | needs: create-release 33 | runs-on: ${{ matrix.os }} 34 | strategy: 35 | matrix: 36 | include: 37 | - target: x86_64-unknown-linux-gnu 38 | os: ubuntu-latest 39 | - target: aarch64-unknown-linux-gnu 40 | os: ubuntu-latest 41 | - target: aarch64-apple-darwin 42 | os: macos-latest 43 | - target: x86_64-apple-darwin 44 | os: macos-latest 45 | - target: x86_64-pc-windows-msvc 46 | os: windows-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | 51 | - name: Build & upload ${{ matrix.target }} 52 | uses: taiki-e/upload-rust-binary-action@v1 53 | with: 54 | token: ${{ secrets.GITHUB_TOKEN }} 55 | bin: bulloak 56 | target: ${{ matrix.target }} 57 | # archives as .tar.gz on UNIX and .zip on Windows by default 58 | ref: 59 | ${{ github.event_name == 'workflow_dispatch' && 60 | format('refs/tags/{0}', github.event.inputs.tag) || github.ref }} 61 | -------------------------------------------------------------------------------- /crates/syntax/benches/bench_data/medium.tree: -------------------------------------------------------------------------------- 1 | CreateWithTimestampsLD_Integration_Concrete_Test 2 | └── when token contract 3 | ├── when segment count zero 4 | │ └── it should revert 5 | └── when segment count not zero 6 | ├── when segment count exceeds max value 7 | │ └── it should revert 8 | └── when segment count not exceed max value 9 | ├── when segment amounts sum overflows 10 | │ └── it should revert 11 | └── when segment amounts sum not overflow 12 | ├── when start time greater than first timestamp 13 | │ └── it should revert 14 | ├── when start time equals first timestamp 15 | │ └── it should revert 16 | └── when start time less than first timestamp 17 | ├── when timestamps not strictly increasing 18 | │ └── it should revert 19 | └── when timestamps strictly increasing 20 | ├── when deposit amount not equal segment amounts sum 21 | │ └── it should revert 22 | └── when deposit amount equals segment amounts sum 23 | ├── when token misses ERC20 return value 24 | │ ├── it should create the stream 25 | │ ├── it should bump the next stream ID 26 | │ ├── it should mint the NFT 27 | │ ├── it should emit {CreateLockupDynamicStream} and {MetadataUpdate} events 28 | │ └── it should perform the ERC-20 transfers 29 | └── when token not miss ERC20 return value 30 | ├── it should create the stream 31 | ├── it should bump the next stream ID 32 | ├── it should mint the NFT 33 | ├── it should emit {CreateLockupDynamicStream} and {MetadataUpdate} events 34 | └── it should perform the ERC-20 transfers 35 | -------------------------------------------------------------------------------- /docs/src/components/icons/index.tsx: -------------------------------------------------------------------------------- 1 | export interface IconProps { 2 | className?: string; 3 | } 4 | 5 | export function GitHubIcon(props: IconProps): JSX.Element { 6 | return ( 7 | 14 | 18 | 19 | ); 20 | } 21 | 22 | export function BulloakIcon(props: IconProps): JSX.Element { 23 | return ( 24 | 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /crates/bulloak/src/cli.rs: -------------------------------------------------------------------------------- 1 | //! `bulloak`'s CLI config. 2 | use clap::{Parser, Subcommand}; 3 | use figment::{providers::Serialized, Figment}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | /// `bulloak`'s configuration. 7 | #[derive(Parser, Debug, Clone, Default, Serialize, Deserialize)] 8 | #[command(author, version, about, long_about = None)] // Read from `Cargo.toml` 9 | pub struct Cli { 10 | /// `bulloak`'s commands. 11 | #[clap(subcommand)] 12 | pub command: Commands, 13 | } 14 | 15 | /// `bulloak`'s commands. 16 | #[derive(Debug, Clone, Subcommand, Serialize, Deserialize)] 17 | pub enum Commands { 18 | /// `bulloak scaffold`. 19 | #[command(name = "scaffold")] 20 | Scaffold(crate::scaffold::Scaffold), 21 | /// `bulloak check`. 22 | #[command(name = "check")] 23 | Check(crate::check::Check), 24 | } 25 | 26 | impl Default for Commands { 27 | fn default() -> Self { 28 | Self::Scaffold(Default::default()) 29 | } 30 | } 31 | 32 | impl From<&Cli> for bulloak_foundry::config::Config { 33 | fn from(cli: &Cli) -> Self { 34 | match &cli.command { 35 | Commands::Scaffold(cmd) => Self { 36 | files: cmd.files.clone(), 37 | solidity_version: cmd.solidity_version.clone(), 38 | emit_vm_skip: cmd.with_vm_skip, 39 | skip_modifiers: cmd.skip_modifiers, 40 | format_descriptions: cmd.format_descriptions, 41 | ..Self::default() 42 | }, 43 | Commands::Check(cmd) => Self { 44 | files: cmd.files.clone(), 45 | skip_modifiers: cmd.skip_modifiers, 46 | format_descriptions: cmd.format_descriptions, 47 | ..Self::default() 48 | }, 49 | } 50 | } 51 | } 52 | 53 | /// Main entrypoint of `bulloak`'s execution. 54 | pub(crate) fn run() -> anyhow::Result<()> { 55 | let config: Cli = 56 | Figment::new().merge(Serialized::defaults(Cli::parse())).extract()?; 57 | 58 | match &config.command { 59 | Commands::Scaffold(command) => command.run(&config), 60 | Commands::Check(command) => command.run(&config), 61 | }; 62 | 63 | Ok(()) 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This is the main CI workflow that runs the test suite on all pushes to main 2 | # and all pull requests. It runs the following jobs: 3 | # - required: runs the test suite on ubuntu with stable and beta rust toolchains 4 | # - os-check: runs the test suite on mac and windows. 5 | permissions: 6 | contents: read 7 | on: 8 | push: 9 | branches: [main] 10 | pull_request: 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 13 | cancel-in-progress: true 14 | name: test 15 | jobs: 16 | required: 17 | runs-on: ubuntu-latest 18 | name: ubuntu / ${{ matrix.toolchain }} 19 | strategy: 20 | matrix: 21 | # run on stable and beta to ensure that tests won't break on the next version of the rust 22 | # toolchain 23 | toolchain: [stable, beta] 24 | steps: 25 | - uses: actions/checkout@v4 26 | with: 27 | submodules: true 28 | - name: Install ${{ matrix.toolchain }} 29 | uses: dtolnay/rust-toolchain@master 30 | with: 31 | toolchain: ${{ matrix.toolchain }} 32 | - name: cargo generate-lockfile 33 | # enable this ci template to run regardless of whether the lockfile is checked in or not 34 | if: hashFiles('Cargo.lock') == '' 35 | run: cargo generate-lockfile 36 | # https://twitter.com/jonhoo/status/1571290371124260865 37 | - name: cargo test --locked 38 | run: cargo test --locked --all-features --all-targets 39 | # https://github.com/rust-lang/cargo/issues/6669 40 | - name: cargo test --doc 41 | run: cargo test --locked --all-features --doc 42 | os-check: 43 | # run cargo test on mac and windows 44 | runs-on: ${{ matrix.os }} 45 | name: ${{ matrix.os }} / stable 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | os: [macos-latest, windows-latest] 50 | steps: 51 | - uses: actions/checkout@v4 52 | with: 53 | submodules: true 54 | - name: Install stable 55 | uses: dtolnay/rust-toolchain@stable 56 | - name: cargo generate-lockfile 57 | if: hashFiles('Cargo.lock') == '' 58 | run: cargo generate-lockfile 59 | - name: cargo test 60 | run: cargo test --locked --all-features --all-targets 61 | -------------------------------------------------------------------------------- /crates/foundry/README.md: -------------------------------------------------------------------------------- 1 | # bulloak-foundry 2 | 3 | `bulloak-foundry` is a Rust crate that serves as a backend for generating 4 | Foundry tests from `bulloak-syntax` Abstract Syntax Trees (ASTs). It provides 5 | functionality to scaffold Solidity test files and check existing tests against 6 | specifications. 7 | 8 | ## Features 9 | 10 | - Generate `.t.sol` files with scaffolded Foundry tests from `bulloak-syntax` 11 | ASTs. 12 | - Check existing Solidity test files against `.tree` specifications. 13 | - Implement and enforce custom rules for test structure and content. 14 | - Automatic fixing of certain rule violations. 15 | - Allow duplicate condition titles; a single modifier per unique title is 16 | emitted and reused where needed. 17 | - Automatic function name disambiguation: when two tests would clash, `bulloak` 18 | prepends nearest ancestor conditions (and if needed multiple ancestors) to 19 | produce a unique name. 20 | 21 | ## Usage 22 | 23 | To use bulloak-foundry in your project, add it to your `Cargo.toml`: 24 | 25 | ```toml 26 | [dependencies] 27 | bulloak-foundry = "0.1.0" # Replace with the actual version 28 | ``` 29 | 30 | ### Scaffolding Tests 31 | 32 | ```rust 33 | use bulloak_foundry::{config::Config, scaffold}; 34 | 35 | fn main() -> anyhow::Result<()> { 36 | let tree_spec = "Your .tree specification here"; 37 | let cfg = Config::default(); // customize as needed 38 | let foundry_test = scaffold::scaffold(tree_spec, &cfg)?; 39 | // Write foundry_test to a .t.sol file 40 | Ok(()) 41 | } 42 | ``` 43 | 44 | ## Semantics 45 | 46 | - Duplicate condition titles are allowed; modifiers are reused. 47 | - Top‑level actions must be unique (duplicates are an error). 48 | - Non‑top‑level test name clashes are resolved automatically by prepending 49 | ancestor conditions. 50 | 51 | ## Violation Checking 52 | 53 | `bulloak-foundry` includes a system for defining and checking rules against 54 | Solidity test files. Violations can be of different kinds, as defined in the 55 | `ViolationKind` enum. 56 | 57 | ## License 58 | 59 | This project is licensed under either of: 60 | 61 | - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or 62 | https://www.apache.org/licenses/LICENSE-2.0). 63 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or 64 | https://opensource.org/licenses/MIT). 65 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/invalid_sol_structure.tree: -------------------------------------------------------------------------------- 1 | CancelTest 2 | ├── when delegate called 3 | │ └── it should revert 4 | └── when not delegate called 5 | ├── given the id references a null stream 6 | │ └── it should revert 7 | └── given the id does not reference a null stream 8 | ├── given the stream is cold 9 | │ ├── given the stream's status is "DEPLETED" 10 | │ │ └── it should revert 11 | │ ├── given the stream's status is "CANCELED" 12 | │ │ └── it should revert 13 | │ └── given the stream's status is "SETTLED" 14 | │ └── it should revert 15 | └── given the stream is warm 16 | └── when the caller is authorized 17 | ├── given the stream is not cancelable 18 | │ └── it should revert 19 | ├── given the sender is not a contract 20 | │ ├── it should cancel the stream 21 | │ └── it should mark the stream as canceled 22 | └── given the sender is a contract 23 | ├── given the sender does not implement the hook 24 | │ ├── it should cancel the stream 25 | │ ├── it should mark the stream as canceled 26 | │ ├── it should call the sender hook 27 | │ └── it should ignore the revert 28 | └── given the sender implements the hook 29 | ├── when the sender reverts 30 | │ ├── it should cancel the stream 31 | │ ├── it should mark the stream as canceled 32 | │ ├── it should call the sender hook 33 | │ └── it should ignore the revert 34 | └── when the sender does not revert 35 | ├── when there is reentrancy 36 | │ ├── it should cancel the stream 37 | │ ├── it should mark the stream as canceled 38 | │ ├── it should call the sender hook 39 | │ └── it should ignore the revert 40 | └── when there is no reentrancy 41 | ├── it should cancel the stream 42 | ├── it should mark the stream as canceled 43 | ├── it should make the stream not cancelable 44 | ├── it should update the refunded amount 45 | ├── it should refund the sender 46 | ├── it should call the sender hook 47 | ├── it should emit a {MetadataUpdate} event 48 | └── it should emit a {CancelLockupStream} event 49 | -------------------------------------------------------------------------------- /crates/bulloak/src/glob.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use glob::glob; 4 | 5 | pub(crate) fn expand_glob( 6 | input: PathBuf, 7 | ) -> anyhow::Result> { 8 | let input = input.to_string_lossy(); 9 | let paths = glob(&input)?.filter_map(Result::ok); 10 | Ok(paths) 11 | } 12 | 13 | #[cfg(test)] 14 | mod tests { 15 | use std::path::PathBuf; 16 | 17 | use super::expand_glob; 18 | 19 | /// Helper to collect and sort the output. 20 | fn sorted_matches(pattern: &str) -> Vec { 21 | let mut v: Vec<_> = expand_glob(PathBuf::from(pattern)) 22 | .unwrap() 23 | .map(|p| p.to_string_lossy().into_owned()) 24 | .collect(); 25 | v.sort(); 26 | v 27 | } 28 | 29 | #[test] 30 | fn literal_path_round_trips() { 31 | // This crate has a Cargo.toml in its root. 32 | let out = sorted_matches("Cargo.toml"); 33 | assert_eq!(out, vec!["Cargo.toml".to_string()]); 34 | } 35 | 36 | #[test] 37 | fn no_such_file_yields_empty() { 38 | let out = sorted_matches("no-such-file-xyz.tree"); 39 | assert!(out.is_empty()); 40 | } 41 | 42 | #[test] 43 | fn simple_star_glob() { 44 | // Match all .rs files in src/ 45 | let out = sorted_matches("src/*.rs"); 46 | assert!(out.iter().any(|e| e.ends_with("main.rs"))); 47 | assert!(out.iter().any(|e| e.ends_with("check.rs"))); 48 | } 49 | 50 | #[test] 51 | fn recursive_double_star_glob() { 52 | // `tests` directory has .tree files under tests/scaffold/. 53 | let out = sorted_matches("tests/scaffold/**/*.tree"); 54 | assert!(out.iter().any(|e| e.ends_with("basic.tree"))); 55 | assert!(out.iter().any(|e| e.ends_with("complex.tree"))); 56 | } 57 | 58 | #[test] 59 | fn forward_slash_glob_works_everywhere() { 60 | let out = sorted_matches("tests/scaffold/*.tree"); 61 | assert!(out.iter().any(|e| e.ends_with("basic.tree"))); 62 | } 63 | 64 | #[test] 65 | #[cfg(windows)] 66 | fn backslash_glob_works_the_same() { 67 | let fwd = sorted_matches("tests/scaffold/*.tree"); 68 | let bwd = sorted_matches("tests\\scaffold\\*.tree"); 69 | assert_eq!( 70 | fwd, bwd, 71 | "backslash‐based glob must match forward‐slash one" 72 | ); 73 | } 74 | 75 | #[test] 76 | fn invalid_pattern_returns_error() { 77 | // Invalid glob syntax (unmatched '[') must return Err. 78 | let bad = PathBuf::from("tests/scaffold/*[.tree"); 79 | let res = expand_glob(bad); 80 | assert!(res.is_err(), "expected invalid glob to Err"); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | [changelog] 2 | # changelog header 3 | header = """ 4 | # Changelog\n 5 | All notable changes to this project will be documented in this file.\n 6 | """ 7 | # template for the changelog body 8 | # https://tera.netlify.app/docs/#introduction 9 | body = """ 10 | {% if version %}\ 11 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 12 | {% else %}\ 13 | ## [unreleased] 14 | {% endif %}\ 15 | {% for group, commits in commits | group_by(attribute="group") %} 16 | ### {{ group | upper_first }} 17 | {% for commit in commits 18 | | filter(attribute="scope") 19 | | sort(attribute="scope") %} 20 | - *({{commit.scope}})* {{ commit.message | upper_first }} 21 | {%- if commit.breaking %} 22 | {% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}} 23 | {%- endif -%} 24 | {%- endfor -%} 25 | {% raw %}\n{% endraw %}\ 26 | {%- for commit in commits %} 27 | {%- if commit.scope -%} 28 | {% else -%} 29 | - {{ commit.message | upper_first }} 30 | {% if commit.breaking -%} 31 | {% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}} 32 | {% endif -%} 33 | {% endif -%} 34 | {% endfor -%} 35 | {% raw %}\n{% endraw %}\ 36 | {% endfor %}\n 37 | """ 38 | # remove the leading and trailing whitespace from the template 39 | trim = true 40 | 41 | [git] 42 | # parse the commits based on https://www.conventionalcommits.org 43 | conventional_commits = true 44 | # filter out the commits that are not conventional 45 | filter_unconventional = true 46 | # process each line of a commit as an individual commit 47 | split_commits = false 48 | # regex for parsing and grouping commits 49 | commit_parsers = [ 50 | { message = "^feat", group = "Features" }, 51 | { message = "^fix", group = "Bug Fixes" }, 52 | { message = "^doc", group = "Documentation" }, 53 | { message = "^perf", group = "Performance" }, 54 | { message = "^ref", group = "Refactor" }, 55 | { message = "^style", skip = true }, 56 | { message = "^test", skip = true }, 57 | { message = "^chore\\(release\\):", skip = true }, 58 | { message = "^chore\\(v[0-9]*\\.[0-9]*\\.[0-9]*\\):", skip = true }, 59 | { message = "^chore", group = "Miscellaneous Tasks" }, 60 | { message = "^lint", skip = true }, 61 | { body = ".*security", group = "Security" }, 62 | ] 63 | # protect breaking changes from being skipped due to matching a skipping commit_parser 64 | protect_breaking_commits = false 65 | # filter out the commits that are not matched by commit parsers 66 | filter_commits = false 67 | # glob pattern for matching git tags 68 | tag_pattern = "v[0-9]*" 69 | # regex for ignoring tags 70 | ignore_tags = "" 71 | # sort the tags topologically 72 | topo_order = false 73 | # sort the commits inside sections by oldest/newest order 74 | sort_commits = "oldest" 75 | -------------------------------------------------------------------------------- /crates/syntax/src/ast.rs: -------------------------------------------------------------------------------- 1 | //! The AST for a bulloak tree file. 2 | 3 | use crate::span::Span; 4 | 5 | /// An Abstract Syntax Tree (AST) that describes the semantic 6 | /// structure of a bulloak tree. 7 | #[derive(Debug, PartialEq, Eq)] 8 | pub enum Ast { 9 | /// The root node of the AST. 10 | Root(Root), 11 | /// A condition node of the AST. 12 | /// 13 | /// This node corresponds to a junction in the tree. 14 | Condition(Condition), 15 | /// An action node of the AST. 16 | /// 17 | /// This node corresponds to a leaf node of the tree. 18 | Action(Action), 19 | /// Additional action description. 20 | /// 21 | /// This node can only appear as a child of an action. 22 | ActionDescription(Description), 23 | } 24 | 25 | impl Ast { 26 | /// Return the span of this abstract syntax tree. 27 | #[must_use] 28 | pub fn span(&self) -> &Span { 29 | match self { 30 | Self::Root(x) => &x.span, 31 | Self::Condition(x) => &x.span, 32 | Self::Action(x) => &x.span, 33 | Self::ActionDescription(x) => &x.span, 34 | } 35 | } 36 | 37 | /// Whether the current node is an `Action` node. 38 | #[must_use] 39 | pub fn is_action(&self) -> bool { 40 | matches!(self, Self::Action(_)) 41 | } 42 | } 43 | 44 | /// The root node of the AST. 45 | #[derive(Debug, PartialEq, Eq)] 46 | pub struct Root { 47 | /// The name that is used for the emitted contract. 48 | pub contract_name: String, 49 | /// The span that encompasses this node. It includes 50 | /// all of its children. 51 | pub span: Span, 52 | /// The children AST nodes of this node. 53 | pub children: Vec, 54 | } 55 | 56 | /// A condition node of the AST. 57 | #[derive(Debug, PartialEq, Eq)] 58 | pub struct Condition { 59 | /// The title of this condition. 60 | /// 61 | /// For example: "when stuff happens". 62 | pub title: String, 63 | /// The span that encompasses this node. It includes 64 | /// all of its children. 65 | pub span: Span, 66 | /// The children AST nodes of this node. 67 | pub children: Vec, 68 | } 69 | 70 | /// An action node of the AST. 71 | #[derive(Debug, PartialEq, Eq)] 72 | pub struct Action { 73 | /// The title of this action. 74 | /// 75 | /// For example: "It should revert." 76 | pub title: String, 77 | /// The span that encompasses this node. 78 | pub span: Span, 79 | /// The children AST nodes of this node. 80 | /// 81 | /// For now we only support action description 82 | /// nodes. 83 | pub children: Vec, 84 | } 85 | 86 | /// A description node of the AST. 87 | #[derive(Debug, PartialEq, Eq)] 88 | pub struct Description { 89 | /// The text of this action. 90 | /// 91 | /// For example: "Describe your actions." 92 | pub text: String, 93 | /// The span that encompasses this node. 94 | pub span: Span, 95 | } 96 | -------------------------------------------------------------------------------- /crates/foundry/src/scaffold/comment.rs: -------------------------------------------------------------------------------- 1 | //! Helpers for normalizing scaffolded comments. 2 | 3 | /// Normalize a description by capitalizing its first alphabetic character and 4 | /// ensuring it ends with a dot, while preserving surrounding whitespace. 5 | pub(crate) fn normalize(lexeme: &str) -> String { 6 | let (prefix, core, _suffix) = split_whitespace_affixes(lexeme); 7 | 8 | if core.is_empty() { 9 | return lexeme.to_string(); 10 | } 11 | 12 | let mut normalized = core.to_string(); 13 | capitalize_first_alpha(&mut normalized); 14 | ensure_terminal_dot(&mut normalized); 15 | 16 | format!("{prefix}{normalized}") 17 | } 18 | 19 | fn capitalize_first_alpha(s: &mut String) { 20 | if let Some((idx, ch)) = s.char_indices().find(|(_, ch)| ch.is_alphabetic()) 21 | { 22 | let uppercase = ch.to_uppercase().to_string(); 23 | s.replace_range(idx..idx + ch.len_utf8(), &uppercase); 24 | } 25 | } 26 | 27 | fn ensure_terminal_dot(s: &mut String) { 28 | let trimmed_len = s.trim_end().len(); 29 | if trimmed_len == 0 { 30 | return; 31 | } 32 | 33 | let trimmed = &s[..trimmed_len]; 34 | if let Some((idx, ch)) = trimmed.char_indices().last() { 35 | if ch == '.' { 36 | return; 37 | } 38 | 39 | if matches!(ch, '!' | '?') { 40 | s.replace_range(idx..idx + ch.len_utf8(), "."); 41 | } else { 42 | s.insert(idx + ch.len_utf8(), '.'); 43 | } 44 | } 45 | } 46 | 47 | fn split_whitespace_affixes(s: &str) -> (&str, &str, &str) { 48 | let mut prefix_len = 0usize; 49 | for (idx, ch) in s.char_indices() { 50 | if ch.is_whitespace() { 51 | prefix_len = idx + ch.len_utf8(); 52 | } else { 53 | break; 54 | } 55 | } 56 | 57 | let mut suffix_len = 0usize; 58 | for (_, ch) in s.char_indices().rev() { 59 | if ch.is_whitespace() { 60 | suffix_len += ch.len_utf8(); 61 | } else { 62 | break; 63 | } 64 | } 65 | 66 | let core_end = s.len().saturating_sub(suffix_len); 67 | let core_start = prefix_len.min(core_end); 68 | 69 | (&s[..core_start], &s[core_start..core_end], &s[core_end..]) 70 | } 71 | 72 | #[cfg(test)] 73 | mod tests { 74 | use super::normalize; 75 | 76 | #[test] 77 | fn preserves_whitespace() { 78 | assert_eq!(normalize(" hello"), " Hello."); 79 | assert_eq!(normalize("hello "), "Hello."); 80 | assert_eq!(normalize(" hello "), " Hello."); 81 | } 82 | 83 | #[test] 84 | fn capitalizes_and_dots() { 85 | assert_eq!(normalize("foo"), "Foo."); 86 | assert_eq!(normalize("Foo."), "Foo."); 87 | assert_eq!(normalize("foo!"), "Foo."); 88 | assert_eq!(normalize("foo?"), "Foo."); 89 | assert_eq!(normalize("FOO"), "FOO."); 90 | } 91 | 92 | #[test] 93 | fn handles_empty_core() { 94 | assert_eq!(normalize(" "), " "); 95 | assert_eq!(normalize(""), ""); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | # If new code is pushed to a PR branch, then cancel in progress workflows for 3 | # that PR. Ensures that we don't waste CI time, and returns results quicker. 4 | # https://github.com/jonhoo/rust-ci-conf/pull/5 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 7 | cancel-in-progress: true 8 | 9 | on: 10 | push: 11 | branches: 12 | - main 13 | pull_request: 14 | branches: 15 | - main 16 | 17 | jobs: 18 | build: 19 | strategy: 20 | matrix: 21 | os: 22 | - ubuntu-latest 23 | - macOS-latest 24 | - windows-latest 25 | runs-on: ${{ matrix.os }} 26 | steps: 27 | - uses: actions/checkout@v1 28 | - uses: actions-rs/toolchain@v1 29 | with: 30 | profile: minimal 31 | toolchain: stable 32 | override: true 33 | - uses: actions-rs/cargo@v1 34 | with: 35 | command: build 36 | args: --all-targets 37 | 38 | fmt: 39 | runs-on: ubuntu-latest 40 | name: nightly / fmt 41 | steps: 42 | - uses: actions/checkout@v4 43 | with: 44 | submodules: true 45 | - name: Install stable 46 | # We run in nightly to make use of some features only available there. 47 | # Check out `rustfmt.toml` to see which ones. 48 | uses: dtolnay/rust-toolchain@nightly 49 | with: 50 | components: rustfmt 51 | - name: cargo fmt --all --check 52 | run: cargo fmt --all --check 53 | 54 | clippy: 55 | runs-on: ubuntu-latest 56 | permissions: 57 | checks: write 58 | steps: 59 | - uses: actions/checkout@v1 60 | - uses: actions-rs/toolchain@v1 61 | with: 62 | profile: minimal 63 | toolchain: stable 64 | override: true 65 | components: clippy 66 | - name: Run Clippy 67 | uses: actions-rs/clippy-check@v1 68 | with: 69 | token: ${{ secrets.GITHUB_TOKEN }} 70 | args: --all-features 71 | 72 | coverage: 73 | runs-on: ubuntu-latest 74 | name: ubuntu / stable / coverage 75 | steps: 76 | - uses: actions/checkout@v4 77 | with: 78 | submodules: true 79 | - name: Install stable 80 | uses: dtolnay/rust-toolchain@stable 81 | with: 82 | components: llvm-tools-preview 83 | - name: cargo install cargo-llvm-cov 84 | uses: taiki-e/install-action@cargo-llvm-cov 85 | - name: cargo generate-lockfile 86 | if: hashFiles('Cargo.lock') == '' 87 | run: cargo generate-lockfile 88 | - name: cargo llvm-cov 89 | run: 90 | cargo llvm-cov --locked --all-features --lcov --output-path lcov.info 91 | - name: Record Rust version 92 | run: echo "RUST=$(rustc --version)" >> "$GITHUB_ENV" 93 | - name: Upload to codecov.io 94 | uses: codecov/codecov-action@v4 95 | with: 96 | fail_ci_if_error: true 97 | token: ${{ secrets.CODECOV_TOKEN }} 98 | env_vars: OS,RUST 99 | -------------------------------------------------------------------------------- /crates/syntax/src/splitter.rs: -------------------------------------------------------------------------------- 1 | /// The separator used between trees when parsing `.tree` files with multiple 2 | /// trees. 3 | pub(crate) const TREES_SEPARATOR: &str = "\n\n"; 4 | 5 | /// Splits the input text into distinct trees, delimited by two consecutive 6 | /// newlines. 7 | pub(crate) fn split_trees(text: &str) -> Box + '_> { 8 | if text.trim().is_empty() { 9 | return Box::new(std::iter::once("")); 10 | } 11 | 12 | let trees = text.split(TREES_SEPARATOR).map(str::trim); 13 | let non_empty_trees = trees.filter(|s| !s.is_empty()); 14 | let no_isolated_comments = non_empty_trees.filter(not_only_comments); 15 | 16 | Box::new(no_isolated_comments) 17 | } 18 | 19 | /// Return whether the given string only contains lines starting with `//`. 20 | fn not_only_comments(tree: &&str) -> bool { 21 | !tree.lines().all(|l| l.trim().starts_with("//")) 22 | } 23 | 24 | #[cfg(test)] 25 | mod tests { 26 | use super::split_trees; 27 | 28 | #[test] 29 | fn splits_trees() { 30 | let test_cases = vec![ 31 | ("Foo_Test\n└── when something bad happens\n └── it should revert", vec![ 32 | "Foo_Test\n└── when something bad happens\n └── it should revert", 33 | ]), 34 | ("Foo_Test\n└── when something bad happens\n └── it should revert\n\nFoo_Test2\n└── when something bad happens\n └── it should revert", vec![ 35 | "Foo_Test\n└── when something bad happens\n └── it should revert", 36 | "Foo_Test2\n└── when something bad happens\n └── it should revert", 37 | ]), 38 | // Test with varying numbers of newlines between tree splits. 39 | // Assumes behavior is the same for 2 or more newlines. 40 | ("Foo_Test\n└── when something bad happens\n └── it should revert\n\n\nFoo_Test2\n└── when something bad happens\n └── it should revert", vec![ 41 | "Foo_Test\n└── when something bad happens\n └── it should revert", 42 | "Foo_Test2\n└── when something bad happens\n └── it should revert", 43 | ]), 44 | ("Foo_Test\n└── when something bad happens\n └── it should revert\n\n\n\nFoo_Test2\n└── when something bad happens\n └── it should revert", vec![ 45 | "Foo_Test\n└── when something bad happens\n └── it should revert", 46 | "Foo_Test2\n└── when something bad happens\n └── it should revert", 47 | ]), 48 | ("Foo_Test\n└── when something bad happens\n └── it should revert\n\n\n\n\nFoo_Test2\n└── when something bad happens\n └── it should revert", vec![ 49 | "Foo_Test\n└── when something bad happens\n └── it should revert", 50 | "Foo_Test2\n└── when something bad happens\n └── it should revert", 51 | ]), 52 | ]; 53 | 54 | for (input, expected) in test_cases { 55 | let trees = split_trees(input); 56 | let results: Vec<_> = trees.collect(); 57 | assert_eq!(results, expected, "Failed on input: {}", input); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /crates/foundry/src/hir/visitor.rs: -------------------------------------------------------------------------------- 1 | //! Defines a trait for visiting a high-level intermediate representation (HIR) 2 | //! in depth-first order. 3 | 4 | use crate::hir; 5 | 6 | /// A trait for visiting a HIR in depth-first order. 7 | pub trait Visitor { 8 | // TODO: Having one associated type per `visit_*` function scales 9 | // terribly, but for now it's fine. We should use a better abstraction. 10 | /// The result of visiting a `Root`. 11 | type RootOutput; 12 | /// The result of visiting a `ContractDefinition`. 13 | type ContractDefinitionOutput; 14 | /// The result of visiting a `FunctionDefinition`. 15 | type FunctionDefinitionOutput; 16 | /// The result of visiting a `Comment`. 17 | type CommentOutput; 18 | /// The result of visiting a `Statement`. 19 | type StatementOutput; 20 | /// An error that might occur when visiting the HIR. 21 | type Error; 22 | 23 | /// Visits the root node of the HIR. This method is typically where the 24 | /// traversal of the HIR begins. 25 | /// 26 | /// # Arguments 27 | /// * `root` - A reference to the root node of the HIR. 28 | /// 29 | /// # Returns 30 | /// A `Result` containing either the output of visiting the root node or an 31 | /// error. 32 | fn visit_root( 33 | &mut self, 34 | root: &hir::Root, 35 | ) -> Result; 36 | /// Visits a contract definition node within the HIR. 37 | /// 38 | /// # Arguments 39 | /// * `contract` - A reference to the contract definition node in the HIR. 40 | /// 41 | /// # Returns 42 | /// A `Result` containing either the output of visiting the contract 43 | /// definition node or an error. 44 | fn visit_contract( 45 | &mut self, 46 | contract: &hir::ContractDefinition, 47 | ) -> Result; 48 | /// Visits a function definition node within the HIR. 49 | /// 50 | /// # Arguments 51 | /// * `function` - A reference to the function definition node in the HIR. 52 | /// 53 | /// # Returns 54 | /// A `Result` containing either the output of visiting the function 55 | /// definition node or an error. 56 | fn visit_function( 57 | &mut self, 58 | function: &hir::FunctionDefinition, 59 | ) -> Result; 60 | /// Visits a comment node within the HIR. This allows for handling comments 61 | /// in the context of the HIR, potentially for documentation generation 62 | /// or other purposes. 63 | /// 64 | /// # Arguments 65 | /// * `comment` - A reference to the comment node in the HIR. 66 | /// 67 | /// # Returns 68 | /// A `Result` containing either the output of visiting the comment node or 69 | /// an error. 70 | fn visit_comment( 71 | &mut self, 72 | comment: &hir::Comment, 73 | ) -> Result; 74 | 75 | /// Visits a statement node within the HIR. 76 | /// 77 | /// # Arguments 78 | /// * `statement` - A reference to the statement node in the HIR. 79 | /// # Returns 80 | /// A `Result` containing either the output of visiting the statement node 81 | /// or an error. 82 | fn visit_statement( 83 | &mut self, 84 | statement: &hir::Statement, 85 | ) -> Result; 86 | } 87 | -------------------------------------------------------------------------------- /crates/syntax/benches/syntax.rs: -------------------------------------------------------------------------------- 1 | #![allow(missing_docs)] 2 | use std::fs; 3 | 4 | use bulloak_syntax::{parse, parser::Parser, semantics, tokenizer}; 5 | use criterion::{ 6 | black_box, criterion_group, criterion_main, BenchmarkId, Criterion, 7 | Throughput, 8 | }; 9 | 10 | fn load(name: &str) -> String { 11 | let path = format!("benches/bench_data/{}", name); 12 | fs::read_to_string(&path).unwrap() 13 | } 14 | 15 | fn bench_tokenizer(c: &mut Criterion) { 16 | let small = load("small.tree"); 17 | let medium = load("medium.tree"); 18 | let large = load("large.tree"); 19 | let cases = [("small", &small), ("medium", &medium), ("large", &large)]; 20 | 21 | let mut group = c.benchmark_group("tokenizer"); 22 | for (label, text) in &cases { 23 | group.throughput(Throughput::Bytes(text.len() as u64)); 24 | group.bench_with_input( 25 | BenchmarkId::new("tokenize", label), 26 | text, 27 | |b, t| { 28 | b.iter(|| { 29 | tokenizer::Tokenizer::new().tokenize(black_box(t)).unwrap() 30 | }); 31 | }, 32 | ); 33 | } 34 | group.finish(); 35 | } 36 | 37 | fn bench_parser(c: &mut Criterion) { 38 | let medium = load("medium.tree"); 39 | let large = load("large.tree"); 40 | let cases = [("medium", &medium), ("large", &large)]; 41 | 42 | let mut group = c.benchmark_group("parser"); 43 | for (label, text) in &cases { 44 | // pre‐tokenize once 45 | let tokens = tokenizer::Tokenizer::new().tokenize(text).unwrap(); 46 | group.throughput(Throughput::Bytes(text.len() as u64)); 47 | group.bench_with_input( 48 | BenchmarkId::new("parse_only", label), 49 | &(text.as_str(), &tokens[..]), 50 | |b, &(txt, toks)| { 51 | let mut p = Parser::new(); 52 | b.iter(|| { 53 | p.parse(black_box(txt), black_box(toks)).unwrap(); 54 | }); 55 | }, 56 | ); 57 | } 58 | group.finish(); 59 | } 60 | 61 | fn bench_semantics(c: &mut Criterion) { 62 | let large = load("large.tree"); 63 | // Build AST once. 64 | let ast = { 65 | let toks = tokenizer::Tokenizer::new().tokenize(&large).unwrap(); 66 | Parser::new().parse(&large, &toks).unwrap() 67 | }; 68 | let mut group = c.benchmark_group("semantics"); 69 | group.throughput(Throughput::Bytes(large.len() as u64)); 70 | group.bench_function("analyze", |b| { 71 | b.iter(|| { 72 | let mut analyzer = 73 | semantics::SemanticAnalyzer::new(black_box(&large)); 74 | analyzer.analyze(black_box(&ast)).unwrap(); 75 | }) 76 | }); 77 | group.finish(); 78 | } 79 | 80 | fn bench_e2e(c: &mut Criterion) { 81 | let example = load("large.tree"); 82 | let mut group = c.benchmark_group("parse+analyze"); 83 | group.throughput(Throughput::Bytes(example.len() as u64)); 84 | group.bench_function("e2e_parse", |b| { 85 | b.iter(|| { 86 | let _ = parse(black_box(&example)).unwrap(); 87 | }) 88 | }); 89 | group.finish(); 90 | } 91 | 92 | criterion_group!( 93 | benches, 94 | bench_tokenizer, 95 | bench_parser, 96 | bench_semantics, 97 | bench_e2e, 98 | ); 99 | criterion_main!(benches); 100 | -------------------------------------------------------------------------------- /crates/foundry/src/sol/visitor.rs: -------------------------------------------------------------------------------- 1 | //! Visitor helpers to traverse the [solang Solidity Parse 2 | //! Tree](solang_parser::pt). 3 | //! 4 | //! This is based on 5 | //! . 6 | #![allow(unused)] 7 | 8 | use solang_parser::pt::{ 9 | Base, ContractDefinition, ContractPart, ErrorDefinition, ErrorParameter, 10 | EventDefinition, EventParameter, Expression, FunctionAttribute, 11 | FunctionDefinition, Parameter, SourceUnit, SourceUnitPart, Statement, 12 | StructDefinition, TypeDefinition, VariableAttribute, 13 | }; 14 | 15 | /// A trait that is invoked while traversing the Solidity Parse Tree. 16 | /// 17 | /// This is a subset of the original implementation since we don't 18 | /// need most of it here. 19 | pub(crate) trait Visitor { 20 | type Output; 21 | type Error; 22 | 23 | fn visit_source_unit( 24 | &mut self, 25 | _source_unit: &mut SourceUnit, 26 | ) -> Result; 27 | 28 | fn visit_source_unit_part( 29 | &mut self, 30 | part: &mut SourceUnitPart, 31 | ) -> Result; 32 | 33 | fn visit_contract( 34 | &mut self, 35 | contract: &mut ContractDefinition, 36 | ) -> Result; 37 | 38 | fn visit_contract_part( 39 | &mut self, 40 | part: &mut ContractPart, 41 | ) -> Result; 42 | 43 | fn visit_function( 44 | &mut self, 45 | func: &mut FunctionDefinition, 46 | ) -> Result; 47 | 48 | fn visit_function_attribute( 49 | &mut self, 50 | attribute: &mut FunctionAttribute, 51 | ) -> Result; 52 | 53 | fn visit_var_attribute( 54 | &mut self, 55 | attribute: &mut VariableAttribute, 56 | ) -> Result; 57 | 58 | fn visit_base( 59 | &mut self, 60 | base: &mut Base, 61 | ) -> Result; 62 | 63 | fn visit_parameter( 64 | &mut self, 65 | parameter: &mut Parameter, 66 | ) -> Result; 67 | 68 | fn visit_statement( 69 | &mut self, 70 | statement: &mut Statement, 71 | ) -> Result; 72 | 73 | fn visit_expr( 74 | &mut self, 75 | expression: &mut Expression, 76 | ) -> Result; 77 | 78 | fn visit_struct( 79 | &mut self, 80 | structure: &mut StructDefinition, 81 | ) -> Result; 82 | 83 | fn visit_event( 84 | &mut self, 85 | event: &mut EventDefinition, 86 | ) -> Result; 87 | 88 | fn visit_event_parameter( 89 | &mut self, 90 | param: &mut EventParameter, 91 | ) -> Result; 92 | 93 | fn visit_error( 94 | &mut self, 95 | error: &mut ErrorDefinition, 96 | ) -> Result; 97 | 98 | fn visit_error_parameter( 99 | &mut self, 100 | param: &mut ErrorParameter, 101 | ) -> Result; 102 | 103 | fn visit_type_definition( 104 | &mut self, 105 | def: &mut TypeDefinition, 106 | ) -> Result; 107 | } 108 | -------------------------------------------------------------------------------- /crates/syntax/src/span.rs: -------------------------------------------------------------------------------- 1 | //! Locations of a construct in a file. 2 | 3 | use std::{cmp::Ordering, fmt}; 4 | 5 | /// Span represents the position information of a single token. 6 | /// 7 | /// All span positions are absolute char offsets that can be used on the 8 | /// original tree that was parsed. 9 | #[derive(Clone, Copy, Eq, PartialEq, Default)] 10 | pub struct Span { 11 | /// The start char offset. 12 | pub start: Position, 13 | /// The end char offset. 14 | pub end: Position, 15 | } 16 | 17 | impl fmt::Debug for Span { 18 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 19 | write!(f, "Span({:?}, {:?})", self.start, self.end) 20 | } 21 | } 22 | 23 | impl Ord for Span { 24 | fn cmp(&self, other: &Self) -> Ordering { 25 | (&self.start, &self.end).cmp(&(&other.start, &other.end)) 26 | } 27 | } 28 | 29 | impl PartialOrd for Span { 30 | fn partial_cmp(&self, other: &Self) -> Option { 31 | Some(self.cmp(other)) 32 | } 33 | } 34 | 35 | /// A single position. 36 | /// 37 | /// A position encodes one half of a span, and includes the char offset, line 38 | /// number and column number. 39 | #[derive(Clone, Copy, Eq, PartialEq)] 40 | pub struct Position { 41 | /// The absolute offset of this position, starting at `0` from the 42 | /// beginning of the tree. 43 | /// 44 | /// Note that this is a `char` offset, which lets us use it when 45 | /// indexing into the original source string. 46 | pub offset: usize, 47 | /// The line number, starting at `1`. 48 | pub line: usize, 49 | /// The approximate column number, starting at `1`. 50 | pub column: usize, 51 | } 52 | 53 | impl Default for Position { 54 | fn default() -> Self { 55 | Self { offset: usize::default(), line: 1, column: 1 } 56 | } 57 | } 58 | 59 | impl fmt::Debug for Position { 60 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 61 | write!( 62 | f, 63 | "Position(o: {:?}, l: {:?}, c: {:?})", 64 | self.offset, self.line, self.column 65 | ) 66 | } 67 | } 68 | 69 | impl Ord for Position { 70 | fn cmp(&self, other: &Self) -> Ordering { 71 | self.offset.cmp(&other.offset) 72 | } 73 | } 74 | 75 | impl PartialOrd for Position { 76 | fn partial_cmp(&self, other: &Self) -> Option { 77 | Some(self.cmp(other)) 78 | } 79 | } 80 | 81 | impl Span { 82 | /// Create a new span with the given positions. 83 | pub const fn new(start: Position, end: Position) -> Self { 84 | Self { start, end } 85 | } 86 | 87 | /// Create a new span using the given position as the start and end. 88 | pub const fn splat(pos: Position) -> Self { 89 | Self::new(pos, pos) 90 | } 91 | 92 | /// Create a new span by replacing the starting position with the one 93 | /// given. 94 | pub const fn with_start(self, pos: Position) -> Self { 95 | Self { start: pos, ..self } 96 | } 97 | 98 | /// Create a new span by replacing the ending position with the one 99 | /// given. 100 | pub const fn with_end(self, pos: Position) -> Self { 101 | Self { end: pos, ..self } 102 | } 103 | } 104 | 105 | impl Position { 106 | /// Create a new position with the given information. 107 | /// 108 | /// `offset` is the absolute offset of the position, starting at `0` from 109 | /// the beginning of the tree. 110 | /// 111 | /// `line` is the line number, starting at `1`. 112 | /// 113 | /// `column` is the approximate column number, starting at `1`. 114 | pub const fn new(offset: usize, line: usize, column: usize) -> Self { 115 | Self { offset, line, column } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /crates/bulloak/tests/check/invalid_sol_structure.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract CancelTest { 5 | function test_RevertWhen_DelegateCalled() external { 6 | // it should revert 7 | } 8 | 9 | modifier whenNotDelegateCalled() { 10 | _; 11 | } 12 | 13 | function test_RevertGiven_TheIdReferencesANullStream() 14 | external 15 | whenNotDelegateCalled 16 | { 17 | // it should revert 18 | } 19 | 20 | modifier givenTheIdDoesNotReferenceANullStream() { 21 | _; 22 | } 23 | 24 | function test_RevertGiven_TheStreamsStatusIsDEPLETED() 25 | external 26 | whenNotDelegateCalled 27 | givenTheIdDoesNotReferenceANullStream 28 | givenTheStreamIsCold 29 | { 30 | // it should revert 31 | } 32 | 33 | function test_RevertGiven_TheStreamsStatusIsSETTLED() 34 | external 35 | whenNotDelegateCalled 36 | givenTheIdDoesNotReferenceANullStream 37 | givenTheStreamIsCold 38 | { 39 | // it should revert 40 | } 41 | 42 | function test_RevertGiven_TheStreamsStatusIsCANCELED() 43 | external 44 | whenNotDelegateCalled 45 | givenTheIdDoesNotReferenceANullStream 46 | givenTheStreamIsCold 47 | { 48 | // it should revert 49 | } 50 | 51 | modifier givenTheStreamIsWarm() { 52 | _; 53 | } 54 | 55 | modifier whenTheCallerIsAuthorized() { 56 | _; 57 | } 58 | 59 | function test_RevertGiven_TheStreamIsNotCancelable() 60 | external 61 | whenNotDelegateCalled 62 | givenTheIdDoesNotReferenceANullStream 63 | givenTheStreamIsWarm 64 | whenTheCallerIsAuthorized 65 | { 66 | // it should revert 67 | } 68 | 69 | function test_GivenTheSenderIsNotAContract() 70 | external 71 | whenNotDelegateCalled 72 | givenTheIdDoesNotReferenceANullStream 73 | givenTheStreamIsWarm 74 | whenTheCallerIsAuthorized 75 | { 76 | // it should cancel the stream 77 | // it should mark the stream as canceled 78 | } 79 | 80 | modifier givenTheSenderIsAContract() { 81 | _; 82 | } 83 | 84 | function test_GivenTheSenderDoesNotImplementTheHook() 85 | external 86 | whenNotDelegateCalled 87 | givenTheIdDoesNotReferenceANullStream 88 | givenTheStreamIsWarm 89 | whenTheCallerIsAuthorized 90 | givenTheSenderIsAContract 91 | { 92 | // it should cancel the stream 93 | // it should mark the stream as canceled 94 | // it should call the sender hook 95 | // it should ignore the revert 96 | } 97 | 98 | modifier givenTheSenderImplementsTheHook() { 99 | _; 100 | } 101 | 102 | function test_WhenThereIsReentrancy() 103 | external 104 | whenNotDelegateCalled 105 | givenTheIdDoesNotReferenceANullStream 106 | givenTheStreamIsWarm 107 | whenTheCallerIsAuthorized 108 | givenTheSenderIsAContract 109 | givenTheSenderImplementsTheHook 110 | whenTheSenderDoesNotRevert 111 | { 112 | // it should cancel the stream 113 | // it should mark the stream as canceled 114 | // it should call the sender hook 115 | // it should ignore the revert 116 | } 117 | 118 | function test_WhenTheSenderReverts() 119 | external 120 | whenNotDelegateCalled 121 | givenTheIdDoesNotReferenceANullStream 122 | givenTheStreamIsWarm 123 | whenTheCallerIsAuthorized 124 | givenTheSenderIsAContract 125 | givenTheSenderImplementsTheHook 126 | { 127 | // it should cancel the stream 128 | // it should mark the stream as canceled 129 | // it should call the sender hook 130 | // it should ignore the revert 131 | } 132 | 133 | function test_WhenThereIsNoReentrancy() 134 | external 135 | whenNotDelegateCalled 136 | givenTheIdDoesNotReferenceANullStream 137 | givenTheStreamIsWarm 138 | whenTheCallerIsAuthorized 139 | givenTheSenderIsAContract 140 | givenTheSenderImplementsTheHook 141 | whenTheSenderDoesNotRevert 142 | { 143 | // it should cancel the stream 144 | // it should mark the stream as canceled 145 | // it should make the stream not cancelable 146 | // it should update the refunded amount 147 | // it should refund the sender 148 | // it should call the sender hook 149 | // it should emit a {MetadataUpdate} event 150 | // it should emit a {CancelLockupStream} event 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /crates/foundry/src/hir/hir.rs: -------------------------------------------------------------------------------- 1 | //! Defines a high-level intermediate representation (HIR). 2 | 3 | use bulloak_syntax::Span; 4 | 5 | /// A high-level intermediate representation (HIR) that describes 6 | /// the semantic structure of a Solidity contract as emitted by `bulloak`. 7 | #[derive(Debug, Clone, PartialEq, Eq)] 8 | pub enum Hir { 9 | /// An abstract root node that does not correspond 10 | /// to any concrete Solidity construct. 11 | /// 12 | /// This is used as a sort of "file" boundary since it 13 | /// is easier to express file-level Solidity constraints, 14 | /// like the pragma directive. 15 | /// 16 | /// Note that this means that there can only be a single 17 | /// root node in any HIR. 18 | Root(Root), 19 | /// A contract definition. 20 | Contract(ContractDefinition), 21 | /// A function definition. 22 | Function(FunctionDefinition), 23 | /// A comment. 24 | Comment(Comment), 25 | /// A Statement. 26 | Statement(Statement), 27 | } 28 | 29 | impl Hir { 30 | /// Returns the first contract object found starting from a root or a 31 | /// contract definition if it exists. 32 | #[must_use] 33 | pub fn find_contract(&self) -> Option<&ContractDefinition> { 34 | match self { 35 | Hir::Root(root) => root.find_contract(), 36 | Hir::Contract(contract) => Some(contract), 37 | _ => None, 38 | } 39 | } 40 | 41 | /// Whether this hir is a root. 42 | pub fn is_root(&self) -> bool { 43 | matches!(self, Hir::Root(_)) 44 | } 45 | 46 | /// Whether this hir is a contract definition. 47 | pub fn is_contract(&self) -> bool { 48 | matches!(self, Hir::Contract(_)) 49 | } 50 | } 51 | 52 | impl Default for Hir { 53 | fn default() -> Self { 54 | Self::Root(Root::default()) 55 | } 56 | } 57 | 58 | type Identifier = String; 59 | 60 | /// The root HIR node. 61 | /// 62 | /// There can only be one root node in any HIR. 63 | #[derive(Debug, Clone, PartialEq, Eq, Default)] 64 | pub struct Root { 65 | /// The children HIR nodes of this node. 66 | pub children: Vec, 67 | } 68 | 69 | impl Root { 70 | pub(crate) fn find_contract(&self) -> Option<&ContractDefinition> { 71 | self.children.iter().find_map(|child| match child { 72 | Hir::Contract(contract) => Some(contract), 73 | _ => None, 74 | }) 75 | } 76 | } 77 | 78 | /// A contract definition HIR node. 79 | #[derive(Debug, Clone, PartialEq, Eq, Default)] 80 | pub struct ContractDefinition { 81 | /// The contract name. 82 | pub identifier: Identifier, 83 | /// The children HIR nodes of this node. 84 | pub children: Vec, 85 | } 86 | 87 | /// A function's type. 88 | /// 89 | /// Currently, we only care about regular functions (tests) 90 | /// and modifier functions. 91 | #[derive(Debug, Clone, PartialEq, Eq)] 92 | pub enum FunctionTy { 93 | /// `function` 94 | Function, 95 | /// `modifier` 96 | Modifier, 97 | } 98 | 99 | impl Default for FunctionTy { 100 | fn default() -> Self { 101 | Self::Function 102 | } 103 | } 104 | 105 | /// A function definition HIR node. 106 | #[derive(Debug, Clone, PartialEq, Eq, Default)] 107 | pub struct FunctionDefinition { 108 | /// The function name. 109 | pub identifier: Identifier, 110 | /// The type of this function. 111 | pub ty: FunctionTy, 112 | /// The span of the branch that generated this 113 | /// function. 114 | pub span: Span, 115 | /// The set of modifiers applied to this function. 116 | /// 117 | /// `None` if the function's type is 118 | /// `FunctionTy::Modifier`. 119 | pub modifiers: Option>, 120 | /// The children HIR nodes of this node. 121 | pub children: Option>, 122 | } 123 | 124 | impl FunctionDefinition { 125 | /// Whether a function's type is `Modifier`. 126 | #[must_use] 127 | pub fn is_modifier(&self) -> bool { 128 | matches!(self.ty, FunctionTy::Modifier) 129 | } 130 | 131 | /// Whether a function's type is `Modifier`. 132 | #[must_use] 133 | pub fn is_function(&self) -> bool { 134 | matches!(self.ty, FunctionTy::Function) 135 | } 136 | } 137 | 138 | /// A comment node. 139 | #[derive(Debug, Clone, PartialEq, Eq, Default)] 140 | pub struct Comment { 141 | /// The contract name. 142 | pub lexeme: String, 143 | } 144 | 145 | /// The statements which are currently supported. 146 | #[derive(Debug, Clone, PartialEq, Eq)] 147 | pub enum StatementType { 148 | /// The `vm.skip(true);` statement. 149 | VmSkip, 150 | } 151 | 152 | /// A statement node. 153 | #[derive(Debug, Clone, PartialEq, Eq)] 154 | pub struct Statement { 155 | /// The statement. 156 | pub ty: StatementType, 157 | } 158 | -------------------------------------------------------------------------------- /crates/syntax/src/utils.rs: -------------------------------------------------------------------------------- 1 | //! Various-string manipulation utilities. 2 | 3 | use unicode_xid::UnicodeXID; 4 | 5 | /// Capitalizes the first letter of a given string. 6 | /// 7 | /// This function takes a string slice and returns a new `String` with the first 8 | /// letter capitalized. If the string is empty, it returns an empty string. 9 | /// 10 | /// # Arguments 11 | /// 12 | /// * `s` - A string slice that holds the input string 13 | /// 14 | /// # Returns 15 | /// 16 | /// A `String` with the first letter capitalized 17 | /// 18 | /// # Examples 19 | /// 20 | /// ``` 21 | /// # use bulloak_syntax::utils::upper_first_letter; 22 | /// let result = upper_first_letter("hello"); 23 | /// assert_eq!(result, "Hello"); 24 | /// ``` 25 | pub fn upper_first_letter(s: &str) -> String { 26 | let mut c = s.chars(); 27 | c.next() 28 | .map(char::to_uppercase) 29 | .map(|first| first.to_string() + c.as_str()) 30 | .unwrap_or_default() 31 | } 32 | 33 | /// Converts the first letter of a given string to lowercase. 34 | /// 35 | /// This function takes a string slice and returns a new `String` with the first 36 | /// letter in lowercase. If the string is empty, it returns an empty string. 37 | /// 38 | /// # Arguments 39 | /// 40 | /// * `s` - A string slice that holds the input string 41 | /// 42 | /// # Returns 43 | /// 44 | /// A `String` with the first letter in lowercase 45 | /// 46 | /// # Examples 47 | /// 48 | /// ``` 49 | /// # use bulloak_syntax::utils::lower_first_letter; 50 | /// let result = lower_first_letter("Hello"); 51 | /// assert_eq!(result, "hello"); 52 | /// ``` 53 | pub fn lower_first_letter(s: &str) -> String { 54 | let mut c = s.chars(); 55 | c.next() 56 | .map(char::to_lowercase) 57 | .map(|first| first.to_string() + c.as_str()) 58 | .unwrap_or_default() 59 | } 60 | 61 | /// Sanitizes a string to make it a valid identifier. 62 | /// 63 | /// This function replaces hyphens with underscores and removes any characters 64 | /// that are not valid in an identifier according to the Unicode Standard Annex 65 | /// #31. 66 | /// 67 | /// # Arguments 68 | /// 69 | /// * `identifier` - A string slice that holds the input identifier 70 | /// 71 | /// # Returns 72 | /// 73 | /// A `String` containing the sanitized identifier 74 | /// 75 | /// # Examples 76 | /// 77 | /// ``` 78 | /// # use bulloak_syntax::utils::sanitize; 79 | /// let result = sanitize("my-variable@123"); 80 | /// assert_eq!(result, "my_variable123"); 81 | /// ``` 82 | pub fn sanitize(identifier: &str) -> String { 83 | identifier 84 | .replace('-', "_") 85 | .replace(|c: char| !c.is_xid_continue() && c != ' ', "") 86 | } 87 | 88 | /// Converts a sentence to pascal case. 89 | /// 90 | /// The conversion is done by capitalizing the first letter of each word 91 | /// in the title and removing the spaces. For example, the sentence 92 | /// `when only owner` is converted to the `WhenOnlyOwner` string. 93 | /// 94 | /// # Arguments 95 | /// 96 | /// * `sentence` - A string slice that holds the input sentence 97 | /// 98 | /// # Returns 99 | /// 100 | /// A `String` in pascal case 101 | /// 102 | /// # Examples 103 | /// 104 | /// ``` 105 | /// # use bulloak_syntax::utils::to_pascal_case; 106 | /// let result = to_pascal_case("when only owner"); 107 | /// assert_eq!(result, "WhenOnlyOwner"); 108 | /// ``` 109 | pub fn to_pascal_case(sentence: &str) -> String { 110 | sentence.split_whitespace().map(upper_first_letter).collect::() 111 | } 112 | 113 | /// Repeats a given string a specified number of times. 114 | /// 115 | /// # Arguments 116 | /// 117 | /// * `s` - A string slice to be repeated 118 | /// * `n` - The number of times to repeat the string 119 | /// 120 | /// # Returns 121 | /// 122 | /// A `String` containing the repeated string 123 | /// 124 | /// # Examples 125 | /// 126 | /// ``` 127 | /// # use bulloak_syntax::utils::repeat_str; 128 | /// let result = repeat_str("abc", 3); 129 | /// assert_eq!(result, "abcabcabc"); 130 | /// ``` 131 | pub fn repeat_str(s: &str, n: usize) -> String { 132 | s.repeat(n) 133 | } 134 | 135 | /// Returns the singular or plural form of a word based on the count. 136 | /// 137 | /// # Arguments 138 | /// 139 | /// * `count` - The count to determine which form to use 140 | /// * `singular` - The singular form of the word 141 | /// * `plural` - The plural form of the word 142 | /// 143 | /// # Returns 144 | /// 145 | /// A string slice containing either the singular or plural form 146 | /// 147 | /// # Examples 148 | /// 149 | /// ``` 150 | /// # use bulloak_syntax::utils::pluralize; 151 | /// assert_eq!(pluralize(1, "apple", "apples"), "apple"); 152 | /// assert_eq!(pluralize(2, "apple", "apples"), "apples"); 153 | /// ``` 154 | pub fn pluralize<'a>( 155 | count: usize, 156 | singular: &'a str, 157 | plural: &'a str, 158 | ) -> &'a str { 159 | if count == 1 { 160 | singular 161 | } else { 162 | plural 163 | } 164 | } 165 | 166 | #[cfg(test)] 167 | mod tests { 168 | use super::to_pascal_case; 169 | 170 | #[test] 171 | fn to_modifier() { 172 | assert_eq!(to_pascal_case("when only owner"), "WhenOnlyOwner"); 173 | assert_eq!(to_pascal_case("when"), "When"); 174 | assert_eq!(to_pascal_case(""), ""); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /crates/syntax/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::{cmp, fmt}; 2 | 3 | use crate::{span::Span, utils::repeat_str}; 4 | 5 | /// A trait for representing frontend errors in the `bulloak-syntax` crate. 6 | /// 7 | /// This trait is implemented by various error types in the crate to provide 8 | /// a consistent interface for error handling and formatting. 9 | pub trait FrontendError: std::error::Error { 10 | /// Return the type of this error. 11 | #[must_use] 12 | fn kind(&self) -> &K; 13 | 14 | /// The original text string in which this error occurred. 15 | #[must_use] 16 | fn text(&self) -> &str; 17 | 18 | /// Return the span at which this error occurred. 19 | #[must_use] 20 | fn span(&self) -> &Span; 21 | 22 | /// Formats the error message with additional context. 23 | /// 24 | /// This method provides a default implementation that creates a formatted 25 | /// error message including the error kind, the relevant text, and visual 26 | /// indicators of where the error occurred. 27 | /// 28 | /// # Arguments 29 | /// * `f` - A mutable reference to a `fmt::Formatter`. 30 | /// 31 | /// # Returns 32 | /// A `fmt::Result` indicating whether the formatting was successful. 33 | fn format_error(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result { 34 | let divider = repeat_str("•", 79); 35 | writeln!(f, "{divider}")?; 36 | 37 | let start_offset = self.span().start.offset; 38 | let end_offset = self.span().end.offset; 39 | if start_offset == end_offset && start_offset == 0 { 40 | write!(f, "bulloak error: {}", self.kind())?; 41 | return Ok(()); 42 | } 43 | 44 | writeln!(f, "bulloak error: {}\n", self.kind())?; 45 | let notated = self.notate(); 46 | writeln!(f, "{notated}")?; 47 | writeln!( 48 | f, 49 | "--- (line {}, column {}) ---", 50 | self.span().start.line, 51 | self.span().start.column 52 | )?; 53 | Ok(()) 54 | } 55 | 56 | /// Creates a string with carets (^) pointing at the span where the error 57 | /// occurred. 58 | /// 59 | /// This method provides a visual representation of where in the text the 60 | /// error was found. 61 | /// 62 | /// # Returns 63 | /// A `String` containing the relevant line of text with carets underneath. 64 | fn notate(&self) -> String { 65 | let mut notated = String::new(); 66 | if let Some(line) = self.text().lines().nth(self.span().start.line - 1) 67 | { 68 | notated.push_str(line); 69 | notated.push('\n'); 70 | notated.push_str(&repeat_str(" ", self.span().start.column - 1)); 71 | let note_len = 72 | self.span().end.column.saturating_sub(self.span().start.column) 73 | + 1; 74 | let note_len = cmp::max(1, note_len); 75 | notated.push_str(&repeat_str("^", note_len)); 76 | notated.push('\n'); 77 | } 78 | 79 | notated 80 | } 81 | } 82 | 83 | #[cfg(test)] 84 | mod test { 85 | use std::fmt; 86 | 87 | use pretty_assertions::assert_eq; 88 | use thiserror::Error; 89 | 90 | use super::{repeat_str, FrontendError}; 91 | use crate::span::{Position, Span}; 92 | 93 | #[derive(Error, Clone, Debug, Eq, PartialEq)] 94 | struct Error { 95 | #[source] 96 | kind: ErrorKind, 97 | text: String, 98 | span: Span, 99 | } 100 | 101 | #[derive(Error, Clone, Debug, Eq, PartialEq)] 102 | #[non_exhaustive] 103 | enum ErrorKind { 104 | #[error("unexpected token '{0}'")] 105 | TokenUnexpected(String), 106 | } 107 | 108 | impl FrontendError for Error { 109 | fn kind(&self) -> &ErrorKind { 110 | &self.kind 111 | } 112 | 113 | fn text(&self) -> &str { 114 | &self.text 115 | } 116 | 117 | fn span(&self) -> &Span { 118 | &self.span 119 | } 120 | } 121 | 122 | impl fmt::Display for Error { 123 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 124 | self.format_error(f) 125 | } 126 | } 127 | 128 | #[test] 129 | fn test_notate() { 130 | let err = Error { 131 | kind: ErrorKind::TokenUnexpected("world".to_owned()), 132 | text: "hello\nworld\n".to_owned(), 133 | span: Span::new(Position::new(0, 2, 1), Position::new(4, 2, 5)), 134 | }; 135 | let notated = format!("{}", err); 136 | 137 | let mut expected = String::from(""); 138 | expected.push_str(&repeat_str("•", 79)); 139 | expected.push('\n'); 140 | expected 141 | .push_str(format!("bulloak error: {}\n\n", err.kind()).as_str()); 142 | expected.push_str("world\n"); 143 | expected.push_str("^^^^^\n\n"); 144 | expected.push_str( 145 | format!( 146 | "--- (line {}, column {}) ---\n", 147 | err.span().start.line, 148 | err.span().start.column 149 | ) 150 | .as_str(), 151 | ); 152 | assert_eq!(expected, notated); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /docs/src/components/TreesAnimation.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useEffect, useRef } from "react"; 3 | 4 | interface Tree { 5 | x: number; 6 | y: number; 7 | angle: number; 8 | maxLevels: number; 9 | color: string; 10 | currentLevel: number; 11 | age: number; 12 | height: number; 13 | branches: Branch[]; 14 | } 15 | 16 | interface Branch { 17 | startX: number; 18 | startY: number; 19 | endX: number; 20 | endY: number; 21 | level: number; 22 | } 23 | 24 | const AsciiTreeAnimation: React.FC = () => { 25 | const canvasRef = useRef(null); 26 | 27 | useEffect(() => { 28 | const canvas = canvasRef.current; 29 | if (!canvas) return; 30 | 31 | const ctx = canvas.getContext("2d"); 32 | if (!ctx) return; 33 | 34 | let trees: Tree[] = []; 35 | let maxTrees: number; 36 | 37 | const colors = [ 38 | "#F9D5E5", // Soft Pink 39 | "#EDE7B1", // Pale Yellow 40 | "#A9D7DA", // Light Sky Blue 41 | "#B8E0D2", // Mint Green 42 | "#D6A2AD", // Dusty Rose 43 | "#F1E0C5", // Sand 44 | "#C7CEEA", // Periwinkle 45 | "#F1C0B9", // Peach 46 | "#A2D2FF", // Baby Blue 47 | "#FFD8BE", // Apricot 48 | "#E8D0B3", // Wheat 49 | "#B5D8CC", // Sea Foam 50 | ]; 51 | 52 | const resizeCanvas = () => { 53 | canvas.width = window.innerWidth; 54 | canvas.height = window.innerHeight; 55 | maxTrees = Math.max(3, Math.floor(canvas.width / 150)); 56 | trees = trees.slice(0, maxTrees); 57 | }; 58 | 59 | resizeCanvas(); 60 | window.addEventListener("resize", resizeCanvas); 61 | 62 | const generateTree = (): Tree => { 63 | const maxLevels = 13; 64 | const height = canvas.height * (0.2 + Math.random() * 0.3); 65 | const x = Math.random() * canvas.width; 66 | const y = canvas.height; 67 | return { 68 | x, 69 | y, 70 | angle: 10 + Math.random() * 30, 71 | maxLevels, 72 | color: colors[Math.floor(Math.random() * colors.length)], 73 | currentLevel: 0, 74 | age: 0, 75 | height, 76 | branches: [ 77 | { 78 | startX: x, 79 | startY: y, 80 | endX: x + Math.random() * 100 - 50, 81 | endY: y - height * 0.3, 82 | level: 0, 83 | }, 84 | ], 85 | }; 86 | }; 87 | 88 | const branchGrow = ( 89 | tree: Tree, 90 | startX: number, 91 | startY: number, 92 | h: number, 93 | angle: number, 94 | level: number, 95 | ) => { 96 | if (level >= tree.currentLevel) return; 97 | 98 | const endX = startX + Math.sin(angle) * h; 99 | const endY = startY - Math.cos(angle) * h; 100 | 101 | tree.branches.push({ startX, startY, endX, endY, level }); 102 | 103 | const newH = h * 0.6 * (1 + Math.random() * 0.6); 104 | const newLevel = level + 1; 105 | 106 | const rangleSign = Math.random() > 0.5 ? 1 : -1; 107 | const langleSign = Math.random() > 0.5 ? 1 : -1; 108 | const rangleDelta = 109 | ((tree.angle * Math.PI) / 180) * (0.5 + Math.random() * 0.7); 110 | const langleDelta = 111 | ((tree.angle * Math.PI) / 180) * (0.5 + Math.random() * 0.7); 112 | const rangle = angle + rangleSign * rangleDelta; 113 | const langle = angle + langleSign * langleDelta; 114 | 115 | const growRightBranch = Math.random() > 0.2; 116 | const growLeftBranch = Math.random() > 0.2; 117 | if (growRightBranch) { 118 | branchGrow(tree, endX, endY, newH, rangle, newLevel); 119 | } 120 | if (growLeftBranch) { 121 | branchGrow(tree, endX, endY, newH, langle, newLevel); 122 | } 123 | }; 124 | 125 | const drawTree = (tree: Tree) => { 126 | ctx.strokeStyle = tree.color; 127 | ctx.globalAlpha = 0.05; 128 | ctx.lineWidth = 1; 129 | 130 | tree.branches.forEach((branch) => { 131 | if (branch.level <= tree.currentLevel) { 132 | ctx.beginPath(); 133 | ctx.moveTo(branch.startX, branch.startY); 134 | ctx.lineTo(branch.endX, branch.endY); 135 | ctx.stroke(); 136 | } 137 | }); 138 | }; 139 | 140 | const updateAndDraw = (deltaTime: number) => { 141 | if (trees.length < maxTrees && Math.random() < 0.03) { 142 | trees.push(generateTree()); 143 | } 144 | 145 | trees = trees.filter((tree) => { 146 | tree.age += deltaTime; 147 | if (tree.age > 500 && tree.currentLevel < tree.maxLevels) { 148 | tree.currentLevel++; 149 | tree.age = 0; 150 | const trunk = tree.branches[0]; 151 | branchGrow( 152 | tree, 153 | trunk.endX, 154 | trunk.endY, 155 | tree.height * 0.3 * 0.8, 156 | 0, 157 | 1, 158 | ); 159 | } 160 | drawTree(tree); 161 | return tree.age < 20000 || tree.currentLevel < tree.maxLevels; 162 | }); 163 | }; 164 | 165 | let lastTime = 0; 166 | const animate = (currentTime: number) => { 167 | const deltaTime = currentTime - lastTime; 168 | lastTime = currentTime; 169 | 170 | updateAndDraw(deltaTime); 171 | requestAnimationFrame(animate); 172 | }; 173 | 174 | requestAnimationFrame(animate); 175 | 176 | return () => { 177 | window.removeEventListener("resize", resizeCanvas); 178 | }; 179 | }, []); 180 | 181 | return ( 182 |
183 | 184 |
185 | ); 186 | }; 187 | 188 | export default AsciiTreeAnimation; 189 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/complex.tree: -------------------------------------------------------------------------------- 1 | CancelTest 2 | ├── when delegate called 3 | │ └── it should revert 4 | └── when not delegate called 5 | ├── given the id references a null stream 6 | │ └── it should revert 7 | └── given the id does not reference a null stream 8 | ├── given the stream is cold 9 | │ ├── given the stream's status is "DEPLETED" 10 | │ │ └── it should revert 11 | │ ├── given the stream's status is "CANCELED" 12 | │ │ └── it should revert 13 | │ └── given the stream's status is "SETTLED" 14 | │ └── it should revert 15 | └── given the stream is warm 16 | ├── when the caller is unauthorized 17 | │ ├── when the caller is a malicious third party 18 | │ │ └── it should revert 19 | │ ├── when the caller is an approved third party 20 | │ │ └── it should revert 21 | │ └── when the caller is a former recipient 22 | │ └── it should revert 23 | └── when the caller is authorized 24 | ├── given the stream is not cancelable 25 | │ └── it should revert 26 | └── given the stream is cancelable 27 | ├── given the stream's status is "PENDING" 28 | │ ├── it should cancel the stream 29 | │ ├── it should mark the stream as depleted 30 | │ └── it should make the stream not cancelable 31 | └── given the stream's status is "STREAMING" 32 | ├── when the caller is the sender 33 | │ ├── given the recipient is not a contract 34 | │ │ ├── it should cancel the stream 35 | │ │ └── it should mark the stream as canceled 36 | │ └── given the recipient is a contract 37 | │ ├── given the recipient does not implement the hook 38 | │ │ ├── it should cancel the stream 39 | │ │ ├── it should mark the stream as canceled 40 | │ │ ├── it should call the recipient hook 41 | │ │ └── it should ignore the revert 42 | │ └── given the recipient implements the hook 43 | │ ├── when the recipient reverts 44 | │ │ ├── it should cancel the stream 45 | │ │ ├── it should mark the stream as canceled 46 | │ │ ├── it should call the recipient hook 47 | │ │ └── it should ignore the revert 48 | │ └── when the recipient does not revert 49 | │ ├── when there is reentrancy 1 50 | │ │ ├── it should cancel the stream 51 | │ │ ├── it should mark the stream as canceled 52 | │ │ ├── it should call the recipient hook 53 | │ │ └── it should ignore the revert 54 | │ └── when there is no reentrancy 1 55 | │ ├── it should cancel the stream 56 | │ ├── it should mark the stream as canceled 57 | │ ├── it should make the stream not cancelable 58 | │ ├── it should update the refunded amount 59 | │ ├── it should refund the sender 60 | │ ├── it should call the recipient hook 61 | │ ├── it should emit a {CancelLockupStream} event 62 | │ └── it should emit a {MetadataUpdate} event 63 | └── when the caller is the recipient 64 | ├── given the sender is not a contract 65 | │ ├── it should cancel the stream 66 | │ └── it should mark the stream as canceled 67 | └── given the sender is a contract 68 | ├── given the sender does not implement the hook 69 | │ ├── it should cancel the stream 70 | │ ├── it should mark the stream as canceled 71 | │ ├── it should call the sender hook 72 | │ └── it should ignore the revert 73 | └── given the sender implements the hook 74 | ├── when the sender reverts 75 | │ ├── it should cancel the stream 76 | │ ├── it should mark the stream as canceled 77 | │ ├── it should call the sender hook 78 | │ └── it should ignore the revert 79 | └── when the sender does not revert 80 | ├── when there is reentrancy 2 81 | │ ├── it should cancel the stream 82 | │ ├── it should mark the stream as canceled 83 | │ ├── it should call the sender hook 84 | │ └── it should ignore the revert 85 | └── when there is no reentrancy 2 86 | ├── it should cancel the stream 87 | ├── it should mark the stream as canceled 88 | ├── it should make the stream not cancelable 89 | ├── it should update the refunded amount 90 | ├── it should refund the sender 91 | ├── it should call the sender hook 92 | ├── it should emit a {MetadataUpdate} event 93 | └── it should emit a {CancelLockupStream} event 94 | -------------------------------------------------------------------------------- /crates/bulloak/benches/bench_data/cancel.tree: -------------------------------------------------------------------------------- 1 | // Tree taken from the Sablier v2-core repo. 2 | cancel.t.sol 3 | ├── when delegate called 4 | │ └── it should revert 5 | └── when not delegate called 6 | ├── when the id references a null stream 7 | │ └── it should revert 8 | └── when the id does not reference a null stream 9 | ├── when the stream is cold 10 | │ ├── when the stream's status is "DEPLETED" 11 | │ │ └── it should revert 12 | │ ├── when the stream's status is "CANCELED" 13 | │ │ └── it should revert 14 | │ └── when the stream's status is "SETTLED" 15 | │ └── it should revert 16 | └── when the stream is warm 17 | ├── when the caller is unauthorized 18 | │ ├── when the caller is a malicious third party 19 | │ │ └── it should revert 20 | │ ├── when the caller is an approved third party 21 | │ │ └── it should revert 22 | │ └── when the caller is a former recipient 23 | │ └── it should revert 24 | └── when the caller is authorized 25 | ├── when the stream is not cancelable 26 | │ └── it should revert 27 | └── when the stream is cancelable 28 | ├── when the stream's status is "PENDING" 29 | │ ├── it should cancel the stream 30 | │ ├── it should mark the stream as depleted 31 | │ └── it should make the stream not cancelable 32 | └── when the stream's status is "STREAMING" 33 | ├── when the caller is the sender 34 | │ ├── when the recipient is not a contract 35 | │ │ ├── it should cancel the stream 36 | │ │ └── it should mark the stream as canceled 37 | │ └── when the recipient is a contract 38 | │ ├── when the recipient does not implement the hook 39 | │ │ ├── it should cancel the stream 40 | │ │ ├── it should mark the stream as canceled 41 | │ │ ├── it should call the recipient hook 42 | │ │ └── it should ignore the revert 43 | │ └── when the recipient implements the hook 44 | │ ├── when the recipient reverts 45 | │ │ ├── it should cancel the stream 46 | │ │ ├── it should mark the stream as canceled 47 | │ │ ├── it should call the recipient hook 48 | │ │ └── it should ignore the revert 49 | │ └── when the recipient does not revert 50 | │ ├── when there is reentrancy 51 | │ │ ├── it should cancel the stream 52 | │ │ ├── it should mark the stream as canceled 53 | │ │ ├── it should call the recipient hook 54 | │ │ └── it should ignore the revert 55 | │ └── when there is no reentrancy 56 | │ ├── it should cancel the stream 57 | │ ├── it should mark the stream as canceled 58 | │ ├── it should make the stream not cancelable 59 | │ ├── it should update the refunded amount 60 | │ ├── it should refund the sender 61 | │ ├── it should call the recipient hook 62 | │ ├── it should emit a {CancelLockupStream} event 63 | │ └── it should emit a {MetadataUpdate} event 64 | └── when the caller is the recipient 65 | ├── when the sender is not a contract 66 | │ ├── it should cancel the stream 67 | │ └── it should mark the stream as canceled 68 | └── when the sender is a contract 69 | ├── when the sender does not implement the hook 70 | │ ├── it should cancel the stream 71 | │ ├── it should mark the stream as canceled 72 | │ ├── it should call the sender hook 73 | │ └── it should ignore the revert 74 | └── when the sender implements the hook 75 | ├── when the sender reverts 76 | │ ├── it should cancel the stream 77 | │ ├── it should mark the stream as canceled 78 | │ ├── it should call the sender hook 79 | │ └── it should ignore the revert 80 | └── when the sender does not revert 81 | ├── when there is reentrancy 82 | │ ├── it should cancel the stream 83 | │ ├── it should mark the stream as canceled 84 | │ ├── it should call the sender hook 85 | │ └── it should ignore the revert 86 | └── when there is no reentrancy 87 | ├── it should cancel the stream 88 | ├── it should mark the stream as canceled 89 | ├── it should make the stream not cancelable 90 | ├── it should update the refunded amount 91 | ├── it should refund the sender 92 | ├── it should call the sender hook 93 | ├── it should emit a {MetadataUpdate} event 94 | └── it should emit a {CancelLockupStream} event 95 | -------------------------------------------------------------------------------- /crates/syntax/benches/bench_data/large.tree: -------------------------------------------------------------------------------- 1 | // Tree taken from the Sablier v2-core repo. 2 | cancel.t.sol 3 | ├── when delegate called 4 | │ └── it should revert 5 | └── when not delegate called 6 | ├── when the id references a null stream 7 | │ └── it should revert 8 | └── when the id does not reference a null stream 9 | ├── when the stream is cold 10 | │ ├── when the stream's status is "DEPLETED" 11 | │ │ └── it should revert 12 | │ ├── when the stream's status is "CANCELED" 13 | │ │ └── it should revert 14 | │ └── when the stream's status is "SETTLED" 15 | │ └── it should revert 16 | └── when the stream is warm 17 | ├── when the caller is unauthorized 18 | │ ├── when the caller is a malicious third party 19 | │ │ └── it should revert 20 | │ ├── when the caller is an approved third party 21 | │ │ └── it should revert 22 | │ └── when the caller is a former recipient 23 | │ └── it should revert 24 | └── when the caller is authorized 25 | ├── when the stream is not cancelable 26 | │ └── it should revert 27 | └── when the stream is cancelable 28 | ├── when the stream's status is "PENDING" 29 | │ ├── it should cancel the stream 30 | │ ├── it should mark the stream as depleted 31 | │ └── it should make the stream not cancelable 32 | └── when the stream's status is "STREAMING" 33 | ├── when the caller is the sender 34 | │ ├── when the recipient is not a contract 35 | │ │ ├── it should cancel the stream 36 | │ │ └── it should mark the stream as canceled 37 | │ └── when the recipient is a contract 38 | │ ├── when the recipient does not implement the hook 39 | │ │ ├── it should cancel the stream 40 | │ │ ├── it should mark the stream as canceled 41 | │ │ ├── it should call the recipient hook 42 | │ │ └── it should ignore the revert 43 | │ └── when the recipient implements the hook 44 | │ ├── when the recipient reverts 45 | │ │ ├── it should cancel the stream 46 | │ │ ├── it should mark the stream as canceled 47 | │ │ ├── it should call the recipient hook 48 | │ │ └── it should ignore the revert 49 | │ └── when the recipient does not revert 50 | │ ├── when there is reentrancy 51 | │ │ ├── it should cancel the stream 52 | │ │ ├── it should mark the stream as canceled 53 | │ │ ├── it should call the recipient hook 54 | │ │ └── it should ignore the revert 55 | │ └── when there is no reentrancy 56 | │ ├── it should cancel the stream 57 | │ ├── it should mark the stream as canceled 58 | │ ├── it should make the stream not cancelable 59 | │ ├── it should update the refunded amount 60 | │ ├── it should refund the sender 61 | │ ├── it should call the recipient hook 62 | │ ├── it should emit a {CancelLockupStream} event 63 | │ └── it should emit a {MetadataUpdate} event 64 | └── when the caller is the recipient 65 | ├── when the sender is not a contract 66 | │ ├── it should cancel the stream 67 | │ └── it should mark the stream as canceled 68 | └── when the sender is a contract 69 | ├── when the sender does not implement the hook 70 | │ ├── it should cancel the stream 71 | │ ├── it should mark the stream as canceled 72 | │ ├── it should call the sender hook 73 | │ └── it should ignore the revert 74 | └── when the sender implements the hook 75 | ├── when the sender reverts 76 | │ ├── it should cancel the stream 77 | │ ├── it should mark the stream as canceled 78 | │ ├── it should call the sender hook 79 | │ └── it should ignore the revert 80 | └── when the sender does not revert 81 | ├── when there is reentrancy 82 | │ ├── it should cancel the stream 83 | │ ├── it should mark the stream as canceled 84 | │ ├── it should call the sender hook 85 | │ └── it should ignore the revert 86 | └── when there is no reentrancy 87 | ├── it should cancel the stream 88 | ├── it should mark the stream as canceled 89 | ├── it should make the stream not cancelable 90 | ├── it should update the refunded amount 91 | ├── it should refund the sender 92 | ├── it should call the sender hook 93 | ├── it should emit a {MetadataUpdate} event 94 | └── it should emit a {CancelLockupStream} event 95 | -------------------------------------------------------------------------------- /crates/bulloak/src/scaffold.rs: -------------------------------------------------------------------------------- 1 | //! Defines the `bulloak scaffold` command. 2 | //! 3 | //! This command scaffolds a Solidity file from a spec `.tree` file. 4 | 5 | use std::{ 6 | fs, 7 | path::{Path, PathBuf}, 8 | }; 9 | 10 | use bulloak_foundry::{constants::DEFAULT_SOL_VERSION, scaffold::scaffold}; 11 | use clap::Parser; 12 | use forge_fmt::fmt; 13 | use owo_colors::OwoColorize; 14 | use serde::{Deserialize, Serialize}; 15 | 16 | use crate::{cli::Cli, glob::expand_glob}; 17 | 18 | /// Generate Solidity tests based on your spec. 19 | #[doc(hidden)] 20 | #[derive(Parser, Debug, Clone, Serialize, Deserialize)] 21 | pub struct Scaffold { 22 | /// The set of tree files to generate from. 23 | /// 24 | /// Each Solidity file will be named after its matching 25 | /// tree spec. 26 | pub files: Vec, 27 | /// Whether to write to files instead of stdout. 28 | /// 29 | /// This will write the output for each input file to the file 30 | /// specified at the root of the input file if the output file 31 | /// doesn't already exist. To overwrite, use `--force-write` 32 | /// together with `--write-files`. 33 | #[arg(short = 'w', long, group = "file-handling", default_value_t = false)] 34 | pub write_files: bool, 35 | /// When `--write-files` is passed, use `--force-write` to 36 | /// overwrite the output files. 37 | #[arg( 38 | short = 'f', 39 | long, 40 | requires = "file-handling", 41 | default_value_t = false 42 | )] 43 | pub force_write: bool, 44 | /// Sets a Solidity version for the test contracts. 45 | #[arg(short = 's', long, default_value = DEFAULT_SOL_VERSION)] 46 | pub solidity_version: String, 47 | /// Whether to add vm.skip(true) at the beginning of each test. 48 | #[arg(short = 'S', long = "vm-skip", default_value_t = false)] 49 | pub with_vm_skip: bool, 50 | /// Whether to emit modifiers. 51 | #[arg(short = 'm', long, default_value_t = false)] 52 | pub skip_modifiers: bool, 53 | /// Whether to capitalize and punctuate branch descriptions. 54 | #[arg(short = 'F', long = "format-descriptions", default_value_t = false)] 55 | pub format_descriptions: bool, 56 | } 57 | 58 | impl Default for Scaffold { 59 | fn default() -> Self { 60 | Scaffold::parse_from(Vec::::new()) 61 | } 62 | } 63 | 64 | impl Scaffold { 65 | /// Runs the scaffold command, processing all specified files. 66 | /// 67 | /// This method iterates through all input files, processes them, and either 68 | /// writes the output to files or prints to stdout based on the config. 69 | /// 70 | /// If any errors occur during processing, they are collected and reported. 71 | pub(crate) fn run(&self, cfg: &Cli) { 72 | let mut files = Vec::with_capacity(self.files.len()); 73 | for pattern in &self.files { 74 | match expand_glob(pattern.clone()) { 75 | Ok(iter) => files.extend(iter), 76 | Err(e) => { 77 | eprintln!( 78 | "{}: could not expand {}: {}", 79 | "warn".yellow(), 80 | pattern.display(), 81 | e 82 | ); 83 | } 84 | } 85 | } 86 | 87 | let errors = files 88 | .iter() 89 | .filter_map(|file| { 90 | self.process_file(file, cfg) 91 | .map_err(|e| (file.as_path(), e)) 92 | .err() 93 | }) 94 | .collect::>(); 95 | 96 | if !errors.is_empty() { 97 | Scaffold::report_errors(&errors); 98 | std::process::exit(1); 99 | } 100 | } 101 | 102 | /// Processes a single input file. 103 | /// 104 | /// This method reads the input file, scaffolds the Solidity code, formats 105 | /// it, and either writes it to a file or prints it to stdout. 106 | fn process_file(&self, file: &Path, cfg: &Cli) -> anyhow::Result<()> { 107 | let text = fs::read_to_string(file)?; 108 | let emitted = scaffold(&text, &cfg.into())?; 109 | let formatted = fmt(&emitted).unwrap_or_else(|err| { 110 | eprintln!("{}: {}", "WARN".yellow(), err); 111 | emitted 112 | }); 113 | 114 | if self.write_files { 115 | let file = file.with_extension("t.sol"); 116 | self.write_file(&formatted, &file); 117 | } else { 118 | println!("{formatted}"); 119 | } 120 | 121 | Ok(()) 122 | } 123 | 124 | /// Writes the provided `text` to `file`. 125 | /// 126 | /// If the file doesn't exist it will create it. If it exists, 127 | /// and `--force-write` was not passed, it will skip writing to the file. 128 | fn write_file(&self, text: &str, file: &PathBuf) { 129 | // Don't overwrite files unless `--force-write` was passed. 130 | if file.exists() && !self.force_write { 131 | eprintln!( 132 | "{}: Skipped emitting {:?}", 133 | "warn".yellow(), 134 | file.as_path().blue() 135 | ); 136 | eprintln!( 137 | " {} The corresponding `.t.sol` file already exists", 138 | "=".blue() 139 | ); 140 | return; 141 | } 142 | 143 | if let Err(err) = fs::write(file, text) { 144 | eprintln!("{}: {err}", "error".red()); 145 | }; 146 | } 147 | 148 | /// Reports errors that occurred during file processing. 149 | /// 150 | /// This method prints error messages for each file that failed to process, 151 | /// along with a summary of the total number of failed files. 152 | fn report_errors(errors: &[(&Path, anyhow::Error)]) { 153 | for (file, err) in errors { 154 | eprintln!("{err}"); 155 | eprintln!("file: {}", file.display()); 156 | } 157 | 158 | eprintln!( 159 | "\n{}: Could not scaffold {} files. Check the output above or run {}, which might prove helpful.", 160 | "warn".yellow(), 161 | errors.len().yellow(), 162 | "bulloak check".blue() 163 | ); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /crates/foundry/src/sol/mod.rs: -------------------------------------------------------------------------------- 1 | //! This module implements functionality related to operating on a parse tree 2 | //! (PT) from `solang_parser`. 3 | 4 | use solang_parser::pt::{ 5 | ContractDefinition, ContractPart, FunctionDefinition, FunctionTy, 6 | Identifier, SourceUnit, SourceUnitPart, 7 | }; 8 | 9 | use crate::hir::hir; 10 | pub(crate) mod fmt; 11 | pub(crate) mod translator; 12 | mod visitor; 13 | 14 | pub(crate) use fmt::Formatter; 15 | pub(crate) use translator::Translator; 16 | 17 | /// Searches for and returns the first `ContractDefinition` found in a given 18 | /// `SourceUnit`. 19 | #[must_use] 20 | pub fn find_contract(pt: &SourceUnit) -> Option> { 21 | pt.0.iter().find_map(|part| match part { 22 | SourceUnitPart::ContractDefinition(contract) => Some(contract.clone()), 23 | _ => None, 24 | }) 25 | } 26 | 27 | /// Given a HIR function, `find_matching_fn` performs a search over the sol 28 | /// contract parts trying to find a sol function with a matching name and type. 29 | pub(crate) fn find_matching_fn<'a>( 30 | contract_sol: &'a ContractDefinition, 31 | fn_hir: &'a hir::FunctionDefinition, 32 | ) -> Option<(usize, &'a FunctionDefinition)> { 33 | contract_sol.parts.iter().enumerate().find_map(|(idx, part)| { 34 | if let ContractPart::FunctionDefinition(fn_sol) = part { 35 | if fns_match(fn_hir, fn_sol) { 36 | return Some((idx, &**fn_sol)); 37 | } 38 | }; 39 | 40 | None 41 | }) 42 | } 43 | 44 | /// Check whether a Solidity function matches its bulloak counterpart. 45 | /// 46 | /// Two functions match if they have the same name and their types match. 47 | fn fns_match( 48 | fn_hir: &hir::FunctionDefinition, 49 | fn_sol: &FunctionDefinition, 50 | ) -> bool { 51 | fn_sol.name.clone().is_some_and(|Identifier { ref name, .. }| { 52 | name == &fn_hir.identifier && fn_types_match(&fn_hir.ty, fn_sol.ty) 53 | }) 54 | } 55 | 56 | /// Checks that the function types between a HIR function 57 | /// and a `solang_parser` function match. 58 | /// 59 | /// We check that the function types match, even though we know that the 60 | /// name not matching is enough, since a modifier will never be 61 | /// named the same as a function per Foundry's best practices. 62 | const fn fn_types_match(ty_hir: &hir::FunctionTy, ty_sol: FunctionTy) -> bool { 63 | match ty_hir { 64 | hir::FunctionTy::Function => matches!(ty_sol, FunctionTy::Function), 65 | hir::FunctionTy::Modifier => matches!(ty_sol, FunctionTy::Modifier), 66 | } 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use pretty_assertions::assert_eq; 72 | use solang_parser::pt; 73 | 74 | use crate::{ 75 | hir, 76 | sol::{find_matching_fn, fn_types_match, fns_match}, 77 | }; 78 | 79 | #[test] 80 | fn test_fn_types_match() { 81 | assert!(fn_types_match( 82 | &hir::FunctionTy::Function, 83 | pt::FunctionTy::Function 84 | )); 85 | assert!(fn_types_match( 86 | &hir::FunctionTy::Modifier, 87 | pt::FunctionTy::Modifier 88 | )); 89 | } 90 | 91 | fn fn_hir(name: &str, ty: hir::FunctionTy) -> hir::FunctionDefinition { 92 | hir::FunctionDefinition { 93 | identifier: name.to_owned(), 94 | ty, 95 | span: Default::default(), 96 | modifiers: Default::default(), 97 | children: Default::default(), 98 | } 99 | } 100 | 101 | fn fn_sol(name: &str, ty: pt::FunctionTy) -> pt::FunctionDefinition { 102 | pt::FunctionDefinition { 103 | name: Some(pt::Identifier::new(name)), 104 | ty, 105 | loc: Default::default(), 106 | name_loc: Default::default(), 107 | params: Default::default(), 108 | attributes: Default::default(), 109 | return_not_returns: Default::default(), 110 | returns: Default::default(), 111 | body: Default::default(), 112 | } 113 | } 114 | 115 | #[test] 116 | fn test_fns_match() { 117 | assert!(fns_match( 118 | &fn_hir("my_fn", hir::FunctionTy::Function), 119 | &fn_sol("my_fn", pt::FunctionTy::Function) 120 | )); 121 | assert!(!fns_match( 122 | &fn_hir("my_fn", hir::FunctionTy::Function), 123 | &fn_sol("not_my_fn", pt::FunctionTy::Function) 124 | )); 125 | assert!(!fns_match( 126 | &fn_hir("not_my_fn", hir::FunctionTy::Function), 127 | &fn_sol("my_fn", pt::FunctionTy::Function) 128 | )); 129 | assert!(fns_match( 130 | &fn_hir("my_fn", hir::FunctionTy::Modifier), 131 | &fn_sol("my_fn", pt::FunctionTy::Modifier) 132 | )); 133 | assert!(!fns_match( 134 | &fn_hir("my_fn", hir::FunctionTy::Modifier), 135 | &fn_sol("my_fn", pt::FunctionTy::Function) 136 | )); 137 | assert!(!fns_match( 138 | &fn_hir("my_fn", hir::FunctionTy::Function), 139 | &fn_sol("my_fn", pt::FunctionTy::Modifier) 140 | )); 141 | } 142 | 143 | fn fn_sol_as_part(name: &str, ty: pt::FunctionTy) -> pt::ContractPart { 144 | pt::ContractPart::FunctionDefinition(Box::new(fn_sol(name, ty))) 145 | } 146 | 147 | #[test] 148 | fn test_find_matching_fn() { 149 | let needle_sol = fn_sol("needle", pt::FunctionTy::Function); 150 | let haystack = vec![ 151 | fn_sol_as_part("hay", pt::FunctionTy::Function), 152 | fn_sol_as_part("more_hay", pt::FunctionTy::Function), 153 | fn_sol_as_part("needle", pt::FunctionTy::Function), 154 | fn_sol_as_part("hay_more", pt::FunctionTy::Function), 155 | ]; 156 | let needle_hir = fn_hir("needle", hir::FunctionTy::Function); 157 | let contract = pt::ContractDefinition { 158 | loc: Default::default(), 159 | ty: pt::ContractTy::Contract(Default::default()), 160 | name: Default::default(), 161 | base: Default::default(), 162 | parts: haystack, 163 | }; 164 | 165 | let expected = needle_sol; 166 | let actual = find_matching_fn(&contract, &needle_hir).unwrap(); 167 | assert_eq!((2, &expected), actual); 168 | 169 | let haystack = vec![]; 170 | let needle_hir = fn_hir("needle", hir::FunctionTy::Function); 171 | let contract = pt::ContractDefinition { 172 | loc: Default::default(), 173 | ty: pt::ContractTy::Contract(Default::default()), 174 | name: Default::default(), 175 | base: Default::default(), 176 | parts: haystack, 177 | }; 178 | 179 | let actual = find_matching_fn(&contract, &needle_hir); 180 | assert_eq!(None, actual); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /crates/bulloak/src/check.rs: -------------------------------------------------------------------------------- 1 | //! Defines the `bulloak check` command. 2 | //! 3 | //! This command performs checks on the relationship between a bulloak tree and 4 | //! a Solidity file. 5 | 6 | use std::{fs, path::PathBuf}; 7 | 8 | use bulloak_foundry::{ 9 | check::{ 10 | context::{fix_order, Context}, 11 | rules::{self, Checker}, 12 | }, 13 | sol::find_contract, 14 | violation::{Violation, ViolationKind}, 15 | }; 16 | use bulloak_syntax::utils::pluralize; 17 | use clap::Parser; 18 | use owo_colors::OwoColorize; 19 | use serde::{Deserialize, Serialize}; 20 | 21 | use crate::{cli::Cli, glob::expand_glob}; 22 | 23 | /// Check that the tests match the spec. 24 | #[doc(hidden)] 25 | #[derive(Debug, Parser, Clone, Serialize, Deserialize)] 26 | pub struct Check { 27 | /// The set of tree files to use as spec. 28 | /// 29 | /// Solidity file names are inferred from the specs. 30 | pub files: Vec, 31 | /// Whether to fix any issues found. 32 | #[arg(long, group = "fix-violations", default_value_t = false)] 33 | pub fix: bool, 34 | /// When `--fix` is passed, use `--stdout` to direct output 35 | /// to standard output instead of writing to files. 36 | #[arg(long, requires = "fix-violations", default_value_t = false)] 37 | pub stdout: bool, 38 | /// Whether to emit modifiers. 39 | #[arg(short = 'm', long, default_value_t = false)] 40 | pub skip_modifiers: bool, 41 | /// Whether to capitalize and punctuate branch descriptions. 42 | #[arg(long = "format-descriptions", default_value_t = false)] 43 | pub format_descriptions: bool, 44 | } 45 | 46 | impl Default for Check { 47 | fn default() -> Self { 48 | Check::parse_from(Vec::::new()) 49 | } 50 | } 51 | 52 | impl Check { 53 | /// Entrypoint for `bulloak check`. 54 | /// 55 | /// Note that we don't deal with `solang_parser` errors at all. 56 | pub(crate) fn run(&self, cfg: &Cli) { 57 | let mut specs = Vec::new(); 58 | for pattern in &self.files { 59 | match expand_glob(pattern.clone()) { 60 | Ok(iter) => specs.extend(iter), 61 | Err(e) => eprintln!( 62 | "{}: could not expand {}: {}", 63 | "warn".yellow(), 64 | pattern.display(), 65 | e 66 | ), 67 | } 68 | } 69 | 70 | let mut violations = Vec::new(); 71 | let ctxs: Vec = specs 72 | .iter() 73 | .filter_map(|tree_path| { 74 | Context::new(tree_path.clone(), &cfg.into()) 75 | .map_err(|violation| violations.push(violation)) 76 | .ok() 77 | }) 78 | .collect(); 79 | 80 | if !self.fix { 81 | for ctx in ctxs { 82 | violations.append(&mut rules::StructuralMatcher::check(&ctx)); 83 | } 84 | 85 | return exit(&violations); 86 | } 87 | 88 | let mut fixed_count = 0; 89 | for mut ctx in ctxs { 90 | let violations = rules::StructuralMatcher::check(&ctx); 91 | let fixable_count = 92 | violations.iter().filter(|v| v.is_fixable()).count(); 93 | 94 | // Process violations that don't affect function order first. 95 | let violations = violations.iter().filter(|v| { 96 | !matches!(v.kind, ViolationKind::FunctionOrderMismatch(_, _, _)) 97 | }); 98 | for violation in violations { 99 | ctx = match violation.kind.fix(ctx.clone()) { 100 | Ok(ctx) => ctx, 101 | Err(e) => { 102 | eprintln!( 103 | "unable to fix \"{}\" due to:\n{}", 104 | violation.kind, e 105 | ); 106 | continue; 107 | } 108 | }; 109 | } 110 | 111 | // Second pass fixing order violations. 112 | let violations = rules::StructuralMatcher::check(&ctx); 113 | let violations: Vec = violations 114 | .into_iter() 115 | .filter(|v| { 116 | matches!( 117 | v.kind, 118 | ViolationKind::FunctionOrderMismatch(_, _, _) 119 | ) 120 | }) 121 | .collect(); 122 | if !violations.is_empty() { 123 | if let Some(contract_sol) = find_contract(&ctx.pt) { 124 | if let Some(contract_hir) = ctx.hir.clone().find_contract() 125 | { 126 | ctx = fix_order( 127 | &violations, 128 | &contract_sol, 129 | contract_hir, 130 | ctx, 131 | ); 132 | } 133 | } 134 | } 135 | 136 | let sol = ctx.sol.clone(); 137 | let formatted = 138 | ctx.fmt().expect("should format the emitted solidity code"); 139 | self.write(&formatted, sol); 140 | 141 | fixed_count += fixable_count; 142 | } 143 | 144 | let issue_literal = pluralize(fixed_count, "issue", "issues"); 145 | println!( 146 | "\n{}: {} {} fixed.", 147 | "success".bold().green(), 148 | fixed_count, 149 | issue_literal 150 | ); 151 | } 152 | 153 | /// Handles writing the output of the `check` command. 154 | /// 155 | /// If the `--stdout` flag was passed, then the output is printed to 156 | /// stdout, else it is written to the corresponding file. 157 | fn write(&self, output: &str, sol: PathBuf) { 158 | if self.stdout { 159 | println!("{} {}", "-->".blue(), sol.to_string_lossy()); 160 | println!("{}", output.trim()); 161 | println!("{}", "<--".blue()); 162 | } else if let Err(e) = fs::write(sol, output) { 163 | eprintln!("{}: {e}", "warn".yellow()); 164 | } 165 | } 166 | } 167 | 168 | fn exit(violations: &[Violation]) { 169 | if violations.is_empty() { 170 | println!( 171 | "{}", 172 | "All checks completed successfully! No issues found.".green() 173 | ); 174 | } else { 175 | for violation in violations { 176 | eprintln!("{violation}"); 177 | } 178 | 179 | let check_literal = pluralize(violations.len(), "check", "checks"); 180 | eprint!( 181 | "{}: {} {} failed", 182 | "warn".bold().yellow(), 183 | violations.len(), 184 | check_literal 185 | ); 186 | let fixable_count = 187 | violations.iter().filter(|v| v.is_fixable()).count(); 188 | if fixable_count > 0 { 189 | let fix_literal = pluralize(fixable_count, "fix", "fixes"); 190 | eprintln!( 191 | " (run `bulloak check --fix <.tree files>` to apply {fixable_count} {fix_literal})" 192 | ); 193 | } else { 194 | eprintln!(); 195 | } 196 | 197 | std::process::exit(1); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /crates/foundry/src/scaffold/modifiers.rs: -------------------------------------------------------------------------------- 1 | //! Defines a modifier-discovering step in the compiler. 2 | //! 3 | //! It visits the AST in depth-first order, storing modifiers for use in later 4 | //! phases. 5 | 6 | use bulloak_syntax::{ 7 | utils::{lower_first_letter, to_pascal_case}, 8 | Action, Ast, Condition, Description, Root, Visitor, 9 | }; 10 | use indexmap::IndexMap; 11 | 12 | /// AST visitor that discovers modifiers. 13 | /// 14 | /// Modifiers are discovered by visiting the AST and collecting all condition 15 | /// titles. The collected titles are then converted to modifiers. For example, 16 | /// the title `when only owner` is converted to the `whenOnlyOwner` modifier. 17 | /// 18 | /// For ease of retrieval, the discovered modifiers are stored in a `IndexMap` 19 | /// for the later phases of the compiler. `IndexMap` was chosen since preserving 20 | /// the order of insertion to match the order of the modifiers in the source 21 | /// tree is helpful and the performance trade-off is negligible. 22 | #[derive(Clone, Default)] 23 | pub struct ModifierDiscoverer { 24 | modifiers: IndexMap, 25 | } 26 | 27 | impl ModifierDiscoverer { 28 | /// Create a new discoverer. 29 | #[must_use] 30 | pub fn new() -> Self { 31 | Self { modifiers: IndexMap::new() } 32 | } 33 | 34 | /// Discover modifiers in the given AST. 35 | /// 36 | /// `discover` is the entry point of the `ModifierDiscoverer`. It takes an 37 | /// abstract syntax tree (AST) and returns a map of modifiers. 38 | pub fn discover(&mut self, ast: &Ast) -> &IndexMap { 39 | match ast { 40 | Ast::Root(root) => { 41 | self.visit_root(root).unwrap(); 42 | &self.modifiers 43 | } 44 | _ => unreachable!(), 45 | } 46 | } 47 | } 48 | 49 | /// A visitor that stores key-value pairs of condition titles and 50 | /// their corresponding modifiers. 51 | impl Visitor for ModifierDiscoverer { 52 | type Error = (); 53 | type Output = (); 54 | 55 | fn visit_root( 56 | &mut self, 57 | root: &Root, 58 | ) -> anyhow::Result { 59 | for condition in &root.children { 60 | if let Ast::Condition(condition) = condition { 61 | self.visit_condition(condition)?; 62 | } 63 | } 64 | 65 | Ok(()) 66 | } 67 | 68 | fn visit_condition( 69 | &mut self, 70 | condition: &Condition, 71 | ) -> anyhow::Result { 72 | self.modifiers.insert( 73 | condition.title.clone(), 74 | lower_first_letter(&to_pascal_case(&condition.title)), 75 | ); 76 | 77 | for condition in &condition.children { 78 | if let Ast::Condition(condition) = condition { 79 | self.visit_condition(condition)?; 80 | } 81 | } 82 | 83 | Ok(()) 84 | } 85 | 86 | fn visit_action( 87 | &mut self, 88 | _action: &Action, 89 | ) -> anyhow::Result { 90 | // No-op. 91 | Ok(()) 92 | } 93 | 94 | fn visit_description( 95 | &mut self, 96 | _description: &Description, 97 | ) -> anyhow::Result { 98 | // No-op. 99 | Ok(()) 100 | } 101 | } 102 | 103 | #[cfg(test)] 104 | mod tests { 105 | use bulloak_syntax::parse_one; 106 | use indexmap::IndexMap; 107 | use pretty_assertions::assert_eq; 108 | 109 | use crate::scaffold::modifiers::ModifierDiscoverer; 110 | 111 | fn discover(text: &str) -> anyhow::Result> { 112 | let ast = parse_one(text)?; 113 | let mut discoverer = ModifierDiscoverer::new(); 114 | discoverer.discover(&ast); 115 | 116 | Ok(discoverer.modifiers) 117 | } 118 | 119 | #[test] 120 | fn test_one_child() { 121 | assert_eq!( 122 | discover("file.sol\n└── when something bad happens\n └── it should revert").unwrap(), 123 | IndexMap::from([( 124 | "when something bad happens".to_owned(), 125 | "whenSomethingBadHappens".to_owned() 126 | )]) 127 | ); 128 | } 129 | 130 | #[test] 131 | fn test_two_children() { 132 | assert_eq!( 133 | discover( 134 | r"two_children.t.sol 135 | ├── when stuff called 136 | │ └── it should revert 137 | └── when not stuff called 138 | └── it should revert", 139 | ) 140 | .unwrap(), 141 | IndexMap::from([ 142 | ("when stuff called".to_owned(), "whenStuffCalled".to_owned()), 143 | ( 144 | "when not stuff called".to_owned(), 145 | "whenNotStuffCalled".to_owned() 146 | ) 147 | ]) 148 | ); 149 | } 150 | 151 | #[test] 152 | fn test_deep_tree() { 153 | assert_eq!( 154 | discover( 155 | r#"deep.sol 156 | ├── when stuff called 157 | │ └── it should revert 158 | └── when not stuff called 159 | ├── when the deposit amount is zero 160 | │ └── it should revert 161 | └── when the deposit amount is not zero 162 | ├── when the number count is zero 163 | │ └── it should revert 164 | ├── when the asset is not a contract 165 | │ └── it should revert 166 | └── when the asset is a contract 167 | ├── when the asset misses the ERC_20 return value 168 | │ ├── it should create the child 169 | │ ├── it should perform the ERC-20 transfers 170 | │ └── it should emit a {MultipleChildren} event 171 | └── when the asset does not miss the ERC_20 return value 172 | ├── it should create the child 173 | └── it should emit a {MultipleChildren} event"#, 174 | ) 175 | .unwrap(), 176 | IndexMap::from([ 177 | ("when stuff called".to_owned(), "whenStuffCalled".to_owned()), 178 | ( 179 | "when not stuff called".to_owned(), 180 | "whenNotStuffCalled".to_owned() 181 | ), 182 | ( 183 | "when the deposit amount is zero".to_owned(), 184 | "whenTheDepositAmountIsZero".to_owned() 185 | ), 186 | ( 187 | "when the deposit amount is not zero".to_owned(), 188 | "whenTheDepositAmountIsNotZero".to_owned() 189 | ), 190 | ( 191 | "when the number count is zero".to_owned(), 192 | "whenTheNumberCountIsZero".to_owned() 193 | ), 194 | ( 195 | "when the asset is not a contract".to_owned(), 196 | "whenTheAssetIsNotAContract".to_owned() 197 | ), 198 | ( 199 | "when the asset is a contract".to_owned(), 200 | "whenTheAssetIsAContract".to_owned() 201 | ), 202 | ( 203 | "when the asset misses the ERC_20 return value".to_owned(), 204 | "whenTheAssetMissesTheERC_20ReturnValue".to_owned() 205 | ), 206 | ( 207 | "when the asset does not miss the ERC_20 return value" 208 | .to_owned(), 209 | "whenTheAssetDoesNotMissTheERC_20ReturnValue".to_owned() 210 | ), 211 | ]) 212 | ); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /crates/foundry/src/check/pretty.rs: -------------------------------------------------------------------------------- 1 | //! Pretty‑print utilities for Solang diagnostics. 2 | //! 3 | //! This module provides the [`Pretty`] wrapper which formats 4 | //! [`solang_parser::diagnostics::Diagnostic`] values in a compact, 5 | //! human‑readable style that resembles `rustc` output: 6 | //! 7 | //! ```text 8 | //! path/to/file.sol:12:34 error [syntax] unexpected token 9 | //! note: a more detailed explanation of the problem 10 | //! ``` 11 | //! 12 | //! The main entry point is [`Pretty::new`]; create a value and use the 13 | //! [`Display`] implementation to render the diagnostic. 14 | //! 15 | //! Helper functionality includes [`ErrorTypeExt::as_str`], converting Solang's 16 | //! [`ErrorType`] enumeration to short lowercase strings, and 17 | //! [`byte_to_line_col`], which translates a byte index into 1‑based line and 18 | //! column numbers. 19 | //! 20 | //! # Example 21 | //! 22 | //! ```rust,ignore 23 | //! let pretty = Pretty::new(&diag, "contract.sol", &source); 24 | //! println!("{}", pretty); // prints a single‑line diagnostic 25 | //! ``` 26 | 27 | use std::fmt::{self, Display, Formatter}; 28 | 29 | use solang_parser::{ 30 | diagnostics::{Diagnostic, ErrorType}, 31 | pt::Loc, 32 | }; 33 | 34 | /// Convenience extension trait for converting [`ErrorType`] values to strings. 35 | pub trait ErrorTypeExt { 36 | /// Returns a short lowercase string such as `"syntax"` or `"type"` that 37 | /// represents the variant, or an empty string when the variant is 38 | /// [`ErrorType::None`]. 39 | fn as_str(&self) -> &'static str; 40 | } 41 | 42 | impl ErrorTypeExt for solang_parser::diagnostics::ErrorType { 43 | #[rustfmt::skip] 44 | fn as_str(&self) -> &'static str { 45 | use solang_parser::diagnostics::ErrorType::*; 46 | match self { 47 | None => "none", 48 | ParserError => "parser", 49 | SyntaxError => "syntax", 50 | DeclarationError => "declaration", 51 | CastError => "cast", 52 | TypeError => "type", 53 | Warning => "warning", 54 | } 55 | } 56 | } 57 | 58 | /// A pretty‑printer for [`Diagnostic`] values. 59 | /// 60 | /// `Pretty` keeps references to the diagnostic itself, the filename, and the 61 | /// full source code so that it can map byte offsets to line/column numbers. 62 | #[derive(Clone, Copy)] 63 | pub struct Pretty<'a> { 64 | diagnostic: &'a Diagnostic, 65 | filename: &'a str, 66 | source: &'a str, 67 | } 68 | 69 | impl<'a> Pretty<'a> { 70 | /// Create a new [`Pretty`] wrapper. 71 | /// 72 | /// * `diagnostic` – the diagnostic to pretty‑print. 73 | /// * `filename` – the file name to display in the output. 74 | /// * `source` – the full source text corresponding to `filename`. 75 | pub fn new( 76 | diagnostic: &'a Diagnostic, 77 | filename: &'a str, 78 | source: &'a str, 79 | ) -> Self { 80 | Pretty { diagnostic, filename, source } 81 | } 82 | } 83 | 84 | impl Pretty<'_> { 85 | /// Render the location part (`::` or a special marker) of 86 | /// the diagnostic. 87 | pub fn fmt_loc(&self) -> String { 88 | match self.diagnostic.loc { 89 | Loc::Builtin => "".to_string(), 90 | Loc::CommandLine => "".to_string(), 91 | Loc::Implicit => "".to_string(), 92 | Loc::Codegen => "".to_string(), 93 | Loc::File(_, byte, _) => { 94 | let (l, c) = byte_to_line_col(self.source, byte); 95 | format!("{}:{l}:{c}", self.filename) 96 | } 97 | } 98 | } 99 | } 100 | 101 | impl<'d> Display for Pretty<'d> { 102 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 103 | let diag = self.diagnostic; 104 | 105 | // ── file:line:col error|warning [ErrorType] message 106 | let loc_str = self.fmt_loc(); 107 | if let ErrorType::None = diag.ty { 108 | writeln!(f, "{}: {} {}", loc_str, diag.level, diag.message)?; 109 | } else { 110 | writeln!( 111 | f, 112 | "{}: {} [{}] {}", 113 | loc_str, 114 | diag.level, 115 | diag.ty.as_str(), 116 | diag.message 117 | )?; 118 | } 119 | 120 | // Each note on its own indented line. 121 | for note in &diag.notes { 122 | writeln!(f, " note: {}", note.message)?; 123 | } 124 | Ok(()) 125 | } 126 | } 127 | 128 | /// Convert a byte offset within `src` to 1‑based (line, column). 129 | /// 130 | /// Traverses `src` up to the offset and counts newlines to determine the line 131 | /// number, resetting the column counter after each `\n`. 132 | fn byte_to_line_col(src: &str, byte: usize) -> (usize, usize) { 133 | let mut line = 1; 134 | let mut col = 1; 135 | 136 | for b in src[..byte.min(src.len())].bytes() { 137 | if b == b'\n' { 138 | line += 1; 139 | col = 1; 140 | } else { 141 | col += 1; 142 | } 143 | } 144 | (line, col) 145 | } 146 | 147 | #[cfg(test)] 148 | mod tests { 149 | use solang_parser::diagnostics::{Diagnostic, Level, Note}; 150 | 151 | use super::*; 152 | 153 | /// A helper that builds a minimal `Diagnostic` we can wrap in `Pretty`. 154 | fn make_diag( 155 | loc: Loc, 156 | level: Level, 157 | ty: ErrorType, 158 | msg: &str, 159 | notes: Vec, 160 | ) -> Diagnostic { 161 | Diagnostic { loc, level, ty, message: msg.to_owned(), notes } 162 | } 163 | 164 | #[test] 165 | fn fmt_loc_translates_byte_offsets() { 166 | let source = "line1\nline2\nline3"; 167 | // byte index 012345 6 789012 3 45678 168 | // byte 13 is 'i' in "line3" -> line 3, col 2 169 | let loc = Loc::File(0, 13, 0); 170 | let diag = make_diag(loc, Level::Info, ErrorType::None, "msg", vec![]); 171 | 172 | let pretty = Pretty { diagnostic: &diag, filename: "test.sol", source }; 173 | assert_eq!(pretty.fmt_loc(), "test.sol:3:2"); 174 | } 175 | 176 | #[test] 177 | fn fmt_loc_handles_special_locations() { 178 | let diag = make_diag( 179 | Loc::Builtin, 180 | Level::Debug, 181 | ErrorType::None, 182 | "msg", 183 | vec![], 184 | ); 185 | let pretty = 186 | Pretty { diagnostic: &diag, filename: "ignored.sol", source: "" }; 187 | assert_eq!(pretty.fmt_loc(), ""); 188 | } 189 | 190 | #[test] 191 | fn display_formats_with_and_without_error_type() { 192 | // 1. With an explicit ErrorType 193 | let source = "x = 1;"; 194 | let err_loc = Loc::File(0, 0, 0); 195 | let diag1 = make_diag( 196 | err_loc, 197 | Level::Error, 198 | ErrorType::SyntaxError, 199 | "unexpected token", 200 | vec![], 201 | ); 202 | let pretty1 = 203 | Pretty { diagnostic: &diag1, filename: "code.sol", source }; 204 | let rendered1 = pretty1.to_string(); 205 | assert!( 206 | rendered1 207 | .starts_with("code.sol:1:1: error [syntax] unexpected token"), 208 | "rendered: {rendered1:?}" 209 | ); 210 | 211 | // 2. Without an ErrorType (ErrorType::None) 212 | let diag2 = make_diag( 213 | err_loc, 214 | Level::Warning, 215 | ErrorType::None, 216 | "unused variable", 217 | vec![], 218 | ); 219 | let pretty2 = 220 | Pretty { diagnostic: &diag2, filename: "code.sol", source }; 221 | let rendered2 = pretty2.to_string(); 222 | assert!( 223 | rendered2.starts_with("code.sol:1:1: warning unused variable"), 224 | "rendered: {rendered2:?}" 225 | ); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /crates/foundry/src/check/violation.rs: -------------------------------------------------------------------------------- 1 | //! Defines a rule-checking error object. 2 | use std::{borrow::Cow, fmt}; 3 | 4 | use bulloak_syntax::FrontendError; 5 | use forge_fmt::solang_ext::SafeUnwrap; 6 | use owo_colors::OwoColorize; 7 | use solang_parser::pt; 8 | use thiserror::Error; 9 | 10 | use super::{context::Context, location::Location}; 11 | use crate::hir; 12 | 13 | /// An error that occurred while checking specification rules between 14 | /// a tree and a Solidity contract. 15 | #[derive(Debug, Error, PartialEq)] 16 | pub struct Violation { 17 | /// The kind of violation. 18 | #[source] 19 | pub kind: ViolationKind, 20 | /// The location information about this violation. 21 | pub location: Location, 22 | } 23 | 24 | impl Violation { 25 | /// Create a new violation. 26 | pub fn new(kind: ViolationKind, location: Location) -> Self { 27 | Self { kind, location } 28 | } 29 | 30 | /// Determines whether a given violation is fixable. 31 | pub fn is_fixable(&self) -> bool { 32 | self.kind.is_fixable() 33 | } 34 | } 35 | 36 | /// The type of an error that occurred while checking specification rules 37 | /// between a tree and a Solidity contract. 38 | /// 39 | /// NOTE: Adding a variant to this enum most certainly will mean adding a 40 | /// variant to the `Rules` section of `bulloak`'s README. Please, do not forget 41 | /// to add it if you are implementing a rule. 42 | #[derive(Debug, Error)] 43 | #[non_exhaustive] 44 | pub enum ViolationKind { 45 | /// Found no matching Solidity contract. 46 | /// 47 | /// (contract name) 48 | #[error("contract \"{0}\" is missing in .sol")] 49 | ContractMissing(String), 50 | 51 | /// Contract name doesn't match. 52 | /// 53 | /// (tree name, sol name) 54 | #[error("contract \"{0}\" is missing in .sol -- found \"{1}\" instead")] 55 | ContractNameNotMatches(String, String), 56 | 57 | /// The corresponding Solidity file does not exist. 58 | #[error("the tree is missing its matching Solidity file: {0}")] 59 | SolidityFileMissing(String), 60 | 61 | /// Couldn't read the corresponding Solidity file. 62 | #[error("bulloak couldn't read the file")] 63 | FileUnreadable, 64 | 65 | /// Found an incorrectly ordered element. 66 | /// 67 | /// (pt function, current position, insertion position) 68 | #[error("incorrect position for function `{}`", .0.name.safe_unwrap())] 69 | FunctionOrderMismatch(pt::FunctionDefinition, usize, usize), 70 | 71 | /// Found a tree element without its matching codegen. 72 | /// 73 | /// (hir function, insertion position) 74 | #[error("function \"{}\" is missing in .sol", .0.identifier.clone())] 75 | MatchingFunctionMissing(hir::FunctionDefinition, usize), 76 | 77 | /// The parsing of a tree or a Solidity file failed. 78 | #[error("{}", format_frontend_error(.0))] 79 | ParsingFailed(#[from] anyhow::Error), 80 | } 81 | 82 | impl ViolationKind { 83 | /// Whether this violation kind is fixable. 84 | pub fn is_fixable(&self) -> bool { 85 | matches!( 86 | self, 87 | ViolationKind::ContractMissing(_) 88 | | ViolationKind::ContractNameNotMatches(_, _) 89 | | ViolationKind::FunctionOrderMismatch(_, _, _) 90 | | ViolationKind::MatchingFunctionMissing(_, _) 91 | ) 92 | } 93 | 94 | /// Optionally returns a help text to be used when displaying the violation 95 | /// kind. 96 | pub fn help(&self) -> Option> { 97 | let text = match self { 98 | ViolationKind::ContractMissing(name) => { 99 | format!(r#"consider adding a contract with name "{name}""#) 100 | .into() 101 | } 102 | ViolationKind::ContractNameNotMatches(name, _) => { 103 | format!(r#"consider renaming the contract to "{name}""#).into() 104 | } 105 | ViolationKind::SolidityFileMissing(filename) => { 106 | let filename = filename.replace(".t.sol", ".tree"); 107 | format!("consider running `bulloak scaffold {filename}`").into() 108 | } 109 | ViolationKind::FunctionOrderMismatch(_, _, _) => { 110 | "consider reordering the function in the file".into() 111 | } 112 | _ => return None, 113 | }; 114 | 115 | Some(text) 116 | } 117 | 118 | /// Returns a new context with this violation fixed. 119 | pub fn fix(&self, ctx: Context) -> anyhow::Result { 120 | match self { 121 | ViolationKind::ContractMissing(_) => ctx.fix_contract_missing(), 122 | ViolationKind::ContractNameNotMatches(new_name, old_name) => { 123 | ctx.fix_contract_rename(new_name, old_name) 124 | } 125 | // Assume order violations have been taken care of first. 126 | ViolationKind::MatchingFunctionMissing(fn_hir, index) => { 127 | ctx.fix_matching_fn_missing(fn_hir, *index) 128 | } 129 | _ => Ok(ctx), 130 | } 131 | } 132 | } 133 | 134 | impl fmt::Display for Violation { 135 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 136 | writeln!(f, "{}: {}", "warn".yellow(), self.kind)?; 137 | if let Some(help_text) = self.kind.help() { 138 | writeln!(f, " {} help: {}", "=".blue(), help_text)?; 139 | } 140 | if self.kind.is_fixable() { 141 | let file = self.location.file().replace(".t.sol", ".tree"); 142 | write!(f, " {} fix: run ", "+".blue())?; 143 | writeln!(f, "`bulloak check --fix {file}`")?; 144 | } 145 | writeln!(f, " {} {}", "-->".blue(), self.location)?; 146 | 147 | Ok(()) 148 | } 149 | } 150 | 151 | impl PartialEq for ViolationKind { 152 | fn eq(&self, other: &Self) -> bool { 153 | use ViolationKind::*; 154 | 155 | match (self, other) { 156 | (ContractMissing(a), ContractMissing(b)) => a == b, 157 | ( 158 | ContractNameNotMatches(a1, a2), 159 | ContractNameNotMatches(b1, b2), 160 | ) => a1 == b1 && a2 == b2, 161 | (SolidityFileMissing(a), SolidityFileMissing(b)) => a == b, 162 | (FileUnreadable, FileUnreadable) => true, 163 | ( 164 | FunctionOrderMismatch(f1, cur1, ins1), 165 | FunctionOrderMismatch(f2, cur2, ins2), 166 | ) => 167 | // Compare on function name and the two positions. 168 | { 169 | f1.name == f2.name && cur1 == cur2 && ins1 == ins2 170 | } 171 | ( 172 | MatchingFunctionMissing(f1, pos1), 173 | MatchingFunctionMissing(f2, pos2), 174 | ) => 175 | // Compare on function identifier and the position. 176 | { 177 | f1.identifier == f2.identifier && pos1 == pos2 178 | } 179 | (ParsingFailed(e1), ParsingFailed(e2)) => 180 | // Compare on the formatted error message. 181 | { 182 | e1.to_string() == e2.to_string() 183 | } 184 | 185 | // any mismatched variant 186 | _ => false, 187 | } 188 | } 189 | } 190 | 191 | /// Formats frontend errors into human-readable messages. 192 | /// 193 | /// # Arguments 194 | /// * `error` - Reference to an `anyhow::Error` 195 | /// 196 | /// # Returns 197 | /// A `String` containing the formatted error message 198 | fn format_frontend_error(error: &anyhow::Error) -> String { 199 | if let Some(error) = 200 | error.downcast_ref::() 201 | { 202 | format!("an error occurred while parsing the tree: {}", error.kind()) 203 | } else if let Some(error) = 204 | error.downcast_ref::() 205 | { 206 | format!("an error occurred while parsing the tree: {}", error.kind()) 207 | } else if let Some(error) = 208 | error.downcast_ref::() 209 | { 210 | format!("an error occurred while parsing the tree: {}", error.kind()) 211 | } else if error.downcast_ref::().is_some() 212 | { 213 | "at least one semantic error occurred while parsing the tree".to_owned() 214 | } else { 215 | "an error occurred while parsing the solidity file".to_owned() 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold.rs: -------------------------------------------------------------------------------- 1 | #![allow(missing_docs)] 2 | use std::{env, fs}; 3 | 4 | use common::{cmd, get_binary_path}; 5 | use owo_colors::OwoColorize; 6 | use pretty_assertions::assert_eq; 7 | 8 | mod common; 9 | 10 | #[cfg(not(target_os = "windows"))] 11 | #[test] 12 | fn scaffolds_trees() { 13 | let cwd = env::current_dir().unwrap(); 14 | let binary_path = get_binary_path(); 15 | let tests_path = cwd.join("tests").join("scaffold"); 16 | let trees = [ 17 | "basic.tree", 18 | "complex.tree", 19 | "multiple_roots.tree", 20 | "removes_invalid_title_chars.tree", 21 | "hash_pair.tree", 22 | "format_descriptions.tree", 23 | "revert_when.tree", 24 | "spurious_comments.tree", 25 | ]; 26 | 27 | for tree_name in trees { 28 | let tree_path = tests_path.join(tree_name); 29 | let output = cmd(&binary_path, "scaffold", &tree_path, &[]); 30 | let actual = String::from_utf8(output.stdout).unwrap(); 31 | 32 | let mut output_file = tree_path.clone(); 33 | output_file.set_extension("t.sol"); 34 | let expected = fs::read_to_string(output_file).unwrap(); 35 | 36 | // We trim here because we don't care about ending newlines. 37 | assert_eq!(expected.trim(), actual.trim()); 38 | } 39 | } 40 | 41 | #[cfg(not(target_os = "windows"))] 42 | #[test] 43 | fn scaffolds_trees_with_vm_skip() { 44 | let cwd = env::current_dir().unwrap(); 45 | let binary_path = get_binary_path(); 46 | let tests_path = cwd.join("tests").join("scaffold"); 47 | let trees = [ 48 | "basic.tree", 49 | "complex.tree", 50 | "multiple_roots.tree", 51 | "removes_invalid_title_chars.tree", 52 | ]; 53 | let args = vec!["--vm-skip"]; 54 | 55 | for tree_name in trees { 56 | let tree_path = tests_path.join(tree_name); 57 | let output = cmd(&binary_path, "scaffold", &tree_path, &args); 58 | let actual = String::from_utf8(output.stdout).unwrap(); 59 | 60 | let mut trimmed_extension = tree_path.clone(); 61 | trimmed_extension.set_extension(""); 62 | 63 | let mut output_file_str = trimmed_extension.into_os_string(); 64 | output_file_str.push("_vm_skip"); 65 | 66 | let mut output_file: std::path::PathBuf = output_file_str.into(); 67 | output_file.set_extension("t.sol"); 68 | 69 | let expected = fs::read_to_string(output_file).unwrap(); 70 | 71 | // We trim here because we don't care about ending newlines. 72 | assert_eq!(expected.trim(), actual.trim()); 73 | } 74 | } 75 | 76 | #[cfg(not(target_os = "windows"))] 77 | #[test] 78 | fn scaffolds_trees_with_format_descriptions() { 79 | let cwd = env::current_dir().unwrap(); 80 | let binary_path = get_binary_path(); 81 | let tests_path = cwd.join("tests").join("scaffold"); 82 | let trees = ["format_descriptions.tree"]; 83 | let args = vec!["--format-descriptions"]; 84 | 85 | for tree_name in trees { 86 | let tree_path = tests_path.join(tree_name); 87 | let output = cmd(&binary_path, "scaffold", &tree_path, &args); 88 | let actual = String::from_utf8(output.stdout).unwrap(); 89 | 90 | let mut trimmed_extension = tree_path.clone(); 91 | trimmed_extension.set_extension(""); 92 | 93 | let mut output_file_str = trimmed_extension.into_os_string(); 94 | output_file_str.push("_formatted"); 95 | 96 | let mut output_file: std::path::PathBuf = output_file_str.into(); 97 | output_file.set_extension("t.sol"); 98 | 99 | let expected = fs::read_to_string(output_file).unwrap(); 100 | 101 | // We trim here because we don't care about ending newlines. 102 | assert_eq!(expected.trim(), actual.trim()); 103 | } 104 | } 105 | 106 | #[cfg(not(target_os = "windows"))] 107 | #[test] 108 | fn scaffolds_trees_with_skip_modifiers() { 109 | let cwd = env::current_dir().unwrap(); 110 | let binary_path = get_binary_path(); 111 | let tests_path = cwd.join("tests").join("scaffold"); 112 | let trees = ["skip_modifiers.tree"]; 113 | 114 | for tree_name in trees { 115 | let tree_path = tests_path.join(tree_name); 116 | let output = cmd(&binary_path, "scaffold", &tree_path, &["-m"]); 117 | let actual = String::from_utf8(output.stdout).unwrap(); 118 | 119 | let mut output_file = tree_path.clone(); 120 | output_file.set_extension("t.sol"); 121 | let expected = fs::read_to_string(output_file).unwrap(); 122 | 123 | // We trim here because we don't care about ending newlines. 124 | assert_eq!(expected.trim(), actual.trim()); 125 | } 126 | } 127 | 128 | #[cfg(not(target_os = "windows"))] 129 | #[test] 130 | fn skips_trees_when_file_exists() { 131 | let cwd = env::current_dir().unwrap(); 132 | let binary_path = get_binary_path(); 133 | let tests_path = cwd.join("tests").join("scaffold"); 134 | let trees = ["basic.tree", "complex.tree", "multiple_roots.tree"]; 135 | 136 | for tree_name in trees { 137 | let tree_path = tests_path.join(tree_name); 138 | let output = cmd(&binary_path, "scaffold", &tree_path, &["-w"]); 139 | let actual = String::from_utf8(output.stderr).unwrap(); 140 | 141 | let expected = format!("{}", "warn".yellow()); 142 | assert!(actual.starts_with(&expected)); 143 | } 144 | } 145 | 146 | #[test] 147 | fn errors_when_tree_is_empty() { 148 | let cwd = env::current_dir().unwrap(); 149 | let binary_path = get_binary_path(); 150 | let tests_path = cwd.join("tests").join("scaffold"); 151 | let trees = ["empty.tree"]; 152 | 153 | for tree_name in trees { 154 | let tree_path = tests_path.join(tree_name); 155 | let output = cmd(&binary_path, "scaffold", &tree_path, &[]); 156 | let actual = String::from_utf8(output.stderr).unwrap(); 157 | 158 | assert!(actual.contains("found an empty tree")); 159 | } 160 | } 161 | 162 | #[test] 163 | fn errors_when_condition_appears_multiple_times() { 164 | let cwd = env::current_dir().unwrap(); 165 | let binary_path = get_binary_path(); 166 | let tests_path = cwd.join("tests").join("scaffold"); 167 | let trees = ["duplicated_top_action.tree"]; 168 | 169 | for tree_name in trees { 170 | let tree_path = tests_path.join(tree_name); 171 | let output = cmd(&binary_path, "scaffold", &tree_path, &[]); 172 | let actual = String::from_utf8(output.stderr).unwrap(); 173 | 174 | assert!(actual.contains("found an identifier more than once")); 175 | } 176 | } 177 | 178 | #[cfg(not(target_os = "windows"))] 179 | #[test] 180 | fn errors_when_root_contract_identifier_is_missing_multiple_roots() { 181 | let cwd = env::current_dir().unwrap(); 182 | let binary_path = get_binary_path(); 183 | let tests_path = cwd.join("tests").join("scaffold"); 184 | let trees = ["contract_name_missing_multiple_roots.tree"]; 185 | 186 | for tree_name in trees { 187 | let tree_path = tests_path.join(tree_name); 188 | let output = cmd(&binary_path, "scaffold", &tree_path, &[]); 189 | let actual = String::from_utf8(output.stderr).unwrap(); 190 | 191 | assert!(actual.contains("contract name missing at tree root #1")); 192 | } 193 | } 194 | 195 | /// If you pass an invalid glob to `bulloak scaffold`, 196 | /// it should warn but still exit code = 0 and produce no contract. 197 | #[test] 198 | fn scaffold_invalid_glob_warns_but_no_output() { 199 | let cwd = env::current_dir().unwrap(); 200 | let bin = common::get_binary_path(); 201 | 202 | // Deliberately invalid glob (unmatched '['). 203 | let bad_glob = cwd.join("tests").join("scaffold").join("*[.tree"); 204 | let out = cmd(&bin, "scaffold", &bad_glob, &[]); 205 | 206 | assert!( 207 | out.status.success(), 208 | "scaffold should succeed even on invalid glob, got {:?}", 209 | out 210 | ); 211 | 212 | let stderr = String::from_utf8_lossy(&out.stderr); 213 | assert!( 214 | stderr.contains("could not expand"), 215 | "did not see the expected warn: {}", 216 | stderr 217 | ); 218 | 219 | let stdout = String::from_utf8_lossy(&out.stdout); 220 | assert!( 221 | !stdout.contains("contract "), 222 | "unexpected scaffold output: {}", 223 | stdout 224 | ); 225 | } 226 | 227 | #[cfg(not(target_os = "windows"))] 228 | #[test] 229 | fn scaffold_dissambiguates_function_name_collisions() { 230 | let cwd = env::current_dir().unwrap(); 231 | let binary_path = get_binary_path(); 232 | let tests_path = cwd.join("tests").join("scaffold"); 233 | let trees = ["disambiguation.tree"]; 234 | 235 | for tree_name in trees { 236 | let tree_path = tests_path.join(tree_name); 237 | let output = cmd(&binary_path, "scaffold", &tree_path, &[]); 238 | let actual = String::from_utf8(output.stdout).unwrap(); 239 | 240 | let mut output_file = tree_path.clone(); 241 | output_file.set_extension("t.sol"); 242 | let expected = fs::read_to_string(output_file).unwrap(); 243 | 244 | // We trim here because we don't care about ending newlines. 245 | assert_eq!(expected.trim(), actual.trim()); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /crates/bulloak/tests/scaffold/complex.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.0; 3 | 4 | contract CancelTest { 5 | function test_RevertWhen_DelegateCalled() external { 6 | // it should revert 7 | } 8 | 9 | modifier whenNotDelegateCalled() { 10 | _; 11 | } 12 | 13 | function test_RevertGiven_TheIdReferencesANullStream() external whenNotDelegateCalled { 14 | // it should revert 15 | } 16 | 17 | modifier givenTheIdDoesNotReferenceANullStream() { 18 | _; 19 | } 20 | 21 | modifier givenTheStreamIsCold() { 22 | _; 23 | } 24 | 25 | function test_RevertGiven_TheStreamsStatusIsDEPLETED() 26 | external 27 | whenNotDelegateCalled 28 | givenTheIdDoesNotReferenceANullStream 29 | givenTheStreamIsCold 30 | { 31 | // it should revert 32 | } 33 | 34 | function test_RevertGiven_TheStreamsStatusIsCANCELED() 35 | external 36 | whenNotDelegateCalled 37 | givenTheIdDoesNotReferenceANullStream 38 | givenTheStreamIsCold 39 | { 40 | // it should revert 41 | } 42 | 43 | function test_RevertGiven_TheStreamsStatusIsSETTLED() 44 | external 45 | whenNotDelegateCalled 46 | givenTheIdDoesNotReferenceANullStream 47 | givenTheStreamIsCold 48 | { 49 | // it should revert 50 | } 51 | 52 | modifier givenTheStreamIsWarm() { 53 | _; 54 | } 55 | 56 | modifier whenTheCallerIsUnauthorized() { 57 | _; 58 | } 59 | 60 | function test_RevertWhen_TheCallerIsAMaliciousThirdParty() 61 | external 62 | whenNotDelegateCalled 63 | givenTheIdDoesNotReferenceANullStream 64 | givenTheStreamIsWarm 65 | whenTheCallerIsUnauthorized 66 | { 67 | // it should revert 68 | } 69 | 70 | function test_RevertWhen_TheCallerIsAnApprovedThirdParty() 71 | external 72 | whenNotDelegateCalled 73 | givenTheIdDoesNotReferenceANullStream 74 | givenTheStreamIsWarm 75 | whenTheCallerIsUnauthorized 76 | { 77 | // it should revert 78 | } 79 | 80 | function test_RevertWhen_TheCallerIsAFormerRecipient() 81 | external 82 | whenNotDelegateCalled 83 | givenTheIdDoesNotReferenceANullStream 84 | givenTheStreamIsWarm 85 | whenTheCallerIsUnauthorized 86 | { 87 | // it should revert 88 | } 89 | 90 | modifier whenTheCallerIsAuthorized() { 91 | _; 92 | } 93 | 94 | function test_RevertGiven_TheStreamIsNotCancelable() 95 | external 96 | whenNotDelegateCalled 97 | givenTheIdDoesNotReferenceANullStream 98 | givenTheStreamIsWarm 99 | whenTheCallerIsAuthorized 100 | { 101 | // it should revert 102 | } 103 | 104 | modifier givenTheStreamIsCancelable() { 105 | _; 106 | } 107 | 108 | function test_GivenTheStreamsStatusIsPENDING() 109 | external 110 | whenNotDelegateCalled 111 | givenTheIdDoesNotReferenceANullStream 112 | givenTheStreamIsWarm 113 | whenTheCallerIsAuthorized 114 | givenTheStreamIsCancelable 115 | { 116 | // it should cancel the stream 117 | // it should mark the stream as depleted 118 | // it should make the stream not cancelable 119 | } 120 | 121 | modifier givenTheStreamsStatusIsSTREAMING() { 122 | _; 123 | } 124 | 125 | modifier whenTheCallerIsTheSender() { 126 | _; 127 | } 128 | 129 | function test_GivenTheRecipientIsNotAContract() 130 | external 131 | whenNotDelegateCalled 132 | givenTheIdDoesNotReferenceANullStream 133 | givenTheStreamIsWarm 134 | whenTheCallerIsAuthorized 135 | givenTheStreamIsCancelable 136 | givenTheStreamsStatusIsSTREAMING 137 | whenTheCallerIsTheSender 138 | { 139 | // it should cancel the stream 140 | // it should mark the stream as canceled 141 | } 142 | 143 | modifier givenTheRecipientIsAContract() { 144 | _; 145 | } 146 | 147 | function test_GivenTheRecipientDoesNotImplementTheHook() 148 | external 149 | whenNotDelegateCalled 150 | givenTheIdDoesNotReferenceANullStream 151 | givenTheStreamIsWarm 152 | whenTheCallerIsAuthorized 153 | givenTheStreamIsCancelable 154 | givenTheStreamsStatusIsSTREAMING 155 | whenTheCallerIsTheSender 156 | givenTheRecipientIsAContract 157 | { 158 | // it should cancel the stream 159 | // it should mark the stream as canceled 160 | // it should call the recipient hook 161 | // it should ignore the revert 162 | } 163 | 164 | modifier givenTheRecipientImplementsTheHook() { 165 | _; 166 | } 167 | 168 | function test_WhenTheRecipientReverts() 169 | external 170 | whenNotDelegateCalled 171 | givenTheIdDoesNotReferenceANullStream 172 | givenTheStreamIsWarm 173 | whenTheCallerIsAuthorized 174 | givenTheStreamIsCancelable 175 | givenTheStreamsStatusIsSTREAMING 176 | whenTheCallerIsTheSender 177 | givenTheRecipientIsAContract 178 | givenTheRecipientImplementsTheHook 179 | { 180 | // it should cancel the stream 181 | // it should mark the stream as canceled 182 | // it should call the recipient hook 183 | // it should ignore the revert 184 | } 185 | 186 | modifier whenTheRecipientDoesNotRevert() { 187 | _; 188 | } 189 | 190 | function test_WhenThereIsReentrancy1() 191 | external 192 | whenNotDelegateCalled 193 | givenTheIdDoesNotReferenceANullStream 194 | givenTheStreamIsWarm 195 | whenTheCallerIsAuthorized 196 | givenTheStreamIsCancelable 197 | givenTheStreamsStatusIsSTREAMING 198 | whenTheCallerIsTheSender 199 | givenTheRecipientIsAContract 200 | givenTheRecipientImplementsTheHook 201 | whenTheRecipientDoesNotRevert 202 | { 203 | // it should cancel the stream 204 | // it should mark the stream as canceled 205 | // it should call the recipient hook 206 | // it should ignore the revert 207 | } 208 | 209 | function test_WhenThereIsNoReentrancy1() 210 | external 211 | whenNotDelegateCalled 212 | givenTheIdDoesNotReferenceANullStream 213 | givenTheStreamIsWarm 214 | whenTheCallerIsAuthorized 215 | givenTheStreamIsCancelable 216 | givenTheStreamsStatusIsSTREAMING 217 | whenTheCallerIsTheSender 218 | givenTheRecipientIsAContract 219 | givenTheRecipientImplementsTheHook 220 | whenTheRecipientDoesNotRevert 221 | { 222 | // it should cancel the stream 223 | // it should mark the stream as canceled 224 | // it should make the stream not cancelable 225 | // it should update the refunded amount 226 | // it should refund the sender 227 | // it should call the recipient hook 228 | // it should emit a {CancelLockupStream} event 229 | // it should emit a {MetadataUpdate} event 230 | } 231 | 232 | modifier whenTheCallerIsTheRecipient() { 233 | _; 234 | } 235 | 236 | function test_GivenTheSenderIsNotAContract() 237 | external 238 | whenNotDelegateCalled 239 | givenTheIdDoesNotReferenceANullStream 240 | givenTheStreamIsWarm 241 | whenTheCallerIsAuthorized 242 | givenTheStreamIsCancelable 243 | givenTheStreamsStatusIsSTREAMING 244 | whenTheCallerIsTheRecipient 245 | { 246 | // it should cancel the stream 247 | // it should mark the stream as canceled 248 | } 249 | 250 | modifier givenTheSenderIsAContract() { 251 | _; 252 | } 253 | 254 | function test_GivenTheSenderDoesNotImplementTheHook() 255 | external 256 | whenNotDelegateCalled 257 | givenTheIdDoesNotReferenceANullStream 258 | givenTheStreamIsWarm 259 | whenTheCallerIsAuthorized 260 | givenTheStreamIsCancelable 261 | givenTheStreamsStatusIsSTREAMING 262 | whenTheCallerIsTheRecipient 263 | givenTheSenderIsAContract 264 | { 265 | // it should cancel the stream 266 | // it should mark the stream as canceled 267 | // it should call the sender hook 268 | // it should ignore the revert 269 | } 270 | 271 | modifier givenTheSenderImplementsTheHook() { 272 | _; 273 | } 274 | 275 | function test_WhenTheSenderReverts() 276 | external 277 | whenNotDelegateCalled 278 | givenTheIdDoesNotReferenceANullStream 279 | givenTheStreamIsWarm 280 | whenTheCallerIsAuthorized 281 | givenTheStreamIsCancelable 282 | givenTheStreamsStatusIsSTREAMING 283 | whenTheCallerIsTheRecipient 284 | givenTheSenderIsAContract 285 | givenTheSenderImplementsTheHook 286 | { 287 | // it should cancel the stream 288 | // it should mark the stream as canceled 289 | // it should call the sender hook 290 | // it should ignore the revert 291 | } 292 | 293 | modifier whenTheSenderDoesNotRevert() { 294 | _; 295 | } 296 | 297 | function test_WhenThereIsReentrancy2() 298 | external 299 | whenNotDelegateCalled 300 | givenTheIdDoesNotReferenceANullStream 301 | givenTheStreamIsWarm 302 | whenTheCallerIsAuthorized 303 | givenTheStreamIsCancelable 304 | givenTheStreamsStatusIsSTREAMING 305 | whenTheCallerIsTheRecipient 306 | givenTheSenderIsAContract 307 | givenTheSenderImplementsTheHook 308 | whenTheSenderDoesNotRevert 309 | { 310 | // it should cancel the stream 311 | // it should mark the stream as canceled 312 | // it should call the sender hook 313 | // it should ignore the revert 314 | } 315 | 316 | function test_WhenThereIsNoReentrancy2() 317 | external 318 | whenNotDelegateCalled 319 | givenTheIdDoesNotReferenceANullStream 320 | givenTheStreamIsWarm 321 | whenTheCallerIsAuthorized 322 | givenTheStreamIsCancelable 323 | givenTheStreamsStatusIsSTREAMING 324 | whenTheCallerIsTheRecipient 325 | givenTheSenderIsAContract 326 | givenTheSenderImplementsTheHook 327 | whenTheSenderDoesNotRevert 328 | { 329 | // it should cancel the stream 330 | // it should mark the stream as canceled 331 | // it should make the stream not cancelable 332 | // it should update the refunded amount 333 | // it should refund the sender 334 | // it should call the sender hook 335 | // it should emit a {MetadataUpdate} event 336 | // it should emit a {CancelLockupStream} event 337 | } 338 | } 339 | --------------------------------------------------------------------------------