ast_transform 3.0.0 → 3.1.1
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/CHANGELOG.md +6 -0
- data/Gemfile.lock +1 -1
- data/README.md +52 -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 +17 -32
- data/lib/ast_transform/kwargs_builder.rb +9 -10
- data/lib/ast_transform/layout.rb +13 -4
- data/lib/ast_transform/line_aligned_emitter.rb +5 -2
- data/lib/ast_transform/source_parser.rb +59 -0
- data/lib/ast_transform/statement_renderer.rb +22 -0
- data/lib/ast_transform/transformation.rb +5 -0
- data/lib/ast_transform/transformer.rb +4 -22
- data/lib/ast_transform/version.rb +1 -1
- data/lib/ast_transform.rb +1 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fb38ad75ff6d31e4f8da1d1b272968b3d65393a3acaf92b914be2c0d35ebdb32
|
|
4
|
+
data.tar.gz: f2621b3116184b0f7b7eb87a9a4b4af184f685e4399e5b52761b1deb1f54c998
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3ff9177f5bdd86e95af2406aeb675a53cd93917a63faa2fdf26bcfb12bc6365e415dbadb576247f7d134a2a36a7866d46cca17626351fb648f156379e3ba5473
|
|
7
|
+
data.tar.gz: 054fe46ee27bc9cda5f18b163460f62a94c496cc3993407e79a3592079d3011c56c78d300db0639d685749e47a704a0a3e6d3a1915fbcbd044de7a7b6a6c173b
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ 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
|
+
|
|
7
13
|
## [3.0.0] - 2026-07-24
|
|
8
14
|
### Added
|
|
9
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`).
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -130,6 +130,16 @@ class MyTransformation < ASTTransformation::AbstractTransformation
|
|
|
130
130
|
end
|
|
131
131
|
```
|
|
132
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
|
+
|
|
133
143
|
#### Transformation discoverability
|
|
134
144
|
|
|
135
145
|
ASTTransform automatically loads your transformations at compile time. As such, we expect your files to be located at a known path.
|
|
@@ -162,6 +172,48 @@ In the above, `node#updated` allows updating the node, either its type or its ch
|
|
|
162
172
|
|
|
163
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.
|
|
164
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
|
+
|
|
165
217
|
### Line-aligned emission and the authoring contract
|
|
166
218
|
|
|
167
219
|
ASTTransform owns text and lines; transform authors own semantics and execution order. The contract:
|
|
@@ -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,12 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "ast_transform/
|
|
3
|
+
require "ast_transform/abstract_processor"
|
|
4
4
|
|
|
5
5
|
module ASTTransform
|
|
6
|
-
class
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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.
|
|
10
22
|
# Note: If you want to add one-time checks to the transformation, override this, then call super.
|
|
11
23
|
#
|
|
12
24
|
# @param node [Parser::AST::Node] The node to be transformed.
|
|
@@ -15,32 +27,5 @@ module ASTTransform
|
|
|
15
27
|
def run(node)
|
|
16
28
|
process(node)
|
|
17
29
|
end
|
|
18
|
-
|
|
19
|
-
# Used internally by Parser::AST::Processor to process each node. DO NOT OVERRIDE.
|
|
20
|
-
def process(node)
|
|
21
|
-
return node unless node.is_a?(Parser::AST::Node)
|
|
22
|
-
|
|
23
|
-
process_node(node)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Thunks are framework-owned IR: descend into the body so passes that don't know about thunks still process the
|
|
27
|
-
# wrapped statements. Without this, Processor's handler_missing default would pass the node through opaquely,
|
|
28
|
-
# hiding the body from every later transformation. The token (first child) is not a node and passes through
|
|
29
|
-
# untouched.
|
|
30
|
-
def on_ast_thunk(node)
|
|
31
|
-
node.updated(nil, [node.children[0], *process_all(node.children.drop(1))])
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
# Processes the given +node+.
|
|
37
|
-
# Note: If you want to do processing on each node, override this.
|
|
38
|
-
#
|
|
39
|
-
# @param node [Parser::AST::Node] The node to be transformed.
|
|
40
|
-
#
|
|
41
|
-
# @return [Parser::AST::Node] The transformed node.
|
|
42
|
-
def process_node(node)
|
|
43
|
-
method(:process).super_method.call(node)
|
|
44
|
-
end
|
|
45
30
|
end
|
|
46
31
|
end
|
|
@@ -3,22 +3,21 @@
|
|
|
3
3
|
require "prism/translation/parser"
|
|
4
4
|
|
|
5
5
|
module ASTTransform
|
|
6
|
-
#
|
|
6
|
+
# The framework's parser builder: the stock builder with the parser gem's `emit_kwargs` opt-in, which
|
|
7
|
+
# distinguishes keyword arguments from hash literals in the AST.
|
|
7
8
|
#
|
|
8
|
-
#
|
|
9
|
-
# node type to decide whether to emit braces: :hash gets `{}`, :kwargs does not. Since Ruby 3.0+
|
|
10
|
-
# semantically different (strict keyword/positional separation),
|
|
9
|
+
# With the flag off (the gem's compatibility default) both `foo(bar: 1)` and `foo({ bar: 1 })` emit :hash nodes.
|
|
10
|
+
# Unparser uses the node type to decide whether to emit braces: :hash gets `{}`, :kwargs does not. Since Ruby 3.0+
|
|
11
|
+
# treats these as semantically different (strict keyword/positional separation), the AST must preserve the
|
|
12
|
+
# distinction. `emit_kwargs` rewrites at the call sites where kwargs semantics live (method calls, index,
|
|
13
|
+
# super/yield); the flag is a class-level ivar, so setting it here opts in this builder only — the global
|
|
14
|
+
# Parser::Builders::Default stays untouched.
|
|
11
15
|
#
|
|
12
16
|
# NOTE: parsed nodes deliberately stay plain Parser::AST::Node. Custom node classes exist only for registered
|
|
13
17
|
# custom types (see ASTTransform::Node), which are IR and never reach Unparser: AST::Node#eql? compares class, and
|
|
14
18
|
# Unparser verifies dynamic-string emission by re-parsing and comparing eql? against the freshly parsed
|
|
15
19
|
# (plain-class) node — custom-class nodes of standard types would fail that verification.
|
|
16
20
|
class KwargsBuilder < Prism::Translation::Parser::Builder
|
|
17
|
-
|
|
18
|
-
node = super
|
|
19
|
-
return node unless begin_t.nil? && end_t.nil?
|
|
20
|
-
|
|
21
|
-
node.updated(:kwargs)
|
|
22
|
-
end
|
|
21
|
+
self.emit_kwargs = true
|
|
23
22
|
end
|
|
24
23
|
end
|
data/lib/ast_transform/layout.rb
CHANGED
|
@@ -9,6 +9,7 @@ module ASTTransform
|
|
|
9
9
|
class Layout
|
|
10
10
|
def initialize
|
|
11
11
|
@lines = []
|
|
12
|
+
@sealed = false
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
# The line number currently being written; the next fresh line would be +cursor + 1+.
|
|
@@ -21,7 +22,10 @@ module ASTTransform
|
|
|
21
22
|
# +column+ — cosmetic only (leading whitespace is never significant in emitted code), but it keeps the artifact
|
|
22
23
|
# visually close to the source. Packed text ignores the column, as do continuation lines (they keep their own
|
|
23
24
|
# relative indentation).
|
|
24
|
-
|
|
25
|
+
#
|
|
26
|
+
# +seal+ declares the text's last line unextendable (the caller's property — e.g. it terminates a heredoc,
|
|
27
|
+
# whose terminator must stand alone): a subsequent pack opens a fresh line instead of joining it.
|
|
28
|
+
def place(target_line, text, column: nil, seal: false)
|
|
25
29
|
first, *rest = text.split("\n")
|
|
26
30
|
|
|
27
31
|
if target_line && target_line > @lines.size
|
|
@@ -32,22 +36,27 @@ module ASTTransform
|
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
@lines.concat(rest)
|
|
39
|
+
@sealed = seal
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
# Appends +text+ on a new line unconditionally — for text that must never be `;`-packed after a statement
|
|
38
43
|
# (e.g. keywords).
|
|
39
44
|
def place_on_fresh_line(text)
|
|
40
45
|
@lines << text
|
|
46
|
+
@sealed = false
|
|
41
47
|
end
|
|
42
48
|
|
|
43
|
-
# Appends +text+ to the current line with a `; ` separator
|
|
44
|
-
#
|
|
49
|
+
# Appends +text+ to the current line with a `; ` separator — unless the current line is sealed, in which case
|
|
50
|
+
# the text opens a fresh line (alignment degrades by one more line; validity is preserved). The last line is
|
|
51
|
+
# never blank here: padding blanks are only created inside +place+, which immediately overwrites the padded
|
|
52
|
+
# line.
|
|
45
53
|
def pack(text)
|
|
46
|
-
if @lines.empty?
|
|
54
|
+
if @lines.empty? || @sealed
|
|
47
55
|
@lines << text
|
|
48
56
|
else
|
|
49
57
|
@lines[-1] = "#{@lines.last}; #{text}"
|
|
50
58
|
end
|
|
59
|
+
@sealed = false
|
|
51
60
|
end
|
|
52
61
|
|
|
53
62
|
# @return [String] the laid-out text, with a trailing newline.
|
|
@@ -71,7 +71,9 @@ module ASTTransform
|
|
|
71
71
|
if recursive_container?(node)
|
|
72
72
|
emit_container(node, layout, renderer)
|
|
73
73
|
else
|
|
74
|
-
|
|
74
|
+
render = renderer.aligned_render(node)
|
|
75
|
+
# A render in heredoc form seals its line: nothing may pack after the terminator (issue #16).
|
|
76
|
+
layout.place(node.loc&.line, render, column: node.loc&.column, seal: renderer.line_terminal?(render))
|
|
75
77
|
end
|
|
76
78
|
end
|
|
77
79
|
|
|
@@ -145,7 +147,8 @@ module ASTTransform
|
|
|
145
147
|
# nested statements align.
|
|
146
148
|
def emit_container(node, layout, renderer)
|
|
147
149
|
opener, closer = container_delimiters(node, renderer)
|
|
148
|
-
|
|
150
|
+
# An opener can carry a heredoc too (e.g. a block call with a heredoc argument) — seal it like a statement.
|
|
151
|
+
layout.place(node.loc&.line, opener, column: node.loc&.column, seal: renderer.line_terminal?(opener))
|
|
149
152
|
emit_body(container_body(node), layout, renderer)
|
|
150
153
|
layout.place(closer_line(node), closer, column: closer_column(node))
|
|
151
154
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require "prism/translation/parser"
|
|
5
|
+
require "ast_transform/kwargs_builder"
|
|
6
|
+
|
|
7
|
+
module ASTTransform
|
|
8
|
+
# Owns source → AST parsing: Prism's C parser through its whitequark translation layer, so every consumer gets the
|
|
9
|
+
# node vocabulary Parser::AST::Processor understands. Transformer parses through this seam; analysis-only
|
|
10
|
+
# consumers that never emit instantiate it directly and keep the instance for as many parses as they need.
|
|
11
|
+
class SourceParser
|
|
12
|
+
# Constructs a new SourceParser instance.
|
|
13
|
+
#
|
|
14
|
+
# KwargsBuilder is a required implementation detail, not an injection seam: the framework's emission depends
|
|
15
|
+
# on the kwargs/hash distinction it preserves, so every parse goes through it.
|
|
16
|
+
def initialize
|
|
17
|
+
@builder = KwargsBuilder.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Parses the given +source+.
|
|
21
|
+
#
|
|
22
|
+
# @param source [String] The input source code.
|
|
23
|
+
# @param file_path [String] The file path recorded on source locations. This is important for source mapping
|
|
24
|
+
# in backtraces.
|
|
25
|
+
#
|
|
26
|
+
# @return [Parser::AST::Node] The AST.
|
|
27
|
+
def parse(source, file_path: "tmp")
|
|
28
|
+
# A fresh parser per parse: parser instances accumulate per-run state (lexer position, diagnostics), and
|
|
29
|
+
# constructing one is trivial next to the parse itself.
|
|
30
|
+
parser = Prism::Translation::Parser.new(@builder)
|
|
31
|
+
parser.parse(create_buffer(source, file_path, parser.default_encoding))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Parses the source in the given +file_path+.
|
|
35
|
+
#
|
|
36
|
+
# @param file_path [String] The input file path.
|
|
37
|
+
#
|
|
38
|
+
# @return [Parser::AST::Node] The AST.
|
|
39
|
+
def parse_file(file_path)
|
|
40
|
+
parse(File.read(file_path), file_path: file_path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Builds a source buffer over +source+ in the given +encoding+.
|
|
46
|
+
#
|
|
47
|
+
# @param source [String] The input source code.
|
|
48
|
+
# @param file_path [String] The file path recorded on the buffer.
|
|
49
|
+
# @param encoding [Encoding] The encoding the buffer's source is coerced to.
|
|
50
|
+
#
|
|
51
|
+
# @return [Parser::Source::Buffer] The buffer.
|
|
52
|
+
def create_buffer(source, file_path, encoding)
|
|
53
|
+
buffer = Parser::Source::Buffer.new(file_path)
|
|
54
|
+
buffer.source = source.dup.force_encoding(encoding)
|
|
55
|
+
|
|
56
|
+
buffer
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -62,8 +62,30 @@ module ASTTransform
|
|
|
62
62
|
compress_to_single_line(render) || render
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# Whether +render+ must be the last text on its final line: a render ending on a heredoc terminator (Unparser
|
|
66
|
+
# falls back to `<<-HEREDOC` form when its quoted-string round-trip fails) cannot have anything `;`-packed
|
|
67
|
+
# after it — the terminator must stand alone, so a join unterminates the heredoc. Probed by re-parse, the same
|
|
68
|
+
# verification style as +compress_to_single_line+, behind a cheap textual gate: only a heredoc makes a last
|
|
69
|
+
# line unextendable, and every heredoc opener contains +<<+. A false positive (the probe failing for another
|
|
70
|
+
# reason, e.g. an isolated container opener carrying a heredoc argument) costs one line of alignment, never
|
|
71
|
+
# correctness.
|
|
72
|
+
def line_terminal?(render)
|
|
73
|
+
return false unless render.include?('<<')
|
|
74
|
+
|
|
75
|
+
# chomp: heredoc renders end with a trailing newline; the probe must extend the last *line* (where the
|
|
76
|
+
# layout would pack), not append below it.
|
|
77
|
+
!parses?("#{render.chomp}; nil")
|
|
78
|
+
end
|
|
79
|
+
|
|
65
80
|
private
|
|
66
81
|
|
|
82
|
+
def parses?(source)
|
|
83
|
+
Unparser.parse(source)
|
|
84
|
+
true
|
|
85
|
+
rescue Parser::SyntaxError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
67
89
|
def compress_to_single_line(render)
|
|
68
90
|
candidate = render.split("\n").map(&:strip).join('; ')
|
|
69
91
|
# Both sides parsed without scope context, so lvar/send ambiguity cancels out; equality means the newline
|
|
@@ -6,6 +6,11 @@ require "ast_transform/transformer"
|
|
|
6
6
|
require "unparser"
|
|
7
7
|
|
|
8
8
|
module ASTTransform
|
|
9
|
+
# The +transform!+ detector — the canonical sibling-annotation pass (see AbstractTransformation for the taxonomy).
|
|
10
|
+
# A +transform!(...)+ statement is a pragma on the NEXT sibling: the transformations it names are applied to the
|
|
11
|
+
# following class definition or constant assignment, and the marker itself is deleted from the child list. Both
|
|
12
|
+
# effects need the parent's child list in view, which is why matching happens in +process_node+ — an +on_send+
|
|
13
|
+
# handler would see the marker node alone, with no access to its next sibling and no way to remove itself.
|
|
9
14
|
class Transformation < ASTTransform::AbstractTransformation
|
|
10
15
|
TRANSFORM_AST = s(:send, nil, :transform!)
|
|
11
16
|
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'prism'
|
|
4
|
-
require 'prism/translation/parser'
|
|
5
|
-
require 'unparser'
|
|
6
|
-
require 'ast_transform/kwargs_builder'
|
|
7
3
|
require 'ast_transform/line_aligned_emitter'
|
|
4
|
+
require 'ast_transform/source_parser'
|
|
8
5
|
|
|
9
6
|
module ASTTransform
|
|
10
7
|
class Transformer
|
|
@@ -15,6 +12,7 @@ module ASTTransform
|
|
|
15
12
|
def initialize(*transformations, emitter: LineAlignedEmitter.new)
|
|
16
13
|
@transformations = transformations
|
|
17
14
|
@emitter = emitter
|
|
15
|
+
@source_parser = SourceParser.new
|
|
18
16
|
end
|
|
19
17
|
|
|
20
18
|
# Builds the AST for the given +source+.
|
|
@@ -24,8 +22,7 @@ module ASTTransform
|
|
|
24
22
|
#
|
|
25
23
|
# @return [Parser::AST::Node] The AST.
|
|
26
24
|
def build_ast(source, file_path: "tmp")
|
|
27
|
-
|
|
28
|
-
parser.parse(buffer)
|
|
25
|
+
@source_parser.parse(source, file_path: file_path)
|
|
29
26
|
end
|
|
30
27
|
|
|
31
28
|
# Builds the AST for the given +file_path+.
|
|
@@ -34,8 +31,7 @@ module ASTTransform
|
|
|
34
31
|
#
|
|
35
32
|
# @return [Parser::AST::Node] The AST.
|
|
36
33
|
def build_ast_from_file(file_path)
|
|
37
|
-
|
|
38
|
-
build_ast(source, file_path: file_path)
|
|
34
|
+
@source_parser.parse_file(file_path)
|
|
39
35
|
end
|
|
40
36
|
|
|
41
37
|
# Transforms the given +source+.
|
|
@@ -89,19 +85,5 @@ module ASTTransform
|
|
|
89
85
|
transformation.run(ast)
|
|
90
86
|
end
|
|
91
87
|
end
|
|
92
|
-
|
|
93
|
-
private
|
|
94
|
-
|
|
95
|
-
def create_buffer(source, file_path)
|
|
96
|
-
buffer = Parser::Source::Buffer.new(file_path)
|
|
97
|
-
buffer.source = source.dup.force_encoding(parser.default_encoding)
|
|
98
|
-
|
|
99
|
-
buffer
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def parser
|
|
103
|
-
@parser&.reset
|
|
104
|
-
@parser ||= Prism::Translation::Parser.new(ASTTransform::KwargsBuilder.new)
|
|
105
|
-
end
|
|
106
88
|
end
|
|
107
89
|
end
|
data/lib/ast_transform.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "ast_transform/version"
|
|
|
4
4
|
require "ast_transform/instruction_sequence"
|
|
5
5
|
require "ast_transform/instruction_sequence/mixin"
|
|
6
6
|
require "ast_transform/instruction_sequence/bootsnap_mixin"
|
|
7
|
+
require "ast_transform/source_parser"
|
|
7
8
|
|
|
8
9
|
module ASTTransform
|
|
9
10
|
DEFAULT_OUTPUT_PATH = Pathname.new("").join("tmp", "ast_transform").to_s
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ast_transform
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jean-Philippe Duchesne
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: parser
|
|
@@ -80,6 +80,8 @@ files:
|
|
|
80
80
|
- dependencies.rb
|
|
81
81
|
- dev.yml
|
|
82
82
|
- lib/ast_transform.rb
|
|
83
|
+
- lib/ast_transform/abstract_analysis.rb
|
|
84
|
+
- lib/ast_transform/abstract_processor.rb
|
|
83
85
|
- lib/ast_transform/abstract_transformation.rb
|
|
84
86
|
- lib/ast_transform/instruction_sequence.rb
|
|
85
87
|
- lib/ast_transform/instruction_sequence/bootsnap_mixin.rb
|
|
@@ -89,6 +91,7 @@ files:
|
|
|
89
91
|
- lib/ast_transform/layout.rb
|
|
90
92
|
- lib/ast_transform/line_aligned_emitter.rb
|
|
91
93
|
- lib/ast_transform/node.rb
|
|
94
|
+
- lib/ast_transform/source_parser.rb
|
|
92
95
|
- lib/ast_transform/statement_renderer.rb
|
|
93
96
|
- lib/ast_transform/testing/assertions.rb
|
|
94
97
|
- lib/ast_transform/thunk.rb
|