scully 0.0.1 → 0.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.
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Scully
6
+ module Frameworks
7
+ # Decides whether a file holds RSpec or Minitest tests: file content first, then its path.
8
+ module Detector
9
+ SKIP_REASON = 'no RSpec or Minitest test structure found'
10
+
11
+ RSPEC_HELPERS = %w[spec_helper rails_helper].freeze
12
+ MINITEST_HELPERS = %w[test_helper].freeze
13
+ # Every example group name the RSpec normalizer reads, so a file it can analyze is not skipped.
14
+ RSPEC_GROUPS = %i[describe context feature example_group shared_examples shared_examples_for shared_context
15
+ fdescribe fcontext ffeature xdescribe xcontext xfeature].freeze
16
+ MINITEST_BASE = /(Test|TestCase)\z/
17
+
18
+ module_function
19
+
20
+ # Returns :rspec, :minitest, or nil when the file shows no test structure.
21
+ # +program+ is the parsed ProgramNode, or nil when the file could not be parsed.
22
+ def detect(path, program)
23
+ from_content(program) || from_path(path)
24
+ end
25
+
26
+ def from_content(program)
27
+ return unless program
28
+
29
+ statements = program.statements.body
30
+ requires = statements.filter_map { required_feature(it) }
31
+ return :rspec if rspec_describe?(statements) || requires.any? { RSPEC_HELPERS.include?(File.basename(it)) }
32
+ return :minitest if minitest_require?(requires) || minitest_class?(program)
33
+
34
+ :rspec if statements.any? { group_call?(it) }
35
+ end
36
+
37
+ def from_path(path)
38
+ segments = File.dirname(path).split('/')
39
+ return :rspec if segments.include?('spec')
40
+
41
+ :minitest if segments.include?('test')
42
+ end
43
+
44
+ def required_feature(node)
45
+ return unless node.is_a?(Prism::CallNode) && node.receiver.nil?
46
+ return unless %i[require require_relative].include?(node.name)
47
+
48
+ argument = node.arguments&.arguments&.first
49
+ argument.unescaped if argument.is_a?(Prism::StringNode)
50
+ end
51
+
52
+ # RSpec.describe and friends name RSpec explicitly; a bare describe may be minitest/spec.
53
+ def rspec_describe?(statements)
54
+ statements.any? { group_call?(it) && rspec_receiver?(it.receiver) }
55
+ end
56
+
57
+ def rspec_receiver?(receiver)
58
+ receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :RSpec
59
+ end
60
+
61
+ def minitest_require?(requires)
62
+ requires.any? do
63
+ it == 'minitest' || it.start_with?('minitest/') || MINITEST_HELPERS.include?(File.basename(it))
64
+ end
65
+ end
66
+
67
+ def group_call?(node)
68
+ node.is_a?(Prism::CallNode) && RSPEC_GROUPS.include?(node.name) &&
69
+ (node.receiver.nil? || rspec_receiver?(node.receiver))
70
+ end
71
+
72
+ def minitest_class?(node)
73
+ return true if node.is_a?(Prism::ClassNode) && minitest_base?(node.superclass)
74
+
75
+ node.compact_child_nodes.any? { minitest_class?(it) }
76
+ end
77
+
78
+ def minitest_base?(superclass)
79
+ case superclass
80
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then MINITEST_BASE.match?(superclass.name.to_s)
81
+ else false
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Scully
6
+ module Frameworks
7
+ # Resolves calls to helper methods against the methods defined in the same file.
8
+ #
9
+ # A helper counts as an assertion when its name starts with `assert_` or `expect_`, or when
10
+ # it is defined in the file and its body makes an assertion, directly or through other
11
+ # helpers defined in the file.
12
+ class Helpers
13
+ CONVENTION = /\A(assert|expect)_/
14
+
15
+ # +program+ is the file's ProgramNode; +assertion+ is called with each CallNode and says
16
+ # whether it is an assertion in the file's framework.
17
+ def initialize(program, assertion:)
18
+ @assertion = assertion
19
+ @methods = {}
20
+ collect_methods(program)
21
+ @asserting = asserting_methods
22
+ end
23
+
24
+ # :convention, :same_file_with_assertion, :same_file_without_assertion or :unresolved.
25
+ def resolve(name)
26
+ return :convention if CONVENTION.match?(name)
27
+ return :unresolved unless @methods.key?(name)
28
+
29
+ @asserting.include?(name) ? :same_file_with_assertion : :same_file_without_assertion
30
+ end
31
+
32
+ def assertion?(name) = %i[convention same_file_with_assertion].include?(resolve(name))
33
+
34
+ def defined?(name) = @methods.key?(name)
35
+
36
+ private
37
+
38
+ # Later definitions replace earlier ones, as they do when Ruby loads the file.
39
+ def collect_methods(node)
40
+ @methods[node.name] = node if node.is_a?(Prism::DefNode)
41
+ node.compact_child_nodes.each { collect_methods(it) }
42
+ end
43
+
44
+ # Fixed point: start from methods that assert directly, then add methods that call one of
45
+ # them until nothing changes. Terminates on recursive helpers.
46
+ def asserting_methods
47
+ calls = @methods.transform_values { helper_calls_in(it) }
48
+ asserting = @methods.select { |_, node| asserts_directly?(node) }.keys.to_set
49
+ loop do
50
+ added = calls.select { |name, called| !asserting.include?(name) && called.intersect?(asserting) }.keys
51
+ return asserting if added.empty?
52
+
53
+ asserting.merge(added)
54
+ end
55
+ end
56
+
57
+ def asserts_directly?(node) = calls_in(node).any? { @assertion.call(it) || convention_call?(it) }
58
+
59
+ def helper_calls_in(node) = calls_in(node).select { it.receiver.nil? }.to_set(&:name)
60
+
61
+ def convention_call?(call) = call.receiver.nil? && CONVENTION.match?(call.name)
62
+
63
+ def calls_in(node, found = [])
64
+ node.compact_child_nodes.each do |child|
65
+ found << child if child.is_a?(Prism::CallNode)
66
+ calls_in(child, found) unless child.is_a?(Prism::DefNode)
67
+ end
68
+ found
69
+ end
70
+ end
71
+
72
+ # Reading of DSL calls shared by both normalizers.
73
+ module Syntax
74
+ module_function
75
+
76
+ def statements(node) = node.is_a?(Prism::StatementsNode) ? node.body : [node].compact
77
+
78
+ # `it "..."` up to its block: the call name and its arguments, with parentheses if any.
79
+ def definition(call)
80
+ call.message_loc.join(call.closing_loc || call.arguments&.location || call.message_loc)
81
+ end
82
+
83
+ # The description a group or test is written with; symbols and hashes are metadata.
84
+ def description(call)
85
+ case (argument = call.arguments&.arguments&.first)
86
+ when nil, Prism::KeywordHashNode, Prism::SymbolNode then nil
87
+ when Prism::StringNode then argument.unescaped
88
+ when Prism::InterpolatedStringNode then argument.parts.map(&:slice).join
89
+ else argument.slice
90
+ end
91
+ end
92
+
93
+ # The name a `let(:name)` or `subject(:name)` binds; a bare `subject` binds `subject`.
94
+ def binding_name(call)
95
+ argument = call.arguments&.arguments&.first
96
+ argument.is_a?(Prism::SymbolNode) ? argument.unescaped.to_sym : call.name
97
+ end
98
+
99
+ # The value a `let` or `subject` block returns: its last expression.
100
+ def block_value(call) = call.block.is_a?(Prism::BlockNode) ? statements(call.block.body).last : nil
101
+
102
+ # An expression that reads +name+, standing for the object a let or variable is bound to.
103
+ def identifier(name) = Prism.parse(name.to_s).value.statements.body.first
104
+
105
+ def arguments(call) = call.arguments&.arguments || []
106
+
107
+ # [method name, value] pairs from `name: value` arguments with symbol keys.
108
+ def keyword_pairs(call)
109
+ arguments(call).grep(Prism::KeywordHashNode).flat_map(&:elements).filter_map do |element|
110
+ next unless element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode)
111
+
112
+ [element.key.unescaped.to_sym, element.value]
113
+ end
114
+ end
115
+
116
+ # [call, its receiver, ...] while the receivers are calls: `a.b(1).c` gives [c, b, a].
117
+ def chain(node)
118
+ calls = []
119
+ while node.is_a?(Prism::CallNode)
120
+ calls << node
121
+ node = node.receiver
122
+ end
123
+ calls
124
+ end
125
+
126
+ # The method name a stub names with its first argument (`receive(:m)`, `stubs(:m)`), or nil.
127
+ def stubbed_name(call)
128
+ argument = arguments(call).first
129
+ argument.is_a?(Prism::SymbolNode) ? argument.unescaped.to_sym : nil
130
+ end
131
+
132
+ # Local and instance variables assigned in +node+, in source order: [[name, value], ...].
133
+ def assignments(node, found = [])
134
+ node&.compact_child_nodes&.each do |child|
135
+ next if child.is_a?(Prism::DefNode)
136
+
137
+ found << [child.name, child.value] if assignment?(child)
138
+ assignments(child, found)
139
+ end
140
+ found
141
+ end
142
+
143
+ def assignment?(node) = node.is_a?(Prism::LocalVariableWriteNode) || node.is_a?(Prism::InstanceVariableWriteNode)
144
+ end
145
+
146
+ # Builds TestDoubles for one file and one origin (:setup or :body).
147
+ class Doubles
148
+ def initialize(source, origin)
149
+ @source = source
150
+ @origin = origin
151
+ end
152
+
153
+ # +location+ is the stub's Prism location; +scope+ is the location of the block a stub is
154
+ # limited to, if any.
155
+ # +fields+ are the TestDouble's receiver, method_name and returns.
156
+ def build(location, stub_source: true, scope: nil, **fields)
157
+ TestDouble.new(span: @source.span(location), origin: @origin, scope: scope && @source.span(scope),
158
+ stub_source:, **fields)
159
+ end
160
+ end
161
+
162
+ # Collects the assertions and candidate helper calls in a test body, in source order.
163
+ #
164
+ # +assertion+ is called with each CallNode and returns the Assertion fields other than the
165
+ # span (kind, comparison, subject, expected) when the call is an assertion, or nil. The
166
+ # assertion's subtree is not searched further. +ignored+ says whether a receiverless call
167
+ # name is framework vocabulary or a binding rather than a possible helper.
168
+ class Inventory
169
+ def initialize(source:, helpers:, assertion:, ignored:)
170
+ @source = source
171
+ @helpers = helpers
172
+ @assertion = assertion
173
+ @ignored = ignored
174
+ end
175
+
176
+ # Returns [assertions, helper_calls] for +body+ (a node or nil).
177
+ def scan(body)
178
+ @assertions = []
179
+ @helper_calls = []
180
+ visit(body) if body
181
+ [@assertions, @helper_calls]
182
+ end
183
+
184
+ private
185
+
186
+ def visit(node)
187
+ node.compact_child_nodes.each do |child|
188
+ next if child.is_a?(Prism::DefNode)
189
+ next if child.is_a?(Prism::CallNode) && assertion(child)
190
+
191
+ helper(child) if child.is_a?(Prism::CallNode)
192
+ visit(child)
193
+ end
194
+ end
195
+
196
+ def assertion(call)
197
+ fields = @assertion.call(call) or return false
198
+ @assertions << Assertion.new(span: @source.span(call.location), **fields)
199
+ end
200
+
201
+ def helper(call)
202
+ return unless call.receiver.nil? && !@ignored.call(call.name)
203
+
204
+ @helper_calls << HelperCall.new(name: call.name, span: @source.span(call.message_loc),
205
+ resolution: @helpers.resolve(call.name))
206
+ helper_assertion(call) if @helpers.assertion?(call.name)
207
+ end
208
+
209
+ def helper_assertion(call)
210
+ @assertions << Assertion.new(span: @source.span(call.location), kind: :helper, comparison: nil,
211
+ subject: nil, expected: nil)
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Scully
6
+ module Frameworks
7
+ # Turns a Minitest file into TestCases: `def test_*` methods and Rails-style `test "..." do`
8
+ # blocks in test classes, and minitest/spec `it` blocks inside `describe`.
9
+ class Minitest
10
+ TEST_METHOD = /\Atest_/
11
+ SETUP_METHODS = { before_setup: :setup, setup: :setup, after_setup: :setup,
12
+ before_teardown: :teardown, teardown: :teardown, after_teardown: :teardown }.freeze
13
+ SETUP_CALLS = { setup: :setup, teardown: :teardown, before: :before, after: :teardown, let: :let,
14
+ subject: :subject }.freeze
15
+ GROUPS = %i[describe].freeze
16
+ EXAMPLES = %i[it specify].freeze
17
+
18
+ # The class, group names, setup, bindings and setup doubles visible at a point in the file.
19
+ Context = Data.define(:class_name, :names, :setup, :bindings, :doubles)
20
+ # Setup kinds whose code runs before the test body.
21
+ BEFORE = %i[setup before].freeze
22
+
23
+ include Syntax
24
+
25
+ def self.call(source) = new(source).test_cases
26
+
27
+ def initialize(source)
28
+ @source = source
29
+ @helpers = Helpers.new(source.result.value, assertion: ->(call) { !Assertions.fields(call).nil? })
30
+ end
31
+
32
+ def test_cases
33
+ @tests = []
34
+ walk(@source.result.value.statements,
35
+ Context.new(class_name: nil, names: [], setup: [], bindings: {}, doubles: []))
36
+ @tests
37
+ end
38
+
39
+ private
40
+
41
+ def walk(node, context)
42
+ statements(node).each { statement(it, context) }
43
+ end
44
+
45
+ def statement(node, context)
46
+ case node
47
+ when Prism::ClassNode then class_statement(node, context)
48
+ when Prism::ModuleNode then walk(node.body, context)
49
+ when Prism::DefNode then test_method(node, context) if context.class_name && TEST_METHOD.match?(node.name)
50
+ when Prism::CallNode then call_statement(node, context)
51
+ end
52
+ end
53
+
54
+ def class_statement(node, context)
55
+ return walk(node.body, context) unless Detector.minitest_base?(node.superclass)
56
+
57
+ name = node.constant_path.slice
58
+ walk(node.body, scope(node.body, context.with(class_name: name, names: [name])))
59
+ end
60
+
61
+ def call_statement(call, context)
62
+ return unless call.receiver.nil?
63
+
64
+ if GROUPS.include?(call.name) && call.block then group(call, context)
65
+ elsif EXAMPLES.include?(call.name) then spec_example(call, context)
66
+ elsif call.name == :test && context.class_name then rails_test(call, context)
67
+ end
68
+ end
69
+
70
+ def group(call, context)
71
+ walk(call.block.body, scope(call.block.body, context.with(names: [*context.names, *description(call)])))
72
+ end
73
+
74
+ # Adds the setup, bindings and setup doubles declared directly in a class or group body.
75
+ def scope(body, context)
76
+ own = statements(body).filter_map { [it, Scope.kind(it)] if Scope.kind(it) }
77
+ Scope.apply(context, own, Doubles.new(@source, :setup))
78
+ .then { it.with(setup: it.setup + own.map { |node, kind| setup_step(node, kind) }) }
79
+ end
80
+
81
+ def setup_step(node, kind) = SetupStep.new(kind:, span: span(node.location))
82
+
83
+ def span(location) = @source.span(location)
84
+
85
+ def test_method(node, context)
86
+ add(context, node.body, name: "#{context.class_name}##{node.name}",
87
+ definition: node.def_keyword_loc.join(node.name_loc), extent: node.location,
88
+ status: skip_call?(node.body) ? :skipped : :active)
89
+ end
90
+
91
+ def rails_test(call, context)
92
+ add(context, call.block&.body, name: "#{context.class_name}#test #{call.arguments&.arguments&.first&.slice}",
93
+ definition: definition(call), extent: call.location,
94
+ status: block_status(call, :pending))
95
+ end
96
+
97
+ # A minitest/spec `it` without a block is skipped by Minitest.
98
+ def spec_example(call, context)
99
+ description = description(call) || "example at line #{call.location.start_line}"
100
+ add(context, call.block&.body, name: [*context.names, description].join(' '),
101
+ definition: definition(call), extent: call.location,
102
+ status: block_status(call, :skipped))
103
+ end
104
+
105
+ def block_status(call, without_block)
106
+ return without_block unless call.block
107
+
108
+ skip_call?(call.block.body) ? :skipped : :active
109
+ end
110
+
111
+ def add(context, body, definition:, extent:, **fields)
112
+ assertions, helper_calls = inventory(context).scan(body)
113
+ @tests << TestCase.new(span: span(definition), extent: span(extent), framework: :minitest, setup: context.setup,
114
+ assertions:, helper_calls:, bindings: context.bindings.merge(assignments(body).to_h),
115
+ doubles: context.doubles + Stubs.scan(body, Doubles.new(@source, :body)), **fields)
116
+ end
117
+
118
+ # A `skip` call anywhere in the body skips the test.
119
+ def skip_call?(node)
120
+ return false unless node
121
+
122
+ node.compact_child_nodes.any? do |child|
123
+ next false if child.is_a?(Prism::DefNode)
124
+
125
+ (child.is_a?(Prism::CallNode) && child.receiver.nil? && child.name == :skip) || skip_call?(child)
126
+ end
127
+ end
128
+
129
+ def inventory(context)
130
+ Inventory.new(source: @source, helpers: @helpers, assertion: Assertions.method(:fields),
131
+ ignored: ->(name) { Assertions::VOCABULARY.include?(name) || context.bindings.include?(name) })
132
+ end
133
+
134
+ # What a class or group's setup gives its tests: bindings and doubles.
135
+ module Scope
136
+ module_function
137
+
138
+ # [node, kind] pairs => +context+ with their bindings and doubles added.
139
+ def apply(context, own, doubles)
140
+ context.with(bindings: context.bindings.merge(*own.map { |node, kind| bindings(node, kind) }),
141
+ doubles: context.doubles + own.flat_map { |node, kind| doubles(node, kind, doubles) })
142
+ end
143
+
144
+ # The setup kind a `def setup` or `setup do` declares, or nil.
145
+ def kind(node)
146
+ case node
147
+ when Prism::DefNode then SETUP_METHODS[node.name]
148
+ when Prism::CallNode then node.receiver.nil? ? SETUP_CALLS[node.name] : nil
149
+ end
150
+ end
151
+
152
+ # `let(:order) { ... }` binds `order`; setup binds the variables it assigns, such as `@order`.
153
+ def bindings(node, kind)
154
+ return { Syntax.binding_name(node) => Syntax.block_value(node) } if %i[let subject].include?(kind)
155
+
156
+ BEFORE.include?(kind) ? Syntax.assignments(body(node)).to_h : {}
157
+ end
158
+
159
+ def doubles(node, kind, doubles) = BEFORE.include?(kind) ? Stubs.scan(body(node), doubles) : []
160
+
161
+ def body(node) = node.is_a?(Prism::DefNode) ? node.body : node.block&.body
162
+ end
163
+
164
+ # Finds Minitest block stubs (`obj.stub(:m, v) do ... end`), mocha stubs and expectations
165
+ # (`obj.stubs(:m).returns(v)`, `obj.expects(:m)`), and `Minitest::Mock#expect`.
166
+ module Stubs
167
+ # mocha method => whether it is a stub source. `expects` can fail on its own.
168
+ MOCHA = { stubs: true, expects: false }.freeze
169
+
170
+ module_function
171
+
172
+ # The TestDoubles in +node+, in source order. +doubles+ is a Frameworks::Doubles.
173
+ def scan(node, doubles, found = [])
174
+ node&.compact_child_nodes&.each do |child|
175
+ next if child.is_a?(Prism::DefNode)
176
+
177
+ matched = mocha(child, doubles) || mock_expect(child, doubles)
178
+ next found.concat(matched) if matched
179
+
180
+ found.concat(block_stub(child, doubles))
181
+ scan(child, doubles, found)
182
+ end
183
+ found
184
+ end
185
+
186
+ # `obj.stubs(:m).with(1).returns(v)`: the chain is inside-out, so `stubs` sits under any
187
+ # `with` and `returns` calls. `stubs(m: v)` stubs several methods.
188
+ def mocha(node, doubles)
189
+ chain = Syntax.chain(node)
190
+ index = chain.index { MOCHA.key?(it.name) && it.receiver } or return
191
+ fields = fields(chain[index])
192
+ name = Syntax.stubbed_name(chain[index]) or return pairs(chain[index], node.location, doubles, fields)
193
+
194
+ [doubles.build(node.location, method_name: name, returns: returned(chain[...index]), **fields)]
195
+ end
196
+
197
+ def fields(call) = { receiver: call.receiver, stub_source: MOCHA[call.name] }
198
+
199
+ def pairs(call, location, doubles, fields)
200
+ Syntax.keyword_pairs(call).map do |name, value|
201
+ doubles.build(location, method_name: name, returns: value, **fields)
202
+ end
203
+ end
204
+
205
+ # The single value given to `returns`; several values are a sequence, not one value.
206
+ def returned(outer)
207
+ returns = outer.find { it.name == :returns } or return
208
+ values = Syntax.arguments(returns)
209
+ values.first if values.size == 1
210
+ end
211
+
212
+ # `mock.expect(:m, v)` is a message expectation, verified later.
213
+ def mock_expect(node, doubles)
214
+ return unless node.is_a?(Prism::CallNode) && node.name == :expect && node.receiver
215
+
216
+ name = Syntax.stubbed_name(node) or return
217
+ [doubles.build(node.location, receiver: node.receiver, method_name: name,
218
+ returns: Syntax.arguments(node)[1], stub_source: false)]
219
+ end
220
+
221
+ # `obj.stub(:m, v) do ... end` stubs only inside its block; the block itself is still scanned.
222
+ def block_stub(node, doubles)
223
+ name = block_stub?(node) && Syntax.stubbed_name(node) or return []
224
+
225
+ location = node.receiver.location.join(node.closing_loc || node.arguments.location)
226
+ [doubles.build(location, receiver: node.receiver, method_name: name, returns: Syntax.arguments(node)[1],
227
+ scope: node.block.location)]
228
+ end
229
+
230
+ def block_stub?(node)
231
+ node.is_a?(Prism::CallNode) && node.name == :stub && node.receiver && node.block.is_a?(Prism::BlockNode)
232
+ end
233
+ end
234
+
235
+ # Recognises Minitest assertions, expectations, mocha expectations and mock verification.
236
+ module Assertions
237
+ COMPARISONS = { assert_equal: :equality, assert_same: :identity, must_equal: :equality,
238
+ must_be_same_as: :identity }.freeze
239
+ RAISE_CHECKS = %i[assert_raises must_raise].freeze
240
+ # `_(x)`, `value(x)` and `expect(x)` wrap the value a minitest/spec expectation checks.
241
+ WRAPPERS = %i[_ value expect].freeze
242
+
243
+ # Receiverless calls that are Minitest or Ruby vocabulary, never assertion helpers.
244
+ VOCABULARY = Set[
245
+ *GROUPS, *EXAMPLES, *SETUP_CALLS.keys, :test, :skip, *WRAPPERS, :puts, :p, :pp, :print, :raise, :fail,
246
+ :lambda, :proc, :loop, :sleep, :require, :require_relative, :format
247
+ ].freeze
248
+
249
+ module_function
250
+
251
+ # Assertion fields for an assertion, or nil when +call+ is not one.
252
+ def fields(call)
253
+ name = call.name.to_s
254
+ if call.receiver.nil? && (name.start_with?('assert', 'refute') || name == 'flunk')
255
+ assertion(call)
256
+ elsif name.start_with?('must_', 'wont_')
257
+ expectation(call)
258
+ elsif message_expectation?(call)
259
+ { kind: :message_expectation, comparison: nil, subject: nil, expected: nil }
260
+ end
261
+ end
262
+
263
+ # `assert_equal expected, actual`
264
+ def assertion(call)
265
+ arguments = call.arguments&.arguments || []
266
+ kind, comparison = classify(call.name)
267
+ { kind:, comparison:, subject: comparison && arguments[1], expected: comparison && arguments[0] }
268
+ end
269
+
270
+ # `_(actual).must_equal expected`, `actual.must_equal expected`
271
+ def expectation(call)
272
+ kind, comparison = classify(call.name)
273
+ { kind:, comparison:, subject: comparison && wrapped(call.receiver),
274
+ expected: comparison && call.arguments&.arguments&.first }
275
+ end
276
+
277
+ def wrapped(receiver)
278
+ return receiver unless receiver.is_a?(Prism::CallNode) && receiver.receiver.nil? &&
279
+ WRAPPERS.include?(receiver.name)
280
+
281
+ receiver.arguments&.arguments&.first
282
+ end
283
+
284
+ # mocha `obj.expects(:m)`, `mock.expect(:m, value)` and `mock.verify`
285
+ def message_expectation?(call)
286
+ return false if call.receiver.nil?
287
+
288
+ case call.name
289
+ when :expects then true
290
+ when :expect then call.arguments&.arguments&.first.is_a?(Prism::SymbolNode)
291
+ when :verify then call.arguments.nil?
292
+ else false
293
+ end
294
+ end
295
+
296
+ def classify(name)
297
+ return [:comparison, COMPARISONS[name]] if COMPARISONS.key?(name)
298
+ return [:raise_check, nil] if RAISE_CHECKS.include?(name)
299
+
300
+ [:other, nil]
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end