1const BUILD_OUTPUT_DIRS: &[&str] = &[
12 "node_modules",
13 "target",
14 "dist",
15 "build",
16 "_build",
17 ".venv",
18 "venv",
19 "__pycache__",
20 ".pytest_cache",
21 ".mypy_cache",
22 ".ruff_cache",
23 ".tox",
24 ".nox",
25 ".next",
26 ".nuxt",
27 ".svelte-kit",
28 ".turbo",
29 ".parcel-cache",
30 ".angular",
31 ".gradle",
32 "coverage",
33 "htmlcov",
34];
35
36const SCRATCH_DIRS: &[&str] = &["tmp", ".tmp", "temp"];
38
39const REGENERABLE_NAMES: &[&str] = &[
42 "*.pyc",
43 "*.pyo",
44 "__pycache__",
45 ".pytest_cache",
46 ".mypy_cache",
47 ".ruff_cache",
48 "*.o",
49 "*.obj",
50 "*.class",
51 "*.gcda",
52 "*.gcno",
53 "*.orig",
54 "*.rej",
55 "*.swp",
56 "*.swo",
57 "*~",
58 ".DS_Store",
59 "._*",
60 "Thumbs.db",
61 "desktop.ini",
62];
63
64fn plain_relative(p: &str) -> bool {
68 let mut rest = p;
69 while let Some(r) = rest.strip_prefix("./") {
70 rest = r;
71 }
72 let rest = rest.trim_end_matches('/');
73 !rest.is_empty()
74 && !rest.starts_with('-')
75 && rest
76 .chars()
77 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+' | '@' | '/'))
78 && rest
79 .split('/')
80 .all(|part| !part.is_empty() && part != "." && part != "..")
81}
82
83#[must_use]
87pub fn build_output(p: &str) -> bool {
88 plain_relative(p)
89 && !p.split('/').any(|part| part == ".git")
90 && p.split('/').any(|part| BUILD_OUTPUT_DIRS.contains(&part))
91}
92
93#[must_use]
97pub fn scratch(p: &str) -> bool {
98 let mut rest = p;
99 while let Some(r) = rest.strip_prefix("./") {
100 rest = r;
101 }
102 plain_relative(p)
103 && rest
104 .split('/')
105 .next()
106 .is_some_and(|first| SCRATCH_DIRS.contains(&first))
107 && !rest.split('/').any(|part| part == ".git")
108}
109
110#[must_use]
114pub fn leaves_tree(words: &[String]) -> bool {
115 let Some(first) = words.first() else {
116 return false;
117 };
118 let name = first.rsplit('/').next().unwrap_or(first);
119 if name == "popd" {
120 return true;
121 }
122 if name != "cd" && name != "pushd" {
123 return false;
124 }
125 match words[1..].iter().find(|a| !a.starts_with('-') || *a == "-") {
126 Some(t) => !plain_relative(t),
127 None => true,
128 }
129}
130
131#[must_use]
138pub fn regenerable_cleanup(starts: &[&str], expr: &[&str]) -> bool {
139 let in_tree = |p: &&str| {
140 let p = p.trim_end_matches('/');
141 (p == "." || plain_relative(p)) && !p.split('/').any(|part| part == ".git")
142 };
143 if !starts.iter().all(in_tree) {
144 return false;
145 }
146 if expr.iter().any(|a| {
147 matches!(
148 *a,
149 "-o" | "-or" | "!" | "-not" | "," | "(" | ")" | "\\(" | "\\)"
150 )
151 }) {
152 return false;
153 }
154 let mut named = 0;
155 for w in expr.windows(2) {
156 match w[0] {
157 "-name" | "-iname" => {
158 if !REGENERABLE_NAMES.contains(&w[1]) {
159 return false;
160 }
161 named += 1;
162 }
163 "-path" | "-ipath" | "-wholename" => {
164 let parts: Vec<&str> = w[1].split('/').filter(|p| *p != "*" && *p != ".").collect();
165 if parts.is_empty() || !parts.iter().all(|p| REGENERABLE_NAMES.contains(p)) {
166 return false;
167 }
168 named += 1;
169 }
170 "-regex" | "-iregex" | "-lname" | "-ilname" => return false,
171 _ => {}
172 }
173 }
174 named > 0
175}