jscpd-rs 0.1.0
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.
- package/CHANGELOG.md +69 -0
- package/Cargo.lock +1323 -0
- package/Cargo.toml +54 -0
- package/LICENSE +21 -0
- package/README.md +372 -0
- package/docs/api-parity.md +49 -0
- package/docs/cloning-plan.md +281 -0
- package/docs/compat-baseline.md +535 -0
- package/docs/format-porting.md +86 -0
- package/docs/junior-task-template.md +62 -0
- package/docs/junior-workflow.md +87 -0
- package/docs/migrating-from-jscpd.md +193 -0
- package/docs/npm-release.md +116 -0
- package/docs/public-benchmark-suite.md +81 -0
- package/docs/release-checklist.md +200 -0
- package/docs/release-decisions.md +103 -0
- package/docs/release-readiness.md +51 -0
- package/docs/upstream-bugs.md +501 -0
- package/docs/upstream-issue-drafts.md +393 -0
- package/docs/user-guide.md +309 -0
- package/examples/dump_oxc_tokens.rs +112 -0
- package/examples/library_api.rs +42 -0
- package/npm/bin/jscpd-rs.js +6 -0
- package/npm/bin/jscpd-server.js +6 -0
- package/npm/lib/run-binary.js +68 -0
- package/npm/scripts/postinstall.js +50 -0
- package/package.json +53 -0
- package/skills/dry-refactoring/SKILL.md +63 -0
- package/skills/jscpd/SKILL.md +85 -0
- package/src/app.rs +512 -0
- package/src/bin/jscpd-server.rs +429 -0
- package/src/blame.rs +130 -0
- package/src/cli/config.rs +543 -0
- package/src/cli/parsing.rs +301 -0
- package/src/cli/tests.rs +543 -0
- package/src/cli.rs +671 -0
- package/src/detector/matching/secondary.rs +387 -0
- package/src/detector/matching.rs +274 -0
- package/src/detector/model.rs +190 -0
- package/src/detector/prepare.rs +71 -0
- package/src/detector/skip_local.rs +40 -0
- package/src/detector/statistics.rs +138 -0
- package/src/detector/store.rs +96 -0
- package/src/detector/tests.rs +238 -0
- package/src/detector.rs +265 -0
- package/src/files/discovery.rs +508 -0
- package/src/files/gitignore.rs +203 -0
- package/src/files/paths.rs +68 -0
- package/src/files/shebang.rs +106 -0
- package/src/files/tests.rs +523 -0
- package/src/files.rs +25 -0
- package/src/formats.rs +570 -0
- package/src/lib.rs +433 -0
- package/src/main.rs +26 -0
- package/src/report/ai.rs +125 -0
- package/src/report/badge.rs +238 -0
- package/src/report/console.rs +180 -0
- package/src/report/console_common.rs +37 -0
- package/src/report/console_full.rs +139 -0
- package/src/report/csv.rs +65 -0
- package/src/report/escape.rs +8 -0
- package/src/report/file_output.rs +28 -0
- package/src/report/html/assets.rs +47 -0
- package/src/report/html.rs +336 -0
- package/src/report/json.rs +119 -0
- package/src/report/markdown.rs +125 -0
- package/src/report/sarif.rs +302 -0
- package/src/report/silent.rs +22 -0
- package/src/report/source.rs +38 -0
- package/src/report/summary.rs +50 -0
- package/src/report/test_support.rs +133 -0
- package/src/report/threshold.rs +76 -0
- package/src/report/xcode.rs +90 -0
- package/src/report/xml.rs +119 -0
- package/src/report.rs +250 -0
- package/src/server/mcp.rs +942 -0
- package/src/server.rs +1081 -0
- package/src/tokenizer/apex.rs +97 -0
- package/src/tokenizer/blocks.rs +532 -0
- package/src/tokenizer/embedded.rs +106 -0
- package/src/tokenizer/generic.rs +511 -0
- package/src/tokenizer/hash.rs +27 -0
- package/src/tokenizer/ignore.rs +33 -0
- package/src/tokenizer/line_index.rs +33 -0
- package/src/tokenizer/markdown.rs +289 -0
- package/src/tokenizer/markup_attrs.rs +289 -0
- package/src/tokenizer/oxc/fallback.rs +275 -0
- package/src/tokenizer/oxc/jsx.rs +168 -0
- package/src/tokenizer/oxc/kind.rs +177 -0
- package/src/tokenizer/oxc/lexical.rs +67 -0
- package/src/tokenizer/oxc.rs +659 -0
- package/src/tokenizer/scan.rs +88 -0
- package/src/tokenizer/tap.rs +150 -0
- package/src/tokenizer/tests.rs +915 -0
- package/src/tokenizer.rs +328 -0
- package/src/verbose.rs +195 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
use std::fs;
|
|
2
|
+
use std::path::{Path, PathBuf};
|
|
3
|
+
use std::sync::{Arc, Mutex};
|
|
4
|
+
|
|
5
|
+
use anyhow::{Context, Result, anyhow};
|
|
6
|
+
use globset::{Glob, GlobSet, GlobSetBuilder};
|
|
7
|
+
use ignore::{WalkBuilder, WalkState};
|
|
8
|
+
use rayon::prelude::*;
|
|
9
|
+
|
|
10
|
+
use crate::cli::Options;
|
|
11
|
+
use crate::formats;
|
|
12
|
+
|
|
13
|
+
use super::SourceFile;
|
|
14
|
+
use super::gitignore::{collect_cwd_gitignore_patterns, collect_gitignore_patterns};
|
|
15
|
+
use super::paths::{display_relative_to, fast_glob_like_path_cmp};
|
|
16
|
+
use super::shebang::shebang_format_for_path;
|
|
17
|
+
|
|
18
|
+
#[derive(Clone, Debug)]
|
|
19
|
+
struct CandidateFile {
|
|
20
|
+
path: PathBuf,
|
|
21
|
+
format: String,
|
|
22
|
+
root_index: usize,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
struct CandidateCollectionContext<'a> {
|
|
26
|
+
root: &'a Path,
|
|
27
|
+
root_index: usize,
|
|
28
|
+
options: &'a Options,
|
|
29
|
+
pattern_set: &'a GlobSet,
|
|
30
|
+
ignore_set: &'a IgnoreMatcher,
|
|
31
|
+
cwd: &'a Path,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pub fn discover(options: &Options) -> Result<Vec<SourceFile>> {
|
|
35
|
+
let pattern_set = build_glob_set(std::slice::from_ref(&options.pattern))
|
|
36
|
+
.with_context(|| format!("invalid pattern `{}`", options.pattern))?;
|
|
37
|
+
let needs_compat_discovery = options
|
|
38
|
+
.reporters
|
|
39
|
+
.iter()
|
|
40
|
+
.any(|reporter| reporter_needs_report_paths(reporter))
|
|
41
|
+
|| !options.silent;
|
|
42
|
+
let cwd = std::env::current_dir().context("failed to resolve current directory")?;
|
|
43
|
+
let mut explicit_ignore = options.ignore.clone();
|
|
44
|
+
if options.gitignore {
|
|
45
|
+
explicit_ignore.extend(collect_cwd_gitignore_patterns(&cwd));
|
|
46
|
+
}
|
|
47
|
+
let mut ignore_patterns = normalize_ignore_patterns(&explicit_ignore, &options.paths, &cwd);
|
|
48
|
+
if options.gitignore && needs_compat_discovery {
|
|
49
|
+
ignore_patterns.extend(collect_gitignore_patterns(&options.paths));
|
|
50
|
+
}
|
|
51
|
+
let ignore_set =
|
|
52
|
+
Arc::new(build_ignore_matcher(&ignore_patterns).context("invalid ignore pattern")?);
|
|
53
|
+
let mut candidates = Vec::new();
|
|
54
|
+
|
|
55
|
+
for (root_index, root) in options.paths.iter().enumerate() {
|
|
56
|
+
if options.no_symlinks && is_symlink(root) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
let metadata = fs::metadata(root)
|
|
60
|
+
.with_context(|| format!("failed to inspect path `{}`", root.display()))?;
|
|
61
|
+
if metadata.is_file() {
|
|
62
|
+
collect_candidate(
|
|
63
|
+
root,
|
|
64
|
+
root_index,
|
|
65
|
+
options,
|
|
66
|
+
&ignore_set,
|
|
67
|
+
&cwd,
|
|
68
|
+
&mut candidates,
|
|
69
|
+
)?;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let mut builder = WalkBuilder::new(root);
|
|
74
|
+
builder
|
|
75
|
+
.hidden(false)
|
|
76
|
+
.ignore(!needs_compat_discovery)
|
|
77
|
+
.git_ignore(options.gitignore && !needs_compat_discovery)
|
|
78
|
+
.git_exclude(options.gitignore)
|
|
79
|
+
.git_global(options.gitignore)
|
|
80
|
+
.follow_links(!options.no_symlinks);
|
|
81
|
+
|
|
82
|
+
if needs_compat_discovery {
|
|
83
|
+
let root_path = root.clone();
|
|
84
|
+
let walk_ignore_set = Arc::clone(&ignore_set);
|
|
85
|
+
let has_negations = walk_ignore_set.has_negations();
|
|
86
|
+
let walk_cwd = cwd.clone();
|
|
87
|
+
builder.filter_entry(move |entry| {
|
|
88
|
+
entry.path() == root_path
|
|
89
|
+
|| !entry
|
|
90
|
+
.file_type()
|
|
91
|
+
.is_some_and(|file_type| file_type.is_dir())
|
|
92
|
+
|| has_negations
|
|
93
|
+
|| !is_ignored(entry.path(), &walk_ignore_set, &walk_cwd)
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let collection_context = CandidateCollectionContext {
|
|
98
|
+
root,
|
|
99
|
+
root_index,
|
|
100
|
+
options,
|
|
101
|
+
pattern_set: &pattern_set,
|
|
102
|
+
ignore_set: ignore_set.as_ref(),
|
|
103
|
+
cwd: &cwd,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if parallel_walk_enabled(options) {
|
|
107
|
+
collect_candidates_parallel(&builder, &collection_context, &mut candidates)?;
|
|
108
|
+
} else {
|
|
109
|
+
collect_candidates_sequential(&builder, &collection_context, &mut candidates)?;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
candidates.sort_by(|left, right| {
|
|
114
|
+
left.root_index
|
|
115
|
+
.cmp(&right.root_index)
|
|
116
|
+
.then_with(|| fast_glob_like_path_cmp(&left.path, &right.path))
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
let mut files = candidates
|
|
120
|
+
.into_par_iter()
|
|
121
|
+
.enumerate()
|
|
122
|
+
.map(|(idx, candidate)| {
|
|
123
|
+
read_candidate(candidate, options, &cwd, needs_compat_discovery)
|
|
124
|
+
.map(|file| file.map(|file| (idx, file)))
|
|
125
|
+
})
|
|
126
|
+
.collect::<Vec<_>>()
|
|
127
|
+
.into_iter()
|
|
128
|
+
.collect::<Result<Vec<_>>>()?
|
|
129
|
+
.into_iter()
|
|
130
|
+
.flatten()
|
|
131
|
+
.collect::<Vec<_>>();
|
|
132
|
+
|
|
133
|
+
files.sort_by_key(|(idx, _)| *idx);
|
|
134
|
+
|
|
135
|
+
Ok(files.into_iter().map(|(_, file)| file).collect())
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fn parallel_walk_enabled(options: &Options) -> bool {
|
|
139
|
+
!options.debug && !options.verbose
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
fn collect_candidates_sequential(
|
|
143
|
+
builder: &WalkBuilder,
|
|
144
|
+
context: &CandidateCollectionContext<'_>,
|
|
145
|
+
candidates: &mut Vec<CandidateFile>,
|
|
146
|
+
) -> Result<()> {
|
|
147
|
+
for entry in builder.build() {
|
|
148
|
+
let entry =
|
|
149
|
+
entry.with_context(|| format!("failed to walk path `{}`", context.root.display()))?;
|
|
150
|
+
let Some(file_type) = entry.file_type() else {
|
|
151
|
+
continue;
|
|
152
|
+
};
|
|
153
|
+
if !file_type.is_file() {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
let path = entry.path();
|
|
157
|
+
let relative = path.strip_prefix(context.root).unwrap_or(path);
|
|
158
|
+
if !context.pattern_set.is_match(relative) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
collect_candidate(
|
|
162
|
+
path,
|
|
163
|
+
context.root_index,
|
|
164
|
+
context.options,
|
|
165
|
+
context.ignore_set,
|
|
166
|
+
context.cwd,
|
|
167
|
+
candidates,
|
|
168
|
+
)?;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
Ok(())
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fn collect_candidates_parallel(
|
|
175
|
+
builder: &WalkBuilder,
|
|
176
|
+
context: &CandidateCollectionContext<'_>,
|
|
177
|
+
candidates: &mut Vec<CandidateFile>,
|
|
178
|
+
) -> Result<()> {
|
|
179
|
+
let collected = Arc::new(Mutex::new(Vec::new()));
|
|
180
|
+
let error = Arc::new(Mutex::new(None));
|
|
181
|
+
|
|
182
|
+
builder.build_parallel().run(|| {
|
|
183
|
+
let collected = Arc::clone(&collected);
|
|
184
|
+
let error = Arc::clone(&error);
|
|
185
|
+
Box::new(move |entry| {
|
|
186
|
+
if error.lock().unwrap().is_some() {
|
|
187
|
+
return WalkState::Quit;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let entry = match entry {
|
|
191
|
+
Ok(entry) => entry,
|
|
192
|
+
Err(err) => {
|
|
193
|
+
*error.lock().unwrap() = Some(anyhow!(
|
|
194
|
+
"failed to walk path `{}`: {err}",
|
|
195
|
+
context.root.display()
|
|
196
|
+
));
|
|
197
|
+
return WalkState::Quit;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
let Some(file_type) = entry.file_type() else {
|
|
202
|
+
return WalkState::Continue;
|
|
203
|
+
};
|
|
204
|
+
if !file_type.is_file() {
|
|
205
|
+
return WalkState::Continue;
|
|
206
|
+
}
|
|
207
|
+
let path = entry.path();
|
|
208
|
+
let relative = path.strip_prefix(context.root).unwrap_or(path);
|
|
209
|
+
if !context.pattern_set.is_match(relative) {
|
|
210
|
+
return WalkState::Continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let mut local = Vec::with_capacity(1);
|
|
214
|
+
if let Err(err) = collect_candidate(
|
|
215
|
+
path,
|
|
216
|
+
context.root_index,
|
|
217
|
+
context.options,
|
|
218
|
+
context.ignore_set,
|
|
219
|
+
context.cwd,
|
|
220
|
+
&mut local,
|
|
221
|
+
) {
|
|
222
|
+
*error.lock().unwrap() = Some(err);
|
|
223
|
+
return WalkState::Quit;
|
|
224
|
+
}
|
|
225
|
+
if !local.is_empty() {
|
|
226
|
+
collected.lock().unwrap().extend(local);
|
|
227
|
+
}
|
|
228
|
+
WalkState::Continue
|
|
229
|
+
})
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if let Some(error) = Arc::try_unwrap(error).unwrap().into_inner().unwrap() {
|
|
233
|
+
return Err(error);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
candidates.extend(Arc::try_unwrap(collected).unwrap().into_inner().unwrap());
|
|
237
|
+
|
|
238
|
+
Ok(())
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
fn collect_candidate(
|
|
242
|
+
path: &Path,
|
|
243
|
+
root_index: usize,
|
|
244
|
+
options: &Options,
|
|
245
|
+
ignore_set: &IgnoreMatcher,
|
|
246
|
+
cwd: &Path,
|
|
247
|
+
candidates: &mut Vec<CandidateFile>,
|
|
248
|
+
) -> Result<()> {
|
|
249
|
+
if options.no_symlinks && is_symlink(path) {
|
|
250
|
+
return Ok(());
|
|
251
|
+
}
|
|
252
|
+
if is_ignored(path, ignore_set, cwd) {
|
|
253
|
+
return Ok(());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let format = if let Some(format) =
|
|
257
|
+
formats::format_for_path(path, &options.formats_exts, &options.formats_names)
|
|
258
|
+
{
|
|
259
|
+
Some(format.to_string())
|
|
260
|
+
} else {
|
|
261
|
+
let metadata = fs::metadata(path)
|
|
262
|
+
.with_context(|| format!("failed to inspect file `{}`", path.display()))?;
|
|
263
|
+
if metadata.len() > options.max_size_bytes {
|
|
264
|
+
if options.verbose {
|
|
265
|
+
eprintln!(
|
|
266
|
+
"skipped large file: {} ({} > {})",
|
|
267
|
+
path.display(),
|
|
268
|
+
metadata.len(),
|
|
269
|
+
options.max_size_bytes
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return Ok(());
|
|
273
|
+
}
|
|
274
|
+
shebang_format_for_path(path, &metadata)?.map(str::to_string)
|
|
275
|
+
};
|
|
276
|
+
let Some(format) = format else {
|
|
277
|
+
if options.verbose {
|
|
278
|
+
eprintln!("skipped unsupported format: {}", path.display());
|
|
279
|
+
}
|
|
280
|
+
return Ok(());
|
|
281
|
+
};
|
|
282
|
+
if let Some(formats) = &options.formats
|
|
283
|
+
&& !formats.contains(format.as_str())
|
|
284
|
+
{
|
|
285
|
+
if options.verbose || options.debug {
|
|
286
|
+
println!("{}", format_filter_skip_message(path, &format, cwd));
|
|
287
|
+
}
|
|
288
|
+
return Ok(());
|
|
289
|
+
}
|
|
290
|
+
candidates.push(CandidateFile {
|
|
291
|
+
path: path.to_path_buf(),
|
|
292
|
+
format,
|
|
293
|
+
root_index,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
Ok(())
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
fn read_candidate(
|
|
300
|
+
candidate: CandidateFile,
|
|
301
|
+
options: &Options,
|
|
302
|
+
cwd: &Path,
|
|
303
|
+
needs_report_paths: bool,
|
|
304
|
+
) -> Result<Option<SourceFile>> {
|
|
305
|
+
let bytes = fs::read(&candidate.path)
|
|
306
|
+
.with_context(|| format!("failed to read `{}`", candidate.path.display()))?;
|
|
307
|
+
if bytes.len() as u64 > options.max_size_bytes {
|
|
308
|
+
if options.verbose {
|
|
309
|
+
eprintln!(
|
|
310
|
+
"skipped large file: {} ({} > {})",
|
|
311
|
+
candidate.path.display(),
|
|
312
|
+
bytes.len(),
|
|
313
|
+
options.max_size_bytes
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return Ok(None);
|
|
317
|
+
}
|
|
318
|
+
let lines = count_lines(&bytes);
|
|
319
|
+
if lines < options.min_lines || lines > options.max_lines {
|
|
320
|
+
return Ok(None);
|
|
321
|
+
}
|
|
322
|
+
let content = decode_source(bytes);
|
|
323
|
+
|
|
324
|
+
let source_id = if options.absolute {
|
|
325
|
+
candidate
|
|
326
|
+
.path
|
|
327
|
+
.canonicalize()
|
|
328
|
+
.unwrap_or_else(|_| candidate.path.clone())
|
|
329
|
+
.display()
|
|
330
|
+
.to_string()
|
|
331
|
+
} else if !needs_report_paths {
|
|
332
|
+
candidate.path.display().to_string()
|
|
333
|
+
} else {
|
|
334
|
+
display_relative_to(&candidate.path, cwd)
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
Ok(Some(SourceFile {
|
|
338
|
+
source_id,
|
|
339
|
+
format: candidate.format,
|
|
340
|
+
content,
|
|
341
|
+
}))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
pub(super) fn decode_source(bytes: Vec<u8>) -> String {
|
|
345
|
+
match String::from_utf8(bytes) {
|
|
346
|
+
Ok(content) => content,
|
|
347
|
+
Err(error) => String::from_utf8_lossy(error.as_bytes()).into_owned(),
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
pub(super) fn count_lines(bytes: &[u8]) -> usize {
|
|
352
|
+
bytes.iter().filter(|byte| **byte == b'\n').count() + 1
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fn is_symlink(path: &Path) -> bool {
|
|
356
|
+
fs::symlink_metadata(path)
|
|
357
|
+
.map(|metadata| metadata.file_type().is_symlink())
|
|
358
|
+
.unwrap_or(false)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
fn reporter_needs_report_paths(reporter: &str) -> bool {
|
|
362
|
+
matches!(reporter, "json" | "xml" | "html" | "sarif" | "xcode")
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
fn normalize_ignore_patterns(patterns: &[String], roots: &[PathBuf], cwd: &Path) -> Vec<String> {
|
|
366
|
+
let scan_dirs = roots
|
|
367
|
+
.iter()
|
|
368
|
+
.map(|root| scan_dir_for_root(root))
|
|
369
|
+
.collect::<Vec<_>>();
|
|
370
|
+
let mut normalized = Vec::new();
|
|
371
|
+
let mut seen = std::collections::HashSet::new();
|
|
372
|
+
|
|
373
|
+
for pattern in patterns {
|
|
374
|
+
let path = Path::new(pattern);
|
|
375
|
+
if path.is_absolute() || pattern.starts_with("**/") {
|
|
376
|
+
push_pattern_once(&mut normalized, &mut seen, pattern.clone());
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
push_pattern_once(&mut normalized, &mut seen, pattern.clone());
|
|
381
|
+
for scan_dir in scan_dirs.iter().chain(std::iter::once(&PathBuf::from("."))) {
|
|
382
|
+
push_pattern_once(
|
|
383
|
+
&mut normalized,
|
|
384
|
+
&mut seen,
|
|
385
|
+
normalize_glob_path(scan_dir.join(pattern)),
|
|
386
|
+
);
|
|
387
|
+
push_pattern_once(
|
|
388
|
+
&mut normalized,
|
|
389
|
+
&mut seen,
|
|
390
|
+
normalize_glob_path(cwd.join(scan_dir).join(pattern)),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
normalized
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
fn scan_dir_for_root(root: &Path) -> PathBuf {
|
|
399
|
+
match fs::canonicalize(root) {
|
|
400
|
+
Ok(real_path) if real_path.is_file() => root
|
|
401
|
+
.parent()
|
|
402
|
+
.unwrap_or_else(|| Path::new("."))
|
|
403
|
+
.to_path_buf(),
|
|
404
|
+
_ => root.to_path_buf(),
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
fn push_pattern_once(
|
|
409
|
+
patterns: &mut Vec<String>,
|
|
410
|
+
seen: &mut std::collections::HashSet<String>,
|
|
411
|
+
pattern: String,
|
|
412
|
+
) {
|
|
413
|
+
if seen.insert(pattern.clone()) {
|
|
414
|
+
patterns.push(pattern);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
fn normalize_glob_path(path: PathBuf) -> String {
|
|
419
|
+
let mut normalized = PathBuf::new();
|
|
420
|
+
for component in path.components() {
|
|
421
|
+
match component {
|
|
422
|
+
std::path::Component::CurDir => {}
|
|
423
|
+
std::path::Component::ParentDir => {
|
|
424
|
+
normalized.pop();
|
|
425
|
+
}
|
|
426
|
+
_ => normalized.push(component.as_os_str()),
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
normalized.display().to_string()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
pub(super) fn format_filter_skip_message(path: &Path, format: &str, cwd: &Path) -> String {
|
|
433
|
+
format!(
|
|
434
|
+
"File {} skipped! Format \"{}\" does not included to supported formats.",
|
|
435
|
+
display_relative_to(path, cwd),
|
|
436
|
+
format
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
pub(super) fn is_ignored(path: &Path, ignore_set: &IgnoreMatcher, cwd: &Path) -> bool {
|
|
441
|
+
if ignore_set.is_empty() {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
if ignore_set.is_match(path) || ignore_set.is_match(&normalize_match_path(path)) {
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
path.strip_prefix(cwd)
|
|
448
|
+
.map(|relative| {
|
|
449
|
+
ignore_set.is_match(relative) || ignore_set.is_match(&normalize_match_path(relative))
|
|
450
|
+
})
|
|
451
|
+
.unwrap_or(false)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
fn normalize_match_path(path: &Path) -> PathBuf {
|
|
455
|
+
let mut normalized = PathBuf::new();
|
|
456
|
+
for component in path.components() {
|
|
457
|
+
if !matches!(component, std::path::Component::CurDir) {
|
|
458
|
+
normalized.push(component.as_os_str());
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
normalized
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
pub(super) struct IgnoreMatcher {
|
|
465
|
+
ignored: GlobSet,
|
|
466
|
+
negated: GlobSet,
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
impl IgnoreMatcher {
|
|
470
|
+
fn is_empty(&self) -> bool {
|
|
471
|
+
self.ignored.is_empty()
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
fn has_negations(&self) -> bool {
|
|
475
|
+
!self.negated.is_empty()
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
fn is_match(&self, path: &Path) -> bool {
|
|
479
|
+
self.ignored.is_match(path) && !self.negated.is_match(path)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
pub(super) fn build_ignore_matcher(patterns: &[String]) -> Result<IgnoreMatcher> {
|
|
484
|
+
let mut ignored = GlobSetBuilder::new();
|
|
485
|
+
let mut negated = GlobSetBuilder::new();
|
|
486
|
+
for pattern in patterns {
|
|
487
|
+
if let Some(pattern) = pattern.strip_prefix('!') {
|
|
488
|
+
negated.add(Glob::new(pattern)?);
|
|
489
|
+
} else {
|
|
490
|
+
ignored.add(Glob::new(pattern)?);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
Ok(IgnoreMatcher {
|
|
494
|
+
ignored: ignored.build()?,
|
|
495
|
+
negated: negated.build()?,
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
fn build_glob_set(patterns: &[String]) -> Result<GlobSet> {
|
|
500
|
+
let mut builder = GlobSetBuilder::new();
|
|
501
|
+
if patterns.is_empty() {
|
|
502
|
+
return Ok(builder.build()?);
|
|
503
|
+
}
|
|
504
|
+
for pattern in patterns {
|
|
505
|
+
builder.add(Glob::new(pattern)?);
|
|
506
|
+
}
|
|
507
|
+
Ok(builder.build()?)
|
|
508
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
use std::collections::HashSet;
|
|
2
|
+
use std::env;
|
|
3
|
+
use std::fs;
|
|
4
|
+
use std::path::{Path, PathBuf};
|
|
5
|
+
use std::process::Command;
|
|
6
|
+
|
|
7
|
+
use super::paths::relative_path;
|
|
8
|
+
|
|
9
|
+
pub(super) fn collect_gitignore_patterns(roots: &[PathBuf]) -> Vec<String> {
|
|
10
|
+
let global_excludes_file = global_gitignore_path();
|
|
11
|
+
collect_gitignore_patterns_with_global(roots, global_excludes_file.as_deref())
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
pub(crate) fn collect_cwd_gitignore_patterns(cwd: &Path) -> Vec<String> {
|
|
15
|
+
let Ok(content) = fs::read_to_string(cwd.join(".gitignore")) else {
|
|
16
|
+
return Vec::new();
|
|
17
|
+
};
|
|
18
|
+
content
|
|
19
|
+
.lines()
|
|
20
|
+
.flat_map(|line| gitignore_line_to_globs(line, None))
|
|
21
|
+
.collect()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
pub(super) fn collect_gitignore_patterns_with_global(
|
|
25
|
+
roots: &[PathBuf],
|
|
26
|
+
global_excludes_file: Option<&Path>,
|
|
27
|
+
) -> Vec<String> {
|
|
28
|
+
let mut patterns = Vec::new();
|
|
29
|
+
let mut visited_dirs = HashSet::new();
|
|
30
|
+
let mut visited_repos = HashSet::new();
|
|
31
|
+
|
|
32
|
+
for root in roots {
|
|
33
|
+
let abs_root = root.canonicalize().unwrap_or_else(|_| root.clone());
|
|
34
|
+
let mut current = if abs_root.is_file() {
|
|
35
|
+
abs_root
|
|
36
|
+
.parent()
|
|
37
|
+
.map(Path::to_path_buf)
|
|
38
|
+
.unwrap_or_else(|| abs_root.clone())
|
|
39
|
+
} else {
|
|
40
|
+
abs_root
|
|
41
|
+
};
|
|
42
|
+
let mut dirs = Vec::new();
|
|
43
|
+
let mut repo_root = None;
|
|
44
|
+
|
|
45
|
+
loop {
|
|
46
|
+
if !visited_dirs.contains(¤t) {
|
|
47
|
+
dirs.push(current.clone());
|
|
48
|
+
}
|
|
49
|
+
if current.join(".git").exists() {
|
|
50
|
+
repo_root = Some(current.clone());
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
let Some(parent) = current.parent() else {
|
|
54
|
+
break;
|
|
55
|
+
};
|
|
56
|
+
if parent == current {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
current = parent.to_path_buf();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for dir in dirs {
|
|
63
|
+
if !visited_dirs.insert(dir.clone()) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
let Ok(content) = fs::read_to_string(dir.join(".gitignore")) else {
|
|
67
|
+
continue;
|
|
68
|
+
};
|
|
69
|
+
for line in content.lines() {
|
|
70
|
+
patterns.extend(gitignore_line_to_globs(line, Some(&dir)));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if let Some(repo_root) = repo_root
|
|
75
|
+
&& visited_repos.insert(repo_root.clone())
|
|
76
|
+
{
|
|
77
|
+
let exclude = repo_root.join(".git").join("info").join("exclude");
|
|
78
|
+
if let Ok(content) = fs::read_to_string(exclude) {
|
|
79
|
+
for line in content.lines() {
|
|
80
|
+
patterns.extend(gitignore_line_to_globs(line, Some(&repo_root)));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if let Some(global_excludes_file) = global_excludes_file
|
|
87
|
+
&& let Ok(content) = fs::read_to_string(global_excludes_file)
|
|
88
|
+
{
|
|
89
|
+
for line in content.lines() {
|
|
90
|
+
patterns.extend(gitignore_line_to_globs(line, None));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
patterns
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fn global_gitignore_path() -> Option<PathBuf> {
|
|
98
|
+
let output = Command::new("git")
|
|
99
|
+
.args(["config", "--global", "core.excludesFile"])
|
|
100
|
+
.output()
|
|
101
|
+
.ok()?;
|
|
102
|
+
if !output.status.success() {
|
|
103
|
+
return None;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
107
|
+
if value.is_empty() {
|
|
108
|
+
return None;
|
|
109
|
+
}
|
|
110
|
+
if value == "~" {
|
|
111
|
+
return home_dir();
|
|
112
|
+
}
|
|
113
|
+
if let Some(rest) = value.strip_prefix("~/") {
|
|
114
|
+
return home_dir().map(|home| home.join(rest));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
Some(PathBuf::from(value))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fn home_dir() -> Option<PathBuf> {
|
|
121
|
+
env::var_os("HOME")
|
|
122
|
+
.or_else(|| env::var_os("USERPROFILE"))
|
|
123
|
+
.map(PathBuf::from)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
pub(super) fn gitignore_line_to_globs(line: &str, base_dir: Option<&Path>) -> Vec<String> {
|
|
127
|
+
let trimmed = line.trim();
|
|
128
|
+
if trimmed.is_empty() || trimmed.starts_with('#') {
|
|
129
|
+
return Vec::new();
|
|
130
|
+
}
|
|
131
|
+
if let Some(pattern) = trimmed.strip_prefix('!') {
|
|
132
|
+
return gitignore_line_to_globs(pattern, base_dir)
|
|
133
|
+
.into_iter()
|
|
134
|
+
.map(|glob| format!("!{glob}"))
|
|
135
|
+
.collect();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let is_rooted = trimmed.starts_with('/');
|
|
139
|
+
let pattern = trimmed
|
|
140
|
+
.trim_start_matches('/')
|
|
141
|
+
.trim_end_matches('/')
|
|
142
|
+
.replace('\\', "/");
|
|
143
|
+
if pattern.is_empty() {
|
|
144
|
+
return Vec::new();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if let Some(base_dir) = base_dir {
|
|
148
|
+
return scoped_gitignore_globs(base_dir, &pattern, is_rooted);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
upstream_gitignore_globs(&pattern, is_rooted)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
fn scoped_gitignore_globs(base_dir: &Path, pattern: &str, is_rooted: bool) -> Vec<String> {
|
|
155
|
+
let mut globs = Vec::new();
|
|
156
|
+
|
|
157
|
+
if is_rooted {
|
|
158
|
+
push_gitignore_glob_variants(&mut globs, &base_dir.join(pattern));
|
|
159
|
+
return globs;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if pattern.contains('/') {
|
|
163
|
+
push_gitignore_glob_variants(&mut globs, &base_dir.join(pattern));
|
|
164
|
+
if !pattern.starts_with("**/") {
|
|
165
|
+
push_gitignore_glob_variants(&mut globs, &base_dir.join("**").join(pattern));
|
|
166
|
+
}
|
|
167
|
+
return globs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
push_gitignore_glob_variants(&mut globs, &base_dir.join("**").join(pattern));
|
|
171
|
+
globs
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fn upstream_gitignore_globs(pattern: &str, is_rooted: bool) -> Vec<String> {
|
|
175
|
+
if is_rooted {
|
|
176
|
+
return vec![pattern.to_string(), format!("{pattern}/**")];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if pattern.contains('/') {
|
|
180
|
+
let mut globs = vec![pattern.to_string(), format!("{pattern}/**")];
|
|
181
|
+
if !pattern.starts_with("**/") {
|
|
182
|
+
globs.push(format!("**/{pattern}"));
|
|
183
|
+
globs.push(format!("**/{pattern}/**"));
|
|
184
|
+
}
|
|
185
|
+
return globs;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
vec![format!("**/{pattern}"), format!("**/{pattern}/**")]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
fn push_gitignore_glob_variants(globs: &mut Vec<String>, path: &Path) {
|
|
192
|
+
let absolute = path.display().to_string().replace('\\', "/");
|
|
193
|
+
globs.push(absolute.clone());
|
|
194
|
+
globs.push(format!("{absolute}/**"));
|
|
195
|
+
|
|
196
|
+
if let Ok(cwd) = std::env::current_dir()
|
|
197
|
+
&& let Some(relative) = relative_path(path, &cwd)
|
|
198
|
+
{
|
|
199
|
+
let relative = relative.display().to_string().replace('\\', "/");
|
|
200
|
+
globs.push(relative.clone());
|
|
201
|
+
globs.push(format!("{relative}/**"));
|
|
202
|
+
}
|
|
203
|
+
}
|