sums_up 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+ require 'rubocop/rake_task'
6
+
7
+ RuboCop::RakeTask.new(:rubocop)
8
+
9
+ RSpec::Core::RakeTask.new(:spec)
10
+
11
+ task default: %i[spec rubocop]
data/bin/console ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'sums_up'
6
+ require 'pry'
7
+
8
+ Pry.start
data/bin/setup ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
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'
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## v1.0.0
4
+
5
+ * Initial release of the gem
@@ -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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SumsUp
4
+ module Core
5
+ # Helpers for functions.
6
+ module Functions
7
+ module_function
8
+
9
+ def const(value)
10
+ proc { value }
11
+ end
12
+ end
13
+ end
14
+ 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