sonicop 26.8.104 → 26.8.105

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: 267d186a55418ccb97a890bcea5da9eda428dd6567ae4282b61755779783d6e2
4
- data.tar.gz: baa0dfdcff20c0a1888222298deab0fd2af37fd9fcd25ef8beb2871bb89083fc
3
+ metadata.gz: f3bc83b9cca7222947c4ef2dc8c0d18456a627dc98650427dea70f68340df060
4
+ data.tar.gz: 3567b01985bf947c14427804f847c3530d324eead1b4d56649a18a9e8f69491d
5
5
  SHA512:
6
- metadata.gz: 1f983d82729522d8a18d1155a4d9b1b6d020b837e987abc2d5bbf14558587997f1abf8808e69ae1e36132b6d15f8cac0d0cf440f116cb9280b98397d7b8f3c03
7
- data.tar.gz: 2ffd0dd8db704b600fda62f08c1f61455fc36310f1adc33d4ae6f1bc1b2a53998c8034103a871e4e9e2b239f798a181a66982dafbda3d9b6ae0f5b74845d0887
6
+ metadata.gz: b10fdaec517230cdfaa915213cf0df5dfab472c65776e0305994f44a49296fb0147c72fd0c1628d66af39052a4985c351e86023f053fbbc50a3363b1f66574f8
7
+ data.tar.gz: b227a8713413f893f96991859278e05e4c4e4916f6ebc564d01beb0a84e7ed4515002b07c8dc6dc0c1f9ba753e625b8f0d278716894a97a50487db29c9f2adcf
data/Cargo.lock CHANGED
@@ -742,7 +742,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
742
742
 
743
743
  [[package]]
744
744
  name = "sonicop"
745
- version = "26.8.104"
745
+ version = "26.8.105"
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.104"
3
+ version = "26.8.105"
4
4
  edition = "2024"
5
5
  rust-version = "1.85"
6
6
  description = "A fast, native RuboCop-compatible Ruby linter and formatter"
data/README.ja.md CHANGED
@@ -119,7 +119,7 @@ Mastodon(15,286 件)で、過剰も不足もメタデータ差もありま
119
119
  Mastodon でバイト単位に一致します。この 2 つは死守ラインとして扱い、バイト一致が崩れた場合は
120
120
  既知差分ではなく退行として直します。
121
121
 
122
- コマンド、コーパスごとの数値、この種の計測が誤った結論を導く 2 つの罠は
122
+ コマンド、この数値を測ったコーパスのコミット、この種の計測が誤った結論を導く 2 つの罠は
123
123
  [CONFORMANCE.md](CONFORMANCE.md) にまとめています。
124
124
 
125
125
  ### 性能
data/README.md CHANGED
@@ -129,8 +129,8 @@ parser recovers from an error and emits diagnostics a tree-sitter parse cannot r
129
129
  Autocorrect is byte-identical on RuboCop's own tree and on Mastodon, the two corpora held as a hard
130
130
  line: a change that breaks byte equality there is a regression, not a new known divergence.
131
131
 
132
- See [CONFORMANCE.md](CONFORMANCE.md) for the commands, the per-corpus numbers, and the two ways a
133
- measurement of this kind can mislead you.
132
+ See [CONFORMANCE.md](CONFORMANCE.md) for the commands, the corpus commits these counts were
133
+ measured at, and the two ways a measurement of this kind can mislead you.
134
134
 
135
135
  ### Performance
136
136
 
@@ -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.104'
5
+ VERSION = '26.8.105'
6
6
  end
data/src/directives.rs CHANGED
@@ -36,8 +36,28 @@ impl DirectiveState {
36
36
  source: &SourceFile,
37
37
  comment_ranges: &[Range<usize>],
38
38
  prevents_directive_disabling: bool,
39
+ ) -> Self {
40
+ Self::parse_opting_in(source, comment_ranges, prevents_directive_disabling, &[])
41
+ }
42
+
43
+ /// The same, plus the cops the configuration switched off that this file asks back with an
44
+ /// `enable` directive.
45
+ ///
46
+ /// `CommentConfig#inject_disabled_cops_directives` gives every cop the configuration switched
47
+ /// off a disable written at `-Float::INFINITY`, so a real `# rubocop:enable Foo` closes that
48
+ /// range and Foo reports from there down. Only the cops the file names are seeded here: the
49
+ /// snapshot is cloned for every line, and the 200-odd cops RuboCop ships switched off would
50
+ /// make that clone the cost of the run.
51
+ pub fn parse_opting_in(
52
+ source: &SourceFile,
53
+ comment_ranges: &[Range<usize>],
54
+ prevents_directive_disabling: bool,
55
+ disabled_by_config: &[&str],
39
56
  ) -> Self {
40
57
  let mut current = Snapshot::default();
58
+ for cop in disabled_by_config {
59
+ current.cops.insert((*cop).to_owned(), None);
60
+ }
41
61
  let mut stack = Vec::new();
42
62
  let mut line_states = Vec::with_capacity(source.line_count());
43
63
  // Where the first comment written on each line begins, as an offset into that line. Only
@@ -157,6 +177,34 @@ impl DirectiveState {
157
177
  }
158
178
  }
159
179
 
180
+ /// `CommentConfig#opt_in_cops`: the cops an `enable` directive in the file names.
181
+ ///
182
+ /// This is what can put a cop the configuration switched off back on duty -- upstream keeps the
183
+ /// registry on standby and mobilizes one when a file asks for it. `enable all` names none of them,
184
+ /// and a `push` directive is not an `enable`, so neither opts a cop in.
185
+ pub fn opted_in_cops(source: &SourceFile, comment_ranges: &[Range<usize>]) -> HashSet<String> {
186
+ let mut names = HashSet::new();
187
+ // `@no_directives`: upstream skips the whole analysis, injection included, for a file that does
188
+ // not mention RuboCop at all. Most files do not, and this is the only reason the scan can be
189
+ // afforded on every one of them.
190
+ if !source.text().contains("rubocop") {
191
+ return names;
192
+ }
193
+ for range in comment_ranges {
194
+ let (line_number, _) = source.line_column(range.start);
195
+ let column = range.start - source.line_start(line_number);
196
+ let line = source.line(line_number);
197
+ let Some(directive) = parse_directive(line, column) else {
198
+ continue;
199
+ };
200
+ if !matches!(directive.action, Action::Enable) {
201
+ continue;
202
+ }
203
+ names.extend(directive.cops.iter().filter(|cop| *cop != "all").cloned());
204
+ }
205
+ names
206
+ }
207
+
160
208
  fn apply_disable(state: &mut Snapshot, cops: &[String], reason: Option<String>) {
161
209
  if cops.iter().any(|cop| cop == "all") {
162
210
  state.all = true;
data/src/engine.rs CHANGED
@@ -114,6 +114,11 @@ impl Selection {
114
114
  /// `Exclude` reads the path being inspected, so it is all that stays per-file.
115
115
  pub(crate) struct RulePlan {
116
116
  entries: Vec<PlannedRule>,
117
+ /// The cops the configuration switched off that a file could ask back with an `enable`
118
+ /// directive. Upstream keeps the whole registry on standby for exactly this and mobilizes one
119
+ /// when `CommentConfig#opt_in_cops` names it, so the selection is settled here but the decision
120
+ /// is per file.
121
+ standby: Vec<PlannedRule>,
117
122
  /// What the cop that reads directives needs to know about the cops that exist. Built once per
118
123
  /// configuration because it depends on nothing in the file, and only when that cop will run.
119
124
  directive_registry: Option<CopRegistry>,
@@ -130,29 +135,36 @@ struct PlannedRule {
130
135
 
131
136
  impl RulePlan {
132
137
  pub(crate) fn build(config: &Config, selection: &Selection) -> Self {
133
- let entries = rules()
134
- .enumerate()
135
- .filter(|(_, rule)| {
136
- let enabled = config.rule_enabled_with_pending(
137
- rule.name,
138
- selection.enable_pending,
139
- selection.disable_pending,
140
- );
141
- selection.includes(rule.name, enabled, config.rule_safe(rule.name))
142
- })
143
- .map(|(index, rule)| PlannedRule {
144
- rule,
145
- index,
146
- severity: config
147
- .cop_value::<String>(rule.name, "Severity")
148
- .and_then(|value| Severity::parse(&value))
149
- .unwrap_or(rule.severity),
150
- // `AutocorrectLogic#safe_autocorrect?` is both halves: a cop whose analysis is
151
- // unsafe cannot have a safe correction either, however `SafeAutoCorrect` was left.
152
- safe_autocorrect: config.rule_safe(rule.name)
153
- && config.rule_safe_autocorrect(rule.name),
154
- })
155
- .collect::<Vec<_>>();
138
+ let planned = |index: usize, rule: &'static Rule| PlannedRule {
139
+ rule,
140
+ index,
141
+ severity: config
142
+ .cop_value::<String>(rule.name, "Severity")
143
+ .and_then(|value| Severity::parse(&value))
144
+ .unwrap_or(rule.severity),
145
+ // `AutocorrectLogic#safe_autocorrect?` is both halves: a cop whose analysis is
146
+ // unsafe cannot have a safe correction either, however `SafeAutoCorrect` was left.
147
+ safe_autocorrect: config.rule_safe(rule.name)
148
+ && config.rule_safe_autocorrect(rule.name),
149
+ };
150
+ let mut entries = Vec::new();
151
+ let mut standby = Vec::new();
152
+ for (index, rule) in rules().enumerate() {
153
+ let safe = config.rule_safe(rule.name);
154
+ let enabled = config.rule_enabled_with_pending(
155
+ rule.name,
156
+ selection.enable_pending,
157
+ selection.disable_pending,
158
+ );
159
+ if selection.includes(rule.name, enabled, safe) {
160
+ entries.push(planned(index, rule));
161
+ } else if !enabled && selection.includes(rule.name, true, safe) {
162
+ // Only the configuration stands in the way, which is what an `enable` directive can
163
+ // undo. A cop the run itself left out (`--only`, `--except`, the safety filters)
164
+ // stays out however the file is written.
165
+ standby.push(planned(index, rule));
166
+ }
167
+ }
156
168
  let directive_registry = (selection.checks_redundant_directives()
157
169
  && entries
158
170
  .iter()
@@ -160,6 +172,7 @@ impl RulePlan {
160
172
  .then(|| CopRegistry::new(config, selection));
161
173
  Self {
162
174
  entries,
175
+ standby,
163
176
  directive_registry,
164
177
  }
165
178
  }
@@ -229,12 +242,27 @@ fn inspect_planned(
229
242
  let ast = crate::profile::phase(crate::profile::Phase::Index, || {
230
243
  AstIndex::new(tree.root_node())
231
244
  });
245
+ // `opted_in_standby_cops`: a cop the configuration switched off is put back on duty for this
246
+ // file when an `enable` directive names it. The names are read once and used twice -- to pick
247
+ // the cops out of the plan's standby list, and to seed the directive state so that only what
248
+ // the `enable` opens is reported.
249
+ let opted_in: Vec<&PlannedRule> = if plan.standby.is_empty() {
250
+ Vec::new()
251
+ } else {
252
+ let names = crate::directives::opted_in_cops(&source, ast.comment_ranges());
253
+ plan.standby
254
+ .iter()
255
+ .filter(|planned| names.contains(planned.rule.name))
256
+ .collect()
257
+ };
258
+ let disabled_by_config: Vec<&str> = opted_in.iter().map(|planned| planned.rule.name).collect();
232
259
  let directives = crate::profile::phase(crate::profile::Phase::Directives, || {
233
260
  (!selection.ignore_disable_comments).then(|| {
234
- DirectiveState::parse_for(
261
+ DirectiveState::parse_opting_in(
235
262
  &source,
236
263
  ast.comment_ranges(),
237
264
  config.prevents_directive_disabling(),
265
+ &disabled_by_config,
238
266
  )
239
267
  })
240
268
  });
@@ -276,8 +304,11 @@ fn inspect_planned(
276
304
  syntax_rule,
277
305
  syntax_severity,
278
306
  selection.correcting,
279
- );
280
- for planned in &plan.entries {
307
+ )
308
+ // `registry.disabled_names(config)`: whether the run switches any cop off at all, which is
309
+ // what an `# rubocop:enable all` has to undo. The standby list is that set already.
310
+ .with_disabled_cops(!plan.standby.is_empty());
311
+ for planned in plan.entries.iter().chain(opted_in.iter().copied()) {
281
312
  let rule = planned.rule;
282
313
  // `Cop::Base#relevant_file?`: a cop applies to a file its own `Include` reaches and its own
283
314
  // `Exclude` does not, which is how a `Bundler` cop stays off everything but a Gemfile.
@@ -1295,6 +1326,27 @@ pub fn corrected_text(
1295
1326
 
1296
1327
  const MAX_CORRECTION_PASSES: usize = 200;
1297
1328
 
1329
+ /// What a pass's text is remembered by, so that the loop can tell it has come round again.
1330
+ ///
1331
+ /// `Runner#check_for_infinite_loop` keeps `processed_source.checksum` rather than the text, and the
1332
+ /// difference shows on a file a cop keeps adding to. `Regexp.new("a\\d]b")` grows about 1.5x per
1333
+ /// pass under `Lint/UnescapedBracketInRegexp`, measured identical to upstream pass for pass, so
1334
+ /// holding every pass costs the sum of that series -- roughly three times the current text -- and
1335
+ /// comparing against every pass re-reads all of it. Neither form bounds the growth: that is
1336
+ /// upstream's defect, reproduced here rather than fixed.
1337
+ ///
1338
+ /// The length rides along with the hash because it is free and makes a collision take two
1339
+ /// coincidences instead of one. A collision would report a loop that is not there, which is a
1340
+ /// worse failure than the one being avoided.
1341
+ type SourceDigest = (u64, usize);
1342
+
1343
+ fn digest(text: &str) -> SourceDigest {
1344
+ use std::hash::{Hash, Hasher};
1345
+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
1346
+ text.hash(&mut hasher);
1347
+ (hasher.finish(), text.len())
1348
+ }
1349
+
1298
1350
  type OffenseKey = (usize, usize, &'static str, String, Severity);
1299
1351
 
1300
1352
  fn offense_key(offense: &Offense, source: &SourceFile) -> OffenseKey {
@@ -1448,7 +1500,7 @@ pub fn correct_file(
1448
1500
 
1449
1501
  let path = report.path.clone();
1450
1502
  let mut log = CorrectionLog::default();
1451
- let mut sources = vec![text.clone()];
1503
+ let mut sources = vec![digest(&text)];
1452
1504
  let mut rewritten = false;
1453
1505
  // Every pass re-inspects the same file under the same configuration, so the plan is resolved
1454
1506
  // once for the whole fixed-point loop.
@@ -1480,7 +1532,8 @@ pub fn correct_file(
1480
1532
 
1481
1533
  // Re-producing a source seen before means the passes are trading edits back and forth; the
1482
1534
  // repeat tells us which pass the cycle closed on.
1483
- let repeated = sources.iter().position(|source| *source == corrected);
1535
+ let corrected_digest = digest(&corrected);
1536
+ let repeated = sources.iter().position(|seen| *seen == corrected_digest);
1484
1537
  if pass == MAX_CORRECTION_PASSES || repeated.is_some() {
1485
1538
  let loop_start = repeated.unwrap_or_else(|| log.cops_by_pass.len().saturating_sub(1));
1486
1539
  let root_cause = log.root_cause(loop_start);
@@ -1497,7 +1550,7 @@ pub fn correct_file(
1497
1550
  });
1498
1551
  }
1499
1552
 
1500
- sources.push(corrected.clone());
1553
+ sources.push(corrected_digest);
1501
1554
  text = corrected;
1502
1555
  report = inspect_planned(
1503
1556
  path.clone(),
@@ -1524,7 +1577,7 @@ pub fn correct_until_stable(
1524
1577
  }
1525
1578
  }
1526
1579
 
1527
- /// The bytes to write back for corrected source, in the encoding the file declares for itself.
1580
+ /// The bytes to write back for corrected source, in the encoding the file was read in.
1528
1581
  ///
1529
1582
  /// This is the one place Sonicop knowingly departs from RuboCop. RuboCop's runner ends in a plain
1530
1583
  /// `File.write`, so a corrected Shift_JIS file comes back out as UTF-8 while its magic comment still
@@ -1532,9 +1585,17 @@ pub fn correct_until_stable(
1532
1585
  /// data loss on purpose, which is further than drop-in compatibility reaches. The divergence is
1533
1586
  /// recorded in `tests/conformance/known_divergences.yml`.
1534
1587
  ///
1588
+ /// That protection only applies to a file the declaration was actually needed for.
1589
+ /// [`decoded_source`] reaches for the declared encoding only when the bytes are not valid UTF-8, so
1590
+ /// a UTF-8 file naming some other encoding was read as UTF-8 and has to go back out as UTF-8 --
1591
+ /// `decoded_as_declared` says which happened, and is only asked once a declaration is found.
1592
+ /// Encoding such a file to the label it names rewrites bytes no cop asked to change: rails'
1593
+ /// `1_currencies_have_symbols.rb` declares `ISO-8859-15` and holds a UTF-8 `€`, and turning those
1594
+ /// three bytes into `\xa4` changes what the program says as surely as editing the literal would.
1595
+ ///
1535
1596
  /// `Err` when the correction cannot be represented in that encoding, so the caller leaves the file
1536
1597
  /// alone rather than writing a lossy approximation.
1537
- fn output_bytes(contents: &str) -> Result<Vec<u8>> {
1598
+ fn output_bytes(contents: &str, decoded_as_declared: impl FnOnce() -> bool) -> Result<Vec<u8>> {
1538
1599
  let Some(label) = encoding_declaration(contents) else {
1539
1600
  return Ok(contents.as_bytes().to_vec());
1540
1601
  };
@@ -1548,7 +1609,7 @@ fn output_bytes(contents: &str) -> Result<Vec<u8>> {
1548
1609
  let Some(encoding) = encoding_for_ruby_label(&label) else {
1549
1610
  return Ok(contents.as_bytes().to_vec());
1550
1611
  };
1551
- if encoding == encoding_rs::UTF_8 {
1612
+ if encoding == encoding_rs::UTF_8 || !decoded_as_declared() {
1552
1613
  return Ok(contents.as_bytes().to_vec());
1553
1614
  }
1554
1615
  let (bytes, _, unmappable) = encoding.encode(contents);
@@ -1561,8 +1622,11 @@ fn output_bytes(contents: &str) -> Result<Vec<u8>> {
1561
1622
  }
1562
1623
 
1563
1624
  pub fn write_corrected(path: &Path, contents: &str) -> Result<()> {
1564
- let bytes = output_bytes(contents)
1565
- .with_context(|| format!("refusing to rewrite {}", path.display()))?;
1625
+ // The file on disk is still the one that was read: the loop corrects in memory and writes once.
1626
+ let bytes = output_bytes(contents, || {
1627
+ fs::read(path).is_ok_and(|bytes| String::from_utf8(bytes).is_err())
1628
+ })
1629
+ .with_context(|| format!("refusing to rewrite {}", path.display()))?;
1566
1630
  let parent = path.parent().unwrap_or(Path::new("."));
1567
1631
  let permissions = fs::metadata(path)
1568
1632
  .ok()
@@ -2075,7 +2139,7 @@ mod tests {
2075
2139
  // RuboCop would write UTF-8 here and leave the file claiming cp932, which no longer loads.
2076
2140
  let corrected = "# encoding: cp932\nx = '\u{65e5}\u{672c}'\n";
2077
2141
 
2078
- let bytes = output_bytes(corrected).unwrap();
2142
+ let bytes = output_bytes(corrected, || true).unwrap();
2079
2143
 
2080
2144
  assert!(bytes.ends_with(b"x = '\x93\xfa\x96\x7b'\n"));
2081
2145
  }
@@ -2086,7 +2150,19 @@ mod tests {
2086
2150
  // very text the correction was meant to leave alone.
2087
2151
  let corrected = "# encoding: cp932\nx = '\u{1f363}'\n";
2088
2152
 
2089
- assert!(output_bytes(corrected).is_err());
2153
+ assert!(output_bytes(corrected, || true).is_err());
2154
+ }
2155
+
2156
+ #[test]
2157
+ fn a_utf8_file_that_names_another_encoding_goes_back_out_unchanged() {
2158
+ // rails' `1_currencies_have_symbols.rb`: the comment says ISO-8859-15, the bytes are UTF-8,
2159
+ // and `decoded_source` read it as UTF-8. Encoding the `€` to `\xa4` on the way out would
2160
+ // turn a three-character string into a one-character one -- a change no cop asked for.
2161
+ let corrected = "# coding: ISO-8859-15\nx = '\u{20ac}'\n";
2162
+
2163
+ let bytes = output_bytes(corrected, || false).unwrap();
2164
+
2165
+ assert_eq!(bytes, corrected.as_bytes());
2090
2166
  }
2091
2167
 
2092
2168
  #[test]
@@ -34,27 +34,28 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
34
34
  }
35
35
  // `private def foo`: the modifier and the definition are one line, and the `end` is
36
36
  // measured against whichever of the two the style names.
37
+ //
38
+ // Every other call falls through: the walk has to keep descending, since a call is
39
+ // what a block hangs off and a definition written inside one is still a definition.
37
40
  "call" => {
38
- let Some(definition) = def_modifier(node) else {
39
- continue;
40
- };
41
- let Some(keyword) = definition.child(0) else {
42
- continue;
43
- };
44
- let (base, column) = match align_with_def {
45
- true => (
46
- keyword.byte_range(),
47
- character_column(context, definition.start_byte()),
48
- ),
49
- false => (
50
- node.start_byte()..keyword.end_byte(),
51
- character_column(context, node.start_byte()),
52
- ),
53
- };
54
- if let Some(offense) = check_definition(context, definition, base, column) {
55
- offenses.push(offense);
41
+ if let Some(definition) = def_modifier(node)
42
+ && let Some(keyword) = definition.child(0)
43
+ {
44
+ let (base, column) = match align_with_def {
45
+ true => (
46
+ keyword.byte_range(),
47
+ character_column(context, definition.start_byte()),
48
+ ),
49
+ false => (
50
+ node.start_byte()..keyword.end_byte(),
51
+ character_column(context, node.start_byte()),
52
+ ),
53
+ };
54
+ if let Some(offense) = check_definition(context, definition, base, column) {
55
+ offenses.push(offense);
56
+ }
57
+ ignored.insert(definition.id());
56
58
  }
57
- ignored.insert(definition.id());
58
59
  }
59
60
  _ => {}
60
61
  }
@@ -179,8 +179,12 @@ fn traverse(
179
179
  MSG_WITHOUT_SAFE_ASSIGNMENT_ALLOWED
180
180
  };
181
181
  let offense = context.offense(message, operator.byte_range());
182
- offenses.push(match correction(context, node, allow_safe) {
183
- Some(edit) => offense.corrected_by(edit),
182
+ offenses.push(match correction(allow_safe, node) {
183
+ // `corrector.wrap(asgn_node, '(', ')')` is one action over the assignment, so
184
+ // both insertions have to hang off that range rather than off the operator.
185
+ Some(edits) => offense
186
+ .corrected_by_all(edits)
187
+ .corrections_anchored_at(node.byte_range()),
184
188
  None => offense,
185
189
  });
186
190
  }
@@ -198,7 +202,12 @@ fn traverse(
198
202
  /// it is not what the condition tests.
199
203
  fn discarded(node: Node<'_>) -> bool {
200
204
  node.parent()
201
- .filter(|parent| matches!(parent.kind_str(), "parenthesized_statements" | "interpolation"))
205
+ .filter(|parent| {
206
+ matches!(
207
+ parent.kind_str(),
208
+ "parenthesized_statements" | "interpolation"
209
+ )
210
+ })
202
211
  .is_some_and(|parent| statements(parent).len() > 1)
203
212
  }
204
213
 
@@ -207,28 +216,47 @@ fn discarded(node: Node<'_>) -> bool {
207
216
  fn statements(node: Node<'_>) -> Vec<Node<'_>> {
208
217
  let mut cursor = node.walk();
209
218
  node.named_children(&mut cursor)
210
- .filter(|child| !matches!(child.kind_str(), "empty_statement" | "comment" | "heredoc_body"))
219
+ .filter(|child| {
220
+ !matches!(
221
+ child.kind_str(),
222
+ "empty_statement" | "comment" | "heredoc_body"
223
+ )
224
+ })
211
225
  .collect()
212
226
  }
213
227
 
214
228
  /// The `=` the offense is reported at, which is `loc.operator` upstream.
215
229
  fn assignment_operator<'tree>(node: Node<'tree>) -> Option<Node<'tree>> {
216
230
  let mut cursor = node.walk();
217
- node.children(&mut cursor).find(|child| child.kind_str() == "=")
231
+ node.children(&mut cursor)
232
+ .find(|child| child.kind_str() == "=")
218
233
  }
219
234
 
220
235
  /// Wrapping the assignment in parentheses is the one correction: it says the assignment was meant.
221
236
  /// With `AllowSafeAssignment: false` that is no longer an answer, and upstream leaves the
222
237
  /// corrector empty.
223
- fn correction(context: &RuleContext<'_>, node: Node<'_>, allow_safe: bool) -> Option<Edit> {
238
+ /// `corrector.wrap` is two insertions at the ends of the range, not a replacement of it. Rewriting
239
+ /// the whole assignment instead hands back the text between the parentheses verbatim, and that text
240
+ /// is everything the assignment spans: an assignment written over several lines then swallows every
241
+ /// other correction inside it. `Layout/IndentationConsistency` shifting those lines sideways in the
242
+ /// same pass loses the shift, and the lines are left where they were.
243
+ fn correction(allow_safe: bool, node: Node<'_>) -> Option<Vec<Edit>> {
224
244
  if !allow_safe {
225
245
  return None;
226
246
  }
227
247
  let range: Range<usize> = node.byte_range();
228
- Some(Edit {
229
- start: range.start,
230
- end: range.end,
231
- replacement: format!("({})", context.source.node_text(node)),
232
- safe: true,
233
- })
248
+ Some(vec![
249
+ Edit {
250
+ start: range.start,
251
+ end: range.start,
252
+ replacement: "(".to_owned(),
253
+ safe: true,
254
+ },
255
+ Edit {
256
+ start: range.end,
257
+ end: range.end,
258
+ replacement: ")".to_owned(),
259
+ safe: true,
260
+ },
261
+ ])
234
262
  }
@@ -45,8 +45,18 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
45
45
  }
46
46
  for index in open {
47
47
  let directive = &parsed[index];
48
+ // `acceptable_range?`: a cop the configuration switched off is not expected to be
49
+ // re-enabled, so the range it leaves open to the end of the file is acceptable. Upstream
50
+ // reads that off `registry.enabled?`, which is the configuration and nothing else -- a cop
51
+ // an `enable` directive put back on duty for this file still counts as switched off here.
52
+ // The ranges are kept per cop upstream, so a directive naming one switched off and one left
53
+ // on is reported under the one left on.
48
54
  // `message` names the first cop of the range, which for a department is the department.
49
- let Some(name) = directive.names.first() else {
55
+ let Some(name) = directive
56
+ .names
57
+ .iter()
58
+ .find(|name| is_department(name) || context.cop_enabled(name))
59
+ else {
50
60
  continue;
51
61
  };
52
62
  let kind = if is_department(name) {
@@ -2,8 +2,8 @@ use tree_sitter::Node;
2
2
 
3
3
  use crate::diagnostic::{Edit, Offense};
4
4
  use crate::rules::RuleContext;
5
- use crate::rules::send_node::arguments;
6
5
  use crate::rules::node_ext::NodeExt;
6
+ use crate::rules::send_node::arguments;
7
7
 
8
8
  /// `OPERATOR_METHODS`, which are written with a space on either side by convention.
9
9
  const OPERATOR_METHODS: [&str; 29] = [
@@ -10,7 +10,14 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
10
10
  if !context.source.text().contains("enable") {
11
11
  return;
12
12
  }
13
- let mut disabled = Counters::default();
13
+ let mut disabled = Counters {
14
+ // `inject_disabled_cops_directives` gives every cop the configuration switched off an
15
+ // outstanding disable, so an `enable all` always has one of them to undo. Only whether the
16
+ // set is empty matters, and the run's selection decides that as much as the configuration:
17
+ // `--only Foo` leaves a registry of one enabled cop and nothing to undo.
18
+ config_pool: context.run_disables_a_cop(),
19
+ ..Counters::default()
20
+ };
14
21
  let parsed = directives(context);
15
22
  // `registry.disabled_names(config)`: an `enable` of a cop the configuration switched off has
16
23
  // something to undo, so it starts out counted as disabled.
@@ -48,6 +55,10 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
48
55
  struct Counters {
49
56
  blanket: usize,
50
57
  named: HashMap<String, i64>,
58
+ /// Whether the cops the configuration switched off still have their injected disable
59
+ /// outstanding. `handle_enable_all` lowers every positive counter, so the first `enable all`
60
+ /// spends them all at once.
61
+ config_pool: bool,
51
62
  }
52
63
 
53
64
  impl Counters {
@@ -74,6 +85,10 @@ impl Counters {
74
85
  self.blanket -= 1;
75
86
  }
76
87
  let mut enabled = blanket;
88
+ if self.config_pool {
89
+ self.config_pool = false;
90
+ enabled = true;
91
+ }
77
92
  for (name, count) in &mut self.named {
78
93
  // A name the blanket covers has already come down with it.
79
94
  if blanket && reached_by_all(name) {
@@ -230,12 +230,21 @@ fn correction(context: &RuleContext<'_>, variable: &Variable<'_>) -> Option<Edit
230
230
  safe: true,
231
231
  })
232
232
  }
233
- _ => Some(Edit {
234
- start: variable.name_node.start_byte(),
235
- end: variable.name_node.start_byte(),
236
- replacement: "_".to_owned(),
237
- safe: true,
238
- }),
233
+ // `corrector.replace(node.loc.name, "_#{variable_name}")`. Writing the whole name rather
234
+ // than inserting a `_` in front of it matters when another cop is rewriting the same
235
+ // argument in the same pass: a replacement of the same range clobbers and is deferred to
236
+ // the next pass, while an insertion at its edge slips out of the range and lands on the
237
+ // neighbour. The name node already excludes the leading `*` of a splat, which is what the
238
+ // `gsub(/\A\*+/, '')` there is for.
239
+ _ => {
240
+ let name = context.source.node_text(variable.name_node);
241
+ Some(Edit {
242
+ start: variable.name_node.start_byte(),
243
+ end: variable.name_node.end_byte(),
244
+ replacement: format!("_{name}"),
245
+ safe: true,
246
+ })
247
+ }
239
248
  }
240
249
  }
241
250
 
data/src/rules/mod.rs CHANGED
@@ -142,6 +142,14 @@ pub(crate) struct RuleContext<'a> {
142
142
  fragments: OnceCell<Fragments>,
143
143
  /// Which identifiers the Metrics cops read as local variables, replayed once per file.
144
144
  metric_locals: OnceCell<Locals>,
145
+ /// Whether the run has any cop the configuration switches off, which is what an
146
+ /// `# rubocop:enable all` undoes.
147
+ ///
148
+ /// `Registry#disabled_names` is the list upstream walks, and only whether it is empty decides
149
+ /// the answer. It depends on the run's selection as well as the configuration -- `--only Foo`
150
+ /// leaves a registry of one cop, and that cop is enabled -- so the engine settles it once
151
+ /// instead of every cop working it out.
152
+ run_disables_a_cop: bool,
145
153
  }
146
154
 
147
155
  /// What `Lint/RedundantCopDisableDirective` is given instead of a walk over the syntax tree.
@@ -178,9 +186,16 @@ impl<'a> RuleContext<'a> {
178
186
  variables: OnceCell::new(),
179
187
  fragments: OnceCell::new(),
180
188
  metric_locals: OnceCell::new(),
189
+ run_disables_a_cop: false,
181
190
  }
182
191
  }
183
192
 
193
+ /// Records whether the run switches any cop off. See [`Self::run_disables_a_cop`].
194
+ pub(crate) fn with_disabled_cops(mut self, any: bool) -> Self {
195
+ self.run_disables_a_cop = any;
196
+ self
197
+ }
198
+
184
199
  /// Points the context at the next cop of the same file, keeping everything the file's cops
185
200
  /// share -- above all [`Self::variable_analysis`], which would otherwise be run once per cop
186
201
  /// that asks for it.
@@ -252,6 +267,12 @@ impl<'a> RuleContext<'a> {
252
267
  self.config.rule_enabled(cop)
253
268
  }
254
269
 
270
+ /// Whether the run switches any cop off, which is what an `# rubocop:enable all` undoes even
271
+ /// when the file disabled nothing itself.
272
+ pub fn run_disables_a_cop(&self) -> bool {
273
+ self.run_disables_a_cop
274
+ }
275
+
255
276
  /// The Ruby version the run analyzes as, which version-gated cops compare against.
256
277
  pub fn target_ruby_version(&self) -> RubyVersion {
257
278
  self.config.target_ruby_version()
@@ -38,28 +38,31 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
38
38
  if text == "other" || text == "_other" {
39
39
  continue;
40
40
  }
41
- let variables = variables
42
- .get_or_insert_with(|| context.variable_roles());
41
+ let variables = variables.get_or_insert_with(|| context.variable_roles());
43
42
  offenses.push(
44
43
  context
45
44
  .offense(
46
45
  format!("When defining the `{name}` operator, name its argument `other`."),
47
46
  parameter.byte_range(),
48
47
  )
49
- .corrected_by(rename(context, variables, node, parameter)),
48
+ .corrected_by_all(rename(context, variables, node, parameter)),
50
49
  );
51
50
  }
52
51
  }
53
52
 
54
- /// One edit standing for the several replacements upstream makes: the parameter and every later
55
- /// read or assignment of it become `other`, so the edit spans from the parameter to the last of
56
- /// them and hands back the text in between unchanged.
53
+ /// The replacements upstream makes, one edit each: the parameter and every later read or
54
+ /// assignment of it become `other`.
55
+ ///
56
+ /// One edit spanning from the parameter to the last of them would hand back the text in between
57
+ /// unchanged, which reproduces the same output only while nothing else corrects inside it. That
58
+ /// span is the whole method body, so any other cop rewriting a line of it clobbers against this
59
+ /// one and is put off to the next pass.
57
60
  fn rename(
58
61
  context: &RuleContext<'_>,
59
62
  variables: &Variables,
60
63
  definition: Node<'_>,
61
64
  parameter: Node<'_>,
62
- ) -> Edit {
65
+ ) -> Vec<Edit> {
63
66
  let name = context.source.node_text(parameter);
64
67
  let mut sites = vec![parameter.byte_range()];
65
68
  for node in context.nodes_of("identifier") {
@@ -72,22 +75,16 @@ fn rename(
72
75
  }
73
76
  sites.push(node.byte_range());
74
77
  }
75
- let start = parameter.start_byte();
76
- let end = sites.last().map_or(parameter.end_byte(), |last| last.end);
77
- let mut replacement = String::new();
78
- let mut cursor = start;
79
- for site in &sites {
80
- replacement.push_str(context.source.slice(cursor..site.start));
81
- replacement.push_str("other");
82
- cursor = site.end;
83
- }
84
- replacement.push_str(context.source.slice(cursor..end));
85
- Edit {
86
- start,
87
- end,
88
- replacement,
89
- safe: context.setting("Safe").unwrap_or(true),
90
- }
78
+ let safe = context.setting("Safe").unwrap_or(true);
79
+ sites
80
+ .into_iter()
81
+ .map(|site| Edit {
82
+ start: site.start,
83
+ end: site.end,
84
+ replacement: "other".to_owned(),
85
+ safe,
86
+ })
87
+ .collect()
91
88
  }
92
89
 
93
90
  fn operator_method(name: &str) -> bool {
@@ -31,8 +31,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
31
31
  if name == preferred {
32
32
  continue;
33
33
  }
34
- let variables = variables
35
- .get_or_insert_with(|| context.variable_roles());
34
+ let variables = variables.get_or_insert_with(|| context.variable_roles());
36
35
  // `shadowed_variable_name?` asks whether the *configured* name is already read inside the
37
36
  // handler. Upstream passes a node where a name is expected, so the underscore prefix never
38
37
  // reaches this test.
@@ -46,7 +45,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
46
45
  format!("Use `{preferred}` instead of `{name}`."),
47
46
  range.clone(),
48
47
  )
49
- .corrected_by(rename(context, variables, node, range, &name, &preferred)),
48
+ .corrected_by_all(rename(context, variables, node, range, &name, &preferred)),
50
49
  );
51
50
  }
52
51
  }
@@ -66,9 +65,7 @@ fn exception_variable<'tree>(
66
65
  }
67
66
  // `rescue => Foo::Bar` is a `casgn` whose name is only the last part, though the offense
68
67
  // still covers the whole path.
69
- "scope_resolution" => context
70
- .source
71
- .node_text(target.field("name")?),
68
+ "scope_resolution" => context.source.node_text(target.field("name")?),
72
69
  _ => return None,
73
70
  };
74
71
  Some((target, name.to_owned()))
@@ -90,7 +87,7 @@ fn reads_name(
90
87
  found
91
88
  }
92
89
 
93
- /// One edit standing for the several replacements upstream makes: the variable itself, its reads
90
+ /// The replacements upstream makes, one edit each: the variable itself, its reads
94
91
  /// inside the handler, and -- when the handler never reassigns it -- its reads in the statements
95
92
  /// that follow the `begin`/`end` it belongs to.
96
93
  fn rename(
@@ -100,7 +97,7 @@ fn rename(
100
97
  variable: Range<usize>,
101
98
  name: &str,
102
99
  preferred: &str,
103
- ) -> Edit {
100
+ ) -> Vec<Edit> {
104
101
  let mut rewrite = Rewrite {
105
102
  context,
106
103
  variables,
@@ -120,25 +117,24 @@ fn rename(
120
117
  }
121
118
  }
122
119
  }
123
- let start = variable.start;
124
- let end = rewrite
120
+ // One edit per site, which is what `corrector.replace` is called for upstream. Collapsing them
121
+ // into a single edit spanning the first site to the last swallows everything written between
122
+ // them, and that span reaches well past this handler: the reads after the `begin`/`end` are
123
+ // renamed too, so a second `rescue` further down the file ends up inside it. Its own offence
124
+ // then clobbers against this one and is put off to the next pass, which leaves a handler whose
125
+ // body reads the new name while the variable still carries the old one -- and
126
+ // `Lint/UselessAssignment` deletes the `=> error` it now believes nothing reads.
127
+ let safe = context.setting("Safe").unwrap_or(true);
128
+ rewrite
125
129
  .sites
126
- .last()
127
- .map_or(variable.end, |(range, _)| range.end);
128
- let mut replacement = String::new();
129
- let mut cursor = start;
130
- for (range, text) in &rewrite.sites {
131
- replacement.push_str(context.source.slice(cursor..range.start));
132
- replacement.push_str(text);
133
- cursor = range.end;
134
- }
135
- replacement.push_str(context.source.slice(cursor..end));
136
- Edit {
137
- start,
138
- end,
139
- replacement,
140
- safe: context.setting("Safe").unwrap_or(true),
141
- }
130
+ .into_iter()
131
+ .map(|(range, replacement)| Edit {
132
+ start: range.start,
133
+ end: range.end,
134
+ replacement,
135
+ safe,
136
+ })
137
+ .collect()
142
138
  }
143
139
 
144
140
  struct Rewrite<'a, 'tree> {
@@ -182,7 +178,10 @@ impl Rewrite<'_, '_> {
182
178
  && self.context.source.node_text(key) == self.name
183
179
  {
184
180
  let mut cursor = node.walk();
185
- if let Some(colon) = node.children(&mut cursor).find(|child| child.kind_str() == ":") {
181
+ if let Some(colon) = node
182
+ .children(&mut cursor)
183
+ .find(|child| child.kind_str() == ":")
184
+ {
186
185
  let at = colon.end_byte();
187
186
  self.sites.push((at..at, format!(" {}", self.preferred)));
188
187
  }
@@ -502,17 +502,58 @@ fn ternary_form(
502
502
  {
503
503
  form.push(')');
504
504
  }
505
- // A conditional written as an argument keeps the `||` from spilling out of the call.
506
- let wrapped = node.parent_of(context).is_some_and(|parent| {
507
- matches!(parent.kind_str(), "argument_list")
508
- || (parent.kind_str() == "call" && parent.field("receiver").is_some())
509
- });
505
+ // `node.parent&.send_type?`: a conditional standing where a send takes an operand keeps the
506
+ // `||` from spilling out of it.
507
+ let wrapped = node
508
+ .parent_of(context)
509
+ .is_some_and(|parent| stands_in_a_send(context, parent));
510
510
  Some(match wrapped {
511
511
  true => format!("({form})"),
512
512
  false => form,
513
513
  })
514
514
  }
515
515
 
516
+ /// Whether a node holding the conditional is one the parser would have built a `send` for.
517
+ ///
518
+ /// The grammar spreads a send over several kinds, and two of those are not sends at all. A logical
519
+ /// operator is an `and`/`or` node upstream rather than a call, and an assignment is a send only
520
+ /// when it writes through `[]=` or an attribute writer -- `x = `, `@x = ` and `X = ` are their own
521
+ /// kinds of assignment, and an operator assignment is an `op-asgn` whatever it writes to. Safe
522
+ /// navigation answers to `csend_type?`, which `send_type?` is false for.
523
+ fn stands_in_a_send(context: &RuleContext<'_>, node: Node<'_>) -> bool {
524
+ match node.kind_str() {
525
+ "argument_list" | "element_reference" => true,
526
+ "call" => node.field("receiver").is_some(),
527
+ "binary" => !matches!(
528
+ binary_operator(context, node),
529
+ Some("&&" | "||" | "and" | "or")
530
+ ),
531
+ "assignment" => node
532
+ .field("left")
533
+ .is_some_and(|left| match left.kind_str() {
534
+ "element_reference" => true,
535
+ "call" => !writes_through_safe_navigation(context, left),
536
+ _ => false,
537
+ }),
538
+ _ => false,
539
+ }
540
+ }
541
+
542
+ /// The operator token a binary expression is written with, which the grammar keeps unnamed.
543
+ fn binary_operator<'a>(context: &'a RuleContext<'_>, node: Node<'_>) -> Option<&'a str> {
544
+ let mut cursor = node.walk();
545
+ let operator = node
546
+ .children(&mut cursor)
547
+ .find(|child| !child.is_named() && !child.is_extra())?;
548
+ Some(context.source.node_text(operator))
549
+ }
550
+
551
+ fn writes_through_safe_navigation(context: &RuleContext<'_>, call: Node<'_>) -> bool {
552
+ let mut cursor = call.walk();
553
+ call.children(&mut cursor)
554
+ .any(|child| !child.is_named() && context.source.node_text(child) == "&.")
555
+ }
556
+
516
557
  fn if_source(
517
558
  context: &RuleContext<'_>,
518
559
  condition: Node<'_>,
@@ -539,10 +580,9 @@ fn if_source(
539
580
  if arguments(condition).is_empty() || is_parenthesized(context, condition) {
540
581
  return condition_source.to_owned();
541
582
  }
542
- let (Some(selector), Some(argument)) = (
543
- condition.field("method"),
544
- first_argument(condition),
545
- ) else {
583
+ let (Some(selector), Some(argument)) =
584
+ (condition.field("method"), first_argument(condition))
585
+ else {
546
586
  return condition_source.to_owned();
547
587
  };
548
588
  return format!(
@@ -639,9 +679,8 @@ fn requires_parentheses(context: &RuleContext<'_>, node: Node<'_>) -> bool {
639
679
  /// `arithmetic_operation?`: one of the operators whose result is a new value.
640
680
  fn arithmetic_operation(context: &RuleContext<'_>, node: Node<'_>) -> bool {
641
681
  is_send(node)
642
- && selector(context, node).is_some_and(|selector| {
643
- matches!(selector, "+" | "-" | "*" | "/" | "%" | "**")
644
- })
682
+ && selector(context, node)
683
+ .is_some_and(|selector| matches!(selector, "+" | "-" | "*" | "/" | "%" | "**"))
645
684
  && arguments(node).len() == 1
646
685
  }
647
686
 
@@ -17,9 +17,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
17
17
  let body = match node.kind_str() {
18
18
  "call" => block_body_of_tracked_call(node, context),
19
19
  // `-> { ... }` reaches RuboCop as a call to `lambda` too, so its body is a body.
20
- "lambda" => node
21
- .field("body")
22
- .and_then(|block| block.field("body")),
20
+ "lambda" => node.field("body").and_then(|block| block.field("body")),
23
21
  _ => node.field("body"),
24
22
  };
25
23
  let Some(body) = body else {
@@ -145,6 +143,14 @@ fn check_branch<'tree>(node: Node<'tree>, returns: &mut Vec<Node<'tree>>) {
145
143
  }
146
144
  }
147
145
 
146
+ /// Named children of a statement list that are not statements of it.
147
+ ///
148
+ /// A heredoc's body is the one that matters here: the grammar hangs it off the statement list
149
+ /// beside the statement that opened it, so `return <<~SQL` leaves the body standing *after* the
150
+ /// `return`. Counting it makes the `return` no longer the last statement, and the cop then never
151
+ /// fires on a method that ends by returning a heredoc.
152
+ const NOT_A_STATEMENT: &[&str] = &["comment", "empty_statement", "heredoc_body"];
153
+
148
154
  /// The tail of a statement sequence, including the exception-handling clauses it may carry.
149
155
  ///
150
156
  /// An `ensure` body is never in tail position -- RuboCop's `check_ensure_node` looks only at the
@@ -153,7 +159,7 @@ fn check_sequence<'tree>(node: Node<'tree>, returns: &mut Vec<Node<'tree>>) {
153
159
  let mut cursor = node.walk();
154
160
  let children: Vec<Node<'tree>> = node
155
161
  .named_children(&mut cursor)
156
- .filter(|child| child.kind_str() != "comment")
162
+ .filter(|child| !NOT_A_STATEMENT.contains(&child.kind_str()))
157
163
  .collect();
158
164
 
159
165
  let mut statements: Vec<Node<'tree>> = Vec::new();
@@ -215,7 +221,10 @@ fn return_arguments<'tree>(node: Node<'tree>) -> Vec<Node<'tree>> {
215
221
  }
216
222
 
217
223
  fn braceless_hash(arguments: &[Node<'_>]) -> bool {
218
- !arguments.is_empty() && arguments.iter().all(|argument| argument.kind_str() == "pair")
224
+ !arguments.is_empty()
225
+ && arguments
226
+ .iter()
227
+ .all(|argument| argument.kind_str() == "pair")
219
228
  }
220
229
 
221
230
  /// Mirrors RuboCop's autocorrection: an argument-less `return` becomes `nil`,
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.104
4
+ version: 26.8.105
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yohei