Skip to content

Commit

Permalink
refactor: extract definer (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
fahchen authored Sep 25, 2024
1 parent ef208e8 commit ecaec7d
Show file tree
Hide file tree
Showing 5 changed files with 240 additions and 131 deletions.
118 changes: 39 additions & 79 deletions lib/typed_structor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,31 @@ defmodule TypedStructor do
* `:module` - if provided, a new submodule will be created with the struct.
* `:enforce` - if `true`, the struct will enforce the keys, see `field/3` options for more information.
* `:define_struct` - if `false`, the type will be defined, but the struct will not be defined. Defaults to `true`.
* `:definer` - the definer module to use to define the struct, record or exception. Defaults to `:defstruct`. It also accepts a macro that receives the definition struct and returns the AST. See definer section below.
* `:type_kind` - the kind of type to use for the struct. Defaults to `type`, can be `opaque` or `typep`.
* `:type_name` - the name of the type to use for the struct. Defaults to `t`.
## Definer
There are one available definer for now, `:defstruct`, which defines a struct and a type for a given definition.
### `:defstruct` options
* `:define_struct` - if `false`, the type will be defined, but the struct will not be defined. Defaults to `true`.
### custom definer
defmodule MyStruct do
# you must require the definer module to use its define/1 macro
require MyDefiner
use TypedStructor
typed_structor definer: &MyDefiner.define/1 do
field :name, String.t()
field :age, integer()
end
end
## Examples
defmodule MyStruct do
Expand Down Expand Up @@ -111,22 +132,20 @@ defmodule TypedStructor do
})

@__ts_definition__ definition
@__ts_options__ definition.options
after
:ok
# cleanup
Module.delete_attribute(__MODULE__, :__ts_options__)
Module.delete_attribute(__MODULE__, :__ts_struct_fields__)
Module.delete_attribute(__MODULE__, :__ts_struct_parameters__)
end

TypedStructor.__struct_ast__()
TypedStructor.__type_ast__()
TypedStructor.__define__(@__ts_definition__)

# create a lexical scope
try do
TypedStructor.__call_plugins_after_definitions__()
TypedStructor.__call_plugins_after_definitions__(@__ts_definition__)
after
# cleanup
Module.delete_attribute(__MODULE__, :__ts_options__)
Module.delete_attribute(__MODULE__, :__ts_struct_fields__)
Module.delete_attribute(__MODULE__, :__ts_struct_parameters__)
Module.delete_attribute(__MODULE__, :__ts_struct_plugins__)
Module.delete_attribute(__MODULE__, :__ts_definition__)
end
Expand Down Expand Up @@ -246,75 +265,16 @@ defmodule TypedStructor do
end
end

@doc false
defmacro __struct_ast__ do
ast =
quote do
{fields, enforce_keys} =
Enum.map_reduce(@__ts_definition__.fields, [], fn field, acc ->
name = Keyword.fetch!(field, :name)
default = Keyword.get(field, :default)

if Keyword.get(field, :enforce, false) and not Keyword.has_key?(field, :default) do
{{name, default}, [name | acc]}
else
{{name, default}, acc}
end
end)

@enforce_keys Enum.reverse(enforce_keys)
defstruct fields
end

quote do
if Keyword.get(@__ts_options__, :define_struct, true) do
unquote(ast)
end
end
end

@doc false
defmacro __type_ast__ do
quote unquote: false do
fields =
Enum.reduce(@__ts_definition__.fields, [], fn field, acc ->
name = Keyword.fetch!(field, :name)
type = Keyword.fetch!(field, :type)

if Keyword.get(field, :enforce, false) or Keyword.has_key?(field, :default) do
[{name, type} | acc]
else
[{name, quote(do: unquote(type) | nil)} | acc]
end
end)

type_name = Keyword.get(@__ts_options__, :type_name, :t)

parameters =
Enum.map(
@__ts_definition__.parameters,
fn parameter ->
parameter
|> Keyword.fetch!(:name)
|> Macro.var(__MODULE__)
end
)
defmacro __define__(definition) do
quote bind_quoted: [definition: definition] do
case Keyword.get(definition.options, :definer, :defstruct) do
:defstruct ->
require TypedStructor.Definer.Defstruct
# credo:disable-for-next-line Credo.Check.Design.AliasUsage
TypedStructor.Definer.Defstruct.define(definition)

case Keyword.get(@__ts_options__, :type_kind, :type) do
:type ->
@type unquote(type_name)(unquote_splicing(parameters)) :: %__MODULE__{
unquote_splicing(fields)
}

:opaque ->
@opaque unquote(type_name)(unquote_splicing(parameters)) :: %__MODULE__{
unquote_splicing(fields)
}

:typep ->
@typep unquote(type_name)(unquote_splicing(parameters)) :: %__MODULE__{
unquote_splicing(fields)
}
fun when is_function(fun) ->
then(definition, fun)
end
end
end
Expand Down Expand Up @@ -352,15 +312,15 @@ defmodule TypedStructor do
end

@doc false
defmacro __call_plugins_after_definitions__ do
defmacro __call_plugins_after_definitions__(definition) do
plugins = Module.get_attribute(__CALLER__.module, :__ts_struct_plugins__)

for {plugin, opts} <- plugins do
quote do
require unquote(plugin)

unquote(plugin).after_definition(
@__ts_definition__,
unquote(definition),
unquote(Macro.escape(opts))
)
end
Expand Down
103 changes: 103 additions & 0 deletions lib/typed_structor/definer/defstruct.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
defmodule TypedStructor.Definer.Defstruct do
@moduledoc """
A definer to define a struct and a type for a given definition.
## Additional options for `typed_structor`
* `:define_struct` - if `false`, the type will be defined, but the struct will not be defined. Defaults to `true`.
## Usage
defmodule MyStruct do
use TypedStructor
typed_structor definer: :defstruct, define_struct: false do
field :name, String.t()
field :age, integer()
end
end
"""

@doc """
Defines a struct and a type for a given definition.
"""
defmacro define(definition) do
quote do
unquote(__MODULE__).__struct_ast__(unquote(definition))
unquote(__MODULE__).__type_ast__(unquote(definition))
end
end

@doc false
defmacro __struct_ast__(definition) do
ast =
quote do
{fields, enforce_keys} =
Enum.map_reduce(unquote(definition).fields, [], fn field, acc ->
name = Keyword.fetch!(field, :name)
default = Keyword.get(field, :default)

if Keyword.get(field, :enforce, false) and not Keyword.has_key?(field, :default) do
{{name, default}, [name | acc]}
else
{{name, default}, acc}
end
end)

@enforce_keys Enum.reverse(enforce_keys)
defstruct fields
end

quote do
if Keyword.get(unquote(definition).options, :define_struct, true) do
unquote(ast)
end
end
end

@doc false
defmacro __type_ast__(definition) do
quote bind_quoted: [definition: definition] do
fields =
Enum.reduce(definition.fields, [], fn field, acc ->
name = Keyword.fetch!(field, :name)
type = Keyword.fetch!(field, :type)

if Keyword.get(field, :enforce, false) or Keyword.has_key?(field, :default) do
[{name, type} | acc]
else
[{name, quote(do: unquote(type) | nil)} | acc]
end
end)

type_name = Keyword.get(definition.options, :type_name, :t)

parameters =
Enum.map(
definition.parameters,
fn parameter ->
parameter
|> Keyword.fetch!(:name)
|> Macro.var(__MODULE__)
end
)

case Keyword.get(definition.options, :type_kind, :type) do
:type ->
@type unquote(type_name)(unquote_splicing(parameters)) :: %__MODULE__{
unquote_splicing(fields)
}

:opaque ->
@opaque unquote(type_name)(unquote_splicing(parameters)) :: %__MODULE__{
unquote_splicing(fields)
}

:typep ->
@typep unquote(type_name)(unquote_splicing(parameters)) :: %__MODULE__{
unquote_splicing(fields)
}
end
end
end
end
14 changes: 13 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ defmodule TypedStructor.MixProject do
TypedStructor.GuideCase,
TypedStructor.TestCase
]
]
],
aliases: aliases()
]
end

Expand All @@ -83,4 +84,15 @@ defmodule TypedStructor.MixProject do

defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]

defp aliases do
[
check: [
"format",
"compile --warning-as-errors",
"credo --strict",
"dialyzer"
]
]
end
end
85 changes: 85 additions & 0 deletions test/definer_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
defmodule DefinerTest do
@compile {:no_warn_undefined, __MODULE__.Struct}

use TypedStructor.TestCase, async: true

describe "defstruct" do
@tag :tmp_dir
test "works", ctx do
expected_types =
with_tmpmodule Struct, ctx do
@type t() :: %__MODULE__{
age: integer() | nil,
name: String.t() | nil
}

defstruct [:age, :name]
after
fetch_types!(Struct)
end

generated_types =
with_tmpmodule Struct, ctx do
use TypedStructor

typed_structor definer: :defstruct do
field :name, String.t()
field :age, integer()
end
after
assert %{__struct__: Struct, name: nil, age: nil} === struct(Struct)

fetch_types!(Struct)
end

assert_type expected_types, generated_types
end

@tag :tmp_dir
test "define_struct false", ctx do
deftmpmodule Struct, ctx do
use TypedStructor

typed_structor define_struct: false do
parameter :age

field :name, String.t()
field :age, age
end

defstruct name: "Phil", age: 20
end

assert %{__struct__: Struct, name: "Phil", age: 20} === struct(Struct)
after
cleanup_modules([__MODULE__.Struct], ctx.tmp_dir)
end

@tag :tmp_dir
test "works with Ecto.Schema", ctx do
deftmpmodule Struct, ctx do
use TypedStructor

typed_structor define_struct: false do
parameter :age

field :name, String.t()
field :age, age
end

use Ecto.Schema

schema "source" do
field :name, :string
field :age, :integer, default: 20
end
end

assert [:id, :name, :age] === Struct.__schema__(:fields)

assert match?(%{__struct__: Struct, id: nil, name: nil, age: 20}, struct(Struct))
after
cleanup_modules([__MODULE__.Struct], ctx.tmp_dir)
end
end
end
Loading

0 comments on commit ecaec7d

Please sign in to comment.