core/mix/propagate_file_modifications.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

defmodule Mix.Tasks.Compile.PropagateFileModifications do
  @shortdoc "Touches source files that need to be recompiled"

  @moduledoc """
  #{@shortdoc}.

  Normally mix automatically resolves which source files to recompile based on file modification times.
  However some macro-related code modifications do not trigger recompilation.
  This mix task finds those kinds of modifications and touch files to compile so that subsequent task recompiles them.

  Note that `@external_resource` module attribute does not support directory as external dependency of a module.
  For a module that depends on all files under a directory, we have to manually check the latest modification time
  of the whole directory tree.

  Current targets of this task are:

  - `mix.exs` files in gear projects depend on `mix_common.exs` in the antikythera repository.
    Mix assumes that project configurations reside only in `mix.exs` and `config/*.exs`,
    so even if `mix_common.exs` has been modified mix does not notice.
    Note that antikythera's `mix.exs` also depends on `mix_common.exs` but it's handled differently
    (at the beginning of `mix.exs`), because we can't run mix tasks before it's compiled!
  - `SomeGear.Template` module depends on all existing HAML templates in `web/template/`.
  - Similarly, `SomeGear.Asset` module depends on all asset files in `priv/static/`.
    Although `SomeGear.Asset` is recompiled after modifying an asset file, it doesn't automatically trigger
    recompilation/reload in iex session because `priv/static/**` is not monitored by `:exsync` (in current configuration).
  """

  use Mix.Task.Compiler

  def run(_) do
    mix_common_path = Path.join([__DIR__, "..", "..", "mix_common.exs"])
    touch_if_older_than_any("mix.exs", [mix_common_path])
    touch_if_older_than_any_in_dir(Path.join("web", "template.ex"), Path.join("web", "template"))
    touch_if_older_than_any_in_dir(Path.join("web", "asset.ex"), Path.join("priv", "static"))
    {:ok, []}
  end

  defp touch_if_older_than_any(target, dependencies) do
    case File.stat(target) do
      {:ok, %File.Stat{mtime: mtime}} ->
        if newer_dependency_exists?(mtime, dependencies) do
          File.touch!(target)
        end

      {:error, :enoent} ->
        :ok
    end
  end

  defp newer_dependency_exists?(target_mtime, dependencies) do
    Enum.any?(dependencies, fn d ->
      case File.stat(d) do
        {:ok, stat} -> target_mtime < stat.mtime
        # neglect nonexisting entry (probably due to absence of toplevel of the directory tree)
        {:error, :enoent} -> false
      end
    end)
  end

  defp touch_if_older_than_any_in_dir(target, dir) do
    paths = Path.wildcard(Path.join(dir, "**"))
    touch_if_older_than_any(target, [dir | paths])
  end
end