lib/markup_parser.ex

defmodule Mecto.MarkupParser do
  @moduledoc """
  Parses `"some text with [[tag.module]]"` into `[tag: [:module]]`
  """

  import NimbleParsec
  alias Mecto.MarkupParser.Field

  whitespace = utf8_string([?\s, ?\n, ?\r, ?\t], min: 1)
  opening_tag = string("[[")
  closing_tag = string("]]")

  tag =
    ignore(opening_tag)
    |> ignore(optional(whitespace))
    |> lookahead_not(closing_tag)
    |> tag(Field.field(), :field)
    |> ignore(optional(whitespace))
    |> ignore(closing_tag)

  text =
    lookahead_not(opening_tag)
    |> utf8_string([], 1)
    |> times(min: 1)
    |> reduce({Enum, :join, []})

  leading_whitespace =
    whitespace
    |> lookahead(opening_tag)
    |> ignore()

  defparsec(:parse, repeat(choice([tag, text, leading_whitespace])) |> eos())

  @spec extract_nodes(String.t()) :: map() | {:error, String.t()}
  def extract_nodes(text) do
    with {:ok, nodes, _, _, _, _} <- parse(text) do
      nodes
      |> Enum.reject(&is_binary/1)
      |> Enum.reduce(%{}, &merge_node/2)
    else
      {:error, _e, _, _, _, _} ->
        {:error, "invalid markup"}
    end
  rescue
    e in ArgumentError ->
      if e.message =~ "1st argument: not an already existing atom" do
        {:error,
         "field cannot be converted to a non-existing atom. Maybe your markup has a typo?"}
      else
        raise e
      end
  end

  defp merge_node({:field, path}, fields) do
    atomised_path =
      Enum.map(path, fn
        entry when is_integer(entry) -> entry
        otherwise -> String.to_existing_atom("#{otherwise}")
      end)

    merge_node(fields, atomised_path)
  end

  defp merge_node(fields, []), do: fields

  defp merge_node(fields, [entry]) do
    case get_node(fields, entry) do
      {nil, remainder} -> Map.put(remainder, entry, 1)
      _ -> Map.update!(fields, entry, &(&1 + 1))
    end
  end

  defp merge_node(fields, [head | tail]) do
    case get_node(fields, head) do
      {nil, remainder} ->
        Map.put(remainder, head, merge_node(%{}, tail))

      {node, remainder} ->
        Map.put(remainder, head, merge_node(node, tail))
    end
  end

  defp get_node(fields, node) do
    Map.pop(fields, node)
  end
end