ast_transform 2.1.4 → 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 +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 +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 -233
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ast_transform/node'
|
|
4
|
+
require 'ast_transform/thunk'
|
|
5
|
+
require 'ast_transform/transformation_helper'
|
|
6
|
+
|
|
7
|
+
module ASTTransform
|
|
8
|
+
# Lowers Thunk nodes into plain Ruby ahead of emission. Each unique thunk (grouped by id identity) becomes a
|
|
9
|
+
# hidden proc; each occurrence becomes the proc's call:
|
|
10
|
+
#
|
|
11
|
+
# thunk placed at the execution point
|
|
12
|
+
# => x = x; __ast_thunk_<n>__ = proc { body } (at the body's source lines)
|
|
13
|
+
# ...
|
|
14
|
+
# __ast_thunk_<n>__.call (at the occurrence)
|
|
15
|
+
#
|
|
16
|
+
# Placement is inferred, not authored: the proc's text is inserted into the statement sequence enclosing the
|
|
17
|
+
# occurrence, positioned among its siblings by the body's first source line — the lines the author removed the
|
|
18
|
+
# statements from. A loc-less body has no textual home and packs immediately before its call. Placements never
|
|
19
|
+
# escape a scope boundary (def/class/module bodies absorb their own), because the hidden lvar must share the call's
|
|
20
|
+
# method activation; they DO escape block literals, which close over the defining scope.
|
|
21
|
+
#
|
|
22
|
+
# The closure is a non-lambda proc on purpose: `return` inside a proc returns from the method where the proc was
|
|
23
|
+
# defined, and placement and execution always share one method activation, so a thunked `return` keeps its original
|
|
24
|
+
# meaning. Jump keywords whose owner lies outside the body keep Ruby's native behavior (`break`/`retry` fail loudly,
|
|
25
|
+
# `next`/`redo` silently alter flow) — what a transform chooses to thunk is the transform author's call.
|
|
26
|
+
#
|
|
27
|
+
# The `x = x` pre-declarations cover every local the body assigns at method scope. A local first assigned inside a
|
|
28
|
+
# block literal is block-local, so without a textual method-scope assignment before the proc, thunked assignments
|
|
29
|
+
# would be invisible to the statements that read them after the execution point. Self-assignment registers the name
|
|
30
|
+
# (nil until the thunk runs — exactly what an unexecuted assignment yields) without clobbering an already-assigned
|
|
31
|
+
# value.
|
|
32
|
+
#
|
|
33
|
+
# Stateless: the thunks encountered during one lowering are tracked in a Registry created at +lower+ entry, so an
|
|
34
|
+
# instance is a reusable collaborator.
|
|
35
|
+
class ThunkLowering
|
|
36
|
+
include TransformationHelper
|
|
37
|
+
|
|
38
|
+
# A pending proc definition: +line+ is the body's first source line (nil for fully synthetic bodies),
|
|
39
|
+
# +statements+ the pre-declarations plus the proc assignment.
|
|
40
|
+
Placement = Struct.new(:line, :statements)
|
|
41
|
+
|
|
42
|
+
# Raised when a thunk cannot be placed: its body's source lines fall after the execution point (the hidden
|
|
43
|
+
# proc's text IS its assignment, so a call can never textually precede the body), or two occurrences of the
|
|
44
|
+
# same thunk carry diverging bodies.
|
|
45
|
+
class PlacementError < StandardError; end
|
|
46
|
+
|
|
47
|
+
# The thunks encountered during one lowering, keyed by identity: allocates each thunk's hidden lvar name
|
|
48
|
+
# on first occurrence and verifies later occurrences carry the same body.
|
|
49
|
+
class Registry
|
|
50
|
+
def initialize
|
|
51
|
+
@names_by_id = {}.compare_by_identity
|
|
52
|
+
@bodies_by_id = {}.compare_by_identity
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def known?(id)
|
|
56
|
+
@names_by_id.key?(id)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Symbol] the hidden lvar name allocated for +id+.
|
|
60
|
+
def register(id, body)
|
|
61
|
+
name = :"__ast_thunk_#{@names_by_id.size + 1}__"
|
|
62
|
+
@names_by_id[id] = name
|
|
63
|
+
@bodies_by_id[id] = body
|
|
64
|
+
name
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def name_for(id)
|
|
68
|
+
@names_by_id.fetch(id)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def verify_same_body!(id, body)
|
|
72
|
+
return if @bodies_by_id[id] == body
|
|
73
|
+
|
|
74
|
+
raise PlacementError,
|
|
75
|
+
'occurrences of one thunk carry diverging bodies; reuse the same thunk node to multiplex'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def hidden_names
|
|
79
|
+
@names_by_id.values
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
private_constant :Registry
|
|
83
|
+
|
|
84
|
+
SEQUENCE_TYPES = [:begin, :kwbegin].freeze
|
|
85
|
+
# Scope-opening containers: the hidden lvar cannot be referenced across these boundaries, so placements arising
|
|
86
|
+
# inside must land inside.
|
|
87
|
+
SCOPE_BODY_INDEXES = { def: 2, defs: 3, class: 2, module: 1, sclass: 1 }.freeze
|
|
88
|
+
|
|
89
|
+
# @param node [Parser::AST::Node] tree possibly containing Thunk nodes
|
|
90
|
+
# @return [Parser::AST::Node] tree with thunks lowered to plain Ruby
|
|
91
|
+
# @raise [PlacementError] when a thunk body's source lines fall after its execution point, or occurrences of
|
|
92
|
+
# one thunk diverge
|
|
93
|
+
def lower(node)
|
|
94
|
+
lower_body(node, Registry.new)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Lowers a node standing in statement-body position (a container's body or the root), absorbing any placements
|
|
100
|
+
# that arise within it.
|
|
101
|
+
def lower_body(node, registry)
|
|
102
|
+
return node unless node.is_a?(::Parser::AST::Node)
|
|
103
|
+
return lower_sequence(node, registry) if SEQUENCE_TYPES.include?(node.type)
|
|
104
|
+
|
|
105
|
+
lowered, placements = lower_expression(node, registry)
|
|
106
|
+
return lowered if placements.empty?
|
|
107
|
+
|
|
108
|
+
# A loc-less :begin in statement position; the emitter flattens it into the surrounding statement stream.
|
|
109
|
+
s(:begin, *placements.flat_map(&:statements), lowered)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Lowers a statement sequence, inserting each placement among the statements by the body's source line.
|
|
113
|
+
def lower_sequence(node, registry)
|
|
114
|
+
statements = []
|
|
115
|
+
|
|
116
|
+
node.children.each_with_index do |child, index|
|
|
117
|
+
lowered, placements = lower_expression(child, registry)
|
|
118
|
+
placements.each do |placement|
|
|
119
|
+
check_placement_precedes_execution!(placement, child, node.children[(index + 1)..])
|
|
120
|
+
statements.insert(insertion_index(statements, placement), *placement.statements)
|
|
121
|
+
end
|
|
122
|
+
statements << lowered
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
node.updated(nil, statements)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Lowers a node in expression position. Returns the lowered node and the placements that must be inserted into
|
|
129
|
+
# the enclosing statement sequence.
|
|
130
|
+
#
|
|
131
|
+
# @return [Array(Parser::AST::Node, Array<Placement>)]
|
|
132
|
+
def lower_expression(node, registry)
|
|
133
|
+
return [node, []] unless node.is_a?(::Parser::AST::Node)
|
|
134
|
+
|
|
135
|
+
case node.type
|
|
136
|
+
when :ast_thunk
|
|
137
|
+
lower_thunk(node, registry)
|
|
138
|
+
when *SEQUENCE_TYPES
|
|
139
|
+
[lower_sequence(node, registry), []]
|
|
140
|
+
when :ensure, :rescue
|
|
141
|
+
[node.updated(nil, node.children.map { |child| lower_body(child, registry) }), []]
|
|
142
|
+
when :resbody
|
|
143
|
+
exceptions, capture, body = node.children
|
|
144
|
+
[node.updated(nil, [exceptions, capture, lower_body(body, registry)]), []]
|
|
145
|
+
else
|
|
146
|
+
lower_generic(node, registry)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def lower_generic(node, registry)
|
|
151
|
+
scope_body_index = SCOPE_BODY_INDEXES[node.type]
|
|
152
|
+
pending = []
|
|
153
|
+
|
|
154
|
+
children = node.children.each_with_index.map do |child, index|
|
|
155
|
+
if index == scope_body_index
|
|
156
|
+
lower_body(child, registry)
|
|
157
|
+
else
|
|
158
|
+
lowered, placements = lower_expression(child, registry)
|
|
159
|
+
pending.concat(placements)
|
|
160
|
+
lowered
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
[node.updated(nil, children), pending]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# An occurrence of a thunk: the first occurrence of its id yields the placement; every occurrence yields the
|
|
168
|
+
# call.
|
|
169
|
+
def lower_thunk(node, registry)
|
|
170
|
+
id = node.id
|
|
171
|
+
|
|
172
|
+
if registry.known?(id)
|
|
173
|
+
registry.verify_same_body!(id, node.body)
|
|
174
|
+
return [call_node(id, registry), []]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
name = registry.register(id, node.body)
|
|
178
|
+
lowered_body = lower_sequence(s(:begin, *node.body), registry)
|
|
179
|
+
placement = Placement.new(body_first_line(node.body), placement_statements(name, lowered_body, registry))
|
|
180
|
+
[call_node(id, registry), [placement]]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def call_node(id, registry)
|
|
184
|
+
s(:send, s(:lvar, registry.name_for(id)), :call)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def placement_statements(name, lowered_body, registry)
|
|
188
|
+
assignment = s(:lvasgn, name, s(:block, s(:send, nil, :proc), s(:args), lowered_body))
|
|
189
|
+
hidden_names = registry.hidden_names
|
|
190
|
+
pre_declared = method_scope_assignments(lowered_body).reject { |local| hidden_names.include?(local) }
|
|
191
|
+
pre_declared.map { |local| s(:lvasgn, local, s(:lvar, local)) } << assignment
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def body_first_line(body)
|
|
195
|
+
body.filter_map { |statement| statement.loc&.line }.min
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The proc's text must precede its call: a placement whose body lines fall at or after the executing statement
|
|
199
|
+
# (or any statement after it) cannot be laid out — the assignment would complete after the call.
|
|
200
|
+
def check_placement_precedes_execution!(placement, executing_statement, following_statements)
|
|
201
|
+
return if placement.line.nil?
|
|
202
|
+
|
|
203
|
+
conflicting = [executing_statement, *following_statements].find do |statement|
|
|
204
|
+
line = statement.is_a?(::Parser::AST::Node) ? statement.loc&.line : nil
|
|
205
|
+
line && line < placement.line
|
|
206
|
+
end
|
|
207
|
+
return if conflicting.nil?
|
|
208
|
+
|
|
209
|
+
raise PlacementError,
|
|
210
|
+
"thunk body's source lines (from line #{placement.line}) fall after its execution point " \
|
|
211
|
+
"(statement at line #{conflicting.loc.line}); a thunk can only delay execution, never text"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def insertion_index(statements, placement)
|
|
215
|
+
return statements.size if placement.line.nil?
|
|
216
|
+
|
|
217
|
+
statements.index { |statement| statement.loc&.line && statement.loc.line > placement.line } || statements.size
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Node types opening a new local-variable scope: assignments inside them were invisible to the method scope in
|
|
221
|
+
# the original source too, so they get no pre-declaration.
|
|
222
|
+
NEW_SCOPE_TYPES = [:def, :defs, :class, :module, :sclass].freeze
|
|
223
|
+
# Block literals: locals first assigned inside them are block-local (the same lexical rule the pre-declarations
|
|
224
|
+
# exist to work around), but their callee/arguments evaluate at method scope and are still descended.
|
|
225
|
+
BLOCK_TYPES = [:block, :numblock, :itblock].freeze
|
|
226
|
+
|
|
227
|
+
# Locals the thunk body assigns at method scope, in first-assignment order (covers masgn/op_asgn targets — they
|
|
228
|
+
# all carry :lvasgn nodes).
|
|
229
|
+
def method_scope_assignments(node, names = [])
|
|
230
|
+
return names unless node.is_a?(::Parser::AST::Node)
|
|
231
|
+
return names if NEW_SCOPE_TYPES.include?(node.type)
|
|
232
|
+
|
|
233
|
+
names << node.children[0] if node.type == :lvasgn && !names.include?(node.children[0])
|
|
234
|
+
|
|
235
|
+
children = BLOCK_TYPES.include?(node.type) ? [node.children[0]] : node.children
|
|
236
|
+
children.each { |child| method_scope_assignments(child, names) }
|
|
237
|
+
names
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
2
|
+
|
|
3
|
+
require "ast_transform"
|
|
4
|
+
require "ast_transform/abstract_transformation"
|
|
5
|
+
require "ast_transform/transformer"
|
|
6
|
+
require "unparser"
|
|
6
7
|
|
|
7
8
|
module ASTTransform
|
|
8
9
|
class Transformation < ASTTransform::AbstractTransformation
|
|
@@ -18,9 +19,9 @@ module ASTTransform
|
|
|
18
19
|
|
|
19
20
|
count_before_reject = children.size
|
|
20
21
|
|
|
21
|
-
children.reject!.with_index
|
|
22
|
+
children.reject!.with_index do |child_node, index|
|
|
22
23
|
transform_node?(child_node) && transformable_node?(next_child(node, index))
|
|
23
|
-
|
|
24
|
+
end
|
|
24
25
|
|
|
25
26
|
processed = process_all(children)
|
|
26
27
|
|
|
@@ -68,19 +69,14 @@ module ASTTransform
|
|
|
68
69
|
|
|
69
70
|
def extract_transformation(node)
|
|
70
71
|
return unless node.is_a?(Parser::AST::Node)
|
|
71
|
-
return
|
|
72
|
-
|
|
73
|
-
if node.children[1] == :new
|
|
74
|
-
require_transformation(node)
|
|
75
|
-
code = Unparser.unparse(node)
|
|
72
|
+
return if node.children.count < 2
|
|
76
73
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
require_transformation(node)
|
|
75
|
+
code = Unparser.unparse(node)
|
|
76
|
+
# A bare constant reference is instantiated; an explicit .new is kept as written.
|
|
77
|
+
code = "#{code}.new" unless node.children[1] == :new
|
|
81
78
|
|
|
82
|
-
|
|
83
|
-
end
|
|
79
|
+
TOPLEVEL_BINDING.eval(code)
|
|
84
80
|
end
|
|
85
81
|
|
|
86
82
|
def require_transformation(node)
|
|
@@ -100,11 +96,12 @@ module ASTTransform
|
|
|
100
96
|
acronyms = ASTTransform.acronyms
|
|
101
97
|
acronym_regex = acronyms.empty? ? /(?=a)b/ : /#{acronyms.join("|")}/
|
|
102
98
|
return const_name unless /[A-Z-]|::/.match?(const_name)
|
|
103
|
-
|
|
104
|
-
word.gsub
|
|
105
|
-
word.gsub!(/([A-
|
|
106
|
-
word.gsub!(/([
|
|
107
|
-
word.
|
|
99
|
+
|
|
100
|
+
word = const_name.to_s.gsub("::", "/")
|
|
101
|
+
word.gsub!(/(?:(?<=([A-Za-z\d]))|\b)(#{acronym_regex})(?=\b|[^a-z])/) { "#{::Regexp.last_match(1) && "_"}#{::Regexp.last_match(2).downcase}" }
|
|
102
|
+
word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
|
103
|
+
word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
|
|
104
|
+
word.tr!("-", "_")
|
|
108
105
|
word.downcase!
|
|
109
106
|
word
|
|
110
107
|
end
|
|
@@ -1,16 +1,127 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require 'parser'
|
|
4
|
+
require 'ast_transform/node'
|
|
5
|
+
require 'ast_transform/thunk'
|
|
3
6
|
|
|
4
7
|
module ASTTransform
|
|
8
|
+
# The transform-authoring layer. Three shapes:
|
|
9
|
+
#
|
|
10
|
+
# - Constructors (+s+, +s_at+): type + children in, fresh node out.
|
|
11
|
+
# - The sequence combinator (+run_after+): sequence in, sequence out — the paved road for execution reordering.
|
|
12
|
+
# - The low-level reordering primitive (+thunk+): statements in, Thunk node out — for execution points inside
|
|
13
|
+
# expressions.
|
|
14
|
+
#
|
|
15
|
+
# The contract these helpers serve: textual order is source order. The emitter places every loc-carrying statement
|
|
16
|
+
# at its source line; when execution order must differ from textual order, authors express it as a thunk instead of
|
|
17
|
+
# moving text.
|
|
5
18
|
module TransformationHelper
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
19
|
+
# Raised by authoring helpers (e.g. +s_at+) when a node that must carry a source location does not have one.
|
|
20
|
+
class MissingLocationError < StandardError; end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def included(base)
|
|
24
|
+
base.extend(Methods)
|
|
25
|
+
base.include(Methods)
|
|
26
|
+
end
|
|
9
27
|
end
|
|
10
28
|
|
|
11
29
|
module Methods
|
|
30
|
+
# Builds a loc-less node. The emitter packs loc-less nodes onto the current output line — the correct default
|
|
31
|
+
# for synthetic code, which has no source-line truth to preserve.
|
|
32
|
+
#
|
|
33
|
+
# @param type [Symbol] node type
|
|
34
|
+
# @param children [Array] child nodes / literals
|
|
35
|
+
# @param properties [Hash] node properties (e.g. location:)
|
|
36
|
+
# @return [ASTTransform::Node] node routed to its registered class
|
|
12
37
|
def s(type, *children, **properties)
|
|
13
|
-
|
|
38
|
+
Node.build(type, children, properties)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Builds a fresh node anchored to another node's source location. Use when composing a replacement tree whose
|
|
42
|
+
# root isn't derived from the node it replaces (otherwise prefer +anchor.updated(...)+). The attached map is a
|
|
43
|
+
# clean expression-only Source::Map over +anchor.loc.expression+ — no stale typed sub-ranges (selector etc.).
|
|
44
|
+
# Anchor inheritance is shallow; children keep or lack their own locs.
|
|
45
|
+
#
|
|
46
|
+
# @param anchor [Parser::AST::Node] node whose line this code replaces
|
|
47
|
+
# @param type [Symbol] node type
|
|
48
|
+
# @param children [Array] child nodes / literals
|
|
49
|
+
# @return [ASTTransform::Node] node carrying anchor's expression range
|
|
50
|
+
# @raise [MissingLocationError] if anchor has no expression location
|
|
51
|
+
def s_at(anchor, type, *children)
|
|
52
|
+
expression = anchor.loc&.expression
|
|
53
|
+
raise MissingLocationError, "anchor #{anchor.type} node has no source location" unless expression
|
|
54
|
+
|
|
55
|
+
s(type, *children, location: ::Parser::Source::Map.new(expression))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The low-level reordering primitive. Thunking is the one reordering lever: text never moves and execution can
|
|
59
|
+
# only move later, so "hoist A above B" is expressed as "run B after A". Returns a single Thunk node: splice it
|
|
60
|
+
# where the statements must RUN — statement position or composed inside an expression, e.g. as an assert_raises
|
|
61
|
+
# block body. The wrapped statements keep their own locs, and the lowering derives the hidden proc's textual
|
|
62
|
+
# placement from them, so the body still emits on its source lines even though execution waits. Reuse the same
|
|
63
|
+
# node to execute one body from several points.
|
|
64
|
+
#
|
|
65
|
+
# Semantics are near-transparent (see ThunkLowering): +return+ still returns from the enclosing method
|
|
66
|
+
# (non-lambda proc), and locals the wrapped statements assign stay method-scope (pre-declared before the proc).
|
|
67
|
+
# Jump keywords whose owner lies outside the wrapped statements keep Ruby's native behavior — +break+/+retry+
|
|
68
|
+
# fail loudly at the jump's own source line, +next+/+redo+ silently end or restart the thunk body. Weigh that
|
|
69
|
+
# when choosing what your surface thunks.
|
|
70
|
+
#
|
|
71
|
+
# Prefer +run_after+ when the execution point sits in the same statement sequence as the statements.
|
|
72
|
+
#
|
|
73
|
+
# @param statements [Array<Parser::AST::Node>] statements to wrap
|
|
74
|
+
# @return [ASTTransform::Thunk] the thunk node
|
|
75
|
+
def thunk(*statements)
|
|
76
|
+
s(:ast_thunk, Thunk::Id.new, *statements)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The paved road for execution reordering in flat statement sequences. Named for the constraint, not the
|
|
80
|
+
# mechanism — with text pinned to source lines the only physical lever is delaying execution, so "run X after
|
|
81
|
+
# Y" is the constraint an author states. Returns a NEW sequence in which the +run+ statements are removed and a
|
|
82
|
+
# thunk wrapping them is inserted immediately after +after+.
|
|
83
|
+
#
|
|
84
|
+
# All membership checks are by identity (equal?), never ==: node equality ignores location, so two textually
|
|
85
|
+
# identical statements on different lines compare == and value matching could splice the wrong one.
|
|
86
|
+
#
|
|
87
|
+
# @param statements [Array<Parser::AST::Node>] the sequence being composed
|
|
88
|
+
# @param run [Array<Parser::AST::Node>] contiguous run of elements of +statements+ (by identity) whose
|
|
89
|
+
# execution must wait
|
|
90
|
+
# @param after [Parser::AST::Node] element of +statements+ (by identity, not inside +run+) the +run+ statements
|
|
91
|
+
# execute after
|
|
92
|
+
# @return [Array<Parser::AST::Node>] new sequence with the thunk placed
|
|
93
|
+
# @raise [ArgumentError] if +run+ is not a contiguous identity-run of +statements+, or +after+ is not an
|
|
94
|
+
# element (or is inside +run+)
|
|
95
|
+
def run_after(statements, run:, after:)
|
|
96
|
+
run_range = contiguous_identity_range(statements, run)
|
|
97
|
+
raise ArgumentError, "run: must be a contiguous run of elements of statements (by identity)" unless run_range
|
|
98
|
+
|
|
99
|
+
after_index = statements.index { |statement| statement.equal?(after) }
|
|
100
|
+
raise ArgumentError, "after: must be an element of statements (by identity)" unless after_index
|
|
101
|
+
raise ArgumentError, "after: cannot be inside run:" if run_range.cover?(after_index)
|
|
102
|
+
|
|
103
|
+
reordered = statements.dup
|
|
104
|
+
reordered[run_range] = []
|
|
105
|
+
|
|
106
|
+
insertion_index = reordered.index { |statement| statement.equal?(after) }
|
|
107
|
+
reordered.insert(insertion_index + 1, thunk(*run))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# The range +members+ occupies in +sequence+, or nil unless members is a non-empty contiguous identity-run in
|
|
113
|
+
# order.
|
|
114
|
+
def contiguous_identity_range(sequence, members)
|
|
115
|
+
return nil if members.empty?
|
|
116
|
+
|
|
117
|
+
start = sequence.index { |element| element.equal?(members.first) }
|
|
118
|
+
return nil unless start
|
|
119
|
+
|
|
120
|
+
contiguous = members.each_with_index.all? do |member, offset|
|
|
121
|
+
sequence[start + offset]&.equal?(member)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
contiguous ? (start...(start + members.size)) : nil
|
|
14
125
|
end
|
|
15
126
|
end
|
|
16
127
|
end
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require 'prism'
|
|
3
4
|
require 'prism/translation/parser'
|
|
4
5
|
require 'unparser'
|
|
5
6
|
require 'ast_transform/kwargs_builder'
|
|
6
|
-
require 'ast_transform/
|
|
7
|
+
require 'ast_transform/line_aligned_emitter'
|
|
7
8
|
|
|
8
9
|
module ASTTransform
|
|
9
10
|
class Transformer
|
|
10
11
|
# Constructs a new Transformer instance.
|
|
11
12
|
#
|
|
12
13
|
# @param transformations [Array<ASTTransform::AbstractTransformation>] The transformations to be run.
|
|
13
|
-
|
|
14
|
+
# @param emitter [ASTTransform::LineAlignedEmitter] The emitter rendering transformed ASTs back to source.
|
|
15
|
+
def initialize(*transformations, emitter: LineAlignedEmitter.new)
|
|
14
16
|
@transformations = transformations
|
|
17
|
+
@emitter = emitter
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
# Builds the AST for the given +source+.
|
|
@@ -20,7 +23,7 @@ module ASTTransform
|
|
|
20
23
|
# @param file_path [String] The file_path. This is important for source mapping in backtraces.
|
|
21
24
|
#
|
|
22
25
|
# @return [Parser::AST::Node] The AST.
|
|
23
|
-
def build_ast(source, file_path:
|
|
26
|
+
def build_ast(source, file_path: "tmp")
|
|
24
27
|
buffer = create_buffer(source, file_path)
|
|
25
28
|
parser.parse(buffer)
|
|
26
29
|
end
|
|
@@ -39,16 +42,17 @@ module ASTTransform
|
|
|
39
42
|
#
|
|
40
43
|
# @param source [String] The input source code to be transformed.
|
|
41
44
|
#
|
|
42
|
-
# @return [String] The transformed code.
|
|
45
|
+
# @return [String] The transformed code, line-aligned (see #transform_file_source).
|
|
43
46
|
def transform(source)
|
|
44
47
|
ast = build_ast(source)
|
|
45
48
|
transformed_ast = transform_ast(ast)
|
|
46
|
-
|
|
49
|
+
@emitter.emit(transformed_ast, 'tmp')
|
|
47
50
|
end
|
|
48
51
|
|
|
49
52
|
# Transforms the give +file_path+.
|
|
50
53
|
#
|
|
51
|
-
# @param file_path [String] The input file to be transformed.
|
|
54
|
+
# @param file_path [String] The input file to be transformed. Statement placement (and therefore
|
|
55
|
+
# backtrace and breakpoint line numbers) is derived from this file's source locations.
|
|
52
56
|
# @param transformed_file_path [String] The file path to the transformed file.
|
|
53
57
|
#
|
|
54
58
|
# @return [String] The transformed code.
|
|
@@ -60,21 +64,19 @@ module ASTTransform
|
|
|
60
64
|
# Transforms the given +source+ in +file_path+.
|
|
61
65
|
#
|
|
62
66
|
# @param source [String] The input source code to be transformed.
|
|
63
|
-
# @param file_path [String] The file path for the input +source+.
|
|
64
|
-
#
|
|
65
|
-
#
|
|
67
|
+
# @param file_path [String] The file path for the input +source+. Statement placement (and
|
|
68
|
+
# therefore backtrace and breakpoint line numbers) is derived from the source locations parsed
|
|
69
|
+
# under this path.
|
|
70
|
+
# @param transformed_file_path [String] The file path the transformed file will be written to.
|
|
66
71
|
#
|
|
67
|
-
# @return [String] The transformed code
|
|
68
|
-
|
|
72
|
+
# @return [String] The transformed code, line-aligned: every statement carrying a source
|
|
73
|
+
# location is emitted at its original source line.
|
|
74
|
+
def transform_file_source(source, file_path, _transformed_file_path)
|
|
69
75
|
source_ast = build_ast(source, file_path: file_path)
|
|
70
|
-
# At this point, the transformed_ast contains
|
|
76
|
+
# At this point, the transformed_ast contains source locations for the original +source+.
|
|
71
77
|
transformed_ast = transform_ast(source_ast)
|
|
72
78
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
register_source_map(file_path, transformed_file_path, transformed_ast, transformed_source)
|
|
76
|
-
|
|
77
|
-
transformed_source
|
|
79
|
+
@emitter.emit(transformed_ast, file_path)
|
|
78
80
|
end
|
|
79
81
|
|
|
80
82
|
# Transforms the given +ast+.
|
|
@@ -101,13 +103,5 @@ module ASTTransform
|
|
|
101
103
|
@parser&.reset
|
|
102
104
|
@parser ||= Prism::Translation::Parser.new(ASTTransform::KwargsBuilder.new)
|
|
103
105
|
end
|
|
104
|
-
|
|
105
|
-
def register_source_map(source_file_path, transformed_file_path, transformed_ast, transformed_source)
|
|
106
|
-
# The transformed_source is re-parsed to get the correct line numbers for the transformed_ast, which is the code
|
|
107
|
-
# that will run.
|
|
108
|
-
rewritten_ast = build_ast(transformed_source)
|
|
109
|
-
source_map = ASTTransform::SourceMap.new(source_file_path, transformed_file_path, transformed_ast, rewritten_ast)
|
|
110
|
-
ASTTransform::SourceMap.register_source_map(source_map)
|
|
111
|
-
end
|
|
112
106
|
end
|
|
113
107
|
end
|
data/lib/ast_transform.rb
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "ast_transform/version"
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
4
|
+
require "ast_transform/instruction_sequence"
|
|
5
|
+
require "ast_transform/instruction_sequence/mixin"
|
|
6
|
+
require "ast_transform/instruction_sequence/bootsnap_mixin"
|
|
7
7
|
|
|
8
8
|
module ASTTransform
|
|
9
9
|
DEFAULT_OUTPUT_PATH = Pathname.new("").join("tmp", "ast_transform").to_s
|
|
@@ -19,22 +19,18 @@ module ASTTransform
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def install
|
|
22
|
-
@installed ||=
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
prepend ::ASTTransform::InstructionSequence::Mixin
|
|
30
|
-
end
|
|
22
|
+
@installed ||= if defined?(Bootsnap) && ASTTransform::InstructionSequence.using_bootsnap_compilation?
|
|
23
|
+
class << Bootsnap::CompileCache::ISeq
|
|
24
|
+
prepend ::ASTTransform::InstructionSequence::BootsnapMixin
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
class << RubyVM::InstructionSequence
|
|
28
|
+
prepend ::ASTTransform::InstructionSequence::Mixin
|
|
31
29
|
end
|
|
32
30
|
end
|
|
33
31
|
end
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
@output_path = path
|
|
37
|
-
end
|
|
33
|
+
attr_writer :output_path
|
|
38
34
|
|
|
39
35
|
def output_path
|
|
40
36
|
@output_path || DEFAULT_OUTPUT_PATH
|