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.
@@ -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