matchers 0.1.0.pre.1 → 0.1.0.pre.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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +233 -0
- data/lib/matcher/autoload.rb +1 -1
- data/lib/matcher/dsl/expression_builder.rb +11 -0
- data/lib/matcher/dsl/optional.rb +20 -0
- data/lib/matcher/dsl/optional_chain.rb +20 -0
- data/lib/matcher/expressions/expression.rb +1 -119
- data/lib/matcher/expressions/expression_building.rb +117 -0
- data/lib/matcher/matchers/array_matcher.rb +29 -5
- data/lib/matcher/matchers/each_matcher.rb +27 -5
- data/lib/matcher/matchers/each_pair_matcher.rb +29 -5
- data/lib/matcher/matchers/imply_some_matcher.rb +81 -30
- data/lib/matcher/matchers/one_matcher.rb +11 -7
- data/lib/matcher/matchers/project_matcher.rb +21 -5
- data/lib/matcher/messages/expected_phrasing.rb +4 -25
- data/lib/matcher/version.rb +1 -1
- data/lib/matcher.rb +13 -15
- data/lib/matchers.rb +3 -0
- metadata +11 -11
- data/lib/matcher/matchers/negated_array_matcher.rb +0 -38
- data/lib/matcher/matchers/negated_each_matcher.rb +0 -36
- data/lib/matcher/matchers/negated_each_pair_matcher.rb +0 -38
- data/lib/matcher/matchers/negated_imply_some_matcher.rb +0 -46
- data/lib/matcher/matchers/negated_project_matcher.rb +0 -31
- /data/lib/matcher/{hash_stack.rb → collections/hash_stack.rb} +0 -0
- /data/lib/matcher/{list.rb → collections/list.rb} +0 -0
- /data/lib/matcher/{reporter.rb → errors/reporter.rb} +0 -0
- /data/lib/matcher/{expression_cache.rb → expressions/expression_cache.rb} +0 -0
- /data/lib/matcher/{expression_labeler.rb → expressions/expression_labeler.rb} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb2e301c6d9e1b3b297d758224ba846e146421e850f3fcde6a7a920d715560f9
|
|
4
|
+
data.tar.gz: 1b852e6d5f8d9f6205a85d55cab54c649eb3bd41500beb782322162523d1e6ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b4134ae3f0f41e6ff1f3ae63bdcbade83f76b1a4761667cb6c0133fb0e7722d734dd26282bf1b1d2d3ee7f96b04d2a28b1f14c1a51d55ea73b50a7f21a41402
|
|
7
|
+
data.tar.gz: 809840f7a204da8bbc28a84b4ea933b76b8c52de865393a42163041b27c238f276a7c1a8e7135b90b479e5cecf97fdf25134ec0c8cbdf9703689d8b12308b35f
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Rico Jasper
|
|
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,233 @@
|
|
|
1
|
+
# Matcher
|
|
2
|
+
|
|
3
|
+
[](https://github.com/rjasper/ruby-matchers/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
A Ruby gem for validating nested data structures. Make simple matchers easy
|
|
6
|
+
and complex matchers possible.
|
|
7
|
+
|
|
8
|
+
Whether you're checking API responses, configuration files, AI output, or
|
|
9
|
+
asserting complex structures in tests — writing validation for nested data by
|
|
10
|
+
hand gets tedious fast. When something doesn't match, a full diff on a large
|
|
11
|
+
hash leaves you searching for the actual problem. This gem points you right
|
|
12
|
+
to it: `root[:users][1][:age]: expected a value >= 18 but got 12`.
|
|
13
|
+
|
|
14
|
+
You describe the expected structure using a DSL that mirrors the shape of the
|
|
15
|
+
data. Ruby literals like classes, ranges, and regexps become matchers
|
|
16
|
+
automatically.
|
|
17
|
+
|
|
18
|
+
## Docs
|
|
19
|
+
|
|
20
|
+
- [Quick Reference](doc/quick-reference.md)
|
|
21
|
+
- [Guide](doc/guide-1-basics.md) — start with the basics, 12 chapters total
|
|
22
|
+
- [Comparison with other libraries](doc/comparison.md)
|
|
23
|
+
|
|
24
|
+
<details>
|
|
25
|
+
<summary>All guide chapters</summary>
|
|
26
|
+
|
|
27
|
+
1. [Basics](doc/guide-1-basics.md)
|
|
28
|
+
2. [Arrays](doc/guide-2-arrays.md)
|
|
29
|
+
3. [Hashes](doc/guide-3-hashes.md)
|
|
30
|
+
4. [Enumerables](doc/guide-4-enumerables.md)
|
|
31
|
+
5. [Combining](doc/guide-5-combine-matchers.md)
|
|
32
|
+
6. [Calls](doc/guide-6-calls.md)
|
|
33
|
+
7. [Strings](doc/guide-7-strings.md)
|
|
34
|
+
8. [Expressions](doc/guide-8-expressions.md)
|
|
35
|
+
9. [ExpressionMatchers](doc/guide-9-expression-matchers.md)
|
|
36
|
+
10. [Miscellaneous](doc/guide-10-misc.md)
|
|
37
|
+
11. [Recursive Matchers](doc/guide-11-refs.md)
|
|
38
|
+
12. [Custom Matchers](doc/guide-12-custom-matchers.md)
|
|
39
|
+
</details>
|
|
40
|
+
|
|
41
|
+
## Examples
|
|
42
|
+
|
|
43
|
+
Validate a file manifest — check paths, verify checksums against content, and
|
|
44
|
+
parse timestamps:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
require "digest"
|
|
48
|
+
|
|
49
|
+
matcher = Matcher.build do
|
|
50
|
+
{
|
|
51
|
+
files: each({
|
|
52
|
+
path: of(String) & _.start_with?("/"),
|
|
53
|
+
size: _.positive?,
|
|
54
|
+
content: String,
|
|
55
|
+
checksum: lazy_all(
|
|
56
|
+
/\A[a-f0-9]{8}\z/, # 8-char hex string
|
|
57
|
+
_ == expr(Digest::MD5).hexdigest(parent[:content])[0, 8] # matches content
|
|
58
|
+
),
|
|
59
|
+
uploaded_at: parse_iso8601 ^ (_ >= Time.new(2025, 1, 1)),
|
|
60
|
+
}),
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
errors = matcher.match({
|
|
65
|
+
files: [
|
|
66
|
+
{
|
|
67
|
+
path: "relative/path",
|
|
68
|
+
size: 0,
|
|
69
|
+
content: "hello",
|
|
70
|
+
checksum: "not-a-checksum",
|
|
71
|
+
uploaded_at: "not-a-date",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
path: "/valid/path",
|
|
75
|
+
size: 100,
|
|
76
|
+
content: "data",
|
|
77
|
+
checksum: Digest::MD5.hexdigest("wrong content")[0, 8],
|
|
78
|
+
uploaded_at: "2024-06-01T00:00:00Z",
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
})
|
|
82
|
+
puts errors.report
|
|
83
|
+
# > root[:files][0][:path]: expected actual.start_with?("/") to be truthy but got false, where actual = "relative/path"
|
|
84
|
+
# > root[:files][0][:size]: expected value to be positive but got 0
|
|
85
|
+
# > root[:files][0][:checksum]: expected value to match /\A[a-f0-9]{8}\z/ but got "not-a-checksum"
|
|
86
|
+
# > root[:files][0][:uploaded_at]: expected a valid iso8601 string but got "not-a-date"
|
|
87
|
+
# > root[:files][1][:checksum]: expected actual == Digest::MD5.hexdigest(parent[:content])[0, 8] but got "5cabbd5b" == "8d777f38", where parent = { ... }
|
|
88
|
+
# > Time.iso8601(root[:files][1][:uploaded_at]): expected a value >= 2025-01-01 00:00:00 +0100 but got 2024-06-01 00:00:00 UTC
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Start simple
|
|
92
|
+
|
|
93
|
+
Ruby literals are matchers automatically — classes, ranges, regexps, arrays,
|
|
94
|
+
and hashes all work out of the box:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
matcher = Matcher.build do
|
|
98
|
+
{
|
|
99
|
+
name: String,
|
|
100
|
+
age: 0..150,
|
|
101
|
+
active: boolean,
|
|
102
|
+
tags: each(String),
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
matcher.match?({ name: "Alice", age: 30, active: true, tags: ["ruby"] })
|
|
107
|
+
# => true
|
|
108
|
+
|
|
109
|
+
errors = matcher.match({ name: nil, age: -1, active: nil, tags: [42] })
|
|
110
|
+
puts errors.report
|
|
111
|
+
# > root[:name]: expected a kind of String but got nil
|
|
112
|
+
# > root[:age]: expected value to be between 0 and 150 but got -1
|
|
113
|
+
# > root[:active]: expected object to be included in [false, true] but got nil
|
|
114
|
+
# > root[:tags][0]: expected a kind of String but got 42
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Combine matchers
|
|
118
|
+
|
|
119
|
+
Matchers combine with `&` (and), `|` (or), and `~` (negate).
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
matcher = Matcher.build do
|
|
123
|
+
{
|
|
124
|
+
id: any(String, Integer),
|
|
125
|
+
score: of(Integer) & _.positive?,
|
|
126
|
+
status: ~equal("deleted"),
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
errors = matcher.match({ id: nil, score: -5, status: "deleted" })
|
|
131
|
+
puts errors.report
|
|
132
|
+
# > expected at least one error to be absent:
|
|
133
|
+
# > - root[:id]: expected a kind of String but got nil
|
|
134
|
+
# > - root[:id]: expected a kind of Integer but got nil
|
|
135
|
+
# > root[:score]: expected value to be positive but got -5
|
|
136
|
+
# > root[:status]: did not expect "deleted"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
See also `one`, `>>` (imply) and more:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
of(String) >> (_.length <= 255) # if it's a String, it must be short
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
See [combining matchers](doc/guide-5-combine-matchers.md).
|
|
146
|
+
|
|
147
|
+
### Error tracing through transformations
|
|
148
|
+
|
|
149
|
+
After `map`, `filter`, or `dig`, errors still point to the position in the
|
|
150
|
+
original data.
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
matcher = Matcher.build do
|
|
154
|
+
filter(_.odd?) ^ each(_ < 10)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
errors = matcher.match([2, 3, 4, 15, 6])
|
|
158
|
+
puts errors.report
|
|
159
|
+
# > root[3]: expected a value < 10 but got 15
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The error reports index 3 in the original array, not index 1 in the filtered
|
|
163
|
+
result. See also `map`, `dig`, `index_by`, and `project`:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
project(_.to_i => 1..100, _.length => 1..3) # match projected values
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
See [enumerables](doc/guide-4-enumerables.md) and [calls](doc/guide-6-calls.md).
|
|
170
|
+
|
|
171
|
+
### Recursive matchers
|
|
172
|
+
|
|
173
|
+
`refs` lets matchers reference themselves for trees and graphs.
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
matcher = Matcher.build do
|
|
177
|
+
refs[:node] = {
|
|
178
|
+
value: Integer,
|
|
179
|
+
children: each(refs[:node]),
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
errors = matcher.match({
|
|
184
|
+
value: 1,
|
|
185
|
+
children: [
|
|
186
|
+
{ value: 2, children: [] },
|
|
187
|
+
{ value: "three", children: [] },
|
|
188
|
+
]
|
|
189
|
+
})
|
|
190
|
+
puts errors.report
|
|
191
|
+
# > root[:children][1][:value]: expected a kind of Integer but got "three"
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
See [Recursive Matchers](doc/guide-11-refs.md)
|
|
195
|
+
|
|
196
|
+
## Installation
|
|
197
|
+
|
|
198
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
199
|
+
|
|
200
|
+
$ bundle add matchers
|
|
201
|
+
|
|
202
|
+
If bundler is not being used to manage dependencies, install the gem by
|
|
203
|
+
executing:
|
|
204
|
+
|
|
205
|
+
$ gem install matchers --pre
|
|
206
|
+
|
|
207
|
+
## Development
|
|
208
|
+
|
|
209
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
|
210
|
+
`rake test` to run the tests. You can also run `bin/console` for an interactive
|
|
211
|
+
prompt that will allow you to experiment.
|
|
212
|
+
|
|
213
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
214
|
+
release a new version, update the version number in `version.rb`, and then run
|
|
215
|
+
`bundle exec rake release`, which will create a git tag for the version, push
|
|
216
|
+
git commits and the created tag, and push the `.gem` file
|
|
217
|
+
to [rubygems.org](https://rubygems.org).
|
|
218
|
+
|
|
219
|
+
## Contributing
|
|
220
|
+
|
|
221
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
222
|
+
https://github.com/rjasper/ruby-matchers. See [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
223
|
+
|
|
224
|
+
## Code of Conduct
|
|
225
|
+
|
|
226
|
+
Everyone interacting in the Matcher project's codebases, issue trackers, chat
|
|
227
|
+
rooms and mailing lists is expected to follow
|
|
228
|
+
the [code of conduct](https://github.com/rjasper/ruby-matchers/blob/master/CODE_OF_CONDUCT.md).
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
The gem is available as open source under the terms of
|
|
233
|
+
the [MIT License](https://opensource.org/licenses/MIT).
|
data/lib/matcher/autoload.rb
CHANGED
data/lib/matcher/dsl/optional.rb
CHANGED
|
@@ -39,6 +39,26 @@ module Matcher
|
|
|
39
39
|
~Matcher.cache(self)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
def +(other)
|
|
43
|
+
Matcher.cache(self) + other
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def *(other)
|
|
47
|
+
Matcher.cache(self) * other
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def |(other)
|
|
51
|
+
Matcher.cache(self) | other
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def &(other)
|
|
55
|
+
Matcher.cache(self) & other
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def >>(other)
|
|
59
|
+
Matcher.cache(self) >> other
|
|
60
|
+
end
|
|
61
|
+
|
|
42
62
|
def ==(other)
|
|
43
63
|
return true if equal?(other)
|
|
44
64
|
|
|
@@ -17,6 +17,26 @@ module Matcher
|
|
|
17
17
|
OptionalChain.new(~@chain, @fallback)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
def +(other)
|
|
21
|
+
Matcher.cache(fallback) + other
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def *(other)
|
|
25
|
+
Matcher.cache(fallback) * other
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def |(other)
|
|
29
|
+
Matcher.cache(fallback) | other
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def &(other)
|
|
33
|
+
Matcher.cache(fallback) & other
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def >>(other)
|
|
37
|
+
Matcher.cache(fallback) >> other
|
|
38
|
+
end
|
|
39
|
+
|
|
20
40
|
def fallback
|
|
21
41
|
@chain ^ @fallback
|
|
22
42
|
end
|
|
@@ -24,125 +24,7 @@ module Matcher
|
|
|
24
24
|
#
|
|
25
25
|
# @see Recorder
|
|
26
26
|
class Expression
|
|
27
|
-
|
|
28
|
-
include ExpressionDsl
|
|
29
|
-
|
|
30
|
-
def initialize(build_session: Matcher.build_session)
|
|
31
|
-
ExpressionDsl.init(self, build_session)
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
##
|
|
36
|
-
# Builds an expression conveniently using {Recorder} and helpers from
|
|
37
|
-
# {ExpressionDsl}.
|
|
38
|
-
#
|
|
39
|
-
# @example
|
|
40
|
-
# Matcher::Expression.build do
|
|
41
|
-
# _.sum(&:to_i)
|
|
42
|
-
# end
|
|
43
|
-
#
|
|
44
|
-
# Matcher::Expression.build do
|
|
45
|
-
# range(vars[:from], vars[:to]).include?(_)
|
|
46
|
-
# end
|
|
47
|
-
#
|
|
48
|
-
# @see ExpressionDsl
|
|
49
|
-
def self.build(&)
|
|
50
|
-
Matcher.with_build_session do |build_session|
|
|
51
|
-
builder = ExpressionBuilder.new(build_session:)
|
|
52
|
-
result = builder.instance_exec(&)
|
|
53
|
-
builder.expression_of(result)
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def self.expression_or_value(obj, expression_cache: nil)
|
|
58
|
-
case obj
|
|
59
|
-
when -> { Recorder.recorder?(_1) }
|
|
60
|
-
return Recorder.to_expression(obj)
|
|
61
|
-
when Base
|
|
62
|
-
raise ArgumentError, "Cannot use matcher as expression"
|
|
63
|
-
when NoExpression
|
|
64
|
-
raise ArgumentError, "Cannot use #{obj.class} as expression"
|
|
65
|
-
when Proc
|
|
66
|
-
raise ArgumentError, "Cannot use Proc as expression. " \
|
|
67
|
-
"Use `expr { ... }' instead"
|
|
68
|
-
when Array
|
|
69
|
-
items = obj.map { expression_or_value(_1, expression_cache:) }
|
|
70
|
-
|
|
71
|
-
if items.any?(Expression)
|
|
72
|
-
items.each_with_index do |item, i|
|
|
73
|
-
unless item.is_a?(Expression)
|
|
74
|
-
items[i] = Constant.cache(item, expression_cache)
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
return ArrayExpression.new(items)
|
|
79
|
-
end
|
|
80
|
-
when Hash
|
|
81
|
-
pairs = obj.map do |key, value|
|
|
82
|
-
key = expression_or_value(key, expression_cache:)
|
|
83
|
-
value = expression_or_value(value, expression_cache:)
|
|
84
|
-
|
|
85
|
-
[key, value]
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
if pairs.any? { |k, v| k.is_a?(Expression) || v.is_a?(Expression) }
|
|
89
|
-
pairs.each do |pair|
|
|
90
|
-
k, v = pair
|
|
91
|
-
|
|
92
|
-
unless k.is_a?(Expression)
|
|
93
|
-
pair[0] = Constant.cache(k, expression_cache)
|
|
94
|
-
end
|
|
95
|
-
unless v.is_a?(Expression)
|
|
96
|
-
pair[1] = Constant.cache(v, expression_cache)
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
return HashExpression.new(pairs)
|
|
101
|
-
end
|
|
102
|
-
when Range
|
|
103
|
-
from = expression_or_value(obj.begin, expression_cache:)
|
|
104
|
-
to = expression_or_value(obj.end, expression_cache:)
|
|
105
|
-
|
|
106
|
-
if from.is_a?(Expression) || to.is_a?(Expression)
|
|
107
|
-
unless from.is_a?(Expression)
|
|
108
|
-
from = Constant.cache(from, expression_cache)
|
|
109
|
-
end
|
|
110
|
-
to = Constant.cache(to, expression_cache) unless to.is_a?(Expression)
|
|
111
|
-
|
|
112
|
-
return RangeExpression.new(from, to, exclude_end: obj.exclude_end?)
|
|
113
|
-
end
|
|
114
|
-
when Set
|
|
115
|
-
items = obj.map { expression_or_value(_1, expression_cache:) }
|
|
116
|
-
|
|
117
|
-
if items.any?(Expression)
|
|
118
|
-
items.each_with_index do |item, i|
|
|
119
|
-
unless item.is_a?(Expression)
|
|
120
|
-
items[i] = Constant.cache(item, expression_cache)
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
return SetExpression.new(items)
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
obj
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def self.of(obj, expression_cache: nil)
|
|
132
|
-
obj = expression_or_value(obj, expression_cache:)
|
|
133
|
-
|
|
134
|
-
if obj.is_a?(Expression)
|
|
135
|
-
obj
|
|
136
|
-
else
|
|
137
|
-
Constant.cache(obj, expression_cache)
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def self.try_recorder(obj)
|
|
142
|
-
return obj unless Recorder.recorder?(obj)
|
|
143
|
-
|
|
144
|
-
Recorder.to_expression(obj)
|
|
145
|
-
end
|
|
27
|
+
extend ExpressionBuilding
|
|
146
28
|
|
|
147
29
|
def initialize
|
|
148
30
|
raise "abstract class" if instance_of?(Expression)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Matcher
|
|
4
|
+
module ExpressionBuilding
|
|
5
|
+
##
|
|
6
|
+
# Builds an expression conveniently using {Recorder} and helpers from
|
|
7
|
+
# {ExpressionDsl}.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# Matcher::Expression.build do
|
|
11
|
+
# _.sum(&:to_i)
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Matcher::Expression.build do
|
|
15
|
+
# range(vars[:from], vars[:to]).include?(_)
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @see ExpressionDsl
|
|
19
|
+
def build(&)
|
|
20
|
+
Matcher.with_build_session do |build_session|
|
|
21
|
+
builder = ExpressionBuilder.new(build_session:)
|
|
22
|
+
result = builder.instance_exec(&)
|
|
23
|
+
builder.expression_of(result)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def expression_or_value(obj, expression_cache: nil)
|
|
28
|
+
case obj
|
|
29
|
+
when -> { Recorder.recorder?(_1) }
|
|
30
|
+
return Recorder.to_expression(obj)
|
|
31
|
+
when Base
|
|
32
|
+
raise ArgumentError, "Cannot use matcher as expression"
|
|
33
|
+
when NoExpression
|
|
34
|
+
raise ArgumentError, "Cannot use #{obj.class} as expression"
|
|
35
|
+
when Proc
|
|
36
|
+
raise ArgumentError, "Cannot use Proc as expression. " \
|
|
37
|
+
"Use `expr { ... }' instead"
|
|
38
|
+
when Array
|
|
39
|
+
items = obj.map { expression_or_value(_1, expression_cache:) }
|
|
40
|
+
|
|
41
|
+
if items.any?(Expression)
|
|
42
|
+
items.each_with_index do |item, i|
|
|
43
|
+
unless item.is_a?(Expression)
|
|
44
|
+
items[i] = Constant.cache(item, expression_cache)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return ArrayExpression.new(items)
|
|
49
|
+
end
|
|
50
|
+
when Hash
|
|
51
|
+
pairs = obj.map do |key, value|
|
|
52
|
+
key = expression_or_value(key, expression_cache:)
|
|
53
|
+
value = expression_or_value(value, expression_cache:)
|
|
54
|
+
|
|
55
|
+
[key, value]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if pairs.any? { |k, v| k.is_a?(Expression) || v.is_a?(Expression) }
|
|
59
|
+
pairs.each do |pair|
|
|
60
|
+
k, v = pair
|
|
61
|
+
|
|
62
|
+
unless k.is_a?(Expression)
|
|
63
|
+
pair[0] = Constant.cache(k, expression_cache)
|
|
64
|
+
end
|
|
65
|
+
unless v.is_a?(Expression)
|
|
66
|
+
pair[1] = Constant.cache(v, expression_cache)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
return HashExpression.new(pairs)
|
|
71
|
+
end
|
|
72
|
+
when Range
|
|
73
|
+
from = expression_or_value(obj.begin, expression_cache:)
|
|
74
|
+
to = expression_or_value(obj.end, expression_cache:)
|
|
75
|
+
|
|
76
|
+
if from.is_a?(Expression) || to.is_a?(Expression)
|
|
77
|
+
unless from.is_a?(Expression)
|
|
78
|
+
from = Constant.cache(from, expression_cache)
|
|
79
|
+
end
|
|
80
|
+
to = Constant.cache(to, expression_cache) unless to.is_a?(Expression)
|
|
81
|
+
|
|
82
|
+
return RangeExpression.new(from, to, exclude_end: obj.exclude_end?)
|
|
83
|
+
end
|
|
84
|
+
when Set
|
|
85
|
+
items = obj.map { expression_or_value(_1, expression_cache:) }
|
|
86
|
+
|
|
87
|
+
if items.any?(Expression)
|
|
88
|
+
items.each_with_index do |item, i|
|
|
89
|
+
unless item.is_a?(Expression)
|
|
90
|
+
items[i] = Constant.cache(item, expression_cache)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
return SetExpression.new(items)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
obj
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def of(obj, expression_cache: nil)
|
|
102
|
+
obj = expression_or_value(obj, expression_cache:)
|
|
103
|
+
|
|
104
|
+
if obj.is_a?(Expression)
|
|
105
|
+
obj
|
|
106
|
+
else
|
|
107
|
+
Constant.cache(obj, expression_cache)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def try_recorder(obj)
|
|
112
|
+
return obj unless Recorder.recorder?(obj)
|
|
113
|
+
|
|
114
|
+
Recorder.to_expression(obj)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -37,17 +37,21 @@ module Matcher
|
|
|
37
37
|
# # > root[2]: expected actual >= parent[index - 1] but got 0 >= 2, where
|
|
38
38
|
# # parent = [1, 2, 0], index = 2
|
|
39
39
|
class ArrayMatcher < Base
|
|
40
|
-
def initialize(array)
|
|
40
|
+
def initialize(array, negated: false)
|
|
41
41
|
super()
|
|
42
42
|
|
|
43
|
-
@array = array
|
|
43
|
+
@array = negated ? array.map(&:~) : array
|
|
44
|
+
@original_array = array
|
|
45
|
+
@negated = negated
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
def negate
|
|
47
|
-
|
|
49
|
+
ArrayMatcher.new(@original_array, negated: !@negated)
|
|
48
50
|
end
|
|
49
51
|
|
|
50
|
-
def validate(state)
|
|
52
|
+
def validate(state, &)
|
|
53
|
+
return validate_negated(state, &) if @negated
|
|
54
|
+
|
|
51
55
|
actual = state.actual
|
|
52
56
|
errors = state.errors
|
|
53
57
|
|
|
@@ -66,7 +70,27 @@ module Matcher
|
|
|
66
70
|
end
|
|
67
71
|
|
|
68
72
|
def to_s
|
|
69
|
-
@
|
|
73
|
+
@negated ? "neg(#{@original_array})" : @original_array.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def validate_negated(state)
|
|
79
|
+
actual = state.actual
|
|
80
|
+
|
|
81
|
+
return if !actual.is_a?(Array) || @array.length != actual.length
|
|
82
|
+
|
|
83
|
+
collector = state.new_collector.or!
|
|
84
|
+
|
|
85
|
+
@array.length.times do |i|
|
|
86
|
+
result = yield @array[i], actual[i], index: i, parent: actual
|
|
87
|
+
|
|
88
|
+
return nil if result.valid?
|
|
89
|
+
|
|
90
|
+
collector[i] << result
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
state.errors << collector.error
|
|
70
94
|
end
|
|
71
95
|
end
|
|
72
96
|
end
|
|
@@ -20,17 +20,21 @@ module Matcher
|
|
|
20
20
|
# m.match?([1, 0, 2]) # => true
|
|
21
21
|
# m.match?([1, 2, 3]) # => false
|
|
22
22
|
class EachMatcher < Base
|
|
23
|
-
def initialize(matcher)
|
|
23
|
+
def initialize(matcher, negated: false)
|
|
24
24
|
super()
|
|
25
25
|
|
|
26
|
-
@matcher = matcher
|
|
26
|
+
@matcher = negated ? ~matcher : matcher
|
|
27
|
+
@original_matcher = matcher
|
|
28
|
+
@negated = negated
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def negate
|
|
30
|
-
|
|
32
|
+
EachMatcher.new(@original_matcher, negated: !@negated)
|
|
31
33
|
end
|
|
32
34
|
|
|
33
|
-
def validate(state)
|
|
35
|
+
def validate(state, &)
|
|
36
|
+
return validate_negated(state, &) if @negated
|
|
37
|
+
|
|
34
38
|
unless state.actual.respond_to?(:each)
|
|
35
39
|
state.errors << state.expected.responding_to(:each)
|
|
36
40
|
return
|
|
@@ -44,7 +48,25 @@ module Matcher
|
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
def to_s
|
|
47
|
-
"each(#{@
|
|
51
|
+
"#{"~" if @negated}each(#{@original_matcher})"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def validate_negated(state)
|
|
57
|
+
return unless state.actual.respond_to?(:each)
|
|
58
|
+
|
|
59
|
+
collector = state.new_collector.or!
|
|
60
|
+
|
|
61
|
+
state.actual.each.with_index do |item, i|
|
|
62
|
+
result = yield @matcher, item, index: i, parent: state.actual
|
|
63
|
+
|
|
64
|
+
return nil if result.valid?
|
|
65
|
+
|
|
66
|
+
collector[i] << result
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
state.errors << collector.error
|
|
48
70
|
end
|
|
49
71
|
end
|
|
50
72
|
|