matchi 3.3.1 → 4.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/LICENSE.md +1 -1
- data/README.md +151 -101
- data/lib/matchi/be.rb +7 -13
- data/lib/matchi/be_a_kind_of.rb +81 -0
- data/lib/matchi/be_an_instance_of.rb +91 -21
- data/lib/matchi/be_within/of.rb +11 -13
- data/lib/matchi/be_within.rb +4 -1
- data/lib/matchi/change/by.rb +11 -14
- data/lib/matchi/change/by_at_least.rb +12 -14
- data/lib/matchi/change/by_at_most.rb +12 -14
- data/lib/matchi/change/from/to.rb +10 -14
- data/lib/matchi/change/from.rb +3 -1
- data/lib/matchi/change/to.rb +10 -14
- data/lib/matchi/change.rb +10 -9
- data/lib/matchi/eq.rb +7 -13
- data/lib/matchi/match.rb +9 -13
- data/lib/matchi/predicate.rb +8 -19
- data/lib/matchi/raise_exception.rb +30 -17
- data/lib/matchi/satisfy.rb +8 -12
- data/lib/matchi.rb +1 -1
- metadata +6 -120
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e8454c54085d68ded31ba34e1ff0ab03cdc3dab452316ef82a15967f3de3ee4c
|
4
|
+
data.tar.gz: ac11c5ab16b7e93f16189b1fa62f0d0502e0d94e67627f81dfab40cd7db48d61
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b984f3a46f4eb2f0743b4407518261ca0539efd780b28ab0b0f476a687925bedeec2d88249160d7c148617809080593d9472957dc8571bf92295007dfaf941b7
|
7
|
+
data.tar.gz: 96f609b1bc8af4ddf6a5b7b839ed0b981018d7aac53f1d26b5ea05c1629318f3cdbef64e9776dd2224c3fdd48b56e148e8e3a94df4de5d07e93164941da0fa25
|
data/LICENSE.md
CHANGED
data/README.md
CHANGED
@@ -1,12 +1,13 @@
|
|
1
1
|
# Matchi
|
2
2
|
|
3
|
-
[![Version](https://img.shields.io/github/v/tag/fixrb/matchi?label=Version&logo=github)](https://github.com/fixrb/matchi/
|
3
|
+
[![Version](https://img.shields.io/github/v/tag/fixrb/matchi?label=Version&logo=github)](https://github.com/fixrb/matchi/tags)
|
4
4
|
[![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/fixrb/matchi/main)
|
5
|
-
[![
|
5
|
+
[![Ruby](https://github.com/fixrb/matchi/workflows/Ruby/badge.svg?branch=main)](https://github.com/fixrb/matchi/actions?query=workflow%3Aruby+branch%3Amain)
|
6
6
|
[![RuboCop](https://github.com/fixrb/matchi/workflows/RuboCop/badge.svg?branch=main)](https://github.com/fixrb/matchi/actions?query=workflow%3Arubocop+branch%3Amain)
|
7
7
|
[![License](https://img.shields.io/github/license/fixrb/matchi?label=License&logo=github)](https://github.com/fixrb/matchi/raw/main/LICENSE.md)
|
8
8
|
|
9
|
-
|
9
|
+
This library provides a comprehensive set of matchers for testing different aspects of your code.
|
10
|
+
Each matcher is designed to handle specific verification needs while maintaining a clear and expressive syntax.
|
10
11
|
|
11
12
|
![A Rubyist juggling between Matchi letters](https://github.com/fixrb/matchi/raw/main/img/matchi.png)
|
12
13
|
|
@@ -27,7 +28,7 @@ gem "matchi"
|
|
27
28
|
And then execute:
|
28
29
|
|
29
30
|
```sh
|
30
|
-
bundle
|
31
|
+
bundle install
|
31
32
|
```
|
32
33
|
|
33
34
|
Or install it yourself as:
|
@@ -52,148 +53,160 @@ All examples here assume that this has been done.
|
|
52
53
|
|
53
54
|
### Anatomy of a matcher
|
54
55
|
|
55
|
-
A
|
56
|
+
A **Matchi** matcher is a simple Ruby object that follows these requirements:
|
56
57
|
|
57
|
-
|
58
|
+
1. It must implement a `match?` method that:
|
59
|
+
- Accepts a block as its only parameter
|
60
|
+
- Executes that block to get the actual value
|
61
|
+
- Returns a boolean indicating if the actual value matches the expected criteria
|
62
|
+
|
63
|
+
2. Optionally, it may implement:
|
64
|
+
- `to_s`: Returns a human-readable description of the match criteria
|
58
65
|
|
59
66
|
### Built-in matchers
|
60
67
|
|
61
|
-
Here is the collection of
|
68
|
+
Here is the collection of generic matchers.
|
62
69
|
|
63
|
-
|
70
|
+
#### Basic Comparison Matchers
|
64
71
|
|
72
|
+
##### `Be`
|
73
|
+
Checks for object identity using Ruby's `equal?` method.
|
65
74
|
```ruby
|
66
|
-
|
67
|
-
|
68
|
-
matcher.expected # => "foo"
|
69
|
-
matcher.matches? { "foo" } # => true
|
75
|
+
Matchi::Be.new(:foo).match? { :foo } # => true (same object)
|
76
|
+
Matchi::Be.new("test").match? { "test" } # => false (different objects)
|
70
77
|
```
|
71
78
|
|
72
|
-
|
73
|
-
|
79
|
+
##### `Eq`
|
80
|
+
Verifies object equivalence using Ruby's `eql?` method.
|
74
81
|
```ruby
|
75
|
-
|
76
|
-
|
77
|
-
matcher.expected # => :foo
|
78
|
-
matcher.matches? { :foo } # => true
|
82
|
+
Matchi::Eq.new("foo").match? { "foo" } # => true (equivalent content)
|
83
|
+
Matchi::Eq.new([1, 2]).match? { [1, 2] } # => true (equivalent arrays)
|
79
84
|
```
|
80
85
|
|
81
|
-
|
86
|
+
#### Type and Class Matchers
|
82
87
|
|
88
|
+
##### `BeAnInstanceOf`
|
89
|
+
Verifies exact class matching (no inheritance).
|
83
90
|
```ruby
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
matcher.matches? { 42 } # => true
|
91
|
+
Matchi::BeAnInstanceOf.new(String).match? { "test" } # => true
|
92
|
+
Matchi::BeAnInstanceOf.new(Integer).match? { 42 } # => true
|
93
|
+
Matchi::BeAnInstanceOf.new(Numeric).match? { 42 } # => false (Integer, not Numeric)
|
88
94
|
```
|
89
95
|
|
90
|
-
|
91
|
-
|
96
|
+
##### `BeAKindOf`
|
97
|
+
Verifies class inheritance and module inclusion.
|
92
98
|
```ruby
|
93
|
-
|
94
|
-
|
95
|
-
matcher.expected # => /^foo$/
|
96
|
-
matcher.matches? { "foo" } # => true
|
99
|
+
Matchi::BeAKindOf.new(Numeric).match? { 42 } # => true (Integer inherits from Numeric)
|
100
|
+
Matchi::BeAKindOf.new(Numeric).match? { 42.0 } # => true (Float inherits from Numeric)
|
97
101
|
```
|
98
102
|
|
99
|
-
|
103
|
+
#### Pattern Matchers
|
100
104
|
|
105
|
+
##### `Match`
|
106
|
+
Tests string patterns against regular expressions.
|
101
107
|
```ruby
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
matcher.matches? { Boom } # => true
|
108
|
+
Matchi::Match.new(/^foo/).match? { "foobar" } # => true
|
109
|
+
Matchi::Match.new(/\d+/).match? { "abc123" } # => true
|
110
|
+
Matchi::Match.new(/^foo/).match? { "barfoo" } # => false
|
106
111
|
```
|
107
112
|
|
108
|
-
|
109
|
-
|
113
|
+
##### `Satisfy`
|
114
|
+
Provides custom matching through a block.
|
110
115
|
```ruby
|
111
|
-
|
112
|
-
|
113
|
-
matcher.expected # => "String"
|
114
|
-
matcher.matches? { "foo" } # => true
|
116
|
+
Matchi::Satisfy.new { |x| x > 0 && x < 10 }.match? { 5 } # => true
|
117
|
+
Matchi::Satisfy.new { |x| x.start_with?("test") }.match? { "test_file" } # => true
|
115
118
|
```
|
116
119
|
|
117
|
-
|
118
|
-
|
119
|
-
```ruby
|
120
|
-
matcher = Matchi::Predicate.new(:be_empty)
|
120
|
+
#### State Change Matchers
|
121
121
|
|
122
|
-
|
123
|
-
|
122
|
+
##### `Change`
|
123
|
+
Verifies state changes in objects with multiple variation methods:
|
124
124
|
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
125
|
+
###### Basic Change
|
126
|
+
```ruby
|
127
|
+
array = []
|
128
|
+
Matchi::Change.new(array, :length).by(2).match? { array.push(1, 2) } # => true
|
129
129
|
```
|
130
130
|
|
131
|
-
|
132
|
-
|
131
|
+
###### Minimum Change
|
133
132
|
```ruby
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
matcher.expected # => 1
|
138
|
-
matcher.matches? { object << 1 } # => true
|
139
|
-
|
140
|
-
object = []
|
141
|
-
matcher = Matchi::Change.new(object, :length).by_at_least(1)
|
133
|
+
counter = 0
|
134
|
+
Matchi::Change.new(counter, :to_i).by_at_least(2).match? { counter += 3 } # => true
|
135
|
+
```
|
142
136
|
|
143
|
-
|
144
|
-
|
137
|
+
###### Maximum Change
|
138
|
+
```ruby
|
139
|
+
value = 10
|
140
|
+
Matchi::Change.new(value, :to_i).by_at_most(5).match? { value += 3 } # => true
|
141
|
+
```
|
145
142
|
|
146
|
-
|
147
|
-
|
143
|
+
###### From-To Change
|
144
|
+
```ruby
|
145
|
+
string = "hello"
|
146
|
+
Matchi::Change.new(string, :upcase).from("hello").to("HELLO").match? { string.upcase! } # => true
|
147
|
+
```
|
148
148
|
|
149
|
-
|
150
|
-
|
149
|
+
###### To-Only Change
|
150
|
+
```ruby
|
151
|
+
number = 1
|
152
|
+
Matchi::Change.new(number, :to_i).to(5).match? { number = 5 } # => true
|
153
|
+
```
|
151
154
|
|
152
|
-
|
153
|
-
matcher = Matchi::Change.new(object, :to_s).from("foo").to("FOO")
|
155
|
+
#### Numeric Matchers
|
154
156
|
|
155
|
-
|
156
|
-
|
157
|
+
##### `BeWithin`
|
158
|
+
Checks if a number is within a specified range of an expected value.
|
159
|
+
```ruby
|
160
|
+
Matchi::BeWithin.new(0.5).of(3.0).match? { 3.2 } # => true
|
161
|
+
Matchi::BeWithin.new(5).of(100).match? { 98 } # => true
|
162
|
+
```
|
157
163
|
|
158
|
-
|
159
|
-
matcher = Matchi::Change.new(object, :to_s).to("FOO")
|
164
|
+
#### Behavior Matchers
|
160
165
|
|
161
|
-
|
162
|
-
|
166
|
+
##### `RaiseException`
|
167
|
+
Verifies that code raises specific exceptions.
|
168
|
+
```ruby
|
169
|
+
Matchi::RaiseException.new(ArgumentError).match? { raise ArgumentError } # => true
|
170
|
+
Matchi::RaiseException.new(NameError).match? { undefined_variable } # => true
|
163
171
|
```
|
164
172
|
|
165
|
-
|
173
|
+
##### `Predicate`
|
174
|
+
Creates matchers for methods ending in `?`.
|
166
175
|
|
176
|
+
###### Using `be_` prefix
|
167
177
|
```ruby
|
168
|
-
|
178
|
+
Matchi::Predicate.new(:be_empty).match? { [] } # => true (calls empty?)
|
179
|
+
Matchi::Predicate.new(:be_nil).match? { nil } # => true (calls nil?)
|
180
|
+
```
|
169
181
|
|
170
|
-
|
171
|
-
|
182
|
+
###### Using `have_` prefix
|
183
|
+
```ruby
|
184
|
+
Matchi::Predicate.new(:have_key, :foo).match? { { foo: 42 } } # => true (calls has_key?)
|
172
185
|
```
|
173
186
|
|
174
187
|
### Custom matchers
|
175
188
|
|
176
|
-
Custom matchers
|
189
|
+
Custom matchers could easily be added to `Matchi` module to express more specific expectations.
|
177
190
|
|
178
191
|
A **Be the answer** matcher:
|
179
192
|
|
180
193
|
```ruby
|
181
194
|
module Matchi
|
182
195
|
class BeTheAnswer
|
183
|
-
def
|
184
|
-
|
196
|
+
def match?
|
197
|
+
expected.equal?(yield)
|
185
198
|
end
|
186
199
|
|
187
|
-
|
188
|
-
|
200
|
+
private
|
201
|
+
|
202
|
+
def expected
|
203
|
+
42
|
189
204
|
end
|
190
205
|
end
|
191
206
|
end
|
192
207
|
|
193
208
|
matcher = Matchi::BeTheAnswer.new
|
194
|
-
|
195
|
-
matcher.expected # => 42
|
196
|
-
matcher.matches? { 42 } # => true
|
209
|
+
matcher.match? { 42 } # => true
|
197
210
|
```
|
198
211
|
|
199
212
|
A **Be prime** matcher:
|
@@ -203,7 +216,7 @@ require "prime"
|
|
203
216
|
|
204
217
|
module Matchi
|
205
218
|
class BePrime
|
206
|
-
def
|
219
|
+
def match?
|
207
220
|
Prime.prime?(yield)
|
208
221
|
end
|
209
222
|
end
|
@@ -211,7 +224,7 @@ end
|
|
211
224
|
|
212
225
|
matcher = Matchi::BePrime.new
|
213
226
|
|
214
|
-
matcher.
|
227
|
+
matcher.match? { 42 } # => false
|
215
228
|
```
|
216
229
|
|
217
230
|
A **Start with** matcher:
|
@@ -219,22 +232,64 @@ A **Start with** matcher:
|
|
219
232
|
```ruby
|
220
233
|
module Matchi
|
221
234
|
class StartWith
|
222
|
-
attr_reader :expected
|
223
|
-
|
224
235
|
def initialize(expected)
|
225
236
|
@expected = expected
|
226
237
|
end
|
227
238
|
|
228
|
-
def
|
229
|
-
|
239
|
+
def match?
|
240
|
+
/\A#{@expected}/.match?(yield)
|
230
241
|
end
|
231
242
|
end
|
232
243
|
end
|
233
244
|
|
234
245
|
matcher = Matchi::StartWith.new("foo")
|
246
|
+
matcher.match? { "foobar" } # => true
|
247
|
+
```
|
248
|
+
|
249
|
+
## Best Practices
|
250
|
+
|
251
|
+
### Proper Value Comparison Order
|
252
|
+
|
253
|
+
One of the most critical aspects when implementing matchers is the order of comparison between expected and actual values. Always compare values in this order:
|
254
|
+
|
255
|
+
```ruby
|
256
|
+
# GOOD: Expected value controls the comparison
|
257
|
+
expected_value.eql?(actual_value)
|
258
|
+
|
259
|
+
# BAD: Actual value controls the comparison
|
260
|
+
actual_value.eql?(expected_value)
|
261
|
+
```
|
262
|
+
|
263
|
+
#### Why This Matters
|
264
|
+
|
265
|
+
The order is crucial because the object receiving the comparison method controls how the comparison is performed. When testing, the actual value might come from untrusted or malicious code that could override comparison methods:
|
266
|
+
|
267
|
+
```ruby
|
268
|
+
# Example of how comparison can be compromised
|
269
|
+
class MaliciousString
|
270
|
+
def eql?(other)
|
271
|
+
true # Always returns true regardless of actual equality
|
272
|
+
end
|
235
273
|
|
236
|
-
|
237
|
-
|
274
|
+
def ==(other)
|
275
|
+
true # Always returns true regardless of actual equality
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
actual = MaliciousString.new
|
280
|
+
expected = "expected string"
|
281
|
+
|
282
|
+
actual.eql?(expected) # => true (incorrect result!)
|
283
|
+
expected.eql?(actual) # => false (correct result)
|
284
|
+
```
|
285
|
+
|
286
|
+
This is why Matchi's built-in matchers are implemented with this security consideration in mind. For example, the `Eq` matcher:
|
287
|
+
|
288
|
+
```ruby
|
289
|
+
# Implementation in Matchi::Eq
|
290
|
+
def match?
|
291
|
+
@expected.eql?(yield) # Expected value controls the comparison
|
292
|
+
end
|
238
293
|
```
|
239
294
|
|
240
295
|
## Contact
|
@@ -250,11 +305,6 @@ __Matchi__ follows [Semantic Versioning 2.0](https://semver.org/).
|
|
250
305
|
|
251
306
|
The [gem](https://rubygems.org/gems/matchi) is available as open source under the terms of the [MIT License](https://github.com/fixrb/matchi/raw/main/LICENSE.md).
|
252
307
|
|
253
|
-
|
308
|
+
## Sponsors
|
254
309
|
|
255
|
-
|
256
|
-
This project is sponsored by:<br />
|
257
|
-
<a href="https://sashite.com/"><img
|
258
|
-
src="https://github.com/fixrb/matchi/raw/main/img/sashite.png"
|
259
|
-
alt="Sashite" /></a>
|
260
|
-
</p>
|
310
|
+
This project is sponsored by [Sashité](https://sashite.com/)
|
data/lib/matchi/be.rb
CHANGED
@@ -3,9 +3,6 @@
|
|
3
3
|
module Matchi
|
4
4
|
# *Identity* matcher.
|
5
5
|
class Be
|
6
|
-
# @return [#equal?] The expected identical object.
|
7
|
-
attr_reader :expected
|
8
|
-
|
9
6
|
# Initialize the matcher with an object.
|
10
7
|
#
|
11
8
|
# @example
|
@@ -24,26 +21,23 @@ module Matchi
|
|
24
21
|
# require "matchi/be"
|
25
22
|
#
|
26
23
|
# matcher = Matchi::Be.new(:foo)
|
27
|
-
#
|
28
|
-
# matcher.expected # => :foo
|
29
|
-
# matcher.matches? { :foo } # => true
|
24
|
+
# matcher.match? { :foo } # => true
|
30
25
|
#
|
31
26
|
# @yieldreturn [#object_id] The actual value to compare to the expected
|
32
27
|
# one.
|
33
28
|
#
|
34
29
|
# @return [Boolean] Comparison between actual and expected values.
|
35
|
-
def
|
36
|
-
|
37
|
-
end
|
30
|
+
def match?
|
31
|
+
raise ::ArgumentError, "a block must be provided" unless block_given?
|
38
32
|
|
39
|
-
|
40
|
-
def inspect
|
41
|
-
"#{self.class}(#{expected.inspect})"
|
33
|
+
@expected.equal?(yield)
|
42
34
|
end
|
43
35
|
|
44
36
|
# Returns a string representing the matcher.
|
37
|
+
#
|
38
|
+
# @return [String] a human-readable description of the matcher
|
45
39
|
def to_s
|
46
|
-
"be #{expected.inspect}"
|
40
|
+
"be #{@expected.inspect}"
|
47
41
|
end
|
48
42
|
end
|
49
43
|
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Matchi
|
4
|
+
# *Type/class* matcher for inheritance-aware type checking.
|
5
|
+
#
|
6
|
+
# This matcher provides a clear way to check if an object is an instance of a
|
7
|
+
# specific class or one of its subclasses. It leverages Ruby's native === operator
|
8
|
+
# which reliably handles class hierarchy relationships.
|
9
|
+
#
|
10
|
+
# @example Basic usage
|
11
|
+
# require "matchi/be_a_kind_of"
|
12
|
+
#
|
13
|
+
# matcher = Matchi::BeAKindOf.new(Numeric)
|
14
|
+
# matcher.match? { 42 } # => true
|
15
|
+
# matcher.match? { 42.0 } # => true
|
16
|
+
# matcher.match? { "42" } # => false
|
17
|
+
class BeAKindOf
|
18
|
+
# Initialize the matcher with (the name of) a class or module.
|
19
|
+
#
|
20
|
+
# @example
|
21
|
+
# require "matchi/be_a_kind_of"
|
22
|
+
#
|
23
|
+
# Matchi::BeAKindOf.new(String)
|
24
|
+
# Matchi::BeAKindOf.new("String")
|
25
|
+
# Matchi::BeAKindOf.new(:String)
|
26
|
+
#
|
27
|
+
# @param expected [Class, #to_s] The expected class name
|
28
|
+
# @raise [ArgumentError] if the class name doesn't start with an uppercase letter
|
29
|
+
def initialize(expected)
|
30
|
+
@expected = String(expected)
|
31
|
+
return if /\A[A-Z]/.match?(@expected)
|
32
|
+
|
33
|
+
raise ::ArgumentError,
|
34
|
+
"expected must start with an uppercase letter (got: #{@expected})"
|
35
|
+
end
|
36
|
+
|
37
|
+
# Checks if the yielded object is an instance of the expected class
|
38
|
+
# or one of its subclasses.
|
39
|
+
#
|
40
|
+
# This method uses the case equality operator (===) which provides a reliable
|
41
|
+
# way to check class hierarchy relationships in Ruby. When a class is the
|
42
|
+
# receiver of ===, it returns true if the argument is an instance of that
|
43
|
+
# class or one of its subclasses.
|
44
|
+
#
|
45
|
+
# @example Class hierarchy check
|
46
|
+
# class Animal; end
|
47
|
+
# class Dog < Animal; end
|
48
|
+
#
|
49
|
+
# matcher = Matchi::BeAKindOf.new(Animal)
|
50
|
+
# matcher.match? { Dog.new } # => true
|
51
|
+
# matcher.match? { Animal.new } # => true
|
52
|
+
# matcher.match? { Object.new } # => false
|
53
|
+
#
|
54
|
+
# @yieldreturn [Object] the actual value to check
|
55
|
+
# @return [Boolean] true if the object is an instance of the expected class or one of its subclasses
|
56
|
+
# @raise [ArgumentError] if no block is provided
|
57
|
+
def match?
|
58
|
+
raise ::ArgumentError, "a block must be provided" unless block_given?
|
59
|
+
|
60
|
+
expected_class === yield # rubocop:disable Style/CaseEquality
|
61
|
+
end
|
62
|
+
|
63
|
+
# Returns a string representing the matcher.
|
64
|
+
#
|
65
|
+
# @return [String] a human-readable description of the matcher
|
66
|
+
def to_s
|
67
|
+
"be a kind of #{@expected}"
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
# Resolves the expected class name to an actual Class object.
|
73
|
+
# This method handles both string and symbol class names through constant resolution.
|
74
|
+
#
|
75
|
+
# @return [Class] the resolved class
|
76
|
+
# @raise [NameError] if the class doesn't exist
|
77
|
+
def expected_class
|
78
|
+
::Object.const_get(@expected)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -1,49 +1,119 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Matchi
|
4
|
-
# *Type/class* matcher.
|
4
|
+
# *Type/class* matcher with enhanced class checking.
|
5
|
+
#
|
6
|
+
# This matcher aims to provide a more reliable way to check if an object is an exact
|
7
|
+
# instance of a specific class (not a subclass). While not foolproof, it uses a more
|
8
|
+
# robust method to get the actual class of an object that helps resist common
|
9
|
+
# attempts at type checking manipulation.
|
10
|
+
#
|
11
|
+
# @example Basic usage
|
12
|
+
# require "matchi/be_an_instance_of"
|
13
|
+
#
|
14
|
+
# matcher = Matchi::BeAnInstanceOf.new(String)
|
15
|
+
# matcher.match? { "foo" } # => true
|
16
|
+
# matcher.match? { :foo } # => false
|
17
|
+
#
|
18
|
+
# @example Enhanced class checking in practice
|
19
|
+
# # Consider a class that attempts to masquerade as String by overriding
|
20
|
+
# # common type checking methods:
|
21
|
+
# class MaliciousString
|
22
|
+
# def class
|
23
|
+
# ::String
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
# def instance_of?(klass)
|
27
|
+
# self.class == klass
|
28
|
+
# end
|
29
|
+
#
|
30
|
+
# def is_a?(klass)
|
31
|
+
# "".is_a?(klass) # Delegates to a real String
|
32
|
+
# end
|
33
|
+
#
|
34
|
+
# def kind_of?(klass)
|
35
|
+
# is_a?(klass) # Maintains Ruby's kind_of? alias for is_a?
|
36
|
+
# end
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
# obj = MaliciousString.new
|
40
|
+
# obj.class # => String
|
41
|
+
# obj.is_a?(String) # => true
|
42
|
+
# obj.kind_of?(String) # => true
|
43
|
+
# obj.instance_of?(String) # => true
|
44
|
+
#
|
45
|
+
# # Using our enhanced checking approach:
|
46
|
+
# matcher = Matchi::BeAnInstanceOf.new(String)
|
47
|
+
# matcher.match? { obj } # => false
|
5
48
|
class BeAnInstanceOf
|
6
|
-
# @return [String] The expected class name.
|
7
|
-
attr_reader :expected
|
8
|
-
|
9
49
|
# Initialize the matcher with (the name of) a class or module.
|
10
50
|
#
|
11
51
|
# @example
|
12
52
|
# require "matchi/be_an_instance_of"
|
13
53
|
#
|
14
54
|
# Matchi::BeAnInstanceOf.new(String)
|
55
|
+
# Matchi::BeAnInstanceOf.new("String")
|
56
|
+
# Matchi::BeAnInstanceOf.new(:String)
|
15
57
|
#
|
16
|
-
# @param expected [Class, #to_s] The expected class name
|
58
|
+
# @param expected [Class, #to_s] The expected class name
|
59
|
+
# @raise [ArgumentError] if the class name doesn't start with an uppercase letter
|
17
60
|
def initialize(expected)
|
18
61
|
@expected = String(expected)
|
62
|
+
return if /\A[A-Z]/.match?(@expected)
|
63
|
+
|
64
|
+
raise ::ArgumentError,
|
65
|
+
"expected must start with an uppercase letter (got: #{@expected})"
|
19
66
|
end
|
20
67
|
|
21
|
-
#
|
22
|
-
# expected class.
|
68
|
+
# Securely checks if the yielded object is an instance of the expected class.
|
23
69
|
#
|
24
|
-
#
|
25
|
-
#
|
70
|
+
# This method uses a specific Ruby reflection technique to get the true class of
|
71
|
+
# an object, bypassing potential method overrides:
|
26
72
|
#
|
27
|
-
#
|
73
|
+
# 1. ::Object.instance_method(:class) retrieves the original, unoverridden 'class'
|
74
|
+
# method from the Object class
|
75
|
+
# 2. .bind_call(obj) binds this original method to our object and calls it,
|
76
|
+
# ensuring we get the real class regardless of method overrides
|
28
77
|
#
|
29
|
-
#
|
30
|
-
#
|
78
|
+
# This approach is more reliable than obj.class because it uses Ruby's method
|
79
|
+
# binding mechanism to call the original implementation directly. While not
|
80
|
+
# completely foolproof, it provides better protection against type check spoofing
|
81
|
+
# than using regular method calls which can be overridden.
|
31
82
|
#
|
32
|
-
# @
|
83
|
+
# @example Basic class check
|
84
|
+
# matcher = Matchi::BeAnInstanceOf.new(String)
|
85
|
+
# matcher.match? { "test" } # => true
|
86
|
+
# matcher.match? { StringIO.new } # => false
|
33
87
|
#
|
34
|
-
# @
|
35
|
-
|
36
|
-
|
37
|
-
|
88
|
+
# @see https://ruby-doc.org/core/Method.html#method-i-bind_call
|
89
|
+
# @see https://ruby-doc.org/core/UnboundMethod.html
|
90
|
+
#
|
91
|
+
# @yieldreturn [Object] the actual value to check
|
92
|
+
# @return [Boolean] true if the object's actual class is exactly the expected class
|
93
|
+
# @raise [ArgumentError] if no block is provided
|
94
|
+
def match?
|
95
|
+
raise ::ArgumentError, "a block must be provided" unless block_given?
|
38
96
|
|
39
|
-
|
40
|
-
|
41
|
-
"#{self.class}(#{expected})"
|
97
|
+
actual_class = ::Object.instance_method(:class).bind_call(yield)
|
98
|
+
expected_class == actual_class
|
42
99
|
end
|
43
100
|
|
44
101
|
# Returns a string representing the matcher.
|
102
|
+
#
|
103
|
+
# @return [String] a human-readable description of the matcher
|
45
104
|
def to_s
|
46
|
-
"be an instance of #{expected}"
|
105
|
+
"be an instance of #{@expected}"
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
# Resolves the expected class name to an actual Class object.
|
111
|
+
# This method handles both string and symbol class names through constant resolution.
|
112
|
+
#
|
113
|
+
# @return [Class] the resolved class
|
114
|
+
# @raise [NameError] if the class doesn't exist
|
115
|
+
def expected_class
|
116
|
+
::Object.const_get(@expected)
|
47
117
|
end
|
48
118
|
end
|
49
119
|
end
|