Current section
Files
Jump to
Current section
Files
src/globlin.gleam
import gleam/list
import gleam/regex
import gleam/string
/// Each path pattern holds a compiled regex and options.
pub opaque type Pattern {
Pattern(regex: regex.Regex, options: PatternOptions)
}
/// Options that can be provided to the `new_pattern_with` method.
///
/// - ignore_case: All matching is case insensitive (Default: False)
/// - match_dotfiles: Match dotfiles when using wildcards (Default: False)
pub type PatternOptions {
PatternOptions(ignore_case: Bool, match_dotfiles: Bool)
}
const empty_options = PatternOptions(ignore_case: False, match_dotfiles: False)
/// - AbsolutePatternFromDirError
/// - The pattern must NOT start with a slash if compiled with a directory prefix.
/// - InvalidGlobStarError
/// - The globstar ("**") must always appear between the end of a string and/or a slash.
/// - MissingClosingBracketError
/// - A char set or range was opened but never closed.
/// - RegexCompileError
/// - Error propogated from the `gleam/regex` library.
/// - This should NEVER happen so let me know if you ever see this.
pub type PatternError {
AbsolutePatternFromDirError
InvalidGlobStarError
MissingClosingBracketError
RegexCompileError(context: regex.CompileError)
}
/// Compile a `Pattern` from a pattern.
pub fn new_pattern(pattern: String) -> Result(Pattern, PatternError) {
new_pattern_with(pattern, from: "", with: empty_options)
}
/// Compile a `Pattern` from a directory, pattern and options.
/// The directory is escaped and prefixed before the pattern.
pub fn new_pattern_with(
pattern: String,
from directory: String,
with options: PatternOptions,
) -> Result(Pattern, PatternError) {
case convert_pattern(directory, pattern, options) {
Ok(pattern) -> {
let regex_options =
regex.Options(case_insensitive: options.ignore_case, multi_line: False)
case regex.compile(pattern, with: regex_options) {
Ok(regex) -> Ok(Pattern(regex:, options:))
Error(err) -> Error(RegexCompileError(err))
}
}
Error(err) -> Error(err)
}
}
/// Compare a path pattern against a path to see if they match.
pub fn match_pattern(pattern pattern: Pattern, path path: String) -> Bool {
regex.check(with: pattern.regex, content: path)
}
// Convert path pattern graphemes into a regex syntax string.
fn convert_pattern(
prefix: String,
pattern: String,
options: PatternOptions,
) -> Result(String, PatternError) {
let graphemes = string.to_graphemes(pattern)
let path_chars = parse_path_chars(prefix)
case graphemes, path_chars {
["/", ..], [_, ..] -> Error(AbsolutePatternFromDirError)
_, _ -> {
case do_convert_pattern(graphemes, path_chars, False, options) {
Ok(regex_pattern) -> Ok("^" <> regex_pattern <> "$")
Error(err) -> Error(err)
}
}
}
}
// Escape all characters in the directory prefix and add a slash
// before the following regex pattern if it's not already there.
//
// Note: The chars are returned in reverse order since we will
// be prepending to them later on in the `do_convert_pattern` method.
fn parse_path_chars(prefix: String) -> List(String) {
prefix
|> string.to_graphemes
|> list.map(escape_meta_char)
|> list.reverse
|> fn(path_chars) {
case path_chars {
[] | ["/", ..] -> path_chars
_ -> ["/", ..path_chars]
}
}
}
// Recursively convert path pattern graphemes into a regex syntax string.
fn do_convert_pattern(
graphemes: List(String),
path_chars: List(String),
in_range: Bool,
options: PatternOptions,
) -> Result(String, PatternError) {
case in_range {
True -> {
case graphemes {
// Error since we've reached the end with an open char set
[] -> Error(MissingClosingBracketError)
// Unescaped closing bracket means the char set is finished
["]", ..rest] ->
do_convert_pattern(rest, ["]", ..path_chars], False, options)
// Continue on until we find the closing bracket
["\\", second, ..rest] ->
[escape_meta_char(second), ..path_chars]
|> do_convert_pattern(rest, _, True, options)
[first, ..rest] ->
[escape_meta_char(first), ..path_chars]
|> do_convert_pattern(rest, _, True, options)
}
}
False -> {
case graphemes {
// Success
[] -> path_chars |> list.reverse |> string.concat |> Ok
// Match empty brackets literally
["[", "]", ..rest] ->
do_convert_pattern(rest, ["\\[\\]", ..path_chars], False, options)
// Convert "[!" negative char set to regex format
["[", "!", ..rest] ->
do_convert_pattern(rest, ["[^", ..path_chars], True, options)
// Convert "[^" positive char set to regex format ("^" has no special meaning here)
["[", "^", ..rest] ->
do_convert_pattern(rest, ["[\\^", ..path_chars], True, options)
// Convert "[" positive char set to regex format
["[", ..rest] ->
do_convert_pattern(rest, ["[", ..path_chars], True, options)
// Escape any path chars preceded by a "\" only if necessary
["\\", second, ..rest] ->
[escape_meta_char(second), ..path_chars]
|> do_convert_pattern(rest, _, False, options)
// Convert "?" which matches any char once to regex format
["?", ..rest] -> {
let wildcard = case ignore_dotfiles(path_chars, options) {
True -> "[^/.]"
False -> "[^/]"
}
do_convert_pattern(rest, [wildcard, ..path_chars], False, options)
}
// Convert "**" to regex format
["*", "*", ..rest] -> {
case path_chars, rest {
// Isolated "**" matches zero or more directories or files
//
// Example: "**"
[], [] -> {
let wildcard = case options.match_dotfiles {
True -> ".*"
False -> "([^.][^/]*(/[^.][^/]*)*)?"
}
let path_chars = [wildcard, ..path_chars]
do_convert_pattern(rest, path_chars, False, options)
}
// Postfix "**" matches zero or more directories or files
//
// Example: "filler/**"
["/", ..path_chars], [] -> {
let wildcard = case options.match_dotfiles {
True -> "(/.*)?"
False -> "(/[^.][^/]*)*"
}
let path_chars = [wildcard, ..path_chars]
do_convert_pattern(rest, path_chars, False, options)
}
// Prefix or infix "**" matches zero or more directories
//
// Examples: "**/filler" or "filler/**/filler"
[], ["/", ..rest] | ["/", ..], ["/", ..rest] -> {
let wildcard = case options.match_dotfiles {
True -> "(.*/)?"
False -> "([^.][^/]*/)*"
}
let path_chars = [wildcard, ..path_chars]
do_convert_pattern(rest, path_chars, False, options)
}
_, _ -> Error(InvalidGlobStarError)
}
}
// Convert "*" which matches any char zero or more times except "/" to regex format
["*", ..rest] -> {
let wildcard = case ignore_dotfiles(path_chars, options) {
True -> "([^.][^/]*)?"
False -> "[^/]*"
}
do_convert_pattern(rest, [wildcard, ..path_chars], False, options)
}
// Escape any other path chars if necessary
[first, ..rest] ->
[escape_meta_char(first), ..path_chars]
|> do_convert_pattern(rest, _, False, options)
}
}
}
}
// Escape regex meta characters that should be matched literally inside the regex.
fn escape_meta_char(char: String) -> String {
case char {
// Erlang: Metacharacters need to be escaped to avoid unexpected matching.
// See https://www.erlang.org/doc/apps/stdlib/re.html#module-characters-and-metacharacters
"\\" -> "\\\\"
"^" -> "\\^"
"$" -> "\\$"
"." -> "\\."
"[" -> "\\["
"|" -> "\\|"
"(" -> "\\("
")" -> "\\)"
"?" -> "\\?"
"*" -> "\\*"
"+" -> "\\+"
"{" -> "\\{"
// JS: In unicode aware mode these need to be escaped explicitly.
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Regex_raw_bracket
"]" -> "\\]"
"}" -> "\\}"
_ -> char
}
}
// All wildcards ignore dotfiles by default unless the `match_dotfiles`
// option is present. It is also possible to match dotfiles using literal dots,
// char sets or ranges.
fn ignore_dotfiles(path_chars: List(String), options: PatternOptions) -> Bool {
!options.match_dotfiles && start_of_directory(path_chars)
}
// The start of a directory is the beginning of the path pattern or
// anything immediately following a slash.
fn start_of_directory(path_chars: List(String)) -> Bool {
case path_chars {
[] | [""] -> True
[previous, ..] -> string.ends_with(previous, "/")
}
}