Skip to content

Commit aa8c2de

Browse files
committed
feat!: change how expectations are nested for have_dir and be_dir
Instead of setting expectations on directory contents using a block like this: ```ruby expect('lib').to( be_dir do file('file1.txt') file('file2.txt') end ) ``` You now call the `#containing` method on the matcher like this: ```ruby expect('lib').to( be_dir.containing( file('file1.txt'), file('file2.txt') ) ) ``` BREAKING CHANGE: You must use `#containing` to to set expectations on dir content instead of a block
1 parent b16bf00 commit aa8c2de

11 files changed

Lines changed: 500 additions & 543 deletions

File tree

README.md

Lines changed: 36 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ Status](https://img.shields.io/github/actions/workflow/status/main-branch/rspec-
88
License](https://img.shields.io/badge/license-MIT-green)](https://opensource.org/licenses/MIT)
99

1010
- [Summary](#summary)
11-
- [Added Value](#added-value)
11+
- [Added Value Over Standard RSpec Matchers](#added-value-over-standard-rspec-matchers)
1212
- [Installation](#installation)
1313
- [Setup](#setup)
14-
- [Usage \& Examples](#usage--examples)
14+
- [Usage And Examples](#usage-and-examples)
1515
- [Basic Assertions](#basic-assertions)
1616
- [Negative Assertions (Checking for Absence)](#negative-assertions-checking-for-absence)
1717
- [File Content Assertions](#file-content-assertions)
@@ -35,43 +35,27 @@ file tree and its properties within your specs. For example:
3535
```ruby
3636
require 'rspec/path_matchers'
3737

38-
RSpec.describe 'Generated Project' do
38+
RSpec.describe 'The newly generated project' do
3939
it "should contain project files" do
4040
project_dir = Dir.pwd
4141

4242
expect(project_dir).to(
43-
be_dir do
44-
file("README.md", content: /MyProject/, birthtime: within(10_000).of(Time.now))
45-
dir("lib", exact: true) do
46-
file("my_project.rb")
47-
dir("my_project", exact: true) do
43+
be_dir.containing(
44+
file("README.md", content: include('MyProject'), birthtime: within(10_000).of(Time.now)),
45+
dir("lib").containing_exactly(
46+
file("my_project.rb"),
47+
dir("my_project").containing_exactly(
4848
file("version.rb", content: include('VERSION = "0.1.0"'), size: be < 1000)
49-
end
50-
end
51-
end
49+
)
50+
),
51+
no_dir_named('tmp')
52+
)
5253
)
5354
end
5455
end
5556
```
5657

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-
74-
## Added Value
58+
## Added Value Over Standard RSpec Matchers
7559

7660
Here’s a breakdown of the value this API provides over what is available in standard
7761
RSpec.
@@ -153,15 +137,17 @@ With this API:
153137

154138
```ruby
155139
# This is a clear, hierarchical specification of the directory's contents.
156-
expect('/etc/service').to(have_dir('nginx') do
157-
file('run')
158-
dir('log')
159-
no_file('down')
160-
end)
140+
expect('/etc/service/nginx').to(
141+
be_dir.containing(
142+
file('run'),
143+
dir('log'),
144+
no_file_named('down')
145+
)
146+
)
161147
```
162148

163-
The nested block is a leap in expressiveness and power, allowing you to write
164-
complex integration and infrastructure tests with ease.
149+
The nested `containing` matchers allows you to write tests that humans
150+
can make sense of.
165151

166152
<h3>4. Descriptive and Intelligible Failure Messages</h3>
167153

@@ -183,8 +169,18 @@ what went wrong.
183169
You get a detailed, hierarchical report that shows the full expectation and
184170
clearly marks what failed.
185171

172+
For this expectation:
173+
174+
```ruby
175+
expect('config').to(
176+
be_dir.containing(
177+
file('database.xml', owner: 'db_user', mode: '0600')
178+
)
179+
)
180+
```
181+
186182
```text
187-
the entry 'my-app' at '/tmp/d20250622-12345-abcdef' was expected to satisfy the following but did not:
183+
'/tmp/d20250622-12345-abcdef/my-app' was expected to satisfy the following but did not:
188184
- have directory "config" containing:
189185
- have file "database.yml" with owner "db_user" and mode "0600"
190186
- expected owner to be "db_user", but was "root"
@@ -222,7 +218,7 @@ Require the gem in your `spec/spec_helper.rb` file:
222218
require 'rspec/path_matchers'
223219
```
224220

225-
## Usage & Examples
221+
## Usage And Examples
226222

227223
All matchers operate on a base path, making your tests clean and portable.
228224

@@ -359,7 +355,7 @@ end
359355

360356
The block syntax is the most powerful feature. It allows you to describe and verify
361357
an entire file tree, including both the presence and *absence* of entries using
362-
methods like `no_file`, `no_dir`, and `no_symlink`.
358+
methods like `no_dir_named`, `no_file_named`, and `no_symlink_named`.
363359

364360
```ruby
365361
before do
@@ -395,7 +391,7 @@ it "validates a nested directory structure" do
395391
dir "log"
396392

397393
# Assert the absence of other entries at the root of 'my-app'
398-
no_dir "tmp"
394+
no_dir_named "tmp"
399395
no_file "README.md"
400396
end)
401397
end

design.rb

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,17 @@
4747

4848
# Nested directory checks ()
4949
expect(path).to(
50-
have_dir(name) do
51-
file('nested_file.txt', content: 'expected content')
52-
dir('nested_dir') do
50+
have_dir(name).containing(
51+
file('nested_file.txt', content: 'expected content'),
52+
symlink('nested_symlink', target: 'expected_target'),
53+
no_file_named('non_existent_file.txt'),
54+
no_dir_named('non_existent_dir'),
55+
no_symlink_named('non_existent_symlink'),
56+
no_entry_named('non_existent_entry'),
57+
dir('nested_dir').containing_exactly(
5358
file('deeply_nested_file.txt', content: 'deeply expected content')
54-
end
55-
symlink('nested_symlink', target: 'expected_target')
56-
no_file('non_existent_file.txt')
57-
no_dir('non_existent_dir')
58-
no_symlink('non_existent_symlink')
59-
no_entry('non_existent_entry') # This checks for the absence of any type of entry with the given name
60-
end
59+
)
60+
)
6161
)
6262

6363
########################################

lib/rspec/path_matchers.rb

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,41 @@ 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
25+
module RSpec
26+
# RSpec::PathMatchers is a collection of matchers for testing directory entries
27+
module PathMatchers
28+
def be_dir(**options_hash)
29+
RSpec::PathMatchers::Matchers::HaveDirectory.new('', matcher_name: __method__, **options_hash)
30+
end
2831

29-
def have_file(name, **options_hash) # rubocop:disable Naming/PredicatePrefix
30-
RSpec::PathMatchers::Matchers::HaveFile.new(name, matcher_name: __method__, **options_hash)
31-
end
32+
def have_file(name, **options_hash) # rubocop:disable Naming/PredicatePrefix
33+
RSpec::PathMatchers::Matchers::HaveFile.new(name, matcher_name: __method__, **options_hash)
34+
end
3235

33-
def have_dir(name, **options_hash, &) # rubocop:disable Naming/PredicatePrefix
34-
RSpec::PathMatchers::Matchers::HaveDirectory.new(name, matcher_name: __method__, **options_hash, &)
35-
end
36+
alias file have_file
37+
38+
def have_dir(name, **options_hash) # rubocop:disable Naming/PredicatePrefix
39+
RSpec::PathMatchers::Matchers::HaveDirectory.new(name, matcher_name: __method__, **options_hash)
40+
end
3641

37-
def have_symlink(name, **options_hash) # rubocop:disable Naming/PredicatePrefix
38-
RSpec::PathMatchers::Matchers::HaveSymlink.new(name, matcher_name: __method__, **options_hash)
42+
alias dir have_dir
43+
44+
def have_symlink(name, **options_hash) # rubocop:disable Naming/PredicatePrefix
45+
RSpec::PathMatchers::Matchers::HaveSymlink.new(name, matcher_name: __method__, **options_hash)
46+
end
47+
48+
alias symlink have_symlink
49+
50+
def no_dir_named(name)
51+
RSpec::PathMatchers::Matchers::HaveNoEntry.new(name, entry_type: :directory)
52+
end
53+
54+
def no_file_named(name)
55+
RSpec::PathMatchers::Matchers::HaveNoEntry.new(name, entry_type: :file)
56+
end
57+
58+
def no_symlink_named(name)
59+
RSpec::PathMatchers::Matchers::HaveNoEntry.new(name, entry_type: :symlink)
60+
end
61+
end
3962
end

lib/rspec/path_matchers/matchers/directory_contents_inspector.rb

Lines changed: 0 additions & 59 deletions
This file was deleted.

lib/rspec/path_matchers/matchers/have_directory.rb

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
# frozen_string_literal: true
22

33
require_relative 'base'
4-
require_relative 'directory_contents_inspector'
54

65
module RSpec
76
module PathMatchers
@@ -21,7 +20,35 @@ class HaveDirectory < Base
2120

2221
def entry_type = :directory
2322

24-
attr_reader :nested_matchers, :exact
23+
# An array of nested matchers that define the expected contents of the
24+
# directory
25+
#
26+
# One element is added to the @nested_matchers array for each call to
27+
# `containing` or `containing_exactly`. Since `containing` or
28+
# `containing_exactly` are allowed only once per matcher, an error will be
29+
# logged during validation if the size of this array is greater than one.
30+
#
31+
# Each element in @nested_matchers is itself an array of the matchers given
32+
# in each `containing` or `containing_exactly` call.
33+
#
34+
# This method returns the first element of @nested_matchers (or an empty
35+
# array of @nested_matchers is itself empty).
36+
#
37+
# @return [Array<Array<RSpec::PathMatchers::Matchers::Base>>]
38+
#
39+
def nested_matchers
40+
@nested_matchers.first || []
41+
end
42+
43+
# @attribute [r] exact
44+
#
45+
# If true, the dir must contain only entries given in `containing_exactly`
46+
#
47+
# The default is false, meaning the directory can contain additional entries.
48+
#
49+
# @return [Boolean]
50+
#
51+
attr_reader :exact
2552

2653
# Initializes the matcher with the directory name and options
2754
#
@@ -33,21 +60,27 @@ def entry_type = :directory
3360
#
3461
# @param specification_block [Proc] A specification block that defines the expected directory contents
3562
#
36-
def initialize(name, exact: false, **options_hash, &specification_block)
37-
super(name, **options_hash)
63+
def initialize(name, **options_hash)
64+
super
3865

39-
@exact = exact
66+
@exact = false
4067
@nested_matchers = []
41-
return unless specification_block
68+
end
69+
70+
def containing(*matchers)
71+
@nested_matchers << matchers
72+
@exact = false
73+
self
74+
end
4275

43-
inspector = DirectoryContentsInspector.new
44-
inspector.instance_eval(&specification_block)
45-
@nested_matchers = inspector.nested_matchers
76+
def containing_exactly(*matchers)
77+
@nested_matchers << matchers
78+
@exact = true
79+
self
4680
end
4781

4882
def description
4983
desc = super
50-
desc += ' exactly' if exact
5184
return desc if nested_matchers.empty?
5285

5386
nested_descriptions = nested_matchers.map do |matcher|
@@ -56,20 +89,22 @@ def description
5689
end.join("\n")
5790
end
5891

59-
"#{desc} containing:\n #{nested_descriptions.join("\n ")}"
92+
"#{desc} containing#{' exactly' if exact}:\n #{nested_descriptions.join("\n ")}"
6093
end
6194

6295
def collect_negative_validation_errors(errors)
6396
super
6497
return unless nested_matchers.any?
6598

66-
errors << "The matcher `not_to #{matcher_name}(...)` cannot be given a specification block"
99+
errors << "The matcher `not_to #{matcher_name}(...)` cannot have expectations on its contents"
67100
end
68101

69102
def collect_validation_errors(errors)
70103
super
71104

72-
errors << "`exact:` must be true or false, but was #{exact.inspect}" unless [true, false].include?(exact)
105+
if @nested_matchers.size > 1
106+
errors << 'Collectively, `#containing` and `#containing_exactly` may be called only once'
107+
end
73108

74109
# Recursively validate nested matchers.
75110
nested_matchers.each { |matcher| matcher.collect_validation_errors(errors) }

0 commit comments

Comments
 (0)