plumb 0.0.1

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.
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/metadata_visitor'
4
+
5
+ module Plumb
6
+ class UndefinedClass
7
+ def inspect
8
+ %(Undefined)
9
+ end
10
+
11
+ def to_s = inspect
12
+ def node_name = :undefined
13
+ end
14
+
15
+ TypeError = Class.new(::TypeError)
16
+ Undefined = UndefinedClass.new.freeze
17
+
18
+ BLANK_STRING = ''
19
+ BLANK_ARRAY = [].freeze
20
+ BLANK_HASH = {}.freeze
21
+ BLANK_RESULT = Result.wrap(Undefined)
22
+
23
+ module Callable
24
+ def metadata
25
+ MetadataVisitor.call(self)
26
+ end
27
+
28
+ def resolve(value = Undefined)
29
+ call(Result.wrap(value))
30
+ end
31
+
32
+ def parse(value = Undefined)
33
+ result = resolve(value)
34
+ raise TypeError, result.errors if result.invalid?
35
+
36
+ result.value
37
+ end
38
+
39
+ def call(result)
40
+ raise NotImplementedError, "Implement #call(Result) => Result in #{self.class}"
41
+ end
42
+ end
43
+
44
+ module Steppable
45
+ include Callable
46
+
47
+ def self.included(base)
48
+ nname = base.name.split('::').last
49
+ nname.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
50
+ nname.downcase!
51
+ nname.gsub!(/_class$/, '')
52
+ nname = nname.to_sym
53
+ base.define_method(:node_name) { nname }
54
+ end
55
+
56
+ def self.wrap(callable)
57
+ if callable.is_a?(Steppable)
58
+ callable
59
+ elsif callable.respond_to?(:call)
60
+ Step.new(callable)
61
+ else
62
+ StaticClass.new(callable)
63
+ end
64
+ end
65
+
66
+ attr_reader :name
67
+
68
+ class Name
69
+ def initialize(name)
70
+ @name = name
71
+ end
72
+
73
+ def to_s = @name
74
+
75
+ def set(n)
76
+ @name = n
77
+ self
78
+ end
79
+ end
80
+
81
+ def freeze
82
+ return self if frozen?
83
+
84
+ @name = Name.new(_inspect)
85
+ super
86
+ end
87
+
88
+ private def _inspect = self.class.name
89
+
90
+ def inspect = name.to_s
91
+
92
+ def node_name = self.class.name.split('::').last.to_sym
93
+
94
+ def defer(definition = nil, &block)
95
+ Deferred.new(definition || block)
96
+ end
97
+
98
+ def >>(other)
99
+ And.new(self, Steppable.wrap(other))
100
+ end
101
+
102
+ def |(other)
103
+ Or.new(self, Steppable.wrap(other))
104
+ end
105
+
106
+ def transform(target_type, callable = nil, &block)
107
+ self >> Transform.new(target_type, callable || block)
108
+ end
109
+
110
+ def check(errors = 'did not pass the check', &block)
111
+ a_check = lambda { |result|
112
+ block.call(result.value) ? result : result.invalid(errors:)
113
+ }
114
+
115
+ self >> a_check
116
+ end
117
+
118
+ def meta(data = {})
119
+ self >> Metadata.new(data)
120
+ end
121
+
122
+ def not(other = self)
123
+ Not.new(other)
124
+ end
125
+
126
+ def invalid(errors: nil)
127
+ Not.new(self, errors:)
128
+ end
129
+
130
+ def value(val)
131
+ self >> ValueClass.new(val)
132
+ end
133
+
134
+ def match(*args)
135
+ self >> MatchClass.new(*args)
136
+ end
137
+
138
+ def [](val) = match(val)
139
+
140
+ DefaultProc = proc do |callable|
141
+ proc do |result|
142
+ result.valid(callable.call)
143
+ end
144
+ end
145
+
146
+ def default(val = Undefined, &block)
147
+ val_type = if val == Undefined
148
+ DefaultProc.call(block)
149
+ else
150
+ Types::Static[val]
151
+ end
152
+
153
+ self | (Types::Undefined >> val_type)
154
+ end
155
+
156
+ class Node
157
+ include Steppable
158
+
159
+ attr_reader :node_name, :type, :attributes
160
+
161
+ def initialize(node_name, type, attributes = BLANK_HASH)
162
+ @node_name = node_name
163
+ @type = type
164
+ @attributes = attributes
165
+ freeze
166
+ end
167
+
168
+ def call(result) = type.call(result)
169
+ end
170
+
171
+ def as_node(node_name, metadata = BLANK_HASH)
172
+ Node.new(node_name, self, metadata)
173
+ end
174
+
175
+ def nullable
176
+ Types::Nil | self
177
+ end
178
+
179
+ def present
180
+ Types::Present >> self
181
+ end
182
+
183
+ def options(opts = [])
184
+ rule(included_in: opts)
185
+ end
186
+
187
+ def rule(*args)
188
+ specs = case args
189
+ in [::Symbol => rule_name, value]
190
+ { rule_name => value }
191
+ in [::Hash => rules]
192
+ rules
193
+ else
194
+ raise ArgumentError, "expected 1 or 2 arguments, but got #{args.size}"
195
+ end
196
+
197
+ self >> Rules.new(specs, metadata[:type])
198
+ end
199
+
200
+ def is_a(klass)
201
+ rule(is_a: klass)
202
+ end
203
+
204
+ def ===(other)
205
+ case other
206
+ when Steppable
207
+ other == self
208
+ else
209
+ resolve(other).valid?
210
+ end
211
+ end
212
+
213
+ def coerce(type, coercion = nil, &block)
214
+ coercion ||= block
215
+ step = lambda { |result|
216
+ if type === result.value
217
+ result.valid(coercion.call(result.value))
218
+ else
219
+ result.invalid(errors: "%s can't be coerced" % result.value.inspect)
220
+ end
221
+ }
222
+ self >> step
223
+ end
224
+
225
+ def build(cns, factory_method = :new, &block)
226
+ self >> Build.new(cns, factory_method:, &block)
227
+ end
228
+
229
+ def pipeline(&block)
230
+ Pipeline.new(self, &block)
231
+ end
232
+
233
+ def to_s
234
+ inspect
235
+ end
236
+ end
237
+ end
238
+
239
+ require 'plumb/deferred'
240
+ require 'plumb/transform'
241
+ require 'plumb/build'
242
+ require 'plumb/metadata'
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class TaggedHash
7
+ include Steppable
8
+
9
+ attr_reader :key, :types
10
+
11
+ def initialize(hash_type, key, types)
12
+ @hash_type = hash_type
13
+ @key = Key.wrap(key)
14
+ @types = types
15
+
16
+ raise ArgumentError, 'all types must be HashClass' if @types.size == 0 || @types.any? do |t|
17
+ !t.is_a?(HashClass)
18
+ end
19
+ raise ArgumentError, "all types must define key #{@key}" unless @types.all? { |t| !!t.at_key(@key) }
20
+
21
+ # types are assumed to have static values for the index field :key
22
+ @index = @types.each.with_object({}) do |t, memo|
23
+ memo[t.at_key(@key).resolve.value] = t
24
+ end
25
+ end
26
+
27
+ def call(result)
28
+ result = @hash_type.call(result)
29
+ return result unless result.valid?
30
+
31
+ child = @index[result.value[@key.to_sym]]
32
+ return result.invalid(errors: "expected :#{@key.to_sym} to be one of #{@index.keys.join(', ')}") unless child
33
+
34
+ child.call(result)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class Transform
7
+ include Steppable
8
+
9
+ attr_reader :target_type
10
+
11
+ def initialize(target_type, callable)
12
+ @target_type = target_type
13
+ @callable = callable
14
+ end
15
+
16
+ def call(result)
17
+ result.valid(@callable.call(result.value))
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class TupleClass
7
+ include Steppable
8
+
9
+ attr_reader :types
10
+
11
+ def initialize(*types)
12
+ @types = types.map { |t| t.is_a?(Steppable) ? t : Types::Any.value(t) }
13
+ end
14
+
15
+ def of(*types)
16
+ self.class.new(*types)
17
+ end
18
+
19
+ alias [] of
20
+
21
+ private def _inspect
22
+ "#{name}[#{@types.map(&:inspect).join(', ')}]"
23
+ end
24
+
25
+ def call(result)
26
+ return result.invalid(errors: 'must be an Array') unless result.value.is_a?(::Array)
27
+ return result.invalid(errors: 'must have the same size') unless result.value.size == @types.size
28
+
29
+ errors = {}
30
+ values = @types.map.with_index do |type, idx|
31
+ val = result.value[idx]
32
+ r = type.resolve(val)
33
+ errors[idx] = ["expected #{type.inspect}, got #{val.inspect}", r.errors].flatten unless r.valid?
34
+ r.value
35
+ end
36
+
37
+ return result.valid(values) unless errors.any?
38
+
39
+ result.invalid(errors:)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ module TypeRegistry
5
+ def const_added(const_name)
6
+ obj = const_get(const_name)
7
+ case obj
8
+ when Module
9
+ obj.extend TypeRegistry
10
+ when Steppable
11
+ anc = [name, const_name].join('::')
12
+ obj.freeze.name.set(anc)
13
+ end
14
+ end
15
+
16
+ def included(host)
17
+ host.extend TypeRegistry
18
+ constants(false).each do |const_name|
19
+ const = const_get(const_name)
20
+ anc = [host.name, const_name].join('::')
21
+ case const
22
+ when Module
23
+ child_mod = Module.new
24
+ child_mod.define_singleton_method(:name) do
25
+ anc
26
+ end
27
+ child_mod.send(:include, const)
28
+ host.const_set(const_name, child_mod)
29
+ when Steppable
30
+ type = const.dup
31
+ type.freeze.name.set(anc)
32
+ host.const_set(const_name, type)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module Plumb
6
+ Rules.define :eq, 'must be equal to %<value>s' do |result, value|
7
+ value == result.value
8
+ end
9
+ Rules.define :not_eq, 'must not be equal to %<value>s' do |result, value|
10
+ value != result.value
11
+ end
12
+ # :gt for numbers and #size (arrays, strings, hashes)
13
+ [::String, ::Array, ::Hash].each do |klass|
14
+ Rules.define :gt, 'must contain more than %<value>s elements', expects: klass do |result, value|
15
+ value < result.value.size
16
+ end
17
+
18
+ # :lt for numbers and #size (arrays, strings, hashes)
19
+ Rules.define :lt, 'must contain fewer than %<value>s elements', expects: klass do |result, value|
20
+ value > result.value.size
21
+ end
22
+
23
+ Rules.define :gte, 'must be size greater or equal to %<value>s', expects: klass do |result, value|
24
+ value <= result.value.size
25
+ end
26
+
27
+ Rules.define :lte, 'must be size less or equal to %<value>s', expects: klass do |result, value|
28
+ value >= result.value
29
+ end
30
+ end
31
+ # :gt and :lt for numbers, BigDecimal
32
+ [::Numeric].each do |klass|
33
+ Rules.define :gt, 'must be greater than %<value>s', expects: klass do |result, value|
34
+ value < result.value
35
+ end
36
+ Rules.define :lt, 'must be greater than %<value>s', expects: klass do |result, value|
37
+ value > result.value
38
+ end
39
+ Rules.define :gte, 'must be greater or equal to %<value>s', expects: klass do |result, value|
40
+ value <= result.value
41
+ end
42
+ # :lte for numbers and #size (arrays, strings, hashes)
43
+ Rules.define :lte, 'must be less or equal to %<value>s', expects: klass do |result, value|
44
+ value >= result.value
45
+ end
46
+ end
47
+
48
+ Rules.define :match, 'must match %<value>s', metadata_key: :pattern do |result, value|
49
+ value === result.value
50
+ end
51
+ Rules.define :included_in, 'elements must be included in %<value>s', expects: ::Array,
52
+ metadata_key: :options do |result, opts|
53
+ result.value.all? { |v| opts.include?(v) }
54
+ end
55
+ Rules.define :included_in, 'must be included in %<value>s', metadata_key: :options do |result, opts|
56
+ opts.include? result.value
57
+ end
58
+ Rules.define :excluded_from, 'elements must not be included in %<value>s', expects: ::Array do |result, value|
59
+ result.value.all? { |v| !value.include?(v) }
60
+ end
61
+ Rules.define :excluded_from, 'must not be included in %<value>s' do |result, value|
62
+ !value.include?(result.value)
63
+ end
64
+ Rules.define :respond_to, 'must respond to %<value>s' do |result, value|
65
+ Array(value).all? { |m| result.value.respond_to?(m) }
66
+ end
67
+ Rules.define :size, 'must be of size %<value>s', expects: :size, metadata_key: :size do |result, value|
68
+ value === result.value.size
69
+ end
70
+
71
+ module Types
72
+ extend TypeRegistry
73
+
74
+ Any = AnyClass.new
75
+ Undefined = Any.value(Plumb::Undefined)
76
+ String = Any[::String]
77
+ Symbol = Any[::Symbol]
78
+ Numeric = Any[::Numeric]
79
+ Integer = Any[::Integer]
80
+ Decimal = Any[BigDecimal]
81
+ Static = StaticClass.new
82
+ Value = ValueClass.new
83
+ Nil = Any[::NilClass]
84
+ True = Any[::TrueClass]
85
+ False = Any[::FalseClass]
86
+ Boolean = (True | False).as_node(:boolean)
87
+ Array = ArrayClass.new
88
+ Tuple = TupleClass.new
89
+ Hash = HashClass.new
90
+ Interface = InterfaceClass.new
91
+ # TODO: type-speficic concept of blank, via Rules
92
+ Blank = (
93
+ Undefined \
94
+ | Nil \
95
+ | String.value(BLANK_STRING) \
96
+ | Hash.value(BLANK_HASH) \
97
+ | Array.value(BLANK_ARRAY)
98
+ )
99
+
100
+ Present = Blank.invalid(errors: 'must be present')
101
+ Split = String.transform(::String) { |v| v.split(/\s*,\s*/) }
102
+
103
+ module Lax
104
+ NUMBER_EXPR = /^\d{1,3}(?:,\d{3})*(?:\.\d+)?$/
105
+
106
+ String = Types::String \
107
+ | Any.coerce(BigDecimal) { |v| v.to_s('F') } \
108
+ | Any.coerce(::Numeric, &:to_s)
109
+
110
+ Symbol = Types::Symbol | Types::String.transform(::Symbol, &:to_sym)
111
+
112
+ NumberString = Types::String.match(NUMBER_EXPR)
113
+ CoercibleNumberString = NumberString.transform(::String) { |v| v.tr(',', '') }
114
+
115
+ Numeric = Types::Numeric | CoercibleNumberString.transform(::Numeric, &:to_f)
116
+
117
+ Decimal = Types::Decimal | \
118
+ (Types::Numeric.transform(::String, &:to_s) | CoercibleNumberString) \
119
+ .transform(::BigDecimal) { |v| BigDecimal(v) }
120
+
121
+ Integer = Numeric.transform(::Integer, &:to_i)
122
+ end
123
+
124
+ module Forms
125
+ True = Types::True \
126
+ | Types::String >> Any.coerce(/^true$/i) { |_| true } \
127
+ | Any.coerce('1') { |_| true } \
128
+ | Any.coerce(1) { |_| true }
129
+
130
+ False = Types::False \
131
+ | Types::String >> Any.coerce(/^false$/i) { |_| false } \
132
+ | Any.coerce('0') { |_| false } \
133
+ | Any.coerce(0) { |_| false }
134
+
135
+ Boolean = True | False
136
+
137
+ Nil = Nil | (String[BLANK_STRING] >> nil)
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class ValueClass
7
+ include Steppable
8
+
9
+ attr_reader :value
10
+
11
+ def initialize(value = Undefined)
12
+ @value = value
13
+ end
14
+
15
+ def inspect = @value.inspect
16
+
17
+ def [](value) = self.class.new(value)
18
+
19
+ def call(result)
20
+ @value == result.value ? result : result.invalid(errors: "Must be equal to #{@value}")
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ VERSION = '0.0.1'
5
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ module VisitorHandlers
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def on(node_name, &block)
11
+ name = node_name.is_a?(Symbol) ? node_name : :"#{node_name}_class"
12
+ self.define_method("visit_#{name}", &block)
13
+ end
14
+
15
+ def visit(type, props = BLANK_HASH)
16
+ new.visit(type, props)
17
+ end
18
+ end
19
+
20
+ def visit(type, props = BLANK_HASH)
21
+ method_name = type.respond_to?(:node_name) ? type.node_name : :"#{(type.is_a?(::Class) ? type : type.class)}_class"
22
+ method_name = "visit_#{method_name}"
23
+ if respond_to?(method_name)
24
+ send(method_name, type, props)
25
+ else
26
+ on_missing_handler(type, props, method_name)
27
+ end
28
+ end
29
+
30
+ def on_missing_handler(type, _props, method_name)
31
+ raise "No handler for #{type.inspect} with :#{method_name}"
32
+ end
33
+ end
34
+ end
data/lib/plumb.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ end
5
+
6
+ require 'plumb/result'
7
+ require 'plumb/type_registry'
8
+ require 'plumb/steppable'
9
+ require 'plumb/any_class'
10
+ require 'plumb/step'
11
+ require 'plumb/and'
12
+ require 'plumb/pipeline'
13
+ require 'plumb/rules'
14
+ require 'plumb/static_class'
15
+ require 'plumb/value_class'
16
+ require 'plumb/match_class'
17
+ require 'plumb/not'
18
+ require 'plumb/or'
19
+ require 'plumb/tuple_class'
20
+ require 'plumb/array_class'
21
+ require 'plumb/hash_class'
22
+ require 'plumb/interface_class'
23
+ require 'plumb/types'
24
+ require 'plumb/json_schema_visitor'
25
+ require 'plumb/schema'