pub fn glob_match(s: &str, pattern: &str) -> bool {
let s: Vec<char> = s.chars().collect();
let p: Vec<char> = pattern.chars().collect();
let mut s_idx: usize = 0;
let mut p_idx: usize = 0;
let mut backtrack: Option<(usize, usize)> = None;
while s_idx < s.len() {
let s_ch = s[s_idx];
if p_idx < p.len() {
match p[p_idx] {
'*' => {
backtrack = Some((p_idx, s_idx));
p_idx += 1;
continue;
}
'?' => {
s_idx += 1;
p_idx += 1;
continue;
}
'[' => {
match try_match_class(&p, p_idx, s_ch) {
Some((true, next_p)) => {
s_idx += 1;
p_idx = next_p;
continue;
}
Some((false, _)) => {
}
None => {
return false;
}
}
}
'\\' => {
let lit = if p_idx + 1 < p.len() {
p[p_idx + 1]
} else {
'\\'
};
if s_ch == lit {
s_idx += 1;
p_idx += if p_idx + 1 < p.len() { 2 } else { 1 };
continue;
}
}
lit => {
if s_ch == lit {
s_idx += 1;
p_idx += 1;
continue;
}
}
}
}
if let Some((star_p, star_s)) = backtrack {
p_idx = star_p + 1;
s_idx = star_s + 1;
backtrack = Some((star_p, star_s + 1));
continue;
}
return false;
}
while p_idx < p.len() && p[p_idx] == '*' {
p_idx += 1;
}
p_idx == p.len()
}
fn try_match_class(p: &[char], start: usize, ch: char) -> Option<(bool, usize)> {
debug_assert_eq!(p[start], '[');
let mut i = start + 1;
let negate = if i < p.len() && p[i] == '^' {
i += 1;
true
} else {
false
};
let mut matched = false;
while i < p.len() {
match p[i] {
']' => {
let result = if negate { !matched } else { matched };
return Some((result, i + 1));
}
'\\' if i + 1 < p.len() => {
if p[i + 1] == ch {
matched = true;
}
i += 2;
}
lit => {
if lit == ch {
matched = true;
}
i += 1;
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_against_empty_matches() {
assert!(glob_match("", ""));
}
#[test]
fn empty_string_against_literal_pattern_rejects() {
assert!(!glob_match("", "a"));
}
#[test]
fn non_empty_against_empty_pattern_rejects() {
assert!(!glob_match("a", ""));
}
#[test]
fn exact_literal_matches() {
assert!(glob_match("foo", "foo"));
}
#[test]
fn literal_mismatch_rejects() {
assert!(!glob_match("foo", "bar"));
}
#[test]
fn literal_substring_rejects() {
assert!(!glob_match("foobar", "foo"));
assert!(!glob_match("foobar", "bar"));
}
#[test]
fn star_matches_empty_string() {
assert!(glob_match("", "*"));
}
#[test]
fn star_matches_any_string() {
assert!(glob_match("anything", "*"));
assert!(glob_match("a", "*"));
assert!(glob_match("a much longer value", "*"));
}
#[test]
fn star_anchored_prefix_matches() {
assert!(glob_match("abc", "a*"));
assert!(glob_match("abcdef", "a*"));
assert!(glob_match("a", "a*"));
}
#[test]
fn star_anchored_suffix_matches() {
assert!(glob_match("abc", "*c"));
assert!(glob_match("xyzc", "*c"));
assert!(glob_match("c", "*c"));
}
#[test]
fn star_in_middle_matches() {
assert!(glob_match("abc", "a*c"));
assert!(glob_match("axyzc", "a*c"));
assert!(glob_match("ac", "a*c"));
}
#[test]
fn double_star_collapses_to_single() {
assert!(glob_match("anything", "**"));
assert!(glob_match("abc", "a**c"));
}
#[test]
fn star_surrounded_matches_substring_anchored() {
assert!(glob_match("hello world", "*world*"));
assert!(glob_match("world", "*world*"));
assert!(glob_match("hello world here", "*world*"));
}
#[test]
fn question_matches_single_code_point() {
assert!(glob_match("a", "?"));
assert!(glob_match("Z", "?"));
}
#[test]
fn question_rejects_empty_string() {
assert!(!glob_match("", "?"));
}
#[test]
fn question_rejects_multi_code_point_string() {
assert!(!glob_match("ab", "?"));
}
#[test]
fn question_in_middle_of_literal() {
assert!(glob_match("abc", "a?c"));
assert!(!glob_match("ac", "a?c"));
assert!(!glob_match("axyc", "a?c"));
}
#[test]
fn character_set_matches_member() {
assert!(glob_match("a", "[abc]"));
assert!(glob_match("b", "[abc]"));
assert!(glob_match("c", "[abc]"));
}
#[test]
fn character_set_rejects_non_member() {
assert!(!glob_match("d", "[abc]"));
assert!(!glob_match("A", "[abc]"));
}
#[test]
fn negated_set_rejects_member() {
assert!(!glob_match("a", "[^abc]"));
}
#[test]
fn negated_set_matches_non_member() {
assert!(glob_match("d", "[^abc]"));
assert!(glob_match("Z", "[^abc]"));
}
#[test]
fn malformed_unterminated_class_rejects() {
assert!(!glob_match("a", "["));
assert!(!glob_match("a", "[abc"));
assert!(!glob_match("anything", "*["));
}
#[test]
fn escaped_star_matches_literal_star() {
assert!(glob_match("*", "\\*"));
assert!(!glob_match("anything", "\\*"));
}
#[test]
fn escaped_question_matches_literal_question() {
assert!(glob_match("?", "\\?"));
assert!(!glob_match("x", "\\?"));
}
#[test]
fn escaped_backslash_matches_literal_backslash() {
assert!(glob_match("\\", "\\\\"));
}
#[test]
fn escape_inside_literal_run() {
assert!(glob_match("a*b", "a\\*b"));
assert!(!glob_match("axxb", "a\\*b"));
}
#[test]
fn trailing_backslash_is_literal() {
assert!(glob_match("\\", "\\"));
assert!(!glob_match("x", "\\"));
}
#[test]
fn question_matches_single_unicode_code_point() {
assert!(glob_match("α", "?"));
assert!(glob_match("€", "?"));
assert!(glob_match("🦀", "?"));
}
#[test]
fn unicode_literal_matches() {
assert!(glob_match("αβ", "αβ"));
assert!(!glob_match("αβ", "α"));
}
#[test]
fn unicode_question_in_literal() {
assert!(glob_match("αβ", "?β"));
assert!(glob_match("αβ", "α?"));
assert!(!glob_match("αβ", "?β "));
}
#[test]
fn unicode_star_spans_multiple_code_points() {
assert!(glob_match("αβγδ", "α*δ"));
assert!(glob_match("αβγδ", "*δ"));
assert!(glob_match("αβγδ", "α*"));
assert!(glob_match("αβ", "*"));
}
#[test]
fn unicode_character_set() {
assert!(glob_match("α", "[αβ]"));
assert!(glob_match("β", "[αβ]"));
assert!(!glob_match("γ", "[αβ]"));
}
#[test]
fn emoji_four_byte_utf8_handled_per_code_point() {
assert!(glob_match("🦀", "?"));
assert!(glob_match("🦀🦀", "??"));
assert!(glob_match("hello 🦀", "hello *"));
assert!(!glob_match("🦀", "????"));
}
#[test]
fn file_glob_matches_extension() {
assert!(glob_match("foo.txt", "*.txt"));
assert!(glob_match("readme.txt", "*.txt"));
assert!(!glob_match("foo.md", "*.txt"));
}
#[test]
fn path_glob_matches_directory_prefix() {
assert!(glob_match("/api/v1/users", "/api/*"));
assert!(glob_match("/api/v1", "/api/*"));
assert!(!glob_match("/other/v1", "/api/*"));
}
#[test]
fn url_glob_matches_https_prefix() {
assert!(glob_match("https://example.com/path", "https://*"));
assert!(!glob_match("http://example.com/path", "https://*"));
}
#[test]
fn linear_time_pathological_star_pattern() {
let s: String = "a".repeat(1000);
let p = "a*a*a*a*a*a*a*b";
let start = std::time::Instant::now();
let result = glob_match(&s, p);
let elapsed = start.elapsed();
assert!(!result, "no `b` in the haystack, so the match must fail");
assert!(
elapsed < std::time::Duration::from_millis(50),
"pathological glob took {elapsed:?} — expected linear scan, not exponential",
);
}
#[test]
fn linear_time_star_a_matches_quickly() {
let s: String = "a".repeat(1000);
let p = "*a";
let start = std::time::Instant::now();
assert!(glob_match(&s, p));
assert!(start.elapsed() < std::time::Duration::from_millis(50));
}
}