matchi 4.2.0 → 6.0.0

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.
data/lib/matchi/eq.rb CHANGED
@@ -1,85 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Matchi
4
- # Value equivalence matcher that checks if two objects have identical values.
4
+ # Equivalence matcher: passes when the actual value is of the same class as
5
+ # the expected value and `eql?` to it.
5
6
  #
6
- # This matcher verifies value equality using Ruby's Object#eql? method, which
7
- # compares the values of objects rather than their identity. This is different
8
- # from identity comparison (equal?) which checks if objects are the same instance.
7
+ # The expected value receives the `eql?` call, so the actual value cannot
8
+ # lie. The class check comes first because a few core `eql?` methods, such
9
+ # as `Hash#eql?`, hand the comparison back to an operand of another class;
10
+ # it costs nothing, since `eql?` never holds across classes for core values.
9
11
  #
10
- # @example Basic usage with strings
11
- # matcher = Matchi::Eq.new("test")
12
- # matcher.match? { "test" } # => true
13
- # matcher.match? { "test".dup } # => true
14
- # matcher.match? { "other" } # => false
15
- #
16
- # @example With numbers
17
- # matcher = Matchi::Eq.new(42)
18
- # matcher.match? { 42 } # => true
19
- # matcher.match? { 42.0 } # => false # Different types
20
- # matcher.match? { 43 } # => false
21
- #
22
- # @example With collections
23
- # array = [1, 2, 3]
24
- # matcher = Matchi::Eq.new(array)
25
- # matcher.match? { array.dup } # => true # Same values
26
- # matcher.match? { array } # => true # Same object
27
- # matcher.match? { [1, 2, 3] } # => true # Same values
28
- # matcher.match? { [1, 2] } # => false # Different values
29
- #
30
- # @see https://ruby-doc.org/core/Object.html#method-i-eql-3F
31
- # @see Matchi::Be
12
+ # @example
13
+ # Matchi::Eq.new("foo").match? { "foo" } # => true
14
+ # Matchi::Eq.new(1).match? { 1.0 } # => false (eql? is strict on type)
32
15
  class Eq
33
- # Initialize the matcher with a reference value.
34
- #
35
- # @api public
36
- #
37
- # @param expected [#eql?] The expected equivalent value
38
- #
39
- # @return [Eq] a new instance of the matcher
40
- #
41
- # @example
42
- # Eq.new("test") # Match strings with same value
43
- # Eq.new([1, 2, 3]) # Match arrays with same elements
16
+ # @param expected [#eql?] the expected value
44
17
  def initialize(expected)
45
18
  @expected = expected
46
19
  end
47
20
 
48
- # Checks if the yielded object has a value equivalent to the expected object.
49
- #
50
- # This method uses Ruby's Object#eql? method, which performs value comparison.
51
- # Two objects are considered equivalent if they have the same value, even if
52
- # they are different instances.
53
- #
54
- # @api public
55
- #
56
- # @yield [] Block that returns the object to check
57
- # @yieldreturn [Object] The object to verify equivalence with
58
- #
59
- # @return [Boolean] true if both objects have equivalent values
60
- #
61
- # @raise [ArgumentError] if no block is provided
62
- #
63
- # @example
64
- # matcher = Eq.new([1, 2, 3])
65
- # matcher.match? { [1, 2, 3] } # => true
66
- # matcher.match? { [1, 2, 3].dup } # => true
21
+ # @yieldreturn [Object] the actual value
22
+ # @return [Boolean]
23
+ # @raise [ArgumentError] when no block is given
67
24
  def match?
68
25
  raise ::ArgumentError, "a block must be provided" unless block_given?
69
26
 
70
- @expected.eql?(yield)
27
+ Eq.eql?(@expected, yield)
71
28
  end
72
29
 
73
- # Returns a human-readable description of the matcher.
74
- #
75
- # @api public
76
- #
77
- # @return [String] A string describing what this matcher verifies
78
- #
79
- # @example
80
- # Eq.new("test").to_s # => 'eq "test"'
30
+ # @return [String]
81
31
  def to_s
82
32
  "eq #{@expected.inspect}"
83
33
  end
34
+
35
+ # @param expected [#eql?] the value that receives the comparison
36
+ # @param actual [Object] a value from the code under test
37
+ # @return [Boolean] whether actual is of the class of expected and `eql?` to it
38
+ def self.eql?(expected, actual)
39
+ return false unless ::Kernel.instance_method(:class).bind_call(actual).equal?(expected.class)
40
+
41
+ expected.eql?(actual) ? true : false
42
+ end
84
43
  end
85
44
  end
data/lib/matchi/match.rb CHANGED
@@ -1,83 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Matchi
4
- # Pattern matching matcher that checks if a value matches a regular expression.
4
+ # Regular expression matcher: passes when the actual value is a String or a
5
+ # Symbol that the expected Regexp matches. Any other actual value fails, as
6
+ # does a string with an invalid or incompatible encoding.
5
7
  #
6
- # This matcher verifies that a value matches a pattern using Ruby's Regexp#match? method.
7
- # It's particularly useful for string validation, pattern matching, and text analysis.
8
- # The matcher ensures secure pattern matching by requiring the pattern to respond to match?.
8
+ # Only a `Regexp` is accepted as pattern: with a `String` pattern,
9
+ # `String#match?` would turn the actual value into the regular expression,
10
+ # handing the comparison to the code under test. Use {Matchi::Satisfy} for
11
+ # other predicates.
9
12
  #
10
- # @example Basic usage
11
- # matcher = Matchi::Match.new(/^test/)
12
- # matcher.match? { "test_string" } # => true
13
- # matcher.match? { "other_string" } # => false
14
- #
15
- # @example Case sensitivity
16
- # matcher = Matchi::Match.new(/^test$/i)
17
- # matcher.match? { "TEST" } # => true
18
- # matcher.match? { "Test" } # => true
19
- # matcher.match? { "testing" } # => false
20
- #
21
- # @example Multiline patterns
22
- # matcher = Matchi::Match.new(/\A\d+\Z/m)
23
- # matcher.match? { "123" } # => true
24
- # matcher.match? { "12.3" } # => false
25
- #
26
- # @see https://ruby-doc.org/core/Regexp.html#method-i-match-3F
13
+ # @example
14
+ # Matchi::Match.new(/^foo/).match? { "foobar" } # => true
15
+ # Matchi::Match.new(/^foo/).match? { 42 } # => false
27
16
  class Match
28
- # Initialize the matcher with a pattern.
29
- #
30
- # @api public
31
- #
32
- # @param expected [#match?] A pattern that responds to match?
33
- #
34
- # @raise [ArgumentError] if the pattern doesn't respond to match?
35
- #
36
- # @return [Match] a new instance of the matcher
37
- #
38
- # @example
39
- # Match.new(/\d+/) # Match digits
40
- # Match.new(/^test$/i) # Case-insensitive match
41
- # Match.new(/\A\w+\Z/) # Full string word characters
17
+ # @param expected [Regexp] the pattern
18
+ # @raise [ArgumentError] when expected is not a Regexp
42
19
  def initialize(expected)
43
- raise ::ArgumentError, "expected must respond to match?" unless expected.respond_to?(:match?)
20
+ raise ::ArgumentError, "expected must be a Regexp (got: #{expected.inspect})" unless ::Regexp === expected
44
21
 
45
22
  @expected = expected
46
23
  end
47
24
 
48
- # Checks if the yielded value matches the expected pattern.
49
- #
50
- # This method uses the pattern's match? method to perform the comparison.
51
- # The match is performed on the entire string unless the pattern specifically
52
- # allows partial matches.
53
- #
54
- # @api public
55
- #
56
- # @yield [] Block that returns the value to check
57
- # @yieldreturn [#to_s] The value to match against the pattern
58
- #
59
- # @return [Boolean] true if the value matches the pattern
60
- #
61
- # @raise [ArgumentError] if no block is provided
62
- #
63
- # @example
64
- # matcher = Match.new(/^\d{3}-\d{2}-\d{4}$/)
65
- # matcher.match? { "123-45-6789" } # => true
66
- # matcher.match? { "123456789" } # => false
25
+ # @yieldreturn [Object] the actual value
26
+ # @return [Boolean]
27
+ # @raise [ArgumentError] when no block is given
67
28
  def match?
68
29
  raise ::ArgumentError, "a block must be provided" unless block_given?
69
30
 
70
- @expected.match?(yield)
31
+ actual = yield
32
+ return false unless ::String === actual || ::Symbol === actual
33
+
34
+ # A plain copy: a subclass of String has no say in the comparison.
35
+ string = ::String.new(::Symbol === actual ? actual.name : actual)
36
+ return false unless string.valid_encoding?
37
+
38
+ begin
39
+ @expected.match?(string)
40
+ rescue ::Encoding::CompatibilityError
41
+ false
42
+ end
71
43
  end
72
44
 
73
- # Returns a human-readable description of the matcher.
74
- #
75
- # @api public
76
- #
77
- # @return [String] A string describing what this matcher verifies
78
- #
79
- # @example
80
- # Match.new(/^test/).to_s # => "match /^test/"
45
+ # @return [String]
81
46
  def to_s
82
47
  "match #{@expected.inspect}"
83
48
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matchi
4
+ # Validation of the numbers given to matchers: deltas and expected values
5
+ # must be finite and real, so that a `NaN`, an infinity or a `Complex`
6
+ # cannot build a matcher that never, or always, passes.
7
+ #
8
+ # @api private
9
+ module Number
10
+ # @param value [Object]
11
+ # @return [Boolean] whether value is a finite real Numeric
12
+ def self.finite_real?(value)
13
+ ::Numeric === value && value.real? && value.finite?
14
+ end
15
+
16
+ # @param value [Object]
17
+ # @param role [String] the argument name, for the error message
18
+ # @return [Numeric] value
19
+ # @raise [ArgumentError] when value is not a finite real Numeric
20
+ def self.finite_real!(value, role)
21
+ raise ::ArgumentError, "#{role} must be a finite real Numeric (got: #{value.inspect})" unless finite_real?(value)
22
+
23
+ value
24
+ end
25
+
26
+ # @param value [Object]
27
+ # @param role [String] the argument name, for the error message
28
+ # @return [Numeric] value
29
+ # @raise [ArgumentError] when value is not a finite real non-negative Numeric
30
+ def self.non_negative!(value, role)
31
+ finite_real!(value, role)
32
+ raise ::ArgumentError, "#{role} must be non-negative (got: #{value.inspect})" if value.negative?
33
+
34
+ value
35
+ end
36
+ end
37
+ end
@@ -1,172 +1,73 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "constant"
4
+
3
5
  module Matchi
4
- # Predicate matcher that checks if an object responds to a predicate method with a truthy value.
5
- #
6
- # This matcher converts a predicate name (starting with 'be_' or 'have_') into a method call
7
- # ending with '?' and verifies that calling this method returns a boolean value. It's useful
8
- # for testing state-checking methods and collection properties. The matcher supports two types
9
- # of predicate formats: 'be_*' which converts to '*?' and 'have_*' which converts to 'has_*?'.
10
- #
11
- # @example Basic empty check
12
- # matcher = Matchi::Predicate.new(:be_empty)
13
- # matcher.match? { [] } # => true
14
- # matcher.match? { [1, 2] } # => false
15
- #
16
- # @example Object property check with arguments
17
- # matcher = Matchi::Predicate.new(:have_key, :name)
18
- # matcher.match? { { name: "Alice" } } # => true
19
- # matcher.match? { { age: 30 } } # => false
20
- #
21
- # @example Using keyword arguments
22
- # class Record
23
- # def complete?(status: nil)
24
- # status.nil? || status == :validated
25
- # end
26
- # end
6
+ # Predicate matcher: passes when a predicate method of the actual value
7
+ # returns `true`. The matcher name `be_xxx` calls `xxx?`, and `have_xxx`
8
+ # calls `has_xxx?`, with `Kernel#public_send` bound to the actual value.
9
+ # The predicate must return `true` or `false`; any other value is an error,
10
+ # not a truthy pass.
27
11
  #
28
- # matcher = Matchi::Predicate.new(:be_complete, status: :validated)
29
- # matcher.match? { Record.new } # => true
12
+ # Since the actual value answers the call, this matcher depends on the
13
+ # honesty of the object under test. Use it for the object's own documented
14
+ # predicates.
30
15
  #
31
- # @example With block arguments
32
- # class List
33
- # def all?(&block)
34
- # block ? super : empty?
35
- # end
36
- # end
37
- #
38
- # matcher = Matchi::Predicate.new(:be_all) { |x| x.positive? }
39
- # matcher.match? { [1, 2, 3] } # => true
40
- # matcher.match? { [-1, 2, 3] } # => false
41
- #
42
- # @see https://ruby-doc.org/core/Object.html#method-i-respond_to-3F
16
+ # @example
17
+ # Matchi::Predicate.new(:be_empty).match? { [] } # => true
18
+ # Matchi::Predicate.new(:have_key, :a).match? { { a: 1 } } # => true
43
19
  class Predicate
44
- # Mapping of predicate prefixes to their method name transformations.
45
- # Each entry defines how a prefix should be converted to its method form.
46
- #
47
- # @api private
48
- PREFIXES = {
49
- "be_" => ->(name) { "#{name.gsub(/\A(?:be_)/, "")}?" },
50
- "have_" => ->(name) { "#{name.gsub(/\A(?:have_)/, "has_")}?" }
51
- }.freeze
20
+ # A matcher name: `be_` or `have_`, then a letter, then letters, digits
21
+ # or underscores, in any script. It never ends with `?` or `!`.
22
+ NAME = /\A(be|have)_\p{Alpha}[\p{Alnum}_]*\z/
52
23
 
53
- # Initialize the matcher with a predicate name and optional arguments.
54
- #
55
- # @api public
56
- #
57
- # @param name [#to_s] A matcher name starting with 'be_' or 'have_'
58
- # @param args [Array] Optional positional arguments to pass to the predicate method
59
- # @param kwargs [Hash] Optional keyword arguments to pass to the predicate method
60
- # @param block [Proc] Optional block to pass to the predicate method
61
- #
62
- # @raise [ArgumentError] if the predicate name format is invalid
63
- #
64
- # @return [Predicate] a new instance of the matcher
65
- #
66
- # @example With simple predicate
67
- # Predicate.new(:be_empty) # Empty check
68
- #
69
- # @example With arguments
70
- # Predicate.new(:have_key, :id) # Key presence check
71
- #
72
- # @example With keyword arguments
73
- # Predicate.new(:be_valid, status: true) # Conditional validation
24
+ # @param name [Symbol, String] `be_xxx` or `have_xxx`
25
+ # @param args [Array] positional arguments for the predicate
26
+ # @param kwargs [Hash] keyword arguments for the predicate
27
+ # @param block [Proc, nil] a block for the predicate
28
+ # @raise [ArgumentError] when the name does not start with be_ or have_
74
29
  def initialize(name, *args, **kwargs, &block)
75
30
  @name = String(name)
76
- raise ::ArgumentError, "invalid predicate name format" unless valid_name?
31
+ raise ::ArgumentError, "name must match #{NAME.inspect} (got: #{name.inspect})" unless NAME.match?(@name)
77
32
 
33
+ @method_name = method_name
78
34
  @args = args
79
35
  @kwargs = kwargs
80
36
  @block = block
81
37
  end
82
38
 
83
- # Checks if the yielded object responds to and returns true for the predicate.
84
- #
85
- # This method converts the predicate name into a method name according to the prefix
86
- # mapping and calls it on the yielded object with any provided arguments. The method
87
- # must return a boolean value, or a TypeError will be raised.
88
- #
89
- # @api public
90
- #
91
- # @yield [] Block that returns the object to check
92
- # @yieldreturn [Object] The object to call the predicate method on
93
- #
94
- # @return [Boolean] true if the predicate method returns true
95
- #
96
- # @raise [ArgumentError] if no block is provided
97
- # @raise [TypeError] if predicate method returns non-boolean value
98
- #
99
- # @example Basic usage
100
- # matcher = Predicate.new(:be_empty)
101
- # matcher.match? { [] } # => true
102
- # matcher.match? { [1] } # => false
103
- #
104
- # @example With arguments
105
- # matcher = Predicate.new(:have_key, :id)
106
- # matcher.match? { { id: 1 } } # => true
39
+ # @yieldreturn [Object] the actual value
40
+ # @return [Boolean]
41
+ # @raise [ArgumentError] when no block is given
42
+ # @raise [TypeError] when the predicate returns something other than true or false
43
+ # @raise [NoMethodError] when the actual value has no such public predicate
107
44
  def match?
108
45
  raise ::ArgumentError, "a block must be provided" unless block_given?
109
46
 
110
- value = yield.send(method_name, *@args, **@kwargs, &@block)
111
- return value if [false, true].include?(value)
47
+ result = ::Kernel.instance_method(:public_send).bind_call(yield, @method_name, *@args, **@kwargs, &@block)
48
+ return result if true.equal?(result) || false.equal?(result)
112
49
 
113
- raise ::TypeError, "Boolean expected, but #{value.class} instance returned."
50
+ raise ::TypeError, "#{@method_name} must return true or false (got a #{Constant.label(result_class(result))})"
114
51
  end
115
52
 
116
- # Returns a human-readable description of the matcher.
117
- #
118
- # @api public
119
- #
120
- # @return [String] A string describing what this matcher verifies
121
- #
122
- # @example Simple predicate
123
- # Predicate.new(:be_empty).to_s # => "be empty"
124
- #
125
- # @example With arguments
126
- # Predicate.new(:have_key, :id).to_s # => "have key :id"
127
- #
128
- # @example With keyword arguments
129
- # Predicate.new(:be_valid, active: true).to_s # => "be valid active: true"
53
+ # @return [String]
130
54
  def to_s
131
- (
132
- "#{@name.tr("_", " ")} " + [
133
- @args.map(&:inspect).join(", "),
134
- @kwargs.map { |k, v| "#{k}: #{v.inspect}" }.join(", "),
135
- (@block.nil? ? "" : "&block")
136
- ].reject { |i| i.eql?("") }.join(", ")
137
- ).strip
55
+ arguments = @args.map(&:inspect) + @kwargs.map { |key, value| "#{key}: #{value.inspect}" }
56
+ arguments << "&block" if @block
57
+
58
+ [@name.tr("_", " "), arguments.join(", ")].reject(&:empty?).join(" ")
138
59
  end
139
60
 
140
61
  private
141
62
 
142
- # Converts the predicate name into the actual method name to call.
143
- #
144
- # @api private
145
- #
146
- # @return [Symbol] The method name to call on the object
147
- # @raise [ArgumentError] if the predicate prefix is unknown
148
- #
149
- # @example
150
- # # With be_ prefix
151
- # method_name # => :empty? (from be_empty)
152
- # # With have_ prefix
153
- # method_name # => :has_key? (from have_key)
154
63
  def method_name
155
- _, transform = PREFIXES.find { |prefix, _| @name.start_with?(prefix) }
156
- return transform.call(@name) if transform
64
+ return "#{@name.delete_prefix("be_")}?" if @name.start_with?("be_")
157
65
 
158
- raise ::ArgumentError, "unknown prefix in predicate name: #{@name}"
66
+ "has_#{@name.delete_prefix("have_")}?"
159
67
  end
160
68
 
161
- # Verifies that the predicate name follows the required format.
162
- #
163
- # @api private
164
- #
165
- # @return [Boolean] true if the name follows the required format
166
- def valid_name?
167
- return false if @name.match?(/[?!]\z/)
168
-
169
- PREFIXES.keys.any? { |prefix| @name.start_with?(prefix) }
69
+ def result_class(result)
70
+ ::Kernel.instance_method(:class).bind_call(result)
170
71
  end
171
72
  end
172
73
  end