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/cli.rs ADDED
@@ -0,0 +1,940 @@
1
+ use std::collections::HashSet;
2
+ use std::fs;
3
+ use std::io::{self, IsTerminal, Read, Write};
4
+ use std::path::{Path, PathBuf};
5
+ use std::time::Instant;
6
+
7
+ use anyhow::{Context, Result, bail};
8
+ use clap::{ArgAction, ArgMatches, CommandFactory, FromArgMatches, Parser, error::ErrorKind};
9
+
10
+ use crate::config::{Config, ConfigStore};
11
+ use crate::cop_name::{self, selector_matches};
12
+ use crate::diagnostic::{FileReport, Offense, Severity};
13
+ use crate::engine::{
14
+ CorrectMode, Selection, correct_file, discover_targets_with_store, inspect_files_with_store,
15
+ inspect_source, is_mandatory_cop, offense_count, write_corrected,
16
+ };
17
+ use crate::formatter::{
18
+ Format, FormatOptions, offenses_by_cop, render, smart_path, yaml_single_quoted,
19
+ };
20
+ use crate::rules::rule_names;
21
+ use crate::{RUBOCOP_COMPAT_VERSION, VERSION};
22
+
23
+ #[derive(Debug, Parser)]
24
+ #[command(name = "sonicop")]
25
+ #[command(about = "A fast, native RuboCop-compatible Ruby linter and formatter")]
26
+ #[command(disable_version_flag = true)]
27
+ #[command(max_term_width = 100)]
28
+ struct Cli {
29
+ /// Files or directories to inspect
30
+ #[arg(value_name = "FILE")]
31
+ paths: Vec<PathBuf>,
32
+
33
+ #[arg(short = 'l', long, help_heading = "Basic Options")]
34
+ lint: bool,
35
+ #[arg(short = 'x', long, help_heading = "Basic Options")]
36
+ fix_layout: bool,
37
+ #[arg(long, help_heading = "Basic Options")]
38
+ safe: bool,
39
+ #[arg(long, value_name = "COP1,COP2", help_heading = "Basic Options")]
40
+ only: Option<String>,
41
+ #[arg(long, value_name = "COP1,COP2", help_heading = "Basic Options")]
42
+ except: Option<String>,
43
+ #[arg(long, help_heading = "Basic Options")]
44
+ only_guide_cops: bool,
45
+ #[arg(short = 'F', long, help_heading = "Basic Options")]
46
+ fail_fast: bool,
47
+ #[arg(long, help_heading = "Basic Options")]
48
+ disable_pending_cops: bool,
49
+ #[arg(long, help_heading = "Basic Options")]
50
+ enable_pending_cops: bool,
51
+ #[arg(
52
+ long,
53
+ conflicts_with = "disable_all_cops",
54
+ help_heading = "Basic Options"
55
+ )]
56
+ enable_all_cops: bool,
57
+ #[arg(long, help_heading = "Basic Options")]
58
+ disable_all_cops: bool,
59
+ #[arg(long, help_heading = "Basic Options")]
60
+ ignore_disable_comments: bool,
61
+ #[arg(long, help_heading = "Basic Options")]
62
+ force_exclusion: bool,
63
+ #[arg(long, help_heading = "Basic Options")]
64
+ only_recognized_file_types: bool,
65
+ #[arg(long, help_heading = "Basic Options")]
66
+ ignore_parent_exclusion: bool,
67
+ #[arg(long, help_heading = "Basic Options")]
68
+ ignore_unrecognized_cops: bool,
69
+ #[arg(long, help_heading = "Basic Options")]
70
+ force_default_config: bool,
71
+ #[arg(short = 's', long, value_name = "FILE", help_heading = "Basic Options")]
72
+ stdin: Option<PathBuf>,
73
+ #[arg(long, help_heading = "Basic Options")]
74
+ editor_mode: bool,
75
+ #[arg(short = 'P', long, action = ArgAction::SetTrue, help_heading = "Basic Options")]
76
+ parallel: bool,
77
+ #[arg(long, conflicts_with = "parallel", help_heading = "Basic Options")]
78
+ no_parallel: bool,
79
+ #[arg(long, help_heading = "Basic Options")]
80
+ raise_cop_error: bool,
81
+ #[arg(
82
+ long,
83
+ default_value = "refactor",
84
+ value_name = "SEVERITY",
85
+ help_heading = "Basic Options"
86
+ )]
87
+ fail_level: String,
88
+
89
+ #[arg(short = 'C', long, value_name = "FLAG", help_heading = "Caching")]
90
+ cache: Option<String>,
91
+ #[arg(long, value_name = "DIR", help_heading = "Caching")]
92
+ cache_root: Option<PathBuf>,
93
+
94
+ #[arg(short = 'f', long = "format", action = ArgAction::Append, value_name = "FORMATTER", help_heading = "Output Options")]
95
+ formats: Vec<String>,
96
+ #[arg(short = 'D', long, action = ArgAction::SetTrue, help_heading = "Output Options")]
97
+ display_cop_names: bool,
98
+ #[arg(long, help_heading = "Output Options")]
99
+ no_display_cop_names: bool,
100
+ #[arg(short = 'E', long, help_heading = "Output Options")]
101
+ extra_details: bool,
102
+ #[arg(short = 'S', long, help_heading = "Output Options")]
103
+ display_style_guide: bool,
104
+ #[arg(short = 'o', long, action = ArgAction::Append, value_name = "FILE", help_heading = "Output Options")]
105
+ out: Vec<PathBuf>,
106
+ #[arg(long, help_heading = "Output Options")]
107
+ stderr: bool,
108
+ #[arg(long, help_heading = "Output Options")]
109
+ display_time: bool,
110
+ #[arg(long, help_heading = "Output Options")]
111
+ display_only_failed: bool,
112
+ #[arg(long, help_heading = "Output Options")]
113
+ display_only_fail_level_offenses: bool,
114
+ #[arg(long, help_heading = "Output Options")]
115
+ display_only_correctable: bool,
116
+ #[arg(long, help_heading = "Output Options")]
117
+ display_only_safe_correctable: bool,
118
+ #[arg(long, help_heading = "Output Options")]
119
+ display_suppressed: bool,
120
+
121
+ #[arg(short = 'a', long = "autocorrect", help_heading = "Autocorrection")]
122
+ autocorrect: bool,
123
+ #[arg(short = 'A', long = "autocorrect-all", help_heading = "Autocorrection")]
124
+ autocorrect_all: bool,
125
+ #[arg(long = "auto-correct", help_heading = "Autocorrection")]
126
+ deprecated_auto_correct: bool,
127
+ #[arg(long = "safe-auto-correct", help_heading = "Autocorrection")]
128
+ deprecated_safe_auto_correct: bool,
129
+ #[arg(long = "auto-correct-all", help_heading = "Autocorrection")]
130
+ deprecated_auto_correct_all: bool,
131
+ #[arg(long, help_heading = "Autocorrection")]
132
+ disable_uncorrectable: bool,
133
+
134
+ #[arg(long, help_heading = "Config Generation")]
135
+ auto_gen_config: bool,
136
+ #[arg(long, help_heading = "Config Generation")]
137
+ regenerate_todo: bool,
138
+ #[arg(
139
+ long,
140
+ default_value_t = 15,
141
+ value_name = "COUNT",
142
+ help_heading = "Config Generation"
143
+ )]
144
+ exclude_limit: usize,
145
+ #[arg(long, help_heading = "Config Generation")]
146
+ no_exclude_limit: bool,
147
+ #[arg(long, action = ArgAction::SetTrue, help_heading = "Config Generation")]
148
+ offense_counts: bool,
149
+ #[arg(long, help_heading = "Config Generation")]
150
+ no_offense_counts: bool,
151
+ #[arg(long, action = ArgAction::SetTrue, help_heading = "Config Generation")]
152
+ auto_gen_only_exclude: bool,
153
+ #[arg(long, help_heading = "Config Generation")]
154
+ no_auto_gen_only_exclude: bool,
155
+ #[arg(long, action = ArgAction::SetTrue, help_heading = "Config Generation")]
156
+ auto_gen_timestamp: bool,
157
+ #[arg(long, help_heading = "Config Generation")]
158
+ no_auto_gen_timestamp: bool,
159
+ #[arg(long, action = ArgAction::SetTrue, help_heading = "Config Generation")]
160
+ auto_gen_enforced_style: bool,
161
+ #[arg(long, help_heading = "Config Generation")]
162
+ no_auto_gen_enforced_style: bool,
163
+
164
+ #[arg(long, help_heading = "LSP Option")]
165
+ lsp: bool,
166
+ #[arg(long, help_heading = "MCP Option")]
167
+ mcp: bool,
168
+ #[arg(long, help_heading = "Server Options")]
169
+ server: bool,
170
+ #[arg(long, help_heading = "Server Options")]
171
+ no_server: bool,
172
+ #[arg(long, help_heading = "Server Options")]
173
+ restart_server: bool,
174
+ #[arg(long, help_heading = "Server Options")]
175
+ start_server: bool,
176
+ #[arg(long, help_heading = "Server Options")]
177
+ stop_server: bool,
178
+ #[arg(long, help_heading = "Server Options")]
179
+ server_status: bool,
180
+ #[arg(long, help_heading = "Server Options")]
181
+ no_detach: bool,
182
+
183
+ #[arg(short = 'L', long, help_heading = "Additional Modes")]
184
+ list_target_files: bool,
185
+ #[arg(long, value_name = "PATH", help_heading = "Additional Modes")]
186
+ list_enabled_cops_for: Option<PathBuf>,
187
+ #[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "COP1,COP2", help_heading = "Additional Modes")]
188
+ show_cops: Option<String>,
189
+ #[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "COP1,COP2", help_heading = "Additional Modes")]
190
+ show_docs_url: Option<String>,
191
+
192
+ #[arg(long, help_heading = "General Options")]
193
+ init: bool,
194
+ #[arg(
195
+ short = 'c',
196
+ long,
197
+ value_name = "FILE",
198
+ help_heading = "General Options"
199
+ )]
200
+ config: Option<PathBuf>,
201
+ #[arg(short = 'd', long, help_heading = "General Options")]
202
+ debug: bool,
203
+ #[arg(long, action = ArgAction::Append, value_name = "FILE", help_heading = "General Options")]
204
+ plugin: Vec<String>,
205
+ #[arg(short = 'r', long = "require", action = ArgAction::Append, value_name = "FILE", help_heading = "General Options")]
206
+ requires: Vec<String>,
207
+ #[arg(long, action = ArgAction::SetTrue, help_heading = "General Options")]
208
+ color: bool,
209
+ #[arg(long, conflicts_with = "color", help_heading = "General Options")]
210
+ no_color: bool,
211
+ #[arg(short = 'v', long = "version", help_heading = "General Options")]
212
+ version: bool,
213
+ #[arg(
214
+ short = 'V',
215
+ long = "verbose-version",
216
+ help_heading = "General Options"
217
+ )]
218
+ verbose_version: bool,
219
+ #[arg(long, help_heading = "Profiling Options")]
220
+ profile: bool,
221
+ #[arg(long, help_heading = "Profiling Options")]
222
+ memory: bool,
223
+ }
224
+
225
+ impl Cli {
226
+ fn correct_mode(&self) -> CorrectMode {
227
+ if self.autocorrect_all || self.deprecated_auto_correct_all || self.fix_layout {
228
+ CorrectMode::All
229
+ } else if self.autocorrect
230
+ || self.deprecated_auto_correct
231
+ || self.deprecated_safe_auto_correct
232
+ {
233
+ CorrectMode::Safe
234
+ } else {
235
+ CorrectMode::None
236
+ }
237
+ }
238
+ }
239
+
240
+ pub fn run() -> i32 {
241
+ let arguments = match composed_arguments() {
242
+ Ok(arguments) => arguments,
243
+ Err(error) => {
244
+ eprintln!("Error: {error:#}");
245
+ return 2;
246
+ }
247
+ };
248
+ let matches = match Cli::command().try_get_matches_from(arguments) {
249
+ Ok(matches) => matches,
250
+ Err(error)
251
+ if matches!(
252
+ error.kind(),
253
+ ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
254
+ ) =>
255
+ {
256
+ print!("{error}");
257
+ return 0;
258
+ }
259
+ Err(error) => {
260
+ eprint!("{error}");
261
+ return 2;
262
+ }
263
+ };
264
+ let cli = match Cli::from_arg_matches(&matches) {
265
+ Ok(cli) => cli,
266
+ Err(error) => {
267
+ eprint!("{error}");
268
+ return 2;
269
+ }
270
+ };
271
+ let outputs = output_paths_by_format(&matches);
272
+ match try_run(cli, &outputs) {
273
+ Ok(code) => code,
274
+ Err(error) => {
275
+ eprintln!("Error: {error:#}");
276
+ 2
277
+ }
278
+ }
279
+ }
280
+
281
+ /// RuboCop attaches each `--out` to the `--format` that preceded it on the command line, so the
282
+ /// slots have to be filled by argument position rather than by ordinal. An `--out` given before any
283
+ /// `--format` belongs to the default formatter, and a second `--out` on the same format is dropped.
284
+ fn output_paths_by_format(matches: &ArgMatches) -> Vec<Option<PathBuf>> {
285
+ let positions = |id| -> Vec<usize> {
286
+ matches
287
+ .indices_of(id)
288
+ .map(Iterator::collect)
289
+ .unwrap_or_default()
290
+ };
291
+ let format_positions = positions("formats");
292
+ let out_positions = positions("out");
293
+ let out_paths: Vec<&PathBuf> = matches
294
+ .get_many::<PathBuf>("out")
295
+ .map(Iterator::collect)
296
+ .unwrap_or_default();
297
+
298
+ let mut slots = vec![None; format_positions.len().max(1)];
299
+ for (out_position, path) in out_positions.into_iter().zip(out_paths) {
300
+ let slot = format_positions
301
+ .iter()
302
+ .rposition(|format_position| *format_position < out_position)
303
+ .unwrap_or(0);
304
+ slots[slot].get_or_insert_with(|| path.clone());
305
+ }
306
+ slots
307
+ }
308
+
309
+ fn composed_arguments() -> Result<Vec<String>> {
310
+ let mut arguments = vec![
311
+ std::env::args()
312
+ .next()
313
+ .unwrap_or_else(|| "sonicop".to_owned()),
314
+ ];
315
+ let dotfile = std::env::current_dir()?.join(".rubocop");
316
+ if dotfile.is_file() {
317
+ let contents = fs::read_to_string(&dotfile)
318
+ .with_context(|| format!("failed to read {}", dotfile.display()))?;
319
+ arguments.extend(
320
+ shell_words::split(&contents)
321
+ .with_context(|| format!("failed to parse {}", dotfile.display()))?,
322
+ );
323
+ }
324
+ if let Some(options) = std::env::var_os("RUBOCOP_OPTS") {
325
+ let options = options
326
+ .into_string()
327
+ .map_err(|_| anyhow::anyhow!("RUBOCOP_OPTS is not valid UTF-8"))?;
328
+ arguments.extend(shell_words::split(&options).context("failed to parse RUBOCOP_OPTS")?);
329
+ }
330
+ arguments.extend(std::env::args().skip(1));
331
+ Ok(arguments)
332
+ }
333
+
334
+ fn try_run(cli: Cli, outputs: &[Option<PathBuf>]) -> Result<i32> {
335
+ let started = Instant::now();
336
+ if cli.version {
337
+ println!("{VERSION}");
338
+ return Ok(0);
339
+ }
340
+ if cli.verbose_version {
341
+ let cwd = std::env::current_dir().context("failed to determine current directory")?;
342
+ let config =
343
+ Config::load_with_options(cli.config.as_deref(), &cwd, cli.force_default_config)?;
344
+ println!(
345
+ "sonicop {VERSION} (RuboCop {RUBOCOP_COMPAT_VERSION} CLI, tree-sitter-ruby owayo@88a64c6, analyzing as Ruby {}) [{} {}]",
346
+ config.target_ruby_version(),
347
+ std::env::consts::OS,
348
+ std::env::consts::ARCH
349
+ );
350
+ return Ok(0);
351
+ }
352
+ validate_compatibility(&cli)?;
353
+ let fail_level = FailLevel::parse(&cli.fail_level)?;
354
+ print_deprecation_warnings(&cli);
355
+
356
+ let cwd = std::env::current_dir().context("failed to determine current directory")?;
357
+ if cli.init {
358
+ return init_config(&cwd);
359
+ }
360
+ let config = Config::load_with_options(cli.config.as_deref(), &cwd, cli.force_default_config)?;
361
+ validate_config(&config, cli.ignore_unrecognized_cops)?;
362
+ let configs = ConfigStore::new(
363
+ config.clone(),
364
+ cli.config.is_none() && !cli.force_default_config,
365
+ cli.ignore_unrecognized_cops,
366
+ );
367
+ report_noop_modes(&cli);
368
+
369
+ if cli.lsp || cli.mcp {
370
+ return Ok(0);
371
+ }
372
+ if let Some(filter) = &cli.show_cops {
373
+ show_cops(filter, &config);
374
+ return Ok(0);
375
+ }
376
+ if let Some(filter) = &cli.show_docs_url {
377
+ show_docs_urls(filter, &config);
378
+ return Ok(0);
379
+ }
380
+ if let Some(path) = &cli.list_enabled_cops_for {
381
+ list_enabled_cops(path, &configs)?;
382
+ return Ok(0);
383
+ }
384
+
385
+ let mut only = csv(cli.only.as_deref());
386
+ if cli.lint {
387
+ only.push("Lint".to_owned());
388
+ }
389
+ if cli.fix_layout {
390
+ only.push("Layout".to_owned());
391
+ }
392
+ validate_selection(&only, "--only", &config)?;
393
+ let except = csv(cli.except.as_deref());
394
+ validate_selection(&except, "--except", &config)?;
395
+ let selection = Selection {
396
+ only,
397
+ except,
398
+ disable_all: cli.disable_all_cops,
399
+ enable_all: cli.enable_all_cops,
400
+ enable_pending: cli.enable_pending_cops,
401
+ disable_pending: cli.disable_pending_cops,
402
+ safe_only: cli.safe,
403
+ ignore_disable_comments: cli.ignore_disable_comments,
404
+ display_suppressed: cli.display_suppressed,
405
+ };
406
+ let correct_mode = cli.correct_mode();
407
+
408
+ let parallel = !cli.no_parallel && (cli.parallel || cli.stdin.is_none());
409
+ let mut reports = if let Some(stdin_path) = &cli.stdin {
410
+ if !cli.paths.is_empty() {
411
+ bail!(
412
+ "--stdin requires exactly one path supplied as its argument and no file arguments"
413
+ );
414
+ }
415
+ let mut text = String::new();
416
+ io::stdin()
417
+ .read_to_string(&mut text)
418
+ .context("failed to read UTF-8 source from stdin")?;
419
+ let target_config = configs.for_path(stdin_path)?;
420
+ vec![inspect_source(
421
+ stdin_path.clone(),
422
+ text,
423
+ &target_config,
424
+ &selection,
425
+ )?]
426
+ } else {
427
+ let mut targets = discover_targets_with_store(
428
+ &cli.paths,
429
+ &cwd,
430
+ &configs,
431
+ cli.force_exclusion,
432
+ cli.only_recognized_file_types,
433
+ )?;
434
+ if cli.list_target_files {
435
+ for path in targets {
436
+ println!("{}", smart_path(&path, &cwd));
437
+ }
438
+ return Ok(0);
439
+ }
440
+ if cli.fail_fast {
441
+ targets.sort_by_key(|path| {
442
+ std::cmp::Reverse(
443
+ fs::metadata(path)
444
+ .and_then(|metadata| metadata.modified())
445
+ .ok(),
446
+ )
447
+ });
448
+ inspect_fail_fast(&targets, &configs, &selection)?
449
+ } else {
450
+ inspect_files_with_store(&targets, &configs, &selection, parallel)?
451
+ }
452
+ };
453
+
454
+ if cli.debug {
455
+ debug_report(&cwd, &config, reports.len(), parallel);
456
+ }
457
+ if cli.auto_gen_config || cli.regenerate_todo {
458
+ generate_todo(&reports, &cwd, &cli)?;
459
+ return Ok(0);
460
+ }
461
+ if cli.disable_uncorrectable {
462
+ eprintln!(
463
+ "Sonicop: --disable-uncorrectable is accepted; todo insertion is not implemented yet."
464
+ );
465
+ }
466
+
467
+ let mut corrected_count = 0;
468
+ let mut stdin_corrected = None;
469
+ let mut run_errors = 0;
470
+ let mut corrected_reports = Vec::with_capacity(reports.len());
471
+ for report in reports {
472
+ let path = report.path.clone();
473
+ let target_config = configs.for_path(&path)?;
474
+ let outcome = correct_file(report, correct_mode, &target_config, &selection)?;
475
+ corrected_count += outcome.corrected_count;
476
+ if let Some(message) = outcome.infinite_loop {
477
+ // RuboCop keeps the run going and still writes what it managed to correct.
478
+ eprintln!("{message}");
479
+ run_errors += 1;
480
+ }
481
+ if outcome.corrected_count > 0 {
482
+ if cli.stdin.is_some() {
483
+ stdin_corrected = Some(outcome.text);
484
+ } else {
485
+ write_corrected(&path, &outcome.text)?;
486
+ }
487
+ }
488
+ corrected_reports.push(outcome.report);
489
+ }
490
+ reports = corrected_reports;
491
+
492
+ // A file RuboCop could not finish counts as a failed run even when nothing else offended.
493
+ let failing = fail_level.failing(&reports) || run_errors > 0;
494
+
495
+ if let Some(corrected) = stdin_corrected {
496
+ print!("{corrected}");
497
+ } else {
498
+ filter_displayed_offenses(&mut reports, &cli, fail_level);
499
+ render_outputs(
500
+ &RenderRequest {
501
+ cli: &cli,
502
+ config: &config,
503
+ cwd: &cwd,
504
+ fail_level,
505
+ outputs,
506
+ corrected_count,
507
+ elapsed: started.elapsed().as_secs_f64(),
508
+ },
509
+ &reports,
510
+ )?;
511
+ }
512
+
513
+ Ok(i32::from(failing))
514
+ }
515
+
516
+ /// RuboCop's `--fail-level` accepts a severity plus the pseudo level `autocorrect`, which changes
517
+ /// which offenses count as failures rather than raising the severity threshold.
518
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
519
+ enum FailLevel {
520
+ Severity(Severity),
521
+ Autocorrect,
522
+ }
523
+
524
+ impl FailLevel {
525
+ fn parse(value: &str) -> Result<Self> {
526
+ if matches!(value.to_ascii_lowercase().as_str(), "a" | "autocorrect") {
527
+ return Ok(Self::Autocorrect);
528
+ }
529
+ Severity::parse(value)
530
+ .map(Self::Severity)
531
+ .with_context(|| format!("unknown severity: {value}"))
532
+ }
533
+
534
+ /// `autocorrect` is not a severity, so everything that needs a threshold falls back to the
535
+ /// default the way RuboCop's `minimum_severity_to_fail` does.
536
+ fn severity(self) -> Severity {
537
+ match self {
538
+ Self::Severity(severity) => severity,
539
+ Self::Autocorrect => Severity::Refactor,
540
+ }
541
+ }
542
+
543
+ fn failing(self, reports: &[FileReport]) -> bool {
544
+ match self {
545
+ // A correctable offense fails even once it has been corrected.
546
+ Self::Autocorrect => reports
547
+ .iter()
548
+ .flat_map(|report| &report.offenses)
549
+ .any(Offense::is_correctable),
550
+ Self::Severity(severity) => offense_count(reports, severity) > 0,
551
+ }
552
+ }
553
+ }
554
+
555
+ fn validate_compatibility(cli: &Cli) -> Result<()> {
556
+ if cli.except.as_deref().is_some_and(|value| {
557
+ csv(Some(value))
558
+ .iter()
559
+ .any(|name| is_mandatory_cop(name.as_str()))
560
+ }) {
561
+ bail!("Syntax checking cannot be turned off.");
562
+ }
563
+ if let Some(cache) = &cli.cache
564
+ && !matches!(cache.as_str(), "true" | "false")
565
+ {
566
+ bail!("-C/--cache argument must be true or false");
567
+ }
568
+ if cli.cache.as_deref() == Some("false") && cli.cache_root.is_some() {
569
+ bail!("--cache-root cannot be used with --cache false");
570
+ }
571
+ if cli.display_only_failed
572
+ && !cli
573
+ .formats
574
+ .iter()
575
+ .any(|format| matches!(format.as_str(), "junit" | "ju"))
576
+ {
577
+ bail!("--display-only-failed can only be used with --format junit");
578
+ }
579
+ if cli.display_only_correctable && (cli.autocorrect || cli.autocorrect_all || cli.fix_layout) {
580
+ bail!("--display-only-correctable cannot be combined with autocorrection");
581
+ }
582
+ if cli.lsp && cli.editor_mode {
583
+ bail!("--lsp cannot be combined with --editor-mode");
584
+ }
585
+ Ok(())
586
+ }
587
+
588
+ fn print_deprecation_warnings(cli: &Cli) {
589
+ if cli.deprecated_auto_correct {
590
+ eprintln!("--auto-correct is deprecated; use --autocorrect instead.");
591
+ }
592
+ if cli.deprecated_safe_auto_correct {
593
+ eprintln!("--safe-auto-correct is deprecated; use --autocorrect instead.");
594
+ }
595
+ if cli.deprecated_auto_correct_all {
596
+ eprintln!("--auto-correct-all is deprecated; use --autocorrect-all instead.");
597
+ }
598
+ }
599
+
600
+ fn report_noop_modes(cli: &Cli) {
601
+ if cli.server
602
+ || cli.no_server
603
+ || cli.restart_server
604
+ || cli.start_server
605
+ || cli.stop_server
606
+ || cli.server_status
607
+ || cli.no_detach
608
+ {
609
+ eprintln!(
610
+ "Sonicop: server flags are accepted as no-ops because native startup is immediate."
611
+ );
612
+ }
613
+ if cli.lsp {
614
+ eprintln!("Sonicop: --lsp is accepted; the LSP transport is not implemented yet.");
615
+ }
616
+ if cli.mcp {
617
+ eprintln!("Sonicop: --mcp is accepted; the MCP transport is not implemented yet.");
618
+ }
619
+ if !cli.plugin.is_empty() || !cli.requires.is_empty() {
620
+ eprintln!("Sonicop: Ruby plugins and --require entries are accepted but not executed.");
621
+ }
622
+ if cli.profile || cli.memory {
623
+ eprintln!(
624
+ "Sonicop: profiling flags are accepted; use an external Rust profiler for native traces."
625
+ );
626
+ }
627
+ let _ = (
628
+ cli.only_guide_cops,
629
+ cli.ignore_parent_exclusion,
630
+ cli.raise_cop_error,
631
+ cli.offense_counts,
632
+ cli.no_offense_counts,
633
+ cli.auto_gen_only_exclude,
634
+ cli.no_auto_gen_only_exclude,
635
+ cli.auto_gen_timestamp,
636
+ cli.no_auto_gen_timestamp,
637
+ cli.auto_gen_enforced_style,
638
+ cli.no_auto_gen_enforced_style,
639
+ );
640
+ }
641
+
642
+ fn inspect_fail_fast(
643
+ paths: &[PathBuf],
644
+ configs: &ConfigStore,
645
+ selection: &Selection,
646
+ ) -> Result<Vec<FileReport>> {
647
+ let mut reports = Vec::new();
648
+ for path in paths {
649
+ let mut inspected =
650
+ inspect_files_with_store(std::slice::from_ref(path), configs, selection, false)?;
651
+ let has_offense = inspected
652
+ .first()
653
+ .is_some_and(|report| !report.offenses.is_empty());
654
+ reports.append(&mut inspected);
655
+ if has_offense {
656
+ break;
657
+ }
658
+ }
659
+ Ok(reports)
660
+ }
661
+
662
+ fn validate_config(config: &Config, ignore_unrecognized: bool) -> Result<()> {
663
+ if !ignore_unrecognized && !config.unrecognized_cop_names().is_empty() {
664
+ bail!(
665
+ "unrecognized cop(s): {}",
666
+ config.unrecognized_cop_names().join(", ")
667
+ );
668
+ }
669
+ Ok(())
670
+ }
671
+
672
+ fn validate_selection(values: &[String], flag: &str, config: &Config) -> Result<()> {
673
+ let known: HashSet<&str> = config.known_cop_names().collect();
674
+ let departments: HashSet<&str> = known
675
+ .iter()
676
+ .map(|name| cop_name::department(name))
677
+ .collect();
678
+ for value in values {
679
+ if !known.contains(value.as_str()) && !departments.contains(value.as_str()) {
680
+ bail!("Unrecognized cop or department for {flag}: {value}.");
681
+ }
682
+ }
683
+ Ok(())
684
+ }
685
+
686
+ fn debug_report(cwd: &Path, config: &Config, targets: usize, parallel: bool) {
687
+ let implemented: HashSet<&str> = rule_names().collect();
688
+ let mut unimplemented = config
689
+ .known_cop_names()
690
+ .filter(|name| !implemented.contains(name))
691
+ .collect::<Vec<_>>();
692
+ unimplemented.sort();
693
+ eprintln!(
694
+ "For {}: configuration from {}, {targets} target(s), parallel={parallel}",
695
+ cwd.display(),
696
+ config.config_path().map_or_else(
697
+ || "built-in defaults".to_owned(),
698
+ |path| path.display().to_string()
699
+ )
700
+ );
701
+ eprintln!(
702
+ "Implemented cops: {}; recognized but not implemented: {}",
703
+ implemented.len(),
704
+ unimplemented.len()
705
+ );
706
+ eprintln!("Unimplemented cops: {}", unimplemented.join(", "));
707
+ }
708
+
709
+ /// The cop names a `--show-cops`/`--show-docs-url` filter selects, sorted. An empty filter names
710
+ /// every known cop, which is how RuboCop prints the whole list.
711
+ fn selected_cop_names<'a>(filter: &str, config: &'a Config) -> Vec<&'a str> {
712
+ let filters = csv((!filter.is_empty()).then_some(filter));
713
+ let mut names = config
714
+ .known_cop_names()
715
+ .filter(|name| {
716
+ filters.is_empty()
717
+ || filters
718
+ .iter()
719
+ .any(|selection| selector_matches(selection, name))
720
+ })
721
+ .collect::<Vec<_>>();
722
+ names.sort_unstable();
723
+ names
724
+ }
725
+
726
+ fn show_cops(filter: &str, config: &Config) {
727
+ let implemented: HashSet<&str> = rule_names().collect();
728
+ for name in selected_cop_names(filter, config) {
729
+ println!("{name}:");
730
+ if let Some(description) = config.description(name) {
731
+ println!(
732
+ " Description: {}",
733
+ description.lines().next().unwrap_or("")
734
+ );
735
+ }
736
+ println!(" Enabled: {}", config.rule_enabled(name));
737
+ println!(" Implemented: {}", implemented.contains(name));
738
+ }
739
+ }
740
+
741
+ fn show_docs_urls(filter: &str, config: &Config) {
742
+ for name in selected_cop_names(filter, config) {
743
+ println!("{name}: {}", docs_url(name));
744
+ }
745
+ }
746
+
747
+ /// RuboCop's `Documentation.url_for`: the page covers the whole department with `/` flattened to
748
+ /// `_`, and the fragment is the qualified name reduced to its lowercased letters, so
749
+ /// `Layout/LineLength` documents at `cops_layout.html#layoutlinelength`.
750
+ fn docs_url(name: &str) -> String {
751
+ let page = cop_name::department(name)
752
+ .replace('/', "_")
753
+ .to_ascii_lowercase();
754
+ let fragment: String = name
755
+ .chars()
756
+ .filter(char::is_ascii_alphabetic)
757
+ .map(|character| character.to_ascii_lowercase())
758
+ .collect();
759
+ format!("https://docs.rubocop.org/rubocop/{RUBOCOP_COMPAT_VERSION}/cops_{page}.html#{fragment}")
760
+ }
761
+
762
+ fn list_enabled_cops(path: &Path, configs: &ConfigStore) -> Result<()> {
763
+ let config = configs.for_path(path)?;
764
+ let mut names = config
765
+ .known_cop_names()
766
+ .filter(|name| config.rule_enabled(name))
767
+ .collect::<Vec<_>>();
768
+ names.sort();
769
+ for name in names {
770
+ println!("{name}");
771
+ }
772
+ Ok(())
773
+ }
774
+
775
+ fn generate_todo(reports: &[FileReport], cwd: &Path, cli: &Cli) -> Result<()> {
776
+ let mut output = format!(
777
+ "# This configuration was generated by\n# `sonicop --auto-gen-config` using Sonicop version {VERSION}.\n\n"
778
+ );
779
+ for (cop, offenses) in offenses_by_cop(reports, cwd) {
780
+ // RuboCop drops Lint/Syntax before writing the records (`disabled_config_formatter.rb:69`)
781
+ // because it is not a real cop and cannot be disabled, so a record for it would only be
782
+ // noise the user can never resolve.
783
+ if is_mandatory_cop(cop) {
784
+ continue;
785
+ }
786
+ if !cli.no_offense_counts {
787
+ output.push_str(&format!("# Offense count: {}\n", offenses.offense_count));
788
+ }
789
+ output.push_str(&format!("{cop}:\n"));
790
+ // The limit counts offending files, not offenses, so a single file cannot exclude itself
791
+ // into `Enabled: false`.
792
+ if !cli.no_exclude_limit && offenses.paths.len() > cli.exclude_limit {
793
+ output.push_str(" Enabled: false\n\n");
794
+ } else {
795
+ output.push_str(" Exclude:\n");
796
+ for path in &offenses.paths {
797
+ output.push_str(&format!(" - {}\n", yaml_single_quoted(path)));
798
+ }
799
+ output.push('\n');
800
+ }
801
+ }
802
+ fs::write(cwd.join(".rubocop_todo.yml"), output)?;
803
+ let root_config = cwd.join(".rubocop.yml");
804
+ let existing = fs::read_to_string(&root_config).unwrap_or_default();
805
+ if !existing.contains(".rubocop_todo.yml") {
806
+ let addition = if existing.is_empty() {
807
+ "inherit_from: .rubocop_todo.yml\n".to_owned()
808
+ } else {
809
+ format!("inherit_from: .rubocop_todo.yml\n{existing}")
810
+ };
811
+ fs::write(root_config, addition)?;
812
+ }
813
+ println!("Generated .rubocop_todo.yml");
814
+ Ok(())
815
+ }
816
+
817
+ fn filter_displayed_offenses(reports: &mut [FileReport], cli: &Cli, fail_level: FailLevel) {
818
+ for report in reports {
819
+ report.offenses.retain(|offense| {
820
+ (!cli.display_only_fail_level_offenses || offense.severity >= fail_level.severity())
821
+ && (!cli.display_only_correctable || offense.is_correctable())
822
+ && (!cli.display_only_safe_correctable
823
+ || (offense.is_correctable()
824
+ && offense.correction.as_ref().is_some_and(|edit| edit.safe)))
825
+ });
826
+ }
827
+ }
828
+
829
+ /// The run-wide state every formatter needs, gathered once so each format only has to supply the
830
+ /// reports.
831
+ struct RenderRequest<'a> {
832
+ cli: &'a Cli,
833
+ config: &'a Config,
834
+ cwd: &'a Path,
835
+ fail_level: FailLevel,
836
+ /// Destination per format, already paired the way RuboCop pairs `--out` with `--format`.
837
+ outputs: &'a [Option<PathBuf>],
838
+ corrected_count: usize,
839
+ elapsed: f64,
840
+ }
841
+
842
+ fn render_outputs(request: &RenderRequest<'_>, reports: &[FileReport]) -> Result<()> {
843
+ let RenderRequest {
844
+ cli,
845
+ config,
846
+ cwd,
847
+ fail_level,
848
+ outputs,
849
+ corrected_count,
850
+ elapsed,
851
+ } = *request;
852
+ let formats = if cli.formats.is_empty() {
853
+ vec![
854
+ config
855
+ .all_cops_value::<String>("DefaultFormatter")
856
+ .unwrap_or_else(|| "progress".to_owned()),
857
+ ]
858
+ } else {
859
+ cli.formats.clone()
860
+ };
861
+ let display_cop_names = if cli.no_display_cop_names {
862
+ false
863
+ } else {
864
+ cli.display_cop_names || config.display_cop_names()
865
+ };
866
+ let color = !cli.no_color
867
+ && (cli.color || (cli.out.is_empty() && !cli.stderr && io::stdout().is_terminal()));
868
+ for (index, name) in formats.iter().enumerate() {
869
+ let format = Format::parse(name)?;
870
+ let mut rendered = render(
871
+ format,
872
+ reports,
873
+ &FormatOptions {
874
+ cwd,
875
+ config,
876
+ display_cop_names,
877
+ display_style_guide: cli.display_style_guide,
878
+ extra_details: cli.extra_details,
879
+ color,
880
+ corrected_count,
881
+ fail_level: fail_level.severity(),
882
+ safe_autocorrect: cli.correct_mode() == CorrectMode::Safe,
883
+ },
884
+ )?;
885
+ if cli.display_time {
886
+ rendered.push_str(&format!("Finished in {elapsed:.3} seconds\n"));
887
+ }
888
+ write_output(
889
+ &rendered,
890
+ outputs.get(index).and_then(Option::as_deref),
891
+ cli.stderr,
892
+ )?;
893
+ }
894
+ Ok(())
895
+ }
896
+
897
+ fn init_config(cwd: &Path) -> Result<i32> {
898
+ let path = cwd.join(".rubocop.yml");
899
+ if path.exists() {
900
+ bail!("{} already exists", path.display());
901
+ }
902
+ fs::write(
903
+ &path,
904
+ "# Sonicop / RuboCop configuration\nAllCops:\n NewCops: enable\n",
905
+ )?;
906
+ println!("Created {}", path.display());
907
+ Ok(0)
908
+ }
909
+
910
+ fn csv(value: Option<&str>) -> Vec<String> {
911
+ value
912
+ .into_iter()
913
+ .flat_map(|value| value.split(','))
914
+ .map(str::trim)
915
+ .filter(|value| !value.is_empty())
916
+ .map(ToOwned::to_owned)
917
+ .collect()
918
+ }
919
+
920
+ fn write_output(output: &str, path: Option<&Path>, stderr: bool) -> Result<()> {
921
+ if let Some(path) = path {
922
+ fs::write(path, output).with_context(|| format!("failed to write {}", path.display()))?;
923
+ } else if stderr {
924
+ io::stderr().write_all(output.as_bytes())?;
925
+ } else {
926
+ io::stdout().write_all(output.as_bytes())?;
927
+ }
928
+ Ok(())
929
+ }
930
+
931
+ #[cfg(test)]
932
+ mod tests {
933
+ use super::Cli;
934
+ use clap::CommandFactory;
935
+
936
+ #[test]
937
+ fn cli_definition_is_valid() {
938
+ Cli::command().debug_assert();
939
+ }
940
+ }