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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/CONFORMANCE.md +109 -0
  3. data/Cargo.lock +975 -0
  4. data/Cargo.toml +40 -0
  5. data/LICENSE +21 -0
  6. data/NOTICE +7 -0
  7. data/README.ja.md +161 -0
  8. data/README.md +173 -0
  9. data/config/default.yml +6305 -0
  10. data/exe/sonicop +7 -0
  11. data/ext/sonicop/extconf.rb +38 -0
  12. data/lib/sonicop/runner.rb +67 -0
  13. data/lib/sonicop/version.rb +6 -0
  14. data/lib/sonicop.rb +8 -0
  15. data/licenses/RUBOCOP.txt +20 -0
  16. data/licenses/TREE_SITTER_RUBY.txt +21 -0
  17. data/src/cli.rs +940 -0
  18. data/src/config/inheritance.rs +332 -0
  19. data/src/config/loader.rs +117 -0
  20. data/src/config/mod.rs +461 -0
  21. data/src/config/paths.rs +436 -0
  22. data/src/config/plugin.rs +247 -0
  23. data/src/config/store.rs +141 -0
  24. data/src/cop_name.rs +71 -0
  25. data/src/diagnostic.rs +205 -0
  26. data/src/directives.rs +305 -0
  27. data/src/engine.rs +734 -0
  28. data/src/formatter.rs +684 -0
  29. data/src/lib.rs +42 -0
  30. data/src/main.rs +3 -0
  31. data/src/ruby_version.rs +393 -0
  32. data/src/rules/layout/empty_line_after_magic_comment.rs +49 -0
  33. data/src/rules/layout/end_of_line.rs +35 -0
  34. data/src/rules/layout/line_length.rs +167 -0
  35. data/src/rules/layout/mod.rs +13 -0
  36. data/src/rules/layout/space_after_comma.rs +32 -0
  37. data/src/rules/layout/space_around_operators.rs +104 -0
  38. data/src/rules/layout/space_inside_parens.rs +100 -0
  39. data/src/rules/layout/support.rs +23 -0
  40. data/src/rules/layout/trailing_empty_lines.rs +38 -0
  41. data/src/rules/layout/trailing_whitespace.rs +26 -0
  42. data/src/rules/lint/duplicate_methods.rs +72 -0
  43. data/src/rules/lint/mod.rs +7 -0
  44. data/src/rules/lint/syntax.rs +200 -0
  45. data/src/rules/lint/unused_block_argument.rs +74 -0
  46. data/src/rules/lint/useless_assignment.rs +104 -0
  47. data/src/rules/metrics/block_length.rs +39 -0
  48. data/src/rules/metrics/class_length.rs +34 -0
  49. data/src/rules/metrics/method_length.rs +10 -0
  50. data/src/rules/metrics/mod.rs +10 -0
  51. data/src/rules/metrics/module_length.rs +17 -0
  52. data/src/rules/metrics/parameter_lists.rs +21 -0
  53. data/src/rules/metrics/support.rs +105 -0
  54. data/src/rules/mod.rs +380 -0
  55. data/src/rules/naming/ascii_identifiers.rs +17 -0
  56. data/src/rules/naming/constant_name.rs +49 -0
  57. data/src/rules/naming/method_name.rs +51 -0
  58. data/src/rules/naming/mod.rs +9 -0
  59. data/src/rules/naming/support.rs +22 -0
  60. data/src/rules/naming/variable_name.rs +50 -0
  61. data/src/rules/security/eval.rs +170 -0
  62. data/src/rules/security/mod.rs +6 -0
  63. data/src/rules/style/frozen_string_literal_comment.rs +56 -0
  64. data/src/rules/style/hash_syntax.rs +63 -0
  65. data/src/rules/style/mod.rs +9 -0
  66. data/src/rules/style/numeric_literals.rs +59 -0
  67. data/src/rules/style/redundant_return.rs +143 -0
  68. data/src/rules/style/semicolon.rs +48 -0
  69. data/src/rules/style/string_literals.rs +74 -0
  70. data/src/rules/support.rs +31 -0
  71. data/src/source.rs +118 -0
  72. 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('&', "&amp;")
592
+ .replace('<', "&lt;")
593
+ .replace('>', "&gt;")
594
+ .replace('"', "&quot;")
595
+ .replace('\'', "&apos;")
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
+ }