defmodule Systemd.UnitFile.Parser do @moduledoc false import NimbleParsec alias Systemd.UnitFile alias Systemd.UnitFile.{Blank, Comment, Directive, ParseError, Raw, Section, Span} whitespace = repeat(choice([string(" "), string("\t")])) eol = choice([string("\r\n"), string("\n")]) section_name = ascii_string([?A..?Z, ?a..?z, ?0..?9, ?., ?@, ?-, ?_], min: 1) directive_name = ascii_string([?A..?Z, ?a..?z, ?0..?9], min: 1) rest_of_line = utf8_string([not: ?\n], min: 0) blank = ignore(whitespace) |> ignore(eol) |> replace({:blank}) comment = ignore(whitespace) |> choice([string("#"), string(";")]) |> concat(rest_of_line) |> ignore(eol) |> post_traverse(:comment_line) section = ignore(whitespace) |> ignore(string("[")) |> concat(section_name) |> ignore(string("]")) |> ignore(whitespace) |> ignore(eol) |> unwrap_and_tag(:section) directive = ignore(whitespace) |> concat(directive_name) |> ignore(whitespace) |> ignore(string("=")) |> concat(rest_of_line) |> ignore(eol) |> post_traverse(:directive_line) raw = utf8_string([?\s, ?\t], min: 1) |> concat(rest_of_line) |> ignore(eol) |> post_traverse(:raw_line) line = choice([blank, comment, section, directive, raw]) defparsecp(:document, repeat(line) |> eos()) @spec parse(String.t()) :: {:ok, UnitFile.t()} | {:error, ParseError.t()} def parse("") do {:ok, %UnitFile{entries: []}} end def parse(text) when is_binary(text) do input = ensure_final_newline(text) case document(input) do {:ok, tokens, "", _context, _line, _offset} -> entries = tokens |> to_entries(source_lines(input)) |> fold_continuations() {:ok, %UnitFile{entries: entries}} {:ok, _tokens, rest, _context, line, offset} -> {:error, parse_error(:unparsed_input, rest, line, offset)} {:error, reason, rest, _context, line, offset} -> {:error, parse_error(reason, rest, line, offset)} end end defp comment_line(rest, [text, marker], context, _line, _offset) do {rest, [{:comment, marker, trim_cr(text)}], context} end defp directive_line(rest, [value, name], context, _line, _offset) do {rest, [{:directive, name, trim_cr(value)}], context} end defp raw_line(rest, [content, whitespace], context, _line, _offset) do {rest, [{:raw, whitespace <> trim_cr(content)}], context} end defp to_entries(tokens, lines) do tokens |> Enum.zip(lines) |> Enum.with_index(1) |> Enum.map(fn {{token, line}, line_number} -> span = %Span{line: line_number, column: column_for(token, line)} case token do {:blank} -> %Blank{span: span} {:comment, marker, text} -> %Comment{marker: marker, text: text, span: span} {:section, name} -> %Section{name: name, span: span} {:directive, name, value} -> %Directive{name: name, value: value, span: span} {:raw, content} -> %Raw{content: trim_cr(content), span: span} end end) end defp fold_continuations(entries) do entries |> Enum.reduce([], &fold_continuation/2) |> Enum.reverse() end defp fold_continuation(%Raw{content: content}, [%Directive{value: value} = directive | rest]) do if continued?(value) do [%{directive | value: join_continuation(value, content)} | rest] else [%Raw{content: content}, directive | rest] end end defp fold_continuation(entry, acc), do: [entry | acc] defp continued?(value), do: String.ends_with?(value, "\\") defp join_continuation(value, content) do value |> String.trim_trailing("\\") |> Kernel.<>(String.trim_leading(content)) end defp parse_error(reason, rest, {line, column}, _offset) do %ParseError{reason: reason, rest: rest, line: line, column: column} end defp source_lines(input) do input |> String.split("\n") |> Enum.drop(-1) end defp column_for({:blank}, line), do: first_non_whitespace_column(line) defp column_for({:comment, marker, _text}, line), do: find_column(line, marker) defp column_for({:section, _name}, line), do: find_column(line, "[") defp column_for({:directive, name, _value}, line), do: find_column(line, name) defp column_for({:raw, _content}, line), do: first_non_whitespace_column(line) defp find_column(line, needle) do case :binary.match(line, needle) do {index, _length} -> index + 1 :nomatch -> 1 end end defp first_non_whitespace_column(line) do line |> :binary.bin_to_list() |> Enum.with_index(1) |> Enum.find_value(1, fn {char, column} when char not in [?\s, ?\t] -> column _whitespace -> false end) end defp ensure_final_newline(text) do if String.ends_with?(text, ["\n", "\r\n"]), do: text, else: text <> "\n" end defp trim_cr(value), do: String.trim_trailing(value, "\r") end