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