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/formatter.rs
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
2
|
+
use std::path::Path;
|
|
3
|
+
|
|
4
|
+
use anyhow::{Result, bail};
|
|
5
|
+
use serde::Serialize;
|
|
6
|
+
|
|
7
|
+
use crate::config::Config;
|
|
8
|
+
use crate::diagnostic::{FileReport, Location, Offense, Severity};
|
|
9
|
+
use crate::{RUBOCOP_COMPAT_FULL_VERSION, VERSION};
|
|
10
|
+
|
|
11
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
12
|
+
pub enum Format {
|
|
13
|
+
Progress,
|
|
14
|
+
Simple,
|
|
15
|
+
Clang,
|
|
16
|
+
Emacs,
|
|
17
|
+
Json,
|
|
18
|
+
Junit,
|
|
19
|
+
Html,
|
|
20
|
+
Markdown,
|
|
21
|
+
Github,
|
|
22
|
+
Tap,
|
|
23
|
+
Files,
|
|
24
|
+
Fuubar,
|
|
25
|
+
Offenses,
|
|
26
|
+
Worst,
|
|
27
|
+
Quiet,
|
|
28
|
+
Pacman,
|
|
29
|
+
Autogenconf,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
impl Format {
|
|
33
|
+
pub fn parse(value: &str) -> Result<Self> {
|
|
34
|
+
match value.to_ascii_lowercase().as_str() {
|
|
35
|
+
"progress" | "p" => Ok(Self::Progress),
|
|
36
|
+
"simple" | "s" => Ok(Self::Simple),
|
|
37
|
+
"clang" | "c" => Ok(Self::Clang),
|
|
38
|
+
"emacs" | "e" => Ok(Self::Emacs),
|
|
39
|
+
"json" | "j" => Ok(Self::Json),
|
|
40
|
+
"junit" | "ju" => Ok(Self::Junit),
|
|
41
|
+
"html" | "h" => Ok(Self::Html),
|
|
42
|
+
"markdown" | "m" => Ok(Self::Markdown),
|
|
43
|
+
"github" | "g" => Ok(Self::Github),
|
|
44
|
+
"tap" | "t" => Ok(Self::Tap),
|
|
45
|
+
"files" | "file-list" | "fi" => Ok(Self::Files),
|
|
46
|
+
"fuubar" | "fu" => Ok(Self::Fuubar),
|
|
47
|
+
"offenses" | "o" => Ok(Self::Offenses),
|
|
48
|
+
"worst" | "w" => Ok(Self::Worst),
|
|
49
|
+
"quiet" | "q" => Ok(Self::Quiet),
|
|
50
|
+
"pacman" | "pa" => Ok(Self::Pacman),
|
|
51
|
+
"autogenconf" | "a" => Ok(Self::Autogenconf),
|
|
52
|
+
_ => bail!(
|
|
53
|
+
"unknown formatter: {value}. Available formatters: progress, simple, clang, emacs, json, junit, html, markdown, github, tap, files, fuubar, offenses, worst, quiet, pacman, autogenconf"
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
pub struct FormatOptions<'a> {
|
|
60
|
+
pub cwd: &'a Path,
|
|
61
|
+
pub config: &'a Config,
|
|
62
|
+
pub display_cop_names: bool,
|
|
63
|
+
pub display_style_guide: bool,
|
|
64
|
+
pub extra_details: bool,
|
|
65
|
+
pub color: bool,
|
|
66
|
+
pub corrected_count: usize,
|
|
67
|
+
pub fail_level: Severity,
|
|
68
|
+
/// True for `-a`, where RuboCop points at `-A` instead of calling the rest autocorrectable.
|
|
69
|
+
pub safe_autocorrect: bool,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
pub fn render(
|
|
73
|
+
format: Format,
|
|
74
|
+
reports: &[FileReport],
|
|
75
|
+
options: &FormatOptions<'_>,
|
|
76
|
+
) -> Result<String> {
|
|
77
|
+
match format {
|
|
78
|
+
Format::Json => render_json(reports, options),
|
|
79
|
+
Format::Emacs => Ok(render_emacs(reports, options)),
|
|
80
|
+
Format::Github => Ok(render_github(reports, options)),
|
|
81
|
+
Format::Junit => Ok(render_junit(reports, options)),
|
|
82
|
+
Format::Html => Ok(render_html(reports, options)),
|
|
83
|
+
Format::Markdown => Ok(render_markdown(reports, options)),
|
|
84
|
+
Format::Tap => Ok(render_tap(reports, options)),
|
|
85
|
+
Format::Files => Ok(render_files(reports, options.cwd)),
|
|
86
|
+
Format::Offenses => Ok(render_offense_counts(reports)),
|
|
87
|
+
Format::Worst => Ok(render_worst(reports, options.cwd)),
|
|
88
|
+
Format::Autogenconf => Ok(render_autogenconf(reports, options.cwd)),
|
|
89
|
+
Format::Simple => Ok(render_simple(reports, options, false)),
|
|
90
|
+
Format::Quiet => Ok(render_simple(reports, options, true)),
|
|
91
|
+
Format::Clang => Ok(render_clang(reports, options, false)),
|
|
92
|
+
Format::Progress | Format::Fuubar | Format::Pacman => {
|
|
93
|
+
Ok(render_clang(reports, options, true))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// `quiet` is RuboCop's `SimpleTextFormatter` with the summary skipped when nothing was found,
|
|
99
|
+
/// which for a clean run leaves no output at all.
|
|
100
|
+
fn render_simple(
|
|
101
|
+
reports: &[FileReport],
|
|
102
|
+
options: &FormatOptions<'_>,
|
|
103
|
+
silent_when_clean: bool,
|
|
104
|
+
) -> String {
|
|
105
|
+
let mut output = String::new();
|
|
106
|
+
if silent_when_clean && offense_count(reports) == 0 {
|
|
107
|
+
return output;
|
|
108
|
+
}
|
|
109
|
+
for report in reports {
|
|
110
|
+
if report.offenses.is_empty() {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
let path = smart_path(&report.path, options.cwd);
|
|
114
|
+
output.push_str(&paint(&format!("== {path} ==\n"), "33", options.color));
|
|
115
|
+
for offense in &report.offenses {
|
|
116
|
+
let location = offense.location(&report.source);
|
|
117
|
+
let message = display_message(offense, options);
|
|
118
|
+
let line = format!(
|
|
119
|
+
"{}:{:>3}:{:>3}: {message}\n",
|
|
120
|
+
offense.severity.code(),
|
|
121
|
+
location.line,
|
|
122
|
+
location.column
|
|
123
|
+
);
|
|
124
|
+
output.push_str(&paint(
|
|
125
|
+
&line,
|
|
126
|
+
severity_color(offense.severity),
|
|
127
|
+
options.color,
|
|
128
|
+
));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
output.push('\n');
|
|
132
|
+
output.push_str(&summary(reports, options));
|
|
133
|
+
output
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fn render_clang(reports: &[FileReport], options: &FormatOptions<'_>, progress: bool) -> String {
|
|
137
|
+
let mut output = String::new();
|
|
138
|
+
if progress {
|
|
139
|
+
output.push_str(&format!("Inspecting {}\n", plural(reports.len(), "file")));
|
|
140
|
+
for report in reports {
|
|
141
|
+
let severity = report.offenses.iter().map(|offense| offense.severity).max();
|
|
142
|
+
output.push(severity.map_or('.', Severity::code));
|
|
143
|
+
}
|
|
144
|
+
output.push_str("\n\n");
|
|
145
|
+
if reports.iter().any(|report| !report.offenses.is_empty()) {
|
|
146
|
+
output.push_str("Offenses:\n\n");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for report in reports {
|
|
150
|
+
let path = smart_path(&report.path, options.cwd);
|
|
151
|
+
for offense in &report.offenses {
|
|
152
|
+
let location = offense.location(&report.source);
|
|
153
|
+
let message = display_message(offense, options);
|
|
154
|
+
output.push_str(&format!(
|
|
155
|
+
"{path}:{}:{}: {}: {message}\n",
|
|
156
|
+
location.line,
|
|
157
|
+
location.column,
|
|
158
|
+
offense.severity.code()
|
|
159
|
+
));
|
|
160
|
+
let source_line = offense
|
|
161
|
+
.source_line(&report.source)
|
|
162
|
+
.trim_end_matches(['\r', '\n']);
|
|
163
|
+
output.push_str(source_line);
|
|
164
|
+
output.push('\n');
|
|
165
|
+
output.push_str(&" ".repeat(location.column.saturating_sub(1)));
|
|
166
|
+
output.push_str(&"^".repeat(location.length.max(1)));
|
|
167
|
+
output.push('\n');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
output.push('\n');
|
|
171
|
+
output.push_str(&summary(reports, options));
|
|
172
|
+
output
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// RuboCop's `EmacsStyleFormatter` prints the raw target path rather than routing it through
|
|
176
|
+
/// `smart_path`, so this one stays unrelativized on purpose.
|
|
177
|
+
fn render_emacs(reports: &[FileReport], options: &FormatOptions<'_>) -> String {
|
|
178
|
+
let mut output = String::new();
|
|
179
|
+
for report in reports {
|
|
180
|
+
for offense in &report.offenses {
|
|
181
|
+
let location = offense.location(&report.source);
|
|
182
|
+
output.push_str(&format!(
|
|
183
|
+
"{}:{}:{}: {}: {}\n",
|
|
184
|
+
report.path.display(),
|
|
185
|
+
location.line,
|
|
186
|
+
location.column,
|
|
187
|
+
offense.severity.code(),
|
|
188
|
+
display_message(offense, options)
|
|
189
|
+
));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
output
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
fn render_github(reports: &[FileReport], options: &FormatOptions<'_>) -> String {
|
|
196
|
+
let mut output = String::new();
|
|
197
|
+
for report in reports {
|
|
198
|
+
let path = smart_path(&report.path, options.cwd);
|
|
199
|
+
for offense in &report.offenses {
|
|
200
|
+
let location = offense.location(&report.source);
|
|
201
|
+
let level = if offense.severity >= options.fail_level {
|
|
202
|
+
"error"
|
|
203
|
+
} else {
|
|
204
|
+
"warning"
|
|
205
|
+
};
|
|
206
|
+
// RuboCop separates annotations with a single leading newline and escapes only the
|
|
207
|
+
// message, then closes the stream with one final newline.
|
|
208
|
+
output.push_str(&format!(
|
|
209
|
+
"\n::{level} file={path},line={},col={}::{}",
|
|
210
|
+
location.line,
|
|
211
|
+
location.column,
|
|
212
|
+
github_escape(&display_message(offense, options))
|
|
213
|
+
));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
output.push('\n');
|
|
217
|
+
output
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
fn render_tap(reports: &[FileReport], options: &FormatOptions<'_>) -> String {
|
|
221
|
+
let mut output = format!("1..{}\n", reports.len());
|
|
222
|
+
for (index, report) in reports.iter().enumerate() {
|
|
223
|
+
let path = smart_path(&report.path, options.cwd);
|
|
224
|
+
if report.offenses.is_empty() {
|
|
225
|
+
output.push_str(&format!("ok {} - {path}\n", index + 1));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
output.push_str(&format!("not ok {} - {path}\n", index + 1));
|
|
229
|
+
for offense in &report.offenses {
|
|
230
|
+
let location = offense.location(&report.source);
|
|
231
|
+
output.push_str(&format!(
|
|
232
|
+
"{path}:{}:{}: {}: {}\n",
|
|
233
|
+
location.line,
|
|
234
|
+
location.column,
|
|
235
|
+
offense.severity.code(),
|
|
236
|
+
display_message(offense, options)
|
|
237
|
+
));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
output
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
fn render_junit(reports: &[FileReport], options: &FormatOptions<'_>) -> String {
|
|
244
|
+
let tests = reports.len();
|
|
245
|
+
let failures = reports
|
|
246
|
+
.iter()
|
|
247
|
+
.filter(|report| !report.offenses.is_empty())
|
|
248
|
+
.count();
|
|
249
|
+
let mut output = format!(
|
|
250
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<testsuite name=\"rubocop\" tests=\"{tests}\" failures=\"{failures}\">\n"
|
|
251
|
+
);
|
|
252
|
+
for report in reports {
|
|
253
|
+
let path = smart_path(&report.path, options.cwd);
|
|
254
|
+
output.push_str(&format!(" <testcase name=\"{}\">", xml_escape(&path)));
|
|
255
|
+
if report.offenses.is_empty() {
|
|
256
|
+
output.push_str("</testcase>\n");
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
let failures = report
|
|
260
|
+
.offenses
|
|
261
|
+
.iter()
|
|
262
|
+
.map(|offense| {
|
|
263
|
+
let location = offense.location(&report.source);
|
|
264
|
+
format!(
|
|
265
|
+
"{}:{}: {}",
|
|
266
|
+
location.line,
|
|
267
|
+
location.column,
|
|
268
|
+
display_message(offense, options)
|
|
269
|
+
)
|
|
270
|
+
})
|
|
271
|
+
.collect::<Vec<_>>()
|
|
272
|
+
.join("\n");
|
|
273
|
+
output.push_str(&format!(
|
|
274
|
+
"<failure message=\"offenses\">{}</failure></testcase>\n",
|
|
275
|
+
xml_escape(&failures)
|
|
276
|
+
));
|
|
277
|
+
}
|
|
278
|
+
output.push_str("</testsuite>\n");
|
|
279
|
+
output
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
fn render_html(reports: &[FileReport], options: &FormatOptions<'_>) -> String {
|
|
283
|
+
let mut output =
|
|
284
|
+
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Sonicop</title></head><body><h1>Sonicop report</h1><ul>"
|
|
285
|
+
.to_owned();
|
|
286
|
+
for report in reports {
|
|
287
|
+
for offense in &report.offenses {
|
|
288
|
+
let location = offense.location(&report.source);
|
|
289
|
+
output.push_str(&format!(
|
|
290
|
+
"<li><code>{}:{}:{}</code> {}</li>",
|
|
291
|
+
xml_escape(&smart_path(&report.path, options.cwd)),
|
|
292
|
+
location.line,
|
|
293
|
+
location.column,
|
|
294
|
+
xml_escape(&display_message(offense, options))
|
|
295
|
+
));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
output.push_str("</ul></body></html>\n");
|
|
299
|
+
output
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
fn render_markdown(reports: &[FileReport], options: &FormatOptions<'_>) -> String {
|
|
303
|
+
let mut output =
|
|
304
|
+
"# Sonicop report\n\n| File | Line | Column | Severity | Message |\n|---|---:|---:|---|---|\n"
|
|
305
|
+
.to_owned();
|
|
306
|
+
for report in reports {
|
|
307
|
+
for offense in &report.offenses {
|
|
308
|
+
let location = offense.location(&report.source);
|
|
309
|
+
output.push_str(&format!(
|
|
310
|
+
"| {} | {} | {} | {} | {} |\n",
|
|
311
|
+
smart_path(&report.path, options.cwd).replace('|', "\\|"),
|
|
312
|
+
location.line,
|
|
313
|
+
location.column,
|
|
314
|
+
offense.severity.as_str(),
|
|
315
|
+
display_message(offense, options).replace('|', "\\|")
|
|
316
|
+
));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
output
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
fn render_files(reports: &[FileReport], cwd: &Path) -> String {
|
|
323
|
+
let mut output = String::new();
|
|
324
|
+
for report in reports.iter().filter(|report| !report.offenses.is_empty()) {
|
|
325
|
+
output.push_str(&smart_path(&report.path, cwd));
|
|
326
|
+
output.push('\n');
|
|
327
|
+
}
|
|
328
|
+
output
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
fn render_offense_counts(reports: &[FileReport]) -> String {
|
|
332
|
+
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
|
|
333
|
+
for offense in reports.iter().flat_map(|report| &report.offenses) {
|
|
334
|
+
*counts.entry(offense.cop_name).or_default() += 1;
|
|
335
|
+
}
|
|
336
|
+
let mut rows = counts.into_iter().collect::<Vec<_>>();
|
|
337
|
+
rows.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
|
|
338
|
+
rows.into_iter()
|
|
339
|
+
.map(|(cop, count)| format!("{count:>6} {cop}\n"))
|
|
340
|
+
.collect()
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
fn render_worst(reports: &[FileReport], cwd: &Path) -> String {
|
|
344
|
+
let mut rows = reports
|
|
345
|
+
.iter()
|
|
346
|
+
.filter(|report| !report.offenses.is_empty())
|
|
347
|
+
.map(|report| (report.offenses.len(), smart_path(&report.path, cwd)))
|
|
348
|
+
.collect::<Vec<_>>();
|
|
349
|
+
rows.sort_by_key(|(count, _)| std::cmp::Reverse(*count));
|
|
350
|
+
rows.into_iter()
|
|
351
|
+
.map(|(count, path)| format!("{count:>6} {path}\n"))
|
|
352
|
+
.collect()
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/// One cop's offenses as the config-generating outputs need them. RuboCop's
|
|
356
|
+
/// `DisabledConfigFormatter` keeps two separate tallies (`disabled_config_formatter.rb:57-63`):
|
|
357
|
+
/// every offense feeds `# Offense count:` (:165), while the exclude limit is weighed against the
|
|
358
|
+
/// *files* those offenses came from (:161, :237). Collapsing the two into one number silently
|
|
359
|
+
/// changes both outputs, so they are modelled apart here.
|
|
360
|
+
#[derive(Default)]
|
|
361
|
+
pub(crate) struct CopOffenses {
|
|
362
|
+
pub(crate) offense_count: usize,
|
|
363
|
+
/// Paths relative to the run's working directory, in the sorted and deduplicated form
|
|
364
|
+
/// RuboCop writes its `Exclude` list from.
|
|
365
|
+
pub(crate) paths: BTreeSet<String>,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/// Group offenses by cop for `--auto-gen-config` and the `autogenconf` formatter. Cops come out in
|
|
369
|
+
/// name order, matching RuboCop's `@cops_with_offenses.sort`.
|
|
370
|
+
pub(crate) fn offenses_by_cop(
|
|
371
|
+
reports: &[FileReport],
|
|
372
|
+
cwd: &Path,
|
|
373
|
+
) -> BTreeMap<&'static str, CopOffenses> {
|
|
374
|
+
let mut by_cop: BTreeMap<&'static str, CopOffenses> = BTreeMap::new();
|
|
375
|
+
for report in reports {
|
|
376
|
+
let path = smart_path(&report.path, cwd);
|
|
377
|
+
for offense in &report.offenses {
|
|
378
|
+
let entry = by_cop.entry(offense.cop_name).or_default();
|
|
379
|
+
entry.offense_count += 1;
|
|
380
|
+
// Every offense in a report shares the one path, which `insert` alone would clone
|
|
381
|
+
// again for each of them.
|
|
382
|
+
if !entry.paths.contains(&path) {
|
|
383
|
+
entry.paths.insert(path.clone());
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
by_cop
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
fn render_autogenconf(reports: &[FileReport], cwd: &Path) -> String {
|
|
391
|
+
let mut output = String::new();
|
|
392
|
+
for (cop, offenses) in offenses_by_cop(reports, cwd) {
|
|
393
|
+
output.push_str(&format!("{cop}:\n Exclude:\n"));
|
|
394
|
+
for path in &offenses.paths {
|
|
395
|
+
output.push_str(&format!(" - {}\n", yaml_single_quoted(path)));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
output
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#[derive(Serialize)]
|
|
402
|
+
struct JsonOutput<'a> {
|
|
403
|
+
metadata: Metadata,
|
|
404
|
+
files: Vec<JsonFile<'a>>,
|
|
405
|
+
summary: Summary,
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
#[derive(Serialize)]
|
|
409
|
+
struct Metadata {
|
|
410
|
+
rubocop_version: &'static str,
|
|
411
|
+
sonicop_version: &'static str,
|
|
412
|
+
ruby_engine: &'static str,
|
|
413
|
+
ruby_version: &'static str,
|
|
414
|
+
ruby_patchlevel: &'static str,
|
|
415
|
+
ruby_platform: &'static str,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#[derive(Serialize)]
|
|
419
|
+
struct JsonFile<'a> {
|
|
420
|
+
path: String,
|
|
421
|
+
offenses: Vec<JsonOffense<'a>>,
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
#[derive(Serialize)]
|
|
425
|
+
struct JsonOffense<'a> {
|
|
426
|
+
severity: Severity,
|
|
427
|
+
message: String,
|
|
428
|
+
cop_name: &'static str,
|
|
429
|
+
corrected: bool,
|
|
430
|
+
correctable: bool,
|
|
431
|
+
location: Location,
|
|
432
|
+
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
|
433
|
+
suppressed: bool,
|
|
434
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
435
|
+
justification: Option<&'a str>,
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#[derive(Serialize)]
|
|
439
|
+
struct Summary {
|
|
440
|
+
offense_count: usize,
|
|
441
|
+
target_file_count: usize,
|
|
442
|
+
inspected_file_count: usize,
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
fn render_json(reports: &[FileReport], options: &FormatOptions<'_>) -> Result<String> {
|
|
446
|
+
let files = reports
|
|
447
|
+
.iter()
|
|
448
|
+
.map(|report| JsonFile {
|
|
449
|
+
path: smart_path(&report.path, options.cwd),
|
|
450
|
+
offenses: report
|
|
451
|
+
.offenses
|
|
452
|
+
.iter()
|
|
453
|
+
.map(|offense| JsonOffense {
|
|
454
|
+
severity: offense.severity,
|
|
455
|
+
message: offense.message.clone(),
|
|
456
|
+
cop_name: offense.cop_name,
|
|
457
|
+
corrected: offense.corrected,
|
|
458
|
+
correctable: offense.is_correctable(),
|
|
459
|
+
location: offense.location(&report.source),
|
|
460
|
+
suppressed: offense.suppressed,
|
|
461
|
+
justification: offense.justification.as_deref(),
|
|
462
|
+
})
|
|
463
|
+
.collect(),
|
|
464
|
+
})
|
|
465
|
+
.collect();
|
|
466
|
+
let offense_count = reports.iter().map(|report| report.offenses.len()).sum();
|
|
467
|
+
let output = JsonOutput {
|
|
468
|
+
metadata: Metadata {
|
|
469
|
+
rubocop_version: RUBOCOP_COMPAT_FULL_VERSION,
|
|
470
|
+
sonicop_version: VERSION,
|
|
471
|
+
ruby_engine: "sonicop",
|
|
472
|
+
ruby_version: "n/a",
|
|
473
|
+
ruby_patchlevel: "0",
|
|
474
|
+
ruby_platform: std::env::consts::OS,
|
|
475
|
+
},
|
|
476
|
+
files,
|
|
477
|
+
summary: Summary {
|
|
478
|
+
offense_count,
|
|
479
|
+
target_file_count: reports.len(),
|
|
480
|
+
inspected_file_count: reports.len(),
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
Ok(serde_json::to_string(&output)?)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
fn display_message(offense: &Offense, options: &FormatOptions<'_>) -> String {
|
|
487
|
+
let status = if offense.suppressed {
|
|
488
|
+
"[Suppressed] "
|
|
489
|
+
} else if offense.corrected {
|
|
490
|
+
"[Corrected] "
|
|
491
|
+
} else {
|
|
492
|
+
""
|
|
493
|
+
};
|
|
494
|
+
let cop = if options.display_cop_names {
|
|
495
|
+
format!("{}: ", offense.cop_name)
|
|
496
|
+
} else {
|
|
497
|
+
String::new()
|
|
498
|
+
};
|
|
499
|
+
let mut message = format!("{status}{cop}{}", offense.message);
|
|
500
|
+
if options.display_style_guide
|
|
501
|
+
&& let Some(anchor) = options
|
|
502
|
+
.config
|
|
503
|
+
.cop_value::<String>(offense.cop_name, "StyleGuide")
|
|
504
|
+
{
|
|
505
|
+
let base = options
|
|
506
|
+
.config
|
|
507
|
+
.all_cops_value::<String>("StyleGuideBaseURL")
|
|
508
|
+
.unwrap_or_else(|| "https://rubystyle.guide".to_owned());
|
|
509
|
+
message.push_str(&format!(" ({base}{anchor})"));
|
|
510
|
+
}
|
|
511
|
+
if options.extra_details
|
|
512
|
+
&& let Some(description) = options.config.description(offense.cop_name)
|
|
513
|
+
{
|
|
514
|
+
message.push_str(&format!(" {}", description.trim()));
|
|
515
|
+
}
|
|
516
|
+
message
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/// RuboCop's `@total_offense_count`: every offense the formatter was handed, corrected and
|
|
520
|
+
/// suppressed ones included. The exit code applies its own, narrower predicate.
|
|
521
|
+
fn offense_count(reports: &[FileReport]) -> usize {
|
|
522
|
+
reports.iter().map(|report| report.offenses.len()).sum()
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
fn summary(reports: &[FileReport], options: &FormatOptions<'_>) -> String {
|
|
526
|
+
let offense_count = offense_count(reports);
|
|
527
|
+
let correctable_count = reports
|
|
528
|
+
.iter()
|
|
529
|
+
.flat_map(|report| &report.offenses)
|
|
530
|
+
.filter(|offense| offense.is_correctable() && !offense.corrected)
|
|
531
|
+
.count();
|
|
532
|
+
let offenses = if offense_count == 0 {
|
|
533
|
+
"no offenses".to_owned()
|
|
534
|
+
} else {
|
|
535
|
+
plural(offense_count, "offense")
|
|
536
|
+
};
|
|
537
|
+
let mut output = format!(
|
|
538
|
+
"{} inspected, {offenses} detected",
|
|
539
|
+
plural(reports.len(), "file")
|
|
540
|
+
);
|
|
541
|
+
if options.corrected_count > 0 {
|
|
542
|
+
output.push_str(&format!(
|
|
543
|
+
", {} corrected",
|
|
544
|
+
plural(options.corrected_count, "offense")
|
|
545
|
+
));
|
|
546
|
+
}
|
|
547
|
+
if correctable_count > 0 {
|
|
548
|
+
if options.safe_autocorrect {
|
|
549
|
+
output.push_str(&format!(
|
|
550
|
+
", {} can be corrected with `rubocop -A`",
|
|
551
|
+
plural(correctable_count, "more offense")
|
|
552
|
+
));
|
|
553
|
+
} else {
|
|
554
|
+
output.push_str(&format!(
|
|
555
|
+
", {} autocorrectable",
|
|
556
|
+
plural(correctable_count, "offense")
|
|
557
|
+
));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
output.push('\n');
|
|
561
|
+
output
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/// The path as RuboCop prints it: relative to the run's directory, with `/` separators on every
|
|
565
|
+
/// platform. Ruby normalizes separators, so a Windows run that emitted `lib\a.rb` would not match
|
|
566
|
+
/// upstream output nor the `Include`/`Exclude` patterns users copy out of it.
|
|
567
|
+
pub(crate) fn smart_path(path: &Path, cwd: &Path) -> String {
|
|
568
|
+
path.strip_prefix(cwd)
|
|
569
|
+
.unwrap_or(path)
|
|
570
|
+
.to_string_lossy()
|
|
571
|
+
.replace('\\', "/")
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
fn github_escape(value: &str) -> String {
|
|
575
|
+
value
|
|
576
|
+
.replace('%', "%25")
|
|
577
|
+
.replace('\r', "%0D")
|
|
578
|
+
.replace('\n', "%0A")
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/// A value as a YAML single-quoted scalar. Doubling `'` is the only escape such a scalar has, and
|
|
582
|
+
/// leaving it out lets a path containing one close the scalar early, so the generated config no
|
|
583
|
+
/// longer parses. RuboCop interpolates the path unescaped (`disabled_config_formatter.rb:283`)
|
|
584
|
+
/// and emits the broken YAML; Sonicop keeps its own output loadable instead.
|
|
585
|
+
pub(crate) fn yaml_single_quoted(value: &str) -> String {
|
|
586
|
+
format!("'{}'", value.replace('\'', "''"))
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
fn xml_escape(value: &str) -> String {
|
|
590
|
+
value
|
|
591
|
+
.replace('&', "&")
|
|
592
|
+
.replace('<', "<")
|
|
593
|
+
.replace('>', ">")
|
|
594
|
+
.replace('"', """)
|
|
595
|
+
.replace('\'', "'")
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
fn severity_color(severity: Severity) -> &'static str {
|
|
599
|
+
match severity {
|
|
600
|
+
Severity::Info => "90",
|
|
601
|
+
Severity::Refactor | Severity::Convention => "33",
|
|
602
|
+
Severity::Warning => "35",
|
|
603
|
+
Severity::Error | Severity::Fatal => "31",
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
fn paint(text: &str, color: &str, enabled: bool) -> String {
|
|
608
|
+
if enabled {
|
|
609
|
+
format!("\x1b[{color}m{text}\x1b[0m")
|
|
610
|
+
} else {
|
|
611
|
+
text.to_owned()
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
fn plural(count: usize, noun: &str) -> String {
|
|
616
|
+
format!("{count} {noun}{}", if count == 1 { "" } else { "s" })
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
#[cfg(test)]
|
|
620
|
+
mod tests {
|
|
621
|
+
use std::path::{Path, PathBuf};
|
|
622
|
+
|
|
623
|
+
use super::{offenses_by_cop, render_autogenconf, yaml_single_quoted};
|
|
624
|
+
use crate::diagnostic::{FileReport, Offense, Severity};
|
|
625
|
+
use crate::source::SourceFile;
|
|
626
|
+
|
|
627
|
+
fn report(path: PathBuf, cops: &[&'static str]) -> FileReport {
|
|
628
|
+
FileReport {
|
|
629
|
+
source: SourceFile::new(path.clone(), "value = 1\n".to_owned()),
|
|
630
|
+
path,
|
|
631
|
+
offenses: cops
|
|
632
|
+
.iter()
|
|
633
|
+
.map(|cop| Offense::new(cop, Severity::Convention, "offense", 0, 1))
|
|
634
|
+
.collect(),
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
#[test]
|
|
639
|
+
fn counts_every_offense_but_lists_each_file_once() {
|
|
640
|
+
let cwd = Path::new("project");
|
|
641
|
+
let reports = [
|
|
642
|
+
report(
|
|
643
|
+
cwd.join("a.rb"),
|
|
644
|
+
&[
|
|
645
|
+
"Layout/TrailingWhitespace",
|
|
646
|
+
"Layout/TrailingWhitespace",
|
|
647
|
+
"Style/FrozenStringLiteralComment",
|
|
648
|
+
],
|
|
649
|
+
),
|
|
650
|
+
report(cwd.join("b.rb"), &["Layout/TrailingWhitespace"]),
|
|
651
|
+
];
|
|
652
|
+
|
|
653
|
+
let by_cop = offenses_by_cop(&reports, cwd);
|
|
654
|
+
|
|
655
|
+
let trailing = &by_cop["Layout/TrailingWhitespace"];
|
|
656
|
+
assert_eq!(trailing.offense_count, 3);
|
|
657
|
+
assert_eq!(
|
|
658
|
+
trailing
|
|
659
|
+
.paths
|
|
660
|
+
.iter()
|
|
661
|
+
.map(String::as_str)
|
|
662
|
+
.collect::<Vec<_>>(),
|
|
663
|
+
["a.rb", "b.rb"]
|
|
664
|
+
);
|
|
665
|
+
assert_eq!(by_cop["Style/FrozenStringLiteralComment"].offense_count, 1);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
#[test]
|
|
669
|
+
fn single_quoted_scalars_double_an_embedded_quote() {
|
|
670
|
+
assert_eq!(yaml_single_quoted("plain.rb"), "'plain.rb'");
|
|
671
|
+
assert_eq!(yaml_single_quoted("it's/a.rb"), "'it''s/a.rb'");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
#[test]
|
|
675
|
+
fn autogenconf_escapes_quotes_in_excluded_paths() {
|
|
676
|
+
let cwd = Path::new("project");
|
|
677
|
+
let reports = [report(cwd.join("it's.rb"), &["Layout/TrailingWhitespace"])];
|
|
678
|
+
|
|
679
|
+
assert_eq!(
|
|
680
|
+
render_autogenconf(&reports, cwd),
|
|
681
|
+
"Layout/TrailingWhitespace:\n Exclude:\n - 'it''s.rb'\n"
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
}
|