rubocop-sane 0.3.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd899cb79b69d5e80e9712f16cff748e59565061964ee199ee948413acadb4ae
4
- data.tar.gz: d5199b3b74466a2ad62c7fc0b6c15633e3c07b3e4aa67a9465b65293c51917f3
3
+ metadata.gz: b17fd1e8de6294cc02871537ea6ab1392304756cfca1fe3357374457df1c05aa
4
+ data.tar.gz: 4d7c5d93b4a375b1b14860049227be3a253e57a7c349d8b2be2510d705f5cf71
5
5
  SHA512:
6
- metadata.gz: f0a243dbd2dab34f2907cf9dd18abf84226ff2cc70992ca7a84faa04a700c8be0c307b07767a7df073a5d52a05d4d21f3aa4868b9acb06c464d84c05a4723a46
7
- data.tar.gz: 403df07bdb9611e535edf8aa868c84bd80c7c62c643380d7e9f1dfc0d2c4a102969c83a2f42ee0f7f7b0cf5f45ec235850de31a2e9cac3a03e91006aaa9e06c1
6
+ metadata.gz: efec9c94cadc379f66a8bcbc71794baf1436a42cc6904efe10646ebe396838974a967f5d48e45027c484ea2b8c6236bc894d0dd9e384e54be945e5ea990b9394
7
+ data.tar.gz: c07fddd9d2a78f16beaf02ab62dc0ea1d493bdc5fd2f901a6909b600b99311400c372a5a33011a7979c88681cdcf8a0d13e0aae7d4d751b3e68b47e857b0b36d
data/.rubocop.yml CHANGED
@@ -62,7 +62,7 @@ Naming/PredicatePrefix:
62
62
  Enabled: false
63
63
 
64
64
  RSpec/ExampleLength:
65
- Max: 20
65
+ Max: 30
66
66
 
67
67
  RSpec/MultipleDescribes:
68
68
  Enabled: false
data/config/default.yml CHANGED
@@ -2,37 +2,97 @@ Sane:
2
2
  Enabled: true
3
3
  DocumentationBaseURL: https://github.com/akodkod/rubocop-sane
4
4
 
5
+ Sane/ProhibitedComments:
6
+ Description: "Detects comments starting with configurable prohibited words"
7
+ Enabled: true
8
+ VersionAdded: "1.0.0"
9
+ ProhibitedWords:
10
+ - DELETE
11
+ - delete
12
+ - REMEMBER
13
+ - remember
14
+
5
15
  Sane/DisallowMethods:
6
- Description: "Enforces method replacements and prohibitions."
16
+ Description: "Enforces method replacements and prohibitions"
7
17
  Enabled: true
8
- VersionAdded: "0.1.0"
18
+ VersionAdded: "1.0.0"
9
19
  SafeAutoCorrect: false
10
20
  ReplaceMethods: {}
11
21
  ProhibitedMethods: {}
12
22
 
13
23
  Sane/ConditionalAssignmentAllowTernary:
14
- Description: "Enforces assignment inside conditions, but allows ternary operators."
24
+ Description: "Enforces assignment inside conditions, but allows ternary operators"
15
25
  Enabled: true
16
- VersionAdded: "0.1.0"
26
+ VersionAdded: "1.0.0"
17
27
 
18
28
  Sane/EmptyLineBeforeComment:
19
- Description: "Enforces empty line before comments, except after block starts."
29
+ Description: "Enforces empty line before comments, except after block starts"
30
+ Enabled: true
31
+ VersionAdded: "1.0.0"
32
+ SafeAutoCorrect: true
33
+
34
+ Sane/EmptyLinesAroundConditionBody:
35
+ Description: "Disallows empty lines at the beginning and end of condition bodies"
20
36
  Enabled: true
21
- VersionAdded: "0.1.0"
37
+ VersionAdded: "1.0.0"
22
38
  SafeAutoCorrect: true
23
39
 
24
40
  Sane/EmptyLinesAroundMultilineBlock:
25
- Description: "Enforces empty lines before and after multiline blocks."
41
+ Description: "Enforces empty lines before and after multiline blocks"
42
+ Enabled: true
43
+ VersionAdded: "1.0.0"
44
+ SafeAutoCorrect: true
45
+
46
+ Sane/EmptyLinesAroundMultilineCall:
47
+ Description: "(Experimental) Enforces empty lines before and after multiline method calls"
48
+ Enabled: false
49
+ VersionAdded: "1.0.0"
50
+ SafeAutoCorrect: true
51
+ SkipMethods:
52
+ - belongs_to
53
+ - has_one
54
+ - has_many
55
+
56
+ Sane/MultilineChainOnSeparateLines:
57
+ Description: "(Experimental) Enforces that each method in a multiline chain starts on its own line with a leading dot"
58
+ Enabled: false
59
+ VersionAdded: "1.0.0"
60
+ SafeAutoCorrect: false
61
+
62
+ Sane/OmitParentheses:
63
+ Description: "Enforces omitting parentheses for specific methods"
26
64
  Enabled: true
27
- VersionAdded: "0.1.0"
65
+ VersionAdded: "1.0.0"
28
66
  SafeAutoCorrect: true
67
+ Methods: []
29
68
 
30
69
  Sane/NoMethodCallAfterEnd:
31
- Description: "Prohibits calling methods directly after `end`."
70
+ Description: "Prohibits calling methods directly after `end`"
32
71
  Enabled: true
33
- VersionAdded: "0.1.0"
72
+ VersionAdded: "1.0.0"
34
73
 
35
74
  Sane/OutdatedComments:
36
- Description: "Detects TODO/NOTE/FIXME comments with dates in the past."
75
+ Description: "Detects TODO/NOTE/FIXME comments with dates in the past"
37
76
  Enabled: true
38
- VersionAdded: "0.2.0"
77
+ VersionAdded: "1.0.0"
78
+
79
+ Sane/RedundantSelfAssignment:
80
+ Description: "Detects redundant assignment within `||=` to the same target"
81
+ Enabled: true
82
+ VersionAdded: "1.0.0"
83
+ SafeAutoCorrect: true
84
+
85
+ Sane/VariableNameLength:
86
+ Description: "Enforces minimum variable name length"
87
+ Enabled: false
88
+ VersionAdded: "1.0.0"
89
+ MinLength: 2
90
+ AllowedNames:
91
+ - i
92
+ - j
93
+ - k
94
+ - n
95
+ - e
96
+ - x
97
+ - y
98
+ - _
@@ -95,7 +95,7 @@ module RuboCop
95
95
  prev_line_number = comment.loc.line - 1
96
96
  return false if prev_line_number < 1
97
97
 
98
- processed_source.comments.any? { |c| c.loc.line == prev_line_number }
98
+ processed_source.comments.any? { |c| c.loc.line == prev_line_number && !inline_comment?(c) }
99
99
  end
100
100
 
101
101
  def preceded_by_block_start?(comment)
@@ -105,9 +105,19 @@ module RuboCop
105
105
  prev_line = processed_source.lines[prev_line_number - 1]
106
106
  return false unless prev_line
107
107
 
108
+ # Strip inline comments from the previous line before checking
109
+ prev_line = strip_inline_comment(prev_line, prev_line_number)
110
+
108
111
  block_start_pattern?(prev_line)
109
112
  end
110
113
 
114
+ def strip_inline_comment(line, line_number)
115
+ inline = processed_source.comments.find { |c| c.loc.line == line_number && c.loc.column > 0 }
116
+ return line unless inline
117
+
118
+ line[0...inline.loc.column].rstrip
119
+ end
120
+
111
121
  def block_start_pattern?(line)
112
122
  stripped = line.strip
113
123
 
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sane
6
+ # This cop checks for empty lines at the beginning and end of
7
+ # condition bodies (`if`, `elsif`, `else`, `unless`).
8
+ #
9
+ # @example
10
+ # # bad
11
+ # if condition
12
+ #
13
+ # body
14
+ #
15
+ # else
16
+ #
17
+ # another_body
18
+ #
19
+ # end
20
+ #
21
+ # # good
22
+ # if condition
23
+ # body
24
+ # else
25
+ # another_body
26
+ # end
27
+ #
28
+ class EmptyLinesAroundConditionBody < Base
29
+ extend AutoCorrector
30
+
31
+ MSG_BEGINNING = "Remove empty line at the beginning of condition body"
32
+ MSG_END = "Remove empty line at the end of condition body"
33
+
34
+ def on_if(node)
35
+ return if node.ternary?
36
+ return if node.modifier_form?
37
+
38
+ check_if_branch(node)
39
+ check_else_branch(node)
40
+ end
41
+
42
+ private
43
+
44
+ def check_if_branch(node)
45
+ body = node.if_branch
46
+ return unless body
47
+
48
+ keyword_line = node.loc.keyword.line
49
+ closing_line = end_line_for_if_branch(node)
50
+
51
+ check_beginning(body, keyword_line)
52
+ check_end(body, closing_line, closing_range_for_if_branch(node))
53
+ end
54
+
55
+ def check_else_branch(node)
56
+ else_branch = node.else_branch
57
+ return unless else_branch
58
+ return unless node.loc.else
59
+
60
+ # elsif is handled by its own on_if call
61
+ return if else_branch.if_type? && else_branch.elsif?
62
+
63
+ else_line = node.loc.else.line
64
+ end_node = find_end_node(node)
65
+ end_line = end_node.loc.end.line
66
+
67
+ check_beginning(else_branch, else_line)
68
+ check_end(else_branch, end_line, end_node.loc.end)
69
+ end
70
+
71
+ def end_line_for_if_branch(node)
72
+ if node.else_branch
73
+ if node.loc.else
74
+ node.loc.else.line
75
+ else
76
+ node.else_branch.loc.keyword.line
77
+ end
78
+ else
79
+ find_end_node(node).loc.end.line
80
+ end
81
+ end
82
+
83
+ def closing_range_for_if_branch(node)
84
+ if node.else_branch
85
+ node.loc.else || node.else_branch.loc.keyword
86
+ else
87
+ find_end_node(node).loc.end
88
+ end
89
+ end
90
+
91
+ def find_end_node(node)
92
+ current = node
93
+ current = current.parent while current.parent&.if_type? && current.elsif?
94
+ current
95
+ end
96
+
97
+ def check_beginning(body, keyword_line)
98
+ first_line = effective_first_line(keyword_line, body.loc.first_line)
99
+ return unless first_line > keyword_line + 1
100
+ return unless blank_lines_between?(keyword_line, first_line)
101
+
102
+ range = first_body_expression(body).source_range
103
+ add_offense(range, message: MSG_BEGINNING) do |corrector|
104
+ remove_blank_lines(corrector, keyword_line, first_line)
105
+ end
106
+ end
107
+
108
+ def check_end(body, closing_line, closing_range)
109
+ last_line = effective_last_line(body.loc.last_line, closing_line)
110
+ return unless closing_line > last_line + 1
111
+ return unless blank_lines_between?(last_line, closing_line)
112
+
113
+ add_offense(closing_range, message: MSG_END) do |corrector|
114
+ remove_blank_lines(corrector, last_line, closing_line)
115
+ end
116
+ end
117
+
118
+ def first_body_expression(body)
119
+ body.begin_type? ? body.children.first : body
120
+ end
121
+
122
+ def effective_first_line(keyword_line, body_first_line)
123
+ comments = comments_between(keyword_line, body_first_line)
124
+ comment = comments.min_by { |c| c.loc.line }
125
+ comment ? comment.loc.line : body_first_line
126
+ end
127
+
128
+ def effective_last_line(body_last_line, closing_line)
129
+ comments = comments_between(body_last_line, closing_line)
130
+ comment = comments.max_by { |c| c.loc.line }
131
+ comment ? comment.loc.line : body_last_line
132
+ end
133
+
134
+ def comments_between(start_line, end_line)
135
+ processed_source.comments.select do |c|
136
+ c.loc.line > start_line && c.loc.line < end_line && standalone_comment?(c)
137
+ end
138
+ end
139
+
140
+ def standalone_comment?(comment)
141
+ source_line = processed_source.lines[comment.loc.line - 1]
142
+ source_line.strip.start_with?("#")
143
+ end
144
+
145
+ def blank_lines_between?(start_line, end_line)
146
+ ((start_line + 1)...end_line).any? { |line_num| blank_line?(line_num) }
147
+ end
148
+
149
+ def blank_line?(line_num)
150
+ processed_source.lines[line_num - 1].strip.empty?
151
+ end
152
+
153
+ def remove_blank_lines(corrector, start_line, end_line)
154
+ buffer = processed_source.buffer
155
+
156
+ ((start_line + 1)...end_line).each do |line_num|
157
+ next unless blank_line?(line_num)
158
+
159
+ line_begin = buffer.line_range(line_num).begin_pos
160
+ next_line_begin =
161
+ if line_num < processed_source.lines.size
162
+ buffer.line_range(line_num + 1).begin_pos
163
+ else
164
+ buffer.line_range(line_num).end_pos
165
+ end
166
+
167
+ corrector.remove(Parser::Source::Range.new(buffer, line_begin, next_line_begin))
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -4,7 +4,10 @@ module RuboCop
4
4
  module Cop
5
5
  module Sane
6
6
  # This cop enforces empty lines before and after multiline blocks
7
- # such as `if/else` and `case/when`.
7
+ # such as `if/else`, `case/when`, and `begin...rescue...end`.
8
+ #
9
+ # Sorbet `sig` blocks require a blank line before, but not after,
10
+ # since they should be tightly coupled to their method definitions.
8
11
  #
9
12
  # @example
10
13
  # # bad
@@ -49,6 +52,33 @@ module RuboCop
49
52
  # end
50
53
  # end
51
54
  #
55
+ # # good - Sorbet sig blocks don't need blank line after
56
+ # something
57
+ #
58
+ # sig do
59
+ # params(x: Integer).returns(String)
60
+ # end
61
+ # def foo(x)
62
+ # x.to_s
63
+ # end
64
+ #
65
+ # # bad - missing blank line before begin
66
+ # temp_file = Tempfile.new(["import", ".csv"])
67
+ # begin
68
+ # temp_file.write(data)
69
+ # rescue IOError
70
+ # handle_error
71
+ # end
72
+ #
73
+ # # good - blank line before begin
74
+ # temp_file = Tempfile.new(["import", ".csv"])
75
+ #
76
+ # begin
77
+ # temp_file.write(data)
78
+ # rescue IOError
79
+ # handle_error
80
+ # end
81
+ #
52
82
  class EmptyLinesAroundMultilineBlock < Base
53
83
  extend AutoCorrector
54
84
 
@@ -97,13 +127,37 @@ module RuboCop
97
127
 
98
128
  alias on_case_match on_case
99
129
 
130
+ def on_kwbegin(node)
131
+ return unless multiline?(node)
132
+
133
+ assignment_parent = find_assignment_parent(node)
134
+ if assignment_parent
135
+ check_empty_line_before_assignment(node, assignment_parent)
136
+ check_empty_line_after_assignment(node, assignment_parent)
137
+ else
138
+ check_empty_line_before(node)
139
+ check_empty_line_after(node)
140
+ end
141
+ end
142
+
100
143
  def on_block(node)
101
144
  return unless multiline?(node)
102
- return if chained_block?(node) # e.g., expect { ... }.to raise_error
145
+ return if method_chain_block?(node) # e.g., expect { ... }.to raise_error
103
146
  return if lambda_block?(node) # e.g., -> { ... } or -> do ... end
104
-
105
- check_empty_line_before(node)
106
- check_empty_line_after(node)
147
+ return if block_in_collection?(node) # e.g., [items.map do...end] or {key: items.map do...end}
148
+
149
+ assignment_parent = find_assignment_parent(node)
150
+ if assignment_parent
151
+ # Don't require blank line before assignment
152
+ # But do check for blank line after the assignment
153
+ check_empty_line_after_assignment(node, assignment_parent)
154
+ elsif sig_block?(node)
155
+ # Sorbet sig blocks: require blank line before, but not after
156
+ check_empty_line_before(node)
157
+ else
158
+ check_empty_line_before(node)
159
+ check_empty_line_after(node)
160
+ end
107
161
  end
108
162
 
109
163
  alias on_numblock on_block
@@ -112,37 +166,76 @@ module RuboCop
112
166
  node.send_node&.lambda_literal?
113
167
  end
114
168
 
115
- def chained_block?(node)
169
+ def sig_block?(node)
170
+ # Sorbet signature blocks: sig { ... } or sig do ... end
171
+ node.send_node&.method_name == :sig && node.send_node&.receiver.nil?
172
+ end
173
+
174
+ def method_chain_block?(node)
116
175
  # Skip blocks that are part of a method chain
117
176
  # e.g., expect { ... }.to raise_error
118
177
  # e.g., foo.map { ... }&.join (csend is safe navigation &.)
119
178
  parent = node.parent
120
- return true if (parent&.send_type? || parent&.csend_type?) && parent.receiver == node
179
+ (parent&.send_type? || parent&.csend_type?) && parent.receiver == node
180
+ end
121
181
 
122
- # Skip blocks that are part of an assignment expression
123
- # e.g., self.foo = items.map do ... end
124
- return true if part_of_assignment?(node)
182
+ def block_in_collection?(node)
183
+ # Skip blocks that are in arrays or hashes (enclosed in a structure)
184
+ parent = node.parent
185
+ return false unless parent
125
186
 
126
- false
187
+ parent.array_type? || parent.pair_type?
127
188
  end
128
189
 
129
- def part_of_assignment?(node)
190
+ def find_assignment_parent(node)
130
191
  parent = node.parent
131
- return false unless parent
192
+ return nil unless parent
132
193
 
133
194
  # Direct assignment: foo = bar.map do...end
134
- return true if assignment_node?(parent)
195
+ return parent if assignment_node?(parent)
135
196
 
136
197
  # Assignment via setter: self.foo = bar.map do...end
137
- return true if parent.send_type? && parent.method_name.to_s.end_with?("=")
198
+ return parent if parent.send_type? && parent.method_name.to_s.end_with?("=")
138
199
 
139
- # Hash value: { key: items.map do...end }
140
- return true if parent.pair_type?
200
+ nil
201
+ end
141
202
 
142
- # Array element: [items.map do...end]
143
- return true if parent.array_type?
203
+ def check_empty_line_before_assignment(block_node, assignment_node)
204
+ return if first_child_of_parent?(assignment_node)
144
205
 
145
- false
206
+ prev_sib = previous_sibling(assignment_node)
207
+ return unless prev_sib
208
+ return if prev_sib.is_a?(Symbol)
209
+ return if empty_line_between?(prev_sib, assignment_node)
210
+ return if comment_line_before?(assignment_node)
211
+
212
+ keyword = block_keyword(block_node)
213
+
214
+ add_offense(block_node, message: format(MSG_BEFORE, keyword: keyword)) do |corrector|
215
+ corrector.insert_before(
216
+ assignment_node.source_range.with(
217
+ begin_pos: assignment_node.source_range.begin_pos - assignment_node.loc.column,
218
+ ),
219
+ "\n",
220
+ )
221
+ end
222
+ end
223
+
224
+ def check_empty_line_after_assignment(block_node, assignment_node)
225
+ return if last_child_of_parent?(assignment_node)
226
+ return if followed_by_rescue?(assignment_node)
227
+
228
+ next_sib = next_sibling(assignment_node)
229
+ return unless next_sib
230
+ return if next_sib.is_a?(Symbol)
231
+ return if empty_line_between?(assignment_node, next_sib)
232
+ return if rubocop_directive_after?(block_node)
233
+
234
+ keyword = block_keyword(block_node)
235
+
236
+ add_offense(block_node.loc.end, message: format(MSG_AFTER, keyword: keyword)) do |corrector|
237
+ corrector.insert_after(block_node, "\n")
238
+ end
146
239
  end
147
240
 
148
241
  def assignment_node?(node)
@@ -196,6 +289,8 @@ module RuboCop
196
289
  node.keyword
197
290
  elsif node.case_type? || node.case_match_type?
198
291
  "case"
292
+ elsif node.kwbegin_type?
293
+ "begin"
199
294
  elsif node.block_type? || node.numblock_type?
200
295
  "do...end"
201
296
  else
@@ -234,7 +329,7 @@ module RuboCop
234
329
  return unless next_sibling
235
330
  return if next_sibling.is_a?(Symbol) # Skip non-node siblings
236
331
  return if empty_line_between?(node, next_sibling)
237
- return if comment_line_after?(node)
332
+ return if rubocop_directive_after?(node)
238
333
 
239
334
  keyword = block_keyword(node)
240
335
 
@@ -271,6 +366,9 @@ module RuboCop
271
366
  return true if parent.type == :def && parent.body == node
272
367
  return true if parent.type == :defs && parent.body == node
273
368
  return true if parent.type == :resbody && parent.body == node
369
+ return true if parent.type == :class && parent.body == node
370
+ return true if parent.type == :module && parent.body == node
371
+ return true if parent.type == :sclass && parent.body == node
274
372
 
275
373
  # For if/unless, check if node is the only thing in the if-branch or else-branch
276
374
  if parent.type == :if
@@ -282,6 +380,9 @@ module RuboCop
282
380
  return true if parent.type == :when && parent.body == node
283
381
  return true if parent.type == :case && parent.else_branch == node
284
382
 
383
+ # For ensure: child[1] is the ensure clause body
384
+ return true if parent.type == :ensure && parent.children[1] == node
385
+
285
386
  false
286
387
  end
287
388
 
@@ -336,20 +437,30 @@ module RuboCop
336
437
  line1 = node1.loc.last_line
337
438
  line2 = node2.loc.first_line
338
439
 
339
- # There's an empty line if there's more than 1 line between them
340
- (line2 - line1) > 1
440
+ # Check that at least one line between the nodes is truly blank
441
+ ((line1 + 1)...line2).any? { |line_num| processed_source.lines[line_num - 1].strip.empty? }
341
442
  end
342
443
 
343
444
  def comment_line_before?(node)
344
445
  line_before = node.loc.first_line - 1
345
446
  return false if line_before < 1
346
447
 
347
- processed_source.comments.any? { |c| c.loc.line == line_before }
448
+ processed_source.comments.any? { |c| c.loc.line == line_before && standalone_comment?(c) }
348
449
  end
349
450
 
350
- def comment_line_after?(node)
451
+ def rubocop_directive_after?(node)
351
452
  line_after = node.loc.last_line + 1
352
- processed_source.comments.any? { |c| c.loc.line == line_after }
453
+ processed_source.comments.any? { |c| c.loc.line == line_after && rubocop_directive?(c) }
454
+ end
455
+
456
+ def rubocop_directive?(comment)
457
+ comment.text.start_with?("# rubocop:")
458
+ end
459
+
460
+ def standalone_comment?(comment)
461
+ # A standalone comment is one that occupies its own line (not inline after code)
462
+ source_line = processed_source.lines[comment.loc.line - 1]
463
+ source_line.strip.start_with?("#")
353
464
  end
354
465
  end
355
466
  end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sane
6
+ # Enforces empty lines before and after multiline method calls,
7
+ # including calls with multiline arguments and multiline method chains.
8
+ #
9
+ class EmptyLinesAroundMultilineCall < Base # rubocop:disable Metrics/ClassLength
10
+ extend AutoCorrector
11
+
12
+ MSG_BEFORE = "Add empty line before multiline method call."
13
+ MSG_AFTER = "Add empty line after multiline method call."
14
+
15
+ ASSIGNMENT_TYPES = [:lvasgn, :ivasgn, :cvasgn, :gvasgn, :casgn, :masgn, :op_asgn, :or_asgn, :and_asgn].freeze
16
+ BODY_PARENT_TYPES = [:block, :numblock, :def, :defs, :resbody, :class, :module, :sclass].freeze
17
+ CONTROL_FLOW_TYPES = [:return, :break, :next].freeze
18
+
19
+ def on_send(node)
20
+ check_call(node)
21
+ end
22
+
23
+ alias on_csend on_send
24
+
25
+ private
26
+
27
+ def check_call(node)
28
+ return unless node.loc.first_line != node.loc.last_line
29
+ return if excluded_call?(node)
30
+
31
+ target = effective_target(node)
32
+ check_empty_line_before(target)
33
+ check_empty_line_after(node, target)
34
+ end
35
+
36
+ def excluded_call?(node)
37
+ has_block?(node) || inner_chain_call?(node) || call_as_argument?(node) ||
38
+ inside_collection?(node) || setter_call?(node) || inside_control_flow?(node) || skip_method?(node)
39
+ end
40
+
41
+ def has_block?(node)
42
+ parent = node.parent
43
+ (parent&.block_type? || parent&.numblock_type?) && node == parent.send_node
44
+ end
45
+
46
+ def inner_chain_call?(node)
47
+ parent = node.parent
48
+ (parent&.send_type? || parent&.csend_type?) && node == parent.receiver
49
+ end
50
+
51
+ def call_as_argument?(node)
52
+ parent = node.parent
53
+ return false unless parent&.send_type? || parent&.csend_type?
54
+
55
+ parent.arguments.include?(node)
56
+ end
57
+
58
+ def inside_collection?(node)
59
+ parent = node.parent
60
+ parent && (parent.array_type? || parent.pair_type?)
61
+ end
62
+
63
+ def setter_call?(node)
64
+ node.method_name.to_s.end_with?("=")
65
+ end
66
+
67
+ def inside_control_flow?(node)
68
+ parent = node.parent
69
+ parent && CONTROL_FLOW_TYPES.include?(parent.type)
70
+ end
71
+
72
+ def skip_method?(node)
73
+ cop_config.fetch("SkipMethods", []).include?(node.method_name.to_s)
74
+ end
75
+
76
+ def preceded_by_sig?(node)
77
+ prev_sib = previous_sibling(node)
78
+ return false unless prev_sib.is_a?(RuboCop::AST::Node)
79
+
80
+ (prev_sib.block_type? || prev_sib.numblock_type?) &&
81
+ prev_sib.send_node&.method_name == :sig &&
82
+ prev_sib.send_node&.receiver.nil?
83
+ end
84
+
85
+ def effective_target(node)
86
+ parent = node.parent
87
+ parent && ASSIGNMENT_TYPES.include?(parent.type) ? parent : node
88
+ end
89
+
90
+ def check_empty_line_before(node)
91
+ return if first_child_of_parent?(node)
92
+ return if preceded_by_sig?(node)
93
+
94
+ prev_sib = previous_sibling(node)
95
+ return if prev_sib.nil? || prev_sib.is_a?(Symbol)
96
+ return if empty_line_between?(prev_sib, node)
97
+ return if comment_line_before?(node)
98
+
99
+ add_offense(node, message: MSG_BEFORE) do |corrector|
100
+ corrector.insert_before(
101
+ node.source_range.with(begin_pos: node.source_range.begin_pos - node.loc.column),
102
+ "\n",
103
+ )
104
+ end
105
+ end
106
+
107
+ def check_empty_line_after(call_node, target)
108
+ return if last_child_of_parent?(target) || followed_by_rescue?(target)
109
+
110
+ next_sib = next_sibling(target)
111
+ return if next_sib.nil? || next_sib.is_a?(Symbol)
112
+ return if empty_line_between?(target, next_sib)
113
+ return if rubocop_directive_after?(call_node)
114
+
115
+ add_offense(after_offense_loc(call_node), message: MSG_AFTER) do |corrector|
116
+ corrector.insert_after(target, "\n")
117
+ end
118
+ end
119
+
120
+ def after_offense_loc(node)
121
+ if node.loc.respond_to?(:end) && node.loc.end
122
+ node.loc.end
123
+ elsif node.loc.respond_to?(:selector) && node.loc.selector
124
+ node.loc.selector
125
+ else
126
+ node
127
+ end
128
+ end
129
+
130
+ def first_child_of_parent?(node)
131
+ parent = node.parent
132
+ return true unless parent
133
+ return true if only_child_in_body?(node, parent)
134
+
135
+ body_siblings(parent).first == node
136
+ end
137
+
138
+ def last_child_of_parent?(node)
139
+ parent = node.parent
140
+ return true unless parent
141
+ return true if only_child_in_body?(node, parent)
142
+
143
+ body_siblings(parent).last == node
144
+ end
145
+
146
+ def only_child_in_body?(node, parent)
147
+ return true if BODY_PARENT_TYPES.include?(parent.type) && parent.body == node
148
+
149
+ return true if (parent.type == :if) && (parent.if_branch == node || parent.else_branch == node)
150
+
151
+ return true if parent.type == :when && parent.body == node
152
+ return true if parent.type == :case && parent.else_branch == node
153
+
154
+ false
155
+ end
156
+
157
+ def body_siblings(parent)
158
+ parent.children.select { |c| c.is_a?(RuboCop::AST::Node) && c.loc&.expression }
159
+ end
160
+
161
+ def previous_sibling(node)
162
+ parent = node.parent
163
+ return unless parent
164
+
165
+ siblings = parent.children
166
+ index = siblings.index(node)
167
+ return unless index&.positive?
168
+
169
+ (index - 1).downto(0) do |i|
170
+ sib = siblings[i]
171
+ return sib if sib.is_a?(RuboCop::AST::Node) && sib.loc&.expression
172
+ end
173
+ nil
174
+ end
175
+
176
+ def next_sibling(node)
177
+ parent = node.parent
178
+ return unless parent
179
+
180
+ siblings = parent.children
181
+ index = siblings.index(node)
182
+ return unless index
183
+
184
+ ((index + 1)...siblings.size).each do |i|
185
+ sib = siblings[i]
186
+ return sib if sib.is_a?(RuboCop::AST::Node) && sib.loc&.expression
187
+ end
188
+ nil
189
+ end
190
+
191
+ def empty_line_between?(node1, node2)
192
+ return true unless node1.loc&.expression && node2.loc&.expression
193
+
194
+ line1 = node1.loc.last_line
195
+ line2 = node2.loc.first_line
196
+ ((line1 + 1)...line2).any? { |ln| processed_source.lines[ln - 1].strip.empty? }
197
+ end
198
+
199
+ def comment_line_before?(node)
200
+ line_before = node.loc.first_line - 1
201
+ return false if line_before < 1
202
+
203
+ processed_source.comments.any? { |c| c.loc.line == line_before && standalone_comment?(c) }
204
+ end
205
+
206
+ def rubocop_directive_after?(node)
207
+ line_after = node.loc.last_line + 1
208
+ processed_source.comments.any? { |c| c.loc.line == line_after && c.text.start_with?("# rubocop:") }
209
+ end
210
+
211
+ def standalone_comment?(comment)
212
+ processed_source.lines[comment.loc.line - 1].strip.start_with?("#")
213
+ end
214
+
215
+ def followed_by_rescue?(node)
216
+ parent = node.parent
217
+ return false unless parent&.rescue_type?
218
+
219
+ siblings = parent.children
220
+ index = siblings.index(node)
221
+ return false unless index
222
+
223
+ siblings[index + 1]&.resbody_type?
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sane
6
+ # Enforces that each method in a multiline chain starts on its own line
7
+ # with a leading dot.
8
+ #
9
+ # @example
10
+ # # bad
11
+ # browser.find(
12
+ # "div[role='menu'] .v-list-item",
13
+ # text: "Confirmation Sheet",
14
+ # wait: 10,
15
+ # ).click
16
+ #
17
+ # # good
18
+ # browser
19
+ # .find(
20
+ # "div[role='menu'] .v-list-item",
21
+ # text: "Confirmation Sheet",
22
+ # wait: 10,
23
+ # )
24
+ # .click
25
+ #
26
+ class MultilineChainOnSeparateLines < Base
27
+ extend AutoCorrector
28
+
29
+ MSG = "Place each method call in a multiline chain on a separate line with a leading dot."
30
+
31
+ def on_send(node)
32
+ check_chain(node)
33
+ end
34
+
35
+ alias on_csend on_send
36
+
37
+ private
38
+
39
+ def check_chain(node)
40
+ return unless node.loc.dot
41
+ return unless node.receiver
42
+
43
+ receiver = node.receiver
44
+ return unless chained_call?(receiver)
45
+
46
+ return unless dot_on_receiver_last_line?(node, receiver)
47
+ return unless multiline?(receiver) || (multiline?(node) && used_as_chain_receiver?(node))
48
+
49
+ register_offense(node)
50
+ end
51
+
52
+ def chained_call?(receiver)
53
+ receiver.send_type? || receiver.csend_type?
54
+ end
55
+
56
+ def multiline?(node)
57
+ node.loc.first_line != node.loc.last_line
58
+ end
59
+
60
+ def dot_on_receiver_last_line?(node, receiver)
61
+ node.loc.dot.line == receiver.loc.last_line
62
+ end
63
+
64
+ def used_as_chain_receiver?(node)
65
+ parent = node.parent
66
+ (parent&.send_type? || parent&.csend_type?) && parent.receiver == node
67
+ end
68
+
69
+ def register_offense(node)
70
+ add_offense(node.loc.dot) do |corrector|
71
+ indent = chain_root_column(node) + indentation_width
72
+ range = node.loc.dot.with(begin_pos: node.receiver.source_range.end_pos)
73
+ dot_text = node.loc.dot.source
74
+ corrector.replace(range, "\n#{' ' * indent}#{dot_text}")
75
+ end
76
+ end
77
+
78
+ def chain_root_column(node)
79
+ current = node
80
+ while (recv = current.receiver) && (recv.send_type? || recv.csend_type?)
81
+ current = recv
82
+ end
83
+ current.receiver ? current.receiver.loc.column : current.loc.column
84
+ end
85
+
86
+ def indentation_width
87
+ config.for_cop("Layout/IndentationWidth")["Width"] || 2
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sane
6
+ # Enforces omitting parentheses for specific methods.
7
+ #
8
+ # @example Methods configuration
9
+ # # .rubocop.yml
10
+ # # Sane/OmitParentheses:
11
+ # # Methods:
12
+ # # - render_record
13
+ # # - Log.info
14
+ #
15
+ # # bad
16
+ # render_record(record)
17
+ # Log.info("message")
18
+ #
19
+ # # good
20
+ # render_record record
21
+ # Log.info "message"
22
+ #
23
+ class OmitParentheses < Base
24
+ extend AutoCorrector
25
+
26
+ MSG = "Omit parentheses for `%<method>s`."
27
+
28
+ def on_send(node)
29
+ return unless node.parenthesized?
30
+ return if node.multiline?
31
+ return if modifier_condition?(node)
32
+
33
+ matched_method = find_matching_method(node)
34
+ return unless matched_method
35
+
36
+ message = format(MSG, method: matched_method)
37
+
38
+ add_offense(node, message: message) do |corrector|
39
+ if node.arguments.empty?
40
+ corrector.remove(node.loc.begin)
41
+ else
42
+ corrector.replace(node.loc.begin, " ")
43
+ end
44
+
45
+ corrector.remove(node.loc.end)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def find_matching_method(node)
52
+ methods.find { |method| matches?(node, method) }
53
+ end
54
+
55
+ def matches?(node, method_config)
56
+ if method_config.include?(".")
57
+ receiver_name, method_name = method_config.split(".", 2)
58
+
59
+ node.receiver&.const_type? &&
60
+ node.receiver.short_name.to_s == receiver_name &&
61
+ node.method_name.to_s == method_name
62
+ else
63
+ node.method_name.to_s == method_config
64
+ end
65
+ end
66
+
67
+ def modifier_condition?(node)
68
+ parent = node.parent
69
+ parent&.if_type? && parent.modifier_form?
70
+ end
71
+
72
+ def methods
73
+ @methods ||= Array(cop_config.fetch("Methods", []))
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sane
6
+ # Detects comments that start with prohibited keywords.
7
+ # Configure ProhibitedWords to replace the case-sensitive default list.
8
+ # Configured words are flagged as warnings. An empty list disables matching.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # # DELETE
13
+ # # DELETE this code after migration
14
+ # # DELETE: remove after v2
15
+ # # REMEMBER to update the docs
16
+ # # remember to notify the team
17
+ #
18
+ # # good
19
+ # # Delete users
20
+ # # DELETED items are archived
21
+ # # Remember what user said
22
+ #
23
+ class ProhibitedComments < Base
24
+ MSG = "%<word>s comment found — review and address the comment"
25
+
26
+ def on_new_investigation
27
+ return unless processed_source.valid_syntax?
28
+
29
+ pattern = /^#\s*(#{Regexp.union(cop_config['ProhibitedWords'])})\b/
30
+ processed_source.comments.each do |comment|
31
+ match = comment.text.match(pattern)
32
+ next unless match
33
+
34
+ add_offense(comment.source_range, message: format(MSG, word: match[1]), severity: :warning)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sane
6
+ # Detects redundant `||=` patterns where the RHS repeats the
7
+ # assignment to the same target.
8
+ #
9
+ # @example
10
+ # # bad
11
+ # self.browser ||= self.browser = Capybara::Session.new(:cuprite)
12
+ #
13
+ # # good
14
+ # self.browser ||= Capybara::Session.new(:cuprite)
15
+ #
16
+ # # bad
17
+ # @foo ||= @foo = compute_value
18
+ #
19
+ # # good
20
+ # @foo ||= compute_value
21
+ #
22
+ class RedundantSelfAssignment < Base
23
+ extend AutoCorrector
24
+
25
+ MSG = "Redundant assignment in `||=`. Use `%<lhs>s ||= %<value>s` instead."
26
+
27
+ VARIABLE_ASGN_TYPES = [:lvasgn, :ivasgn, :cvasgn, :gvasgn].freeze
28
+
29
+ def on_or_asgn(node)
30
+ lhs, rhs = *node
31
+ return unless redundant_rhs?(lhs, rhs)
32
+
33
+ value_node = rhs.children.last
34
+ message = format(MSG, lhs: lhs.source, value: value_node.source)
35
+
36
+ add_offense(rhs, message: message) do |corrector|
37
+ corrector.replace(rhs, value_node.source)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def redundant_rhs?(lhs, rhs)
44
+ if VARIABLE_ASGN_TYPES.include?(lhs.type)
45
+ redundant_variable_assignment?(lhs, rhs)
46
+ elsif lhs.type == :casgn
47
+ redundant_const_assignment?(lhs, rhs)
48
+ elsif lhs.send_type?
49
+ redundant_method_assignment?(lhs, rhs)
50
+ else
51
+ false
52
+ end
53
+ end
54
+
55
+ def redundant_variable_assignment?(lhs, rhs)
56
+ rhs.type == lhs.type &&
57
+ rhs.children[0] == lhs.children[0]
58
+ end
59
+
60
+ def redundant_const_assignment?(lhs, rhs)
61
+ rhs.type == :casgn &&
62
+ rhs.children[0] == lhs.children[0] &&
63
+ rhs.children[1] == lhs.children[1]
64
+ end
65
+
66
+ def redundant_method_assignment?(lhs, rhs)
67
+ rhs.send_type? &&
68
+ rhs.receiver == lhs.receiver &&
69
+ rhs.method_name == :"#{lhs.method_name}="
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sane
6
+ # Enforces a minimum length for variable names.
7
+ #
8
+ # Short variable names can reduce code readability. This cop checks
9
+ # local variable assignments and method/block parameters against a
10
+ # configurable minimum length. Common names like loop counters can
11
+ # be allowed via the `AllowedNames` option.
12
+ #
13
+ # @example MinLength: 3 (default)
14
+ # # bad
15
+ # a = 1
16
+ # ab = 2
17
+ # def foo(a, ab); end
18
+ #
19
+ # # good
20
+ # age = 1
21
+ # amount = 2
22
+ # def foo(age, amount); end
23
+ #
24
+ # @example AllowedNames: ['i', 'j', 'k'] (default includes more)
25
+ # # good (allowed by default)
26
+ # items.each_with_index { |item, i| }
27
+ #
28
+ class VariableNameLength < Base
29
+ MSG = "Variable name '%<name>s' is too short (minimum is %<min>s characters)."
30
+
31
+ def on_lvasgn(node)
32
+ name = node.name
33
+ check_name(name, node)
34
+ end
35
+
36
+ alias on_arg on_lvasgn
37
+ alias on_optarg on_lvasgn
38
+ alias on_restarg on_lvasgn
39
+ alias on_kwarg on_lvasgn
40
+ alias on_kwoptarg on_lvasgn
41
+ alias on_kwrestarg on_lvasgn
42
+ alias on_blockarg on_lvasgn
43
+
44
+ private
45
+
46
+ def check_name(name, node)
47
+ return unless name
48
+
49
+ name_str = name.to_s
50
+ return if name_str.start_with?("_")
51
+ return if name_str.length >= min_length
52
+ return if allowed_names.include?(name_str)
53
+
54
+ add_offense(node, message: format(MSG, name: name_str, min: min_length))
55
+ end
56
+
57
+ def min_length
58
+ @min_length ||= cop_config.fetch("MinLength", 3)
59
+ end
60
+
61
+ def allowed_names
62
+ @allowed_names ||= cop_config.fetch("AllowedNames", []).map(&:to_s)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -1,8 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "sane/prohibited_comments"
3
4
  require_relative "sane/disallow_methods"
4
5
  require_relative "sane/conditional_assignment_allow_ternary"
5
6
  require_relative "sane/empty_line_before_comment"
7
+ require_relative "sane/empty_lines_around_condition_body"
6
8
  require_relative "sane/empty_lines_around_multiline_block"
9
+ require_relative "sane/empty_lines_around_multiline_call"
10
+ require_relative "sane/multiline_chain_on_separate_lines"
7
11
  require_relative "sane/no_method_call_after_end"
8
12
  require_relative "sane/outdated_comments"
13
+ require_relative "sane/redundant_self_assignment"
14
+ require_relative "sane/omit_parentheses"
15
+ require_relative "sane/variable_name_length"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Sane
5
- VERSION = "0.3.0"
5
+ VERSION = "1.1.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-sane
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kodkod
@@ -13,16 +13,16 @@ dependencies:
13
13
  name: lint_roller
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '1.1'
18
+ version: '1.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - "~>"
23
+ - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '1.1'
25
+ version: '1.0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rubocop
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -54,7 +54,6 @@ files:
54
54
  - ".rubocop.yml"
55
55
  - ".vscode/cspell.json"
56
56
  - ".vscode/project-words.txt"
57
- - CHANGELOG.md
58
57
  - CLAUDE.md
59
58
  - CODE_OF_CONDUCT.md
60
59
  - LICENSE.txt
@@ -65,9 +64,16 @@ files:
65
64
  - lib/rubocop/cop/sane/conditional_assignment_allow_ternary.rb
66
65
  - lib/rubocop/cop/sane/disallow_methods.rb
67
66
  - lib/rubocop/cop/sane/empty_line_before_comment.rb
67
+ - lib/rubocop/cop/sane/empty_lines_around_condition_body.rb
68
68
  - lib/rubocop/cop/sane/empty_lines_around_multiline_block.rb
69
+ - lib/rubocop/cop/sane/empty_lines_around_multiline_call.rb
70
+ - lib/rubocop/cop/sane/multiline_chain_on_separate_lines.rb
69
71
  - lib/rubocop/cop/sane/no_method_call_after_end.rb
72
+ - lib/rubocop/cop/sane/omit_parentheses.rb
70
73
  - lib/rubocop/cop/sane/outdated_comments.rb
74
+ - lib/rubocop/cop/sane/prohibited_comments.rb
75
+ - lib/rubocop/cop/sane/redundant_self_assignment.rb
76
+ - lib/rubocop/cop/sane/variable_name_length.rb
71
77
  - lib/rubocop/cop/sane_cops.rb
72
78
  - lib/rubocop/sane.rb
73
79
  - lib/rubocop/sane/plugin.rb
@@ -96,7 +102,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
96
102
  - !ruby/object:Gem::Version
97
103
  version: '0'
98
104
  requirements: []
99
- rubygems_version: 3.6.9
105
+ rubygems_version: 4.0.6
100
106
  specification_version: 4
101
107
  summary: Sane RuboCop cops for modern Ruby development
102
108
  test_files: []
data/CHANGELOG.md DELETED
@@ -1,14 +0,0 @@
1
- ## [Unreleased]
2
-
3
- ## [0.3.0] - 2025-12-20
4
-
5
- - Add `Sane/OutdatedComments` cop
6
-
7
- ## [0.2.0] - 2025-12-20
8
-
9
- - Add `Sane/NoMethodCallAfterEnd` cop
10
- - Improve `Sane/EmptyLineBeforeComment` cop
11
-
12
- ## [0.1.0] - 2025-12-19
13
-
14
- - Initial release