ast_transform 2.1.2 → 3.0.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 +16 -0
- data/Gemfile +12 -1
- data/Gemfile.lock +38 -11
- data/README.md +54 -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_transformation.rb +10 -1
- data/lib/ast_transform/instruction_sequence/bootsnap_mixin.rb +4 -4
- data/lib/ast_transform/instruction_sequence/mixin.rb +10 -6
- data/lib/ast_transform/instruction_sequence/mixin_utils.rb +1 -1
- data/lib/ast_transform/instruction_sequence.rb +4 -3
- data/lib/ast_transform/kwargs_builder.rb +10 -8
- 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/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 +19 -22
- data/lib/ast_transform/transformation_helper.rb +115 -4
- data/lib/ast_transform/transformer.rb +19 -25
- data/lib/ast_transform/version.rb +3 -1
- data/lib/ast_transform.rb +11 -15
- metadata +19 -92
- data/lib/ast_transform/source_map.rb +0 -232
|
@@ -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,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
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ast_transform/node'
|
|
4
|
+
|
|
5
|
+
module ASTTransform
|
|
6
|
+
# The reordering primitive: an eagerly built wrapper node, spliced wherever the wrapped statements must EXECUTE —
|
|
7
|
+
# statement position or composed inside an expression (e.g. an assert_raises block body). Its body keeps its own
|
|
8
|
+
# source locations, and the lowering derives the wrapper's textual placement from them (see ThunkLowering), so the
|
|
9
|
+
# statements still emit on their original lines even though execution waits.
|
|
10
|
+
#
|
|
11
|
+
# Children are +[id, *body_statements]+ and the invariants are enforced here in +initialize+, which every
|
|
12
|
+
# construction path shares — +s+ routing, the +thunk+ helper, and Processor rebuilds (+updated+ re-initializes).
|
|
13
|
+
# Build thunks with +TransformationHelper#thunk+; reuse the same node to execute one body from several points
|
|
14
|
+
# (multiplexing).
|
|
15
|
+
#
|
|
16
|
+
# Runtime semantics are near-transparent (proc lowering): +return+ still returns from the enclosing method, and
|
|
17
|
+
# locals the body assigns stay method-scope. See ThunkLowering for the full contract.
|
|
18
|
+
class Thunk < Node
|
|
19
|
+
register :ast_thunk
|
|
20
|
+
|
|
21
|
+
# The identity of a Thunk across transformation passes: Processor and Node#updated rebuilds create new node
|
|
22
|
+
# objects, so node identity does not survive — but children DO (carried by reference through every rebuild). Every
|
|
23
|
+
# rebuild of a thunk therefore carries this same id object, and the lowering groups occurrences by its object
|
|
24
|
+
# identity: one proc, one call per occurrence. Minted internally by the +thunk+ helper, never handled by authors.
|
|
25
|
+
# No behavior — a named class over a bare Object.new only for self-documenting AST dumps and greppability.
|
|
26
|
+
class Id; end
|
|
27
|
+
|
|
28
|
+
def initialize(type, children, properties = {})
|
|
29
|
+
id, *body = children
|
|
30
|
+
unless id.is_a?(ASTTransform::Thunk::Id)
|
|
31
|
+
raise ArgumentError,
|
|
32
|
+
"a Thunk's first child must be its #{Thunk::Id} (got #{id.class}); build thunks with the " \
|
|
33
|
+
"thunk(*statements) helper"
|
|
34
|
+
end
|
|
35
|
+
raise ArgumentError, "a Thunk must wrap at least one statement" if body.empty?
|
|
36
|
+
|
|
37
|
+
# Captured before super (which freezes the node); frozen so the shared array cannot be mutated out from under
|
|
38
|
+
# +children+. Rebuilds via +updated+ re-run initialize, so the capture can never go stale.
|
|
39
|
+
@id = id
|
|
40
|
+
@body = body.freeze
|
|
41
|
+
|
|
42
|
+
super
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Retrieves the Thunk's id.
|
|
46
|
+
# @return [ASTTransform::Thunk::Id] The Id.
|
|
47
|
+
attr_reader(:id)
|
|
48
|
+
|
|
49
|
+
# Retrieves the Thunk's body.
|
|
50
|
+
#
|
|
51
|
+
# @note Same as the +Parser::AST::Node#children+
|
|
52
|
+
# @return [Array<Parser::AST::Node>] The nodes forming the body of the Thunk.
|
|
53
|
+
attr_reader(:body)
|
|
54
|
+
end
|
|
55
|
+
end
|