/// # Glob Pattern Matching
///
/// `@glob` provides a small, path-aware glob matcher for MoonBit.
/// The matcher currently understands:
///
/// - `?` for exactly one character
/// - `*` for any characters inside one path segment
/// - `**` for any characters across path separators
///
/// `is_valid_pattern` also checks balanced brackets such as `[abc]`, but
/// character classes are not interpreted by `glob` yet.
///
/// # Example
/// ```mbt check
/// test {
/// assert_eq(@glob.glob("*.txt", "notes.txt"), true)
/// assert_eq(@glob.glob("src/**/*.mbt", "src/lib/main.mbt"), true)
/// assert_eq(@glob.glob("src/*.mbt", "src/lib/main.mbt"), false)
/// }
/// ```
///|
/// Match one pattern character against one text character.
///
/// `?` matches any single character. All other characters must be equal.
///
/// # Example
/// ```mbt check
/// test {
/// assert_eq(@glob.match_char('a', 'a'), true)
/// assert_eq(@glob.match_char('?', 'x'), true)
/// assert_eq(@glob.match_char('a', 'b'), false)
/// }
/// ```
pub fn match_char(pattern : Char, char : Char) -> Bool {
pattern == '?' || pattern == char
}
///|
/// Return whether `c` is a supported path separator.
///
/// Both Unix (`/`) and Windows (`\`) separators are recognized.
///
/// # Example
/// ```mbt check
/// test {
/// assert_eq(@glob.is_path_separator('/'), true)
/// assert_eq(@glob.is_path_separator('\\'), true)
/// assert_eq(@glob.is_path_separator('a'), false)
/// }
/// ```
pub fn is_path_separator(c : Char) -> Bool {
c == '/' || c == '\\'
}
///|
/// Safely read one character from `s` by zero-based index.
///
/// This helper returns `None` instead of raising when the index is out of
/// bounds.
///
/// # Example
/// ```mbt check
/// test {
/// assert_true(@glob.get_char("moon", 0) == Some('m'))
/// assert_true(@glob.get_char("moon", 3) == Some('n'))
/// assert_true(@glob.get_char("moon", 4) == None)
/// }
/// ```
pub fn get_char(s : String, index : Int) -> Char? {
if index >= 0 && index < s.length() {
let code = s[index]
Some(code.unsafe_to_char())
} else {
None
}
}
///|
/// Match `pattern` against `text` using the package's recursive glob rules.
///
/// This is the low-level matcher that powers `glob`. Most callers should use
/// `glob` directly.
///
/// Matching rules:
///
/// - `**` can consume any suffix, including path separators
/// - `*` can consume any suffix until the next path separator
/// - `?` matches exactly one character
/// - every other character matches literally
///
/// # Example
/// ```mbt check
/// test {
/// assert_eq(@glob.match_here("**/*.mbt", "src/lib/main.mbt"), true)
/// assert_eq(@glob.match_here("*.mbt", "src/lib/main.mbt"), false)
/// }
/// ```
pub fn match_here(pattern : String, text : String) -> Bool raise {
let pattern_len = pattern.length()
let text_len = text.length()
if pattern_len == 0 {
return text_len == 0
}
// `**` can span across directory boundaries.
if pattern.has_prefix("**") {
for i = 0; i <= text.length(); i = i + 1 {
if match_here(pattern[2:].to_owned(), text[i:].to_owned()) {
return true
}
}
return false
}
// `*` stays within a single path segment.
if pattern.has_prefix("*") {
let remaining_pattern = pattern[1:].to_owned()
if remaining_pattern.length() == 0 {
for i = 0; i < text.length(); i = i + 1 {
match get_char(text, i) {
Some(c) if is_path_separator(c) => return false
_ => ()
}
}
return true
}
for i = 0; i <= text.length(); i = i + 1 {
if i < text.length() {
match get_char(text, i) {
Some(c) if is_path_separator(c) => break
_ => ()
}
}
if match_here(remaining_pattern, text[i:].to_owned()) {
return true
}
}
return false
}
// Match one character literally or through `?`.
if text_len > 0 && pattern_len > 0 {
let pattern_char = pattern[0].unsafe_to_char()
let text_char = text[0].unsafe_to_char()
if match_char(pattern_char, text_char) {
return match_here(pattern[1:].to_owned(), text[1:].to_owned())
}
}
false
}
///|
/// Match one glob `pattern` against one `text` path.
///
/// This is the main public entry point for the package.
///
/// Supported syntax:
///
/// - `*` matches zero or more non-separator characters
/// - `**` matches zero or more characters, including separators
/// - `?` matches exactly one character
///
/// Bracket expressions such as `[abc]` are validated by
/// `is_valid_pattern`, but they are not matched specially yet.
///
/// # Example
/// ```mbt check
/// test {
/// assert_eq(@glob.glob("file?.log", "file1.log"), true)
/// assert_eq(@glob.glob("**/*.md", "docs/api/readme.md"), true)
/// assert_eq(@glob.glob("*.jpg", "images/logo.png"), false)
/// }
/// ```
pub fn glob(pattern : String, text : String) -> Bool raise {
match_here(pattern, text)
}
///|
/// Return whether any path in `paths` matches `pattern`.
///
/// The function stops at the first match.
///
/// # Example
/// ```mbt check
/// test {
/// let files = ["README.md", "src/main.mbt", "test/main_test.mbt"]
/// assert_eq(@glob.glob_match_any("**/*.mbt", files), true)
/// assert_eq(@glob.glob_match_any("**/*.rs", files), false)
/// }
/// ```
pub fn glob_match_any(pattern : String, paths : Array[String]) -> Bool raise {
for path in paths {
if glob(pattern, path) {
return true
}
}
false
}
///|
/// Return a new array containing only the paths that match `pattern`.
///
/// The original array is left unchanged.
///
/// # Example
/// ```mbt check
/// test {
/// let files = ["README.md", "src/main.mbt", "src/lib/util.mbt"]
/// assert_true(@glob.glob_filter("src/*.mbt", files) == ["src/main.mbt"])
/// assert_true(@glob.glob_filter("src/**/*.mbt", files) == ["src/lib/util.mbt"])
/// }
/// ```
pub fn glob_filter(
pattern : String,
paths : Array[String],
) -> Array[String] raise {
let result = []
for path in paths {
if glob(pattern, path) {
result.push(path)
}
}
result
}
///|
/// Return whether `path` matches at least one glob in `patterns`.
///
/// This is useful when several include rules should be checked together.
///
/// # Example
/// ```mbt check
/// test {
/// let patterns = ["*.md", "**/*.mbt"]
/// assert_eq(@glob.match_any_pattern(patterns, "README.md"), true)
/// assert_eq(@glob.match_any_pattern(patterns, "src/main.mbt"), true)
/// assert_eq(@glob.match_any_pattern(patterns, "package.json"), false)
/// }
/// ```
pub fn match_any_pattern(patterns : Array[String], path : String) -> Bool raise {
for pattern in patterns {
if glob(pattern, path) {
return true
}
}
false
}
///|
/// Check whether `pattern` is structurally valid.
///
/// The current validation is intentionally small in scope: it only checks that
/// `[` and `]` are balanced. It does not validate or implement the full glob
/// grammar.
///
/// # Example
/// ```mbt check
/// test {
/// assert_eq(@glob.is_valid_pattern("*.mbt"), true)
/// assert_eq(@glob.is_valid_pattern("[abc]*.txt"), true)
/// assert_eq(@glob.is_valid_pattern("[abc*.txt"), false)
/// assert_eq(@glob.is_valid_pattern("abc]*.txt"), false)
/// }
/// ```
pub fn is_valid_pattern(pattern : String) -> Bool {
let mut bracket_count = 0
for i = 0; i < pattern.length(); i = i + 1 {
match get_char(pattern, i) {
Some('[') => bracket_count = bracket_count + 1
Some(']') => {
bracket_count = bracket_count - 1
if bracket_count < 0 {
return false
}
}
_ => ()
}
}
bracket_count == 0
}
///|
/// Prefix common glob metacharacters in `text` with backslashes.
///
/// This helper is useful when you want a readable escaped representation of a
/// pattern fragment or when interoperating with tools that treat backslashes as
/// escapes. The current matcher does not interpret backslashes specially.
///
/// # Example
/// ```mbt check
/// test {
/// assert_eq(@glob.escape_glob("file*.txt"), "file\\*.txt")
/// assert_eq(@glob.escape_glob("test?[1].log"), "test\\?\\[1\\].log")
/// assert_eq(@glob.escape_glob("plain.txt"), "plain.txt")
/// }
/// ```
pub fn escape_glob(text : String) -> String {
let mut result = ""
for i = 0; i < text.length(); i = i + 1 {
match text[i].unsafe_to_char() {
'*' => result = result + "\\*"
'?' => result = result + "\\?"
'[' => result = result + "\\["
']' => result = result + "\\]"
c => result = result + c.to_string()
}
}
result
}