api-surface #1
3 changed files with 118 additions and 0 deletions
feat(flags): bitfield DSL with raw-int storage and named views
commit
ed084ef5a3
60
lib/dexcord/flags.ex
Normal file
60
lib/dexcord/flags.ex
Normal 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
|
||||
53
test/dexcord/flags_test.exs
Normal file
53
test/dexcord/flags_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue