Skip to main content

nu_command/filesystem/
glob.rs

1use nu_engine::command_prelude::*;
2use nu_protocol::{ListStream, Signals, shell_error::generic::GenericError};
3use wax::{
4    Glob as WaxGlob, any, walk::DepthBehavior, walk::DepthMax, walk::Entry, walk::FileIterator,
5    walk::GlobEntry, walk::LinkBehavior, walk::WalkBehavior,
6};
7
8#[derive(Clone)]
9pub struct Glob;
10
11impl Command for Glob {
12    fn name(&self) -> &str {
13        "glob"
14    }
15
16    fn signature(&self) -> Signature {
17        Signature::build("glob")
18            .input_output_types(vec![(Type::Nothing, Type::List(Box::new(Type::String)))])
19            .required("glob", SyntaxShape::OneOf(vec![SyntaxShape::String, SyntaxShape::GlobPattern]), "The glob expression.")
20            .named(
21                "depth",
22                SyntaxShape::Int,
23                "Directory depth to search.",
24                Some('d'),
25            )
26            .switch(
27                "no-dir",
28                "Whether to filter out directories from the returned paths.",
29                Some('D'),
30            )
31            .switch(
32                "no-file",
33                "Whether to filter out files from the returned paths.",
34                Some('F'),
35            )
36            .switch(
37                "no-symlink",
38                "Whether to filter out symlinks from the returned paths.",
39                Some('S'),
40            )
41            .switch(
42                "follow-symlinks",
43                "Whether to follow symbolic links to their targets.",
44                Some('l'),
45            )
46            .named(
47                "exclude",
48                SyntaxShape::List(Box::new(SyntaxShape::String)),
49                "Patterns to exclude from the search: `glob` will not walk the inside of directories matching the excluded patterns.",
50                Some('e'),
51            )
52            .category(Category::FileSystem)
53    }
54
55    fn description(&self) -> &str {
56        "Creates a list of files and/or folders based on the glob pattern provided."
57    }
58
59    fn search_terms(&self) -> Vec<&str> {
60        vec!["pattern", "files", "folders", "list", "ls"]
61    }
62
63    fn examples(&self) -> Vec<Example<'_>> {
64        vec![
65            Example {
66                description: "Search for *.rs files.",
67                example: "glob *.rs",
68                result: None,
69            },
70            Example {
71                description: "Search for *.rs and *.toml files recursively up to 2 folders deep.",
72                example: "glob **/*.{rs,toml} --depth 2",
73                result: None,
74            },
75            Example {
76                description: "Search for files and folders that begin with uppercase C or lowercase c.",
77                example: r#"glob "[Cc]*""#,
78                result: None,
79            },
80            Example {
81                description: "Search for files and folders like abc or xyz substituting a character for ?.",
82                example: r#"glob "{a?c,x?z}""#,
83                result: None,
84            },
85            Example {
86                description: "A case-insensitive search for files and folders that begin with c.",
87                example: r#"glob "(?i)c*""#,
88                result: None,
89            },
90            Example {
91                description: "Search for files or folders that do not begin with c, C, b, M, or s.",
92                example: r#"glob "[!cCbMs]*""#,
93                result: None,
94            },
95            Example {
96                description: "Search for files or folders with 3 a's in a row in the name.",
97                example: "glob <a*:3>",
98                result: None,
99            },
100            Example {
101                description: "Search for files or folders with only a, b, c, or d in the file name between 1 and 10 times.",
102                example: "glob <[a-d]:1,10>",
103                result: None,
104            },
105            Example {
106                description: "Search for folders that begin with an uppercase ASCII letter, ignoring files and symlinks.",
107                example: r#"glob "[A-Z]*" --no-file --no-symlink"#,
108                result: None,
109            },
110            Example {
111                description: "Search for files named tsconfig.json that are not in node_modules directories.",
112                example: "glob **/tsconfig.json --exclude [**/node_modules/**]",
113                result: None,
114            },
115            Example {
116                description: "Search for all files that are not in the target nor .git directories.",
117                example: "glob **/* --exclude [**/target/** **/.git/** */]",
118                result: None,
119            },
120            Example {
121                description: "Search for files following symbolic links to their targets.",
122                example: r#"glob "**/*.txt" --follow-symlinks"#,
123                result: None,
124            },
125        ]
126    }
127
128    fn extra_description(&self) -> &str {
129        "For more glob pattern help, please refer to https://docs.rs/crate/wax/latest"
130    }
131
132    fn run(
133        &self,
134        engine_state: &EngineState,
135        stack: &mut Stack,
136        call: &Call,
137        _input: PipelineData,
138    ) -> Result<PipelineData, ShellError> {
139        let span = call.head;
140        let glob_pattern_input: Value = call.req(engine_state, stack, 0)?;
141        let glob_span = glob_pattern_input.span();
142        let depth = call.get_flag(engine_state, stack, "depth")?;
143        let no_dirs = call.has_flag(engine_state, stack, "no-dir")?;
144        let no_files = call.has_flag(engine_state, stack, "no-file")?;
145        let no_symlinks = call.has_flag(engine_state, stack, "no-symlink")?;
146        let follow_symlinks = call.has_flag(engine_state, stack, "follow-symlinks")?;
147        let paths_to_exclude: Option<Value> = call.get_flag(engine_state, stack, "exclude")?;
148
149        let (not_patterns, not_pattern_span): (Vec<String>, Span) = match paths_to_exclude {
150            None => (vec![], span),
151            Some(f) => {
152                let pat_span = f.span();
153                match f {
154                    Value::List { vals: pats, .. } => {
155                        let p = convert_patterns(pats.as_slice())?;
156                        (p, pat_span)
157                    }
158                    _ => (vec![], span),
159                }
160            }
161        };
162
163        let glob_pattern =
164            match glob_pattern_input {
165                Value::String { val, .. } | Value::Glob { val, .. } => val,
166                _ => return Err(ShellError::IncorrectValue {
167                    msg: "Incorrect glob pattern supplied to glob. Please use string or glob only."
168                        .to_string(),
169                    val_span: call.head,
170                    call_span: glob_span,
171                }),
172            };
173
174        // paths starting with drive letters must be escaped on Windows
175        #[cfg(windows)]
176        let glob_pattern = patch_windows_glob_pattern(glob_pattern, glob_span)?;
177
178        if glob_pattern.is_empty() {
179            return Err(ShellError::Generic(
180                GenericError::new(
181                    "glob pattern must not be empty",
182                    "glob pattern is empty",
183                    glob_span,
184                )
185                .with_help("add characters to the glob pattern"),
186            ));
187        }
188
189        // below we have to check / instead of MAIN_SEPARATOR because glob uses / as separator
190        // using a glob like **\*.rs should fail because it's not a valid glob pattern
191        let folder_depth = if let Some(depth) = depth {
192            depth
193        } else if glob_pattern.contains("**") {
194            usize::MAX
195        } else if glob_pattern.contains('/') {
196            glob_pattern.split('/').count() + 1
197        } else {
198            1
199        };
200
201        let (prefix, glob) = match WaxGlob::new(&glob_pattern) {
202            Ok(p) => p.partition_or_empty(),
203            Err(e) => {
204                return Err(ShellError::Generic(GenericError::new(
205                    "error with glob pattern",
206                    format!("{e}"),
207                    glob_span,
208                )));
209            }
210        };
211
212        let path = engine_state.cwd_as_string(Some(stack))?;
213        let path = nu_path::absolute_with(prefix, path).map_err(|e| {
214            ShellError::Generic(GenericError::new("invalid path", format!("{e}"), glob_span))
215        })?;
216        let path = match path.try_exists() {
217            Ok(true) => path,
218            Ok(false) =>
219            // path we're trying to glob doesn't exist,
220            {
221                std::path::PathBuf::new() // user should get empty list not an error
222            }
223            Err(e) => {
224                return Err(ShellError::Generic(GenericError::new(
225                    "error accessing path",
226                    format!("{e}"),
227                    glob_span,
228                )));
229            }
230        };
231
232        let link_behavior = match follow_symlinks {
233            true => LinkBehavior::ReadTarget,
234            false => LinkBehavior::ReadFile,
235        };
236
237        let make_walk_behavior = |depth: usize| WalkBehavior {
238            depth: DepthBehavior::Max(DepthMax(depth)),
239            link: link_behavior,
240        };
241
242        let result = if !not_patterns.is_empty() {
243            let patterns: Vec<WaxGlob<'static>> = not_patterns
244                .into_iter()
245                .map(|pattern| {
246                    WaxGlob::new(&pattern)
247                        .map_err(|err| {
248                            ShellError::Generic(GenericError::new(
249                                "error with glob's not pattern",
250                                format!("{err}"),
251                                not_pattern_span,
252                            ))
253                        })
254                        .map(|g| g.into_owned())
255                })
256                .collect::<Result<_, _>>()?;
257
258            let any_pattern = any(patterns).map_err(|err| {
259                ShellError::Generic(GenericError::new(
260                    "error with glob's not pattern",
261                    format!("{err}"),
262                    not_pattern_span,
263                ))
264            })?;
265
266            let glob_results = glob
267                .walk_with_behavior(path, make_walk_behavior(folder_depth))
268                .not(any_pattern)
269                .map_err(|err| {
270                    ShellError::Generic(GenericError::new(
271                        "error with glob's not pattern",
272                        format!("{err}"),
273                        not_pattern_span,
274                    ))
275                })?
276                .flatten();
277
278            glob_to_value(
279                engine_state.signals(),
280                glob_results,
281                no_dirs,
282                no_files,
283                no_symlinks,
284                span,
285            )
286        } else {
287            let glob_results = glob
288                .walk_with_behavior(path, make_walk_behavior(folder_depth))
289                .flatten();
290            glob_to_value(
291                engine_state.signals(),
292                glob_results,
293                no_dirs,
294                no_files,
295                no_symlinks,
296                span,
297            )
298        };
299
300        Ok(result.into_pipeline_data(span, engine_state.signals().clone()))
301    }
302}
303
304#[cfg(windows)]
305fn patch_windows_glob_pattern(glob_pattern: String, glob_span: Span) -> Result<String, ShellError> {
306    let mut chars = glob_pattern.chars();
307    match (chars.next(), chars.next(), chars.next()) {
308        (Some(drive), Some(':'), Some('/' | '\\')) if drive.is_ascii_alphabetic() => {
309            Ok(format!("{drive}\\:/{}", chars.as_str()))
310        }
311        (Some(drive), Some(':'), Some(_)) if drive.is_ascii_alphabetic() => {
312            Err(ShellError::Generic(
313                GenericError::new(
314                    "invalid Windows path format",
315                    "Windows paths with drive letters must include a path separator (/) after the colon",
316                    glob_span,
317                )
318                .with_help("use format like 'C:/' instead of 'C:'"),
319            ))
320        }
321        _ => Ok(glob_pattern),
322    }
323}
324
325fn convert_patterns(columns: &[Value]) -> Result<Vec<String>, ShellError> {
326    let res = columns
327        .iter()
328        .map(|value| match &value {
329            Value::String { val: s, .. } => Ok(s.clone()),
330            _ => Err(ShellError::IncompatibleParametersSingle {
331                msg: "Incorrect column format, Only string as column name".to_string(),
332                span: value.span(),
333            }),
334        })
335        .collect::<Result<Vec<String>, _>>()?;
336
337    Ok(res)
338}
339
340fn glob_to_value(
341    signals: &Signals,
342    glob_results: impl Iterator<Item = GlobEntry> + Send + 'static,
343    no_dirs: bool,
344    no_files: bool,
345    no_symlinks: bool,
346    span: Span,
347) -> ListStream {
348    let map_signals = signals.clone();
349    let result = glob_results.filter_map(move |entry| {
350        if let Err(err) = map_signals.check(&span) {
351            return Some(Value::error(err, span));
352        };
353        let file_type = entry.file_type();
354
355        if !(no_dirs && file_type.is_dir()
356            || no_files && file_type.is_file()
357            || no_symlinks && file_type.is_symlink())
358        {
359            Some(Value::string(
360                entry.into_path().to_string_lossy().into_owned(),
361                span,
362            ))
363        } else {
364            None
365        }
366    });
367
368    ListStream::new(result, span, signals.clone())
369}
370
371#[cfg(windows)]
372#[cfg(test)]
373mod windows_tests {
374    use super::*;
375
376    #[test]
377    fn glob_pattern_with_drive_letter() {
378        let pattern = "D:/*.mp4".to_string();
379        let result = patch_windows_glob_pattern(pattern, Span::test_data()).unwrap();
380        assert!(WaxGlob::new(&result).is_ok());
381
382        let pattern = "Z:/**/*.md".to_string();
383        let result = patch_windows_glob_pattern(pattern, Span::test_data()).unwrap();
384        assert!(WaxGlob::new(&result).is_ok());
385
386        let pattern = "C:/nested/**/escaped/path/<[_a-zA-Z\\-]>.md".to_string();
387        let result = patch_windows_glob_pattern(pattern, Span::test_data()).unwrap();
388        assert!(dbg!(WaxGlob::new(&result)).is_ok());
389    }
390
391    #[test]
392    fn glob_pattern_without_drive_letter() {
393        let pattern = "/usr/bin/*.sh".to_string();
394        let result = patch_windows_glob_pattern(pattern.clone(), Span::test_data()).unwrap();
395        assert_eq!(result, pattern);
396        assert!(WaxGlob::new(&result).is_ok());
397
398        let pattern = "a".to_string();
399        let result = patch_windows_glob_pattern(pattern.clone(), Span::test_data()).unwrap();
400        assert_eq!(result, pattern);
401        assert!(WaxGlob::new(&result).is_ok());
402    }
403
404    #[test]
405    fn invalid_path_format() {
406        let invalid = "C:lol".to_string();
407        let result = patch_windows_glob_pattern(invalid, Span::test_data());
408        assert!(result.is_err());
409    }
410
411    #[test]
412    fn unpatched_patterns() {
413        let unpatched = "C:/Users/*.txt".to_string();
414        assert!(WaxGlob::new(&unpatched).is_err());
415
416        let patched = patch_windows_glob_pattern(unpatched, Span::test_data()).unwrap();
417        assert!(WaxGlob::new(&patched).is_ok());
418    }
419}