yard_example_runner 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.
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,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YARD
4
+ module CLI
5
+ # Implements the +yard run-examples+ command
6
+ #
7
+ # Registered with YARD's command dispatcher so that running
8
+ # +yard run-examples [paths...] [options]+ invokes {#run}. The full
9
+ # pipeline is:
10
+ #
11
+ # 1. {#parse_files} — expand the given paths/globs into +.rb+ file paths,
12
+ # defaulting to +app/+ and +lib/+ when none are given.
13
+ # 2. {#parse_examples} — YARD-parse those files and collect every
14
+ # +@example+ tag from the registry.
15
+ # 3. {#add_pwd_to_path} — ensure the current working directory is on
16
+ # +$LOAD_PATH+ so that +require+ calls inside examples resolve correctly.
17
+ # 4. {#generate_tests} — convert each tag into a {YardExampleRunner::Example}
18
+ # and register it as a +Minitest::Spec+.
19
+ # 5. {#run_tests} — schedule the specs to run via +Minitest.autorun+ when
20
+ # the process exits.
21
+ #
22
+ # @see YardExampleRunner::Example
23
+ #
24
+ # @see YardExampleRunner::Expectation
25
+ #
26
+ # @api private
27
+ #
28
+ class RunExamples < Command
29
+ # Returns the one-line description of the command shown in +yard help+
30
+ #
31
+ # @return [String] the description string
32
+ def description
33
+ 'Run @example tags as tests'
34
+ end
35
+
36
+ # Runs the command line, parsing arguments and generating tests
37
+ #
38
+ # @param [Array<String>] args Switches are passed to minitest, everything else
39
+ # is treated as the list of directories/files or glob
40
+ #
41
+ # @return [void]
42
+ #
43
+ def run(*args)
44
+ files = parse_files(args.grep_v(/^-/))
45
+ examples = parse_examples(files)
46
+ add_pwd_to_path
47
+ generate_tests(examples)
48
+ run_tests
49
+ end
50
+
51
+ private
52
+
53
+ # Expands a list of glob patterns or directory names into Ruby source file paths
54
+ #
55
+ # Each entry in +globs+ is treated as follows:
56
+ # - If it is an existing directory, +/**/*.rb+ is appended before
57
+ # expansion, recursively matching all Ruby files under that directory.
58
+ # - Otherwise, it is passed directly to +Dir[]+ as-is, allowing explicit
59
+ # file paths or glob patterns.
60
+ #
61
+ # If +globs+ is empty, it defaults to +['app', 'lib']+.
62
+ #
63
+ # @param globs [Array<String>] file paths, directory names, or glob
64
+ # patterns to expand; defaults to +['app', 'lib']+ when empty
65
+ #
66
+ # @return [Array<String>] the flat list of +.rb+ file paths matched
67
+ # by expanding all globs; an empty array if no files are matched
68
+ #
69
+ def parse_files(globs)
70
+ globs = %w[app lib] if globs.empty?
71
+
72
+ files = globs.map do |glob|
73
+ glob = "#{glob}/**/*.rb" if File.directory?(glob)
74
+
75
+ Dir[glob]
76
+ end
77
+
78
+ files.flatten
79
+ end
80
+
81
+ # Parses the given Ruby source files and returns all +@example+ tags found
82
+ #
83
+ # Instructs YARD to parse +files+, excluding any files matched by the
84
+ # patterns returned from {#excluded_files}. Once parsed, the full YARD
85
+ # registry is loaded and every code object is inspected for +@example+
86
+ # tags. The resulting tags from all objects are collected and flattened
87
+ # into a single array.
88
+ #
89
+ # Note: YARD silently swallows parse-level errors (syntax errors,
90
+ # +ArgumentError+, +NotImplementedError+) and logs warnings rather than
91
+ # raising. Only OS-level I/O errors propagate.
92
+ #
93
+ # @param files [Array<String>] absolute or relative paths to the Ruby
94
+ # source files to parse
95
+ #
96
+ # @return [Array<YARD::Tags::Tag>] all +@example+ tags found across every
97
+ # documented code object in the parsed files, in registry order
98
+ #
99
+ # @raise [Errno::EACCES] if a file in +files+ exists but cannot be read
100
+ # due to insufficient permissions
101
+ #
102
+ # @raise [Errno::ENOENT] if a file in +files+ is deleted between directory
103
+ # expansion and the point at which YARD reads it
104
+ #
105
+ def parse_examples(files)
106
+ YARD.parse(files, excluded_files)
107
+ registry = YARD::Registry.load_all
108
+ registry.all.map { |object| object.tags(:example) }.flatten
109
+ end
110
+
111
+ # Returns the list of file patterns to exclude from YARD parsing
112
+ #
113
+ # Reads the combined arguments from the command line and the +.yardopts+
114
+ # file (via {YARD::Config.with_yardopts}) and extracts the values of any
115
+ # +--exclude+ options. These patterns are passed directly to {YARD.parse}
116
+ # which treats them as case-insensitive regular expressions.
117
+ #
118
+ # If +--exclude+ appears without a following value (e.g. as the last
119
+ # argument), it is silently ignored.
120
+ #
121
+ # @return [Array<String>] the exclusion patterns, one per +--exclude+
122
+ # argument found; empty if none are present
123
+ #
124
+ def excluded_files
125
+ excluded = []
126
+ args = YARD::Config.with_yardopts { YARD::Config.arguments.dup }
127
+ args.each_with_index do |arg, i|
128
+ next unless arg == '--exclude'
129
+ next if args[i + 1].nil?
130
+
131
+ excluded << args[i + 1]
132
+ end
133
+
134
+ excluded
135
+ end
136
+
137
+ # Generates an in-memory Minitest spec for each +@example+ tag
138
+ #
139
+ # Calls {#build_spec} to construct a {YardExampleRunner::Example} for
140
+ # each tag, then calls +generate+ on it, which dynamically defines and
141
+ # registers an anonymous +Minitest::Spec+ subclass. The registered specs
142
+ # are held in memory by Minitest and executed when {#run_tests} triggers
143
+ # the test run.
144
+ #
145
+ # @param examples [Array<YARD::Tags::Tag>] the +@example+ tags to convert
146
+ # into Minitest specs, as returned by {#parse_examples}
147
+ #
148
+ # @return [void]
149
+ #
150
+ def generate_tests(examples)
151
+ examples.each do |example|
152
+ build_spec(example).generate
153
+ end
154
+ end
155
+
156
+ # Builds a {YardExampleRunner::Example} from a YARD +@example+ tag
157
+ #
158
+ # Constructs a new {YardExampleRunner::Example} and populates it with
159
+ # the metadata needed to generate and run a Minitest spec:
160
+ #
161
+ # - +name+ — the title from the +@example+ tag (e.g. +"Adding two numbers"+),
162
+ # or an empty string if the tag has no title
163
+ # - +definition+ — the YARD path of the owning code object
164
+ # (e.g. +"MyClass#my_method"+), used as the spec description
165
+ # - +filepath+ — the absolute path and line number of the code object's
166
+ # definition (e.g. +"/project/lib/my_class.rb:10"+), used to enrich
167
+ # failure backtraces
168
+ # - +expectations+ — the list of expectations parsed from the example body
169
+ # by {#extract_expectations}
170
+ #
171
+ # @param example [YARD::Tags::Tag] a single +@example+ tag whose +object+,
172
+ # +name+, and +text+ attributes will be used to populate the spec
173
+ #
174
+ # @return [YardExampleRunner::Example] the populated example object, ready
175
+ # to have +generate+ called on it
176
+ #
177
+ def build_spec(example)
178
+ YardExampleRunner::Example.new(example.name).tap do |spec|
179
+ spec.definition = example.object.path
180
+ spec.filepath = "#{Dir.pwd}/#{example.object.files.first.join(':')}"
181
+ spec.expectations = extract_expectations(example)
182
+ end
183
+ end
184
+
185
+ # Parses the body of a YARD +@example+ tag into expectations
186
+ #
187
+ # Parses the body of a YARD +@example+ tag into a list of
188
+ # {YardExampleRunner::Expectation} objects
189
+ #
190
+ # The example body is first normalized by {#normalize_example_lines}, which
191
+ # puts each +#=>+ annotation on its own line and strips whitespace. The
192
+ # resulting lines are then consumed in chunks:
193
+ #
194
+ # 1. All lines up to (but not including) the next +#=>+ line are joined as the
195
+ # +actual+ Ruby expression.
196
+ # 2. The +#=>+ line that follows (if any) is stripped of its prefix and
197
+ # whitespace to form the +expected+ value string.
198
+ # 3. An {YardExampleRunner::Expectation} is appended to the result array and
199
+ # the process repeats.
200
+ #
201
+ # If a chunk of code lines has no following +#=>+ line, +expected+ is set to
202
+ # +nil+, which signals to the test runner that the expression should be
203
+ # evaluated but not asserted against a specific value.
204
+ #
205
+ # @example Parsing a single expectation tag.text = "sum(1, 2) #=> 3"
206
+ # extract_expectations(tag) #=> [Expectation[actual: "sum(1, 2)", expected:
207
+ # "3"]]
208
+ #
209
+ # @example Parsing multiple expectations with shared setup tag.text = "a =
210
+ # 1\nsum(a, 2) #=> 3\nsum(a, 3) #=> 4" extract_expectations(tag) #=>
211
+ # [Expectation[actual: "a = 1\nsum(a, 2)", expected: "3"],
212
+ # # Expectation[actual: "sum(a, 3)", expected: "4"]]
213
+ #
214
+ # @param example [YARD::Tags::Tag] the +@example+ tag whose +text+ body will be
215
+ # parsed into expectations
216
+ #
217
+ # @return [Array<YardExampleRunner::Expectation>] one +Expectation+ per +#=>+
218
+ # annotation found in the example body; each holds:
219
+ # - +actual+ [String] — one or more lines of Ruby code to evaluate
220
+ # - +expected+ [String, nil] — the expected return value, or +nil+ if the
221
+ # expression should be evaluated without asserting a result
222
+ #
223
+ def extract_expectations(example)
224
+ lines = normalize_example_lines(example.text)
225
+ [].tap do |arr|
226
+ until lines.empty?
227
+ actual = lines.take_while { |l| l !~ /^#=>/ }
228
+ expected = lines[actual.size]&.sub('#=>', '')&.strip
229
+ lines.slice! 0..actual.size
230
+ arr << YardExampleRunner::Expectation.new(actual: actual.join("\n"), expected: expected)
231
+ end
232
+ end
233
+ end
234
+
235
+ # Normalizes the raw text of a +@example+ tag into a flat array of lines
236
+ #
237
+ # Performs three transformations in order:
238
+ #
239
+ # 1. Normalizes the +# =>+ annotation style (with a space) to +#=>+
240
+ # (without a space), so both forms are treated identically.
241
+ # 2. Ensures each +#=>+ annotation is on its own line by inserting a
242
+ # newline before every occurrence. This handles inline annotations
243
+ # such as +sum(1, 2) #=> 3+, splitting them into two lines.
244
+ # 3. Splits on newlines, strips leading and trailing whitespace from
245
+ # each line, and discards any blank lines.
246
+ #
247
+ # The resulting array is consumed by {#extract_expectations} to identify
248
+ # code lines and their corresponding +#=>+ assertion lines.
249
+ #
250
+ # @param text [String] the raw body text of a +@example+ tag
251
+ #
252
+ # @return [Array<String>] the normalized, non-empty lines of the example;
253
+ # +#=>+ lines are always separate entries, never inline with code
254
+ #
255
+ def normalize_example_lines(text)
256
+ text = text.gsub('# =>', '#=>')
257
+ text = text.gsub('#=>', "\n#=>")
258
+ text.split("\n").map(&:strip).reject(&:empty?)
259
+ end
260
+
261
+ # Schedules the generated Minitest specs to run when the process exits
262
+ #
263
+ # Calls +Minitest.autorun+, which registers an +at_exit+ hook. That hook
264
+ # calls +Minitest.run+ and then fires any callbacks registered via
265
+ # +Minitest.after_run+ (including {YardExampleRunner.after_run} blocks).
266
+ #
267
+ # +Minitest.autorun+ is used rather than calling +Minitest.run+ directly
268
+ # because +Minitest.after_run+ callbacks are only invoked inside
269
+ # +autorun+'s +at_exit+ handler — a bare +Minitest.run+ call does not
270
+ # trigger them.
271
+ #
272
+ # @return [void]
273
+ #
274
+ def run_tests
275
+ Minitest.autorun
276
+ end
277
+
278
+ # Adds the current working directory to Ruby's load path if not present
279
+ #
280
+ # Example code in +@example+ tags is evaluated in the same Ruby process
281
+ # as +yard run-examples+. That code commonly uses +require+ to load the
282
+ # project's own files (e.g. +require 'lib/my_class'+), but Ruby's default
283
+ # +$LOAD_PATH+ does not include the current working directory. Without
284
+ # this, those +require+ calls would raise +LoadError+.
285
+ #
286
+ # This method must be called before {#generate_tests} so that any
287
+ # +require+ statements executed during example evaluation can resolve
288
+ # paths relative to the project root.
289
+ #
290
+ # @return [void]
291
+ #
292
+ def add_pwd_to_path
293
+ $LOAD_PATH.unshift(Dir.pwd) unless $LOAD_PATH.include?(Dir.pwd)
294
+ end
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardExampleRunner
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