defmodule EditorConfig.Parser do @moduledoc "Parser for EditorConfig files." alias EditorConfig.File alias EditorConfig.File.Section @bom <<0xEF, 0xBB, 0xBF>> @doc """ Parses EditorConfig contents. ## Examples iex> {:ok, file} = EditorConfig.Parser.parse("[*.ex]\\nindent_size = 2\\n") iex> hd(file.sections).name "*.ex" """ @spec parse(binary(), keyword()) :: {:ok, File.t()} | {:error, term()} def parse(binary, opts \\ []) when is_binary(binary) do case parse_with_warnings(binary, opts) do {:ok, file, _warnings} -> {:ok, file} {:error, reason} -> {:error, reason} end end @doc """ Parses EditorConfig contents and returns warnings for ignored invalid lines. ## Examples iex> {:ok, file, warnings} = ...> EditorConfig.Parser.parse_with_warnings("[*]\\ninvalid line\\n") iex> {length(file.sections), warnings} {1, [%{line: 2, reason: :invalid_line, text: "invalid line"}]} """ @spec parse_with_warnings(binary(), keyword()) :: {:ok, File.t(), [map()]} | {:error, term()} def parse_with_warnings(binary, opts \\ []) when is_binary(binary) do strict? = Keyword.get(opts, :strict, false) binary |> strip_bom() |> String.split(~r/\r\n|\n/, trim: false) |> Enum.with_index(1) |> Enum.reduce_while({%File{}, nil, [], strict?}, &parse_line/2) |> finish() end defp strip_bom(<<@bom, rest::binary>>), do: rest defp strip_bom(binary), do: binary defp parse_line({raw, line}, {file, current, warnings, strict?}) do text = String.trim(raw) cond do text == "" -> {:cont, {file, current, warnings, strict?}} String.starts_with?(text, [";", "#"]) -> {:cont, {file, current, warnings, strict?}} String.starts_with?(text, "[") and String.ends_with?(text, "]") -> section = %Section{name: String.slice(text, 1..-2//1), line: line} {:cont, {append_section(file, current), section, warnings, strict?}} String.contains?(text, "=") -> {key, value} = split_pair(text) {:cont, {put_pair(file, current, key, value), update_current(current, key, value), warnings, strict?}} strict? -> {:halt, {:error, {:invalid_line, line, text}}} true -> warning = %{line: line, reason: :invalid_line, text: text} {:cont, {file, current, [warning | warnings], strict?}} end end defp split_pair(text) do [key, value] = String.split(text, "=", parts: 2) {String.downcase(String.trim(key)), String.trim(value)} end defp put_pair(%File{} = file, nil, "root" = key, value) do %{ file | preamble: Map.put(file.preamble, key, value), root?: root_value?(key, value) } end defp put_pair(%File{} = file, nil, key, value) do %{file | preamble: Map.put(file.preamble, key, value)} end defp put_pair(%File{} = file, %Section{} = current, key, value) do file = append_section(file, current) section = update_current(current, key, value) %{file | sections: List.replace_at(file.sections, -1, section)} end defp update_current(nil, _key, _value), do: nil defp update_current(%Section{} = section, key, value) do %{section | properties: Map.put(section.properties, key, value)} end defp append_section(file, nil), do: file defp append_section(%File{} = file, %Section{} = section) do if Enum.any?(file.sections, &(&1.line == section.line)) do file else %{file | sections: file.sections ++ [section]} end end defp root_value?("root", value), do: String.downcase(value) == "true" defp root_value?(_key, _value), do: false defp finish({:error, reason}), do: {:error, reason} defp finish({file, nil, warnings, _strict?}), do: {:ok, file, Enum.reverse(warnings)} defp finish({file, %Section{} = section, warnings, _strict?}) do {:ok, append_section(file, section), Enum.reverse(warnings)} end end