sonicop 26.8.106 → 26.8.107

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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/Cargo.toml +1 -1
  4. data/lib/sonicop/version.rb +1 -1
  5. data/src/directives.rs +2 -2
  6. data/src/rules/layout/empty_line_after_magic_comment.rs +11 -1
  7. data/src/rules/layout/empty_line_after_multiline_condition.rs +2 -13
  8. data/src/rules/layout/empty_lines_after_module_inclusion.rs +2 -12
  9. data/src/rules/layout/line_length.rs +5 -10
  10. data/src/rules/layout/multiline_brace.rs +3 -18
  11. data/src/rules/layout/space_around_operators.rs +9 -18
  12. data/src/rules/layout/space_inside_array_percent_literal.rs +4 -2
  13. data/src/rules/layout/space_inside_block_braces.rs +14 -27
  14. data/src/rules/layout/trailing_empty_lines.rs +7 -1
  15. data/src/rules/lint/ambiguous_assignment.rs +1 -1
  16. data/src/rules/lint/comments_help.rs +39 -0
  17. data/src/rules/lint/constant_reassignment.rs +1 -1
  18. data/src/rules/lint/cop_directives.rs +1 -26
  19. data/src/rules/lint/empty_conditional_body.rs +23 -0
  20. data/src/rules/lint/empty_in_pattern.rs +2 -25
  21. data/src/rules/lint/empty_when.rs +2 -27
  22. data/src/rules/lint/missing_super.rs +1 -15
  23. data/src/rules/lint/mod.rs +2 -0
  24. data/src/rules/lint/ranges.rs +8 -6
  25. data/src/rules/lint/unused_arg_corrector.rs +55 -0
  26. data/src/rules/lint/unused_block_argument.rs +3 -45
  27. data/src/rules/lint/unused_method_argument.rs +4 -61
  28. data/src/rules/lint/useless_assignment.rs +2 -1
  29. data/src/rules/lint/variable_force.rs +1 -45
  30. data/src/rules/lint/void.rs +9 -7
  31. data/src/rules/metrics/complexity.rs +2 -1
  32. data/src/rules/metrics/locals.rs +1 -46
  33. data/src/rules/mod.rs +3 -1
  34. data/src/rules/naming/constant_name.rs +2 -1
  35. data/src/rules/naming/rescued_exceptions_variable_name.rs +2 -1
  36. data/src/rules/naming/support.rs +1 -35
  37. data/src/rules/style/collection_compact.rs +7 -1
  38. data/src/rules/style/hash_syntax.rs +2 -12
  39. data/src/rules/style/identical_conditional_branches.rs +2 -8
  40. data/src/rules/style/if_inside_else.rs +4 -10
  41. data/src/rules/style/if_unless_modifier.rs +10 -30
  42. data/src/rules/style/keyword_parameters_order.rs +9 -22
  43. data/src/rules/style/lambda.rs +2 -4
  44. data/src/rules/style/line_length_help.rs +4 -8
  45. data/src/rules/style/mod.rs +1 -0
  46. data/src/rules/style/multiline_method_signature.rs +1 -13
  47. data/src/rules/style/negated_if_else_condition.rs +2 -13
  48. data/src/rules/style/nested_parenthesized_calls.rs +19 -4
  49. data/src/rules/style/nested_ternary_operator.rs +13 -11
  50. data/src/rules/style/nil_lambda.rs +1 -12
  51. data/src/rules/style/parens.rs +38 -17
  52. data/src/rules/style/partition_instead_of_double_select.rs +2 -12
  53. data/src/rules/style/percent_array.rs +2 -6
  54. data/src/rules/style/ranges.rs +4 -22
  55. data/src/rules/style/redundant_begin.rs +9 -16
  56. data/src/rules/style/redundant_parentheses.rs +29 -25
  57. data/src/rules/style/safe_navigation.rs +9 -9
  58. data/src/rules/style/statement_modifier.rs +31 -0
  59. data/src/rules/style/struct_inheritance.rs +3 -12
  60. data/src/rules/style/while_until_modifier.rs +2 -22
  61. data/src/rules/support.rs +226 -14
  62. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c90ed3a9d03214ba6922d2aecd35c70b28e09791c870f06c22848ef05b336054
4
- data.tar.gz: 0557036a886b2023e5151d8b18b1df8bdd66725faf957d19013412a0bc62c6ef
3
+ metadata.gz: 5972ef39a175515ffae2b30a2061487cc72c3d3945b58fb0a4277a55ed37517c
4
+ data.tar.gz: 70853c91d098efd59af3442c50ccee948279c76b77a8b2daa0ba66a34a5a3ca1
5
5
  SHA512:
6
- metadata.gz: 63191904f82e06d89200c5b81a22a9ad6a4ebcbf07ed86039966605a2d87225d7984a2dfd7a3b0a7b38ca54aaebc1b36518fdb1eeb5dead16318168e3441d692
7
- data.tar.gz: deb43652b8dfd0c2c9fac7ed27a1a33fb1097e76bd2d455851ba04d30d2fc6751025fadfdc4012d06f6a45140a983a5686b7bdfc3e5436e5d9ea0e238da26ddd
6
+ metadata.gz: 0cffd8b515ab79c07087248461158a204d92f47c1b88889c4f1ef14581493cff38c83891ed7cd8eac205db5b177292c9ec7ed2a63b2bf876a9cfd024174c5c26
7
+ data.tar.gz: f7d324f0b70eb06b5b12a1f905cb23a4fc8b681fa15b2943e2f6946593e95075b70f68f9f150ce994a90a7c35b94ccecac962c555de232a52a92c4d86f0ef99a
data/Cargo.lock CHANGED
@@ -742,7 +742,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
742
742
 
743
743
  [[package]]
744
744
  name = "sonicop"
745
- version = "26.8.106"
745
+ version = "26.8.107"
746
746
  dependencies = [
747
747
  "anyhow",
748
748
  "assert_cmd",
data/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "sonicop"
3
- version = "26.8.106"
3
+ version = "26.8.107"
4
4
  edition = "2024"
5
5
  rust-version = "1.85"
6
6
  description = "A fast, native RuboCop-compatible Ruby linter and formatter"
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Generated from Cargo.toml by `rake version:sync`. Do not edit by hand.
4
4
  module Sonicop
5
- VERSION = '26.8.106'
5
+ VERSION = '26.8.107'
6
6
  end
data/src/directives.rs CHANGED
@@ -334,7 +334,7 @@ fn cop_list(arguments: &str) -> Vec<String> {
334
334
 
335
335
  /// The length of the cop name `text` starts with, following `COP_NAME_PATTERN`: one or more
336
336
  /// `[A-Za-z]\w+` segments separated by slashes.
337
- fn cop_name_length(text: &str) -> Option<usize> {
337
+ pub(crate) fn cop_name_length(text: &str) -> Option<usize> {
338
338
  let bytes = text.as_bytes();
339
339
  let mut index = 0;
340
340
  loop {
@@ -619,7 +619,7 @@ fn commented_out(prefix: &str) -> bool {
619
619
  prefix.starts_with('#')
620
620
  && prefix[1..]
621
621
  .bytes()
622
- .all(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'\n' | 0x0b | 0x0c))
622
+ .all(crate::rules::support::is_ruby_space)
623
623
  }
624
624
 
625
625
  /// A `# rubocop:` comment, parsed the way `RuboCop::DirectiveComment` parses one.
@@ -1,6 +1,7 @@
1
1
  use crate::diagnostic::{Edit, Offense};
2
2
  use crate::magic_comment::MagicComment;
3
3
  use crate::rules::RuleContext;
4
+ use crate::rules::support;
4
5
 
5
6
  pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
6
7
  // RuboCop takes every comment that precedes the first line of code and keeps the *last* magic
@@ -11,7 +12,16 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
11
12
  return;
12
13
  };
13
14
  let next = line_number + 1;
14
- if next > context.source.line_count() || context.source.line(next).trim().is_empty() {
15
+ // `next_line.strip.empty?`. **`str::trim` would also reach over a no-break space**, which
16
+ // Ruby's `strip` leaves in place: a line holding one is not an empty line, and upstream asks for
17
+ // a real one to be added under the magic comment.
18
+ if next > context.source.line_count()
19
+ || context
20
+ .source
21
+ .line(next)
22
+ .trim_matches(support::is_ruby_strippable)
23
+ .is_empty()
24
+ {
15
25
  return;
16
26
  }
17
27
  let insertion = context.source.line_start(next);
@@ -6,6 +6,7 @@ use crate::diagnostic::{Edit, Offense};
6
6
  use crate::rules::RuleContext;
7
7
  use crate::rules::node_ext::NodeExt;
8
8
  use crate::rules::send_node::named_children;
9
+ use crate::rules::support;
9
10
 
10
11
  const MSG: &str = "Use empty line after multiline condition.";
11
12
 
@@ -139,7 +140,7 @@ fn rescue_branch(node: Node<'_>, context: &RuleContext<'_>) -> Option<Offense> {
139
140
 
140
141
  /// The offense, whose correction writes a blank line under the last line of `anchor`.
141
142
  fn offense(reported: Node<'_>, anchor: Node<'_>, context: &RuleContext<'_>) -> Offense {
142
- let lines = whole_lines(anchor.byte_range(), context);
143
+ let lines = support::whole_lines_without_terminator(anchor.byte_range(), context);
143
144
  context
144
145
  .offense(MSG, reported.byte_range())
145
146
  .corrections_anchored_at(lines.clone())
@@ -184,18 +185,6 @@ fn is_blank(line: usize, context: &RuleContext<'_>) -> bool {
184
185
  line > context.source.line_count() || context.source.line(line).trim().is_empty()
185
186
  }
186
187
 
187
- /// `range_by_whole_lines(range)`: the lines the node sits on, without the break that closes them.
188
- fn whole_lines(range: Range<usize>, context: &RuleContext<'_>) -> Range<usize> {
189
- let text = context.source.text();
190
- let start = text[..range.start]
191
- .rfind('\n')
192
- .map_or(0, |offset| offset + 1);
193
- let end = text[range.end..]
194
- .find('\n')
195
- .map_or(text.len(), |offset| range.end + offset);
196
- start..end
197
- }
198
-
199
188
  fn line(offset: usize, context: &RuleContext<'_>) -> usize {
200
189
  context.source.line_column(offset).0
201
190
  }
@@ -7,6 +7,7 @@ use crate::directives::directive_header;
7
7
  use crate::rules::RuleContext;
8
8
  use crate::rules::node_ext::NodeExt;
9
9
  use crate::rules::send_node::{arguments, is_plain_send, named_children, send_range};
10
+ use crate::rules::support;
10
11
 
11
12
  const MSG: &str = "Add an empty line after module inclusion.";
12
13
 
@@ -73,7 +74,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
73
74
  }
74
75
  // `autocorrect`: the blank line goes after the inclusion, or after the `rubocop:enable`
75
76
  // comment written directly below it.
76
- let mut anchor = whole_lines(send_range(node, context), context);
77
+ let mut anchor = support::whole_lines_without_terminator(send_range(node, context), context);
77
78
  let below = context.source.line_column(anchor.end).0 + 1;
78
79
  if let Some(comment) = enable_directive_at(below, context) {
79
80
  anchor = comment;
@@ -184,14 +185,3 @@ fn enable_directive_at(line: usize, context: &RuleContext<'_>) -> Option<Range<u
184
185
  (header.mode == "enable").then(|| comment.clone())
185
186
  }
186
187
 
187
- /// `range_by_whole_lines(range)`: the lines the node sits on, without the break that closes them.
188
- fn whole_lines(range: Range<usize>, context: &RuleContext<'_>) -> Range<usize> {
189
- let text = context.source.text();
190
- let start = text[..range.start]
191
- .rfind('\n')
192
- .map_or(0, |offset| offset + 1);
193
- let end = text[range.end..]
194
- .find('\n')
195
- .map_or(text.len(), |offset| range.end + offset);
196
- start..end
197
- }
@@ -9,6 +9,7 @@ use crate::rules::RuleContext;
9
9
  use crate::rules::support::Interpolations;
10
10
  use crate::source::is_protected;
11
11
  use crate::rules::node_ext::NodeExt;
12
+ use crate::rules::support::is_ruby_space_char;
12
13
 
13
14
  /// `Layout/IndentationStyle`'s `IndentationWidth` is unset by default, so RuboCop falls back to
14
15
  /// `Layout/IndentationWidth`'s `Width`, which is 2. A cop only ever sees its own configuration
@@ -117,12 +118,6 @@ fn allow_cop_directives(context: &RuleContext<'_>) -> bool {
117
118
  }
118
119
  }
119
120
 
120
- /// Ruby's `\s`, which is ASCII-only. Using `char::is_whitespace` here would let a non-breaking
121
- /// space end a word that RuboCop keeps going through.
122
- fn is_ruby_space(character: char) -> bool {
123
- matches!(character, ' ' | '\t' | '\r' | '\n' | '\u{b}' | '\u{c}')
124
- }
125
-
126
121
  fn indentation_difference(line: &str) -> usize {
127
122
  if !line.starts_with('\t') {
128
123
  return 0;
@@ -209,14 +204,14 @@ fn is_endless_method(node: Node<'_>) -> bool {
209
204
  fn marker_only(prefix: &str) -> bool {
210
205
  prefix
211
206
  .strip_prefix('#')
212
- .is_some_and(|rest| rest.chars().all(is_ruby_space))
207
+ .is_some_and(|rest| rest.chars().all(is_ruby_space_char))
213
208
  }
214
209
 
215
210
  fn length_without_directive(line: &str) -> usize {
216
211
  DIRECTIVE
217
212
  .find(line)
218
213
  .map_or(line, |found| &line[..found.start()])
219
- .trim_end_matches(is_ruby_space)
214
+ .trim_end_matches(is_ruby_space_char)
220
215
  .chars()
221
216
  .count()
222
217
  }
@@ -260,8 +255,8 @@ fn extended_end(line: &str, mut end: usize) -> usize {
260
255
  }
261
256
  }
262
257
  let rest = &line[end..];
263
- if rest.starts_with(|character| !is_ruby_space(character)) {
264
- end += rest.find(is_ruby_space).unwrap_or(rest.len());
258
+ if rest.starts_with(|character| !is_ruby_space_char(character)) {
259
+ end += rest.find(is_ruby_space_char).unwrap_or(rest.len());
265
260
  }
266
261
  end
267
262
  }
@@ -11,6 +11,7 @@ use tree_sitter::Node;
11
11
  use crate::diagnostic::{Edit, Offense};
12
12
  use crate::rules::RuleContext;
13
13
  use crate::rules::node_ext::NodeExt;
14
+ use crate::rules::support;
14
15
 
15
16
  /// The four sentences one of these cops reports with.
16
17
  pub(super) struct Messages {
@@ -260,28 +261,12 @@ fn line_end(context: &RuleContext<'_>, offset: usize) -> usize {
260
261
  /// `range_with_surrounding_space(range, side: :left)`: the blanks before `offset`, then the line
261
262
  /// breaks before those -- and no further, so a blank line above is left alone.
262
263
  fn space_before(text: &str, offset: usize) -> usize {
263
- let bytes = text.as_bytes();
264
- let mut position = offset;
265
- while position > 0 && matches!(bytes[position - 1], b' ' | b'\t') {
266
- position -= 1;
267
- }
268
- while position > 0 && bytes[position - 1] == b'\n' {
269
- position -= 1;
270
- }
271
- position
264
+ support::final_pos(text, offset, false, false, true, false)
272
265
  }
273
266
 
274
267
  /// `range_with_surrounding_space(range, side: :right)`.
275
268
  fn space_after(text: &str, offset: usize) -> usize {
276
- let bytes = text.as_bytes();
277
- let mut position = offset;
278
- while position < bytes.len() && matches!(bytes[position], b' ' | b'\t') {
279
- position += 1;
280
- }
281
- while position < bytes.len() && bytes[position] == b'\n' {
282
- position += 1;
283
- }
284
- position
269
+ support::final_pos(text, offset, true, false, true, false)
285
270
  }
286
271
 
287
272
  /// `Node#chained?`: the node is the receiver of the call written around it.
@@ -17,6 +17,8 @@ use crate::diagnostic::{Edit, Offense};
17
17
  use crate::ruby_version::RubyVersion;
18
18
  use crate::rules::RuleContext;
19
19
  use crate::rules::node_ext::NodeExt;
20
+ use crate::rules::support;
21
+ use crate::rules::support::is_ruby_space;
20
22
 
21
23
  /// The node kinds carrying an operator RuboCop checks. `binary` stands in for `on_send`,
22
24
  /// `on_binary`, `on_and` and `on_or`, which all reduce to the same shape in tree-sitter.
@@ -455,22 +457,15 @@ fn check_operator<'src>(
455
457
  /// either side, plus the line breaks that follow it. `None` stands for the range that begins
456
458
  /// with a line break, which `check_operator` refuses to judge.
457
459
  fn surrounding_space(source: &str, operator: &Range<usize>) -> Option<Range<usize>> {
458
- let bytes = source.as_bytes();
459
- let mut start = operator.start;
460
- while start > 0 && matches!(bytes[start - 1], b' ' | b'\t') {
461
- start -= 1;
462
- }
463
- if start > 0 && bytes[start - 1] == b'\n' {
460
+ // The left side is `range_with_surrounding_space(side: :left, newlines: false)` and the right is
461
+ // the same with `newlines: true`, **but this cop gives up entirely when a line break sits on the
462
+ // left**: an operator that opens a line is aligned rather than spaced, so the range is no longer
463
+ // the thing to measure. That decision belongs to the cop and stays here.
464
+ let start = support::final_pos(source, operator.start, false, false, false, false);
465
+ if start > 0 && source.as_bytes()[start - 1] == b'\n' {
464
466
  return None;
465
467
  }
466
- let mut end = operator.end;
467
- while end < bytes.len() && matches!(bytes[end], b' ' | b'\t') {
468
- end += 1;
469
- }
470
- while end < bytes.len() && bytes[end] == b'\n' {
471
- end += 1;
472
- }
473
- Some(start..end)
468
+ Some(start..support::final_pos(source, operator.end, true, false, true, false))
474
469
  }
475
470
 
476
471
  fn should_not_have_surrounding_space(
@@ -533,10 +528,6 @@ fn wrapped_in_space(padded: &str) -> bool {
533
528
  false
534
529
  }
535
530
 
536
- fn is_ruby_space(byte: u8) -> bool {
537
- matches!(byte, b' ' | b'\t' | b'\r' | b'\n' | 0x0b | 0x0c)
538
- }
539
-
540
531
  fn excess_leading_space(
541
532
  settings: &Settings,
542
533
  alignment: &Alignment<'_>,
@@ -54,7 +54,7 @@ fn unnecessary_spaces(contents: &str) -> Vec<std::ops::Range<usize>> {
54
54
  if index - start < 2
55
55
  || bytes
56
56
  .get(index)
57
- .is_none_or(|byte| byte.is_ascii_whitespace())
57
+ .is_none_or(|byte| crate::rules::support::separates_words(*byte))
58
58
  {
59
59
  continue;
60
60
  }
@@ -72,5 +72,7 @@ fn word_ends_before(bytes: &[u8], start: usize) -> bool {
72
72
  while position >= 2 && bytes[position - 1] == b' ' && bytes[position - 2] == b'\\' {
73
73
  position -= 2;
74
74
  }
75
- position > 0 && !bytes[position - 1].is_ascii_whitespace() && bytes[position - 1] != b'\\'
75
+ position > 0
76
+ && !crate::rules::support::separates_words(bytes[position - 1])
77
+ && bytes[position - 1] != b'\\'
76
78
  }
@@ -6,6 +6,8 @@ use super::support::{character_column, parser_node_start};
6
6
  use crate::diagnostic::{Edit, Offense};
7
7
  use crate::rules::RuleContext;
8
8
  use crate::rules::node_ext::NodeExt;
9
+ use crate::rules::support;
10
+ use crate::rules::support::is_ruby_space;
9
11
 
10
12
  #[derive(Clone, Copy, Eq, PartialEq)]
11
13
  enum Style {
@@ -91,7 +93,7 @@ impl Cop<'_, '_> {
91
93
  return;
92
94
  }
93
95
  let inner = &self.text()[left.end_byte()..right.start_byte()];
94
- if inner.bytes().any(|byte| !is_space(byte)) {
96
+ if inner.bytes().any(|byte| !is_ruby_space(byte)) {
95
97
  self.braces_with_contents_inside(node, braces, inner, offenses);
96
98
  } else if self.empty_style == Style::NoSpace {
97
99
  self.offense(
@@ -127,7 +129,7 @@ impl Cop<'_, '_> {
127
129
  pipe: Option<Node<'_>>,
128
130
  offenses: &mut Vec<Offense>,
129
131
  ) {
130
- if inner.bytes().next().is_some_and(|byte| !is_space(byte)) {
132
+ if inner.bytes().next().is_some_and(|byte| !is_ruby_space(byte)) {
131
133
  self.no_space_inside_left_brace(left, pipe, offenses);
132
134
  } else {
133
135
  self.space_inside_left_brace(left, pipe, offenses);
@@ -192,7 +194,7 @@ impl Cop<'_, '_> {
192
194
  && inner
193
195
  .bytes()
194
196
  .next_back()
195
- .is_some_and(|byte| !is_space(byte))
197
+ .is_some_and(|byte| !is_ruby_space(byte))
196
198
  {
197
199
  self.no_space(
198
200
  right.start_byte(),
@@ -263,7 +265,7 @@ impl Cop<'_, '_> {
263
265
  return;
264
266
  }
265
267
  let source = &self.text()[begin_pos..end_pos];
266
- let edit = if source.bytes().any(is_space) {
268
+ let edit = if source.bytes().any(is_ruby_space) {
267
269
  Edit {
268
270
  start: begin_pos,
269
271
  end: end_pos,
@@ -330,12 +332,6 @@ fn last_line_spaces(inner: &str) -> i64 {
330
332
  })
331
333
  }
332
334
 
333
- /// `\s` as Ruby's regexp engine defines it, which is ASCII only -- a non-breaking space is a
334
- /// character the cop counts as content.
335
- fn is_space(byte: u8) -> bool {
336
- matches!(byte, b' ' | b'\t' | b'\r' | b'\n' | 0x0b | 0x0c)
337
- }
338
-
339
335
  #[derive(Clone, Copy, Eq, PartialEq)]
340
336
  enum Direction {
341
337
  Left,
@@ -345,23 +341,14 @@ enum Direction {
345
341
  /// `RangeHelp#range_with_surrounding_space` with the defaults this cop passes: the run of spaces
346
342
  /// and tabs, and then the run of newlines beyond it.
347
343
  fn expand_space(text: &str, offset: usize, direction: Direction) -> usize {
348
- let bytes = text.as_bytes();
349
- let mut position = offset;
350
- let peek = |position: usize| match direction {
351
- Direction::Left => (position > 0).then(|| bytes[position - 1]),
352
- Direction::Right => bytes.get(position).copied(),
353
- };
354
- let step = |position: usize| match direction {
355
- Direction::Left => position - 1,
356
- Direction::Right => position + 1,
357
- };
358
- while peek(position).is_some_and(|byte| byte == b' ' || byte == b'\t') {
359
- position = step(position);
360
- }
361
- while peek(position).is_some_and(|byte| byte == b'\n') {
362
- position = step(position);
363
- }
364
- position
344
+ support::final_pos(
345
+ text,
346
+ offset,
347
+ direction == Direction::Right,
348
+ false,
349
+ true,
350
+ false,
351
+ )
365
352
  }
366
353
 
367
354
  /// Moves `offset` by `delta` characters. Upstream addresses source by character, so a span it
@@ -1,5 +1,6 @@
1
1
  use crate::diagnostic::{Edit, Offense};
2
2
  use crate::rules::RuleContext;
3
+ use crate::rules::support;
3
4
 
4
5
  pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
5
6
  let text = context.source.text();
@@ -14,7 +15,12 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
14
15
  // All trailing whitespace, as RuboCop's `/\s*\Z/` takes it -- not just newlines, so a file
15
16
  // ending in spaces is measured from where the whitespace starts rather than from the last
16
17
  // newline. Only `\n` is counted, leaving carriage returns to `Layout/EndOfLine`.
17
- let whitespace_start = text.trim_end_matches(char::is_whitespace).len();
18
+ // **`char::is_whitespace` would reach over a no-break space**, which `/\s/` does not match:
19
+ // a last line holding one is not trailing whitespace, and counting it as such reports a blank
20
+ // line upstream does not see -- and this cop's correction would then delete that line.
21
+ let whitespace_start = text
22
+ .trim_end_matches(support::is_ruby_space_char)
23
+ .len();
18
24
  let whitespace = &text[whitespace_start..];
19
25
  let blank_lines = whitespace.matches('\n').count() as isize - 1;
20
26
  if blank_lines == wanted_blank_lines {
@@ -3,8 +3,8 @@ use tree_sitter::Node;
3
3
  use crate::diagnostic::Offense;
4
4
  use crate::rules::RuleContext;
5
5
  use crate::rules::node_ext::NodeExt;
6
+ use crate::rules::support::spurious_assignment_list;
6
7
 
7
- use super::variable_force::spurious_assignment_list;
8
8
 
9
9
  /// `MISTAKES`: the operator each two-character run was probably meant to be.
10
10
  const MISTAKES: [(u8, &str); 4] = [(b'-', "-="), (b'+', "+="), (b'*', "*="), (b'!', "!=")];
@@ -0,0 +1,39 @@
1
+ //! `CommentsHelp`: the lines a branch owns, which is where a cop looks for the comment that
2
+ //! excuses an empty one.
3
+ //!
4
+ //! Upstream is one mixin included by both `Lint/EmptyWhen` and `Lint/EmptyInPattern`, so the two
5
+ //! read the same lines for `when` and for `in`.
6
+
7
+ use std::ops::Range;
8
+
9
+ use tree_sitter::Node;
10
+
11
+ use crate::rules::RuleContext;
12
+ use crate::rules::node_ext::NodeExt;
13
+
14
+ /// `find_end_line`: a `when` runs up to the line the next branch starts on, or to the `end` of the
15
+ /// `case` when it is the last. The range excludes that line, which is why the keyword itself never
16
+ /// counts as a comment of the branch before it.
17
+ pub(super) fn comment_search_lines(
18
+ context: &RuleContext<'_>,
19
+ case: Node<'_>,
20
+ children: &[Node<'_>],
21
+ index: usize,
22
+ ) -> Range<usize> {
23
+ let branch = children[index];
24
+ let (start, _) = context.source.line_column(branch.start_byte());
25
+ let end = children[index + 1..]
26
+ .iter()
27
+ .find(|sibling| sibling.kind_str() != "comment")
28
+ .map_or_else(
29
+ || {
30
+ // `parent.loc.end.line`, the `end` keyword closing the `case`.
31
+ let (line, _) = context
32
+ .source
33
+ .line_column(case.end_byte().saturating_sub(1));
34
+ line
35
+ },
36
+ |next| context.source.line_column(next.start_byte()).0,
37
+ );
38
+ start..end
39
+ }
@@ -6,8 +6,8 @@ use crate::diagnostic::Offense;
6
6
  use crate::rules::RuleContext;
7
7
  use crate::rules::node_ext::NodeExt;
8
8
  use crate::rules::send_node::{arguments, named_children, string_text, symbol_name};
9
+ use crate::rules::support::spurious_assignment_list;
9
10
 
10
- use super::variable_force::spurious_assignment_list;
11
11
 
12
12
  /// The parts of a constant path, as `each_path` walks them.
13
13
  struct ConstantPath {
@@ -12,6 +12,7 @@
12
12
 
13
13
  use std::ops::Range;
14
14
 
15
+ use crate::directives::cop_name_length;
15
16
  use crate::rules::RuleContext;
16
17
 
17
18
  /// The departments a cop name can be shortened to. `cop_registry.department?` answers from the
@@ -140,29 +141,3 @@ fn next_is_name_character(text: &str, index: usize) -> bool {
140
141
  .get(index)
141
142
  .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
142
143
  }
143
-
144
- /// `COP_NAME_PATTERN`: one or more `[A-Za-z]\w+` segments joined by slashes.
145
- fn cop_name_length(text: &str) -> Option<usize> {
146
- let bytes = text.as_bytes();
147
- let mut index = 0;
148
- loop {
149
- let start = index;
150
- if !bytes.get(index).is_some_and(u8::is_ascii_alphabetic) {
151
- return None;
152
- }
153
- index += 1;
154
- while bytes
155
- .get(index)
156
- .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
157
- {
158
- index += 1;
159
- }
160
- if index - start < 2 {
161
- return None;
162
- }
163
- if bytes.get(index) != Some(&b'/') {
164
- return Some(index);
165
- }
166
- index += 1;
167
- }
168
- }
@@ -132,6 +132,16 @@ fn contains_comments(node: Node<'_>, context: &RuleContext<'_>) -> bool {
132
132
  let end = match node.field("alternative") {
133
133
  // `find_end_line`: the `else` or `elsif` that follows ends the span.
134
134
  Some(clause) => context.source.line_column(clause.start_byte()).0,
135
+ // `find_end_line`'s `elsif?` branch: `node.each_ancestor(:if).find(&:if?).loc.end.line`.
136
+ // An `elsif` with nothing after it owns the lines up to the `end` that closes the whole
137
+ // conditional, and that `end` belongs to the `if` above it. The grammar stops the `elsif`
138
+ // node at its own last statement, so reading its end instead leaves an empty span and the
139
+ // comment that excuses the branch is never seen.
140
+ None if node.kind_str() == "elsif" => enclosing_end(node, context)
141
+ .map_or_else(
142
+ || context.source.line_column(node.end_byte()).0,
143
+ |keyword| context.source.line_column(keyword.start_byte()).0,
144
+ ),
135
145
  None => context.source.line_column(node.end_byte()).0,
136
146
  };
137
147
  context.comment_ranges().iter().any(|comment| {
@@ -140,6 +150,19 @@ fn contains_comments(node: Node<'_>, context: &RuleContext<'_>) -> bool {
140
150
  })
141
151
  }
142
152
 
153
+ /// The `end` closing the conditional an `elsif` sits in, which is the `if` above it. A chain of
154
+ /// `elsif` nests, so the walk passes any number of them before it reaches the `if`.
155
+ fn enclosing_end<'tree>(node: Node<'tree>, context: &RuleContext<'_>) -> Option<Node<'tree>> {
156
+ let mut current = node.parent();
157
+ while let Some(ancestor) = current {
158
+ if ancestor.kind_str() == "if" {
159
+ return end_keyword(ancestor, context);
160
+ }
161
+ current = ancestor.parent();
162
+ }
163
+ None
164
+ }
165
+
143
166
  /// `node.loc.begin`: the `then` or `;` the branch was introduced with. The grammar puts a `then`
144
167
  /// inside the branch it opens, but a `;` stays beside the condition -- and a branch holding nothing
145
168
  /// is no node at all, which leaves the `;` as the only trace of it.
@@ -6,6 +6,8 @@ use crate::diagnostic::Offense;
6
6
  use crate::directives::DirectiveState;
7
7
  use crate::rules::RuleContext;
8
8
  use crate::rules::node_ext::NodeExt;
9
+
10
+ use super::comments_help::comment_search_lines;
9
11
  use crate::ruby_version::RubyVersion;
10
12
 
11
13
  use super::statements::statements;
@@ -64,31 +66,6 @@ fn allowed_by_comments(
64
66
  has_comment && !comments_contain_disables(context, &lines)
65
67
  }
66
68
 
67
- /// `find_end_line`: a branch runs up to the line the next one starts on, or to the `end` of the
68
- /// `case` when it is the last. The range excludes that line.
69
- fn comment_search_lines(
70
- context: &RuleContext<'_>,
71
- case: Node<'_>,
72
- children: &[Node<'_>],
73
- index: usize,
74
- ) -> Range<usize> {
75
- let branch = children[index];
76
- let (start, _) = context.source.line_column(branch.start_byte());
77
- let end = children[index + 1..]
78
- .iter()
79
- .find(|sibling| sibling.kind_str() != "comment")
80
- .map_or_else(
81
- || {
82
- let (line, _) = context
83
- .source
84
- .line_column(case.end_byte().saturating_sub(1));
85
- line
86
- },
87
- |next| context.source.line_column(next.start_byte()).0,
88
- );
89
- start..end
90
- }
91
-
92
69
  fn comments_contain_disables(context: &RuleContext<'_>, lines: &Range<usize>) -> bool {
93
70
  let directives = DirectiveState::parse(context.source, context.comment_ranges());
94
71
  directives
@@ -7,6 +7,8 @@ use crate::directives::DirectiveState;
7
7
  use crate::rules::RuleContext;
8
8
  use crate::rules::node_ext::NodeExt;
9
9
 
10
+ use super::comments_help::comment_search_lines;
11
+
10
12
  const MSG: &str = "Avoid `when` branches without a body.";
11
13
 
12
14
  pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
@@ -61,33 +63,6 @@ fn allowed_by_comments(
61
63
  has_comment && !comments_contain_disables(context, &lines)
62
64
  }
63
65
 
64
- /// `find_end_line`: a `when` runs up to the line the next branch starts on, or to the `end` of the
65
- /// `case` when it is the last. The range excludes that line, which is why the keyword itself never
66
- /// counts as a comment of the branch before it.
67
- fn comment_search_lines(
68
- context: &RuleContext<'_>,
69
- case: Node<'_>,
70
- children: &[Node<'_>],
71
- index: usize,
72
- ) -> Range<usize> {
73
- let branch = children[index];
74
- let (start, _) = context.source.line_column(branch.start_byte());
75
- let end = children[index + 1..]
76
- .iter()
77
- .find(|sibling| sibling.kind_str() != "comment")
78
- .map_or_else(
79
- || {
80
- // `parent.loc.end.line`, the `end` keyword closing the `case`.
81
- let (line, _) = context
82
- .source
83
- .line_column(case.end_byte().saturating_sub(1));
84
- line
85
- },
86
- |next| context.source.line_column(next.start_byte()).0,
87
- );
88
- start..end
89
- }
90
-
91
66
  /// `comments_contain_disables?`: whether a `rubocop:disable` of this cop overlaps the branch. The
92
67
  /// directives are read only here, where a branch already turned out to be empty and commented.
93
68
  fn comments_contain_disables(context: &RuleContext<'_>, lines: &Range<usize>) -> bool {