sonicop 26.8.109 → 26.8.110

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: af12abdfed80466315a48ac714c41b356e8da379f918ad620ae5a8806751aff3
4
- data.tar.gz: 494b54b05f97ce808baaaf0b935f66d76af5ec6b3a19731d90fbda3ab3576d80
3
+ metadata.gz: a00a7d0dc817fde927c8f9a5eb24db975f85aa2f4dda36ae1f7de548aa6557f6
4
+ data.tar.gz: 2c09e86e186f680c8f31cf5fd0e979fb974bc23ad173fa14e4f100f98dc95ce3
5
5
  SHA512:
6
- metadata.gz: c7ea549c725c29d7a1d4574e95dc8ffcf162723babc0782ea07911185640a054724deea5e2777912834bf495ab4b17082641ecadef310364ddb450dd2b2b226d
7
- data.tar.gz: 2d1666311efcf108a3c43f1ead16e7c3ceb54e34c40cfed2cc6505d95ed8d7ca25ffba6a2d2d5c20e195c6f481461da27e9ea9134963221340e5ec136e817bd2
6
+ metadata.gz: 786c6eb994b2d5020f9f035623ec0f54ab95ad3f0359f747efd0bf97fd1bd4cca00d21b7a652b483861aef2af80dabb18dea4185bf9320a214307c4ea8780a8a
7
+ data.tar.gz: 8c0b93e14e6e7a56f497cc55aad95ce730c681515f9e2f8d67efcfe3c8ae44d98dc8f5ab1bf9c25b60f6de0791b0ed077bc3df0b4a679d3c07416a731426c66d
data/Cargo.lock CHANGED
@@ -742,7 +742,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
742
742
 
743
743
  [[package]]
744
744
  name = "sonicop"
745
- version = "26.8.109"
745
+ version = "26.8.110"
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.109"
3
+ version = "26.8.110"
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.109'
5
+ VERSION = '26.8.110'
6
6
  end
data/src/config/loader.rs CHANGED
@@ -11,7 +11,17 @@ pub(super) fn find_config(start: &Path) -> Option<PathBuf> {
11
11
  if candidate.is_file() {
12
12
  return fs::canonicalize(candidate).ok();
13
13
  }
14
- if project_root.as_deref().is_none_or(|root| directory == root) {
14
+ // `break if dir == stop_dir || dir == FileFinder.root_level`
15
+ //
16
+ // **`stop_dir` が無いことは「止まらない」であって「止まる」ではない。**
17
+ // 上流の `find_project_dotfile` は `find_file_upwards(DOTFILE, target_dir, project_root)`
18
+ // で、`project_root` が nil のときは何とも一致しないので**ファイルシステムの根まで
19
+ // 昇る**。ここを `is_none_or` で書いていたため、Gemfile の無い木では最初の 1 段で
20
+ // 止まり、**`.rubocop.yml` がリポジトリ直下・コードが `lib/` という標準の配置で
21
+ // 設定が 1 つも効かなかった。**
22
+ //
23
+ // `ancestors()` は根で終わるので、`root_level` の側は自然に満たされる。
24
+ if project_root.as_deref() == Some(directory) {
15
25
  break;
16
26
  }
17
27
  }
data/src/engine.rs CHANGED
@@ -1255,6 +1255,64 @@ fn anchor_range(offense: &Offense, source: &str) -> (usize, usize) {
1255
1255
  }
1256
1256
  }
1257
1257
 
1258
+ /// Writes the corrector of every cop of one pass, and what the merge did with it, to stderr under
1259
+ /// `SONICOP_TRACE_CORRECTORS`.
1260
+ ///
1261
+ /// The output is the same shape upstream's correctors can be dumped in -- one line per scheduled
1262
+ /// edit, `line:column-line:column` then the replacement -- so that the two can be read side by side.
1263
+ /// Comparing text alone cannot say *why* a pass landed where it did: a cop whose corrections are
1264
+ /// discarded whole still reports every offense as corrected, so the report is identical either way.
1265
+ mod trace {
1266
+ pub(super) fn enabled() -> bool {
1267
+ static ENABLED: std::sync::LazyLock<bool> =
1268
+ std::sync::LazyLock::new(|| std::env::var_os("SONICOP_TRACE_CORRECTORS").is_some());
1269
+ *ENABLED
1270
+ }
1271
+
1272
+ /// The 1-based line and column of an offset, counted the way an offense is reported.
1273
+ ///
1274
+ /// Slices with `get` rather than `[..]`: the offsets this walks are the ones under suspicion
1275
+ /// whenever the trace is worth reading, and an offset landing inside a multi-byte character
1276
+ /// would panic exactly when it is being investigated. A malformed edit should print oddly, not
1277
+ /// take the run down.
1278
+ fn position(source: &str, offset: usize) -> (usize, usize) {
1279
+ let mut offset = offset.min(source.len());
1280
+ while offset > 0 && !source.is_char_boundary(offset) {
1281
+ offset -= 1;
1282
+ }
1283
+ let head = &source[..offset];
1284
+ let line = head.bytes().filter(|byte| *byte == b'\n').count() + 1;
1285
+ let start = head.rfind('\n').map_or(0, |index| index + 1);
1286
+ (line, head[start..].chars().count())
1287
+ }
1288
+
1289
+ pub(super) fn span(source: &str, start: usize, end: usize) -> String {
1290
+ let (first_line, first_column) = position(source, start);
1291
+ let (last_line, last_column) = position(source, end);
1292
+ format!("{first_line}:{first_column}-{last_line}:{last_column}")
1293
+ }
1294
+
1295
+ /// Names a cop whose own edits cannot stand together, under `SONICOP_TRACE_OVERLAP`.
1296
+ ///
1297
+ /// The cop-side guard only covers the four cops that reparse their own correction; this end sees
1298
+ /// every cop, because every offense's edits pass through the correction tree. See
1299
+ /// [`crate::rules::support::report_overlap`] for what the stages mean.
1300
+ pub(super) fn overlap(report: &super::FileReport, index: usize, stage: &str) {
1301
+ if !crate::rules::support::overlap_trace_enabled() {
1302
+ return;
1303
+ }
1304
+ let offense = &report.offenses[index];
1305
+ let (line, column) = report.source.line_column(offense.start);
1306
+ crate::rules::support::report_overlap(
1307
+ offense.cop_name,
1308
+ &report.path.display().to_string(),
1309
+ line,
1310
+ column,
1311
+ stage,
1312
+ );
1313
+ }
1314
+ }
1315
+
1258
1316
  /// Which cops a correction pass takes edits from.
1259
1317
  ///
1260
1318
  /// RuboCop runs the cop that reads directives on a team of its own, after the inspection loop has
@@ -1320,6 +1378,9 @@ pub fn corrected_text(
1320
1378
  // `Team#each_corrector`'s skip set. See [`autocorrect_incompatible_with`].
1321
1379
  let mut skips: HashSet<&'static str> = HashSet::new();
1322
1380
  let mut rest = candidates.as_slice();
1381
+ if trace::enabled() {
1382
+ eprintln!("=== cop ごとの corrector (マージ順)");
1383
+ }
1323
1384
  while let Some(&first) = rest.first() {
1324
1385
  let cop_name = report.offenses[first].cop_name;
1325
1386
  let taken = rest
@@ -1330,12 +1391,24 @@ pub fn corrected_text(
1330
1391
  rest = remainder;
1331
1392
 
1332
1393
  let skipped = skips.contains(cop_name);
1394
+ if trace::enabled() {
1395
+ eprintln!(" {cop_name}{}", if skipped { " (skip 済み)" } else { "" });
1396
+ }
1333
1397
 
1334
1398
  // The cop's own corrector. An offense that cannot be placed in it is the cop error RuboCop
1335
1399
  // reports and steps over, so it costs that offense alone.
1336
1400
  let mut cop = Action::root();
1337
1401
  let mut placed = Vec::new();
1338
1402
  for &index in group {
1403
+ if trace::enabled() {
1404
+ for edit in &report.offenses[index].corrections {
1405
+ eprintln!(
1406
+ " {:16} {:?}",
1407
+ trace::span(source, edit.start, edit.end),
1408
+ edit.replacement
1409
+ );
1410
+ }
1411
+ }
1339
1412
  // `combine` rather than `combine_children`: it is the entry point that drops an edit
1340
1413
  // asking for nothing at all, the way `Corrector#replace` and friends do.
1341
1414
  let anchor = anchor_range(&report.offenses[index], source);
@@ -1345,11 +1418,23 @@ pub fn corrected_text(
1345
1418
  .try_fold(Action::root(), |tree, edit| {
1346
1419
  tree.combine(&Action::from_edit(edit, anchor))
1347
1420
  });
1348
- let Ok(offense) = offense else { continue };
1421
+ let Ok(offense) = offense else {
1422
+ if trace::enabled() {
1423
+ eprintln!(" ★ この offense の中で衝突");
1424
+ }
1425
+ // The guard that names a corrector written twice over. Reaching it from here covers
1426
+ // every cop; the cop-side path only sees the four that reparse their own correction.
1427
+ trace::overlap(report, index, "offense-tree");
1428
+ continue;
1429
+ };
1349
1430
  if offense.children.is_empty() {
1350
1431
  continue;
1351
1432
  }
1352
1433
  let Ok(merged) = cop.clone().combine_children(&offense.children) else {
1434
+ if trace::enabled() {
1435
+ eprintln!(" ★ cop の corrector に入らなかった (この offense だけ捨てた)");
1436
+ }
1437
+ trace::overlap(report, index, "cop-tree");
1353
1438
  continue;
1354
1439
  };
1355
1440
  cop = merged;
@@ -1378,8 +1463,16 @@ pub fn corrected_text(
1378
1463
  run = merged;
1379
1464
  applied += placed.len();
1380
1465
  trace_outcome("apply", cop_name);
1466
+ if trace::enabled() {
1467
+ eprintln!(" 取り込み");
1468
+ }
1469
+ }
1470
+ Err(_) => {
1471
+ trace_outcome("clash", cop_name);
1472
+ if trace::enabled() {
1473
+ eprintln!(" ★ 丸ごと捨てた");
1474
+ }
1381
1475
  }
1382
- Err(_) => trace_outcome("clash", cop_name),
1383
1476
  }
1384
1477
  }
1385
1478
 
@@ -2073,6 +2166,69 @@ mod tests {
2073
2166
  );
2074
2167
  }
2075
2168
 
2169
+ /// The range a cop hands `insert_after` -- not the offset the text lands at -- decides whether
2170
+ /// another cop's replacement of the same construct swallows the insertion or wraps around it.
2171
+ ///
2172
+ /// `Layout/EmptyLineAfterGuardClause` is the pair that measures it: it reports the `end` keyword
2173
+ /// but inserts after `range_by_whole_lines(node.source_range)`, and `Style/IfUnlessModifier`
2174
+ /// replaces exactly that conditional. On the whole lines the insertion is the *parent* of the
2175
+ /// replacement and both land; on the keyword it is a *child* of it, which is the
2176
+ /// `swallowed_insertions` clobbering -- and that costs `Style/IfUnlessModifier` every correction
2177
+ /// it asked for in the file, so a different cop's form wins the node in a later pass. Measured on
2178
+ /// rails' `activerecord/.../schema_definitions.rb`.
2179
+ #[test]
2180
+ fn the_range_an_insertion_hangs_off_decides_who_survives() {
2181
+ let source = "if a\n raise\nend\nb\n";
2182
+ // `end` is 13..16; the whole lines of the conditional are 0..16; the blank line lands at 16.
2183
+ let insertion = |anchor: std::ops::Range<usize>| {
2184
+ Offense::new(
2185
+ "Layout/EmptyLineAfterGuardClause",
2186
+ Severity::Convention,
2187
+ "test",
2188
+ 13,
2189
+ 16,
2190
+ )
2191
+ .corrected_by(Edit {
2192
+ start: 16,
2193
+ end: 16,
2194
+ replacement: "\n".to_owned(),
2195
+ safe: true,
2196
+ })
2197
+ .corrections_anchored_at(anchor)
2198
+ };
2199
+ let fold = || {
2200
+ Offense::new(
2201
+ "Style/IfUnlessModifier",
2202
+ Severity::Convention,
2203
+ "test",
2204
+ 0,
2205
+ 16,
2206
+ )
2207
+ .corrected_by(Edit {
2208
+ start: 0,
2209
+ end: 16,
2210
+ replacement: "raise if a".to_owned(),
2211
+ safe: true,
2212
+ })
2213
+ };
2214
+ let run = |offenses: Vec<Offense>| {
2215
+ let mut report = FileReport {
2216
+ path: "test.rb".into(),
2217
+ source: SourceFile::new("test.rb", source.to_owned()),
2218
+ offenses,
2219
+ };
2220
+ corrected_text(&mut report, CorrectMode::All, Correcting::Everything).0
2221
+ };
2222
+
2223
+ assert_eq!(run(vec![insertion(0..16), fold()]), "raise if a\n\nb\n");
2224
+ // The negative control: anchored on the keyword the fold is swallowed and dropped whole, so
2225
+ // the conditional keeps its written form and only the blank line lands.
2226
+ assert_eq!(
2227
+ run(vec![insertion(13..16), fold()]),
2228
+ "if a\n raise\nend\n\nb\n"
2229
+ );
2230
+ }
2231
+
2076
2232
  #[test]
2077
2233
  fn a_clobbering_cop_loses_every_correction_it_asked_for() {
2078
2234
  // The cop's second edit collides with what is already scheduled, so the run drops the cop
@@ -74,10 +74,13 @@ fn inspect<'tree>(
74
74
  return;
75
75
  }
76
76
  let terminator = terminator_range(context, body);
77
+ // `range_by_whole_lines(node.loc.heredoc_body)`: the body's own lines, not the statement's.
78
+ let (edit, anchor) = insertion(context, body.byte_range());
77
79
  offenses.push(
78
80
  context
79
81
  .offense(MESSAGE, terminator.clone())
80
- .corrected_by(insertion(context, body.end_byte())),
82
+ .corrected_by(edit)
83
+ .corrections_anchored_at(anchor),
81
84
  );
82
85
  return;
83
86
  }
@@ -86,27 +89,44 @@ fn inspect<'tree>(
86
89
  return;
87
90
  }
88
91
  let range = end_keyword(node).map_or_else(|| node.byte_range(), |keyword| keyword.byte_range());
92
+ let (edit, anchor) = insertion(context, node.byte_range());
89
93
  offenses.push(
90
94
  context
91
95
  .offense(MESSAGE, range)
92
- .corrected_by(insertion(context, node.end_byte())),
96
+ .corrected_by(edit)
97
+ .corrections_anchored_at(anchor),
93
98
  );
94
99
  }
95
100
 
96
101
  /// `corrector.insert_after(range_by_whole_lines(...), "\n")`, stepping over a directive comment on
97
102
  /// the line that follows.
98
- fn insertion(context: &RuleContext<'_>, end: usize) -> Edit {
99
- let line = context.source.line_column(end).0;
100
- let mut offset = line_end(context, line);
101
- if let Some(comment) = allowed_directive_comment(context, line + 1) {
102
- offset = comment.end;
103
- }
104
- Edit {
105
- start: offset,
106
- end: offset,
107
- replacement: "\n".to_owned(),
108
- safe: true,
103
+ ///
104
+ /// The second half of the pair is the range upstream hands `insert_after`, which is **not** the
105
+ /// `end` keyword the offense is reported on: it is the whole lines of the conditional. Which of the
106
+ /// two is recorded decides where the insertion sits in the correction tree, and the two fall on
107
+ /// opposite sides of `Style/IfUnlessModifier`'s replacement of the same conditional -- the
108
+ /// whole-lines range contains it, so the two merge, while the `end` keyword is contained by it and
109
+ /// reads as an insertion the replacement swallows. That swallowing is the one RuboCop raises on, and
110
+ /// it costs the clobbering cop every correction it asked for in the file. Measured on
111
+ /// `rails/activerecord/.../schema_definitions.rb`, where recording the keyword sent
112
+ /// `Style/IfUnlessModifier` away and left `Style/GuardClause`'s form in its place.
113
+ fn insertion(context: &RuleContext<'_>, range: Range<usize>) -> (Edit, Range<usize>) {
114
+ let first_line = context.source.line_column(range.start).0;
115
+ let last_line = context.source.line_column(range.end).0;
116
+ let mut anchor = context.source.line_range(first_line).start..line_end(context, last_line);
117
+ if let Some(comment) = allowed_directive_comment(context, last_line + 1) {
118
+ anchor = comment;
109
119
  }
120
+ let offset = anchor.end;
121
+ (
122
+ Edit {
123
+ start: offset,
124
+ end: offset,
125
+ replacement: "\n".to_owned(),
126
+ safe: true,
127
+ },
128
+ anchor,
129
+ )
110
130
  }
111
131
 
112
132
  fn line_end(context: &RuleContext<'_>, line: usize) -> usize {
@@ -9,18 +9,29 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
9
9
  let heredocs = heredocs(context);
10
10
  let text = context.source.text();
11
11
 
12
+ // `/[[:blank:]]\z/` and `sub(/[[:blank:]]+\z/, '')`: **the widest of Ruby's three whitespace
13
+ // sets, and the only one that reaches past ASCII.** A line ending in a no-break space or an
14
+ // ideographic space carries trailing whitespace upstream, and reading the run as `[' ', '\t']`
15
+ // left those lines unreported. (A line that *begins* with one parses as an identifier, so the
16
+ // judgement has to be made on the line's text rather than on a node.)
17
+ //
18
+ // **On a source that declares itself binary the set collapses back to ASCII.** `[[:blank:]]`
19
+ // names Unicode's `Zs`, and an `ASCII-8BIT` string holds no Unicode characters for it to
20
+ // name -- Ruby matches only tab and space there. The decoder maps each byte of such a file to
21
+ // one `char` so that columns count bytes, which hands this cop U+00A0 for byte `0xA0`: the
22
+ // tail of `à`, `Р` or `ภ` written in a comment. Reading that as a no-break space reported 35
23
+ // lines of `ruby/ruby`'s `test/ruby/test_transcode.rb` that upstream leaves alone, and the
24
+ // correction **deleted the byte**, leaving a lone `0xC3` where a character had been. The file
25
+ // still parsed, so nothing downstream noticed.
26
+ let blank: fn(char) -> bool = match crate::engine::declared_literal_encoding(text) {
27
+ crate::engine::LiteralEncoding::Binary => |character| matches!(character, ' ' | '\t'),
28
+ _ => crate::rules::support::is_ruby_blank,
29
+ };
12
30
  for line_number in 1..=context.source.line_count() {
13
31
  let range = context.source.line_range(line_number);
14
32
  let line = &text[range.clone()];
15
33
  let content_end = line.trim_end_matches(['\r', '\n']).len();
16
- // `/[[:blank:]]\z/` and `sub(/[[:blank:]]+\z/, '')`: **the widest of Ruby's three whitespace
17
- // sets, and the only one that reaches past ASCII.** A line ending in a no-break space or an
18
- // ideographic space carries trailing whitespace upstream, and reading the run as `[' ', '\t']`
19
- // left those lines unreported. (A line that *begins* with one parses as an identifier, so the
20
- // judgement has to be made on the line's text rather than on a node.)
21
- let trimmed_end = line[..content_end]
22
- .trim_end_matches(crate::rules::support::is_ruby_blank)
23
- .len();
34
+ let trimmed_end = line[..content_end].trim_end_matches(blank).len();
24
35
  if trimmed_end == content_end {
25
36
  continue;
26
37
  }
@@ -4,6 +4,7 @@ use tree_sitter::Node;
4
4
 
5
5
  use crate::diagnostic::{Edit, Offense};
6
6
  use crate::rules::RuleContext;
7
+ use crate::rules::send_node;
7
8
  use crate::rules::send_node::named_children;
8
9
 
9
10
  use super::literals::{is_basic_literal, is_falsey_literal, is_literal, is_truthy_literal};
@@ -85,6 +86,10 @@ fn literal(node: Node<'_>, context: &RuleContext<'_>) -> bool {
85
86
  const HANDLED: &[&str] = &[
86
87
  "binary",
87
88
  "unary",
89
+ // `x.!` is the same `(send _ :!)` upstream reaches through `on_send`, but the grammar writes
90
+ // it as a `call`. Without it here the negation entry is never reached for that spelling, and
91
+ // `if 1.!` / `while 1.!` / `until 1.!` / `1.! ? a : b` / `s if 1.!` all go unreported.
92
+ "call",
88
93
  "if",
89
94
  "elsif",
90
95
  "unless",
@@ -109,7 +114,7 @@ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
109
114
  for node in context.nodes_of_any(HANDLED) {
110
115
  match node.kind_str() {
111
116
  "binary" => check_operator_keyword(node, context, offenses),
112
- "unary" => check_negation(node, context, offenses),
117
+ "unary" | "call" => check_negation(node, context, offenses),
113
118
  "while" | "while_modifier" => check_loop(node, true, context, offenses),
114
119
  "until" | "until_modifier" => check_loop(node, false, context, offenses),
115
120
  "case" => check_case(node, context, offenses),
@@ -168,35 +173,30 @@ fn check_operator_keyword(node: Node<'_>, context: &RuleContext<'_>, offenses: &
168
173
  }
169
174
 
170
175
  /// `on_send` with `RESTRICT_ON_SEND = [:!]`: what a negation is applied to is a condition too.
176
+ ///
177
+ /// **The entry uses `negation_method?`, not `prefix_bang?`.** That is why `if not 1` is reported
178
+ /// here while `check_node` -- the recursive half -- leaves it alone: the two halves of this cop ask
179
+ /// different questions, and answering both with one predicate loses a form either way.
171
180
  fn check_negation(node: Node<'_>, context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
172
- if node
173
- .field("operator")
174
- .is_none_or(|operator| !matches!(context.source.node_text(operator), "!" | "not"))
175
- {
176
- return;
177
- }
178
- let Some(operand) = node.field("operand") else {
181
+ let Some(found) = send_node::negation(node, context) else {
179
182
  return;
180
183
  };
181
- if literal(operand, context) {
182
- offenses.push(report(operand.byte_range(), context));
184
+ if literal(found.operand, context) {
185
+ offenses.push(report(found.operand.byte_range(), context));
183
186
  return;
184
187
  }
185
- check_node(operand, context, offenses);
188
+ check_node(found.operand, context, offenses);
186
189
  }
187
190
 
188
191
  /// `check_node`: the shapes whose operands are conditions in their own right.
189
192
  fn check_node(node: Node<'_>, context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
193
+ // **`prefix_bang?` here, `negation_method?` at the entry.** `not` is excluded on this side, so
194
+ // widening both to the same predicate reports an operand upstream never looks at.
195
+ if let Some(found) = send_node::bang(node, context) {
196
+ handle_node(found.operand, context, offenses);
197
+ return;
198
+ }
190
199
  match node.kind_str() {
191
- "unary"
192
- if node
193
- .field("operator")
194
- .is_some_and(|operator| context.source.node_text(operator) == "!") =>
195
- {
196
- if let Some(operand) = node.field("operand") {
197
- handle_node(operand, context, offenses);
198
- }
199
- }
200
200
  "binary"
201
201
  if node
202
202
  .field("operator")
data/src/rules/support.rs CHANGED
@@ -217,11 +217,12 @@ pub(crate) fn correction_parses(context: &RuleContext<'_>, edits: &[Edit]) -> bo
217
217
  /// and the offense it loses never reaches the output to be noticed.
218
218
  ///
219
219
  /// Set `SONICOP_TRACE_OVERLAP=1` to list them.
220
+ ///
221
+ /// This path only sees the cops that verify their own correction by reparsing it -- four of the 609.
222
+ /// The engine reports the same thing for every cop from the other end, where an offense's edits are
223
+ /// folded into the correction tree ([`report_overlap`]); the `stage` field says which end named it.
220
224
  fn trace_overlapping_edits(context: &RuleContext<'_>, edits: &[Edit], at: usize) {
221
- /// Read once. Every offense of every cop on this path would otherwise pay for the lookup.
222
- static ENABLED: std::sync::LazyLock<bool> =
223
- std::sync::LazyLock::new(|| std::env::var_os("SONICOP_TRACE_OVERLAP").is_some());
224
- if !*ENABLED {
225
+ if !overlap_trace_enabled() {
225
226
  return;
226
227
  }
227
228
  let mut ordered: Vec<&Edit> = edits.iter().collect();
@@ -230,12 +231,12 @@ fn trace_overlapping_edits(context: &RuleContext<'_>, edits: &[Edit], at: usize)
230
231
  for edit in ordered {
231
232
  if edit.start < cursor {
232
233
  let (line, column) = context.source.line_column(at);
233
- eprintln!(
234
- "[overlap]\t{}\t{}:{}:{}",
234
+ report_overlap(
235
235
  context.rule.name,
236
- context.source.path().display(),
236
+ &context.source.path().display().to_string(),
237
237
  line,
238
- column
238
+ column,
239
+ "reparse-gate",
239
240
  );
240
241
  return;
241
242
  }
@@ -243,6 +244,29 @@ fn trace_overlapping_edits(context: &RuleContext<'_>, edits: &[Edit], at: usize)
243
244
  }
244
245
  }
245
246
 
247
+ /// Read once. Every offense of every cop on these paths would otherwise pay for the lookup.
248
+ pub(crate) fn overlap_trace_enabled() -> bool {
249
+ static ENABLED: std::sync::LazyLock<bool> =
250
+ std::sync::LazyLock::new(|| std::env::var_os("SONICOP_TRACE_OVERLAP").is_some());
251
+ *ENABLED
252
+ }
253
+
254
+ /// One line naming a cop whose own edits cannot stand together.
255
+ ///
256
+ /// `stage` says which end noticed, because the two do not cover the same thing:
257
+ ///
258
+ /// * `reparse-gate` -- [`apply_edits`] refused the set while the cop was checking that its own
259
+ /// correction still parses. Only the four cops behind that gate reach it.
260
+ /// * `offense-tree` -- the engine could not fold one offense's edits into a correction tree. Every
261
+ /// cop reaches this one, and it is strictly the worse condition: the tree accepts overlaps that
262
+ /// `apply_edits` refuses (two insertions at one offset merge), so a failure here is a corrector
263
+ /// written twice over rather than a policy call.
264
+ /// * `cop-tree` -- two offenses of the same cop collide. RuboCop reports that as the cop error it
265
+ /// steps over, so it is not by itself a defect, but a cop appearing here often is worth reading.
266
+ pub(crate) fn report_overlap(cop_name: &str, path: &str, line: usize, column: usize, stage: &str) {
267
+ eprintln!("[overlap]\t{cop_name}\t{path}:{line}:{column}\t{stage}");
268
+ }
269
+
246
270
  /// The source with every edit applied, or `None` when two of them overlap.
247
271
  ///
248
272
  /// Sorting by span puts an insertion at a span's start before the span itself and one at its end
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.109
4
+ version: 26.8.110
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yohei