rubocop-sorted_methods_by_call 1.0.0
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +25 -0
- data/.rubocop_todo.yml +59 -0
- data/.ruby-version +1 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +92 -0
- data/LICENSE.txt +11 -0
- data/README.md +222 -0
- data/Rakefile +10 -0
- data/config/default.yml +7 -0
- data/exe/rubocop-sorted_methods_by_call +4 -0
- data/lib/rubocop/cop/sorted_methods_by_call/waterfall.rb +491 -0
- data/lib/rubocop/sorted_methods_by_call/compare.rb +73 -0
- data/lib/rubocop/sorted_methods_by_call/extensions/util.rb +32 -0
- data/lib/rubocop/sorted_methods_by_call/plugin.rb +61 -0
- data/lib/rubocop/sorted_methods_by_call/version.rb +7 -0
- data/lib/rubocop-sorted_methods_by_call.rb +5 -0
- data/test_project/.rubocop.test.yml +13 -0
- data/test_project/Gemfile +5 -0
- metadata +191 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module SortedMethodsByCall
|
|
6
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall+ enforces "waterfall" ordering:
|
|
7
|
+
# define a method after any method that calls it (within the same scope).
|
|
8
|
+
#
|
|
9
|
+
# - Scopes: class/module/sclass (top-level can be enabled in config)
|
|
10
|
+
# - Offense: when a callee is defined above its caller
|
|
11
|
+
# - Autocorrect: UNSAFE; reorders methods within a contiguous visibility section
|
|
12
|
+
#
|
|
13
|
+
# Example (good):
|
|
14
|
+
# def foo
|
|
15
|
+
# bar
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def bar
|
|
19
|
+
# 123
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# Example (bad):
|
|
23
|
+
# def bar
|
|
24
|
+
# 123
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# def foo
|
|
28
|
+
# bar
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# Autocorrect (unsafe, opt-in via SafeAutoCorrect: false): topologically sorts the contiguous
|
|
32
|
+
# block of defs to satisfy edges (caller -> callee). Skips cycles and non-contiguous groups.
|
|
33
|
+
class Waterfall < ::RuboCop::Cop::Base # rubocop:disable Metrics/ClassLength
|
|
34
|
+
include ::RuboCop::Cop::RangeHelp
|
|
35
|
+
extend ::RuboCop::Cop::AutoCorrector
|
|
36
|
+
|
|
37
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall::MSG+ -> String
|
|
38
|
+
#
|
|
39
|
+
# Template message for offenses.
|
|
40
|
+
MSG = 'Define %<callee>s after its caller %<caller>s (waterfall order).'
|
|
41
|
+
|
|
42
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#on_begin+ -> void
|
|
43
|
+
#
|
|
44
|
+
# Entry point for root :begin nodes (top-level). Whether it is analyzed
|
|
45
|
+
# depends on configuration (e.g., CheckTopLevel). By default, only class/module scopes are analyzed.
|
|
46
|
+
#
|
|
47
|
+
# @param [RuboCop::AST::Node] node
|
|
48
|
+
# @return [void]
|
|
49
|
+
def on_begin(node)
|
|
50
|
+
analyze_scope(node)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#on_class+ -> void
|
|
54
|
+
#
|
|
55
|
+
# Entry point for class scopes.
|
|
56
|
+
#
|
|
57
|
+
# @param [RuboCop::AST::Node] node
|
|
58
|
+
# @return [void]
|
|
59
|
+
def on_class(node)
|
|
60
|
+
analyze_scope(node)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#on_module+ -> void
|
|
64
|
+
#
|
|
65
|
+
# Entry point for module scopes.
|
|
66
|
+
#
|
|
67
|
+
# @param [RuboCop::AST::Node] node
|
|
68
|
+
# @return [void]
|
|
69
|
+
def on_module(node)
|
|
70
|
+
analyze_scope(node)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#on_sclass+ -> void
|
|
74
|
+
#
|
|
75
|
+
# Entry point for singleton class scopes (class << self).
|
|
76
|
+
#
|
|
77
|
+
# @param [RuboCop::AST::Node] node
|
|
78
|
+
# @return [void]
|
|
79
|
+
def on_sclass(node)
|
|
80
|
+
analyze_scope(node)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#analyze_scope+ -> void
|
|
86
|
+
#
|
|
87
|
+
# Collects defs in the current scope, builds caller->callee edges
|
|
88
|
+
# for local sends, finds the first backward edge (callee defined before caller),
|
|
89
|
+
# and registers an offense. If autocorrection is requested, attempts to reorder
|
|
90
|
+
# methods within the same visibility section.
|
|
91
|
+
#
|
|
92
|
+
# @param [RuboCop::AST::Node] scope_node
|
|
93
|
+
# @return [void]
|
|
94
|
+
def analyze_scope(scope_node)
|
|
95
|
+
body_nodes = scope_body_nodes(scope_node)
|
|
96
|
+
return if body_nodes.empty?
|
|
97
|
+
|
|
98
|
+
def_nodes = body_nodes.select { |n| %i[def defs].include?(n.type) }
|
|
99
|
+
return if def_nodes.size <= 1
|
|
100
|
+
|
|
101
|
+
names = def_nodes.map(&:method_name)
|
|
102
|
+
names_set = names.to_set
|
|
103
|
+
index_of = names.each_with_index.to_h
|
|
104
|
+
|
|
105
|
+
edges = []
|
|
106
|
+
def_nodes.each do |def_node|
|
|
107
|
+
local_calls(def_node, names_set).each do |callee|
|
|
108
|
+
next if callee == def_node.method_name # self-recursion
|
|
109
|
+
|
|
110
|
+
edges << [def_node.method_name, callee]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
allow_recursion = cop_config.fetch('AllowedRecursion') { true }
|
|
115
|
+
adj = build_adj(names, edges)
|
|
116
|
+
|
|
117
|
+
violation = first_backward_edge(edges, index_of, adj, allow_recursion)
|
|
118
|
+
return unless violation
|
|
119
|
+
|
|
120
|
+
caller_name, callee_name = violation
|
|
121
|
+
callee_node = def_nodes[index_of[callee_name]]
|
|
122
|
+
|
|
123
|
+
add_offense(callee_node,
|
|
124
|
+
message: format(MSG, callee: "##{callee_name}", caller: "##{caller_name}")) do |corrector|
|
|
125
|
+
try_autocorrect(corrector, body_nodes, def_nodes, edges)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Recurse into nested scopes
|
|
129
|
+
body_nodes.each { |n| analyze_scope(n) if n.class_type? || n.module_type? || n.sclass_type? }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#scope_body_nodes+ -> Array<RuboCop::AST::Node>
|
|
133
|
+
#
|
|
134
|
+
# Normalizes a scope node to its immediate "body" items we iterate over.
|
|
135
|
+
#
|
|
136
|
+
# @param [RuboCop::AST::Node] node
|
|
137
|
+
# @return [Array<RuboCop::AST::Node>]
|
|
138
|
+
def scope_body_nodes(node)
|
|
139
|
+
case node.type
|
|
140
|
+
when :begin
|
|
141
|
+
node.children
|
|
142
|
+
when :class, :module, :sclass
|
|
143
|
+
body = node.body
|
|
144
|
+
return [] unless body
|
|
145
|
+
|
|
146
|
+
body.begin_type? ? body.children : [body]
|
|
147
|
+
else
|
|
148
|
+
[]
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#local_calls+ -> Array<Symbol>
|
|
153
|
+
#
|
|
154
|
+
# Returns the set of local method names (receiver is nil/self) invoked inside
|
|
155
|
+
# a given def node whose names exist in the provided name set.
|
|
156
|
+
#
|
|
157
|
+
# @param [RuboCop::AST::Node] def_node
|
|
158
|
+
# @param [Set<Symbol>] names_set
|
|
159
|
+
# @return [Array<Symbol>]
|
|
160
|
+
def local_calls(def_node, names_set)
|
|
161
|
+
body = def_node.body
|
|
162
|
+
return [] unless body
|
|
163
|
+
|
|
164
|
+
res = []
|
|
165
|
+
body.each_node(:send) do |send|
|
|
166
|
+
recv = send.receiver
|
|
167
|
+
next unless recv.nil? || recv&.self_type?
|
|
168
|
+
|
|
169
|
+
mname = send.method_name
|
|
170
|
+
res << mname if names_set.include?(mname)
|
|
171
|
+
end
|
|
172
|
+
res.uniq
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#find_violation+ -> [Symbol, Symbol], nil
|
|
176
|
+
#
|
|
177
|
+
# Finds the first backward edge (caller->callee where callee is defined above caller)
|
|
178
|
+
# using the provided index map.
|
|
179
|
+
#
|
|
180
|
+
# @param [Array<Array(Symbol, Symbol)>] edges
|
|
181
|
+
# @param [Hash{Symbol=>Integer}] index_of
|
|
182
|
+
# @return [[Symbol, Symbol], nil] tuple [caller, callee] or nil if none found
|
|
183
|
+
def find_violation(edges, index_of)
|
|
184
|
+
edges.find do |caller, callee|
|
|
185
|
+
index_of.key?(caller) && index_of.key?(callee) && index_of[callee] < index_of[caller]
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#try_autocorrect+ -> void
|
|
190
|
+
#
|
|
191
|
+
# UNSAFE: Reorders method definitions inside the target visibility section only
|
|
192
|
+
# (does not cross private/protected/public boundaries). Skips if defs are not
|
|
193
|
+
# contiguous within the section or if a cycle prevents a consistent topo order.
|
|
194
|
+
#
|
|
195
|
+
# @param [RuboCop::Cop::Corrector] corrector
|
|
196
|
+
# @param [Array<RuboCop::AST::Node>] body_nodes
|
|
197
|
+
# @param [Array<RuboCop::AST::Node>] def_nodes
|
|
198
|
+
# @param [Array<Array(Symbol, Symbol)>] edges
|
|
199
|
+
# @return [void]
|
|
200
|
+
#
|
|
201
|
+
# @note Applied only when user asked for autocorrections; with SafeAutoCorrect: false, this runs under -A.
|
|
202
|
+
# @note Also preserves contiguous leading doc comments above each method.
|
|
203
|
+
def try_autocorrect(corrector, body_nodes, def_nodes, edges)
|
|
204
|
+
# Group method definitions into visibility sections
|
|
205
|
+
sections = extract_visibility_sections(body_nodes)
|
|
206
|
+
|
|
207
|
+
# Find the section that contains our violating methods
|
|
208
|
+
caller_name, callee_name = first_backward_edge(
|
|
209
|
+
edges,
|
|
210
|
+
def_nodes.map(&:method_name).each_with_index.to_h,
|
|
211
|
+
build_adj(def_nodes.map(&:method_name), edges),
|
|
212
|
+
cop_config.fetch('AllowedRecursion') { true }
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# No violation -> nothing to do
|
|
216
|
+
return unless caller_name && callee_name
|
|
217
|
+
|
|
218
|
+
# Find a visibility section that contains both names
|
|
219
|
+
target_section = sections.find do |section|
|
|
220
|
+
names_in_section = section[:defs].to_set(&:method_name)
|
|
221
|
+
names_in_section.include?(caller_name) && names_in_section.include?(callee_name)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# If violation spans multiple sections, skip autocorrect
|
|
225
|
+
return unless target_section
|
|
226
|
+
|
|
227
|
+
defs = target_section[:defs]
|
|
228
|
+
return unless defs.size > 1
|
|
229
|
+
|
|
230
|
+
# Apply topological sort only within this visibility section
|
|
231
|
+
defs = target_section[:defs]
|
|
232
|
+
names = defs.map(&:method_name)
|
|
233
|
+
idx_of = names.each_with_index.to_h
|
|
234
|
+
|
|
235
|
+
# Filter edges to only those within this section
|
|
236
|
+
# Filter edges to only those within this section
|
|
237
|
+
section_names = names.to_set
|
|
238
|
+
section_edges = edges.select { |u, v| section_names.include?(u) && section_names.include?(v) }
|
|
239
|
+
|
|
240
|
+
sorted_names = topo_sort(names, section_edges, idx_of)
|
|
241
|
+
return unless sorted_names
|
|
242
|
+
|
|
243
|
+
# Capture each def with its leading contiguous comment block
|
|
244
|
+
ranges_by_name = defs.to_h { |d| [d.method_name, range_with_leading_comments(d)] }
|
|
245
|
+
sorted_def_sources = sorted_names.map { |name| ranges_by_name[name].source }
|
|
246
|
+
|
|
247
|
+
# Reconstruct the section: keep the visibility modifier (if any) above the first def
|
|
248
|
+
visibility_node = target_section[:visibility]
|
|
249
|
+
visibility_source = visibility_node&.source.to_s
|
|
250
|
+
|
|
251
|
+
new_content = if visibility_source.empty?
|
|
252
|
+
sorted_def_sources.join("\n\n")
|
|
253
|
+
else
|
|
254
|
+
"#{visibility_source}\n\n#{sorted_def_sources.join("\n\n")}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Expand the replaced region:
|
|
258
|
+
# - if a visibility node exists, start from its begin_pos (so we replace it)
|
|
259
|
+
# - otherwise, start from the earliest leading doc-comment of the defs
|
|
260
|
+
section_begin =
|
|
261
|
+
if visibility_node
|
|
262
|
+
visibility_node.source_range.begin_pos
|
|
263
|
+
else
|
|
264
|
+
defs.map { |d| range_with_leading_comments(d).begin_pos }.min
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Always end at the end of the last def
|
|
268
|
+
section_end = defs.last.source_range.end_pos
|
|
269
|
+
|
|
270
|
+
region = Parser::Source::Range.new(processed_source.buffer, section_begin, section_end)
|
|
271
|
+
corrector.replace(region, new_content)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#build_adj+ -> Hash{Symbol=>Array<Symbol>}
|
|
275
|
+
#
|
|
276
|
+
# Builds an adjacency list for edges restricted to known names.
|
|
277
|
+
#
|
|
278
|
+
# @param [Array<Symbol>] names
|
|
279
|
+
# @param [Array<Array(Symbol, Symbol)>] edges
|
|
280
|
+
# @return [Hash{Symbol=>Array<Symbol>}]
|
|
281
|
+
def build_adj(names, edges)
|
|
282
|
+
allowed = names.to_set
|
|
283
|
+
adj = Hash.new { |h, k| h[k] = [] }
|
|
284
|
+
edges.each do |u, v|
|
|
285
|
+
next unless allowed.include?(u) && allowed.include?(v)
|
|
286
|
+
next if u == v
|
|
287
|
+
|
|
288
|
+
adj[u] << v
|
|
289
|
+
end
|
|
290
|
+
adj
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#first_backward_edge+ -> [Symbol, Symbol], nil
|
|
294
|
+
#
|
|
295
|
+
# Returns the first backward edge found, optionally skipping mutual recursion
|
|
296
|
+
# if so configured.
|
|
297
|
+
#
|
|
298
|
+
# @param [Array<Array(Symbol, Symbol)>] edges
|
|
299
|
+
# @param [Hash{Symbol=>Integer}] index_of
|
|
300
|
+
# @param [Hash{Symbol=>Array<Symbol>}] adj
|
|
301
|
+
# @param [Boolean] allow_recursion whether to ignore cycles
|
|
302
|
+
# @return [[Symbol, Symbol], nil]
|
|
303
|
+
def first_backward_edge(edges, index_of, adj, allow_recursion)
|
|
304
|
+
edges.find do |caller, callee|
|
|
305
|
+
next unless index_of.key?(caller) && index_of.key?(callee)
|
|
306
|
+
# If mutual recursion allowed and there is a path callee -> caller, skip
|
|
307
|
+
next if allow_recursion && path_exists?(callee, caller, adj)
|
|
308
|
+
|
|
309
|
+
index_of[callee] < index_of[caller]
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#path_exists?+ -> Boolean
|
|
314
|
+
#
|
|
315
|
+
# Tests whether a path exists in the adjacency graph from +src+ to +dst+ (BFS).
|
|
316
|
+
#
|
|
317
|
+
# @param [Symbol] src
|
|
318
|
+
# @param [Symbol] dst
|
|
319
|
+
# @param [Hash{Symbol=>Array<Symbol>}] adj
|
|
320
|
+
# @param [Integer] limit traversal step limit (guard)
|
|
321
|
+
# @return [Boolean]
|
|
322
|
+
def path_exists?(src, dst, adj, limit = 200)
|
|
323
|
+
return true if src == dst
|
|
324
|
+
|
|
325
|
+
visited = {}
|
|
326
|
+
q = [src]
|
|
327
|
+
steps = 0
|
|
328
|
+
until q.empty?
|
|
329
|
+
steps += 1
|
|
330
|
+
return false if steps > limit
|
|
331
|
+
|
|
332
|
+
u = q.shift
|
|
333
|
+
next if visited[u]
|
|
334
|
+
|
|
335
|
+
visited[u] = true
|
|
336
|
+
return true if u == dst
|
|
337
|
+
|
|
338
|
+
adj[u].each { |v| q << v unless visited[v] }
|
|
339
|
+
end
|
|
340
|
+
false
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#extract_visibility_sections+ -> Array<Hash>
|
|
344
|
+
#
|
|
345
|
+
# Splits the body into contiguous sections of defs grouped by visibility modifier
|
|
346
|
+
# (private/protected/public). Returns metadata for each section including:
|
|
347
|
+
# :visibility -> visibility modifier node or nil
|
|
348
|
+
# :defs -> array of def/defs nodes
|
|
349
|
+
# :start_pos -> Integer (begin_pos)
|
|
350
|
+
# :end_pos -> Integer (end_pos)
|
|
351
|
+
#
|
|
352
|
+
# @param [Array<RuboCop::AST::Node>] body_nodes
|
|
353
|
+
# @return [Array<Hash{Symbol=>untyped}>]
|
|
354
|
+
def extract_visibility_sections(body_nodes)
|
|
355
|
+
sections = []
|
|
356
|
+
current_visibility = nil
|
|
357
|
+
current_defs = []
|
|
358
|
+
section_start = nil
|
|
359
|
+
|
|
360
|
+
body_nodes.each_with_index do |node, idx|
|
|
361
|
+
case node.type
|
|
362
|
+
when :def, :defs
|
|
363
|
+
current_defs << node
|
|
364
|
+
section_start ||= node.source_range.begin_pos
|
|
365
|
+
when :send
|
|
366
|
+
# Check if this is a visibility modifier (private/protected/public)
|
|
367
|
+
if node.receiver.nil? && %i[private protected public].include?(node.method_name) && node.arguments.empty?
|
|
368
|
+
# End current section if it has defs
|
|
369
|
+
unless current_defs.empty?
|
|
370
|
+
sections << {
|
|
371
|
+
visibility: current_visibility,
|
|
372
|
+
defs: current_defs.dup,
|
|
373
|
+
start_pos: section_start,
|
|
374
|
+
end_pos: body_nodes[idx - 1].source_range.end_pos
|
|
375
|
+
}
|
|
376
|
+
current_defs = []
|
|
377
|
+
section_start = nil
|
|
378
|
+
end
|
|
379
|
+
current_visibility = node
|
|
380
|
+
else
|
|
381
|
+
# Non-visibility send - breaks contiguity
|
|
382
|
+
unless current_defs.empty?
|
|
383
|
+
sections << {
|
|
384
|
+
visibility: current_visibility,
|
|
385
|
+
defs: current_defs.dup,
|
|
386
|
+
start_pos: section_start,
|
|
387
|
+
end_pos: body_nodes[idx - 1].source_range.end_pos
|
|
388
|
+
}
|
|
389
|
+
current_defs = []
|
|
390
|
+
section_start = nil
|
|
391
|
+
current_visibility = nil
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
else
|
|
395
|
+
# Any other node type breaks contiguity
|
|
396
|
+
unless current_defs.empty?
|
|
397
|
+
sections << {
|
|
398
|
+
visibility: current_visibility,
|
|
399
|
+
defs: current_defs.dup,
|
|
400
|
+
start_pos: section_start,
|
|
401
|
+
end_pos: body_nodes[idx - 1].source_range.end_pos
|
|
402
|
+
}
|
|
403
|
+
current_defs = []
|
|
404
|
+
section_start = nil
|
|
405
|
+
current_visibility = nil
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Handle trailing defs
|
|
411
|
+
unless current_defs.empty?
|
|
412
|
+
sections << {
|
|
413
|
+
visibility: current_visibility,
|
|
414
|
+
defs: current_defs,
|
|
415
|
+
start_pos: section_start,
|
|
416
|
+
end_pos: current_defs.last.source_range.end_pos
|
|
417
|
+
}
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
sections
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#topo_sort+ -> Array<Symbol>, nil
|
|
424
|
+
#
|
|
425
|
+
# Performs a stable topological sort using current order as a tie-breaker.
|
|
426
|
+
#
|
|
427
|
+
# @param [Array<Symbol>] names
|
|
428
|
+
# @param [Array<Array(Symbol, Symbol)>] edges
|
|
429
|
+
# @param [Hash{Symbol=>Integer}] idx_of
|
|
430
|
+
# @return [Array<Symbol>, nil] sorted names or nil if a cycle prevents a full order
|
|
431
|
+
def topo_sort(names, edges, idx_of)
|
|
432
|
+
indegree = Hash.new(0)
|
|
433
|
+
adj = Hash.new { |h, k| h[k] = [] }
|
|
434
|
+
|
|
435
|
+
edges.each do |caller, callee|
|
|
436
|
+
next unless names.include?(caller) && names.include?(callee)
|
|
437
|
+
next if caller == callee
|
|
438
|
+
|
|
439
|
+
adj[caller] << callee
|
|
440
|
+
indegree[callee] += 1
|
|
441
|
+
indegree[caller] ||= 0
|
|
442
|
+
end
|
|
443
|
+
names.each { |n| indegree[n] ||= 0 }
|
|
444
|
+
|
|
445
|
+
queue = names.select { |n| indegree[n].zero? }.sort_by { |n| idx_of[n] }
|
|
446
|
+
result = []
|
|
447
|
+
|
|
448
|
+
until queue.empty?
|
|
449
|
+
n = queue.shift
|
|
450
|
+
result << n
|
|
451
|
+
adj[n].each do |m|
|
|
452
|
+
indegree[m] -= 1
|
|
453
|
+
queue << m if indegree[m].zero?
|
|
454
|
+
end
|
|
455
|
+
queue.sort_by! { |x| idx_of[x] }
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
return nil unless result.size == names.size
|
|
459
|
+
|
|
460
|
+
result
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# +RuboCop::Cop::SortedMethodsByCall::Waterfall#range_with_leading_comments+ -> Parser::Source::Range
|
|
464
|
+
#
|
|
465
|
+
# Returns a range that starts at the first contiguous comment line immediately
|
|
466
|
+
# above the def/defs node, and ends at the end of the def. This preserves
|
|
467
|
+
# YARD/RDoc doc comments when methods are moved during autocorrect.
|
|
468
|
+
#
|
|
469
|
+
# @param [RuboCop::AST::Node] node The def/defs node.
|
|
470
|
+
# @return [Parser::Source::Range] Range covering leading comments + method body.
|
|
471
|
+
def range_with_leading_comments(node)
|
|
472
|
+
buffer = processed_source.buffer
|
|
473
|
+
expr = node.source_range
|
|
474
|
+
|
|
475
|
+
start_line = expr.line
|
|
476
|
+
lineno = start_line - 1
|
|
477
|
+
while lineno >= 1
|
|
478
|
+
line = buffer.source_line(lineno)
|
|
479
|
+
break unless line =~ /\A\s*#/
|
|
480
|
+
|
|
481
|
+
start_line = lineno
|
|
482
|
+
lineno -= 1
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
start_pos = buffer.line_range(start_line).begin_pos
|
|
486
|
+
Parser::Source::Range.new(buffer, start_pos, expr.end_pos)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module SortedMethodsByCall
|
|
5
|
+
# +RuboCop::SortedMethodsByCall::Compare+ provides helpers to compare
|
|
6
|
+
# definition orders and call orders using “ordered subsequence” semantics.
|
|
7
|
+
# It’s used by the cop to check that called methods appear in the same
|
|
8
|
+
# relative order as they are defined (not necessarily contiguously).
|
|
9
|
+
module Compare
|
|
10
|
+
class << self
|
|
11
|
+
# +RuboCop::SortedMethodsByCall::Compare.hashes_ordered_equal?(actual, expected)+ -> Bool
|
|
12
|
+
#
|
|
13
|
+
# For each scope key, checks that every call in +expected[k]+ exists in +actual[k]+ and
|
|
14
|
+
# appears in the same relative order (i.e., +expected[k]+ is a subsequence of +actual[k]+).
|
|
15
|
+
# Returns false if a call is unknown (not present in +actual[k]+) or out of order.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# defs = { main: %i[abc foo bar a hello] }
|
|
19
|
+
# calls = { main: %i[foo bar hello] }
|
|
20
|
+
# RuboCop::SortedMethodsByCall::Compare.hashes_ordered_equal?(defs, calls) #=> true
|
|
21
|
+
#
|
|
22
|
+
# calls2 = { main: %i[bar foo] }
|
|
23
|
+
# RuboCop::SortedMethodsByCall::Compare.hashes_ordered_equal?(defs, calls2) #=> false
|
|
24
|
+
#
|
|
25
|
+
# @param [Hash{Object=>Array<Symbol>}] actual Actual definitions per scope.
|
|
26
|
+
# @param [Hash{Object=>Array<Symbol>}] expected Expected calls per scope.
|
|
27
|
+
# @return [Bool] true if for every scope +k+, +expected[k]+ is a subsequence of +actual[k]+
|
|
28
|
+
# and contains no unknown methods.
|
|
29
|
+
def hashes_ordered_equal?(actual, expected)
|
|
30
|
+
return false unless actual.is_a?(Hash) && expected.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
(actual.keys | expected.keys).all? do |k|
|
|
33
|
+
defs = Array(actual[k])
|
|
34
|
+
calls = Array(expected[k])
|
|
35
|
+
(calls - defs).empty? && subsequence?(defs, calls)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# +RuboCop::SortedMethodsByCall::Compare.subsequence?(arr, sub)+ -> Bool
|
|
40
|
+
#
|
|
41
|
+
# Returns true if +sub+ is a subsequence of +arr+ (order preserved),
|
|
42
|
+
# not necessarily contiguous. An empty +sub+ returns true.
|
|
43
|
+
#
|
|
44
|
+
# @example
|
|
45
|
+
# arr = %i[abc foo bar a hello]
|
|
46
|
+
# RuboCop::SortedMethodsByCall::Compare.subsequence?(arr, %i[foo bar hello]) #=> true
|
|
47
|
+
# RuboCop::SortedMethodsByCall::Compare.subsequence?(arr, %i[bar foo]) #=> false
|
|
48
|
+
#
|
|
49
|
+
# @param [Array<#==>] arr Base sequence (typically Array<Symbol>).
|
|
50
|
+
# @param [Array<#==>, nil] sub Candidate subsequence (typically Array<Symbol>).
|
|
51
|
+
# @return [Bool] true if +sub+ appears in +arr+ in order.
|
|
52
|
+
def subsequence?(arr, sub)
|
|
53
|
+
return true if sub.nil? || sub.empty?
|
|
54
|
+
|
|
55
|
+
i = 0
|
|
56
|
+
sub.each do |el|
|
|
57
|
+
found = false
|
|
58
|
+
while i < arr.length
|
|
59
|
+
if arr[i] == el
|
|
60
|
+
found = true
|
|
61
|
+
i += 1
|
|
62
|
+
break
|
|
63
|
+
end
|
|
64
|
+
i += 1
|
|
65
|
+
end
|
|
66
|
+
return false unless found
|
|
67
|
+
end
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module SortedMethodsByCall
|
|
5
|
+
module Util # :nodoc:
|
|
6
|
+
# +RuboCop::SortedMethodsByCall::Util.deep_merge(hash, other)+ -> Hash
|
|
7
|
+
#
|
|
8
|
+
# Merges two hashes without overwriting values that share the same key.
|
|
9
|
+
# When a key exists in both hashes, values are accumulated into an array
|
|
10
|
+
# ("buckets"). Scalars are wrapped to arrays automatically.
|
|
11
|
+
#
|
|
12
|
+
# - Non-destructive: returns a new Hash; does not mutate +hash+.
|
|
13
|
+
# - If +other+ is not a Hash, the original +hash+ is returned as-is.
|
|
14
|
+
#
|
|
15
|
+
# @example Accumulate values into buckets
|
|
16
|
+
# base = { main: :abc, class_T: :hi }
|
|
17
|
+
# other = { class_T: :h1 }
|
|
18
|
+
# RuboCop::SortedMethodsByCall::Util.deep_merge(base, other)
|
|
19
|
+
# #=> { main: [:abc], class_T: [:hi, :h1] }
|
|
20
|
+
#
|
|
21
|
+
# @param [Hash] hash The base hash to merge from.
|
|
22
|
+
# @param [Hash] other The hash to merge into +hash+.
|
|
23
|
+
# @return [Hash] A new hash with accumulated values per key.
|
|
24
|
+
# @see Hash#merge
|
|
25
|
+
def self.deep_merge(h, other)
|
|
26
|
+
return h unless other.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
h.merge(other) { |_, a, b| Array(a) + Array(b) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'lint_roller'
|
|
4
|
+
require_relative 'version'
|
|
5
|
+
|
|
6
|
+
module RuboCop
|
|
7
|
+
module SortedMethodsByCall
|
|
8
|
+
# +RuboCop::SortedMethodsByCall::Plugin+ integrates this extension with RuboCop's
|
|
9
|
+
# plugin system via lint_roller. It declares metadata and tells RuboCop where
|
|
10
|
+
# to find the plugin's default configuration.
|
|
11
|
+
#
|
|
12
|
+
# The plugin is discovered by RuboCop when you configure:
|
|
13
|
+
# plugins:
|
|
14
|
+
# - rubocop-sorted_methods_by_call
|
|
15
|
+
#
|
|
16
|
+
# It will automatically apply rules (config/default.yml) and make the cops
|
|
17
|
+
# available to the engine.
|
|
18
|
+
class Plugin < LintRoller::Plugin
|
|
19
|
+
# +RuboCop::SortedMethodsByCall::Plugin#about+ -> LintRoller::About
|
|
20
|
+
#
|
|
21
|
+
# Declares plugin metadata (name, version, homepage, description).
|
|
22
|
+
#
|
|
23
|
+
# @return [LintRoller::About] Metadata describing this plugin.
|
|
24
|
+
def about
|
|
25
|
+
LintRoller::About.new(
|
|
26
|
+
name: 'rubocop-sorted_methods_by_call',
|
|
27
|
+
version: VERSION,
|
|
28
|
+
homepage: 'https://github.com/unurgunite/rubocop-sorted_methods_by_call',
|
|
29
|
+
description: 'Enforces waterfall ordering: define methods after the methods that call them.'
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# +RuboCop::SortedMethodsByCall::Plugin#supported?+ -> Boolean
|
|
34
|
+
#
|
|
35
|
+
# Indicates that this plugin supports RuboCop as the lint engine.
|
|
36
|
+
#
|
|
37
|
+
# @param [Object] context LintRoller context (engine, versions, etc.).
|
|
38
|
+
# @return [Boolean] true for RuboCop engine; false otherwise.
|
|
39
|
+
def supported?(context)
|
|
40
|
+
context.engine == :rubocop
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# +RuboCop::SortedMethodsByCall::Plugin#rules+ -> LintRoller::Rules
|
|
44
|
+
#
|
|
45
|
+
# Returns the plugin rules for RuboCop. This points RuboCop to the default
|
|
46
|
+
# configuration file shipped with the gem (config/default.yml).
|
|
47
|
+
#
|
|
48
|
+
# @param [Object] _context LintRoller context (unused).
|
|
49
|
+
# @return [LintRoller::Rules] Rule declaration for RuboCop to load.
|
|
50
|
+
#
|
|
51
|
+
# @see config/default.yml
|
|
52
|
+
def rules(_context)
|
|
53
|
+
LintRoller::Rules.new(
|
|
54
|
+
type: :path,
|
|
55
|
+
config_format: :rubocop,
|
|
56
|
+
value: Pathname.new(__dir__).join('../../../config/default.yml')
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|