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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module ASTTransform
|
|
3
4
|
module InstructionSequence
|
|
4
5
|
class << self
|
|
@@ -22,12 +23,12 @@ module ASTTransform
|
|
|
22
23
|
def write_pathname(file_path)
|
|
23
24
|
project_path = File.expand_path("")
|
|
24
25
|
relative_source_file_pathname = Pathname.new(file_path).relative_path_from(Pathname.new(project_path))
|
|
25
|
-
Pathname.new("").join(project_path,
|
|
26
|
+
Pathname.new("").join(project_path, "tmp", "ast_transform", relative_source_file_pathname)
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def write(string, pathname)
|
|
29
30
|
FileUtils.mkdir_p(pathname.dirname)
|
|
30
|
-
File.open(pathname,
|
|
31
|
+
File.open(pathname, "w") do |file|
|
|
31
32
|
file.write(string)
|
|
32
33
|
end
|
|
33
34
|
end
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "prism/translation/parser"
|
|
4
4
|
|
|
5
5
|
module ASTTransform
|
|
6
|
-
#
|
|
7
|
-
# from hash literals in the AST.
|
|
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.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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.
|
|
15
|
+
#
|
|
16
|
+
# NOTE: parsed nodes deliberately stay plain Parser::AST::Node. Custom node classes exist only for registered
|
|
17
|
+
# custom types (see ASTTransform::Node), which are IR and never reach Unparser: AST::Node#eql? compares class, and
|
|
18
|
+
# Unparser verifies dynamic-string emission by re-parsing and comparing eql? against the freshly parsed
|
|
19
|
+
# (plain-class) node — custom-class nodes of standard types would fail that verification.
|
|
14
20
|
class KwargsBuilder < Prism::Translation::Parser::Builder
|
|
15
|
-
|
|
16
|
-
node = super
|
|
17
|
-
return node unless begin_t.nil? && end_t.nil?
|
|
18
|
-
|
|
19
|
-
node.updated(:kwargs)
|
|
20
|
-
end
|
|
21
|
+
self.emit_kwargs = true
|
|
21
22
|
end
|
|
22
23
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ASTTransform
|
|
4
|
+
# Line-addressed output: text is placed at absolute line numbers, top to bottom, and the cursor never rewinds.
|
|
5
|
+
# When a placement's target line is already behind the cursor, the text is packed (`; `) onto the current line
|
|
6
|
+
# instead — Ruby lets statements share a physical line, so alignment degrades locally and the next placement
|
|
7
|
+
# whose target is still ahead re-anchors. Knows nothing about Ruby structure or ASTs; callers decide WHAT goes
|
|
8
|
+
# on WHICH line, the layout owns the pad-or-pack mechanics.
|
|
9
|
+
class Layout
|
|
10
|
+
def initialize
|
|
11
|
+
@lines = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The line number currently being written; the next fresh line would be +cursor + 1+.
|
|
15
|
+
def cursor
|
|
16
|
+
@lines.size
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Places +text+ at +target_line+ when the cursor hasn't passed it; otherwise packs onto the current line.
|
|
20
|
+
# Multi-line text advances the cursor by its height. When opening a fresh line, the first line is indented to
|
|
21
|
+
# +column+ — cosmetic only (leading whitespace is never significant in emitted code), but it keeps the artifact
|
|
22
|
+
# visually close to the source. Packed text ignores the column, as do continuation lines (they keep their own
|
|
23
|
+
# relative indentation).
|
|
24
|
+
def place(target_line, text, column: nil)
|
|
25
|
+
first, *rest = text.split("\n")
|
|
26
|
+
|
|
27
|
+
if target_line && target_line > @lines.size
|
|
28
|
+
@lines << '' while @lines.size < target_line
|
|
29
|
+
@lines[-1] = indented(first, column)
|
|
30
|
+
else
|
|
31
|
+
pack(first)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@lines.concat(rest)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Appends +text+ on a new line unconditionally — for text that must never be `;`-packed after a statement
|
|
38
|
+
# (e.g. keywords).
|
|
39
|
+
def place_on_fresh_line(text)
|
|
40
|
+
@lines << text
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Appends +text+ to the current line with a `; ` separator. The last line is never blank here: padding blanks
|
|
44
|
+
# are only created inside +place+, which immediately overwrites the padded line.
|
|
45
|
+
def pack(text)
|
|
46
|
+
if @lines.empty?
|
|
47
|
+
@lines << text
|
|
48
|
+
else
|
|
49
|
+
@lines[-1] = "#{@lines.last}; #{text}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [String] the laid-out text, with a trailing newline.
|
|
54
|
+
def to_source
|
|
55
|
+
"#{@lines.join("\n")}\n"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def indented(text, column)
|
|
61
|
+
column && column.positive? ? "#{' ' * column}#{text}" : text
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ast_transform/node'
|
|
4
|
+
require 'ast_transform/layout'
|
|
5
|
+
require 'ast_transform/statement_renderer'
|
|
6
|
+
require 'ast_transform/thunk_lowering'
|
|
7
|
+
|
|
8
|
+
module ASTTransform
|
|
9
|
+
# Emits a transformed AST as text in which every loc-carrying statement occupies its original source line, so that
|
|
10
|
+
# backtraces, breakpoints and debugger display are correct by construction — CRuby derives line numbers from
|
|
11
|
+
# physical text position, so placement is our line table.
|
|
12
|
+
#
|
|
13
|
+
# Placement policy over statement sequences:
|
|
14
|
+
#
|
|
15
|
+
# 1. Statement has loc: target its source line — the Layout pads to reach it, or packs (`; `) when the cursor has
|
|
16
|
+
# already passed it. A user statement packing means the transform moved it — the alignment auditor's concern,
|
|
17
|
+
# not a runtime failure.
|
|
18
|
+
# 2. No loc: pack onto the current line — synthetic code has no source-line truth to preserve.
|
|
19
|
+
#
|
|
20
|
+
# The emitter owns the Ruby knowledge: walking statement structure, deciding which line each node targets, and
|
|
21
|
+
# that keywords can never be `;`-packed. The pad-or-pack mechanics live in Layout; statement-to-text rendering
|
|
22
|
+
# (and its isolation workarounds) in StatementRenderer — both created per emission, so the emitter itself is
|
|
23
|
+
# stateless and an instance is a reusable collaborator.
|
|
24
|
+
#
|
|
25
|
+
# Thunk nodes are lowered (ThunkLowering) before layout; the emitter's postcondition is that no custom node type
|
|
26
|
+
# (ast_* markers or types registered on ASTTransform::Node) crosses the unparse boundary — they are IR between
|
|
27
|
+
# stages that understand them.
|
|
28
|
+
class LineAlignedEmitter
|
|
29
|
+
# Raised as the emitter's postcondition when a custom node type (ast_* markers or types registered on
|
|
30
|
+
# ASTTransform::Node) reaches the unparse boundary instead of being lowered by the stage that understands it.
|
|
31
|
+
class UnloweredNodeTypeError < StandardError; end
|
|
32
|
+
|
|
33
|
+
# Containers the emitter recurses into so nested statements align; every other node renders as an Unparser blob
|
|
34
|
+
# at its head line.
|
|
35
|
+
RECURSIVE_CONTAINER_TYPES = [:class, :module, :sclass, :def, :defs, :block, :numblock, :itblock, :kwbegin].freeze
|
|
36
|
+
BODY_INDEXES = {
|
|
37
|
+
class: 2, module: 1, sclass: 1, def: 2, defs: 3, block: 2, numblock: 2, itblock: 2
|
|
38
|
+
}.freeze
|
|
39
|
+
# Assignments whose value is a block (e.g. the lowered thunk proc) recurse into the block so its body statements
|
|
40
|
+
# align.
|
|
41
|
+
ASSIGNMENT_TYPES = [:lvasgn, :ivasgn, :gvasgn, :casgn].freeze
|
|
42
|
+
BLOCK_VALUE_TYPES = [:block, :numblock, :itblock].freeze
|
|
43
|
+
|
|
44
|
+
# @param thunk_lowering [ThunkLowering] the lowering run ahead of emission.
|
|
45
|
+
def initialize(thunk_lowering: ThunkLowering.new)
|
|
46
|
+
@thunk_lowering = thunk_lowering
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param ast [Parser::AST::Node] transformed AST
|
|
50
|
+
# @param source_path [String] original file path (for error messages)
|
|
51
|
+
# @return [String] transformed source, line-aligned
|
|
52
|
+
# @raise [ThunkLowering::PlacementError] if a thunk cannot be textually placed
|
|
53
|
+
# @raise [UnloweredNodeTypeError] if a custom node type survived to emission
|
|
54
|
+
def emit(ast, source_path)
|
|
55
|
+
lowered = @thunk_lowering.lower(ast)
|
|
56
|
+
assert_no_custom_types(lowered, source_path)
|
|
57
|
+
|
|
58
|
+
layout = Layout.new
|
|
59
|
+
renderer = StatementRenderer.for_tree(lowered)
|
|
60
|
+
emit_statements(statements_of(lowered), layout, renderer)
|
|
61
|
+
layout.to_source
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def emit_statements(statements, layout, renderer)
|
|
67
|
+
statements.each { |statement| emit_statement(statement, layout, renderer) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def emit_statement(node, layout, renderer)
|
|
71
|
+
if recursive_container?(node)
|
|
72
|
+
emit_container(node, layout, renderer)
|
|
73
|
+
else
|
|
74
|
+
layout.place(node.loc&.line, renderer.aligned_render(node), column: node.loc&.column)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Emits a container body that may be a bare :ensure/:rescue node (their begin/end context comes from the
|
|
79
|
+
# surrounding def/block/kwbegin, so the keywords must be emitted inline, aligned like statements).
|
|
80
|
+
def emit_body(body, layout, renderer)
|
|
81
|
+
case body&.type
|
|
82
|
+
when :ensure then emit_ensure(body, layout, renderer)
|
|
83
|
+
when :rescue then emit_rescue(body, layout, renderer)
|
|
84
|
+
else emit_statements(statements_of(body), layout, renderer)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def emit_ensure(node, layout, renderer)
|
|
89
|
+
*body, ensurer = node.children
|
|
90
|
+
body.each { |statement| emit_body(statement, layout, renderer) }
|
|
91
|
+
place_keyword(layout, keyword_line(node), 'ensure', column: keyword_column(node))
|
|
92
|
+
emit_statements(statements_of(ensurer), layout, renderer)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def emit_rescue(node, layout, renderer)
|
|
96
|
+
body, *resbodies, else_body = node.children
|
|
97
|
+
emit_body(body, layout, renderer)
|
|
98
|
+
resbodies.each { |resbody| emit_resbody(resbody, layout, renderer) }
|
|
99
|
+
return if else_body.nil?
|
|
100
|
+
|
|
101
|
+
else_range = node.loc.else if node.loc.respond_to?(:else)
|
|
102
|
+
place_keyword(layout, else_range&.line, 'else', column: else_range&.column)
|
|
103
|
+
emit_statements(statements_of(else_body), layout, renderer)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def emit_resbody(node, layout, renderer)
|
|
107
|
+
exceptions, capture, body = node.children
|
|
108
|
+
header = ['rescue']
|
|
109
|
+
header << " #{renderer.unparse(exceptions).delete_prefix('[').delete_suffix(']')}" if exceptions
|
|
110
|
+
header << " => #{capture.children[0]}" if capture
|
|
111
|
+
place_keyword(layout, node.loc&.line, header.join, column: node.loc&.column)
|
|
112
|
+
emit_statements(statements_of(body), layout, renderer)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Keywords (rescue/ensure/else) cannot be `;`-packed after a statement; when their line is taken they go on a
|
|
116
|
+
# fresh line instead.
|
|
117
|
+
def place_keyword(layout, target_line, keyword, column: nil)
|
|
118
|
+
if target_line && target_line > layout.cursor
|
|
119
|
+
layout.place(target_line, keyword, column: column)
|
|
120
|
+
else
|
|
121
|
+
layout.place_on_fresh_line(keyword)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def keyword_line(node)
|
|
126
|
+
loc = node.loc
|
|
127
|
+
loc.keyword.line if loc.respond_to?(:keyword) && loc.keyword
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def keyword_column(node)
|
|
131
|
+
loc = node.loc
|
|
132
|
+
loc.keyword.column if loc.respond_to?(:keyword) && loc.keyword
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def recursive_container?(node)
|
|
136
|
+
RECURSIVE_CONTAINER_TYPES.include?(node.type) || block_assignment?(node)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def block_assignment?(node)
|
|
140
|
+
ASSIGNMENT_TYPES.include?(node.type) && node.children.last.is_a?(::Parser::AST::Node) &&
|
|
141
|
+
BLOCK_VALUE_TYPES.include?(node.children.last.type)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Renders a container's opener and closer from the node with its body emptied, then recurses into the body so
|
|
145
|
+
# nested statements align.
|
|
146
|
+
def emit_container(node, layout, renderer)
|
|
147
|
+
opener, closer = container_delimiters(node, renderer)
|
|
148
|
+
layout.place(node.loc&.line, opener, column: node.loc&.column)
|
|
149
|
+
emit_body(container_body(node), layout, renderer)
|
|
150
|
+
layout.place(closer_line(node), closer, column: closer_column(node))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def container_delimiters(node, renderer)
|
|
154
|
+
rendered = renderer.unparse(empty_container(node)).split("\n").reject(&:empty?)
|
|
155
|
+
opener = rendered[0..-2].join("\n")
|
|
156
|
+
closer = rendered.last
|
|
157
|
+
|
|
158
|
+
# Unparser renders empty blocks with braces, but brace blocks cannot hold rescue/ensure bodies; do/end always
|
|
159
|
+
# can.
|
|
160
|
+
if opener.end_with?(' {') && closer == '}'
|
|
161
|
+
[opener.sub(/ \{\z/, ' do'), 'end']
|
|
162
|
+
else
|
|
163
|
+
[opener, closer]
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def empty_container(node)
|
|
168
|
+
case node.type
|
|
169
|
+
when :kwbegin
|
|
170
|
+
node.updated(nil, [])
|
|
171
|
+
when *ASSIGNMENT_TYPES
|
|
172
|
+
block_node = node.children.last
|
|
173
|
+
emptied_block = block_node.updated(nil, [*block_node.children[0..-2], nil])
|
|
174
|
+
node.updated(nil, [*node.children[0..-2], emptied_block])
|
|
175
|
+
else
|
|
176
|
+
children = node.children.dup
|
|
177
|
+
children[BODY_INDEXES.fetch(node.type)] = nil
|
|
178
|
+
node.updated(nil, children)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def container_body(node)
|
|
183
|
+
case node.type
|
|
184
|
+
when :kwbegin then node.children.size == 1 ? node.children.first : node.updated(:begin, node.children)
|
|
185
|
+
when *ASSIGNMENT_TYPES then node.children.last.children[2]
|
|
186
|
+
else node.children[BODY_INDEXES.fetch(node.type)]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The line the container's `end`/`}` occupies in the source, when known.
|
|
191
|
+
def closer_line(node)
|
|
192
|
+
loc = node.loc
|
|
193
|
+
loc.end.line if loc.respond_to?(:end) && loc.end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def closer_column(node)
|
|
197
|
+
loc = node.loc
|
|
198
|
+
loc.end.column if loc.respond_to?(:end) && loc.end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Loc-less :begin nodes in statement position (e.g. a lowered thunk in a single-statement container body, carried
|
|
202
|
+
# with its placement) are grouping, not structure: flatten them so each inner statement is laid out independently.
|
|
203
|
+
def statements_of(body)
|
|
204
|
+
return [] if body.nil?
|
|
205
|
+
return [body] unless body.type == :begin
|
|
206
|
+
|
|
207
|
+
body.children.flat_map do |child|
|
|
208
|
+
child.is_a?(::Parser::AST::Node) && child.type == :begin && child.loc.nil? ? statements_of(child) : [child]
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def assert_no_custom_types(node, source_path)
|
|
213
|
+
return unless node.is_a?(::Parser::AST::Node)
|
|
214
|
+
|
|
215
|
+
if node.type.start_with?('ast_') || Node.registry.key?(node.type)
|
|
216
|
+
raise UnloweredNodeTypeError,
|
|
217
|
+
"custom node type :#{node.type} reached emission in #{source_path}; custom types are " \
|
|
218
|
+
"IR between transformation stages and must be lowered by the stage that understands them"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
node.children.each { |child| assert_no_custom_types(child, source_path) }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'parser'
|
|
4
|
+
|
|
5
|
+
module ASTTransform
|
|
6
|
+
# Base class for custom IR nodes. Transform authors subclass it and register a custom node type to get type-routed
|
|
7
|
+
# construction from +s+ with domain accessors:
|
|
8
|
+
#
|
|
9
|
+
# class InteractionNode < ASTTransform::Node
|
|
10
|
+
# register :rspock_interaction
|
|
11
|
+
#
|
|
12
|
+
# def cardinality = children[0]
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# s(:rspock_interaction, ...) # => InteractionNode
|
|
16
|
+
#
|
|
17
|
+
# Custom node *types* are IR between stages that understand them and must be lowered before emission (the emitter
|
|
18
|
+
# enforces this). Standard-typed nodes deliberately stay plain Parser::AST::Node everywhere — parsed and +s+-built
|
|
19
|
+
# alike: AST::Node#eql? compares class, and Unparser verifies dynamic-string emission by re-parsing and
|
|
20
|
+
# eql?-comparing, so a custom class on a standard type breaks emission.
|
|
21
|
+
class Node < ::Parser::AST::Node
|
|
22
|
+
class << self
|
|
23
|
+
# Registers +self+ as the class to construct for +type+ nodes.
|
|
24
|
+
#
|
|
25
|
+
# @param type [Symbol] the custom node type routed to this class
|
|
26
|
+
# @return [void]
|
|
27
|
+
def register(type)
|
|
28
|
+
Node.registry[type] = self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Builds a node of +type+: registered types construct their custom class, everything else a plain
|
|
32
|
+
# Parser::AST::Node.
|
|
33
|
+
#
|
|
34
|
+
# @param type [Symbol] node type
|
|
35
|
+
# @param children [Array] child nodes / literals
|
|
36
|
+
# @param properties [Hash] node properties (e.g. location:)
|
|
37
|
+
# @return [Parser::AST::Node]
|
|
38
|
+
def build(type, children, properties = {})
|
|
39
|
+
klass = Node.registry.fetch(type, ::Parser::AST::Node)
|
|
40
|
+
klass.new(type, children, properties)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def registry
|
|
44
|
+
@registry ||= {}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
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
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'unparser'
|
|
4
|
+
|
|
5
|
+
module ASTTransform
|
|
6
|
+
# Renders individual statements to text via Unparser, working around the two consequences of unparsing them in
|
|
7
|
+
# isolation (line-aligned emission places each statement independently, so each is ripped out of its context):
|
|
8
|
+
#
|
|
9
|
+
# * Isolation loses the surrounding scope's local variables, making Unparser re-parse identifiers as method calls
|
|
10
|
+
# and fail its dstr round-trip verification — so a renderer is built once per tree with every local bound
|
|
11
|
+
# anywhere in it, and feeds Unparser that set on each render.
|
|
12
|
+
# * Unparser normalizes some single-line constructs into multi-line form, which would push following statements
|
|
13
|
+
# off their lines — so renders taller than their source are compressed back to one line when safely possible.
|
|
14
|
+
#
|
|
15
|
+
# Immutable: configured with the tree's locals at construction, no per-render state.
|
|
16
|
+
class StatementRenderer
|
|
17
|
+
# Node types that bind a local variable name: assignments plus every method/block parameter flavor.
|
|
18
|
+
LOCAL_BINDING_TYPES = [:lvasgn, :arg, :optarg, :restarg, :kwarg, :kwoptarg, :blockarg, :shadowarg].freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Builds a renderer for statements of +node+'s tree, holding every local bound anywhere in it — an
|
|
22
|
+
# over-approximation that is safe because the set only informs Unparser's re-parse verification, never the
|
|
23
|
+
# rendered text.
|
|
24
|
+
def for_tree(node)
|
|
25
|
+
new(local_variables: collect_local_variables(node))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def collect_local_variables(node, names = Set.new)
|
|
31
|
+
return names unless node.is_a?(::Parser::AST::Node)
|
|
32
|
+
|
|
33
|
+
names << node.children[0] if LOCAL_BINDING_TYPES.include?(node.type) && node.children[0]
|
|
34
|
+
node.children.each { |child| collect_local_variables(child, names) }
|
|
35
|
+
names
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param local_variables [Set<Symbol>] every local bound in the tree the statements come from.
|
|
40
|
+
def initialize(local_variables:)
|
|
41
|
+
@local_variables = local_variables
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param node [Parser::AST::Node] the statement to render.
|
|
45
|
+
# @return [String] Unparser's render, informed of the tree's locals.
|
|
46
|
+
def unparse(node)
|
|
47
|
+
Unparser.unparse(node, static_local_variables: @local_variables)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Renders +node+ no taller than its source when safely possible. Unparser normalizes some single-line
|
|
51
|
+
# constructs into multi-line form (e.g. modifier-if into if/end); when the render is taller than the
|
|
52
|
+
# statement's source, compress it back to one line — verified by re-parse so a statement that cannot be safely
|
|
53
|
+
# single-lined (e.g. containing a heredoc) falls back to its multi-line render.
|
|
54
|
+
def aligned_render(node)
|
|
55
|
+
render = unparse(node)
|
|
56
|
+
loc = node.loc
|
|
57
|
+
return render unless loc.respond_to?(:last_line) && loc.line
|
|
58
|
+
|
|
59
|
+
source_height = loc.last_line - loc.line + 1
|
|
60
|
+
return render if render.count("\n") < source_height
|
|
61
|
+
|
|
62
|
+
compress_to_single_line(render) || render
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def compress_to_single_line(render)
|
|
68
|
+
candidate = render.split("\n").map(&:strip).join('; ')
|
|
69
|
+
# Both sides parsed without scope context, so lvar/send ambiguity cancels out; equality means the newline
|
|
70
|
+
# join preserved structure.
|
|
71
|
+
Unparser.parse(candidate) == Unparser.parse(render) ? candidate : nil
|
|
72
|
+
rescue Parser::SyntaxError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ast_transform/transformer'
|
|
4
|
+
require 'ast_transform/instruction_sequence'
|
|
5
|
+
|
|
6
|
+
module ASTTransform
|
|
7
|
+
module Testing
|
|
8
|
+
# Assertions for transform authors' own test suites — the enforcement arm of the authoring contract ("textual
|
|
9
|
+
# order is source order"). Minitest-flavored. Never loaded in production; require it from test code:
|
|
10
|
+
#
|
|
11
|
+
# require "ast_transform/testing/assertions"
|
|
12
|
+
#
|
|
13
|
+
# class MyTransformationTest < Minitest::Test
|
|
14
|
+
# include ASTTransform::Testing::Assertions
|
|
15
|
+
# end
|
|
16
|
+
module Assertions
|
|
17
|
+
# Transforms +source+ through the real pipeline (transform + line-aligned emission), re-parses both sides,
|
|
18
|
+
# matches surviving statements by location, and asserts each one's emitted line equals its source line.
|
|
19
|
+
# Statements the transform deletes (e.g. description strings) are exempt; statements the transform rewrites in
|
|
20
|
+
# place keep their anchor and are checked.
|
|
21
|
+
#
|
|
22
|
+
# @param source [String] fixture source
|
|
23
|
+
# @param transformations [Array<ASTTransform::AbstractTransformation>]
|
|
24
|
+
# @param path [String] pseudo-path used for parsing and messages
|
|
25
|
+
# @return [void]
|
|
26
|
+
def assert_line_aligned(source, *transformations, path: 'fixture.rb')
|
|
27
|
+
transformer = Transformer.new(*transformations)
|
|
28
|
+
emitted = transformer.transform_file_source(source, path, path)
|
|
29
|
+
|
|
30
|
+
source_lines_by_statement = statement_lines(transformer.build_ast(source, file_path: path))
|
|
31
|
+
emitted_lines_by_statement = statement_lines(transformer.build_ast(emitted, file_path: path))
|
|
32
|
+
|
|
33
|
+
misaligned = source_lines_by_statement.filter_map do |render, source_line|
|
|
34
|
+
emitted_line = emitted_lines_by_statement[render]
|
|
35
|
+
next if emitted_line.nil? || emitted_line == source_line
|
|
36
|
+
|
|
37
|
+
format(' MISALIGNED %s: source line %d, emitted line %d', render, source_line, emitted_line)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
assert misaligned.empty?, <<~MESSAGE
|
|
41
|
+
expected every surviving statement at its source line in #{path}:
|
|
42
|
+
#{misaligned.join("\n")}
|
|
43
|
+
|
|
44
|
+
emitted:
|
|
45
|
+
#{numbered_listing(emitted)}
|
|
46
|
+
MESSAGE
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Runtime complement of assert_line_aligned: compiles +source+ through the full pipeline under +path+, executes
|
|
50
|
+
# it, and asserts the raw first backtrace frame — no filtering of any kind — is "<path>:<raise_at>".
|
|
51
|
+
#
|
|
52
|
+
# @param source [String] fixture that raises when executed
|
|
53
|
+
# @param path [String] pseudo source path to compile under
|
|
54
|
+
# @param raise_at [Integer] expected source line of the raise
|
|
55
|
+
# @return [void]
|
|
56
|
+
def assert_backtrace_lines(source, path:, raise_at:)
|
|
57
|
+
iseq = InstructionSequence.source_to_transformed_iseq(source, path)
|
|
58
|
+
|
|
59
|
+
error = assert_raises(StandardError, "fixture at #{path} should raise when executed") do
|
|
60
|
+
iseq.eval
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
location = error.backtrace_locations.first
|
|
64
|
+
assert_equal "#{location.path}:#{raise_at}", "#{location.path}:#{location.lineno}",
|
|
65
|
+
"raw backtrace should cite source line #{raise_at} of #{path}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Flat statement renders and their first line, keyed by unparsed text so source and emitted sides can be matched
|
|
71
|
+
# without location identity. Duplicate renders keep their first occurrence — good enough for fixtures, which
|
|
72
|
+
# authors control.
|
|
73
|
+
def statement_lines(ast, lines = {})
|
|
74
|
+
return lines unless ast.is_a?(::Parser::AST::Node)
|
|
75
|
+
|
|
76
|
+
if statement_sequence?(ast)
|
|
77
|
+
ast.children.each do |statement|
|
|
78
|
+
next unless statement.is_a?(::Parser::AST::Node) && statement.loc&.expression
|
|
79
|
+
|
|
80
|
+
lines[Unparser.unparse(statement)] ||= statement.loc.line
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
ast.children.each { |child| statement_lines(child, lines) }
|
|
85
|
+
lines
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def statement_sequence?(node)
|
|
89
|
+
[:begin, :kwbegin].include?(node.type)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def numbered_listing(source)
|
|
93
|
+
source.lines.map.with_index(1) { |line, number| format('%3d| %s', number, line) }.join
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|