rspock 2.2.0 → 2.3.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,103 @@
1
+ # frozen_string_literal: true
2
+ require 'rspock/ast/node'
3
+
4
+ module RSpock
5
+ module AST
6
+ module Parser
7
+ # Parses a Ruby test method AST node into a self-contained RSpock AST.
8
+ #
9
+ # Input: s(:block, s(:send, nil, :test, ...), s(:args), s(:begin, ...))
10
+ # Output: s(:rspock_test,
11
+ # s(:rspock_def, method_call_node, args_node),
12
+ # s(:rspock_body, s(:rspock_given, ...), s(:rspock_when, ...), ...),
13
+ # s(:rspock_where, ...)) # optional
14
+ class TestMethodParser
15
+ include RSpock::AST::NodeBuilder
16
+
17
+ def initialize(block_registry, strict: true)
18
+ @block_registry = block_registry
19
+ @strict = strict
20
+ end
21
+
22
+ # Parses a Ruby test method AST into an RSpock AST (TestNode).
23
+ # Returns nil when non-strict and no RSpock blocks are found.
24
+ def parse(node)
25
+ blocks = parse_blocks(node)
26
+
27
+ if blocks.empty?
28
+ return nil unless @strict
29
+ raise BlockError, "Test method @ #{node.loc&.expression || '?'} must start with one of: Given, When, Expect"
30
+ end
31
+
32
+ validate_blocks(blocks, node)
33
+ build_rspock_ast(node, blocks)
34
+ end
35
+
36
+ private
37
+
38
+ def parse_blocks(node)
39
+ blocks = []
40
+ test_method_nodes(node).each do |n|
41
+ new_block = parse_block(n)
42
+ if new_block
43
+ validate_succession(blocks, new_block)
44
+ blocks << new_block
45
+ elsif blocks.empty?
46
+ raise BlockError, "Test method must start with one of: Given, When, Expect" if @strict
47
+ # non-strict: ignore pre-block statements in plain minitest tests
48
+ else
49
+ # regular statement — associate with the current block as a child
50
+ blocks.last << n
51
+ end
52
+ end
53
+ blocks
54
+ end
55
+
56
+ def parse_block(node)
57
+ return unless @block_registry.key?(node.children[1])
58
+
59
+ @block_registry[node.children[1]].new(node)
60
+ end
61
+
62
+ def validate_succession(blocks, new_block)
63
+ return if blocks.empty?
64
+
65
+ current = blocks.last
66
+ unless current.valid_successor?(new_block)
67
+ raise BlockError, current.succession_error_msg
68
+ end
69
+ end
70
+
71
+ def validate_blocks(blocks, node)
72
+ unless blocks.first.can_start?
73
+ raise BlockError, "Test method @ #{node.loc&.expression || '?'} must start with one of: Given, When, Expect"
74
+ end
75
+
76
+ unless blocks.last.can_end?
77
+ raise BlockError, "Block #{blocks.last.type} @ #{blocks.last.range} must be followed by one of these Blocks: #{blocks.last.successors}"
78
+ end
79
+ end
80
+
81
+ def test_method_nodes(node)
82
+ return [] if node.children[2].nil?
83
+
84
+ node.children[2]&.type == :begin ? node.children[2].children : [node.children[2]]
85
+ end
86
+
87
+ def build_rspock_ast(node, blocks)
88
+ def_node = s(:rspock_def, node.children[0], node.children[1])
89
+ where_block = blocks.find { |b| b.type == :Where }
90
+ body_blocks = blocks.reject { |b| b.type == :Where }
91
+
92
+ body_node = s(:rspock_body, *body_blocks.map(&:to_rspock_node))
93
+
94
+ if where_block
95
+ s(:rspock_test, def_node, body_node, where_block.to_rspock_node)
96
+ else
97
+ s(:rspock_test, def_node, body_node)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ require 'rspock/ast/parser/block'
3
+ require 'rspock/ast/parser/interaction_parser'
4
+
5
+ module RSpock
6
+ module AST
7
+ module Parser
8
+ class ThenBlock < Block
9
+ def initialize(node)
10
+ super(:Then, node)
11
+ end
12
+
13
+ def can_end?
14
+ true
15
+ end
16
+
17
+ def successors
18
+ @successors ||= [:Cleanup, :Where].freeze
19
+ end
20
+
21
+ def to_rspock_node
22
+ parser = InteractionParser.new
23
+ spock_children = @children.map { |child| parser.parse(child) }
24
+ s(:rspock_then, *spock_children)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ require 'rspock/ast/parser/block'
3
+
4
+ module RSpock
5
+ module AST
6
+ module Parser
7
+ class WhenBlock < Block
8
+ def initialize(node)
9
+ super(:When, node)
10
+ end
11
+
12
+ def can_start?
13
+ true
14
+ end
15
+
16
+ def successors
17
+ @successors ||= [:Then].freeze
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+ require 'rspock/ast/parser/block'
3
+
4
+ module RSpock
5
+ module AST
6
+ module Parser
7
+ class WhereBlock < Block
8
+ class MalformedError < StandardError; end
9
+
10
+ def initialize(node)
11
+ super(:Where, node)
12
+ end
13
+
14
+ def header
15
+ @header ||= parse_header
16
+ end
17
+
18
+ def data
19
+ @data ||= parse_data
20
+ end
21
+
22
+ def to_rspock_node
23
+ header_node = s(:rspock_where_header, *header.map { |col| s(:sym, col) })
24
+ data_nodes = data.map { |row| s(:array, *row) }
25
+ s(:rspock_where, header_node, *data_nodes)
26
+ end
27
+
28
+ def can_end?
29
+ true
30
+ end
31
+
32
+ private
33
+
34
+ def parse_header
35
+ header = []
36
+ header_pipe_node?(children.first, header) || terminal_header_node?(children.first, header)
37
+ header
38
+ end
39
+
40
+ def header_pipe_node?(node, header)
41
+ return false if node.nil?
42
+
43
+ node.type == :send &&
44
+ node.children.count == 3 &&
45
+ (header_pipe_node?(node.children[0], header) || terminal_header_node?(node.children[0], header)) &&
46
+ node.children[1] == :| &&
47
+ terminal_header_node?(node.children[2], header)
48
+ end
49
+
50
+ def terminal_header_node?(node, header)
51
+ return false if node.nil?
52
+
53
+ result = node.type == :send &&
54
+ node.children.count == 2 &&
55
+ node.children.first.nil? &&
56
+ node.children.last.is_a?(Symbol)
57
+
58
+ raise MalformedError, "Where Block is malformed at location: #{node.loc&.expression || "?"}" unless result
59
+
60
+ header << node.children.last if result
61
+
62
+ result
63
+ end
64
+
65
+ def parse_data
66
+ _header_node, *row_nodes = children
67
+ row_nodes.map do |node|
68
+ data = []
69
+ data_pipe_node?(node, data) || terminal_data_node?(node, data)
70
+ data
71
+ end
72
+ end
73
+
74
+ def data_pipe_node?(node, data)
75
+ return false if node.nil?
76
+ return false unless node.type == :send && node.children.count == 3 && node.children[1] == :|
77
+
78
+ unless data_pipe_node?(node.children[0], data)
79
+ terminal_data_node?(node.children[0], data)
80
+ end
81
+
82
+ unless data_pipe_node?(node.children[2], data)
83
+ terminal_data_node?(node.children[2], data)
84
+ end
85
+ end
86
+
87
+ def terminal_data_node?(node, data)
88
+ data << node
89
+ true
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -1,165 +1,164 @@
1
1
  # frozen_string_literal: true
2
2
  require 'ast_transform/abstract_transformation'
3
+ require 'rspock/ast/node'
4
+ require 'rspock/ast/comparison_to_assertion_transformation'
3
5
  require 'rspock/ast/header_nodes_transformation'
6
+ require 'rspock/ast/interaction_to_mocha_mock_transformation'
7
+ require 'rspock/ast/interaction_to_block_identity_assertion_transformation'
4
8
  require 'rspock/ast/method_call_to_lvar_transformation'
5
9
  require 'rspock/ast/test_method_def_transformation'
10
+ require 'rspock/ast/parser/test_method_parser'
6
11
 
7
12
  module RSpock
8
13
  module AST
9
14
  class TestMethodTransformation < ASTTransform::AbstractTransformation
10
- def initialize(source_map, start_block_class, end_block_class, strict: true)
11
- @source_map = source_map
12
- @start_block_class = start_block_class
13
- @end_block_class = end_block_class
14
- @strict = strict
15
- @blocks = []
15
+ def initialize(block_registry, strict: true)
16
+ @parser = Parser::TestMethodParser.new(block_registry, strict: strict)
17
+ @comparison_transformation = ComparisonToAssertionTransformation.new(:_test_index_, :_line_number_)
16
18
  end
17
19
 
18
20
  def run(node)
19
- parse(node)
20
- build_ast(node)
21
+ rspock_ast = @parser.parse(node)
22
+ return node if rspock_ast.nil?
23
+ transform(rspock_ast)
21
24
  end
22
25
 
23
26
  private
24
27
 
25
- def parse(node)
26
- start_block = @start_block_class.new(node)
27
- start_block.node_container = !@strict
28
+ def transform(rspock_ast)
29
+ hoisted_setups = []
30
+
31
+ method_call = rspock_ast.def_node.method_call
32
+ method_args = rspock_ast.def_node.args
33
+ where = rspock_ast.where_node
34
+
35
+ transformed_blocks = rspock_ast.body_node.children.map do |block_node|
36
+ case block_node.type
37
+ when :rspock_then
38
+ transform_then_block(block_node, hoisted_setups)
39
+ when :rspock_expect
40
+ transform_expect_block(block_node)
41
+ else
42
+ block_node
43
+ end
44
+ end
28
45
 
29
- add_block(start_block)
30
- test_method_nodes(node).each { |n| parse_node(n) }
31
- add_block(@end_block_class.new)
32
- nil
46
+ transformed_body = rspock_ast.body_node.updated(nil, transformed_blocks)
47
+ build_ruby_ast(method_call, method_args, transformed_body, where, hoisted_setups)
33
48
  end
34
49
 
35
- def build_ast(node)
36
- if where_block
37
- s(:block,
38
- build_where_block_iterator(where_block.data),
39
- build_where_block_args(where_block.header),
40
- build_test_method_def(node)
41
- )
42
- else
43
- build_test_method_def(node)
50
+ def transform_then_block(then_node, hoisted_setups)
51
+ interaction_setups = []
52
+ then_children = []
53
+
54
+ then_node.children.each_with_index do |child, idx|
55
+ if child.type == :rspock_interaction
56
+ setup = InteractionToMochaMockTransformation.new(idx).run(child)
57
+ assertion = InteractionToBlockIdentityAssertionTransformation.new(idx).run(child)
58
+
59
+ interaction_setups << setup
60
+ then_children << assertion unless assertion.equal?(child)
61
+ else
62
+ then_children << @comparison_transformation.run(child)
63
+ end
44
64
  end
45
- end
46
65
 
47
- def build_where_block_iterator(rows)
48
- s(:send,
49
- s(:send,
50
- s(:array, *build_where_block_data_rows(rows)),
51
- :each,
52
- ),
53
- :with_index
54
- )
55
- end
66
+ unless interaction_setups.empty?
67
+ interaction_setups.each do |node|
68
+ if node.type == :begin
69
+ hoisted_setups.concat(node.children)
70
+ else
71
+ hoisted_setups << node
72
+ end
73
+ end
74
+ end
56
75
 
57
- def build_where_block_data_rows(rows)
58
- rows.map(&method(:build_where_block_data_row))
76
+ then_node.updated(nil, then_children)
59
77
  end
60
78
 
61
- def build_where_block_data_row(row)
62
- children = row.dup
63
- children << s(:int, row.first&.loc&.expression&.line)
64
-
65
- s(:array, *children)
79
+ def transform_expect_block(expect_node)
80
+ new_children = expect_node.children.map { |child| @comparison_transformation.run(child) }
81
+ expect_node.updated(nil, new_children)
66
82
  end
67
83
 
68
- def build_where_block_args(header)
69
- injected_args = header.map { |column| s(:arg, column) }
70
- injected_args << s(:arg, :_line_number_)
84
+ # --- Build final Ruby AST ---
71
85
 
72
- s(:args,
73
- s(:mlhs, *injected_args),
74
- s(:arg, :_test_index_),
75
- )
76
- end
86
+ def build_ruby_ast(method_call, method_args, body_node, where, hoisted_setups)
87
+ if where
88
+ test_def = s(:block,
89
+ TestMethodDefTransformation.new.run(method_call),
90
+ method_args,
91
+ build_test_body(body_node, hoisted_setups)
92
+ )
93
+ test_def = HeaderNodesTransformation.new(where.header).run(test_def)
77
94
 
78
- def build_test_method_def(node)
79
- if where_block
80
- ast = s(:block,
81
- TestMethodDefTransformation.new.run(node.children[0]),
82
- node.children[1],
83
- build_test_body
95
+ s(:block,
96
+ build_where_iterator(where.data_rows),
97
+ build_where_args(where.header),
98
+ test_def
84
99
  )
85
- HeaderNodesTransformation.new(where_block.header).run(ast)
86
100
  else
87
101
  s(:block,
88
- node.children[0],
89
- node.children[1],
90
- build_test_body
102
+ method_call,
103
+ method_args,
104
+ build_test_body(body_node, hoisted_setups)
91
105
  )
92
106
  end
93
107
  end
94
108
 
95
- def first_scope
96
- @blocks.first
97
- end
98
-
99
- def current_scope
100
- @blocks.last
101
- end
102
-
103
- def add_block(block)
104
- if current_scope && !current_scope.valid_successor?(block)
105
- raise RSpock::AST::BlockError, current_scope.succession_error_msg
109
+ def build_test_body(body_node, hoisted_setups)
110
+ body_children = []
111
+
112
+ body_node.children.each do |block_node|
113
+ case block_node.type
114
+ when :rspock_given
115
+ body_children.concat(block_node.children)
116
+ when :rspock_when
117
+ body_children.concat(hoisted_setups)
118
+ body_children.concat(block_node.children)
119
+ when :rspock_then, :rspock_expect
120
+ body_children.concat(block_node.children)
121
+ when :rspock_cleanup
122
+ # handled below as ensure
123
+ end
106
124
  end
107
125
 
108
- @blocks << block
109
- end
126
+ ast = s(:begin, *body_children)
110
127
 
111
- def test_method_nodes(node)
112
- return [] if node.children[2].nil?
113
-
114
- node.children[2]&.type == :begin ? node.children[2].children : [node.children[2]]
115
- end
116
-
117
- def parse_node(node)
118
- if @source_map.key?(node.children[1])
119
- add_block(build_block(node))
120
- else
121
- current_scope << node
128
+ cleanup = body_node.children.find { |n| n.type == :rspock_cleanup }
129
+ if cleanup && !cleanup.children.empty?
130
+ ensure_node = s(:begin, *cleanup.children)
131
+ ast = s(:kwbegin, s(:ensure, ast, ensure_node))
122
132
  end
123
- end
124
-
125
- def build_block(node)
126
- @source_map[node.children[1]].new(node)
127
- end
128
133
 
129
- def when_block
130
- @when_block ||= @blocks.detect { |block| block.type == :When }
134
+ MethodCallToLVarTransformation.new(:_test_index_, :_line_number_).run(ast)
131
135
  end
132
136
 
133
- def then_block
134
- @then_block ||= @blocks.detect { |block| block.type == :Then }
135
- end
137
+ # --- Where block helpers ---
136
138
 
137
- def where_block
138
- @where_block ||= @blocks.detect { |block| block.type == :Where }
139
+ def build_where_iterator(data_rows)
140
+ s(:send,
141
+ s(:send,
142
+ s(:array, *data_rows.map { |row| build_where_data_row(row) }),
143
+ :each,
144
+ ),
145
+ :with_index
146
+ )
139
147
  end
140
148
 
141
- def cleanup_block
142
- @cleanup_block ||= @blocks.detect { |block| block.type == :Cleanup }
149
+ def build_where_data_row(row)
150
+ children = row.dup
151
+ children << s(:int, row.first&.loc&.expression&.line)
152
+ s(:array, *children)
143
153
  end
144
154
 
145
- def build_test_body
146
- then_block&.interactions&.reverse&.each { |interaction| when_block.unshift(interaction) }
147
-
148
- test_body_children = @blocks.select { |block| [:Start, :Given, :When, :Then, :Expect].include?(block.type) }
149
- .map { |block| block.children }
150
- .flatten
151
-
152
- ast = s(:begin, *test_body_children)
153
-
154
- if cleanup_block && !cleanup_block.children.empty?
155
- ensure_node = s(:begin, *cleanup_block.children)
156
-
157
- ast = s(:kwbegin,
158
- s(:ensure, ast, ensure_node)
159
- )
160
- end
161
-
162
- MethodCallToLVarTransformation.new(:_test_index_, :_line_number_).run(ast)
155
+ def build_where_args(header)
156
+ injected_args = header.map { |column| s(:arg, column) }
157
+ injected_args << s(:arg, :_line_number_)
158
+ s(:args,
159
+ s(:mlhs, *injected_args),
160
+ s(:arg, :_test_index_),
161
+ )
163
162
  end
164
163
  end
165
164
  end
@@ -1,37 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
  require 'ast_transform/abstract_transformation'
3
- require 'rspock/ast/start_block'
4
- require 'rspock/ast/given_block'
5
- require 'rspock/ast/when_block'
6
- require 'rspock/ast/then_block'
7
- require 'rspock/ast/expect_block'
8
- require 'rspock/ast/cleanup_block'
9
- require 'rspock/ast/where_block'
10
- require 'rspock/ast/end_block'
3
+ require 'rspock/ast/parser/given_block'
4
+ require 'rspock/ast/parser/when_block'
5
+ require 'rspock/ast/parser/then_block'
6
+ require 'rspock/ast/parser/expect_block'
7
+ require 'rspock/ast/parser/cleanup_block'
8
+ require 'rspock/ast/parser/where_block'
11
9
  require 'rspock/ast/test_method_transformation'
12
10
 
13
11
  module RSpock
14
12
  module AST
15
13
  class Transformation < ASTTransform::AbstractTransformation
16
- DefaultSourceMap = {
17
- Given: RSpock::AST::GivenBlock,
18
- When: RSpock::AST::WhenBlock,
19
- Then: RSpock::AST::ThenBlock,
20
- Expect: RSpock::AST::ExpectBlock,
21
- Cleanup: RSpock::AST::CleanupBlock,
22
- Where: RSpock::AST::WhereBlock,
14
+ DEFAULT_BLOCK_REGISTRY = {
15
+ Given: Parser::GivenBlock,
16
+ When: Parser::WhenBlock,
17
+ Then: Parser::ThenBlock,
18
+ Expect: Parser::ExpectBlock,
19
+ Cleanup: Parser::CleanupBlock,
20
+ Where: Parser::WhereBlock,
23
21
  }.freeze
24
22
 
25
23
  def initialize(
26
- start_block_class: StartBlock,
27
- end_block_class: EndBlock,
28
- source_map: DefaultSourceMap,
24
+ block_registry: DEFAULT_BLOCK_REGISTRY,
29
25
  strict: true
30
26
  )
31
27
  super()
32
- @start_block_class = start_block_class
33
- @source_map = source_map
34
- @end_block_class = end_block_class
28
+ @block_registry = block_registry
35
29
  @strict = strict
36
30
  end
37
31
 
@@ -93,9 +87,7 @@ module RSpock
93
87
  end
94
88
 
95
89
  TestMethodTransformation.new(
96
- @source_map,
97
- @start_block_class,
98
- @end_block_class,
90
+ @block_registry,
99
91
  strict: @strict
100
92
  ).run(node)
101
93
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpock
4
+ module Helpers
5
+ module BlockCapture
6
+ # Installs a block-capture wrapper on +obj+ for +method_name+.
7
+ # Must be called AFTER Mocha's expects/stubs so the wrapper sits
8
+ # in front of whatever Mocha installed.
9
+ #
10
+ # Returns a lambda that, when called, returns the captured block
11
+ # (or nil if no block was passed).
12
+ def self.capture(obj, method_name)
13
+ state = { captured: nil }
14
+
15
+ if obj.respond_to?(method_name, true)
16
+ # Real objects or objects where Mocha defined the method on
17
+ # the singleton class. Prepend a module so we intercept the
18
+ # call before Mocha's stub (prepend wins over define_singleton_method).
19
+ s = state
20
+ capture_mod = Module.new do
21
+ define_method(method_name) do |*args, **kwargs, &blk|
22
+ s[:captured] = blk
23
+ super(*args, **kwargs, &blk)
24
+ end
25
+ end
26
+ obj.singleton_class.prepend(capture_mod)
27
+ else
28
+ # Mock objects where the method goes through method_missing.
29
+ original_mm = obj.method(:method_missing)
30
+ s = state
31
+ obj.define_singleton_method(:method_missing) do |name, *args, **kwargs, &blk|
32
+ s[:captured] = blk if name == method_name
33
+ original_mm.call(name, *args, **kwargs, &blk)
34
+ end
35
+ end
36
+
37
+ -> { state[:captured] }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -1,3 +1,3 @@
1
1
  module RSpock
2
- VERSION = "2.2.0"
2
+ VERSION = "2.3.0"
3
3
  end