Skip to main content

nu_glob/dc_glob/
mod.rs

1//! Experimental dc-glob backend imported from glob_experiment by Devyn Cairns for evaluation in Nushell.
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::sync::atomic::AtomicBool;
6
7use crate::Pattern;
8
9mod compiler;
10mod globber;
11mod matcher;
12mod parser;
13
14const GLOB_CHARS: &[char] = &['*', '?', '['];
15
16/// Options for dc-glob filesystem traversal.
17#[derive(Debug, Clone, Default)]
18pub struct GlobWalkOptions {
19    /// Maximum recursion depth. `None` means unbounded.
20    pub max_depth: Option<usize>,
21    /// Follow directory symlinks while traversing.
22    pub follow_symlinks: bool,
23    /// Exclusion glob patterns used to filter and prune traversal.
24    pub excludes: Vec<String>,
25    /// Optional interrupt flag. When set to `true` the traversal stops as soon
26    /// as possible and the iterator ends.
27    pub interrupt: Option<Arc<AtomicBool>>,
28    /// When `true`, match path components case-insensitively (ASCII only).
29    pub ignore_case: bool,
30}
31
32/// Return true if the given pattern contains glob metacharacters.
33pub fn is_glob(pattern: &str) -> bool {
34    pattern.contains(GLOB_CHARS)
35}
36
37/// Escape glob metacharacters so a pattern is matched literally.
38pub fn escape(pattern: &str) -> String {
39    crate::Pattern::escape(pattern)
40}
41
42/// Expand `pattern` relative to `relative_to` using the experimental dc-glob backend.
43pub fn glob_from(
44    relative_to: impl AsRef<Path>,
45    pattern: impl AsRef<str>,
46) -> anyhow::Result<Box<dyn Iterator<Item = anyhow::Result<PathBuf>> + Send>> {
47    let pattern = pattern.as_ref().to_owned();
48    let parsed = parser::parse(&pattern);
49    let fast_path = detect_recursive_suffix_fast_path(&parsed);
50    let compiled = compiler::compile(&parsed)?;
51    Ok(Box::new(globber::glob(
52        relative_to.as_ref().to_path_buf(),
53        Arc::new(compiled),
54        fast_path,
55    )))
56}
57
58/// Like [`glob_from`] but accepts an interrupt flag so the caller can cancel the traversal.
59pub fn glob_from_interruptible(
60    relative_to: impl AsRef<Path>,
61    pattern: impl AsRef<str>,
62    interrupt: Option<Arc<AtomicBool>>,
63) -> anyhow::Result<Box<dyn Iterator<Item = anyhow::Result<PathBuf>> + Send>> {
64    let pattern = pattern.as_ref().to_owned();
65    let parsed = parser::parse(&pattern);
66    let fast_path = detect_recursive_suffix_fast_path(&parsed);
67    let compiled = compiler::compile(&parsed)?;
68    Ok(Box::new(globber::glob_with_options(
69        relative_to.as_ref().to_path_buf(),
70        Arc::new(compiled),
71        globber::TraversalOptions::default(),
72        vec![],
73        globber::InterruptFlag(interrupt),
74        fast_path,
75    )))
76}
77
78/// Expand `pattern` relative to `relative_to` using dc-glob with traversal options.
79pub fn glob_with(
80    relative_to: impl AsRef<Path>,
81    pattern: impl AsRef<str>,
82    options: &GlobWalkOptions,
83) -> anyhow::Result<Box<dyn Iterator<Item = anyhow::Result<PathBuf>> + Send>> {
84    let pattern = pattern.as_ref().to_owned();
85    let parsed = parser::parse(&pattern);
86    let fast_path = detect_recursive_suffix_fast_path(&parsed);
87    let include_program = compiler::compile_with_options(&parsed, options.ignore_case)?;
88
89    let exclude_programs = options
90        .excludes
91        .iter()
92        .map(|exclude| Pattern::new(exclude).map_err(anyhow::Error::from))
93        .collect::<Vec<_>>();
94    let exclude_programs = exclude_programs
95        .into_iter()
96        .collect::<anyhow::Result<Vec<_>>>()?;
97
98    Ok(Box::new(globber::glob_with_options(
99        relative_to.as_ref().to_path_buf(),
100        Arc::new(include_program),
101        globber::TraversalOptions {
102            max_depth: options.max_depth,
103            follow_symlinks: options.follow_symlinks,
104        },
105        exclude_programs,
106        globber::InterruptFlag(options.interrupt.clone()),
107        fast_path,
108    )))
109}
110
111fn detect_recursive_suffix_fast_path(
112    pattern: &parser::Pattern,
113) -> Option<globber::RecursiveFastPath> {
114    use parser::AstNode;
115
116    let nodes = pattern.nodes.as_slice();
117    if nodes.len() < 3 {
118        return None;
119    }
120
121    let recurse_index = nodes
122        .windows(2)
123        .position(|window| matches!(window, [AstNode::Recurse, AstNode::Separator]))?;
124
125    let tail = &nodes[(recurse_index + 2)..];
126    if tail.is_empty() {
127        return None;
128    }
129
130    let static_prefix_only = nodes[..recurse_index].iter().all(|node| {
131        matches!(
132            node,
133            AstNode::Prefix(_)
134                | AstNode::RootDir
135                | AstNode::CurDir
136                | AstNode::ParentDir
137                | AstNode::LiteralString(_)
138                | AstNode::Separator
139        )
140    });
141
142    if !static_prefix_only || tail.iter().any(|node| matches!(node, AstNode::Separator)) {
143        return None;
144    }
145
146    if let [AstNode::Wildcard] = tail {
147        return Some(globber::RecursiveFastPath::Suffix(Box::<[u8]>::default()));
148    }
149
150    if let [AstNode::Wildcard, AstNode::LiteralString(bytes)] = tail {
151        return Some(globber::RecursiveFastPath::Suffix(
152            bytes.clone().into_boxed_slice(),
153        ));
154    }
155
156    let tokens = tail
157        .iter()
158        .map(|node| match node {
159            AstNode::LiteralString(bytes) => Some(globber::BasenameToken::Literal(
160                bytes.clone().into_boxed_slice(),
161            )),
162            AstNode::Wildcard => Some(globber::BasenameToken::Wildcard),
163            AstNode::AnyCharacter => Some(globber::BasenameToken::AnyCharacter),
164            _ => None,
165        })
166        .collect::<Option<Vec<_>>>()?;
167
168    Some(globber::RecursiveFastPath::BasenamePattern(
169        tokens.into_boxed_slice(),
170    ))
171}
172
173/// Return a formatted AST dump for a glob pattern.
174pub fn debug_parse(pattern: impl AsRef<str>) -> String {
175    let parsed = parser::parse(pattern.as_ref());
176    format!("{parsed:#?}")
177}
178
179/// Return a formatted compiled-program dump for a glob pattern.
180pub fn debug_compile(pattern: impl AsRef<str>) -> anyhow::Result<String> {
181    let parsed = parser::parse(pattern.as_ref());
182    let compiled = compiler::compile(&parsed)?;
183    Ok(format!("{compiled:#?}"))
184}
185
186/// Return whether `path` matches `pattern` as a complete dc-glob match.
187pub fn debug_matches(pattern: impl AsRef<str>, path: impl AsRef<Path>) -> anyhow::Result<bool> {
188    let parsed = parser::parse(pattern.as_ref());
189    let compiled = compiler::compile(&parsed)?;
190    Ok(matcher::path_matches(path.as_ref(), &compiled).valid_as_complete_match)
191}
192
193/// A compiled dc-glob pattern for repeated path-matching without filesystem traversal.
194///
195/// Unlike the free-function `debug_matches`, this compiles the pattern once and can be
196/// reused efficiently across many paths.
197#[derive(Clone)]
198pub struct DcPattern {
199    pattern: String,
200    compiled: std::sync::Arc<compiler::Program>,
201}
202
203impl std::fmt::Debug for DcPattern {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        f.debug_struct("DcPattern")
206            .field("pattern", &self.pattern)
207            .finish()
208    }
209}
210
211impl DcPattern {
212    /// Compile a glob pattern. Returns an error if the pattern is invalid.
213    pub fn new(pattern: &str) -> anyhow::Result<Self> {
214        let parsed = parser::parse(pattern);
215        let compiled = compiler::compile(&parsed)?;
216        Ok(DcPattern {
217            pattern: pattern.to_owned(),
218            compiled: std::sync::Arc::new(compiled),
219        })
220    }
221
222    /// Compile a glob pattern with case-insensitive matching (ASCII only).
223    pub fn with_ignore_case(pattern: &str) -> anyhow::Result<Self> {
224        let parsed = parser::parse(pattern);
225        let compiled = compiler::compile_with_options(&parsed, true)?;
226        Ok(DcPattern {
227            pattern: pattern.to_owned(),
228            compiled: std::sync::Arc::new(compiled),
229        })
230    }
231
232    /// Return `true` if the given path matches this pattern.
233    pub fn matches_path(&self, path: &Path) -> bool {
234        matcher::path_matches(path, &self.compiled).valid_as_complete_match
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::compiler::{Instruction, Program};
241    use super::matcher::path_matches;
242    use super::parser::{AstNode, CharacterClass};
243    use super::*;
244    use std::fs;
245    use std::path::Path;
246    use std::sync::atomic::{AtomicU64, Ordering};
247    use std::time::{SystemTime, UNIX_EPOCH};
248
249    static NEXT_ID: AtomicU64 = AtomicU64::new(0);
250
251    fn expected_path(parts: &[&str]) -> String {
252        parts.join(std::path::MAIN_SEPARATOR_STR)
253    }
254
255    fn unique_test_dir(prefix: &str) -> PathBuf {
256        let ts = SystemTime::now()
257            .duration_since(UNIX_EPOCH)
258            .map(|d| d.as_nanos())
259            .unwrap_or(0);
260        std::env::temp_dir().join(format!(
261            "nu_dc_glob_{prefix}_{}_{}",
262            std::process::id(),
263            ts + u128::from(NEXT_ID.fetch_add(1, Ordering::Relaxed))
264        ))
265    }
266
267    fn write_file(path: &Path) {
268        fs::create_dir_all(path.parent().expect("file path must have parent"))
269            .expect("failed to create parent directory");
270        fs::write(path, b"x").expect("failed to write test file");
271    }
272
273    fn collect_ok_paths(
274        iter: impl Iterator<Item = anyhow::Result<PathBuf>>,
275    ) -> anyhow::Result<Vec<String>> {
276        let mut out = Vec::new();
277        for item in iter {
278            let path_str = item?.to_string_lossy().into_owned();
279            // Normalize path separators to native platform style
280            #[cfg(windows)]
281            let normalized = path_str.replace('/', "\\");
282            #[cfg(not(windows))]
283            let normalized = path_str.replace('\\', "/");
284            out.push(normalized);
285        }
286        out.sort();
287        Ok(out)
288    }
289
290    #[test]
291    fn glob_with_streams_and_matches_simple_pattern() {
292        let root = unique_test_dir("basic");
293        fs::create_dir_all(&root).expect("failed to create root test directory");
294        write_file(&root.join("five.txt"));
295        write_file(&root.join("six.md"));
296
297        let result = glob_with(root.as_path(), "*.txt", &GlobWalkOptions::default())
298            .expect("glob_with should succeed");
299        let paths = collect_ok_paths(result).expect("failed to collect streamed paths");
300
301        assert_eq!(paths, vec!["five.txt"]);
302
303        let _ = fs::remove_dir_all(&root);
304    }
305
306    #[test]
307    fn glob_with_respects_depth_limit() {
308        let root = unique_test_dir("depth");
309        fs::create_dir_all(&root).expect("failed to create root test directory");
310        write_file(&root.join("a.txt"));
311        write_file(&root.join("nested/inner.txt"));
312
313        let options = GlobWalkOptions {
314            max_depth: Some(1),
315            ..Default::default()
316        };
317        let result =
318            glob_with(root.as_path(), "**/*.txt", &options).expect("glob_with should succeed");
319        let paths = collect_ok_paths(result).expect("failed to collect streamed paths");
320
321        assert_eq!(paths, vec!["a.txt"]);
322
323        let _ = fs::remove_dir_all(&root);
324    }
325
326    #[test]
327    fn glob_with_respects_excludes_and_prunes_nested_dirs() {
328        let root = unique_test_dir("exclude_prune");
329        fs::create_dir_all(&root).expect("failed to create root test directory");
330        write_file(&root.join("src/keep/main.rs"));
331        write_file(&root.join("src/target/skip.rs"));
332        write_file(&root.join("src/.git/config"));
333        write_file(&root.join("src/.git/hooks/pre-commit"));
334        write_file(&root.join("src/node_modules/pkg/index.js"));
335
336        let options = GlobWalkOptions {
337            excludes: vec![
338                "**/target/**".to_string(),
339                "**/.git/**".to_string(),
340                "**/node_modules/**".to_string(),
341            ],
342            ..Default::default()
343        };
344
345        let result = glob_with(root.as_path(), "**/*", &options).expect("glob_with should succeed");
346        let paths = collect_ok_paths(result).expect("failed to collect streamed paths");
347
348        assert!(paths.contains(&expected_path(&["src", "keep", "main.rs"])));
349        assert!(
350            !paths
351                .iter()
352                .any(|p| p.contains(&format!("target{}skip.rs", std::path::MAIN_SEPARATOR)))
353        );
354        assert!(
355            !paths
356                .iter()
357                .any(|p| p.contains(&format!(".git{}config", std::path::MAIN_SEPARATOR)))
358        );
359        assert!(!paths.iter().any(|p| p.contains(&format!(
360            ".git{}hooks{}pre-commit",
361            std::path::MAIN_SEPARATOR,
362            std::path::MAIN_SEPARATOR
363        ))));
364        assert!(!paths.iter().any(|p| p.contains(&format!(
365            "node_modules{}pkg{}index.js",
366            std::path::MAIN_SEPARATOR,
367            std::path::MAIN_SEPARATOR
368        ))));
369
370        let _ = fs::remove_dir_all(&root);
371    }
372
373    #[test]
374    fn glob_with_absolute_recursive_pattern_finds_nested_files() {
375        let root = unique_test_dir("absolute_recursive");
376        fs::create_dir_all(&root).expect("failed to create root test directory");
377        write_file(&root.join("src/lib.rs"));
378        write_file(&root.join("README.md"));
379
380        let pattern = format!("{}/**/*", root.to_string_lossy());
381        let result = glob_with(root.as_path(), &pattern, &GlobWalkOptions::default())
382            .expect("glob_with should succeed");
383        let paths = collect_ok_paths(result).expect("failed to collect streamed paths");
384
385        assert!(
386            paths.iter().any(|p| {
387                Path::new(p).is_absolute() && p.ends_with(&expected_path(&["src", "lib.rs"]))
388            }),
389            "absolute recursive pattern should include nested absolute files"
390        );
391
392        let _ = fs::remove_dir_all(&root);
393    }
394
395    #[test]
396    fn debug_helpers_behave_as_expected() {
397        let parse = debug_parse("**/*.txt");
398        assert!(parse.contains("Recurse"));
399
400        let compile = debug_compile("**/*.txt").expect("debug_compile should succeed");
401        assert!(compile.contains("Complete"));
402
403        assert!(debug_matches("*.txt", "file.txt").expect("debug_matches should succeed"));
404        assert!(!debug_matches("*.txt", "file.md").expect("debug_matches should succeed"));
405    }
406
407    #[test]
408    fn detect_fast_path_for_recursive_richer_basename_pattern() {
409        let parsed = parser::parse("crates/**/mod*.rs");
410        let fast_path = detect_recursive_suffix_fast_path(&parsed);
411
412        assert!(matches!(
413            fast_path,
414            Some(globber::RecursiveFastPath::BasenamePattern(_))
415        ));
416    }
417
418    #[test]
419    fn glob_with_matches_recursive_richer_basename_pattern() {
420        let root = unique_test_dir("richer_tail");
421        fs::create_dir_all(&root).expect("failed to create root test directory");
422        write_file(&root.join("crates/nu-glob/src/mod.rs"));
423        write_file(&root.join("crates/nu-glob/src/index.rs"));
424        write_file(&root.join("crates/nu-protocol/src/mod_helpers.rs"));
425
426        let result = glob_with(
427            root.as_path(),
428            "crates/**/mod*.rs",
429            &GlobWalkOptions::default(),
430        )
431        .expect("glob_with should succeed");
432        let paths = collect_ok_paths(result).expect("failed to collect streamed paths");
433
434        assert!(paths.contains(&expected_path(&["crates", "nu-glob", "src", "mod.rs"])));
435        assert!(paths.contains(&expected_path(&[
436            "crates",
437            "nu-protocol",
438            "src",
439            "mod_helpers.rs"
440        ])));
441        assert!(!paths.contains(&expected_path(&["crates", "nu-glob", "src", "index.rs"])));
442
443        let _ = fs::remove_dir_all(&root);
444    }
445
446    // ── parser tests ──────────────────────────────────────────────────────────
447
448    fn node_kinds(pattern: &str) -> Vec<std::mem::Discriminant<AstNode>> {
449        parser::parse(pattern)
450            .nodes
451            .iter()
452            .map(std::mem::discriminant)
453            .collect()
454    }
455
456    fn discriminant<T>(val: &T) -> std::mem::Discriminant<T> {
457        std::mem::discriminant(val)
458    }
459
460    #[test]
461    fn parser_literal_string() {
462        let p = parser::parse("hello");
463        assert_eq!(p.nodes.len(), 1);
464        match &p.nodes[0] {
465            AstNode::LiteralString(bytes) => assert_eq!(bytes, b"hello"),
466            other => panic!("expected LiteralString, got {other:?}"),
467        }
468    }
469
470    #[test]
471    fn parser_wildcard() {
472        let p = parser::parse("*");
473        assert!(p.nodes.iter().any(|n| matches!(n, AstNode::Wildcard)));
474    }
475
476    #[test]
477    fn parser_recurse() {
478        let p = parser::parse("**");
479        assert!(p.nodes.iter().any(|n| matches!(n, AstNode::Recurse)));
480        // ** must not produce a plain Wildcard
481        assert!(!p.nodes.iter().any(|n| matches!(n, AstNode::Wildcard)));
482    }
483
484    #[test]
485    fn parser_any_character() {
486        let p = parser::parse("?");
487        assert_eq!(p.nodes.len(), 1);
488        assert!(matches!(p.nodes[0], AstNode::AnyCharacter));
489    }
490
491    #[test]
492    fn parser_separator_between_components() {
493        let p = parser::parse("a/b");
494        assert!(p.nodes.iter().any(|n| matches!(n, AstNode::Separator)));
495        assert!(
496            p.nodes
497                .iter()
498                .any(|n| matches!(n, AstNode::LiteralString(_)))
499        );
500    }
501
502    #[test]
503    fn parser_cur_dir() {
504        let p = parser::parse("./foo");
505        assert!(p.nodes.iter().any(|n| matches!(n, AstNode::CurDir)));
506    }
507
508    #[test]
509    fn parser_parent_dir() {
510        let p = parser::parse("../foo");
511        assert!(p.nodes.iter().any(|n| matches!(n, AstNode::ParentDir)));
512    }
513
514    #[test]
515    fn parser_alternatives() {
516        let p = parser::parse("{a,b,c}");
517        assert_eq!(p.nodes.len(), 1);
518        match &p.nodes[0] {
519            AstNode::Alternatives { choices } => assert_eq!(choices.len(), 3),
520            other => panic!("expected Alternatives, got {other:?}"),
521        }
522    }
523
524    #[test]
525    fn parser_character_class_range() {
526        let p = parser::parse("[a-z]");
527        assert_eq!(p.nodes.len(), 1);
528        match &p.nodes[0] {
529            AstNode::Characters(classes) => {
530                assert_eq!(classes.len(), 1);
531                assert_eq!(classes[0], CharacterClass::Range('a', 'z'));
532            }
533            other => panic!("expected Characters, got {other:?}"),
534        }
535    }
536
537    #[test]
538    fn parser_character_class_single() {
539        let p = parser::parse("[abc]");
540        assert_eq!(p.nodes.len(), 1);
541        match &p.nodes[0] {
542            AstNode::Characters(classes) => {
543                assert_eq!(classes.len(), 3);
544                assert!(classes.contains(&CharacterClass::Single('a')));
545                assert!(classes.contains(&CharacterClass::Single('b')));
546                assert!(classes.contains(&CharacterClass::Single('c')));
547            }
548            other => panic!("expected Characters, got {other:?}"),
549        }
550    }
551
552    #[test]
553    fn parser_repeat_exact() {
554        let p = parser::parse("<*:3>");
555        assert_eq!(p.nodes.len(), 1);
556        match &p.nodes[0] {
557            AstNode::Repeat { min, max, .. } => {
558                assert_eq!(*min, 3);
559                assert_eq!(*max, 3);
560            }
561            other => panic!("expected Repeat, got {other:?}"),
562        }
563    }
564
565    #[test]
566    fn parser_repeat_range() {
567        let p = parser::parse("<*:1,4>");
568        assert_eq!(p.nodes.len(), 1);
569        match &p.nodes[0] {
570            AstNode::Repeat { min, max, .. } => {
571                assert_eq!(*min, 1);
572                assert_eq!(*max, 4);
573            }
574            other => panic!("expected Repeat, got {other:?}"),
575        }
576    }
577
578    #[test]
579    fn parser_glob_pattern_recurse_then_literal() {
580        let p = parser::parse("**/*.rs");
581        // Should contain Recurse, Separator, Wildcard, LiteralString(".rs")
582        let kinds = node_kinds("**/*.rs");
583        assert!(
584            kinds.contains(&discriminant(&AstNode::Recurse)),
585            "must have Recurse"
586        );
587        assert!(
588            kinds.contains(&discriminant(&AstNode::Separator)),
589            "must have Separator"
590        );
591        assert!(
592            kinds.contains(&discriminant(&AstNode::Wildcard)),
593            "must have Wildcard"
594        );
595        let _ = p; // used above
596    }
597
598    // ── compiler tests ────────────────────────────────────────────────────────
599
600    fn compile_pattern(pattern: &str) -> Program {
601        let parsed = parser::parse(pattern);
602        compiler::compile(&parsed).expect("compile should not fail for valid pattern")
603    }
604
605    fn last_instruction(prog: &Program) -> &Instruction {
606        prog.instructions
607            .last()
608            .expect("program must have at least one instruction")
609    }
610
611    #[test]
612    fn compiler_program_ends_with_complete() {
613        let prog = compile_pattern("*.txt");
614        assert_eq!(last_instruction(&prog), &Instruction::Complete);
615    }
616
617    #[test]
618    fn compiler_literal_produces_literal_string_instruction() {
619        let prog = compile_pattern("hello");
620        assert!(
621            prog.instructions
622                .iter()
623                .any(|i| matches!(i, Instruction::LiteralString(b) if &**b == b"hello")),
624            "expected LiteralString(hello) in {:?}",
625            prog.instructions
626        );
627    }
628
629    #[test]
630    fn compiler_wildcard_produces_alternative_jump_gadget() {
631        let prog = compile_pattern("*");
632        // Wildcard gadget must contain Alternative and AnyCharacter instructions
633        assert!(
634            prog.instructions
635                .iter()
636                .any(|i| matches!(i, Instruction::Alternative(_))),
637            "wildcard must produce Alternative instruction"
638        );
639        assert!(
640            prog.instructions
641                .iter()
642                .any(|i| matches!(i, Instruction::AnyCharacter)),
643            "wildcard must produce AnyCharacter instruction"
644        );
645    }
646
647    #[test]
648    fn compiler_recurse_produces_anystring_separator_gadget() {
649        let prog = compile_pattern("**");
650        assert!(
651            prog.instructions
652                .iter()
653                .any(|i| matches!(i, Instruction::AnyString)),
654            "recurse must produce AnyString instruction"
655        );
656        assert!(
657            prog.instructions
658                .iter()
659                .any(|i| matches!(i, Instruction::Separator)),
660            "recurse must produce Separator instruction"
661        );
662        assert!(
663            prog.trailing_recursive,
664            "bare ** must set trailing_recursive"
665        );
666    }
667
668    #[test]
669    fn compiler_star_star_slash_star_is_not_trailing_recursive() {
670        let prog = compile_pattern("**/*");
671        assert!(
672            !prog.trailing_recursive,
673            "**/* must not be treated as directory-only trailing **"
674        );
675    }
676
677    #[test]
678    fn compiler_prefixed_trailing_recursive_sets_flag() {
679        let prog = compile_pattern("foo/**");
680        assert!(
681            prog.trailing_recursive,
682            "foo/** must set trailing_recursive"
683        );
684    }
685
686    #[test]
687    fn compiler_multi_component_trailing_recursive_keeps_intermediate_separators() {
688        // Regression: separator elision must only drop the `/` immediately before
689        // pure terminal `**`, not every `/` in `a/b/**` (broke `ls **` via absolute
690        // `{cwd}/**` rewriting in glob_from).
691        let prog = compile_pattern("a/b/**");
692        assert!(
693            prog.trailing_recursive,
694            "a/b/** must set trailing_recursive"
695        );
696
697        let mut saw_a = false;
698        let mut saw_sep_after_a = false;
699        let mut saw_b = false;
700        let mut saw_boundary = false;
701        for inst in &prog.instructions {
702            match inst {
703                Instruction::LiteralString(bytes) if &**bytes == b"a" => {
704                    saw_a = true;
705                }
706                Instruction::Separator if saw_a && !saw_b => {
707                    saw_sep_after_a = true;
708                }
709                Instruction::LiteralString(bytes) if &**bytes == b"b" => {
710                    assert!(
711                        saw_sep_after_a,
712                        "a/b/** must keep Separator between a and b, got {prog}"
713                    );
714                    saw_b = true;
715                }
716                Instruction::ComponentBoundary if saw_b => {
717                    saw_boundary = true;
718                }
719                _ => {}
720            }
721        }
722        assert!(saw_a && saw_b && saw_boundary, "unexpected program: {prog}");
723    }
724
725    #[test]
726    fn compiler_nested_terminal_recurse_in_alternatives() {
727        // `{**}` should compile a terminal recurse gadget and be dir-only.
728        let prog = compile_pattern("{**}");
729        assert!(
730            prog.trailing_recursive,
731            "{{**}} must set trailing_recursive"
732        );
733        // Mixed alternatives: do not force directory-only for non-** branches.
734        let mixed = compile_pattern("{**,README.md}");
735        assert!(
736            !mixed.trailing_recursive,
737            "mixed {{**,file}} must not force directory-only expansion"
738        );
739        // Prefixed alternative of only trailing **.
740        let nested = compile_pattern("foo/{**}");
741        assert!(
742            nested.trailing_recursive,
743            "foo/{{**}} must set trailing_recursive"
744        );
745    }
746
747    #[test]
748    fn compiler_any_character_produces_any_character_instruction() {
749        let prog = compile_pattern("?");
750        assert!(
751            prog.instructions
752                .iter()
753                .any(|i| matches!(i, Instruction::AnyCharacter)),
754            "? must produce AnyCharacter instruction"
755        );
756    }
757
758    #[test]
759    fn compiler_alternatives_produce_alternative_instruction() {
760        let prog = compile_pattern("{foo,bar}");
761        assert!(
762            prog.instructions
763                .iter()
764                .any(|i| matches!(i, Instruction::Alternative(_))),
765            "{{foo,bar}} must produce Alternative instruction"
766        );
767    }
768
769    #[test]
770    fn compiler_absolute_path_sets_absolute_prefix_on_unix() {
771        // On Unix, /foo/bar has RootDir + literal components
772        #[cfg(unix)]
773        {
774            let prog = compile_pattern("/foo/bar");
775            assert!(
776                prog.absolute_prefix.is_some(),
777                "absolute path should set absolute_prefix"
778            );
779        }
780    }
781
782    #[test]
783    fn compiler_relative_path_leaves_absolute_prefix_empty() {
784        let prog = compile_pattern("foo/bar");
785        assert!(
786            prog.absolute_prefix.is_none(),
787            "relative path must not set absolute_prefix"
788        );
789    }
790
791    #[test]
792    fn compiler_repeat_produces_increment_and_branch_instructions() {
793        let prog = compile_pattern("<*:2,4>");
794        assert!(
795            prog.instructions
796                .iter()
797                .any(|i| matches!(i, Instruction::Increment(_))),
798            "repeat must produce Increment instruction"
799        );
800        assert!(
801            prog.instructions
802                .iter()
803                .any(|i| matches!(i, Instruction::BranchIfLessThan(..))),
804            "repeat must produce BranchIfLessThan instruction"
805        );
806    }
807
808    // ── matcher tests ─────────────────────────────────────────────────────────
809
810    fn compile_for_match(pattern: &str) -> Program {
811        let parsed = parser::parse(pattern);
812        compiler::compile(&parsed).expect("compile should succeed")
813    }
814
815    fn matches_complete(pattern: &str, path: &str) -> bool {
816        let prog = compile_for_match(pattern);
817        path_matches(Path::new(path), &prog).valid_as_complete_match
818    }
819
820    fn matches_prefix(pattern: &str, path: &str) -> bool {
821        let prog = compile_for_match(pattern);
822        path_matches(Path::new(path), &prog).valid_as_prefix
823    }
824
825    #[test]
826    fn matcher_literal_exact_match() {
827        assert!(matches_complete("hello", "hello"));
828        assert!(!matches_complete("hello", "world"));
829    }
830
831    #[test]
832    fn matcher_wildcard_matches_any_string_without_separator() {
833        assert!(matches_complete("*.txt", "file.txt"));
834        assert!(matches_complete("*.txt", "my_file.txt"));
835        assert!(!matches_complete("*.txt", "file.md"));
836        // wildcard does not cross separator boundaries
837        assert!(!matches_complete("*.txt", "dir/file.txt"));
838    }
839
840    #[test]
841    fn matcher_any_character_matches_single_char() {
842        assert!(matches_complete("f?o", "foo"));
843        assert!(matches_complete("f?o", "fXo"));
844        assert!(!matches_complete("o?f", "of"));
845        assert!(!matches_complete("f?o", "fXXo"));
846    }
847
848    #[test]
849    fn matcher_recurse_matches_across_separators() {
850        assert!(matches_complete("**/*.txt", "a/b/c/file.txt"));
851        assert!(matches_complete("**/*.txt", "file.txt"));
852        assert!(!matches_complete("**/*.txt", "a/b/c/file.md"));
853    }
854
855    #[test]
856    fn matcher_character_class_range() {
857        assert!(matches_complete("[a-z]", "a"));
858        assert!(matches_complete("[a-z]", "m"));
859        assert!(matches_complete("[a-z]", "z"));
860        assert!(!matches_complete("[a-z]", "A"));
861        assert!(!matches_complete("[a-z]", "1"));
862    }
863
864    #[test]
865    fn matcher_character_class_single_chars() {
866        assert!(matches_complete("[abc]", "a"));
867        assert!(matches_complete("[abc]", "b"));
868        assert!(matches_complete("[abc]", "c"));
869        assert!(!matches_complete("[abc]", "d"));
870    }
871
872    #[test]
873    fn matcher_alternatives() {
874        assert!(matches_complete("{foo,bar}", "foo"));
875        assert!(matches_complete("{foo,bar}", "bar"));
876        assert!(!matches_complete("{foo,bar}", "baz"));
877    }
878
879    #[test]
880    fn matcher_alternatives_in_path() {
881        assert!(matches_complete("src/{lib,main}.rs", "src/lib.rs"));
882        assert!(matches_complete("src/{lib,main}.rs", "src/main.rs"));
883        assert!(!matches_complete("src/{lib,main}.rs", "src/other.rs"));
884    }
885
886    #[test]
887    fn matcher_valid_as_prefix_with_short_path() {
888        // "a/b/c.txt" pattern – the path "a/b" is a valid prefix (could match with more input)
889        assert!(matches_prefix("a/b/c.txt", "a/b"));
890        // but a completely unrelated path is not a valid prefix
891        assert!(!matches_prefix("a/b/c.txt", "x/y"));
892    }
893
894    #[test]
895    fn matcher_recurse_double_star_matches_deep_paths() {
896        // **/*.rs matches files at any depth
897        assert!(matches_complete("**/*.rs", "src/lib.rs"));
898        assert!(matches_complete("**/*.rs", "a/b/c/deep.rs"));
899        assert!(!matches_complete("**/*.rs", "src/lib.txt"));
900        // ** as a prefix matches with a separator suffix
901        assert!(matches_prefix("**/foo", "a/b"));
902    }
903
904    #[test]
905    fn matcher_bare_double_star_matches_any_depth() {
906        // Bare ** matches the empty path (start dir) and nested components.
907        assert!(matches_complete("**", ""));
908        assert!(matches_complete("**", "1"));
909        assert!(matches_complete("**", "1/2"));
910        assert!(matches_complete("**", "1/2/3"));
911    }
912
913    #[test]
914    fn matcher_star_slash_star_requires_two_components() {
915        // Regression for #18600: empty `*` must not skip a path component.
916        assert!(!matches_complete("*/*", "1"));
917        assert!(matches_complete("*/*", "1/2"));
918        assert!(!matches_complete("*/*", "1/2/3"));
919    }
920
921    #[test]
922    fn matcher_prefixed_trailing_double_star_matches_directory_itself() {
923        // `foo/**` must complete-match `foo` (zero further components after Separator).
924        assert!(matches_complete("foo/**", "foo"));
925        assert!(matches_complete("foo/**", "foo/bar"));
926        assert!(matches_complete("foo/**", "foo/bar/baz"));
927        assert!(!matches_complete("foo/**", "bar"));
928        assert!(!matches_complete("foo/**", "foobar"));
929        // #18600 guard still holds after Separator-at-EOF advance.
930        assert!(!matches_complete("*/*", "1"));
931    }
932
933    #[test]
934    fn matcher_nested_terminal_double_star_in_alternatives() {
935        assert!(matches_complete("{**}", ""));
936        assert!(matches_complete("{**}", "a/b"));
937        assert!(matches_complete("foo/{**}", "foo"));
938        assert!(matches_complete("foo/{**}", "foo/bar"));
939        assert!(!matches_complete("foo/{**}", "bar"));
940    }
941
942    #[test]
943    fn matcher_nested_recursive_stars_enforce_min_depth() {
944        // **/* matches paths at any depth (rust-lang/glob also matches the empty
945        // string; filesystem expansion still omits the start directory).
946        assert!(matches_complete("**/*", "1"));
947        assert!(matches_complete("**/*", "1/2"));
948        assert!(matches_complete("**/*", "1/2/3"));
949
950        // **/*/* requires at least two components
951        assert!(!matches_complete("**/*/*", "1"));
952        assert!(matches_complete("**/*/*", "1/2"));
953        assert!(matches_complete("**/*/*", "1/2/3"));
954
955        // **/*/*/* requires at least three components
956        assert!(!matches_complete("**/*/*/*", "1"));
957        assert!(!matches_complete("**/*/*/*", "1/2"));
958        assert!(matches_complete("**/*/*/*", "1/2/3"));
959        assert!(matches_complete("**/*/*/*", "1/2/3/4"));
960    }
961
962    #[test]
963    fn matcher_double_star_slash_literal_matches_at_any_depth() {
964        assert!(matches_complete("**/foo", "foo"));
965        assert!(matches_complete("**/foo", "a/foo"));
966        assert!(matches_complete("**/foo", "a/b/foo"));
967        assert!(!matches_complete("**/foo", "foo/bar"));
968        assert!(!matches_complete("**/foo", "bar"));
969    }
970
971    #[test]
972    fn matcher_literal_multi_component_path() {
973        assert!(matches_complete("foo/bar/baz", "foo/bar/baz"));
974        assert!(!matches_complete("foo/bar/baz", "foo/bar"));
975        assert!(!matches_complete("foo/bar/baz", "foo/bar/baz/extra"));
976    }
977
978    #[test]
979    fn matcher_complete_match_requires_full_consumption() {
980        // Pattern "*.txt" does NOT match "file.txt.bak" as a complete match
981        assert!(!matches_complete("*.txt", "file.txt.bak"));
982    }
983
984    #[test]
985    fn matcher_repeat_exact_count() {
986        // <a:3> means repeat literal "a" exactly 3 times → matches "aaa" but not "aa"
987        assert!(matches_complete("<a:3>", "aaa"));
988        assert!(!matches_complete("<a:3>", "aa"));
989        assert!(!matches_complete("<a:3>", "aaaa"));
990    }
991
992    // ── traversal start dir tests ──────────────────────────────────────────────
993    //
994    // These test that traversal_start_dir correctly excludes filename-pattern
995    // literals from the starting directory (Bug #1 in the dc-glob regression report).
996
997    #[test]
998    fn glob_with_issue_18600_nested_depth_and_bare_double_star() {
999        // Fixture from https://github.com/nushell/nushell/issues/18600
1000        // mkdir 0/1/2/3 && cd 0
1001        let root = unique_test_dir("issue_18600");
1002        fs::create_dir_all(root.join("1/2/3")).expect("failed to create nested dirs");
1003        write_file(&root.join("1/2/3/file.txt"));
1004
1005        let options = GlobWalkOptions::default();
1006
1007        // bare ** → start dir + nested directories only (not files)
1008        let paths = collect_ok_paths(
1009            glob_with(root.as_path(), "**", &options).expect("glob ** should succeed"),
1010        )
1011        .expect("collect **");
1012        assert!(
1013            paths.iter().any(|p| p.is_empty()),
1014            "bare ** should include the start directory, got {paths:?}"
1015        );
1016        assert!(paths.contains(&expected_path(&["1"])));
1017        assert!(paths.contains(&expected_path(&["1", "2"])));
1018        assert!(paths.contains(&expected_path(&["1", "2", "3"])));
1019        assert!(
1020            !paths.iter().any(|p| p.ends_with("file.txt")),
1021            "bare ** must not list files, got {paths:?}"
1022        );
1023
1024        // **/* → all entries under start, not including start itself
1025        let paths = collect_ok_paths(
1026            glob_with(root.as_path(), "**/*", &options).expect("glob **/* should succeed"),
1027        )
1028        .expect("collect **/*");
1029        assert!(
1030            !paths.iter().any(|p| p.is_empty()),
1031            "**/* must not include the start directory, got {paths:?}"
1032        );
1033        assert!(paths.contains(&expected_path(&["1"])));
1034        assert!(paths.contains(&expected_path(&["1", "2"])));
1035        assert!(paths.contains(&expected_path(&["1", "2", "3"])));
1036        assert!(paths.contains(&expected_path(&["1", "2", "3", "file.txt"])));
1037
1038        // **/*/* → min depth 2
1039        let paths = collect_ok_paths(
1040            glob_with(root.as_path(), "**/*/*", &options).expect("glob **/*/* should succeed"),
1041        )
1042        .expect("collect **/*/*");
1043        assert!(!paths.contains(&expected_path(&["1"])));
1044        assert!(paths.contains(&expected_path(&["1", "2"])));
1045        assert!(paths.contains(&expected_path(&["1", "2", "3"])));
1046        assert!(paths.contains(&expected_path(&["1", "2", "3", "file.txt"])));
1047
1048        // **/*/*/* → min depth 3
1049        let paths = collect_ok_paths(
1050            glob_with(root.as_path(), "**/*/*/*", &options).expect("glob **/*/*/* should succeed"),
1051        )
1052        .expect("collect **/*/*/*");
1053        assert!(!paths.contains(&expected_path(&["1"])));
1054        assert!(!paths.contains(&expected_path(&["1", "2"])));
1055        assert!(paths.contains(&expected_path(&["1", "2", "3"])));
1056        assert!(paths.contains(&expected_path(&["1", "2", "3", "file.txt"])));
1057
1058        let _ = fs::remove_dir_all(&root);
1059    }
1060
1061    #[test]
1062    fn glob_with_prefixed_trailing_double_star_emits_prefix_dir() {
1063        // `foo/**` must include `foo` itself (not only descendants), directories only.
1064        let root = unique_test_dir("prefixed_trailing");
1065        fs::create_dir_all(root.join("foo/bar")).expect("failed to create nested dirs");
1066        write_file(&root.join("foo/bar/file.txt"));
1067        write_file(&root.join("foo/sibling.txt"));
1068
1069        let paths = collect_ok_paths(
1070            glob_with(root.as_path(), "foo/**", &GlobWalkOptions::default())
1071                .expect("glob foo/** should succeed"),
1072        )
1073        .expect("collect foo/**");
1074
1075        assert!(
1076            paths.contains(&expected_path(&["foo"])),
1077            "foo/** should include the prefix directory itself, got {paths:?}"
1078        );
1079        assert!(paths.contains(&expected_path(&["foo", "bar"])));
1080        assert!(
1081            !paths
1082                .iter()
1083                .any(|p| p.ends_with("file.txt") || p.ends_with("sibling.txt")),
1084            "foo/** must not list files, got {paths:?}"
1085        );
1086
1087        let _ = fs::remove_dir_all(&root);
1088    }
1089
1090    #[test]
1091    fn glob_with_multi_component_trailing_double_star() {
1092        // `a/b/**` must keep intermediate separators and emit a/b + nested dirs only.
1093        let root = unique_test_dir("multi_component_trailing");
1094        fs::create_dir_all(root.join("a/b/c")).expect("failed to create nested dirs");
1095        write_file(&root.join("a/b/c/file.txt"));
1096        write_file(&root.join("a/sibling.txt"));
1097
1098        let paths = collect_ok_paths(
1099            glob_with(root.as_path(), "a/b/**", &GlobWalkOptions::default())
1100                .expect("glob a/b/** should succeed"),
1101        )
1102        .expect("collect a/b/**");
1103
1104        assert!(
1105            paths.contains(&expected_path(&["a", "b"])),
1106            "a/b/** should include the prefix directory itself, got {paths:?}"
1107        );
1108        assert!(paths.contains(&expected_path(&["a", "b", "c"])));
1109        assert!(
1110            !paths
1111                .iter()
1112                .any(|p| p.ends_with("file.txt") || p.ends_with("sibling.txt")),
1113            "a/b/** must not list files, got {paths:?}"
1114        );
1115        assert!(
1116            !paths.contains(&expected_path(&["a"])),
1117            "a/b/** must not list the parent of the prefix, got {paths:?}"
1118        );
1119
1120        let _ = fs::remove_dir_all(&root);
1121    }
1122
1123    #[test]
1124    fn glob_with_absolute_trailing_double_star() {
1125        // Absolute `{root}/**` is the shape `glob_from` builds for `ls **`.
1126        let root = unique_test_dir("absolute_trailing");
1127        fs::create_dir_all(root.join("1/2")).expect("failed to create nested dirs");
1128        write_file(&root.join("1/2/file.txt"));
1129
1130        let pattern = format!("{}/**", root.to_string_lossy());
1131        let paths = collect_ok_paths(
1132            glob_with(root.as_path(), &pattern, &GlobWalkOptions::default())
1133                .expect("glob absolute /** should succeed"),
1134        )
1135        .expect("collect absolute /**");
1136
1137        let root_str = root.to_string_lossy().to_string();
1138        assert!(
1139            paths.iter().any(|p| {
1140                let p = p.trim_end_matches(['/', '\\']);
1141                p == root_str.trim_end_matches(['/', '\\'])
1142            }),
1143            "absolute /** should include the start directory, got {paths:?}"
1144        );
1145        assert!(
1146            paths
1147                .iter()
1148                .any(|p| Path::new(p).ends_with(expected_path(&["1"]))),
1149            "absolute /** should include nested dir 1, got {paths:?}"
1150        );
1151        assert!(
1152            paths
1153                .iter()
1154                .any(|p| Path::new(p).ends_with(expected_path(&["1", "2"]))),
1155            "absolute /** should include nested dir 1/2, got {paths:?}"
1156        );
1157        assert!(
1158            !paths.iter().any(|p| p.ends_with("file.txt")),
1159            "absolute /** must not list files, got {paths:?}"
1160        );
1161
1162        let _ = fs::remove_dir_all(&root);
1163    }
1164
1165    #[test]
1166    fn glob_with_trailing_double_star_respects_excludes_on_start() {
1167        // Start-dir emit for trailing `**` must consult exclude_patterns.
1168        let root = unique_test_dir("trailing_exclude");
1169        fs::create_dir_all(root.join("foo/bar")).expect("create foo/bar");
1170        fs::create_dir_all(root.join("other")).expect("create other");
1171        fs::create_dir_all(root.join("1")).expect("create 1");
1172
1173        // Prefixed `foo/**`: excluding `foo` must suppress the start-dir emit and descendants.
1174        let options = GlobWalkOptions {
1175            excludes: vec!["foo".to_string(), "foo/**".to_string()],
1176            ..Default::default()
1177        };
1178        let paths = collect_ok_paths(
1179            glob_with(root.as_path(), "foo/**", &options).expect("glob foo/** should succeed"),
1180        )
1181        .expect("collect foo/** with excludes");
1182
1183        assert!(
1184            !paths.iter().any(|p| {
1185                p == &expected_path(&["foo"])
1186                    || p.starts_with(&format!("foo{}", std::path::MAIN_SEPARATOR))
1187            }),
1188            "excluded foo/** must not emit foo or descendants, got {paths:?}"
1189        );
1190
1191        // Bare `**` with nested excludes still emits the start directory.
1192        let options = GlobWalkOptions {
1193            excludes: vec!["1".to_string(), "1/**".to_string()],
1194            ..Default::default()
1195        };
1196        let paths = collect_ok_paths(
1197            glob_with(root.as_path(), "**", &options).expect("glob ** should succeed"),
1198        )
1199        .expect("collect ** with excludes");
1200        assert!(
1201            paths.iter().any(|p| p.is_empty()),
1202            "bare ** should still emit start dir when only nested paths are excluded, got {paths:?}"
1203        );
1204        assert!(!paths.contains(&expected_path(&["1"])));
1205        assert!(paths.contains(&expected_path(&["other"])));
1206        assert!(paths.contains(&expected_path(&["foo"])));
1207
1208        let _ = fs::remove_dir_all(&root);
1209    }
1210
1211    #[test]
1212    fn glob_with_dir_prefix_literal_then_wildcard() {
1213        // Pattern `dir/nu*` — "dir" is a directory literal, "nu*" is a filename
1214        // pattern. The traversal must start from `root/dir`, NOT `root/dir/nu`.
1215        let root = unique_test_dir("literal_wildcard");
1216        fs::create_dir_all(&root).expect("failed to create root");
1217        write_file(&root.join("dir/nu_test1"));
1218        write_file(&root.join("dir/nu_test2"));
1219        write_file(&root.join("dir/other"));
1220
1221        let result = glob_with(root.as_path(), "dir/nu*", &GlobWalkOptions::default())
1222            .expect("glob_with should succeed");
1223        let paths = collect_ok_paths(result).expect("failed to collect paths");
1224
1225        assert_eq!(paths.len(), 2);
1226        assert!(paths.contains(&expected_path(&["dir", "nu_test1"])));
1227        assert!(paths.contains(&expected_path(&["dir", "nu_test2"])));
1228
1229        let _ = fs::remove_dir_all(&root);
1230    }
1231
1232    #[test]
1233    fn glob_with_dir_prefix_wildcard_then_literal() {
1234        // Pattern `dir/*nu*` — wildcard comes first in the filename pattern.
1235        // This must continue to work correctly (regression guard).
1236        let root = unique_test_dir("wildcard_literal");
1237        fs::create_dir_all(&root).expect("failed to create root");
1238        write_file(&root.join("dir/nu_test1"));
1239        write_file(&root.join("dir/nu_test2"));
1240        write_file(&root.join("dir/other"));
1241
1242        let result = glob_with(root.as_path(), "dir/*nu*", &GlobWalkOptions::default())
1243            .expect("glob_with should succeed");
1244        let paths = collect_ok_paths(result).expect("failed to collect paths");
1245
1246        assert_eq!(paths.len(), 2);
1247        assert!(paths.contains(&expected_path(&["dir", "nu_test1"])));
1248        assert!(paths.contains(&expected_path(&["dir", "nu_test2"])));
1249
1250        let _ = fs::remove_dir_all(&root);
1251    }
1252
1253    #[test]
1254    fn glob_with_nested_dir_prefix_literal_then_wildcard() {
1255        // Pattern `a/b/nu*` — multiple directory components before the filename
1256        // pattern. Traversal must start from `root/a/b`, not `root/a/b/nu`.
1257        let root = unique_test_dir("nested_literal_wildcard");
1258        fs::create_dir_all(&root).expect("failed to create root");
1259        write_file(&root.join("a/b/nu_test1"));
1260        write_file(&root.join("a/b/nu_test2"));
1261        write_file(&root.join("a/b/other"));
1262
1263        let result = glob_with(root.as_path(), "a/b/nu*", &GlobWalkOptions::default())
1264            .expect("glob_with should succeed");
1265        let paths = collect_ok_paths(result).expect("failed to collect paths");
1266
1267        assert_eq!(paths.len(), 2);
1268        assert!(paths.contains(&expected_path(&["a", "b", "nu_test1"])));
1269        assert!(paths.contains(&expected_path(&["a", "b", "nu_test2"])));
1270
1271        let _ = fs::remove_dir_all(&root);
1272    }
1273
1274    #[test]
1275    fn glob_with_dir_prefix_literal_then_any_char() {
1276        // Pattern `dir/nu?` — "?" is a single-character wildcard. The "nu"
1277        // literal before it must not be included in the traversal start dir.
1278        let root = unique_test_dir("literal_any_char");
1279        fs::create_dir_all(&root).expect("failed to create root");
1280        write_file(&root.join("dir/nu1"));
1281        write_file(&root.join("dir/nu2"));
1282        write_file(&root.join("dir/not"));
1283
1284        let result = glob_with(root.as_path(), "dir/nu?", &GlobWalkOptions::default())
1285            .expect("glob_with should succeed");
1286        let paths = collect_ok_paths(result).expect("failed to collect paths");
1287
1288        assert_eq!(paths.len(), 2);
1289        assert!(paths.contains(&expected_path(&["dir", "nu1"])));
1290        assert!(paths.contains(&expected_path(&["dir", "nu2"])));
1291
1292        let _ = fs::remove_dir_all(&root);
1293    }
1294
1295    #[test]
1296    fn glob_with_absolute_pattern_literal_then_wildcard() {
1297        // Absolute pattern like `<root>/dir/nu*` should emit absolute paths,
1298        // not paths relative to the current working directory.
1299        let root = unique_test_dir("absolute_literal_wildcard");
1300        fs::create_dir_all(&root).expect("failed to create root");
1301        write_file(&root.join("dir/nu_test1"));
1302        write_file(&root.join("dir/other"));
1303
1304        let pattern = format!("{}/dir/nu*", root.to_string_lossy());
1305        let result = glob_with(root.as_path(), &pattern, &GlobWalkOptions::default())
1306            .expect("glob_with should succeed");
1307        let paths = collect_ok_paths(result).expect("failed to collect paths");
1308
1309        assert_eq!(paths.len(), 1);
1310        assert!(Path::new(&paths[0]).is_absolute());
1311
1312        #[cfg(windows)]
1313        let expected = root
1314            .join("dir")
1315            .join("nu_test1")
1316            .to_string_lossy()
1317            .replace('/', "\\");
1318        #[cfg(not(windows))]
1319        let expected = root
1320            .join("dir")
1321            .join("nu_test1")
1322            .to_string_lossy()
1323            .into_owned();
1324
1325        assert_eq!(paths[0], expected);
1326
1327        let _ = fs::remove_dir_all(&root);
1328    }
1329}