sums_up 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|