Skip to content

Commit

Permalink
Merge pull request #43 from peterfication/add-exhaustive-case-statement
Browse files Browse the repository at this point in the history
Add exhaustive case matcher
  • Loading branch information
dblock authored Jan 7, 2024
2 parents b5740c3 + 37349b4 commit c38afd8
Show file tree
Hide file tree
Showing 12 changed files with 315 additions and 12 deletions.
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

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

0 comments on commit c38afd8

Please sign in to comment.