Skip to content

Commit

Permalink
Allow generating versions for a project
Browse files Browse the repository at this point in the history
  • Loading branch information
brunvez committed May 2, 2022
1 parent af8411c commit c41515e
Show file tree
Hide file tree
Showing 10 changed files with 269 additions and 80 deletions.
2 changes: 2 additions & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
elixir 1.13.3
erlang 24.3.3
3 changes: 2 additions & 1 deletion coveralls.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
},
"skip_files": [
"test/support",
"lib/scapa/mix/tasks/coverage_report.ex"
"lib/scapa/mix/tasks/coverage_report.ex",
"lib/mix"
]
}
8 changes: 8 additions & 0 deletions lib/mix/tasks/scapa.gen.versions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
defmodule Mix.Tasks.Scapa.Gen.Versions do
@moduledoc false

@doc false
def run(_argv) do
for {path, content} <- Scapa.CLI.generate_versions(), do: File.write(path, content)
end
end
49 changes: 49 additions & 0 deletions lib/scapa/cli.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
defmodule Scapa.CLI do
@moduledoc false

alias Scapa.FunctionDefinition
alias Scapa.VersionCalculator

@doc """
Receives a pattern for files to look into and generates versions for those
"""
def generate_versions(files_pattern \\ "lib/**/*.ex") do
files_to_versionate(files_pattern)
|> Enum.map(&{&1, add_versions_to_file(&1)})
|> Enum.filter(&elem(&1, 1))
end

defp files_to_versionate(files_pattern) do
files_pattern
|> Path.wildcard()
|> Enum.map(&Path.expand/1)
|> MapSet.new()
end

defp add_versions_to_file(file_path) do
file_content = File.read!(file_path)

case funtions_to_versionate(file_content) do
[] ->
nil

function_definitions ->
Enum.reduce(function_definitions, file_content, fn function_definition, content ->
Scapa.Code.upsert_doc_version(
content,
function_definition,
VersionCalculator.calculate(function_definition)
)
end)
end
end

defp funtions_to_versionate(file_contents) do
file_contents
|> Code.string_to_quoted!()
|> Scapa.Code.defined_modules()
|> Enum.flat_map(&Scapa.Code.functions_with_doc({:module, &1, file_contents}))
|> Enum.sort_by(&FunctionDefinition.line_number/1)
|> Enum.reverse()
end
end
93 changes: 49 additions & 44 deletions lib/scapa/code.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,29 @@ defmodule Scapa.Code do
@moduledoc false
alias Scapa.FunctionDefinition

@type source() :: {:module, module()}
@type source() :: {:module, module(), source_code()}
@type source_code() :: String.t()
@type ast() :: Macro.t()

@doc """
Returns a FunctionDefinition for each function present in the source with a
@doc tag.
"""
@spec functions_with_doc([source()]) :: [FunctionDefinition.t()]
def functions_with_doc(sources) do
Enum.flat_map(sources, fn {:module, module} -> docs_from_module(module) end)
@spec functions_with_doc(source()) :: [FunctionDefinition.t()]
def functions_with_doc({:module, module, module_source}) do
docs = function_docs(module)
ast = Code.string_to_quoted!(module_source, columns: true)

docs
|> Enum.filter(&has_doc?/1)
|> Enum.map(fn
{{:function, name, arity}, doc_start, [string_signature], _, metadata} ->
%FunctionDefinition{
signature: {module, name, arity, string_signature},
version: metadata[:version],
position: function_position(doc_start, ast)
}
end)
end

@doc """
Expand All @@ -24,23 +37,12 @@ defmodule Scapa.Code do

def upsert_doc_version(
module_string,
%FunctionDefinition{version: nil} = function_definition,
%FunctionDefinition{version: nil, position: {line, column}},
new_version
) do
doc_start = doc_location(function_definition)
{line, column} = doc_tag_position(module_string, doc_start)

doc_tag =
String.duplicate(" ", column - 1) <>
Macro.to_string(
quote do
@doc version: unquote(new_version)
end
)

module_string
|> String.split("\n")
|> List.insert_at(line - 1, doc_tag)
|> List.insert_at(line - 1, String.duplicate(" ", column - 1) <> doc_tag(new_version))
|> Enum.join("\n")
end

Expand All @@ -50,38 +52,30 @@ defmodule Scapa.Code do
end

@doc """
Returns the line where the @doc tag is located for a given funtion
"""
@spec doc_location(FunctionDefinition.t()) :: nil | pos_integer()
def doc_location(%FunctionDefinition{signature: {module, name, arity, _}}) do
docs = function_docs(module)
Returns the modules defined in an AST as modules.
Enum.find_value(docs, fn
{{:function, ^name, ^arity}, line_number, _, _, _} -> line_number
_ -> nil
end)
end

defp docs_from_module(module) do
docs = function_docs(module)
## Examples
iex> ast = quote do defmodule Scapa do defmodule Scapa.Insider, do: nil end end
iex> Scapa.Code.defined_modules(ast)
[Scapa.Insider, Scapa]
"""
@spec defined_modules(Macro.t()) :: [atom()]
def defined_modules(ast) do
ast
|> Macro.prewalk([], fn
{:defmodule, _, [{:__aliases__, _, module_name} | _]} = t, acc ->
{t, [module_name | acc]}

docs
|> Enum.filter(&has_doc?/1)
|> Enum.map(fn
{{:function, name, arity}, _, [string_signature], _, metadata} ->
%FunctionDefinition{
signature: {module, name, arity, string_signature},
version: metadata[:version]
}
t, acc ->
{t, acc}
end)
|> elem(1)
|> Enum.map(&Enum.join(["Elixir"] ++ &1, "."))
|> Enum.map(&String.to_existing_atom/1)
end

defp has_doc?({_, _, _, doc_content, _}) when doc_content in [:none, :hidden], do: false
defp has_doc?(_), do: true

defp doc_tag_position(module_string, doc_start) do
module_string
|> Code.string_to_quoted!(columns: true)
defp function_position(doc_start, ast) do
ast
|> Macro.prewalk([], fn
{:def, [line: line, column: column], _} = t, acc when line >= doc_start ->
{t, [{line, column} | acc]}
Expand All @@ -93,9 +87,20 @@ defmodule Scapa.Code do
|> Enum.min()
end

defp has_doc?({_, _, _, doc_content, _}) when doc_content in [:none, :hidden], do: false
defp has_doc?(_), do: true

defp function_docs(module) do
{:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(module)

docs
end

defp doc_tag(version) do
Macro.to_string(
quote do
@doc version: unquote(version)
end
)
end
end
18 changes: 16 additions & 2 deletions lib/scapa/function_definition.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,25 @@ defmodule Scapa.FunctionDefinition do

@type signature() :: {module(), atom(), arity(), String.t()}
@type version() :: String.t()
@type position :: {line(), column()}
@typep line() :: pos_integer()
@typep column() :: pos_integer()

@type t :: %__MODULE__{
signature: signature(),
version: nil | version()
version: nil | version(),
position: position()
}

defstruct [:signature, :version]
defstruct [:signature, :version, :position]

@doc """
Returns the line number for a function definition
## Example
iex> Scapa.FunctionDefinition.line_number(%Scapa.FunctionDefinition{position: {42, 8}})
42
"""
@spec line_number(t()) :: line()
def line_number(%__MODULE__{position: {line, _column}}), do: line
end
61 changes: 61 additions & 0 deletions test/scapa/cli_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
defmodule Scapa.CLITest do
use ExUnit.Case, async: true

alias Scapa.CLI

describe "generate_versions/1" do
test "returns the file location and new source code wih versions" do
[module_with_doc, module_with_hidden_doc] = CLI.generate_versions("test/support/*.ex")

assert String.ends_with?(elem(module_with_doc, 0), "/support/module_with_doc.ex")

assert elem(module_with_doc, 1) == """
defmodule Scapa.ModuleWithDoc do
@moduledoc \"""
Test module used to test the returned function definitions and
the corresponding version.
\"""
@doc "Public with doc"
@doc version: "75335224"
def public_with_doc, do: nil
@doc "Public with version"
@doc version: "27952351"
def public_with_version, do: nil
@doc "Multiple def"
@doc version: "30685952"
def multiple_def(1), do: 2
def multiple_def("2"), do: 4
@doc "Multiple def with default"
@doc version: "119275990"
def multiple_def_with_default(num \\\\ 42)
def multiple_def_with_default(1), do: 2
def multiple_def_with_default(2), do: 4
def public_no_doc, do: nil
defp private_fun, do: nil
end
"""

assert String.ends_with?(
elem(module_with_hidden_doc, 0),
"/support/module_with_hidden_doc.ex"
)

assert elem(module_with_hidden_doc, 1) == """
defmodule Scapa.ModuleWithHiddenDoc do
@moduledoc false
@doc "Public with doc"
@doc version: "67474296"
def public_with_doc, do: nil
end
"""
end
end
end
Loading

0 comments on commit c41515e

Please sign in to comment.