normalizy 0.1.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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/LICENSE +21 -0
  4. data/README.md +341 -0
  5. data/lib/generators/normalizy/install_generator.rb +13 -0
  6. data/lib/generators/normalizy/templates/config/initializers/normalizy.rb +7 -0
  7. data/lib/normalizy/config.rb +35 -0
  8. data/lib/normalizy/extensions.rb +105 -0
  9. data/lib/normalizy/filters/number.rb +17 -0
  10. data/lib/normalizy/filters/strip.rb +19 -0
  11. data/lib/normalizy/filters.rb +8 -0
  12. data/lib/normalizy/rspec/matcher.rb +115 -0
  13. data/lib/normalizy/version.rb +5 -0
  14. data/lib/normalizy.rb +20 -0
  15. data/spec/normalizy/config/add_spec.rb +17 -0
  16. data/spec/normalizy/config/alias_spec.rb +63 -0
  17. data/spec/normalizy/config/default_filters_spec.rb +19 -0
  18. data/spec/normalizy/config/initialize_spec.rb +12 -0
  19. data/spec/normalizy/config/normalizy_aliases_spec.rb +9 -0
  20. data/spec/normalizy/config/normalizy_raws_spec.rb +9 -0
  21. data/spec/normalizy/extensions/apply_normalizations_spec.rb +163 -0
  22. data/spec/normalizy/extensions/normalizy_rules_spec.rb +244 -0
  23. data/spec/normalizy/extensions/normalizy_spec.rb +11 -0
  24. data/spec/normalizy/filters/number_spec.rb +15 -0
  25. data/spec/normalizy/filters/strip_spec.rb +13 -0
  26. data/spec/normalizy/normalizy/configure_spec.rb +11 -0
  27. data/spec/normalizy/rspec/matcher/description_spec.rb +26 -0
  28. data/spec/normalizy/rspec/matcher/failure_message_spec.rb +70 -0
  29. data/spec/normalizy/rspec/matcher/failure_message_when_negated_spec.rb +32 -0
  30. data/spec/normalizy/rspec/matcher/from_spec.rb +18 -0
  31. data/spec/normalizy/rspec/matcher/matchers_spec.rb +97 -0
  32. data/spec/normalizy/rspec/matcher/to_spec.rb +18 -0
  33. data/spec/normalizy/rspec/normalizy_spec.rb +8 -0
  34. data/spec/rails_helper.rb +9 -0
  35. data/spec/support/common.rb +11 -0
  36. data/spec/support/db/schema.rb +13 -0
  37. data/spec/support/filters/blacklist_filter.rb +15 -0
  38. data/spec/support/models/clean.rb +4 -0
  39. data/spec/support/models/user.rb +9 -0
  40. metadata +210 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 781b68ea361e5e2a698bba7230a2a2459f2cb758
4
+ data.tar.gz: 01d9c17b15ee6ec08c5dda0da645897a43f9370d
5
+ SHA512:
6
+ metadata.gz: cef08c0e44de84158e91659510c0d4bcd50715739a977452c9c7037b44dbaa06acfe29993388b357a1c0e863e7cafa22e8b5acbc8f995b740340c8efa7aeef76
7
+ data.tar.gz: 632e6116a884839be1e018f905599766ecf9223ef9336ae5ba79c503bd549c41242fdcab975a489372f75a530f97b495ec71e0f53d649e0c8cf844c870788341
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## v0.1.0
2
+
3
+ - First release.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Washington Botelho
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,341 @@
1
+ # Normalizy
2
+
3
+ [![Build Status](https://travis-ci.org/wbotelhos/normalizy.svg)](https://travis-ci.org/wbotelhos/normalizy)
4
+ [![Gem Version](https://badge.fury.io/rb/normalizy.svg)](https://badge.fury.io/rb/normalizy)
5
+
6
+ Attribute normalizer for ActiveRecord.
7
+
8
+ ## Description
9
+
10
+ If you know the obvious format of an input, why not normalize it instead of raise an validation error to your use? Make the follow email ` myemail@example.org ` valid like `email@example.com` with no need to override acessors methods.
11
+
12
+ ## install
13
+
14
+ Add the following code on your `Gemfile` and run `bundle install`:
15
+
16
+ ```ruby
17
+ gem 'normalizy'
18
+ ```
19
+
20
+ So generates an initializer for future custom configurations:
21
+
22
+ ```ruby
23
+ rails g normalizy:install
24
+ ```
25
+
26
+ It will generates a file `config/initializers/normalizy.rb` where you can configure you own normalizer and choose some defaults one.
27
+
28
+ ## Usage
29
+
30
+ On your model, just add `normalizy` callback with the attribute you want to normalize and the filter to be used:
31
+
32
+ ```ruby
33
+ class User < ApplicationRecord
34
+ normalizy :name, with: :strip
35
+ end
36
+ ```
37
+
38
+ Now some email like ` myemail@example.org ` will be saved as `email@example.com`.
39
+
40
+ ## Filters
41
+
42
+ We have a couple of built-in filters. The most one is just a wrapper o the original String methods:
43
+
44
+ ### Number
45
+
46
+ ```ruby
47
+ normalizy :age, with: :number
48
+
49
+ ' 32'
50
+ # 32
51
+ ```
52
+
53
+ By default, `number` works with input value before [Type Cast](#type-cast)
54
+ ### Strip
55
+
56
+ Options:
57
+
58
+ - `side`: `:left`, `:right` or `:both`. Default: `:both`
59
+
60
+ ```ruby
61
+ normalizy :name, with: :strip
62
+ ' Washington Botelho '
63
+ # 'Washington Botelho'
64
+ ```
65
+
66
+ ```ruby
67
+ normalizy :name, with: { strip: { side: :left } }
68
+ ' Washington Botelho '
69
+ # 'Washington Botelho '
70
+ ```
71
+
72
+ ```ruby
73
+ normalizy :name, with: { strip: { side: :right } }
74
+ ' Washington Botelho '
75
+ # ' Washington Botelho'
76
+ ```
77
+
78
+ ```ruby
79
+ normalizy :name, with: { strip: { side: :both } }
80
+ ' Washington Botelho '
81
+ # 'Washington Botelho'
82
+ ```
83
+
84
+ As you can see, the rules can be passed as Symbol/String or as Hash if it has options.
85
+
86
+ ## Multiple Filters
87
+
88
+ You can normalize with a couple of filters at once:
89
+
90
+ ```ruby
91
+ normalizy :name, with: %i[squish titleize]
92
+ ' washington botelho '
93
+ # 'Washington Botelho'
94
+ ```
95
+
96
+ ## Multiple Attributes
97
+
98
+ You can normalize more than one attribute at once too, with one or muiltiple filters:
99
+
100
+ ```ruby
101
+ normalizy :email, :username, with: :downcase
102
+ ```
103
+
104
+ Of course you can declare muiltiples attribute and multiple filters together.
105
+ It is possible to make sequential normalizy calls:
106
+
107
+ ```ruby
108
+ normalizy :email, :username, with: :squish
109
+ normalizy :email , with: :downcase
110
+ ```
111
+
112
+ In this case, each line will be evaluated from the top to the bottom.
113
+
114
+ ## Default Filters
115
+
116
+ You can configure some default filters to be runned. Edit you initializer at `config/initializers/normalizy.rb`:
117
+
118
+ ```ruby
119
+ Normalizy.configure do |config|
120
+ config.default_filters = [:squish]
121
+ end
122
+ ```
123
+
124
+ Now, all normalization will include squish, even when no rule is declared.
125
+
126
+ ```ruby
127
+ normalizy :name
128
+ ' Washington Botelho '
129
+ # 'Washington Botelho'
130
+ ```
131
+
132
+ If you declare some filter, the default filter `squish` will be runned together:
133
+
134
+ ```ruby
135
+ normalizy :name, with: :downcase
136
+ ' washington botelho '
137
+ # 'Washington Botelho'
138
+ ```
139
+
140
+ ## Custom Filter
141
+
142
+ You can create a custom filter that implements `call` method with an `input` as argument and an optional `options`:
143
+
144
+ ```ruby
145
+ module Normalizy
146
+ module Filters
147
+ module Blacklist
148
+ def self.call(input)
149
+ input.gsub 'Fuck', replacement: '***'
150
+ end
151
+ end
152
+ end
153
+ end
154
+ ```
155
+
156
+ ```ruby
157
+ Normalizy.configure do |config|
158
+ config.add :blacklist, Normalizy::Filters::Blacklist
159
+ end
160
+ ```
161
+
162
+ Now you can use your custom filter:
163
+
164
+ ```ruby
165
+ normalizy :name, with: :blacklist
166
+
167
+ 'Washington Fuck Botelho'
168
+ # 'Washington *** Botelho'
169
+ ```
170
+
171
+ If you want to pass options to your filter, just call it as hash and the value will be passed to the custom filter:
172
+
173
+ ```ruby
174
+ module Normalizy
175
+ module Filters
176
+ module Blacklist
177
+ def self.call(input, options: {})
178
+ input.gsub 'Fuck', replacement: options[:replacement]
179
+ end
180
+ end
181
+ end
182
+ end
183
+ ```
184
+
185
+ ```ruby
186
+ normalizy :name, with: blacklist: { replacement: '---' }
187
+
188
+ 'Washington Fuck Botelho'
189
+ # 'Washington --- Botelho'
190
+ ```
191
+
192
+ You can pass a block and it will be received on filter:
193
+
194
+ ```ruby
195
+ module Normalizy
196
+ module Filters
197
+ module Blacklist
198
+ def self.call(input, options: {})
199
+ value = input.gsub('Fuck', 'filtered')
200
+
201
+ value = yield(value) if block_given?
202
+
203
+ value
204
+ end
205
+ end
206
+ end
207
+ end
208
+ ```
209
+
210
+ ```ruby
211
+ normalizy :name, with: :blacklist, &->(value) { value.sub('filtered', '(filtered 2x)') }
212
+
213
+ 'Washington Fuck Botelho'
214
+ # 'Washington (filtered 2x) Botelho'
215
+ ```
216
+
217
+ The block
218
+
219
+ ## Method Filters
220
+
221
+ If a built-in filter is not found, Normalizy will try to find a method to suply the normalize with the same name of the given filter:
222
+
223
+ ```ruby
224
+ normalizy :birthday, with: :parse_date
225
+
226
+ def parse_date(input, options = {})
227
+ Time.zone.parse(input).strftime '%Y/%m/%d'
228
+ end
229
+
230
+ '1984-10-23'
231
+ # '1984/10/23'
232
+ ```
233
+
234
+ If you gives an option, it will be passed to the function too:
235
+
236
+ ```ruby
237
+ normalizy :birthday, with: { parse_date: { format: '%Y/%m/%d' }
238
+
239
+ def parse_date(input, options = {})
240
+ Time.zone.parse(input).strftime options[:format]
241
+ end
242
+
243
+ '1984-10-23'
244
+ # '1984/10/23'
245
+ ```
246
+
247
+ Block methods works here too.
248
+
249
+ ## Native Filter
250
+
251
+ After the missing built-in and class method, the fallback will be the value of native methods.
252
+
253
+ ```ruby
254
+ normalizy :name, with: :reverse
255
+
256
+ 'Washington Botelho'
257
+ # "ohletoB notgnihsaW"
258
+ ```
259
+
260
+ ## Inline Filter
261
+
262
+ Maybe you want to declare an inline filter, in this case, just use a Lambda or Proc:
263
+
264
+ ```ruby
265
+ normalizy :age, with: ->(input) { input.abs }
266
+
267
+ -32
268
+ # 32
269
+ ```
270
+
271
+ You can use it on filters declaration too:
272
+
273
+ ```ruby
274
+ Normalizy.configure do |config|
275
+ config.add :age, ->(input) { input.abs }
276
+ end
277
+ ```
278
+
279
+ ## Type Cast
280
+
281
+ An input field with `$ 42.00` dollars when sent to model with a field with `integer` type,
282
+ will be converted to `0`, since the type does not match. But you want to use the value before Rails do this cast the type.
283
+
284
+ To receive the value before type cast, just pass a `raw` options as `true`:
285
+
286
+ ```ruby
287
+ normalizy :amount, with: :number, raw: true
288
+
289
+ '$ 42.00'
290
+ # 4200
291
+ ```
292
+
293
+ To avoid repeat the `raw: true` where you will always to use, you can register a filter with this options:
294
+
295
+ ```ruby
296
+ Normalizy.configure do |config|
297
+ config.add :money, ->(input) { input.gsub(/\D/, '') }, raw: true
298
+ end
299
+ ```
300
+
301
+ ## Alias
302
+
303
+ Sometimes you want to give a better name to your filter, just to keep the things semantic.
304
+ But duplicates the code just to redefine a new name is not a good idea, so, just create an alias:
305
+
306
+ ```ruby
307
+ Normalizy.configure do |config|
308
+ config.alias :money, :number
309
+ end
310
+ ```
311
+
312
+ Now, `money` will delegate to `number` filter.
313
+ Since we already know the need of `raw` options, we can declare it here too:
314
+
315
+ ```ruby
316
+ Normalizy.configure do |config|
317
+ config.alias :money, :number, raw: true
318
+ end
319
+ ```
320
+
321
+ But `number` filter already works with `raw: true`, don't need to tell it again.
322
+ An our previous example, about `amount`, was refactored to:
323
+
324
+ ```ruby
325
+ normalizy :amount, with: :money
326
+
327
+ '$ 42.00'
328
+ # 4200
329
+ ```
330
+
331
+ If you need to alias multiple filters, just provide an array of them:
332
+
333
+ ```ruby
334
+ Normalizy.configure do |config|
335
+ config.alias :username, %i[squish downcase]
336
+ end
337
+ ```
338
+
339
+ ## Love it!
340
+
341
+ Via [PayPal](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=X8HEP2878NDEG&item_name=normalizy) or [Gratipay](https://gratipay.com/~wbotelhos). Thanks! (:
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Normalizy
4
+ class InstallGenerator < Rails::Generators::Base
5
+ source_root File.expand_path('../templates', __FILE__)
6
+
7
+ desc 'creates an initializer'
8
+
9
+ def copy_initializer
10
+ copy_file 'config/initializers/normalizy.rb', 'config/initializers/normalizy.rb'
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ Normalizy.configure do |config|
4
+ config.default_filters = [:squish]
5
+
6
+ # config.filters[:blacklist] = Blacklist
7
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'normalizy/filters'
4
+
5
+ module Normalizy
6
+ class Config
7
+ attr_accessor :default_filters
8
+ attr_reader :filters, :normalizy_aliases, :normalizy_raws
9
+
10
+ def add(name, value, raw: false)
11
+ @filters[name] = value
12
+ @normalizy_raws << name if raw
13
+
14
+ self
15
+ end
16
+
17
+ def alias(name, to, raw: false)
18
+ @normalizy_aliases[name] = to
19
+ @normalizy_raws << name if raw
20
+
21
+ self
22
+ end
23
+
24
+ def initialize
25
+ @default_filters = {}
26
+ @normalizy_aliases = {}
27
+ @normalizy_raws = [:number]
28
+
29
+ @filters = {
30
+ number: Normalizy::Filters::Number,
31
+ strip: Normalizy::Filters::Strip
32
+ }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Normalizy
4
+ module Extension
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ before_validation :apply_normalizy, if: -> {
9
+ self.class.respond_to? :normalizy
10
+ }
11
+
12
+ private
13
+
14
+ def apply_normalizy
15
+ (self.class.normalizy_rules || {}).each do |attribute, rules|
16
+ rules.each { |rule| normalizy! rule.merge(attribute: attribute) }
17
+ end
18
+ end
19
+
20
+ def extract_filter(rule, filters: Normalizy.config.filters)
21
+ if rule.is_a?(Hash)
22
+ result = filters[rule.keys.first] || rule.keys.first
23
+ options = rule.values.first
24
+ else
25
+ result = filters[rule]
26
+ end
27
+
28
+ [result || rule, options || {}]
29
+ end
30
+
31
+ def extract_value(value, filter, options, block)
32
+ if filter.respond_to?(:call)
33
+ if filter.method(:call).arity == -2
34
+ filter.call value, options, &block
35
+ else
36
+ filter.call value, &block
37
+ end
38
+ elsif respond_to?(filter)
39
+ send filter, value, options, &block
40
+ elsif value.respond_to?(filter)
41
+ value.send filter, &block
42
+ else
43
+ value
44
+ end
45
+ end
46
+
47
+ def normalizy!(attribute:, rules:, options:, block:)
48
+ return if rules.blank? && block.blank?
49
+
50
+ aliases = Normalizy.config.normalizy_aliases
51
+ value = nil
52
+
53
+ [rules].flatten.compact.each do |rule|
54
+ value = original_value(attribute, rule, options)
55
+ aliased_rules = [aliases.key?(rule) ? aliases[rule] : rule]
56
+
57
+ aliased_rules.flatten.compact.each do |alias_rule|
58
+ filter, filter_options = extract_filter(alias_rule)
59
+ value = extract_value(value, filter, filter_options, block)
60
+ end
61
+ end
62
+
63
+ return unless value
64
+
65
+ write attribute, value
66
+ end
67
+
68
+ def original_value(attribute, rule, options)
69
+ if raw? attribute, rule, options
70
+ send "#{attribute}_before_type_cast"
71
+ else
72
+ send attribute
73
+ end
74
+ end
75
+
76
+ def raw?(attribute, rule, options)
77
+ return false unless respond_to?("#{attribute}_before_type_cast")
78
+
79
+ options[:raw] || Normalizy.config.normalizy_raws.include?(rule)
80
+ end
81
+
82
+ def write(attribute, value)
83
+ write_attribute attribute, value
84
+ rescue ActiveModel::MissingAttributeError
85
+ send "#{attribute}=", value
86
+ end
87
+ end
88
+
89
+ module ClassMethods
90
+ attr_accessor :normalizy_rules
91
+
92
+ def normalizy(*args, &block)
93
+ options = args.extract_options!
94
+ rules = options[:with] || Normalizy.config.default_filters
95
+
96
+ self.normalizy_rules ||= {}
97
+
98
+ args.each do |field|
99
+ normalizy_rules[field] ||= []
100
+ normalizy_rules[field] << { block: block, options: options.except(:with), rules: rules }
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Normalizy
4
+ module Filters
5
+ module Number
6
+ def self.call(input)
7
+ return input unless input.is_a?(String)
8
+
9
+ value = input.gsub(/\D/, '')
10
+
11
+ return nil if value.blank?
12
+
13
+ value.to_i
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Normalizy
4
+ module Filters
5
+ module Strip
6
+ def self.call(input, options = {})
7
+ return input unless input.is_a?(String)
8
+
9
+ regex = {
10
+ both: '\A\s*|\s*\z',
11
+ left: '\A\s*',
12
+ right: '\s*\z'
13
+ }[options[:side] || :both]
14
+
15
+ input.gsub Regexp.new(/#{regex}/), ''
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Normalizy
4
+ module Filters
5
+ end
6
+ end
7
+
8
+ Dir["#{File.dirname(__FILE__)}/filters/*.rb"].each { |file| require file }
@@ -0,0 +1,115 @@
1
+ module Normalizy
2
+ module RSpec
3
+ def normalizy(attribute)
4
+ Matcher.new attribute
5
+ end
6
+
7
+ class Matcher
8
+ def initialize(attribute)
9
+ @attribute = attribute
10
+ end
11
+
12
+ def description
13
+ return "normalizy #{@attribute} with #{with_expected}" if @with.present?
14
+
15
+ "normalizy #{@attribute} from #{from_value} to #{to_value}"
16
+ end
17
+
18
+ def failure_message
19
+ return "expected: #{with_expected}\n got: #{actual_value}" if @with.present?
20
+
21
+ "expected: #{to_value}\n got: #{actual_value}"
22
+ end
23
+
24
+ def failure_message_when_negated
25
+ return "expected: value != #{with_expected}\n got: #{actual_value}" if @with.present?
26
+
27
+ "expected: value != #{to_value}\n got: #{actual_value}"
28
+ end
29
+
30
+ def from(value)
31
+ @from = value
32
+
33
+ self
34
+ end
35
+
36
+ def matches?(subject)
37
+ @subject = subject
38
+
39
+ if @with.present?
40
+ options = @subject.class.normalizy_rules[@attribute]
41
+
42
+ return false if options.blank?
43
+
44
+ options.each do |option|
45
+ rules = option[:rules]
46
+
47
+ return true if rules.is_a?(Array) && rules.include?(@with)
48
+ return true if rules == @with
49
+ end
50
+
51
+ false
52
+ else
53
+ @subject.send "#{@attribute}=", @from
54
+
55
+ @subject.send(@attribute) == @to
56
+ end
57
+ end
58
+
59
+ def to(value)
60
+ @to = value
61
+
62
+ self
63
+ end
64
+
65
+ def with(value)
66
+ @with = value
67
+
68
+ self
69
+ end
70
+
71
+ private
72
+
73
+ def actual_value
74
+ return with_value if @with
75
+
76
+ value = @subject.send(@attribute)
77
+
78
+ value.is_a?(String) ? %("#{value}") : value
79
+ end
80
+
81
+ def from_value
82
+ @from.nil? ? :nil : %("#{@from}")
83
+ end
84
+
85
+ def to_value
86
+ @to.nil? ? :nil : %("#{@to}")
87
+ end
88
+
89
+ def with_expected
90
+ @with
91
+ end
92
+
93
+ def with_value
94
+ options = @subject.class.normalizy_rules[@attribute]
95
+
96
+ return :nil if options.nil?
97
+ return %("#{options}") if options.blank?
98
+
99
+ result = options.map do |option|
100
+ rules = option[:rules]
101
+
102
+ if rules.nil?
103
+ :nil
104
+ elsif rules.blank?
105
+ %("#{rules}")
106
+ else
107
+ rules
108
+ end
109
+ end
110
+
111
+ result.join ', '
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Normalizy
4
+ VERSION = '0.1.0'
5
+ end