sonicop 26.8.104 → 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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/Cargo.toml +1 -1
  4. data/README.ja.md +1 -1
  5. data/README.md +2 -2
  6. data/lib/sonicop/version.rb +1 -1
  7. data/src/directives.rs +48 -0
  8. data/src/engine.rs +112 -36
  9. data/src/rules/layout/def_end_alignment.rs +20 -19
  10. data/src/rules/layout/empty_lines_around_arguments.rs +1 -1
  11. data/src/rules/layout/multiline_block_layout.rs +1 -1
  12. data/src/rules/layout/redundant_line_break.rs +1 -0
  13. data/src/rules/layout/space_around_block_parameters.rs +1 -1
  14. data/src/rules/layout/space_before_block_braces.rs +1 -1
  15. data/src/rules/layout/space_before_first_arg.rs +1 -1
  16. data/src/rules/lint/assignment_in_condition.rs +40 -12
  17. data/src/rules/lint/locals.rs +7 -0
  18. data/src/rules/lint/missing_cop_enable_directive.rs +11 -1
  19. data/src/rules/lint/parentheses_as_grouped_expression.rs +1 -1
  20. data/src/rules/lint/redundant_cop_disable_directive.rs +2 -2
  21. data/src/rules/lint/redundant_cop_enable_directive.rs +17 -2
  22. data/src/rules/lint/trailing_comma_in_attribute_declaration.rs +1 -1
  23. data/src/rules/lint/unused_method_argument.rs +15 -6
  24. data/src/rules/lint/variable_force.rs +34 -0
  25. data/src/rules/mod.rs +21 -0
  26. data/src/rules/naming/binary_operator_parameter_name.rs +20 -23
  27. data/src/rules/naming/rescued_exceptions_variable_name.rs +26 -27
  28. data/src/rules/style/collection_querying.rs +1 -1
  29. data/src/rules/style/combinable_defined.rs +1 -1
  30. data/src/rules/style/data_inheritance.rs +4 -4
  31. data/src/rules/style/map_into_array.rs +4 -4
  32. data/src/rules/style/map_to_hash.rs +1 -1
  33. data/src/rules/style/map_to_set.rs +1 -1
  34. data/src/rules/style/method_call_with_args_parentheses.rs +40 -16
  35. data/src/rules/style/multiline_in_pattern_then.rs +1 -1
  36. data/src/rules/style/multiline_method_signature.rs +1 -1
  37. data/src/rules/style/nested_parenthesized_calls.rs +8 -7
  38. data/src/rules/style/nil_lambda.rs +2 -2
  39. data/src/rules/style/operator_method_call.rs +2 -2
  40. data/src/rules/style/parens.rs +16 -7
  41. data/src/rules/style/redundant_argument.rs +2 -2
  42. data/src/rules/style/redundant_condition.rs +51 -12
  43. data/src/rules/style/redundant_return.rs +14 -5
  44. data/src/rules/support.rs +98 -2
  45. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 267d186a55418ccb97a890bcea5da9eda428dd6567ae4282b61755779783d6e2
4
- data.tar.gz: baa0dfdcff20c0a1888222298deab0fd2af37fd9fcd25ef8beb2871bb89083fc
3
+ metadata.gz: c90ed3a9d03214ba6922d2aecd35c70b28e09791c870f06c22848ef05b336054
4
+ data.tar.gz: 0557036a886b2023e5151d8b18b1df8bdd66725faf957d19013412a0bc62c6ef
5
5
  SHA512:
6
- metadata.gz: 1f983d82729522d8a18d1155a4d9b1b6d020b837e987abc2d5bbf14558587997f1abf8808e69ae1e36132b6d15f8cac0d0cf440f116cb9280b98397d7b8f3c03
7
- data.tar.gz: 2ffd0dd8db704b600fda62f08c1f61455fc36310f1adc33d4ae6f1bc1b2a53998c8034103a871e4e9e2b239f798a181a66982dafbda3d9b6ae0f5b74845d0887
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.104"
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.104"
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"
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.106'
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
  }
@@ -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
  }
@@ -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
  }
@@ -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
@@ -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] = [
@@ -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> {