ast_transform 2.1.4 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.github/CODEOWNERS +1 -0
  3. data/.github/workflows/ci.yml +11 -1
  4. data/.gitignore +4 -1
  5. data/.rubocop.yml +5 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +22 -0
  8. data/Gemfile +12 -1
  9. data/Gemfile.lock +38 -11
  10. data/README.md +106 -2
  11. data/Rakefile +8 -7
  12. data/ast_transform.gemspec +11 -12
  13. data/bin/console +1 -0
  14. data/dependencies.rb +11 -0
  15. data/dev.yml +11 -0
  16. data/lib/ast_transform/abstract_analysis.rb +38 -0
  17. data/lib/ast_transform/abstract_processor.rb +39 -0
  18. data/lib/ast_transform/abstract_transformation.rb +18 -24
  19. data/lib/ast_transform/instruction_sequence/bootsnap_mixin.rb +4 -4
  20. data/lib/ast_transform/instruction_sequence/mixin.rb +6 -5
  21. data/lib/ast_transform/instruction_sequence/mixin_utils.rb +1 -1
  22. data/lib/ast_transform/instruction_sequence.rb +3 -2
  23. data/lib/ast_transform/kwargs_builder.rb +15 -14
  24. data/lib/ast_transform/layout.rb +64 -0
  25. data/lib/ast_transform/line_aligned_emitter.rb +224 -0
  26. data/lib/ast_transform/node.rb +48 -0
  27. data/lib/ast_transform/source_parser.rb +59 -0
  28. data/lib/ast_transform/statement_renderer.rb +76 -0
  29. data/lib/ast_transform/testing/assertions.rb +97 -0
  30. data/lib/ast_transform/thunk.rb +55 -0
  31. data/lib/ast_transform/thunk_lowering.rb +240 -0
  32. data/lib/ast_transform/transformation.rb +24 -22
  33. data/lib/ast_transform/transformation_helper.rb +115 -4
  34. data/lib/ast_transform/transformer.rb +23 -47
  35. data/lib/ast_transform/version.rb +3 -1
  36. data/lib/ast_transform.rb +12 -15
  37. metadata +22 -92
  38. data/lib/ast_transform/source_map.rb +0 -233
@@ -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
@@ -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,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
- require 'ast_transform'
3
- require 'ast_transform/abstract_transformation'
4
- require 'ast_transform/transformer'
5
- require 'unparser'
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
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.
8
14
  class Transformation < ASTTransform::AbstractTransformation
9
15
  TRANSFORM_AST = s(:send, nil, :transform!)
10
16
 
@@ -18,9 +24,9 @@ module ASTTransform
18
24
 
19
25
  count_before_reject = children.size
20
26
 
21
- children.reject!.with_index { |child_node, index|
27
+ children.reject!.with_index do |child_node, index|
22
28
  transform_node?(child_node) && transformable_node?(next_child(node, index))
23
- }
29
+ end
24
30
 
25
31
  processed = process_all(children)
26
32
 
@@ -68,19 +74,14 @@ module ASTTransform
68
74
 
69
75
  def extract_transformation(node)
70
76
  return unless node.is_a?(Parser::AST::Node)
71
- return unless node.children.count >= 2
72
-
73
- if node.children[1] == :new
74
- require_transformation(node)
75
- code = Unparser.unparse(node)
77
+ return if node.children.count < 2
76
78
 
77
- TOPLEVEL_BINDING.eval(code)
78
- else
79
- require_transformation(node)
80
- code = "#{Unparser.unparse(node)}.new"
79
+ require_transformation(node)
80
+ code = Unparser.unparse(node)
81
+ # A bare constant reference is instantiated; an explicit .new is kept as written.
82
+ code = "#{code}.new" unless node.children[1] == :new
81
83
 
82
- TOPLEVEL_BINDING.eval(code)
83
- end
84
+ TOPLEVEL_BINDING.eval(code)
84
85
  end
85
86
 
86
87
  def require_transformation(node)
@@ -100,11 +101,12 @@ module ASTTransform
100
101
  acronyms = ASTTransform.acronyms
101
102
  acronym_regex = acronyms.empty? ? /(?=a)b/ : /#{acronyms.join("|")}/
102
103
  return const_name unless /[A-Z-]|::/.match?(const_name)
103
- word = const_name.to_s.gsub("::".freeze, "/".freeze)
104
- word.gsub!(/(?:(?<=([A-Za-z\d]))|\b)(#{acronym_regex})(?=\b|[^a-z])/) { "#{$1 && '_'.freeze }#{$2.downcase}" }
105
- word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2'.freeze)
106
- word.gsub!(/([a-z\d])([A-Z])/, '\1_\2'.freeze)
107
- word.tr!("-".freeze, "_".freeze)
104
+
105
+ word = const_name.to_s.gsub("::", "/")
106
+ word.gsub!(/(?:(?<=([A-Za-z\d]))|\b)(#{acronym_regex})(?=\b|[^a-z])/) { "#{::Regexp.last_match(1) && "_"}#{::Regexp.last_match(2).downcase}" }
107
+ word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
108
+ word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
109
+ word.tr!("-", "_")
108
110
  word.downcase!
109
111
  word
110
112
  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
- def self.included(base)
7
- base.extend(Methods)
8
- base.include(Methods)
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
- Parser::AST::Node.new(type, children, properties)
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,18 @@
1
1
  # frozen_string_literal: true
2
- require 'prism'
3
- require 'prism/translation/parser'
4
- require 'unparser'
5
- require 'ast_transform/kwargs_builder'
6
- require 'ast_transform/source_map'
2
+
3
+ require 'ast_transform/line_aligned_emitter'
4
+ require 'ast_transform/source_parser'
7
5
 
8
6
  module ASTTransform
9
7
  class Transformer
10
8
  # Constructs a new Transformer instance.
11
9
  #
12
10
  # @param transformations [Array<ASTTransform::AbstractTransformation>] The transformations to be run.
13
- def initialize(*transformations)
11
+ # @param emitter [ASTTransform::LineAlignedEmitter] The emitter rendering transformed ASTs back to source.
12
+ def initialize(*transformations, emitter: LineAlignedEmitter.new)
14
13
  @transformations = transformations
14
+ @emitter = emitter
15
+ @source_parser = SourceParser.new
15
16
  end
16
17
 
17
18
  # Builds the AST for the given +source+.
@@ -20,9 +21,8 @@ module ASTTransform
20
21
  # @param file_path [String] The file_path. This is important for source mapping in backtraces.
21
22
  #
22
23
  # @return [Parser::AST::Node] The AST.
23
- def build_ast(source, file_path: 'tmp')
24
- buffer = create_buffer(source, file_path)
25
- parser.parse(buffer)
24
+ def build_ast(source, file_path: "tmp")
25
+ @source_parser.parse(source, file_path: file_path)
26
26
  end
27
27
 
28
28
  # Builds the AST for the given +file_path+.
@@ -31,24 +31,24 @@ module ASTTransform
31
31
  #
32
32
  # @return [Parser::AST::Node] The AST.
33
33
  def build_ast_from_file(file_path)
34
- source = File.read(file_path)
35
- build_ast(source, file_path: file_path)
34
+ @source_parser.parse_file(file_path)
36
35
  end
37
36
 
38
37
  # Transforms the given +source+.
39
38
  #
40
39
  # @param source [String] The input source code to be transformed.
41
40
  #
42
- # @return [String] The transformed code.
41
+ # @return [String] The transformed code, line-aligned (see #transform_file_source).
43
42
  def transform(source)
44
43
  ast = build_ast(source)
45
44
  transformed_ast = transform_ast(ast)
46
- Unparser.unparse(transformed_ast)
45
+ @emitter.emit(transformed_ast, 'tmp')
47
46
  end
48
47
 
49
48
  # Transforms the give +file_path+.
50
49
  #
51
- # @param file_path [String] The input file to be transformed. This is required for source mapping in backtraces.
50
+ # @param file_path [String] The input file to be transformed. Statement placement (and therefore
51
+ # backtrace and breakpoint line numbers) is derived from this file's source locations.
52
52
  # @param transformed_file_path [String] The file path to the transformed file.
53
53
  #
54
54
  # @return [String] The transformed code.
@@ -60,21 +60,19 @@ module ASTTransform
60
60
  # Transforms the given +source+ in +file_path+.
61
61
  #
62
62
  # @param source [String] The input source code to be transformed.
63
- # @param file_path [String] The file path for the input +source+. This is required for source mapping in backtraces.
64
- # @param transformed_file_path [String] The file path to the transformed filed. This is required to register the
65
- # SourceMap.
63
+ # @param file_path [String] The file path for the input +source+. Statement placement (and
64
+ # therefore backtrace and breakpoint line numbers) is derived from the source locations parsed
65
+ # under this path.
66
+ # @param transformed_file_path [String] The file path the transformed file will be written to.
66
67
  #
67
- # @return [String] The transformed code.
68
- def transform_file_source(source, file_path, transformed_file_path)
68
+ # @return [String] The transformed code, line-aligned: every statement carrying a source
69
+ # location is emitted at its original source line.
70
+ def transform_file_source(source, file_path, _transformed_file_path)
69
71
  source_ast = build_ast(source, file_path: file_path)
70
- # At this point, the transformed_ast contains line number mappings for the original +source+.
72
+ # At this point, the transformed_ast contains source locations for the original +source+.
71
73
  transformed_ast = transform_ast(source_ast)
72
74
 
73
- transformed_source = Unparser.unparse(transformed_ast)
74
-
75
- register_source_map(file_path, transformed_file_path, transformed_ast, transformed_source)
76
-
77
- transformed_source
75
+ @emitter.emit(transformed_ast, file_path)
78
76
  end
79
77
 
80
78
  # Transforms the given +ast+.
@@ -87,27 +85,5 @@ module ASTTransform
87
85
  transformation.run(ast)
88
86
  end
89
87
  end
90
-
91
- private
92
-
93
- def create_buffer(source, file_path)
94
- buffer = Parser::Source::Buffer.new(file_path)
95
- buffer.source = source.dup.force_encoding(parser.default_encoding)
96
-
97
- buffer
98
- end
99
-
100
- def parser
101
- @parser&.reset
102
- @parser ||= Prism::Translation::Parser.new(ASTTransform::KwargsBuilder.new)
103
- 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
88
  end
113
89
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ASTTransform
2
- VERSION = "2.1.4"
4
+ VERSION = "3.1.0"
3
5
  end