Skip to main content

lib/systemd/unit_file/parser.ex

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