defmodule ExXml do @moduledoc """ Documentation for ExXml. """ import NimbleParsec alias __MODULE__.{Element, Fragment} defmacro __using__(_opts) do quote do import ExXml defmacro sigil_x(params, options) do caller = __CALLER__ module = __MODULE__ do_sigil_x(params, options, caller, module) end end end whitespace = ascii_string([?\s, ?\n, ?\t], max: 100) |> label("whitespace") tag = ascii_char([?a..?z]) |> reduce({Kernel, :to_string, []}) |> concat(optional(ascii_string([?a..?z, ?_, ?0..?9, not: ?=], min: 1))) |> ignore(whitespace) |> reduce({Enum, :join, [""]}) |> label("tag") |> tag(:tag) module = ascii_char([?A..?Z]) |> reduce({Kernel, :to_string, []}) |> concat(optional(ascii_string([?a..?z, ?0..?9, ?A..?Z, ?., not: ?=], min: 1))) |> ignore(whitespace) |> reduce({Enum, :join, [""]}) |> label("module") |> tag(:module) element_name = choice([tag, module]) |> label("element_name") |> tag(:element_name) text = ignore(whitespace) |> utf8_string([not: ?<, not: ?\n], min: 1) |> reduce({:trim, []}) |> post_traverse({:sub_context_in_text, []}) |> label("text") sub = string("$") |> concat(ascii_string([?0..?9], min: 1)) |> post_traverse({:sub_context, []}) |> label("sub") quote_string = ascii_char([?"]) |> label("quote_string") quoted_attribute_text = ignore(whitespace) |> ignore(quote_string) |> repeat( lookahead_not(ascii_char([?"])) |> choice([ ~s(\") |> string() |> replace(?'), utf8_char([]) ]) ) |> ignore(quote_string) |> reduce({List, :to_string, []}) |> label("quoted_attribute_text") attribute = ignore(whitespace) |> concat(tag) |> ignore(string("=")) |> choice([sub, quoted_attribute_text]) |> label("attribute") |> tag(:attribute) opening_tag = ignore(whitespace) |> ignore(string("<")) |> concat(element_name) |> repeat( lookahead_not(choice([ascii_char([?>]), string("/>")])) |> choice([attribute, ascii_char([?>]), string("/>")]) ) |> ignore(optional(string(">"))) |> ignore(whitespace) |> label("opening_tag") |> tag(:element) fragment_tag = ignore(whitespace) |> ignore(string("<")) |> repeat( lookahead_not(choice([ascii_char([?>]), string("/>")])) |> choice([attribute, ascii_char([?>]), string("/>")]) ) |> ignore(string(">")) |> ignore(whitespace) |> label("fragment_tag") |> tag(:fragment) closing_tag = ignore(whitespace) |> ignore(string(" concat(element_name) |> ignore(string(">")) |> ignore(whitespace) |> label("closing_tag") |> tag(:closing_tag) closing_fragment = ignore(whitespace) |> ignore(string("")) |> ignore(whitespace) |> label("closing_fragment") |> tag(:closing_fragment) self_closing = ignore(whitespace) |> ignore(string("/>")) |> ignore(whitespace) |> label("self_closing") defparsec( :parse_xml, parsec(:xml) ) defcombinatorp( :xml, choice([fragment_tag, opening_tag]) |> repeat( lookahead_not(choice([string("")])) |> choice([parsec(:xml), sub, text]) ) |> choice([closing_fragment, closing_tag, self_closing]) |> reduce({:fix_element, []}) ) @spec parse_ex_xml([...]) :: {:ok, [%Element{} | %Fragment{}]} | {:error, String.t()} def parse_ex_xml(ex_xml) do {bin, context} = list_to_context(ex_xml) with {:ok, results, _, _, _, _} <- parse_xml(String.trim(bin), context: context) do {:ok, results} end end @spec list_to_context([...]) :: {binary, map} def list_to_context(list) when is_list(list) do {_, context, acc_list} = list |> Enum.reduce({1, [], []}, fn bin, {index, context, acc_list} when is_binary(bin) -> {index, context, [bin | acc_list]} other, {index, context, acc_list} -> ref = "$#{index}" {index + 1, [{ref, other} | context], [ref | acc_list]} end) {acc_list |> Enum.reverse() |> Enum.join(), Enum.into(context, %{})} end def do_sigil_x({:<<>>, _meta, pieces}, 'raw', _, _) do pieces |> Enum.map(&clean_litteral/1) end def do_sigil_x({:<<>>, _meta, pieces}, 'parse', _, _) do pieces |> Enum.map(&clean_litteral/1) |> parse_ex_xml() nil end def do_sigil_x({:<<>>, _meta, pieces}, 'debug', caller, module) do {:ok, ex_xml} = pieces |> Enum.map(&clean_litteral/1) |> parse_ex_xml() ast = if Kernel.function_exported?(module, :process_ex_xml, 2) do module.process_ex_xml(ex_xml, caller) else case Module.get_attribute(caller.module, :process_ex_xml) do nil -> {:ok, escape_ex_xml(ex_xml)} process_ex_xml -> process_ex_xml.(ex_xml, caller) end end ast |> Macro.to_string() |> Code.format_string!() |> IO.puts() ast end def do_sigil_x({:<<>>, _meta, pieces}, '', caller, module) do with {:ok, ex_xml} <- pieces |> Enum.map(&clean_litteral/1) |> parse_ex_xml() do if Kernel.function_exported?(module, :process_ex_xml, 2) do module.process_ex_xml(ex_xml, caller) else case Module.get_attribute(caller.module, :process_ex_xml) do nil -> {:ok, escape_ex_xml(ex_xml)} process_ex_xml -> process_ex_xml.(ex_xml, caller) end end else {:error, message, _rest, _context, _line, _column} -> {:error, message} end end @spec escape_ex_xml([...]) :: Macro.t() def escape_ex_xml(list) when is_list(list) do do_escape_ex_xml(list) end defp do_escape_ex_xml(list) when is_list(list) do Enum.map(list, &do_escape_ex_xml/1) end defp do_escape_ex_xml(%{__struct__: module} = struct) do keyword_list = struct |> Map.from_struct() |> Enum.map(&do_escape_ex_xml/1) {:%, [], [{:__aliases__, [alias: false], [module]}, {:%{}, [], keyword_list}]} end defp do_escape_ex_xml(%{} = map) do keyword_list = map |> Enum.map(&do_escape_ex_xml/1) {:%{}, [], keyword_list} end defp do_escape_ex_xml({key, value}) do {key, do_escape_ex_xml(value)} end defp do_escape_ex_xml(other) do other end @spec fix_element_based_on_type(atom, [...], [...]) :: {:ok, %Element{} | %Fragment{}} | {:error, String.t()} defp fix_element_based_on_type(:fragment, content, nested) do meta = Enum.reduce(content, %{}, &get_meta_content/2) {closing_fragment, new_nested} = List.pop_at(nested, -1) if {:closing_fragment, []} !== closing_fragment do {:error, "Fragment isn't closed"} else {:ok, struct(Fragment, Map.put(meta, :children, List.flatten(new_nested)))} end end defp fix_element_based_on_type(:element, content, nested) do meta = Enum.reduce(content, %{}, &get_meta_content/2) tag = List.first(content) {closing_tag, new_nested} = List.pop_at(nested, -1) if not (is_nil(closing_tag) or {:closing_tag, List.wrap(tag)} === closing_tag) do with {:closing_tag, cl_tag} <- closing_tag do {:error, "Closing tag doesn't match opening tag open_tag: #{inspect(tag)} closing_tag: #{ inspect(cl_tag) }"} else _ -> {:error, "Closing tag doesn't match opening tag open_tag: #{inspect(tag)} closing_tag: #{ inspect(closing_tag) }"} end else {:ok, struct(Element, Map.put(meta, :children, List.flatten(new_nested)))} end end @spec fix_element([{atom, [...]}, ...]) :: %Element{} | %Fragment{} | {:error, String.t()} defp fix_element([{type, content} | nested]) do with {:ok, result} <- fix_element_based_on_type(type, content, nested) do result end end @spec trim([binary]) :: binary defp trim([string]) when is_binary(string) do string |> String.trim() end @spec get_meta_content({atom, [...]}, map) :: map def get_meta_content({:attribute, [{:tag, [key]}, value]}, acc) do Map.update(acc, :attributes, %{key => value}, &Map.put(&1, key, value)) end def get_meta_content({:element_name, [{type, [name]}]}, acc) do acc |> Map.put(:name, name) |> Map.put(:type, type) end @spec clean_litteral(Macro.t() | binary) :: Macro.t() | binary defp clean_litteral( {:"::", _, [{{:., _, [Kernel, :to_string]}, _, [litteral]}, {:binary, _, nil}]} ) do {:ok, litteral} end defp clean_litteral(other) do other end @spec sub_context(binary, [binary], map, {integer, integer}, integer) :: {[...], map} defp sub_context(_rest, args, context, _line, _offset) do ref = args |> Enum.reverse() |> Enum.join() {:ok, value} = context |> Map.get(ref) {[value], context} end @spec sub_context_in_text(binary, [binary], map, {integer, integer}, integer) :: {[...], map} defp sub_context_in_text(_rest, [text], context, _line, _offset) do new_text = Regex.split(~r/\$\d+/, text, include_captures: true) |> Enum.map(fn text_fragment -> case Map.get(context, text_fragment) do {:ok, value} -> value _ -> text_fragment end end) {new_text, context} end end