rubocop-vibe 0.1.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/config/default.yml +126 -0
- data/lib/rubocop/cop/vibe/blank_line_before_expectation.rb +141 -0
- data/lib/rubocop/cop/vibe/class_organization.rb +609 -0
- data/lib/rubocop/cop/vibe/consecutive_assignment_alignment.rb +219 -0
- data/lib/rubocop/cop/vibe/describe_block_order.rb +262 -0
- data/lib/rubocop/cop/vibe/is_expected_one_liner.rb +108 -0
- data/lib/rubocop/cop/vibe/mixin/spec_file_helper.rb +20 -0
- data/lib/rubocop/cop/vibe/no_assigns_attribute_testing.rb +62 -0
- data/lib/rubocop/cop/vibe/no_rubocop_disable.rb +60 -0
- data/lib/rubocop/cop/vibe/no_skipped_tests.rb +115 -0
- data/lib/rubocop/cop/vibe/no_unless_guard_clause.rb +250 -0
- data/lib/rubocop/cop/vibe/prefer_one_liner_expectation.rb +185 -0
- data/lib/rubocop/cop/vibe/service_call_method.rb +82 -0
- data/lib/rubocop/cop/vibe_cops.rb +15 -0
- data/lib/rubocop/vibe/plugin.rb +41 -0
- data/lib/rubocop/vibe/version.rb +7 -0
- data/lib/rubocop/vibe.rb +9 -0
- data/lib/rubocop-vibe.rb +9 -0
- metadata +131 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Vibe
|
|
6
|
+
# Enforces consistent organization of class definitions.
|
|
7
|
+
#
|
|
8
|
+
# For Rails models, enforces: concerns → constants → associations → validations →
|
|
9
|
+
# callbacks → scopes → class methods → instance methods → protected → private.
|
|
10
|
+
#
|
|
11
|
+
# For regular classes, enforces: includes → constants → initialize → class methods →
|
|
12
|
+
# instance methods → protected → private.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# # bad
|
|
16
|
+
# class User < ApplicationRecord
|
|
17
|
+
# def admin?
|
|
18
|
+
# role == "admin"
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# validates :name, presence: true
|
|
22
|
+
#
|
|
23
|
+
# has_many :posts
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# # good
|
|
27
|
+
# class User < ApplicationRecord
|
|
28
|
+
# has_many :posts
|
|
29
|
+
#
|
|
30
|
+
# validates :name, presence: true
|
|
31
|
+
#
|
|
32
|
+
# def admin?
|
|
33
|
+
# role == "admin"
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
class ClassOrganization < Base
|
|
37
|
+
extend AutoCorrector
|
|
38
|
+
|
|
39
|
+
MODEL_MSG = "Model elements should be ordered: concerns → constants → associations → " \
|
|
40
|
+
"validations → callbacks → scopes → class methods → instance methods → " \
|
|
41
|
+
"protected → private."
|
|
42
|
+
CLASS_MSG = "Class elements should be ordered: includes → constants → initialize → " \
|
|
43
|
+
"class methods → instance methods → protected → private."
|
|
44
|
+
|
|
45
|
+
ASSOCIATIONS = %i(belongs_to has_one has_many has_and_belongs_to_many).freeze
|
|
46
|
+
VALIDATIONS = %i(
|
|
47
|
+
validates validate validates_each validates_with
|
|
48
|
+
validates_absence_of validates_acceptance_of validates_confirmation_of
|
|
49
|
+
validates_exclusion_of validates_format_of validates_inclusion_of
|
|
50
|
+
validates_length_of validates_numericality_of validates_presence_of
|
|
51
|
+
validates_size_of validates_uniqueness_of validates_associated
|
|
52
|
+
).freeze
|
|
53
|
+
CALLBACKS = %i(
|
|
54
|
+
before_validation after_validation
|
|
55
|
+
before_save after_save around_save
|
|
56
|
+
before_create after_create around_create
|
|
57
|
+
before_update after_update around_update
|
|
58
|
+
before_destroy after_destroy around_destroy
|
|
59
|
+
after_commit after_rollback
|
|
60
|
+
after_initialize after_find after_touch
|
|
61
|
+
).freeze
|
|
62
|
+
MODEL_PRIORITIES = {
|
|
63
|
+
concerns: 10,
|
|
64
|
+
constants: 20,
|
|
65
|
+
associations: 30,
|
|
66
|
+
validations: 40,
|
|
67
|
+
callbacks: 50,
|
|
68
|
+
scopes: 60,
|
|
69
|
+
class_methods: 70,
|
|
70
|
+
instance_methods: 80,
|
|
71
|
+
protected_methods: 90,
|
|
72
|
+
private_methods: 100
|
|
73
|
+
}.freeze
|
|
74
|
+
CLASS_PRIORITIES = {
|
|
75
|
+
concerns: 10,
|
|
76
|
+
constants: 20,
|
|
77
|
+
initialize: 30,
|
|
78
|
+
class_methods: 40,
|
|
79
|
+
instance_methods: 50,
|
|
80
|
+
protected_methods: 60,
|
|
81
|
+
private_methods: 70
|
|
82
|
+
}.freeze
|
|
83
|
+
VISIBILITY_CATEGORIES = {
|
|
84
|
+
protected: :protected_methods,
|
|
85
|
+
private: :private_methods,
|
|
86
|
+
public: :instance_methods
|
|
87
|
+
}.freeze
|
|
88
|
+
|
|
89
|
+
# @!method visibility_modifier?(node)
|
|
90
|
+
# Check if node is a visibility modifier (public, protected, private).
|
|
91
|
+
def_node_matcher :visibility_modifier?, <<~PATTERN
|
|
92
|
+
(send nil? {:public :protected :private})
|
|
93
|
+
PATTERN
|
|
94
|
+
|
|
95
|
+
# Check and register violations.
|
|
96
|
+
#
|
|
97
|
+
# @param [RuboCop::AST::Node] node The class node.
|
|
98
|
+
# @param [Array<Hash>] elements The elements.
|
|
99
|
+
# @param [Boolean] is_model Whether this is a Rails model.
|
|
100
|
+
# @return [void]
|
|
101
|
+
def check_violations(node, elements, is_model)
|
|
102
|
+
violations = find_violations(elements)
|
|
103
|
+
return if violations.empty?
|
|
104
|
+
|
|
105
|
+
message = is_model ? MODEL_MSG : CLASS_MSG
|
|
106
|
+
|
|
107
|
+
# Register offense on first violation only, but autocorrect sorts all elements.
|
|
108
|
+
add_offense(violations.first[:node], message: message) do |corrector|
|
|
109
|
+
autocorrect(corrector, node, elements)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Check class nodes for organization.
|
|
114
|
+
#
|
|
115
|
+
# @param [RuboCop::AST::Node] node The class node.
|
|
116
|
+
# @return [void]
|
|
117
|
+
def on_class(node)
|
|
118
|
+
is_model = rails_model?(node)
|
|
119
|
+
is_controller = rails_controller?(node)
|
|
120
|
+
return if !is_model && !is_controller && !node.body
|
|
121
|
+
|
|
122
|
+
elements = extract_elements(node, is_model: is_model, is_controller: is_controller)
|
|
123
|
+
return if elements.size < 2
|
|
124
|
+
|
|
125
|
+
check_violations(node, elements, is_model)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# Check if this is a Rails model.
|
|
131
|
+
#
|
|
132
|
+
# @param [RuboCop::AST::Node] node The class node.
|
|
133
|
+
# @return [Boolean]
|
|
134
|
+
def rails_model?(node)
|
|
135
|
+
return false unless node.parent_class
|
|
136
|
+
|
|
137
|
+
parent_name = node.parent_class.const_name
|
|
138
|
+
return false unless parent_name
|
|
139
|
+
|
|
140
|
+
# Check for direct ActiveRecord inheritance.
|
|
141
|
+
parent_name == "ApplicationRecord" ||
|
|
142
|
+
parent_name == "ActiveRecord::Base" ||
|
|
143
|
+
parent_name.end_with?("::ApplicationRecord")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Check if this is a Rails controller.
|
|
147
|
+
#
|
|
148
|
+
# @param [RuboCop::AST::Node] node The class node.
|
|
149
|
+
# @return [Boolean]
|
|
150
|
+
def rails_controller?(node)
|
|
151
|
+
return false unless node.parent_class
|
|
152
|
+
|
|
153
|
+
parent_name = node.parent_class.const_name
|
|
154
|
+
return false unless parent_name
|
|
155
|
+
|
|
156
|
+
# Check for direct ActionController inheritance.
|
|
157
|
+
parent_name == "ApplicationController" ||
|
|
158
|
+
parent_name == "ActionController::Base" ||
|
|
159
|
+
parent_name.end_with?("::ApplicationController")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Extract and categorize elements from the class.
|
|
163
|
+
#
|
|
164
|
+
# @param [RuboCop::AST::Node] node The class node.
|
|
165
|
+
# @param [Boolean] is_model Whether this is a Rails model.
|
|
166
|
+
# @param [Boolean] is_controller Whether this is a Rails controller.
|
|
167
|
+
# @return [Array<Hash>] Array of element info.
|
|
168
|
+
def extract_elements(node, is_model:, is_controller: false)
|
|
169
|
+
if node.body
|
|
170
|
+
collect_elements(node.body, is_model, is_controller)
|
|
171
|
+
else
|
|
172
|
+
[]
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Collect elements from body nodes.
|
|
177
|
+
#
|
|
178
|
+
# @param [RuboCop::AST::Node] body The body node.
|
|
179
|
+
# @param [Boolean] is_model Whether this is a Rails model.
|
|
180
|
+
# @param [Boolean] is_controller Whether this is a Rails controller.
|
|
181
|
+
# @return [Array<Hash>] Array of element hashes.
|
|
182
|
+
def collect_elements(body, is_model, is_controller)
|
|
183
|
+
visibility = :public
|
|
184
|
+
elements = []
|
|
185
|
+
index = 0
|
|
186
|
+
process_body_nodes(body).each do |child|
|
|
187
|
+
visibility = child.method_name if visibility_modifier?(child)
|
|
188
|
+
|
|
189
|
+
element = build_element(child, visibility, index, is_model, is_controller)
|
|
190
|
+
elements << element and index += 1 if element
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
elements
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Build element hash for a node.
|
|
197
|
+
#
|
|
198
|
+
# @param [RuboCop::AST::Node] child The node.
|
|
199
|
+
# @param [Symbol] visibility The current visibility.
|
|
200
|
+
# @param [Integer] index The original index.
|
|
201
|
+
# @param [Boolean] is_model Whether this is a Rails model.
|
|
202
|
+
# @param [Boolean] is_controller Whether this is a Rails controller.
|
|
203
|
+
# @return [Hash]
|
|
204
|
+
# @return [nil]
|
|
205
|
+
def build_element(child, visibility, index, is_model, is_controller)
|
|
206
|
+
return unless categorizable?(child)
|
|
207
|
+
|
|
208
|
+
category = categorize_node(child, visibility, is_model)
|
|
209
|
+
return unless category
|
|
210
|
+
|
|
211
|
+
# Skip public instance methods in controllers (Rails/ActionOrder handles them).
|
|
212
|
+
return if is_controller && category == :instance_methods && visibility == :public
|
|
213
|
+
|
|
214
|
+
element_hash(child, category, visibility, index, is_model)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Create element hash.
|
|
218
|
+
#
|
|
219
|
+
# @param [RuboCop::AST::Node] node The node.
|
|
220
|
+
# @param [Symbol] category The category.
|
|
221
|
+
# @param [Symbol] visibility The visibility.
|
|
222
|
+
# @param [Integer] index The original index.
|
|
223
|
+
# @param [Boolean] is_model Whether this is a Rails model.
|
|
224
|
+
# @return [Hash]
|
|
225
|
+
def element_hash(node, category, visibility, index, is_model)
|
|
226
|
+
{
|
|
227
|
+
node: node,
|
|
228
|
+
category: category,
|
|
229
|
+
visibility: visibility,
|
|
230
|
+
original_index: index,
|
|
231
|
+
priority: priority_for(category, node, is_model),
|
|
232
|
+
sort_key: sort_key_for(category, node),
|
|
233
|
+
source: extract_source_with_comments(node)
|
|
234
|
+
}
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Extract source including preceding comments.
|
|
238
|
+
#
|
|
239
|
+
# Stores each line's content with its original column offset for later normalization.
|
|
240
|
+
#
|
|
241
|
+
# @param [RuboCop::AST::Node] node The node.
|
|
242
|
+
# @return [Array<Hash>] Array of hashes with :text and :column.
|
|
243
|
+
def extract_source_with_comments(node)
|
|
244
|
+
lines = extract_comment_lines(node)
|
|
245
|
+
lines.concat(extract_node_lines(node))
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Extract comment lines before a node.
|
|
249
|
+
#
|
|
250
|
+
# @param [RuboCop::AST::Node] node The node.
|
|
251
|
+
# @return [Array<Hash>] Array of hashes with :text and :column.
|
|
252
|
+
def extract_comment_lines(node)
|
|
253
|
+
comments_before(node).map do |comment|
|
|
254
|
+
{ text: comment.text, column: comment.source_range.column }
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Extract node source lines with column information.
|
|
259
|
+
#
|
|
260
|
+
# @param [RuboCop::AST::Node] node The node.
|
|
261
|
+
# @return [Array<Hash>] Array of hashes with :text and :column.
|
|
262
|
+
def extract_node_lines(node)
|
|
263
|
+
node_column = node.source_range.column
|
|
264
|
+
|
|
265
|
+
node.source.lines.map.with_index do |line, idx|
|
|
266
|
+
col = idx.zero? ? node_column : line[/\A\s*/].length
|
|
267
|
+
{ text: line.chomp, column: col }
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Get comments indexed by line number for fast lookup.
|
|
272
|
+
#
|
|
273
|
+
# @return [Hash<Integer, Parser::Source::Comment>] Comments keyed by line.
|
|
274
|
+
def comments_by_line
|
|
275
|
+
@comments_by_line ||= processed_source.comments.to_h { |c| [c.location.line, c] }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Get comments immediately before a node.
|
|
279
|
+
#
|
|
280
|
+
# Loops backwards from the node until finding a non-comment line.
|
|
281
|
+
# Only consecutive comments immediately before the node are included.
|
|
282
|
+
#
|
|
283
|
+
# @param [RuboCop::AST::Node] node The node.
|
|
284
|
+
# @return [Array<Parser::Source::Comment>] Consecutive comments before node.
|
|
285
|
+
def comments_before(node)
|
|
286
|
+
consecutive = []
|
|
287
|
+
expected_line = node.first_line - 1
|
|
288
|
+
|
|
289
|
+
while (comment = comments_by_line[expected_line])
|
|
290
|
+
consecutive.unshift(comment)
|
|
291
|
+
expected_line -= 1
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
consecutive
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Process body nodes to get a flat list.
|
|
298
|
+
#
|
|
299
|
+
# @param [RuboCop::AST::Node] body The body node.
|
|
300
|
+
# @return [Array<RuboCop::AST::Node>]
|
|
301
|
+
def process_body_nodes(body)
|
|
302
|
+
if body.begin_type?
|
|
303
|
+
body.children
|
|
304
|
+
else
|
|
305
|
+
[body]
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Check if node should be categorized.
|
|
310
|
+
#
|
|
311
|
+
# @param [RuboCop::AST::Node] node The node to check.
|
|
312
|
+
# @return [Boolean]
|
|
313
|
+
def categorizable?(node)
|
|
314
|
+
node.type?(:send, :any_def, :casgn)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Categorize a node.
|
|
318
|
+
#
|
|
319
|
+
# @param [RuboCop::AST::Node] node The node to categorize.
|
|
320
|
+
# @param [Symbol] visibility The current visibility.
|
|
321
|
+
# @param [Boolean] is_model Whether this is a Rails model.
|
|
322
|
+
# @return [Symbol]
|
|
323
|
+
# @return [nil] When node doesn't fit a category.
|
|
324
|
+
def categorize_node(node, visibility, is_model)
|
|
325
|
+
return method_category(node, visibility) if node.any_def_type?
|
|
326
|
+
return send_category(node, is_model) if node.send_type?
|
|
327
|
+
|
|
328
|
+
:constants
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Categorize method nodes.
|
|
332
|
+
#
|
|
333
|
+
# @param [RuboCop::AST::Node] node The node to categorize.
|
|
334
|
+
# @param [Symbol] visibility The current visibility.
|
|
335
|
+
# @return [Symbol]
|
|
336
|
+
def method_category(node, visibility)
|
|
337
|
+
return :class_methods if node.defs_type?
|
|
338
|
+
return :initialize if node.method?(:initialize) && visibility == :public
|
|
339
|
+
|
|
340
|
+
visibility_method_category(visibility)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Get category for visibility-based instance methods.
|
|
344
|
+
#
|
|
345
|
+
# @param [Symbol] visibility The visibility.
|
|
346
|
+
# @return [Symbol]
|
|
347
|
+
# @return [nil]
|
|
348
|
+
def visibility_method_category(visibility)
|
|
349
|
+
VISIBILITY_CATEGORIES[visibility]
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Categorize send nodes.
|
|
353
|
+
#
|
|
354
|
+
# @param [RuboCop::AST::Node] node The node to categorize.
|
|
355
|
+
# @param [Boolean] is_model Whether this is a Rails model.
|
|
356
|
+
# @return [Symbol]
|
|
357
|
+
# @return [nil]
|
|
358
|
+
def send_category(node, is_model)
|
|
359
|
+
return :concerns if node.method?(:include)
|
|
360
|
+
return nil unless is_model
|
|
361
|
+
|
|
362
|
+
return :associations if ASSOCIATIONS.include?(node.method_name)
|
|
363
|
+
return :validations if validation_method?(node)
|
|
364
|
+
return :callbacks if CALLBACKS.include?(node.method_name)
|
|
365
|
+
return :scopes if node.method?(:scope)
|
|
366
|
+
|
|
367
|
+
nil
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Check if node is a validation method.
|
|
371
|
+
#
|
|
372
|
+
# @param [RuboCop::AST::Node] node The node to check.
|
|
373
|
+
# @return [Boolean]
|
|
374
|
+
def validation_method?(node)
|
|
375
|
+
VALIDATIONS.include?(node.method_name) ||
|
|
376
|
+
node.method_name.to_s.start_with?("validates_")
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Get priority for a category.
|
|
380
|
+
#
|
|
381
|
+
# @param [Symbol] category The category.
|
|
382
|
+
# @param [RuboCop::AST::Node] _node The node.
|
|
383
|
+
# @param [Boolean] is_model Whether this is a Rails model.
|
|
384
|
+
# @return [Integer]
|
|
385
|
+
def priority_for(category, _node, is_model)
|
|
386
|
+
priorities = is_model ? MODEL_PRIORITIES : CLASS_PRIORITIES
|
|
387
|
+
priorities[category] || 999
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Get sort key for alphabetical ordering within category.
|
|
391
|
+
#
|
|
392
|
+
# @param [Symbol] category The category.
|
|
393
|
+
# @param [RuboCop::AST::Node] node The node.
|
|
394
|
+
# @return [String]
|
|
395
|
+
def sort_key_for(category, node)
|
|
396
|
+
return "" unless %i(scopes class_methods instance_methods).include?(category)
|
|
397
|
+
|
|
398
|
+
if category == :scopes
|
|
399
|
+
scope_sort_key(node)
|
|
400
|
+
elsif category == :class_methods && node.method?(:call)
|
|
401
|
+
"!" # Ensures call is first among class methods
|
|
402
|
+
else
|
|
403
|
+
node.method_name.to_s
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Get sort key for scope nodes.
|
|
408
|
+
#
|
|
409
|
+
# @param [RuboCop::AST::Node] node The scope node.
|
|
410
|
+
# @return [String]
|
|
411
|
+
def scope_sort_key(node)
|
|
412
|
+
first_arg = node.first_argument
|
|
413
|
+
return "" if first_arg.nil?
|
|
414
|
+
return "" unless first_arg.sym_type?
|
|
415
|
+
|
|
416
|
+
first_arg.value.to_s
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Find elements that violate ordering.
|
|
420
|
+
#
|
|
421
|
+
# @param [Array<Hash>] elements The list of elements.
|
|
422
|
+
# @return [Array<Hash>] Elements that violate ordering.
|
|
423
|
+
def find_violations(elements)
|
|
424
|
+
violations = []
|
|
425
|
+
|
|
426
|
+
elements.each_cons(2) do |current, following|
|
|
427
|
+
violations << following if violates_order?(current, following)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
violations.uniq
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Check if element violates ordering.
|
|
434
|
+
#
|
|
435
|
+
# @param [Hash] current The current element.
|
|
436
|
+
# @param [Hash] following The following element.
|
|
437
|
+
# @return [Boolean]
|
|
438
|
+
def violates_order?(current, following)
|
|
439
|
+
violates_category_order?(current, following) ||
|
|
440
|
+
violates_alphabetical_order?(current, following)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Check if element violates category ordering.
|
|
444
|
+
#
|
|
445
|
+
# @param [Hash] current The current element.
|
|
446
|
+
# @param [Hash] following The following element.
|
|
447
|
+
# @return [Boolean]
|
|
448
|
+
def violates_category_order?(current, following)
|
|
449
|
+
current[:priority] > following[:priority]
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Check if element violates alphabetical ordering.
|
|
453
|
+
#
|
|
454
|
+
# @param [Hash] current The current element.
|
|
455
|
+
# @param [Hash] following The following element.
|
|
456
|
+
# @return [Boolean]
|
|
457
|
+
def violates_alphabetical_order?(current, following)
|
|
458
|
+
current[:priority] == following[:priority] &&
|
|
459
|
+
!current[:sort_key].empty? &&
|
|
460
|
+
current[:sort_key] > following[:sort_key]
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Auto-correct by reordering elements.
|
|
464
|
+
#
|
|
465
|
+
# @param [RuboCop::AST::Corrector] corrector The corrector.
|
|
466
|
+
# @param [RuboCop::AST::Node] class_node The class node.
|
|
467
|
+
# @param [Array<Hash>] elements The list of elements.
|
|
468
|
+
# @return [void]
|
|
469
|
+
def autocorrect(corrector, class_node, elements)
|
|
470
|
+
sorted = sort_elements(elements)
|
|
471
|
+
base_column = calculate_base_indent(elements)
|
|
472
|
+
replacement = build_replacement(sorted, base_column)
|
|
473
|
+
range = replacement_range(class_node, elements)
|
|
474
|
+
corrector.replace(range, replacement.chomp)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Calculate the source range to replace when autocorrecting.
|
|
478
|
+
#
|
|
479
|
+
# @param [RuboCop::AST::Node] class_node The class node.
|
|
480
|
+
# @param [Array<Hash>] elements The list of elements.
|
|
481
|
+
# @return [Parser::Source::Range]
|
|
482
|
+
def replacement_range(class_node, elements)
|
|
483
|
+
first_elem = elements.min_by { |e| e[:node].source_range.begin_pos }
|
|
484
|
+
last_elem = elements.max_by { |e| e[:node].source_range.end_pos }
|
|
485
|
+
|
|
486
|
+
range_start = range_start_position(class_node, first_elem)
|
|
487
|
+
range_end = last_elem[:node].source_range.end_pos
|
|
488
|
+
|
|
489
|
+
Parser::Source::Range.new(processed_source.buffer, range_start, range_end)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Get the start position for replacement range.
|
|
493
|
+
#
|
|
494
|
+
# Includes any visibility modifiers that appear before the first element.
|
|
495
|
+
#
|
|
496
|
+
# @param [RuboCop::AST::Node] class_node The class node.
|
|
497
|
+
# @param [Hash] first_elem The first element.
|
|
498
|
+
# @return [Integer]
|
|
499
|
+
def range_start_position(class_node, first_elem)
|
|
500
|
+
first_node = find_first_body_node(class_node, first_elem)
|
|
501
|
+
first_comments = comments_before(first_node)
|
|
502
|
+
first_range = first_comments.any? ? first_comments.first.source_range : first_node.source_range
|
|
503
|
+
|
|
504
|
+
first_range.begin_pos - first_range.column
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Find the first node in the class body, including visibility modifiers.
|
|
508
|
+
#
|
|
509
|
+
# @param [RuboCop::AST::Node] class_node The class node.
|
|
510
|
+
# @param [Hash] first_elem The first categorizable element.
|
|
511
|
+
# @return [RuboCop::AST::Node]
|
|
512
|
+
def find_first_body_node(class_node, first_elem)
|
|
513
|
+
first_elem_pos = first_elem[:node].source_range.begin_pos
|
|
514
|
+
|
|
515
|
+
process_body_nodes(class_node.body).find do |child|
|
|
516
|
+
child.source_range.begin_pos >= first_elem_pos || visibility_modifier?(child)
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Sort elements by priority, sort key, and original index.
|
|
521
|
+
#
|
|
522
|
+
# @param [Array<Hash>] elements The list of elements.
|
|
523
|
+
# @return [Array<Hash>]
|
|
524
|
+
def sort_elements(elements)
|
|
525
|
+
elements.sort_by { |e| [e[:priority], e[:sort_key], e[:original_index]] }
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Build replacement source for sorted elements.
|
|
529
|
+
#
|
|
530
|
+
# @param [Array<Hash>] sorted The sorted elements.
|
|
531
|
+
# @param [Integer] base_column The base indentation column.
|
|
532
|
+
# @return [String]
|
|
533
|
+
def build_replacement(sorted, base_column)
|
|
534
|
+
state = { parts: [], visibility: :public, category: nil, column: base_column }
|
|
535
|
+
|
|
536
|
+
sorted.each { |element| process_element(element, state) }
|
|
537
|
+
|
|
538
|
+
state[:parts].join.chomp
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Calculate base indentation column from the first element.
|
|
542
|
+
#
|
|
543
|
+
# @param [Array<Hash>] elements The list of elements.
|
|
544
|
+
# @return [Integer] The base indentation column.
|
|
545
|
+
def calculate_base_indent(elements)
|
|
546
|
+
first_elem = elements.min_by { |e| e[:node].source_range.begin_pos }
|
|
547
|
+
first_elem[:node].source_range.column
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Render source lines with normalized indentation.
|
|
551
|
+
#
|
|
552
|
+
# @param [Array<Hash>] source_lines Lines with :text and :column.
|
|
553
|
+
# @param [Integer] base_column The target indentation column.
|
|
554
|
+
# @return [String] The rendered source.
|
|
555
|
+
def render_source(source_lines, base_column)
|
|
556
|
+
min_column = source_lines.map { |l| l[:column] }.min
|
|
557
|
+
indent = " " * base_column
|
|
558
|
+
|
|
559
|
+
source_lines.map do |line|
|
|
560
|
+
relative_indent = " " * [0, line[:column] - min_column].max
|
|
561
|
+
"#{indent}#{relative_indent}#{line[:text].lstrip}"
|
|
562
|
+
end.join("\n")
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Process element for replacement.
|
|
566
|
+
#
|
|
567
|
+
# @param [Hash] element The element.
|
|
568
|
+
# @param [Hash] state The state hash.
|
|
569
|
+
# @return [void]
|
|
570
|
+
def process_element(element, state)
|
|
571
|
+
add_visibility_modifier(state, element[:visibility])
|
|
572
|
+
state[:visibility] = element[:visibility]
|
|
573
|
+
|
|
574
|
+
add_category_separator(state[:parts], element[:category], state[:category])
|
|
575
|
+
state[:category] = element[:category]
|
|
576
|
+
|
|
577
|
+
rendered_source = render_source(element[:source], state[:column])
|
|
578
|
+
state[:parts] << rendered_source << "\n"
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Add visibility modifier if needed.
|
|
582
|
+
#
|
|
583
|
+
# @param [Hash] state The state hash.
|
|
584
|
+
# @param [Symbol] new_visibility The new visibility.
|
|
585
|
+
# @return [void]
|
|
586
|
+
def add_visibility_modifier(state, new_visibility)
|
|
587
|
+
return if new_visibility == state[:visibility]
|
|
588
|
+
|
|
589
|
+
indent = " " * state[:column]
|
|
590
|
+
state[:parts] << "\n" if state[:parts].any?
|
|
591
|
+
state[:parts] << "#{indent}#{new_visibility}\n"
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Add blank line between elements.
|
|
595
|
+
#
|
|
596
|
+
# Adds a blank line before elements (except the first one) to maintain
|
|
597
|
+
# readable spacing between class members.
|
|
598
|
+
#
|
|
599
|
+
# @param [Array<String>] parts The parts array.
|
|
600
|
+
# @param [Symbol] _category The current category (unused but kept for API).
|
|
601
|
+
# @param [Symbol] last_category The last category.
|
|
602
|
+
# @return [void]
|
|
603
|
+
def add_category_separator(parts, _category, last_category)
|
|
604
|
+
parts << "\n" if last_category
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
end
|