/
safe_navigation.rb
260 lines (215 loc) · 8.41 KB
/
safe_navigation.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# frozen_string_literal: true
module RuboCop
module Cop
module Style
# This cop transforms usages of a method call safeguarded by a non `nil`
# check for the variable whose method is being called to
# safe navigation (`&.`). If there is a method chain, all of the methods
# in the chain need to be checked for safety, and all of the methods will
# need to be changed to use safe navigation. We have limited the cop to
# not register an offense for method chains that exceed 2 methods.
#
# Configuration option: ConvertCodeThatCanStartToReturnNil
# The default for this is `false`. When configured to `true`, this will
# check for code in the format `!foo.nil? && foo.bar`. As it is written,
# the return of this code is limited to `false` and whatever the return
# of the method is. If this is converted to safe navigation,
# `foo&.bar` can start returning `nil` as well as what the method
# returns.
#
# @example
# # bad
# foo.bar if foo
# foo.bar.baz if foo
# foo.bar(param1, param2) if foo
# foo.bar { |e| e.something } if foo
# foo.bar(param) { |e| e.something } if foo
#
# foo.bar if !foo.nil?
# foo.bar unless !foo
# foo.bar unless foo.nil?
#
# foo && foo.bar
# foo && foo.bar.baz
# foo && foo.bar(param1, param2)
# foo && foo.bar { |e| e.something }
# foo && foo.bar(param) { |e| e.something }
#
# # good
# foo&.bar
# foo&.bar&.baz
# foo&.bar(param1, param2)
# foo&.bar { |e| e.something }
# foo&.bar(param) { |e| e.something }
# foo && foo.bar.baz.qux # method chain with more than 2 methods
# foo && foo.nil? # method that `nil` responds to
#
# # Method calls that do not use `.`
# foo && foo < bar
# foo < bar if foo
#
# # This could start returning `nil` as well as the return of the method
# foo.nil? || foo.bar
# !foo || foo.bar
#
# # Methods that are used on assignment, arithmetic operation or
# # comparison should not be converted to use safe navigation
# foo.baz = bar if foo
# foo.baz + bar if foo
# foo.bar > 2 if foo
class SafeNavigation < Cop
extend TargetRubyVersion
include NilMethods
include RangeHelp
MSG = 'Use safe navigation (`&.`) instead of checking if an object ' \
'exists before calling the method.'.freeze
LOGIC_JUMP_KEYWORDS = %i[break fail next raise
return throw yield].freeze
minimum_target_ruby_version 2.3
# if format: (if checked_variable body nil)
# unless format: (if checked_variable nil body)
def_node_matcher :modifier_if_safe_navigation_candidate, <<-PATTERN
{
(if {
(send $_ {:nil? :!})
$_
} nil? $_)
(if {
(send (send $_ :nil?) :!)
$_
} $_ nil?)
}
PATTERN
def_node_matcher :not_nil_check?, '(send (send $_ :nil?) :!)'
def on_if(node)
return if allowed_if_condition?(node)
check_node(node)
end
def on_and(node)
check_node(node)
end
def check_node(node)
return if target_ruby_version < 2.3
checked_variable, receiver, method_chain, method = extract_parts(node)
return unless receiver == checked_variable
return if use_var_only_in_unless_modifier?(node, checked_variable)
# method is already a method call so this is actually checking for a
# chain greater than 2
return if chain_size(method_chain, method) > 1
return if unsafe_method_used?(method_chain, method)
add_offense(node)
end
def use_var_only_in_unless_modifier?(node, variable)
node.if_type? && node.unless? && !method_called?(variable)
end
def autocorrect(node)
_check, body, = node.node_parts
_checked_variable, matching_receiver, = extract_parts(node)
method_call, = matching_receiver.parent
lambda do |corrector|
corrector.remove(begin_range(node, body))
corrector.remove(end_range(node, body))
corrector.insert_before(method_call.loc.dot, '&')
add_safe_nav_to_all_methods_in_chain(corrector, method_call, body)
end
end
private
def allowed_if_condition?(node)
node.else? || node.elsif? || node.ternary?
end
def extract_parts(node)
case node.type
when :if
extract_parts_from_if(node)
when :and
extract_parts_from_and(node)
end
end
def extract_parts_from_if(node)
variable, receiver =
modifier_if_safe_navigation_candidate(node)
checked_variable, matching_receiver, method =
extract_common_parts(receiver, variable)
if receiver && LOGIC_JUMP_KEYWORDS.include?(receiver.type)
matching_receiver = nil
end
[checked_variable, matching_receiver, receiver, method]
end
def extract_parts_from_and(node)
checked_variable, rhs = *node
if cop_config['ConvertCodeThatCanStartToReturnNil']
checked_variable =
not_nil_check?(checked_variable) || checked_variable
end
checked_variable, matching_receiver, method =
extract_common_parts(rhs, checked_variable)
[checked_variable, matching_receiver, rhs, method]
end
def extract_common_parts(method_chain, checked_variable)
matching_receiver =
find_matching_receiver_invocation(method_chain, checked_variable)
method = matching_receiver.parent if matching_receiver
[checked_variable, matching_receiver, method]
end
def find_matching_receiver_invocation(method_chain, checked_variable)
return nil unless method_chain
receiver = if method_chain.block_type?
method_chain.send_node.receiver
else
method_chain.receiver
end
return receiver if receiver == checked_variable
find_matching_receiver_invocation(receiver, checked_variable)
end
def chain_size(method_chain, method)
method.each_ancestor(:send).inject(0) do |total, ancestor|
break total + 1 if ancestor == method_chain
total + 1
end
end
def unsafe_method_used?(method_chain, method)
return true if unsafe_method?(method)
method.each_ancestor(:send).any? do |ancestor|
unless config.for_cop('Lint/SafeNavigationChain')['Enabled']
break true
end
break true if unsafe_method?(ancestor)
break true if nil_methods.include?(ancestor.method_name)
break false if ancestor == method_chain
end
end
def unsafe_method?(send_node)
negated?(send_node) || send_node.assignment? || !send_node.dot?
end
def negated?(send_node)
if method_called?(send_node)
negated?(send_node.parent)
else
send_node.send_type? && send_node.method?(:!)
end
end
def method_called?(send_node)
send_node.parent && send_node.parent.send_type?
end
def begin_range(node, method_call)
range_between(node.loc.expression.begin_pos,
method_call.loc.expression.begin_pos)
end
def end_range(node, method_call)
range_between(method_call.loc.expression.end_pos,
node.loc.expression.end_pos)
end
def add_safe_nav_to_all_methods_in_chain(corrector,
start_method,
method_chain)
start_method.each_ancestor do |ancestor|
break unless %i[send block].include?(ancestor.type)
next unless ancestor.send_type?
corrector.insert_before(ancestor.loc.dot, '&')
break if ancestor == method_chain
end
end
end
end
end
end