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
@@ -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, 'tmp', 'ast_transform', relative_source_file_pathname)
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, 'w') do |file|
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 'prism/translation/parser'
3
+ require "prism/translation/parser"
4
4
 
5
5
  module ASTTransform
6
- # Extends the default Prism parser builder to distinguish keyword arguments
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
- # The upstream builder always emits :hash nodes for both `foo(bar: 1)` and
10
- # `foo({ bar: 1 })`. Unparser uses the node type to decide whether to emit
11
- # braces: :hash gets `{}`, :kwargs does not. Since Ruby 3.0+ treats these as
12
- # semantically different (strict keyword/positional separation), we need the
13
- # AST to preserve the distinction.
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
- def associate(begin_t, pairs, end_t)
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