lib/deep_sinker.ex

defmodule DeepSinker do
  @moduledoc """
  Customizable file traverser.
  """

  use TypedStruct

  @type filepath :: String.t()
  @type item_path :: String.t()
  @type order :: :asc | :desc
  @type item_type :: :file | :directory | :ignore
  @type handler :: (item_path() -> item_type())

  typedstruct do
    @typedoc "State of traversing."

    field :root_items, [item_path()], enforce: true
    field :order, order, enforce: true
    field :found_but_not_used_yet_items, [item_path()], enforce: true
  end

  @doc """
  Create initial state.

  ## Examples

      iex> DeepSinker.new(["/path/to/dir1", "/path/to/dir2"])
      ...> |> is_struct(DeepSinker)
      true

      iex> DeepSinker.new(["/path/to/dir1", "/path/to/dir2"], order: :desc)
      ...> |> is_struct(DeepSinker)
      true
  """
  @type new_option :: {:order, order()}
  @spec new([item_path()]) :: DeepSinker.t()
  @spec new([item_path()], [new_option()]) :: DeepSinker.t()
  def new(root_items, opt \\ []) do
    order = Keyword.get(opt, :order, :asc)
    found_items = Enum.sort(root_items, order)

    %DeepSinker{
      root_items: root_items,
      order: order,
      found_but_not_used_yet_items: found_items
    }
  end

  @doc """
  Pop file and update state.

  ## Examples

      iex> DeepSinker.new(["./"])
      ...> |> DeepSinker.next()
      ...> # {state, {:ok, filepath}} | {state, :done}

      iex> DeepSinker.new(["./"])
      ...> |> DeepSinker.next(
      ...>   handler: fn item_path ->
      ...>     cond do
      ...>       String.ends_with?(item_path, ".git") -> :ignore
      ...>       String.contains?(item_path, ".") -> :file
      ...>       true -> :directory
      ...>     end
      ...>   end
      ...> )
      ...> # {state, {:ok, filepath}} | {state, :done}
  """
  @type next_option :: {:handler, handler()}
  @type next_result :: {:ok, filepath()} | :done
  @spec next(DeepSinker.t()) :: {DeepSinker.t(), next_result()}
  @spec next(DeepSinker.t(), [next_option()]) :: {DeepSinker.t(), next_result()}
  def next(state, opt \\ [])

  def next(%DeepSinker{found_but_not_used_yet_items: []} = state, _opt) do
    # There are no files should search
    {state, :done}
  end

  def next(
        %DeepSinker{
          found_but_not_used_yet_items: [item_path | items_not_used_yet],
          order: order
        } = state,
        opt
      ) do
    handler = Keyword.get(opt, :handler, &default_handler/1)

    case handler.(item_path) do
      :file ->
        state = %{state | found_but_not_used_yet_items: items_not_used_yet}
        {state, {:ok, item_path}}

      :directory ->
        children = find_children(item_path, order)
        items_not_used_yet = children ++ items_not_used_yet
        state = %{state | found_but_not_used_yet_items: items_not_used_yet}
        next(state, opt)

      :ignore ->
        state = %{state | found_but_not_used_yet_items: items_not_used_yet}
        next(state, opt)
    end
  end

  @doc """
  Stream filepaths.

  ## Examples

      iex> DeepSinker.new(["./"])
      ...> # |> DeepSinker.stream()
      ...> # |> Enum.to_list()
      ...> # ["list", "of" "filepaths"]

      iex> DeepSinker.new(["./"])
      ...> |> DeepSinker.stream(
      ...>   handler: fn item_path ->
      ...>     cond do
      ...>       String.ends_with?(item_path, ".git") -> :ignore
      ...>       String.contains?(item_path, ".") -> :file
      ...>       true -> :directory
      ...>     end
      ...>   end
      ...> )
      ...> # ["list", "of" "filepaths"]
  """
  @spec stream(DeepSinker.t()) :: Enumerable.t(filepath())
  @spec stream(DeepSinker.t(), [next_option()]) :: Enumerable.t(filepath())
  def stream(state, opt \\ [])

  def stream(%DeepSinker{} = state, opt) do
    Stream.resource(
      fn -> state end,
      fn state ->
        with {state, {:ok, filepath}} <- DeepSinker.next(state, opt) do
          {[filepath], state}
        else
          {state, :done} -> {:halt, state}
        end
      end,
      fn _ -> nil end
    )
  end

  defp find_children(directory_path, order)
       when is_bitstring(directory_path) and order in [:asc, :desc] do
    with {:ok, filenames} <- :file.list_dir(directory_path) do
      filenames
      |> Enum.map(&to_string/1)
      |> Enum.map(fn name -> Path.join(directory_path, name) end)
      |> Enum.sort(order)
    else
      {:error, reason} ->
        :logger.error("Failed to load directory `#{directory_path}` because of #{reason}")
        []
    end
  end

  defp default_handler(item_path) do
    cond do
      File.dir?(item_path) -> :directory
      true -> :file
    end
  end
end