sonicop 26.8.105 → 26.8.106

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: f3bc83b9cca7222947c4ef2dc8c0d18456a627dc98650427dea70f68340df060
4
- data.tar.gz: 3567b01985bf947c14427804f847c3530d324eead1b4d56649a18a9e8f69491d
3
+ metadata.gz: c90ed3a9d03214ba6922d2aecd35c70b28e09791c870f06c22848ef05b336054
4
+ data.tar.gz: 0557036a886b2023e5151d8b18b1df8bdd66725faf957d19013412a0bc62c6ef
5
5
  SHA512:
6
- metadata.gz: b10fdaec517230cdfaa915213cf0df5dfab472c65776e0305994f44a49296fb0147c72fd0c1628d66af39052a4985c351e86023f053fbbc50a3363b1f66574f8
7
- data.tar.gz: b227a8713413f893f96991859278e05e4c4e4916f6ebc564d01beb0a84e7ed4515002b07c8dc6dc0c1f9ba753e625b8f0d278716894a97a50487db29c9f2adcf
6
+ metadata.gz: 63191904f82e06d89200c5b81a22a9ad6a4ebcbf07ed86039966605a2d87225d7984a2dfd7a3b0a7b38ca54aaebc1b36518fdb1eeb5dead16318168e3441d692
7
+ data.tar.gz: deb43652b8dfd0c2c9fac7ed27a1a33fb1097e76bd2d455851ba04d30d2fc6751025fadfdc4012d06f6a45140a983a5686b7bdfc3e5436e5d9ea0e238da26ddd
data/Cargo.lock CHANGED
@@ -742,7 +742,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
742
742
 
743
743
  [[package]]
744
744
  name = "sonicop"
745
- version = "26.8.105"
745
+ version = "26.8.106"
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.105"
3
+ version = "26.8.106"
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.105'
5
+ VERSION = '26.8.106'
6
6
  end
@@ -43,7 +43,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
43
43
  /// `empty_range_for_starting_point`: the blank line immediately before `start`, when the run of
44
44
  /// whitespace reaching back from it spans more than one line break.
45
45
  fn empty_range_before(context: &RuleContext<'_>, start: usize) -> Option<Range<usize>> {
46
- let begin = final_pos(context.source.text(), start, false, true, true);
46
+ let begin = final_pos(context.source.text(), start, false, false, true, true);
47
47
  let first_line = context.source.line_column(begin).0;
48
48
  let last_line = context.source.line_column(start).0;
49
49
  (last_line.checked_sub(first_line)? > 1).then(|| context.source.line_range(last_line - 1))
@@ -35,7 +35,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
35
35
  let mut edits = Vec::new();
36
36
  let mut expression_before_body = open.start_byte();
37
37
  if let Some(arguments) = arguments.filter(|_| !on_beginning_line) {
38
- let end = final_pos(text, arguments.end_byte(), true, false, false);
38
+ let end = final_pos(text, arguments.end_byte(), true, false, false, false);
39
39
  edits.push(Edit {
40
40
  start: open.end_byte(),
41
41
  end,
@@ -230,6 +230,7 @@ fn register(
230
230
  let verification = Verification {
231
231
  verify_oversized: true,
232
232
  fold_string_concatenation: true,
233
+ ..Default::default()
233
234
  };
234
235
  let verified = verified_by_reparse(
235
236
  context,
@@ -96,7 +96,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
96
96
  let start = argument.start_byte();
97
97
  no_space(
98
98
  context,
99
- final_pos(text, start, false, true, false)..start.saturating_sub(1),
99
+ final_pos(text, start, false, false, true, false)..start.saturating_sub(1),
100
100
  "Extra space before",
101
101
  offenses,
102
102
  );
@@ -36,7 +36,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
36
36
  continue;
37
37
  }
38
38
 
39
- let space_start = final_pos(text, left.start_byte(), false, true, false);
39
+ let space_start = final_pos(text, left.start_byte(), false, false, true, false);
40
40
  let used_space = space_start != left.start_byte();
41
41
  let empty = left.end_byte() == right.start_byte();
42
42
  let wanted_space = if empty {
@@ -50,7 +50,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
50
50
  continue;
51
51
  }
52
52
 
53
- let space = final_pos(text, first.start, false, true, false)..first.start;
53
+ let space = final_pos(text, first.start, false, false, true, false)..first.start;
54
54
  if text[space.clone()].chars().count() == 1 {
55
55
  continue;
56
56
  }
@@ -31,6 +31,13 @@ impl<'ctx, 'tree> LocalVariables<'ctx, 'tree> {
31
31
  self.context.variable_analysis().is_variable_reference(node)
32
32
  }
33
33
 
34
+ /// Whether this `foo()` would read a local variable instead of calling anything once its
35
+ /// parentheses were gone. The call itself is a call whatever the name holds, so [`Self::is_lvar`]
36
+ /// says no about every part of it; the question is about the bare name a correction would leave.
37
+ pub(in crate::rules) fn shadows_a_local(&self, node: Node<'_>) -> bool {
38
+ self.context.variable_analysis().shadows_a_local(node)
39
+ }
40
+
34
41
  /// Where each variable in the file is written and where it is read.
35
42
  ///
36
43
  /// A cop that joins `VariableForce` through `after_leaving_scope` sees the whole table rather
@@ -439,12 +439,12 @@ fn trailing_range(source: &SourceFile, ranges: &[Range<usize>], range: &Range<us
439
439
 
440
440
  /// `range_with_surrounding_space(side: :left, newlines: true)`.
441
441
  fn grow_left(source: &SourceFile, range: Range<usize>) -> Range<usize> {
442
- final_pos(source.text(), range.start, false, true, false)..range.end
442
+ final_pos(source.text(), range.start, false, false, true, false)..range.end
443
443
  }
444
444
 
445
445
  /// `range_with_surrounding_space(side: :right)`, with the newline eaten only when asked.
446
446
  fn grow_right(source: &SourceFile, range: Range<usize>, newlines: bool) -> Range<usize> {
447
- range.start..final_pos(source.text(), range.end, true, newlines, false)
447
+ range.start..final_pos(source.text(), range.end, true, false, newlines, false)
448
448
  }
449
449
 
450
450
  fn grow_right_spaces_only(source: &SourceFile, range: Range<usize>) -> Range<usize> {
@@ -173,7 +173,7 @@ fn register(
173
173
  end: final_pos(
174
174
  context.source.text(),
175
175
  directive.range.end,
176
- true,
176
+ true, false,
177
177
  true,
178
178
  false,
179
179
  ),
@@ -34,7 +34,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
34
34
  let comma = final_pos(
35
35
  context.source.text(),
36
36
  arguments[arguments.len() - 2].range().end,
37
- true,
37
+ true, false,
38
38
  true,
39
39
  false,
40
40
  );
@@ -142,6 +142,10 @@ pub(in crate::rules) struct Analysis<'tree> {
142
142
  /// local from a receiverless call, and only the analysis knows which one the parser upstream
143
143
  /// would have built.
144
144
  lvars: HashSet<usize>,
145
+ /// The `foo()` calls whose name a local variable already holds. The parentheses make these
146
+ /// calls whatever the name resolves to, so nothing here is an `lvar` -- but writing the same
147
+ /// name without them would be one, which is the question a cop that drops parentheses asks.
148
+ shadowed_calls: HashSet<usize>,
145
149
  }
146
150
 
147
151
  impl<'tree> Analysis<'tree> {
@@ -150,6 +154,12 @@ impl<'tree> Analysis<'tree> {
150
154
  self.lvars.contains(&node.id())
151
155
  }
152
156
 
157
+ /// Whether this `foo()` would stop being a call if its parentheses went away, because a local
158
+ /// variable of that name is in scope. Only the walk knows what is in scope at a given node.
159
+ pub(in crate::rules) fn shadows_a_local(&self, node: Node<'_>) -> bool {
160
+ self.shadowed_calls.contains(&node.id())
161
+ }
162
+
153
163
  pub(in crate::rules) fn run(root: Node<'tree>, source: &SourceFile) -> Self {
154
164
  let mut force = Force {
155
165
  source,
@@ -161,6 +171,7 @@ impl<'tree> Analysis<'tree> {
161
171
  heredocs: heredoc_bodies(root),
162
172
  scanned: HashSet::new(),
163
173
  lvars: HashSet::new(),
174
+ shadowed_calls: HashSet::new(),
164
175
  };
165
176
  force.push_scope(root, true);
166
177
  force.process_children(root);
@@ -169,6 +180,7 @@ impl<'tree> Analysis<'tree> {
169
180
  scopes: force.scopes,
170
181
  variables: force.variables,
171
182
  lvars: force.lvars,
183
+ shadowed_calls: force.shadowed_calls,
172
184
  }
173
185
  }
174
186
  }
@@ -207,6 +219,7 @@ struct Force<'tree, 'a> {
207
219
  /// Nodes already walked in an outer scope, which the scope they sit in must not walk again.
208
220
  scanned: HashSet<usize>,
209
221
  lvars: HashSet<usize>,
222
+ shadowed_calls: HashSet<usize>,
210
223
  }
211
224
 
212
225
  // ---------------------------------------------------------------------------
@@ -779,6 +792,9 @@ impl<'tree> Force<'tree, '_> {
779
792
  if self.binary_operator_on_a_local(node) {
780
793
  return;
781
794
  }
795
+ if self.name_is_taken_by_a_local(node) {
796
+ self.shadowed_calls.insert(node.id());
797
+ }
782
798
  if let Some(method) = node.field("method")
783
799
  && self.text(method) == "binding"
784
800
  && opaque_binding_argument(node)
@@ -804,6 +820,24 @@ impl<'tree> Force<'tree, '_> {
804
820
  }
805
821
  }
806
822
 
823
+ /// Whether `foo()` is written where a local variable already holds the name `foo`.
824
+ ///
825
+ /// The empty parentheses make it a call either way, so no `lvar` is built here and the name is
826
+ /// not a read of the variable. Dropping them would leave a bare name, and that one _is_ the
827
+ /// variable -- which is why a cop that offers to drop them has to leave this call alone.
828
+ fn name_is_taken_by_a_local(&self, node: Node<'tree>) -> bool {
829
+ if node.field("receiver").is_some() {
830
+ return false;
831
+ }
832
+ let (Some(method), Some(arguments)) = (node.field("method"), node.field("arguments"))
833
+ else {
834
+ return false;
835
+ };
836
+ method.kind_str() == "identifier"
837
+ && arguments.named_child_count() == 0
838
+ && self.find_variable(self.text(method)).is_some()
839
+ }
840
+
807
841
  /// Whether the call is really `local & expr` or `local * expr`. Ruby resolves the ambiguity by
808
842
  /// what it has already seen: once the name is a local variable, the `&` is an operator and not
809
843
  /// the start of a block-pass argument. tree-sitter has no scope to consult and always reads the
@@ -47,7 +47,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
47
47
  // `removal_range`: everything from the comparison's own dot or operator, with the blank in
48
48
  // front of it.
49
49
  let removal =
50
- final_pos(context.source.text(), dot.start, false, true, false)..node.end_byte();
50
+ final_pos(context.source.text(), dot.start, false, false, true, false)..node.end_byte();
51
51
  offenses.push(
52
52
  context
53
53
  .offense(format!("Use `{replacement}` instead."), range)
@@ -101,7 +101,7 @@ fn removal(context: &RuleContext<'_>, subject: Node<'_>) -> Option<std::ops::Ran
101
101
  term.start_byte()..position
102
102
  };
103
103
  // `range_with_surrounding_space(side: :right, newlines: false)`.
104
- Some(range.start..final_pos(text, range.end, true, false, false))
104
+ Some(range.start..final_pos(text, range.end, true, false, false, false))
105
105
  }
106
106
 
107
107
  /// `node.parent.and_type?`: `&&` and `and`, which are one node type upstream.
@@ -39,8 +39,8 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
39
39
  let mut edits = vec![
40
40
  // `range_with_surrounding_space(node.loc.keyword, newlines: false)`.
41
41
  Edit {
42
- start: final_pos(text, keyword.start_byte(), false, false, false),
43
- end: final_pos(text, keyword.end_byte(), true, false, false),
42
+ start: final_pos(text, keyword.start_byte(), false, false, false, false),
43
+ end: final_pos(text, keyword.end_byte(), true, false, false, false),
44
44
  replacement: String::new(),
45
45
  safe: true,
46
46
  },
@@ -65,8 +65,8 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
65
65
  }
66
66
  if let Some(close) = super::conditional::token(block, &["}", "end"]) {
67
67
  edits.push(Edit {
68
- start: final_pos(text, close.start_byte(), false, false, false),
69
- end: final_pos(text, close.end_byte(), true, false, false),
68
+ start: final_pos(text, close.start_byte(), false, false, false, false),
69
+ end: final_pos(text, close.end_byte(), true, false, false, false),
70
70
  replacement: String::new(),
71
71
  safe: true,
72
72
  });
@@ -93,14 +93,14 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
93
93
  continue;
94
94
  };
95
95
  edits.push(removal(
96
- final_pos(text, close.start_byte(), false, true, false)..close.end_byte(),
96
+ final_pos(text, close.start_byte(), false, false, true, false)..close.end_byte(),
97
97
  ));
98
98
  }
99
99
  // `remove_assignment`: the blank after it goes too, line break included.
100
100
  (None, Some(assignment)) => {
101
- let end = final_pos(text, assignment.end_byte(), true, true, false);
101
+ let end = final_pos(text, assignment.end_byte(), true, false, true, false);
102
102
  edits.push(removal(
103
- assignment.start_byte()..final_pos(text, end, true, false, false),
103
+ assignment.start_byte()..final_pos(text, end, true, false, false, false),
104
104
  ));
105
105
  }
106
106
  (None, None) => continue,
@@ -128,7 +128,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
128
128
  && context.source.node_text(sibling) == variable.name
129
129
  {
130
130
  edits.push(removal(
131
- final_pos(text, sibling.start_byte(), false, true, false)..sibling.end_byte(),
131
+ final_pos(text, sibling.start_byte(), false, false, true, false)..sibling.end_byte(),
132
132
  ));
133
133
  }
134
134
  edits.push(Edit {
@@ -40,7 +40,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
40
40
  continue;
41
41
  };
42
42
  let dot_source = context.source.node_text(dot);
43
- let start = final_pos(context.source.text(), dot.start_byte(), false, true, false);
43
+ let start = final_pos(context.source.text(), dot.start_byte(), false, false, true, false);
44
44
  let mut edits = vec![
45
45
  Edit {
46
46
  start,
@@ -34,7 +34,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
34
34
  };
35
35
  // `range_with_surrounding_space(side: :left)`: the conversion goes with the blank in front
36
36
  // of it, so a chain written over two lines closes up.
37
- let start = final_pos(context.source.text(), dot.start_byte(), false, true, false);
37
+ let start = final_pos(context.source.text(), dot.start_byte(), false, false, true, false);
38
38
  offenses.push(
39
39
  context
40
40
  .offense(
@@ -46,7 +46,7 @@ fn require_parentheses(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
46
46
  continue;
47
47
  };
48
48
  let written = super::nodes::children(arguments);
49
- if written.is_empty() || is_parenthesized(arguments) {
49
+ if written.is_empty() || is_parenthesized(node, arguments, context) {
50
50
  continue;
51
51
  }
52
52
  // `args_begin`: the character after the selector, which is the blank before the arguments.
@@ -164,10 +164,23 @@ fn argument_list<'tree>(node: Node<'tree>) -> Option<Node<'tree>> {
164
164
 
165
165
  /// `parenthesized?`: the parentheses belong to the call rather than to its first argument, which is
166
166
  /// what `foo(1)` has and `foo (1)` does not.
167
- fn is_parenthesized(arguments: Node<'_>) -> bool {
168
- arguments
169
- .child(0)
170
- .is_some_and(|first| !first.is_named() && first.kind_str() == "(")
167
+ ///
168
+ /// Upstream settles this while lexing -- a `(` written straight against the selector opens the call
169
+ /// -- so the answer is adjacency, not shape. Reading the shape instead is wrong in one direction:
170
+ /// the grammar here usually gives the call's own parentheses as `(` and `)` tokens of the argument
171
+ /// list, but for some receivers it wraps them in a `parenthesized_statements` node, which then reads
172
+ /// as `foo (1)` and makes a properly parenthesized call look bare.
173
+ ///
174
+ /// `expect(x).to receive(:y) do ... end.at_least(:once)` is such a receiver: the `at_least(:once)`
175
+ /// there parses to the wrapped shape while the same call after a simpler receiver does not.
176
+ fn is_parenthesized(node: Node<'_>, arguments: Node<'_>, context: &RuleContext<'_>) -> bool {
177
+ let Some(selector) = selector_end(node) else {
178
+ return false;
179
+ };
180
+ // The argument list begins where the arguments do, so a `(` of its own is the call's only when
181
+ // nothing separates it from the selector.
182
+ arguments.start_byte() == selector
183
+ && context.source.text().as_bytes().get(selector) == Some(&b'(')
171
184
  }
172
185
 
173
186
  /// `omit_parentheses`.
@@ -195,7 +208,13 @@ fn omit_parentheses(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
195
208
  |node| omission_edits(*node, context),
196
209
  |node| omission_range(*node),
197
210
  // 本家の `verified_by_reparse(@pending_omit_offenses)` は追加のオプションを渡さない。
198
- crate::rules::support::Verification::default(),
211
+ // `fold_empty_call_parentheses` は本家の設定ではなく、`foo()` と `foo` を 1 つの
212
+ // ノードにする本家のパーサに合わせるためのもの。名前を局所変数が持っている呼び出しは
213
+ // `omits` が先に外している。
214
+ crate::rules::support::Verification {
215
+ fold_empty_call_parentheses: true,
216
+ ..Default::default()
217
+ },
199
218
  );
200
219
  verified.extend(with_heredoc);
201
220
  verified.sort_by_key(tree_sitter::Node::start_byte);
@@ -243,11 +262,15 @@ impl Omission {
243
262
  let Some(arguments) = argument_list(node) else {
244
263
  return false;
245
264
  };
246
- if !is_parenthesized(arguments) {
265
+ if !is_parenthesized(node, arguments, context) {
247
266
  return false;
248
267
  }
249
268
  let written = super::nodes::children(arguments);
250
- !inside_endless_method_def(node, &written, context)
269
+ // Upstream's reparse settles this one: `foo()` is a call, `foo` is the local variable of
270
+ // that name, and the two trees do not match. The comparison here cannot tell them apart,
271
+ // so the candidate never reaches it.
272
+ !locals.shadows_a_local(node)
273
+ && !inside_endless_method_def(node, &written, context)
251
274
  && !hash_value_omission_needs_parentheses(node, &written, context)
252
275
  && !syntax_like_method_call(node, context)
253
276
  && !before_constant_resolution(node, context)
@@ -272,7 +295,7 @@ impl Omission {
272
295
  || call_in_optional_arguments(node, context)
273
296
  || call_in_single_line_inheritance(node, context)
274
297
  || (self.multiline && is_multiline(node, arguments, context))
275
- || (self.chaining && chained_with_parentheses(node))
298
+ || (self.chaining && chained_with_parentheses(node, context))
276
299
  || assignment_in_condition(node, context)
277
300
  || forwards_anonymous_rest_arguments(written)
278
301
  }
@@ -488,7 +511,9 @@ fn call_with_ambiguous_arguments(
488
511
  return true;
489
512
  }
490
513
  let _ = arguments;
491
- if hash_literal_in_arguments(node, written, context, locals) || ambiguous_range_argument(written) {
514
+ if hash_literal_in_arguments(node, written, context, locals)
515
+ || ambiguous_range_argument(written)
516
+ {
492
517
  return true;
493
518
  }
494
519
  descendants(node, context)
@@ -567,7 +592,7 @@ fn call_in_single_line_inheritance(node: Node<'_>, context: &RuleContext<'_>) ->
567
592
  }
568
593
 
569
594
  /// `allowed_chained_call_with_parentheses?`: the call it hangs off already writes its own.
570
- fn chained_with_parentheses(node: Node<'_>) -> bool {
595
+ fn chained_with_parentheses(node: Node<'_>, context: &RuleContext<'_>) -> bool {
571
596
  let mut current = node;
572
597
  loop {
573
598
  let Some(previous) = current
@@ -576,7 +601,9 @@ fn chained_with_parentheses(node: Node<'_>) -> bool {
576
601
  else {
577
602
  return false;
578
603
  };
579
- if argument_list(previous).is_some_and(is_parenthesized) {
604
+ if argument_list(previous)
605
+ .is_some_and(|arguments| is_parenthesized(previous, arguments, context))
606
+ {
580
607
  return true;
581
608
  }
582
609
  current = previous;
@@ -697,10 +724,7 @@ fn inside_string_interpolation(node: Node<'_>, context: &RuleContext<'_>) -> boo
697
724
 
698
725
  /// The node's descendants, leaving out the block a call carries: upstream's parser wraps the block
699
726
  /// around the call rather than hanging it off it, so a block is never a call's descendant there.
700
- fn descendants<'tree>(
701
- node: Node<'tree>,
702
- context: &'tree RuleContext<'_>,
703
- ) -> Vec<Node<'tree>> {
727
+ fn descendants<'tree>(node: Node<'tree>, context: &'tree RuleContext<'_>) -> Vec<Node<'tree>> {
704
728
  let mut out = Vec::new();
705
729
  let block = node.field("block").map(|block| block.id());
706
730
  for child in super::nodes::children(node) {
@@ -38,7 +38,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
38
38
  let start = final_pos(
39
39
  context.source.text(),
40
40
  keyword.start_byte(),
41
- false,
41
+ false, false,
42
42
  false,
43
43
  false,
44
44
  );
@@ -61,7 +61,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
61
61
  }
62
62
  // `range_with_surrounding_space(arguments_range(node), side: :left)`.
63
63
  let arguments_range =
64
- support::final_pos(text, first.start_byte(), false, true, false)..last.end_byte();
64
+ support::final_pos(text, first.start_byte(), false, false, true, false)..last.end_byte();
65
65
  // A list that starts on a later line leaves the name alone on the first one.
66
66
  if context.source.line_column(arguments_range.start).0 != opening_line {
67
67
  let prefix = node.start_byte() + DEF_LENGTH..parameters.start_byte();
@@ -122,12 +122,13 @@ fn reads_as_binary_and(
122
122
  .ends_with([' ', '\t', '\n'])
123
123
  }
124
124
 
125
- /// Every blank, line continuation and newline before `start`.
125
+ /// `range_with_surrounding_space(first_arg.begin, side: :left, whitespace: true,
126
+ /// continuations: true)`.
127
+ ///
128
+ /// The line continuation is the part that matters: walking back over whitespace alone stops at the
129
+ /// `\` and leaves it in the source, so the `(` that replaces the run lands after it and writes
130
+ /// `foo \(bar)` -- which is not Ruby. The shared walk takes the `\` and its line break together,
131
+ /// since a backslash only ends a line when the break follows it.
126
132
  fn leading_space(context: &RuleContext<'_>, start: usize) -> usize {
127
- let bytes = context.source.text().as_bytes();
128
- let mut position = start;
129
- while position > 0 && bytes[position - 1].is_ascii_whitespace() {
130
- position -= 1;
131
- }
132
- position
133
+ crate::rules::support::final_pos(context.source.text(), start, false, true, true, true)
133
134
  }
@@ -38,8 +38,8 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
38
38
  == context.source.line_column(block.end_byte()).0
39
39
  {
40
40
  // `range_with_surrounding_space(body.source_range)`.
41
- support::final_pos(text, body.start_byte(), false, true, false)
42
- ..support::final_pos(text, body.end_byte(), true, true, false)
41
+ support::final_pos(text, body.start_byte(), false, false, true, false)
42
+ ..support::final_pos(text, body.end_byte(), true, false, true, false)
43
43
  } else {
44
44
  // `range_by_whole_lines(body.source_range, include_final_newline: true)`.
45
45
  whole_lines(body.byte_range(), context)
@@ -76,10 +76,10 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
76
76
  // `ParenthesesCorrector.correct`: the argument's own parentheses go away.
77
77
  edits.push(remove(
78
78
  list.start_byte()
79
- ..support::final_pos(text, list.start_byte() + 1, true, false, true),
79
+ ..support::final_pos(text, list.start_byte() + 1, true, false, false, true),
80
80
  ));
81
81
  edits.push(remove(
82
- support::final_pos(text, list.end_byte() - 1, false, true, false)..list.end_byte(),
82
+ support::final_pos(text, list.end_byte() - 1, false, false, true, false)..list.end_byte(),
83
83
  ));
84
84
  edits.push(insert(selector.end_byte(), " "));
85
85
  edits.push(insert(node.start_byte(), "("));
@@ -31,6 +31,11 @@ pub(super) fn correct(context: &RuleContext<'_>, node: Node<'_>) -> Vec<Edit> {
31
31
  safe: true,
32
32
  },
33
33
  Edit {
34
+ // `remove_close_paren`: `range_with_surrounding_space(side: :left, newlines: newlines)`.
35
+ // `whitespace` and `continuations` both stay at their default of `false`, so the walk
36
+ // stops at a `\` that ends the line and leaves it standing. The `continuations: true`
37
+ // walk belongs to `parens_range`, which is a *different* range that upstream builds
38
+ // only when a comma would be left stranded -- see `orphaned_comma_start` below.
34
39
  start: super::ranges::extended_left(text, close, newlines),
35
40
  end: range.end,
36
41
  replacement: String::new(),
@@ -72,13 +77,17 @@ fn orphaned_comma_start(context: &RuleContext<'_>, close: usize) -> Option<usize
72
77
  return None;
73
78
  }
74
79
  // `range_with_surrounding_space(side: :left, newlines: true, whitespace: true,
75
- // continuations: true)`.
76
- let bytes = context.source.text().as_bytes();
77
- let mut start = close;
78
- while start > 0 && bytes[start - 1].is_ascii_whitespace() {
79
- start -= 1;
80
- }
81
- Some(start)
80
+ // continuations: true)`. The `continuations` step is the one that matters: a `\` ending the
81
+ // line would otherwise be left behind once the whitespace around it goes, stranding the
82
+ // backslash in front of the comma and producing source that does not parse.
83
+ Some(crate::rules::support::final_pos(
84
+ context.source.text(),
85
+ close,
86
+ false,
87
+ true,
88
+ true,
89
+ true,
90
+ ))
82
91
  }
83
92
 
84
93
  /// `ternary_condition?(node) && next_char_is_question_mark?(node)`.
@@ -66,8 +66,8 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
66
66
  let range = if context.source.node_text(arguments).starts_with('(') {
67
67
  arguments.byte_range()
68
68
  } else {
69
- support::final_pos(text, only.start_byte(), false, false, false)
70
- ..support::final_pos(text, only.end_byte(), true, false, false)
69
+ support::final_pos(text, only.start_byte(), false, false, false, false)
70
+ ..support::final_pos(text, only.end_byte(), true, false, false, false)
71
71
  };
72
72
  offenses.push(
73
73
  context
data/src/rules/support.rs CHANGED
@@ -16,18 +16,53 @@ pub(crate) fn final_pos(
16
16
  text: &str,
17
17
  position: usize,
18
18
  forward: bool,
19
+ continuations: bool,
19
20
  newlines: bool,
20
21
  whitespace: bool,
21
22
  ) -> usize {
22
23
  let mut position = move_pos(text, position, forward, true, |byte| {
23
24
  matches!(byte, b' ' | b'\t')
24
25
  });
26
+ position = move_pos_str(text, position, forward, continuations, "\\\n");
25
27
  position = move_pos(text, position, forward, newlines, |byte| byte == b'\n');
28
+ // `/\s/` upstream, which is Ruby's and takes the vertical tab that Rust's
29
+ // `is_ascii_whitespace` leaves out.
26
30
  move_pos(text, position, forward, whitespace, |byte| {
27
- byte.is_ascii_whitespace()
31
+ matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c)
28
32
  })
29
33
  }
30
34
 
35
+ /// `move_pos_str`: the same walk over a fixed run of characters rather than a class.
36
+ ///
37
+ /// The one caller wants `\` and a line break, which a class cannot express -- a backslash only ends
38
+ /// the line when the break follows it, and eating it on its own would join two statements.
39
+ fn move_pos_str(
40
+ text: &str,
41
+ mut position: usize,
42
+ forward: bool,
43
+ enabled: bool,
44
+ needle: &str,
45
+ ) -> usize {
46
+ if !enabled {
47
+ return position;
48
+ }
49
+ let width = needle.len();
50
+ loop {
51
+ let found = match forward {
52
+ true => text.get(position..position + width) == Some(needle),
53
+ false => position >= width && text.get(position - width..position) == Some(needle),
54
+ };
55
+ if !found {
56
+ return position;
57
+ }
58
+ position = if forward {
59
+ position + width
60
+ } else {
61
+ position - width
62
+ };
63
+ }
64
+ }
65
+
31
66
  fn move_pos(
32
67
  text: &str,
33
68
  mut position: usize,
@@ -173,6 +208,16 @@ pub(crate) struct Verification {
173
208
  /// without changing the string it builds. Folding every concatenation to a canonical form is
174
209
  /// what lets the two compare equal.
175
210
  pub(crate) fold_string_concatenation: bool,
211
+ /// `foo()` and `foo` are one and the same node upstream: the parser builds nothing for an empty
212
+ /// argument list, and a receiverless call carrying only its name is spelled as a bare name.
213
+ /// The grammar here keeps the two apart -- `call` with an `argument_list` that has no children
214
+ /// on one side, a lone `identifier` on the other -- so a correction that drops nothing but a
215
+ /// pair of empty parentheses never compares equal without this.
216
+ ///
217
+ /// Upstream's parser tells `(send nil :foo)` from `(lvar :foo)` by remembering what it has seen
218
+ /// assigned, which a tree already built cannot. The caller that turns this on has to keep the
219
+ /// calls whose name a local variable holds out on its own.
220
+ pub(crate) fold_empty_call_parentheses: bool,
176
221
  }
177
222
 
178
223
  /// `ReparsedEquivalence#verified_by_reparse`: the items whose corrections leave a tree equal to the
@@ -321,12 +366,18 @@ fn normalized(node: Node<'_>, text: &str, verification: Verification) -> Sexp {
321
366
  return literal;
322
367
  }
323
368
  let mut children: Vec<Sexp> = Vec::new();
369
+ let mut dropped_parentheses = false;
324
370
  let mut cursor = node.walk();
325
371
  for child in node.named_children(&mut cursor) {
326
372
  // Comments are invisible to the tree upstream compares.
327
373
  if child.kind_str() == "comment" {
328
374
  continue;
329
375
  }
376
+ // So is the empty argument list of `foo()`, which upstream's parser never builds.
377
+ if verification.fold_empty_call_parentheses && empty_argument_list(child) {
378
+ dropped_parentheses = true;
379
+ continue;
380
+ }
330
381
  let normalized = normalized(child, text, verification);
331
382
  // `splice_nested_sequences`: `x; (a; b)` and `x; a; b` are the same statement list.
332
383
  match SEQUENCES.contains(&node.kind_str()) && normalized.label == BEGIN {
@@ -346,6 +397,9 @@ fn normalized(node: Node<'_>, text: &str, verification: Verification) -> Sexp {
346
397
  children,
347
398
  };
348
399
  }
400
+ if dropped_parentheses && let Some(bare) = written_without_parentheses(node, &mut children) {
401
+ return bare;
402
+ }
349
403
  if !verification.fold_string_concatenation {
350
404
  let label = label_of(node, text, children.is_empty());
351
405
  return rotate_same_operator(Sexp { label, children });
@@ -358,6 +412,39 @@ fn normalized(node: Node<'_>, text: &str, verification: Verification) -> Sexp {
358
412
  fold_string_concatenation(rotate_same_operator(Sexp { label, children }))
359
413
  }
360
414
 
415
+ /// Whether the node is the `()` of `foo()`, which upstream's parser leaves no node behind for.
416
+ fn empty_argument_list(node: Node<'_>) -> bool {
417
+ node.kind_str() == "argument_list" && node.named_child_count() == 0
418
+ }
419
+
420
+ /// The label a bare `yield` carries, which is a leaf holding nothing but the keyword.
421
+ const BARE_YIELD: &str = "yield yield";
422
+
423
+ /// What a call whose empty parentheses were just dropped reads as, when dropping them leaves the
424
+ /// spelling the source would have used without them.
425
+ ///
426
+ /// `foo()` and `foo` reach upstream as one node, and so do `yield()` and `yield`. Here the first of
427
+ /// each pair is a parent with an `argument_list` under it and the second is written with no node in
428
+ /// between at all -- a lone `identifier`, a childless `yield` -- so the two only compare equal once
429
+ /// the first is put in the shape of the second. A receiver or a block means something is still
430
+ /// written around the name and the spellings stay apart.
431
+ fn written_without_parentheses(node: Node<'_>, children: &mut Vec<Sexp>) -> Option<Sexp> {
432
+ match node.kind_str() {
433
+ "yield" if children.is_empty() => Some(Sexp {
434
+ label: BARE_YIELD.to_owned(),
435
+ children: Vec::new(),
436
+ }),
437
+ "call"
438
+ if children.len() == 1
439
+ && node.field("receiver").is_none()
440
+ && node.field("block").is_none() =>
441
+ {
442
+ children.pop()
443
+ }
444
+ _ => None,
445
+ }
446
+ }
447
+
361
448
  /// The kinds whose contents upstream's parser reads as whitespace-separated words, one node each.
362
449
  const WORD_ARRAYS: &[&str] = &["string_array", "symbol_array"];
363
450
 
@@ -386,7 +473,7 @@ fn word_array(node: Node<'_>, text: &str) -> Option<Sexp> {
386
473
  }
387
474
  continue;
388
475
  }
389
- if character.is_ascii_whitespace() {
476
+ if separates_words(character) {
390
477
  if !word.is_empty() {
391
478
  words.push(word_leaf(std::mem::take(&mut word)));
392
479
  }
@@ -403,6 +490,15 @@ fn word_array(node: Node<'_>, text: &str) -> Option<Sexp> {
403
490
  })
404
491
  }
405
492
 
493
+ /// Whether the character ends a word of a `%w` or `%i` literal.
494
+ ///
495
+ /// Ruby's lexer asks `ISSPACE`, which is the six ASCII spacing characters and nothing else -- a
496
+ /// no-break space is part of the word. `char::is_ascii_whitespace` is the same set minus the
497
+ /// vertical tab, so spelling the six out is what matches (measured: `%w[a\vb]` is two words).
498
+ fn separates_words(character: char) -> bool {
499
+ matches!(character, ' ' | '\t' | '\n' | '\r' | '\u{b}' | '\u{c}')
500
+ }
501
+
406
502
  fn word_leaf(text: String) -> Sexp {
407
503
  Sexp {
408
504
  label: format!("word {text}"),
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sonicop
3
3
  version: !ruby/object:Gem::Version
4
- version: 26.8.105
4
+ version: 26.8.106
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yohei