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.
@@ -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