sonicop 26.8.101
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 +7 -0
- data/CONFORMANCE.md +109 -0
- data/Cargo.lock +975 -0
- data/Cargo.toml +40 -0
- data/LICENSE +21 -0
- data/NOTICE +7 -0
- data/README.ja.md +161 -0
- data/README.md +173 -0
- data/config/default.yml +6305 -0
- data/exe/sonicop +7 -0
- data/ext/sonicop/extconf.rb +38 -0
- data/lib/sonicop/runner.rb +67 -0
- data/lib/sonicop/version.rb +6 -0
- data/lib/sonicop.rb +8 -0
- data/licenses/RUBOCOP.txt +20 -0
- data/licenses/TREE_SITTER_RUBY.txt +21 -0
- data/src/cli.rs +940 -0
- data/src/config/inheritance.rs +332 -0
- data/src/config/loader.rs +117 -0
- data/src/config/mod.rs +461 -0
- data/src/config/paths.rs +436 -0
- data/src/config/plugin.rs +247 -0
- data/src/config/store.rs +141 -0
- data/src/cop_name.rs +71 -0
- data/src/diagnostic.rs +205 -0
- data/src/directives.rs +305 -0
- data/src/engine.rs +734 -0
- data/src/formatter.rs +684 -0
- data/src/lib.rs +42 -0
- data/src/main.rs +3 -0
- data/src/ruby_version.rs +393 -0
- data/src/rules/layout/empty_line_after_magic_comment.rs +49 -0
- data/src/rules/layout/end_of_line.rs +35 -0
- data/src/rules/layout/line_length.rs +167 -0
- data/src/rules/layout/mod.rs +13 -0
- data/src/rules/layout/space_after_comma.rs +32 -0
- data/src/rules/layout/space_around_operators.rs +104 -0
- data/src/rules/layout/space_inside_parens.rs +100 -0
- data/src/rules/layout/support.rs +23 -0
- data/src/rules/layout/trailing_empty_lines.rs +38 -0
- data/src/rules/layout/trailing_whitespace.rs +26 -0
- data/src/rules/lint/duplicate_methods.rs +72 -0
- data/src/rules/lint/mod.rs +7 -0
- data/src/rules/lint/syntax.rs +200 -0
- data/src/rules/lint/unused_block_argument.rs +74 -0
- data/src/rules/lint/useless_assignment.rs +104 -0
- data/src/rules/metrics/block_length.rs +39 -0
- data/src/rules/metrics/class_length.rs +34 -0
- data/src/rules/metrics/method_length.rs +10 -0
- data/src/rules/metrics/mod.rs +10 -0
- data/src/rules/metrics/module_length.rs +17 -0
- data/src/rules/metrics/parameter_lists.rs +21 -0
- data/src/rules/metrics/support.rs +105 -0
- data/src/rules/mod.rs +380 -0
- data/src/rules/naming/ascii_identifiers.rs +17 -0
- data/src/rules/naming/constant_name.rs +49 -0
- data/src/rules/naming/method_name.rs +51 -0
- data/src/rules/naming/mod.rs +9 -0
- data/src/rules/naming/support.rs +22 -0
- data/src/rules/naming/variable_name.rs +50 -0
- data/src/rules/security/eval.rs +170 -0
- data/src/rules/security/mod.rs +6 -0
- data/src/rules/style/frozen_string_literal_comment.rs +56 -0
- data/src/rules/style/hash_syntax.rs +63 -0
- data/src/rules/style/mod.rs +9 -0
- data/src/rules/style/numeric_literals.rs +59 -0
- data/src/rules/style/redundant_return.rs +143 -0
- data/src/rules/style/semicolon.rs +48 -0
- data/src/rules/style/string_literals.rs +74 -0
- data/src/rules/support.rs +31 -0
- data/src/source.rs +118 -0
- metadata +117 -0
data/src/engine.rs
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
use std::collections::HashSet;
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::io::{self, Write};
|
|
4
|
+
use std::path::{Path, PathBuf};
|
|
5
|
+
use std::sync::Arc;
|
|
6
|
+
|
|
7
|
+
use anyhow::{Context, Result, bail};
|
|
8
|
+
use ignore::WalkBuilder;
|
|
9
|
+
use rayon::prelude::*;
|
|
10
|
+
use tempfile::NamedTempFile;
|
|
11
|
+
use tree_sitter::Parser;
|
|
12
|
+
|
|
13
|
+
use crate::config::{Config, ConfigStore};
|
|
14
|
+
use crate::cop_name::selector_matches;
|
|
15
|
+
use crate::diagnostic::{FileReport, Offense, Severity};
|
|
16
|
+
use crate::directives::DirectiveState;
|
|
17
|
+
use crate::rules::{AstIndex, Rule, RuleContext, rules};
|
|
18
|
+
use crate::source::SourceFile;
|
|
19
|
+
|
|
20
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
21
|
+
pub enum CorrectMode {
|
|
22
|
+
None,
|
|
23
|
+
Safe,
|
|
24
|
+
All,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[derive(Clone, Debug, Default)]
|
|
28
|
+
pub struct Selection {
|
|
29
|
+
pub only: Vec<String>,
|
|
30
|
+
pub except: Vec<String>,
|
|
31
|
+
pub disable_all: bool,
|
|
32
|
+
pub enable_all: bool,
|
|
33
|
+
pub enable_pending: bool,
|
|
34
|
+
pub disable_pending: bool,
|
|
35
|
+
pub safe_only: bool,
|
|
36
|
+
pub ignore_disable_comments: bool,
|
|
37
|
+
pub display_suppressed: bool,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// RuboCop refuses to let syntax checking be turned off, so the cop stays on no matter how it is
|
|
41
|
+
/// selected away. Both the `--except` guard and cop selection have to agree on the names that
|
|
42
|
+
/// denote it, including the legacy `Syntax` spelling RuboCop still accepts.
|
|
43
|
+
pub fn is_mandatory_cop(name: &str) -> bool {
|
|
44
|
+
matches!(name, "Lint/Syntax" | "Syntax")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
impl Selection {
|
|
48
|
+
pub fn includes(&self, name: &str, configured_enabled: bool, safe: bool) -> bool {
|
|
49
|
+
if is_mandatory_cop(name) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
let explicitly_selected = self
|
|
53
|
+
.only
|
|
54
|
+
.iter()
|
|
55
|
+
.any(|selection| selector_matches(selection, name));
|
|
56
|
+
let selected = if self.only.is_empty() {
|
|
57
|
+
if self.disable_all {
|
|
58
|
+
false
|
|
59
|
+
} else if self.enable_all {
|
|
60
|
+
true
|
|
61
|
+
} else {
|
|
62
|
+
configured_enabled
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
explicitly_selected
|
|
66
|
+
};
|
|
67
|
+
selected
|
|
68
|
+
&& (!self.safe_only || safe)
|
|
69
|
+
&& !self
|
|
70
|
+
.except
|
|
71
|
+
.iter()
|
|
72
|
+
.any(|except| selector_matches(except, name))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// The cops a run applies, with every configuration decision that does not depend on the file
|
|
77
|
+
/// resolved once.
|
|
78
|
+
///
|
|
79
|
+
/// Resolving `Enabled`, `Severity` and `SafeAutoCorrect` out of YAML costs a lookup per cop per
|
|
80
|
+
/// file, which is work that grows with the registry as it fills out RuboCop's full cop set. Only
|
|
81
|
+
/// `Exclude` reads the path being inspected, so it is all that stays per-file.
|
|
82
|
+
pub(crate) struct RulePlan {
|
|
83
|
+
entries: Vec<PlannedRule>,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
struct PlannedRule {
|
|
87
|
+
rule: &'static Rule,
|
|
88
|
+
/// `rule.severity` unless the configuration overrode it.
|
|
89
|
+
severity: Severity,
|
|
90
|
+
safe_autocorrect: bool,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
impl RulePlan {
|
|
94
|
+
pub(crate) fn build(config: &Config, selection: &Selection) -> Self {
|
|
95
|
+
let entries = rules()
|
|
96
|
+
.filter(|rule| {
|
|
97
|
+
let enabled = config.rule_enabled_with_pending(
|
|
98
|
+
rule.name,
|
|
99
|
+
selection.enable_pending,
|
|
100
|
+
selection.disable_pending,
|
|
101
|
+
);
|
|
102
|
+
selection.includes(rule.name, enabled, config.rule_safe(rule.name))
|
|
103
|
+
})
|
|
104
|
+
.map(|rule| PlannedRule {
|
|
105
|
+
rule,
|
|
106
|
+
severity: config
|
|
107
|
+
.cop_value::<String>(rule.name, "Severity")
|
|
108
|
+
.and_then(|value| Severity::parse(&value))
|
|
109
|
+
.unwrap_or(rule.severity),
|
|
110
|
+
safe_autocorrect: config.rule_safe_autocorrect(rule.name),
|
|
111
|
+
})
|
|
112
|
+
.collect();
|
|
113
|
+
Self { entries }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pub fn inspect_source(
|
|
118
|
+
path: impl Into<PathBuf>,
|
|
119
|
+
text: String,
|
|
120
|
+
config: &Config,
|
|
121
|
+
selection: &Selection,
|
|
122
|
+
) -> Result<FileReport> {
|
|
123
|
+
inspect_planned(
|
|
124
|
+
path,
|
|
125
|
+
text,
|
|
126
|
+
config,
|
|
127
|
+
selection,
|
|
128
|
+
&RulePlan::build(config, selection),
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Inspects one file against an already-resolved [`RulePlan`], which must have been built from
|
|
133
|
+
/// `config` and `selection`.
|
|
134
|
+
fn inspect_planned(
|
|
135
|
+
path: impl Into<PathBuf>,
|
|
136
|
+
text: String,
|
|
137
|
+
config: &Config,
|
|
138
|
+
selection: &Selection,
|
|
139
|
+
plan: &RulePlan,
|
|
140
|
+
) -> Result<FileReport> {
|
|
141
|
+
let source = SourceFile::new(path, text);
|
|
142
|
+
let mut parser = Parser::new();
|
|
143
|
+
parser
|
|
144
|
+
.set_language(&tree_sitter_ruby::LANGUAGE.into())
|
|
145
|
+
.context("failed to initialize the Ruby parser")?;
|
|
146
|
+
let tree = parser
|
|
147
|
+
.parse(source.text(), None)
|
|
148
|
+
.context("Ruby parser returned no syntax tree")?;
|
|
149
|
+
let ast = AstIndex::new(tree.root_node());
|
|
150
|
+
let directives = (!selection.ignore_disable_comments)
|
|
151
|
+
.then(|| DirectiveState::parse(&source, ast.comment_ranges()));
|
|
152
|
+
let mut offenses = Vec::new();
|
|
153
|
+
|
|
154
|
+
for planned in &plan.entries {
|
|
155
|
+
let rule = planned.rule;
|
|
156
|
+
if config.rule_excluded(rule.name, source.path()) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
let context = RuleContext::new(&source, &ast, config, rule, planned.severity);
|
|
160
|
+
let start = offenses.len();
|
|
161
|
+
(rule.check)(&context, &mut offenses);
|
|
162
|
+
// The cop's name comes from the registry through `RuleContext`, so a mismatch here means
|
|
163
|
+
// an offense was built outside `context.offense` and would be attributed to a cop that
|
|
164
|
+
// never ran -- directives and severity overrides would both consult the wrong entry.
|
|
165
|
+
debug_assert!(
|
|
166
|
+
offenses[start..]
|
|
167
|
+
.iter()
|
|
168
|
+
.all(|offense| offense.cop_name == rule.name),
|
|
169
|
+
"{} reported an offense under another cop's name",
|
|
170
|
+
rule.name
|
|
171
|
+
);
|
|
172
|
+
if !planned.safe_autocorrect {
|
|
173
|
+
for offense in &mut offenses[start..] {
|
|
174
|
+
if let Some(correction) = &mut offense.correction {
|
|
175
|
+
correction.safe = false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if let Some(directives) = directives {
|
|
182
|
+
offenses.retain_mut(|offense| {
|
|
183
|
+
let Some(justification) = directives.suppression(offense, &source) else {
|
|
184
|
+
return true;
|
|
185
|
+
};
|
|
186
|
+
offense.suppressed = true;
|
|
187
|
+
offense.justification = justification;
|
|
188
|
+
selection.display_suppressed
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
sort_offenses(&mut offenses, &source);
|
|
192
|
+
|
|
193
|
+
Ok(FileReport {
|
|
194
|
+
path: source.path().to_path_buf(),
|
|
195
|
+
source,
|
|
196
|
+
offenses,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
pub fn inspect_files(
|
|
201
|
+
paths: &[PathBuf],
|
|
202
|
+
config: &Config,
|
|
203
|
+
selection: &Selection,
|
|
204
|
+
parallel: bool,
|
|
205
|
+
) -> Result<Vec<FileReport>> {
|
|
206
|
+
let configs = ConfigStore::new(config.clone(), false, false);
|
|
207
|
+
inspect_files_with_store(paths, &configs, selection, parallel)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
pub fn inspect_files_with_store(
|
|
211
|
+
paths: &[PathBuf],
|
|
212
|
+
configs: &ConfigStore,
|
|
213
|
+
selection: &Selection,
|
|
214
|
+
parallel: bool,
|
|
215
|
+
) -> Result<Vec<FileReport>> {
|
|
216
|
+
// Most runs resolve every file to the store's root configuration, so the plan for it is worth
|
|
217
|
+
// building once. A file that a nested `.rubocop.yml` gives a different configuration falls back
|
|
218
|
+
// to building its own, which costs no more than resolving the cops inline would have.
|
|
219
|
+
let root_plan = RulePlan::build(configs.root(), selection);
|
|
220
|
+
let inspect = |path: &PathBuf| -> Result<FileReport> {
|
|
221
|
+
let Some(text) = decoded_source(path)? else {
|
|
222
|
+
return Ok(undecodable_report(path));
|
|
223
|
+
};
|
|
224
|
+
let config = configs.for_path(path)?;
|
|
225
|
+
let own_plan = (!std::ptr::eq(Arc::as_ptr(&config), configs.root()))
|
|
226
|
+
.then(|| RulePlan::build(&config, selection));
|
|
227
|
+
inspect_planned(
|
|
228
|
+
path.clone(),
|
|
229
|
+
text,
|
|
230
|
+
&config,
|
|
231
|
+
selection,
|
|
232
|
+
own_plan.as_ref().unwrap_or(&root_plan),
|
|
233
|
+
)
|
|
234
|
+
};
|
|
235
|
+
// Collecting every outcome rather than short-circuiting keeps the surfaced error the first one
|
|
236
|
+
// in path order instead of whichever thread rayon happened to finish first.
|
|
237
|
+
let inspected: Vec<Result<FileReport>> = if parallel && paths.len() > 1 {
|
|
238
|
+
paths.par_iter().map(inspect).collect()
|
|
239
|
+
} else {
|
|
240
|
+
paths.iter().map(inspect).collect()
|
|
241
|
+
};
|
|
242
|
+
let mut reports = inspected.into_iter().collect::<Result<Vec<_>>>()?;
|
|
243
|
+
reports.sort_by(|left, right| left.path.cmp(&right.path));
|
|
244
|
+
Ok(reports)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// `None` when the file exists but does not decode as UTF-8. RuboCop reports that as a fatal
|
|
248
|
+
/// `Lint/Syntax` offense and inspects the remaining files, so it must not abort the run; a genuine
|
|
249
|
+
/// IO failure still does.
|
|
250
|
+
fn decoded_source(path: &Path) -> Result<Option<String>> {
|
|
251
|
+
match fs::read_to_string(path) {
|
|
252
|
+
Ok(text) => Ok(Some(text)),
|
|
253
|
+
Err(error) if error.kind() == io::ErrorKind::InvalidData => Ok(None),
|
|
254
|
+
Err(error) => Err(anyhow::Error::new(error))
|
|
255
|
+
.with_context(|| format!("failed to read {}", path.display())),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
fn undecodable_report(path: &Path) -> FileReport {
|
|
260
|
+
// RuboCop capitalizes the parser's `invalid byte sequence in UTF-8` and anchors the offense at
|
|
261
|
+
// the head of the file, since it never got a syntax tree to locate anything against.
|
|
262
|
+
let mut offense = Offense::new(
|
|
263
|
+
"Lint/Syntax",
|
|
264
|
+
Severity::Fatal,
|
|
265
|
+
"Invalid byte sequence in utf-8.",
|
|
266
|
+
0,
|
|
267
|
+
0,
|
|
268
|
+
);
|
|
269
|
+
let source = SourceFile::new(path.to_path_buf(), String::new());
|
|
270
|
+
offense.freeze_location(&source);
|
|
271
|
+
FileReport {
|
|
272
|
+
path: path.to_path_buf(),
|
|
273
|
+
source,
|
|
274
|
+
offenses: vec![offense],
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
pub fn discover_targets(
|
|
279
|
+
arguments: &[PathBuf],
|
|
280
|
+
cwd: &Path,
|
|
281
|
+
config: &Config,
|
|
282
|
+
force_exclusion: bool,
|
|
283
|
+
only_recognized_file_types: bool,
|
|
284
|
+
) -> Result<Vec<PathBuf>> {
|
|
285
|
+
let configs = ConfigStore::new(config.clone(), false, false);
|
|
286
|
+
discover_targets_with_store(
|
|
287
|
+
arguments,
|
|
288
|
+
cwd,
|
|
289
|
+
&configs,
|
|
290
|
+
force_exclusion,
|
|
291
|
+
only_recognized_file_types,
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
pub fn discover_targets_with_store(
|
|
296
|
+
arguments: &[PathBuf],
|
|
297
|
+
cwd: &Path,
|
|
298
|
+
configs: &ConfigStore,
|
|
299
|
+
force_exclusion: bool,
|
|
300
|
+
only_recognized_file_types: bool,
|
|
301
|
+
) -> Result<Vec<PathBuf>> {
|
|
302
|
+
let roots = if arguments.is_empty() {
|
|
303
|
+
vec![cwd.to_path_buf()]
|
|
304
|
+
} else {
|
|
305
|
+
arguments.to_vec()
|
|
306
|
+
};
|
|
307
|
+
let mut targets = Vec::new();
|
|
308
|
+
|
|
309
|
+
for root in roots {
|
|
310
|
+
if !root.exists() {
|
|
311
|
+
bail!("No such file or directory: {}", root.display());
|
|
312
|
+
}
|
|
313
|
+
if root.is_file() {
|
|
314
|
+
let config = configs.for_path(&root)?;
|
|
315
|
+
let recognized = config.path_included(&root) || has_ruby_shebang(&root);
|
|
316
|
+
if (!force_exclusion || !config.path_excluded(&root))
|
|
317
|
+
&& (!only_recognized_file_types || recognized)
|
|
318
|
+
{
|
|
319
|
+
targets.push(root);
|
|
320
|
+
}
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let mut builder = WalkBuilder::new(&root);
|
|
325
|
+
builder
|
|
326
|
+
.hidden(!configs.root().possibly_include_hidden())
|
|
327
|
+
.parents(true)
|
|
328
|
+
.git_ignore(true)
|
|
329
|
+
.git_exclude(true)
|
|
330
|
+
.git_global(true)
|
|
331
|
+
.follow_links(false);
|
|
332
|
+
for entry in builder.build() {
|
|
333
|
+
let entry = entry.with_context(|| format!("failed to traverse {}", root.display()))?;
|
|
334
|
+
let path = entry.path();
|
|
335
|
+
if !entry.file_type().is_some_and(|kind| kind.is_file()) {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
let config = configs.for_path(path)?;
|
|
339
|
+
let included = config.path_included(path);
|
|
340
|
+
if config.path_excluded(path)
|
|
341
|
+
|| (!included && (config.path_hidden(path) || !has_ruby_shebang(path)))
|
|
342
|
+
{
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
targets.push(normalized_target_path(path));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
targets.sort_by(|left, right| left.to_string_lossy().cmp(&right.to_string_lossy()));
|
|
350
|
+
targets.dedup();
|
|
351
|
+
Ok(targets)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
fn normalized_target_path(path: &Path) -> PathBuf {
|
|
355
|
+
if path.is_absolute() {
|
|
356
|
+
return path.to_path_buf();
|
|
357
|
+
}
|
|
358
|
+
path.strip_prefix(".").unwrap_or(path).to_path_buf()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
fn has_ruby_shebang(path: &Path) -> bool {
|
|
362
|
+
let Ok(contents) = fs::read(path) else {
|
|
363
|
+
return false;
|
|
364
|
+
};
|
|
365
|
+
let first_line = contents
|
|
366
|
+
.split(|byte| *byte == b'\n')
|
|
367
|
+
.next()
|
|
368
|
+
.unwrap_or_default();
|
|
369
|
+
first_line.starts_with(b"#!")
|
|
370
|
+
&& [b"ruby".as_slice(), b"rake".as_slice(), b"jruby".as_slice()]
|
|
371
|
+
.iter()
|
|
372
|
+
.any(|interpreter| {
|
|
373
|
+
first_line
|
|
374
|
+
.windows(interpreter.len())
|
|
375
|
+
.any(|part| part == *interpreter)
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
pub fn corrected_text(report: &mut FileReport, mode: CorrectMode) -> (String, usize) {
|
|
380
|
+
if mode == CorrectMode::None {
|
|
381
|
+
return (report.source.text().to_owned(), 0);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let mut candidates: Vec<(usize, crate::diagnostic::Edit)> = report
|
|
385
|
+
.offenses
|
|
386
|
+
.iter()
|
|
387
|
+
.enumerate()
|
|
388
|
+
.filter_map(|(index, offense)| {
|
|
389
|
+
offense
|
|
390
|
+
.correction
|
|
391
|
+
.clone()
|
|
392
|
+
.filter(|edit| mode == CorrectMode::All || edit.safe)
|
|
393
|
+
.map(|edit| (index, edit))
|
|
394
|
+
})
|
|
395
|
+
.collect();
|
|
396
|
+
candidates.sort_by_key(|(_, edit)| (edit.start, edit.end));
|
|
397
|
+
|
|
398
|
+
let mut selected = Vec::new();
|
|
399
|
+
let mut occupied_end = 0;
|
|
400
|
+
let mut occupied_insertions = HashSet::new();
|
|
401
|
+
for candidate in candidates {
|
|
402
|
+
let edit = &candidate.1;
|
|
403
|
+
let insertion_conflict = edit.start == edit.end && !occupied_insertions.insert(edit.start);
|
|
404
|
+
if edit.start < occupied_end || insertion_conflict {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
occupied_end = occupied_end.max(edit.end);
|
|
408
|
+
selected.push(candidate);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let mut text = report.source.text().to_owned();
|
|
412
|
+
for (offense_index, edit) in selected.iter().rev() {
|
|
413
|
+
if edit.start <= edit.end
|
|
414
|
+
&& edit.end <= text.len()
|
|
415
|
+
&& text.is_char_boundary(edit.start)
|
|
416
|
+
&& text.is_char_boundary(edit.end)
|
|
417
|
+
{
|
|
418
|
+
text.replace_range(edit.start..edit.end, &edit.replacement);
|
|
419
|
+
report.offenses[*offense_index].corrected = true;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
let corrected = report
|
|
423
|
+
.offenses
|
|
424
|
+
.iter()
|
|
425
|
+
.filter(|offense| offense.corrected)
|
|
426
|
+
.count();
|
|
427
|
+
(text, corrected)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const MAX_CORRECTION_PASSES: usize = 200;
|
|
431
|
+
|
|
432
|
+
type OffenseKey = (usize, usize, &'static str, String, Severity);
|
|
433
|
+
|
|
434
|
+
fn offense_key(offense: &Offense, source: &SourceFile) -> OffenseKey {
|
|
435
|
+
let (line, column) = offense.start_position(source);
|
|
436
|
+
(
|
|
437
|
+
line,
|
|
438
|
+
column,
|
|
439
|
+
offense.cop_name,
|
|
440
|
+
offense.message.clone(),
|
|
441
|
+
offense.severity,
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
fn sort_offenses(offenses: &mut [Offense], source: &SourceFile) {
|
|
446
|
+
offenses.sort_by(|left, right| {
|
|
447
|
+
let (left_line, left_column) = left.start_position(source);
|
|
448
|
+
let (right_line, right_column) = right.start_position(source);
|
|
449
|
+
(
|
|
450
|
+
left_line,
|
|
451
|
+
left_column,
|
|
452
|
+
left.cop_name,
|
|
453
|
+
&left.message,
|
|
454
|
+
left.severity,
|
|
455
|
+
)
|
|
456
|
+
.cmp(&(
|
|
457
|
+
right_line,
|
|
458
|
+
right_column,
|
|
459
|
+
right.cop_name,
|
|
460
|
+
&right.message,
|
|
461
|
+
right.severity,
|
|
462
|
+
))
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/// Offenses an earlier autocorrect pass already fixed. Re-inspecting the rewritten text cannot
|
|
467
|
+
/// rediscover them, so without this ledger every `[Corrected]` marker and every corrected count
|
|
468
|
+
/// would vanish the moment the fix landed.
|
|
469
|
+
#[derive(Default)]
|
|
470
|
+
struct CorrectionLog {
|
|
471
|
+
offenses: Vec<Offense>,
|
|
472
|
+
keys: HashSet<OffenseKey>,
|
|
473
|
+
/// The cops credited with each pass's corrections, used to name the culprits of a loop.
|
|
474
|
+
cops_by_pass: Vec<Vec<&'static str>>,
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
impl CorrectionLog {
|
|
478
|
+
fn record_pass(&mut self, report: &mut FileReport) {
|
|
479
|
+
let source = &report.source;
|
|
480
|
+
let mut cops: Vec<&'static str> = Vec::new();
|
|
481
|
+
for offense in &mut report.offenses {
|
|
482
|
+
if !offense.corrected {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
offense.freeze_location(source);
|
|
486
|
+
if !cops.contains(&offense.cop_name) {
|
|
487
|
+
cops.push(offense.cop_name);
|
|
488
|
+
}
|
|
489
|
+
if self.keys.insert(offense_key(offense, source)) {
|
|
490
|
+
self.offenses.push(offense.clone());
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
self.cops_by_pass.push(cops);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
fn root_cause(&self, loop_start: usize) -> String {
|
|
497
|
+
self.cops_by_pass
|
|
498
|
+
.get(loop_start..)
|
|
499
|
+
.unwrap_or_default()
|
|
500
|
+
.iter()
|
|
501
|
+
.map(|cops| cops.join(", "))
|
|
502
|
+
.collect::<Vec<_>>()
|
|
503
|
+
.join(" -> ")
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/// Union the ledger with the last pass the way RuboCop does: an offense a later pass
|
|
507
|
+
/// rediscovered at the same place loses to the corrected entry already on file.
|
|
508
|
+
fn merge_into(self, mut report: FileReport) -> (FileReport, usize) {
|
|
509
|
+
let Self {
|
|
510
|
+
mut offenses, keys, ..
|
|
511
|
+
} = self;
|
|
512
|
+
let source = &report.source;
|
|
513
|
+
offenses.extend(
|
|
514
|
+
report
|
|
515
|
+
.offenses
|
|
516
|
+
.drain(..)
|
|
517
|
+
.filter(|offense| !keys.contains(&offense_key(offense, source))),
|
|
518
|
+
);
|
|
519
|
+
sort_offenses(&mut offenses, source);
|
|
520
|
+
let corrected_count = offenses.iter().filter(|offense| offense.corrected).count();
|
|
521
|
+
report.offenses = offenses;
|
|
522
|
+
(report, corrected_count)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/// The result of driving autocorrect to a fixed point.
|
|
527
|
+
pub struct CorrectionOutcome {
|
|
528
|
+
pub report: FileReport,
|
|
529
|
+
pub text: String,
|
|
530
|
+
pub corrected_count: usize,
|
|
531
|
+
/// Set when the passes never settled. RuboCop reports this per file, still writes the last
|
|
532
|
+
/// corrected text, and keeps inspecting the rest of the run.
|
|
533
|
+
pub infinite_loop: Option<String>,
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
pub fn correct_file(
|
|
537
|
+
mut report: FileReport,
|
|
538
|
+
mode: CorrectMode,
|
|
539
|
+
config: &Config,
|
|
540
|
+
selection: &Selection,
|
|
541
|
+
) -> Result<CorrectionOutcome> {
|
|
542
|
+
let mut text = report.source.text().to_owned();
|
|
543
|
+
if mode == CorrectMode::None {
|
|
544
|
+
return Ok(CorrectionOutcome {
|
|
545
|
+
report,
|
|
546
|
+
text,
|
|
547
|
+
corrected_count: 0,
|
|
548
|
+
infinite_loop: None,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
let path = report.path.clone();
|
|
553
|
+
let mut log = CorrectionLog::default();
|
|
554
|
+
let mut sources = vec![text.clone()];
|
|
555
|
+
// Every pass re-inspects the same file under the same configuration, so the plan is resolved
|
|
556
|
+
// once for the whole fixed-point loop.
|
|
557
|
+
let plan = RulePlan::build(config, selection);
|
|
558
|
+
for pass in 0..=MAX_CORRECTION_PASSES {
|
|
559
|
+
let (corrected, count) = corrected_text(&mut report, mode);
|
|
560
|
+
if count == 0 {
|
|
561
|
+
let (report, corrected_count) = log.merge_into(report);
|
|
562
|
+
return Ok(CorrectionOutcome {
|
|
563
|
+
report,
|
|
564
|
+
text,
|
|
565
|
+
corrected_count,
|
|
566
|
+
infinite_loop: None,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
log.record_pass(&mut report);
|
|
570
|
+
|
|
571
|
+
// Re-producing a source seen before means the passes are trading edits back and forth; the
|
|
572
|
+
// repeat tells us which pass the cycle closed on.
|
|
573
|
+
let repeated = sources.iter().position(|source| *source == corrected);
|
|
574
|
+
if pass == MAX_CORRECTION_PASSES || repeated.is_some() {
|
|
575
|
+
let loop_start = repeated.unwrap_or_else(|| log.cops_by_pass.len().saturating_sub(1));
|
|
576
|
+
let root_cause = log.root_cause(loop_start);
|
|
577
|
+
let (report, corrected_count) = log.merge_into(report);
|
|
578
|
+
return Ok(CorrectionOutcome {
|
|
579
|
+
report,
|
|
580
|
+
text: corrected,
|
|
581
|
+
corrected_count,
|
|
582
|
+
infinite_loop: Some(format!(
|
|
583
|
+
"Infinite loop detected in {} and caused by {root_cause}",
|
|
584
|
+
path.display()
|
|
585
|
+
)),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
sources.push(corrected.clone());
|
|
590
|
+
text = corrected;
|
|
591
|
+
report = inspect_planned(path.clone(), text.clone(), config, selection, &plan)?;
|
|
592
|
+
}
|
|
593
|
+
unreachable!("the autocorrect loop always returns before exhausting its passes")
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
pub fn correct_until_stable(
|
|
597
|
+
report: FileReport,
|
|
598
|
+
mode: CorrectMode,
|
|
599
|
+
config: &Config,
|
|
600
|
+
selection: &Selection,
|
|
601
|
+
) -> Result<(FileReport, String, usize)> {
|
|
602
|
+
let outcome = correct_file(report, mode, config, selection)?;
|
|
603
|
+
match outcome.infinite_loop {
|
|
604
|
+
Some(message) => bail!(message),
|
|
605
|
+
None => Ok((outcome.report, outcome.text, outcome.corrected_count)),
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
pub fn write_corrected(path: &Path, contents: &str) -> Result<()> {
|
|
610
|
+
let parent = path.parent().unwrap_or(Path::new("."));
|
|
611
|
+
let permissions = fs::metadata(path)
|
|
612
|
+
.ok()
|
|
613
|
+
.map(|metadata| metadata.permissions());
|
|
614
|
+
let mut temporary = NamedTempFile::new_in(parent)
|
|
615
|
+
.with_context(|| format!("failed to create temporary file beside {}", path.display()))?;
|
|
616
|
+
temporary
|
|
617
|
+
.write_all(contents.as_bytes())
|
|
618
|
+
.with_context(|| format!("failed to write corrected contents for {}", path.display()))?;
|
|
619
|
+
temporary
|
|
620
|
+
.as_file_mut()
|
|
621
|
+
.sync_all()
|
|
622
|
+
.with_context(|| format!("failed to flush corrected contents for {}", path.display()))?;
|
|
623
|
+
if let Some(permissions) = permissions {
|
|
624
|
+
temporary
|
|
625
|
+
.as_file()
|
|
626
|
+
.set_permissions(permissions)
|
|
627
|
+
.with_context(|| format!("failed to preserve permissions for {}", path.display()))?;
|
|
628
|
+
}
|
|
629
|
+
temporary
|
|
630
|
+
.persist(path)
|
|
631
|
+
.map_err(|error| error.error)
|
|
632
|
+
.with_context(|| format!("failed to replace {} atomically", path.display()))?;
|
|
633
|
+
Ok(())
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
pub fn offense_count(reports: &[FileReport], fail_level: Severity) -> usize {
|
|
637
|
+
reports
|
|
638
|
+
.iter()
|
|
639
|
+
.flat_map(|report| &report.offenses)
|
|
640
|
+
.filter(|offense| {
|
|
641
|
+
offense.severity >= fail_level && !offense.corrected && !offense.suppressed
|
|
642
|
+
})
|
|
643
|
+
.count()
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
#[cfg(test)]
|
|
647
|
+
mod tests {
|
|
648
|
+
use tempfile::tempdir;
|
|
649
|
+
|
|
650
|
+
use crate::config::Config;
|
|
651
|
+
use crate::diagnostic::Severity;
|
|
652
|
+
|
|
653
|
+
use super::{
|
|
654
|
+
CorrectMode, Selection, correct_file, discover_targets, inspect_files, inspect_source,
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
#[test]
|
|
658
|
+
fn discovers_ruby_files_and_honors_exclusions() {
|
|
659
|
+
let directory = tempdir().unwrap();
|
|
660
|
+
std::fs::write(directory.path().join("good.rb"), "puts 1\n").unwrap();
|
|
661
|
+
std::fs::create_dir(directory.path().join("vendor")).unwrap();
|
|
662
|
+
std::fs::write(directory.path().join("vendor/skip.rb"), "puts 1\n").unwrap();
|
|
663
|
+
let config = Config::load(None, directory.path()).unwrap();
|
|
664
|
+
let selection = Selection::default();
|
|
665
|
+
let targets = discover_targets(&[], directory.path(), &config, false, false).unwrap();
|
|
666
|
+
assert_eq!(targets.len(), 1);
|
|
667
|
+
assert_eq!(
|
|
668
|
+
inspect_files(&targets, &config, &selection, false)
|
|
669
|
+
.unwrap()
|
|
670
|
+
.len(),
|
|
671
|
+
1
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
#[test]
|
|
676
|
+
fn autocorrect_keeps_corrected_offenses_and_their_original_lines() {
|
|
677
|
+
let directory = tempdir().unwrap();
|
|
678
|
+
let config = Config::load(None, directory.path()).unwrap();
|
|
679
|
+
let selection = Selection::default();
|
|
680
|
+
let report = inspect_source(
|
|
681
|
+
directory.path().join("example.rb"),
|
|
682
|
+
"x = 'a' \n".to_owned(),
|
|
683
|
+
&config,
|
|
684
|
+
&selection,
|
|
685
|
+
)
|
|
686
|
+
.unwrap();
|
|
687
|
+
|
|
688
|
+
let outcome = correct_file(report, CorrectMode::Safe, &config, &selection).unwrap();
|
|
689
|
+
|
|
690
|
+
assert!(outcome.infinite_loop.is_none());
|
|
691
|
+
assert!(outcome.corrected_count > 0);
|
|
692
|
+
assert_eq!(
|
|
693
|
+
outcome
|
|
694
|
+
.report
|
|
695
|
+
.offenses
|
|
696
|
+
.iter()
|
|
697
|
+
.filter(|offense| offense.corrected)
|
|
698
|
+
.count(),
|
|
699
|
+
outcome.corrected_count
|
|
700
|
+
);
|
|
701
|
+
let trailing = outcome
|
|
702
|
+
.report
|
|
703
|
+
.offenses
|
|
704
|
+
.iter()
|
|
705
|
+
.find(|offense| offense.cop_name == "Layout/TrailingWhitespace")
|
|
706
|
+
.expect("the corrected trailing whitespace offense survives into the final report");
|
|
707
|
+
assert!(trailing.corrected);
|
|
708
|
+
assert_eq!(trailing.source_line(&outcome.report.source), "x = 'a' \n");
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
#[test]
|
|
712
|
+
fn an_undecodable_file_reports_a_fatal_offense_without_stopping_the_run() {
|
|
713
|
+
let directory = tempdir().unwrap();
|
|
714
|
+
std::fs::write(directory.path().join("good.rb"), "puts 1\n").unwrap();
|
|
715
|
+
std::fs::write(directory.path().join("bad.rb"), b"x = \"\xff\xfe\"\n").unwrap();
|
|
716
|
+
let config = Config::load(None, directory.path()).unwrap();
|
|
717
|
+
let selection = Selection::default();
|
|
718
|
+
let targets = discover_targets(&[], directory.path(), &config, false, false).unwrap();
|
|
719
|
+
|
|
720
|
+
let reports = inspect_files(&targets, &config, &selection, false).unwrap();
|
|
721
|
+
|
|
722
|
+
assert_eq!(reports.len(), 2);
|
|
723
|
+
let bad = reports
|
|
724
|
+
.iter()
|
|
725
|
+
.find(|report| report.path.ends_with("bad.rb"))
|
|
726
|
+
.unwrap();
|
|
727
|
+
assert_eq!(bad.offenses.len(), 1);
|
|
728
|
+
assert_eq!(bad.offenses[0].cop_name, "Lint/Syntax");
|
|
729
|
+
assert_eq!(bad.offenses[0].severity, Severity::Fatal);
|
|
730
|
+
assert_eq!(bad.offenses[0].message, "Invalid byte sequence in utf-8.");
|
|
731
|
+
let location = bad.offenses[0].location(&bad.source);
|
|
732
|
+
assert_eq!((location.line, location.column), (1, 1));
|
|
733
|
+
}
|
|
734
|
+
}
|