Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New matcher idea: match_pattern for Ruby's pattern-matching #1436

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
27 changes: 27 additions & 0 deletions features/built_in_matchers/match_pattern.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Feature: `match_pattern` matcher

Use the `match_pattern` matcher to specify that a value matches with
expected patterns by Ruby's pattern-matching.

```ruby
expect([1, 2, 3]).to match_pattern([Integer, Integer, Integer])
expect([1, 2, 3]).to match_pattern([Integer, Integer, String])
```

Scenario: Pattern usage
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would also have to be tagged and excluded on unsupported ruby, and as it is used in our docs we would need a note about supported ruby versions in that,

Given a file named "example_spec.rb" with:
"""ruby
RSpec.describe [1, 2, 3] do
it { is_expected.to match_pattern([Integer, Integer, Integer]) }
it { is_expected.not_to match_pattern([Integer, Integer, String]) }

# deliberate failures
it { is_expected.to match_pattern([Integer, Integer, String]) }
it { is_expected.not_to match_pattern([Integer, Integer, Integer]) }
end
"""
When I run `rspec example_spec.rb`
Then the output should contain all of these:
| 4 examples, 2 failures |
| expected [1, 2, 3] to match pattern [Integer, Integer, String] |
| expected [1, 2, 3] not to match pattern [Integer, Integer, Integer] |
11 changes: 11 additions & 0 deletions lib/rspec/matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,17 @@ def match_array(items)
desc.sub("contain exactly", "an array containing exactly")
end

# Passes if the actual value matches the expected pattern with Ruby's pattern-matching.
#
# Note that the given pattern is processed with `#inspect`
# and then evaluated with pattern-matching by `#instance_eval`.
#
# @example
# expect([1, 2, 3]).to match_pattern([Integer, Integer, Integer])
def match_pattern(expected)
BuiltIn::MatchPattern.new(expected)
end

# With no arg, passes if the block outputs `to_stdout` or `to_stderr`.
# With a string, passes if the block outputs that specific string `to_stdout` or `to_stderr`.
# With a regexp or matcher, passes if the block outputs a string `to_stdout` or `to_stderr` that matches.
Expand Down
1 change: 1 addition & 0 deletions lib/rspec/matchers/built_in.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ module BuiltIn
autoload :Include, 'rspec/matchers/built_in/include'
autoload :All, 'rspec/matchers/built_in/all'
autoload :Match, 'rspec/matchers/built_in/match'
autoload :MatchPattern, 'rspec/matchers/built_in/match_pattern'
autoload :NegativeOperatorMatcher, 'rspec/matchers/built_in/operators'
autoload :OperatorMatcher, 'rspec/matchers/built_in/operators'
autoload :Output, 'rspec/matchers/built_in/output'
Expand Down
47 changes: 47 additions & 0 deletions lib/rspec/matchers/built_in/match_pattern.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
module RSpec
module Matchers
module BuiltIn
# @api private
# Provides the implementation for `match_pattern`.
# Not intended to be instantiated directly.
class MatchPattern < BaseMatcher
def match(expected, actual) # rubocop:disable Lint/UnusedMethodArgument
raise_not_supported_ruby_version_error unless supported_ruby_version?

begin
instance_eval(<<~RUBY, __FILE__, __LINE__ + 1)
actual in #{expected.inspect}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If theres a way to avoid using eval that would be ideal...

RUBY
rescue SyntaxError
raise_invalid_pattern_error
end
end

def failure_message
"expected #{description_of(@actual)} to match pattern #{@expected.inspect}"
end

def failure_message_when_negated
"expected #{description_of(@actual)} not to match pattern #{@expected.inspect}"
end

private

def raise_invalid_pattern_error
raise SyntaxError, "The #{matcher_name} matcher requires that " \
"the expected object can be used as Ruby's " \
"pattern-matching but a `SyntaxError` was raised instead."
end

def raise_not_supported_ruby_version_error
raise NotImplementedError, "The #{matcher_name} matcher is only " \
"supported on Ruby 3 or higher."
end

def supported_ruby_version?
RUBY_VERSION.to_f >= 3.0
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would typically conditionally define the entire matcher on supported rubies, and then have a dummy implementated that raises the not implemented error.

end
end
end
end
40 changes: 40 additions & 0 deletions spec/rspec/matchers/built_in/match_pattern_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
RSpec.describe 'match_pattern matcher' do
it_behaves_like 'an RSpec value matcher', :valid_value => [1, 2, 3],
:invalid_value => [1, 2, '3'] do
let(:matcher) { match_pattern([Integer, Integer, Integer]) }
end

context 'when expected pattern matches with actual' do
it 'passes' do
expect([1, 2, 3]).to match_pattern([Integer, Integer, Integer])
end
end

context 'when expected pattern does not match with actual' do
it 'fails' do
expect {
expect([1, 2, 3]).to match_pattern([Integer, Integer, String])
}.to fail_with('expected [1, 2, 3] to match pattern [Integer, Integer, String]')
end
end

context 'when expected pattern cannot be used as pattern-matching' do
it 'raises SyntaxError' do
expect {
expect([1, 2, 3]).to match_pattern(Object.new)
}.to raise_error(SyntaxError, "The match_pattern matcher requires that the expected object can be used as Ruby's pattern-matching but a `SyntaxError` was raised instead.")
end
end

context 'when pattern-matching is not supported on the current Ruby version' do
before do
stub_const('RUBY_VERSION', '2.7.0')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering we run builds on multiple rubies, you don't need to do this, you can add a flag to rspec-support for wether pattern matching is supported, then flag one set of tests as :if => ... and the other has :unless => ...

end

it 'raises NotImplementedError' do
expect {
expect([1, 2, 3]).to match_pattern([Integer, Integer, Integer])
}.to raise_error(NotImplementedError, 'The match_pattern matcher is only supported on Ruby 3 or higher.')
end
end
end