├── .gitignore ├── spec ├── spec_helper.rb └── matchete_spec.rb ├── lib ├── matchete │ └── exceptions.rb └── matchete.rb ├── .travis.yml ├── Gemfile ├── Rakefile ├── matchete.gemspec ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "codeclimate-test-reporter" 2 | CodeClimate::TestReporter.start 3 | -------------------------------------------------------------------------------- /lib/matchete/exceptions.rb: -------------------------------------------------------------------------------- 1 | module Matchete 2 | class NotResolvedError < StandardError 3 | end 4 | end 5 | 6 | 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.2.1 4 | script: 5 | - bundle exec rake 6 | addons: 7 | code_climate: 8 | repo_token: wxJo5SHv2RawNqcV2JjT 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | group :development, :test do 4 | gem 'rspec' 5 | gem 'bundler' 6 | gem 'jeweler' 7 | end 8 | 9 | gem "codeclimate-test-reporter", group: :test, require: nil 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | begin 6 | Bundler.setup(:default, :development) 7 | rescue Bundler::BundlerError => e 8 | $stderr.puts e.message 9 | $stderr.puts "Run `bundle install` to install missing gems" 10 | exit e.status_code 11 | end 12 | require 'rake' 13 | 14 | require 'rspec/core' 15 | require 'rspec/core/rake_task' 16 | RSpec::Core::RakeTask.new(:spec) do |spec| 17 | spec.pattern = FileList['spec/**/*_spec.rb'] 18 | end 19 | 20 | task :default => :spec 21 | -------------------------------------------------------------------------------- /matchete.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'matchete' 6 | s.version = '0.5.1' 7 | s.platform = Gem::Platform::RUBY 8 | s.authors = ["Alexander Ivanov"] 9 | s.email = ["alehander42@gmail.com"] 10 | s.homepage = 'https://github.com/alehander42/matchete' 11 | s.summary = %q{Method overloading for Ruby based on pattern matching} 12 | s.description = %q{A DSL for method overloading for Ruby based on pattern matching} 13 | 14 | s.add_development_dependency 'rspec', '~> 0' 15 | 16 | s.license = 'MIT' 17 | s.files = `git ls-files`.split("\n") 18 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 19 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 20 | s.require_paths = ["lib"] 21 | end 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Alexander Ivanov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/matchete.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | require_relative 'matchete/exceptions' 3 | 4 | module Matchete 5 | def self.included(klass) 6 | klass.extend ClassMethods 7 | klass.instance_variable_set "@methods", {} 8 | klass.instance_variable_set "@default_methods", {} 9 | end 10 | 11 | Any = -> (x) { true } 12 | None = -> (x) { false } 13 | 14 | module ClassMethods 15 | def on(*args, **kwargs) 16 | if kwargs.count.zero? 17 | *guard_args, method_name = args 18 | guard_kwargs = {} 19 | else 20 | method_name = kwargs[:method] 21 | kwargs.delete :method 22 | guard_args = args 23 | guard_kwargs = kwargs 24 | end 25 | @methods[method_name] ||= [] 26 | @methods[method_name] << [guard_args, guard_kwargs, instance_method(method_name)] 27 | convert_to_matcher method_name 28 | end 29 | 30 | def default(method_name) 31 | @default_methods[method_name] = instance_method(method_name) 32 | convert_to_matcher method_name 33 | end 34 | 35 | # Matches something like sum types: 36 | # either(Integer, Array) 37 | # matches both [2] and 2 38 | def either(*guards) 39 | -> arg { guards.any? { |g| match_guard(g, arg) } } 40 | end 41 | 42 | # Matches an exact value 43 | # useful if you want to match a string starting with '#' or the value of a class 44 | # exact(Integer) matches Integer, not 2 45 | def exact(value) 46 | -> arg { arg == value } 47 | end 48 | 49 | # Matches property results 50 | # e.g. having('#to_s': '[]') will match [] 51 | def having(**properties) 52 | -> arg do 53 | properties.all? { |prop, result| arg.respond_to?(prop[1..-1]) && arg.send(prop[1..-1]) == result } 54 | end 55 | end 56 | 57 | # Matches each guard 58 | # full_match(Integer, '#value') 59 | # matches only instances of Integer which respond to '#value' 60 | def full_match(*guards) 61 | -> arg { guards.all? { |g| match_guard(g, arg) } } 62 | end 63 | 64 | def supporting(*method_names) 65 | -> object do 66 | method_names.all? do |method_name| 67 | object.respond_to? method_name 68 | end 69 | end 70 | end 71 | 72 | def convert_to_matcher(method_name) 73 | define_method(method_name) do |*args, **kwargs| 74 | call_overloaded(method_name, args: args, kwargs: kwargs) 75 | end 76 | end 77 | end 78 | 79 | def call_overloaded(method_name, args: [], kwargs: {}) 80 | handler = find_handler(method_name, args, kwargs) 81 | 82 | if kwargs.empty? 83 | handler.bind(self).call *args 84 | else 85 | handler.bind(self).call *args, **kwargs 86 | end 87 | #insane workaround, because if you have 88 | #def z(f);end 89 | #and you call it like that 90 | #empty = {} 91 | #z(2, **empty) 92 | #it raises wrong number of arguments (2 for 1) 93 | #clean up later 94 | end 95 | 96 | def find_handler(method_name, args, kwargs) 97 | guards = self.class.instance_variable_get('@methods')[method_name].find do |guard_args, guard_kwargs, _| 98 | match_guards guard_args, guard_kwargs, args, kwargs 99 | end 100 | 101 | if guards.nil? 102 | default_method = self.class.instance_variable_get('@default_methods')[method_name] 103 | if default_method 104 | default_method 105 | else 106 | raise NotResolvedError.new("No matching #{method_name} method for args #{args}") 107 | end 108 | else 109 | guards.last 110 | end 111 | end 112 | 113 | def match_guards(guard_args, guard_kwargs, args, kwargs) 114 | return false if guard_args.count != args.count || 115 | guard_kwargs.count != kwargs.count 116 | guard_args.zip(args).all? do |guard, arg| 117 | match_guard guard, arg 118 | end and 119 | guard_kwargs.all? do |label, guard| 120 | match_guard guard, kwargs[label] 121 | end 122 | end 123 | 124 | def match_guard(guard, arg) 125 | case guard 126 | when Module 127 | arg.is_a? guard 128 | when Symbol 129 | if guard.to_s[-1] == '?' 130 | send guard, arg 131 | else 132 | guard == arg 133 | end 134 | when Proc 135 | instance_exec arg, &guard 136 | when Regexp 137 | arg.is_a? String and guard.match arg 138 | when Array 139 | arg.is_a?(Array) and 140 | guard.zip(arg).all? { |child_guard, child| match_guard child_guard, child } 141 | else 142 | if guard.is_a?(String) && guard[0] == '#' 143 | arg.respond_to? guard[1..-1] 144 | else 145 | guard == arg 146 | end 147 | end 148 | end 149 | end 150 | 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Matchete 2 | ========= 3 | 4 | Matchete provides a DSL for method overloading based on pattern matching for Ruby. 5 | 6 | [![Build Status](https://travis-ci.org/alehander42/matchete.svg)](https://travis-ci.org/alehander42/matchete) 7 | 8 | It's just a quick hack inspired by weissbier and the use-return-values-of-method-definitions DSL technique used in [harmonic](https://github.com/s2gatev/harmonic) 9 | 10 | **It supports only ruby 2.1+** 11 | 12 | Features 13 | -------- 14 | 15 | * `on [:value, Integer]` matches an arg with the same internal structure 16 | * `on '#method_name'` matches args responding to `method_name` 17 | * `on AClass` matches instances of `AClass` 18 | * `on a: 2, method:...` matches keyword args 19 | * `on :test?` matches with user-defined predicate methods 20 | * `on either('#count', Array)` matches if any of the tests returns true for an arg 21 | * `on full_match('#count', '#combine')` matches if all of the tests return true for an arg 22 | * `on exact(Integer)` matches special values, used as shortcuts in other cases: 23 | classes, strings starting with '#', etc 24 | * `on having('#count' => 2)` matches objects with properties with certain values 25 | * `default` matches when no match has been found in `on` branches 26 | 27 | 28 | 29 | Install 30 | ----- 31 | `gem install matchete` 32 | 33 | 34 | Usage 35 | ----- 36 | 37 | ```ruby 38 | class Translator 39 | include Matchete 40 | 41 | on Any, :string, 42 | def translate(value, to) 43 | value.to_s 44 | end 45 | 46 | on '#-@', :negative, 47 | def translate(value, to) 48 | - value 49 | end 50 | 51 | on String, :integer, 52 | def translate(value, to) 53 | value.to_i 54 | end 55 | 56 | default def translate(value, to) 57 | 0 58 | end 59 | end 60 | 61 | t = Translator.new 62 | p t.translate 72, :negative # -72 63 | p t.translate nil, :integer # 0 64 | ``` 65 | 66 | ```ruby 67 | require 'matchete' 68 | 69 | class FactorialStrikesAgain 70 | include Matchete 71 | 72 | on 1, 73 | def factorial(value) 74 | 1 75 | end 76 | 77 | on -> x { x > 1 }, 78 | def factorial(value) 79 | value * factorial(value - 1) 80 | end 81 | end 82 | 83 | FactorialStrikesAgain.new.factorial(4) #24 84 | FactorialStrikesAgain.new.factorial(-2) #Matchete::NotResolvedError No matching factorial method for args [-2] 85 | ``` 86 | 87 | ```ruby 88 | class Converter 89 | include Matchete 90 | 91 | on '#special_convert', 92 | def convert(value) 93 | value.special_convert 94 | end 95 | 96 | on Integer, 97 | def convert(value) 98 | [:integer, value] 99 | end 100 | 101 | on Hash, 102 | def convert(values) 103 | [:dict, values.map { |k, v| [convert(k), convert(v)] }] 104 | end 105 | 106 | on /reserved_/, 107 | def convert(value) 108 | [:reserved_symbol, value] 109 | end 110 | 111 | on String, 112 | def convert(value) 113 | [:string, value] 114 | end 115 | 116 | on ['deleted', [Integer, Any]], 117 | def convert(value) 118 | ['deleted', value[1]] 119 | end 120 | 121 | on :starts_with_cat?, 122 | def convert(value) 123 | [:fail, value] 124 | end 125 | 126 | on free: Integer, method: 127 | def convert(free:) 128 | [:rofl, free] 129 | end 130 | 131 | on either('#count', Array), 132 | def convert(value) 133 | value.count 134 | end 135 | 136 | on full_match('#count', '#lala'), 137 | def convert(value) 138 | value.count + value.lala 139 | end 140 | 141 | default def convert(value) 142 | [:z, value] 143 | end 144 | 145 | def starts_with_cat?(value) 146 | value.to_s.start_with?('cat') 147 | end 148 | end 149 | 150 | class Z 151 | def special_convert 152 | [:special_convert, nil] 153 | end 154 | end 155 | 156 | converter = Converter.new 157 | p Converter.instance_methods 158 | p converter.convert(2) #[:integer, 2] 159 | p converter.convert(Z.new) #[:special_convert, nil] 160 | p converter.convert([4, 4]) # 2 161 | p converter.convert({2 => 4}) #[:dict, [[[:integer, 2], [:integer, 4]]] 162 | p converter.convert('reserved_l') #[:reserved_symbol, 'reserved_l'] 163 | p converter.convert('zaza') #[:string, 'zaza'] 164 | p converter.convert(['deleted', [2, Array]]) #['deleted', [2, Array]] 165 | p converter.convert(:cat_hehe) #[:fail, :cat_hehe] 166 | p converter.convert(free: 2) #[:rofl, 2] 167 | p converter.convert(2.2) #[:z, 2.2] 168 | 169 | ``` 170 | 171 | version 172 | ------- 173 | 0.5.1 174 | 175 | 176 | cbb 177 | ----- 178 | ![](https://global3.memecdn.com/kawaii-danny-trejo_o_2031011.jpg) 179 | 180 | Todo 181 | ----- 182 | * Clean up the specs, right now they're a mess. 183 | * Fix all kinds of edge cases 184 | 185 | 186 | Copyright 187 | ----- 188 | 189 | Copyright (c) 2015 Alexander Ivanov. See LICENSE for further details. 190 | -------------------------------------------------------------------------------- /spec/matchete_spec.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 2 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 3 | require 'rspec' 4 | require 'matchete' 5 | 6 | describe Matchete do 7 | it 'can use a pattern based on `either` helpers' do 8 | class A 9 | include Matchete 10 | 11 | on either(FalseClass, Proc), FalseClass, 12 | def work(g, h) 13 | g 14 | end 15 | 16 | on FalseClass, NilClass, 17 | def work(g, h) 18 | :love 19 | end 20 | end 21 | 22 | expect(A.new.work(false, false)).to eq false 23 | expect(A.new.work(false, nil)).to eq :love 24 | end 25 | 26 | it 'can be used to overload a method in a class' do 27 | class A 28 | include Matchete 29 | 30 | on Integer, 31 | def play(value) 32 | :integer 33 | end 34 | 35 | on Float, Any, 36 | def play(value, object) 37 | :float 38 | end 39 | end 40 | 41 | a = A.new 42 | expect(a.play(2)).to eq :integer 43 | expect(a.play(2.2, 4)).to eq :float 44 | end 45 | 46 | it 'can use a pattern based on classes and modules' do 47 | class A 48 | include Matchete 49 | 50 | on Integer, 51 | def play(value) 52 | :integer 53 | end 54 | 55 | on Float, 56 | def play(value) 57 | :float 58 | end 59 | 60 | on Enumerable, 61 | def play(value) 62 | :enumerable 63 | end 64 | end 65 | 66 | a = A.new 67 | expect(a.play(2)).to eq :integer 68 | expect(a.play(2.2)).to eq :float 69 | expect(a.play([2])).to eq :enumerable 70 | end 71 | 72 | it 'can use a pattern based on nested arrays with classes/modules' do 73 | class A 74 | include Matchete 75 | 76 | on [Integer, Float], 77 | def play(values) 78 | [:integer, :float] 79 | end 80 | 81 | on [[Integer], Any], 82 | def play(values) 83 | :s 84 | end 85 | end 86 | 87 | a = A.new 88 | expect(a.play([2, 2.2])).to eq [:integer, :float] 89 | expect(a.play([[2], Matchete])).to eq :s 90 | end 91 | 92 | it 'can use a pattern based on exact values' do 93 | class A 94 | include Matchete 95 | 96 | on 2, Integer, 97 | def play(value, obj) 98 | 2 99 | end 100 | 101 | on 4, Integer, 102 | def play(value, obj) 103 | 4 104 | end 105 | end 106 | 107 | a = A.new 108 | expect(a.play(2, 4)).to eq 2 109 | expect(a.play(4, 4)).to eq 4 110 | expect { a.play(8, 2) }.to raise_error(Matchete::NotResolvedError) 111 | end 112 | 113 | it 'can use a pattern based on regexes' do 114 | class A 115 | include Matchete 116 | 117 | on /z/, 118 | def play(value) 119 | 'z' 120 | end 121 | 122 | on /y/, 123 | def play(value) 124 | 'y' 125 | end 126 | end 127 | 128 | a = A.new 129 | expect(a.play('zewr')).to eq 'z' 130 | expect(a.play('yy')).to eq 'y' 131 | end 132 | 133 | it 'can use a default method when everything else fails' do 134 | class A 135 | include Matchete 136 | 137 | on Integer, 138 | def play(value) 139 | :integer 140 | end 141 | end 142 | 143 | expect { A.new.play(2.2) }.to raise_error(Matchete::NotResolvedError) 144 | 145 | class A 146 | default def play(value) 147 | :else 148 | end 149 | end 150 | 151 | expect(A.new.play(2.2)).to eq :else 152 | end 153 | 154 | it 'can use a pattern based on `exact` helpers' do 155 | class A 156 | include Matchete 157 | 158 | on Integer, 159 | def lala(a) 160 | a - 2 161 | end 162 | 163 | on exact(Integer), 164 | def lala(a) 165 | a 166 | end 167 | end 168 | 169 | expect(A.new.lala(4)).to eq 2 170 | expect(A.new.lala(Integer)).to eq Integer 171 | end 172 | 173 | it 'can use a pattern based on existing predicate methods given as symbols' do 174 | class A 175 | include Matchete 176 | 177 | on :even?, 178 | def play(value) 179 | value 180 | end 181 | 182 | def even?(value) 183 | value.remainder(2).zero? #so gay and gay 184 | end 185 | end 186 | 187 | expect(A.new.play(2)).to eq 2 188 | expect { A.new.play(5) }.to raise_error(Matchete::NotResolvedError) 189 | end 190 | 191 | it 'can use a pattern based on a lambda predicate' do 192 | class A 193 | include Matchete 194 | 195 | on -> x { x % 2 == 0 }, 196 | def play(value) 197 | value 198 | end 199 | end 200 | 201 | expect(A.new.play(2)).to eq 2 202 | expect { A.new.play(7) }.to raise_error(Matchete::NotResolvedError) 203 | end 204 | 205 | it 'can use a pattern based on responding to methods' do 206 | class A 207 | include Matchete 208 | 209 | on supporting(:map), 210 | def play(value) 211 | value 212 | end 213 | end 214 | 215 | expect(A.new.play([])).to eq [] 216 | expect { A.new.play(4) }.to raise_error(Matchete::NotResolvedError) 217 | end 218 | 219 | it 'can match on different keyword arguments' do 220 | class A 221 | include Matchete 222 | 223 | on e: Integer, f: String, method: 224 | def play(e:, f:) 225 | :y 226 | end 227 | end 228 | 229 | expect(A.new.play(e: 0, f: "y")).to eq :y 230 | expect { A.new.play(e: "f", f: Class)}.to raise_error(Matchete::NotResolvedError) 231 | end 232 | 233 | it 'can match on multiple different kinds of patterns' do 234 | class A 235 | include Matchete 236 | end 237 | 238 | expect(A.new.match_guards([Integer, Float], {}, [8, 8.8], {})).to be_truthy 239 | end 240 | 241 | describe '#match_guard' do 242 | before :all do 243 | class A 244 | include Matchete 245 | 246 | def even?(value) 247 | value.remainder(2).zero? 248 | end 249 | end 250 | @a = A.new 251 | end 252 | 253 | it 'matches modules and classes' do 254 | expect(@a.match_guard(Integer, 2)).to be_truthy 255 | expect(@a.match_guard(Class, 4)).to be_falsey 256 | end 257 | 258 | it 'matches methods given as symbols' do 259 | expect(@a.match_guard(:even?, 2)).to be_truthy 260 | expect { @a.match_guard(:odd?, 4) }.to raise_error 261 | end 262 | 263 | it 'matches predicates given as lambdas' do 264 | expect(@a.match_guard(-> x { x == {} }, {})).to be_truthy 265 | end 266 | 267 | it 'matches on regex' do 268 | expect(@a.match_guard(/a/, 'aw')).to be_truthy 269 | expect(@a.match_guard(/z/, 'lol')).to be_falsey 270 | end 271 | 272 | it 'matches on nested arrays' do 273 | expect(@a.match_guard([Integer, [:even?]], [2, [4]])).to be_truthy 274 | expect(@a.match_guard([Float, [:even?]], [2.2, [7]])).to be_falsey 275 | end 276 | 277 | it 'matches on exact values' do 278 | expect(@a.match_guard(2, 2)).to be_truthy 279 | expect(@a.match_guard('d', 'f')).to be_falsey 280 | end 281 | end 282 | end 283 | 284 | --------------------------------------------------------------------------------