api-surface #1

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

feat(enum): open integer enum DSL with {:unknown, n} lossless round-trip

Luna 2026-07-05 03:13:31 -03:00

63
lib/dexcord/enum.ex Normal file
View file

@ -0,0 +1,63 @@
defmodule Dexcord.Enum do
@moduledoc """
A DSL for Discord's open integer enums.
Discord adds enum values over time; decoding must not break when it does.
`decode/1` maps a recognized integer to its named atom and any other
integer to `{:unknown, n}`; `encode/1` reverses both losslessly.
defmodule Dexcord.MessageType do
use Dexcord.Enum, default: 0, reply: 19
end
Dexcord.MessageType.decode(19) #=> :reply
Dexcord.MessageType.decode(999) #=> {:unknown, 999}
Dexcord.MessageType.encode({:unknown, 999}) #=> 999
"""
defmacro __using__(values) do
unless is_list(values) and values != [] and Keyword.keyword?(values) do
raise ArgumentError,
"use Dexcord.Enum expects a non-empty keyword list of name: integer pairs"
end
names = Keyword.keys(values)
type_union =
Enum.reduce(names, quote(do: {:unknown, integer()}), fn name, acc ->
{:|, [], [name, acc]}
end)
decode_clauses =
for {name, int} <- values do
quote do
def decode(unquote(int)), do: unquote(name)
end
end
encode_clauses =
for {name, int} <- values do
quote do
def encode(unquote(name)), do: unquote(int)
end
end
quote do
@type t :: unquote(type_union)
@doc "Decodes a wire integer; unrecognized values become `{:unknown, n}`."
@spec decode(integer()) :: t()
unquote_splicing(decode_clauses)
def decode(n) when is_integer(n), do: {:unknown, n}
@doc "Encodes a named atom or `{:unknown, n}` back to the wire integer."
@spec encode(t()) :: integer()
unquote_splicing(encode_clauses)
def encode({:unknown, n}) when is_integer(n), do: n
@doc "The full name => integer table."
@spec values() :: %{atom() => integer()}
def values, do: unquote({:%{}, [], values})
end
end
end

View file

@ -0,0 +1,49 @@
defmodule Dexcord.EnumTest do
use ExUnit.Case, async: true
alias Dexcord.Test.Color
# api-surface.AC2.4: unknown enum int -> {:unknown, n}; encode(decode(n)) lossless.
test "decode/1 maps known integers to their named atoms" do
assert Color.decode(0) == :red
assert Color.decode(1) == :green
assert Color.decode(4) == :blue
end
test "decode/1 maps a gap value (unnamed but in range) to {:unknown, n}" do
assert Color.decode(2) == {:unknown, 2}
end
test "decode/1 maps out-of-range and negative integers to {:unknown, n}" do
assert Color.decode(999) == {:unknown, 999}
assert Color.decode(-5) == {:unknown, -5}
end
test "encode(decode(n)) is lossless for known and unknown values, including negative" do
for n <- [0, 1, 4, 2, 999, -5] do
assert Color.encode(Color.decode(n)) == n
end
end
test "encode/1 maps named atoms to their wire integer" do
assert Color.encode(:red) == 0
assert Color.encode(:green) == 1
assert Color.encode(:blue) == 4
end
test "encode/1 raises FunctionClauseError for an unnamed atom" do
# apply/3 keeps the invalid atom opaque to the compile-time type checker,
# so this deliberate misuse doesn't emit a typing-violation warning.
assert_raise FunctionClauseError, fn -> apply(Color, :encode, [:not_a_color]) end
end
test "values/0 returns the full name => integer table" do
assert Color.values() == %{red: 0, green: 1, blue: 4}
end
test "the module generates a :t type" do
{:ok, types} = Code.Typespec.fetch_types(Color)
assert Enum.any?(types, fn {_kind, {name, _def, _args}} -> name == :t end)
end
end

View file

@ -0,0 +1,4 @@
defmodule Dexcord.Test.Color do
@moduledoc false
use Dexcord.Enum, red: 0, green: 1, blue: 4
end