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 118 additions and 0 deletions
Showing only changes of commit ed084ef5a3 - Show all commits

feat(flags): bitfield DSL with raw-int storage and named views

Luna 2026-07-05 03:17:20 -03:00

60
lib/dexcord/flags.ex Normal file
View file

@ -0,0 +1,60 @@
defmodule Dexcord.Flags do
@moduledoc """
A DSL for Discord bitfields.
Struct fields declared `{:flags, Module}` store the **raw integer** so
unknown bits are never lost; the generated module provides named views:
defmodule Dexcord.MessageFlags do
use Dexcord.Flags, crossposted: 0, suppress_embeds: 2
end
Dexcord.MessageFlags.has?(4, :suppress_embeds) #=> true
Dexcord.MessageFlags.to_list(5) #=> [:crossposted, :suppress_embeds]
Dexcord.MessageFlags.from_list([:crossposted]) #=> 1
"""
defmacro __using__(bits) do
unless is_list(bits) and bits != [] and Keyword.keyword?(bits) do
raise ArgumentError,
"use Dexcord.Flags expects a non-empty keyword list of name: bit_position pairs"
end
flag_values =
for {name, pos} <- bits do
{name, Bitwise.bsl(1, pos)}
end
flag_map = {:%{}, [], flag_values}
quote do
@dexcord_flag_values unquote(flag_map)
@doc "All named flags as a name => integer-value map."
@spec all() :: %{atom() => pos_integer()}
def all, do: @dexcord_flag_values
@doc "Whether `int` has the named flag set."
@spec has?(integer(), atom()) :: boolean()
def has?(int, flag) when is_integer(int) and is_map_key(@dexcord_flag_values, flag),
do: Bitwise.band(int, @dexcord_flag_values[flag]) != 0
@doc "Named flags set in `int`, ordered by bit value. Unknown bits are omitted."
@spec to_list(integer()) :: [atom()]
def to_list(int) when is_integer(int) do
@dexcord_flag_values
|> Enum.filter(fn {_name, val} -> Bitwise.band(int, val) != 0 end)
|> Enum.sort_by(fn {_name, val} -> val end)
|> Enum.map(fn {name, _val} -> name end)
end
@doc "Combines named flags into an integer. Raises `KeyError` on unknown names."
@spec from_list([atom()]) :: non_neg_integer()
def from_list(flags) when is_list(flags) do
Enum.reduce(flags, 0, fn flag, acc ->
Bitwise.bor(acc, Map.fetch!(@dexcord_flag_values, flag))
end)
end
end
end
end

View file

@ -0,0 +1,53 @@
defmodule Dexcord.FlagsTest do
use ExUnit.Case, async: true
alias Dexcord.Test.TestFlags
# api-surface.AC2.5: flags fields keep the raw integer; has?/2, to_list/1,
# from_list/1 correct; unknown bits survive round-trip in the int.
#
# TestFlags: alpha: bit 0 (value 1), beta: bit 1 (value 2), gamma: bit 4 (value 16).
test "has?/2 reports whether a named flag is set in the integer" do
# 0b10001 = 17 = alpha (1) | gamma (16)
assert TestFlags.has?(0b10001, :alpha)
refute TestFlags.has?(0b10001, :beta)
assert TestFlags.has?(0b10001, :gamma)
end
test "to_list/1 returns set named flags ordered by bit value" do
# 0b10011 = 19 = alpha (1) | beta (2) | gamma (16)
assert TestFlags.to_list(0b10011) == [:alpha, :beta, :gamma]
end
test "from_list/1 combines named flags into an integer" do
# alpha (1) | gamma (16) = 17 = 0b10001
assert TestFlags.from_list([:alpha, :gamma]) == 0b10001
end
test "from_list(to_list(int)) recovers exactly the known bits" do
int = 0b10011
assert TestFlags.from_list(TestFlags.to_list(int)) == int
end
test "to_list/1 omits unknown bits, leaving the raw int untouched elsewhere" do
# 0b1000_0001 = 129: bit 0 (alpha) is named, bit 7 is unnamed.
assert TestFlags.to_list(0b1000_0001) == [:alpha]
# from_list of that view drops the unknown bit — the module never mutates
# the raw int; the round-trip contract lives on the raw integer field
# (verified again at the struct level in Task 5).
assert TestFlags.from_list(TestFlags.to_list(0b1000_0001)) == 0b0000_0001
end
test "all/0 returns the full name => integer-value map" do
assert TestFlags.all() == %{alpha: 1, beta: 2, gamma: 16}
end
test "from_list/1 raises KeyError on an unknown flag name" do
assert_raise KeyError, fn -> TestFlags.from_list([:nope]) end
end
test "has?/2 raises FunctionClauseError on an unknown flag name" do
assert_raise FunctionClauseError, fn -> apply(TestFlags, :has?, [1, :nope]) end
end
end

View file

@ -2,3 +2,8 @@ defmodule Dexcord.Test.Color do
@moduledoc false
use Dexcord.Enum, red: 0, green: 1, blue: 4
end
defmodule Dexcord.Test.TestFlags do
@moduledoc false
use Dexcord.Flags, alpha: 0, beta: 1, gamma: 4
end