diff --git a/lib/qo/exceptions.rb b/lib/qo/exceptions.rb index 50e13d7..792f5ca 100644 --- a/lib/qo/exceptions.rb +++ b/lib/qo/exceptions.rb @@ -15,11 +15,25 @@ module Exceptions # @author baweaver # @since 0.99.1 class ExhaustiveMatchNotMet < StandardError - MESSAGE = 'Exhaustive match required - pattern does not satisfy all possible conditions' + MESSAGE = 'Exhaustive match required: pattern does not satisfy all possible conditions' def initialize super(MESSAGE) end end + + # Not all branches were definied in an exhaustive matcher + # + # @author baweaver + # @since 0.99.1 + class ExhaustiveMatchMissingBranches < StandardError + def initialize(expected_branches:, given_branches:) + super <<~MESSAGE + Exhaustive match required: pattern does not specify all branches. + Expected Branches: #{expected_branches.join(', ')} + Given Branches: #{given_branches.join(', ')} + MESSAGE + end + end end end diff --git a/lib/qo/pattern_matchers/branching.rb b/lib/qo/pattern_matchers/branching.rb index b702927..bdbaf2c 100644 --- a/lib/qo/pattern_matchers/branching.rb +++ b/lib/qo/pattern_matchers/branching.rb @@ -20,6 +20,8 @@ def self.included(base) # @author baweaver # @since 1.0.0 module ClassMethods + attr_reader :available_branches + # Registers a branch to a pattern matcher. # # This defines a method on the pattern matcher matching the `name` of @@ -29,10 +31,19 @@ module ClassMethods # When called, this will either ammend a matcher to the list of matchers # or set a default matcher if the branch happens to be a default. # + # It also adds the branch to a registry of branches for later use in + # error handling or other such potential requirements. + # # @param branch [Branch] # Branch object to register with a pattern matcher def register_branch(branch) + @available_branches ||= {} + @available_branches[branch.name] = branch + define_method(branch.name) do |*conditions, **keyword_conditions, &function| + @provided_matchers ||= [] + @provided_matchers.push(branch.name) + qo_matcher = Qo::Matchers::Matcher.new('and', conditions, keyword_conditions) branch_matcher = branch.create_matcher( diff --git a/lib/qo/pattern_matchers/pattern_match.rb b/lib/qo/pattern_matchers/pattern_match.rb index efc8ff7..ed9c274 100644 --- a/lib/qo/pattern_matchers/pattern_match.rb +++ b/lib/qo/pattern_matchers/pattern_match.rb @@ -8,6 +8,10 @@ module PatternMatchers class PatternMatch include Branching + # All matchers that have currently been added to an instance + # of a pattern match + attr_reader :provided_matchers + # The regular pattern matcher from classic Qo uses `when` and `else` # branches, like a `case` statement register_branch Qo::Branches::WhenBranch.new @@ -20,7 +24,7 @@ class PatternMatch # # @param exhaustive: false [Boolean] # If no matches are found, this will raise a - # `Qo::ExhaustiveMatchNotMet` error. + # `Qo::Errors::ExhaustiveMatchNotMet` error. # # @param &fn [Proc] # Function to be used to construct the pattern matcher's branches @@ -33,6 +37,13 @@ def initialize(destructure: false, exhaustive: false, &fn) @exhaustive = exhaustive yield(self) if block_given? + + if lacking_branches? + raise Qo::Exceptions::ExhaustiveMatchMissingBranches.new( + expected_branches: available_branch_names, + given_branches: provided_matchers + ) + end end # Allows for the creation of an anonymous PatternMatcher based on this @@ -121,13 +132,19 @@ def self.create(branches: []) # @param destructure: false [Boolean] # Whether or not to destructure values before yielding to a block # + # @param exhaustive: false [Boolean] + # If no matches are found, this will raise a + # `Qo::Errors::ExhaustiveMatchNotMet` error. + # # @param as: :match [Symbol] # Name to use as a method name bound to the including class # # @return [Module] # Module to be mixed into a class - def self.mixin(destructure: false, as: :match) - create_self = -> &function { new(destructure: destructure, &function) } + def self.mixin(destructure: false, exhaustive: false, as: :match) + create_self = -> &function { + new(destructure: destructure, exhaustive: exhaustive, &function) + } Module.new do define_method(as) do |&function| @@ -139,19 +156,56 @@ def self.mixin(destructure: false, as: :match) # Whether or not the current pattern match requires a matching branch # # @return [Boolean] - def exhaustive_match? - @exhaustive && !@default + def exhaustive? + @exhaustive + end + + # Whether or not the current pattern match is exhaustive and has a missing + # default branch + # + # @return [Boolean] + def exhaustive_no_default? + exhaustive? && !@default + end + + # Names of all of the available branch names set in `Branching` on + # registration of a branch + # + # @return [Array[String]] + def available_branch_names + self.class.available_branches.keys + end + + # Whether or not all branch types have been provided to the matcher. + # + # @return [Boolean] + def all_branches_provided? + available_branch_names == @provided_matchers.uniq + end + + # Whether the current matcher is lacking branches + # + # @return [Boolean] + def lacking_branches? + exhaustive_no_default? && !all_branches_provided? end # Calls the pattern matcher, yielding the target value to the first # matching branch it encounters. # + # In the case of an exhaustive match, this will raise an error if no + # default branch is provided. + # # @param value [Any] # Value to match against # # @return [Any] # Result of the called branch # + # @raises [Qo::Exceptions::ExhaustiveMatchNotMet] + # If the matcher is exhaustive and no default branch is provided, it is + # considered to have failed an optimistic exhaustive match. + # # @return [nil] # Returns nil if no branch is matched def call(value) @@ -160,7 +214,7 @@ def call(value) return return_value if status end - raise Qo::Exceptions::ExhaustiveMatchNotMet if exhaustive_match? + raise Qo::Exceptions::ExhaustiveMatchNotMet if exhaustive_no_default? if @default _, return_value = @default.call(value) diff --git a/spec/pattern_matchers/pattern_match_spec.rb b/spec/pattern_matchers/pattern_match_spec.rb index 40c1d1e..0a72455 100644 --- a/spec/pattern_matchers/pattern_match_spec.rb +++ b/spec/pattern_matchers/pattern_match_spec.rb @@ -1,8 +1,10 @@ require "spec_helper" RSpec.describe Qo::PatternMatchers::PatternMatch do + let(:exhaustive) { false } + let(:pattern_match) do - Qo::PatternMatchers::PatternMatch.new { |m| + Qo::PatternMatchers::PatternMatch.new(exhaustive: exhaustive) { |m| m.when(1) { |v| v + 4 } m.when(2) { |v| v * 2 } m.else { |v| v } @@ -41,6 +43,57 @@ end end + describe '#exhaustive?' do + it 'checks if a match is exhaustive' do + expect(pattern_match.exhaustive?).to eq(false) + end + + context 'When a match is set as exhaustive' do + let(:exhaustive) { true } + + it 'will be an exhaustive match' do + expect(pattern_match.exhaustive?).to eq(true) + end + end + end + + describe '#exhaustive_no_default?' do + it 'checks if a match is exhaustive and lacks a default branch' do + expect(pattern_match.exhaustive_no_default?).to eq(false) + end + + context 'When an exhaustive match is specified' do + let(:exhaustive) { true } + + it 'will be false if there is a default' do + expect(pattern_match.exhaustive_no_default?).to eq(false) + end + + context 'When there is no default' do + let(:pattern_match) do + Qo::PatternMatchers::PatternMatch.new(exhaustive: exhaustive) { |m| + m.when(1) { |v| v + 4 } + m.when(2) { |v| v * 2 } + } + end + + it 'will raise a pessimistic error' do + error_message = <<~ERROR + Exhaustive match required: pattern does not specify all branches. + Expected Branches: when, else + Given Branches: when, when + ERROR + + expect { + pattern_match.exhaustive_no_default? + }.to raise_error( + Qo::Exceptions::ExhaustiveMatchMissingBranches, error_message + ) + end + end + end + end + # These are entirely for people to get ideas from. It uses the Public API # shorthand to be less formal than the above specs. # @@ -114,5 +167,50 @@ expect(pattern_match.call(Person.new('Foo', 42)).age).to eq(43) end end + + context 'When working with exhaustive matches' do + let(:pattern_match) { + Qo::PatternMatchers::PatternMatch.new(exhaustive: true) { |m| + m.when(name: /^F/) { |person| person.age + 1 } + } + } + + it 'will raise an exception if not all branches are provided' do + expected_error = <<~ERROR + Exhaustive match required: pattern does not specify all branches. + Expected Branches: when, else + Given Branches: when + ERROR + + expect { + pattern_match.call(Person.new('Foo', 42)).age + }.to raise_error(Qo::Exceptions::ExhaustiveMatchMissingBranches, expected_error) + end + + context 'When all branches are provided' do + let(:pattern_match) { + Qo::PatternMatchers::PatternMatch.new(exhaustive: true) { |m| + m.when(name: /^F/) { |person| person.age + 1 } + m.else { 7 } + } + } + + it 'will proceed as normal' do + expect(pattern_match.call(Person.new('Foo', 42))).to eq(43) + end + end + + context 'When given a default branch' do + let(:pattern_match) { + Qo::PatternMatchers::PatternMatch.new(exhaustive: true) { |m| + m.else { |person| person.age + 1 } + } + } + + it 'will ignore the strict requirement for all branches, as default satisfies exhaustive' do + expect(pattern_match.call(Person.new('Foo', 42))).to eq(43) + end + end + end end end