ASTTransform
ASTTransform is an Abstract Syntax Tree (AST) transformation framework. It hooks into the compilation process and allows to perform AST transformations using an annotation: transform!.
Transformed code is emitted line-aligned: every statement carrying a source location is placed on its original source line. Backtraces, failure messages, break file:line breakpoints, and debugger display are therefore correct by construction — no source maps, no backtrace filtering, no debugger integration required.
Installation
Add this line to your application's Gemfile:
gem 'ast_transform'
And then execute:
$ bundle
Or install it yourself as:
$ gem install ast_transform
Add this to the very beginning of your script or application to install the ASTTransform hook:
require 'ast_transform'
ASTTransform.install
Compatibility with Bootsnap
ASTTransform is compatible with Bootsnap. The only requirement is to install the above hook after Bootsnap, and ASTTransform does the rest for you.
Usage
Getting started using ASTTransform is extremely easy! All you need is to use the transform! annotation:
transform!(MyTransformation)
class MyClass
# ...
end
When your class is required and loaded into the runtime, ASTTransform will run the MyTransformation transformation on the annotated code.
Supported annotated code
The following expressions can be annotated, which will pass only the annotated AST node to the transformation:
Class definitions
transform!(MyTransformation)
class Foo
# ...
end
Constant assignments
transform!(MyTransformation)
Foo = Class.new do
# ...
end
Running multiple transformations
On the same AST node
You can run multiple transformations on the same code, by passing multiple transformations to the annotation:
transform!(MyTransformation1, MyTransformation2)
class Foo
# ...
end
Note: The transformations will be executed in order, the output of the previous transformation being fed into the next, etc...
On different AST nodes
Because each transform! annotation runs transformations in isolated scope, it is possible to have multiple annotated nodes in the same file:
transform!(MyTransformation)
class Foo
# ...
end
transform!(MyTransformation)
class Bar
# ...
end
You can even have nested transform! annotations:
transform!(FooTransformation)
class Foo
transform!(BarTransformation)
class Bar
# ...
end
# ...
end
The above code would first process class Foo using FooTransformation (which could even make modifications to Bar on its own), and then BarTransformation would be run against Bar.
Writing Transformations
For more in-depth information regarding processing AST nodes, we recommend looking at https://github.com/whitequark/ast, as transformations are built on top of Parser::AST::Processor, which in turn is built on top of the ast gem.
Transformations should derive from ASTTransform::AbstractTransformation:
require 'ast_transform/abstract_transformation'
class MyTransformation < ASTTransformation::AbstractTransformation
# ...
end
Transformation discoverability
ASTTransform automatically loads your transformations at compile time. As such, we expect your files to be located at a known path.
Transformations are required using the following scheme, i.e. for MyNamespace::MyTransformation, it will make the following call, so your file must be placed accordingly for ASTTransform to find it:
require 'my_namespace/my_transformation'
Processing each node
To do some processing on each node, override the process_node private method. If you do this, make sure to also process the children nodes if required.
require 'ast_transform/abstract_transformation'
class MyTransformation < ASTTransformation::AbstractTransformation
private
def process_node(node)
# ... processing
node.updated(nil, process_all(node.children))
end
end
In the above, node#updated allows updating the node, either its type or its children. Each node is immutable, so updating nodes requires recursively re-creating the tree from the deepest modified nodes. Passing nil as the first argument keeps the same node type.
Processing on certain types of nodes only
The ast gem uses a pattern in which a Transformation may implement a method matching a node type, i.e. on_class, on_send, on_lvar, etc... This is very useful when transformations should process all nodes of this type.
Line-aligned emission and the authoring contract
ASTTransform owns text and lines; transform authors own semantics and execution order. The contract:
- A node with a source location is emitted at that location's line (the emitter pads with blank lines to reach it, and packs with
;when a line is already occupied). - A node without a source location is synthetic: it packs onto the current line and inherits its neighbors' line number.
- Textual order is source order. If your transform needs code to execute in a different order than it appears, use a thunk (below) instead of moving nodes.
ASTTransform::TransformationHelper (included by AbstractTransformation) provides the authoring toolkit:
s(type, *children)— builds a loc-less (synthetic) node. Registered custom types (see below) construct their registered class.s_at(anchor, type, *children)— builds a node anchored atanchor's source location, so it is emitted atanchor's line. RaisesTransformationHelper::MissingLocationErrorif the anchor has no location.thunk(*statements)— wraps statements in a singleThunknode: splice it wherever the statements must run, in statement position or composed inside an expression (e.g. anassert_raisesblock body). The wrapped statements keep their own locations, and the lowering derives the hidden proc's textual placement from them — the body still emits on its source lines even though execution waits. Reuse the same node to execute one body from several points. Thunk construction is invariant-checked (ArgumentErroron a missing id or empty body); a body whose source lines fall after its execution point fails lowering withThunkLowering::PlacementError(a thunk can only delay execution, never text).run_after(statements, run:, after:)— the paved road overthunk: returns a reordered copy ofstatementswhere the contiguousrunexecutes afterafter, while remaining at its source position textually. Elements are matched by object identity.
Thunked statements keep their original meaning as far as Ruby's closure semantics allow:
returnstill returns from the enclosing method — the hidden closure is a non-lambda proc, and the proc and its call always share one method activation (placements never cross adefboundary).- Locals assigned by the thunked statements stay method-scope: the lowering pre-declares each one (
result = result) before the proc, so code after the execution point can read them. Before the thunk runs they arenil— exactly what an unexecuted assignment yields. - Jump keywords whose owner lies outside the thunked statements keep Ruby's native behavior:
break/retryraiseLocalJumpErrorat the jump's own source line, whilenext/redosilently end or restart the thunk body. ASTTransform does not validate this — what a transform surface allows users to thunk is the transform author's call.
Gotcha: location-only rewrites are silently dropped
Parser::AST::Node#updated returns self when the new children compare == to the old ones — and AST::Node#== ignores source locations. A Processor pass that replaces a node with an equal-valued one (e.g. the same call rebuilt loc-less, hoping to change its emitted line) is a no-op: every node.updated(nil, process_all(node)) up the tree discards the replacement. Location is part of a node's emission, not its value — to change where a node emits, change what it is (s_at an anchored rebuild with different children), or restructure the parent explicitly rather than relying on updated.
Custom node types (intermediate representation)
Transformations that parse a DSL can build their own IR by registering node classes:
class InteractionNode < ASTTransform::Node
register :my_interaction
def cardinality = children[0]
end
s(:my_interaction, ...) # => InteractionNode, with domain accessors
Custom node types are IR between stages that understand them — the stage that owns a type must lower it to plain Ruby nodes before emission. The emitter enforces this: any registered or ast_-prefixed type reaching emission raises LineAlignedEmitter::UnloweredNodeTypeError.
Testing your transformation
require 'ast_transform/testing/assertions' (test-only) provides ASTTransform::Testing::Assertions, a Minitest-flavored module to include in your test class:
assert_line_aligned(source, *transformations)— transformssourcethrough the real pipeline and asserts every surviving statement is emitted at its source line.assert_backtrace_lines(source, path:, raise_at:)— compiles and executessource, asserting the raw first backtrace frame citespath:raise_atwith no filtering.
Parameterizable transformations
If you want your transformation to be customizable, accept the parameters in the constructor. The annotation can the be changed accordingly:
class FooTransformation < ASTTransform::AbstractTransformation
def initialize(param1, params2: false)
# ...
end
# ...
end
transform!(FooTransformation.new(param1, param2: true))
class Foo
# ...
end
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
If you use the d3mlabs dev tool, dev up provisions the pinned Ruby (see .ruby-version) with a per-project shadowenv, and dev test runs the suite — plain Bundler as above works just as well.
To install this gem onto your local machine, run bundle exec rake install.
Releasing a New Version
There are two ways to create a release. Both require that version.rb has already been updated and merged to main.
Via GitHub UI
- Update
VERSIONinlib/ast_transform/version.rband runbundle installto regenerateGemfile.lock, commit, open a PR, and merge to main - Go to the repo on GitHub → Releases → Draft a new release
- Enter a new tag (e.g.
v2.0.0), selectmainas the target branch - Add a title and release notes (GitHub can auto-generate these from merged PRs)
- Click Publish release
Via CLI
- Update
VERSIONinlib/ast_transform/version.rband runbundle installto regenerateGemfile.lock, commit, open a PR, and merge to main - Tag and push:
git checkout main && git pull git tag v2.0.0 git push origin v2.0.0
In both cases, the release workflow validates that the tag matches version.rb, builds the gem, and publishes it to rubygems.org via Trusted Publishing (no API key needed). If there's a mismatch, the workflow fails before publishing.
One-time setup
Configure the gem as a trusted publisher on rubygems.org so that the release workflow can publish automatically. See the Trusted Publishing guide for details.