drep/files/mod.rs
1//! The only file-target policy.
2//!
3//! Every code path in drep that decides which files to look at goes through
4//! these predicates, so a Python file is a file is a file regardless of whether
5//! it was discovered by a full scan, the staged-files index, or a
6//! `--diff <ref>` query. Markdown has its own predicate and its own command:
7//! see `is_scan_target` for why the two file classes are disjoint.
8//!
9//! Walking is the `ignore` crate rather than `std::fs::read_dir` so a project's
10//! gitignore is honoured without a second pass, and vendored directories are
11//! pruned during the walk (rather than collected-then-filtered, which would
12//! `stat` every entry under `node_modules/` before discarding them).
13//!
14//! Discovery of explicit filenames (`drep check a.rs .`) deliberately ignores
15//! gitignore: the user naming a path is a stronger signal than a repo-wide
16//! pattern. `expand_paths` enforces that; `walk_targets` enforces the inverse.
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::path::{Path, PathBuf};
20
21use ignore::{DirEntry, WalkBuilder};
22
23use crate::languages;
24
25/// True iff `path`'s lowercased extension matches `target_without_dot`.
26///
27/// `target` is e.g. `"py"`; `"FOO.PY"` matches, `"py"` (no extension) does not.
28fn extension_is(path: &Path, target: &str) -> bool {
29 match path.extension() {
30 None => false,
31 Some(ext) => ext.eq_ignore_ascii_case(target),
32 }
33}
34
35/// True iff `path`'s lowercased extension appears in `targets_with_dots`.
36///
37/// Used by the walkers; the inline `extension_is` is for single-suffix
38/// predicates where allocation-free comparison is clearer.
39fn extension_in(path: &Path, targets_with_dots: &[&str]) -> bool {
40 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
41 return false;
42 };
43 let lower = ext.to_ascii_lowercase();
44 let dotted = format!(".{lower}");
45 targets_with_dots.iter().any(|t| **t == dotted)
46}
47
48/// Any registered language's source file - the file class `drep check` reads.
49///
50/// Markdown is **not** here, and that is the point. It used to be, because 1.x
51/// ran one scan over everything; the consequence was that `drep check
52/// README.md` accepted the file, found no deterministic tool and no LLM
53/// language for it, and printed "No issues found." A file drep declined to
54/// analyze, reported as clean, on a path the user named explicitly.
55///
56/// Each command now owns one file class: `check` reads code through this
57/// predicate, `lint-docs` reads markdown through [`is_markdown`]. A path the
58/// user names that falls outside the running command's class is a
59/// [`crate::analysis::result::FailureReason::Unsupported`] pointing at the
60/// other command - never a silent skip.
61pub fn is_scan_target(path: &Path) -> bool {
62 extension_in(path, languages::source_extensions())
63}
64
65/// Python source file. Kept distinct from `is_scan_target` because the
66/// docstring pass runs `ast.parse`, and adding a language must never widen
67/// that filter.
68pub fn is_python_source(path: &Path) -> bool {
69 extension_is(path, "py")
70}
71
72/// Markdown document.
73pub fn is_markdown(path: &Path) -> bool {
74 extension_is(path, "md")
75}
76
77/// Hardcoded ignored dirs that belong to no single language: VCS metadata,
78/// build output, caches.
79const SHARED_IGNORED_DIRS: &[&str] = &[".git", "build", "dist", ".cache"];
80
81/// A directory that should never be descended into.
82///
83/// Case-insensitive on purpose: a directory called `VENV` and one called `venv`
84/// are the same on a case-insensitive filesystem, and matching only one of
85/// them would walk it anyway — defeating the whole point of having the list.
86pub fn is_ignored_dir(name: &str) -> bool {
87 let folded = name.to_ascii_lowercase();
88 if folded.ends_with(".egg-info") {
89 return true;
90 }
91 if SHARED_IGNORED_DIRS.iter().any(|d| *d == folded) {
92 return true;
93 }
94 crate::languages::vendored_dirs()
95 .iter()
96 .any(|d| d.eq_ignore_ascii_case(name))
97}
98
99/// Walk `root` and collect every regular file matching `predicate`.
100///
101/// Pruning happens **during** the walk via `ignore`'s `filter_entry`, so
102/// ignoring `node_modules/` does not mean stat-ing every entry under it first
103/// — the failure mode that `rglob` falls into on a real repo. Honors
104/// per-directory `.gitignore`, which is the difference between a walk that
105/// respects a project's policy and one that simply skips a directory named
106/// `build`.
107pub fn walk_targets(root: &Path, predicate: fn(&Path) -> bool) -> Vec<PathBuf> {
108 let walker = WalkBuilder::new(root)
109 .hidden(false)
110 .git_ignore(true)
111 .git_global(false)
112 .git_exclude(true)
113 .require_git(false)
114 .sort_by_file_name(|a, b| a.cmp(b))
115 .filter_entry(|entry: &DirEntry| {
116 if entry.depth() == 0 {
117 return true;
118 }
119 if entry.file_type().is_some_and(|ft| ft.is_dir())
120 && is_ignored_dir(&entry.file_name().to_string_lossy())
121 {
122 return false;
123 }
124 true
125 })
126 .build();
127
128 let mut found = Vec::new();
129 for result in walker {
130 let Ok(entry) = result else { continue };
131 if entry.file_type().is_some_and(|ft| ft.is_file()) {
132 let path = entry.path();
133 if predicate(path) {
134 found.push(path.to_path_buf());
135 }
136 }
137 }
138 found
139}
140
141/// Expand a list of explicit paths (files and/or directories) into a sorted,
142/// deduplicated set of files matching `predicate`.
143///
144/// Dedup because `drep check a.rs .` would otherwise pay a whole LLM
145/// round-trip twice. An explicit file is filtered by the same predicate as
146/// directory walks, so naming `notes.txt` cannot smuggle in a type drep does
147/// not read; conversely, gitignore is *not* consulted for explicit files —
148/// the user naming a path is a stronger signal than the repo's ignore file.
149/// Paths that do not exist are skipped silently.
150pub fn expand_paths(paths: &[PathBuf], predicate: fn(&Path) -> bool) -> Vec<PathBuf> {
151 let mut found = BTreeSet::new();
152 for path in paths {
153 if path.is_dir() {
154 for file in walk_targets(path, predicate) {
155 found.insert(file);
156 }
157 } else if path.is_file() && predicate(path) {
158 found.insert(path.clone());
159 }
160 // Non-existent paths fall through: the user typed something the
161 // filesystem does not have, and the contract is to skip without
162 // erroring. Distinguishing "exists but is neither file nor dir"
163 // (broken symlink, /dev/null-style specials) is not worth a branch.
164 }
165 found.into_iter().collect()
166}
167
168/// Why an explicitly named path produced no target.
169///
170/// Only *named* paths are ever rejected. A directory walk that yields nothing
171/// is legitimately empty: `drep check .` in a documentation repository has
172/// correctly found no code. A path the user typed is the opposite case, and
173/// reporting "No issues found." for it is the single failure this codebase is
174/// built to prevent.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum Rejected {
177 /// Nothing exists at that path.
178 Missing,
179 /// Something exists there, but this command cannot analyze it: the
180 /// predicate declined the file type, or the path is neither a regular file
181 /// nor a directory (a fifo, a socket, `/dev/stdin`).
182 Unanalyzable,
183}
184
185/// The result of resolving a command's path arguments.
186pub struct Expansion {
187 /// Files to analyze, sorted and deduplicated.
188 pub targets: Vec<PathBuf>,
189 /// Named paths that produced nothing, and why.
190 pub rejected: BTreeMap<PathBuf, Rejected>,
191}
192
193/// Resolve a command's path arguments against `root`.
194///
195/// The single answer to "what did the user ask for, and what could I not do
196/// with it". [`expand_paths`] reports only the targets, which meant every
197/// caller re-walked the same argument list with its own `exists()` /
198/// `is_file()` tests to reconstruct what the expander had already decided and
199/// thrown away. Two commands did that, and the reconstruction was lossy in the
200/// same way in both: a named fifo satisfies neither `is_file` nor `is_dir`, so
201/// it was dropped by the expander, missed by the reconstruction, and reported
202/// as a clean run.
203///
204/// No arguments means `root`, which is how bare `drep check` means "the whole
205/// tree". `root` goes through the expander like any other directory - an
206/// earlier version returned it unexpanded, so `read_to_string` was handed a
207/// *directory* and the plainest invocation of the primary command exited 2
208/// without analyzing anything.
209pub fn expand_named(paths: &[PathBuf], root: &Path, predicate: fn(&Path) -> bool) -> Expansion {
210 if paths.is_empty() {
211 return Expansion {
212 targets: expand_paths(&[root.to_path_buf()], predicate),
213 rejected: BTreeMap::new(),
214 };
215 }
216 let mut rejected = BTreeMap::new();
217 for named in paths {
218 // One `metadata` call answers file/dir/missing together. The three
219 // separate `exists()` / `is_dir()` / `is_file()` probes this replaced
220 // were three syscalls with a window between each, and expressed the
221 // "neither a regular file nor a directory" case as a negation chain
222 // rather than as the arm it is.
223 let verdict = match std::fs::metadata(named) {
224 Err(_) => Some(Rejected::Missing),
225 Ok(meta) if meta.is_dir() => None,
226 Ok(meta) if meta.is_file() && predicate(named) => None,
227 Ok(_) => Some(Rejected::Unanalyzable),
228 };
229 if let Some(verdict) = verdict {
230 rejected.insert(named.clone(), verdict);
231 }
232 }
233 Expansion {
234 targets: expand_paths(paths, predicate),
235 rejected,
236 }
237}
238
239/// The subcommand that does analyze `path`, if any.
240///
241/// One table, consulted from both directions. Each command previously
242/// hardcoded a pointer at the other - `check` asked `is_markdown`, `lint-docs`
243/// asked `languages::detect` - which is two definitions of one question and an
244/// edit in every existing command each time a file class is added.
245pub fn owning_command(path: &Path) -> Option<&'static str> {
246 if is_scan_target(path) {
247 Some("check")
248 } else if is_markdown(path) {
249 Some("lint-docs")
250 } else {
251 None
252 }
253}
254
255/// What to tell a user who named `path` at the wrong command.
256///
257/// `None` when no command claims the type, because drep genuinely has nothing
258/// to say about a `.png` and inventing a suggestion would be worse than
259/// admitting that.
260pub fn redirect_hint(path: &Path) -> Option<String> {
261 owning_command(path).map(|command| format!("run `drep {command}` instead"))
262}
263
264#[cfg(test)]
265mod tests;