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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +0 -4
  3. data/README.md +61 -0
  4. data/Rakefile +0 -7
  5. data/benchmark/.rubocop.yml +7 -0
  6. data/benchmark/abstract_methods_benchmark.rb +221 -0
  7. data/benchmark/interface_startup_performance.rb +128 -0
  8. data/benchmark/module_benchmark.rb +62 -0
  9. data/config/default.yml +5 -0
  10. data/docs/design/inherited_implementation_problem.md +258 -0
  11. data/docs/design/self_dot_methods.md +79 -0
  12. data/lib/rubocop/cop/type_toolkit/plugin.rb +1 -1
  13. data/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb +150 -0
  14. data/lib/rubocop-type_toolkit.rb +1 -0
  15. data/lib/type_toolkit/abstract_method_receiver.rb +39 -0
  16. data/lib/type_toolkit/dsl.rb +66 -0
  17. data/lib/type_toolkit/ext/method.rb +11 -0
  18. data/lib/type_toolkit/ext/module.rb +7 -0
  19. data/lib/type_toolkit/ext/nil_assertions.rb +3 -1
  20. data/lib/type_toolkit/has_abstract_methods.rb +108 -0
  21. data/lib/type_toolkit/interface.rb +38 -0
  22. data/lib/type_toolkit/method_def_recorder.rb +53 -0
  23. data/lib/type_toolkit/method_patch.rb +16 -0
  24. data/lib/type_toolkit/version.rb +1 -1
  25. data/lib/type_toolkit.rb +3 -0
  26. data/sorbet/config +3 -1
  27. data/sorbet/rbi/gems/benchmark-ips@2.14.0.rbi +981 -0
  28. data/sorbet/rbi/gems/{erb@6.0.1.rbi → erb@6.0.1.1.rbi} +2 -2
  29. data/sorbet/rbi/gems/{json@2.18.1.rbi → json@2.19.9.rbi} +115 -161
  30. data/sorbet/rbi/gems/minitest@5.27.0.rbi +707 -0
  31. data/sorbet/rbi/gems/rexml@3.4.4.rbi +0 -167
  32. data/sorbet/rbi/gems/rubocop-ast@1.49.0.rbi +6 -0
  33. data/sorbet/rbi/gems/rubocop@1.84.2.rbi +7 -0
  34. data/sorbet/rbi/gems/tapioca@0.17.10.rbi +1 -0
  35. data/sorbet/rbi/gems/{yard@0.9.38.rbi → yard@0.9.44.rbi} +1206 -241
  36. data/sorbet/rbi/shims/core.rbi +19 -0
  37. data/sorbet/rbi/shims/minitest.rbi +5 -2
  38. data/sorbet/rbi/shims/rubocop_minitest.rbi +5 -2
  39. data/spec/interface_spec.rb +419 -0
  40. data/spec/misc/how_method_added_hooks_work_spec.rb +63 -0
  41. data/spec/rubocop/cop/type_toolkit/prefer_not_nil_spec.rb +464 -0
  42. data/spec/spec_helper.rb +14 -0
  43. 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: "Detects misuse of UnexpectedNilError.",
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
@@ -3,3 +3,4 @@
3
3
  require "rubocop"
4
4
  require_relative "rubocop/cop/type_toolkit/plugin"
5
5
  require_relative "rubocop/cop/type_toolkit/dont_expect_unexpected_nil"
6
+ require_relative "rubocop/cop/type_toolkit/prefer_not_nil"
@@ -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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "type_toolkit/method_patch"
4
+
5
+ class Method
6
+ prepend TypeToolkit::MethodPatch
7
+ end
8
+
9
+ class UnboundMethod
10
+ prepend TypeToolkit::MethodPatch
11
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Module
4
+ def interface!
5
+ TypeToolkit.make_interface!(self)
6
+ end
7
+ 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 rescues clauses (like `rescue => e`) don't rescue it.
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