From 60362f1b2e8cb3ccbc1bf80dfb67faf20b1d2c51 Mon Sep 17 00:00:00 2001 From: Brandon Weaver Date: Mon, 18 Feb 2019 02:15:10 -0800 Subject: [PATCH 1/2] Add exhaustive match --- lib/qo/exceptions.rb | 13 +++++++++++++ lib/qo/pattern_matchers/pattern_match.rb | 16 +++++++++++++++- lib/qo/pattern_matchers/result_pattern_match.rb | 10 +++++++--- lib/qo/version.rb | 2 +- .../result_pattern_match_spec.rb | 14 +++++++++++++- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/lib/qo/exceptions.rb b/lib/qo/exceptions.rb index 6529b2d..50e13d7 100644 --- a/lib/qo/exceptions.rb +++ b/lib/qo/exceptions.rb @@ -8,5 +8,18 @@ module Qo # @since 0.2.0 # module Exceptions + # Error for not all possible cases being handled. This is optimistic as currently + # it will only catch when an actual failure to handle a case is present. This + # should be patched in later versions + # + # @author baweaver + # @since 0.99.1 + class ExhaustiveMatchNotMet < StandardError + MESSAGE = 'Exhaustive match required - pattern does not satisfy all possible conditions' + + def initialize + super(MESSAGE) + end + end end end diff --git a/lib/qo/pattern_matchers/pattern_match.rb b/lib/qo/pattern_matchers/pattern_match.rb index 7054fb0..efc8ff7 100644 --- a/lib/qo/pattern_matchers/pattern_match.rb +++ b/lib/qo/pattern_matchers/pattern_match.rb @@ -18,14 +18,19 @@ class PatternMatch # @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::ExhaustiveMatchNotMet` error. + # # @param &fn [Proc] # Function to be used to construct the pattern matcher's branches # # @return [Qo::PatternMatchers::PatternMatch] - def initialize(destructure: false, &fn) + def initialize(destructure: false, exhaustive: false, &fn) @matchers = [] @default = nil @destructure = destructure + @exhaustive = exhaustive yield(self) if block_given? end @@ -131,6 +136,13 @@ def self.mixin(destructure: false, as: :match) end end + # Whether or not the current pattern match requires a matching branch + # + # @return [Boolean] + def exhaustive_match? + @exhaustive && !@default + end + # Calls the pattern matcher, yielding the target value to the first # matching branch it encounters. # @@ -148,6 +160,8 @@ def call(value) return return_value if status end + raise Qo::Exceptions::ExhaustiveMatchNotMet if exhaustive_match? + if @default _, return_value = @default.call(value) return_value diff --git a/lib/qo/pattern_matchers/result_pattern_match.rb b/lib/qo/pattern_matchers/result_pattern_match.rb index 2f0b328..3d0168c 100644 --- a/lib/qo/pattern_matchers/result_pattern_match.rb +++ b/lib/qo/pattern_matchers/result_pattern_match.rb @@ -36,9 +36,13 @@ class ResultPatternMatch < PatternMatch # Whether or not to destructure the value before yielding to # the first matched block # - # @return [type] [description] - def initialize(destructure: false) - super(destructure: destructure) + # @param exhaustive: false [Boolean] + # If no matches are found, this will raise a + # `Qo::ExhaustiveMatchNotMet` error. + # + # @return [Qo::PatternMatch::ResultPatternMatch] + def initialize(destructure: false, exhaustive: false) + super(destructure: destructure, exhaustive: exhaustive) end end end diff --git a/lib/qo/version.rb b/lib/qo/version.rb index e92e895..7e3f0d0 100644 --- a/lib/qo/version.rb +++ b/lib/qo/version.rb @@ -1,3 +1,3 @@ module Qo - VERSION = '0.99.0' + VERSION = '0.99.1' end diff --git a/spec/pattern_matchers/result_pattern_match_spec.rb b/spec/pattern_matchers/result_pattern_match_spec.rb index b494117..cc384bf 100644 --- a/spec/pattern_matchers/result_pattern_match_spec.rb +++ b/spec/pattern_matchers/result_pattern_match_spec.rb @@ -1,8 +1,10 @@ require "spec_helper" RSpec.describe Qo::PatternMatchers::ResultPatternMatch do + let(:exhaustive) { false } + let(:pattern_match) do - Qo::PatternMatchers::ResultPatternMatch.new { |m| + Qo::PatternMatchers::ResultPatternMatch.new(exhaustive: exhaustive) { |m| m.success(Integer) { |v| v + 1 } m.success(String) { |v| "OHAI #{v}!" } m.success { |v| 42 } @@ -47,6 +49,16 @@ it 'will return nil' do expect(pattern_match.call([:wat?, :wat?])).to eq(nil) end + + context 'When the match is required to be exhaustive' do + let(:exhaustive) { true } + + it 'will raise an error' do + expect { + pattern_match.call([:wat?, :wat?]) + }.to raise_error(Qo::Exceptions::ExhaustiveMatchNotMet) + end + end end end end From e8d7d017001c543907fed3f2cd7d39c8d7875f79 Mon Sep 17 00:00:00 2001 From: Brandon Weaver Date: Mon, 18 Feb 2019 15:11:57 -0800 Subject: [PATCH 2/2] Add error messages and optimistic / pessimistic failures --- lib/qo/exceptions.rb | 16 +++- lib/qo/pattern_matchers/branching.rb | 11 +++ lib/qo/pattern_matchers/pattern_match.rb | 66 +++++++++++-- spec/pattern_matchers/pattern_match_spec.rb | 100 +++++++++++++++++++- 4 files changed, 185 insertions(+), 8 deletions(-) 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