duck_typer 0.1.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.
- checksums.yaml +7 -0
- data/.standard.yml +5 -0
- data/CONTRIBUTING.md +63 -0
- data/LICENSE +21 -0
- data/README.md +225 -0
- data/Rakefile +15 -0
- data/lib/duck_typer/bulk_interface_checker.rb +17 -0
- data/lib/duck_typer/interface_checker/method_inspector.rb +49 -0
- data/lib/duck_typer/interface_checker/params_normalizer.rb +28 -0
- data/lib/duck_typer/interface_checker/result.rb +33 -0
- data/lib/duck_typer/interface_checker.rb +92 -0
- data/lib/duck_typer/minitest.rb +14 -0
- data/lib/duck_typer/rspec.rb +28 -0
- data/lib/duck_typer/version.rb +5 -0
- data/lib/duck_typer.rb +12 -0
- data/sig/duck_typer.rbs +4 -0
- metadata +62 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 06033bd68c3648b66718874887865c212abf0da7597d8904d511551fc2e651da
|
|
4
|
+
data.tar.gz: 18a800f2632fe5233946b4220df58a0728a5443d63881fa08d8f1296c4455e48
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 99787de2400a1d21667f64c981e6ad1047c866a00645ec1f2b8534c3d1a692a1204a8e6f6d422c45caca61ef17123aa814a82f046ea5848c62a6b926cf35027c
|
|
7
|
+
data.tar.gz: 9034b7b8e3df7342e51ec9bbf1cb8cfc58d0f1f0d9d64839c53545585a2d5dfa5af0ee7c6d6cfc6fac613015ce8dfc9dbb9e5579d68ff1b607414fd608a46dbc
|
data/.standard.yml
ADDED
data/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
[Bug reports][bugs] and [pull requests][prs] are welcome on GitHub
|
|
4
|
+
at https://github.com/thoughtbot/duck_typer.
|
|
5
|
+
|
|
6
|
+
[bugs]: https://github.com/thoughtbot/duck_typer/issues/new
|
|
7
|
+
[prs]: https://github.com/thoughtbot/duck_typer/pulls
|
|
8
|
+
|
|
9
|
+
Please create a [new discussion][discussion] if you want to share
|
|
10
|
+
ideas for new features.
|
|
11
|
+
|
|
12
|
+
[discussion]: https://github.com/thoughtbot/duck_typer/discussions/new?category=ideas
|
|
13
|
+
|
|
14
|
+
We love contributions from everyone.
|
|
15
|
+
By participating in this project,
|
|
16
|
+
you agree to abide by the thoughtbot [code of conduct].
|
|
17
|
+
|
|
18
|
+
[code of conduct]: https://thoughtbot.com/open-source-code-of-conduct
|
|
19
|
+
|
|
20
|
+
We expect everyone to follow the code of conduct
|
|
21
|
+
anywhere in thoughtbot's project codebases,
|
|
22
|
+
issue trackers, chatrooms, and mailing lists.
|
|
23
|
+
|
|
24
|
+
## Contributing Code
|
|
25
|
+
|
|
26
|
+
Fork the repo.
|
|
27
|
+
|
|
28
|
+
Run the setup script.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
./bin/setup
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Make sure everything passes:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
bundle exec rake test
|
|
38
|
+
bundle exec standardrb
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Make your change, with new passing tests.
|
|
42
|
+
|
|
43
|
+
Push to your fork. Write a [good commit message][commit]. Submit a
|
|
44
|
+
pull request.
|
|
45
|
+
|
|
46
|
+
[commit]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
|
|
47
|
+
|
|
48
|
+
Others will give constructive feedback.
|
|
49
|
+
This is a time for discussion and improvements,
|
|
50
|
+
and making the necessary changes will be required before we can
|
|
51
|
+
merge the contribution.
|
|
52
|
+
|
|
53
|
+
## Publishing to RubyGems
|
|
54
|
+
|
|
55
|
+
When the gem is ready to be shared as a formal release, it can be
|
|
56
|
+
[published][published] to RubyGems.
|
|
57
|
+
|
|
58
|
+
[published]: https://guides.rubyonrails.org/plugins.html#publishing-your-gem
|
|
59
|
+
|
|
60
|
+
1. Bump the version number in `DuckTyper::VERSION`
|
|
61
|
+
2. Run `bundle exec rake build`
|
|
62
|
+
3. Run `bundle exec rake install`
|
|
63
|
+
4. Run `bundle exec rake release`
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) thoughtbot, inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# DuckTyper
|
|
2
|
+
|
|
3
|
+
[](https://github.com/thoughtbot/duck_typer/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
DuckTyper enforces duck-typed interfaces in Ruby by comparing the
|
|
6
|
+
public method signatures of classes, surfacing mismatches through
|
|
7
|
+
your test suite.
|
|
8
|
+
|
|
9
|
+
## Why DuckTyper?
|
|
10
|
+
|
|
11
|
+
Ruby is a duck-typed language. When multiple classes play the same
|
|
12
|
+
role, what matters is not what they _are_, but what they _do_ — the
|
|
13
|
+
methods they respond to and the signatures they expose. No base
|
|
14
|
+
class required. No type annotations. No interface declarations.
|
|
15
|
+
|
|
16
|
+
Most approaches to enforcing this kind of contract pull Ruby away
|
|
17
|
+
from its dynamic nature: abstract base classes that raise
|
|
18
|
+
`NotImplementedError`, type-checking libraries that annotate method
|
|
19
|
+
signatures, or inheritance hierarchies that couple unrelated
|
|
20
|
+
classes. These work, but they're not very Ruby.
|
|
21
|
+
|
|
22
|
+
DuckTyper takes a different approach. It compares public method
|
|
23
|
+
signatures directly and reports mismatches through your test suite —
|
|
24
|
+
the natural place to enforce design constraints in Ruby. There's
|
|
25
|
+
nothing to annotate and nothing to inherit from. The classes remain
|
|
26
|
+
independent; DuckTyper simply verifies that they're speaking the
|
|
27
|
+
same language. The interface itself needs no declaration — it is
|
|
28
|
+
the intersection of methods your classes define in common, a living
|
|
29
|
+
document that evolves naturally.
|
|
30
|
+
|
|
31
|
+
It's also useful during active development. When an interface
|
|
32
|
+
evolves, implementations can easily fall out of sync. DuckTyper
|
|
33
|
+
catches that immediately and reports clear, precise error messages
|
|
34
|
+
showing exactly which signatures diverged — keeping your classes
|
|
35
|
+
aligned as the design changes.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
Add to your Gemfile:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
gem "duck_typer"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then run:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
bundle install
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
When interfaces don't match, DuckTyper reports the differing
|
|
54
|
+
signatures:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
Expected StripeProcessor and BraintreeProcessor to have compatible
|
|
58
|
+
method signatures, but the following signatures do not match:
|
|
59
|
+
|
|
60
|
+
StripeProcessor: charge(amount, currency:)
|
|
61
|
+
BraintreeProcessor: charge(amount, currency:, description:)
|
|
62
|
+
|
|
63
|
+
StripeProcessor: refund(transaction_id)
|
|
64
|
+
BraintreeProcessor: refund(transaction_id, amount)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Minitest
|
|
68
|
+
|
|
69
|
+
Require the Minitest integration and include the module in your
|
|
70
|
+
test class:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
require "duck_typer/minitest"
|
|
74
|
+
|
|
75
|
+
class PaymentProcessorTest < Minitest::Test
|
|
76
|
+
include DuckTyper::Minitest
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
To make `assert_interface_matches` available across all tests,
|
|
81
|
+
require the integration in `test_helper.rb` and include the module
|
|
82
|
+
in your base test class:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# In test_helper.rb
|
|
86
|
+
require "duck_typer/minitest"
|
|
87
|
+
|
|
88
|
+
class ActiveSupport::TestCase
|
|
89
|
+
include DuckTyper::Minitest
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
If you're not using Rails, include it in `Minitest::Test` directly:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class Minitest::Test
|
|
97
|
+
include DuckTyper::Minitest
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Then use `assert_interface_matches` to assert that a list of
|
|
102
|
+
classes share compatible interfaces:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
def test_payment_processors_have_compatible_interfaces
|
|
106
|
+
assert_interface_matches [StripeProcessor, PaypalProcessor, BraintreeProcessor]
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The same `type:` and `methods:` options are supported:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
assert_interface_matches [StripeProcessor, PaypalProcessor],
|
|
114
|
+
type: :class_methods,
|
|
115
|
+
methods: %i[charge refund]
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### RSpec
|
|
119
|
+
|
|
120
|
+
Require the RSpec integration in your `spec_helper.rb`:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
require "duck_typer/rspec"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
#### Matcher
|
|
127
|
+
|
|
128
|
+
Use `have_matching_interfaces` to assert that a list of classes
|
|
129
|
+
share compatible interfaces:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
RSpec.describe "payment processors" do
|
|
133
|
+
it "have compatible interfaces" do
|
|
134
|
+
expect([StripeProcessor, PaypalProcessor, BraintreeProcessor]).to have_matching_interfaces
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
For class-level interfaces, pass `type: :class_methods`:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
expect([StripeProcessor, PaypalProcessor]).to have_matching_interfaces(type: :class_methods)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
To check only a subset of methods, use `methods:`:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
expect([StripeProcessor, PaypalProcessor]).to have_matching_interfaces(methods: %i[charge refund])
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
#### Shared example
|
|
152
|
+
|
|
153
|
+
If you prefer shared examples, register one in `spec_helper.rb`
|
|
154
|
+
by calling:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
DuckTyper::RSpec.define_shared_example
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
This registers a shared example named `"an interface"`. To avoid
|
|
161
|
+
conflicts with an existing shared example of the same name, pass
|
|
162
|
+
a custom name:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
DuckTyper::RSpec.define_shared_example("a compatible interface")
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Then use it in your specs:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
RSpec.describe "payment processors" do
|
|
172
|
+
it_behaves_like "an interface", [StripeProcessor, PaypalProcessor, BraintreeProcessor]
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The same `type:` and `methods:` options are supported:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
it_behaves_like "an interface", [StripeProcessor, PaypalProcessor],
|
|
180
|
+
type: :class_methods,
|
|
181
|
+
methods: %i[charge refund]
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Development
|
|
185
|
+
|
|
186
|
+
After checking out the repo, run `bin/setup` to install
|
|
187
|
+
dependencies. You can also run `bin/console` for an interactive
|
|
188
|
+
prompt that will allow you to experiment.
|
|
189
|
+
|
|
190
|
+
To install this gem onto your local machine, run
|
|
191
|
+
`bundle exec rake install`. To release a new version, update the
|
|
192
|
+
version number in `version.rb`, and then run
|
|
193
|
+
`bundle exec rake release`, which will create a git tag for the
|
|
194
|
+
version, push git commits and the created tag, and push the `.gem`
|
|
195
|
+
file to [rubygems.org](https://rubygems.org).
|
|
196
|
+
|
|
197
|
+
## Contributing
|
|
198
|
+
|
|
199
|
+
See the [CONTRIBUTING] document.
|
|
200
|
+
Thank you, [contributors]!
|
|
201
|
+
|
|
202
|
+
[CONTRIBUTING]: CONTRIBUTING.md
|
|
203
|
+
[contributors]: https://github.com/thoughtbot/duck_typer/graphs/contributors
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
DuckTyper is Copyright (c) thoughtbot, inc.
|
|
208
|
+
It is free software, and may be redistributed
|
|
209
|
+
under the terms specified in the [LICENSE] file.
|
|
210
|
+
|
|
211
|
+
[LICENSE]: /LICENSE
|
|
212
|
+
|
|
213
|
+
## About thoughtbot
|
|
214
|
+
|
|
215
|
+

|
|
216
|
+
|
|
217
|
+
This repo is maintained and funded by thoughtbot, inc.
|
|
218
|
+
The names and logos for thoughtbot are trademarks of thoughtbot, inc.
|
|
219
|
+
|
|
220
|
+
We love open source software!
|
|
221
|
+
See [our other projects][community].
|
|
222
|
+
We are [available for hire][hire].
|
|
223
|
+
|
|
224
|
+
[community]: https://thoughtbot.com/community?utm_source=github
|
|
225
|
+
[hire]: https://thoughtbot.com/hire-us?utm_source=github
|
data/Rakefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
require "rspec/core/rake_task"
|
|
6
|
+
|
|
7
|
+
Rake::TestTask.new(:minitest) do |t|
|
|
8
|
+
t.pattern = "test/**/*_test.rb"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
RSpec::Core::RakeTask.new(:rspec)
|
|
12
|
+
|
|
13
|
+
task test: %i[minitest rspec]
|
|
14
|
+
|
|
15
|
+
task default: %i[]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module DuckTyper
|
|
2
|
+
# Runs interface checks across all consecutive pairs of classes in a list.
|
|
3
|
+
class BulkInterfaceChecker
|
|
4
|
+
def initialize(objects, type: :instance_methods, partial_interface_methods: nil)
|
|
5
|
+
@objects = objects
|
|
6
|
+
@checker = InterfaceChecker.new(type:, partial_interface_methods:)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(&block)
|
|
10
|
+
@objects.each_cons(2).map do |left, right|
|
|
11
|
+
result = @checker.call(left, right)
|
|
12
|
+
block&.call(left, right, result)
|
|
13
|
+
result
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module DuckTyper
|
|
2
|
+
class InterfaceChecker
|
|
3
|
+
class MethodInspector
|
|
4
|
+
def self.for(object, type)
|
|
5
|
+
if type == :class_methods
|
|
6
|
+
ClassMethodInspector
|
|
7
|
+
else
|
|
8
|
+
InstanceMethodInspector
|
|
9
|
+
end.new(object)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class ClassMethodInspector
|
|
14
|
+
def initialize(object)
|
|
15
|
+
@object = object
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def public_methods
|
|
19
|
+
@object.public_methods - Object.methods
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parameters_for(method_name)
|
|
23
|
+
@object.method(method_name).parameters
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def display_name_for(method_name)
|
|
27
|
+
"self.#{method_name}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class InstanceMethodInspector
|
|
32
|
+
def initialize(object)
|
|
33
|
+
@object = object
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def public_methods
|
|
37
|
+
@object.public_instance_methods - Object.methods
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parameters_for(method_name)
|
|
41
|
+
@object.instance_method(method_name).parameters
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def display_name_for(method_name)
|
|
45
|
+
method_name
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module DuckTyper
|
|
2
|
+
class InterfaceChecker
|
|
3
|
+
# Normalizes method parameters to enable interface comparison. For
|
|
4
|
+
# example, two methods may use different names for positional
|
|
5
|
+
# arguments, but if the parameter types and order match, they should
|
|
6
|
+
# be considered equivalent. This class replaces argument names with
|
|
7
|
+
# sequential placeholders when appropriate, focusing the comparison on
|
|
8
|
+
# parameter structure rather than naming.
|
|
9
|
+
class ParamsNormalizer
|
|
10
|
+
def self.call(params)
|
|
11
|
+
sequential_name = ("a".."z").to_enum
|
|
12
|
+
|
|
13
|
+
params.map do |type, name|
|
|
14
|
+
name = next_sequential_param(sequential_name) if %i[req opt rest keyrest block].include?(type)
|
|
15
|
+
|
|
16
|
+
[type, name]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.next_sequential_param(enumerator)
|
|
21
|
+
enumerator.next
|
|
22
|
+
rescue StopIteration
|
|
23
|
+
raise TooManyParametersError, "too many positional parameters, maximum supported is 26"
|
|
24
|
+
end
|
|
25
|
+
private_class_method :next_sequential_param
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module DuckTyper
|
|
2
|
+
class InterfaceChecker
|
|
3
|
+
class Result
|
|
4
|
+
attr_reader :left, :right
|
|
5
|
+
|
|
6
|
+
def initialize(left:, right:, match:, method_signatures:)
|
|
7
|
+
@left = left
|
|
8
|
+
@right = right
|
|
9
|
+
@match = match
|
|
10
|
+
@method_signatures = method_signatures
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def match?
|
|
14
|
+
@match.call
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def failure_message
|
|
18
|
+
<<~MSG
|
|
19
|
+
Expected #{@left} and #{@right} to have compatible method \
|
|
20
|
+
signatures, but the following signatures do not match:
|
|
21
|
+
|
|
22
|
+
#{method_signatures}
|
|
23
|
+
MSG
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def method_signatures
|
|
29
|
+
@method_signatures.call
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require_relative "interface_checker/result"
|
|
2
|
+
require_relative "interface_checker/method_inspector"
|
|
3
|
+
require_relative "interface_checker/params_normalizer"
|
|
4
|
+
|
|
5
|
+
module DuckTyper
|
|
6
|
+
# Compares the public method signatures of two classes and reports mismatches.
|
|
7
|
+
class InterfaceChecker
|
|
8
|
+
TYPES = %i[instance_methods class_methods].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(type: :instance_methods, partial_interface_methods: nil)
|
|
11
|
+
unless TYPES.include?(type)
|
|
12
|
+
raise ArgumentError, "Invalid type #{type.inspect}, must be one of #{TYPES}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@type = type
|
|
16
|
+
@partial_interface_methods = partial_interface_methods
|
|
17
|
+
@inspectors = Hash.new { |h, k| h[k] = MethodInspector.for(k, @type) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(left, right)
|
|
21
|
+
left_params = params_for_comparison(left, ParamsNormalizer)
|
|
22
|
+
right_params = params_for_comparison(right, ParamsNormalizer)
|
|
23
|
+
diff = (left_params - right_params) + (right_params - left_params)
|
|
24
|
+
|
|
25
|
+
match = -> { match?(left_params, right_params) }
|
|
26
|
+
method_signatures = -> { build_method_signatures(left, right, diff) }
|
|
27
|
+
|
|
28
|
+
Result.new(left:, right:, match:, method_signatures:)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def match?(left_params, right_params)
|
|
34
|
+
diff = (left_params - right_params) + (right_params - left_params)
|
|
35
|
+
diff.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def build_method_signatures(left, right, diff)
|
|
39
|
+
methods = diff.map(&:first).uniq
|
|
40
|
+
left_params = params_for_comparison(left).to_h.slice(*methods)
|
|
41
|
+
right_params = params_for_comparison(right).to_h.slice(*methods)
|
|
42
|
+
|
|
43
|
+
methods.map do |method_name|
|
|
44
|
+
<<~DIFF
|
|
45
|
+
#{join_signature(left, method_name, left_params)}
|
|
46
|
+
#{join_signature(right, method_name, right_params)}
|
|
47
|
+
DIFF
|
|
48
|
+
end.join("\n")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def join_signature(object, method_name, params)
|
|
52
|
+
inspector = @inspectors[object]
|
|
53
|
+
display_name = inspector.display_name_for(method_name)
|
|
54
|
+
|
|
55
|
+
signature = if params[method_name]
|
|
56
|
+
"#{display_name}(#{params[method_name].join(", ")})"
|
|
57
|
+
else
|
|
58
|
+
"#{display_name} not defined"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
"#{object}: #{signature}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def method_params(inspector, method_name, object)
|
|
65
|
+
inspector.parameters_for(method_name)
|
|
66
|
+
rescue NameError
|
|
67
|
+
raise MethodNotFoundError, "undefined method `#{method_name}' for #{object}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def params_for_comparison(object, params_processor = -> { _1 })
|
|
71
|
+
inspector = @inspectors[object]
|
|
72
|
+
methods = @partial_interface_methods || inspector.public_methods
|
|
73
|
+
|
|
74
|
+
methods.map do |method_name|
|
|
75
|
+
params = method_params(inspector, method_name, object)
|
|
76
|
+
args = params_processor.call(params).map do |type, name|
|
|
77
|
+
case type
|
|
78
|
+
when :key then "#{name}: :opt"
|
|
79
|
+
when :keyreq then "#{name}:"
|
|
80
|
+
when :keyrest then "**#{name}"
|
|
81
|
+
when :block then "&#{name}"
|
|
82
|
+
when :req then name.to_s
|
|
83
|
+
when :opt then "#{name} = :opt"
|
|
84
|
+
when :rest then "*#{name}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
[method_name, args]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require_relative "../duck_typer"
|
|
2
|
+
|
|
3
|
+
module DuckTyper
|
|
4
|
+
module Minitest
|
|
5
|
+
def assert_interface_matches(objects, type: :instance_methods, methods: nil)
|
|
6
|
+
checker = BulkInterfaceChecker
|
|
7
|
+
.new(objects, type:, partial_interface_methods: methods)
|
|
8
|
+
|
|
9
|
+
checker.call do |_, _, result|
|
|
10
|
+
assert result.match?, result.failure_message
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require_relative "../duck_typer"
|
|
2
|
+
|
|
3
|
+
RSpec::Matchers.define :have_matching_interfaces do |type: :instance_methods, methods: nil|
|
|
4
|
+
match do |objects|
|
|
5
|
+
checker = DuckTyper::BulkInterfaceChecker
|
|
6
|
+
.new(objects, type:, partial_interface_methods: methods)
|
|
7
|
+
|
|
8
|
+
@failures = checker.call.reject(&:match?)
|
|
9
|
+
@failures.empty?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
failure_message do
|
|
13
|
+
@failures.map(&:failure_message).join("\n")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module DuckTyper
|
|
18
|
+
module RSpec
|
|
19
|
+
def self.define_shared_example(name = "an interface")
|
|
20
|
+
::RSpec.shared_examples name do |objects, type: :instance_methods, methods: nil|
|
|
21
|
+
it "has compatible interfaces" do
|
|
22
|
+
failures = DuckTyper::BulkInterfaceChecker.new(objects, type:, partial_interface_methods: methods).call.reject(&:match?)
|
|
23
|
+
raise ::RSpec::Expectations::ExpectationNotMetError, failures.map(&:failure_message).join("\n") if failures.any?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/duck_typer.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "duck_typer/version"
|
|
4
|
+
require_relative "duck_typer/interface_checker"
|
|
5
|
+
require_relative "duck_typer/bulk_interface_checker"
|
|
6
|
+
|
|
7
|
+
# DuckTyper enforces duck-typed interfaces in Ruby by comparing the public
|
|
8
|
+
# method signatures of classes, surfacing mismatches through your test suite.
|
|
9
|
+
module DuckTyper
|
|
10
|
+
class MethodNotFoundError < StandardError; end
|
|
11
|
+
class TooManyParametersError < StandardError; end
|
|
12
|
+
end
|
data/sig/duck_typer.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: duck_typer
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Thiago A. Silva
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-07 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description:
|
|
14
|
+
email:
|
|
15
|
+
- thiagoaraujos@gmail.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- ".standard.yml"
|
|
21
|
+
- CONTRIBUTING.md
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- Rakefile
|
|
25
|
+
- lib/duck_typer.rb
|
|
26
|
+
- lib/duck_typer/bulk_interface_checker.rb
|
|
27
|
+
- lib/duck_typer/interface_checker.rb
|
|
28
|
+
- lib/duck_typer/interface_checker/method_inspector.rb
|
|
29
|
+
- lib/duck_typer/interface_checker/params_normalizer.rb
|
|
30
|
+
- lib/duck_typer/interface_checker/result.rb
|
|
31
|
+
- lib/duck_typer/minitest.rb
|
|
32
|
+
- lib/duck_typer/rspec.rb
|
|
33
|
+
- lib/duck_typer/version.rb
|
|
34
|
+
- sig/duck_typer.rbs
|
|
35
|
+
homepage: https://github.com/thoughtbot/duck_typer
|
|
36
|
+
licenses: []
|
|
37
|
+
metadata:
|
|
38
|
+
allowed_push_host: https://rubygems.org
|
|
39
|
+
homepage_uri: https://github.com/thoughtbot/duck_typer
|
|
40
|
+
source_code_uri: https://github.com/thoughtbot/duck_typer
|
|
41
|
+
changelog_uri: https://github.com/thoughtbot/duck_typer/blob/main/CHANGELOG.md
|
|
42
|
+
rubygems_mfa_required: 'true'
|
|
43
|
+
post_install_message:
|
|
44
|
+
rdoc_options: []
|
|
45
|
+
require_paths:
|
|
46
|
+
- lib
|
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: 3.1.0
|
|
52
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '0'
|
|
57
|
+
requirements: []
|
|
58
|
+
rubygems_version: 3.5.22
|
|
59
|
+
signing_key:
|
|
60
|
+
specification_version: 4
|
|
61
|
+
summary: Enforce duck-typed interfaces in Ruby through your test suite.
|
|
62
|
+
test_files: []
|