Skip to main content

nu_engine/
glob_from.rs

1use nu_glob::MatchOptions;
2use nu_path::{absolute_with, expand_path_with};
3use nu_protocol::{
4    NuGlob, ShellError, Signals, Span, Spanned, shell_error::generic::GenericError,
5    shell_error::io::IoError,
6};
7use std::{
8    fs, io,
9    path::{Component, Path, PathBuf},
10};
11
12/// This function is like `nu_glob::glob` from the `glob` crate, except it is relative to a given cwd.
13///
14/// It returns a tuple of two values: the first is an optional prefix that the expanded filenames share.
15/// This prefix can be removed from the front of each value to give an approximation of the relative path
16/// to the user
17///
18/// The second of the two values is an iterator over the matching filepaths.
19#[allow(clippy::type_complexity)]
20pub fn glob_from(
21    pattern: &Spanned<NuGlob>,
22    cwd: &Path,
23    span: Span,
24    options: Option<MatchOptions>,
25    signals: Signals,
26) -> Result<
27    (
28        Option<PathBuf>,
29        Box<dyn Iterator<Item = Result<PathBuf, ShellError>> + Send>,
30    ),
31    ShellError,
32> {
33    let no_glob_for_pattern = matches!(pattern.item, NuGlob::DoNotExpand(_));
34    let pattern_span = pattern.span;
35    let (prefix, pattern) = if nu_glob::is_glob_with_backend(pattern.item.as_ref()) {
36        // Pattern contains glob, split it
37        let mut p = PathBuf::new();
38        let path = PathBuf::from(&pattern.item.as_ref());
39        let components = path.components();
40        let mut counter = 0;
41
42        for c in components {
43            if let Component::Normal(os) = c
44                && nu_glob::is_glob_with_backend(os.to_string_lossy().as_ref())
45            {
46                break;
47            }
48            p.push(c);
49            counter += 1;
50        }
51
52        let mut just_pattern = PathBuf::new();
53        for c in counter..path.components().count() {
54            if let Some(comp) = path.components().nth(c) {
55                just_pattern.push(comp);
56            }
57        }
58        if no_glob_for_pattern {
59            just_pattern = PathBuf::from(nu_glob::escape_with_backend(
60                &just_pattern.to_string_lossy(),
61            ));
62        }
63
64        // Now expand `p` to get full prefix
65        let path = expand_path_with(p, cwd, pattern.item.is_expand());
66        let escaped_prefix = PathBuf::from(nu_glob::escape_with_backend(&path.to_string_lossy()));
67
68        (Some(path), escaped_prefix.join(just_pattern))
69    } else {
70        let path = PathBuf::from(&pattern.item.as_ref());
71        let path = expand_path_with(path, cwd, pattern.item.is_expand());
72        let is_symlink = match fs::symlink_metadata(&path) {
73            Ok(attr) => attr.file_type().is_symlink(),
74            Err(_) => false,
75        };
76
77        if is_symlink {
78            (path.parent().map(|parent| parent.to_path_buf()), path)
79        } else {
80            let path = match absolute_with(path.clone(), cwd) {
81                Ok(p) if p.exists() => {
82                    if nu_glob::is_glob_with_backend(p.to_string_lossy().as_ref()) {
83                        // our path might contain glob metacharacters too.
84                        // in such case, we need to escape our path to make
85                        // glob work successfully
86                        PathBuf::from(nu_glob::escape_with_backend(&p.to_string_lossy()))
87                    } else {
88                        p
89                    }
90                }
91                Ok(_) => {
92                    return Err(IoError::new(
93                        io::Error::from(io::ErrorKind::NotFound),
94                        pattern_span,
95                        path,
96                    )
97                    .into());
98                }
99                Err(err) => {
100                    return Err(IoError::new(err, pattern_span, path).into());
101                }
102            };
103            (path.parent().map(|parent| parent.to_path_buf()), path)
104        }
105    };
106
107    let pattern = pattern.to_string_lossy().to_string();
108
109    if nu_experimental::DC_GLOB.get() {
110        let pattern_path = PathBuf::from(&pattern);
111        // If the resolved pattern is an existing path, return it directly.
112        // Passing a plain path to glob_from_interruptible makes the traversal engine
113        // call read_dir() on it, which either fails with "Not a directory" (for files)
114        // or iterates the directory's contents instead of matching the directory itself
115        // (for directories), both of which produce incorrect empty results.
116        if pattern_path.exists() {
117            return Ok((prefix, Box::new(std::iter::once(Ok(pattern_path)))));
118        }
119
120        let iter =
121            nu_glob::dc_glob::glob_from_interruptible(cwd, &pattern, signals.interrupt_flag())
122                .map_err(|e| {
123                    ShellError::Generic(GenericError::new(
124                        "Error extracting glob pattern",
125                        e.to_string(),
126                        span,
127                    ))
128                })?;
129
130        // dc-glob returns paths relative to the traversal start directory.
131        // Join them with `prefix` to produce absolute paths, matching the
132        // legacy backend's behaviour.
133        let prefix_for_map = prefix.clone();
134        let mapped = iter.map(move |x| match x {
135            Ok(v) => {
136                let v = match &prefix_for_map {
137                    Some(p) if v.is_relative() => p.join(&v),
138                    _ => v,
139                };
140                Ok(v)
141            }
142            Err(e) => Err(ShellError::Generic(GenericError::new(
143                "Error extracting glob pattern",
144                e.to_string(),
145                span,
146            ))),
147        });
148
149        Ok((prefix, Box::new(mapped)))
150    } else {
151        let glob_options = options.unwrap_or_default();
152        let glob = nu_glob::glob_with(&pattern, glob_options, signals).map_err(|e| {
153            ShellError::Generic(GenericError::new(
154                "Error extracting glob pattern",
155                e.to_string(),
156                span,
157            ))
158        })?;
159
160        let mapped = glob.map(move |x| match x {
161            Ok(v) => Ok(v),
162            Err(e) => Err(ShellError::Generic(GenericError::new(
163                "Error extracting glob pattern",
164                e.error().to_string(),
165                span,
166            ))),
167        });
168
169        Ok((prefix, Box::new(mapped)))
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::glob_from;
176    use nu_protocol::{NuGlob, Signals, Span, Spanned};
177    use std::fs;
178    use std::path::{Path, PathBuf};
179    use std::sync::Arc;
180    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
181    use std::time::{SystemTime, UNIX_EPOCH};
182
183    static NEXT_ID: AtomicU64 = AtomicU64::new(0);
184
185    fn unique_test_dir(prefix: &str) -> PathBuf {
186        let ts = SystemTime::now()
187            .duration_since(UNIX_EPOCH)
188            .map(|d| d.as_nanos())
189            .unwrap_or(0);
190
191        std::env::temp_dir().join(format!(
192            "nu_engine_glob_from_{prefix}_{}_{}",
193            std::process::id(),
194            ts + u128::from(NEXT_ID.fetch_add(1, Ordering::Relaxed))
195        ))
196    }
197
198    fn write_file(path: &PathBuf) {
199        let create_result = fs::create_dir_all(path.parent().unwrap_or(path));
200        assert!(
201            create_result.is_ok(),
202            "failed to create parent dir for {}: {:?}",
203            path.display(),
204            create_result
205        );
206
207        let write_result = fs::write(path, b"x");
208        assert!(
209            write_result.is_ok(),
210            "failed to write test file {}: {:?}",
211            path.display(),
212            write_result
213        );
214    }
215
216    #[test]
217    #[exp(nu_experimental::DC_GLOB)]
218    fn glob_from_dc_glob_remains_lazy_for_first_item() {
219        let root = unique_test_dir("lazy_first_item");
220        let root_create_result = fs::create_dir_all(&root);
221        assert!(
222            root_create_result.is_ok(),
223            "failed to create root test directory {}: {:?}",
224            root.display(),
225            root_create_result
226        );
227
228        // A top-level match gives the iterator a fast first row.
229        write_file(&root.join("top.rs"));
230
231        // Create enough matches that eager collection would fully drain on construction.
232        let nested_count = 9000usize;
233        for idx in 0..nested_count {
234            write_file(&root.join(format!("deep/dir_{idx}/file_{idx}.rs")));
235        }
236
237        let ctrlc = Arc::new(AtomicBool::new(false));
238        let signals = Signals::new(ctrlc);
239        let pattern = Spanned {
240            item: NuGlob::Expand("**/*.rs".to_string()),
241            span: Span::test_data(),
242        };
243
244        let result = glob_from(&pattern, &root, Span::test_data(), None, signals.clone());
245        assert!(result.is_ok(), "glob_from failed");
246
247        let (_, mut iter) = match result {
248            Ok(v) => v,
249            Err(err) => panic!("glob_from failed unexpectedly: {err}"),
250        };
251
252        let first = iter.next();
253        assert!(
254            matches!(first, Some(Ok(_))),
255            "expected first iterator item to be a match, got: {first:?}"
256        );
257
258        // Interrupt after the first row. If glob_from eagerly materializes,
259        // the returned iterator has already consumed all rows and this has no effect.
260        signals.trigger();
261
262        let remaining = iter.count();
263        assert!(
264            remaining < 6000,
265            "expected interrupt to stop iteration before full drain; remaining={remaining}"
266        );
267
268        let _ = fs::remove_dir_all(&root);
269    }
270
271    #[test]
272    #[exp(nu_experimental::DC_GLOB)]
273    fn glob_from_dc_glob_matches_literal_file() {
274        let root = unique_test_dir("literal_file");
275        fs::create_dir_all(&root).expect("failed to create root");
276        let file = root.join("test.txt");
277        write_file(&file);
278
279        let ctrlc = Arc::new(AtomicBool::new(false));
280        let signals = Signals::new(ctrlc);
281        let pattern = Spanned {
282            item: NuGlob::Expand(file.to_string_lossy().to_string()),
283            span: Span::test_data(),
284        };
285
286        let result = glob_from(&pattern, Path::new("/"), Span::test_data(), None, signals);
287        assert!(result.is_ok(), "glob_from failed");
288
289        let (_, mut iter) = result.unwrap();
290        let first = iter.next();
291        assert!(
292            matches!(first, Some(Ok(ref p)) if *p == file),
293            "expected file path itself, got: {first:?}"
294        );
295        assert!(iter.next().is_none(), "expected exactly one result");
296
297        let _ = fs::remove_dir_all(&root);
298    }
299
300    #[test]
301    #[exp(nu_experimental::DC_GLOB)]
302    fn glob_from_dc_glob_matches_literal_directory() {
303        let root = unique_test_dir("literal_dir");
304        fs::create_dir_all(&root).expect("failed to create root");
305
306        let ctrlc = Arc::new(AtomicBool::new(false));
307        let signals = Signals::new(ctrlc);
308        let pattern = Spanned {
309            item: NuGlob::Expand(root.to_string_lossy().to_string()),
310            span: Span::test_data(),
311        };
312
313        let result = glob_from(&pattern, Path::new("/"), Span::test_data(), None, signals);
314        assert!(result.is_ok(), "glob_from failed");
315
316        let (_, mut iter) = result.unwrap();
317        let first = iter.next();
318        assert!(
319            matches!(first, Some(Ok(ref p)) if *p == root),
320            "expected directory path itself, got: {first:?}"
321        );
322        assert!(iter.next().is_none(), "expected exactly one result");
323
324        let _ = fs::remove_dir_all(&root);
325    }
326}