@@ -4,14 +4,102 @@ module RSpec
44 module PathMatchers
55 module Matchers
66 # The base class for matchers
7+ #
8+ # Implements the [RSpec matcher
9+ # protocol](https://rspec.info/documentation/3.13/rspec-expectations/RSpec/Matchers/MatcherProtocol.html)
10+ # for value expectations including:
11+ #
12+ # - `#matches?(base_path)` - checks if the matcher matches the given base_path
13+ # - `#failure_message` - returns a human-readable failure message
14+ # - `#actual` - returns the actual value that was matched against
15+ # - `#expected` - returns the expected value that was matched against
16+ # - `#description` - returns a human-readable description of the matcher
17+ # - `#does_not_match?(base_path)` - checks if the matcher does not match the given base_path
18+ # - `#failure_message_when_negated` - returns a human-readable failure message for negative matches
19+ #
720 class Base # rubocop:disable Metrics/ClassLength
8- def initialize ( name , **options_hash )
21+ # Create a new matcher instance
22+ #
23+ # Subclasses may override this to provide additional arguments or options.
24+ # They should call `super` to ensure the base class is initialized correctly.
25+ #
26+ # @param entry_name [String] The name of the entry to match against
27+ #
28+ # - If entry_name is empty, the matcher will match against the base_path
29+ # directly
30+ # - If entry_name is NOT empty, the matcher will match against
31+ # File.join(base_path, entry_name)
32+ #
33+ # @param matcher_name [Symbol] The matcher name (e.g. :have_dir, :have_file,
34+ # etc.) to use in descriptions and messages
35+ #
36+ # @param options_hash [Hash] Options for the matcher passed to the options
37+ # factory to get an options object
38+ #
39+ def initialize ( entry_name , matcher_name :, **options_hash )
940 super ( )
10- @name = name . to_s
41+ @entry_name = entry_name . to_s
42+ @matcher_name = matcher_name
1143 @options = options_factory ( *option_keys , **options_hash )
44+ @failure_messages = [ ]
1245 end
1346
14- attr_reader :name , :options , :base_path , :path
47+ # @attribute [r] options
48+ #
49+ # The matcher options loaded from the options_hash passed to the initializer
50+ #
51+ # @return [Object] A Data object containing the options
52+ #
53+ attr_reader :options
54+
55+ # @attribute [r] base_path
56+ #
57+ # The base path against which the matcher is applied
58+ #
59+ # @return [String]
60+ #
61+ attr_reader :base_path
62+
63+ # @attribute [r] path
64+ #
65+ # The full path to the entry being matched against
66+ #
67+ # @return [String]
68+ #
69+ attr_reader :path
70+
71+ # @attribute [r] matcher_name
72+ #
73+ # The name of this matcher, used for descriptions and messages
74+ #
75+ # @return [Symbol] The matcher name (e.g. :have_dir, :have_file, etc.)
76+ #
77+ attr_reader :matcher_name
78+
79+ # @attribute [r] failure_messages
80+ #
81+ # An array of failure messages that describe why the matcher did not match
82+ # the actual value
83+ #
84+ # Only populated after `matches?` or `execute_match` is called
85+ #
86+ # @return [Array<String>]
87+ #
88+ attr_reader :failure_messages
89+
90+ # @attribute [r] entry_name
91+ #
92+ # The name of the entry being matched against or an empty String
93+ #
94+ # If `entry_name` is empty, the matcher will match against the base_path
95+ # directly. If `entry_name` is not empty, the matcher will match against
96+ # `File.join(base_path, entry_name)`.
97+ #
98+ # @return [String]
99+ #
100+ def entry_name
101+ @entry_name || base_path . basename
102+ end
15103
16104 # A human-readable description of the matcher's expectation
17105 #
@@ -20,25 +108,31 @@ def initialize(name, **options_hash)
20108 # have_file("foo")` and the file does not exist, the failure message will
21109 # include the output of this method: "expected to have file \"foo\"".
22110 #
111+ # Subclasses can override this method to add to or provide a more specific
112+ # description based on the entry type, options, or other factors.
113+ #
23114 # @return [String] A description of the matcher
24115 #
25116 def description
26- desc = " have #{ entry_type } #{ name . inspect } "
117+ desc = ( @entry_name . empty? ? "be a #{ entry_type } " : " have #{ entry_type } #{ entry_name . inspect } ")
27118 options_description = build_options_description
28119 desc += " with #{ options_description } " unless options_description . empty?
29120 desc
30121 end
31122
32- def failure_messages
33- @failure_messages ||= [ ]
34- end
35-
123+ # Returns `true` if the matcher matches the actual value
124+ #
125+ # If `false` is returned, the `failure_messages` array will be populated with
126+ # human-readable error messages that describe why the match failed.
127+ #
128+ # @param base_path [Object] The base_path together with entry_name determine the actual value
129+ #
130+ # @return [Boolean] `true` if the matcher matches, `false` otherwise
131+ #
132+ # @raise [ArgumentError] if there are errors in the matcher or its options
133+ #
36134 def matches? ( base_path )
37- # It is important to reset failure_messages in case this matcher instance
38- # is reused
39- @failure_messages = [ ]
40-
41- # Phase 1: Validate all options recursively for syntax errors
135+ # Phase 1: Validate all options for syntax errors
42136 validation_errors = [ ]
43137 collect_validation_errors ( validation_errors )
44138 raise ArgumentError , validation_errors . join ( ', ' ) if validation_errors . any?
@@ -47,24 +141,31 @@ def matches?(base_path)
47141 execute_match ( base_path )
48142 end
49143
50- def collect_negative_validation_errors ( errors )
51- errors << "The matcher `not_to #{ matcher_name } (...)` cannot be given options" if options . any_given?
144+ def failure_message
145+ header = "the entry '#{ entry_name } ' at '#{ base_path } ' was expected to satisfy the following but did not:"
146+ # Format single- and multi-line nested messages with proper indentation.
147+ messages = failure_messages . map do |msg |
148+ msg . lines . map . with_index do |line , i |
149+ i . zero? ? " - #{ line . chomp } " : " #{ line . chomp } "
150+ end . join ( "\n " )
151+ end . join ( "\n " )
152+ "#{ header } \n #{ messages } "
52153 end
53154
54155 # This method is called by RSpec for `expect(...).not_to have_...`
55156 def does_not_match? ( base_path ) # rubocop:disable Naming/PredicatePrefix
56- # 1. Validate that no options were passed to the negative matcher.
157+ # Phase 1: Validate all options for syntax errors (in this case options are not allowed)
57158 validation_errors = [ ]
58159 collect_negative_validation_errors ( validation_errors )
59160 raise ArgumentError , validation_errors . join ( ', ' ) if validation_errors . any?
60161
61162 @base_path = base_path . to_s
62- @path = File . join ( base_path , name )
163+ @path = @entry_name . empty? ? base_path : File . join ( base_path , entry_name )
63164
64- # 2. A negative match SUCCEEDS if the entry of the specified type does NOT exist.
65- # We delegate the type-specific check to the subclass.
66- # The method returns `true` if the entry is NOT of the correct type (pass),
67- # and `false` if it IS of the correct type (fail) .
165+ # Phase 2: Execute the actual match logic
166+ #
167+ # A negative match SUCCEEDS if the entry of the specified type does NOT
168+ # exist. We delegate the type-specific check to the subclass .
68169 !correct_type?
69170 end
70171
@@ -73,12 +174,19 @@ def failure_message_when_negated
73174 "expected it not to be a #{ entry_type } "
74175 end
75176
76- # Recursively gathers all syntax/validation errors
177+ protected
178+
179+ # Add to `errors` if the matcher is not defined correctly
180+ #
181+ # Subclasses may override this method to provide additional checking. For
182+ # instance, HaveDirectory extends this to add validation for nested matchers.
77183 #
78- # Subclasses (like HaveDirectory) may extend this to recurse into nested
79- # matchers.
184+ # A nesting matcher (such as HaveDirectory) may call this method on the
185+ # nested matchers to collect all validation errors before raising an
186+ # ArgumentError.
80187 #
81- # @param errors [Array<String>] An array to append validation error messages to.
188+ # @param errors [Array<String>] An array to populate with validation error
189+ # messages
82190 #
83191 # @return [void]
84192 #
@@ -88,40 +196,62 @@ def collect_validation_errors(errors)
88196 validate_option_values ( errors )
89197 end
90198
91- def failure_message
92- header = "the entry '#{ name } ' at '#{ base_path } ' was expected to satisfy the following but did not:"
93- # Format single- and multi-line nested messages with proper indentation.
94- messages = failure_messages . map do |msg |
95- msg . lines . map . with_index do |line , i |
96- i . zero? ? " - #{ line . chomp } " : " #{ line . chomp } "
97- end . join ( "\n " )
98- end . join ( "\n " )
99- "#{ header } \n #{ messages } "
100- end
101-
199+ # Returns `true` if `path` exists and is of the correct type for this matcher
200+ #
201+ # Subclasses must implement this method to check the type of the entry. For
202+ # example, HaveFile checks if the path is a regular file; HaveDirectory, a
203+ # directory; and HaveSymlink, a symlink.
204+ #
205+ # @return [Boolean]
206+ #
207+ # @abstract
208+ #
102209 def correct_type?
103- raise NotImplementedError , 'This method should be implemented in a subclass '
210+ raise NotImplementedError , 'Subclasses must implement Base#correct_type? '
104211 end
105212
106- def matcher_name
107- raise NotImplementedError , 'This method should be implemented in a subclass'
213+ # Add to errors if options were passed to the negative matcher
214+ #
215+ # This method is called by RSpec for `expect(...).not_to <matcher>...`
216+ #
217+ # Subclasses can override this to add additional validation.
218+ #
219+ # @param errors [Array<String>] An array to append validation error messages to
220+ #
221+ # @return [void]
222+ #
223+ def collect_negative_validation_errors ( errors )
224+ errors << "The matcher `not_to #{ matcher_name } (...)` cannot be given options" if options . any_given?
108225 end
109226
110- protected
227+ # Subclasses should define their own option definitions
228+ #
229+ # @return [Array<RSpec::PathMatchers::Options::Base>] An array of option definitions
230+ #
231+ def option_definitions = [ ]
111232
233+ # The type of entry this matcher is checking for
234+ #
235+ # @return [Symbol] The entry type (e.g. :file, :directory, :symlink, ...)
236+ #
112237 def entry_type
113- self . class . name . split ( '::' ) . last . sub ( /^Have/ , '' ) . downcase
238+ raise NotImplementedError , 'Subclasses must implement Base#entry_type'
114239 end
115240
116241 # Performs the actual matching against the directory entry
117242 #
118243 # This method assumes that collect_validation_errors has already been called
119- # and passed. This method is protected so that container matchers (like
120- # HaveDirectory) can call it on nested matchers without using .send.
244+ # and passed.
245+ #
246+ # This method is protected so that container matchers (like HaveDirectory)
247+ # can call it on nested matchers without using .send.
121248 #
122249 def execute_match ( base_path ) # rubocop:disable Naming/PredicateMethod
250+ # It is important to reset failure_messages in case this matcher is reused
251+ @failure_messages = [ ]
252+
123253 @base_path = base_path . to_s
124- @path = File . join ( base_path , name )
254+ @path = @entry_name . empty? ? base_path : File . join ( base_path , entry_name )
125255
126256 # Validate existence and type.
127257 validate_existance ( failure_messages )
@@ -177,12 +307,14 @@ def validate_option_values(errors)
177307 end
178308
179309 def validate_existance ( _failure_messages )
180- raise NotImplementedError , 'This method should be implemented in a subclass '
310+ raise NotImplementedError , 'Subclasses must implement Base#validate_existance '
181311 end
182312
183313 # Validate the options for the current matcher
184314 #
185- # Subclasses will override this to add nested execution.
315+ # Subclasses may override this to add additional validation logic. For
316+ # instance, HaveDirectory extends this to check nested matchers.
317+ #
186318 def validate_options
187319 options . members . each do |key |
188320 expected = options . send ( key )
0 commit comments