api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
4 changed files with 358 additions and 0 deletions
Showing only changes of commit 1bf9b84518 - Show all commits

feat(struct): discord_struct DSL — defstruct, @type t, field table generation

Luna 2026-07-05 03:23:18 -03:00

203
lib/dexcord/struct.ex Normal file
View file

@ -0,0 +1,203 @@
defmodule Dexcord.Struct do
@moduledoc """
A DSL for declaring Discord object shapes once.
One `field` declaration per field generates, at compile time, the
`defstruct`, `@type t`, `from_map/1` (string-keyed wire map -> struct,
minting zero atoms), `to_map/1` (struct -> JSON-ready map), and
`merge_map/2` (partial-update semantics for the cache).
defmodule Dexcord.Example do
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :roles, {:list, :snowflake}, default: []
end
end
Field types: `:snowflake`, `:string`, `:integer`, `:boolean`, `:datetime`,
`{:struct, Module}`, `{:list, type}`, `{:enum, Module}`, `{:flags, Module}`,
`:raw` (kept verbatim). Modifiers: `default:`, `tristate: true`
(`:absent | nil | value`), `presence: true` (key-presence boolean).
`hydrate slot, from: :id_field, type: Module` declares a cache-fillable
slot (always `nil` after decode; populated only by `Dexcord.Cache.fill/1`).
"""
@scalar_types [:snowflake, :string, :integer, :boolean, :datetime, :raw]
defmacro __using__(_opts) do
quote do
import Dexcord.Struct, only: [discord_struct: 1]
Module.register_attribute(__MODULE__, :dexcord_fields, accumulate: true)
Module.register_attribute(__MODULE__, :dexcord_hydrates, accumulate: true)
@before_compile Dexcord.Struct
end
end
defmacro discord_struct(do: block) do
quote do
try do
import Dexcord.Struct, only: [field: 2, field: 3, hydrate: 2]
unquote(block)
after
:ok
end
end
end
@doc false
defmacro field(name, type, opts \\ []) do
quote do
@dexcord_fields {unquote(name), unquote(Macro.escape(type)), unquote(opts)}
end
end
@doc false
defmacro hydrate(name, opts) do
quote do
@dexcord_hydrates {unquote(name), unquote(Macro.escape(opts))}
end
end
@doc false
defmacro __before_compile__(env) do
module = env.module
fields =
(Module.get_attribute(module, :dexcord_fields) || [])
|> Enum.reverse()
|> Enum.map(fn {name, type_ast, opts} -> build_field(name, type_ast, opts, env) end)
hydrates =
(Module.get_attribute(module, :dexcord_hydrates) || [])
|> Enum.reverse()
|> Enum.map(fn {name, opts} -> build_hydrate(name, opts, env) end)
struct_defaults =
Enum.map(fields, fn f -> {f.name, f.default} end) ++
Enum.map(hydrates, fn h -> {h.name, nil} end)
field_type_pairs =
Enum.map(fields, fn f -> {f.name, field_typespec(f, module)} end) ++
Enum.map(hydrates, fn h -> {h.name, hydrate_typespec(h, module)} end)
type_ast = {:%, [], [{:__MODULE__, [], Elixir}, {:%{}, [], field_type_pairs}]}
quote do
defstruct unquote(Macro.escape(struct_defaults))
@type t :: unquote(type_ast)
@doc false
def __fields__, do: unquote(Macro.escape(fields))
@doc false
def __hydrations__, do: unquote(Macro.escape(hydrates))
@doc "Decodes a string-keyed wire map into `t()`. Non-maps decode to `nil`."
@spec from_map(term()) :: t() | nil
def from_map(map), do: Dexcord.Struct.Codec.from_map(__MODULE__, map)
@doc "Encodes to a JSON-ready string-keyed map. `nil`/`:absent` fields are omitted."
@spec to_map(t()) :: %{optional(String.t()) => term()}
def to_map(%__MODULE__{} = struct), do: Dexcord.Struct.Codec.to_map(struct)
@doc "Applies a partial wire map, updating only keys present in it."
@spec merge_map(t(), map()) :: t()
def merge_map(%__MODULE__{} = struct, map),
do: Dexcord.Struct.Codec.merge_map(struct, map)
end
end
# -- compile-time helpers (run in the macro, not in generated code) --
@doc false
def build_field(name, type_ast, opts, env) when type_ast == :presence,
do: build_field(name, :boolean, Keyword.put(opts, :presence, true), env)
def build_field(name, type_ast, opts, env) do
type = resolve_type(type_ast, env)
validate_type!(type, name, env.module)
tristate = Keyword.get(opts, :tristate, false)
presence = Keyword.get(opts, :presence, false)
if tristate and presence do
raise ArgumentError,
"#{inspect(env.module)}.#{name}: tristate and presence are mutually exclusive"
end
default =
cond do
Keyword.has_key?(opts, :default) -> Keyword.fetch!(opts, :default)
tristate -> :absent
presence -> false
true -> nil
end
%{
name: name,
key: Atom.to_string(name),
type: type,
default: default,
tristate: tristate,
presence: presence
}
end
@doc false
def build_hydrate(name, opts, env) do
%{
name: name,
from: Keyword.fetch!(opts, :from),
type: resolve_type(Keyword.fetch!(opts, :type), env)
}
end
defp resolve_type({:struct, mod_ast}, env), do: {:struct, resolve_type(mod_ast, env)}
defp resolve_type({:list, inner}, env), do: {:list, resolve_type(inner, env)}
defp resolve_type({:enum, mod_ast}, env), do: {:enum, resolve_type(mod_ast, env)}
defp resolve_type({:flags, mod_ast}, env), do: {:flags, resolve_type(mod_ast, env)}
defp resolve_type(atom, _env) when is_atom(atom), do: atom
defp resolve_type(ast, env), do: Macro.expand(ast, env)
defp validate_type!(type, _name, _module) when type in @scalar_types, do: :ok
defp validate_type!({:struct, mod}, _name, _module) when is_atom(mod), do: :ok
defp validate_type!({:enum, mod}, _name, _module) when is_atom(mod), do: :ok
defp validate_type!({:flags, mod}, _name, _module) when is_atom(mod), do: :ok
defp validate_type!({:list, inner}, name, module), do: validate_type!(inner, name, module)
defp validate_type!(other, name, module) do
raise ArgumentError,
"#{inspect(module)}.#{name}: unknown field type #{inspect(other)}"
end
# -- typespec construction --
defp field_typespec(%{presence: true}, _module), do: quote(do: boolean())
defp field_typespec(%{tristate: true} = f, module),
do: quote(do: :absent | nil | unquote(base_typespec(f.type, module)))
defp field_typespec(f, module),
do: quote(do: unquote(base_typespec(f.type, module)) | nil)
defp hydrate_typespec(%{type: :channel}, _module), do: quote(do: struct() | nil)
defp hydrate_typespec(%{type: mod}, module),
do: quote(do: unquote(base_typespec({:struct, mod}, module)) | nil)
defp base_typespec(:snowflake, _m), do: quote(do: Dexcord.Snowflake.t())
defp base_typespec(:string, _m), do: quote(do: String.t())
defp base_typespec(:integer, _m), do: quote(do: integer())
defp base_typespec(:boolean, _m), do: quote(do: boolean())
defp base_typespec(:datetime, _m), do: quote(do: DateTime.t())
defp base_typespec(:raw, _m), do: quote(do: term())
defp base_typespec({:struct, mod}, mod), do: quote(do: t())
defp base_typespec({:struct, mod}, _m), do: quote(do: unquote(mod).t())
defp base_typespec({:list, inner}, m), do: quote(do: [unquote(base_typespec(inner, m))])
defp base_typespec({:enum, mod}, _m), do: quote(do: unquote(mod).t())
defp base_typespec({:flags, _mod}, _m), do: quote(do: non_neg_integer())
end

View file

@ -0,0 +1,7 @@
defmodule Dexcord.Struct.Codec do
@moduledoc false
def from_map(_module, _map), do: raise("not implemented until Task 5")
def to_map(_struct), do: raise("not implemented until Task 5")
def merge_map(_struct, _map), do: raise("not implemented until Task 5")
end

View file

@ -0,0 +1,115 @@
defmodule Dexcord.StructTest do
use ExUnit.Case, async: true
alias Dexcord.Test.Widget
describe "compile shape (Task 4)" do
test "struct has all declared keys with correct defaults" do
w = %Widget{}
assert w.id == nil
assert w.name == nil
assert w.count == nil
assert w.enabled == nil
assert w.created_at == nil
assert w.gadget == nil
assert w.gadgets == []
assert w.tag_ids == []
assert w.color == nil
assert w.flags == nil
assert w.parent == :absent
assert w.premium == false
assert w.note == "n/a"
assert w.extra == nil
assert w.gadget_full == nil
end
test "__fields__/0 returns the table in declaration order with resolved types" do
fields = Widget.__fields__()
names = Enum.map(fields, & &1.name)
assert names == [
:id,
:name,
:count,
:enabled,
:created_at,
:gadget,
:gadgets,
:tag_ids,
:color,
:flags,
:parent,
:premium,
:note,
:extra
]
by_name = Map.new(fields, &{&1.name, &1})
# nested struct type resolved to a real atom module
assert by_name[:gadget].type == {:struct, Dexcord.Test.Gadget}
assert by_name[:gadgets].type == {:list, {:struct, Dexcord.Test.Gadget}}
assert by_name[:tag_ids].type == {:list, :snowflake}
assert by_name[:color].type == {:enum, Dexcord.Test.Color}
assert by_name[:flags].type == {:flags, Dexcord.Test.TestFlags}
# self-referencing struct resolves __MODULE__ to the module atom
assert by_name[:parent].type == {:struct, Dexcord.Test.Widget}
assert by_name[:parent].tristate == true
# presence sugar
assert by_name[:premium].presence == true
assert by_name[:premium].type == :boolean
# keys are strings
assert by_name[:id].key == "id"
assert by_name[:created_at].key == "created_at"
# defaults
assert by_name[:gadgets].default == []
assert by_name[:tag_ids].default == []
assert by_name[:parent].default == :absent
assert by_name[:premium].default == false
assert by_name[:note].default == "n/a"
end
test "__hydrations__/0 returns the hydrate table" do
assert Widget.__hydrations__() == [
%{name: :gadget_full, from: :id, type: Dexcord.Test.Gadget}
]
end
test "the module generates a :t type" do
{:ok, types} = Code.Typespec.fetch_types(Widget)
assert Enum.any?(types, fn {_kind, {name, _def, _args}} -> name == :t end)
end
test "an unknown field type raises ArgumentError at compile time" do
assert_raise ArgumentError, ~r/unknown field type/, fn ->
Code.compile_string("""
defmodule Dexcord.Test.BogusCompile do
use Dexcord.Struct
discord_struct do
field :x, :bogus_type
end
end
""")
end
end
test "tristate and presence are mutually exclusive at compile time" do
assert_raise ArgumentError, ~r/mutually exclusive/, fn ->
Code.compile_string("""
defmodule Dexcord.Test.BadModifiers do
use Dexcord.Struct
discord_struct do
field :x, :string, tristate: true, presence: true
end
end
""")
end
end
end
end

View file

@ -7,3 +7,36 @@ defmodule Dexcord.Test.TestFlags do
@moduledoc false
use Dexcord.Flags, alpha: 0, beta: 1, gamma: 4
end
defmodule Dexcord.Test.Gadget do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
end
end
defmodule Dexcord.Test.Widget do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :count, :integer
field :enabled, :boolean
field :created_at, :datetime
field :gadget, {:struct, Dexcord.Test.Gadget}
field :gadgets, {:list, {:struct, Dexcord.Test.Gadget}}, default: []
field :tag_ids, {:list, :snowflake}, default: []
field :color, {:enum, Dexcord.Test.Color}
field :flags, {:flags, Dexcord.Test.TestFlags}
field :parent, {:struct, __MODULE__}, tristate: true
field :premium, :presence
field :note, :string, default: "n/a"
field :extra, :raw
hydrate :gadget_full, from: :id, type: Dexcord.Test.Gadget
end
end