Packages
editorconfig_core
0.1.0
EditorConfig Core implementation for Elixir: parser, glob matcher, resolver, and CLI.
Current section
Files
Jump to
Current section
Files
lib/editor_config/glob.ex
defmodule EditorConfig.Glob do
@moduledoc "EditorConfig section glob compiler and matcher."
alias EditorConfig.Glob.Compiled
@doc """
Compiles an EditorConfig section glob.
## Examples
iex> {:ok, compiled} = EditorConfig.Glob.compile("*.ex")
iex> compiled.pattern
"*.ex"
"""
@spec compile(String.t()) :: {:ok, Compiled.t()} | {:error, term()}
def compile(pattern) when is_binary(pattern) do
anchored? = anchored?(pattern)
source = if anchored?, do: String.trim_leading(pattern, "/"), else: pattern
if String.ends_with?(source, "/") do
{:ok, %Compiled{pattern: pattern, matches_nothing?: true}}
else
{body, _capture_index, ranges} = compile_chars(String.graphemes(source), 1, [])
prefix = if anchored?, do: "", else: "(?:.*/)?"
case Regex.compile("\\A" <> prefix <> body <> "\\z", "u") do
{:ok, regex} ->
{:ok,
%Compiled{
pattern: pattern,
regex: regex,
ranges: Enum.reverse(ranges),
anchored?: anchored?
}}
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Checks whether a path matches an EditorConfig section glob.
## Examples
iex> EditorConfig.Glob.match?("lib/*.ex", "/project/lib/file.ex", "/project")
true
"""
@spec match?(String.t(), Path.t(), Path.t()) :: boolean()
def match?(pattern, path, config_dir) do
case compile(pattern) do
{:ok, compiled} -> match_compiled?(compiled, path, config_dir)
{:error, _reason} -> false
end
end
defp match_compiled?(%Compiled{matches_nothing?: true}, _path, _config_dir), do: false
defp match_compiled?(%Compiled{} = compiled, path, config_dir) do
relative = relative_path(path, config_dir)
case Regex.run(compiled.regex, relative) do
nil -> false
captures -> ranges_match?(compiled.ranges, captures)
end
end
defp anchored?(pattern), do: slash_outside_class?(String.graphemes(pattern), false)
defp slash_outside_class?([], _inside?), do: false
defp slash_outside_class?(["\\", _escaped | rest], inside?),
do: slash_outside_class?(rest, inside?)
defp slash_outside_class?(["[" | rest], false), do: slash_outside_class?(rest, true)
defp slash_outside_class?(["]" | rest], true), do: slash_outside_class?(rest, false)
defp slash_outside_class?(["/" | _rest], false), do: true
defp slash_outside_class?([_char | rest], inside?), do: slash_outside_class?(rest, inside?)
defp compile_chars([], capture_index, ranges), do: {"", capture_index, ranges}
defp compile_chars(["*", "*", "/" | rest], capture_index, ranges) do
append("(?:.*/)?", rest, capture_index, ranges)
end
defp compile_chars(["*", "*" | rest], capture_index, ranges) do
append(".*", rest, capture_index, ranges)
end
defp compile_chars(["*" | rest], capture_index, ranges) do
append("[^/]*", rest, capture_index, ranges)
end
defp compile_chars(["?" | rest], capture_index, ranges) do
append("[^/]", rest, capture_index, ranges)
end
defp compile_chars(["\\" | [char | rest]], capture_index, ranges) do
append(Regex.escape(char), rest, capture_index, ranges)
end
defp compile_chars(["\\"], capture_index, ranges) do
append(Regex.escape("\\"), [], capture_index, ranges)
end
defp compile_chars(["[" | rest], capture_index, ranges) do
case take_until_class_end(rest, []) do
{:ok, class_chars, remaining} ->
fragment =
if "/" in class_chars do
Regex.escape("[" <> Enum.join(class_chars) <> "]")
else
compile_class(class_chars)
end
append(fragment, remaining, capture_index, ranges)
:error ->
append(Regex.escape("["), rest, capture_index, ranges)
end
end
defp compile_chars(["{" | rest], capture_index, ranges) do
case take_balanced_brace(rest, [], 0) do
{:ok, content, remaining} ->
{fragment, capture_index, ranges} = compile_brace(content, capture_index, ranges)
append(fragment, remaining, capture_index, ranges)
:error ->
append(Regex.escape("{"), rest, capture_index, ranges)
end
end
defp compile_chars([char | rest], capture_index, ranges) do
append(Regex.escape(char), rest, capture_index, ranges)
end
defp append(fragment, rest, capture_index, ranges) do
{tail, capture_index, ranges} = compile_chars(rest, capture_index, ranges)
{fragment <> tail, capture_index, ranges}
end
defp take_until_class_end([], _acc), do: :error
defp take_until_class_end(["\\", char | rest], acc),
do: take_until_class_end(rest, [char | acc])
defp take_until_class_end(["]" | rest], acc), do: {:ok, Enum.reverse(acc), rest}
defp take_until_class_end([char | rest], acc), do: take_until_class_end(rest, [char | acc])
defp compile_class(["!" | chars]), do: "[^" <> class_body(chars) <> "]"
defp compile_class(chars), do: "[" <> class_body(chars) <> "]"
defp class_body(chars) do
Enum.map_join(chars, "", &escape_class_char/1)
end
defp escape_class_char("\\"), do: "\\\\"
defp escape_class_char("]"), do: "\\]"
defp escape_class_char("^"), do: "\\^"
defp escape_class_char(char), do: char
defp take_balanced_brace([], _acc, _depth), do: :error
defp take_balanced_brace(["\\", char | rest], acc, depth) do
take_balanced_brace(rest, [char, "\\" | acc], depth)
end
defp take_balanced_brace(["}" | rest], acc, 0) do
{:ok, Enum.reverse(acc), rest}
end
defp take_balanced_brace(["}" | rest], acc, depth) do
take_balanced_brace(rest, ["}" | acc], depth - 1)
end
defp take_balanced_brace(["{" | rest], acc, depth) do
take_balanced_brace(rest, ["{" | acc], depth + 1)
end
defp take_balanced_brace([char | rest], acc, depth) do
take_balanced_brace(rest, [char | acc], depth)
end
defp compile_brace(content, capture_index, ranges) do
text = Enum.join(content)
cond do
numeric = numeric_range(text) ->
{min, max} = numeric
{"(-?\\d+)", capture_index + 1, [%{capture: capture_index, min: min, max: max} | ranges]}
options = split_top_level_options(content) ->
compile_alternation(options, capture_index, ranges)
true ->
{Regex.escape("{" <> text <> "}"), capture_index, ranges}
end
end
defp numeric_range(text) do
case Regex.run(~r/\A(-?\d+)\.\.(-?\d+)\z/, text) do
[_match, min, max] ->
min = String.to_integer(min)
max = String.to_integer(max)
if min < max, do: {min, max}, else: nil
nil ->
nil
end
end
defp split_top_level_options(chars) do
split_top_level_options(chars, [], [], 0, false)
end
defp split_top_level_options([], options, current, 0, true) do
Enum.reverse([Enum.reverse(current) | options])
end
defp split_top_level_options([], _options, _current, _depth, _saw_comma?), do: nil
defp split_top_level_options(["\\", char | rest], options, current, depth, saw_comma?) do
split_top_level_options(rest, options, [char, "\\" | current], depth, saw_comma?)
end
defp split_top_level_options(["{" | rest], options, current, depth, saw_comma?) do
split_top_level_options(rest, options, ["{" | current], depth + 1, saw_comma?)
end
defp split_top_level_options(["}" | rest], options, current, depth, saw_comma?)
when depth > 0 do
split_top_level_options(rest, options, ["}" | current], depth - 1, saw_comma?)
end
defp split_top_level_options(["," | rest], options, current, 0, _saw_comma?) do
split_top_level_options(rest, [Enum.reverse(current) | options], [], 0, true)
end
defp split_top_level_options([char | rest], options, current, depth, saw_comma?) do
split_top_level_options(rest, options, [char | current], depth, saw_comma?)
end
defp compile_alternation(options, capture_index, ranges) do
{compiled, capture_index, ranges} =
Enum.reduce(options, {[], capture_index, ranges}, fn option,
{compiled, capture_index, ranges} ->
{fragment, capture_index, ranges} = compile_chars(option, capture_index, ranges)
{[fragment | compiled], capture_index, ranges}
end)
{"(?:" <> Enum.join(Enum.reverse(compiled), "|") <> ")", capture_index, ranges}
end
defp relative_path(path, config_dir) do
path = normalize_path(path)
config_dir = normalize_path(config_dir)
path
|> String.replace_prefix(config_dir, "")
|> String.trim_leading("/")
end
defp normalize_path(path) do
Path.expand(path, "/")
end
defp ranges_match?(ranges, captures) do
Enum.all?(ranges, fn %{capture: capture, min: min, max: max} ->
captures
|> Enum.at(capture)
|> integer_in_range?(min, max)
end)
end
defp integer_in_range?(nil, _min, _max), do: true
defp integer_in_range?("", _min, _max), do: true
defp integer_in_range?(value, min, max) do
if leading_zero?(value) do
false
else
case Integer.parse(value) do
{integer, ""} -> integer >= min and integer <= max
_invalid -> false
end
end
end
defp leading_zero?("-" <> digits), do: leading_zero?(digits)
defp leading_zero?("0" <> rest), do: rest != ""
defp leading_zero?(_value), do: false
end