yard_example_test 0.2.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.
data/Rakefile ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'rake/clean'
5
+
6
+ # Load all .rake files from tasks and its subdirectories.
7
+ Dir.glob('rake_tasks/**/*.rake').each { |r| load r }
8
+
9
+ CLOBBER << '.husky/_'
10
+ CLOBBER << 'node_modules'
11
+ CLOBBER << 'Gemfile.lock'
12
+ CLOBBER << 'package-lock.json'
13
+
14
+ default_tasks = %i[cucumber markdownlint rubocop yard]
15
+
16
+ task default: default_tasks
17
+
18
+ # Prepend a module into Rake::Task to add per-task logging without stomping on
19
+ # other patches that may also wrap execute.
20
+ module Rake
21
+ # Rake::Task with per-task box logging prepended.
22
+ class Task
23
+ prepend(Module.new do
24
+ def execute(args = nil)
25
+ # Only output the task name if it wasn't the only top-level task
26
+ # rake default # => output task name for each task called by the default task
27
+ # rake rubocop # => do not output the task name
28
+ # rake rubocop yard # => output task name for rubocop and yard
29
+ top_level_tasks = Rake.application.top_level_tasks
30
+ box("Rake task: #{name}") unless top_level_tasks.length == 1 && name == top_level_tasks[0]
31
+ super
32
+ end
33
+
34
+ private
35
+
36
+ def box(message)
37
+ width = message.length + 2
38
+ puts "┌#{'─' * width}┐"
39
+ puts "│ #{message} │"
40
+ puts "└#{'─' * width}┘"
41
+ end
42
+ end)
43
+ end
44
+
45
+ # Rake::Application with SUCCESS/FAIL summary appended after the full run.
46
+ # top_level is used rather than run because the Rakefile is loaded inside
47
+ # run, so any patch to run is applied too late to affect the current call.
48
+ class Application
49
+ prepend(Module.new do
50
+ def top_level
51
+ super
52
+ puts "\nSUCCESS"
53
+ rescue Exception => e # rubocop:disable Lint/RescueException
54
+ puts "\n#{e.is_a?(SystemExit) && e.success? ? 'SUCCESS' : 'FAILED'}"
55
+ raise
56
+ end
57
+ end)
58
+ end
59
+ end
data/bin/setup ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ if [ -x "$(command -v npm)" ]; then
9
+ npm install
10
+ else
11
+ echo "npm is not installed"
12
+ echo "Install npm then re-run this script to enable the conventional commit git hook."
13
+ fi
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Extension of the {YARD} module provided by the `yard` gem
4
+ #
5
+ # This gem reopens the top-level {YARD} namespace from the `yard` dependency
6
+ # in order to register additional CLI commands.
7
+ #
8
+ # @api private
9
+ #
10
+ # @see https://rubydoc.info/docs/yard/YARD YARD
11
+ #
12
+ module YARD
13
+ # Namespace for command-line interface components provided by the `yard` gem
14
+ #
15
+ # This gem reopens {YARD::CLI} to add the {YARD::CLI::TestExamples} command.
16
+ #
17
+ # @api private
18
+ #
19
+ # @see https://rubydoc.info/docs/yard/YARD/CLI YARD::CLI
20
+ #
21
+ module CLI
22
+ # Implements the +yard test-examples+ command
23
+ #
24
+ # Registered with YARD's command dispatcher so that running
25
+ # +yard test-examples [paths...] [options]+ invokes {#run}. The full
26
+ # pipeline is:
27
+ #
28
+ # 1. {#parse_files} — expand the given paths/globs into +.rb+ file paths,
29
+ # defaulting to +app/+ and +lib/+ when none are given.
30
+ # 2. {#parse_examples} — YARD-parse those files and collect every
31
+ # +@example+ tag from the registry.
32
+ # 3. {#add_pwd_to_path} — ensure the current working directory is on
33
+ # +$LOAD_PATH+ so that +require+ calls inside examples resolve correctly.
34
+ # 4. {#generate_tests} — convert each tag into a {YardExampleTest::Example}
35
+ # and register it as a +Minitest::Spec+.
36
+ # 5. {#run_tests} — schedule the specs to run via +Minitest.autorun+ when
37
+ # the process exits.
38
+ #
39
+ # @see YardExampleTest::Example
40
+ #
41
+ # @see YardExampleTest::Expectation
42
+ #
43
+ # @api private
44
+ #
45
+ class TestExamples < Command
46
+ # Returns the one-line description of the command shown in +yard help+
47
+ #
48
+ # @return [String] the description string
49
+ def description
50
+ 'Run @example tags as tests'
51
+ end
52
+
53
+ # Runs the command line, parsing arguments and generating tests
54
+ #
55
+ # @param [Array<String>] args Switches are passed to minitest, everything else
56
+ # is treated as the list of directories/files or glob
57
+ #
58
+ # @return [void]
59
+ #
60
+ def run(*args)
61
+ files = parse_files(args.grep_v(/^-/))
62
+ examples = parse_examples(files)
63
+ add_pwd_to_path
64
+ generate_tests(examples)
65
+ run_tests
66
+ end
67
+
68
+ private
69
+
70
+ # Expands a list of glob patterns or directory names into Ruby source file paths
71
+ #
72
+ # Each entry in +globs+ is treated as follows:
73
+ # - If it is an existing directory, +/**/*.rb+ is appended before
74
+ # expansion, recursively matching all Ruby files under that directory.
75
+ # - Otherwise, it is passed directly to +Dir[]+ as-is, allowing explicit
76
+ # file paths or glob patterns.
77
+ #
78
+ # If +globs+ is empty, it defaults to +['app', 'lib']+.
79
+ #
80
+ # @param globs [Array<String>] file paths, directory names, or glob
81
+ # patterns to expand; defaults to +['app', 'lib']+ when empty
82
+ #
83
+ # @return [Array<String>] the flat list of +.rb+ file paths matched
84
+ # by expanding all globs; an empty array if no files are matched
85
+ #
86
+ def parse_files(globs)
87
+ globs = %w[app lib] if globs.empty?
88
+
89
+ files = globs.map do |glob|
90
+ glob = "#{glob}/**/*.rb" if File.directory?(glob)
91
+
92
+ Dir[glob]
93
+ end
94
+
95
+ files.flatten
96
+ end
97
+
98
+ # Parses the given Ruby source files and returns all +@example+ tags found
99
+ #
100
+ # Instructs YARD to parse +files+, excluding any files matched by the
101
+ # patterns returned from {#excluded_files}. Once parsed, the full YARD
102
+ # registry is loaded and every code object is inspected for +@example+
103
+ # tags. The resulting tags from all objects are collected and flattened
104
+ # into a single array.
105
+ #
106
+ # Note: YARD silently swallows parse-level errors (syntax errors,
107
+ # +ArgumentError+, +NotImplementedError+) and logs warnings rather than
108
+ # raising. Only OS-level I/O errors propagate.
109
+ #
110
+ # @param files [Array<String>] absolute or relative paths to the Ruby
111
+ # source files to parse
112
+ #
113
+ # @return [Array<YARD::Tags::Tag>] all +@example+ tags found across every
114
+ # documented code object in the parsed files, in registry order
115
+ #
116
+ # @raise [Errno::EACCES] if a file in +files+ exists but cannot be read
117
+ # due to insufficient permissions
118
+ #
119
+ # @raise [Errno::ENOENT] if a file in +files+ is deleted between directory
120
+ # expansion and the point at which YARD reads it
121
+ #
122
+ def parse_examples(files)
123
+ YARD.parse(files, excluded_files)
124
+ registry = YARD::Registry.load_all
125
+ registry.all.map { |object| object.tags(:example) }.flatten
126
+ end
127
+
128
+ # Returns the list of file patterns to exclude from YARD parsing
129
+ #
130
+ # Reads the combined arguments from the command line and the +.yardopts+
131
+ # file (via {YARD::Config.with_yardopts}) and extracts the values of any
132
+ # +--exclude+ options. These patterns are passed directly to {YARD.parse}
133
+ # which treats them as case-insensitive regular expressions.
134
+ #
135
+ # If +--exclude+ appears without a following value (e.g. as the last
136
+ # argument), it is silently ignored.
137
+ #
138
+ # @return [Array<String>] the exclusion patterns, one per +--exclude+
139
+ # argument found; empty if none are present
140
+ #
141
+ def excluded_files
142
+ excluded = []
143
+ args = YARD::Config.with_yardopts { YARD::Config.arguments.dup }
144
+ args.each_with_index do |arg, i|
145
+ next unless arg == '--exclude'
146
+ next if args[i + 1].nil?
147
+
148
+ excluded << args[i + 1]
149
+ end
150
+
151
+ excluded
152
+ end
153
+
154
+ # Generates an in-memory Minitest spec for each +@example+ tag
155
+ #
156
+ # Calls {#build_spec} to construct a {YardExampleTest::Example} for
157
+ # each tag, then calls +generate+ on it, which dynamically defines and
158
+ # registers an anonymous +Minitest::Spec+ subclass. The registered specs
159
+ # are held in memory by Minitest and executed when {#run_tests} triggers
160
+ # the test run.
161
+ #
162
+ # @param examples [Array<YARD::Tags::Tag>] the +@example+ tags to convert
163
+ # into Minitest specs, as returned by {#parse_examples}
164
+ #
165
+ # @return [void]
166
+ #
167
+ def generate_tests(examples)
168
+ examples.each do |example|
169
+ build_spec(example).generate
170
+ end
171
+ end
172
+
173
+ # Builds a {YardExampleTest::Example} from a YARD +@example+ tag
174
+ #
175
+ # Constructs a new {YardExampleTest::Example} and populates it with
176
+ # the metadata needed to generate and run a Minitest spec:
177
+ #
178
+ # - +name+ — the title from the +@example+ tag (e.g. +"Adding two numbers"+),
179
+ # or an empty string if the tag has no title
180
+ # - +definition+ — the YARD path of the owning code object
181
+ # (e.g. +"MyClass#my_method"+), used as the spec description
182
+ # - +filepath+ — the absolute path and line number of the code object's
183
+ # definition (e.g. +"/project/lib/my_class.rb:10"+), used to enrich
184
+ # failure backtraces
185
+ # - +expectations+ — the list of expectations parsed from the example body
186
+ # by {#extract_expectations}
187
+ #
188
+ # @param example [YARD::Tags::Tag] a single +@example+ tag whose +object+,
189
+ # +name+, and +text+ attributes will be used to populate the spec
190
+ #
191
+ # @return [YardExampleTest::Example] the populated example object, ready
192
+ # to have +generate+ called on it
193
+ #
194
+ def build_spec(example)
195
+ YardExampleTest::Example.new(example.name).tap do |spec|
196
+ spec.definition = example.object.path
197
+ spec.filepath = "#{Dir.pwd}/#{example.object.files.first.join(':')}"
198
+ spec.expectations = extract_expectations(example)
199
+ end
200
+ end
201
+
202
+ # Parses the body of a YARD +@example+ tag into expectations
203
+ #
204
+ # Parses the body of a YARD +@example+ tag into a list of
205
+ # {YardExampleTest::Expectation} objects
206
+ #
207
+ # The example body is first normalized by {#normalize_example_lines}, which
208
+ # puts each +#=>+ annotation on its own line and strips whitespace. The
209
+ # resulting lines are then consumed in chunks:
210
+ #
211
+ # 1. All lines up to (but not including) the next +#=>+ line are joined as the
212
+ # +actual+ Ruby expression.
213
+ # 2. The +#=>+ line that follows (if any) is stripped of its prefix and
214
+ # whitespace to form the +expected+ value string.
215
+ # 3. An {YardExampleTest::Expectation} is appended to the result array and
216
+ # the process repeats.
217
+ #
218
+ # If a chunk of code lines has no following +#=>+ line, +expected+ is set to
219
+ # +nil+, which signals to the test runner that the expression should be
220
+ # evaluated but not asserted against a specific value.
221
+ #
222
+ # @example Parsing a single expectation tag.text = "sum(1, 2) #=> 3"
223
+ # extract_expectations(tag) #=> [Expectation[actual: "sum(1, 2)", expected:
224
+ # "3"]]
225
+ #
226
+ # @example Parsing multiple expectations with shared setup tag.text = "a =
227
+ # 1\nsum(a, 2) #=> 3\nsum(a, 3) #=> 4" extract_expectations(tag) #=>
228
+ # [Expectation[actual: "a = 1\nsum(a, 2)", expected: "3"],
229
+ # # Expectation[actual: "sum(a, 3)", expected: "4"]]
230
+ #
231
+ # @param example [YARD::Tags::Tag] the +@example+ tag whose +text+ body will be
232
+ # parsed into expectations
233
+ #
234
+ # @return [Array<YardExampleTest::Expectation>] one +Expectation+ per +#=>+
235
+ # annotation found in the example body; each holds:
236
+ # - +actual+ [String] — one or more lines of Ruby code to evaluate
237
+ # - +expected+ [String, nil] — the expected return value, or +nil+ if the
238
+ # expression should be evaluated without asserting a result
239
+ #
240
+ def extract_expectations(example)
241
+ lines = normalize_example_lines(example.text)
242
+ [].tap do |arr|
243
+ until lines.empty?
244
+ actual = lines.take_while { |l| l !~ /^#=>/ }
245
+ expected = lines[actual.size]&.sub('#=>', '')&.strip
246
+ lines.slice! 0..actual.size
247
+ arr << YardExampleTest::Expectation.new(actual: actual.join("\n"), expected: expected)
248
+ end
249
+ end
250
+ end
251
+
252
+ # Normalizes the raw text of a +@example+ tag into a flat array of lines
253
+ #
254
+ # Performs three transformations in order:
255
+ #
256
+ # 1. Normalizes the +# =>+ annotation style (with a space) to +#=>+
257
+ # (without a space), so both forms are treated identically.
258
+ # 2. Ensures each +#=>+ annotation is on its own line by inserting a
259
+ # newline before every occurrence. This handles inline annotations
260
+ # such as +sum(1, 2) #=> 3+, splitting them into two lines.
261
+ # 3. Splits on newlines, strips leading and trailing whitespace from
262
+ # each line, and discards any blank lines.
263
+ #
264
+ # The resulting array is consumed by {#extract_expectations} to identify
265
+ # code lines and their corresponding +#=>+ assertion lines.
266
+ #
267
+ # @param text [String] the raw body text of a +@example+ tag
268
+ #
269
+ # @return [Array<String>] the normalized, non-empty lines of the example;
270
+ # +#=>+ lines are always separate entries, never inline with code
271
+ #
272
+ def normalize_example_lines(text)
273
+ text = text.gsub('# =>', '#=>')
274
+ text = text.gsub('#=>', "\n#=>")
275
+ text.split("\n").map(&:strip).reject(&:empty?)
276
+ end
277
+
278
+ # Schedules the generated Minitest specs to run when the process exits
279
+ #
280
+ # Calls +Minitest.autorun+, which registers an +at_exit+ hook. That hook
281
+ # calls +Minitest.run+ and then fires any callbacks registered via
282
+ # +Minitest.after_run+ (including {YardExampleTest.after_run} blocks).
283
+ #
284
+ # +Minitest.autorun+ is used rather than calling +Minitest.run+ directly
285
+ # because +Minitest.after_run+ callbacks are only invoked inside
286
+ # +autorun+'s +at_exit+ handler — a bare +Minitest.run+ call does not
287
+ # trigger them.
288
+ #
289
+ # @return [void]
290
+ #
291
+ def run_tests
292
+ Minitest.autorun
293
+ end
294
+
295
+ # Adds the current working directory to Ruby's load path if not present
296
+ #
297
+ # Example code in +@example+ tags is evaluated in the same Ruby process
298
+ # as +yard test-examples+. That code commonly uses +require+ to load the
299
+ # project's own files (e.g. +require 'lib/my_class'+), but Ruby's default
300
+ # +$LOAD_PATH+ does not include the current working directory. Without
301
+ # this, those +require+ calls would raise +LoadError+.
302
+ #
303
+ # This method must be called before {#generate_tests} so that any
304
+ # +require+ statements executed during example evaluation can resolve
305
+ # paths relative to the project root.
306
+ #
307
+ # @return [void]
308
+ #
309
+ def add_pwd_to_path
310
+ $LOAD_PATH.unshift(Dir.pwd) unless $LOAD_PATH.include?(Dir.pwd)
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardExampleTest
4
+ class Example < ::Minitest::Spec
5
+ # Comparison and matcher logic for verifying example expectations
6
+ #
7
+ # This module is included into {Example} so that its methods have direct
8
+ # access to Minitest assertions (+assert+, +assert_equal+, +assert_nil+,
9
+ # +diff+) inherited from +Minitest::Spec+. It also delegates to the
10
+ # {Evaluator} (via {Example#evaluator}) for expression evaluation.
11
+ #
12
+ # The module handles three verification strategies:
13
+ #
14
+ # 1. **Block matchers** — matchers that implement +supports_block_expectations?+
15
+ # (e.g. RSpec's +raise_error+, +change+, +output+). The actual expression
16
+ # is wrapped in a +Proc+ and passed unevaluated.
17
+ # 2. **Value matchers** — matchers that implement +matches?+ (e.g. +eq+,
18
+ # +be_a+, +be_within+). The actual expression is evaluated and the
19
+ # resulting value is passed.
20
+ # 3. **Bare values** — compared via {#compare_values} using error handling,
21
+ # +nil+ checks, and +===+ case equality.
22
+ #
23
+ module Comparison
24
+ protected
25
+
26
+ # Evaluates both the actual and expected expressions and compares their results
27
+ #
28
+ # The expected expression is always evaluated first via
29
+ # {Evaluator#evaluate_with_assertion} (which captures any +StandardError+
30
+ # as a value). The result determines which of two branches is taken:
31
+ #
32
+ # 1. **Matcher** ({#matcher?}) — delegates to {#assert_matcher}, which
33
+ # handles both block matchers (e.g. +raise_error+) and value matchers
34
+ # (e.g. +eq+, +be_a+, +be_within+).
35
+ # 2. **Bare value** — +actual+ is also evaluated via
36
+ # {Evaluator#evaluate_with_assertion} and the pair is compared via
37
+ # {#compare_values}, which handles error-vs-error, single-error,
38
+ # +nil+, and +===+ cases.
39
+ #
40
+ # On failure the backtrace is decorated with the example's source location
41
+ # via {Example#add_filepath_to_backtrace}.
42
+ #
43
+ # @param example [Example] the owning example, used to decorate failure backtraces
44
+ #
45
+ # @param expected [String] the Ruby expression representing the expected value
46
+ #
47
+ # @param actual [String] the Ruby expression representing the actual value
48
+ #
49
+ # @param bind [Class, nil] the class scope to evaluate +actual+ in; +expected+
50
+ # is always evaluated in a +nil+ binding (top-level context)
51
+ #
52
+ # @return [void]
53
+ #
54
+ # @raise [Minitest::Assertion] re-raises any assertion failure with the
55
+ # example's filepath prepended to the backtrace
56
+ #
57
+ # @example
58
+ # verify_actual(example, '42', 'answer', nil)
59
+ #
60
+ # @api private
61
+ #
62
+ def verify_actual(example, expected, actual, bind)
63
+ expected = evaluator.evaluate_with_assertion(expected, nil)
64
+
65
+ if matcher?(expected)
66
+ assert_matcher(expected, actual, bind)
67
+ else
68
+ actual = evaluator.evaluate_with_assertion(actual, bind)
69
+ compare_values(expected, actual)
70
+ end
71
+ rescue Minitest::Assertion => e
72
+ add_filepath_to_backtrace(e, example.filepath)
73
+ raise e
74
+ end
75
+
76
+ # Evaluates +actual+ and asserts a matcher against it
77
+ #
78
+ # When the matcher is a {#block_matcher?}, the actual expression is wrapped
79
+ # in a +Proc+ and passed unevaluated so the matcher can invoke it (e.g.
80
+ # +raise_error+). Otherwise the actual expression is evaluated via
81
+ # {Evaluator#evaluate} and the resulting value is matched. If evaluation
82
+ # raises, the error propagates — use a block matcher like +raise_error+
83
+ # to assert on exceptions.
84
+ #
85
+ # @param expected [#matches?] a matcher object
86
+ # @param actual [String] the Ruby expression representing the actual value
87
+ # @param bind [Class, nil] the class scope for evaluation
88
+ #
89
+ # @return [void]
90
+ #
91
+ # @example
92
+ # assert_matcher(eq(42), 'answer', nil)
93
+ #
94
+ # @api private
95
+ #
96
+ def assert_matcher(expected, actual, bind)
97
+ subject = if block_matcher?(expected)
98
+ -> { evaluator.evaluate(actual, bind) }
99
+ else
100
+ evaluator.evaluate(actual, bind)
101
+ end
102
+ assert expected.matches?(subject), failure_message_for(expected)
103
+ end
104
+
105
+ # Compares two already-evaluated values and raises on mismatch
106
+ #
107
+ # Handles four cases in priority order:
108
+ #
109
+ # 1. **Both are errors** — compares their string representations
110
+ # (++"#<ClassName: message>"+++) with +assert_equal+, so mismatched error
111
+ # types or messages produce a readable diff.
112
+ # 2. **Only one is an error** — raises that error directly, surfacing an
113
+ # unexpected exception as a test failure.
114
+ # 3. **Expected is +nil+** — uses +assert_nil+ so Minitest's nil-specific
115
+ # failure message is produced.
116
+ # 4. **Otherwise** — uses +assert+ with +===+ (case equality), which allows
117
+ # +expected+ to be a +Regexp+, a +Range+, a +Proc+, or any other object
118
+ # that implements a meaningful +===+.
119
+ #
120
+ # @param expected [Object] the already-evaluated expected value (or a
121
+ # +StandardError+ if evaluation raised)
122
+ #
123
+ # @param actual [Object] the already-evaluated actual value (or a
124
+ # +StandardError+ if evaluation raised)
125
+ #
126
+ # @return [void]
127
+ #
128
+ # @raise [Minitest::Assertion] if the values do not match under the applicable rule
129
+ #
130
+ # @raise [StandardError] if exactly one of the values is an error
131
+ #
132
+ # @example
133
+ # compare_values(42, 42)
134
+ #
135
+ # @api private
136
+ #
137
+ def compare_values(expected, actual)
138
+ if both_are_errors?(expected, actual)
139
+ assert_equal("#<#{expected.class}: #{expected}>", "#<#{actual.class}: #{actual}>")
140
+ elsif (error = only_one_is_error?(expected, actual))
141
+ raise error
142
+ elsif expected.nil?
143
+ assert_nil(actual)
144
+ else
145
+ assert expected === actual, diff(expected, actual) # rubocop:disable Style/CaseEquality
146
+ end
147
+ end
148
+
149
+ # Returns +true+ if +obj+ implements the matcher protocol
150
+ #
151
+ # Checks for the presence of a +matches?+ method, which is the standard
152
+ # interface for both RSpec matchers and +minitest-matchers+.
153
+ #
154
+ # @param obj [Object] the object to test
155
+ #
156
+ # @return [Boolean]
157
+ #
158
+ # @example
159
+ # matcher?(eq(42)) # => true
160
+ #
161
+ # @api private
162
+ #
163
+ def matcher?(obj)
164
+ obj.respond_to?(:matches?)
165
+ end
166
+
167
+ # Returns +true+ if +obj+ is a block-style matcher
168
+ #
169
+ # A block matcher is a {#matcher?} that also responds to
170
+ # +supports_block_expectations?+ and returns +true+ from it. RSpec's
171
+ # +raise_error+, +change+, and +output+ matchers follow this protocol.
172
+ #
173
+ # @param obj [Object] the object to test
174
+ #
175
+ # @return [Boolean]
176
+ #
177
+ # @example
178
+ # block_matcher?(raise_error(RuntimeError)) # => true
179
+ #
180
+ # @api private
181
+ #
182
+ def block_matcher?(obj)
183
+ matcher?(obj) &&
184
+ obj.respond_to?(:supports_block_expectations?) &&
185
+ obj.supports_block_expectations?
186
+ end
187
+
188
+ # Returns the failure message from a matcher, with legacy fallback
189
+ #
190
+ # Tries +failure_message+ first (RSpec 3.x / modern minitest-matchers),
191
+ # then falls back to +failure_message_for_should+ (RSpec 2.x / older
192
+ # minitest-matchers).
193
+ #
194
+ # @param a_matcher [#failure_message, #failure_message_for_should] the matcher
195
+ #
196
+ # @return [String, nil] the failure message, or +nil+ if neither method exists
197
+ #
198
+ # @example
199
+ # failure_message_for(eq(42))
200
+ #
201
+ # @api private
202
+ #
203
+ def failure_message_for(a_matcher)
204
+ if a_matcher.respond_to?(:failure_message)
205
+ a_matcher.failure_message
206
+ elsif a_matcher.respond_to?(:failure_message_for_should)
207
+ a_matcher.failure_message_for_should
208
+ end
209
+ end
210
+
211
+ private
212
+
213
+ # Returns +true+ if both values are +StandardError+ instances
214
+ #
215
+ # @param expected [Object] the expected value
216
+ # @param actual [Object] the actual value
217
+ #
218
+ # @return [Boolean]
219
+ #
220
+ # @example
221
+ # both_are_errors?(RuntimeError.new, ArgumentError.new) # => true
222
+ #
223
+ # @api private
224
+ #
225
+ def both_are_errors?(expected, actual)
226
+ expected.is_a?(StandardError) && actual.is_a?(StandardError)
227
+ end
228
+
229
+ # Returns the error if exactly one value is a +StandardError+, otherwise +nil+
230
+ #
231
+ # @param expected [Object] the expected value
232
+ # @param actual [Object] the actual value
233
+ #
234
+ # @return [StandardError, nil]
235
+ #
236
+ # @example
237
+ # only_one_is_error?(RuntimeError.new, 42) # => RuntimeError
238
+ #
239
+ # @api private
240
+ #
241
+ def only_one_is_error?(expected, actual)
242
+ if expected.is_a?(StandardError) && !actual.is_a?(StandardError)
243
+ expected
244
+ elsif !expected.is_a?(StandardError) && actual.is_a?(StandardError)
245
+ actual
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end