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