defmodule PathGlob do
@moduledoc """
Implements glob matching using the same semantics as `Path.wildcard/2`, but
without any filesystem interaction.
"""
import PathGlob.Parser
import NimbleParsec, only: [defparsecp: 3]
if System.version() >= "1.11" && Code.ensure_loaded?(Mix) && Mix.env() == :test do
require Logger
Logger.put_module_level(__MODULE__, :none)
defmacrop debug(message) do
quote do
require Logger
Logger.debug("PathGlob: " <> unquote(message))
end
end
else
defmacrop debug(message) do
quote do
# Avoid unused variable warning
_ = fn -> unquote(message) end
:ok
end
end
end
defparsecp(:parse, glob(), inline: true)
@doc """
Returns whether or not `path` matches the `glob`.
The glob is first parsed and compiled as a regular expression. If you're
using the same glob multiple times in performance-critical code, consider
using `compile/1` and caching the result.
## Examples
iex> PathGlob.match?("lib/path_glob.ex", "{lib,test}/path_*.ex")
true
iex> PathGlob.match?("lib/.formatter.exs", "lib/*", match_dot: true)
true
"""
@spec match?(String.t(), String.t(), match_dot: boolean()) :: boolean()
def match?(path, glob, opts \\ []) do
String.match?(path, compile(glob, opts))
end
@doc """
Compiles `glob` to a `Regex`.
Raises `ArgumentError` if `glob` is invalid.
## Examples
iex> PathGlob.compile("{lib,test}/*")
~r{^(lib|test)/([^\\./]|(?<=[^/])\\.)*$}
iex> PathGlob.compile("{lib,test}/path_*.ex", match_dot: true)
~r{^(lib|test)/path_[^/]*\\.ex$}
"""
@spec compile(String.t(), match_dot: boolean()) :: Regex.t()
def compile(glob, opts \\ []) do
case parse(glob) do
{:ok, [parse_tree], "", _, _, _} ->
regex =
parse_tree
|> transform(Keyword.get(opts, :match_dot, false))
|> Regex.compile!()
inspect(
%{
glob: glob,
regex: regex,
parse_tree: parse_tree
},
pretty: true
)
|> debug()
regex
{:error, _, _, _, _, _} = error ->
debug(inspect(error))
raise ArgumentError, "failed to parse '#{glob}'"
end
end
defp transform_join(list, match_dot?, joiner \\ "") when is_list(list) do
list
|> Enum.map(&transform(&1, match_dot?))
|> Enum.join(joiner)
end
defp transform(token, match_dot?) do
case token do
{:glob, terms} ->
"^#{transform_join(terms, match_dot?)}$"
{:literal, items} ->
items
|> Enum.join()
|> Regex.escape()
{:question, _} ->
any_single(match_dot?)
{:double_star_slash, _} ->
pattern = "(#{any_single(match_dot?)}+/)*"
if match_dot? do
pattern
else
"#{pattern}(?!\\.)"
end
{:double_star, _} ->
"(#{any_single(match_dot?)}+/)*#{any_single(match_dot?)}+"
{:star, _} ->
"#{any_single(match_dot?)}*"
{:alternatives, items} ->
choice(items, match_dot?)
{:alternatives_item, items} ->
transform_join(items, match_dot?)
{:character_list, items} ->
transform_join(items, match_dot?, "|")
{:character_range, [start, finish]} ->
"[#{transform(start, match_dot?)}-#{transform(finish, match_dot?)}]"
{:character_class, items} ->
choice(items, match_dot?)
end
end
defp any_single(match_dot?) do
if match_dot? do
"[^/]"
else
"([^\\./]|(?<=[^/])\\.)"
end
end
defp choice(items, match_dot?) do
"(#{transform_join(items, match_dot?, "|")})"
end
end