lib/bylaw/credo/plugin/heex_sources.ex

defmodule Bylaw.Credo.Plugin.HEExSources do
  @moduledoc """
  Loads standalone `.html.heex` templates into Credo source files.

  Credo only parses Elixir source files during its normal source-loading step.
  Enable this plugin when checks need to run against standalone Phoenix HEEx
  templates.

      plugins: [
        {Bylaw.Credo.Plugin.HEExSources, []}
      ]
  """

  import Credo.Plugin

  @commands [
    Credo.CLI.Command.Diff.DiffCommand,
    Credo.CLI.Command.Info.InfoCommand,
    Credo.CLI.Command.List.ListCommand,
    Credo.CLI.Command.Suggest.SuggestCommand
  ]

  # Registers the HEEx source-loading task in Credo's command pipelines.
  @doc false
  @spec init(term()) :: term()
  def init(exec) do
    Enum.reduce(@commands, exec, fn command, exec ->
      append_task(exec, command, :load_and_validate_source_files, __MODULE__.LoadSourceFiles)
    end)
  end

  defmodule LoadSourceFiles do
    @moduledoc false

    use Credo.Execution.Task

    alias Credo.Execution
    alias Credo.SourceFile
    alias Credo.Service.SourceFileAST
    alias Credo.Service.SourceFileLines
    alias Credo.Service.SourceFileSource

    @extension ".html.heex"
    @wildcard "**/*.html.heex"

    @doc false
    @spec call(Execution.t(), keyword()) :: Execution.t()
    def call(%Execution{} = exec, _opts \\ []) do
      source_files = Execution.get_source_files(exec)

      known_filenames =
        MapSet.new(source_files, &Path.expand(&1.filename, Execution.working_dir(exec)))

      heex_source_files =
        exec
        |> find_filenames()
        |> Enum.reject(&MapSet.member?(known_filenames, &1))
        |> Enum.map(&to_source_file/1)

      Execution.put_source_files(exec, source_files ++ heex_source_files)
    end

    @doc false
    @spec find_filenames(Execution.t()) :: list(String.t())
    def find_filenames(%Execution{files: %{included: included, excluded: excluded}} = exec) do
      working_dir = Execution.working_dir(exec)
      excluded = List.wrap(excluded)

      included
      |> List.wrap()
      |> Enum.flat_map(&find_included(working_dir, &1))
      |> Enum.uniq()
      |> Enum.reject(&excluded?(working_dir, &1, excluded))
      |> Enum.sort()
    end

    def find_filenames(%Execution{}), do: []

    defp find_included(working_dir, pattern) when is_binary(pattern) do
      path = Path.expand(pattern, working_dir)

      cond do
        String.ends_with?(path, @extension) and File.regular?(path) ->
          [path]

        File.dir?(path) ->
          path
          |> Path.join(@wildcard)
          |> Path.wildcard()

        wildcard?(path) ->
          path
          |> heex_wildcards()
          |> Enum.flat_map(&Path.wildcard/1)
          |> Enum.flat_map(&find_path/1)

        true ->
          []
      end
    end

    defp find_included(_working_dir, _pattern), do: []

    defp find_path(path) do
      cond do
        String.ends_with?(path, @extension) and File.regular?(path) ->
          [Path.expand(path)]

        File.dir?(path) ->
          path
          |> Path.join(@wildcard)
          |> Path.wildcard()

        true ->
          []
      end
    end

    defp wildcard?(path) do
      String.contains?(path, ["*", "{", "}", "?", "[", "]"])
    end

    defp heex_wildcards(path) do
      [path | derived_heex_wildcards(path)]
      |> Enum.uniq()
    end

    defp derived_heex_wildcards(path) do
      if String.ends_with?(path, "*.{ex,exs}") do
        [String.replace_suffix(path, "*.{ex,exs}", "*.html.heex")]
      else
        []
      end
    end

    defp excluded?(working_dir, filename, excluded_patterns) do
      relative_filename = Path.relative_to(filename, working_dir)

      Enum.any?(excluded_patterns, fn
        pattern when is_binary(pattern) ->
          expanded_pattern = Path.expand(pattern, working_dir)

          Credo.Sources.filename_matches?(filename, expanded_pattern) ||
            Credo.Sources.filename_matches?(relative_filename, pattern)

        %Regex{} = pattern ->
          String.match?(filename, pattern) || String.match?(relative_filename, pattern)

        _pattern ->
          false
      end)
    end

    defp to_source_file(filename) do
      source = File.read!(filename)
      lines = Credo.Code.to_lines(source)

      hash =
        :sha256
        |> :crypto.hash(source)
        |> Base.encode16()

      source_file = %SourceFile{
        filename: Path.relative_to_cwd(filename),
        hash: hash,
        status: :valid
      }

      SourceFileAST.put(source_file, [])
      SourceFileLines.put(source_file, lines)
      SourceFileSource.put(source_file, source)

      source_file
    end
  end
end