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 *literal* path (no active glob
112        // metacharacters), return it directly. Passing a plain path to
113        // glob_from_interruptible makes the traversal engine call read_dir() on it,
114        // which either fails with "Not a directory" (for files) or iterates the
115        // directory's contents instead of matching the directory itself (for
116        // directories), both of which produce incorrect empty results.
117        //
118        // Patterns that still contain glob metacharacters must go through the
119        // walker even when a same-named path exists (e.g. a file named `*` must
120        // not make bare `*` / `ls` return only that one entry). See #18631.
121        if pattern_path.exists() && !nu_glob::is_glob_with_backend(&pattern) {
122            return Ok((prefix, Box::new(std::iter::once(Ok(pattern_path)))));
123        }
124
125        let iter =
126            nu_glob::dc_glob::glob_from_interruptible(cwd, &pattern, signals.interrupt_flag())
127                .map_err(|e| {
128                    ShellError::Generic(GenericError::new(
129                        "Error extracting glob pattern",
130                        e.to_string(),
131                        span,
132                    ))
133                })?;
134
135        // dc-glob returns paths relative to the traversal start directory.
136        // Join them with `prefix` to produce absolute paths, matching the
137        // legacy backend's behaviour.
138        let prefix_for_map = prefix.clone();
139        let mapped = iter.map(move |x| match x {
140            Ok(v) => {
141                let v = match &prefix_for_map {
142                    Some(p) if v.is_relative() => p.join(&v),
143                    _ => v,
144                };
145                Ok(v)
146            }
147            Err(e) => Err(ShellError::Generic(GenericError::new(
148                "Error extracting glob pattern",
149                e.to_string(),
150                span,
151            ))),
152        });
153
154        Ok((prefix, Box::new(mapped)))
155    } else {
156        let glob_options = options.unwrap_or_default();
157        let glob = nu_glob::glob_with(&pattern, glob_options, signals).map_err(|e| {
158            ShellError::Generic(GenericError::new(
159                "Error extracting glob pattern",
160                e.to_string(),
161                span,
162            ))
163        })?;
164
165        let mapped = glob.map(move |x| match x {
166            Ok(v) => Ok(v),
167            Err(e) => Err(ShellError::Generic(GenericError::new(
168                "Error extracting glob pattern",
169                e.error().to_string(),
170                span,
171            ))),
172        });
173
174        Ok((prefix, Box::new(mapped)))
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::glob_from;
181    use nu_protocol::{NuGlob, Signals, Span, Spanned};
182    use std::fs;
183    use std::path::{Path, PathBuf};
184    use std::sync::Arc;
185    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
186    use std::time::{SystemTime, UNIX_EPOCH};
187
188    static NEXT_ID: AtomicU64 = AtomicU64::new(0);
189
190    fn unique_test_dir(prefix: &str) -> PathBuf {
191        let ts = SystemTime::now()
192            .duration_since(UNIX_EPOCH)
193            .map(|d| d.as_nanos())
194            .unwrap_or(0);
195
196        std::env::temp_dir().join(format!(
197            "nu_engine_glob_from_{prefix}_{}_{}",
198            std::process::id(),
199            ts + u128::from(NEXT_ID.fetch_add(1, Ordering::Relaxed))
200        ))
201    }
202
203    fn write_file(path: &PathBuf) {
204        let create_result = fs::create_dir_all(path.parent().unwrap_or(path));
205        assert!(
206            create_result.is_ok(),
207            "failed to create parent dir for {}: {:?}",
208            path.display(),
209            create_result
210        );
211
212        let write_result = fs::write(path, b"x");
213        assert!(
214            write_result.is_ok(),
215            "failed to write test file {}: {:?}",
216            path.display(),
217            write_result
218        );
219    }
220
221    #[test]
222    #[exp(nu_experimental::DC_GLOB)]
223    fn glob_from_dc_glob_remains_lazy_for_first_item() {
224        let root = unique_test_dir("lazy_first_item");
225        let root_create_result = fs::create_dir_all(&root);
226        assert!(
227            root_create_result.is_ok(),
228            "failed to create root test directory {}: {:?}",
229            root.display(),
230            root_create_result
231        );
232
233        // A top-level match gives the iterator a fast first row.
234        write_file(&root.join("top.rs"));
235
236        // Create enough matches that eager collection would fully drain on construction.
237        let nested_count = 9000usize;
238        for idx in 0..nested_count {
239            write_file(&root.join(format!("deep/dir_{idx}/file_{idx}.rs")));
240        }
241
242        let ctrlc = Arc::new(AtomicBool::new(false));
243        let signals = Signals::new(ctrlc);
244        let pattern = Spanned {
245            item: NuGlob::Expand("**/*.rs".to_string()),
246            span: Span::test_data(),
247        };
248
249        let result = glob_from(&pattern, &root, Span::test_data(), None, signals.clone());
250        assert!(result.is_ok(), "glob_from failed");
251
252        let (_, mut iter) = match result {
253            Ok(v) => v,
254            Err(err) => panic!("glob_from failed unexpectedly: {err}"),
255        };
256
257        let first = iter.next();
258        assert!(
259            matches!(first, Some(Ok(_))),
260            "expected first iterator item to be a match, got: {first:?}"
261        );
262
263        // Interrupt after the first row. If glob_from eagerly materializes,
264        // the returned iterator has already consumed all rows and this has no effect.
265        signals.trigger();
266
267        let remaining = iter.count();
268        assert!(
269            remaining < 6000,
270            "expected interrupt to stop iteration before full drain; remaining={remaining}"
271        );
272
273        let _ = fs::remove_dir_all(&root);
274    }
275
276    #[test]
277    #[exp(nu_experimental::DC_GLOB)]
278    fn glob_from_dc_glob_matches_literal_file() {
279        let root = unique_test_dir("literal_file");
280        fs::create_dir_all(&root).expect("failed to create root");
281        let file = root.join("test.txt");
282        write_file(&file);
283
284        let ctrlc = Arc::new(AtomicBool::new(false));
285        let signals = Signals::new(ctrlc);
286        let pattern = Spanned {
287            item: NuGlob::Expand(file.to_string_lossy().to_string()),
288            span: Span::test_data(),
289        };
290
291        let result = glob_from(&pattern, Path::new("/"), Span::test_data(), None, signals);
292        assert!(result.is_ok(), "glob_from failed");
293
294        let (_, mut iter) = result.unwrap();
295        let first = iter.next();
296        assert!(
297            matches!(first, Some(Ok(ref p)) if *p == file),
298            "expected file path itself, got: {first:?}"
299        );
300        assert!(iter.next().is_none(), "expected exactly one result");
301
302        let _ = fs::remove_dir_all(&root);
303    }
304
305    #[test]
306    #[exp(nu_experimental::DC_GLOB)]
307    fn glob_from_dc_glob_matches_literal_directory() {
308        let root = unique_test_dir("literal_dir");
309        fs::create_dir_all(&root).expect("failed to create root");
310
311        let ctrlc = Arc::new(AtomicBool::new(false));
312        let signals = Signals::new(ctrlc);
313        let pattern = Spanned {
314            item: NuGlob::Expand(root.to_string_lossy().to_string()),
315            span: Span::test_data(),
316        };
317
318        let result = glob_from(&pattern, Path::new("/"), Span::test_data(), None, signals);
319        assert!(result.is_ok(), "glob_from failed");
320
321        let (_, mut iter) = result.unwrap();
322        let first = iter.next();
323        assert!(
324            matches!(first, Some(Ok(ref p)) if *p == root),
325            "expected directory path itself, got: {first:?}"
326        );
327        assert!(iter.next().is_none(), "expected exactly one result");
328
329        let _ = fs::remove_dir_all(&root);
330    }
331
332    // Windows does not allow `*` in filenames, so this regression only applies on Unix.
333    #[cfg(not(windows))]
334    #[test]
335    #[exp(nu_experimental::DC_GLOB)]
336    fn glob_from_dc_glob_star_with_literal_star_file() {
337        // Regression for #18631: a file named `*` must not make pattern `*`
338        // short-circuit to only that path.
339        let root = unique_test_dir("star_file");
340        fs::create_dir_all(&root).expect("failed to create root");
341        write_file(&root.join("a"));
342        write_file(&root.join("b"));
343        write_file(&root.join("*"));
344
345        let ctrlc = Arc::new(AtomicBool::new(false));
346        let signals = Signals::new(ctrlc);
347        let pattern = Spanned {
348            item: NuGlob::Expand("*".to_string()),
349            span: Span::test_data(),
350        };
351
352        let result = glob_from(&pattern, &root, Span::test_data(), None, signals);
353        assert!(result.is_ok(), "glob_from failed");
354
355        let (_, iter) = match result {
356            Ok(v) => v,
357            Err(err) => panic!("glob_from failed unexpectedly: {err}"),
358        };
359        let mut names: Vec<String> = iter
360            .map(|r| {
361                r.expect("glob path ok")
362                    .file_name()
363                    .expect("basename")
364                    .to_string_lossy()
365                    .into_owned()
366            })
367            .collect();
368        names.sort();
369
370        assert_eq!(
371            names,
372            vec!["*".to_string(), "a".to_string(), "b".to_string()]
373        );
374
375        let _ = fs::remove_dir_all(&root);
376    }
377
378    // Windows does not allow `*` in filenames, so this regression only applies on Unix.
379    #[cfg(not(windows))]
380    #[test]
381    #[exp(nu_experimental::DC_GLOB)]
382    fn glob_from_dc_glob_prefix_wildcard_with_literal_match_name() {
383        // Pattern `foo*` must still expand when a file literally named `foo*` exists.
384        let root = unique_test_dir("foo_star");
385        fs::create_dir_all(&root).expect("failed to create root");
386        write_file(&root.join("foo1"));
387        write_file(&root.join("foo2"));
388        write_file(&root.join("foo*"));
389        write_file(&root.join("other"));
390
391        let ctrlc = Arc::new(AtomicBool::new(false));
392        let signals = Signals::new(ctrlc);
393        let pattern = Spanned {
394            item: NuGlob::Expand("foo*".to_string()),
395            span: Span::test_data(),
396        };
397
398        let result = glob_from(&pattern, &root, Span::test_data(), None, signals);
399        assert!(result.is_ok(), "glob_from failed");
400
401        let (_, iter) = match result {
402            Ok(v) => v,
403            Err(err) => panic!("glob_from failed unexpectedly: {err}"),
404        };
405        let mut names: Vec<String> = iter
406            .map(|r| {
407                r.expect("glob path ok")
408                    .file_name()
409                    .expect("basename")
410                    .to_string_lossy()
411                    .into_owned()
412            })
413            .collect();
414        names.sort();
415
416        assert_eq!(
417            names,
418            vec!["foo*".to_string(), "foo1".to_string(), "foo2".to_string()]
419        );
420
421        let _ = fs::remove_dir_all(&root);
422    }
423}