sums_up 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +3 -0
- data/.rubocop.yml +19 -0
- data/.ruby-version +1 -0
- data/.travis.yml +10 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +815 -0
- data/Rakefile +11 -0
- data/bin/console +8 -0
- data/bin/setup +6 -0
- data/lib/sums_up.rb +31 -0
- data/lib/sums_up/CHANGELOG.md +5 -0
- data/lib/sums_up/core.rb +32 -0
- data/lib/sums_up/core/functions.rb +14 -0
- data/lib/sums_up/core/matcher.rb +133 -0
- data/lib/sums_up/core/parser.rb +68 -0
- data/lib/sums_up/core/strings.rb +17 -0
- data/lib/sums_up/core/sum_type.rb +65 -0
- data/lib/sums_up/core/variant.rb +137 -0
- data/lib/sums_up/maybe.rb +40 -0
- data/lib/sums_up/result.rb +36 -0
- data/lib/sums_up/version.rb +5 -0
- data/sums_up.gemspec +34 -0
- metadata +128 -0
data/Rakefile
ADDED
data/bin/console
ADDED
data/bin/setup
ADDED
data/lib/sums_up.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
|
5
|
+
require 'sums_up/core'
|
6
|
+
require 'sums_up/version'
|
7
|
+
|
8
|
+
# UI-level functions for the gem.
|
9
|
+
module SumsUp
|
10
|
+
Error = Class.new(StandardError)
|
11
|
+
|
12
|
+
MatchError = Class.new(Error)
|
13
|
+
UnmatchedVariantError = Class.new(MatchError)
|
14
|
+
MatchAfterWildcardError = Class.new(MatchError)
|
15
|
+
DuplicateMatchError = Class.new(MatchError)
|
16
|
+
UnknownVariantError = Class.new(MatchError)
|
17
|
+
|
18
|
+
ParserError = Class.new(Error)
|
19
|
+
VariantNameError = Class.new(ParserError)
|
20
|
+
VariantArgsError = Class.new(ParserError)
|
21
|
+
DuplicateNameError = Class.new(ParserError)
|
22
|
+
|
23
|
+
class << self
|
24
|
+
extend Forwardable
|
25
|
+
|
26
|
+
def_delegators(Core, :define)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
require 'sums_up/maybe'
|
31
|
+
require 'sums_up/result'
|
data/lib/sums_up/core.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sums_up/core/functions'
|
4
|
+
require 'sums_up/core/matcher'
|
5
|
+
require 'sums_up/core/parser'
|
6
|
+
require 'sums_up/core/strings'
|
7
|
+
require 'sums_up/core/sum_type'
|
8
|
+
require 'sums_up/core/variant'
|
9
|
+
|
10
|
+
module SumsUp
|
11
|
+
# Core functionality which builds modules for sum types and classes for
|
12
|
+
# variants.
|
13
|
+
module Core
|
14
|
+
module_function
|
15
|
+
|
16
|
+
def define(*no_arg_variants, **arg_variants, &block)
|
17
|
+
variant_specs = Parser.parse_variant_specs!(no_arg_variants, arg_variants)
|
18
|
+
variant_names = variant_specs.keys
|
19
|
+
|
20
|
+
variant_classes = variant_specs.map do |name, members|
|
21
|
+
others = variant_names - [name]
|
22
|
+
matcher_class = Matcher.build_matcher_class(name, others)
|
23
|
+
|
24
|
+
Variant.build_variant_class(name, others, members, matcher_class)
|
25
|
+
end
|
26
|
+
|
27
|
+
SumType
|
28
|
+
.build(variant_classes)
|
29
|
+
.tap { |sum_type| sum_type.module_eval(&block) if block }
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SumsUp
|
4
|
+
module Core
|
5
|
+
# Matching DSL for sum type variants. Methods in this class are prefixed
|
6
|
+
# with an _ so as not to conflict with the names of user-defined variant
|
7
|
+
# names. Use .build_matcher_class to generate a new subclass for a variant
|
8
|
+
# given the other variant names.
|
9
|
+
class Matcher
|
10
|
+
def self.build_matcher_class(variant, other_variants)
|
11
|
+
Class.new(self) do
|
12
|
+
const_set(:VARIANT, variant)
|
13
|
+
const_set(:ALL_VARIANTS, [variant, *other_variants].freeze)
|
14
|
+
const_set(:IncorrectMatcher, incorrect_matcher_module(other_variants))
|
15
|
+
|
16
|
+
include(const_get(:IncorrectMatcher))
|
17
|
+
|
18
|
+
alias_method(variant, :_correct_variant_matcher)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.incorrect_matcher_module(variants)
|
23
|
+
Module.new do
|
24
|
+
variants.each do |variant|
|
25
|
+
define_method(variant) do |_value = nil|
|
26
|
+
_ensure_wildcard_not_matched!(variant)
|
27
|
+
_ensure_no_duplicate_match!(variant)
|
28
|
+
|
29
|
+
@matched_variants << variant
|
30
|
+
|
31
|
+
self
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def initialize(variant_instance)
|
38
|
+
@variant_instance = variant_instance
|
39
|
+
|
40
|
+
@matched = false
|
41
|
+
@matched_variants = []
|
42
|
+
@wildcard_matched = false
|
43
|
+
@result = nil
|
44
|
+
end
|
45
|
+
|
46
|
+
def _(value = nil)
|
47
|
+
_ensure_wildcard_not_matched!(:_)
|
48
|
+
|
49
|
+
@wildcard_matched = true
|
50
|
+
|
51
|
+
return self if @matched
|
52
|
+
|
53
|
+
@result = block_given? ? yield(@variant_instance) : value
|
54
|
+
|
55
|
+
self
|
56
|
+
end
|
57
|
+
|
58
|
+
def _match_hash(hash)
|
59
|
+
variants = self.class::ALL_VARIANTS
|
60
|
+
unknown_variants = hash
|
61
|
+
.each_key
|
62
|
+
.reject { |key| (key == :_) || variants.include?(key) }
|
63
|
+
|
64
|
+
if unknown_variants.any?
|
65
|
+
raise(
|
66
|
+
UnknownVariantError,
|
67
|
+
"Unknown variant(s): #{unknown_variants.join(', ')}; " \
|
68
|
+
"valid variant(s) are: #{variants.join(', ')}"
|
69
|
+
)
|
70
|
+
end
|
71
|
+
|
72
|
+
hash.each do |variant, value|
|
73
|
+
public_send(variant, value)
|
74
|
+
end
|
75
|
+
|
76
|
+
self
|
77
|
+
end
|
78
|
+
|
79
|
+
def _fetch_result
|
80
|
+
variants = self.class::ALL_VARIANTS
|
81
|
+
|
82
|
+
return @result if @wildcard_matched
|
83
|
+
return @result if @matched_variants.length == variants.length
|
84
|
+
|
85
|
+
unmatched_variants = (variants - @matched_variants).join(', ')
|
86
|
+
|
87
|
+
raise(
|
88
|
+
UnmatchedVariantError,
|
89
|
+
"Did not match the following variants: #{unmatched_variants}"
|
90
|
+
)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Defining #_correct_variant_matcher "statically" allows us to use yield
|
94
|
+
# instead of block.call, which is much faster in most ruby versions.
|
95
|
+
# https://github.com/JuanitoFatas/fast-ruby/blob/256fa4916b577befb40ba5ffaa22af08dc16565c/README.md#proc--block
|
96
|
+
def _correct_variant_matcher(value = nil)
|
97
|
+
variant = self.class::VARIANT
|
98
|
+
|
99
|
+
_ensure_wildcard_not_matched!(variant)
|
100
|
+
_ensure_no_duplicate_match!(variant)
|
101
|
+
|
102
|
+
@matched_variants << variant
|
103
|
+
@matched = true
|
104
|
+
|
105
|
+
@result =
|
106
|
+
if block_given?
|
107
|
+
yield(*@variant_instance.members(dup: false))
|
108
|
+
else
|
109
|
+
value
|
110
|
+
end
|
111
|
+
|
112
|
+
self
|
113
|
+
end
|
114
|
+
|
115
|
+
private
|
116
|
+
|
117
|
+
def _ensure_wildcard_not_matched!(variant)
|
118
|
+
return unless @wildcard_matched
|
119
|
+
|
120
|
+
raise(
|
121
|
+
MatchAfterWildcardError,
|
122
|
+
"Attempted to match variant after wildcard (_): #{variant}"
|
123
|
+
)
|
124
|
+
end
|
125
|
+
|
126
|
+
def _ensure_no_duplicate_match!(variant)
|
127
|
+
return unless @matched_variants.include?(variant)
|
128
|
+
|
129
|
+
raise(DuplicateMatchError, "Duplicated match for variant: #{variant}")
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SumsUp
|
4
|
+
module Core
|
5
|
+
# Validates and normalizes arguments passed to SumsUp.define.
|
6
|
+
module Parser
|
7
|
+
LOWER_SNAKE_CASE_REGEXP = /\A[[:lower:]]+(_[[:lower:]]+)*\z/.freeze
|
8
|
+
|
9
|
+
module_function
|
10
|
+
|
11
|
+
def parse_variant_specs!(no_arg_variants, arg_variants)
|
12
|
+
variant_names = no_arg_variants + arg_variants.keys
|
13
|
+
|
14
|
+
validate_unique!(variant_names)
|
15
|
+
variant_names.each(&method(:validate_name_format!))
|
16
|
+
arg_variants.each_value(&method(:validate_variant_args!))
|
17
|
+
|
18
|
+
no_arg_variants
|
19
|
+
.map { |variant| [variant, []] }
|
20
|
+
.to_h
|
21
|
+
.merge(arg_variants.transform_values { |ary| [*ary] })
|
22
|
+
end
|
23
|
+
|
24
|
+
def validate_unique!(variant_names)
|
25
|
+
duplicates = variant_names
|
26
|
+
.group_by(&:itself)
|
27
|
+
.select { |_, values| values.length > 1 }
|
28
|
+
.keys
|
29
|
+
|
30
|
+
return if duplicates.empty?
|
31
|
+
|
32
|
+
raise(
|
33
|
+
DuplicateNameError,
|
34
|
+
"Duplicated names: #{duplicates.join(', ')}"
|
35
|
+
)
|
36
|
+
end
|
37
|
+
|
38
|
+
def validate_name_format!(variant_name)
|
39
|
+
unless variant_name.is_a?(Symbol)
|
40
|
+
raise(VariantNameError, "Expected a Symbol, got: #{variant_name}")
|
41
|
+
end
|
42
|
+
|
43
|
+
return if LOWER_SNAKE_CASE_REGEXP.match?(variant_name.to_s)
|
44
|
+
|
45
|
+
raise(
|
46
|
+
VariantNameError,
|
47
|
+
"Name is not lower_snake_case: #{variant_name}"
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
def validate_variant_args!(variant_args)
|
52
|
+
case variant_args
|
53
|
+
when Symbol
|
54
|
+
validate_name_format!(variant_args)
|
55
|
+
when Array
|
56
|
+
variant_args.each(&method(:validate_name_format!))
|
57
|
+
|
58
|
+
validate_unique!(variant_args)
|
59
|
+
else
|
60
|
+
raise(
|
61
|
+
VariantArgsError,
|
62
|
+
"Expected a Symbol or Array, got: #{variant_args}"
|
63
|
+
)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SumsUp
|
4
|
+
module Core
|
5
|
+
# Helpers for Strings.
|
6
|
+
module Strings
|
7
|
+
module_function
|
8
|
+
|
9
|
+
def snake_to_class(snake_case_name)
|
10
|
+
snake_case_name
|
11
|
+
.split('_')
|
12
|
+
.map(&:capitalize)
|
13
|
+
.join
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SumsUp
|
4
|
+
module Core
|
5
|
+
# SumTypes are just modules with a meta-programmed methods to construct
|
6
|
+
# variants. Use SumType.build to define a new one.
|
7
|
+
#
|
8
|
+
# Each variant class must have the following constants defined:
|
9
|
+
#
|
10
|
+
# * VARIANT - Symbol name of the variant
|
11
|
+
# * MEMBERS - Array of Symbols which enumerate the variant's arguments
|
12
|
+
#
|
13
|
+
class SumType < Module
|
14
|
+
private_class_method :new
|
15
|
+
|
16
|
+
def self.build(variant_classes)
|
17
|
+
new do
|
18
|
+
const_set(:VARIANTS, variant_classes.freeze)
|
19
|
+
|
20
|
+
variant_classes.each do |variant_class|
|
21
|
+
variant_name = variant_class.const_get(:VARIANT)
|
22
|
+
class_name = SumType.variant_class_name(variant_name)
|
23
|
+
initializer = SumType.variant_initializer(variant_class)
|
24
|
+
|
25
|
+
const_set(class_name, variant_class)
|
26
|
+
define_singleton_method(variant_name, &initializer)
|
27
|
+
|
28
|
+
variant_class.include(self)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.variant_class_name(variant_name)
|
34
|
+
Strings
|
35
|
+
.snake_to_class(variant_name.to_s)
|
36
|
+
.to_sym
|
37
|
+
end
|
38
|
+
|
39
|
+
# Variants without any members are frozen by default for performance.
|
40
|
+
# Pass `memo: false` to its initializer to opt out of this behavior:
|
41
|
+
#
|
42
|
+
# Maybe = SumsUp.define(:nothing, just: :value)
|
43
|
+
#
|
44
|
+
# frozen_nothing = Maybe.nothing
|
45
|
+
# unfrozen_nothing = Maybe.nothing(memo: false)
|
46
|
+
#
|
47
|
+
# # Variants with members are never frozen.
|
48
|
+
# unfrozen_just = Maybe.just(1)
|
49
|
+
#
|
50
|
+
def self.variant_initializer(variant_class)
|
51
|
+
if variant_class.const_get(:MEMBERS).empty?
|
52
|
+
dup_if_overridden(variant_class.new.freeze)
|
53
|
+
else
|
54
|
+
variant_class.method(:new)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.dup_if_overridden(frozen)
|
59
|
+
proc do |memo: true|
|
60
|
+
memo ? frozen : frozen.dup
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,137 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SumsUp
|
4
|
+
module Core
|
5
|
+
# Represents a variant of a sumtype. Use build_variant_class to generate
|
6
|
+
# a new subclass for a given variant.
|
7
|
+
class Variant
|
8
|
+
def self.build_variant_class(name, other_names, members, matcher_class)
|
9
|
+
Class.new(self) do
|
10
|
+
const_set(:VARIANT, name)
|
11
|
+
const_set(:MEMBERS, members.freeze)
|
12
|
+
|
13
|
+
const_set(:Accessors, accessors_module(members))
|
14
|
+
const_set(:Matcher, matcher_class)
|
15
|
+
const_set(:Predicates, predicates_module(name, other_names))
|
16
|
+
|
17
|
+
include(const_get(:Accessors))
|
18
|
+
include(const_get(:Predicates))
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.accessors_module(members)
|
23
|
+
Module.new do
|
24
|
+
members.each_with_index do |member, idx|
|
25
|
+
define_method(member) { @values[idx] }
|
26
|
+
|
27
|
+
define_method(:"#{member}=") { |val| @values[idx] = val }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.predicates_module(correct_name, incorrect_names)
|
33
|
+
Module.new do
|
34
|
+
define_method(:"#{correct_name}?", &Functions.const(true))
|
35
|
+
|
36
|
+
incorrect_names.each do |incorrect_name|
|
37
|
+
define_method(:"#{incorrect_name}?", &Functions.const(false))
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def initialize(*values)
|
43
|
+
given = values.length
|
44
|
+
expected = self.class::MEMBERS.length
|
45
|
+
|
46
|
+
if given != expected
|
47
|
+
raise(
|
48
|
+
ArgumentError,
|
49
|
+
"wrong number of arguments (given #{given}, expected #{expected})"
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
53
|
+
@values = values
|
54
|
+
end
|
55
|
+
|
56
|
+
def [](key)
|
57
|
+
idx = index_for_key(key)
|
58
|
+
|
59
|
+
@values[idx]
|
60
|
+
end
|
61
|
+
|
62
|
+
def []=(key, val)
|
63
|
+
idx = index_for_key(key)
|
64
|
+
|
65
|
+
@values[idx] = val
|
66
|
+
end
|
67
|
+
|
68
|
+
def match(**kwargs)
|
69
|
+
matcher = self.class::Matcher.new(self)
|
70
|
+
|
71
|
+
if block_given?
|
72
|
+
yield(matcher)
|
73
|
+
else
|
74
|
+
matcher._match_hash(kwargs)
|
75
|
+
end
|
76
|
+
|
77
|
+
matcher._fetch_result
|
78
|
+
end
|
79
|
+
|
80
|
+
def members(dup: true)
|
81
|
+
if dup
|
82
|
+
@values.dup
|
83
|
+
else
|
84
|
+
@values
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
alias to_a members
|
89
|
+
|
90
|
+
def attributes
|
91
|
+
self.class::MEMBERS.zip(@values).to_h
|
92
|
+
end
|
93
|
+
|
94
|
+
def to_h(include_root: true)
|
95
|
+
if include_root
|
96
|
+
{ self.class::VARIANT => attributes }
|
97
|
+
else
|
98
|
+
attributes
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def inspect
|
103
|
+
# If a sum type is defined but not assigned to a constant, Class.name
|
104
|
+
# name will return nil in Ruby 2.
|
105
|
+
variant = self.class.name || self.class::VARIANT
|
106
|
+
|
107
|
+
attrs = self.class::MEMBERS
|
108
|
+
.zip(@values)
|
109
|
+
.map { |member, value| "#{member}=#{value.inspect}" }
|
110
|
+
.join(', ')
|
111
|
+
|
112
|
+
if attrs.empty?
|
113
|
+
"#<variant #{variant}>"
|
114
|
+
else
|
115
|
+
"#<variant #{variant} #{attrs}>"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
alias to_s inspect
|
120
|
+
|
121
|
+
def ==(other)
|
122
|
+
other.is_a?(self.class) &&
|
123
|
+
(other.to_a(dup: false) == @values)
|
124
|
+
end
|
125
|
+
|
126
|
+
private
|
127
|
+
|
128
|
+
def index_for_key(key)
|
129
|
+
idx = self.class::MEMBERS.index(key.to_sym)
|
130
|
+
|
131
|
+
return idx if idx
|
132
|
+
|
133
|
+
raise(NameError, "No member '#{key}' in variant #{self.class::VARIANT}")
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|