defmodule TypeCheck.Macros do
@moduledoc """
Contains the `@spec!`, `@type!`, `@typep!`, `@opaque!` macros to define runtime-checked function- and type-specifications.
## Usage
This module is included by calling `use TypeCheck`.
This will set up the module to use the special macros.
Usually you'll want to use the module attribute-style of the macros, like
`@spec!` and `@type!`.
Using these forms has two advantages over using the direct calls:
1. Syntax highlighting will highlight the types correctly
and the Elixir formatter will not mess with the way you write your type.
2. It is clear to people who have not heard of `TypeCheck` before that `@type!` and `@spec!`
will work similarly to resp. `@type` and `@spec`.
### Avoiding naming conflicts with TypeCheck.Builtin
If you want to define a type with the same name as one in TypeCheck.Builtin,
_(which is not particularly recommended)_,
you should hide those particular functions from TypeCheck.Builtin by adding
`import TypeCheck.Builtin, except: [...]`
below `use TypeCheck` manually.
### Calling the explicit implementations
In case you are working in an environment where the `@/1` is already overridden
by another library, you can still use this library,
by simply adding `import TypeCheck.Macros, except: [@: 1]` to your module
and calling the direct versions of the macros instead.
### TypeCheck and metaprogramming
In certain cases you might want to use TypeCheck to dynamically generate
types or functions, such as to add `@spec!`-s to functions
that themselves are dynamically generated.
TypeCheck's macros support 'unquote fragments',
just like many builtin 'definition' constructs like `def`, but also `@type` do.
(c.f. `Elixir.Kernel.SpecialForms.quote/2` for more details about unquote fragments.)
An example:
```
defmodule MetaExample do
use TypeCheck
people = ~w[joe robert mike]a
for name <- people do
@type! unquote(name)() :: %{name: unquote(name), coolness_level: :high}
end
end
```
```
iex> MetaExample.joe
#TypeCheck.Type< TypeCheck.MacrosTest.MetaExample.joe() :: %{coolness_level: :high, name: :joe} >
iex> MetaExample.mike
#TypeCheck.Type< TypeCheck.MacrosTest.MetaExample.mike() :: %{coolness_level: :high, name: :mike} >
```
#### Macros
Inside macros, we use unquote fragments in the same way.
There is however one more thing to keep in mind:
You'll need to add a call to `import Kernel, except: [@: 1]` in your macro (before the quote)
to make sure you can call `@type!`, `@spec!` etc.
This is a subtle consequence of Elixir's macro-hygiene rules.
[See this issue on Elixir's GitHub repository for more info](https://github.com/elixir-lang/elixir/issues/10497#issuecomment-729479434)
(Alternatively, directly calls to `type!`, `spec!` etc. are possible without overriding the import.)
An example:
```
defmodule GreeterMacro do
defmacro generate_greeter(greeting) do
import Kernel, except: [@: 1] # Ensures TypeSpec's overridden `@` is used in the quote
quote do
@spec! unquote(greeting)(binary) :: binary
def unquote(greeting)(name) do
"\#{greeting}, \#{name}!"
end
end
end
end
defmodule GreeterExample do
use TypeCheck
require GreeterMacro
GreeterMacro.generate_greeter(:hi)
GreeterMacro.generate_greeter(:hello)
end
```
```
iex> GreeterExample.hi("John")
"hi, John!"
iex> GreeterExample.hello("Frank")
"hello, Frank!"
iex> GreeterExample.hi(42)
** (TypeCheck.TypeError) At test/type_check/macros_test.exs:32:
The call to `hi/1` failed,
because parameter no. 1 does not adhere to the spec `binary()`.
Rather, its value is: `42`.
Details:
The call `hi(42)`
does not adhere to spec `hi(binary()) :: binary()`. Reason:
parameter no. 1:
`42` is not a binary.
```
#### About `use TypeCheck`
The `use TypeCheck` statement adds an `@before_compile`-hook to the final module,
which is used to wrap functions with the specified runtime type-checks.
This means that some care needs to be taken to ensure that a call to `use TypeCheck` exists
in the final module, if you're generating specs dynamically from inside macros.
#### Hiding the autogenerated typespec
By default, `TypeCheck` will automatically generate `@type`, `@opaque` and `@spec`-attributes,
which will be shown in the documentation, as well as used by tools such as Dialyzer.
In rare situations, `TypeCheck` might try to generate typespecs which are invalid.
(In such case, please [open a bug report!](https://github.com/Qqwy/elixir-type_check/issues))
Or sometimes, you might want to alter the type which is exported.
In such situations, you can disable the autogeneration of these attributes,
by calling `@autogen_typespec false` just before the next `@type!`/`@opaque!`/`@spec!`:
```
defmodule AutogenTypespecsExample do
use TypeCheck
# The typespec of `foo` is auto-generated:
# A line `@type foo() :: integer()` will be visible in the documentation/Dialyzer.
@type! foo() :: integer()
# The typespec of `bar` is _not_ auto-generated.
# As such, we could write a completely different `@type` (or leave it out all-together).
@autogen_typespec false
@type! bar() :: integer()
end
```
```
iex>t AutogenTypespecsExample # Will show type of `foo` but not `bar`
"@type foo() :: integer()"
```
"""
defmacro __using__(options) do
# Application.get_application(module) does not work while compiling `module`:
otp_app = Mix.Project.config() |> Keyword.get(:app)
quote generated: true, location: :keep do
import Kernel, except: [@: 1]
import TypeCheck.Macros, only: [type!: 1, typep!: 1, opaque!: 1, spec!: 1, @: 1]
Module.register_attribute(__MODULE__, TypeCheck.TypeDefs, accumulate: true)
Module.register_attribute(__MODULE__, TypeCheck.TypeDefNames, accumulate: true)
Module.register_attribute(__MODULE__, TypeCheck.Specs, accumulate: true)
@before_compile TypeCheck.Macros
# first parameter _needs_ to be an atom for the macro to work on Elixir < v.1.13
default_options =
if unquote(otp_app) != nil do
Application.compile_env(unquote(otp_app), :type_check, [])
else
[]
end
Module.put_attribute(
__MODULE__,
TypeCheck.Options,
TypeCheck.Options.new(unquote(options) ++ default_options)
)
Module.put_attribute(__MODULE__, :autogen_typespec, true)
end
end
defmacro __before_compile__(env) do
defs = Module.get_attribute(env.module, TypeCheck.TypeDefs)
typedef_names = Module.get_attribute(env.module, TypeCheck.TypeDefNames)
options = Module.get_attribute(env.module, TypeCheck.Options)
compile_time_imports_module_name = Module.concat(TypeCheck.Internals.UserTypes, env.module)
Module.create(
compile_time_imports_module_name,
quote generated: true, location: :keep do
@moduledoc false
# This extra module is created
# so that we can already access the custom user types
# at compile-time
# _inside_ the module they will be part of
unquote(defs)
end,
env
)
# And now, define all specs:
definitions = Module.definitions_in(env.module)
specs = Module.get_attribute(env.module, TypeCheck.Specs)
spec_defs = create_spec_defs(specs, definitions, env)
spec_quotes =
if Map.get(options, :enable_runtime_checks) do
wrap_functions_with_specs(specs, definitions, env)
else
quote do
end
end
spec_names = specs |> Enum.map(fn {name, _, arity, _, _, _} -> {name, arity} end)
# And now for the tricky bit ;-)
quote generated: true, location: :keep do
unquote(spec_defs)
import unquote(compile_time_imports_module_name)
unquote(spec_quotes)
@doc false
def __type_check__(arg)
def __type_check__(:specs) do
unquote(spec_names)
end
def __type_check__(:types) do
unquote(typedef_names)
end
end
end
defp create_spec_defs(specs, _definitions, caller) do
for {name, location, arity, _clean_params, params_ast, return_type_ast} <- specs do
require TypeCheck.Type
typecheck_options =
Module.get_attribute(caller.module, TypeCheck.Options, TypeCheck.Options.new())
param_types =
Enum.map(params_ast, &TypeCheck.Type.build_unescaped(&1, caller, typecheck_options, true))
return_type =
TypeCheck.Type.build_unescaped(return_type_ast, caller, typecheck_options, true)
TypeCheck.Spec.create_spec_def(name, arity, param_types, return_type, location)
end
end
defp wrap_functions_with_specs(specs, definitions, caller) do
for {name, location, arity, clean_params, params_ast, return_type_ast} <- specs do
unless {name, arity} in definitions do
raise TypeCheck.CompileError, "spec for undefined function #{name}/#{arity}"
end
require TypeCheck.Type
typecheck_options =
Module.get_attribute(caller.module, TypeCheck.Options, TypeCheck.Options.new())
param_types =
Enum.map(params_ast, &TypeCheck.Type.build_unescaped(&1, caller, typecheck_options, true))
return_type =
TypeCheck.Type.build_unescaped(return_type_ast, caller, typecheck_options, true)
clean_specdef = TypeCheck.Spec.to_typespec(name, params_ast, return_type_ast, caller)
{params_spec_code, return_spec_code} =
TypeCheck.Spec.prepare_spec_wrapper_code(
name,
param_types,
clean_params,
return_type,
caller,
location
)
res =
TypeCheck.Spec.wrap_function_with_spec(
name,
location,
arity,
clean_params,
params_spec_code,
return_spec_code,
clean_specdef,
caller
)
if typecheck_options.debug do
TypeCheck.Internals.Helper.prettyprint_spec("TypeCheck.Macros @spec", res)
end
res
end
end
@doc """
Define a public type specification.
Usually invoked as `@type!`
This behaves similarly to Elixir's builtin `@type` attribute,
and will create a type whose name and definition are public.
Calling this macro will:
- Fill the `@type`-attribute with a Typespec-friendly
representation of the TypeCheck type.
- Add a (or append to an already existing) `@typedoc` detailing that the type is
managed by TypeCheck, and containing the full definition of the TypeCheck type.
- Define a (hidden) public function with the same name (and arity) as the type
that returns the TypeCheck.Type as a datastructure when called.
This makes the type usable in calls to:
- definitions of other type-specifications (in the same or different modules).
- definitions of function-specifications (in the same or different modules).
- `TypeCheck.conforms/2` and variants,
- `TypeCheck.Type.build/1`
## Usage
The syntax is essentially the same as for the built-in `@type` attribute:
```elixir
@type! type_name :: type_description
```
It is possible to create parameterized types as well:
```
@type! dict(key, value) :: [{key, value}]
```
### Named types
You can also introduce named types:
```
@type! color :: {red :: integer, green :: integer, blue :: integer}
```
Not only is this nice to document that the same type
is being used for different purposes,
it can also be used with a 'type guard' to add custom checks
to your type specifications:
```
@type! sorted_pair(a, b) :: {first :: a, second :: b} when first <= second
```
"""
defmacro type!(typedef) do
# The extra indirection here ensures we are able to support unquote fragments
quote generated: true, location: :keep do
unquote(Macro.escape(typedef, unquote: true))
|> TypeCheck.Macros.define_type(:type, __ENV__)
|> Code.eval_quoted(binding(__ENV__), __ENV__)
end
end
@doc """
Define a private type specification.
Usually invoked as `@typep!`
This behaves similarly to Elixir's builtin `@typep` attribute,
and will create a type whose name and structure is private
(therefore only usable in the current module).
- Fill the `@typep`-attribute with a Typespec-friendly
representation of the TypeCheck type.
- Define a private function with the same name (and arity) as the type
that returns the TypeCheck.Type as a datastructure when called.
This makes the type usable in calls (in the same module) to:
- definitions of other type-specifications
- definitions of function-specifications
- `TypeCheck.conforms/2` and variants,
- `TypeCheck.Type.build/1`
`typep!/1` accepts the same typedef expression as `type!/1`.
"""
defmacro typep!(typedef) do
# The extra indirection here ensures we are able to support unquote fragments
quote generated: true, location: :keep do
unquote(Macro.escape(typedef, unquote: true))
|> TypeCheck.Macros.define_type(:typep, __ENV__)
|> Code.eval_quoted(binding(__ENV__), __ENV__)
end
end
@doc """
Define a opaque type specification.
Usually invoked as `@opaque!`
This behaves similarly to Elixir's builtin `@opaque` attribute,
and will create a type whose name is public
but whose structure is private.
Calling this macro will:
- Fill the `@opaque`-attribute with a Typespec-friendly
representation of the TypeCheck type.
- Add a (or append to an already existing) `@typedoc` detailing that the type is
managed by TypeCheck, and containing the name of the TypeCheck type.
(not the definition, since it is an opaque type).
- Define a (hidden) public function with the same name (and arity) as the type
that returns the TypeCheck.Type as a datastructure when called.
This makes the type usable in calls to:
- definitions of other type-specifications (in the same or different modules).
- definitions of function-specifications (in the same or different modules).
- `TypeCheck.conforms/2` and variants,
- `TypeCheck.Type.build/1`
`opaque!/1` accepts the same typedef expression as `type!/1`.
"""
defmacro opaque!(typedef) do
# The extra indirection here ensures we are able to support unquote fragments
quote generated: true, location: :keep do
unquote(Macro.escape(typedef, unquote: true))
|> TypeCheck.Macros.define_type(:opaque, __ENV__)
|> Code.eval_quoted(binding(__ENV__), __ENV__)
end
end
@doc """
Define a function specification.
Usually invoked as `@spec!`
A function specification will wrap the function
with checks that each of its parameters are of the types it expects.
as well as checking that the return type is as expected.
## Usage
The syntax is essentially the same as for built-in `@spec` attributes:
```
@spec! function_name(type1, type2) :: return_type
```
It is also allowed to introduce named types:
```
@spec! days_since_epoch(year :: integer, month :: integer, day :: integer) :: integer
```
Note that `TypeCheck` does _not_ allow the `when` keyword to be used
to restrict the types of recurring type variables (which Elixir's
builtin Typespecs allow). This is because:
- Usually it is more clear to give a recurring type
an explicit name.
- The `when` keyword is used instead for TypeCheck's type guards'.
(See `TypeCheck.Builtin.guarded_by/2` for more information.)
"""
defmacro spec!(specdef) do
# The extra indirection here ensures we are able to support unquote fragments
quote generated: true, location: :keep do
unquote(Macro.escape(specdef, unquote: true))
|> TypeCheck.Macros.define_spec(__ENV__)
|> Code.eval_quoted(binding(__ENV__), __ENV__)
end
end
@doc false
def define_type(
{:when, _, [named_type = {:"::", _, [name_with_maybe_params, _type]}, guard_ast]},
kind,
caller
) do
define_type(
{:"::", [], [name_with_maybe_params, {:when, [], [named_type, guard_ast]}]},
kind,
caller
)
end
def define_type({:"::", _meta, [name_with_maybe_params, type]}, kind, caller) do
clean_typedef = TypeCheck.Internals.ToTypespec.full_rewrite(type, caller)
new_typedoc =
case kind do
:typep ->
false
_ ->
append_typedoc(caller, """
_(This type is managed by `TypeCheck`,
which allows checking values against the type at runtime.)_
Full definition:
#{type_definition_doc(name_with_maybe_params, type, kind, caller)}
""")
end
typecheck_options =
Module.get_attribute(caller.module, TypeCheck.Options, TypeCheck.Options.new())
type = TypeCheck.Internals.PreExpander.rewrite(type, caller, typecheck_options)
name_with_arity =
case name_with_maybe_params do
{name, _, context} when is_atom(context) -> {name, 0}
{name, _, params} when is_list(params) -> {name, length(params)}
end
res =
type_fun_definition(
name_with_maybe_params,
type,
caller.module,
typecheck_options.overrides,
kind
)
quote generated: true, location: :keep do
if Module.get_attribute(__MODULE__, :autogen_typespec) do
case unquote(kind) do
:opaque ->
@typedoc unquote(new_typedoc)
@opaque unquote(name_with_maybe_params) :: unquote(clean_typedef)
:type ->
@typedoc unquote(new_typedoc)
@type unquote(name_with_maybe_params) :: unquote(clean_typedef)
:typep ->
@typep unquote(name_with_maybe_params) :: unquote(clean_typedef)
end
end
Module.put_attribute(__MODULE__, :autogen_typespec, true)
unquote(res)
Module.put_attribute(__MODULE__, TypeCheck.TypeDefs, unquote(Macro.escape(res)))
Module.put_attribute(__MODULE__, TypeCheck.TypeDefNames, unquote(name_with_arity))
end
end
def define_type(other, kind, caller) do
raise TypeCheck.CompileError, """
Compilation error encountered while attempting to create a `#{to_string(kind)}`
in `#{to_string(caller.module)}`.
Cannot parse syntax:
#{Macro.to_string(other)}
Maybe you forgot to give the type a name?
"""
end
defp append_typedoc(caller, extra_doc) do
{_line, old_doc} = Module.get_attribute(caller.module, :typedoc) || {0, ""}
newdoc = old_doc <> extra_doc
Module.delete_attribute(caller.module, :typedoc)
newdoc
end
defp type_definition_doc(name_with_maybe_params, type_ast, kind, caller) do
head = Macro.to_string(name_with_maybe_params)
if kind == :opaque do
"""
`#{head}` _(opaque type)_
"""
else
type_ast =
Macro.prewalk(type_ast, fn
lazy_ast = {:lazy, _, _} -> lazy_ast
ast -> Macro.expand(ast, caller)
end)
"""
```elixir
#{head} :: #{Code.format_string!(Macro.to_string(type_ast))}
```
"""
end
end
defp type_fun_definition(name_with_params, type, module_name, overrides, kind) do
{name, params} = Macro.decompose_call(name_with_params)
params_check_code =
params
|> Enum.map(fn param ->
quote generated: true, location: :keep do
TypeCheck.Type.ensure_type!(unquote(param))
end
end)
overridden_modules =
overrides |> Enum.map(fn {{m1, _f1, _a1}, {m2, _f2, _a2}} -> {m2, m1} end)
pretty_module_name = Keyword.get(overridden_modules, module_name, module_name)
pretty_module_name =
case TypeCheck.Internals.Helper.module_split_safe(pretty_module_name) do
["TypeCheck", "DefaultOverrides" | rest] ->
Module.concat(rest)
_ ->
pretty_module_name
end
pretty_type_name = "#{inspect(pretty_module_name)}.#{Macro.to_string(name_with_params)}"
quote generated: true, location: :keep do
@doc false
def unquote(name_with_params) do
unquote_splicing(params_check_code)
# import TypeCheck.Builtin
unquote(type_expansion_loop_prevention_code(name_with_params))
mfa = {unquote(module_name), unquote(name), unquote(params)}
TypeCheck.Builtin.named_type(unquote(pretty_type_name), unquote(type), unquote(kind), mfa)
|> Map.put(:local, false)
end
end
end
# If a type is refered to more than 1_000_000 times
# we're probably in a type expansion loop
defp type_expansion_loop_prevention_code(name_with_params) do
key = {Macro.escape(name_with_params), :expansion_tracker}
quote generated: true, location: :keep do
expansion_tracker = Process.get({__MODULE__, unquote(key)}, 0)
if expansion_tracker > 1_000_000 do
IO.warn("""
Potentially infinite type expansion loop detected while expanding `#{unquote(Macro.to_string(name_with_params))}`.
You probably want to use `TypeCheck.Builtin.lazy` to defer type expansion to runtime.
""")
else
Process.put({__MODULE__, unquote(key)}, expansion_tracker + 1)
end
end
end
@doc false
def define_spec({:"::", _meta, [name_with_params_ast, return_type_ast]}, caller) do
{name, params_ast} = Macro.decompose_call(name_with_params_ast)
arity = length(params_ast)
# return_type_ast = TypeCheck.Internals.PreExpander.rewrite(return_type_ast, caller)
# require TypeCheck.Type
# param_types = Enum.map(params_ast, &TypeCheck.Type.build_unescaped(&1, caller))
# return_type = TypeCheck.Type.build_unescaped(return_type_ast, caller)
clean_params = Macro.generate_arguments(arity, caller.module)
quote generated: true, location: :keep do
Module.put_attribute(
__MODULE__,
TypeCheck.Specs,
{unquote(name), {unquote(caller.file), unquote(caller.line)}, unquote(arity),
unquote(Macro.escape(clean_params)), unquote(Macro.escape(params_ast)),
unquote(Macro.escape(return_type_ast))}
)
end
end
import Kernel, except: [@: 1]
defmacro @ast do
case ast do
{name, _, expr} when name in ~w[type! typep! opaque! spec!]a ->
quote generated: true, location: :keep do
TypeCheck.Macros.unquote(name)(unquote_splicing(expr))
end
_ ->
quote generated: true, location: :keep do
Kernel.@(unquote(ast))
end
end
end
end