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/config/paths.rs
ADDED
|
@@ -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
|
+
}
|