glob-parent 0.1.1

Extract the non-magic parent path from a glob: foo/bar/*.js -> foo/bar. A faithful port of the glob-parent npm package. Zero dependencies, no_std.
Documentation
//! # glob-parent — the non-magic parent of a glob
//!
//! Given a glob, return the deepest parent directory that contains no glob magic —
//! exactly what file watchers and build tools need to know which directory to watch.
//! A faithful Rust port of the [`glob-parent`](https://www.npmjs.com/package/glob-parent)
//! npm package (used by gulp, chokidar, and friends), bundling ports of `is-glob`
//! and `is-extglob`. Zero dependencies and `#![no_std]`.
//!
//! ```
//! use glob_parent::glob_parent;
//!
//! assert_eq!(glob_parent("path/to/*.js"), "path/to");
//! assert_eq!(glob_parent("path/*/file.js"), "path");
//! assert_eq!(glob_parent("path/**/*.js"), "path");
//! assert_eq!(glob_parent("*.js"), ".");
//! assert_eq!(glob_parent("/path/to/file.js"), "/path/to");
//! ```

#![no_std]
#![doc(html_root_url = "https://docs.rs/glob-parent/0.1.0")]
// Index arithmetic mirrors the reference's `-1`/`indexOf` logic; casts are on values
// already bounded by the input length.
#![allow(
    clippy::cast_possible_wrap,
    clippy::cast_sign_loss,
    clippy::cast_possible_truncation
)]

extern crate alloc;

use alloc::string::{String, ToString};
use alloc::vec::Vec;

// Compile-test the README's examples as part of `cargo test`.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;

/// Extract the non-magic parent path from a `glob`.
///
/// Equivalent to [`glob_parent_with_options(glob, true)`](glob_parent_with_options).
///
/// ```
/// assert_eq!(glob_parent::glob_parent("foo/bar/*.js"), "foo/bar");
/// ```
#[must_use]
pub fn glob_parent(glob: &str) -> String {
    glob_parent_with_options(glob, true)
}

/// Extract the non-magic parent path from a `glob`.
///
/// When `flip_backslashes` is set, backslashes are converted to forward slashes on
/// Windows targets (only when the input has no forward slash), matching the npm
/// package. On other targets backslashes are always treated as escapes.
#[must_use]
pub fn glob_parent_with_options(glob: &str, flip_backslashes: bool) -> String {
    let mut s = glob.to_string();

    if flip_backslashes && cfg!(windows) && !s.contains('/') {
        s = s.replace('\\', "/");
    }

    // A string ending in an enclosure that spans a path separator keeps that segment.
    if is_enclosure(&s) {
        s.push('/');
    }

    // Append a sentinel so the final segment is treated as a path part.
    s.push('a');

    // Drop trailing path parts while they are globby.
    loop {
        s = posix_dirname(&s);
        if !is_globby(&s) {
            break;
        }
    }

    unescape_glob(&s)
}

/// Whether `s` looks like a glob (the strict check of the `is-glob` npm package).
///
/// ```
/// assert!(glob_parent::is_glob("a/*.js"));
/// assert!(!glob_parent::is_glob("a/b.js"));
/// ```
#[must_use]
pub fn is_glob(s: &str) -> bool {
    if s.is_empty() {
        return false;
    }
    if is_extglob(s) {
        return true;
    }
    // Operate on UTF-16 code units to match JavaScript's string indexing exactly.
    let units: Vec<u16> = s.encode_utf16().collect();
    is_glob_strict(&units)
}

/// Whether `s` contains an extglob pattern such as `@(…)`, `!(…)`, or `+(…)` (the
/// `is-extglob` npm package).
///
/// ```
/// assert!(glob_parent::is_extglob("a/@(b|c)"));
/// assert!(!glob_parent::is_extglob("a/(b|c)"));
/// ```
#[must_use]
pub fn is_extglob(s: &str) -> bool {
    if s.is_empty() {
        return false;
    }
    // Mirror the JS regex `/(\\).|([@?!+*]\(.*\))/g` over UTF-16 code units, where
    // `.` matches any unit except a line terminator.
    let units: Vec<u16> = s.encode_utf16().collect();
    let len = units.len();
    let mut start = 0;
    while start < len {
        let mut hit: Option<(usize, bool)> = None;
        let mut p = start;
        while p < len {
            // an escape `\X`, where `X` is not a line terminator
            if units[p] == cu('\\') && units.get(p + 1).is_some_and(|&n| !is_line_terminator(n)) {
                hit = Some((p, false));
                break;
            }
            // an extglob `X(…)` whose `)` is reachable without crossing a line terminator
            if is_extglob_char(units[p])
                && units.get(p + 1) == Some(&cu('('))
                && close_paren_before_line_terminator(&units[p + 2..])
            {
                hit = Some((p, true));
                break;
            }
            p += 1;
        }
        match hit {
            None => return false,
            Some((_, true)) => return true,
            Some((p, false)) => start = p + 2,
        }
    }
    false
}

/// `c as u16` for a BMP character (all glob metacharacters are ASCII).
const fn cu(c: char) -> u16 {
    c as u16
}

/// JavaScript regex line terminators (the four code points its `.` never matches).
fn is_line_terminator(u: u16) -> bool {
    matches!(u, 0x000A | 0x000D | 0x2028 | 0x2029)
}

fn is_extglob_char(u: u16) -> bool {
    u == cu('@') || u == cu('?') || u == cu('!') || u == cu('+') || u == cu('*')
}

/// Whether a `)` appears before any line terminator (and before the end).
fn close_paren_before_line_terminator(units: &[u16]) -> bool {
    for &u in units {
        if u == cu(')') {
            return true;
        }
        if is_line_terminator(u) {
            return false;
        }
    }
    false
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// `units[from..]`'s first index of `c`, or `-1` (like JavaScript's `indexOf`).
fn find_from(units: &[u16], c: u16, from: usize) -> i64 {
    if from > units.len() {
        return -1;
    }
    units[from..]
        .iter()
        .position(|&x| x == c)
        .map_or(-1, |p| (from + p) as i64)
}

/// The strict `is-glob` check, ported line-for-line over UTF-16 code units.
#[allow(clippy::too_many_lines)]
fn is_glob_strict(units: &[u16]) -> bool {
    let len = units.len();
    if units.first() == Some(&cu('!')) {
        return true;
    }
    let mut index: usize = 0;
    let mut pipe_index: i64 = -2;
    let mut close_square: i64 = -2;
    let mut close_curly: i64 = -2;
    let mut close_paren: i64 = -2;
    let mut back_slash: i64 = -2;

    while index < len {
        let c = units[index];
        if c == cu('*') {
            return true;
        }
        if units.get(index + 1) == Some(&cu('?'))
            && (c == cu(']') || c == cu('.') || c == cu('+') || c == cu(')'))
        {
            return true;
        }

        if close_square != -1 && c == cu('[') && units.get(index + 1) != Some(&cu(']')) {
            if close_square < index as i64 {
                close_square = find_from(units, cu(']'), index);
            }
            if close_square > index as i64 {
                if back_slash == -1 || back_slash > close_square {
                    return true;
                }
                back_slash = find_from(units, cu('\\'), index);
                if back_slash == -1 || back_slash > close_square {
                    return true;
                }
            }
        }

        if close_curly != -1 && c == cu('{') && units.get(index + 1) != Some(&cu('}')) {
            close_curly = find_from(units, cu('}'), index);
            if close_curly > index as i64 {
                back_slash = find_from(units, cu('\\'), index);
                if back_slash == -1 || back_slash > close_curly {
                    return true;
                }
            }
        }

        if close_paren != -1
            && c == cu('(')
            && units.get(index + 1) == Some(&cu('?'))
            && matches!(units.get(index + 2).copied(), Some(x) if x == cu(':') || x == cu('!') || x == cu('='))
            && units.get(index + 3) != Some(&cu(')'))
        {
            close_paren = find_from(units, cu(')'), index);
            if close_paren > index as i64 {
                back_slash = find_from(units, cu('\\'), index);
                if back_slash == -1 || back_slash > close_paren {
                    return true;
                }
            }
        }

        if pipe_index != -1 && c == cu('(') && units.get(index + 1) != Some(&cu('|')) {
            if pipe_index < index as i64 {
                pipe_index = find_from(units, cu('|'), index);
            }
            if pipe_index != -1 && units.get(pipe_index as usize + 1) != Some(&cu(')')) {
                close_paren = find_from(units, cu(')'), pipe_index as usize);
                if close_paren > pipe_index {
                    back_slash = find_from(units, cu('\\'), pipe_index as usize);
                    if back_slash == -1 || back_slash > close_paren {
                        return true;
                    }
                }
            }
        }

        if c == cu('\\') {
            let open = units.get(index + 1).copied();
            index += 2;
            let close = if open == Some(cu('{')) {
                Some(cu('}'))
            } else if open == Some(cu('(')) {
                Some(cu(')'))
            } else if open == Some(cu('[')) {
                Some(cu(']'))
            } else {
                None
            };
            if let Some(close) = close {
                let n = find_from(units, close, index);
                if n != -1 {
                    index = n as usize + 1;
                }
            }
            if units.get(index) == Some(&cu('!')) {
                return true;
            }
        } else {
            index += 1;
        }
    }
    false
}

/// POSIX `path.dirname`.
fn posix_dirname(s: &str) -> String {
    let chars: Vec<char> = s.chars().collect();
    let len = chars.len();
    if len == 0 {
        return ".".to_string();
    }
    let has_root = chars[0] == '/';
    let mut end: i64 = -1;
    let mut matched_slash = true;
    let mut i = len as i64 - 1;
    while i >= 1 {
        if chars[i as usize] == '/' {
            if !matched_slash {
                end = i;
                break;
            }
        } else {
            matched_slash = false;
        }
        i -= 1;
    }
    if end == -1 {
        return if has_root { "/" } else { "." }.to_string();
    }
    if has_root && end == 1 {
        return "//".to_string();
    }
    chars[..end as usize].iter().collect()
}

/// A string ending in `}`/`]` whose enclosure spans a path separator.
fn is_enclosure(s: &str) -> bool {
    let chars: Vec<char> = s.chars().collect();
    let n = chars.len();
    if n == 0 {
        return false;
    }
    let start = match chars[n - 1] {
        '}' => '{',
        ']' => '[',
        _ => return false,
    };
    let Some(found) = chars.iter().position(|&c| c == start) else {
        return false;
    };
    chars[found + 1..n - 1].contains(&'/')
}

/// Whether `s` still contains glob magic that should be stripped.
fn is_globby(s: &str) -> bool {
    let chars: Vec<char> = s.chars().collect();
    // an unclosed `(…` running to the end
    if open_paren_tail(&chars) {
        return true;
    }
    if matches!(chars.first(), Some('{' | '[')) {
        return true;
    }
    // an unescaped `{` or `[` after the first character
    for i in 1..chars.len() {
        if matches!(chars[i], '{' | '[') && chars[i - 1] != '\\' {
            return true;
        }
    }
    // `isGlob` (which also catches extglobs), matching the reference's `isGlobby`
    is_glob(s)
}

/// Whether the rightmost parenthesis is an unclosed `(` with content after it
/// (`/\([^()]+$/`).
fn open_paren_tail(chars: &[char]) -> bool {
    let mut last = None;
    for (i, &c) in chars.iter().enumerate() {
        if c == '(' || c == ')' {
            last = Some((i, c));
        }
    }
    matches!(last, Some((i, '(')) if i + 1 < chars.len())
}

/// Remove a backslash before any glob metacharacter (`/\\([!*?|[\](){}])/g`).
fn unescape_glob(s: &str) -> String {
    let chars: Vec<char> = s.chars().collect();
    let mut out = String::with_capacity(s.len());
    let mut i = 0;
    while i < chars.len() {
        if chars[i] == '\\'
            && matches!(
                chars.get(i + 1),
                Some('!' | '*' | '?' | '|' | '[' | ']' | '(' | ')' | '{' | '}')
            )
        {
            out.push(chars[i + 1]);
            i += 2;
        } else {
            out.push(chars[i]);
            i += 1;
        }
    }
    out
}