Skip to main content

shrub_core/
pathsearch.rs

1//! Path searching utilities.
2
3use std::collections::VecDeque;
4use std::path::{Path, PathBuf};
5
6use crate::sys;
7use crate::sys::fs::PathExt;
8
9/// Encapsulates the result of a path search.
10pub struct ExecutablePathSearch<PI, N> {
11    paths: VecDeque<PI>,
12    filename: N,
13}
14
15impl<PI, N> Iterator for ExecutablePathSearch<PI, N>
16where
17    PI: AsRef<Path>,
18    N: AsRef<Path>,
19{
20    type Item = PathBuf;
21
22    fn next(&mut self) -> Option<Self::Item> {
23        while let Some(path) = self.paths.pop_front() {
24            let path = PathBuf::from(path.as_ref()).join(self.filename.as_ref());
25
26            // Ask the platform to resolve the path to an actual executable
27            // file, which on Windows may involve appending a
28            // PATHEXT extension. The helper takes ownership so Unix
29            // — where no resolution is needed — can return the path unchanged
30            // without allocating.
31            //
32            // A directory carries the execute bit on Unix but is never a
33            // command. Filter the *resolved* path rather than the
34            // input: on Windows, resolution appends a
35            // PATHEXT extension, so a `prog` directory must not stop `prog.exe`
36            // in the same PATH entry from being found.
37            if let Some(resolved) = sys::fs::resolve_executable(path)
38                && !resolved.is_dir()
39            {
40                return Some(resolved);
41            }
42        }
43
44        None
45    }
46}
47
48pub(crate) struct ExecutablePathPrefixSearch<PI> {
49    paths: VecDeque<PI>,
50    queued_items: VecDeque<PathBuf>,
51    filename_prefix: String,
52    case_insensitive: bool,
53}
54
55impl<PI> Iterator for ExecutablePathPrefixSearch<PI>
56where
57    PI: AsRef<Path>,
58{
59    type Item = PathBuf;
60
61    fn next(&mut self) -> Option<Self::Item> {
62        // If we already found some items and queued them, then yield one now.
63        if let Some(item) = self.queued_items.pop_front() {
64            return Some(item);
65        }
66
67        while let Some(path) = self.paths.pop_front() {
68            let path = PathBuf::from(path.as_ref());
69
70            if let Ok(readdir) = path.read_dir() {
71                for entry in readdir.flatten() {
72                    if let Ok(mut filename) = entry.file_name().into_string() {
73                        if self.case_insensitive {
74                            filename = filename.to_ascii_lowercase();
75                        }
76
77                        if !filename.starts_with(&self.filename_prefix) {
78                            continue;
79                        }
80                    }
81
82                    let entry_path = entry.path();
83                    if let Ok(file_type) = entry.file_type() {
84                        if file_type.is_file() && entry_path.executable() {
85                            self.queued_items.push_back(entry_path);
86                            continue;
87                        }
88                        if file_type.is_symlink() && entry_path.executable() {
89                            self.queued_items.push_back(entry_path);
90                        }
91                    }
92                }
93            }
94            if let Some(item) = self.queued_items.pop_front() {
95                return Some(item);
96            }
97        }
98
99        None
100    }
101}
102
103/// Search for the given executable name in the provided paths.
104///
105/// # Arguments
106///
107/// * `paths` - An iterator over the paths to search.
108/// * `filename` - The name of the executable file to search for.
109pub fn search_for_executable<P, PI, N>(paths: P, filename: N) -> ExecutablePathSearch<PI, N>
110where
111    P: Iterator<Item = PI>,
112    PI: AsRef<Path>,
113    N: AsRef<Path>,
114{
115    ExecutablePathSearch {
116        paths: paths.collect(),
117        filename,
118    }
119}
120
121/// Resolves a command name the way the shell does when it is about to run it.
122///
123/// Returns the first executable in search order, or -- if there is none -- the
124/// first entry that exists and is not a directory, which the shell reports as
125/// the command and then fails to run.
126///
127/// # Arguments
128///
129/// * `paths` - An iterator over the paths to search.
130/// * `filename` - The name of the command to resolve.
131pub fn resolve_command<P, PI, N>(paths: P, filename: N) -> Option<PathBuf>
132where
133    P: IntoIterator<Item = PI>,
134    PI: AsRef<Path>,
135    N: AsRef<Path>,
136{
137    let mut first_non_executable = None;
138
139    for dir in paths {
140        let path = dir.as_ref().join(filename.as_ref());
141
142        // Remember the first non-directory entry seen, in case no executable
143        // turns up.
144        if first_non_executable.is_none() && path.metadata().is_ok_and(|m| !m.is_dir()) {
145            first_non_executable = Some(path.clone());
146        }
147
148        // Resolve, then reject directories; see `ExecutablePathSearch::next`.
149        if let Some(resolved) = sys::fs::resolve_executable(path)
150            && !resolved.is_dir()
151        {
152            return Some(resolved);
153        }
154    }
155
156    first_non_executable
157}
158
159pub(crate) fn search_for_executable_with_prefix<P, PI>(
160    paths: P,
161    filename_prefix: &str,
162    case_insensitive: bool,
163) -> ExecutablePathPrefixSearch<PI>
164where
165    P: Iterator<Item = PI>,
166    PI: AsRef<Path>,
167{
168    let stored_prefix = if case_insensitive {
169        filename_prefix.to_ascii_lowercase()
170    } else {
171        filename_prefix.into()
172    };
173
174    ExecutablePathPrefixSearch {
175        paths: paths.collect(),
176        queued_items: VecDeque::new(),
177        filename_prefix: stored_prefix,
178        case_insensitive,
179    }
180}
181
182#[cfg(test)]
183#[expect(clippy::panic_in_result_fn)]
184mod tests {
185    use anyhow::Result;
186
187    use super::*;
188
189    /// A directory carries the execute bit on Unix, so it must not be mistaken
190    /// for a command; an executable later in the search order takes its
191    /// place.
192    #[test]
193    fn directory_is_not_a_command() -> Result<()> {
194        let scratch = tempfile::tempdir()?;
195        let first = scratch.path().join("first");
196        let second = scratch.path().join("second");
197        std::fs::create_dir_all(first.join("prog"))?;
198        std::fs::create_dir_all(&second)?;
199        std::fs::write(second.join("prog"), "")?;
200
201        let paths = [first.as_path(), second.as_path()];
202
203        // The plain (non-executable) file wins over the directory, rather than
204        // the directory being reported as the command.
205        assert_eq!(resolve_command(paths, "prog"), Some(second.join("prog")));
206
207        // ...and a directory is never yielded as an executable at all.
208        assert_eq!(search_for_executable(paths.iter(), "prog").next(), None);
209
210        Ok(())
211    }
212
213    /// On Windows, `PATHEXT` resolution has to run before directories are
214    /// rejected: a `prog` directory must not keep `prog.bat` in the same
215    /// PATH entry from being found.
216    #[cfg(windows)] // ast-grep-ignore: platform-cfg-outside-sys
217    #[test]
218    fn same_named_directory_does_not_hide_a_pathext_match() -> Result<()> {
219        let scratch = tempfile::tempdir()?;
220        std::fs::create_dir_all(scratch.path().join("prog"))?;
221        std::fs::write(scratch.path().join("prog.bat"), "@echo off\r\n")?;
222
223        let paths = [scratch.path()];
224        for found in [
225            search_for_executable(paths.iter(), "prog").next(),
226            resolve_command(paths, "prog"),
227        ] {
228            assert!(
229                found.as_ref().is_some_and(|path| path
230                    .extension()
231                    .is_some_and(|ext| ext.eq_ignore_ascii_case("bat"))),
232                "unexpected resolution: {found:?}"
233            );
234        }
235
236        Ok(())
237    }
238}