Skip to main content

glob_parent/
lib.rs

1//! # glob-parent — the non-magic parent of a glob
2//!
3//! Given a glob, return the deepest parent directory that contains no glob magic —
4//! exactly what file watchers and build tools need to know which directory to watch.
5//! A faithful Rust port of the [`glob-parent`](https://www.npmjs.com/package/glob-parent)
6//! npm package (used by gulp, chokidar, and friends), bundling ports of `is-glob`
7//! and `is-extglob`. Zero dependencies and `#![no_std]`.
8//!
9//! ```
10//! use glob_parent::glob_parent;
11//!
12//! assert_eq!(glob_parent("path/to/*.js"), "path/to");
13//! assert_eq!(glob_parent("path/*/file.js"), "path");
14//! assert_eq!(glob_parent("path/**/*.js"), "path");
15//! assert_eq!(glob_parent("*.js"), ".");
16//! assert_eq!(glob_parent("/path/to/file.js"), "/path/to");
17//! ```
18
19#![no_std]
20#![doc(html_root_url = "https://docs.rs/glob-parent/0.1.0")]
21// Index arithmetic mirrors the reference's `-1`/`indexOf` logic; casts are on values
22// already bounded by the input length.
23#![allow(
24    clippy::cast_possible_wrap,
25    clippy::cast_sign_loss,
26    clippy::cast_possible_truncation
27)]
28
29extern crate alloc;
30
31use alloc::string::{String, ToString};
32use alloc::vec::Vec;
33
34// Compile-test the README's examples as part of `cargo test`.
35#[cfg(doctest)]
36#[doc = include_str!("../README.md")]
37struct ReadmeDoctests;
38
39/// Extract the non-magic parent path from a `glob`.
40///
41/// Equivalent to [`glob_parent_with_options(glob, true)`](glob_parent_with_options).
42///
43/// ```
44/// assert_eq!(glob_parent::glob_parent("foo/bar/*.js"), "foo/bar");
45/// ```
46#[must_use]
47pub fn glob_parent(glob: &str) -> String {
48    glob_parent_with_options(glob, true)
49}
50
51/// Extract the non-magic parent path from a `glob`.
52///
53/// When `flip_backslashes` is set, backslashes are converted to forward slashes on
54/// Windows targets (only when the input has no forward slash), matching the npm
55/// package. On other targets backslashes are always treated as escapes.
56#[must_use]
57pub fn glob_parent_with_options(glob: &str, flip_backslashes: bool) -> String {
58    let mut s = glob.to_string();
59
60    if flip_backslashes && cfg!(windows) && !s.contains('/') {
61        s = s.replace('\\', "/");
62    }
63
64    // A string ending in an enclosure that spans a path separator keeps that segment.
65    if is_enclosure(&s) {
66        s.push('/');
67    }
68
69    // Append a sentinel so the final segment is treated as a path part.
70    s.push('a');
71
72    // Drop trailing path parts while they are globby.
73    loop {
74        s = posix_dirname(&s);
75        if !is_globby(&s) {
76            break;
77        }
78    }
79
80    unescape_glob(&s)
81}
82
83/// Whether `s` looks like a glob (the strict check of the `is-glob` npm package).
84///
85/// ```
86/// assert!(glob_parent::is_glob("a/*.js"));
87/// assert!(!glob_parent::is_glob("a/b.js"));
88/// ```
89#[must_use]
90pub fn is_glob(s: &str) -> bool {
91    if s.is_empty() {
92        return false;
93    }
94    if is_extglob(s) {
95        return true;
96    }
97    // Operate on UTF-16 code units to match JavaScript's string indexing exactly.
98    let units: Vec<u16> = s.encode_utf16().collect();
99    is_glob_strict(&units)
100}
101
102/// Whether `s` contains an extglob pattern such as `@(…)`, `!(…)`, or `+(…)` (the
103/// `is-extglob` npm package).
104///
105/// ```
106/// assert!(glob_parent::is_extglob("a/@(b|c)"));
107/// assert!(!glob_parent::is_extglob("a/(b|c)"));
108/// ```
109#[must_use]
110pub fn is_extglob(s: &str) -> bool {
111    if s.is_empty() {
112        return false;
113    }
114    // Mirror the JS regex `/(\\).|([@?!+*]\(.*\))/g` over UTF-16 code units, where
115    // `.` matches any unit except a line terminator.
116    let units: Vec<u16> = s.encode_utf16().collect();
117    let len = units.len();
118    let mut start = 0;
119    while start < len {
120        let mut hit: Option<(usize, bool)> = None;
121        let mut p = start;
122        while p < len {
123            // an escape `\X`, where `X` is not a line terminator
124            if units[p] == cu('\\') && units.get(p + 1).is_some_and(|&n| !is_line_terminator(n)) {
125                hit = Some((p, false));
126                break;
127            }
128            // an extglob `X(…)` whose `)` is reachable without crossing a line terminator
129            if is_extglob_char(units[p])
130                && units.get(p + 1) == Some(&cu('('))
131                && close_paren_before_line_terminator(&units[p + 2..])
132            {
133                hit = Some((p, true));
134                break;
135            }
136            p += 1;
137        }
138        match hit {
139            None => return false,
140            Some((_, true)) => return true,
141            Some((p, false)) => start = p + 2,
142        }
143    }
144    false
145}
146
147/// `c as u16` for a BMP character (all glob metacharacters are ASCII).
148const fn cu(c: char) -> u16 {
149    c as u16
150}
151
152/// JavaScript regex line terminators (the four code points its `.` never matches).
153fn is_line_terminator(u: u16) -> bool {
154    matches!(u, 0x000A | 0x000D | 0x2028 | 0x2029)
155}
156
157fn is_extglob_char(u: u16) -> bool {
158    u == cu('@') || u == cu('?') || u == cu('!') || u == cu('+') || u == cu('*')
159}
160
161/// Whether a `)` appears before any line terminator (and before the end).
162fn close_paren_before_line_terminator(units: &[u16]) -> bool {
163    for &u in units {
164        if u == cu(')') {
165            return true;
166        }
167        if is_line_terminator(u) {
168            return false;
169        }
170    }
171    false
172}
173
174// ---------------------------------------------------------------------------
175// Helpers
176// ---------------------------------------------------------------------------
177
178/// `units[from..]`'s first index of `c`, or `-1` (like JavaScript's `indexOf`).
179fn find_from(units: &[u16], c: u16, from: usize) -> i64 {
180    if from > units.len() {
181        return -1;
182    }
183    units[from..]
184        .iter()
185        .position(|&x| x == c)
186        .map_or(-1, |p| (from + p) as i64)
187}
188
189/// The strict `is-glob` check, ported line-for-line over UTF-16 code units.
190#[allow(clippy::too_many_lines)]
191fn is_glob_strict(units: &[u16]) -> bool {
192    let len = units.len();
193    if units.first() == Some(&cu('!')) {
194        return true;
195    }
196    let mut index: usize = 0;
197    let mut pipe_index: i64 = -2;
198    let mut close_square: i64 = -2;
199    let mut close_curly: i64 = -2;
200    let mut close_paren: i64 = -2;
201    let mut back_slash: i64 = -2;
202
203    while index < len {
204        let c = units[index];
205        if c == cu('*') {
206            return true;
207        }
208        if units.get(index + 1) == Some(&cu('?'))
209            && (c == cu(']') || c == cu('.') || c == cu('+') || c == cu(')'))
210        {
211            return true;
212        }
213
214        if close_square != -1 && c == cu('[') && units.get(index + 1) != Some(&cu(']')) {
215            if close_square < index as i64 {
216                close_square = find_from(units, cu(']'), index);
217            }
218            if close_square > index as i64 {
219                if back_slash == -1 || back_slash > close_square {
220                    return true;
221                }
222                back_slash = find_from(units, cu('\\'), index);
223                if back_slash == -1 || back_slash > close_square {
224                    return true;
225                }
226            }
227        }
228
229        if close_curly != -1 && c == cu('{') && units.get(index + 1) != Some(&cu('}')) {
230            close_curly = find_from(units, cu('}'), index);
231            if close_curly > index as i64 {
232                back_slash = find_from(units, cu('\\'), index);
233                if back_slash == -1 || back_slash > close_curly {
234                    return true;
235                }
236            }
237        }
238
239        if close_paren != -1
240            && c == cu('(')
241            && units.get(index + 1) == Some(&cu('?'))
242            && matches!(units.get(index + 2).copied(), Some(x) if x == cu(':') || x == cu('!') || x == cu('='))
243            && units.get(index + 3) != Some(&cu(')'))
244        {
245            close_paren = find_from(units, cu(')'), index);
246            if close_paren > index as i64 {
247                back_slash = find_from(units, cu('\\'), index);
248                if back_slash == -1 || back_slash > close_paren {
249                    return true;
250                }
251            }
252        }
253
254        if pipe_index != -1 && c == cu('(') && units.get(index + 1) != Some(&cu('|')) {
255            if pipe_index < index as i64 {
256                pipe_index = find_from(units, cu('|'), index);
257            }
258            if pipe_index != -1 && units.get(pipe_index as usize + 1) != Some(&cu(')')) {
259                close_paren = find_from(units, cu(')'), pipe_index as usize);
260                if close_paren > pipe_index {
261                    back_slash = find_from(units, cu('\\'), pipe_index as usize);
262                    if back_slash == -1 || back_slash > close_paren {
263                        return true;
264                    }
265                }
266            }
267        }
268
269        if c == cu('\\') {
270            let open = units.get(index + 1).copied();
271            index += 2;
272            let close = if open == Some(cu('{')) {
273                Some(cu('}'))
274            } else if open == Some(cu('(')) {
275                Some(cu(')'))
276            } else if open == Some(cu('[')) {
277                Some(cu(']'))
278            } else {
279                None
280            };
281            if let Some(close) = close {
282                let n = find_from(units, close, index);
283                if n != -1 {
284                    index = n as usize + 1;
285                }
286            }
287            if units.get(index) == Some(&cu('!')) {
288                return true;
289            }
290        } else {
291            index += 1;
292        }
293    }
294    false
295}
296
297/// POSIX `path.dirname`.
298fn posix_dirname(s: &str) -> String {
299    let chars: Vec<char> = s.chars().collect();
300    let len = chars.len();
301    if len == 0 {
302        return ".".to_string();
303    }
304    let has_root = chars[0] == '/';
305    let mut end: i64 = -1;
306    let mut matched_slash = true;
307    let mut i = len as i64 - 1;
308    while i >= 1 {
309        if chars[i as usize] == '/' {
310            if !matched_slash {
311                end = i;
312                break;
313            }
314        } else {
315            matched_slash = false;
316        }
317        i -= 1;
318    }
319    if end == -1 {
320        return if has_root { "/" } else { "." }.to_string();
321    }
322    if has_root && end == 1 {
323        return "//".to_string();
324    }
325    chars[..end as usize].iter().collect()
326}
327
328/// A string ending in `}`/`]` whose enclosure spans a path separator.
329fn is_enclosure(s: &str) -> bool {
330    let chars: Vec<char> = s.chars().collect();
331    let n = chars.len();
332    if n == 0 {
333        return false;
334    }
335    let start = match chars[n - 1] {
336        '}' => '{',
337        ']' => '[',
338        _ => return false,
339    };
340    let Some(found) = chars.iter().position(|&c| c == start) else {
341        return false;
342    };
343    chars[found + 1..n - 1].contains(&'/')
344}
345
346/// Whether `s` still contains glob magic that should be stripped.
347fn is_globby(s: &str) -> bool {
348    let chars: Vec<char> = s.chars().collect();
349    // an unclosed `(…` running to the end
350    if open_paren_tail(&chars) {
351        return true;
352    }
353    if matches!(chars.first(), Some('{' | '[')) {
354        return true;
355    }
356    // an unescaped `{` or `[` after the first character
357    for i in 1..chars.len() {
358        if matches!(chars[i], '{' | '[') && chars[i - 1] != '\\' {
359            return true;
360        }
361    }
362    // `isGlob` (which also catches extglobs), matching the reference's `isGlobby`
363    is_glob(s)
364}
365
366/// Whether the rightmost parenthesis is an unclosed `(` with content after it
367/// (`/\([^()]+$/`).
368fn open_paren_tail(chars: &[char]) -> bool {
369    let mut last = None;
370    for (i, &c) in chars.iter().enumerate() {
371        if c == '(' || c == ')' {
372            last = Some((i, c));
373        }
374    }
375    matches!(last, Some((i, '(')) if i + 1 < chars.len())
376}
377
378/// Remove a backslash before any glob metacharacter (`/\\([!*?|[\](){}])/g`).
379fn unescape_glob(s: &str) -> String {
380    let chars: Vec<char> = s.chars().collect();
381    let mut out = String::with_capacity(s.len());
382    let mut i = 0;
383    while i < chars.len() {
384        if chars[i] == '\\'
385            && matches!(
386                chars.get(i + 1),
387                Some('!' | '*' | '?' | '|' | '[' | ']' | '(' | ')' | '{' | '}')
388            )
389        {
390            out.push(chars[i + 1]);
391            i += 2;
392        } else {
393            out.push(chars[i]);
394            i += 1;
395        }
396    }
397    out
398}