From 0d91bc2672cfd73eaac466611e3744750ad2bca9 Mon Sep 17 00:00:00 2001 From: Peter Morgenstern Date: Fri, 5 Jan 2024 07:29:20 +0100 Subject: [PATCH] Add exhaustive case matcher 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. --- README.md | 36 +++++++++++++ lib/ruby-enum.rb | 1 + lib/ruby-enum/enum/ecase.rb | 72 +++++++++++++++++++++++++ spec/ruby-enum/enum/ecase_spec.rb | 88 +++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 lib/ruby-enum/enum/ecase.rb create mode 100644 spec/ruby-enum/enum/ecase_spec.rb diff --git a/README.md b/README.md index 1e6e66d..c8e515d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/ruby-enum.rb b/lib/ruby-enum.rb index 855c8ee..9e8ffe7 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/ecase' I18n.load_path << File.join(File.dirname(__FILE__), 'config', 'locales', 'en.yml') diff --git a/lib/ruby-enum/enum/ecase.rb b/lib/ruby-enum/enum/ecase.rb new file mode 100644 index 0000000..2c0cd00 --- /dev/null +++ b/lib/ruby-enum/enum/ecase.rb @@ -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 diff --git a/spec/ruby-enum/enum/ecase_spec.rb b/spec/ruby-enum/enum/ecase_spec.rb new file mode 100644 index 0000000..91e0a32 --- /dev/null +++ b/spec/ruby-enum/enum/ecase_spec.rb @@ -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