diff --git a/.rubocop.yml b/.rubocop.yml index b55a7df..1f7eac4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -12,6 +12,12 @@ Metrics/BlockLength: Exclude: - 'spec/**/*_spec.rb' +RSpec/SpecFilePathFormat: + Enabled: false + +RSpec/FilePath: + Enabled: false + Style/HashEachMethods: Enabled: true diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 641b22e..9039cdc 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c810f1..ec2fbe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/README.md b/README.md index 1e6e66d..5e6ba08 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. diff --git a/Rakefile b/Rakefile index dd0ed12..2557ba4 100644 --- a/Rakefile +++ b/Rakefile @@ -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 diff --git a/benchmarks/case.rb b/benchmarks/case.rb new file mode 100644 index 0000000..2c51d0f --- /dev/null +++ b/benchmarks/case.rb @@ -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" diff --git a/lib/ruby-enum.rb b/lib/ruby-enum.rb index 855c8ee..0404a0a 100644 --- a/lib/ruby-enum.rb +++ b/lib/ruby-enum.rb @@ -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') diff --git a/lib/ruby-enum/enum/case.rb b/lib/ruby-enum/enum/case.rb new file mode 100644 index 0000000..79e1cab --- /dev/null +++ b/lib/ruby-enum/enum/case.rb @@ -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 diff --git a/lib/ruby-enum/errors/base.rb b/lib/ruby-enum/errors/base.rb index c1fd922..66cc604 100644 --- a/lib/ruby-enum/errors/base.rb +++ b/lib/ruby-enum/errors/base.rb @@ -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. diff --git a/lib/ruby-enum/version.rb b/lib/ruby-enum/version.rb index 9fa4713..a329355 100644 --- a/lib/ruby-enum/version.rb +++ b/lib/ruby-enum/version.rb @@ -2,6 +2,6 @@ module Ruby module Enum - VERSION = '0.9.1' + VERSION = '1.0.0' end end diff --git a/spec/ruby-enum/enum/case_spec.rb b/spec/ruby-enum/enum/case_spec.rb new file mode 100644 index 0000000..8e7c588 --- /dev/null +++ b/spec/ruby-enum/enum/case_spec.rb @@ -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 diff --git a/spec/ruby-enum/enum_spec.rb b/spec/ruby-enum/enum_spec.rb index 9ef7c9e..3cb3b30 100644 --- a/spec/ruby-enum/enum_spec.rb +++ b/spec/ruby-enum/enum_spec.rb @@ -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