Skip to content

Commit b16bf00

Browse files
committed
feat: add the be_dir matcher
1 parent ece4afc commit b16bf00

14 files changed

Lines changed: 1492 additions & 101 deletions

File tree

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ RSpec.describe 'Generated Project' do
4040
project_dir = Dir.pwd
4141

4242
expect(project_dir).to(
43-
have_dir(".") do
43+
be_dir do
4444
file("README.md", content: /MyProject/, birthtime: within(10_000).of(Time.now))
4545
dir("lib", exact: true) do
4646
file("my_project.rb")
@@ -54,6 +54,23 @@ RSpec.describe 'Generated Project' do
5454
end
5555
```
5656

57+
Coming soon. Plan to change the interface to use `containing` or `containing_exactly` rather than
58+
nested blocks and the `exact:` option:
59+
60+
```ruby
61+
expect(project_dir).to(
62+
be_dir.containing(
63+
file("README.md", content: /MyProject/, birthtime: within(10_000).of(Time.now)),
64+
dir("lib").containing_exactly(
65+
file("my_project.rb"),
66+
dir("my_project").containing_exactly(
67+
file("version.rb", content: include('VERSION = "0.1.0"'), size: be < 1000)
68+
)
69+
)
70+
)
71+
)
72+
```
73+
5774
## Added Value
5875

5976
Here’s a breakdown of the value this API provides over what is available in standard

lib/rspec/path_matchers.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,18 @@ def self.matcher?(object)
2222

2323
require_relative 'path_matchers/refinements'
2424

25+
def be_dir(**options_hash, &)
26+
RSpec::PathMatchers::Matchers::HaveDirectory.new('', matcher_name: __method__, **options_hash, &)
27+
end
28+
2529
def have_file(name, **options_hash) # rubocop:disable Naming/PredicatePrefix
26-
RSpec::PathMatchers::Matchers::HaveFile.new(name, **options_hash)
30+
RSpec::PathMatchers::Matchers::HaveFile.new(name, matcher_name: __method__, **options_hash)
2731
end
2832

2933
def have_dir(name, **options_hash, &) # rubocop:disable Naming/PredicatePrefix
30-
RSpec::PathMatchers::Matchers::HaveDirectory.new(name, **options_hash, &)
34+
RSpec::PathMatchers::Matchers::HaveDirectory.new(name, matcher_name: __method__, **options_hash, &)
3135
end
3236

3337
def have_symlink(name, **options_hash) # rubocop:disable Naming/PredicatePrefix
34-
RSpec::PathMatchers::Matchers::HaveSymlink.new(name, **options_hash)
38+
RSpec::PathMatchers::Matchers::HaveSymlink.new(name, matcher_name: __method__, **options_hash)
3539
end

lib/rspec/path_matchers/matchers/base.rb

Lines changed: 178 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)