Skip to main content

lib/docx_tmpl/interpreter.ex

defmodule DocxTmpl.Interpreter do
  @moduledoc false
  # Render a parsed AST against assigns. Pure: ast + map -> binary.

  @spec render(list(), map()) :: binary()
  def render(ast, assigns) when is_list(ast) and is_map(assigns) do
    render_nodes(ast, [assigns])
  end

  defp render_nodes(nodes, scopes) do
    nodes
    |> Enum.map(&render_node(&1, scopes))
    |> IO.iodata_to_binary()
  end

  defp render_node({:text, t}, _scopes), do: t

  defp render_node({:var, path}, scopes) do
    scopes |> lookup_scoped(path) |> stringify() |> xml_escape()
  end

  defp render_node({:if, path, children}, scopes) do
    if truthy?(lookup_scoped(scopes, path)),
      do: render_nodes(children, scopes),
      else: ""
  end

  defp render_node({:unless, path, children}, scopes) do
    if truthy?(lookup_scoped(scopes, path)),
      do: "",
      else: render_nodes(children, scopes)
  end

  defp render_node({:each, path, children}, scopes) do
    case lookup_scoped(scopes, path) do
      list when is_list(list) ->
        Enum.map(list, fn item -> render_nodes(children, [item_scope(item) | scopes]) end)

      _ ->
        ""
    end
  end

  defp item_scope(item) when is_map(item), do: Map.put(item, "this", item)
  defp item_scope(item), do: %{"this" => item}

  defp lookup_scoped(scopes, path) do
    parts = String.split(path, ".")

    Enum.find_value(scopes, fn scope ->
      case fetch_path(scope, parts) do
        {:ok, v} -> {:found, v}
        :error -> nil
      end
    end)
    |> case do
      {:found, v} -> v
      nil -> nil
    end
  end

  defp fetch_path(value, []), do: {:ok, value}

  defp fetch_path(map, [head | rest]) when is_map(map) do
    cond do
      Map.has_key?(map, head) -> fetch_path(Map.get(map, head), rest)
      Map.has_key?(map, safe_atom(head)) -> fetch_path(Map.get(map, safe_atom(head)), rest)
      true -> :error
    end
  end

  defp fetch_path(_, _), do: :error

  defp safe_atom(s) do
    String.to_existing_atom(s)
  rescue
    ArgumentError -> :__docx_tmpl_missing__
  end

  defp truthy?(nil), do: false
  defp truthy?(false), do: false
  defp truthy?(""), do: false
  defp truthy?([]), do: false
  defp truthy?(_), do: true

  defp stringify(nil), do: ""
  defp stringify(v) when is_binary(v), do: v
  defp stringify(v), do: to_string(v)

  defp xml_escape(s) do
    s
    |> String.replace("&", "&")
    |> String.replace("<", "&lt;")
    |> String.replace(">", "&gt;")
  end
end