defmodule Recode.Source do
@moduledoc """
A representation of some source in a project.
The `%Source{}` contains the `code` of the file given by `path`. The module
contains `Source.update/3` to update the `path` and/or the `code`. The changes
are recorded in the `updates` list.
The struct also holds `issues` for the source.
"""
alias Recode.Context
alias Recode.DotFormatter
alias Recode.Issue
alias Recode.Source
alias Recode.SourceError
alias Sourceror.Zipper
defstruct [
:id,
:path,
:code,
:ast,
:hash,
:modules,
updates: [],
issues: []
]
@typedoc """
The `version` of a `%Source{}`. The version `1` indicates that the source has
no changes.
"""
@type version :: pos_integer()
@type kind :: :code | :path
@type by :: module()
@type id :: String.t()
@type t :: %Source{
id: id(),
path: Path.t() | nil,
code: String.t(),
ast: Macro.t(),
hash: String.t(),
modules: [module()],
updates: [{kind(), by(), String.t()}],
issues: term()
}
@doc ~S'''
Creates a new `%Source{}` from the given `path`.
## Examples
iex> source = Source.new!("test/fixtures/source/simple.ex")
iex> source.modules
[MyApp.Simple]
iex> source.code
"""
defmodule MyApp.Simple do
def foo(x) do
x * 2
end
end
"""
'''
@spec new!(Path.t()) :: t()
def new!(path) do
path |> File.read!() |> from_string(path)
end
@doc """
Creates a new `%Source{}` from the given `string`.
## Examples
iex> source = Source.from_string("a + b")
iex> source.modules
[]
iex> source.code
"a + b"
"""
@spec from_string(String.t(), Path.t() | nil) :: t()
def from_string(string, path \\ nil) do
ast = Sourceror.parse_string!(string)
struct!(
Source,
id: make_ref(),
path: path,
code: string,
ast: ast,
hash: hash(path, string),
modules: get_modules(ast)
)
end
@doc """
Marks the given `source` as deleted.
This function set the `path` of the `given` source to `nil`.
"""
@spec del(t(), nil | module()) :: t()
def del(source, by \\ nil)
def del(%Source{path: nil} = source, _by), do: source
def del(%Source{path: legacy} = source, by) do
source
|> put(:path, nil)
|> update_updates({:path, by, legacy})
|> update_hash()
end
@doc ~S"""
Saves the source to disk.
If the source `:path` was updated then the old file will be deleted. The
original file will also deleted when the `source` was marked as deleted with
`del/1`.
Missing directories are created.
## Examples
iex> ":test" |> Source.from_string() |> Source.save()
{:error, :nofile}
iex> path = "tmp/foo.ex"
iex> File.write(path, ":foo")
iex> source = path |> Source.new!() |> Source.update(:test, code: ":bar")
iex> Source.save(source)
:ok
iex> File.read(path)
{:ok, ":bar\n"}
iex> source |> Source.del() |> Source.save()
iex> File.exists?(path)
false
iex> source = Source.from_string(":bar")
iex> Source.save(source)
{:error, :nofile}
iex> source |> Source.update(:test, path: "tmp/bar.ex") |> Source.save()
:ok
iex> path = "tmp/ping.ex"
iex> File.write(path, ":ping")
iex> source = path |> Source.new!()
iex> new_path = "tmp/pong.ex"
iex> source |> Source.update(:test, path: new_path) |> Source.save()
:ok
iex> File.exists?(path)
false
iex> File.read(new_path)
{:ok, ":ping"}
"""
@spec save(t()) :: :ok | {:error, :nofile | File.posix()}
def save(%Source{path: nil, updates: []}), do: {:error, :nofile}
def save(%Source{updates: []}), do: :ok
def save(%Source{path: nil} = source), do: rm(source)
def save(%Source{path: path, code: code} = source) do
with :ok <- mkdir_p(path),
:ok <- File.write(path, code) do
rm(source)
end
end
defp mkdir_p(path) do
path |> Path.dirname() |> File.mkdir_p()
end
defp rm(source) do
case {Source.updated?(source, :path), Source.path(source, 1)} do
{false, _path} -> :ok
{true, nil} -> :ok
{true, path} -> File.rm(path)
end
end
@doc """
Returns the `version` of the given `source`. The value `1` indicates that the
source has no changes.
"""
@spec version(t()) :: version()
def version(%Source{updates: updates}), do: length(updates) + 1
@doc """
Adds the given `issues` to the `source`.
"""
@spec add_issues(t(), [Issue.t()]) :: t()
def add_issues(%Source{issues: list} = source, issues) do
version = version(source)
issues = issues |> Enum.map(fn issue -> {version, issue} end) |> Enum.concat(list)
%Source{source | issues: issues}
end
@doc """
Adds the given `issue` to the `source`.
"""
@spec add_issue(t(), Issue.t()) :: t()
def add_issue(%Source{} = source, %Issue{} = issue), do: add_issues(source, [issue])
@doc ~S"""
Updates the `code` or the `path` of a `source`.
## Examples
iex> source =
...> "a + b"
...> |> Source.from_string()
...> |> Source.update(:example, path: "test/fixtures/new.exs")
...> |> Source.update(:example, code: "a - b")
iex> source.updates
[{:code, :example, "a + b"}, {:path, :example, nil}]
iex> source.code
"a - b\n"
If the new value equal to the current value, no updates will be added.
iex> source =
...> "a = 42"
...> |> Source.from_string()
...> |> Source.update(:example, code: "b = 21")
...> |> Source.update(:example, code: "b = 21")
...> |> Source.update(:example, code: "b = 21")
iex> source.updates
[{:code, :example, "a = 42"}]
"""
@spec update(t(), by(), [code: String.t() | Zipper.zipper()] | [path: Path.t()]) :: t()
def update(%Source{ast: ast} = source, _by, [{:code, {ast, _meta}}]), do: source
def update(%Source{} = source, by, [{:code, {ast, _meta}}]) do
code = ast |> Sourceror.to_string(DotFormatter.opts()) |> newline()
update(source, by, code: code)
end
def update(%Source{} = source, by, [{key, value}])
when is_atom(by) and key in [:code, :path] and is_binary(value) do
legacy = Map.fetch!(source, key)
value = if key == :code, do: newline(value), else: value
case legacy == value do
true ->
source
false ->
update = {key, by, legacy}
source
|> put(key, value)
|> update_updates(update)
|> update_modules(key, value)
|> update_hash()
end
end
@doc """
Returns `true` if the source was updated.
The optional argument `kind` specifies whether only `:code` changes or `:path`
changes are considered. Defaults to `:any`.
## Examples
iex> source = Source.from_string("a = 42")
iex> Source.updated?(source)
false
iex> source = Source.update(source, :example, code: "b = 21")
iex> Source.updated?(source)
true
iex> Source.updated?(source, :path)
false
iex> Source.updated?(source, :code)
true
"""
@spec updated?(t(), kind :: :code | :path | :any) :: boolean()
def updated?(source, kind \\ :any)
def updated?(%Source{updates: []}, _kind), do: false
def updated?(%Source{updates: _updates}, :any), do: true
def updated?(%Source{updates: updates}, kind) when kind in [:code, :path] do
Enum.any?(updates, fn
{^kind, _by, _value} -> true
_update -> false
end)
end
@doc """
Returns `true` if the `source` has issues for the given `version`.
The `version` argument also accepts `:actual` and `:all` to check whether the
`source` has problems for the actual version or if there are problems at all.
## Examples
iex> alias Recode.Issue
iex> source =
...> "a + b"
...> |> Source.from_string("some/where/plus.exs")
...> |> Source.add_issue(Issue.new(:test, "no comment", line: 1))
...> |> Source.update(:example, path: "some/where/else/plus.exs")
...> |> Source.add_issue(Issue.new(:test, "no comment", line: 1))
iex> Source.has_issues?(source)
true
iex> Source.has_issues?(source, 1)
true
iex> Source.has_issues?(source, :all)
true
iex> source = Source.update(source, :example, code: "a - b")
iex> Source.has_issues?(source)
false
iex> Source.has_issues?(source, 2)
true
iex> Source.has_issues?(source, :all)
true
"""
@spec has_issues?(t(), version() | :actual | :all) :: boolean
def has_issues?(source, version \\ :actual)
def has_issues?(%Source{issues: issues}, :all), do: not_empty?(issues)
def has_issues?(%Source{} = source, :actual) do
has_issues?(source, version(source))
end
def has_issues?(%Source{issues: issues, updates: updates}, version)
when version >= 1 and version <= length(updates) + 1 do
issues
|> Enum.filter(fn {for_version, _issue} -> for_version == version end)
|> not_empty?()
end
@doc """
Returns the current path for the given `source`.
"""
@spec path(t()) :: Path.t() | nil
def path(%Source{path: path}), do: path
@doc """
Returns the path of a `source` for the given `version`.
## Examples
iex> source =
...> "a + b"
...> |> Source.from_string("some/where/plus.exs")
...> |> Source.update(:example, path: "some/where/else/plus.exs")
...> Source.path(source, 1)
"some/where/plus.exs"
iex> Source.path(source, 2)
"some/where/else/plus.exs"
"""
@spec path(t(), version()) :: Path.t() | nil
def path(%Source{path: path, updates: updates}, version)
when version >= 1 and version <= length(updates) + 1 do
updates
|> Enum.take(length(updates) - version + 1)
|> Enum.reduce(path, fn
{:path, _by, path}, _path -> path
_version, path -> path
end)
end
@doc """
Returns the current modules for the given `source`.
"""
@spec modules(t()) :: [module()]
def modules(%Source{modules: modules}), do: modules
@doc ~S'''
Returns the modules of a `source` for the given `version`.
## Examples
iex> bar =
...> """
...> defmodule Bar do
...> def bar, do: :bar
...> end
...> """
iex> foo =
...> """
...> defmodule Foo do
...> def foo, do: :foo
...> end
...> """
iex> source = Source.from_string(bar)
iex> source = Source.update(source, :example, code: bar <> foo)
iex> Source.modules(source)
[Bar, Foo]
iex> Source.modules(source, 2)
[Bar, Foo]
iex> Source.modules(source, 1)
[Bar]
'''
@spec modules(t(), version()) :: [module()]
def modules(%Source{updates: updates} = source, version)
when version >= 1 and version <= length(updates) + 1 do
source |> code(version) |> get_modules()
end
@doc """
Returns the current code for the given `source`.
"""
@spec code(t()) :: String.t()
def code(%Source{code: code}), do: code
@doc ~S'''
Returns the code of a `source` for the given `version`.
## Examples
iex> bar =
...> """
...> defmodule Bar do
...> def bar, do: :bar
...> end
...> """
iex> foo =
...> """
...> defmodule Foo do
...> def foo, do: :foo
...> end
...> """
iex> source = Source.from_string(bar)
iex> source = Source.update(source, :example, code: foo)
iex> Source.code(source) == foo
true
iex> Source.code(source, 2) == foo
true
iex> Source.code(source, 1) == bar
true
'''
@spec code(t(), version()) :: String.t()
def code(%Source{code: code, updates: updates}, version)
when version >= 1 and version <= length(updates) + 1 do
updates
|> Enum.take(length(updates) - version + 1)
|> Enum.reduce(code, fn
{:code, _by, code}, _code -> code
_version, code -> code
end)
end
@doc """
Returns the AST for the given `%Source`.
The returned extended AST is generated with `Sourceror.parse_string/1`.
Uses the current `code` of the `source`.
## Examples
iex> "def foo, do: :foo" |> Source.from_string() |> Source.ast()
{:def, [trailing_comments: [], leading_comments: [], line: 1, column: 1],
[
{:foo, [trailing_comments: [], leading_comments: [], line: 1, column: 5], nil},
[
{{:__block__,
[trailing_comments: [], leading_comments: [], format: :keyword, line: 1, column: 10],
[:do]},
{:__block__, [trailing_comments: [], leading_comments: [], line: 1, column: 14], [:foo]}}
]
]
}
"""
@spec ast(t()) :: {:ok, Macro.t()} | {:error, term()}
def ast(%Source{ast: ast}), do: ast
@doc """
Returns a `Sourceror.Zipper` with the AST for the given `%Source`.
"""
@spec zipper(t()) :: {:ok, Zipper.zipper()} | {:error, term()}
def zipper(%Source{ast: ast}), do: Zipper.zip(ast)
@doc """
Compares the `path` values of the given sources.
## Examples
iex> a = Source.from_string(":foo", "a.exs")
iex> Source.compare(a, a)
:eq
iex> b = Source.from_string(":foo", "b.exs")
iex> Source.compare(a, b)
:lt
iex> Source.compare(b, a)
:gt
"""
@spec compare(t(), t()) :: :lt | :eq | :gt
def compare(%Source{path: path1}, %Source{path: path2}) do
cond do
path1 < path2 -> :lt
path1 > path2 -> :gt
true -> :eq
end
end
@doc ~S'''
Returns the debug info for the give `source` and `module`.
Uses the current `code` of the `source`.
## Examples
iex> bar =
...> """
...> defmodule Bar do
...> def bar, do: :bar
...> end
...> """
iex> bar |> Source.from_string() |> Source.debug_info(Bar)
{:ok,
%{
attributes: [],
compile_opts: [],
definitions: [{{:bar, 0}, :def, [line: 2], [{[line: 2], [], [], :bar}]}],
deprecated: [],
file: "nofile",
is_behaviour: false,
line: 1,
module: Bar,
relative_file: "nofile",
struct: nil,
unreachable: []
}}
'''
@spec debug_info(t(), module()) :: {:ok, term()} | {:error, term()}
def debug_info(%Source{modules: modules, code: code, path: path} = source, module) do
case module in modules do
true -> do_debug_info(module, code, path, updated?(source))
false -> {:error, :non_existing}
end
end
@doc """
Same as `debug_info/1` but raises on error.
"""
@spec debug_info!(t(), module()) :: term()
def debug_info!(%Source{} = source, module) do
case debug_info(source, module) do
{:ok, debug_info} ->
debug_info
{:error, reason} ->
raise SourceError, "Can not find debug info, reason: #{inspect(reason)}"
end
end
defp do_debug_info(module, code, path, updated?) do
case not updated? and BeamFile.exists?(module) do
true -> BeamFile.debug_info(module)
false -> code |> compile_module(path, module) |> BeamFile.debug_info()
end
end
defp get_modules(code) when is_binary(code) do
code
|> Sourceror.parse_string!()
|> get_modules()
end
defp get_modules(code) do
# `get_modules/1` does not use `Code.compile_*/2` so that code fragments and
# non-compilable code can also be examined.
code
|> Zipper.zip()
|> Context.traverse(MapSet.new(), fn zipper, context, acc ->
acc =
case Context.module(context) do
nil -> acc
module -> MapSet.put(acc, module)
end
{zipper, context, acc}
end)
|> elem(1)
|> MapSet.to_list()
end
defp update_modules(source, :code, code), do: %{source | modules: get_modules(code)}
defp update_modules(source, _key, _value), do: source
defp hash(nil, code), do: :crypto.hash(:md5, code)
defp hash(path, code), do: :crypto.hash(:md5, path <> code)
defp update_hash(%Source{path: path, code: code} = source) do
%{source | hash: hash(path, code)}
end
defp put(source, :code, value) do
code = newline(value)
source
|> Map.put(:code, code)
|> Map.put(:ast, Sourceror.parse_string!(code))
end
defp put(source, key, value), do: Map.put(source, key, value)
defp update_updates(%Source{updates: updates} = source, update) do
%{source | updates: [update | updates]}
end
defp compile_module(code, path, module) do
code |> Code.compile_string(path || "nofile") |> Keyword.fetch!(module)
end
defp not_empty?(enum), do: not Enum.empty?(enum)
defp newline(string), do: String.trim_trailing(string) <> "\n"
end