type_toolkit 0.0.6 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4227c717f707bc4d21aea4b6026e4ec48c62bf48044518b7545b4a12d499662
4
- data.tar.gz: afbee5d26bf77e402c0425dc60cfb53b6d8c80ba8af6f69af39bad034459a3bc
3
+ metadata.gz: 12374ec2c61c2218bb47b3e2f79d1d7c9bf59a7f4d75d2c429ebeecb02d4d51d
4
+ data.tar.gz: 13ef655859f7305f5ce532d6f9a3ac53513f2fcc8935d1b1c1f85b07c5f9e5d9
5
5
  SHA512:
6
- metadata.gz: a251f77a0cc924ae2b621f1eda192d24d862406f076c875227ada2f7d16883a8c98e8c8b7310843a00a7d08b2fcab902e0f7bf6bcc5c4262da519a2e7a2721d8
7
- data.tar.gz: a93074b2e21d1ea1b7dac91b9cf8e52690f68d1a2ae12e2c898cbd87b3ea71367a4d956d13933faef666e6683d868e7360a5b66a2039c8ccd735bdeefa82ae09
6
+ metadata.gz: 1ef3a56d5b8838ffcd97adf208f2ce67598dfa6157414ba084a91e146d43669ab1b06810659c9b3226ad5cd80f2ac875f937301238f45ba68d918ddabd177fd4
7
+ data.tar.gz: df81fdc2a72a0146a244a9a1028c227d789db201d3e1f66300a1cb4ccc9caf5345b27474dac8fe858a6f5753ae20bae184380a4837afd1c45da8a9ec4de1f6cf
@@ -19,12 +19,8 @@ jobs:
19
19
  rubygems: 4.0.4
20
20
  - name: Run type check
21
21
  run: bundle exec rake typecheck
22
- - name: Run type check with Prism parser
23
- run: bundle exec rake typecheck_prism
24
- continue-on-error: true
25
22
  - name: Run tests
26
23
  run: bundle exec rake test
27
- continue-on-error: true
28
24
  - name: Lint Ruby files
29
25
  run: bundle exec rake rubocop
30
26
  - name: Verify gem RBIs are up-to-date
data/README.md CHANGED
@@ -70,6 +70,24 @@ last_delivery = user.not_nil!
70
70
  .deliveries.last.not_nil!
71
71
  ```
72
72
 
73
+ The `TypeToolkit/PreferNotNil` RuboCop cop autocorrects `T.must` and `T.must_because` to `.not_nil!`.
74
+ For `T.must_because`, the reason block's source is preserved as comments inside a parenthesized expression,
75
+ so the correction also works in chained calls and nested expressions:
76
+
77
+ ```rb
78
+ # Before
79
+ value = T.must_because(foo) { "Proven non-nil by validation." }
80
+
81
+ # After
82
+ value = (
83
+ # Proven non-nil by validation.
84
+ foo.not_nil!
85
+ )
86
+ ```
87
+
88
+ The reason is documentation only after correction: it is no longer evaluated or included in the exception message.
89
+ Calls with heredocs in the value or reason block are reported but not autocorrected, because heredoc bodies can extend beyond the block's source range.
90
+
73
91
  ### Interfaces
74
92
 
75
93
  Interfaces are modules with abstract methods which a conforming class must implement. They help make duck-typing easier to use in Ruby, by validating that your conforming classes do actually provide the correct methods needed of them.
data/Rakefile CHANGED
@@ -14,13 +14,6 @@ desc "alias for typecheck"; task tc: :typecheck
14
14
  desc "alias for typecheck"; task srb: :typecheck
15
15
  desc "alias for typecheck"; task sorbet: :typecheck
16
16
 
17
- desc "Type-check the code base with Sorbet using Prism"
18
- task :typecheck_prism do
19
- sh "bundle exec srb tc --parser=prism" do |ok, _res|
20
- abort unless ok
21
- end
22
- end
23
-
24
17
  require "minitest/test_task"
25
18
 
26
19
  Minitest::TestTask.create do |t|
data/config/default.yml CHANGED
@@ -4,6 +4,6 @@ TypeToolkit/DontExpectUnexpectedNil:
4
4
  VersionAdded: "0.1.0"
5
5
 
6
6
  TypeToolkit/PreferNotNil:
7
- Description: "Replaces T.must assertions with Type Toolkit's not_nil! assertion."
7
+ Description: "Replaces T.must and T.must_because assertions with Type Toolkit's not_nil! assertion."
8
8
  Enabled: true
9
9
  VersionAdded: "0.1.0"
@@ -6,7 +6,7 @@ Take this example:
6
6
  class Parent
7
7
  def m = "Parent#m"
8
8
  end
9
-
9
+
10
10
  module I
11
11
  interface!
12
12
 
@@ -17,7 +17,7 @@ class Child < Parent
17
17
  include I
18
18
  end
19
19
 
20
- Child.new.m
20
+ Child.new.m
21
21
  # => Vanilla Ruby: raises (from `I#m`)
22
22
  # => With this gem: `Parent#m`
23
23
  ```
@@ -117,3 +117,142 @@ Cons:
117
117
  - All the downsides of doing this the hand-written way.
118
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
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
+ ```
@@ -4,12 +4,12 @@
4
4
  module RuboCop
5
5
  module Cop
6
6
  module TypeToolkit
7
- # Replaces Sorbet's `T.must(value)` assertion with Type Toolkit's `value.not_nil!` assertion.
7
+ # Replaces Sorbet's `T.must` and `T.must_because` assertions with `.not_nil!`.
8
8
  class PreferNotNil < Base
9
9
  extend AutoCorrector
10
10
 
11
- MSG = "Use `.not_nil!` instead of `T.must()`."
12
- RESTRICT_ON_SEND = [:must].freeze
11
+ MSG = "Use `.not_nil!` instead of `T.%<method>s()`."
12
+ RESTRICT_ON_SEND = [:must, :must_because].freeze
13
13
 
14
14
  COMMA_BYTE = ",".ord
15
15
  private_constant :COMMA_BYTE
@@ -19,16 +19,21 @@ module RuboCop
19
19
 
20
20
  #: (RuboCop::AST::SendNode) -> void
21
21
  def on_send(node)
22
- return unless (argument = extract_t_must_argument(node))
22
+ return unless (argument = extract_assertion_argument(node))
23
23
 
24
- if nested_t_must?(node)
25
- add_offense(node, message: MSG)
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:)
26
30
  else
27
31
  replacement = replacement_for(argument)
28
32
  correction = correction_for(node, argument, replacement)
33
+ correction = commented_correction(block, correction) if block
29
34
 
30
- add_offense(node, message: MSG) do |corrector|
31
- corrector.replace(node, correction)
35
+ add_offense(target, message:) do |corrector|
36
+ corrector.replace(target, correction)
32
37
  end
33
38
  end
34
39
  end
@@ -36,10 +41,10 @@ module RuboCop
36
41
  private
37
42
 
38
43
  #: (RuboCop::AST::SendNode) -> RuboCop::AST::Node?
39
- def extract_t_must_argument(node)
44
+ def extract_assertion_argument(node)
40
45
  receiver = node.receiver
41
46
  return unless receiver.is_a?(RuboCop::AST::ConstNode)
42
- return unless receiver.short_name == :T && node.method?(:must) && node.arguments.one?
47
+ return unless receiver.short_name == :T && RESTRICT_ON_SEND.include?(node.method_name) && node.arguments.one?
43
48
 
44
49
  namespace = receiver.namespace
45
50
  return unless namespace.nil? || namespace.cbase_type?
@@ -54,13 +59,16 @@ module RuboCop
54
59
  #: (RuboCop::AST::Node) -> String
55
60
  def replacement_for(argument)
56
61
  source = argument.source
62
+
57
63
  source = "(#{source})" if requires_parentheses?(argument)
64
+
58
65
  "#{source}.not_nil!"
59
66
  end
60
67
 
61
68
  #: (RuboCop::AST::SendNode, RuboCop::AST::Node, String) -> String
62
69
  def correction_for(node, argument, replacement)
63
- return replacement unless node.multiline?
70
+ return replacement unless node.multiline? && node.parenthesized_call?
71
+ return replacement unless comments_inside_parentheses?(node) || contains_heredoc?(argument)
64
72
 
65
73
  grouped_range = node.source_range.with(begin_pos: node.loc.begin.begin_pos, end_pos: node.loc.end.end_pos)
66
74
  grouped_source = grouped_range.source
@@ -69,11 +77,51 @@ module RuboCop
69
77
  "#{grouped_source}.not_nil!"
70
78
  end
71
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
+
72
100
  #: (RuboCop::AST::SendNode) -> bool
73
- def nested_t_must?(node)
74
- node.each_ancestor(:send).any? do |ancestor|
75
- ancestor.is_a?(RuboCop::AST::SendNode) && extract_t_must_argument(ancestor)
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)
76
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})"
77
125
  end
78
126
 
79
127
  #: (RuboCop::AST::Node) -> bool
@@ -81,15 +129,21 @@ module RuboCop
81
129
  return false if argument.begin_type?
82
130
 
83
131
  if argument.is_a?(RuboCop::AST::SendNode)
132
+ return bracket_call_requires_parentheses?(argument) if argument.method?(:[])
84
133
  return true if argument.operator_method?
85
134
  return true if argument.arguments? && !argument.parenthesized_call?
86
135
  end
87
136
  return true if argument.range_type? || argument.operator_keyword?
88
137
  return true if argument.if_type? || argument.assignment?
89
- return true if argument.any_block_type?
90
138
 
91
139
  KEYWORD_EXPRESSION_TYPES.include?(argument.type)
92
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
93
147
  end
94
148
  end
95
149
  end
@@ -37,7 +37,9 @@ module TypeToolkit
37
37
  # # is_singleton_method = true, owner is `Foo.singleton_class`
38
38
  # abstract def self.foo; end
39
39
  # end
40
- method_owner = is_singleton_method ? singleton_class : self #: as Module[top] & HasAbstractMethods
40
+ method_owner = is_singleton_method ? raise(NotImplementedError, <<~MSG) : self
41
+ Abstract singleton methods are not supported yet.
42
+ MSG
41
43
 
42
44
  # Register the fact that this method is meant to be abstract,
43
45
  # used by APIs like `abstract_method_declared?` and `Method#abstract?`
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module TypeToolkit
5
- VERSION = "0.0.6"
5
+ VERSION = "0.0.7"
6
6
  end
@@ -4,14 +4,17 @@
4
4
  module RuboCop
5
5
  module Minitest
6
6
  module AssertOffense
7
- sig { params(source: String).void }
8
- def assert_offense(source); end
7
+ sig { params(source: String, file: T.nilable(String), replacements: String).void }
8
+ def assert_offense(source, file = nil, **replacements); end
9
9
 
10
10
  sig { params(source: String).void }
11
11
  def assert_no_offenses(source); end
12
12
 
13
13
  sig { params(source: String).void }
14
14
  def assert_correction(source); end
15
+
16
+ sig { void }
17
+ def assert_no_corrections; end
15
18
  end
16
19
  end
17
20
  end
@@ -401,5 +401,19 @@ module TypeToolkit
401
401
  end
402
402
  end
403
403
  end
404
+
405
+ describe "Abstract class methods" do
406
+ it "are not yet supported" do
407
+ test_context = self
408
+
409
+ Module.new do
410
+ interface!
411
+
412
+ test_context.assert_raises(NotImplementedError) do
413
+ abstract def self.abstract_class_method; end # rubocop:disable Style/ClassMethodsDefinitions
414
+ end
415
+ end
416
+ end
417
+ end
404
418
  end
405
419
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module TypeToolkit
6
+ module Misc
7
+ # This test just characterizes how `method_added` hooks work. It doesn't have anything to do with TypeToolkit itself,
8
+ # but it's useful for devs to confirm how this works across instance and class methods.
9
+ class HowMethodAddedHooksWorkSpec < Minitest::Spec
10
+ it "characterizes how method_added hooks work" do
11
+ test_context = self
12
+
13
+ Class.new do
14
+ class << self
15
+ attr_reader :__last_method_def
16
+
17
+ class << self
18
+ def __last_method_def
19
+ attached_object.__last_method_def
20
+ end
21
+ end
22
+
23
+ def method_added(m)
24
+ is_singleton_method = false
25
+ @__last_method_def = [m, is_singleton_method]
26
+
27
+ super
28
+ end
29
+
30
+ def singleton_method_added(m)
31
+ return super if m == :singleton_method_added
32
+
33
+ is_singleton_method = true
34
+ @__last_method_def = [m, is_singleton_method]
35
+
36
+ super
37
+ end
38
+ end
39
+
40
+ test_context.assert_nil __last_method_def
41
+
42
+ def instance_method; end
43
+
44
+ test_context.assert_equal [:instance_method, false], __last_method_def
45
+
46
+ def self.singleton_method_via_self_dot; end # rubocop:disable Style/ClassMethodsDefinitions
47
+
48
+ test_context.assert_equal [:singleton_method_via_self_dot, true], __last_method_def
49
+
50
+ r = class << self
51
+ def singleton_method_via_sclass; end
52
+
53
+ __last_method_def
54
+ end
55
+
56
+ test_context.assert_equal [:singleton_method_via_sclass, true], r
57
+
58
+ test_context.assert_equal [:singleton_method_via_sclass, true], __last_method_def
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -12,7 +12,8 @@ module RuboCop
12
12
  class PreferNotNilSpec < ::Minitest::Spec
13
13
  include RuboCop::Minitest::AssertOffense
14
14
 
15
- MSG = "TypeToolkit/PreferNotNil: Use `.not_nil!` instead of `T.must()`."
15
+ MSG = "TypeToolkit/PreferNotNil: #{format(PreferNotNil::MSG, method: :must)}"
16
+ BECAUSE_MSG = "TypeToolkit/PreferNotNil: #{format(PreferNotNil::MSG, method: :must_because)}"
16
17
 
17
18
  before do
18
19
  @cop = PreferNotNil.new
@@ -77,6 +78,8 @@ module RuboCop
77
78
  ^^^^^^^^^^^^^^^^^ #{MSG}
78
79
  block_value = T.must(foo { bar })
79
80
  ^^^^^^^^^^^^^^^^^^^ #{MSG}
81
+ block_with_arguments = T.must(foo(arg) { |value| value })
82
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{MSG}
80
83
  defined_value = T.must(defined?(foo))
81
84
  ^^^^^^^^^^^^^^^^^^^^^ #{MSG}
82
85
  def example
@@ -98,7 +101,8 @@ module RuboCop
98
101
  logical = (foo || bar).not_nil!
99
102
  grouped = (foo || bar).not_nil!
100
103
  assignment = (foo = bar).not_nil!
101
- block_value = (foo { bar }).not_nil!
104
+ block_value = foo { bar }.not_nil!
105
+ block_with_arguments = foo(arg) { |value| value }.not_nil!
102
106
  defined_value = (defined?(foo)).not_nil!
103
107
  def example
104
108
  (yield foo).not_nil!
@@ -123,6 +127,19 @@ module RuboCop
123
127
  RUBY
124
128
  end
125
129
 
130
+ it "autocorrects multiline command calls" do
131
+ assert_offense(<<~RUBY)
132
+ T.must foo
133
+ ^^^^^^^^^^ #{MSG}
134
+ .bar
135
+ RUBY
136
+
137
+ assert_correction(<<~RUBY)
138
+ foo
139
+ .bar.not_nil!
140
+ RUBY
141
+ end
142
+
126
143
  it "does not add unnecessary parentheses to parenthesized calls" do
127
144
  assert_offense(<<~RUBY)
128
145
  value = T.must(fetch(value))
@@ -134,6 +151,51 @@ module RuboCop
134
151
  RUBY
135
152
  end
136
153
 
154
+ it "parenthesizes bracket method calls only when required" do
155
+ assert_offense(<<~RUBY)
156
+ bracketed = T.must(foo[bar]).foo
157
+ ^^^^^^^^^^^^^^^^ #{MSG}
158
+ explicit = T.must(foo.[](bar)).foo
159
+ ^^^^^^^^^^^^^^^^^^^ #{MSG}
160
+ command = T.must(foo.[] bar).foo
161
+ ^^^^^^^^^^^^^^^^^^ #{MSG}
162
+ RUBY
163
+
164
+ assert_correction(<<~RUBY)
165
+ bracketed = foo[bar].not_nil!.foo
166
+ explicit = foo.[](bar).not_nil!.foo
167
+ command = (foo.[] bar).not_nil!.foo
168
+ RUBY
169
+ end
170
+
171
+ it "does not group an argument that makes the call multiline" do
172
+ assert_offense(<<~RUBY)
173
+ T.must(foo
174
+ ^^^^^^^^^^ #{MSG}
175
+ .bar)
176
+ RUBY
177
+
178
+ assert_correction(<<~RUBY)
179
+ foo
180
+ .bar.not_nil!
181
+ RUBY
182
+ end
183
+
184
+ it "preserves a multiline receiver without parentheses" do
185
+ assert_offense(<<~RUBY)
186
+ T.must(
187
+ ^^^^^^^ #{MSG}
188
+ foo.
189
+ bar
190
+ )
191
+ RUBY
192
+
193
+ assert_correction(<<~RUBY)
194
+ foo.
195
+ bar.not_nil!
196
+ RUBY
197
+ end
198
+
137
199
  it "preserves comments in multiline calls" do
138
200
  assert_offense(<<~RUBY)
139
201
  value = T.must(
@@ -151,7 +213,7 @@ module RuboCop
151
213
  RUBY
152
214
  end
153
215
 
154
- it "autocorrects multiline calls with whitespace before the method" do
216
+ it "drops unnecessary grouping from multiline calls without comments" do
155
217
  assert_offense(<<~RUBY)
156
218
  first = T .must(
157
219
  ^^^^^^^^ #{MSG}
@@ -165,12 +227,21 @@ module RuboCop
165
227
  RUBY
166
228
 
167
229
  assert_correction(<<~RUBY)
168
- first = (
169
- foo
170
- ).not_nil!
171
- second = (
172
- bar
173
- ).not_nil!
230
+ first = foo.not_nil!
231
+ second = bar.not_nil!
232
+ RUBY
233
+ end
234
+
235
+ it "preserves a multiline call chain without parentheses" do
236
+ assert_offense(<<~RUBY)
237
+ variant = T.must(
238
+ ^^^^^^^ #{MSG}
239
+ InventoryItemVariant.preload(:variant).where(inventory_item_id: @inventory_item_id).first!.variant
240
+ )
241
+ RUBY
242
+
243
+ assert_correction(<<~RUBY)
244
+ variant = InventoryItemVariant.preload(:variant).where(inventory_item_id: @inventory_item_id).first!.variant.not_nil!
174
245
  RUBY
175
246
  end
176
247
 
@@ -189,6 +260,67 @@ module RuboCop
189
260
  RUBY
190
261
  end
191
262
 
263
+ it "preserves grouping around heredocs in multiline calls" do
264
+ assert_offense(<<~RUBY)
265
+ value = T.must(
266
+ ^^^^^^^ #{MSG}
267
+ <<~TEXT
268
+ hello
269
+ TEXT
270
+ )
271
+ RUBY
272
+
273
+ assert_correction(<<~RUBY)
274
+ value = (
275
+ <<~TEXT
276
+ hello
277
+ TEXT
278
+ ).not_nil!
279
+ RUBY
280
+ end
281
+
282
+ it "preserves grouping around nested heredocs in multiline calls" do
283
+ assert_offense(<<~RUBY)
284
+ value = T.must(
285
+ ^^^^^^^ #{MSG}
286
+ foo(<<~TEXT)
287
+ hello
288
+ TEXT
289
+ )
290
+ RUBY
291
+
292
+ assert_correction(<<~RUBY)
293
+ value = (
294
+ foo(<<~TEXT)
295
+ hello
296
+ TEXT
297
+ ).not_nil!
298
+ RUBY
299
+ end
300
+
301
+ it "preserves grouping around a heredoc T.must nested in another call" do
302
+ assert_offense(<<~RUBY)
303
+ value = foo(
304
+ T.must(
305
+ ^^^^^^^ #{MSG}
306
+ <<~TEXT
307
+ hello
308
+ TEXT
309
+ )
310
+ )
311
+ RUBY
312
+
313
+ assert_correction(<<~RUBY)
314
+ value = foo(
315
+ (
316
+ <<~TEXT
317
+ hello
318
+ TEXT
319
+ ).not_nil!
320
+ )
321
+ RUBY
322
+ end
323
+
192
324
  it "autocorrects nested T.must calls" do
193
325
  assert_offense(<<~RUBY)
194
326
  value = T.must(T.must(foo).bar)
@@ -214,6 +346,104 @@ module RuboCop
214
346
  RUBY
215
347
  end
216
348
 
349
+ it "reports heredoc reasons without leaving orphaned heredoc bodies" do
350
+ assert_offense(<<~RUBY)
351
+ value = T.must_because(foo) { <<~REASON }
352
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{BECAUSE_MSG}
353
+ Proven non-nil.
354
+ REASON
355
+ RUBY
356
+
357
+ assert_no_corrections
358
+ end
359
+
360
+ it "preserves the reason without attaching the block to the corrected call" do
361
+ assert_offense(<<~RUBY)
362
+ value = T.must_because(foo) { "reason" }.bar
363
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{BECAUSE_MSG}
364
+ RUBY
365
+
366
+ assert_correction(<<~RUBY)
367
+ value = (
368
+ # reason
369
+ foo.not_nil!
370
+ ).bar
371
+ RUBY
372
+ end
373
+
374
+ it "preserves multiline reasons and argument comments" do
375
+ assert_offense(<<~RUBY)
376
+ value = ::T.must_because(
377
+ ^^^^^^^^^^^^^^^^^ #{BECAUSE_MSG}
378
+ # Validated earlier.
379
+ foo,
380
+ ) do
381
+ # Explain the invariant.
382
+ "validated for \#{user}"
383
+ end
384
+ RUBY
385
+
386
+ assert_correction(<<~RUBY)
387
+ value = (
388
+ # # Explain the invariant.
389
+ # validated for \#{user}
390
+ # Validated earlier.
391
+ foo
392
+ ).not_nil!
393
+ RUBY
394
+ end
395
+
396
+ it "keeps trailing arguments and string interpolation outside the reason comment" do
397
+ assert_offense(<<~'RUBY', message: BECAUSE_MSG)
398
+ values = [T.must_because(foo) { 'reason' }, bar]
399
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ %{message}
400
+ text = "#{T.must_because(foo) { "reason" }}"
401
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ %{message}
402
+ RUBY
403
+
404
+ assert_correction(<<~'RUBY')
405
+ values = [(
406
+ # reason
407
+ foo.not_nil!
408
+ ), bar]
409
+ text = "#{(
410
+ # reason
411
+ foo.not_nil!
412
+ )}"
413
+ RUBY
414
+ end
415
+
416
+ it "corrects mixed nested assertions without overlapping edits" do
417
+ assert_offense(<<~RUBY)
418
+ value = T.must(T.must_because(T.must(foo)) { "reason" })
419
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{MSG}
420
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{BECAUSE_MSG}
421
+ ^^^^^^^^^^^ #{MSG}
422
+ RUBY
423
+
424
+ assert_correction(<<~RUBY)
425
+ value = (
426
+ # reason
427
+ foo.not_nil!.not_nil!
428
+ ).not_nil!
429
+ RUBY
430
+ end
431
+
432
+ it "comments out assertions in the reason rather than correcting them" do
433
+ assert_offense(<<~RUBY)
434
+ value = T.must_because(foo) { T.must(bar) }
435
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{BECAUSE_MSG}
436
+ ^^^^^^^^^^^ #{MSG}
437
+ RUBY
438
+
439
+ assert_correction(<<~RUBY)
440
+ value = (
441
+ # T.must(bar)
442
+ foo.not_nil!
443
+ )
444
+ RUBY
445
+ end
446
+
217
447
  it "ignores other receivers, methods, and argument counts" do
218
448
  assert_no_offenses(<<~RUBY)
219
449
  Other::T.must(foo)
@@ -221,6 +451,11 @@ module RuboCop
221
451
  T.let(foo, String)
222
452
  T.must(foo, bar)
223
453
  T.must(*values)
454
+ Other::T.must_because(foo) { "reason" }
455
+ object.must_because(foo) { "reason" }
456
+ T.must_because(foo, bar) { "reason" }
457
+ T.must_because(*values) { "reason" }
458
+ T.must_because(foo, &reason)
224
459
  RUBY
225
460
  end
226
461
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: type_toolkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.6
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Momchilov
@@ -132,6 +132,7 @@ files:
132
132
  - sorbet/tapioca/require.rb
133
133
  - spec/.rubocop.yml
134
134
  - spec/interface_spec.rb
135
+ - spec/misc/how_method_added_hooks_work_spec.rb
135
136
  - spec/nil_assertions_spec.rb
136
137
  - spec/rubocop/cop/type_toolkit/dont_expect_unexpected_nil_spec.rb
137
138
  - spec/rubocop/cop/type_toolkit/prefer_not_nil_spec.rb