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