-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fetching types and docs from modules by compiling them into a temporary file.
- Loading branch information
Showing
11 changed files
with
577 additions
and
388 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
defmodule DocTest do | ||
use TypedStructor.TestCase, async: true | ||
|
||
@tag :tmp_dir | ||
test "typedoc", ctx do | ||
generated_doc = | ||
with_tmpmodule User, ctx do | ||
use TypedStructor | ||
|
||
@typedoc "A user struct" | ||
typed_structor do | ||
field :name, String.t() | ||
field :age, integer() | ||
end | ||
after | ||
fetch_doc!(User, {:type, :t, 0}) | ||
end | ||
|
||
assert "A user struct" === generated_doc | ||
end | ||
|
||
@tag :tmp_dir | ||
test "typedoc inside block", ctx do | ||
generated_doc = | ||
with_tmpmodule User, ctx do | ||
use TypedStructor | ||
|
||
typed_structor do | ||
@typedoc "A user struct" | ||
field :name, String.t() | ||
field :age, integer() | ||
end | ||
after | ||
fetch_doc!(User, {:type, :t, 0}) | ||
end | ||
|
||
assert "A user struct" === generated_doc | ||
end | ||
|
||
@tag :tmp_dir | ||
test "moduledoc and typedoc inside submodule's block", ctx do | ||
generated_docs = | ||
with_tmpmodule MyModule, ctx do | ||
use TypedStructor | ||
|
||
typed_structor module: User do | ||
@moduledoc "A user module" | ||
@typedoc "A user struct" | ||
field :name, String.t() | ||
field :age, integer() | ||
end | ||
after | ||
{ | ||
fetch_doc!(MyModule.User, :moduledoc), | ||
fetch_doc!(MyModule.User, {:type, :t, 0}) | ||
} | ||
|> tap(fn _ -> | ||
cleanup_modules([MyModule.User], ctx.tmp_dir) | ||
end) | ||
end | ||
|
||
assert {"A user module", "A user struct"} === generated_docs | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,195 @@ | ||
defmodule TypedStructor.TestCase do | ||
@moduledoc false | ||
use ExUnit.CaseTemplate | ||
|
||
setup ctx do | ||
if Map.has_key?(ctx, :tmp_dir) do | ||
true = Code.append_path(ctx.tmp_dir) | ||
on_exit(fn -> Code.delete_path(ctx.tmp_dir) end) | ||
end | ||
|
||
:ok | ||
end | ||
|
||
using do | ||
quote do | ||
import unquote(__MODULE__) | ||
end | ||
end | ||
|
||
@doc """ | ||
Defines a temporary module with the given `module_name` and executes the code | ||
in the `after` block. The module is removed after the block is executed. | ||
And the `after` block's return value is returned. | ||
Note that the `module_name` is expanded to the caller's module. | ||
""" | ||
defmacro with_tmpmodule(module_name, ctx, options) when is_list(options) do | ||
module_name = | ||
module_name | ||
|> Macro.expand(__CALLER__) | ||
|> then(&Module.concat(__CALLER__.module, &1)) | ||
|
||
code = Keyword.fetch(options, :do) | ||
|
||
content = | ||
""" | ||
defmodule #{Atom.to_string(module_name)} do | ||
#{Macro.to_string(code)} | ||
end | ||
""" | ||
|
||
fun = | ||
quote do | ||
fn -> | ||
alias unquote(module_name) | ||
unquote(Keyword.get(options, :after)) | ||
end | ||
end | ||
|
||
quote do | ||
unquote(__MODULE__).__with_file__( | ||
unquote(ctx), | ||
{unquote(module_name), unquote(content)}, | ||
unquote(fun) | ||
) | ||
end | ||
end | ||
|
||
@doc false | ||
def __with_file__(%{tmp_dir: dir}, {module_name, content}, fun) when is_function(fun, 0) do | ||
path = Path.join([dir, Atom.to_string(module_name)]) | ||
|
||
try do | ||
File.write!(path, content) | ||
compile_file!(path, dir) | ||
|
||
fun.() | ||
after | ||
File.rm!(path) | ||
cleanup_modules([module_name], dir) | ||
end | ||
end | ||
|
||
@doc """ | ||
Defines a temporary module with the given `module_name`, | ||
returns the compiled modules. | ||
You should clean up the modules by calling `cleanup_modules/2` | ||
after you are done. | ||
Note that the `module_name` is expanded to the caller's module | ||
like `with_tmpmodule/3`. | ||
""" | ||
defmacro deftmpmodule(module_name, ctx, do: block) do | ||
module_name = | ||
module_name | ||
|> Macro.expand(__CALLER__) | ||
|> then(&Module.concat(__CALLER__.module, &1)) | ||
|
||
content = | ||
""" | ||
defmodule #{Atom.to_string(module_name)} do | ||
#{Macro.to_string(block)} | ||
end | ||
""" | ||
|
||
quote do | ||
alias unquote(module_name) | ||
|
||
unquote(__MODULE__).__compile_tmpmodule__( | ||
unquote(ctx), | ||
{unquote(module_name), unquote(content)} | ||
) | ||
end | ||
end | ||
|
||
@doc false | ||
def __compile_tmpmodule__(%{tmp_dir: dir}, {module_name, content}) do | ||
path = Path.join([dir, Atom.to_string(module_name)]) | ||
|
||
File.write!(path, content) | ||
compile_file!(path, dir) | ||
end | ||
|
||
defp compile_file!(path, dir) do | ||
Code.compiler_options(docs: true, debug_info: true) | ||
{:ok, modules, []} = Kernel.ParallelCompiler.compile_to_path(List.wrap(path), dir) | ||
|
||
modules | ||
end | ||
|
||
@doc """ | ||
Cleans up the modules by removing the beam files and purging the code. | ||
""" | ||
@spec cleanup_modules([module()], dir :: Path.t()) :: term() | ||
def cleanup_modules(mods, dir) do | ||
Enum.each(mods, fn mod -> | ||
File.rm(Path.join([dir, "#{mod}.beam"])) | ||
:code.purge(mod) | ||
true = :code.delete(mod) | ||
end) | ||
end | ||
|
||
@doc """ | ||
Fetches the types for the given module. | ||
""" | ||
@spec fetch_types!(module() | binary) :: [tuple()] | ||
def fetch_types!(module) when is_atom(module) or is_binary(module) do | ||
module | ||
|> Code.Typespec.fetch_types() | ||
|> case do | ||
:error -> refute "Failed to fetch types for module #{module}" | ||
{:ok, types} -> types | ||
end | ||
end | ||
|
||
@doc """ | ||
Fetches the doc for the given module or its functions and types. | ||
""" | ||
def fetch_doc!(module, :moduledoc) when is_atom(module) do | ||
case Code.fetch_docs(module) do | ||
{:docs_v1, _, :elixir, _, %{"en" => doc}, _, _} -> doc | ||
_ -> refute "Failed to fetch moduledoc for #{module}" | ||
end | ||
end | ||
|
||
def fetch_doc!(module, {type, name, arity}) when is_atom(module) do | ||
with( | ||
{:docs_v1, _, :elixir, _, _, _, docs} <- Code.fetch_docs(module), | ||
{_, _, _, %{"en" => doc}, _} <- List.keyfind(docs, {type, name, arity}, 0) | ||
) do | ||
doc | ||
else | ||
_other -> refute "Failed to fetch doc for #{inspect({type, name, arity})} at #{module}" | ||
end | ||
end | ||
|
||
@doc """ | ||
Asserts that the expected types are equal to the actual types by comparing | ||
their formatted strings. | ||
""" | ||
@spec assert_type(expected :: [tuple()], actual :: [tuple()]) :: term() | ||
def assert_type(expected, actual) do | ||
expected_types = format_types(expected) | ||
|
||
if String.length(String.trim(expected_types)) === 0 do | ||
refute "Expected types are empty: #{inspect(expected)}" | ||
end | ||
|
||
assert expected_types == format_types(actual) | ||
end | ||
|
||
@spec format_types([tuple()]) :: String.t() | ||
def format_types(types) do | ||
types | ||
|> Enum.sort_by(fn {_, {name, _, args}} -> {name, length(args)} end) | ||
|> Enum.map_join( | ||
"\n", | ||
fn {kind, type} -> | ||
ast = Code.Typespec.type_to_quoted(type) | ||
"@#{kind} #{Macro.to_string(ast)}" | ||
end | ||
) | ||
end | ||
end |
Oops, something went wrong.