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 +4 -4
- data/Cargo.lock +1 -1
- data/Cargo.toml +1 -1
- data/README.ja.md +1 -1
- data/README.md +2 -2
- data/lib/sonicop/version.rb +1 -1
- data/src/directives.rs +48 -0
- data/src/engine.rs +112 -36
- data/src/rules/layout/def_end_alignment.rs +20 -19
- data/src/rules/lint/assignment_in_condition.rs +40 -12
- data/src/rules/lint/missing_cop_enable_directive.rs +11 -1
- data/src/rules/lint/parentheses_as_grouped_expression.rs +1 -1
- data/src/rules/lint/redundant_cop_enable_directive.rs +16 -1
- data/src/rules/lint/unused_method_argument.rs +15 -6
- data/src/rules/mod.rs +21 -0
- data/src/rules/naming/binary_operator_parameter_name.rs +20 -23
- data/src/rules/naming/rescued_exceptions_variable_name.rs +26 -27
- data/src/rules/style/redundant_condition.rs +51 -12
- data/src/rules/style/redundant_return.rs +14 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3bc83b9cca7222947c4ef2dc8c0d18456a627dc98650427dea70f68340df060
|
|
4
|
+
data.tar.gz: 3567b01985bf947c14427804f847c3530d324eead1b4d56649a18a9e8f69491d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b10fdaec517230cdfaa915213cf0df5dfab472c65776e0305994f44a49296fb0147c72fd0c1628d66af39052a4985c351e86023f053fbbc50a3363b1f66574f8
|
|
7
|
+
data.tar.gz: b227a8713413f893f96991859278e05e4c4e4916f6ebc564d01beb0a84e7ed4515002b07c8dc6dc0c1f9ba753e625b8f0d278716894a97a50487db29c9f2adcf
|
data/Cargo.lock
CHANGED
data/Cargo.toml
CHANGED
data/README.ja.md
CHANGED
|
@@ -119,7 +119,7 @@ Mastodon(15,286 件)で、過剰も不足もメタデータ差もありま
|
|
|
119
119
|
Mastodon でバイト単位に一致します。この 2 つは死守ラインとして扱い、バイト一致が崩れた場合は
|
|
120
120
|
既知差分ではなく退行として直します。
|
|
121
121
|
|
|
122
|
-
|
|
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
|
|
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
|
|
data/lib/sonicop/version.rb
CHANGED
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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::
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
1565
|
-
|
|
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)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
183
|
-
|
|
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|
|
|
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|
|
|
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)
|
|
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
|
-
|
|
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(
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
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
|
|
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
|
-
_
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
.
|
|
48
|
+
.corrected_by_all(rename(context, variables, node, parameter)),
|
|
50
49
|
);
|
|
51
50
|
}
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
///
|
|
55
|
-
///
|
|
56
|
-
///
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
.
|
|
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
|
-
///
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
.
|
|
127
|
-
.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
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)
|
|
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()
|
|
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()
|
|
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`,
|