Skip to main content

lib/dust/protocol/glob.ex

defmodule Dust.Protocol.Glob do
  @moduledoc """
  Segment-aware glob matching against `Dust.Protocol.Path` segment
  lists.

  Mirrors `DustProtocol.Glob` from the canonical wire-protocol
  package.

  ## Pattern grammar

  A pattern is a non-empty list of pattern segments. Each segment is
  either:

    * `"*"` — matches exactly one path segment
    * `"**"` — matches one or more path segments; **only valid in the
      tail position**
    * `"\\*"` — matches a path segment that is literally `"*"`
    * `"\\**"` — matches a path segment that is literally `"**"`
    * any other string — matches that exact path segment

  Patterns can also be given as rendered slash strings, decoded with
  the same JSON Pointer escape rules as `Dust.Protocol.Path`.
  """

  # Shadow the imported Kernel.match?/2 macro — we define a function
  # with the same name.
  import Kernel, except: [match?: 2]

  alias Dust.Protocol.Path

  @type pattern_input :: String.t() | [String.t(), ...]
  @type compiled :: {:compiled, [token]}
  @type token :: {:literal, String.t()} | :wildcard_one | :wildcard_many

  @type error ::
          :empty_path
          | :empty_segment
          | :invalid_escape
          | :not_a_string
          | :not_a_list
          | :wildcard_many_not_tail

  @spec compile(pattern_input()) :: {:ok, compiled()} | {:error, error()}
  def compile(pattern) when is_binary(pattern) do
    with {:ok, segments} <- Path.parse_rendered(pattern) do
      compile_segments(segments)
    end
  end

  def compile(pattern) when is_list(pattern) do
    with {:ok, segments} <- Path.from_segments(pattern) do
      compile_segments(segments)
    end
  end

  def compile(_), do: {:error, :not_a_string}

  @spec compile!(pattern_input()) :: compiled()
  def compile!(pattern) do
    case compile(pattern) do
      {:ok, c} -> c
      {:error, reason} -> raise ArgumentError, "invalid glob pattern: #{inspect(reason)}"
    end
  end

  defp compile_segments(segments) do
    tokens = Enum.map(segments, &classify_segment/1)

    case validate_tokens(tokens) do
      :ok -> {:ok, {:compiled, tokens}}
      err -> err
    end
  end

  defp classify_segment("*"), do: :wildcard_one
  defp classify_segment("**"), do: :wildcard_many
  defp classify_segment("\\*"), do: {:literal, "*"}
  defp classify_segment("\\**"), do: {:literal, "**"}
  defp classify_segment(other), do: {:literal, other}

  defp validate_tokens(tokens) do
    case Enum.find_index(tokens, &(&1 == :wildcard_many)) do
      nil -> :ok
      idx when idx == length(tokens) - 1 -> :ok
      _ -> {:error, :wildcard_many_not_tail}
    end
  end

  @spec match?(compiled() | pattern_input(), [String.t()]) :: boolean()
  def match?({:compiled, tokens}, path) when is_list(path) do
    do_match(tokens, path)
  end

  def match?(pattern, path) when is_list(path) do
    match?(compile!(pattern), path)
  end

  defp do_match([], []), do: true
  defp do_match([], _path), do: false
  defp do_match([:wildcard_many], path), do: path != []
  defp do_match(_tokens, []), do: false

  defp do_match([:wildcard_one | t_rest], [_ | p_rest]),
    do: do_match(t_rest, p_rest)

  defp do_match([{:literal, lit} | t_rest], [lit | p_rest]),
    do: do_match(t_rest, p_rest)

  defp do_match(_, _), do: false
end