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.
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