defmodule EditorConfig.Resolver do @moduledoc "Resolves EditorConfig properties for a file path." alias EditorConfig.FileSystem alias EditorConfig.FileSystem.Memory alias EditorConfig.Glob alias EditorConfig.Parser @spec properties(Path.t(), keyword()) :: {:ok, map()} | {:error, term()} def properties(path, opts \\ []) do with {:ok, props, _sources} <- properties_with_sources(path, opts), do: {:ok, props} end @spec properties_with_sources(Path.t(), keyword()) :: {:ok, map(), list()} | {:error, term()} def properties_with_sources(path, opts \\ []) do fs = Keyword.get(opts, :fs, FileSystem) config_name = Keyword.get(opts, :config_name, ".editorconfig") path = normalize_path(path) result = path |> candidate_config_paths(config_name) |> Enum.reduce_while({[], false}, &collect_config(&1, &2, fs)) case result do {:error, reason} -> {:error, reason} {configs, _root_found?} -> apply_configs(configs, path, opts) end end defp normalize_path(path) do Path.expand(path) end defp candidate_config_paths(path, config_name) do path |> Path.dirname() |> ancestors() |> Enum.map(&Path.join(&1, config_name)) end defp ancestors("/"), do: ["/"] defp ancestors(dir), do: [dir | ancestors(Path.dirname(dir))] defp collect_config(_candidate, {configs, true}, _fs), do: {:halt, {configs, true}} defp collect_config(candidate, {configs, false}, fs) do if exists?(fs, candidate) do with {:ok, body} <- read(fs, candidate), {:ok, file} <- Parser.parse(body) do {:cont, {[{candidate, file} | configs], file.root?}} else {:error, reason} -> {:halt, {:error, reason}} end else {:cont, {configs, false}} end end defp apply_configs(configs, path, opts) do {props, sources} = Enum.reduce(configs, {%{}, []}, fn {config_path, file}, acc -> config_dir = Path.dirname(config_path) Enum.reduce(file.sections, acc, &apply_section(&1, &2, path, config_path, config_dir)) end) {:ok, normalize_props(props, opts), sources} end defp apply_section(section, {props, sources}, path, config_path, config_dir) do if Glob.match?(section.name, path, config_dir) do {merge_props(props, section.properties), sources ++ [{config_path, section.name}]} else {props, sources} end end defp merge_props(props, section_props) do Enum.reduce(section_props, props, fn {key, value}, acc -> Map.put(acc, key, value) end) end defp normalize_props(props, opts) do props |> downcase_known_values() |> apply_defaults(Keyword.get(opts, :version, "0.17.2")) end defp downcase_known_values(props) do always_lowercase = ~w(charset end_of_line indent_style insert_final_newline trim_trailing_whitespace) Map.new(props, fn {key, value} -> cond do key in always_lowercase -> {key, String.downcase(value)} key == "indent_size" and String.downcase(value) in ["tab", "unset"] -> {key, String.downcase(value)} true -> {key, value} end end) end defp apply_defaults(props, version) do if before_0_9_0?(version) do props else props |> default_indent_size() |> default_tab_width() end end defp default_indent_size(%{"indent_style" => "tab"} = props) do Map.put_new(props, "indent_size", Map.get(props, "tab_width", "tab")) end defp default_indent_size(props), do: props defp default_tab_width(%{"indent_size" => "tab"} = props), do: props defp default_tab_width(%{"indent_size" => indent_size} = props) do Map.put_new(props, "tab_width", indent_size) end defp default_tab_width(props), do: props defp before_0_9_0?(version) do Version.compare(version, "0.9.0") == :lt rescue Version.InvalidVersionError -> false end defp exists?(%Memory{} = fs, path), do: Memory.exists?(fs, path) defp exists?(module, path), do: module.exists?(path) defp read(%Memory{} = fs, path), do: Memory.read(fs, path) defp read(module, path), do: module.read(path) end