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
@@ -0,0 +1,436 @@
1
+ use std::collections::HashMap;
2
+ use std::fs;
3
+ use std::path::{Path, PathBuf};
4
+
5
+ use globset::{Glob, GlobMatcher};
6
+ use serde_yaml_ng::{Mapping, Value};
7
+
8
+ pub(super) fn compile_excludes(raw: &Value) -> HashMap<String, PathPatterns> {
9
+ let Some(mapping) = raw.as_mapping() else {
10
+ return HashMap::new();
11
+ };
12
+ mapping
13
+ .iter()
14
+ .filter_map(|(name, value)| {
15
+ let name = name.as_str()?;
16
+ let patterns = mapping_patterns(value.as_mapping()?, "Exclude")?;
17
+ Some((name.to_owned(), patterns))
18
+ })
19
+ .collect()
20
+ }
21
+
22
+ pub(super) fn cop_patterns(raw: &Value, name: &str, key: &str) -> Option<PathPatterns> {
23
+ mapping_patterns(raw.as_mapping()?.get(name)?.as_mapping()?, key)
24
+ }
25
+
26
+ fn mapping_patterns(mapping: &Mapping, key: &str) -> Option<PathPatterns> {
27
+ let patterns: Vec<String> = serde_yaml_ng::from_value(mapping.get(key)?.clone()).ok()?;
28
+ Some(PathPatterns::compile(&patterns))
29
+ }
30
+
31
+ /// `Include`/`Exclude` globs compiled once when the configuration is built.
32
+ ///
33
+ /// `globset` builds a regular expression per glob, so compiling inside the match
34
+ /// call meant cop-count x file-count regex builds per run.
35
+ #[derive(Clone, Debug, Default)]
36
+ pub(super) struct PathPatterns {
37
+ /// Patterns that fail to compile are dropped but still counted, so an
38
+ /// unusable `Include` list keeps meaning "nothing matches" rather than
39
+ /// degrading into "no list configured".
40
+ configured: usize,
41
+ patterns: Vec<CompiledPattern>,
42
+ }
43
+
44
+ #[derive(Clone, Debug)]
45
+ struct CompiledPattern {
46
+ matcher: GlobMatcher,
47
+ /// Set for `dir/**/*`, which also matches paths under an ancestor matching `dir`.
48
+ ancestor: Option<GlobMatcher>,
49
+ absolute: bool,
50
+ /// A separator-free pattern such as `Gemfile` also matches by basename.
51
+ basename: bool,
52
+ /// A pattern naming no dot-component does not opt into hidden paths.
53
+ skips_hidden: bool,
54
+ }
55
+
56
+ impl PathPatterns {
57
+ fn compile(patterns: &[String]) -> Self {
58
+ Self {
59
+ configured: patterns.len(),
60
+ patterns: patterns
61
+ .iter()
62
+ .filter_map(|pattern| CompiledPattern::compile(pattern))
63
+ .collect(),
64
+ }
65
+ }
66
+
67
+ pub(super) fn is_empty(&self) -> bool {
68
+ self.configured == 0
69
+ }
70
+
71
+ pub(super) fn matches_any(&self, path: &Path, root: &Path) -> bool {
72
+ // Relative patterns must not reach paths outside the project root.
73
+ let absolute_only = path.is_absolute() && project_relative(path, root).is_none();
74
+ self.matches(path, root, false, absolute_only)
75
+ }
76
+
77
+ pub(super) fn matches_includes(&self, path: &Path, root: &Path) -> bool {
78
+ self.matches(path, root, true, false)
79
+ }
80
+
81
+ fn matches(&self, path: &Path, root: &Path, respect_hidden: bool, absolute_only: bool) -> bool {
82
+ if self.patterns.is_empty() {
83
+ return false;
84
+ }
85
+ let relative = project_relative(path, root).unwrap_or_else(|| path.to_path_buf());
86
+ let relative = relative.to_string_lossy().replace('\\', "/");
87
+ let normalized = relative.trim_start_matches("./");
88
+ let basename = path
89
+ .file_name()
90
+ .and_then(|name| name.to_str())
91
+ .unwrap_or("");
92
+ let hidden = respect_hidden && has_hidden_component(Path::new(normalized));
93
+ self.patterns
94
+ .iter()
95
+ .filter(|pattern| !absolute_only || pattern.absolute)
96
+ .any(|pattern| pattern.matches(normalized, basename, hidden))
97
+ }
98
+ }
99
+
100
+ impl CompiledPattern {
101
+ fn compile(pattern: &str) -> Option<Self> {
102
+ let absolute = Path::new(pattern).is_absolute();
103
+ let pattern = pattern.trim_start_matches("./");
104
+ Some(Self {
105
+ matcher: Glob::new(pattern).ok()?.compile_matcher(),
106
+ ancestor: pattern
107
+ .strip_suffix("/**/*")
108
+ .and_then(|prefix| Glob::new(prefix).ok())
109
+ .map(|prefix| prefix.compile_matcher()),
110
+ absolute,
111
+ basename: !pattern.contains('/'),
112
+ skips_hidden: !pattern.starts_with('.') && !pattern.contains("/."),
113
+ })
114
+ }
115
+
116
+ fn matches(&self, normalized: &str, basename: &str, hidden: bool) -> bool {
117
+ if hidden && self.skips_hidden {
118
+ return false;
119
+ }
120
+ self.matcher.is_match(normalized)
121
+ || self.ancestor.as_ref().is_some_and(|ancestor| {
122
+ Path::new(normalized)
123
+ .ancestors()
124
+ .skip(1)
125
+ .any(|path| ancestor.is_match(path.to_string_lossy().replace('\\', "/")))
126
+ })
127
+ || (self.basename && self.matcher.is_match(basename))
128
+ }
129
+ }
130
+
131
+ /// `path` expressed relative to the project root, or `None` when it lies outside.
132
+ ///
133
+ /// `strip_prefix` compares text, and on Windows the same directory has more than one spelling:
134
+ /// `fs::canonicalize` -- which is how the project root is resolved -- returns a `\\?\` verbatim
135
+ /// path and expands 8.3 short names, while a path taken from the current directory or the command
136
+ /// line keeps whatever spelling the caller used. A plain `strip_prefix` therefore fails for every
137
+ /// file, every `Include`/`Exclude` pattern is then matched against an absolute path instead of a
138
+ /// project-relative one, and a project living under a dot-named directory has all of its files
139
+ /// treated as hidden. The text comparisons are tried first because they cover every normal case;
140
+ /// only when they disagree is it worth asking the filesystem.
141
+ pub(super) fn project_relative(path: &Path, root: &Path) -> Option<PathBuf> {
142
+ if let Ok(relative) = path.strip_prefix(root) {
143
+ return Some(relative.to_path_buf());
144
+ }
145
+ if let Ok(relative) = strip_verbatim(path).strip_prefix(strip_verbatim(root)) {
146
+ return Some(relative.to_path_buf());
147
+ }
148
+ let resolved = fs::canonicalize(path).ok()?;
149
+ let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
150
+ resolved
151
+ .strip_prefix(&root)
152
+ .ok()
153
+ .or_else(|| {
154
+ strip_verbatim(&resolved)
155
+ .strip_prefix(strip_verbatim(&root))
156
+ .ok()
157
+ })
158
+ .map(Path::to_path_buf)
159
+ }
160
+
161
+ /// Drops Windows' `\\?\` extended-length marker so that two spellings of one path can be compared.
162
+ /// Other platforms never carry the prefix, so this is the identity there.
163
+ fn strip_verbatim(path: &Path) -> &Path {
164
+ let text = match path.to_str() {
165
+ Some(text) => text,
166
+ None => return path,
167
+ };
168
+ text.strip_prefix(r"\\?\UNC\")
169
+ .map(Path::new)
170
+ .or_else(|| text.strip_prefix(r"\\?\").map(Path::new))
171
+ .unwrap_or(path)
172
+ }
173
+
174
+ /// A path counts as hidden when any component below the project root begins with
175
+ /// a dot.
176
+ ///
177
+ /// `Config::path_hidden` and the `Include` matcher ask exactly this question of
178
+ /// the same project-relative path, so both walk the components here instead of
179
+ /// each carrying its own copy of the rule.
180
+ pub(super) fn has_hidden_component(path: &Path) -> bool {
181
+ path.components().any(|component| {
182
+ component
183
+ .as_os_str()
184
+ .to_str()
185
+ .is_some_and(|part| part.starts_with('.') && part != "." && part != "..")
186
+ })
187
+ }
188
+
189
+ /// Verbatim copy of the pre-compilation matcher, kept so the compiled matcher can be
190
+ /// proven byte-for-byte equivalent over a cross product of paths and patterns.
191
+ #[cfg(test)]
192
+ fn matches_patterns_reference(
193
+ path: &Path,
194
+ root: &Path,
195
+ patterns: &[String],
196
+ respect_hidden: bool,
197
+ ) -> bool {
198
+ let relative = path.strip_prefix(root).unwrap_or(path);
199
+ let normalized = relative
200
+ .to_string_lossy()
201
+ .replace('\\', "/")
202
+ .trim_start_matches("./")
203
+ .to_owned();
204
+ let basename = path
205
+ .file_name()
206
+ .and_then(|name| name.to_str())
207
+ .unwrap_or("");
208
+ let contains_hidden_component = Path::new(&normalized).components().any(|component| {
209
+ component
210
+ .as_os_str()
211
+ .to_str()
212
+ .is_some_and(|part| part.starts_with('.') && part != "." && part != "..")
213
+ });
214
+ patterns.iter().any(|pattern| {
215
+ let pattern = pattern.trim_start_matches("./");
216
+ if respect_hidden
217
+ && contains_hidden_component
218
+ && !pattern.starts_with('.')
219
+ && !pattern.contains("/.")
220
+ {
221
+ return false;
222
+ }
223
+ Glob::new(pattern)
224
+ .map(|glob| {
225
+ let matcher = glob.compile_matcher();
226
+ matcher.is_match(&normalized)
227
+ || pattern.strip_suffix("/**/*").is_some_and(|prefix| {
228
+ Glob::new(prefix).is_ok_and(|prefix_glob| {
229
+ let prefix_matcher = prefix_glob.compile_matcher();
230
+ Path::new(&normalized).ancestors().skip(1).any(|ancestor| {
231
+ prefix_matcher
232
+ .is_match(ancestor.to_string_lossy().replace('\\', "/"))
233
+ })
234
+ })
235
+ })
236
+ || (!pattern.contains('/') && matcher.is_match(basename))
237
+ })
238
+ .unwrap_or(false)
239
+ })
240
+ }
241
+
242
+ #[cfg(test)]
243
+ fn matches_any_reference(path: &Path, root: &Path, patterns: &[String]) -> bool {
244
+ if path.is_absolute() && path.strip_prefix(root).is_err() {
245
+ let absolute_patterns: Vec<_> = patterns
246
+ .iter()
247
+ .filter(|pattern| Path::new(pattern.as_str()).is_absolute())
248
+ .cloned()
249
+ .collect();
250
+ return matches_patterns_reference(path, root, &absolute_patterns, false);
251
+ }
252
+ matches_patterns_reference(path, root, patterns, false)
253
+ }
254
+
255
+ #[cfg(test)]
256
+ mod tests {
257
+ use std::path::{Path, PathBuf};
258
+
259
+ use super::{
260
+ PathPatterns, matches_any_reference, matches_patterns_reference, project_relative,
261
+ strip_verbatim,
262
+ };
263
+
264
+ fn owned(patterns: &[&str]) -> Vec<String> {
265
+ patterns
266
+ .iter()
267
+ .map(|pattern| (*pattern).to_owned())
268
+ .collect()
269
+ }
270
+
271
+ fn includes(path: &str, root: &str, patterns: &[&str]) -> bool {
272
+ PathPatterns::compile(&owned(patterns)).matches_includes(Path::new(path), Path::new(root))
273
+ }
274
+
275
+ fn excludes(path: &str, root: &str, patterns: &[&str]) -> bool {
276
+ PathPatterns::compile(&owned(patterns)).matches_any(Path::new(path), Path::new(root))
277
+ }
278
+
279
+ /// `[project root, a path outside it, an absolute pattern reaching it]`. Forward slashes work
280
+ /// on Windows too, and a glob treats a backslash as an escape, so the pattern keeps `/`.
281
+ ///
282
+ /// `cfg!` rather than `#[cfg]` so that both spellings are type checked everywhere. An item
283
+ /// behind `#[cfg(windows)]` is compiled only on Windows, which is where a mistake in it would
284
+ /// first appear -- and this repository is developed on Unix.
285
+ fn outside_root_case() -> [&'static str; 3] {
286
+ if cfg!(windows) {
287
+ ["C:/p", "C:/other/x.rb", "C:/other/**/*"]
288
+ } else {
289
+ ["/p", "/other/x.rb", "/other/**/*"]
290
+ }
291
+ }
292
+
293
+ /// Windows は同じディレクトリを複数の綴りで表す。プロジェクトルートは `fs::canonicalize`
294
+ /// 由来の `\\?\` 付きになる一方、検査対象は `current_dir` やコマンドラインの綴りのまま
295
+ /// 届くため、素の `strip_prefix` は全ファイルで失敗する。綴りを揃えるこの部分だけは
296
+ /// 文字列処理なので、Windows でなくても固定できる。
297
+ #[test]
298
+ fn the_verbatim_marker_is_dropped_before_comparing() {
299
+ assert_eq!(strip_verbatim(Path::new(r"\\?\C:\p")), Path::new(r"C:\p"));
300
+ assert_eq!(
301
+ strip_verbatim(Path::new(r"\\?\UNC\server\share")),
302
+ Path::new(r"server\share")
303
+ );
304
+ // 付いていないものは素通し。
305
+ assert_eq!(strip_verbatim(Path::new(r"C:\p")), Path::new(r"C:\p"));
306
+ assert_eq!(strip_verbatim(Path::new("/p")), Path::new("/p"));
307
+ }
308
+
309
+ #[test]
310
+ fn a_path_under_the_root_is_relative_to_it() {
311
+ assert_eq!(
312
+ project_relative(Path::new("/p/lib/a.rb"), Path::new("/p")),
313
+ Some(PathBuf::from("lib/a.rb"))
314
+ );
315
+ }
316
+
317
+ /// プロジェクト外は None のままでなければ、相対パターンが外部へ届いてしまう。
318
+ #[test]
319
+ fn a_path_outside_the_root_has_no_project_relative_form() {
320
+ assert_eq!(
321
+ project_relative(Path::new("/other/x.rb"), Path::new("/p/does-not-exist")),
322
+ None
323
+ );
324
+ }
325
+
326
+ #[test]
327
+ fn exclude_patterns_follow_rubocop_path_semantics() {
328
+ // `dir/**/*` matches everything below the directory.
329
+ assert!(excludes("/p/vendor/bundle/x.rb", "/p", &["vendor/**/*"]));
330
+ assert!(excludes("/p/vendor/bundle", "/p", &["vendor/**/*"]));
331
+ assert!(!excludes("/p/vendor", "/p", &["vendor/**/*"]));
332
+ // A separator-free pattern also matches the basename at any depth.
333
+ assert!(excludes("/p/a/b/Gemfile", "/p", &["Gemfile"]));
334
+ assert!(!excludes("/p/vendor/bundle/x.rb", "/p", &["vendor"]));
335
+ // Relative patterns must not reach outside the project root. The guard turns on `Path::
336
+ // is_absolute`, and a Windows absolute path needs a drive letter, so `/other/x.rb` would
337
+ // be a relative path there and leave the rule untested. Spell the case per platform.
338
+ let [outside_root, outside, outside_pattern] = outside_root_case();
339
+ assert!(excludes(outside, outside_root, &[outside_pattern]));
340
+ assert!(!excludes(outside, outside_root, &["**/*.rb"]));
341
+ assert!(excludes("/p/x.rb", "/p", &["./x.rb"]));
342
+ // An uncompilable pattern never matches.
343
+ assert!(!excludes("/p/x.rb", "/p", &["["]));
344
+ }
345
+
346
+ #[test]
347
+ fn include_patterns_opt_out_of_hidden_paths() {
348
+ assert!(!includes("/p/.git/config.rb", "/p", &["**/*.rb"]));
349
+ assert!(includes("/p/.git/config.rb", "/p", &[".git/**/*"]));
350
+ assert!(includes("/p/.git/config.rb", "/p", &["**/.git/**/*"]));
351
+ assert!(includes("/p/a/x.rb", "/p", &["**/*.rb"]));
352
+ // Excludes ignore the hidden rule entirely.
353
+ assert!(excludes("/p/.git/config.rb", "/p", &["**/*.rb"]));
354
+ }
355
+
356
+ #[test]
357
+ fn an_unusable_include_list_still_counts_as_configured() {
358
+ let patterns = PathPatterns::compile(&owned(&["["]));
359
+ assert!(!patterns.is_empty());
360
+ assert!(!includes("/p/x.rb", "/p", &["["]));
361
+ }
362
+
363
+ #[test]
364
+ fn compiled_matcher_agrees_with_the_reference_implementation() {
365
+ let patterns = [
366
+ "**/*.rb",
367
+ "*.rb",
368
+ "Gemfile",
369
+ "./x.rb",
370
+ "vendor/**/*",
371
+ "**/vendor/**/*",
372
+ "db/**/*",
373
+ "**/node_modules/**/*",
374
+ ".git/**/*",
375
+ "**/.*",
376
+ "/abs/**/*",
377
+ "/abs/x.rb",
378
+ "spec/**/*_spec.rb",
379
+ "a/*/c",
380
+ "[",
381
+ "tmp",
382
+ "**/tmp/**/*",
383
+ "lib/**/*.rb",
384
+ ];
385
+ let paths = [
386
+ "/p/x.rb",
387
+ "/p/a/x.rb",
388
+ "/p/a/b/c.rb",
389
+ "/p/vendor",
390
+ "/p/vendor/bundle",
391
+ "/p/vendor/bundle/x.rb",
392
+ "/p/.git/config.rb",
393
+ "/p/a/.hidden/x.rb",
394
+ "/p/Gemfile",
395
+ "/p/a/Gemfile",
396
+ "/p/db/migrate",
397
+ "/p/db/migrate/1.rb",
398
+ "/p/node_modules/a/b.rb",
399
+ "/p/a/node_modules",
400
+ "/p/spec/models/user_spec.rb",
401
+ "/p/tmp/deep/dir/file.rb",
402
+ "/abs/x.rb",
403
+ "/abs/deep/x.rb",
404
+ "/other/x.rb",
405
+ "relative/x.rb",
406
+ "/p/a/c",
407
+ "/p/a/b/c",
408
+ ];
409
+ let root = Path::new("/p");
410
+ for window in 1..=3 {
411
+ for start in 0..patterns.len() {
412
+ let selected: Vec<String> = patterns
413
+ .iter()
414
+ .cycle()
415
+ .skip(start)
416
+ .take(window)
417
+ .map(|pattern| (*pattern).to_owned())
418
+ .collect();
419
+ let compiled = PathPatterns::compile(&selected);
420
+ for path in paths {
421
+ let path = Path::new(path);
422
+ assert_eq!(
423
+ compiled.matches_includes(path, root),
424
+ matches_patterns_reference(path, root, &selected, true),
425
+ "includes {path:?} against {selected:?}"
426
+ );
427
+ assert_eq!(
428
+ compiled.matches_any(path, root),
429
+ matches_any_reference(path, root, &selected),
430
+ "excludes {path:?} against {selected:?}"
431
+ );
432
+ }
433
+ }
434
+ }
435
+ }
436
+ }
@@ -0,0 +1,247 @@
1
+ use std::collections::HashSet;
2
+
3
+ use serde_yaml_ng::Value;
4
+
5
+ use crate::cop_name;
6
+
7
+ pub(super) fn configured_plugin_departments(config: &Value) -> HashSet<String> {
8
+ let Some(mapping) = config.as_mapping() else {
9
+ return HashSet::new();
10
+ };
11
+ ["plugins", "require"]
12
+ .into_iter()
13
+ .filter_map(|key| mapping.get(key))
14
+ .flat_map(configured_plugin_names)
15
+ .flat_map(|plugin| plugin_departments(&plugin))
16
+ .collect()
17
+ }
18
+
19
+ /// A plugin owns every department nested below the one it declares, because
20
+ /// `rubocop-i18n` ships `I18n/GetText/*` and pre-3.0 `rubocop-rspec` shipped
21
+ /// `RSpec/Rails/*`.
22
+ pub(super) fn belongs_to_plugin(name: &str, departments: &HashSet<String>) -> bool {
23
+ cop_name::department_ancestors(name).any(|candidate| departments.contains(candidate))
24
+ }
25
+
26
+ fn configured_plugin_names(value: &Value) -> Vec<String> {
27
+ match value {
28
+ Value::String(plugin) => vec![plugin.clone()],
29
+ Value::Sequence(plugins) => plugins.iter().flat_map(configured_plugin_names).collect(),
30
+ Value::Mapping(plugins) => plugins
31
+ .keys()
32
+ .filter_map(Value::as_str)
33
+ .map(ToOwned::to_owned)
34
+ .collect(),
35
+ _ => Vec::new(),
36
+ }
37
+ }
38
+
39
+ /// Gem name to the departments it ships, for plugins RuboCop documents as
40
+ /// official or widely used.
41
+ ///
42
+ /// A table is required because capitalizing each `-`/`_` segment guesses
43
+ /// `Rspec`/`Graphql`/`Github` instead of the real `RSpec`/`GraphQL`/`GitHub`, and
44
+ /// because `cookstyle` carries no `rubocop-` prefix to strip at all. Departments
45
+ /// are a flat namespace upstream, so one gem may ship several *sibling*
46
+ /// departments — `rubocop-sketchup` ships five that share no common parent — which
47
+ /// is why each entry holds a slice. Entries naming a parent namespace such as
48
+ /// `Chef` or `I18n` reach their nested departments through `belongs_to_plugin`.
49
+ const PLUGIN_DEPARTMENTS: &[(&str, &[&str])] = &[
50
+ ("cookstyle", &["Chef"]),
51
+ ("rubocop-capybara", &["Capybara"]),
52
+ ("rubocop-factory_bot", &["FactoryBot"]),
53
+ ("rubocop-github", &["GitHub"]),
54
+ ("rubocop-graphql", &["GraphQL"]),
55
+ ("rubocop-i18n", &["I18n"]),
56
+ ("rubocop-minitest", &["Minitest"]),
57
+ ("rubocop-packaging", &["Packaging"]),
58
+ ("rubocop-performance", &["Performance"]),
59
+ ("rubocop-rails", &["Rails"]),
60
+ ("rubocop-rake", &["Rake"]),
61
+ ("rubocop-rspec", &["RSpec"]),
62
+ ("rubocop-rspec_rails", &["RSpecRails"]),
63
+ ("rubocop-sequel", &["Sequel"]),
64
+ (
65
+ "rubocop-sketchup",
66
+ &[
67
+ "SketchupBugs",
68
+ "SketchupDeprecations",
69
+ "SketchupPerformance",
70
+ "SketchupRequirements",
71
+ "SketchupSuggestions",
72
+ ],
73
+ ),
74
+ ("rubocop-sorbet", &["Sorbet"]),
75
+ ("rubocop-thread_safety", &["ThreadSafety"]),
76
+ ];
77
+
78
+ fn plugin_departments(plugin: &str) -> Vec<String> {
79
+ // Each fallback must apply to the previous step's result: chaining
80
+ // `unwrap_or(plugin)` restores the whole string and loses the directory strip.
81
+ let stem = plugin.rsplit('/').next().unwrap_or(plugin);
82
+ let stem = stem.strip_suffix(".rb").unwrap_or(stem);
83
+ if let Some((_, departments)) = PLUGIN_DEPARTMENTS.iter().find(|(gem, _)| *gem == stem) {
84
+ return departments.iter().map(|name| (*name).to_owned()).collect();
85
+ }
86
+ let Some(extension) = stem.strip_prefix("rubocop-") else {
87
+ return Vec::new();
88
+ };
89
+ let department = extension
90
+ .split(['-', '_'])
91
+ .filter(|part| !part.is_empty())
92
+ .map(|part| {
93
+ let mut characters = part.chars();
94
+ characters.next().map_or_else(String::new, |first| {
95
+ first.to_uppercase().chain(characters).collect()
96
+ })
97
+ })
98
+ .collect::<String>();
99
+ if department.is_empty() {
100
+ return Vec::new();
101
+ }
102
+ vec![department]
103
+ }
104
+
105
+ #[cfg(test)]
106
+ mod tests {
107
+ use std::fs;
108
+
109
+ use tempfile::tempdir;
110
+
111
+ use super::{belongs_to_plugin, plugin_departments};
112
+ use crate::config::Config;
113
+
114
+ fn departments(plugin: &str) -> Vec<String> {
115
+ plugin_departments(plugin)
116
+ }
117
+
118
+ #[test]
119
+ fn resolves_departments_for_known_plugins() {
120
+ let cases: &[(&str, &[&str])] = &[
121
+ ("rubocop-rspec", &["RSpec"]),
122
+ ("rubocop-rspec_rails", &["RSpecRails"]),
123
+ ("rubocop-graphql", &["GraphQL"]),
124
+ ("rubocop-github", &["GitHub"]),
125
+ ("rubocop-i18n", &["I18n"]),
126
+ ("rubocop-factory_bot", &["FactoryBot"]),
127
+ ("rubocop-thread_safety", &["ThreadSafety"]),
128
+ ("rubocop-performance", &["Performance"]),
129
+ ("rubocop-rails", &["Rails"]),
130
+ // One gem can ship several sibling departments sharing no parent.
131
+ (
132
+ "rubocop-sketchup",
133
+ &[
134
+ "SketchupBugs",
135
+ "SketchupDeprecations",
136
+ "SketchupPerformance",
137
+ "SketchupRequirements",
138
+ "SketchupSuggestions",
139
+ ],
140
+ ),
141
+ // No `rubocop-` prefix exists to strip, so only the table can resolve it.
142
+ ("cookstyle", &["Chef"]),
143
+ // Uncatalogued plugins keep falling back to the capitalization guess.
144
+ ("rubocop-my_house_style", &["MyHouseStyle"]),
145
+ ("../my/custom/file.rb", &[]),
146
+ ("rubocop", &[]),
147
+ ];
148
+ for (plugin, expected) in cases {
149
+ let resolved = departments(plugin);
150
+ let resolved: Vec<&str> = resolved.iter().map(String::as_str).collect();
151
+ assert_eq!(resolved, *expected, "plugin: {plugin}");
152
+ }
153
+ }
154
+
155
+ #[test]
156
+ fn resolves_departments_for_plugins_written_as_paths() {
157
+ let cases: &[(&str, &[&str])] = &[
158
+ ("gems/rubocop-rspec", &["RSpec"]),
159
+ ("vendor/bundle/rubocop-performance", &["Performance"]),
160
+ ("./rubocop-rails", &["Rails"]),
161
+ ("rubocop-rspec.rb", &["RSpec"]),
162
+ ("./gems/rubocop-graphql.rb", &["GraphQL"]),
163
+ ("/abs/path/rubocop-minitest.rb", &["Minitest"]),
164
+ ("vendor/cookstyle.rb", &["Chef"]),
165
+ ];
166
+ for (plugin, expected) in cases {
167
+ let resolved = departments(plugin);
168
+ let resolved: Vec<&str> = resolved.iter().map(String::as_str).collect();
169
+ assert_eq!(resolved, *expected, "plugin: {plugin}");
170
+ }
171
+ }
172
+
173
+ #[test]
174
+ fn plugin_ownership_covers_nested_departments() {
175
+ let departments = ["RSpec".to_owned(), "I18n".to_owned()]
176
+ .into_iter()
177
+ .collect();
178
+ assert!(belongs_to_plugin("RSpec/ExampleLength", &departments));
179
+ assert!(belongs_to_plugin("RSpec/Rails/HttpStatus", &departments));
180
+ assert!(belongs_to_plugin(
181
+ "I18n/GetText/DecorateString",
182
+ &departments
183
+ ));
184
+ assert!(!belongs_to_plugin("Style/HashSyntax", &departments));
185
+ // A sibling department is not owned by a prefix that merely looks similar.
186
+ assert!(!belongs_to_plugin("RSpecRails/HttpStatus", &departments));
187
+ }
188
+
189
+ #[test]
190
+ fn recognizes_cops_from_multi_department_and_namespaced_plugins() {
191
+ let directory = tempdir().unwrap();
192
+ fs::write(directory.path().join("Gemfile"), "").unwrap();
193
+ fs::write(
194
+ directory.path().join(".rubocop.yml"),
195
+ "require:\n - rubocop-sketchup\n - cookstyle\nSketchupRequirements/GlobalMethods:\n Enabled: true\nSketchupBugs/UniformScaleReference:\n Enabled: true\nChef/Correctness/ServiceResource:\n Enabled: false\n",
196
+ )
197
+ .unwrap();
198
+
199
+ let config = Config::load(None, directory.path()).unwrap();
200
+
201
+ assert!(
202
+ config.unrecognized_cop_names().is_empty(),
203
+ "unexpected: {:?}",
204
+ config.unrecognized_cop_names()
205
+ );
206
+ }
207
+
208
+ #[test]
209
+ fn recognizes_rspec_cops_declared_through_the_rspec_plugin() {
210
+ let directory = tempdir().unwrap();
211
+ fs::write(directory.path().join("Gemfile"), "").unwrap();
212
+ fs::write(
213
+ directory.path().join(".rubocop.yml"),
214
+ "plugins:\n - rubocop-rspec\n - rubocop-graphql\nRSpec/ExampleLength:\n Max: 20\nGraphQL/ObjectDescription:\n Enabled: false\n",
215
+ )
216
+ .unwrap();
217
+
218
+ let config = Config::load(None, directory.path()).unwrap();
219
+
220
+ assert!(
221
+ config.unrecognized_cop_names().is_empty(),
222
+ "unexpected: {:?}",
223
+ config.unrecognized_cop_names()
224
+ );
225
+ }
226
+
227
+ #[test]
228
+ fn recognizes_configured_cops_from_declared_plugins() {
229
+ let directory = tempdir().unwrap();
230
+ fs::write(directory.path().join("Gemfile"), "").unwrap();
231
+ fs::write(
232
+ directory.path().join(".rubocop.yml"),
233
+ "plugins:\n - rubocop-performance\nPerformance/MapCompact:\n Enabled: true\n",
234
+ )
235
+ .unwrap();
236
+
237
+ let config = Config::load(None, directory.path()).unwrap();
238
+
239
+ assert!(config.unrecognized_cop_names().is_empty());
240
+ assert!(
241
+ config
242
+ .known_cop_names()
243
+ .any(|name| name == "Performance/MapCompact")
244
+ );
245
+ assert!(config.rule_enabled("Performance/MapCompact"));
246
+ }
247
+ }