lib/mix/tasks/qs.gen.add_tailwind.ex

defmodule Mix.Tasks.Qs.Gen.AddTailwind do
  use Mix.Task

  @tailwind_config """
  config :tailwind,
    version: "3.0.23",
    default: [
      args: ~w(
        --config=tailwind.config.js
        --input=css/app.css
        --output=../priv/static/assets/app.css
      ),
      cd: Path.expand("../assets", __DIR__)
    ]
  """

  @tailwind_dep "\t\t\t{:tailwind, \"~> 0.1\", runtime: Mix.env() == :dev},"
  @tailwind_watcher "\t\ttailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}"

  @impl Mix.Task
  def run(_args) do
    add_into(
      "mix.exs",
      fn
        {:defp, _, [{:deps, _, nil}, _]} -> true
        _ -> false
      end,
      @tailwind_dep
    )

    add_after(
      "config/config.exs",
      fn
        {:config, _, [:esbuild, _]} -> true
        _ -> false
      end,
      @tailwind_config
    )

    add_into(
      "config/dev.exs",
      fn node ->
        Keyword.keyword?(node) and Keyword.has_key?(node, :watchers)
      end,
      @tailwind_watcher
    )
  end

  defp add_into(file_name, is_match, addition), do: add_to_file(file_name, is_match, 0, addition)
  defp add_after(file_name, is_match, addition), do: add_to_file(file_name, is_match, 2, addition)

  defp add_to_file(file_name, is_match, line_offset, addition) do
    contents = File.read!(file_name)
    ast = Code.string_to_quoted!(contents)
    {_start, max_line} = find_span(ast, is_match)

    if not is_nil(max_line) do
      Mix.shell().info("Patching #{file_name}")

      contents =
        contents
        |> String.split("\n")
        |> List.insert_at(max_line + line_offset, addition)
        |> Enum.join("\n")

      File.write!(file_name, contents)
    end
  end

  defp find_span_end(node) do
    {_, max_line} =
      Macro.postwalk(node, 0, fn item, acc ->
        case item do
          {_, [line: line], _} -> {item, max(acc, line)}
          node -> {node, acc}
        end
      end)

    max_line
  end

  defp find_span(ast, is_match) do
    {_, span} =
      Macro.postwalk(ast, nil, fn node, acc ->
        if is_match.(node) do
          case node do
            {_, [line: line], _} ->
              {node, {line, find_span_end(node)}}

            _ ->
              {node, {0, find_span_end(node)}}
          end
        else
          {node, acc}
        end
      end)

    span
  end
end