Skip to main content

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