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