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 #[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 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 {
221 std::path::PathBuf::new() }
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}