Skip to content

Commit

Permalink
Add exhaustive case matcher
Browse files Browse the repository at this point in the history
Can be used by including `Ruby::Enum::Ecase` in an enum class. It will
add a method called `ecase` that can be used to simulate a case
statement that will raise an error if a case/enum value is not handled.
  • Loading branch information
peterfication committed Jan 5, 2024
1 parent b5740c3 commit 0d91bc2
Show file tree
Hide file tree
Showing 4 changed files with 197 additions and 0 deletions.
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,42 @@ 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::Ecase`. 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).

> NOTE: This will add checks at runtime which might lead to slightly worse performance.
> NOTE: `:else` is a reserved keyword if you want to use `Ruby::Enum::Ecase`.
```ruby
class ShippedOrderState < OrderState
include Ruby::Enum::Ecase

define :PREPARED, 'PREPARED'
define :SHIPPED, 'SHIPPED'
end
```

```ruby
shipped_order_state = ShippedOrderState::SHIPPED
ShippedOrderState.ecase(shipped_order_state, {
[ShippedOrderState::CREATED, ShippedOrderState::PAID] => -> { "order is created or paid" },
ShippedOrderState::PREPARED => -> { "order is prepared" },
ShippedOrderState::SHIPPED => -> { "order is shipped" },
})
```

It also supports default/else:

```ruby
shipped_order_state = ShippedOrderState::SHIPPED
ShippedOrderState.ecase(shipped_order_state, {
[ShippedOrderState::CREATED, ShippedOrderState::PAID] => -> { "order is created or paid" },
else: -> { "order is prepared or shipped" },
})
```

## Contributing

You're encouraged to contribute to ruby-enum. See [CONTRIBUTING](CONTRIBUTING.md) for details.
Expand Down
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/ecase'

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

Expand Down
72 changes: 72 additions & 0 deletions lib/ruby-enum/enum/ecase.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 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::Ecase
#
# define :RED, :red
# define :GREEN, :green
# define :BLUE, :blue
# end
#
# Color.ecase(Color::RED, {
# [Color::RED, Color::GREEN] => -> { puts "red or green" },
# Color::BLUE => -> { puts "blue" },
# })
#
# Reserves the :else key for a default case:
# Color.ecase(Color::RED, {
# [Color::RED, Color::GREEN] => -> { puts "red or green" },
# else: -> { puts "blue" },
# })
module Ecase
def self.included(klass)
klass.extend(ClassMethods)
end

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

class NotAllCasesHandledError < StandardError
end

def ecase(value, cases)
validate_cases(cases)

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

return cases[:else]&.call if filtered_cases.none?

results = filtered_cases.map { |_values, block| block.call }

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

private

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
88 changes: 88 additions & 0 deletions spec/ruby-enum/enum/ecase_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# frozen_string_literal: true

require 'spec_helper'

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

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

describe '.ecase' do
context 'when all cases are defined' do
subject do
test_enum.ecase(
test_enum::RED,
{
[test_enum::RED, test_enum::GREEN] => -> { 'red or green' },
test_enum::BLUE => -> { 'blue' }
}
)
end

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

context 'when there are mutliple matches' do
subject do
test_enum.ecase(
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.ecase(
test_enum::RED,
{ [test_enum::RED, test_enum::GREEN] => -> { 'red or green' } }
)
end.to raise_error(Ruby::Enum::Ecase::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.ecase(
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.ecase(
test_enum::RED,
{
[test_enum::RED, test_enum::GREEN] => -> { 'red or green' },
test_enum::BLUE => -> { 'blue' },
:something => -> { 'green' }
}
)
end.to raise_error(Ruby::Enum::Ecase::ClassMethods::ValuesNotDefinedError)
end
end
end
end

0 comments on commit 0d91bc2

Please sign in to comment.