nu_command/filesystem/
glob.rs

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