Current section

Files

Jump to
rewrite lib rewrite project.ex
Raw

lib/rewrite/project.ex

defmodule Rewrite.Project do
@moduledoc """
The `%Project{}` contains all `%Rewrite.Sources{}` of a project.
"""
alias Rewrite.Project
alias Rewrite.ProjectError
alias Rewrite.Source
defstruct sources: %{}
@type id :: reference()
@type t :: %Project{sources: %{id() => Source.t()}}
@type wildcard :: IO.chardata()
@doc """
Creates a `%Project{}` from the given `inputs`.
"""
@spec read!(input | [input]) :: t() when input: Path.t() | wildcard() | GlobEx.t()
def read!(inputs) do
inputs =
inputs
|> List.wrap()
|> Enum.map(&compile_globs!/1)
|> Enum.flat_map(&GlobEx.ls/1)
sources =
Enum.reduce(inputs, %{}, fn path, sources ->
source = Source.read!(path)
Map.put(sources, source.id, source)
end)
struct!(Project, sources: sources)
end
@doc ~S"""
Creates a `%Project{}` from the given sources.
"""
@spec from_sources([Source.t()]) :: Project.t()
def from_sources(sources) do
sources =
Enum.reduce(sources, %{}, fn source, sources ->
Map.put(sources, source.id, source)
end)
struct!(Project, sources: sources)
end
@doc """
Returns all sources sorted by path.
"""
@spec sources(t()) :: [Source.t()]
def sources(%Project{sources: sources}) do
sources
|> Map.values()
|> Enum.sort_by(fn source -> source.path end)
end
@doc ~S'''
Returns a list of `%Source{}` for the given `path`.
It is possible that the project contains multiple sources with the same path.
The function `conflicts/1` returns all conflicts in a project and the function
`save/2` returns an error tuple when trying to save a project with conflicts.
It is up to the user of `rewrite` to handle conflicts.
## Examples
iex> source = Source.from_string(
...> """
...> defmodule MyApp.Mode do
...> end
...> """,
...> "my_app/mode.ex"
...> )
iex> project = Project.from_sources([source])
iex> Project.sources(project, "my_app/mode.ex")
[source]
iex> Project.sources(project, "foo")
[]
iex> a = Source.from_string(":a", "a.ex")
iex> b = Source.from_string(":b", "b.ex")
iex> project = Project.from_sources([a, b])
iex> update = Source.update(a, :test, path: "b.ex")
iex> project = Project.update(project, update)
iex> Project.sources(project, "a.ex")
[]
iex> Project.sources(project, "b.ex")
[b, update]
'''
@spec sources(t(), Path.t()) :: [Source.t()]
def sources(%Project{sources: sources}, path) do
Enum.reduce(sources, [], fn {_id, source}, acc ->
case source.path == path do
true -> [source | acc]
false -> acc
end
end)
end
@doc """
Returns the `%Rewrite.Source{}` for the given `path`.
Returns an `:ok` tuple with the found source, if no or multiple sources are
available an `:error` is returned.
"""
@spec source(t(), Path.t()) :: {:ok, Source.t()} | :error
def source(%Project{} = project, path) do
case sources(project, path) do
[source] -> {:ok, source}
_else -> :error
end
end
@doc """
Same as `source/2` but raises a `ProjectError`.
"""
@spec source!(t(), Path.t()) :: Source.t()
def source!(%Project{} = project, path) do
case source(project, path) do
{:ok, source} -> source
:error -> raise ProjectError, "No source for #{inspect(path)} found."
end
end
@doc """
Returns a list of `%Rewrite.Source{}` with an implementation for the given
`module`.
"""
@spec sources_by_module(t(), module()) :: [Source.t()]
def sources_by_module(%Project{sources: sources}, module) do
Enum.reduce(sources, [], fn {_id, source}, acc ->
case module in source.modules do
true -> [source | acc]
false -> acc
end
end)
end
@doc """
Returns the `%Rewrite.source{}` for the given `module`.
Returns an `:ok` tuple with the found source, if no or multiple sources are
available an `:error` is returned.
"""
@spec source_by_module(t(), module()) :: {:ok, Source.t()} | :error
def source_by_module(%Project{} = project, module) do
case sources_by_module(project, module) do
[source] -> {:ok, source}
_else -> :error
end
end
@doc """
Same as `source_by_module/2` but raises a `ProjectError`.
"""
@spec source_by_module!(t(), module()) :: Source.t()
def source_by_module!(%Project{} = project, module) do
case source_by_module(project, module) do
{:ok, source} -> source
:error -> raise ProjectError, "No source for #{inspect(module)} found."
end
end
@doc """
Updates the `project` with the given `source`.
If the `source` is part of the project the `source` will be replaced,
otherwise the `source` will be added.
"""
@spec update(t(), Source.t() | [Source.t()]) :: t()
def update(%Project{sources: sources} = project, %Source{} = source) do
case update?(project, source) do
false ->
project
true ->
sources = Map.put(sources, source.id, source)
%Project{project | sources: sources}
end
end
def update(%Project{} = project, sources) when is_list(sources) do
Enum.reduce(sources, project, fn source, project -> update(project, source) end)
end
defp update?(%Project{sources: sources}, %Source{id: id} = source) do
case Map.fetch(sources, id) do
{:ok, legacy} -> legacy != source
:error -> true
end
end
@doc """
Returns the unreferenced sources.
Unreferenced source are sources whose original path is no longer part of the
project.
"""
@spec unreferenced(t()) :: [Source.t()]
def unreferenced(%Project{sources: sources}) do
{actual, orig} =
sources
|> Map.values()
|> Enum.reduce({MapSet.new(), MapSet.new()}, fn source, {actual, orig} ->
case {Source.path(source), Source.path(source, 1)} do
{path, path} ->
{actual, orig}
{actual_path, orig_path} ->
{MapSet.put(actual, actual_path), MapSet.put(orig, orig_path)}
end
end)
orig
|> MapSet.difference(actual)
|> MapSet.to_list()
|> Enum.sort()
end
@doc """
Returns conflicts between sources.
Sources with the same path have a conflict.
"""
@spec conflicts(t()) :: %{Path.t() => [Source.t()]}
def conflicts(%Project{sources: sources}) do
sources
|> Map.values()
|> conflicts(%{}, %{})
end
defp conflicts([], _seen, conflicts), do: conflicts
defp conflicts([source | sources], seen, conflicts) do
path = Source.path(source)
case Map.fetch(conflicts, path) do
{:ok, list} ->
conflicts = Map.put(conflicts, path, [source | list])
conflicts(sources, seen, conflicts)
:error ->
case Map.fetch(seen, path) do
{:ok, item} ->
seen = Map.delete(seen, path)
conflicts = Map.put(conflicts, path, [source, item])
conflicts(sources, seen, conflicts)
:error ->
seen = Map.put(seen, path, source)
conflicts(sources, seen, conflicts)
end
end
end
@doc """
Returns `true` if any source has one or more issues.
"""
@spec issues?(t) :: boolean
def issues?(%Project{sources: sources}) do
sources
|> Map.values()
|> Enum.any?(fn %Source{issues: issues} -> not Enum.empty?(issues) end)
end
@doc """
Counts the items of the given `type` in the `project`.
The `type` `:sources` returns the count for all sources in the project,
including scripts.
The `type` `:scripts` returns the count of all sources with a path that ends
with `".exs"`.
"""
@spec count(t, type :: :sources | :scripts) :: non_neg_integer
def count(%Project{sources: sources}, :sources), do: map_size(sources)
def count(%Project{sources: sources}, :scripts) do
sources
|> Map.values()
|> Enum.filter(fn
%{path: nil} -> false
%{path: path} -> String.ends_with?(path, ".exs")
end)
|> Enum.count()
end
@doc """
Return a `%Project{}` where each `source` is the result of invoking `fun` on
each `source` of the given `project`.
"""
@spec map(t(), (Source.t() -> Source.t())) :: t()
def map(%Project{} = project, fun) do
Enum.reduce(project, project, fn source, project ->
Project.update(project, fun.(source))
end)
end
@doc """
Saves all sources in the `project` to disk.
This function call `Rewrite.Source.save/1` on all sources in the `project`.
The optional second argument accepts a list of paths for files to be excluded.
"""
@spec save(t(), [Path.t()]) ::
:ok | {:error, :conflicts | {Path.t(), File.posix()}}
def save(%Project{sources: sources} = project, exclude \\ []) do
with :ok <- conflict_free(project, exclude) do
result =
sources
|> Map.values()
|> Enum.reduce([], fn source, errors ->
save(source, exclude, errors)
end)
case result do
[] -> :ok
errors -> {:error, errors}
end
end
end
defp save(source, exclude, errors) do
case write?(source, exclude) do
false ->
errors
true ->
case Source.save(source) do
:ok -> errors
{:error, :nofile} -> errors
{:error, reason} -> [{source.path, reason} | errors]
end
end
end
defp conflict_free(project, exclude) do
conflicts =
project
|> conflicts()
|> Map.keys()
|> Enum.reject(fn conflict -> conflict in exclude end)
case conflicts do
[] -> :ok
_list -> {:error, :conflicts}
end
end
defp write?(%Source{path: path}, exclude), do: path not in exclude
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(project) do
{:ok, map_size(project.sources)}
end
def member?(project, %Source{} = source) do
{:ok, project.sources |> Map.values() |> Enum.member?(source)}
end
def member?(_project, _other) do
{:ok, false}
end
def slice(project) do
sources = project.sources |> Map.values() |> Enum.sort_by(fn source -> source.path end)
length = length(sources)
{:ok, length,
fn
_start, 0 -> []
start, count when start + count == length -> Enum.drop(sources, start)
start, count -> sources |> Enum.drop(start) |> Enum.take(count)
end}
end
def reduce(project, acc, fun) do
sources = Map.values(project.sources)
Enumerable.List.reduce(sources, acc, fun)
end
end
end