3.0 KiB · text History 34b2c06
defmodule GitGud.Workflows.Glob do
  @moduledoc """
  File path patterns as workflows write them under `paths:` and
  `paths-ignore:`, with GitHub's rules (its "filter pattern cheat sheet"),
  because that is the documentation the patterns are copied from:

    * `*` matches any run of characters except `/`
    * `**` matches any run of characters including `/`; `**/` also matches
      no directory at all, so `**/*.md` matches `README.md`
    * `?` matches one character except `/`
    * `[abc]`, `[a-z]` are character classes
    * everything else is literal — `.` is a dot

  Patterns are relative to the repository root and anchored at both ends.
  A leading `!` (negation) is the caller's business: it means something
  only in a list of patterns, see `GitGud.Workflows.Yaml`.

  Not used for `branches:` / `tags:`, whose `*` still crosses `/`; making
  those follow the same rule would change which existing filters fire.
  """

  import Kernel, except: [match?: 2]

  @doc """
  Compile `pattern` into an anchored regex.

      iex> Regex.source(GitGud.Workflows.Glob.compile("docs/*.md"))
      "^docs/[^/]*\\\\.md$"
  """
  @spec compile(String.t()) :: Regex.t()
  def compile(pattern) when is_binary(pattern) do
    Regex.compile!("^" <> translate(pattern, "") <> "$")
  end

  @doc """
  Whether `path` matches `pattern` (a string, or a regex from `compile/1`).

      iex> GitGud.Workflows.Glob.match?("**/*.md", "README.md")
      true
  """
  @spec match?(String.t() | Regex.t(), String.t()) :: boolean()
  def match?(%Regex{} = regex, path) when is_binary(path), do: Regex.match?(regex, path)
  def match?(pattern, path) when is_binary(pattern), do: Regex.match?(compile(pattern), path)

  # `**/` — zero or more whole directories.
  defp translate("**/" <> rest, acc), do: translate(rest, acc <> "(?:.*/)?")
  # `**` anywhere else (`docs/**`, `**.js`, bare `**`) — anything at all.
  defp translate("**" <> rest, acc), do: translate(rest, acc <> ".*")
  defp translate("*" <> rest, acc), do: translate(rest, acc <> "[^/]*")
  defp translate("?" <> rest, acc), do: translate(rest, acc <> "[^/]")

  defp translate("[" <> rest, acc) do
    case class(rest, "") do
      {:ok, body, rest} -> translate(rest, acc <> "[" <> body <> "]")
      # No closing bracket: the `[` was just a character.
      :error -> translate(rest, acc <> "\\[")
    end
  end

  defp translate(<<c::utf8, rest::binary>>, acc),
    do: translate(rest, acc <> Regex.escape(<<c::utf8>>))

  defp translate("", acc), do: acc

  # The inside of `[...]`: ranges (`-`) and a leading `^` keep their regex
  # meaning; anything else that a regex class would read specially is escaped.
  defp class("]" <> rest, body) when body not in ["", "^"], do: {:ok, body, rest}
  defp class("-" <> rest, body), do: class(rest, body <> "-")
  defp class("^" <> rest, ""), do: class(rest, "^")

  defp class(<<c::utf8, rest::binary>>, body) when c in [?\\, ?[, ?], ?^],
    do: class(rest, body <> "\\" <> <<c::utf8>>)

  defp class(<<c::utf8, rest::binary>>, body), do: class(rest, body <> <<c::utf8>>)
  defp class("", _body), do: :error
end