plumb 0.0.2 → 0.0.3

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.
@@ -12,12 +12,17 @@ module Plumb
12
12
  DEFAULT = 'default'
13
13
  ANY_OF = 'anyOf'
14
14
  ALL_OF = 'allOf'
15
+ NOT = 'not'
15
16
  ENUM = 'enum'
16
17
  CONST = 'const'
17
18
  ITEMS = 'items'
18
19
  PATTERN = 'pattern'
19
20
  MINIMUM = 'minimum'
20
21
  MAXIMUM = 'maximum'
22
+ MIN_ITEMS = 'minItems'
23
+ MAX_ITEMS = 'maxItems'
24
+ MIN_LENGTH = 'minLength'
25
+ MAX_LENGTH = 'maxLength'
21
26
 
22
27
  def self.call(node)
23
28
  {
@@ -78,7 +83,7 @@ module Plumb
78
83
  end
79
84
 
80
85
  on(:not) do |node, props|
81
- props.merge('not' => visit(node.step))
86
+ props.merge(NOT => visit(node.step))
82
87
  end
83
88
 
84
89
  on(:value) do |node, props|
@@ -113,14 +118,52 @@ module Plumb
113
118
  visit(node.value, props)
114
119
  end
115
120
 
116
- on(:rules) do |node, props|
117
- node.rules.reduce(props) do |acc, rule|
118
- acc.merge(visit(rule))
121
+ on(:policy) do |node, props|
122
+ props = visit(node.step, props)
123
+ method_name = :"visit_#{node.policy_name}_policy"
124
+ if respond_to?(method_name)
125
+ send(method_name, node, props)
126
+ else
127
+ props
119
128
  end
120
129
  end
121
130
 
122
- on(:rule_included_in) do |node, props|
123
- props.merge(ENUM => node.arg_value)
131
+ on(:options_policy) do |node, props|
132
+ props.merge(ENUM => node.arg)
133
+ end
134
+
135
+ on(:size_policy) do |node, props|
136
+ opts = {}
137
+ case props[TYPE]
138
+ when 'array'
139
+ case node.arg
140
+ when Range
141
+ opts[MIN_ITEMS] = node.arg.min if node.arg.begin
142
+ opts[MAX_ITEMS] = node.arg.max if node.arg.end
143
+ when Numeric
144
+ opts[MIN_ITEMS] = node.arg
145
+ opts[MAX_ITEMS] = node.arg
146
+ end
147
+ when 'string'
148
+ case node.arg
149
+ when Range
150
+ opts[MIN_LENGTH] = node.arg.min if node.arg.begin
151
+ opts[MAX_LENGTH] = node.arg.max if node.arg.end
152
+ when Numeric
153
+ opts[MIN_LENGTH] = node.arg
154
+ opts[MAX_LENGTH] = node.arg
155
+ end
156
+ end
157
+
158
+ props.merge(opts)
159
+ end
160
+
161
+ on(:excluded_from_policy) do |node, props|
162
+ props.merge(NOT => { ENUM => node.arg })
163
+ end
164
+
165
+ on(Proc) do |_node, props|
166
+ props
124
167
  end
125
168
 
126
169
  on(:match) do |node, props|
@@ -8,11 +8,12 @@ module Plumb
8
8
 
9
9
  attr_reader :matcher
10
10
 
11
- def initialize(matcher = Undefined, error: nil)
11
+ def initialize(matcher = Undefined, error: nil, label: nil)
12
12
  raise TypeError 'matcher must respond to #===' unless matcher.respond_to?(:===)
13
13
 
14
14
  @matcher = matcher
15
15
  @error = error.nil? ? build_error(matcher) : (error % matcher)
16
+ @label = matcher.is_a?(Class) ? matcher.inspect : "Match(#{label || @matcher.inspect})"
16
17
  freeze
17
18
  end
18
19
 
@@ -22,9 +23,7 @@ module Plumb
22
23
 
23
24
  private
24
25
 
25
- def _inspect
26
- @matcher.inspect
27
- end
26
+ def _inspect = @label
28
27
 
29
28
  def build_error(matcher)
30
29
  case matcher
@@ -77,7 +77,12 @@ module Plumb
77
77
  end
78
78
 
79
79
  on(:static) do |node, props|
80
- props.merge(static: node.value)
80
+ type = node.value.is_a?(Class) ? node.value : node.value.class
81
+ props.merge(static: node.value, type:)
82
+ end
83
+
84
+ on(:policy) do |node, props|
85
+ visit(node.step, props).merge(node.policy_name => node.arg)
81
86
  end
82
87
 
83
88
  on(:rules) do |node, props|
@@ -117,5 +122,9 @@ module Plumb
117
122
  on(:tagged_hash) do |_node, props|
118
123
  props.merge(type: Hash)
119
124
  end
125
+
126
+ on(Proc) do |_node, props|
127
+ props
128
+ end
120
129
  end
121
130
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/policies'
4
+
5
+ module Plumb
6
+ # A policy registry for Plumb
7
+ # It holds and gets registered policies.
8
+ # Policies are callable objects that act as factories for type compositions.
9
+ class Policies
10
+ UnknownPolicyError = Class.new(StandardError)
11
+ MethodAlreadyDefinedError = Class.new(StandardError)
12
+
13
+ def initialize
14
+ @policies = {}
15
+ end
16
+
17
+ # Register a policy for all or specific outpyt types.
18
+ # Example for a policy that works for all types:
19
+ # #register(Object, :my_policy, ->(node, arg) { ... })
20
+ # Example for a policy that works for a specific type:
21
+ # #register(String, :my_policy, ->(node, arg) { ... })
22
+ # Example for a policy that works for a specific interface:
23
+ # #register(:size, :my_policy, ->(node, arg) { ... })
24
+ #
25
+ # The policy callable takes the step it is applied to, a policy argument (if any) and a policy block (if any).
26
+ # Example for a policy #default(default_value = Undefined) { 'some-default-value' }
27
+ # policy = proc do |type, default_value = Undefined, &block|
28
+ # type | (Plumb::Types::Undefined >> Plumb::Types::Static[default_value])
29
+ # end
30
+ #
31
+ # @param for_type [Class, Symbol] the type the policy is for.
32
+ # @param name [Symbol] the name of the policy.
33
+ # @param policy [Proc] the policy to register.
34
+ def register(for_type, name, policy)
35
+ @policies[name] ||= {}
36
+ @policies[name][for_type] = policy
37
+ end
38
+
39
+ # Get a policy for a given type.
40
+ # @param types [Array<Class>] the types
41
+ # @param name [Symbol] the policy name
42
+ # @return [#call] the policy callable
43
+ # @raise [UnknownPolicyError] if the policy is not registered for the given types
44
+ def get(types, name)
45
+ if (pol = resolve_shared_policy(types, name))
46
+ pol
47
+ elsif (pol = @policies.dig(name, Object))
48
+ raise UnknownPolicyError, "Unknown policy #{name} for #{types.inspect}" unless pol
49
+
50
+ pol
51
+ else
52
+ raise UnknownPolicyError, "Unknown or incompatible policy #{name} for #{types.inspect}"
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def resolve_shared_policy(types, name)
59
+ pols = types.map do |type|
60
+ resolve_policy(type, name)
61
+ end.uniq
62
+ pols.size == 1 ? pols.first : nil
63
+ end
64
+
65
+ def resolve_policy(type, name)
66
+ policies = @policies[name]
67
+ return nil unless policies
68
+
69
+ # { Object => policy1, String => policy2, size: policy3 }
70
+ #
71
+ policies.find do |for_type, _pol|
72
+ case for_type
73
+ when Symbol # :size
74
+ type.instance_methods.include?(for_type)
75
+ when Class # String
76
+ for_type == type
77
+ end
78
+ end&.last
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ # Wrap a policy composition ("step") in a Policy object.
7
+ # So that visitors such as JSONSchema and Metadata visitors
8
+ # can define dedicated handlers for policies, if they need to.
9
+ class Policy
10
+ include Steppable
11
+
12
+ attr_reader :policy_name, :arg, :step
13
+
14
+ # @param policy_name [Symbol]
15
+ # @param arg [Object, nil] the argument to the policy, if any.
16
+ # @param step [Step] the step composition wrapped by this policy.
17
+ def initialize(policy_name, arg, step)
18
+ @policy_name = policy_name
19
+ @arg = arg
20
+ @step = step
21
+ freeze
22
+ end
23
+
24
+ # The standard Step interface.
25
+ # @param result [Result::Valid]
26
+ # @return [Result::Valid, Result::Invalid]
27
+ def call(result) = @step.call(result)
28
+
29
+ private def _inspect = @step.inspect
30
+ end
31
+ end
data/lib/plumb/schema.rb CHANGED
@@ -172,8 +172,8 @@ module Plumb
172
172
  self
173
173
  end
174
174
 
175
- def rule(...)
176
- @_type = @_type.rule(...)
175
+ def policy(...)
176
+ @_type = @_type.policy(...)
177
177
  self
178
178
  end
179
179
 
@@ -10,6 +10,7 @@ module Plumb
10
10
 
11
11
  def to_s = inspect
12
12
  def node_name = :undefined
13
+ def empty? = true
13
14
  end
14
15
 
15
16
  TypeError = Class.new(::TypeError)
@@ -111,7 +112,7 @@ module Plumb
111
112
  end
112
113
 
113
114
  def check(errors = 'did not pass the check', &block)
114
- self >> MatchClass.new(block, error: errors)
115
+ self >> MatchClass.new(block, error: errors, label: errors)
115
116
  end
116
117
 
117
118
  def meta(data = {})
@@ -136,22 +137,6 @@ module Plumb
136
137
 
137
138
  def [](val) = match(val)
138
139
 
139
- DefaultProc = proc do |callable|
140
- proc do |result|
141
- result.valid(callable.call)
142
- end
143
- end
144
-
145
- def default(val = Undefined, &block)
146
- val_type = if val == Undefined
147
- DefaultProc.call(block)
148
- else
149
- Types::Static[val]
150
- end
151
-
152
- self | (Types::Undefined >> val_type)
153
- end
154
-
155
140
  class Node
156
141
  include Steppable
157
142
 
@@ -171,29 +156,28 @@ module Plumb
171
156
  Node.new(node_name, self, metadata)
172
157
  end
173
158
 
174
- def nullable
175
- Types::Nil | self
176
- end
177
-
178
- def present
179
- Types::Present >> self
180
- end
181
-
182
- def options(opts = [])
183
- rule(included_in: opts)
184
- end
159
+ # Register a policy for this step.
160
+ # Mode 1.a: #policy(:name, arg) a single policy with an argument
161
+ # Mode 1.b: #policy(:name) a single policy without an argument
162
+ # Mode 2: #policy(p1: value, p2: value) multiple policies with arguments
163
+ # The latter mode will be expanded to multiple #policy calls.
164
+ # @return [Step]
165
+ def policy(*args, &blk)
166
+ case args
167
+ in [::Symbol => name, *rest] # #policy(:name, arg)
168
+ types = Array(metadata[:type]).uniq
185
169
 
186
- def rule(*args)
187
- specs = case args
188
- in [::Symbol => rule_name, value]
189
- { rule_name => value }
190
- in [::Hash => rules]
191
- rules
192
- else
193
- raise ArgumentError, "expected 1 or 2 arguments, but got #{args.size}"
194
- end
170
+ bargs = [self]
171
+ bargs << rest.first if rest.any?
172
+ block = Plumb.policies.get(types, name)
173
+ pol = block.call(*bargs, &blk)
195
174
 
196
- self >> Rules.new(specs, metadata[:type])
175
+ Policy.new(name, rest.first, pol)
176
+ in [::Hash => opts] # #policy(p1: value, p2: value)
177
+ opts.reduce(self) { |step, (name, value)| step.policy(name, value) }
178
+ else
179
+ raise ArgumentError, "expected a symbol or hash, got #{args.inspect}"
180
+ end
197
181
  end
198
182
 
199
183
  def ===(other)
@@ -217,7 +201,7 @@ module Plumb
217
201
  inspect
218
202
  end
219
203
 
220
- # Build a step that will invoke onr or more methods on the value.
204
+ # Build a step that will invoke one or more methods on the value.
221
205
  # Ex 1: Types::String.invoke(:downcase)
222
206
  # Ex 2: Types::Array.invoke(:[], 1)
223
207
  # Ex 3 chain of methods: Types::String.invoke([:downcase, :to_sym])
@@ -240,5 +224,6 @@ end
240
224
 
241
225
  require 'plumb/deferred'
242
226
  require 'plumb/transform'
227
+ require 'plumb/policy'
243
228
  require 'plumb/build'
244
229
  require 'plumb/metadata'
data/lib/plumb/types.rb CHANGED
@@ -3,25 +3,127 @@
3
3
  require 'bigdecimal'
4
4
 
5
5
  module Plumb
6
- Rules.define :included_in, 'elements must be included in %<value>s', expects: ::Array do |result, opts|
7
- result.value.all? { |v| opts.include?(v) }
6
+ # Define core policies
7
+ # Allowed options for an array type.
8
+ # It validates that each element is in the options array.
9
+ # Usage:
10
+ # type = Types::Array.options(['a', 'b'])
11
+ policy :options, helper: true, for_type: ::Array do |type, opts|
12
+ type.check("must be included in #{opts.inspect}") do |v|
13
+ v.all? { |val| opts.include?(val) }
14
+ end
15
+ end
16
+
17
+ # Generic options policy for all other types.
18
+ # Usage:
19
+ # type = Types::String.options(['a', 'b'])
20
+ policy :options do |type, opts|
21
+ type.check("must be included in #{opts.inspect}") do |v|
22
+ opts.include?(v)
23
+ end
24
+ end
25
+
26
+ # Validate that array elements are NOT in the options array.
27
+ # Usage:
28
+ # type = Types::Array.policy(excluded_from: ['a', 'b'])
29
+ policy :excluded_from, for_type: ::Array do |type, opts|
30
+ type.check("must not be included in #{opts.inspect}") do |v|
31
+ v.none? { |val| opts.include?(val) }
32
+ end
33
+ end
34
+
35
+ # Usage:
36
+ # type = Types::String.policy(excluded_from: ['a', 'b'])
37
+ policy :excluded_from do |type, opts|
38
+ type.check("must not be included in #{opts.inspect}") do |v|
39
+ !opts.include?(v)
40
+ end
41
+ end
42
+
43
+ # Validate #size against a number or any object that responds to #===.
44
+ # This works with any type that repsonds to #size.
45
+ # Usage:
46
+ # type = Types::String.policy(size: 10)
47
+ # type = Types::Integer.policy(size: 1..10)
48
+ # type = Types::Array.policy(size: 1..)
49
+ # type = Types::Any[Set].policy(size: 1..)
50
+ policy :size, for_type: :size do |type, size|
51
+ type.check("must be of size #{size}") { |v| size === v.size }
8
52
  end
9
- Rules.define :included_in, 'must be included in %<value>s' do |result, opts|
10
- opts.include? result.value
53
+
54
+ # Validate that an object is not #empty? nor #nil?
55
+ # Usage:
56
+ # Types::String.present
57
+ # Types::Array.present
58
+ policy :present, helper: true do |type, *_args|
59
+ type.check('must be present') do |v|
60
+ if v.respond_to?(:empty?)
61
+ !v.empty?
62
+ else
63
+ !v.nil?
64
+ end
65
+ end
11
66
  end
12
- Rules.define :excluded_from, 'elements must not be included in %<value>s', expects: ::Array do |result, value|
13
- result.value.all? { |v| !value.include?(v) }
67
+
68
+ # Allow nil values for a type.
69
+ # Usage:
70
+ # nullable_int = Types::Integer.nullable
71
+ # nullable_int.parse(nil) # => nil
72
+ # nullable_int.parse(10) # => 10
73
+ # nullable_int.parse('nope') # => error: not an Integer
74
+ policy :nullable, helper: true do |type, *_args|
75
+ Types::Nil | type
14
76
  end
15
- Rules.define :excluded_from, 'must not be included in %<value>s' do |result, value|
16
- !value.include?(result.value)
77
+
78
+ # Validate that a value responds to a method
79
+ # Usage:
80
+ # type = Types::Any.policy(respond_to: :upcase)
81
+ # type = Types::Any.policy(respond_to: [:upcase, :strip])
82
+ policy :respond_to do |type, method_names|
83
+ type.check("must respond to #{method_names.inspect}") do |value|
84
+ Array(method_names).all? { |m| value.respond_to?(m) }
85
+ end
17
86
  end
18
- Rules.define :respond_to, 'must respond to %<value>s' do |result, value|
19
- Array(value).all? { |m| result.value.respond_to?(m) }
87
+
88
+ # Return a default value if the input value is Undefined (ie key not present in a Hash).
89
+ # Usage:
90
+ # type = Types::String.default('default')
91
+ # type.parse(Undefined) # => 'default'
92
+ # type.parse('yes') # => 'yes'
93
+ #
94
+ # Works with a block too:
95
+ # date = Type::Any[Date].default { Date.today }
96
+ #
97
+ policy :default, helper: true do |type, value = Undefined, &block|
98
+ val_type = if value == Undefined
99
+ Step.new(->(result) { result.valid(block.call) }, 'default proc')
100
+ else
101
+ Types::Static[value]
102
+ end
103
+
104
+ type | (Types::Undefined >> val_type)
20
105
  end
21
- Rules.define :size, 'must be of size %<value>s', expects: :size do |result, value|
22
- value === result.value.size
106
+
107
+ # Split a string into an array. Default separator is /\s*,\s*/
108
+ # Usage:
109
+ # type = Types::String.split
110
+ # type.parse('a,b,c') # => ['a', 'b', 'c']
111
+ #
112
+ # Custom separator:
113
+ # type = Types::String.split(';')
114
+ module SplitPolicy
115
+ DEFAULT_SEPARATOR = /\s*,\s*/
116
+
117
+ def self.call(type, separator = DEFAULT_SEPARATOR)
118
+ type.invoke(:split, separator) >> Types::Array[String]
119
+ end
120
+
121
+ def self.for_type = ::String
122
+ def self.helper = false
23
123
  end
24
124
 
125
+ policy :split, SplitPolicy
126
+
25
127
  module Types
26
128
  extend TypeRegistry
27
129
 
@@ -43,17 +145,6 @@ module Plumb
43
145
  Tuple = TupleClass.new
44
146
  Hash = HashClass.new
45
147
  Interface = InterfaceClass.new
46
- # TODO: type-speficic concept of blank, via Rules
47
- Blank = (
48
- Undefined \
49
- | Nil \
50
- | String.value(BLANK_STRING) \
51
- | Hash.value(BLANK_HASH) \
52
- | Array.value(BLANK_ARRAY)
53
- )
54
-
55
- Present = Blank.invalid(errors: 'must be present')
56
- Split = String.transform(::String) { |v| v.split(/\s*,\s*/) }
57
148
 
58
149
  module Lax
59
150
  NUMBER_EXPR = /^\d{1,3}(?:,\d{3})*(?:\.\d+)?$/
data/lib/plumb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Plumb
4
- VERSION = '0.0.2'
4
+ VERSION = '0.0.3'
5
5
  end
@@ -23,7 +23,12 @@ module Plumb
23
23
  else
24
24
  :"#{(node.is_a?(::Class) ? node : node.class)}_class"
25
25
  end
26
- method_name = "visit_#{method_name}"
26
+
27
+ visit_name(method_name, node, props)
28
+ end
29
+
30
+ def visit_name(method_name, node, props = BLANK_HASH)
31
+ method_name = :"visit_#{method_name}"
27
32
  if respond_to?(method_name)
28
33
  send(method_name, node, props)
29
34
  else
data/lib/plumb.rb CHANGED
@@ -1,6 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'plumb/policies'
4
+
3
5
  module Plumb
6
+ @policies = Policies.new
7
+
8
+ def self.policies
9
+ @policies
10
+ end
11
+
12
+ # Register a policy with the given name and block.
13
+ # Optionally define a method on the Steppable method to call the policy.
14
+ # Example:
15
+ # Plumb.policy(:multiply_by, for_type: Integer, helper: true) do |step, factor, &block|
16
+ # step.transform(Integer) { |number| number * factor }
17
+ # end
18
+ #
19
+ # type = Types::Integer.multiply_by(2)
20
+ # type.parse(10) # => 20
21
+ #
22
+ # @param name [Symbol] the name of the policy
23
+ # @param opts [Hash] options for the policy
24
+ # @yield [Step, Object, &block] the step (type), policy argument, and policy block, if any.
25
+ def self.policy(name, opts = {}, &block)
26
+ name = name.to_sym
27
+ if opts.is_a?(Hash) && block_given?
28
+ for_type = opts[:for_type] || Object
29
+ helper = opts[:helper] || false
30
+ elsif opts.respond_to?(:call) && opts.respond_to?(:for_type) && opts.respond_to?(:helper)
31
+ for_type = opts.for_type
32
+ helper = opts.helper
33
+ block = opts.method(:call)
34
+ else
35
+ raise ArgumentError, 'Expected a block or a hash with :for_type and :helper keys'
36
+ end
37
+
38
+ policies.register(for_type, name, block)
39
+
40
+ return self unless helper
41
+
42
+ if Steppable.instance_methods.include?(name)
43
+ raise Policies::MethodAlreadyDefinedError, "Method #{name} is already defined on Steppable"
44
+ end
45
+
46
+ Steppable.define_method(name) do |arg = Undefined, &bl|
47
+ if arg == Undefined
48
+ policy(name, &bl)
49
+ else
50
+ policy(name, arg, &bl)
51
+ end
52
+ end
53
+
54
+ self
55
+ end
4
56
  end
5
57
 
6
58
  require 'plumb/result'
@@ -10,7 +62,6 @@ require 'plumb/any_class'
10
62
  require 'plumb/step'
11
63
  require 'plumb/and'
12
64
  require 'plumb/pipeline'
13
- require 'plumb/rules'
14
65
  require 'plumb/static_class'
15
66
  require 'plumb/value_class'
16
67
  require 'plumb/match_class'
metadata CHANGED
@@ -1,17 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plumb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Celis
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-07-11 00:00:00.000000000 Z
11
+ date: 2024-07-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: concurrent-ruby
14
+ name: bigdecimal
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - ">="
@@ -25,7 +25,7 @@ dependencies:
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
27
  - !ruby/object:Gem::Dependency
28
- name: bigdecimal
28
+ name: concurrent-ruby
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
@@ -53,6 +53,7 @@ files:
53
53
  - examples/command_objects.rb
54
54
  - examples/concurrent_downloads.rb
55
55
  - examples/csv_stream.rb
56
+ - examples/env_config.rb
56
57
  - examples/programmers.csv
57
58
  - examples/weekdays.rb
58
59
  - lib/plumb.rb
@@ -72,8 +73,9 @@ files:
72
73
  - lib/plumb/not.rb
73
74
  - lib/plumb/or.rb
74
75
  - lib/plumb/pipeline.rb
76
+ - lib/plumb/policies.rb
77
+ - lib/plumb/policy.rb
75
78
  - lib/plumb/result.rb
76
- - lib/plumb/rules.rb
77
79
  - lib/plumb/schema.rb
78
80
  - lib/plumb/static_class.rb
79
81
  - lib/plumb/step.rb
@@ -87,7 +89,7 @@ files:
87
89
  - lib/plumb/value_class.rb
88
90
  - lib/plumb/version.rb
89
91
  - lib/plumb/visitor_handlers.rb
90
- homepage: ''
92
+ homepage: https://github.com/ismasan/plumb
91
93
  licenses:
92
94
  - MIT
93
95
  metadata: {}