lib/rewrite.ex

defmodule Rewrite do
  @moduledoc """
  `Rewrite` is a tool for modifying, adding and removing files in a `Mix` project.

  The package is intended for use in `Mix` tasks. `Rewrite` itself uses functions 
  provided by `Mix`. 

  With `Rewrite.read!/2` you can load the whole project. Then you can modify the 
  project with a number of functions provided by `Rewrite` and `Rewrite.Source` 
  without writing any changes back to the file system. All changes are stored in 
  the source structs. Any version of a source is available in the project. To 
  write the whole project back to the file system, the `Rewrite.write_all/2` can
  be used.

  Elixir source files can be modified by modifying the AST. For this `Rewrite` 
  uses the `Sourceror` package to create the AST and to convert it back. The
  `Sourceror` package also provides all the utilities needed to manipulate the
  AST.

  Sources can also receive a `Rewrite.Issue` to document problems or information 
  with the source. 

  `Rewrite` respects the `.formatter.exs` in the project when rewriting sources. 
  To do this, the formatter can be read by `Rewrite.DotFormatter` and the 
  resulting DotFormatter struct can be used in the function to update the 
  sources.
  """

  alias Rewrite.DotFormatter
  alias Rewrite.Error
  alias Rewrite.Source
  alias Rewrite.SourceError
  alias Rewrite.UpdateError

  defstruct sources: %{},
            extensions: %{},
            hooks: [],
            dot_formatter: nil,
            excluded: []

  @type t :: %Rewrite{
          sources: %{Path.t() => Source.t()},
          extensions: %{String.t() => [module()]},
          hooks: [module()],
          dot_formatter: DotFormatter.t() | nil,
          excluded: [Path.t()]
        }

  @type input :: Path.t() | wildcard() | GlobEx.t()
  @type wildcard :: IO.chardata()
  @type opts :: keyword()
  @type by :: module()
  @type key :: atom()
  @type updater :: (term() -> term())

  @doc """
  Creates an empty project.

  ## Options

    * `:filetypes` - a list of modules implementing the behavior 
      `Rewrite.Filetype`. 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 read.

      Defaults to `[Rewrite.Source, Rewrite.Source.Ex]`.

    * `:dot_formatter` - a `%DotFormatter{}` that is used to format sources.
      To get and update a dot formatter see `dot_formatter/2` and to create one
      see `Rewrite.DotFormatter`.

  ## Examples

      iex> project = Rewrite.new()
      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(filetypes: [{Rewrite.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(keyword()) :: t()
  def new(opts \\ []) do
    Rewrite
    |> struct!(
      extensions: extensions(opts),
      hooks: Keyword.get(opts, :hooks, [])
    )
    |> dot_formatter(Keyword.get(opts, :dot_formatter))
    |> handle_hooks(:new)
  end

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

  ## Options

    * Accepts the same options as `new/1`.

    * 'exclude' - a list of paths and/or glob expressions to exclude sources 
      from the project. The option also accepts a predicate function which is 
      called for each source path.  The exclusion takes place before the file is 
      read.
  """
  @spec new!(input() | [input()], opts) :: t()
  def new!(inputs, opts \\ []) do
    opts |> new() |> read!(inputs, opts)
  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.

    * `:exclude` - a list of paths and/or glob expressions to exclude sources 
      from the project. The option also accepts a predicate function which is 
      called for each source path.  The exclusion takes place before the file is 
      read.
  """
  @spec read!(t(), input() | [input()], opts()) :: t()
  def read!(%Rewrite{} = rewrite, inputs, opts \\ []) do
    reader = rewrite.sources |> Map.keys() |> reader(rewrite.extensions, opts)

    inputs = expand(inputs)

    {added, excluded, sources} =
      Rewrite.TaskSupervisor
      |> Task.Supervisor.async_stream_nolink(inputs, reader)
      |> Enum.reduce({[], [], rewrite.sources}, fn
        {:ok, {path, :excluded}}, {added, excluded, sources} ->
          {added, [path | excluded], sources}

        {:ok, {path, source}}, {added, excluded, sources} ->
          {[path | added], excluded, Map.put(sources, path, source)}

        {:exit, {error, _stacktrace}}, _sources when is_exception(error) ->
          raise error
      end)

    rewrite
    |> Map.put(:sources, sources)
    |> Map.update!(:excluded, fn list -> list |> Enum.concat(excluded) |> Enum.uniq() end)
    |> handle_hooks({:added, added})
  end

  defp reader(paths, extensions, opts) do
    force = Keyword.get(opts, :force, false)
    exclude? = opts |> Keyword.get(:exclude) |> exclude()

    fn path ->
      Logger.disable(self())

      if exclude?.(path) || File.dir?(path) || (!force && path in paths) do
        {path, :excluded}
      else
        source = read_source!(path, extensions)
        {path, source}
      end
    end
  end

  defp exclude(nil) do
    fn _path -> false end
  end

  defp exclude(list) when is_list(list) do
    globs =
      Enum.map(list, fn
        %GlobEx{} = glob -> glob
        path -> GlobEx.compile!(path)
      end)

    fn path ->
      Enum.any?(globs, fn glob -> GlobEx.match?(glob, path) end)
    end
  end

  defp exclude(fun) when is_function(fun, 1), do: fun

  defp read_source!(path, extensions) when not is_nil(path) do
    {source, opts} = extension_for_file(extensions, path)

    source.read!(path, opts)
  end

  @doc """
  Returns the extension of the given `file`.
  """
  @spec extension_for_file(t() | map(), Path.t() | nil) :: {module(), opts()}
  def extension_for_file(%Rewrite{extensions: extensions}, path) do
    extension_for_file(extensions, path)
  end

  def extension_for_file(extensions, path) do
    ext = if path, do: Path.extname(path)
    default = Map.fetch!(extensions, "default")

    case Map.get(extensions, ext, default) do
      {module, opts} -> {module, opts}
      module -> {module, []}
    end
  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", path: "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", path: "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 ->
        rewrite = %{rewrite | sources: Map.put(sources, path, source)}
        rewrite = handle_hooks(rewrite, {:added, [path]})

        {:ok, rewrite}
    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 system files are not removed, even if the project is written. Use 
  `rm/2` or `rm!/2` to delete a file and source.

  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", path: "a.exs"),
      ...>   Source.from_string(":b", path: "b.exs"),
      ...>   Source.from_string(":a", path: "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 file system files are not removed, even if the project is written. Use 
  `rm/2` or `rm!/2` to delete a file and source.

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

  ## Examples

      iex> {:ok, project} = Rewrite.from_sources([
      ...>   Source.from_string(":a", path: "a.exs"),
      ...>   Source.from_string(":b", path: "b.exs"),
      ...>   Source.from_string(":a", path: "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 in the file system 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(), Source.t() | Path.t()) ::
          {:ok, t()} | {:error, Error.t() | SourceError.t()}
  def rm(%Rewrite{} = rewrite, %Source{} = source) do
    with :ok <- Source.rm(source) do
      {:ok, delete(rewrite, source.path)}
    end
  end

  def rm(%Rewrite{} = rewrite, source) when is_binary(source) do
    with {:ok, source} <- source(rewrite, source) do
      rm(rewrite, source)
    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 """
  Moves a source from one path to another.
  """
  @spec move(t(), Source.t() | Path.t(), Path.t(), module()) :: {:ok, t()} | {:error, term()}
  def move(rewrite, from, to, by \\ Rewrite)

  def move(%Rewrite{} = rewrite, from, to, by)
      when is_struct(from, Source) and is_binary(to) and is_atom(by) do
    case Map.has_key?(rewrite.sources, to) do
      true ->
        {:error, UpdateError.exception(reason: :overwrites, path: to, source: from)}

      false ->
        update(rewrite, from.path, fn source ->
          Source.update(source, :path, to, by: by)
        end)
    end
  end

  def move(%Rewrite{} = rewrite, from, to, by)
      when is_binary(from) and is_binary(to) and is_atom(by) do
    with {:ok, source} <- source(rewrite, from) do
      move(rewrite, source, to, by)
    end
  end

  @doc """
  Same as `move/4`, but raises an exception in case of failure.
  """
  @spec move!(t(), Source.t() | Path.t(), Path.t(), module()) :: t()
  def move!(%Rewrite{} = rewrite, from, to, by \\ Rewrite) do
    case move(rewrite, from, to, by) 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", path: "a.exs"),
      ...>   Source.Ex.from_string(":b", path: "b.exs"),
      ...>   Source.Ex.from_string("c", path: "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()], opts()) :: {:ok, t()} | {:error, term()}
  def from_sources(sources, opts \\ []) 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
      rewrite = new(opts)

      rewrite = %{rewrite | sources: sources}

      rewrite =
        rewrite
        |> Map.put(:sources, sources)
        |> handle_hooks({:added_sources, sources})

      {:ok, rewrite}
    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()], opts()) :: t()
  def from_sources!(sources, opts \\ []) when is_list(sources) do
    case from_sources(sources, opts) 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` is 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", path: "a.exs")
      iex> b = Source.Ex.from_string(":b", path: "b.exs")
      iex> {:ok, project} = Rewrite.from_sources([a, b])
      iex> {:ok, project} = Rewrite.update(project, "a.exs", Source.Ex.from_string(":foo", path: "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", path: "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 ->
        rewrite = %{rewrite | sources: Map.put(rewrite.sources, path, source)}
        rewrite = handle_hooks(rewrite, {:updated, path})
        {:ok, rewrite}

      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 """
  Updates the source for the given `path` and `key` with the given `fun`.

  The function combines `update/3` and `Source.update/4` in one call.

  ## Examples

      iex> project =
      ...>   Rewrite.new()
      ...>   |> Rewrite.new_source!("test.md", "foo")
      ...>   |> Rewrite.update_source!("test.md", :content, fn content ->
      ...>     content <> "bar"
      ...>   end)
      ...>   |> Rewrite.update_source!("test.md", :content, &String.upcase/1, by: MyApp)
      iex> source = Rewrite.source!(project, "test.md")
      iex> source.content
      "FOOBAR"
      iex> source.history
      [{:content, MyApp, "foobar"}, {:content, Rewrite, "foo"}]
  """
  @spec update_source(t(), Path.t(), key(), updater(), opts()) ::
          {:ok, t()} | {:error, term()}
  def update_source(%Rewrite{} = rewrite, path, key, fun, opts \\ []) do
    update(rewrite, path, fn source ->
      Source.update(source, key, fun, opts)
    end)
  end

  @doc """
  The same as `update_source/5` but raises a `Rewrite.Error` exception in case
  of an error.
  """
  @spec update_source!(t(), Path.t(), key(), updater(), opts()) :: t()
  def update_source!(%Rewrite{} = rewrite, path, key, fun, opts \\ []) do
    case update_source(rewrite, path, key, fun, opts) 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", path: "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 """
  The same as `write/3` but raises an exception in case of an error.
  """
  @spec write!(t(), Path.t() | Source.t(), nil | :force) :: t()
  def write!(%Rewrite{} = rewrite, source, force \\ nil) do
    case write(rewrite, source, force) do
      {:ok, rewrite} -> rewrite
      {:error, error} -> raise error
    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 successfully.

  Returns `{:error, reasons, rewrite}` where `rewrite` is updated for all 
  sources that are written successfully.

  ## Options

  + `exclude` - a list paths to exclude form writting.
  + `force`, default: `false` - forces the writting of unchanged 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
    sources = for {path, source} <- sources, path not in exclude, do: source
    writer = fn source -> Source.write(source, force: force) end

    {rewrite, errors} =
      Rewrite.TaskSupervisor
      |> Task.Supervisor.async_stream_nolink(sources, writer)
      |> Enum.reduce({rewrite, []}, fn {:ok, result}, {rewrite, errors} ->
        case result do
          {:ok, source} -> {Rewrite.update!(rewrite, source), errors}
          {:error, error} -> {rewrite, [error | errors]}
        end
      end)

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

  @doc """
  Formats the given `rewrite` project with the given `dot_formatter`.

  Uses the formatter from `dot_formatter/2` if no formatter ist set by 
  `:dot_formatter` in the options. The other options are the same as for 
  `DotFormatter.read!/2`. 
  """
  @spec format(t(), opts()) :: {:ok, t()} | {:error, term()}
  def format(%Rewrite{} = rewrite, opts \\ []) do
    dot_formatter = Keyword.get(opts, :dot_formatter, dot_formatter(rewrite))
    DotFormatter.format_rewrite(dot_formatter, rewrite, opts)
  end

  @doc """
  The same as `format/2` but raises an exception in case of an error.
  """
  @spec format!(t(), opts()) :: t()
  def format!(rewrite, opts \\ []) do
    case format(rewrite, opts) do
      {:ok, rewrite} -> rewrite
      {:error, error} -> raise error
    end
  end

  @doc """
  Formats a source in a `rewrite` project.

  Uses the formatter from `dot_formatter/2` if no formatter ist set by 
  `:dot_formatter` in the options. The other options are the same as for 
  `Code.format_string!/2`. 
  """
  @spec format_source(t(), Path.t() | Source.t(), keyword()) :: {:ok, t()} | {:error, term()}
  def format_source(rewrite, file, opts \\ [])

  def format_source(%Rewrite{} = rewrite, %Source{path: path}, opts) when is_binary(path) do
    format_source(rewrite, path, opts)
  end

  def format_source(%Rewrite{} = rewrite, file, opts) do
    dot_formatter = Keyword.get_lazy(opts, :dot_formatter, fn -> dot_formatter(rewrite) end)
    DotFormatter.format_source(dot_formatter, rewrite, file, opts)
  end

  @doc """
  The same as `format_source/3` but raises an exception in case of an error.
  """
  @spec format_source!(t(), Path.t() | Source.t(), keyword()) :: t()
  def format_source!(rewrite, file, opts \\ []) do
    case format_source(rewrite, file, opts) do
      {:ok, source} -> source
      {:error, error} -> raise error
    end
  end

  @doc """
  Returns the `DotFormatter` for the given `rewrite` project.

  When no formatter is set, the default formatter from 
  `Rewrite.DotFormatter.default/0` is returned. A dot formatter can be set with
  `dot_formatter/2`.
  """
  @spec dot_formatter(t()) :: DotFormatter.t()
  def dot_formatter(%Rewrite{dot_formatter: nil}), do: DotFormatter.default()
  def dot_formatter(%Rewrite{dot_formatter: dot_formatter}), do: dot_formatter

  @doc """
  Sets a `dot_formatter` for the given `rewrite` project.
  """
  @spec dot_formatter(t(), DotFormatter.t() | nil) :: t()
  def dot_formatter(%Rewrite{} = rewrite, dot_formatter)
      when is_struct(dot_formatter, DotFormatter) or is_nil(dot_formatter) do
    %{rewrite | dot_formatter: dot_formatter}
  end

  @doc """
  Creates a new `%Source{}` and puts the source to the `%Rewrite{}` project.

  The `:filetypes` option of the project is used to create the source. If 
  options have been specified for the file type, the given options will be 
  merged into those options.

  Use `create_source/4` if the source is not to be inserted directly into the 
  project.
  """
  @spec new_source(t(), Path.t(), String.t(), opts()) :: {:ok, t()} | {:error, Error.t()}
  def new_source(%Rewrite{sources: sources} = rewrite, path, content, opts \\ [])
      when is_binary(path) do
    case Map.has_key?(sources, path) do
      true ->
        {:error, Error.exception(reason: :overwrites, path: path)}

      false ->
        source = create_source(rewrite, path, content, opts)
        put(rewrite, source)
    end
  end

  @doc """
  Same as `new_source/4`, but raises a `Rewrite.Error` exception in case of failure.
  """
  @spec new_source!(t(), Path.t(), String.t(), opts()) :: t()
  def new_source!(%Rewrite{} = rewrite, path, content, opts \\ []) do
    case new_source(rewrite, path, content, opts) do
      {:ok, rewrite} -> rewrite
      {:error, error} -> raise error
    end
  end

  @doc """
  Creates a new `%Source{}` without putting it to the `%Rewrite{}` project.

  The `:filetypes` option of the project is used to create the source. If 
  options have been specified for the file type, the given options will be 
  merged into those options. If no `path` is given, the default file type is 
  created.

  The function does not check whether the `%Rewrite{}` project already has a 
  `%Source{}` with the specified path.

  Use `new_source/4` if the source is to be inserted directly into the project.
  """
  @spec create_source(t(), Path.t() | nil, String.t(), opts()) :: Source.t()
  def create_source(%Rewrite{} = rewrite, path, content, opts \\ []) do
    {source, source_opts} = extension_for_file(rewrite, path)
    opts = source_opts |> Keyword.merge(opts) |> Keyword.put(:path, path)

    source.from_string(content, opts)
  end

  defp extensions(opts) do
    opts
    |> Keyword.get(:filetypes, [Source, Source.Ex])
    |> 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 expand(inputs) do
    inputs
    |> List.wrap()
    |> Stream.map(&compile_globs!/1)
    |> Stream.flat_map(&GlobEx.ls/1)
    |> Stream.uniq()
  end

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

  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

  defp handle_hooks(%{hooks: []} = rewrite, _action), do: rewrite

  defp handle_hooks(rewrite, {:added_sources, sources}) do
    paths = Enum.map(sources, fn {path, _source} -> path end)
    handle_hooks(rewrite, {:added, paths})
  end

  defp handle_hooks(%{hooks: hooks} = rewrite, action) do
    Enum.reduce(hooks, rewrite, fn hook, rewrite ->
      case hook.handle(action, rewrite) do
        :ok ->
          rewrite

        {:ok, rewrite} ->
          rewrite

        unexpected ->
          raise Error.exception(
                  reason: :unexpected_hook_response,
                  message: """
                  unexpected response from hook, got: #{inspect(unexpected)}\
                  """
                )
      end
    end)
  end

  defimpl Inspect do
    def inspect(rewrite, _opts) do
      "#Rewrite<#{Enum.count(rewrite.sources)} source(s)>"
    end
  end
end