Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.1.0] - 2026-07-29
### Added
- Analysis passes: `ASTTransform::AbstractAnalysis`, the read-only counterpart of `AbstractTransformation`. Both leaves share the extracted traversal core `ASTTransform::AbstractProcessor` (sealed `process`, `process_node` hook, thunk descent, `TransformationHelper`) and differ only in what `run` returns — the rebuilt tree for transformations, the analysis instance (result readers) for analyses.
- Public parse seam: `ASTTransform::SourceParser` (`#parse(source, file_path:)` / `#parse_file(path)`) extracts the framework's Prism-backed parsing from `Transformer` so analysis-only consumers that never emit can instantiate it directly. `Transformer#build_ast` / `#build_ast_from_file` delegate to it (unchanged public behavior).
- Pass-taxonomy documentation on `AbstractTransformation`, `Transformation`, and the README: structural (`on_*` handlers, pattern matched anywhere), positional (node builders with the bare `run(node) → node` duck type, caller owns traversal), and sibling-annotation (marker statement + next sibling, matched in `process_node` where the child list is visible — `transform!` itself).

## [3.0.0] - 2026-07-24
### Added
- Line-aligned emission: transformed code is emitted with every loc-carrying statement on its original source line, making backtraces, breakpoints, and debugger display correct by construction (`LineAlignedEmitter`).
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
ast_transform (3.0.0)
ast_transform (3.1.0)
parser (>= 3.3)
prism (>= 1.5)
unparser (>= 0.8)
Expand Down
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,16 @@ class MyTransformation < ASTTransformation::AbstractTransformation
end
```

#### Choosing a pass shape

Passes come in three shapes, distinguished by how the rewrite site is found:

* **Structural** — the pattern alone identifies the site. Subclass `AbstractTransformation` and write `on_*` handlers; the pass rewrites the pattern wherever it occurs in the tree.
* **Positional (node builders)** — the *caller* owns traversal and has already located the site. Implement a plain `run(node) → node` object (include `TransformationHelper` for the `s(...)` vocabulary); the builder maps a single node to its replacement subtree and never walks. Anything with that duck type composes with `Transformer` and other passes.
* **Sibling-annotation** — the pattern is a marker statement plus its NEXT sibling. Match in `process_node`, where a node's child list is visible: an `on_*` handler sees one node, never its siblings, and cannot delete itself from its parent. `ASTTransform::Transformation` (the `transform!` detector) is the canonical example.

For read-only passes that harvest information instead of rewriting, see [Analysis passes](#analysis-passes).

#### Transformation discoverability

ASTTransform automatically loads your transformations at compile time. As such, we expect your files to be located at a known path.
Expand Down Expand Up @@ -162,6 +172,48 @@ In the above, `node#updated` allows updating the node, either its type or its ch

The [ast gem](https://github.com/whitequark/ast) 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.

### Analysis passes

Not every pass rewrites. An analysis walks the tree and harvests information — `Parser::AST::Processor` has no read-only mode (every walk functionally rebuilds the tree), so an analysis is simply a walk whose rebuilt tree is discarded. Derive from `ASTTransform::AbstractAnalysis`: it shares the traversal engine with `AbstractTransformation` (including thunk descent and the `s(...)` matching vocabulary) and differs only in what `run` returns — the analysis instance, so callers chain result readers off the run.

```ruby
require 'ast_transform/abstract_analysis'

class TypeAliasRanges < ASTTransform::AbstractAnalysis
attr_reader :ranges

def initialize
@ranges = []
super
end

def on_block(node)
send_node = node.children.first
@ranges << (node.loc.expression.first_line..node.loc.expression.last_line) if type_alias?(send_node)
super
end

private

def type_alias?(send_node)
send_node == s(:send, s(:const, nil, :T), :type_alias)
end
end
```

Harvest state in `on_*` handlers and always call `super` so traversal continues. Node equality ignores source locations, so `s(...)` patterns match structurally.

Analysis-only consumers don't need a `Transformer`; instantiate the parsing seam directly (`Transformer` uses the same class internally) and keep the instance for as many parses as you need:

```ruby
parser = ASTTransform::SourceParser.new

parser.parse(source) # => Parser::AST::Node
parser.parse_file(file_path) # => Parser::AST::Node

TypeAliasRanges.new.run(parser.parse(source)).ranges
```

### Line-aligned emission and the authoring contract

ASTTransform owns text and lines; transform authors own semantics and execution order. The contract:
Expand Down
1 change: 1 addition & 0 deletions lib/ast_transform.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "ast_transform/instruction_sequence"
require "ast_transform/instruction_sequence/mixin"
require "ast_transform/instruction_sequence/bootsnap_mixin"
require "ast_transform/source_parser"

module ASTTransform
DEFAULT_OUTPUT_PATH = Pathname.new("").join("tmp", "ast_transform").to_s
Expand Down
38 changes: 38 additions & 0 deletions lib/ast_transform/abstract_analysis.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

require "ast_transform/abstract_processor"

module ASTTransform
# Base class for read-only analysis passes: subclass, harvest state in +on_*+ handlers (always call +super+ so
# traversal continues), and expose results through readers. The walk still functionally rebuilds the tree —
# Parser::AST::Processor has no read-only mode — but +run+ discards the rebuilt tree, so handlers never need to
# care what they return.
#
# class SendCounter < ASTTransform::AbstractAnalysis
# attr_reader :count
#
# def initialize
# @count = 0
# super
# end
#
# def on_send(node)
# @count += 1
# super
# end
# end
#
# SendCounter.new.run(SourceParser.new.parse(source)).count
class AbstractAnalysis < AbstractProcessor
# Runs this analysis on +node+, discarding the rebuilt tree.
# Note: If you want to add one-time setup or result finalization, override this, then call super.
#
# @param node [Parser::AST::Node] The node to be analyzed.
#
# @return [ASTTransform::AbstractAnalysis] self, so callers can chain result readers off the run.
def run(node)
process(node)
self
end
end
end
39 changes: 39 additions & 0 deletions lib/ast_transform/abstract_processor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

require "ast_transform/transformation_helper"

module ASTTransform
# Shared traversal core for tree passes. Parser::AST::Processor is a rewriting walker — every visit functionally
# rebuilds the tree — so transformation and analysis share one engine and differ only in what they keep:
# AbstractTransformation's +run+ returns the rebuilt tree, AbstractAnalysis's +run+ discards it and returns the
# harvested results. Subclass one of those leaves rather than this class.
class AbstractProcessor < Parser::AST::Processor
include TransformationHelper

# Used internally by Parser::AST::Processor to process each node. DO NOT OVERRIDE.
def process(node)
return node unless node.is_a?(Parser::AST::Node)

process_node(node)
end

# Thunks are framework-owned IR: descend into the body so passes that don't know about thunks still process the
# wrapped statements. Without this, Processor's handler_missing default would pass the node through opaquely,
# hiding the body from every later pass. The token (first child) is not a node and passes through untouched.
def on_ast_thunk(node)
node.updated(nil, [node.children[0], *process_all(node.children.drop(1))])
end

private

# Processes the given +node+.
# Note: If you want to do processing on each node, override this.
#
# @param node [Parser::AST::Node] The node being visited.
#
# @return [Parser::AST::Node] The rebuilt node.
def process_node(node)
method(:process).super_method.call(node)
end
end
end
49 changes: 17 additions & 32 deletions lib/ast_transform/abstract_transformation.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
# frozen_string_literal: true

require "ast_transform/transformation_helper"
require "ast_transform/abstract_processor"

module ASTTransform
class AbstractTransformation < Parser::AST::Processor
include TransformationHelper

# Runs this transformation on +node+.
# Base class for structural transformations: subclass and write +on_*+ handlers to rewrite a pattern wherever it
# occurs in the tree. This is one of three authoring shapes — pick by how the rewrite site is found:
#
# - Structural (this class): the pattern alone identifies the site, so +on_*+ handlers rewrite it anywhere in
# the tree.
# - Positional (node builders): a plain object with +run(node) → node+, including only TransformationHelper. The
# caller owns traversal and applies the builder at a site it already located; the builder never walks. Anything
# with that duck type composes with Transformer and other passes — deriving from this class is just one way to
# implement it.
# - Sibling-annotation: the pattern is a marker statement plus its NEXT sibling, matched in +process_node+ where
# the child list is visible — an +on_*+ handler sees one node, never its siblings, and cannot delete itself
# from its parent. See ASTTransform::Transformation (the +transform!+ detector) for the canonical example.
#
# For read-only passes that harvest information instead of rewriting, see ASTTransform::AbstractAnalysis.
class AbstractTransformation < AbstractProcessor
# Runs this transformation on +node+ and returns the rebuilt tree.
# Note: If you want to add one-time checks to the transformation, override this, then call super.
#
# @param node [Parser::AST::Node] The node to be transformed.
Expand All @@ -15,32 +27,5 @@ class AbstractTransformation < Parser::AST::Processor
def run(node)
process(node)
end

# Used internally by Parser::AST::Processor to process each node. DO NOT OVERRIDE.
def process(node)
return node unless node.is_a?(Parser::AST::Node)

process_node(node)
end

# Thunks are framework-owned IR: descend into the body so passes that don't know about thunks still process the
# wrapped statements. Without this, Processor's handler_missing default would pass the node through opaquely,
# hiding the body from every later transformation. The token (first child) is not a node and passes through
# untouched.
def on_ast_thunk(node)
node.updated(nil, [node.children[0], *process_all(node.children.drop(1))])
end

private

# Processes the given +node+.
# Note: If you want to do processing on each node, override this.
#
# @param node [Parser::AST::Node] The node to be transformed.
#
# @return [Parser::AST::Node] The transformed node.
def process_node(node)
method(:process).super_method.call(node)
end
end
end
19 changes: 9 additions & 10 deletions lib/ast_transform/kwargs_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,21 @@
require "prism/translation/parser"

module ASTTransform
# Extends the default Prism parser builder to distinguish keyword arguments from hash literals in the AST.
# The framework's parser builder: the stock builder with the parser gem's `emit_kwargs` opt-in, which
# distinguishes keyword arguments from hash literals in the AST.
#
# The upstream builder always emits :hash nodes for both `foo(bar: 1)` and `foo({ bar: 1 })`. Unparser uses the
# node type to decide whether to emit braces: :hash gets `{}`, :kwargs does not. Since Ruby 3.0+ treats these as
# semantically different (strict keyword/positional separation), we need the AST to preserve the distinction.
# With the flag off (the gem's compatibility default) both `foo(bar: 1)` and `foo({ bar: 1 })` emit :hash nodes.
# Unparser uses the node type to decide whether to emit braces: :hash gets `{}`, :kwargs does not. Since Ruby 3.0+
# treats these as semantically different (strict keyword/positional separation), the AST must preserve the
# distinction. `emit_kwargs` rewrites at the call sites where kwargs semantics live (method calls, index,
# super/yield); the flag is a class-level ivar, so setting it here opts in this builder only — the global
# Parser::Builders::Default stays untouched.
#
# NOTE: parsed nodes deliberately stay plain Parser::AST::Node. Custom node classes exist only for registered
# custom types (see ASTTransform::Node), which are IR and never reach Unparser: AST::Node#eql? compares class, and
# Unparser verifies dynamic-string emission by re-parsing and comparing eql? against the freshly parsed
# (plain-class) node — custom-class nodes of standard types would fail that verification.
class KwargsBuilder < Prism::Translation::Parser::Builder
def associate(begin_t, pairs, end_t)
node = super
return node unless begin_t.nil? && end_t.nil?

node.updated(:kwargs)
end
self.emit_kwargs = true
end
end
59 changes: 59 additions & 0 deletions lib/ast_transform/source_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

require "prism"
require "prism/translation/parser"
require "ast_transform/kwargs_builder"

module ASTTransform
# Owns source → AST parsing: Prism's C parser through its whitequark translation layer, so every consumer gets the
# node vocabulary Parser::AST::Processor understands. Transformer parses through this seam; analysis-only
# consumers that never emit instantiate it directly and keep the instance for as many parses as they need.
class SourceParser
# Constructs a new SourceParser instance.
#
# KwargsBuilder is a required implementation detail, not an injection seam: the framework's emission depends
# on the kwargs/hash distinction it preserves, so every parse goes through it.
def initialize
@builder = KwargsBuilder.new
end

# Parses the given +source+.
#
# @param source [String] The input source code.
# @param file_path [String] The file path recorded on source locations. This is important for source mapping
# in backtraces.
#
# @return [Parser::AST::Node] The AST.
def parse(source, file_path: "tmp")
# A fresh parser per parse: parser instances accumulate per-run state (lexer position, diagnostics), and
# constructing one is trivial next to the parse itself.
parser = Prism::Translation::Parser.new(@builder)
parser.parse(create_buffer(source, file_path, parser.default_encoding))
end

# Parses the source in the given +file_path+.
#
# @param file_path [String] The input file path.
#
# @return [Parser::AST::Node] The AST.
def parse_file(file_path)
parse(File.read(file_path), file_path: file_path)
end

private

# Builds a source buffer over +source+ in the given +encoding+.
#
# @param source [String] The input source code.
# @param file_path [String] The file path recorded on the buffer.
# @param encoding [Encoding] The encoding the buffer's source is coerced to.
#
# @return [Parser::Source::Buffer] The buffer.
def create_buffer(source, file_path, encoding)
buffer = Parser::Source::Buffer.new(file_path)
buffer.source = source.dup.force_encoding(encoding)

buffer
end
end
end
5 changes: 5 additions & 0 deletions lib/ast_transform/transformation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
require "unparser"

module ASTTransform
# The +transform!+ detector — the canonical sibling-annotation pass (see AbstractTransformation for the taxonomy).
# A +transform!(...)+ statement is a pragma on the NEXT sibling: the transformations it names are applied to the
# following class definition or constant assignment, and the marker itself is deleted from the child list. Both
# effects need the parent's child list in view, which is why matching happens in +process_node+ — an +on_send+
# handler would see the marker node alone, with no access to its next sibling and no way to remove itself.
class Transformation < ASTTransform::AbstractTransformation
TRANSFORM_AST = s(:send, nil, :transform!)

Expand Down
Loading
Loading