matchers 0.1.0.pre.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.
- checksums.yaml +7 -0
- data/lib/matcher/assertions.rb +19 -0
- data/lib/matcher/autoload.rb +5 -0
- data/lib/matcher/base.rb +183 -0
- data/lib/matcher/compatibility.rb +34 -0
- data/lib/matcher/debug.rb +62 -0
- data/lib/matcher/dsl/builder.rb +99 -0
- data/lib/matcher/dsl/chain.rb +84 -0
- data/lib/matcher/dsl/expression_dsl.rb +306 -0
- data/lib/matcher/dsl/matcher_dsl.rb +5 -0
- data/lib/matcher/dsl/optional.rb +82 -0
- data/lib/matcher/dsl/optional_chain.rb +24 -0
- data/lib/matcher/dsl/others.rb +28 -0
- data/lib/matcher/errors/and_error.rb +88 -0
- data/lib/matcher/errors/boolean_collector.rb +51 -0
- data/lib/matcher/errors/element_error.rb +24 -0
- data/lib/matcher/errors/empty_error.rb +23 -0
- data/lib/matcher/errors/error.rb +39 -0
- data/lib/matcher/errors/error_collector.rb +100 -0
- data/lib/matcher/errors/nested_error.rb +98 -0
- data/lib/matcher/errors/or_error.rb +88 -0
- data/lib/matcher/expression_cache.rb +57 -0
- data/lib/matcher/expression_labeler.rb +96 -0
- data/lib/matcher/expressions/array_expression.rb +45 -0
- data/lib/matcher/expressions/block.rb +189 -0
- data/lib/matcher/expressions/call.rb +307 -0
- data/lib/matcher/expressions/call_error.rb +45 -0
- data/lib/matcher/expressions/constant.rb +53 -0
- data/lib/matcher/expressions/expression.rb +237 -0
- data/lib/matcher/expressions/expression_walker.rb +77 -0
- data/lib/matcher/expressions/hash_expression.rb +59 -0
- data/lib/matcher/expressions/proc_expression.rb +96 -0
- data/lib/matcher/expressions/range_expression.rb +65 -0
- data/lib/matcher/expressions/recorder.rb +136 -0
- data/lib/matcher/expressions/rescue_last_error_expression.rb +49 -0
- data/lib/matcher/expressions/set_expression.rb +45 -0
- data/lib/matcher/expressions/string_expression.rb +53 -0
- data/lib/matcher/expressions/symbol_proc.rb +53 -0
- data/lib/matcher/expressions/variable.rb +87 -0
- data/lib/matcher/hash_stack.rb +52 -0
- data/lib/matcher/list.rb +102 -0
- data/lib/matcher/markers.rb +7 -0
- data/lib/matcher/matcher_cache.rb +18 -0
- data/lib/matcher/matchers/all_matcher.rb +60 -0
- data/lib/matcher/matchers/always_matcher.rb +34 -0
- data/lib/matcher/matchers/any_matcher.rb +70 -0
- data/lib/matcher/matchers/array_matcher.rb +72 -0
- data/lib/matcher/matchers/block_matcher.rb +61 -0
- data/lib/matcher/matchers/boolean_matcher.rb +37 -0
- data/lib/matcher/matchers/dig_matcher.rb +149 -0
- data/lib/matcher/matchers/each_matcher.rb +85 -0
- data/lib/matcher/matchers/each_pair_matcher.rb +119 -0
- data/lib/matcher/matchers/equal_matcher.rb +198 -0
- data/lib/matcher/matchers/equal_set_matcher.rb +112 -0
- data/lib/matcher/matchers/expression_matcher.rb +69 -0
- data/lib/matcher/matchers/filter_matcher.rb +115 -0
- data/lib/matcher/matchers/hash_matcher.rb +315 -0
- data/lib/matcher/matchers/imply_matcher.rb +83 -0
- data/lib/matcher/matchers/imply_some_matcher.rb +116 -0
- data/lib/matcher/matchers/index_by_matcher.rb +177 -0
- data/lib/matcher/matchers/inline_matcher.rb +101 -0
- data/lib/matcher/matchers/keys_matcher.rb +131 -0
- data/lib/matcher/matchers/kind_of_matcher.rb +35 -0
- data/lib/matcher/matchers/lazy_all_matcher.rb +69 -0
- data/lib/matcher/matchers/lazy_any_matcher.rb +69 -0
- data/lib/matcher/matchers/let_matcher.rb +73 -0
- data/lib/matcher/matchers/map_matcher.rb +148 -0
- data/lib/matcher/matchers/negated_array_matcher.rb +38 -0
- data/lib/matcher/matchers/negated_each_matcher.rb +36 -0
- data/lib/matcher/matchers/negated_each_pair_matcher.rb +38 -0
- data/lib/matcher/matchers/negated_imply_some_matcher.rb +46 -0
- data/lib/matcher/matchers/negated_matcher.rb +25 -0
- data/lib/matcher/matchers/negated_project_matcher.rb +31 -0
- data/lib/matcher/matchers/never_matcher.rb +35 -0
- data/lib/matcher/matchers/one_matcher.rb +68 -0
- data/lib/matcher/matchers/optional_matcher.rb +38 -0
- data/lib/matcher/matchers/parse_float_matcher.rb +86 -0
- data/lib/matcher/matchers/parse_integer_matcher.rb +101 -0
- data/lib/matcher/matchers/parse_iso8601_helper.rb +41 -0
- data/lib/matcher/matchers/parse_iso8601_matcher.rb +52 -0
- data/lib/matcher/matchers/parse_json_helper.rb +43 -0
- data/lib/matcher/matchers/parse_json_matcher.rb +59 -0
- data/lib/matcher/matchers/project_matcher.rb +72 -0
- data/lib/matcher/matchers/raises_matcher.rb +131 -0
- data/lib/matcher/matchers/range_matcher.rb +50 -0
- data/lib/matcher/matchers/reference_matcher.rb +213 -0
- data/lib/matcher/matchers/reference_matcher_collection.rb +57 -0
- data/lib/matcher/matchers/regexp_matcher.rb +86 -0
- data/lib/matcher/messages/expected_phrasing.rb +355 -0
- data/lib/matcher/messages/message.rb +104 -0
- data/lib/matcher/messages/message_builder.rb +35 -0
- data/lib/matcher/messages/message_rules.rb +240 -0
- data/lib/matcher/messages/namespaced_message_builder.rb +19 -0
- data/lib/matcher/messages/phrasing.rb +59 -0
- data/lib/matcher/messages/standard_message_builder.rb +105 -0
- data/lib/matcher/patterns/ast_mapping.rb +42 -0
- data/lib/matcher/patterns/capture_hole.rb +33 -0
- data/lib/matcher/patterns/constant_hole.rb +14 -0
- data/lib/matcher/patterns/hole.rb +30 -0
- data/lib/matcher/patterns/method_hole.rb +62 -0
- data/lib/matcher/patterns/pattern.rb +104 -0
- data/lib/matcher/patterns/pattern_building.rb +39 -0
- data/lib/matcher/patterns/pattern_capture.rb +11 -0
- data/lib/matcher/patterns/pattern_match.rb +29 -0
- data/lib/matcher/patterns/variable_hole.rb +14 -0
- data/lib/matcher/reporter.rb +103 -0
- data/lib/matcher/rules/message_factory.rb +26 -0
- data/lib/matcher/rules/message_rule.rb +18 -0
- data/lib/matcher/rules/message_rule_context.rb +26 -0
- data/lib/matcher/rules/rule_builder.rb +29 -0
- data/lib/matcher/rules/rule_set.rb +57 -0
- data/lib/matcher/rules/transform_builder.rb +24 -0
- data/lib/matcher/rules/transform_mapping.rb +5 -0
- data/lib/matcher/rules/transform_rule.rb +21 -0
- data/lib/matcher/state.rb +40 -0
- data/lib/matcher/testing/error_builder.rb +62 -0
- data/lib/matcher/testing/error_checker.rb +514 -0
- data/lib/matcher/testing/error_testing.rb +37 -0
- data/lib/matcher/testing/pattern_testing.rb +11 -0
- data/lib/matcher/testing/pattern_testing_scope.rb +34 -0
- data/lib/matcher/testing.rb +107 -0
- data/lib/matcher/undefined.rb +10 -0
- data/lib/matcher/utils/mapping_utils.rb +61 -0
- data/lib/matcher/utils.rb +72 -0
- data/lib/matcher/version.rb +5 -0
- data/lib/matcher.rb +346 -0
- metadata +174 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Matcher
|
|
4
|
+
##
|
|
5
|
+
# == Basic hash matching
|
|
6
|
+
#
|
|
7
|
+
# m = Matcher.build do
|
|
8
|
+
# { foo: 1 }
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# m.match?({ foo: 1 })
|
|
12
|
+
# # => true
|
|
13
|
+
# m.match({ foo: 0 })
|
|
14
|
+
# # > root[:foo]: expected 1 but got 0
|
|
15
|
+
# m.match({ foo: 1, bar: 1 })
|
|
16
|
+
# # > root[:bar]: did not expect to include key :bar
|
|
17
|
+
# # but got {:foo=>1, :bar=>2}
|
|
18
|
+
#
|
|
19
|
+
# # Use matchers for values
|
|
20
|
+
# m = Matcher.build { { foo: Integer } }
|
|
21
|
+
# m.match?({ foo: 2 }) # => true
|
|
22
|
+
#
|
|
23
|
+
# == Variables passed to value matchers
|
|
24
|
+
#
|
|
25
|
+
# HashMatcher passes +key+, +value+ and +parent+ to its value matchers.
|
|
26
|
+
#
|
|
27
|
+
# # key
|
|
28
|
+
# m = Matcher.build { { foo: _ == k.to_s } }
|
|
29
|
+
# m.match?({ foo: "foo" })
|
|
30
|
+
# # => true
|
|
31
|
+
# m.match({ foo: "bar" })
|
|
32
|
+
# # > root[:foo]: expected actual == key.to_s but got "bar" == "foo",
|
|
33
|
+
# # where k = :foo
|
|
34
|
+
#
|
|
35
|
+
# # parent
|
|
36
|
+
# m = Matcher.build do
|
|
37
|
+
# {
|
|
38
|
+
# items: Array,
|
|
39
|
+
# length: equal(parent[:items].length),
|
|
40
|
+
# }
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# m.match({ items: [1], length: 10 })
|
|
44
|
+
# # > root[:length]: expected 1 but got 10
|
|
45
|
+
#
|
|
46
|
+
# == Match hash partially
|
|
47
|
+
#
|
|
48
|
+
# Use +partial+ and +partial_r+ to match a hash only partially.
|
|
49
|
+
#
|
|
50
|
+
# == Optional keys
|
|
51
|
+
#
|
|
52
|
+
# Match value only if key included:
|
|
53
|
+
#
|
|
54
|
+
# m = Matcher.build do
|
|
55
|
+
# { optional(:foo) => 1 }
|
|
56
|
+
# end
|
|
57
|
+
#
|
|
58
|
+
# m.match?({}) # => true
|
|
59
|
+
# m.match?({ foo: 1 }) # => true
|
|
60
|
+
# m.match?({ foo: 2 }) # => false
|
|
61
|
+
# m.match?({ foo: nil }) # => false
|
|
62
|
+
#
|
|
63
|
+
# == Match remaining entries
|
|
64
|
+
#
|
|
65
|
+
# m = Matcher.build do
|
|
66
|
+
# {
|
|
67
|
+
# id: Integer,
|
|
68
|
+
# others => each_value(String),
|
|
69
|
+
# }
|
|
70
|
+
# end
|
|
71
|
+
#
|
|
72
|
+
# m.match?({ id: 1, foo: "bar" })
|
|
73
|
+
# # => true
|
|
74
|
+
# m.match({ id: 1, foo: nil })
|
|
75
|
+
# # > root[:foo]: expected a kind of String but got nil
|
|
76
|
+
#
|
|
77
|
+
# == Expression keys
|
|
78
|
+
#
|
|
79
|
+
# m = Matcher.build do
|
|
80
|
+
# { vars[:my_key] => 1 }
|
|
81
|
+
# end
|
|
82
|
+
#
|
|
83
|
+
# m.match?({ foo: 1 }, my_key: :foo) # => true
|
|
84
|
+
#
|
|
85
|
+
# @see MatcherDsl#partial
|
|
86
|
+
# @see MatcherDsl#partial_r
|
|
87
|
+
# @see MatcherDsl#each_pair
|
|
88
|
+
# @see MatcherDsl#each_key
|
|
89
|
+
# @see MatcherDsl#each_value
|
|
90
|
+
class HashMatcher < Base
|
|
91
|
+
def initialize(hash, partial: false, negated: false)
|
|
92
|
+
super()
|
|
93
|
+
|
|
94
|
+
@hash = negated ? hash.transform_values(&:~) : hash
|
|
95
|
+
@original_hash = hash
|
|
96
|
+
@partial = partial
|
|
97
|
+
@negated = negated
|
|
98
|
+
@includes_others = hash.include?(Others.instance)
|
|
99
|
+
@includes_optionals = hash.each_key.any?(Optional)
|
|
100
|
+
@includes_expressions = hash.each_key.any?(Expression)
|
|
101
|
+
|
|
102
|
+
raise "cannot use partial(others => ...)" if @partial && @includes_others
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def negate
|
|
106
|
+
HashMatcher.new(@original_hash, partial: @partial, negated: !@negated)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate(state, &)
|
|
110
|
+
return validate_negated(state, &) if @negated
|
|
111
|
+
|
|
112
|
+
actual = state.actual
|
|
113
|
+
|
|
114
|
+
unless actual.is_a?(Hash)
|
|
115
|
+
state.errors << state.expected.kind_of(Hash)
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if @includes_expressions
|
|
120
|
+
expression_values = {}
|
|
121
|
+
|
|
122
|
+
@hash.each_key.with_index do |key, i|
|
|
123
|
+
key = key.value if key.is_a?(Optional)
|
|
124
|
+
|
|
125
|
+
if key.is_a?(Expression)
|
|
126
|
+
expression_values[i] = key.evaluate(state.values)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
expected_keys = if @includes_optionals || @includes_expressions
|
|
132
|
+
@hash.keys.map.with_index do |k, i|
|
|
133
|
+
k = k.value if k.is_a?(Optional)
|
|
134
|
+
k = expression_values[i] if k.is_a?(Expression)
|
|
135
|
+
k
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
@hash.keys
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
extra_keys = actual.keys - expected_keys
|
|
142
|
+
|
|
143
|
+
if !@partial && !@includes_others
|
|
144
|
+
extra_keys.each do |key|
|
|
145
|
+
state.errors[key] << state.expected.not.having_key(key)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
@hash.each_with_index do |(key, value), i|
|
|
150
|
+
if key.is_a?(Others)
|
|
151
|
+
state.errors << yield(value, actual.slice(*extra_keys))
|
|
152
|
+
|
|
153
|
+
next
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
is_optional = key.is_a?(Optional)
|
|
157
|
+
key = key.value if is_optional
|
|
158
|
+
error_key = key
|
|
159
|
+
key = expression_values[i] if key.is_a?(Expression)
|
|
160
|
+
actual_value = actual[key]
|
|
161
|
+
|
|
162
|
+
if actual_value.nil? && !actual.key?(key)
|
|
163
|
+
state.errors << state.expected.having_key(key) unless is_optional
|
|
164
|
+
else
|
|
165
|
+
error = yield(value, actual_value, key:, parent: actual)
|
|
166
|
+
|
|
167
|
+
next if error.valid?
|
|
168
|
+
|
|
169
|
+
error_key = key_call_for(error_key) if error_key.is_a?(Expression)
|
|
170
|
+
|
|
171
|
+
state.errors[error_key] << error
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def to_s
|
|
177
|
+
if @negated
|
|
178
|
+
@partial ? "~partial(#{@original_hash})" : "neg(#{@original_hash})"
|
|
179
|
+
else
|
|
180
|
+
@partial ? "partial(#{@hash})" : @hash.to_s
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def validate_negated(state)
|
|
187
|
+
actual = state.actual
|
|
188
|
+
|
|
189
|
+
return unless actual.is_a?(Hash)
|
|
190
|
+
|
|
191
|
+
if @hash.empty?
|
|
192
|
+
if @partial
|
|
193
|
+
state.errors << state.report.kind_of(Hash)
|
|
194
|
+
elsif actual.empty?
|
|
195
|
+
state.errors << state.report.predicate(:empty?)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
return
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if @includes_expressions
|
|
202
|
+
expression_values = {}
|
|
203
|
+
|
|
204
|
+
@hash.each_key.with_index do |key, i|
|
|
205
|
+
key = key.value if key.is_a?(Optional)
|
|
206
|
+
|
|
207
|
+
if key.is_a?(Expression)
|
|
208
|
+
expression_values[i] = key.evaluate(state.values)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
expected_keys = if @includes_optionals || @includes_expressions
|
|
214
|
+
@hash.keys.map.with_index do |k, i|
|
|
215
|
+
k = k.value if k.is_a?(Optional)
|
|
216
|
+
k = expression_values[i] if k.is_a?(Expression)
|
|
217
|
+
k
|
|
218
|
+
end
|
|
219
|
+
else
|
|
220
|
+
@hash.keys
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
extra_keys = actual.keys - expected_keys
|
|
224
|
+
|
|
225
|
+
return if !@partial && !@includes_others && !extra_keys.empty?
|
|
226
|
+
|
|
227
|
+
collector = state.new_collector.or!
|
|
228
|
+
|
|
229
|
+
@hash.each_with_index do |(key, value), i|
|
|
230
|
+
if key.is_a?(Others)
|
|
231
|
+
result = yield(value, actual.slice(*extra_keys))
|
|
232
|
+
|
|
233
|
+
return nil if result.valid?
|
|
234
|
+
|
|
235
|
+
collector << result
|
|
236
|
+
|
|
237
|
+
next
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
is_optional = key.is_a?(Optional)
|
|
241
|
+
key = key.value if is_optional
|
|
242
|
+
error_key = key
|
|
243
|
+
key = expression_values[i] if key.is_a?(Expression)
|
|
244
|
+
actual_value = actual[key]
|
|
245
|
+
|
|
246
|
+
if actual_value.nil? && !actual.key?(key)
|
|
247
|
+
next if is_optional
|
|
248
|
+
|
|
249
|
+
return nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
result = yield(value, actual_value, key:, parent: actual)
|
|
253
|
+
|
|
254
|
+
return nil if result.valid?
|
|
255
|
+
|
|
256
|
+
error_key = key_call_for(error_key) if error_key.is_a?(Expression)
|
|
257
|
+
|
|
258
|
+
collector[error_key] << result
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
state.errors << if collector.empty?
|
|
262
|
+
state.report.predicate(:empty?)
|
|
263
|
+
else
|
|
264
|
+
collector.error
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def key_call_for(key)
|
|
269
|
+
Call.new(Variable.actual, :[], [key])
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
module MatcherDsl
|
|
274
|
+
##
|
|
275
|
+
# Matches hash partially
|
|
276
|
+
# @example
|
|
277
|
+
# # matches { foo: 1, bar: 2 } but not { foo: 0, bar: 2 }
|
|
278
|
+
# partial(foo: 1)
|
|
279
|
+
# @param hash [Hash]
|
|
280
|
+
# @return [HashMatcher]
|
|
281
|
+
def partial(hash)
|
|
282
|
+
hash = hash.to_h do |k, v|
|
|
283
|
+
[expression_or_value(k), matcher_of(v)]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
HashMatcher.new(hash, partial: true)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
##
|
|
290
|
+
# Matches nested hashes partially
|
|
291
|
+
# @example
|
|
292
|
+
# # matches { foo: { bar: 1, baz: 2 }, qux: 3 }
|
|
293
|
+
# partial_r(foo: { bar: 1 })
|
|
294
|
+
# # equivalent to:
|
|
295
|
+
# partial(foo: partial(bar: 1))
|
|
296
|
+
# @param hash [Hash]
|
|
297
|
+
# @return [HashMatcher]
|
|
298
|
+
def partial_r(hash)
|
|
299
|
+
hash = hash.to_h do |k, v|
|
|
300
|
+
[expression_or_value(k), partial_r_helper(v)]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
HashMatcher.new(hash, partial: true)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def partial_r_helper(value)
|
|
307
|
+
if Recorder.recorder?(value) || !value.is_a?(Hash)
|
|
308
|
+
matcher_of(value)
|
|
309
|
+
else
|
|
310
|
+
partial_r(value)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
private :partial_r_helper
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Matcher
|
|
4
|
+
class ImplyMatcher < Base
|
|
5
|
+
attr_reader :condition, :matcher
|
|
6
|
+
|
|
7
|
+
def initialize(condition, matcher, negated: false)
|
|
8
|
+
super()
|
|
9
|
+
|
|
10
|
+
@condition = condition
|
|
11
|
+
@matcher = negated ? ~matcher : matcher
|
|
12
|
+
@original_matcher = matcher
|
|
13
|
+
@negated = negated
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def negate
|
|
17
|
+
ImplyMatcher.new(@condition, @original_matcher, negated: !@negated)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate(state, &)
|
|
21
|
+
return validate_negated(state, &) if @negated
|
|
22
|
+
|
|
23
|
+
if @condition.is_a?(ExpressionMatcher)
|
|
24
|
+
begin
|
|
25
|
+
# evaluate expression directly
|
|
26
|
+
return unless @condition.expression.evaluate(state.values)
|
|
27
|
+
rescue CallError
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
else
|
|
31
|
+
return unless yield(@condition).valid?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
state.errors << yield(@matcher)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_s
|
|
38
|
+
"#{'~' if @negated}imply(#{@condition}, #{@original_matcher})"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validate_negated(state)
|
|
44
|
+
condition_errors = yield @condition
|
|
45
|
+
|
|
46
|
+
state.errors << if condition_errors.valid?
|
|
47
|
+
yield(@matcher)
|
|
48
|
+
else
|
|
49
|
+
condition_errors
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
module MatcherDsl
|
|
55
|
+
##
|
|
56
|
+
# Matches if condition mismatches or given matcher matches.
|
|
57
|
+
# In other words, ignore +matcher+ unless +condition+ is met.
|
|
58
|
+
# @example
|
|
59
|
+
# # matches "hello" and 42 but not "hi"
|
|
60
|
+
# imply(String, _.length <= 4)
|
|
61
|
+
# # alternatively:
|
|
62
|
+
# imply(String) ^ (_.length <= 4) # or
|
|
63
|
+
# of(String) >> (_.length <= 4)
|
|
64
|
+
# @overload imply(condition, matcher)
|
|
65
|
+
# @param condition [Expression]
|
|
66
|
+
# @param matcher [Base]
|
|
67
|
+
# @return [ImplyMatcher]
|
|
68
|
+
# @overload imply(condition)
|
|
69
|
+
# @param condition [Expression]
|
|
70
|
+
# @return [Chain<ImplyMatcher>]
|
|
71
|
+
# @see Base#>>
|
|
72
|
+
# @see #imply_one
|
|
73
|
+
# @see #imply_any
|
|
74
|
+
def imply(condition, matcher = UNDEFINED)
|
|
75
|
+
return Chain.new { imply(condition, _1) } if Matcher.undefined?(matcher)
|
|
76
|
+
|
|
77
|
+
condition = matcher_of(condition)
|
|
78
|
+
matcher = matcher_of(matcher)
|
|
79
|
+
|
|
80
|
+
ImplyMatcher.new(condition, matcher)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Matcher
|
|
4
|
+
class ImplySomeMatcher < Base
|
|
5
|
+
def self.check(matchers, else_matcher, count)
|
|
6
|
+
raise "count must be a positive integer or :any. Got #{count.inspect}" if
|
|
7
|
+
count != :any && (!count.is_a?(Integer) || count <= 0)
|
|
8
|
+
|
|
9
|
+
raise "else cannot be combined with count > 1" if
|
|
10
|
+
else_matcher && count != :any && count != 1
|
|
11
|
+
|
|
12
|
+
invalid_matcher = matchers.find { !_1.is_a?(ImplyMatcher) }
|
|
13
|
+
|
|
14
|
+
raise "Not an ImplyMatcher: #{invalid_matcher.inspect}" if invalid_matcher
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(matchers, else_matcher, count)
|
|
18
|
+
ImplySomeMatcher.check(matchers, else_matcher, count)
|
|
19
|
+
|
|
20
|
+
super()
|
|
21
|
+
|
|
22
|
+
@matchers = matchers
|
|
23
|
+
@else_matcher = else_matcher
|
|
24
|
+
@count = count
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def negate
|
|
28
|
+
NegatedImplySomeMatcher.new(@matchers, @else_matcher, @count)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate(state)
|
|
32
|
+
errors = state.errors
|
|
33
|
+
matchers = @matchers.filter { yield(_1.condition).valid? }
|
|
34
|
+
|
|
35
|
+
if matchers.empty?
|
|
36
|
+
errors << if @else_matcher
|
|
37
|
+
yield @else_matcher
|
|
38
|
+
else
|
|
39
|
+
state.report.namespace(:imply_some)
|
|
40
|
+
.no_condition_satisfied(@matchers.map(&:condition), @count)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
return
|
|
44
|
+
elsif @count != :any && matchers.length != @count
|
|
45
|
+
errors << state.report.namespace(:imply_some)
|
|
46
|
+
.x_conditions_satisfied(matchers.map(&:condition), @count)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
matchers.each { errors << yield(_1.matcher) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_s
|
|
53
|
+
args = @matchers.map(&:to_s)
|
|
54
|
+
|
|
55
|
+
case @count
|
|
56
|
+
when :any
|
|
57
|
+
method = "imply_any"
|
|
58
|
+
when 1
|
|
59
|
+
method = "imply_one"
|
|
60
|
+
else
|
|
61
|
+
method = "imply_some"
|
|
62
|
+
args << "count: #{@count}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
args << "else: #{@else_matcher}" if @else_matcher
|
|
66
|
+
|
|
67
|
+
"#{method}(#{args.join(', ')})"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
module MatcherDsl
|
|
72
|
+
##
|
|
73
|
+
# Matches exactly one implied matcher
|
|
74
|
+
# @example
|
|
75
|
+
# # Strings should be lower case and integers positive. But it should
|
|
76
|
+
# # either be a string or an integer.
|
|
77
|
+
# # matches "foo" and 1 but not "BAR", -1, or nil
|
|
78
|
+
# imply_one(
|
|
79
|
+
# imply(String, _ == _.downcase),
|
|
80
|
+
# imply(Integer, _.positive?),
|
|
81
|
+
# )
|
|
82
|
+
# @param matchers [ImplyMatcher]
|
|
83
|
+
# @param else [Base] if no condition passed match against +else+ matcher.
|
|
84
|
+
# @return [ImplySomeMatcher]
|
|
85
|
+
# @see #imply
|
|
86
|
+
def imply_one(*matchers, else: UNDEFINED)
|
|
87
|
+
els = { else: }[:else]
|
|
88
|
+
else_matcher = Matcher.undefined?(els) ? nil : matcher_of(els)
|
|
89
|
+
|
|
90
|
+
ImplySomeMatcher.new(matchers, else_matcher, 1)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
##
|
|
94
|
+
# Matches at least one implied matcher
|
|
95
|
+
# @example
|
|
96
|
+
# # matches 9, 12, 40 but not 8, 21, 15.5
|
|
97
|
+
# imply_any(
|
|
98
|
+
# imply(_.even?, _ > 10),
|
|
99
|
+
# imply(_ % 3 == 0, _ < 20),
|
|
100
|
+
# )
|
|
101
|
+
# @param matchers [ImplyMatcher]
|
|
102
|
+
# @param else [Base] if no condition passed match against +else+ matcher.
|
|
103
|
+
# @return [ImplySomeMatcher]
|
|
104
|
+
# @see #imply
|
|
105
|
+
def imply_any(*matchers, else: UNDEFINED)
|
|
106
|
+
els = { else: }[:else]
|
|
107
|
+
else_matcher = Matcher.undefined?(els) ? nil : matcher_of(els)
|
|
108
|
+
|
|
109
|
+
ImplySomeMatcher.new(matchers, else_matcher, :any)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def imply_some(*matchers, count:)
|
|
113
|
+
ImplySomeMatcher.new(matchers, nil, count)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Matcher
|
|
4
|
+
class IndexByMatcher < Base
|
|
5
|
+
include MappingUtils
|
|
6
|
+
|
|
7
|
+
def initialize(projection, matcher, negated: false)
|
|
8
|
+
super()
|
|
9
|
+
|
|
10
|
+
@projection = projection
|
|
11
|
+
@matcher = negated ? ~matcher : matcher
|
|
12
|
+
@original_matcher = matcher
|
|
13
|
+
@negated = negated
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def negate
|
|
17
|
+
IndexByMatcher.new(@projection, @original_matcher, negated: !@negated)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate(state, &)
|
|
21
|
+
return validate_negated(state, &) if @negated
|
|
22
|
+
|
|
23
|
+
actual = state.actual
|
|
24
|
+
|
|
25
|
+
unless actual.respond_to?(:each)
|
|
26
|
+
state.errors << state.expected.responding_to(:each)
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
values = state.values
|
|
31
|
+
failed = false
|
|
32
|
+
index = {}
|
|
33
|
+
mapping = {}
|
|
34
|
+
duplicates = []
|
|
35
|
+
|
|
36
|
+
actual.each_with_index do |item, i|
|
|
37
|
+
key = @projection.evaluate(
|
|
38
|
+
values.merge(actual: item, index: i, original: actual),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
mapped_index = mapping[key]
|
|
42
|
+
|
|
43
|
+
if mapped_index
|
|
44
|
+
duplicates << [key, i, mapped_index]
|
|
45
|
+
else
|
|
46
|
+
index[key] = item
|
|
47
|
+
mapping[key] = i
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
key
|
|
51
|
+
rescue CallError => e
|
|
52
|
+
state.errors[i] << e.message_for_errors(item)
|
|
53
|
+
failed = true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
duplicates.each do |key, i, j|
|
|
57
|
+
state.errors[i] << state.expected(actual[i])
|
|
58
|
+
.not.duplicate_by(@projection, key, j)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
return if failed
|
|
62
|
+
|
|
63
|
+
errors = yield(@matcher, index, original: actual)
|
|
64
|
+
errors = map_errors2(errors, mapping) unless state.boolean?
|
|
65
|
+
|
|
66
|
+
state.errors << errors
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_s
|
|
70
|
+
"#{'~' if @negated}index_by(#{@projection}, #{@original_matcher})"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def validate_negated(state)
|
|
76
|
+
actual = state.actual
|
|
77
|
+
|
|
78
|
+
return unless actual.respond_to?(:each)
|
|
79
|
+
|
|
80
|
+
values = state.values
|
|
81
|
+
index = {}
|
|
82
|
+
mapping = {}
|
|
83
|
+
|
|
84
|
+
actual.each_with_index do |item, i|
|
|
85
|
+
key = @projection.evaluate(
|
|
86
|
+
values.merge(actual: item, index: i, original: actual),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return nil if mapping.key?(key)
|
|
90
|
+
|
|
91
|
+
index[key] = item
|
|
92
|
+
mapping[key] = i
|
|
93
|
+
|
|
94
|
+
key
|
|
95
|
+
rescue CallError
|
|
96
|
+
return nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
errors = yield(@matcher, index, original: actual)
|
|
100
|
+
|
|
101
|
+
state.errors << map_errors2(errors, mapping)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def map_errors2(error, mapping)
|
|
105
|
+
map_errors(error) do |nested_error|
|
|
106
|
+
key = nested_error.key
|
|
107
|
+
|
|
108
|
+
next unless index_call?(key)
|
|
109
|
+
|
|
110
|
+
index = mapping[operand_of(key)]
|
|
111
|
+
|
|
112
|
+
NestedError.new(index_call_to(index), nested_error.child) if index
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def mapped_base
|
|
117
|
+
return @mapped_base if @mapped_base
|
|
118
|
+
|
|
119
|
+
expression = @projection
|
|
120
|
+
with_index = expression.variables.include?(:index)
|
|
121
|
+
element = expression.free_symbol(:e)
|
|
122
|
+
parameters = [[:opt, element]]
|
|
123
|
+
parameters << %i[opt index] if with_index
|
|
124
|
+
substituted = expression.substitute(actual: element, original: :actual)
|
|
125
|
+
pair = ArrayExpression.new([substituted, Variable.new(element)])
|
|
126
|
+
block = Block.new(parameters, pair)
|
|
127
|
+
|
|
128
|
+
receiver = if with_index
|
|
129
|
+
Call.new(Variable.actual, :each_with_index)
|
|
130
|
+
else
|
|
131
|
+
Variable.actual
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
@mapped_base = Call.new(receiver, :to_h, [], {}, block)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
module MatcherDsl
|
|
139
|
+
##
|
|
140
|
+
# Matches against an indexed version of actual.
|
|
141
|
+
#
|
|
142
|
+
# This is really useful when validating an array of items where the order
|
|
143
|
+
# shouldn't matter.
|
|
144
|
+
# @example
|
|
145
|
+
# # matches:
|
|
146
|
+
# # [
|
|
147
|
+
# # { name: "bar", value: 2 },
|
|
148
|
+
# # { name: "foo", value: 1 },
|
|
149
|
+
# # ]
|
|
150
|
+
# index_by(_[:name], {
|
|
151
|
+
# "foo" => { name: "foo", value: 1 },
|
|
152
|
+
# "bar" => { name: "bar", value: 2 },
|
|
153
|
+
# })
|
|
154
|
+
# # alternatively:
|
|
155
|
+
# index_by(_[:name]) ^ {
|
|
156
|
+
# "foo" => { name: "foo", value: 1 },
|
|
157
|
+
# "bar" => { name: "bar", value: 2 },
|
|
158
|
+
# }
|
|
159
|
+
# @overload index_by(expression, matcher)
|
|
160
|
+
# @param expression [Expression]
|
|
161
|
+
# @param matcher [Base]
|
|
162
|
+
# @return [IndexByMatcher]
|
|
163
|
+
# @overload index_by(expression)
|
|
164
|
+
# @param expression [Expression]
|
|
165
|
+
# @return [Chain<IndexByMatcher>]
|
|
166
|
+
def index_by(expression, matcher = UNDEFINED)
|
|
167
|
+
if Matcher.undefined?(matcher)
|
|
168
|
+
return Chain.new { index_by(expression, _1) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
expression = expression_of(expression)
|
|
172
|
+
matcher = matcher_of(matcher)
|
|
173
|
+
|
|
174
|
+
IndexByMatcher.new(expression, matcher)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|