ast_transform 2.1.4 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/CODEOWNERS +1 -0
- data/.github/workflows/ci.yml +11 -1
- data/.gitignore +4 -1
- data/.rubocop.yml +5 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +22 -0
- data/Gemfile +12 -1
- data/Gemfile.lock +38 -11
- data/README.md +106 -2
- data/Rakefile +8 -7
- data/ast_transform.gemspec +11 -12
- data/bin/console +1 -0
- data/dependencies.rb +11 -0
- data/dev.yml +11 -0
- data/lib/ast_transform/abstract_analysis.rb +38 -0
- data/lib/ast_transform/abstract_processor.rb +39 -0
- data/lib/ast_transform/abstract_transformation.rb +18 -24
- data/lib/ast_transform/instruction_sequence/bootsnap_mixin.rb +4 -4
- data/lib/ast_transform/instruction_sequence/mixin.rb +6 -5
- data/lib/ast_transform/instruction_sequence/mixin_utils.rb +1 -1
- data/lib/ast_transform/instruction_sequence.rb +3 -2
- data/lib/ast_transform/kwargs_builder.rb +15 -14
- data/lib/ast_transform/layout.rb +64 -0
- data/lib/ast_transform/line_aligned_emitter.rb +224 -0
- data/lib/ast_transform/node.rb +48 -0
- data/lib/ast_transform/source_parser.rb +59 -0
- data/lib/ast_transform/statement_renderer.rb +76 -0
- data/lib/ast_transform/testing/assertions.rb +97 -0
- data/lib/ast_transform/thunk.rb +55 -0
- data/lib/ast_transform/thunk_lowering.rb +240 -0
- data/lib/ast_transform/transformation.rb +24 -22
- data/lib/ast_transform/transformation_helper.rb +115 -4
- data/lib/ast_transform/transformer.rb +23 -47
- data/lib/ast_transform/version.rb +3 -1
- data/lib/ast_transform.rb +12 -15
- metadata +22 -92
- data/lib/ast_transform/source_map.rb +0 -233
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a74867dd7f6d788c78d286901e1e4c6a362e0a341300fb9bb22ccb92503a0223
|
|
4
|
+
data.tar.gz: c2968b8000a875e169f89d85deaa38868ad49dcc8d193db6a78c88ea4eafb986
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9f4e007503a09479a7df3f595097bf384eb0196fb192cd012855c63417582e34a32cfd318ae765f1a8122de408bbfd337c8e3adfe889d2fd1bc36b3a20c96599
|
|
7
|
+
data.tar.gz: 900412939feb57ee329d2c3adca9b9712c1da8243038268104db916f9270ea09427d0d96cbf5aded11f86d919d4b45e6629be20372eb4dfc1d0531f05757b1e5
|
data/.github/CODEOWNERS
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* @JPDuchesne
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -7,12 +7,22 @@ on:
|
|
|
7
7
|
branches: [main, master]
|
|
8
8
|
|
|
9
9
|
jobs:
|
|
10
|
+
lint:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: ruby/setup-ruby@v1
|
|
15
|
+
with:
|
|
16
|
+
ruby-version: '4.0'
|
|
17
|
+
bundler-cache: true
|
|
18
|
+
- run: bundle exec rubocop
|
|
19
|
+
|
|
10
20
|
test:
|
|
11
21
|
runs-on: ubuntu-latest
|
|
12
22
|
strategy:
|
|
13
23
|
fail-fast: false
|
|
14
24
|
matrix:
|
|
15
|
-
ruby: ['3.
|
|
25
|
+
ruby: ['3.3', '3.4', '4.0']
|
|
16
26
|
steps:
|
|
17
27
|
- uses: actions/checkout@v4
|
|
18
28
|
- uses: ruby/setup-ruby@v1
|
data/.gitignore
CHANGED
data/.rubocop.yml
ADDED
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.0.6
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [3.1.0] - 2026-07-29
|
|
8
|
+
### Added
|
|
9
|
+
- 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.
|
|
10
|
+
- 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).
|
|
11
|
+
- 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).
|
|
12
|
+
|
|
13
|
+
## [3.0.0] - 2026-07-24
|
|
14
|
+
### Added
|
|
15
|
+
- 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`).
|
|
16
|
+
- Authoring toolkit in `TransformationHelper`: `s_at` (loc-anchored node construction), `thunk` (a single invariant-checked `Thunk` node spliced at the execution point; the lowering derives the hidden proc's textual placement from the body's source locations), and `run_after` (sequence-level execution reordering that preserves textual/source order). Thunks lower to a non-lambda proc, so `return` still returns from the enclosing method, and locals assigned by thunked statements are pre-declared to stay method-scope. Reusing one thunk node executes its body from several points.
|
|
17
|
+
- `ASTTransform::Node.register`: type-routed construction of custom IR node classes through `s`, with an emitter postcondition (`LineAlignedEmitter::UnloweredNodeTypeError`) rejecting custom types that were not lowered before emission.
|
|
18
|
+
- `ast_transform/testing/assertions` (test-only): `assert_line_aligned` and `assert_backtrace_lines` for transform authors' suites.
|
|
19
|
+
- Error types, each owned by its producer: `TransformationHelper::MissingLocationError`, `ThunkLowering::PlacementError`, `LineAlignedEmitter::UnloweredNodeTypeError`. Thunk construction invariants raise plain `ArgumentError`.
|
|
20
|
+
|
|
21
|
+
### Removed
|
|
22
|
+
- **Breaking:** `ASTTransform::SourceMap` and source-map registration. Line-aligned emission makes raw VM line numbers the source line numbers, so there is nothing left to map at display time.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- **Breaking:** `Transformer#transform` and `#transform_file_source` emit line-aligned output (source-anchored layout, always newline-terminated) instead of Unparser's re-normalized formatting.
|
|
26
|
+
- **Breaking:** dropped Ruby 3.2 support (EOL since March 2026); `required_ruby_version` is now `>= 3.3`.
|
|
27
|
+
- Dependency floors now reflect reality: `unparser >= 0.8` (the emitter uses `static_local_variables:`, a 0.7 interface, and 0.8's prism-based round-trip verification is required for Ruby >= 3.4 syntax) and `parser >= 3.3` (unparser's own floor; the declared `>= 3.0` could never resolve lower).
|
|
28
|
+
|
|
7
29
|
## [0.1.4] 2019-06-20
|
|
8
30
|
### Fixed
|
|
9
31
|
- Source mapping for transformations wrapping source nodes into virtual nodes now work.
|
data/Gemfile
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
source "https://rubygems.org"
|
|
2
4
|
|
|
3
|
-
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
|
|
5
|
+
git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
|
|
4
6
|
|
|
5
7
|
# Specify your gem's dependencies in ast_transform.gemspec
|
|
6
8
|
gemspec
|
|
9
|
+
|
|
10
|
+
# Development dependencies
|
|
11
|
+
gem "bundler", ">= 2.1"
|
|
12
|
+
gem "minitest", "~> 5.14"
|
|
13
|
+
gem "minitest-reporters", "~> 1.4"
|
|
14
|
+
gem "pry", ">= 0.14"
|
|
15
|
+
gem "rake", "~> 13.0"
|
|
16
|
+
gem "rubocop-shopify", "~> 3.0", require: false
|
|
17
|
+
gem "simplecov", "~> 0.22"
|
data/Gemfile.lock
CHANGED
|
@@ -1,29 +1,33 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
ast_transform (
|
|
5
|
-
parser (>= 3.
|
|
4
|
+
ast_transform (3.1.0)
|
|
5
|
+
parser (>= 3.3)
|
|
6
6
|
prism (>= 1.5)
|
|
7
|
-
unparser (>= 0.
|
|
7
|
+
unparser (>= 0.8)
|
|
8
8
|
|
|
9
9
|
GEM
|
|
10
10
|
remote: https://rubygems.org/
|
|
11
11
|
specs:
|
|
12
|
-
ansi (1.
|
|
12
|
+
ansi (1.6.0)
|
|
13
13
|
ast (2.4.3)
|
|
14
14
|
builder (3.3.0)
|
|
15
15
|
coderay (1.1.3)
|
|
16
|
-
diff-lcs (
|
|
16
|
+
diff-lcs (2.0.0)
|
|
17
17
|
docile (1.4.1)
|
|
18
18
|
io-console (0.8.2)
|
|
19
|
+
json (2.21.1)
|
|
20
|
+
language_server-protocol (3.17.0.6)
|
|
21
|
+
lint_roller (1.1.0)
|
|
19
22
|
method_source (1.1.0)
|
|
20
23
|
minitest (5.27.0)
|
|
21
|
-
minitest-reporters (1.
|
|
24
|
+
minitest-reporters (1.8.0)
|
|
22
25
|
ansi
|
|
23
26
|
builder
|
|
24
|
-
minitest (>= 5.0)
|
|
27
|
+
minitest (>= 5.0, < 7)
|
|
25
28
|
ruby-progressbar
|
|
26
|
-
|
|
29
|
+
parallel (2.1.0)
|
|
30
|
+
parser (3.3.12.0)
|
|
27
31
|
ast (~> 2.4.1)
|
|
28
32
|
racc
|
|
29
33
|
prism (1.9.0)
|
|
@@ -32,9 +36,28 @@ GEM
|
|
|
32
36
|
method_source (~> 1.0)
|
|
33
37
|
reline (>= 0.6.0)
|
|
34
38
|
racc (1.8.1)
|
|
35
|
-
|
|
39
|
+
rainbow (3.1.1)
|
|
40
|
+
rake (13.4.2)
|
|
41
|
+
regexp_parser (2.12.0)
|
|
36
42
|
reline (0.6.3)
|
|
37
43
|
io-console (~> 0.5)
|
|
44
|
+
rubocop (1.88.2)
|
|
45
|
+
json (~> 2.3)
|
|
46
|
+
language_server-protocol (~> 3.17.0.2)
|
|
47
|
+
lint_roller (~> 1.1.0)
|
|
48
|
+
parallel (>= 1.10)
|
|
49
|
+
parser (>= 3.3.0.2)
|
|
50
|
+
rainbow (>= 2.2.2, < 4.0)
|
|
51
|
+
regexp_parser (>= 2.9.3, < 3.0)
|
|
52
|
+
rubocop-ast (>= 1.49.0, < 2.0)
|
|
53
|
+
ruby-progressbar (~> 1.7)
|
|
54
|
+
unicode-display_width (>= 2.4.0, < 4.0)
|
|
55
|
+
rubocop-ast (1.50.0)
|
|
56
|
+
parser (>= 3.3.7.2)
|
|
57
|
+
prism (~> 1.7)
|
|
58
|
+
rubocop-shopify (3.0.1)
|
|
59
|
+
lint_roller
|
|
60
|
+
rubocop (~> 1.72, >= 1.72.1)
|
|
38
61
|
ruby-progressbar (1.13.0)
|
|
39
62
|
simplecov (0.22.0)
|
|
40
63
|
docile (~> 1.1)
|
|
@@ -42,8 +65,11 @@ GEM
|
|
|
42
65
|
simplecov_json_formatter (~> 0.1)
|
|
43
66
|
simplecov-html (0.13.2)
|
|
44
67
|
simplecov_json_formatter (0.1.4)
|
|
45
|
-
|
|
46
|
-
|
|
68
|
+
unicode-display_width (3.2.0)
|
|
69
|
+
unicode-emoji (~> 4.1)
|
|
70
|
+
unicode-emoji (4.2.0)
|
|
71
|
+
unparser (0.9.0)
|
|
72
|
+
diff-lcs (>= 1.6, < 3)
|
|
47
73
|
parser (>= 3.3.0)
|
|
48
74
|
prism (>= 1.5.1)
|
|
49
75
|
|
|
@@ -58,6 +84,7 @@ DEPENDENCIES
|
|
|
58
84
|
minitest-reporters (~> 1.4)
|
|
59
85
|
pry (>= 0.14)
|
|
60
86
|
rake (~> 13.0)
|
|
87
|
+
rubocop-shopify (~> 3.0)
|
|
61
88
|
simplecov (~> 0.22)
|
|
62
89
|
|
|
63
90
|
BUNDLED WITH
|
data/README.md
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
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!`.
|
|
7
7
|
|
|
8
|
+
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.
|
|
9
|
+
|
|
8
10
|
## Installation
|
|
9
11
|
|
|
10
12
|
Add this line to your application's Gemfile:
|
|
@@ -128,6 +130,16 @@ class MyTransformation < ASTTransformation::AbstractTransformation
|
|
|
128
130
|
end
|
|
129
131
|
```
|
|
130
132
|
|
|
133
|
+
#### Choosing a pass shape
|
|
134
|
+
|
|
135
|
+
Passes come in three shapes, distinguished by how the rewrite site is found:
|
|
136
|
+
|
|
137
|
+
* **Structural** — the pattern alone identifies the site. Subclass `AbstractTransformation` and write `on_*` handlers; the pass rewrites the pattern wherever it occurs in the tree.
|
|
138
|
+
* **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.
|
|
139
|
+
* **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.
|
|
140
|
+
|
|
141
|
+
For read-only passes that harvest information instead of rewriting, see [Analysis passes](#analysis-passes).
|
|
142
|
+
|
|
131
143
|
#### Transformation discoverability
|
|
132
144
|
|
|
133
145
|
ASTTransform automatically loads your transformations at compile time. As such, we expect your files to be located at a known path.
|
|
@@ -160,6 +172,96 @@ In the above, `node#updated` allows updating the node, either its type or its ch
|
|
|
160
172
|
|
|
161
173
|
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.
|
|
162
174
|
|
|
175
|
+
### Analysis passes
|
|
176
|
+
|
|
177
|
+
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.
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
require 'ast_transform/abstract_analysis'
|
|
181
|
+
|
|
182
|
+
class TypeAliasRanges < ASTTransform::AbstractAnalysis
|
|
183
|
+
attr_reader :ranges
|
|
184
|
+
|
|
185
|
+
def initialize
|
|
186
|
+
@ranges = []
|
|
187
|
+
super
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def on_block(node)
|
|
191
|
+
send_node = node.children.first
|
|
192
|
+
@ranges << (node.loc.expression.first_line..node.loc.expression.last_line) if type_alias?(send_node)
|
|
193
|
+
super
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def type_alias?(send_node)
|
|
199
|
+
send_node == s(:send, s(:const, nil, :T), :type_alias)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Harvest state in `on_*` handlers and always call `super` so traversal continues. Node equality ignores source locations, so `s(...)` patterns match structurally.
|
|
205
|
+
|
|
206
|
+
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:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
parser = ASTTransform::SourceParser.new
|
|
210
|
+
|
|
211
|
+
parser.parse(source) # => Parser::AST::Node
|
|
212
|
+
parser.parse_file(file_path) # => Parser::AST::Node
|
|
213
|
+
|
|
214
|
+
TypeAliasRanges.new.run(parser.parse(source)).ranges
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Line-aligned emission and the authoring contract
|
|
218
|
+
|
|
219
|
+
ASTTransform owns text and lines; transform authors own semantics and execution order. The contract:
|
|
220
|
+
|
|
221
|
+
* 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).
|
|
222
|
+
* A node **without** a source location is synthetic: it packs onto the current line and inherits its neighbors' line number.
|
|
223
|
+
* 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.
|
|
224
|
+
|
|
225
|
+
`ASTTransform::TransformationHelper` (included by `AbstractTransformation`) provides the authoring toolkit:
|
|
226
|
+
|
|
227
|
+
* `s(type, *children)` — builds a loc-less (synthetic) node. Registered custom types (see below) construct their registered class.
|
|
228
|
+
* `s_at(anchor, type, *children)` — builds a node anchored at `anchor`'s source location, so it is emitted at `anchor`'s line. Raises `TransformationHelper::MissingLocationError` if the anchor has no location.
|
|
229
|
+
* `thunk(*statements)` — wraps statements in a single `Thunk` node: splice it wherever the statements must *run*, in statement position or composed inside an expression (e.g. an `assert_raises` block 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 (`ArgumentError` on a missing id or empty body); a body whose source lines fall after its execution point fails lowering with `ThunkLowering::PlacementError` (a thunk can only delay execution, never text).
|
|
230
|
+
* `run_after(statements, run:, after:)` — the paved road over `thunk`: returns a reordered copy of `statements` where the contiguous `run` executes after `after`, while remaining at its source position textually. Elements are matched by object identity.
|
|
231
|
+
|
|
232
|
+
Thunked statements keep their original meaning as far as Ruby's closure semantics allow:
|
|
233
|
+
|
|
234
|
+
* `return` still 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 a `def` boundary).
|
|
235
|
+
* 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 are `nil` — exactly what an unexecuted assignment yields.
|
|
236
|
+
* Jump keywords whose owner lies *outside* the thunked statements keep Ruby's native behavior: `break`/`retry` raise `LocalJumpError` at the jump's own source line, while `next`/`redo` silently 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.
|
|
237
|
+
|
|
238
|
+
#### Gotcha: location-only rewrites are silently dropped
|
|
239
|
+
|
|
240
|
+
`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`.
|
|
241
|
+
|
|
242
|
+
#### Custom node types (intermediate representation)
|
|
243
|
+
|
|
244
|
+
Transformations that parse a DSL can build their own IR by registering node classes:
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
class InteractionNode < ASTTransform::Node
|
|
248
|
+
register :my_interaction
|
|
249
|
+
|
|
250
|
+
def cardinality = children[0]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
s(:my_interaction, ...) # => InteractionNode, with domain accessors
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
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`.
|
|
257
|
+
|
|
258
|
+
#### Testing your transformation
|
|
259
|
+
|
|
260
|
+
`require 'ast_transform/testing/assertions'` (test-only) provides `ASTTransform::Testing::Assertions`, a Minitest-flavored module to include in your test class:
|
|
261
|
+
|
|
262
|
+
* `assert_line_aligned(source, *transformations)` — transforms `source` through the real pipeline and asserts every surviving statement is emitted at its source line.
|
|
263
|
+
* `assert_backtrace_lines(source, path:, raise_at:)` — compiles and executes `source`, asserting the raw first backtrace frame cites `path:raise_at` with no filtering.
|
|
264
|
+
|
|
163
265
|
### Parameterizable transformations
|
|
164
266
|
|
|
165
267
|
If you want your transformation to be customizable, accept the parameters in the constructor. The annotation can the be changed accordingly:
|
|
@@ -183,6 +285,8 @@ end
|
|
|
183
285
|
|
|
184
286
|
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.
|
|
185
287
|
|
|
288
|
+
If you use the [d3mlabs dev tool](https://github.com/d3mlabs/dev), `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.
|
|
289
|
+
|
|
186
290
|
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
187
291
|
|
|
188
292
|
## Releasing a New Version
|
|
@@ -191,7 +295,7 @@ There are two ways to create a release. Both require that `version.rb` has alrea
|
|
|
191
295
|
|
|
192
296
|
### Via GitHub UI
|
|
193
297
|
|
|
194
|
-
1. Update `VERSION` in `lib/ast_transform/version.rb`, commit, open a PR, and merge to main
|
|
298
|
+
1. Update `VERSION` in `lib/ast_transform/version.rb` and run `bundle install` to regenerate `Gemfile.lock`, commit, open a PR, and merge to main
|
|
195
299
|
2. Go to the repo on GitHub → **Releases** → **Draft a new release**
|
|
196
300
|
3. Enter a new tag (e.g. `v2.0.0`), select `main` as the target branch
|
|
197
301
|
4. Add a title and release notes (GitHub can auto-generate these from merged PRs)
|
|
@@ -199,7 +303,7 @@ There are two ways to create a release. Both require that `version.rb` has alrea
|
|
|
199
303
|
|
|
200
304
|
### Via CLI
|
|
201
305
|
|
|
202
|
-
1. Update `VERSION` in `lib/ast_transform/version.rb`, commit, open a PR, and merge to main
|
|
306
|
+
1. Update `VERSION` in `lib/ast_transform/version.rb` and run `bundle install` to regenerate `Gemfile.lock`, commit, open a PR, and merge to main
|
|
203
307
|
2. Tag and push:
|
|
204
308
|
```
|
|
205
309
|
git checkout main && git pull
|
data/Rakefile
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
4
5
|
|
|
5
6
|
Rake::TestTask.new(:test) do |t|
|
|
6
7
|
# Ensure we load test_loader first for ASTTransform.install
|
|
7
|
-
filepath = File.expand_path(
|
|
8
|
+
filepath = File.expand_path("test/test_loader.rb", __dir__)
|
|
8
9
|
t.ruby_opts << "-r #{filepath}"
|
|
9
10
|
t.warning = false
|
|
10
11
|
|
|
11
|
-
t.libs <<
|
|
12
|
-
t.libs <<
|
|
13
|
-
t.test_files = FileList[
|
|
12
|
+
t.libs << "test"
|
|
13
|
+
t.libs << "lib"
|
|
14
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
14
15
|
end
|
|
15
16
|
|
|
16
|
-
task :
|
|
17
|
+
task default: :test
|
data/ast_transform.gemspec
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
|
|
2
3
|
lib = File.expand_path("../lib", __FILE__)
|
|
3
4
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
@@ -9,28 +10,26 @@ Gem::Specification.new do |spec|
|
|
|
9
10
|
spec.authors = ["Jean-Philippe Duchesne"]
|
|
10
11
|
spec.email = ["jpduchesne89@gmail.com"]
|
|
11
12
|
|
|
12
|
-
spec.summary =
|
|
13
|
+
spec.summary = "An AST transformation framework."
|
|
13
14
|
spec.description = spec.summary
|
|
14
15
|
spec.homepage = "https://github.com/rspockframework/ast-transform"
|
|
15
16
|
spec.license = "MIT"
|
|
16
|
-
spec.files =
|
|
17
|
+
spec.files = %x(git ls-files -z).split("\x0").reject do |f|
|
|
17
18
|
f.match(%r{^(test|spec|features)/})
|
|
18
19
|
end
|
|
19
20
|
spec.bindir = "exe"
|
|
20
21
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
21
22
|
spec.require_paths = ["lib"]
|
|
22
|
-
spec.required_ruby_version = '>= 3.
|
|
23
|
+
spec.required_ruby_version = '>= 3.3'
|
|
23
24
|
|
|
24
|
-
# Development dependencies
|
|
25
|
-
spec.add_development_dependency "bundler", ">= 2.1"
|
|
26
|
-
spec.add_development_dependency "rake", "~> 13.0"
|
|
27
|
-
spec.add_development_dependency "minitest", "~> 5.14"
|
|
28
|
-
spec.add_development_dependency "minitest-reporters", "~> 1.4"
|
|
29
|
-
spec.add_development_dependency "pry", ">= 0.14"
|
|
30
|
-
spec.add_development_dependency "simplecov", "~> 0.22"
|
|
25
|
+
# Development dependencies live in the Gemfile (Gemspec/DevelopmentDependencies).
|
|
31
26
|
|
|
32
27
|
# Runtime dependencies
|
|
33
|
-
|
|
28
|
+
# parser provides the runtime AST vocabulary (Parser::AST::Node/Processor,
|
|
29
|
+
# Source::Buffer/Map); parsing itself goes through prism's translation layer.
|
|
30
|
+
spec.add_runtime_dependency "parser", ">= 3.3"
|
|
34
31
|
spec.add_runtime_dependency "prism", ">= 1.5"
|
|
35
|
-
|
|
32
|
+
# unparser >= 0.8: static_local_variables: (0.7 interface) + the prism-based
|
|
33
|
+
# round-trip verification parser required for Ruby >= 3.4 syntax.
|
|
34
|
+
spec.add_runtime_dependency "unparser", ">= 0.8"
|
|
36
35
|
end
|
data/bin/console
CHANGED
data/dependencies.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Toolchain-only manifest for d3mlabs' dev tool: it provisions this exact
|
|
4
|
+
# Ruby (rbenv + shadowenv) for `dev` commands. Gems stay bundler-managed
|
|
5
|
+
# through the hand-written gemspec/Gemfile; contributors without dev can
|
|
6
|
+
# ignore this file and use .ruby-version.
|
|
7
|
+
require "dev/deps"
|
|
8
|
+
|
|
9
|
+
Dev::Deps.define do
|
|
10
|
+
ruby "4.0.6"
|
|
11
|
+
end
|
data/dev.yml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ast_transform/abstract_processor"
|
|
4
|
+
|
|
5
|
+
module ASTTransform
|
|
6
|
+
# Base class for read-only analysis passes: subclass, harvest state in +on_*+ handlers (always call +super+ so
|
|
7
|
+
# traversal continues), and expose results through readers. The walk still functionally rebuilds the tree —
|
|
8
|
+
# Parser::AST::Processor has no read-only mode — but +run+ discards the rebuilt tree, so handlers never need to
|
|
9
|
+
# care what they return.
|
|
10
|
+
#
|
|
11
|
+
# class SendCounter < ASTTransform::AbstractAnalysis
|
|
12
|
+
# attr_reader :count
|
|
13
|
+
#
|
|
14
|
+
# def initialize
|
|
15
|
+
# @count = 0
|
|
16
|
+
# super
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# def on_send(node)
|
|
20
|
+
# @count += 1
|
|
21
|
+
# super
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# SendCounter.new.run(SourceParser.new.parse(source)).count
|
|
26
|
+
class AbstractAnalysis < AbstractProcessor
|
|
27
|
+
# Runs this analysis on +node+, discarding the rebuilt tree.
|
|
28
|
+
# Note: If you want to add one-time setup or result finalization, override this, then call super.
|
|
29
|
+
#
|
|
30
|
+
# @param node [Parser::AST::Node] The node to be analyzed.
|
|
31
|
+
#
|
|
32
|
+
# @return [ASTTransform::AbstractAnalysis] self, so callers can chain result readers off the run.
|
|
33
|
+
def run(node)
|
|
34
|
+
process(node)
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ast_transform/transformation_helper"
|
|
4
|
+
|
|
5
|
+
module ASTTransform
|
|
6
|
+
# Shared traversal core for tree passes. Parser::AST::Processor is a rewriting walker — every visit functionally
|
|
7
|
+
# rebuilds the tree — so transformation and analysis share one engine and differ only in what they keep:
|
|
8
|
+
# AbstractTransformation's +run+ returns the rebuilt tree, AbstractAnalysis's +run+ discards it and returns the
|
|
9
|
+
# harvested results. Subclass one of those leaves rather than this class.
|
|
10
|
+
class AbstractProcessor < Parser::AST::Processor
|
|
11
|
+
include TransformationHelper
|
|
12
|
+
|
|
13
|
+
# Used internally by Parser::AST::Processor to process each node. DO NOT OVERRIDE.
|
|
14
|
+
def process(node)
|
|
15
|
+
return node unless node.is_a?(Parser::AST::Node)
|
|
16
|
+
|
|
17
|
+
process_node(node)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Thunks are framework-owned IR: descend into the body so passes that don't know about thunks still process the
|
|
21
|
+
# wrapped statements. Without this, Processor's handler_missing default would pass the node through opaquely,
|
|
22
|
+
# hiding the body from every later pass. The token (first child) is not a node and passes through untouched.
|
|
23
|
+
def on_ast_thunk(node)
|
|
24
|
+
node.updated(nil, [node.children[0], *process_all(node.children.drop(1))])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Processes the given +node+.
|
|
30
|
+
# Note: If you want to do processing on each node, override this.
|
|
31
|
+
#
|
|
32
|
+
# @param node [Parser::AST::Node] The node being visited.
|
|
33
|
+
#
|
|
34
|
+
# @return [Parser::AST::Node] The rebuilt node.
|
|
35
|
+
def process_node(node)
|
|
36
|
+
method(:process).super_method.call(node)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
require 'ast_transform/transformation_helper'
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
class AbstractTransformation < Parser::AST::Processor
|
|
6
|
-
include TransformationHelper
|
|
3
|
+
require "ast_transform/abstract_processor"
|
|
7
4
|
|
|
8
|
-
|
|
5
|
+
module ASTTransform
|
|
6
|
+
# Base class for structural transformations: subclass and write +on_*+ handlers to rewrite a pattern wherever it
|
|
7
|
+
# occurs in the tree. This is one of three authoring shapes — pick by how the rewrite site is found:
|
|
8
|
+
#
|
|
9
|
+
# - Structural (this class): the pattern alone identifies the site, so +on_*+ handlers rewrite it anywhere in
|
|
10
|
+
# the tree.
|
|
11
|
+
# - Positional (node builders): a plain object with +run(node) → node+, including only TransformationHelper. The
|
|
12
|
+
# caller owns traversal and applies the builder at a site it already located; the builder never walks. Anything
|
|
13
|
+
# with that duck type composes with Transformer and other passes — deriving from this class is just one way to
|
|
14
|
+
# implement it.
|
|
15
|
+
# - Sibling-annotation: the pattern is a marker statement plus its NEXT sibling, matched in +process_node+ where
|
|
16
|
+
# the child list is visible — an +on_*+ handler sees one node, never its siblings, and cannot delete itself
|
|
17
|
+
# from its parent. See ASTTransform::Transformation (the +transform!+ detector) for the canonical example.
|
|
18
|
+
#
|
|
19
|
+
# For read-only passes that harvest information instead of rewriting, see ASTTransform::AbstractAnalysis.
|
|
20
|
+
class AbstractTransformation < AbstractProcessor
|
|
21
|
+
# Runs this transformation on +node+ and returns the rebuilt tree.
|
|
9
22
|
# Note: If you want to add one-time checks to the transformation, override this, then call super.
|
|
10
23
|
#
|
|
11
24
|
# @param node [Parser::AST::Node] The node to be transformed.
|
|
@@ -14,24 +27,5 @@ module ASTTransform
|
|
|
14
27
|
def run(node)
|
|
15
28
|
process(node)
|
|
16
29
|
end
|
|
17
|
-
|
|
18
|
-
# Used internally by Parser::AST::Processor to process each node. DO NOT OVERRIDE.
|
|
19
|
-
def process(node)
|
|
20
|
-
return node unless node.is_a?(Parser::AST::Node)
|
|
21
|
-
|
|
22
|
-
process_node(node)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
private
|
|
26
|
-
|
|
27
|
-
# Processes the given +node+.
|
|
28
|
-
# Note: If you want to do processing on each node, override this.
|
|
29
|
-
#
|
|
30
|
-
# @param node [Parser::AST::Node] The node to be transformed.
|
|
31
|
-
#
|
|
32
|
-
# @return [Parser::AST::Node] The transformed node.
|
|
33
|
-
def process_node(node)
|
|
34
|
-
method(:process).super_method.call(node)
|
|
35
|
-
end
|
|
36
30
|
end
|
|
37
31
|
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
3
|
+
require "ast_transform/transformer"
|
|
4
|
+
require "ast_transform/instruction_sequence/mixin_utils"
|
|
5
|
+
require "pathname"
|
|
6
6
|
|
|
7
7
|
module ASTTransform
|
|
8
8
|
module InstructionSequence
|
|
@@ -14,7 +14,7 @@ module ASTTransform
|
|
|
14
14
|
iseq = ASTTransform::InstructionSequence.source_to_transformed_iseq(source, source_path)
|
|
15
15
|
iseq.to_binary
|
|
16
16
|
rescue SyntaxError
|
|
17
|
-
raise ::Bootsnap::CompileCache::Uncompilable,
|
|
17
|
+
raise ::Bootsnap::CompileCache::Uncompilable, "syntax error"
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
end
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "ast_transform/transformer"
|
|
5
|
+
require "ast_transform/transformation"
|
|
6
|
+
require "ast_transform/instruction_sequence/mixin_utils"
|
|
6
7
|
|
|
7
8
|
module ASTTransform
|
|
8
9
|
module InstructionSequence
|
|
@@ -15,7 +16,7 @@ module ASTTransform
|
|
|
15
16
|
# via magic comments, so we never need to set it ourselves.
|
|
16
17
|
source = File.binread(source_path)
|
|
17
18
|
|
|
18
|
-
return ASTTransform::MixinUtils.try_super(self, :load_iseq, source_path) unless source.include?(
|
|
19
|
+
return ASTTransform::MixinUtils.try_super(self, :load_iseq, source_path) unless source.include?("transform!".b)
|
|
19
20
|
|
|
20
21
|
ASTTransform::InstructionSequence.source_to_transformed_iseq(source, source_path)
|
|
21
22
|
end
|