/
node_matcher_directive.rb
241 lines (202 loc) · 8.77 KB
/
node_matcher_directive.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
# frozen_string_literal: true
module RuboCop
module Cop
module InternalAffairs
# Checks that node matcher definitions are tagged with a YARD `@!method`
# directive so that editors are able to find the dynamically defined
# method.
#
# @example
# # bad
# def_node_matcher :foo?, <<~PATTERN
# ...
# PATTERN
#
# # good
# # @!method foo?(node)
# def_node_matcher :foo?, <<~PATTERN
# ...
# PATTERN
#
class NodeMatcherDirective < Base
extend AutoCorrector
include RangeHelp
MSG = 'Precede `%<method>s` with a `@!method` YARD directive.'
MSG_WRONG_NAME = '`@!method` YARD directive has invalid method name, ' \
'use `%<expected>s` instead of `%<actual>s`.'
MSG_MISSING_SCOPE_SELF = 'Follow the `@!method` YARD directive with ' \
'`@!scope class` if it is a class method.'
MSG_WRONG_SCOPE_SELF = 'Do not use the `@!scope class` YARD directive if it ' \
'is not a class method.'
MSG_TOO_MANY = 'Multiple `@!method` YARD directives found for this matcher.'
RESTRICT_ON_SEND = %i[def_node_matcher def_node_search].to_set.freeze
REGEXP_METHOD = /
^\s*\#\s*
@!method\s+(?<receiver>self\.)?(?<method_name>[a-z0-9_]+[?!]?)(?:\((?<args>.*)\))?
/x.freeze
REGEXP_SCOPE = /^\s*\#\s*@!scope class/.freeze
# @!method pattern_matcher?(node)
def_node_matcher :pattern_matcher?, <<~PATTERN
(send _ %RESTRICT_ON_SEND {str sym} {str dstr})
PATTERN
def on_send(node)
return if node.arguments.none?
return unless valid_method_name?(node)
actual_name = node.first_argument.value.to_s
# Ignore cases where the method has a receiver that isn't self
return if actual_name.include?('.') && !actual_name.start_with?('self.')
directives = method_directives(node)
return too_many_directives(node) if directives.size > 1
process_directive(node, actual_name, directives.first)
end
private
def valid_method_name?(node)
node.first_argument.str_type? || node.first_argument.sym_type?
end
def method_directives(node)
comments = processed_source.ast_with_comments[node]
group_comments(comments).filter_map do |comment_method, comment_scope|
match = comment_method.text.match(REGEXP_METHOD)
next unless match
{
node_method: comment_method,
node_scope: comment_scope,
method_name: match[:method_name],
args: match[:args],
receiver: match[:receiver],
has_scope_directive: comment_scope&.text&.match?(REGEXP_SCOPE)
}
end
end
def group_comments(comments)
result = []
comments.each.with_index do |comment, index|
# Grab the scope directive if it is preceded by a method directive
if comment.text.include?('@!method')
result << if (next_comment = comments[index + 1])&.text&.include?('@!scope')
[comment, next_comment]
else
[comment, nil]
end
end
end
result
end
def too_many_directives(node)
add_offense(node, message: MSG_TOO_MANY)
end
def process_directive(node, actual_name, directive)
return unless (offense_type = directive_offense_type(directive, actual_name))
register_offense(offense_type, node, directive, actual_name)
end
def directive_offense_type(directive, actual_name)
return :missing_directive unless directive
return :wrong_scope if wrong_scope(directive, actual_name)
return :no_scope if no_scope(directive, actual_name)
# The method directive being prefixed by 'self.' is always an offense.
# The matched method_name does not contain the receiver but the
# def_node_match method name may so it must be removed.
if directive[:method_name] != remove_receiver(actual_name) || directive[:receiver]
:wrong_name
end
end
def wrong_scope(directive, actual_name)
!actual_name.start_with?('self.') && directive[:has_scope_directive]
end
def no_scope(directive, actual_name)
actual_name.start_with?('self.') && !directive[:has_scope_directive]
end
def register_offense(offense_type, node, directive, actual_name)
message = formatted_message(offense_type, directive, actual_name, node.method_name)
add_offense(node, message: message) do |corrector|
case offense_type
when :wrong_name
correct_method_directive(corrector, directive, actual_name)
when :wrong_scope
remove_scope_directive(corrector, directive)
when :no_scope
insert_scope_directive(corrector, directive[:node_method])
when :missing_directive
insert_method_directive(corrector, node, actual_name)
end
end
end
# rubocop:disable Metrics/MethodLength
def formatted_message(offense_type, directive, actual_name, method_name)
case offense_type
when :wrong_name
# Add the receiver to the name when showing an offense
current_name = if directive[:receiver]
directive[:receiver] + directive[:method_name]
else
directive[:method_name]
end
# The correct name will never include a receiver, remove it
format(MSG_WRONG_NAME, expected: remove_receiver(actual_name), actual: current_name)
when :wrong_scope
MSG_WRONG_SCOPE_SELF
when :no_scope
MSG_MISSING_SCOPE_SELF
when :missing_directive
format(MSG, method: method_name)
end
end
# rubocop:enable Metrics/MethodLength
def remove_receiver(current)
current.delete_prefix('self.')
end
def insert_method_directive(corrector, node, actual_name)
# If the pattern matcher uses arguments (`%1`, `%2`, etc.), include them in the directive
arguments = pattern_arguments(node.arguments[1].source)
range = range_with_surrounding_space(node.source_range, side: :left, newlines: false)
indentation = range.source.match(/^\s*/)[0]
directive = "#{indentation}# @!method #{actual_name}(#{arguments.join(', ')})\n"
directive = "\n#{directive}" if add_newline?(node)
corrector.insert_before(range, directive)
end
def insert_scope_directive(corrector, node)
range = range_with_surrounding_space(node.source_range, side: :left, newlines: false)
indentation = range.source.match(/^\s*/)[0]
directive = "\n#{indentation}# @!scope class"
corrector.insert_after(node, directive)
end
def pattern_arguments(pattern)
arguments = %w[node]
max_pattern_var = pattern.scan(/(?<=%)\d+/).map(&:to_i).max
max_pattern_var&.times { |i| arguments << "arg#{i + 1}" }
arguments
end
def add_newline?(node)
# Determine if a blank line should be inserted before the new directive
# in order to spread out pattern matchers
return false if node.sibling_index&.zero?
return false unless node.parent
prev_sibling = node.parent.child_nodes[node.sibling_index - 1]
return false unless prev_sibling && pattern_matcher?(prev_sibling)
node.loc.line == last_line(prev_sibling) + 1
end
def last_line(node)
if node.last_argument.heredoc?
node.last_argument.loc.heredoc_end.line
else
node.loc.last_line
end
end
def correct_method_directive(corrector, directive, actual_name)
correct = "@!method #{remove_receiver(actual_name)}"
current_name = (directive[:receiver] || '') + directive[:method_name]
regexp = /@!method\s+#{Regexp.escape(current_name)}/
replacement = directive[:node_method].text.gsub(regexp, correct)
corrector.replace(directive[:node_method], replacement)
end
def remove_scope_directive(corrector, directive)
range = range_by_whole_lines(
directive[:node_scope].source_range,
include_final_newline: true
)
corrector.remove(range)
end
end
end
end
end