Skip to content

Commit c38afd8

Browse files
authored
Merge pull request #43 from peterfication/add-exhaustive-case-statement
Add exhaustive case matcher
2 parents b5740c3 + 37349b4 commit c38afd8

12 files changed

Lines changed: 315 additions & 12 deletions

File tree

.rubocop.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ Metrics/BlockLength:
1212
Exclude:
1313
- 'spec/**/*_spec.rb'
1414

15+
RSpec/SpecFilePathFormat:
16+
Enabled: false
17+
18+
RSpec/FilePath:
19+
Enabled: false
20+
1521
Style/HashEachMethods:
1622
Enabled: true
1723

.rubocop_todo.yml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,6 @@ Naming/MethodParameterName:
3333
RSpec/ExampleLength:
3434
Max: 11
3535

36-
# Offense count: 2
37-
# Configuration parameters: Include, CustomTransform, IgnoreMethods, SpecSuffixOnly.
38-
# Include: **/*_spec*rb*, **/spec/**/*
39-
RSpec/FilePath:
40-
Exclude:
41-
- 'spec/ruby-enum/enum_spec.rb'
42-
- 'spec/ruby-enum/version_spec.rb'
43-
4436
# Offense count: 4
4537
RSpec/LeakyConstantDeclaration:
4638
Exclude:

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
### 0.9.1 (Next)
1+
### 1.0.0 (Next)
22

3+
* [#43](https://github.com/dblock/ruby-enum/pull/43): Add exhaustive case matcher - [@peterfication](https://github.com/peterfication).
34
* [#40](https://github.com/dblock/ruby-enum/pull/39): Enable new Rubocop cops and address/allowlist lints - [@petergoldstein](https://github.com/petergoldstein).
45
* [#39](https://github.com/dblock/ruby-enum/pull/39): Require Ruby >= 2.7 - [@petergoldstein](https://github.com/petergoldstein).
56
* [#38](https://github.com/dblock/ruby-enum/pull/38): Ensure Ruby >= 2.3 - [@ojab](https://github.com/ojab).

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Enum-like behavior for Ruby, heavily inspired by [this](http://www.rubyfleebie.c
2424
- [Mapping values to keys](#mapping-values-to-keys)
2525
- [Duplicate enumerator keys or duplicate values](#duplicate-enumerator-keys-or-duplicate-values)
2626
- [Inheritance](#inheritance)
27+
- [Exhaustive case matcher](#exhaustive-case-matcher)
28+
- [Benchmarks](#benchmarks)
2729
- [Contributing](#contributing)
2830
- [Copyright and License](#copyright-and-license)
2931
- [Related Projects](#related-projects)
@@ -259,6 +261,53 @@ OrderState.values # ['CREATED', 'PAID']
259261
ShippedOrderState.values # ['CREATED', 'PAID', 'PREPARED', SHIPPED']
260262
```
261263

264+
### Exhaustive case matcher
265+
266+
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.
267+
268+
> NOTE: This will add checks at runtime which might lead to worse performance. See [benchmarks](#benchmarks).
269+
270+
> NOTE: `:else` is a reserved keyword if you want to use `Ruby::Enum::Case`.
271+
272+
```ruby
273+
class Color < OrderState
274+
include Ruby::Enum
275+
include Ruby::Enum::Case
276+
277+
define :RED, :red
278+
define :GREEN, :green
279+
define :BLUE, :blue
280+
define :YELLOW, :yellow
281+
end
282+
```
283+
284+
```ruby
285+
color = Color::RED
286+
Color.Case(color, {
287+
[Color::GREEN, Color::BLUE] => -> { "order is green or blue" },
288+
Color::YELLOW => -> { "order is yellow" },
289+
Color::RED => -> { "order is red" },
290+
})
291+
```
292+
293+
It also supports default/else:
294+
295+
```ruby
296+
color = Color::RED
297+
Color.Case(color, {
298+
[Color::GREEN, Color::BLUE] => -> { "order is green or blue" },
299+
else: -> { "order is yellow or red" },
300+
})
301+
```
302+
303+
## Benchmarks
304+
305+
Benchmark scripts are defined in the [`benchmarks`](benchmarks) folder and can be run with Rake:
306+
307+
```console
308+
rake benchmarks:case
309+
```
310+
262311
## Contributing
263312

264313
You're encouraged to contribute to ruby-enum. See [CONTRIBUTING](CONTRIBUTING.md) for details.

Rakefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,10 @@ require 'rubocop/rake_task'
1616
RuboCop::RakeTask.new(:rubocop)
1717

1818
task default: %i[rubocop spec]
19+
20+
namespace :benchmark do
21+
desc 'Run benchmark for the Ruby::Enum::Case'
22+
task :case do
23+
require_relative 'benchmarks/case'
24+
end
25+
end

benchmarks/case.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
4+
5+
require 'benchmark'
6+
require 'ruby-enum'
7+
8+
##
9+
# Test enum
10+
class Color
11+
include Ruby::Enum
12+
include Ruby::Enum::Case
13+
14+
define :RED, :red
15+
define :GREEN, :green
16+
define :BLUE, :blue
17+
end
18+
19+
puts 'Running 1.000.000 normal case statements'
20+
case_statement_time = Benchmark.realtime do
21+
1_000_000.times do
22+
case Color::RED
23+
when Color::RED, Color::GREEN
24+
'red or green'
25+
when Color::BLUE
26+
'blue'
27+
end
28+
end
29+
end
30+
31+
puts 'Running 1.000.000 ruby-enum case statements'
32+
ruby_enum_time = Benchmark.realtime do
33+
1_000_000.times do
34+
Color.case(Color::RED,
35+
{
36+
[Color::RED, Color::GREEN] => -> { 'red or green' },
37+
Color::BLUE => -> { 'blue' }
38+
})
39+
end
40+
end
41+
42+
puts "ruby-enum case: #{ruby_enum_time.round(4)}"
43+
puts "case statement: #{case_statement_time.round(4)}"
44+
45+
puts "ruby-enum case is #{(ruby_enum_time / case_statement_time).round(2)} times slower"

lib/ruby-enum.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
require 'ruby-enum/version'
66
require 'ruby-enum/enum'
7+
require 'ruby-enum/enum/case'
78

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

lib/ruby-enum/enum/case.rb

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# frozen_string_literal: true
2+
3+
module Ruby
4+
module Enum
5+
##
6+
# Adds a method to an enum class that allows for exhaustive matching on a value.
7+
#
8+
# @example
9+
# class Color
10+
# include Ruby::Enum
11+
# include Ruby::Enum::Case
12+
#
13+
# define :RED, :red
14+
# define :GREEN, :green
15+
# define :BLUE, :blue
16+
# define :YELLOW, :yellow
17+
# end
18+
#
19+
# Color.case(Color::RED, {
20+
# [Color::RED, Color::GREEN] => -> { "red or green" },
21+
# Color::BLUE => -> { "blue" },
22+
# Color::YELLOW => -> { "yellow" },
23+
# })
24+
#
25+
# Reserves the :else key for a default case:
26+
# Color.case(Color::RED, {
27+
# [Color::RED, Color::GREEN] => -> { "red or green" },
28+
# else: -> { "blue or yellow" },
29+
# })
30+
module Case
31+
def self.included(klass)
32+
klass.extend(ClassMethods)
33+
end
34+
35+
##
36+
# @see Ruby::Enum::Case
37+
module ClassMethods
38+
class ValuesNotDefinedError < StandardError
39+
end
40+
41+
class NotAllCasesHandledError < StandardError
42+
end
43+
44+
def case(value, cases)
45+
validate_cases(cases)
46+
47+
filtered_cases = cases.select do |values, _proc|
48+
values = [values] unless values.is_a?(Array)
49+
values.include?(value)
50+
end
51+
52+
return call_proc(cases[:else], value) if filtered_cases.none?
53+
54+
results = filtered_cases.map { |_values, proc| call_proc(proc, value) }
55+
56+
# Return the first result if there is only one result
57+
results.size == 1 ? results.first : results
58+
end
59+
60+
private
61+
62+
def call_proc(proc, value)
63+
return if proc.nil?
64+
65+
if proc.arity == 1
66+
proc.call(value)
67+
else
68+
proc.call
69+
end
70+
end
71+
72+
def validate_cases(cases)
73+
all_values = cases.keys.flatten - [:else]
74+
else_defined = cases.key?(:else)
75+
superfluous_values = all_values - values
76+
missing_values = values - all_values
77+
78+
raise ValuesNotDefinedError, "Value(s) not defined: #{superfluous_values.join(', ')}" if superfluous_values.any?
79+
raise NotAllCasesHandledError, "Not all cases handled: #{missing_values.join(', ')}" if missing_values.any? && !else_defined
80+
end
81+
end
82+
end
83+
end
84+
end

lib/ruby-enum/errors/base.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def compose_message(key, attributes = {})
3939
#
4040
# Returns a localized error message string.
4141
def translate(key, options)
42-
::I18n.translate("#{BASE_KEY}.#{key}", **{ locale: :en }.merge(options)).strip
42+
::I18n.translate("#{BASE_KEY}.#{key}", locale: :en, **options).strip
4343
end
4444

4545
# Create the problem.

lib/ruby-enum/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33
module Ruby
44
module Enum
5-
VERSION = '0.9.1'
5+
VERSION = '1.0.0'
66
end
77
end

0 commit comments

Comments
 (0)