lib/phoenix_live_view/html_engine.ex

defmodule Phoenix.LiveView.HTMLEngine do
  @moduledoc """
  The HTMLEngine that powers `.heex` templates and the `~H` sigil.

  It works by adding a HTML parsing and validation layer on top
  of EEx engine. By default it uses `Phoenix.LiveView.Engine` as
  its "subengine".
  """

  # TODO: Use @impl true instead of @doc false when we require Elixir v1.12
  alias Phoenix.LiveView.HTMLTokenizer
  alias Phoenix.LiveView.HTMLTokenizer.ParseError

  @behaviour Phoenix.Template.Engine

  @doc false
  def compile(path, _name) do
    trim = Application.get_env(:phoenix, :trim_on_html_eex_engine, true)
    EEx.compile_file(path, engine: __MODULE__, line: 1, trim: trim)
  end

  @behaviour EEx.Engine

  @doc false
  def init(opts) do
    {subengine, opts} = Keyword.pop(opts, :subengine, Phoenix.LiveView.Engine)
    {module, opts} = Keyword.pop(opts, :module)

    unless subengine do
      raise ArgumentError, ":subengine is missing for HTMLEngine"
    end

    %{
      cont: :text,
      tokens: [],
      subengine: subengine,
      substate: subengine.init([]),
      module: module,
      file: Keyword.get(opts, :file, "nofile"),
      indentation: Keyword.get(opts, :indentation, 0)
    }
  end

  ## These callbacks return AST

  @doc false
  def handle_body(%{tokens: tokens, file: file, cont: cont} = state) do
    tokens = HTMLTokenizer.finalize(tokens, file, cont)

    token_state =
      state
      |> token_state()
      |> handle_tokens(tokens)

    validate_unclosed_tags!(token_state)
    opts = [root: token_state.root || false]
    ast = invoke_subengine(token_state, :handle_body, [opts])

    # Do not require if calling module is helpers. Fix for elixir < 1.12
    # TODO remove after Elixir >= 1.12 support
    if state.module === Phoenix.LiveView.Helpers do
      ast
    else
      quote do
        require Phoenix.LiveView.Helpers
        unquote(ast)
      end
    end
  end

  defp validate_unclosed_tags!(%{tags: []} = state) do
    state
  end

  defp validate_unclosed_tags!(%{tags: [tag | _]} = state) do
    {:tag_open, name, _attrs, %{line: line, column: column}} = tag
    file = state.file
    message = "end of file reached without closing tag for <#{name}>"
    raise ParseError, line: line, column: column, file: file, description: message
  end

  @doc false
  def handle_end(state) do
    state
    |> token_state()
    |> handle_tokens(Enum.reverse(state.tokens))
    |> invoke_subengine(:handle_end, [])
  end

  defp token_state(%{subengine: subengine, substate: substate, file: file}) do
    %{
      subengine: subengine,
      substate: substate,
      file: file,
      stack: [],
      tags: [],
      slots: [],
      root: nil
    }
  end

  defp handle_tokens(token_state, tokens) do
    Enum.reduce(tokens, token_state, &handle_token/2)
  end

  ## These callbacks update the state

  @doc false
  def handle_begin(state) do
    update_subengine(%{state | tokens: []}, :handle_begin, [])
  end

  @doc false
  def handle_text(state, text) do
    handle_text(state, [], text)
  end

  def handle_text(state, meta, text) do
    %{file: file, indentation: indentation, tokens: tokens, cont: cont} = state
    {tokens, cont} = HTMLTokenizer.tokenize(text, file, indentation, meta, tokens, cont)
    %{state | tokens: tokens, cont: cont}
  end

  @doc false
  def handle_expr(%{tokens: tokens} = state, marker, expr) do
    %{state | tokens: [{:expr, marker, expr} | tokens]}
  end

  ## Helpers

  defp push_substate_to_stack(%{substate: substate, stack: stack} = state) do
    %{state | stack: [{:substate, substate} | stack]}
  end

  defp pop_substate_from_stack(%{stack: [{:substate, substate} | stack]} = state) do
    %{state | stack: stack, substate: substate}
  end

  defp invoke_subengine(%{subengine: subengine, substate: substate}, :handle_text, args) do
    # TODO: Remove this once we require Elixir v1.12
    if function_exported?(subengine, :handle_text, 3) do
      apply(subengine, :handle_text, [substate | args])
    else
      apply(subengine, :handle_text, [substate | tl(args)])
    end
  end

  defp invoke_subengine(%{subengine: subengine, substate: substate}, fun, args) do
    apply(subengine, fun, [substate | args])
  end

  defp update_subengine(state, fun, args) do
    %{state | substate: invoke_subengine(state, fun, args)}
  end

  defp init_slots(state) do
    %{state | slots: [[] | state.slots]}
  end

  defp add_slot!(
         %{slots: [slots | other_slots], tags: [{:tag_open, <<first, _::binary>>, _, _} | _]} =
           state,
         slot,
         _meta
       )
       when first in ?A..?Z or first == ?. do
    %{state | slots: [[slot | slots] | other_slots]}
  end

  defp add_slot!(state, slot, meta) do
    %{line: line, column: column} = meta
    {slot_name, _} = slot
    file = state.file

    message =
      "invalid slot entry <:#{slot_name}>. A slot entry must be a direct child of a component"

    raise ParseError, line: line, column: column, file: file, description: message
  end

  defp pop_slots(%{slots: [slots | other_slots]} = state) do
    grouped =
      slots
      |> Enum.reverse()
      |> Enum.group_by(&elem(&1, 0), fn {_name, slot_ast} -> slot_ast end)
      |> Map.to_list()

    {grouped, %{state | slots: other_slots}}
  end

  defp push_tag(state, token) do
    # If we have a void tag, we don't actually push it into the stack.
    with {:tag_open, name, _attrs, _meta} <- token,
         true <- void?(name) do
      state
    else
      _ -> %{state | tags: [token | state.tags]}
    end
  end

  defp pop_tag!(
         %{tags: [{:tag_open, tag_name, _attrs, _meta} = tag | tags]} = state,
         {:tag_close, tag_name, _}
       ) do
    {tag, %{state | tags: tags}}
  end

  defp pop_tag!(
         %{tags: [{:tag_open, tag_open_name, _attrs, tag_open_meta} | _]} = state,
         {:tag_close, tag_close_name, tag_close_meta}
       ) do
    %{line: line, column: column} = tag_close_meta
    file = state.file

    message = """
    unmatched closing tag. Expected </#{tag_open_name}> for <#{tag_open_name}> \
    at line #{tag_open_meta.line}, got: </#{tag_close_name}>\
    """

    raise ParseError, line: line, column: column, file: file, description: message
  end

  defp pop_tag!(state, {:tag_close, tag_name, tag_meta}) do
    %{line: line, column: column} = tag_meta
    file = state.file
    message = "missing opening tag for </#{tag_name}>"
    raise ParseError, line: line, column: column, file: file, description: message
  end

  ## handle_token

  # Expr

  defp handle_token({:expr, marker, expr}, state) do
    state
    |> set_root_on_not_tag()
    |> update_subengine(:handle_expr, [marker, expr])
  end

  # Text

  defp handle_token({:text, text, %{line_end: line, column_end: column}}, state) do
    state
    |> set_root_on_not_tag()
    |> update_subengine(:handle_text, [[line: line, column: column], text])
  end

  # Remote function component (self close)

  defp handle_token(
         {:tag_open, <<first, _::binary>> = tag_name, attrs, %{self_close: true} = tag_meta},
         state
       )
       when first in ?A..?Z do
    file = state.file
    {mod, fun} = decompose_remote_component_tag!(tag_name, tag_meta, file)
    {assigns, state} = build_self_close_component_assigns(attrs, tag_meta.line, state)

    ast =
      quote line: tag_meta.line do
        Phoenix.LiveView.Helpers.component(&(unquote(mod).unquote(fun) / 1), unquote(assigns))
      end

    state
    |> set_root_on_not_tag()
    |> update_subengine(:handle_expr, ["=", ast])
  end

  # Remote function component (with inner content)

  defp handle_token({:tag_open, <<first, _::binary>> = tag_name, attrs, tag_meta}, state)
       when first in ?A..?Z do
    mod_fun = decompose_remote_component_tag!(tag_name, tag_meta, state.file)
    token = {:tag_open, tag_name, attrs, Map.put(tag_meta, :mod_fun, mod_fun)}

    state
    |> set_root_on_not_tag()
    |> push_tag(token)
    |> init_slots()
    |> push_substate_to_stack()
    |> update_subengine(:handle_begin, [])
  end

  defp handle_token({:tag_close, <<first, _::binary>>, _tag_close_meta} = token, state)
       when first in ?A..?Z do
    {{:tag_open, _name, attrs, %{mod_fun: {mod, fun}, line: line}}, state} =
      pop_tag!(state, token)

    {assigns, state} = build_component_assigns(attrs, line, state)

    ast =
      quote line: line do
        Phoenix.LiveView.Helpers.component(&(unquote(mod).unquote(fun) / 1), unquote(assigns))
      end

    state
    |> pop_substate_from_stack()
    |> update_subengine(:handle_expr, ["=", ast])
  end

  # Local function component (self close)

  defp handle_token(
         {:tag_open, "." <> name, attrs, %{self_close: true, line: line}},
         state
       ) do
    fun = String.to_atom(name)
    {assigns, state} = build_self_close_component_assigns(attrs, line, state)

    ast =
      quote line: line do
        Phoenix.LiveView.Helpers.component(
          &(unquote(Macro.var(fun, __MODULE__)) / 1),
          unquote(assigns)
        )
      end

    state
    |> set_root_on_not_tag()
    |> update_subengine(:handle_expr, ["=", ast])
  end

  # Slot

  defp handle_token({:tag_open, ":inner_block", _attrs, meta}, state) do
    raise ParseError,
      line: meta.line,
      column: meta.column,
      file: state.file,
      description: "the slot name :inner_block is reserved"
  end

  # Slot (self close)

  defp handle_token({:tag_open, ":" <> slot_name, attrs, %{self_close: true} = tag_meta}, state) do
    %{line: line} = tag_meta
    slot_key = String.to_atom(slot_name)

    {let, roots, attrs} = split_component_attrs(attrs, state.file)

    with {_, let_meta} <- let do
      raise ParseError,
        line: let_meta.line,
        column: let_meta.column,
        file: state.file,
        description: "cannot use `let` on a slot without inner content"
    end

    attrs = [inner_block: nil, __slot__: slot_key] ++ attrs
    assigns = merge_component_attrs(roots, attrs, line)
    add_slot!(state, {slot_key, assigns}, tag_meta)
  end

  # Slot (with inner content)

  defp handle_token({:tag_open, ":" <> _, _attrs, _tag_meta} = token, state) do
    state
    |> push_tag(token)
    |> push_substate_to_stack()
    |> update_subengine(:handle_begin, [])
  end

  defp handle_token({:tag_close, ":" <> slot_name, _tag_close_meta} = token, state) do
    {{:tag_open, _name, attrs, %{line: line} = tag_meta}, state} = pop_tag!(state, token)
    slot_key = String.to_atom(slot_name)

    {let, roots, attrs} = split_component_attrs(attrs, state.file)
    clauses = build_component_clauses(let, state)

    ast =
      quote line: line do
        Phoenix.LiveView.Helpers.inner_block(unquote(slot_key), do: unquote(clauses))
      end

    attrs = [__slot__: slot_key, inner_block: ast] ++ attrs
    assigns = merge_component_attrs(roots, attrs, line)

    state
    |> add_slot!({slot_key, assigns}, tag_meta)
    |> pop_substate_from_stack()
  end

  # Local function component (with inner content)

  defp handle_token({:tag_open, "." <> _, _attrs, _tag_meta} = token, state) do
    state
    |> set_root_on_not_tag()
    |> push_tag(token)
    |> init_slots()
    |> push_substate_to_stack()
    |> update_subengine(:handle_begin, [])
  end

  defp handle_token({:tag_close, "." <> fun_name, _tag_close_meta} = token, state) do
    {{:tag_open, _name, attrs, %{line: line}}, state} = pop_tag!(state, token)
    fun = String.to_atom(fun_name)
    {assigns, state} = build_component_assigns(attrs, line, state)

    ast =
      quote line: line do
        Phoenix.LiveView.Helpers.component(
          &(unquote(Macro.var(fun, __MODULE__)) / 1),
          unquote(assigns)
        )
      end

    state
    |> pop_substate_from_stack()
    |> update_subengine(:handle_expr, ["=", ast])
  end

  # HTML element (self close)

  defp handle_token({:tag_open, name, attrs, %{self_close: true} = tag_meta}, state) do
    suffix = if void?(name), do: ">", else: "></#{name}>"

    state
    |> set_root_on_tag()
    |> handle_tag_and_attrs(name, attrs, suffix, to_location(tag_meta))
  end

  # HTML element

  defp handle_token({:tag_open, name, attrs, tag_meta} = token, state) do
    state
    |> set_root_on_tag()
    |> push_tag(token)
    |> handle_tag_and_attrs(name, attrs, ">", to_location(tag_meta))
  end

  defp handle_token({:tag_close, name, tag_meta} = token, state) do
    {{:tag_open, _name, _attrs, _tag_meta}, state} = pop_tag!(state, token)
    update_subengine(state, :handle_text, [to_location(tag_meta), "</#{name}>"])
  end

  # Root tracking

  defp set_root_on_not_tag(%{root: root, tags: tags} = state) do
    if tags == [] and root != false do
      %{state | root: false}
    else
      state
    end
  end

  defp set_root_on_tag(state) do
    case state do
      %{root: nil, tags: []} -> %{state | root: true}
      %{root: true, tags: []} -> %{state | root: false}
      %{root: bool} when is_boolean(bool) -> state
    end
  end

  ## handle_tag_and_attrs

  defp handle_tag_and_attrs(state, name, attrs, suffix, meta) do
    state
    |> update_subengine(:handle_text, [meta, "<#{name}"])
    |> handle_tag_attrs(meta, attrs)
    |> update_subengine(:handle_text, [meta, suffix])
  end

  defp handle_tag_attrs(state, meta, attrs) do
    Enum.reduce(attrs, state, fn
      {:root, {:expr, value, %{line: line, column: col}}}, state ->
        attrs = Code.string_to_quoted!(value, line: line, column: col, file: state.file)
        handle_attrs_escape(state, meta, attrs)

      {name, {:expr, value, %{line: line, column: col}}}, state ->
        attr = Code.string_to_quoted!(value, line: line, column: col, file: state.file)
        handle_attr_escape(state, meta, name, attr)

      {name, {:string, value, %{delimiter: ?"}}}, state ->
        update_subengine(state, :handle_text, [meta, ~s( #{name}="#{value}")])

      {name, {:string, value, %{delimiter: ?'}}}, state ->
        update_subengine(state, :handle_text, [meta, ~s( #{name}='#{value}')])

      {name, nil}, state ->
        update_subengine(state, :handle_text, [meta, " #{name}"])
    end)
  end

  defp handle_attrs_escape(state, meta, attrs) do
    ast =
      quote line: meta[:line] do
        Phoenix.HTML.attributes_escape(unquote(attrs))
      end

    update_subengine(state, :handle_expr, ["=", ast])
  end

  defp handle_attr_escape(state, meta, name, value) do
    case extract_binaries(value, true, []) do
      :error ->
        if call = empty_attribute_encoder(name, value, meta) do
          state
          |> update_subengine(:handle_text, [meta, ~s( #{name}=")])
          |> update_subengine(:handle_expr, ["=", {:safe, call}])
          |> update_subengine(:handle_text, [meta, ~s(")])
        else
          handle_attrs_escape(state, meta, [{safe_unless_special(name), value}])
        end

      binaries ->
        state
        |> update_subengine(:handle_text, [meta, ~s( #{name}=")])
        |> handle_binaries(meta, binaries)
        |> update_subengine(:handle_text, [meta, ~s(")])
    end
  end

  defp handle_binaries(state, meta, binaries) do
    binaries
    |> Enum.reverse()
    |> Enum.reduce(state, fn
      {:text, value}, state ->
        update_subengine(state, :handle_text, [meta, binary_encode(value)])

      {:binary, value}, state ->
        ast =
          quote line: meta[:line] do
            {:safe, unquote(__MODULE__).binary_encode(unquote(value))}
          end

        update_subengine(state, :handle_expr, ["=", ast])
    end)
  end

  defp extract_binaries({:<>, _, [left, right]}, _root?, acc) do
    extract_binaries(right, false, extract_binaries(left, false, acc))
  end

  defp extract_binaries({:<<>>, _, parts} = bin, _root?, acc) do
    Enum.reduce(parts, acc, fn
      part, acc when is_binary(part) ->
        [{:text, part} | acc]

      {:"::", _, [binary, {:binary, _, _}]}, acc ->
        [{:binary, binary} | acc]

      _, _ ->
        throw(:unknown_part)
    end)
  catch
    :unknown_part -> [{:binary, bin} | acc]
  end

  defp extract_binaries(binary, _root?, acc) when is_binary(binary), do: [{:text, binary} | acc]
  defp extract_binaries(value, false, acc), do: [{:binary, value} | acc]
  defp extract_binaries(_value, true, _acc), do: :error

  # TODO: We can refactor the empty_attribute_encoder to simply return an atom
  # but there is a bug in Elixir v1.12 and earlier where mixing `line: expr`
  # with .unquote(fun) leads to bugs in line numbers.
  defp empty_attribute_encoder("class", value, meta) do
    quote line: meta[:line], do: unquote(__MODULE__).class_attribute_encode(unquote(value))
  end

  defp empty_attribute_encoder("style", value, meta) do
    quote line: meta[:line], do: unquote(__MODULE__).empty_attribute_encode(unquote(value))
  end

  defp empty_attribute_encoder(_, _, _), do: nil

  @doc false
  def class_attribute_encode([_ | _] = list),
    do: list |> Enum.filter(& &1) |> Enum.join(" ") |> Phoenix.HTML.Engine.encode_to_iodata!()

  def class_attribute_encode(other),
    do: empty_attribute_encode(other)

  @doc false
  def empty_attribute_encode(nil), do: ""
  def empty_attribute_encode(false), do: ""
  def empty_attribute_encode(true), do: ""
  def empty_attribute_encode(value), do: Phoenix.HTML.Engine.encode_to_iodata!(value)

  @doc false
  def binary_encode(value) when is_binary(value) do
    value
    |> Phoenix.HTML.Engine.encode_to_iodata!()
    |> IO.iodata_to_binary()
  end

  def binary_encode(value) do
    raise ArgumentError, "expected a binary in <>, got: #{inspect(value)}"
  end

  defp safe_unless_special("aria"), do: "aria"
  defp safe_unless_special("class"), do: "class"
  defp safe_unless_special("data"), do: "data"
  defp safe_unless_special(name), do: {:safe, name}

  ## build_self_close_component_assigns/build_component_assigns

  defp build_self_close_component_assigns(attrs, line, %{file: file} = state) do
    {let, roots, attrs} = split_component_attrs(attrs, file)
    raise_if_let!(let, file)
    {merge_component_attrs(roots, attrs, line), state}
  end

  defp build_component_assigns(attrs, line, %{file: file} = state) do
    {let, roots, attrs} = split_component_attrs(attrs, file)
    clauses = build_component_clauses(let, state)

    inner_block_assigns =
      quote line: line do
        %{__slot__: :inner_block,
          inner_block: Phoenix.LiveView.Helpers.inner_block(:inner_block, do: unquote(clauses))}
      end

    {slots, state} = pop_slots(state)
    attrs = attrs ++ [{:inner_block, [inner_block_assigns]} | slots]
    {merge_component_attrs(roots, attrs, line), state}
  end

  defp split_component_attrs(attrs, file) do
    attrs
    |> Enum.reverse()
    |> Enum.reduce({nil, [], []}, &split_component_attr(&1, &2, file))
  end

  defp split_component_attr(
         {:root, {:expr, value, %{line: line, column: col}}},
         {let, r, a},
         file
       ) do
    quoted_value = Code.string_to_quoted!(value, line: line, column: col, file: file)
    quoted_value = quote line: line, do: Map.new(unquote(quoted_value))
    {let, [quoted_value | r], a}
  end

  defp split_component_attr(
         {"let", {:expr, value, %{line: line, column: col} = meta}},
         {nil, r, a},
         file
       ) do
    quoted_value = Code.string_to_quoted!(value, line: line, column: col, file: file)
    {{quoted_value, meta}, r, a}
  end

  defp split_component_attr(
         {"let", {:expr, _value, previous_meta}},
         {{_, meta}, _, _},
         file
       ) do
    message = """
    cannot define multiple `let` attributes. \
    Another `let` has already been defined at line #{previous_meta.line}\
    """

    raise ParseError,
      line: meta.line,
      column: meta.column,
      file: file,
      description: message
  end

  defp split_component_attr(
         {name, {:expr, value, %{line: line, column: col}}},
         {let, r, a},
         file
       ) do
    quoted_value = Code.string_to_quoted!(value, line: line, column: col, file: file)
    {let, r, [{String.to_atom(name), quoted_value} | a]}
  end

  defp split_component_attr({name, {:string, value, _}}, {let, r, a}, _file) do
    {let, r, [{String.to_atom(name), value} | a]}
  end

  defp split_component_attr({name, nil}, {let, r, a}, _file) do
    {let, r, [{String.to_atom(name), true} | a]}
  end

  defp merge_component_attrs(roots, attrs, line) do
    entries =
      case {roots, attrs} do
        {[], []} -> [{:%{}, [], []}]
        {_, []} -> roots
        {_, _} -> roots ++ [{:%{}, [], attrs}]
      end

    Enum.reduce(entries, fn expr, acc ->
      quote line: line, do: Map.merge(unquote(acc), unquote(expr))
    end)
  end

  defp decompose_remote_component_tag!(tag_name, tag_meta, file) do
    case String.split(tag_name, ".") |> Enum.reverse() do
      [<<first, _::binary>> = fun_name | rest] when first in ?a..?z ->
        aliases = rest |> Enum.reverse() |> Enum.map(&String.to_atom/1)
        fun = String.to_atom(fun_name)
        {{:__aliases__, [], aliases}, fun}

      _ ->
        %{line: line, column: column} = tag_meta
        message = "invalid tag <#{tag_name}>"
        raise ParseError, line: line, column: column, file: file, description: message
    end
  end

  @doc false
  def __unmatched_let__!(pattern, value) do
    message = """
    cannot match arguments sent from `render_slot/2` against the pattern in `let`.

    Expected a value matching `#{pattern}`, got: `#{inspect(value)}`.
    """

    stacktrace =
      self()
      |> Process.info(:current_stacktrace)
      |> elem(1)
      |> Enum.drop(2)

    reraise(message, stacktrace)
  end

  defp raise_if_let!(let, file) do
    with {_pattern, %{line: line}} <- let do
      message = "cannot use `let` on a component without inner content"
      raise CompileError, line: line, file: file, description: message
    end
  end

  defp build_component_clauses(let, state) do
    case let do
      {pattern, %{line: line}} ->
        quote line: line do
          unquote(pattern) ->
            unquote(invoke_subengine(state, :handle_end, []))
        end ++
          quote line: line, generated: true do
            other ->
              Phoenix.LiveView.HTMLEngine.__unmatched_let__!(
                unquote(Macro.to_string(pattern)),
                other
              )
          end

      _ ->
        quote do
          _ -> unquote(invoke_subengine(state, :handle_end, []))
        end
    end
  end

  ## Helpers

  for void <- ~w(area base br col hr img input link meta param command keygen source) do
    defp void?(unquote(void)), do: true
  end

  defp void?(_), do: false

  defp to_location(%{line: line, column: column}), do: [line: line, column: column]
end