lib/svx/compiler.ex

defmodule Svx.Compiler do
  require Logger
  use GenServer

  @extensions ~w(.lsvx .svx)

  defstruct [:path, :namespace, :css_output_path, :js_output_path, :module_map]

  defmodule ParseError do
    @moduledoc false
    defexception [:file, :line, :column, :description]

    @impl true
    def message(exception) do
      location =
        exception.file
        |> Path.relative_to_cwd()
        |> format_file_line_column(exception.line, exception.column)

      "#{location} #{exception.description}"
    end

    # Use Exception.format_file_line_column/4 instead when support
    # for Elixir < v1.11 is removed.
    def format_file_line_column(file, line, column, suffix \\ "") do
      cond do
        is_nil(file) -> ""
        is_nil(line) or line == 0 -> "#{file}:#{suffix}"
        is_nil(column) or column == 0 -> "#{file}:#{line}:#{suffix}"
        true -> "#{file}:#{line}:#{column}:#{suffix}"
      end
    end
  end

  ##-------------------------------------------------------------------------##
  # GenServer
  ##-------------------------------------------------------------------------##

  def start_link(opts \\ []) do
    opts[:path] || raise ArgumentError, message: "invalid option :path"
    opts[:namespace] || raise ArgumentError, message: "invalid option :namespace"

    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl true
  @spec init(%__MODULE__{}) :: {:ok, %__MODULE__{}}
  def init(opts) do
    assets_path = Path.absname("")
                  |> Path.join("assets")

    default_css_output_path = assets_path
                              |> Path.join("css")
                              |> Path.join("generated.css")

    default_js_output_path = assets_path
                             |> Path.join("js")
                             |> Path.join("generated.js")

    css_output_path = opts[:css_output_path] || default_css_output_path
    js_output_path = opts[:js_output_path] || default_js_output_path

    path = Path.absname(opts[:path])

    state = %__MODULE__{
      path: path,
      namespace: opts[:namespace],
      css_output_path: css_output_path,
      js_output_path: js_output_path
    }

    Logger.info("Svx starting with the following options: #{inspect state}")

    {:ok, _pid} =
      Sentix.start_link(:templates, [path], recursive: true, includes: "*.l?svx")

    Sentix.subscribe(:templates)

    module_map = compile_all(path, state)

    {
      :ok,
      state
      |> Map.put(:module_map, module_map)
    }
  end

  ##-------------------------------------------------------------------------##
  # File system events
  ##-------------------------------------------------------------------------##

  @impl true
  def handle_info({_pid, {:fswatch, :file_event}, {file_path, event_list}}, %{module_map: compiled} = state) do

    case Path.extname(file_path) in @extensions do
      true -> cond do
                :updated in event_list or :created in event_list ->
                  compiled = Map.merge(compiled, compile_many([file_path], state))
                  if css_changed?(file_path, compiled, state) do
                    Logger.info("#{Map.get(compiled, file_path).module} will update css")
                    update_css(compiled, state)
                  end
                  {:noreply, %{state | module_map: compiled}}
                :removed in event_list ->
                  # TODO: remove module
                  {:noreply, state}
              end
      false -> {:noreply, state}
    end

  end

  ##-------------------------------------------------------------------------##
  # Internal functionality
  ##-------------------------------------------------------------------------##

  def compile_all(path, state) do
    Logger.info("Recompiling all files in #{path}")
    compiled = ls_r(path)
               |> Enum.filter(fn file -> Path.extname(file) in @extensions end)
               |> Enum.chunk_every(4)
               |> Enum.map(
                    &Task.async(fn -> compile_many(&1, state) end)
                  )
               |> Task.await_many()
               |> Enum.reduce(
                    %{},
                    fn results, acc ->
                      acc
                      |> Map.merge(results)
                    end
                  )

    update_css(compiled, state)

    compiled
  end

  def compile_many(files, state) do
    files
    |> Enum.reduce(
         %{},
         fn file, acc ->
           relative_path = file
                           |> Path.relative_to(state.path)

           module_name = to_module_name(
             relative_path,
             state.namespace
           )

           Logger.info("Compiling #{module_name} (#{relative_path})")

           try do
             {:ok, content} = File.read(file)

             result = get_module(file, module_name, content, is_live?(file))

             Code.compiler_options(ignore_module_conflict: true)
             Code.compile_quoted(result.module, file)
             Code.compiler_options(ignore_module_conflict: false)

             Map.put(
               acc,
               file,
               result
               |> Map.put(:module, module_name)
             )
           rescue
             e ->
               formatted = Exception.format(:error, e, __STACKTRACE__) |> Phoenix.HTML.html_escape |> elem(1)
               Logger.error(formatted)

               module = """
                        defmodule #{module_name} do
                          use Phoenix.LiveView
                          import Phoenix.LiveView.Helpers

                          def render(assigns) do
                          ~H\"\"\"
                        <pre style=\"font-size: 1.2em; color: red; padding: 0.5em; width: 80ch; margin:auto; white-space: pre-wrap; overflow-wrap: break-word\">
                        #{formatted}
                        </pre>
                        \"\"\"
                          end
                        end
                        """
                        |> Code.string_to_quoted()
                        |> elem(1)

               Code.compiler_options(ignore_module_conflict: true)
               Code.compile_quoted(module, file)
               Code.compiler_options(ignore_module_conflict: false)

               Map.put(
                 acc,
                 file,
                 %{module: module, css: [], module_name: module_name}
               )
           end
         end
       )
  end

  defp ls_r(path) do
    cond do
      File.regular?(path) ->
        [path]

      File.dir?(path) ->
        File.ls!(path)
        |> Enum.map(&Path.join(path, &1))
        |> Enum.map(&ls_r/1)
        |> Enum.concat()

      true ->
        []
    end
  end

  defp to_module_name(path, namespace) do
    module_name =
      path
      #|> Path.relative_to("lib/")
      |> Path.rootname() # "some/path_chunks/with/file_name"
      |> Path.split() # ["some", "path_chunks", "with", "file_name"]
        # convert ["some", "path_chunks", "with", "file_name"]
        # to ["Some", "PathChunks", "With", "FileName"]
      |> Enum.map(
           fn chunk ->
             # chunk may contain underscore
             # we split them and uppercase them
             # path_chunk -> ["path", "chunk"] -> ["Path", "Chunk"] -> PathChunk
             chunk
             |> String.split("_")
             |> Enum.map(
                  fn lowercase ->
                    with <<first :: utf8, rest :: binary>> <- lowercase,
                         do: String.upcase(<<first :: utf8>>) <> rest
                  end
                )
             |> Enum.join("")
           end
         )
      |> Enum.join(".")
    "#{namespace}.#{module_name}"
  end

  defp is_live?(file), do: Path.extname(file) == ".lsvx"

  defp get_module(_file, _module_name, _content, false) do
    #    module = quote do
    #      defmodule unquote(module_name) do
    #        require EEx
    #
    #        EEx.function_from_string(:def, :render, unquote(content), [:assigns], [engine: Phoenix.HTML.Engine])
    #      end
    #    end
    #
    #    %{module: module, css: ""}
    raise "Not implemented yet"
  end

  defp get_module(file, module_name, content, true) do
    parsed = collect_content(content, file)

    module = """
             defmodule #{module_name} do
               import Phoenix.LiveView.Helpers

               #{parsed.module}

               def render(assigns) do
               ~H\"\"\"
             #{parsed.content}
             \"\"\"
               end
             end
             """
             |> Code.string_to_quoted()
             |> elem(1)

    %{module: module, css: IO.iodata_to_binary(parsed.css)}
  end

  defp update_css(compiled, %{css_output_path: output_path}) do
    out = compiled
          |> Enum.reduce(
               "",
               fn ({_, %{css: css}}, acc) ->
                 case css
                      |> IO.iodata_to_binary()
                      |> String.trim() do
                   "" -> acc
                   trimmed -> acc
                              <> "\n\n"
                              <> trimmed
                 end
               end
             )

    File.write(output_path, out)
  end

  defp css_changed?(file_path, compiled, %{module_map: old_compiled}) do
    case Map.get(old_compiled, file_path) do
      nil -> true
      %{css: old_css} -> Map.get(compiled, file_path).css != old_css
    end
  end

  ##-------------------------------------------------------------------------##
  # Parse .lsvx
  ##-------------------------------------------------------------------------##

  defp collect_content(content, file) do
    {:ok, eex_regex} = Regex.compile("(<%)(.|[\r\n\s])+(%>)", [:unicode, :ungreedy])

    content = Regex.replace(
      eex_regex,
      content,
      fn full_match, _ ->
        full_match
        |> String.replace("<%", "OPENING_%_EEX")
        |> String.replace("%>", "CLOSING_%_EEX")
        |> String.replace("<", "BRACKET_%_EEX")
        |> String.replace("%{", "MAP_%_EEX")
      end
    )

    {tokens, _} = content
                  |> String.replace("<%", "OPENING_%_EEX")
                  |> String.replace("%>", "CLOSING_%_EEX")
                  |> String.replace("%{", "MAP_%_EEX")
                  |> Phoenix.LiveView.HTMLTokenizer.tokenize("", 0, [], [], :text)

    collect_tokens(
      Enum.reverse(tokens),
      %{
        module: [],
        content: [],
        css: [],
        file: file
      },
      :content
    )
  end

  defp collect_tokens([], parsed, _) do
    %{
      module: to_content(parsed.module),
      content: to_content(parsed.content),
      css: to_content(parsed.css)
    }
  end
  defp collect_tokens([token | rest], parsed, :content) do
    case token do
      {:tag_open, "script", attrs, _} ->
        case is_module_tag?(attrs) do
          true ->
            assert_not(token, :module, parsed)
            collect_tokens(rest, parsed, :module)
          false -> collect_tokens(rest, %{parsed | content: [token | parsed.content]}, :content)
        end
      {:tag_open, "style", _, _} ->
        assert_not(token, :css, parsed)
        collect_tokens(rest, parsed, :css)
      {:tag_open, _, _, _} -> collect_tokens(rest, %{parsed | content: [token | parsed.content]}, :content)
      {:tag_close, _, _} -> collect_tokens(rest, %{parsed | content: [token | parsed.content]}, :content)
      {:text, _, _} ->
        collect_tokens(rest, %{parsed | content: [token | parsed.content]}, :content)
    end
  end
  defp collect_tokens([token | rest], parsed, :module) do
    case token do
      {:tag_close, "script", _} ->
        collect_tokens(rest, parsed, :content)
      {:text, _, _} ->
        collect_tokens(rest, %{parsed | module: [token | parsed.module]}, :module)
    end
  end
  defp collect_tokens([token | rest], parsed, :css) do
    case token do
      {:tag_close, "style", _} ->
        collect_tokens(rest, parsed, :content)
      {:text, _, _} ->
        collect_tokens(rest, %{parsed | css: [token | parsed.css]}, :css)
    end
  end

  defp is_module_tag?(attrs) do
    Enum.find(attrs, fn {key, {_, value, _}} -> key == "language" and value == "elixir" end) != nil
  end

  defp to_content(lst) when is_list(lst),
       do: lst
           |> Enum.map(&to_content/1)
           |> Enum.reverse()
  defp to_content({:text, text, _}), do: restore_eex(text)
  defp to_content({:tag_open, tag, attrs, _}) do
    "<#{tag} #{attributes_to_content(attrs, [])}>"
  end
  defp to_content({:tag_close, tag, _}) do
    "</#{tag}>"
  end

  defp attributes_to_content([], acc),
       do: acc
           |> Enum.join(" ")
  defp attributes_to_content([{name, {:expr, value, _}} | rest], acc) do
    attributes_to_content(rest, ["#{name}={#{value}}" | acc])
  end
  defp attributes_to_content([{name, {_, value, meta}} | rest], acc) do
    delimiter = Map.get(meta, :delimiter, "")
    attributes_to_content(rest, ["#{name}=#{<<delimiter>>}#{value}#{<<delimiter>>}" | acc])
  end

  defp assert_not({_, _, _, meta}, what, parsed) do
    case parsed[what] do
      [] -> :ok
      _ ->
        raise ParseError,
              line: meta.line, column: meta.column, file: parsed.file, description: "Can only have one #{what} per file"
    end
  end

  defp restore_eex(text) do
    text
    |> String.replace("OPENING_%_EEX", "<%")
    |> String.replace("CLOSING_%_EEX", "%>")
    |> String.replace("BRACKET_%_EEX", "<")
    |> String.replace("MAP_%_EEX", "%{")
  end

end