Skip to main content

opys_core/
glob.rs

1//! Tiny glob → Regex converter. Frozen semantics — see TS `core/lib/glob.ts`.
2//!
3//! `*` non-separator chars, `**` any chars, `?` one non-sep, `{a,b}` flat alt.
4//! Separator is `/`; callers normalize Windows backslashes.
5
6use regex::Regex;
7
8const STAR: &str = "__OPYS_GLOBSTAR_";
9
10fn is_rx_meta(c: char) -> bool {
11    matches!(c, '.' | '+' | '^' | '$' | '(' | ')' | '|' | '[' | ']' | '\\')
12}
13
14fn is_alt_meta(c: char) -> bool {
15    matches!(
16        c,
17        '.' | '+' | '^' | '$' | '(' | ')' | '|' | '[' | ']' | '\\' | '*' | '?' | '{' | '}'
18    )
19}
20
21/// Compile a glob to an anchored `Regex`. Pattern source is `^…$`.
22pub fn glob_to_regex(glob: &str) -> Regex {
23    let mut s = glob.to_owned();
24    let null = '\0';
25    // Most-specific-first so the sentinels don't overlap.
26    s = replace_pattern(&s, r"/\*\*$", &format!("{null}{STAR}END{null}"));
27    s = replace_pattern(&s, r"/\*\*/", &format!("{null}{STAR}MID{null}"));
28    s = replace_pattern(&s, r"^\*\*/", &format!("{null}{STAR}START{null}"));
29    s = replace_pattern(&s, r"\*\*", &format!("{null}{STAR}BARE{null}"));
30
31    let mut out = String::with_capacity(s.len() * 2);
32    let chars: Vec<char> = s.chars().collect();
33    let mut i = 0;
34    while i < chars.len() {
35        let ch = chars[i];
36        if ch == '\0' {
37            let close = chars[i + 1..]
38                .iter()
39                .position(|&c| c == '\0')
40                .map(|p| p + i + 1)
41                .expect("matching sentinel");
42            let tag: String = chars[i + 1 + STAR.len()..close].iter().collect();
43            out.push_str(match tag.as_str() {
44                "END" => "(?:/.*)?",
45                "MID" => "(?:/.*)?/",
46                "START" => "(?:.*/)?",
47                _ => ".*", // BARE
48            });
49            i = close + 1;
50        } else if ch == '*' {
51            out.push_str("[^/]*");
52            i += 1;
53        } else if ch == '?' {
54            out.push_str("[^/]");
55            i += 1;
56        } else if ch == '{' {
57            if let Some(end) = chars[i..].iter().position(|&c| c == '}') {
58                let end = end + i;
59                let parts: String = chars[i + 1..end].iter().collect();
60                let alts: Vec<String> = parts
61                    .split(',')
62                    .map(|p| {
63                        let mut esc = String::with_capacity(p.len() * 2);
64                        for c in p.chars() {
65                            if is_alt_meta(c) {
66                                esc.push('\\');
67                            }
68                            esc.push(c);
69                        }
70                        esc
71                    })
72                    .collect();
73                out.push_str("(?:");
74                out.push_str(&alts.join("|"));
75                out.push(')');
76                i = end + 1;
77            } else {
78                out.push_str("\\{");
79                i += 1;
80            }
81        } else if is_rx_meta(ch) {
82            out.push('\\');
83            out.push(ch);
84            i += 1;
85        } else {
86            out.push(ch);
87            i += 1;
88        }
89    }
90    Regex::new(&format!("^{out}$")).expect("valid glob → regex")
91}
92
93fn replace_pattern(haystack: &str, pattern: &str, replacement: &str) -> String {
94    Regex::new(pattern).unwrap().replace_all(haystack, replacement).into_owned()
95}
96
97/// Longest non-glob prefix, truncated to the last `/`.
98pub fn glob_base(glob: &str) -> String {
99    let mut last_slash: Option<usize> = None;
100    for (i, ch) in glob.char_indices() {
101        if matches!(ch, '*' | '?' | '{' | '[') {
102            break;
103        }
104        if ch == '/' {
105            last_slash = Some(i);
106        }
107    }
108    match last_slash {
109        Some(i) => glob[..i].to_owned(),
110        None => String::new(),
111    }
112}