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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b329fa6d8f4acd083fa33dc5a875ab30d521ad598a9a98d87e2e9fa0f3a4ea9
4
- data.tar.gz: 122df6c5b532ae6ce316108ac76b32b5528dde249318c45272793df55a6bf240
3
+ metadata.gz: e8454c54085d68ded31ba34e1ff0ab03cdc3dab452316ef82a15967f3de3ee4c
4
+ data.tar.gz: ac11c5ab16b7e93f16189b1fa62f0d0502e0d94e67627f81dfab40cd7db48d61
5
5
  SHA512:
6
- metadata.gz: 61dc6797282ee0c2643e57a259b45d0f3b857cc0c725bd55adc1de4fddce61d7822bb25a6dc5bfac30b160c1130a7f76d6b0912a960d8ca930c7485fbfcb84bc
7
- data.tar.gz: 40d1d11e0b104d82bd30198de0eb1dba07f0a4fcc725f6936f8ccbec47276e2cbf91e67f4f57065f79e43dcb46b623ea4c5a085c37ac0d70b7516baad3e0d5f7
6
+ metadata.gz: b984f3a46f4eb2f0743b4407518261ca0539efd780b28ab0b0f476a687925bedeec2d88249160d7c148617809080593d9472957dc8571bf92295007dfaf941b7
7
+ data.tar.gz: 96f609b1bc8af4ddf6a5b7b839ed0b981018d7aac53f1d26b5ea05c1629318f3cdbef64e9776dd2224c3fdd48b56e148e8e3a94df4de5d07e93164941da0fa25
data/LICENSE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2015-2022 Cyril Kato
3
+ Copyright (c) 2015-2024 Cyril Kato
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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/releases)
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
- [![CI](https://github.com/fixrb/matchi/workflows/CI/badge.svg?branch=main)](https://github.com/fixrb/matchi/actions?query=workflow%3Aci+branch%3Amain)
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
- > Collection of expectation matchers for Rubyists 🤹
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 __Matchi__ matcher is an object that must respond to the `matches?` method with a block as argument, and return a boolean.
56
+ A **Matchi** matcher is a simple Ruby object that follows these requirements:
56
57
 
57
- To facilitate the integration of the matchers in other tools, __Matchi__ matchers may expose expected values via the `expected` method.
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 useful generic matchers.
68
+ Here is the collection of generic matchers.
62
69
 
63
- **Equivalence** matcher:
70
+ #### Basic Comparison Matchers
64
71
 
72
+ ##### `Be`
73
+ Checks for object identity using Ruby's `equal?` method.
65
74
  ```ruby
66
- matcher = Matchi::Eq.new("foo")
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
- **Identity** matcher:
73
-
79
+ ##### `Eq`
80
+ Verifies object equivalence using Ruby's `eql?` method.
74
81
  ```ruby
75
- matcher = Matchi::Be.new(:foo)
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
- **Comparisons** matcher:
86
+ #### Type and Class Matchers
82
87
 
88
+ ##### `BeAnInstanceOf`
89
+ Verifies exact class matching (no inheritance).
83
90
  ```ruby
84
- matcher = Matchi::BeWithin.new(8).of(37)
85
-
86
- matcher.expected # => 37
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
- **Regular expressions** matcher:
91
-
96
+ ##### `BeAKindOf`
97
+ Verifies class inheritance and module inclusion.
92
98
  ```ruby
93
- matcher = Matchi::Match.new(/^foo$/)
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
- **Expecting errors** matcher:
103
+ #### Pattern Matchers
100
104
 
105
+ ##### `Match`
106
+ Tests string patterns against regular expressions.
101
107
  ```ruby
102
- matcher = Matchi::RaiseException.new(:NameError)
103
-
104
- matcher.expected # => "NameError"
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
- **Type/class** matcher:
109
-
113
+ ##### `Satisfy`
114
+ Provides custom matching through a block.
110
115
  ```ruby
111
- matcher = Matchi::BeAnInstanceOf.new(:String)
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
- **Predicate** matcher:
118
-
119
- ```ruby
120
- matcher = Matchi::Predicate.new(:be_empty)
120
+ #### State Change Matchers
121
121
 
122
- matcher.expected # => [:empty?, [], {}, nil]
123
- matcher.matches? { [] } # => true
122
+ ##### `Change`
123
+ Verifies state changes in objects with multiple variation methods:
124
124
 
125
- matcher = Matchi::Predicate.new(:have_key, :foo)
126
-
127
- matcher.expected # => [:has_key?, [:foo], {}, nil]
128
- matcher.matches? { { foo: 42 } } # => true
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
- **Change** matcher:
132
-
131
+ ###### Minimum Change
133
132
  ```ruby
134
- object = []
135
- matcher = Matchi::Change.new(object, :length).by(1)
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
- matcher.expected # => 1
144
- matcher.matches? { object << 1 } # => true
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
- object = []
147
- matcher = Matchi::Change.new(object, :length).by_at_most(1)
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
- matcher.expected # => 1
150
- matcher.matches? { object << 1 } # => true
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
- object = "foo"
153
- matcher = Matchi::Change.new(object, :to_s).from("foo").to("FOO")
155
+ #### Numeric Matchers
154
156
 
155
- matcher.expected # => "FOO"
156
- matcher.matches? { object.upcase! } # => true
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
- object = "foo"
159
- matcher = Matchi::Change.new(object, :to_s).to("FOO")
164
+ #### Behavior Matchers
160
165
 
161
- matcher.expected # => "FOO"
162
- matcher.matches? { object.upcase! } # => true
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
- **Satisfy** matcher:
173
+ ##### `Predicate`
174
+ Creates matchers for methods ending in `?`.
166
175
 
176
+ ###### Using `be_` prefix
167
177
  ```ruby
168
- matcher = Matchi::Satisfy.new { |value| value == 42 }
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
- matcher.expected # => #<Proc:0x00007fbaafc65540>
171
- matcher.matches? { 42 } # => true
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 can easily be added to express more specific expectations.
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 expected
184
- 42
196
+ def match?
197
+ expected.equal?(yield)
185
198
  end
186
199
 
187
- def matches?
188
- expected.equal?(yield)
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 matches?
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.matches? { 42 } # => false
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 matches?
229
- Regexp.new(/\A#{expected}/).match?(yield)
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
- matcher.expected # => "foo"
237
- matcher.matches? { "foobar" } # => true
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
- <p>
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 matches?
36
- expected.equal?(yield)
37
- end
30
+ def match?
31
+ raise ::ArgumentError, "a block must be provided" unless block_given?
38
32
 
39
- # A string containing a human-readable representation of the matcher.
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
- # Boolean comparison between the class of the actual value and the
22
- # expected class.
68
+ # Securely checks if the yielded object is an instance of the expected class.
23
69
  #
24
- # @example
25
- # require "matchi/be_an_instance_of"
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
- # matcher = Matchi::BeAnInstanceOf.new(String)
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
- # matcher.expected # => "String"
30
- # matcher.matches? { "foo" } # => true
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
- # @yieldreturn [#class] the actual value to compare to the expected one.
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
- # @return [Boolean] Comparison between actual and expected values.
35
- def matches?
36
- self.class.const_get(expected).equal?(yield.class)
37
- end
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
- # A string containing a human-readable representation of the matcher.
40
- def inspect
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