lib/rewrite.ex

defmodule Rewrite do
  @moduledoc """
  `Rewrite` provides a struct that contains all resources that could be handeld
  by `Rewrite`.
  """

  alias Rewrite.Source

  alias Rewrite.Error
  alias Rewrite.SourceError
  alias Rewrite.UpdateError

  defstruct sources: %{}, extensions: %{}

  @type t :: %Rewrite{sources: %{Path.t() => Source.t()}}
  @type input :: Path.t() | wildcard() | GlobEx.t()
  @type wildcard :: IO.chardata()
  @type opts :: keyword()

  @doc """
  Creates an empty project.

  The optional argument is a list of modules implementing the behavior
  `Rewrite.Filetye`. This list is used to add the `filetype` to the `sources` of
  the corresponding files. The list can contain modules representing a file
  type or a tuple of `{module(), keyword()}`. Rewrite uses the keyword list from
  the tuple as the options argument when a file is reading.

  ## Examples

      iex> project = Rewrite.new()
      %Rewrite{
        sources: %{},
        extensions: %{
          "default" => Source,
          ".ex" => Source.Ex,
          ".exs" => Source.Ex,
        }
      }
      iex> path = "test/fixtures/source/hello.txt"
      iex> project = Rewrite.read!(project, path)
      iex> project |> Rewrite.source!(path) |> Source.get(:content)
      "hello\\n"
      iex> project |> Rewrite.source!(path) |> Source.owner()
      Rewrite

      iex> project = Rewrite.new([{Rewrite.Source, owner: MyApp}])
      %Rewrite{
        sources: %{},
        extensions: %{
          "default" => {Source, owner: MyApp}
        }
      }
      iex> path = "test/fixtures/source/hello.txt"
      iex> project = Rewrite.read!(project, path)
      iex> project |> Rewrite.source!(path) |> Source.owner()
      MyApp
  """
  @spec new([module() | {module(), keyword()}]) :: t()
  def new(filetypes \\ [Source, Source.Ex]) when is_list(filetypes) do
    %Rewrite{extensions: extensions(filetypes)}
  end

  @doc """
  Creates a `%Rewrite{}` from the given `inputs`.

  The optional second argument is a list of modules implementing the behavior
  `Rewrite.Filetye`. For more info, see `new/1`.
  """
  @spec new!(input() | [input()], [module() | {module(), keyword()}]) :: t()
  def new!(inputs, filetypes \\ [Source, Source.Ex]) do
    extensions = extensions(filetypes)

    sources =
      inputs
      |> expand()
      |> Enum.reduce(%{}, fn path, sources ->
        source = read_source!(path, extensions)
        Map.put(sources, source.path, source)
      end)

    struct!(Rewrite, sources: sources, extensions: extensions)
  end

  @doc """
  Reads the given `input`/`inputs` and adds the source/sources to the `project`
  when not already readed.

  ## Options

  + `:force`, default: `false` - forces the reading of sources. With
    `force: true` updates and issues for an already existing source are deleted.
  """
  @spec read!(t(), input() | [input()], opts()) :: t()
  def read!(%Rewrite{} = rewrite, inputs, opts \\ []) do
    force = Keyword.get(opts, :force, false)

    sources =
      inputs
      |> expand()
      |> Enum.reduce(rewrite.sources, fn path, sources ->
        if !force && Map.has_key?(sources, path) do
          sources
        else
          source = read_source!(path, rewrite.extensions)
          Map.put(sources, source.path, source)
        end
      end)

    %{rewrite | sources: sources}
  end

  @doc """
  Puts the given `source` to the given `rewrite` project.

  Returns `{:ok, rewrite}` if successful, `{:error, reason}` otherwise.

  ## Examples

      iex> project = Rewrite.new()
      iex> {:ok, project} = Rewrite.put(project, Source.from_string(":a", "a.exs"))
      iex> map_size(project.sources)
      1
      iex> Rewrite.put(project, Source.from_string(":b"))
      {:error, %Rewrite.Error{reason: :nopath}}
      iex> Rewrite.put(project, Source.from_string(":a", "a.exs"))
      {:error, %Rewrite.Error{reason: :overwrites, path: "a.exs"}}
  """
  @spec put(t(), Source.t()) :: {:ok, t()} | {:error, Error.t()}
  def put(%Rewrite{}, %Source{path: nil}), do: {:error, Error.exception(reason: :nopath)}

  def put(%Rewrite{sources: sources} = rewrite, %Source{path: path} = source) do
    case Map.has_key?(sources, path) do
      true -> {:error, Error.exception(reason: :overwrites, path: path)}
      false -> {:ok, %{rewrite | sources: Map.put(sources, path, source)}}
    end
  end

  @doc """
  Same as `put/2`, but raises a `Rewrite.Error` exception in case of failure.
  """
  @spec put!(t(), Source.t()) :: t()
  def put!(%Rewrite{} = rewrite, %Source{} = source) do
    case put(rewrite, source) do
      {:ok, rewrite} -> rewrite
      {:error, error} -> raise error
    end
  end

  @doc """
  Deletes the source for the given `path` from the `rewrite`. The file on disk
  is not removed.

  If the source is not part of the `rewrite` project the unchanged `rewrite` is
  returned.

  ## Examples

      iex> {:ok, project} = Rewrite.from_sources([
      ...>   Source.from_string(":a", "a.exs"),
      ...>   Source.from_string(":b", "b.exs"),
      ...>   Source.from_string(":a", "c.exs")
      ...> ])
      iex> Rewrite.paths(project)
      ["a.exs", "b.exs", "c.exs"]
      iex> project = Rewrite.delete(project, "a.exs")
      iex> Rewrite.paths(project)
      ["b.exs", "c.exs"]
      iex> project = Rewrite.delete(project, "b.exs")
      iex> Rewrite.paths(project)
      ["c.exs"]
      iex> project = Rewrite.delete(project, "b.exs")
      iex> Rewrite.paths(project)
      ["c.exs"]
  """
  @spec delete(t(), Path.t()) :: t()
  def delete(%Rewrite{sources: sources} = rewrite, path) when is_binary(path) do
    %{rewrite | sources: Map.delete(sources, path)}
  end

  @doc """
  Drops the sources with the given `paths` from the `rewrite` project.

  The files for the dropped sources are not removed from disk.

  If `paths` contains paths that are not in `rewrite`, they're simply ignored.

  ## Examples

      iex> {:ok, project} = Rewrite.from_sources([
      ...>   Source.from_string(":a", "a.exs"),
      ...>   Source.from_string(":b", "b.exs"),
      ...>   Source.from_string(":a", "c.exs")
      ...> ])
      iex> project = Rewrite.drop(project, ["a.exs", "b.exs", "z.exs"])
      iex> Rewrite.paths(project)
      ["c.exs"]
  """
  @spec drop(t(), [Path.t()]) :: t()
  def drop(%Rewrite{} = rewrite, paths) when is_list(paths) do
    Enum.reduce(paths, rewrite, fn source, rewrite -> delete(rewrite, source) end)
  end

  @doc """
  Tries to delete the `source` file and removes the `source` from the `rewrite`
  project.

  Returns `{:ok, rewrite}` if successful, or `{:error, error}` if an error
  occurs.

  Note the file is deleted even if in read-only mode.
  """
  @spec rm(t(), Path.t()) ::
          {:ok, t()} | {:error, Error.t() | SourceError.t()}
  def rm(%Rewrite{} = rewrite, path) when is_binary(path) do
    with {:ok, source} <- source(rewrite, path),
         :ok <- Source.rm(source) do
      {:ok, delete(rewrite, source.path)}
    end
  end

  @doc """
  Same as `source/2`, but raises a `Rewrite.Error` exception in case of failure.
  """
  @spec rm!(t(), Source.t() | Path.t()) :: t()
  def rm!(%Rewrite{} = rewrite, source) when is_binary(source) or is_struct(source, Source) do
    case rm(rewrite, source) do
      {:ok, rewrite} -> rewrite
      {:error, error} -> raise error
    end
  end

  @doc """
  Returns a sorted list of all paths in the `rewrite` project.
  """
  @spec paths(t()) :: [Path.t()]
  def paths(%Rewrite{sources: sources}) do
    sources |> Map.keys() |> Enum.sort()
  end

  @doc """
  Returns `true` if any source in the `rewrite` project returns `true` for
  `Source.updated?/1`.

  ## Examples

      iex> {:ok, project} = Rewrite.from_sources([
      ...>   Source.Ex.from_string(":a", "a.exs"),
      ...>   Source.Ex.from_string(":b", "b.exs"),
      ...>   Source.Ex.from_string("c", "c.txt")
      ...> ])
      iex> Rewrite.updated?(project)
      false
      iex> project = Rewrite.update!(project, "a.exs", fn source ->
      ...>   Source.update(source, :quoted, ":z")
      ...> end)
      iex> Rewrite.updated?(project)
      true
  """
  @spec updated?(t()) :: boolean()
  def updated?(%Rewrite{} = rewrite) do
    rewrite.sources |> Map.values() |> Enum.any?(fn source -> Source.updated?(source) end)
  end

  @doc ~S"""
  Creates a `%Rewrite{}` from the given sources.

  Returns `{:ok, rewrite}` for a list of regular sources.

  Returns `{:error, error}` for sources with a missing path and/or duplicated
  paths.
  """
  @spec from_sources([Source.t()], [module()]) :: {:ok, t()} | {:error, Error.t()}
  def from_sources(sources, filetypes \\ [Source.Ex]) when is_list(sources) do
    {sources, missing, duplicated} =
      Enum.reduce(sources, {%{}, [], []}, fn %Source{} = source, {sources, missing, duplicated} ->
        cond do
          is_nil(source.path) ->
            {sources, [source | missing], duplicated}

          Map.has_key?(sources, source.path) ->
            {sources, missing, [source | duplicated]}

          true ->
            {Map.put(sources, source.path, source), missing, duplicated}
        end
      end)

    if Enum.empty?(missing) && Enum.empty?(duplicated) do
      {:ok, struct!(Rewrite, sources: sources, extensions: extensions(filetypes))}
    else
      {:error,
       Error.exception(
         reason: :invalid_sources,
         missing_paths: missing,
         duplicated_paths: duplicated
       )}
    end
  end

  @doc """
  Same as `from_sources/2`, but raises a `Rewrite.Error` exception in case of
  failure.
  """
  @spec from_sources!([Source.t()], [module()]) :: t()
  def from_sources!(sources, filetypes \\ [Source.Ex]) when is_list(sources) do
    case from_sources(sources, filetypes) do
      {:ok, rewrite} -> rewrite
      {:error, error} -> raise error
    end
  end

  @doc """
  Returns all sources sorted by path.
  """
  @spec sources(t()) :: [Source.t()]
  def sources(%Rewrite{sources: sources}) do
    sources
    |> Map.values()
    |> Enum.sort_by(fn source -> source.path end)
  end

  @doc """
  Returns the `%Rewrite.Source{}` for the given `path`.

  Returns an `:ok` tuple with the found source, if not exactly one source is
  available an `:error` is returned.

  See also `sources/2` to get a list of sources for a given `path`.
  """
  @spec source(t(), Path.t()) :: {:ok, Source.t()} | {:error, Error.t()}
  def source(%Rewrite{sources: sources}, path) when is_binary(path) do
    with :error <- Map.fetch(sources, path) do
      {:error, Error.exception(reason: :nosource, path: path)}
    end
  end

  @doc """
  Same as `source/2`, but raises a `Rewrite.Error` exception in case of
  failure.
  """
  @spec source!(t(), Path.t()) :: Source.t()
  def source!(%Rewrite{} = rewrite, path) do
    case source(rewrite, path) do
      {:ok, source} -> source
      {:error, error} -> raise error
    end
  end

  @doc """
  Updates the given `source` in the `rewrite` project.

  This function will be usually used if the `path` for the `source` has not
  changed.

  Returns `{:ok, rewrite}` if successful, `{:error, error}` otherwise.
  """
  @spec update(t(), Source.t()) ::
          {:ok, t()} | {:error, Error.t()}
  def update(%Rewrite{}, %Source{path: nil}),
    do: {:error, Error.exception(reason: :nopath)}

  def update(%Rewrite{} = rewrite, %Source{} = source) do
    update(rewrite, source.path, source)
  end

  @doc """
  The same as `update/2` but raises a `Rewrite.Error` exception in case
  of an error.
  """
  @spec update!(t(), Source.t()) :: t()
  def update!(%Rewrite{} = rewrite, %Source{} = source) do
    case update(rewrite, source) do
      {:ok, rewrite} -> rewrite
      {:error, error} -> raise error
    end
  end

  @doc """
  Updates a source for the given `path` in the `rewrite` project.

  If `source` a `Rewrite.Source` struct the struct is used to update the
  `rewrite` project.

  If `source` a function the source for the given `path` is passed to the
  function and the result is used to update the `rewrite` project.

  Returns `{:ok, rewrite}` if the update was successful, `{:error, error}`
  otherwise.

  ## Examples

      iex> a = Source.Ex.from_string(":a", "a.exs")
      iex> b = Source.Ex.from_string(":b", "b.exs")
      iex> {:ok, project} = Rewrite.from_sources([a, b])
      iex> {:ok, project} = Rewrite.update(project, "a.exs", Source.Ex.from_string(":foo", "a.exs"))
      iex> project |> Rewrite.source!("a.exs") |> Source.get(:content)
      ":foo"
      iex> {:ok, project} = Rewrite.update(project, "a.exs", fn s -> Source.update(s, :content, ":baz") end)
      iex> project |> Rewrite.source!("a.exs") |> Source.get(:content)
      ":baz"
      iex> {:ok, project} = Rewrite.update(project, "a.exs", fn s -> Source.update(s, :path, "c.exs") end)
      iex> Rewrite.paths(project)
      ["b.exs", "c.exs"]
      iex> Rewrite.update(project, "no.exs", Source.from_string(":foo", "x.exs"))
      {:error, %Rewrite.Error{reason: :nosource, path: "no.exs"}}
      iex> Rewrite.update(project, "c.exs", Source.from_string(":foo"))
      {:error, %Rewrite.UpdateError{reason: :nopath, source: "c.exs"}}
      iex> Rewrite.update(project, "c.exs", fn _ -> b end)
      {:error, %Rewrite.UpdateError{reason: :overwrites, path: "b.exs", source: "c.exs"}}
  """
  @spec update(t(), Path.t(), Source.t() | function()) ::
          {:ok, t()} | {:error, Error.t() | UpdateError.t()}
  def update(%Rewrite{}, path, %Source{path: nil}) when is_binary(path) do
    {:error, UpdateError.exception(reason: :nopath, source: path)}
  end

  def update(%Rewrite{} = rewrite, path, %Source{} = source)
      when is_binary(path) do
    with {:ok, _stored} <- source(rewrite, path) do
      do_update(rewrite, path, source)
    end
  end

  def update(%Rewrite{} = rewrite, path, fun) when is_binary(path) and is_function(fun, 1) do
    with {:ok, stored} <- source(rewrite, path),
         {:ok, source} <- apply_update!(stored, fun) do
      do_update(rewrite, path, source)
    end
  end

  defp do_update(rewrite, path, source) do
    case path == source.path do
      true ->
        {:ok, %{rewrite | sources: Map.put(rewrite.sources, path, source)}}

      false ->
        case Map.has_key?(rewrite.sources, source.path) do
          true ->
            {:error, UpdateError.exception(reason: :overwrites, path: source.path, source: path)}

          false ->
            sources = rewrite.sources |> Map.delete(path) |> Map.put(source.path, source)
            {:ok, %{rewrite | sources: sources}}
        end
    end
  end

  defp apply_update!(source, fun) do
    case fun.(source) do
      %Source{path: nil} ->
        {:error, UpdateError.exception(reason: :nopath, source: source.path)}

      %Source{} = source ->
        {:ok, source}

      got ->
        raise RuntimeError, """
        expected %Source{} from anonymous function given to Rewrite.update/3, got: #{inspect(got)}\
        """
    end
  end

  @doc """
  The same as `update/3` but raises a `Rewrite.Error` exception in case
  of an error.
  """
  @spec update!(t(), Path.t(), Source.t() | function()) :: t()
  def update!(%Rewrite{} = rewrite, path, new) when is_binary(path) do
    case update(rewrite, path, new) do
      {:ok, rewrite} -> rewrite
      {:error, error} -> raise error
    end
  end

  @doc """
  Returns `true` when the `%Rewrite{}` contains a `%Source{}` with the given
  `path`.

  ## Examples

      iex> {:ok, project} = Rewrite.from_sources([
      ...>   Source.from_string(":a", "a.exs")
      ...> ])
      iex> Rewrite.has_source?(project, "a.exs")
      true
      iex> Rewrite.has_source?(project, "b.exs")
      false
  """
  @spec has_source?(t(), Path.t()) :: boolean()
  def has_source?(%Rewrite{sources: sources}, path) when is_binary(path) do
    Map.has_key?(sources, path)
  end

  @doc """
  Returns `true` if any source has one or more issues.
  """
  @spec issues?(t) :: boolean
  def issues?(%Rewrite{sources: sources}) do
    sources
    |> Map.values()
    |> Enum.any?(fn %Source{issues: issues} -> not Enum.empty?(issues) end)
  end

  @doc """
  Counts the sources with the given `extname` in the `rewrite` project.
  """
  @spec count(t, String.t()) :: non_neg_integer
  def count(%Rewrite{sources: sources}, extname) when is_binary(extname) do
    sources
    |> Map.keys()
    |> Enum.count(fn path -> Path.extname(path) == extname end)
  end

  @doc """
  Invokes `fun` for each `source` in the `rewrite` project and updates the
  `rewirte` project with the result of `fun`.

  Returns a `{:ok, rewrite}` if any update is successful.

  Returns `{:error, errors, rewrite}` where `rewrite` is updated for all sources
  that are updated successful. The `errors` are the `errors` of `update/3`.
  """
  @spec map(t(), (Source.t() -> Source.t())) ::
          {:ok, t()} | {:error, [{:nosource | :overwrites | :nopath, Source.t()}]}
  def map(%Rewrite{} = rewrite, fun) when is_function(fun, 1) do
    {rewrite, errors} =
      Enum.reduce(rewrite, {rewrite, []}, fn source, {rewrite, errors} ->
        with {:ok, updated} <- apply_update!(source, fun),
             {:ok, rewrite} <- do_update(rewrite, source.path, updated) do
          {rewrite, errors}
        else
          {:error, error} -> {rewrite, [error | errors]}
        end
      end)

    if Enum.empty?(errors) do
      {:ok, rewrite}
    else
      {:error, errors, rewrite}
    end
  end

  @doc """
  Return a `rewrite` project where each `source` is the result of invoking
  `fun` on each `source` of the given `rewrite` project.
  """
  @spec map!(t(), (Source.t() -> Source.t())) :: t()
  def map!(%Rewrite{} = rewrite, fun) when is_function(fun, 1) do
    Enum.reduce(rewrite, rewrite, fn source, rewrite ->
      with {:ok, updated} <- apply_update!(source, fun),
           {:ok, rewrite} <- do_update(rewrite, source.path, updated) do
        rewrite
      else
        {:error, error} -> raise error
      end
    end)
  end

  @doc """
  Writes a source to disk.

  The function expects a path or a `%Source{}` as first argument.

  Returns `{:ok, rewrite}` if the file was written successful. See also
  `Source.write/2`.

  If the given `source` is not part of the `rewrite` project then it is added.
  """
  @spec write(t(), Path.t() | Source.t(), nil | :force) ::
          {:ok, t()} | {:error, Error.t() | SourceError.t()}
  def write(rewrite, path, force \\ nil)

  def write(%Rewrite{} = rewrite, path, force) when is_binary(path) and force in [nil, :force] do
    with {:ok, source} <- source(rewrite, path) do
      write(rewrite, source, force)
    end
  end

  def write(%Rewrite{} = rewrite, %Source{} = source, force) when force in [nil, :force] do
    with {:ok, source} <- Source.write(source) do
      {:ok, Rewrite.update!(rewrite, source)}
    end
  end

  @doc """
  Writes all sources in the `rewrite` project to disk.

  This function calls `Rewrite.Source.write/1` on all sources in the `rewrite`
  project.

  Returns `{:ok, rewrite}` if all sources are written successful.

  Returns `{:error, reasons, rewrite}` where rewrite is updated for all sources
  that are written successful. The reasons is a keyword list with the keys
  `File.posix()` or `:changed` and the affected path as value. The key
  `:changed` indicates a file that was changed sind reading.

  ## Options

  + `exclude` - a list paths to exclude form writting.
  + `foece`, default: `false` - forces the writting of changed files.
  """
  @spec write_all(t(), opts()) ::
          {:ok, t()} | {:error, [SourceError.t()], t()}
  def write_all(%Rewrite{} = rewrite, opts \\ []) do
    exclude = Keyword.get(opts, :exclude, [])
    force = if Keyword.get(opts, :force, false), do: :force, else: nil

    write_all(rewrite, exclude, force)
  end

  defp write_all(%Rewrite{sources: sources} = rewrite, exclude, force)
       when force in [nil, :force] do
    {rewrite, errors} =
      sources
      |> Map.values()
      |> Enum.reduce({rewrite, []}, fn source, acc ->
        do_write_all(source, exclude, force, acc)
      end)

    if Enum.empty?(errors), do: {:ok, rewrite}, else: {:error, errors, rewrite}
  end

  defp do_write_all(source, exclude, force, {rewrite, errors}) do
    if source.path in exclude do
      {rewrite, errors}
    else
      case Source.write(source, force: force) do
        {:ok, source} -> {Rewrite.update!(rewrite, source), errors}
        {:error, error} -> {rewrite, [error | errors]}
      end
    end
  end

  defp extensions(modules) do
    modules
    |> Enum.flat_map(fn
      Source ->
        [{"default", Source}]

      {Source, opts} ->
        [{"default", {Source, opts}}]

      {module, opts} ->
        Enum.map(module.extensions, fn extension -> {extension, {module, opts}} end)

      module ->
        Enum.map(module.extensions, fn extension -> {extension, module} end)
    end)
    |> Map.new()
    |> Map.put_new("default", Source)
  end

  defp read_source!(path, extensions) when not is_nil(path) do
    ext = Path.extname(path)

    {source, opts} =
      case Map.get(extensions, ext, Map.fetch!(extensions, "default")) do
        {module, opts} -> {module, opts}
        module -> {module, []}
      end

    source.read!(path, opts)
  end

  defp expand(inputs) do
    inputs
    |> List.wrap()
    |> Enum.map(&compile_globs!/1)
    |> Enum.flat_map(&GlobEx.ls/1)
  end

  defp compile_globs!(str) when is_binary(str), do: GlobEx.compile!(str)

  defp compile_globs!(glob) when is_struct(glob, GlobEx), do: glob

  defimpl Enumerable do
    def count(rewrite) do
      {:ok, map_size(rewrite.sources)}
    end

    def member?(rewrite, %Source{} = source) do
      member? = Map.get(rewrite.sources, source.path) == source
      {:ok, member?}
    end

    def member?(_rewrite, _other) do
      {:ok, false}
    end

    def slice(rewrite) do
      sources = rewrite.sources |> Map.values() |> Enum.sort_by(fn source -> source.path end)
      length = length(sources)

      {:ok, length,
       fn
         start, count when start + count == length -> Enum.drop(sources, start)
         start, count -> sources |> Enum.drop(start) |> Enum.take(count)
       end}
    end

    def reduce(rewrite, acc, fun) do
      sources = Map.values(rewrite.sources)
      Enumerable.List.reduce(sources, acc, fun)
    end
  end
end