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.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/lib/matcher/assertions.rb +19 -0
  3. data/lib/matcher/autoload.rb +5 -0
  4. data/lib/matcher/base.rb +183 -0
  5. data/lib/matcher/compatibility.rb +34 -0
  6. data/lib/matcher/debug.rb +62 -0
  7. data/lib/matcher/dsl/builder.rb +99 -0
  8. data/lib/matcher/dsl/chain.rb +84 -0
  9. data/lib/matcher/dsl/expression_dsl.rb +306 -0
  10. data/lib/matcher/dsl/matcher_dsl.rb +5 -0
  11. data/lib/matcher/dsl/optional.rb +82 -0
  12. data/lib/matcher/dsl/optional_chain.rb +24 -0
  13. data/lib/matcher/dsl/others.rb +28 -0
  14. data/lib/matcher/errors/and_error.rb +88 -0
  15. data/lib/matcher/errors/boolean_collector.rb +51 -0
  16. data/lib/matcher/errors/element_error.rb +24 -0
  17. data/lib/matcher/errors/empty_error.rb +23 -0
  18. data/lib/matcher/errors/error.rb +39 -0
  19. data/lib/matcher/errors/error_collector.rb +100 -0
  20. data/lib/matcher/errors/nested_error.rb +98 -0
  21. data/lib/matcher/errors/or_error.rb +88 -0
  22. data/lib/matcher/expression_cache.rb +57 -0
  23. data/lib/matcher/expression_labeler.rb +96 -0
  24. data/lib/matcher/expressions/array_expression.rb +45 -0
  25. data/lib/matcher/expressions/block.rb +189 -0
  26. data/lib/matcher/expressions/call.rb +307 -0
  27. data/lib/matcher/expressions/call_error.rb +45 -0
  28. data/lib/matcher/expressions/constant.rb +53 -0
  29. data/lib/matcher/expressions/expression.rb +237 -0
  30. data/lib/matcher/expressions/expression_walker.rb +77 -0
  31. data/lib/matcher/expressions/hash_expression.rb +59 -0
  32. data/lib/matcher/expressions/proc_expression.rb +96 -0
  33. data/lib/matcher/expressions/range_expression.rb +65 -0
  34. data/lib/matcher/expressions/recorder.rb +136 -0
  35. data/lib/matcher/expressions/rescue_last_error_expression.rb +49 -0
  36. data/lib/matcher/expressions/set_expression.rb +45 -0
  37. data/lib/matcher/expressions/string_expression.rb +53 -0
  38. data/lib/matcher/expressions/symbol_proc.rb +53 -0
  39. data/lib/matcher/expressions/variable.rb +87 -0
  40. data/lib/matcher/hash_stack.rb +52 -0
  41. data/lib/matcher/list.rb +102 -0
  42. data/lib/matcher/markers.rb +7 -0
  43. data/lib/matcher/matcher_cache.rb +18 -0
  44. data/lib/matcher/matchers/all_matcher.rb +60 -0
  45. data/lib/matcher/matchers/always_matcher.rb +34 -0
  46. data/lib/matcher/matchers/any_matcher.rb +70 -0
  47. data/lib/matcher/matchers/array_matcher.rb +72 -0
  48. data/lib/matcher/matchers/block_matcher.rb +61 -0
  49. data/lib/matcher/matchers/boolean_matcher.rb +37 -0
  50. data/lib/matcher/matchers/dig_matcher.rb +149 -0
  51. data/lib/matcher/matchers/each_matcher.rb +85 -0
  52. data/lib/matcher/matchers/each_pair_matcher.rb +119 -0
  53. data/lib/matcher/matchers/equal_matcher.rb +198 -0
  54. data/lib/matcher/matchers/equal_set_matcher.rb +112 -0
  55. data/lib/matcher/matchers/expression_matcher.rb +69 -0
  56. data/lib/matcher/matchers/filter_matcher.rb +115 -0
  57. data/lib/matcher/matchers/hash_matcher.rb +315 -0
  58. data/lib/matcher/matchers/imply_matcher.rb +83 -0
  59. data/lib/matcher/matchers/imply_some_matcher.rb +116 -0
  60. data/lib/matcher/matchers/index_by_matcher.rb +177 -0
  61. data/lib/matcher/matchers/inline_matcher.rb +101 -0
  62. data/lib/matcher/matchers/keys_matcher.rb +131 -0
  63. data/lib/matcher/matchers/kind_of_matcher.rb +35 -0
  64. data/lib/matcher/matchers/lazy_all_matcher.rb +69 -0
  65. data/lib/matcher/matchers/lazy_any_matcher.rb +69 -0
  66. data/lib/matcher/matchers/let_matcher.rb +73 -0
  67. data/lib/matcher/matchers/map_matcher.rb +148 -0
  68. data/lib/matcher/matchers/negated_array_matcher.rb +38 -0
  69. data/lib/matcher/matchers/negated_each_matcher.rb +36 -0
  70. data/lib/matcher/matchers/negated_each_pair_matcher.rb +38 -0
  71. data/lib/matcher/matchers/negated_imply_some_matcher.rb +46 -0
  72. data/lib/matcher/matchers/negated_matcher.rb +25 -0
  73. data/lib/matcher/matchers/negated_project_matcher.rb +31 -0
  74. data/lib/matcher/matchers/never_matcher.rb +35 -0
  75. data/lib/matcher/matchers/one_matcher.rb +68 -0
  76. data/lib/matcher/matchers/optional_matcher.rb +38 -0
  77. data/lib/matcher/matchers/parse_float_matcher.rb +86 -0
  78. data/lib/matcher/matchers/parse_integer_matcher.rb +101 -0
  79. data/lib/matcher/matchers/parse_iso8601_helper.rb +41 -0
  80. data/lib/matcher/matchers/parse_iso8601_matcher.rb +52 -0
  81. data/lib/matcher/matchers/parse_json_helper.rb +43 -0
  82. data/lib/matcher/matchers/parse_json_matcher.rb +59 -0
  83. data/lib/matcher/matchers/project_matcher.rb +72 -0
  84. data/lib/matcher/matchers/raises_matcher.rb +131 -0
  85. data/lib/matcher/matchers/range_matcher.rb +50 -0
  86. data/lib/matcher/matchers/reference_matcher.rb +213 -0
  87. data/lib/matcher/matchers/reference_matcher_collection.rb +57 -0
  88. data/lib/matcher/matchers/regexp_matcher.rb +86 -0
  89. data/lib/matcher/messages/expected_phrasing.rb +355 -0
  90. data/lib/matcher/messages/message.rb +104 -0
  91. data/lib/matcher/messages/message_builder.rb +35 -0
  92. data/lib/matcher/messages/message_rules.rb +240 -0
  93. data/lib/matcher/messages/namespaced_message_builder.rb +19 -0
  94. data/lib/matcher/messages/phrasing.rb +59 -0
  95. data/lib/matcher/messages/standard_message_builder.rb +105 -0
  96. data/lib/matcher/patterns/ast_mapping.rb +42 -0
  97. data/lib/matcher/patterns/capture_hole.rb +33 -0
  98. data/lib/matcher/patterns/constant_hole.rb +14 -0
  99. data/lib/matcher/patterns/hole.rb +30 -0
  100. data/lib/matcher/patterns/method_hole.rb +62 -0
  101. data/lib/matcher/patterns/pattern.rb +104 -0
  102. data/lib/matcher/patterns/pattern_building.rb +39 -0
  103. data/lib/matcher/patterns/pattern_capture.rb +11 -0
  104. data/lib/matcher/patterns/pattern_match.rb +29 -0
  105. data/lib/matcher/patterns/variable_hole.rb +14 -0
  106. data/lib/matcher/reporter.rb +103 -0
  107. data/lib/matcher/rules/message_factory.rb +26 -0
  108. data/lib/matcher/rules/message_rule.rb +18 -0
  109. data/lib/matcher/rules/message_rule_context.rb +26 -0
  110. data/lib/matcher/rules/rule_builder.rb +29 -0
  111. data/lib/matcher/rules/rule_set.rb +57 -0
  112. data/lib/matcher/rules/transform_builder.rb +24 -0
  113. data/lib/matcher/rules/transform_mapping.rb +5 -0
  114. data/lib/matcher/rules/transform_rule.rb +21 -0
  115. data/lib/matcher/state.rb +40 -0
  116. data/lib/matcher/testing/error_builder.rb +62 -0
  117. data/lib/matcher/testing/error_checker.rb +514 -0
  118. data/lib/matcher/testing/error_testing.rb +37 -0
  119. data/lib/matcher/testing/pattern_testing.rb +11 -0
  120. data/lib/matcher/testing/pattern_testing_scope.rb +34 -0
  121. data/lib/matcher/testing.rb +107 -0
  122. data/lib/matcher/undefined.rb +10 -0
  123. data/lib/matcher/utils/mapping_utils.rb +61 -0
  124. data/lib/matcher/utils.rb +72 -0
  125. data/lib/matcher/version.rb +5 -0
  126. data/lib/matcher.rb +346 -0
  127. 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