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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.github/CODEOWNERS +1 -0
  3. data/.github/workflows/ci.yml +11 -1
  4. data/.gitignore +4 -1
  5. data/.rubocop.yml +5 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +22 -0
  8. data/Gemfile +12 -1
  9. data/Gemfile.lock +38 -11
  10. data/README.md +106 -2
  11. data/Rakefile +8 -7
  12. data/ast_transform.gemspec +11 -12
  13. data/bin/console +1 -0
  14. data/dependencies.rb +11 -0
  15. data/dev.yml +11 -0
  16. data/lib/ast_transform/abstract_analysis.rb +38 -0
  17. data/lib/ast_transform/abstract_processor.rb +39 -0
  18. data/lib/ast_transform/abstract_transformation.rb +18 -24
  19. data/lib/ast_transform/instruction_sequence/bootsnap_mixin.rb +4 -4
  20. data/lib/ast_transform/instruction_sequence/mixin.rb +6 -5
  21. data/lib/ast_transform/instruction_sequence/mixin_utils.rb +1 -1
  22. data/lib/ast_transform/instruction_sequence.rb +3 -2
  23. data/lib/ast_transform/kwargs_builder.rb +15 -14
  24. data/lib/ast_transform/layout.rb +64 -0
  25. data/lib/ast_transform/line_aligned_emitter.rb +224 -0
  26. data/lib/ast_transform/node.rb +48 -0
  27. data/lib/ast_transform/source_parser.rb +59 -0
  28. data/lib/ast_transform/statement_renderer.rb +76 -0
  29. data/lib/ast_transform/testing/assertions.rb +97 -0
  30. data/lib/ast_transform/thunk.rb +55 -0
  31. data/lib/ast_transform/thunk_lowering.rb +240 -0
  32. data/lib/ast_transform/transformation.rb +24 -22
  33. data/lib/ast_transform/transformation_helper.rb +115 -4
  34. data/lib/ast_transform/transformer.rb +23 -47
  35. data/lib/ast_transform/version.rb +3 -1
  36. data/lib/ast_transform.rb +12 -15
  37. metadata +22 -92
  38. data/lib/ast_transform/source_map.rb +0 -233
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '02391c6a71f241da83617ab3dae2ff61a66c7a9ad3f9d154c6f6f3aee29e86ad'
4
- data.tar.gz: 9b25a4ae5581efc378793af4b4d3d4fd697cc88104567610caf4dea77c2d4d64
3
+ metadata.gz: a74867dd7f6d788c78d286901e1e4c6a362e0a341300fb9bb22ccb92503a0223
4
+ data.tar.gz: c2968b8000a875e169f89d85deaa38868ad49dcc8d193db6a78c88ea4eafb986
5
5
  SHA512:
6
- metadata.gz: 0e0c312499e1abdd8eea7f2c704cf3aaca4a06ed1b7c62c048bb6f4b350b85a89fc9170c8dc0d0cfd84d5b1e2c9822608a4767600350c097b1da1f4c98f5d759
7
- data.tar.gz: d02faba1167586d4bb7aceb138b3452e3cb9ab7c2f294ae02803fb09a565d5ea884ec44568ec60efa1ab3b6600cc17fe911559c02afb6d8e5350bf37ce83ed04
6
+ metadata.gz: 9f4e007503a09479a7df3f595097bf384eb0196fb192cd012855c63417582e34a32cfd318ae765f1a8122de408bbfd337c8e3adfe889d2fd1bc36b3a20c96599
7
+ data.tar.gz: 900412939feb57ee329d2c3adca9b9712c1da8243038268104db916f9270ea09427d0d96cbf5aded11f86d919d4b45e6629be20372eb4dfc1d0531f05757b1e5
@@ -0,0 +1 @@
1
+ * @JPDuchesne
@@ -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.2', '3.3', '4.0']
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
@@ -50,4 +50,7 @@ build-iPhoneSimulator/
50
50
  .rvmrc
51
51
 
52
52
  # RubyMine
53
- /.idea
53
+ /.idea
54
+
55
+ # Generated per-machine by `dev up` (d3mlabs dev tool); never committed
56
+ /.shadowenv.d/
data/.rubocop.yml ADDED
@@ -0,0 +1,5 @@
1
+ inherit_gem:
2
+ rubocop-shopify: rubocop.yml
3
+
4
+ AllCops:
5
+ SuggestExtensions: false
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 (2.1.4)
5
- parser (>= 3.0)
4
+ ast_transform (3.1.0)
5
+ parser (>= 3.3)
6
6
  prism (>= 1.5)
7
- unparser (>= 0.6)
7
+ unparser (>= 0.8)
8
8
 
9
9
  GEM
10
10
  remote: https://rubygems.org/
11
11
  specs:
12
- ansi (1.5.0)
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 (1.6.2)
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.7.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
- parser (3.3.10.2)
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
- rake (13.3.1)
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
- unparser (0.8.1)
46
- diff-lcs (~> 1.6)
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
- require 'bundler/gem_tasks'
3
- require 'rake/testtask'
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('test/test_loader.rb', __dir__)
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 << 'test'
12
- t.libs << 'lib'
13
- t.test_files = FileList['test/**/*_test.rb']
12
+ t.libs << "test"
13
+ t.libs << "lib"
14
+ t.test_files = FileList["test/**/*_test.rb"]
14
15
  end
15
16
 
16
- task :default => :test
17
+ task default: :test
@@ -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 = 'An AST transformation framework.'
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 = `git ls-files -z`.split("\x0").reject do |f|
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.2'
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
- spec.add_runtime_dependency "parser", ">= 3.0"
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
- spec.add_runtime_dependency "unparser", ">= 0.6"
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
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "bundler/setup"
4
5
  require "ast_transform"
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,11 @@
1
+ name: ast_transform
2
+ commands:
3
+ up:
4
+ desc: Install gems
5
+ run: bundle install
6
+ test:
7
+ desc: Run this repo's tests
8
+ run: bundle exec rake test
9
+ style:
10
+ desc: Run RuboCop
11
+ run: bundle exec rubocop
@@ -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
- module ASTTransform
5
- class AbstractTransformation < Parser::AST::Processor
6
- include TransformationHelper
3
+ require "ast_transform/abstract_processor"
7
4
 
8
- # Runs this transformation on +node+.
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 'ast_transform/transformer'
4
- require 'ast_transform/instruction_sequence/mixin_utils'
5
- require 'pathname'
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, 'syntax error'
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
- require 'pathname'
3
- require 'ast_transform/transformer'
4
- require 'ast_transform/transformation'
5
- require 'ast_transform/instruction_sequence/mixin_utils'
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?('transform!'.b)
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
@@ -5,7 +5,7 @@ module ASTTransform
5
5
  class << self
6
6
  def try_super(target, method_sym, *args, &block)
7
7
  super_method = target.method(method_sym).super_method
8
- super_method ? super_method.call(*args, &block) : nil
8
+ super_method&.call(*args, &block)
9
9
  end
10
10
  end
11
11
  end