_|_|_| _|_|_|_| _|_|_|_| _|_| _|_|_| _|_|_| _|_|_|_| _| _| _| _| _| _| _| _| _| _| _|_|_| _|_|_| _|_|_| _| _| _|_|_| _| _|_| _|_|_| _| _| _| _| _| _| _| _| _| _| _| _| _| _|_|_|_| _| _|_| _| _| _|_|_| _|_|_|_|
Reforge is a Rust library that wraps Forge with a macro expansion pipeline for Solidity. It lets you programmatically transform Solidity source files before they are handed to solc, using the full semantic analysis provided by Solar.
Reforge intercepts the Forge build pipeline at the preprocessing stage. Before solc sees your sources, each registered macro rule runs against the fully-parsed and type-resolved HIR (High-level Intermediate Representation) of the project. Rules can inspect any declaration in the project and inject or rewrite source text. The modified sources are then compiled as normal.
This repository vendors Foundry source directly. After cloning, initialise the forge-std submodule used by the sample project before building:
git clone https://github.com/ava-labs/reforge
cd reforge
git submodule update --init --recursive
cargo buildReforge is a library. Downstream users write a small Rust binary that depends on it, define their macro rules, and call MacroRules::run() as their main. The resulting binary is a drop-in replacement for forge, supporting the build, test, coverage, and snapshot subcommands with macro expansion applied automatically.
A macro rule is a plain function with the signature:
fn my_rule(ctx: &solar::sema::Gcx, data: &mut reforge::PreprocessingData<'_>)
-> foundry_compilers::error::Result<()>ctx gives you read access to the full Solar HIR — structs, contracts, functions, variables, etc. data.input is a mutable map of file paths to source content; write your transformations there by inserting or replacing text at byte offsets.
use reforge::{MacroRules, PreprocessingData};
use solar::sema::Gcx;
fn main() -> eyre::Result<()> {
let mut macros = MacroRules::default();
macros.rules.push(my_rule);
macros.run()
}run() parses the CLI and executes the command with your rules applied as a preprocessing step. Macro expansion is enabled by default. Two reforge-specific flags are available on top of all standard forge flags:
| Flag | Description |
|---|---|
--disable-macros |
Skip macro expansion and run as a plain forge wrapper. |
--display <GLOB> |
Print macro-expanded sources matching GLOB (relative to the build root) to stdout and exit. Only supported with the build subcommand. |
| Field | Type | Description |
|---|---|---|
input |
&mut Sources |
Mutable map of path → source content |
root_dir |
&Path |
Project root |
src_dir |
&Path |
Configured sources directory |
mocks |
&mut HashSet<PathBuf> |
Mock files generated by Forge |
offset_adjustments |
OffsetAdjustment |
Accumulated byte-offset and line-number shifts from prior edits. Prefer entry() over editing this directly. |
Solar's HIR spans reference byte positions in the original, unmodified source. When an earlier macro inserts or removes text, every subsequent HIR-derived offset in that file is wrong by the cumulative amount of those edits.
Use data.entry instead of editing data.input directly. The builder translates original-source offsets through prior adjustments, applies the edit, and records the delta automatically:
// Insert text at an original-source offset:
data.entry(path, "new text").insert(original_offset);
// Replace a range given in original-source coordinates:
data.entry(path, "replacement").replace(original_start..original_end);Both methods return an EditInfo with the position of the edit in the expanded source:
let info = data.entry(path, "new text").insert(original_offset);
// info.expanded_line — 1-based line in the expanded source where the edit landed
// info.delta_lines — net lines added (positive) or removed (negative)If you need the current adjusted position of an original-source offset (e.g. to scan the already-modified text before deciding what to insert), use data.adjusted_offset(path, original_offset).
When inserting generated code, attach a name and an optional triggering location so that compiler errors inside the generated code are reported with context rather than a raw line number:
use reforge::MacroOriginalLocation;
let trigger_line = 10; // 1-based line of the attribute or comment that triggered generation
let trigger_col = 1;
let loc = MacroOriginalLocation { file: path.to_path_buf(), line: trigger_line, col: trigger_col };
data.entry(path, "generated code")
.with("my_macro_name", Some(loc))
.insert(original_offset);When solc reports an error inside code produced by this insertion, Reforge rewrites the message to:
error in code generated by macro 'my_macro_name': <original message>
--> path/to/file.sol:10:1:
|
10 | // #[my_attribute]
|
Pass None as the location to attribute the error to the macro by name only, without a specific source position.
pub fn get_comment(
ctx: &Gcx,
source_id: solar::sema::hir::SourceId,
span: solar::interface::Span,
data: &PreprocessingData<'_>,
) -> Option<String>Returns the comment block immediately preceding the HIR item at span, or None if there is no comment. Useful for attribute-style macros that look for annotations like // #[derive(foo)] placed above a declaration.
More examples can be found in examples/macros.rs.
When solc reports an error in a macro-expanded file, Reforge automatically translates the line numbers back to the original source. If the error falls inside code that a macro injected, the message is rewritten to attribute it to the macro:
- If the insertion was annotated via
entry().with(name, loc), the message becomes"error in code generated by macro '<name>': <original message>"and the formatted error points to the triggering location in the original source. - Otherwise the message becomes
"error in macro-generated code: <original message>".
No action is required from macro authors for basic remapping; use entry().with() for richer attribution (see Macro attribution).
Reforge provides utilities in reforge::testing for writing unit tests against macro rules without needing a full Forge project.
Tests are structured around three directories:
| Directory | Purpose |
|---|---|
source/ |
Input .sol files (read-only — macros run against a copy) |
expected/ |
Pre-expanded .sol files to compare against |
mismatches/ |
Written on failure: the actual expanded output, ready to copy to expected/ if correct |
reforge::testing::test_macros(
"tests/source",
"tests/expected",
"tests/mismatches",
&[rule1, rule2, ...],
)?;Runs the rules in order and compares the expanded sources against expected/ file-by-file (matched by relative path). On any mismatch the actual content is written to mismatches/ and the call returns an error listing the differing files. To accept new output, copy mismatches/ over expected/.
let err = reforge::testing::test_macro_err("tests/source", my_rule)?;
assert!(err.to_string().contains("expected error message"));Expects the rule to return an error. Returns the error as an eyre::Report so you can assert on its message. Fails if the rule completes without error.
let sources = reforge::testing::expand_macros("tests/source", &[my_rule])?;Runs the rules and returns the expanded Sources map directly, for cases where you need custom assertions beyond file equality.
The examples/ directory contains a working example. It defines a print_name macro that, for every struct Foo in the project, injects a print_Foo() function into a pre-declared library FooLibrary.
The sample project lives in sample_proj/. It contains a Dummy struct and an empty DummyLibrary library.
From the repository root:
Build:
cargo run --example macros -- build --root sample_proj --forceTest:
cargo run --example macros -- test --root sample_projIf your tests use vm.createFork or other RPC-dependent cheatcodes, set ETHERSCAN_API_KEY in the environment before running — the test subcommand reads it automatically (equivalent to --etherscan-api-key).
Snapshot:
cargo run --example macros -- snapshot --root sample_projCoverage:
cargo run --example macros -- coverage --root sample_projDisplay expanded output:
cargo run --example macros -- --display "*.sol" build --root sample_projThis prints the macro-expanded content of every .sol file directly under sample_proj/ to stdout. The glob is matched relative to the root with path-separator-aware rules: * does not cross directory boundaries, so *.sol matches only root-level files (e.g. Sample.sol) while **/*.sol matches recursively across all subdirectories.
Expected build output:
Compiling 11 files with Solc 0.8.30
Compiler run successful!
After compilation, sample_proj/out/Sample.sol/DummyLibrary.json will contain a print_Dummy() function in its ABI (injected by the macro):
{
"type": "function",
"name": "printDummy",
"inputs": [],
"outputs": [{ "name": "", "type": "string", "internalType": "string" }],
"stateMutability": "pure"
}When running tests, Solar (used internally by forge for source mapping) may print errors like:
error: unresolved symbol `printDummy`
These refer to symbols that macros will inject and are safe to ignore — they do not affect whether tests pass or fail.
Solidity inlines free-standing functions (those declared outside any contract or library) at call sites. If no contract calls them, the compiler drops them entirely. Any function you want to appear in the compiled artifact must be injected inside a contract or library body.
Forge's --dynamic-test-linking flag is incompatible with macro expansion and will be ignored if enabled.
Text injected by one macro rule is not re-parsed by Solar, so it is invisible to subsequent rules. If you need a later rule to act on something an earlier rule injected, emit the final form directly from the first rule rather than relying on the second rule to transform it.
Foundry discovers which contracts exist by scanning source files before the preprocessor runs. If a macro injects an entirely new library or contract declaration into a file, Foundry will compile it without errors but will not write a .json artifact for it. The workaround is to pre-declare an empty shell of every contract the macro will populate, so Foundry registers it during its initial scan.
reforge is licensed under the MIT License. Copyright (c) 2026 Ava Labs, Inc.
Built on Foundry and Solar — reforge vendors Foundry source directly and uses Solar for full semantic analysis of Solidity.
This repository includes vendored source from Foundry (MIT/Apache-2.0, Copyright (c) 2021 Georgios Konstantopoulos) and forge-std (MIT/Apache-2.0, Copyright Contributors to Forge Standard Library). See NOTICE for full attribution.
