type_toolkit 0.0.5 → 0.0.7
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 +4 -4
- data/.github/workflows/ci.yml +0 -4
- data/README.md +61 -0
- data/Rakefile +0 -7
- data/benchmark/.rubocop.yml +7 -0
- data/benchmark/abstract_methods_benchmark.rb +221 -0
- data/benchmark/interface_startup_performance.rb +128 -0
- data/benchmark/module_benchmark.rb +62 -0
- data/config/default.yml +5 -0
- data/docs/design/inherited_implementation_problem.md +258 -0
- data/docs/design/self_dot_methods.md +79 -0
- data/lib/rubocop/cop/type_toolkit/plugin.rb +1 -1
- data/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb +150 -0
- data/lib/rubocop-type_toolkit.rb +1 -0
- data/lib/type_toolkit/abstract_method_receiver.rb +39 -0
- data/lib/type_toolkit/dsl.rb +66 -0
- data/lib/type_toolkit/ext/method.rb +11 -0
- data/lib/type_toolkit/ext/module.rb +7 -0
- data/lib/type_toolkit/ext/nil_assertions.rb +3 -1
- data/lib/type_toolkit/has_abstract_methods.rb +108 -0
- data/lib/type_toolkit/interface.rb +38 -0
- data/lib/type_toolkit/method_def_recorder.rb +53 -0
- data/lib/type_toolkit/method_patch.rb +16 -0
- data/lib/type_toolkit/version.rb +1 -1
- data/lib/type_toolkit.rb +3 -0
- data/sorbet/config +3 -1
- data/sorbet/rbi/gems/benchmark-ips@2.14.0.rbi +981 -0
- data/sorbet/rbi/gems/{erb@6.0.1.rbi → erb@6.0.1.1.rbi} +2 -2
- data/sorbet/rbi/gems/{json@2.18.1.rbi → json@2.19.9.rbi} +115 -161
- data/sorbet/rbi/gems/minitest@5.27.0.rbi +707 -0
- data/sorbet/rbi/gems/rexml@3.4.4.rbi +0 -167
- data/sorbet/rbi/gems/rubocop-ast@1.49.0.rbi +6 -0
- data/sorbet/rbi/gems/rubocop@1.84.2.rbi +7 -0
- data/sorbet/rbi/gems/tapioca@0.17.10.rbi +1 -0
- data/sorbet/rbi/gems/{yard@0.9.38.rbi → yard@0.9.44.rbi} +1206 -241
- data/sorbet/rbi/shims/core.rbi +19 -0
- data/sorbet/rbi/shims/minitest.rbi +5 -2
- data/sorbet/rbi/shims/rubocop_minitest.rbi +5 -2
- data/spec/interface_spec.rb +419 -0
- data/spec/misc/how_method_added_hooks_work_spec.rb +63 -0
- data/spec/rubocop/cop/type_toolkit/prefer_not_nil_spec.rb +464 -0
- data/spec/spec_helper.rb +14 -0
- metadata +24 -4
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# The inherited implementation problem
|
|
2
|
+
|
|
3
|
+
Take this example:
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
class Parent
|
|
7
|
+
def m = "Parent#m"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module I
|
|
11
|
+
interface!
|
|
12
|
+
|
|
13
|
+
abstract def m = raise "Abstract method `#m` not implemented"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class Child < Parent
|
|
17
|
+
include I
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Child.new.m
|
|
21
|
+
# => Vanilla Ruby: raises (from `I#m`)
|
|
22
|
+
# => With this gem: `Parent#m`
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
There are two challenges here:
|
|
26
|
+
|
|
27
|
+
1. We need to not let the `I#m` stub implementation "get in the way", so that we can find real implementation that get inherited from further ancestors (like `Parent#m`).
|
|
28
|
+
2. *but* we still want something to raise an error if you attempt to call an unimplemented abstract method.
|
|
29
|
+
|
|
30
|
+
If you lookup `m` on an instance of `Child`, you would usually hit the empty stub `I#m` instead of the inherited implementation `Parent#m`:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
Child.ancestors
|
|
34
|
+
|
|
35
|
+
# => [Child, Interface, Parent, Object, Kernel, BasicObject]
|
|
36
|
+
# ^ ^ ^
|
|
37
|
+
# | | Provides the implementation for Child to inherit
|
|
38
|
+
# | Its stub abstract method "gets in the way" and needs to be side-stepped
|
|
39
|
+
# We want Child to inherit the implementation from Parent
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
Child.instance_method(:m).owner
|
|
43
|
+
# => Vanilla Ruby: `I`
|
|
44
|
+
# => With this gem: `Parent`
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
# Solution
|
|
48
|
+
|
|
49
|
+
Solve the inheritance problem by just yoinking the abstract method stub out of the ancestor chain, by just using `remove_method()`.
|
|
50
|
+
|
|
51
|
+
Now if you send `#m` to a child, there's no `I#m` implementation to hit, so it just jumps over to `Parent#m`. This is a direct method call with absolutely no runtime overhead.
|
|
52
|
+
|
|
53
|
+
This introduces a new problem, that now calling unimplemented abstract methods just raises `NoMethodError`, as if the name never existed. For a better developer experience, we'd like to give a more helpful message.
|
|
54
|
+
|
|
55
|
+
To do this, we implement `#method_missing` and check if the missing method name is one of the abstract methods (based on a list we append to every time you call `abstract`). If it is, we can raise our nicer error, otherwise we just delegate up the rest of the `#method_missing` chain (ultimately triggering a `NoMethodError`, like normal). This part isn't strictly necessary, but it's a nice-to-have. We can make it configurable.
|
|
56
|
+
|
|
57
|
+
Syntax:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
module I
|
|
61
|
+
interface!
|
|
62
|
+
|
|
63
|
+
abstract def m; end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
That's it. That simple!
|
|
68
|
+
|
|
69
|
+
Pros:
|
|
70
|
+
- Good DX when calling an abstract method that you forgot to implement
|
|
71
|
+
- Really nice syntax
|
|
72
|
+
|
|
73
|
+
Cons:
|
|
74
|
+
- Some implementation complexity (but all tucked into this gem, with less than 130 lines of implementation code)
|
|
75
|
+
- Calls to implemented abstract methods are faster, at the expense of unimplemented ones
|
|
76
|
+
- 1.9x slower than the hand-written alternative
|
|
77
|
+
- 1.1x slower than sorbet-runtime alternative
|
|
78
|
+
- but that's totally acceptable, because this should never happen in a completed program. The performance of calls to actually implemented methods is what matters, since that's what the real code will be doing at a huge volume.
|
|
79
|
+
|
|
80
|
+
# Alternative 1: Hand-written delegation
|
|
81
|
+
|
|
82
|
+
You duplicate this delegation logic in every abstract method:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
module I
|
|
86
|
+
interface!
|
|
87
|
+
|
|
88
|
+
# @abstract
|
|
89
|
+
def m = defined?(super) ? super : raise("Abstract method `#m` not implemented")
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Pros:
|
|
94
|
+
- There's no magic, no runtime needed.
|
|
95
|
+
|
|
96
|
+
Cons:
|
|
97
|
+
- Repetitive
|
|
98
|
+
- Can be forgotten
|
|
99
|
+
- There's more overhead on every method call (but only to abstract methods with an inherited implementation):
|
|
100
|
+
- Checking `defined?(super)`
|
|
101
|
+
- Making the `super` call
|
|
102
|
+
- It adds an extra frame to your backtrace which will be seen in debuggers and exception backtraces (unless you configure them to filter it out)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Alternative 2: Sorbet runtime's solution
|
|
106
|
+
|
|
107
|
+
Here's a Sorbet version of the example: [Sorbet.run](https://sorbet.run/#%23%20typed%3A%20true%0A%0Aclass%20Parent%0A%20%20extend%20T%3A%3ASig%0A%0A%20%20sig%20%7B%20returns%28String%29%20%7D%0A%20%20def%20m%20%3D%20%22Parent%23m%22%0Aend%0A%20%20%0Amodule%20I%0A%20%20extend%20T%3A%3ASig%0A%20%20extend%20T%3A%3AHelpers%0A%0A%20%20interface!%0A%0A%20%20sig%20%7B%20abstract.returns%28String%29%20%7D%0A%20%20def%20m%3B%20end%0Aend%0A%0Aclass%20Child%20%3C%20Parent%3B%20end).
|
|
108
|
+
|
|
109
|
+
Sorbet runtime basically automates the hand-written solution above, by wrapping abstract methods:
|
|
110
|
+
|
|
111
|
+
https://github.com/sorbet/sorbet/blob/703498a0dcddbe7ec4b87ec6cc5d7d55cfa9b270/gems/sorbet-runtime/lib/types/private/methods/call_validation.rb#L48-L68
|
|
112
|
+
|
|
113
|
+
Pros:
|
|
114
|
+
- "just works"
|
|
115
|
+
|
|
116
|
+
Cons:
|
|
117
|
+
- All the downsides of doing this the hand-written way.
|
|
118
|
+
- To determine if a method is abstract or not, every `sig` needs to have its block evaluated, to see if it calls `abstract`.
|
|
119
|
+
- This used to be slower because it was defined via `defined_methods` with a block body. This produces a slower kind of method (`VM_METHOD_TYPE_BMETHOD`), than the equivalent code via `def` (`VM_METHOD_TYPE_ISEQ`). This was fixed in [this PR](https://github.com/sorbet/sorbet/pull/8238), which switch to using `module_eval` to define the method via `def`.
|
|
120
|
+
|
|
121
|
+
# Other languages
|
|
122
|
+
|
|
123
|
+
Most languages with abstract methods are ahead-of-time compiled. They don't have this issue because their compilers eagerly catch issues. Don't have the issue of runtime stub methods clobbering access to inherited implementation.
|
|
124
|
+
|
|
125
|
+
Python and Elixir are usual comparison points, as interpreted languages with growing static typing features.
|
|
126
|
+
|
|
127
|
+
## Python
|
|
128
|
+
|
|
129
|
+
Python has both [Abstract Base Classes](https://typing.python.org/en/latest/guides/libraries.html#abstract-classes-and-methods) and [Protocols](https://typing.python.org/en/latest/spec/protocol.html), and the two work differently.
|
|
130
|
+
|
|
131
|
+
Abstract classes don't have the inherited implementation problem, because they eagerly defend against instantiating a class with any unimplemented abstract methods. If you circumvent this defence, you just hit the `NotImplementedError` rather than an inherited implementation of the method.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
#!/usr/bin/env python3
|
|
135
|
+
|
|
136
|
+
from abc import ABC, abstractmethod
|
|
137
|
+
|
|
138
|
+
class Parent:
|
|
139
|
+
def m(self) -> str:
|
|
140
|
+
return "Parent.m"
|
|
141
|
+
|
|
142
|
+
class I(ABC):
|
|
143
|
+
@abstractmethod
|
|
144
|
+
def m(self) -> str:
|
|
145
|
+
"""Subclasses must override"""
|
|
146
|
+
raise NotImplementedError()
|
|
147
|
+
|
|
148
|
+
class Child(I, Parent):
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
# print(Child.mro()) # Equivalent to Ruby's ancestor chain
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
Child() # Can't instantiate abstract class Child without an implementation for abstract method 'm'
|
|
155
|
+
except TypeError as e:
|
|
156
|
+
print(e)
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
# Force the instantiation anyway
|
|
160
|
+
Child.__abstractmethods__ = frozenset()
|
|
161
|
+
Child().m() # => NotImplementedError
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
[Implicitly implementing a Protocol](https://typing.python.org/en/latest/spec/protocol.html#protocols:~:text=The%20default%20implementations%20cannot%20be%20used%20if%20the%20assignable%2Dto%20relationship%20is%20implicit%20and%20only%20structural%20%E2%80%93%20the%20semantics%20of%20inheritance%20is%20not%20changed%2E) doesn't have the problem, because the Protocol is not made part of the MRO (equivalent to Ruby's ancestor chain):
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
#!/usr/bin/env python3
|
|
168
|
+
|
|
169
|
+
from typing import Protocol
|
|
170
|
+
from abc import abstractmethod
|
|
171
|
+
|
|
172
|
+
class Parent:
|
|
173
|
+
def m(self) -> str:
|
|
174
|
+
return "Parent.m"
|
|
175
|
+
|
|
176
|
+
class MyProtocol(Protocol):
|
|
177
|
+
@abstractmethod
|
|
178
|
+
def m(self) -> str:
|
|
179
|
+
raise NotImplementedError
|
|
180
|
+
|
|
181
|
+
class ImplicitChild(Parent): # <- MyProtocol not in this list
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
# Equivalent to Ruby's ancestor chain. `MyProtocol` is not in it.
|
|
185
|
+
print(ImplicitChild.mro()) # => [<class 'ImplicitChild'>, <class 'Parent'>, <class 'object'>]
|
|
186
|
+
|
|
187
|
+
# Calls the inherited implementation, no problem:
|
|
188
|
+
print(ImplicitChild().m()) # => "Parent.m"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
However, the moment you make the Protocol implementation explicit, you get the same problem as the Abstract Base Class case:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
# ... continued from previous script
|
|
195
|
+
|
|
196
|
+
class ExplicitChild(MyProtocol, Parent):
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
# Equivalent to Ruby's ancestor chain. `MyProtocol` is in it.
|
|
200
|
+
print(ExplicitChild.mro()) # => [<class 'ExplicitChild'>, <class 'MyProtocol'>, <class 'typing.Protocol'>, <class 'typing.Generic'>, <class 'Parent'>, <class 'object'>]
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
ExplicitChild() # Can't instantiate abstract class Child without an implementation for abstract method 'm'
|
|
204
|
+
except TypeError as e:
|
|
205
|
+
print(e)
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
# Force the instantiation anyway
|
|
209
|
+
ExplicitChild.__abstractmethods__ = frozenset()
|
|
210
|
+
ExplicitChild().m() # => NotImplementedError
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The [docs](https://typing.python.org/en/latest/spec/protocol.html#protocols:~:text=defined-,A,instantiated) are explicit about this:
|
|
214
|
+
|
|
215
|
+
> A class can explicitly inherit from multiple protocols and also from normal classes. In this case methods are resolved using normal MRO and a type checker verifies that all member assignability is correct. The semantics of `@abstractmethod` is not changed; all of them must be implemented by an explicit subclass before it can be instantiated.
|
|
216
|
+
|
|
217
|
+
## Elixir
|
|
218
|
+
|
|
219
|
+
Elixir doesn't have class-style inheritance. It's still interesting to see how it handles its equivalent to abstract methods: protocol functions.
|
|
220
|
+
When statically compiled, `elixirc` statically catches unimplemented methods and fails the build.
|
|
221
|
+
|
|
222
|
+
When dynamically executed (e.g. `iex`, `elixir`) will raise warnings for unimplemented methods. It records extra runtime metadata, used to surface more concrete error messages.
|
|
223
|
+
|
|
224
|
+
```elixir
|
|
225
|
+
#!/usr/bin/env elixir
|
|
226
|
+
|
|
227
|
+
defprotocol Size do
|
|
228
|
+
def size(value)
|
|
229
|
+
def empty?(value)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Protocol requirements are reified at runtime, can be reflected:
|
|
233
|
+
IO.inspect Size.__protocol__(:functions) # [empty?: 1, size: 1]
|
|
234
|
+
|
|
235
|
+
defimpl Size, for: List do
|
|
236
|
+
def size(value), do: length(value)
|
|
237
|
+
|
|
238
|
+
# Intentionally missing `def empty?`, Elixir warns:
|
|
239
|
+
# > warning: function empty?/1 required by protocol Size is not implemented
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Calling an implemented function
|
|
243
|
+
IO.inspect Size.size([]) # => 0
|
|
244
|
+
|
|
245
|
+
try do
|
|
246
|
+
Size.empty?([]) # Calling an unimplemented function
|
|
247
|
+
rescue
|
|
248
|
+
e in UndefinedFunctionError ->
|
|
249
|
+
# Replicates what e.g. `iex` would print.
|
|
250
|
+
{blame, _} = Exception.blame(:error, e, __STACKTRACE__)
|
|
251
|
+
IO.puts "(UndefinedFunctionError) #{blame.message}"
|
|
252
|
+
# => (UndefinedFunctionError) function Size.List.empty?/1 is undefined or private, but the behaviour Size expects it to be present
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Not how it knows that the `Size` behaviour expects it.
|
|
256
|
+
# Compare with the more generic error raised otherwise:
|
|
257
|
+
Size.foo # => (UndefinedFunctionError) function Size.foo/0 is undefined or private
|
|
258
|
+
```
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# The `def self.` problem
|
|
2
|
+
|
|
3
|
+
Ruby method definitions evaluate to the name of the method that was defined. This fact is used by the `public`/`protected`/`private` methods:
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
class
|
|
7
|
+
puts def demo; end
|
|
8
|
+
# Equivalent to `puts(:demo)`
|
|
9
|
+
end
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
However, there's no way to distinguish whether the method was an instance method, or a singleton method:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
class C
|
|
16
|
+
puts def demo; end # prints ":demo"
|
|
17
|
+
puts def self.demo; end # *also* prints ":demo"
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
# Solution
|
|
22
|
+
|
|
23
|
+
Track into whether the last method call was an instance method or a singleton method, by hooking into `method_added` and `singleton_method_added`. See the `MethodDefRecorder` for details.
|
|
24
|
+
|
|
25
|
+
This way, both of these "just work"
|
|
26
|
+
|
|
27
|
+
```rb
|
|
28
|
+
class C
|
|
29
|
+
# Correctly defines an abstract instance method
|
|
30
|
+
abstract def demo; end
|
|
31
|
+
|
|
32
|
+
# Correctly defines an abstract "class method"
|
|
33
|
+
abstract def self.demo; end
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
# Alternative 1: Do nothing
|
|
38
|
+
|
|
39
|
+
This is already a problem for access level modifiers, which don't do anything to handle it:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
class C
|
|
43
|
+
private def self.demo; end
|
|
44
|
+
# => ❌ undefined method 'demo' for class 'C' (NameError)
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
We can just follow suit. However, there's a risk that if an instance method called `demo` _actually_ existed, we inadvertently make it abstract without intending. Again, the access level modifier methods already have this issue, but it's a sharp edge that we don't need to have.
|
|
49
|
+
|
|
50
|
+
If we choose to do nothing, we could encourage users to enable the [`Style/ClassMethodsDefinitions`](https://docs.rubocop.org/rubocop/cops_style.html#styleclassmethodsdefinitions) on the `EnforcedStyle: self_class` mode, so that their code bases don't contain `def self.foo` methods at all.
|
|
51
|
+
|
|
52
|
+
Pros:
|
|
53
|
+
- Simpler implementation (none!)
|
|
54
|
+
|
|
55
|
+
Cons:
|
|
56
|
+
- Sharp edge
|
|
57
|
+
- More complex mental model for users of RBS
|
|
58
|
+
|
|
59
|
+
# Alternative 2: separate macro
|
|
60
|
+
|
|
61
|
+
E.g.
|
|
62
|
+
|
|
63
|
+
```rb
|
|
64
|
+
class C
|
|
65
|
+
# Correctly defines an abstract instance method
|
|
66
|
+
abstract_instance_method def demo; end
|
|
67
|
+
|
|
68
|
+
# Correctly defines an abstract "class method"
|
|
69
|
+
abstract_class_method def self.demo; end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Pros:
|
|
74
|
+
- Simple implementation
|
|
75
|
+
|
|
76
|
+
Cons:
|
|
77
|
+
- Correct usage can't be enforced at runtime.
|
|
78
|
+
- Rubocop cop could enforce it, but that would need to be written
|
|
79
|
+
- Still a sharp edge, has the same cognitive complexity as alternative 1.
|
|
@@ -13,7 +13,7 @@ module RuboCop
|
|
|
13
13
|
name: "rubocop-type_toolkit",
|
|
14
14
|
version: ::TypeToolkit::VERSION,
|
|
15
15
|
homepage: "https://github.com/Shopify/type_toolkit",
|
|
16
|
-
description: "
|
|
16
|
+
description: "RuboCop rules for Type Toolkit.",
|
|
17
17
|
)
|
|
18
18
|
end
|
|
19
19
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module RuboCop
|
|
5
|
+
module Cop
|
|
6
|
+
module TypeToolkit
|
|
7
|
+
# Replaces Sorbet's `T.must` and `T.must_because` assertions with `.not_nil!`.
|
|
8
|
+
class PreferNotNil < Base
|
|
9
|
+
extend AutoCorrector
|
|
10
|
+
|
|
11
|
+
MSG = "Use `.not_nil!` instead of `T.%<method>s()`."
|
|
12
|
+
RESTRICT_ON_SEND = [:must, :must_because].freeze
|
|
13
|
+
|
|
14
|
+
COMMA_BYTE = ",".ord
|
|
15
|
+
private_constant :COMMA_BYTE
|
|
16
|
+
|
|
17
|
+
KEYWORD_EXPRESSION_TYPES = [:defined?, :super, :yield, :zsuper].freeze
|
|
18
|
+
private_constant :KEYWORD_EXPRESSION_TYPES
|
|
19
|
+
|
|
20
|
+
#: (RuboCop::AST::SendNode) -> void
|
|
21
|
+
def on_send(node)
|
|
22
|
+
return unless (argument = extract_assertion_argument(node))
|
|
23
|
+
|
|
24
|
+
block = node.block_node if node.method?(:must_because)
|
|
25
|
+
target = block || node
|
|
26
|
+
message = format(MSG, method: node.method_name)
|
|
27
|
+
|
|
28
|
+
if nested_assertion?(node) || (block && contains_heredoc?(block))
|
|
29
|
+
add_offense(target, message:)
|
|
30
|
+
else
|
|
31
|
+
replacement = replacement_for(argument)
|
|
32
|
+
correction = correction_for(node, argument, replacement)
|
|
33
|
+
correction = commented_correction(block, correction) if block
|
|
34
|
+
|
|
35
|
+
add_offense(target, message:) do |corrector|
|
|
36
|
+
corrector.replace(target, correction)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
#: (RuboCop::AST::SendNode) -> RuboCop::AST::Node?
|
|
44
|
+
def extract_assertion_argument(node)
|
|
45
|
+
receiver = node.receiver
|
|
46
|
+
return unless receiver.is_a?(RuboCop::AST::ConstNode)
|
|
47
|
+
return unless receiver.short_name == :T && RESTRICT_ON_SEND.include?(node.method_name) && node.arguments.one?
|
|
48
|
+
|
|
49
|
+
namespace = receiver.namespace
|
|
50
|
+
return unless namespace.nil? || namespace.cbase_type?
|
|
51
|
+
|
|
52
|
+
argument = node.first_argument
|
|
53
|
+
return unless argument
|
|
54
|
+
return if argument.splat_type? || argument.kwsplat_type?
|
|
55
|
+
|
|
56
|
+
argument
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
#: (RuboCop::AST::Node) -> String
|
|
60
|
+
def replacement_for(argument)
|
|
61
|
+
source = argument.source
|
|
62
|
+
|
|
63
|
+
source = "(#{source})" if requires_parentheses?(argument)
|
|
64
|
+
|
|
65
|
+
"#{source}.not_nil!"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
#: (RuboCop::AST::SendNode, RuboCop::AST::Node, String) -> String
|
|
69
|
+
def correction_for(node, argument, replacement)
|
|
70
|
+
return replacement unless node.multiline? && node.parenthesized_call?
|
|
71
|
+
return replacement unless comments_inside_parentheses?(node) || contains_heredoc?(argument)
|
|
72
|
+
|
|
73
|
+
grouped_range = node.source_range.with(begin_pos: node.loc.begin.begin_pos, end_pos: node.loc.end.end_pos)
|
|
74
|
+
grouped_source = grouped_range.source
|
|
75
|
+
comma_offset = argument.source_range.end_pos - grouped_range.begin_pos
|
|
76
|
+
grouped_source.slice!(comma_offset) if grouped_source.getbyte(comma_offset) == COMMA_BYTE
|
|
77
|
+
"#{grouped_source}.not_nil!"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
#: (RuboCop::AST::Node) -> bool
|
|
81
|
+
def contains_heredoc?(node)
|
|
82
|
+
return true if node.loc.is_a?(Parser::Source::Map::Heredoc)
|
|
83
|
+
|
|
84
|
+
node.each_descendant(:any_str).any? do |descendant|
|
|
85
|
+
descendant.loc.is_a?(Parser::Source::Map::Heredoc)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
#: (RuboCop::AST::SendNode) -> bool
|
|
90
|
+
def comments_inside_parentheses?(node)
|
|
91
|
+
contents_begin = node.loc.begin.end_pos
|
|
92
|
+
contents_end = node.loc.end.begin_pos
|
|
93
|
+
|
|
94
|
+
processed_source.comments.any? do |comment|
|
|
95
|
+
comment_range = comment.loc.expression
|
|
96
|
+
contents_begin <= comment_range.begin_pos && comment_range.end_pos <= contents_end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
#: (RuboCop::AST::SendNode) -> bool
|
|
101
|
+
def nested_assertion?(node)
|
|
102
|
+
node.each_ancestor(:send, :block, :numblock, :itblock).any? do |ancestor|
|
|
103
|
+
send_node = ancestor.is_a?(RuboCop::AST::SendNode) ? ancestor : ancestor.send_node
|
|
104
|
+
!send_node.equal?(node) && send_node.is_a?(RuboCop::AST::SendNode) && extract_assertion_argument(send_node)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
#: (RuboCop::AST::BlockNode, String) -> String
|
|
109
|
+
def commented_correction(block, correction)
|
|
110
|
+
indentation = block.source_range.source_line[/\A\s*/]
|
|
111
|
+
reason_range = block.source_range.with(
|
|
112
|
+
begin_pos: block.loc.begin.end_pos,
|
|
113
|
+
end_pos: block.loc.end.begin_pos,
|
|
114
|
+
)
|
|
115
|
+
reason = reason_range.source
|
|
116
|
+
body = block.body
|
|
117
|
+
if body && (body.str_type? || body.dstr_type?) && ["\"", "'"].include?(body.loc.begin&.source)
|
|
118
|
+
reason.slice!(body.loc.end.begin_pos - reason_range.begin_pos)
|
|
119
|
+
reason.slice!(body.loc.begin.begin_pos - reason_range.begin_pos)
|
|
120
|
+
end
|
|
121
|
+
comments = reason.strip.lines.map { |line| "#{indentation} # #{line.strip}\n" }.join
|
|
122
|
+
return "(\n#{comments}#{correction.delete_prefix("(\n")}" if correction.start_with?("(\n")
|
|
123
|
+
|
|
124
|
+
"(\n#{comments}#{indentation} #{correction}\n#{indentation})"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
#: (RuboCop::AST::Node) -> bool
|
|
128
|
+
def requires_parentheses?(argument)
|
|
129
|
+
return false if argument.begin_type?
|
|
130
|
+
|
|
131
|
+
if argument.is_a?(RuboCop::AST::SendNode)
|
|
132
|
+
return bracket_call_requires_parentheses?(argument) if argument.method?(:[])
|
|
133
|
+
return true if argument.operator_method?
|
|
134
|
+
return true if argument.arguments? && !argument.parenthesized_call?
|
|
135
|
+
end
|
|
136
|
+
return true if argument.range_type? || argument.operator_keyword?
|
|
137
|
+
return true if argument.if_type? || argument.assignment?
|
|
138
|
+
|
|
139
|
+
KEYWORD_EXPRESSION_TYPES.include?(argument.type)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# `foo[bar]` and `foo.[](bar)` can be chained directly, but command-style `foo.[] bar` cannot.
|
|
143
|
+
#: (RuboCop::AST::SendNode) -> bool
|
|
144
|
+
def bracket_call_requires_parentheses?(argument)
|
|
145
|
+
argument.dot? && !argument.parenthesized_call?
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
data/lib/rubocop-type_toolkit.rb
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypeToolkit
|
|
5
|
+
# Raised when a call is made to an abstract method that never had a real implementation.
|
|
6
|
+
class AbstractMethodNotImplementedError < Exception # rubocop:disable Lint/InheritException
|
|
7
|
+
def initialize(method_name:)
|
|
8
|
+
# Do not rely on this message content! Its content is subject to change.
|
|
9
|
+
super("Abstract method `##{method_name}` was never implemented.")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# This module is included on a class whose instances can be receivers of calls to abstract methods.
|
|
14
|
+
#
|
|
15
|
+
# Since abstract methods are removed at runtime (see `TypeToolkit::DSL#abstract`), attempting to call
|
|
16
|
+
# an unimplemented abstract method would usually raise a `NoMethodError`.
|
|
17
|
+
# This module uses `method_missing` to raise `AbstractMethodNotImplementedError` instead.
|
|
18
|
+
# @requires_ancestor: Kernel
|
|
19
|
+
module AbstractInstanceMethodReceiver
|
|
20
|
+
# This `#method_missing` is hit when calling a potentially abstract method on an instance
|
|
21
|
+
# E.g. TheClass.new.maybe_abstract_method
|
|
22
|
+
#
|
|
23
|
+
# (Symbol, ...) -> untyped
|
|
24
|
+
def method_missing(method_name, ...)
|
|
25
|
+
c = self.class #: as Class[top] & HasAbstractMethods
|
|
26
|
+
|
|
27
|
+
if c.abstract_method_declared?(method_name)
|
|
28
|
+
raise AbstractMethodNotImplementedError.new(method_name:)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#: (Symbol, ?bool) -> bool
|
|
35
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
36
|
+
self.class.abstract_method_declared?(method_name) || super
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypeToolkit
|
|
5
|
+
# @requires_ancestor: MethodDefRecorder
|
|
6
|
+
module DSL
|
|
7
|
+
# Mark `method_name` as abstract.
|
|
8
|
+
#
|
|
9
|
+
# A real implementation of the method must be provided somewhere in the ancestor chain.
|
|
10
|
+
# Calls to an unimplemented abstract method will raise `AbstractMethodNotImplementedError`.
|
|
11
|
+
#
|
|
12
|
+
#: (Symbol) -> Symbol
|
|
13
|
+
def abstract(method_name)
|
|
14
|
+
#: self as (Module[top] & HasAbstractMethods & MethodDefRecorder)
|
|
15
|
+
|
|
16
|
+
recorded_method_name, is_singleton_method = __last_method_def
|
|
17
|
+
|
|
18
|
+
if recorded_method_name != method_name
|
|
19
|
+
prefix = is_singleton_method ? "." : "#"
|
|
20
|
+
|
|
21
|
+
# Do not rely on this message content! Its content is subject to change.
|
|
22
|
+
raise <<~MSG.chomp
|
|
23
|
+
`abstract` expected to see `#{prefix}#{method_name}`, but the last recorded method was called `#{recorded_method_name}`.
|
|
24
|
+
This can happen when `abstract` is combined with other metaprogramming.
|
|
25
|
+
If you think this is a bug, please open an issue: https://github.com/Shopify/type_toolkit/issues
|
|
26
|
+
MSG
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The `method_owner` is the class whose method table stores the abstract method.
|
|
30
|
+
#
|
|
31
|
+
# Example:
|
|
32
|
+
#
|
|
33
|
+
# class Foo
|
|
34
|
+
# # is_singleton_method = false, owner is the `Foo` class
|
|
35
|
+
# abstract def foo; end
|
|
36
|
+
#
|
|
37
|
+
# # is_singleton_method = true, owner is `Foo.singleton_class`
|
|
38
|
+
# abstract def self.foo; end
|
|
39
|
+
# end
|
|
40
|
+
method_owner = is_singleton_method ? raise(NotImplementedError, <<~MSG) : self
|
|
41
|
+
Abstract singleton methods are not supported yet.
|
|
42
|
+
MSG
|
|
43
|
+
|
|
44
|
+
# Register the fact that this method is meant to be abstract,
|
|
45
|
+
# used by APIs like `abstract_method_declared?` and `Method#abstract?`
|
|
46
|
+
method_owner.__register_abstract_method(method_name)
|
|
47
|
+
|
|
48
|
+
# We never want the empty "stub" method to be called, so we remove it. This has one of 3 effects:
|
|
49
|
+
#
|
|
50
|
+
# 1. If the abstract method is implemented by a subclass, then there's no effect.
|
|
51
|
+
# The subclass' implementation will always be invoked, so this removal does nothing.
|
|
52
|
+
#
|
|
53
|
+
# 2. If the abstract method was already implemented by a superclass,
|
|
54
|
+
# Then this removal ensures that calls to the method will resolve to
|
|
55
|
+
# the superclass' implementation, and never the empty stub.
|
|
56
|
+
#
|
|
57
|
+
# 3. If the abstract method was not implemented anywhere in the ancestor chain,
|
|
58
|
+
# then this removal ensures we hit `method_missing`, which will then raise
|
|
59
|
+
# the `AbstractMethodNotImplementedError`.
|
|
60
|
+
method_owner.remove_method(method_name)
|
|
61
|
+
|
|
62
|
+
# Return the method name, so `abstract` can be chained, e.g. `private abstract def foo; end`
|
|
63
|
+
method_name
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -27,7 +27,9 @@ module TypeToolkit
|
|
|
27
27
|
#
|
|
28
28
|
# `UnexpectedNilError` should never occur in well-formed code, so it should never be rescued.
|
|
29
29
|
# This is why it inherits from `Exception` instead of `StandardError`,
|
|
30
|
-
# so that bare
|
|
30
|
+
# so that bare rescue clauses (like `rescue => e`) don't accidentally swallow it.
|
|
31
|
+
#
|
|
32
|
+
# Note: `rescue Exception` can still catch it, but that's intentionally harder to write accidentally.
|
|
31
33
|
class UnexpectedNilError < Exception # rubocop:disable Lint/InheritException
|
|
32
34
|
def initialize(message = "Called `not_nil!` on nil.")
|
|
33
35
|
super
|