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

Add exhaustive case matcher #43

Merged
merged 4 commits into from
Jan 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ Metrics/BlockLength:
Exclude:
- 'spec/**/*_spec.rb'

RSpec/SpecFilePathFormat:
Enabled: false

RSpec/FilePath:
Enabled: false

Style/HashEachMethods:
Enabled: true

Expand Down
8 changes: 0 additions & 8 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,6 @@ Naming/MethodParameterName:
RSpec/ExampleLength:
Max: 11

# Offense count: 2
# Configuration parameters: Include, CustomTransform, IgnoreMethods, SpecSuffixOnly.
# Include: **/*_spec*rb*, **/spec/**/*
RSpec/FilePath:
Exclude:
- 'spec/ruby-enum/enum_spec.rb'
- 'spec/ruby-enum/version_spec.rb'

# Offense count: 4
RSpec/LeakyConstantDeclaration:
Exclude:
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
### 0.9.1 (Next)
### 1.0.0 (Next)

* [#43](https://github.com/dblock/ruby-enum/pull/43): Add exhaustive case matcher - [@peterfication](https://github.com/peterfication).
* [#40](https://github.com/dblock/ruby-enum/pull/39): Enable new Rubocop cops and address/allowlist lints - [@petergoldstein](https://github.com/petergoldstein).
* [#39](https://github.com/dblock/ruby-enum/pull/39): Require Ruby >= 2.7 - [@petergoldstein](https://github.com/petergoldstein).
* [#38](https://github.com/dblock/ruby-enum/pull/38): Ensure Ruby >= 2.3 - [@ojab](https://github.com/ojab).
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Enum-like behavior for Ruby, heavily inspired by [this](http://www.rubyfleebie.c
- [Mapping values to keys](#mapping-values-to-keys)
- [Duplicate enumerator keys or duplicate values](#duplicate-enumerator-keys-or-duplicate-values)
- [Inheritance](#inheritance)
- [Exhaustive case matcher](#exhaustive-case-matcher)
- [Benchmarks](#benchmarks)
- [Contributing](#contributing)
- [Copyright and License](#copyright-and-license)
- [Related Projects](#related-projects)
Expand Down Expand Up @@ -259,6 +261,53 @@ OrderState.values # ['CREATED', 'PAID']
ShippedOrderState.values # ['CREATED', 'PAID', 'PREPARED', SHIPPED']
```

### Exhaustive case matcher
dblock marked this conversation as resolved.
Show resolved Hide resolved

If you want to make sure that you cover all cases in a case stament, you can use the exhaustive case matcher: `Ruby::Enum::Case`. It will raise an error if a case/enum value is not handled, or if a value is specified that's not part of the enum. This is inspired by the [Rust Pattern Syntax](https://doc.rust-lang.org/book/ch18-03-pattern-syntax.html). If multiple cases match, all matches are being executed. The return value is the value from the matched case, or an array of return values if multiple cases matched.

> NOTE: This will add checks at runtime which might lead to worse performance. See [benchmarks](#benchmarks).

> NOTE: `:else` is a reserved keyword if you want to use `Ruby::Enum::Case`.

```ruby
class Color < OrderState
include Ruby::Enum
include Ruby::Enum::Case

define :RED, :red
define :GREEN, :green
define :BLUE, :blue
define :YELLOW, :yellow
end
```

```ruby
color = Color::RED
Color.Case(color, {
[Color::GREEN, Color::BLUE] => -> { "order is green or blue" },
Color::YELLOW => -> { "order is yellow" },
Color::RED => -> { "order is red" },
})
```

It also supports default/else:

```ruby
color = Color::RED
Color.Case(color, {
[Color::GREEN, Color::BLUE] => -> { "order is green or blue" },
else: -> { "order is yellow or red" },
})
```

## Benchmarks

Benchmark scripts are defined in the [`benchmarks`](benchmarks) folder and can be run with Rake:

```console
rake benchmarks:case
```

## Contributing

You're encouraged to contribute to ruby-enum. See [CONTRIBUTING](CONTRIBUTING.md) for details.
Expand Down
7 changes: 7 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,10 @@ require 'rubocop/rake_task'
RuboCop::RakeTask.new(:rubocop)

task default: %i[rubocop spec]

namespace :benchmark do
desc 'Run benchmark for the Ruby::Enum::Case'
task :case do
require_relative 'benchmarks/case'
end
end
45 changes: 45 additions & 0 deletions benchmarks/case.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))

require 'benchmark'
require 'ruby-enum'

##
# Test enum
class Color
include Ruby::Enum
include Ruby::Enum::Case

define :RED, :red
define :GREEN, :green
define :BLUE, :blue
end

puts 'Running 1.000.000 normal case statements'
case_statement_time = Benchmark.realtime do
1_000_000.times do
case Color::RED
when Color::RED, Color::GREEN
'red or green'
when Color::BLUE
'blue'
end
end
end

puts 'Running 1.000.000 ruby-enum case statements'
ruby_enum_time = Benchmark.realtime do
1_000_000.times do
Color.case(Color::RED,
{
[Color::RED, Color::GREEN] => -> { 'red or green' },
Color::BLUE => -> { 'blue' }
})
end
end

puts "ruby-enum case: #{ruby_enum_time.round(4)}"
puts "case statement: #{case_statement_time.round(4)}"

puts "ruby-enum case is #{(ruby_enum_time / case_statement_time).round(2)} times slower"
1 change: 1 addition & 0 deletions lib/ruby-enum.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

require 'ruby-enum/version'
require 'ruby-enum/enum'
require 'ruby-enum/enum/case'

I18n.load_path << File.join(File.dirname(__FILE__), 'config', 'locales', 'en.yml')

Expand Down
84 changes: 84 additions & 0 deletions lib/ruby-enum/enum/case.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

module Ruby
module Enum
##
# Adds a method to an enum class that allows for exhaustive matching on a value.
#
# @example
# class Color
# include Ruby::Enum
# include Ruby::Enum::Case
#
# define :RED, :red
# define :GREEN, :green
# define :BLUE, :blue
# define :YELLOW, :yellow
# end
#
# Color.case(Color::RED, {
# [Color::RED, Color::GREEN] => -> { "red or green" },
# Color::BLUE => -> { "blue" },
# Color::YELLOW => -> { "yellow" },
# })
#
# Reserves the :else key for a default case:
# Color.case(Color::RED, {
# [Color::RED, Color::GREEN] => -> { "red or green" },
# else: -> { "blue or yellow" },
# })
module Case
def self.included(klass)
klass.extend(ClassMethods)
end

##
# @see Ruby::Enum::Case
module ClassMethods
class ValuesNotDefinedError < StandardError
end

class NotAllCasesHandledError < StandardError
end

def case(value, cases)
validate_cases(cases)

filtered_cases = cases.select do |values, _proc|
values = [values] unless values.is_a?(Array)
values.include?(value)
end

return call_proc(cases[:else], value) if filtered_cases.none?

results = filtered_cases.map { |_values, proc| call_proc(proc, value) }

# Return the first result if there is only one result
results.size == 1 ? results.first : results
end

private

def call_proc(proc, value)
return if proc.nil?

if proc.arity == 1
proc.call(value)
else
proc.call
end
end

def validate_cases(cases)
all_values = cases.keys.flatten - [:else]
else_defined = cases.key?(:else)
superfluous_values = all_values - values
missing_values = values - all_values

raise ValuesNotDefinedError, "Value(s) not defined: #{superfluous_values.join(', ')}" if superfluous_values.any?
raise NotAllCasesHandledError, "Not all cases handled: #{missing_values.join(', ')}" if missing_values.any? && !else_defined
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/ruby-enum/errors/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def compose_message(key, attributes = {})
#
# Returns a localized error message string.
def translate(key, options)
::I18n.translate("#{BASE_KEY}.#{key}", **{ locale: :en }.merge(options)).strip
::I18n.translate("#{BASE_KEY}.#{key}", locale: :en, **options).strip
end

# Create the problem.
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby-enum/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

module Ruby
module Enum
VERSION = '0.9.1'
VERSION = '1.0.0'
end
end
118 changes: 118 additions & 0 deletions spec/ruby-enum/enum/case_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Ruby::Enum::Case do
test_enum =
Class.new do
include Ruby::Enum
include Ruby::Enum::Case

define :RED, :red
define :GREEN, :green
define :BLUE, :blue
end

describe '.case' do
context 'when all cases are defined' do
subject { test_enum.case(test_enum::RED, cases) }

let(:cases) do
{
[test_enum::RED, test_enum::GREEN] => -> { 'red or green' },
test_enum::BLUE => -> { 'blue' }
}
end

it { is_expected.to eq('red or green') }

context 'when the value is nil' do
subject { test_enum.case(nil, cases) }

it { is_expected.to be_nil }
end

context 'when the value is empty' do
subject { test_enum.case('', cases) }

it { is_expected.to be_nil }
end

context 'when the value is the value of the enum' do
subject { test_enum.case(:red, cases) }

it { is_expected.to eq('red or green') }
end

context 'when the value is used inside the lambda' do
subject { test_enum.case(test_enum::RED, cases) }

let(:cases) do
{
[test_enum::RED, test_enum::GREEN] => ->(color) { "is #{color}" },
test_enum::BLUE => -> { 'blue' }
}
end

it { is_expected.to eq('is red') }
end
end

context 'when there are mutliple matches' do
subject do
test_enum.case(
test_enum::RED,
{
[test_enum::RED, test_enum::GREEN] => -> { 'red or green' },
test_enum::RED => -> { 'red' },
test_enum::BLUE => -> { 'blue' }
}
)
end

it { is_expected.to eq(['red or green', 'red']) }
end

context 'when not all cases are defined' do
it 'raises an error' do
expect do
test_enum.case(
test_enum::RED,
{ [test_enum::RED, test_enum::GREEN] => -> { 'red or green' } }
)
end.to raise_error(Ruby::Enum::Case::ClassMethods::NotAllCasesHandledError)
end
end

context 'when not all cases are defined but :else is specified (default case)' do
it 'does not raise an error' do
expect do
result = test_enum.case(
test_enum::BLUE,
{
[test_enum::RED, test_enum::GREEN] => -> { 'red or green' },
else: -> { 'blue' }
}
)

expect(result).to eq('blue')
end.not_to raise_error
end
end

context 'when a superfluous case is defined' do
it 'raises an error' do
expect do
test_enum.case(
test_enum::RED,
{
[test_enum::RED, test_enum::GREEN] => -> { 'red or green' },
test_enum::BLUE => -> { 'blue' },
:something => -> { 'green' }
}
)
end.to raise_error(Ruby::Enum::Case::ClassMethods::ValuesNotDefinedError)
end
end
end
end
2 changes: 1 addition & 1 deletion spec/ruby-enum/enum_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ class SecondSubclass < FirstSubclass

describe '#key' do
it 'returns enum instances for values' do
Colors.each do |_, enum|
Colors.each do |_, enum| # rubocop:disable Style/HashEachMethods
expect(Colors.key(enum.value)).to eq(enum.key)
end
end
Expand Down