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