From 53d0aacbf79587e95d8dbdebce9928402eaad1c8 Mon Sep 17 00:00:00 2001 From: Dich0tomy Date: Sun, 16 Aug 2026 22:53:02 +0200 Subject: [PATCH] feat(dce-core.discord): add a `Snowflake` class Represents the discord ID used for every resource. --- src/dce-core/CMakeLists.txt | 19 ++++ src/dce-core/include/dce/core/concepts.hpp | 15 +++ src/dce-core/include/dce/core/prelude.hpp | 47 +++++++++ .../include/dce/core/strong_variant.hpp | 92 ++++++++++++++++++ src/dce-core/include/dce/core/type_name.hpp | 67 +++++++++++++ .../include/dce/discord/snowflake.hpp | 76 +++++++++++++++ .../dce/parsing/generic_parse_error.hpp | 19 ++++ src/dce-core/src/dce/discord/snowflake.cpp | 94 ++++++++++++++++++ .../src/dce/parsing/generic_parse_error.cpp | 25 +++++ src/dce/CMakeLists.txt | 19 ++++ src/{ => dce}/main.cpp | 3 +- tests/CMakeLists.txt | 36 +++++++ tests/snowflake.cpp | 95 +++++++++++++++++++ 13 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 src/dce-core/CMakeLists.txt create mode 100644 src/dce-core/include/dce/core/concepts.hpp create mode 100644 src/dce-core/include/dce/core/prelude.hpp create mode 100644 src/dce-core/include/dce/core/strong_variant.hpp create mode 100644 src/dce-core/include/dce/core/type_name.hpp create mode 100644 src/dce-core/include/dce/discord/snowflake.hpp create mode 100644 src/dce-core/include/dce/parsing/generic_parse_error.hpp create mode 100644 src/dce-core/src/dce/discord/snowflake.cpp create mode 100644 src/dce-core/src/dce/parsing/generic_parse_error.cpp create mode 100644 src/dce/CMakeLists.txt rename src/{ => dce}/main.cpp (74%) create mode 100644 tests/CMakeLists.txt create mode 100644 tests/snowflake.cpp diff --git a/src/dce-core/CMakeLists.txt b/src/dce-core/CMakeLists.txt new file mode 100644 index 0000000..9cd7e9f --- /dev/null +++ b/src/dce-core/CMakeLists.txt @@ -0,0 +1,19 @@ +add_library( + dce-core + + src/dce/parsing/generic_parse_error.cpp + src/dce/discord/snowflake.cpp +) +dce_try_apply_debug_options(dce-core PUBLIC) + +target_include_directories(dce-core PRIVATE src) +target_include_directories(dce-core PUBLIC include) + +target_link_libraries( + dce-core + PUBLIC + fmt +) + +target_compile_features(dce-core PUBLIC cxx_std_26) +set_target_properties(dce-core PROPERTIES CXX_EXTENSIONS OFF) diff --git a/src/dce-core/include/dce/core/concepts.hpp b/src/dce-core/include/dce/core/concepts.hpp new file mode 100644 index 0000000..aa1cf5a --- /dev/null +++ b/src/dce-core/include/dce/core/concepts.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include +// Smallest header providing std::hash lol +#include + +namespace dce +{ + +template +concept StdHashable = requires(T t) { + { std::hash()(t) } -> std::same_as; +}; + +} // namespace dce diff --git a/src/dce-core/include/dce/core/prelude.hpp b/src/dce-core/include/dce/core/prelude.hpp new file mode 100644 index 0000000..1beadf3 --- /dev/null +++ b/src/dce-core/include/dce/core/prelude.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +namespace rg = std::ranges; +namespace vw = rg::views; + +using namespace std::string_view_literals; + +using u8 = std::uint8_t; +using u16 = std::uint16_t; +using u32 = std::uint32_t; +using u64 = std::uint64_t; + +using i8 = std::int8_t; +using i16 = std::int16_t; +using i32 = std::int32_t; +using i64 = std::int64_t; + +using usize = std::size_t; +using ssize = std::ptrdiff_t; + +using f32 = float; +using f64 = double; + +static_assert(sizeof(float) == 4); +static_assert(sizeof(double) == 8); + +#define DCE_LIFT_FWD(...) (static_cast(__VA_ARGS__)) +#define DCE_LIFT(...) \ + [](auto&&... args) noexcept(noexcept(__VA_ARGS__(DCE_LIFT_FWD(args)...))) \ + -> decltype(__VA_ARGS__(DCE_LIFT_FWD(args)...)) { return __VA_ARGS__(DCE_LIFT_FWD(args)...); } + +#define DCE_ACCESS(...) \ + [](auto&& arg) noexcept(noexcept(DCE_LIFT_FWD(arg).__VA_ARGS__)) \ + -> auto&& { return DCE_LIFT_FWD(arg).__VA_ARGS__; } + +template +constexpr auto id(T&& arg) noexcept -> decltype(auto) +{ + return static_cast(arg); +} + +inline constexpr auto Err = DCE_LIFT(::std::unexpected); +inline constexpr auto Ok = DCE_LIFT(::id); diff --git a/src/dce-core/include/dce/core/strong_variant.hpp b/src/dce-core/include/dce/core/strong_variant.hpp new file mode 100644 index 0000000..3b32cf7 --- /dev/null +++ b/src/dce-core/include/dce/core/strong_variant.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include +#include + +#include +#include + +namespace dce +{ + +template +struct StrongVariant : std::variant +{ + using Base = std::variant; + using Base::Base; + + static constexpr auto StdHashable = ::dce::StdHashable; + + using View = std::conditional_t< + (sizeof(Base) >= sizeof(void*) * 2), + StrongVariant const&, + StrongVariant>; + + template + [[nodiscard]] constexpr auto as() const noexcept -> U const* + { + return std::get_if(this); + } + + template + [[nodiscard]] constexpr auto as() noexcept -> U* + { + return std::get_if(this); + } + + template + [[nodiscard]] constexpr auto as_unchecked(this Self&& self) noexcept -> decltype(auto) + { + return std::get(std::forward(self)); + } + + template + [[nodiscard]] constexpr auto is() const noexcept -> bool + { + return std::holds_alternative(*this); + } + + template + [[nodiscard]] constexpr auto visit(this Self&& self, Fns&&... fns) -> decltype(auto) + { + struct Overloaded final : Fns... + { + using Fns::operator()...; + }; + + return std::visit( + Overloaded { std::forward(fns)... }, + std::forward(self) + ); + } + + [[nodiscard]] constexpr auto held_type_name() const noexcept -> std::string_view + { + return visit([](T const&) constexpr { return type_name(); }); + } +}; + +template +constexpr auto format_as(StrongVariant const& v) +{ + return v.visit([](auto const& v) { return format_as(v); }); +} + +} // namespace dce + +namespace std +{ + +template + requires dce::StrongVariant::AllStdHashable +struct hash> +{ + using T = dce::StrongVariant; + + [[nodiscard]] constexpr auto operator()(T::View v) -> size_t + { + return std::hash()(static_cast(v)); + } +}; + +} // namespace std diff --git a/src/dce-core/include/dce/core/type_name.hpp b/src/dce-core/include/dce/core/type_name.hpp new file mode 100644 index 0000000..a6798e6 --- /dev/null +++ b/src/dce-core/include/dce/core/type_name.hpp @@ -0,0 +1,67 @@ +#include + +#include + +namespace dce +{ + +namespace detail +{ + +template +consteval auto as_array(std::string_view str, std::index_sequence) +{ + return std::array { str[Idxs]... }; +} + +// We have reflections at home +// Reflections at home: +template +consteval auto type_name_array() +{ +#ifdef __clang__ + static constexpr auto prefix = std::string_view("[T = "); + static constexpr auto suffix = std::string_view("]"); + static constexpr auto sig = std::string_view(__PRETTY_FUNCTION__); +#elifdef __GNUC__ + static constexpr auto prefix = std::string_view("with T = "); + static constexpr auto suffix = std::string_view("]"); + static constexpr auto sig = std::string_view(__PRETTY_FUNCTION__); +#elif defined(_MSC_VER) + static constexpr auto prefix = std::string_view("type_name_array<"); + static constexpr auto suffix = std::string_view(">(void)"); + static constexpr auto sig = std::string_view(__FUNCSIG__); +#endif + + static constexpr auto start = sig.find(prefix) + prefix.size(); + static constexpr auto end = sig.rfind(suffix); + + static_assert(start < end, "Failed to parse function signature!"); + + static constexpr auto name = sig.substr(start, end - start); + return as_array(name, std::make_index_sequence()); +} + +template +struct TypeName +{ + static constexpr auto Value = type_name_array(); +}; + +} // namespace detail + +template +[[nodiscard]] consteval auto type_name() noexcept -> std::string_view +{ + static constexpr auto& value = detail::TypeName::Value; + return std::string_view(value.data(), value.size()); +} + +template +[[nodiscard]] consteval auto type_name_str() noexcept -> std::string +{ + static constexpr auto value = detail::type_name_array(); + return std::string(value.data(), value.size()); +} + +} // namespace dce diff --git a/src/dce-core/include/dce/discord/snowflake.hpp b/src/dce-core/include/dce/discord/snowflake.hpp new file mode 100644 index 0000000..57477f4 --- /dev/null +++ b/src/dce-core/include/dce/discord/snowflake.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include + +namespace dce::discord +{ + +struct SPE_InvalidValue +{ + GenericParseError gpe; +}; + +struct SPE_InvalidRange +{ + enum class Reason : u8 + { + TooSmall, + TooBig, + }; + + GenericParseError gpe; + Reason reason; +}; + +struct SnowflakeParseError : StrongVariant +{ + using StrongVariant::StrongVariant; + + [[nodiscard]] auto generic_parse_error() const -> GenericParseError const& + { + return visit(DCE_ACCESS(gpe)); + } +}; + +auto format_as(SnowflakeParseError const&) -> std::string; +auto operator<<(std::ostream& os, SnowflakeParseError const& gpe) -> std::ostream&; + +class Snowflake +{ + using Underlying = u64; + +public: + static constexpr auto DiscordEpoch = 1420070400000; + static constexpr auto MinLength = 18; + static constexpr auto MaxLength = std::numeric_limits::digits10 + 1; + + static auto parse(std::string_view str) -> std::expected; + + [[nodiscard]] auto string() const -> std::string; + + [[nodiscard]] auto length() const -> usize; + + [[nodiscard]] auto epoch_timestamp() const noexcept -> u64; + [[nodiscard]] auto internal_worked_id() const noexcept -> u8; + [[nodiscard]] auto internal_process_id() const noexcept -> u8; + [[nodiscard]] auto increment() const noexcept -> u8; + +private: + + constexpr Snowflake(Underlying val) noexcept + : _val(val) + {} + + Underlying _val; +}; + +auto format_as(Snowflake s) -> std::string; + +} // namespace dce::discord diff --git a/src/dce-core/include/dce/parsing/generic_parse_error.hpp b/src/dce-core/include/dce/parsing/generic_parse_error.hpp new file mode 100644 index 0000000..6bc5b1e --- /dev/null +++ b/src/dce-core/include/dce/parsing/generic_parse_error.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include + +namespace dce +{ + +struct GenericParseError +{ + ssize offset = 0; + std::string source; +}; + +auto format_as(GenericParseError const&) -> std::string; +auto operator<<(std::ostream& os, GenericParseError const& gpe) -> std::ostream&; + +} // namespace dce diff --git a/src/dce-core/src/dce/discord/snowflake.cpp b/src/dce-core/src/dce/discord/snowflake.cpp new file mode 100644 index 0000000..f6f246a --- /dev/null +++ b/src/dce-core/src/dce/discord/snowflake.cpp @@ -0,0 +1,94 @@ +#include +#include + +#include + +#include + +namespace dce::discord +{ + +auto Snowflake::parse(std::string_view str) -> std::expected +{ + auto snowflake_out = u64(); + auto const last = str.data() + str.size(); // NOLINT(cppcoreguidelines-pro-bounds-pointer-arithmetic) + auto const [ptr, ec] = std::from_chars(str.data(), last, snowflake_out); // NOLINT(bugprone-suspicious-stringview-data-usage) + + auto const parsed_len = ptr - str.data(); + auto const fully_parsed = std::cmp_equal(parsed_len, str.size()); + auto generic_parse_error = GenericParseError { + .offset = parsed_len > 0 ? parsed_len - 1 : 0, + .source = std::string(str), + }; + + if(ec == std::errc::invalid_argument or not fully_parsed) { + return Err(SPE_InvalidValue { .gpe = std::move(generic_parse_error) }); + } + + auto const too_small = str.size() < MinLength; + auto const too_big = ec == std::errc::result_out_of_range; + if(too_small or too_big) { + using enum SPE_InvalidRange::Reason; + + return Err(SPE_InvalidRange { + .gpe = std::move(generic_parse_error), + .reason = too_small ? TooSmall : TooBig, + }); + } + + return Ok(Snowflake(snowflake_out)); +} + +auto Snowflake::string() const -> std::string +{ + return fmt::format("{}", _val); +} + +[[nodiscard]] auto Snowflake::length() const -> usize +{ + return static_cast(std::log10(_val)) + 1; +} + +// NOLINTBEGIN(cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers) +auto Snowflake::epoch_timestamp() const noexcept -> u64 +{ + return (_val >> 22) + DiscordEpoch; +} + +auto Snowflake::internal_worked_id() const noexcept -> u8 +{ + return (_val & 0x3E0000) >> 17; +} + +auto Snowflake::internal_process_id() const noexcept -> u8 +{ + return (_val & 0x1F000) >> 12; +} + +auto Snowflake::increment() const noexcept -> u8 +{ + return _val & 0xFFF; +} + +// NOLINTEND(cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers) + +auto format_as(Snowflake s) -> std::string +{ + return s.string(); +} + +auto format_as(SnowflakeParseError const& err) -> std::string +{ + return fmt::format( + "{}\n{}", + err.held_type_name(), + err.generic_parse_error() + ); +} + +auto operator<<(std::ostream& os, SnowflakeParseError const& err) -> std::ostream& +{ + return (os << fmt::format("{}", err)); +} + +} // namespace dce::discord diff --git a/src/dce-core/src/dce/parsing/generic_parse_error.cpp b/src/dce-core/src/dce/parsing/generic_parse_error.cpp new file mode 100644 index 0000000..a443ebb --- /dev/null +++ b/src/dce-core/src/dce/parsing/generic_parse_error.cpp @@ -0,0 +1,25 @@ +#include + +#include + +#include + +namespace dce +{ + +auto format_as(GenericParseError const& gpe) -> std::string +{ + return fmt::format( + "{}\n" + "{}^", + gpe.source, + gpe.offset > 0 ? std::string(gpe.offset, ' ') : "" + ); +} + +auto operator<<(std::ostream& os, GenericParseError const& gpe) -> std::ostream& +{ + return (os << fmt::format("{}", gpe)); +} + +} // namespace dce diff --git a/src/dce/CMakeLists.txt b/src/dce/CMakeLists.txt new file mode 100644 index 0000000..b2ffb71 --- /dev/null +++ b/src/dce/CMakeLists.txt @@ -0,0 +1,19 @@ +add_executable( + dce + main.cpp +) +dce_try_apply_debug_options(dce PRIVATE) + +target_compile_features(dce PRIVATE cxx_std_26) +set_target_properties( + dce + PROPERTIES + CXX_EXTENSIONS OFF +) + +target_link_libraries( + dce + PRIVATE + dce-core + fmt +) diff --git a/src/main.cpp b/src/dce/main.cpp similarity index 74% rename from src/main.cpp rename to src/dce/main.cpp index 578a881..73352aa 100644 --- a/src/main.cpp +++ b/src/dce/main.cpp @@ -1,5 +1,6 @@ #include -auto main() -> int { +auto main() -> int +{ fmt::println("Hello, DCE! :)"); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..cab859e --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,36 @@ +include(FetchContent) + +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG main + GIT_SHALLOW 1 +) + +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + +FetchContent_MakeAvailable(googletest) + +enable_testing() + +message(STATUS "DCE tests enabled!") + +add_executable( + tests + snowflake.cpp +) +dce_try_apply_debug_options(tests PRIVATE) + +target_compile_features(tests PRIVATE cxx_std_26) +set_target_properties(tests PROPERTIES CXX_EXTENSIONS OFF) + +target_link_libraries( + tests + PRIVATE + GTest::gtest_main + + dce-core +) + +include(GoogleTest) +gtest_discover_tests(tests) diff --git a/tests/snowflake.cpp b/tests/snowflake.cpp new file mode 100644 index 0000000..d7083e2 --- /dev/null +++ b/tests/snowflake.cpp @@ -0,0 +1,95 @@ +#include + +#include + +#include + +using namespace dce::discord; + +TEST(Snowflake, DoesntDefaultConstruct) +{ + auto const x = std::is_default_constructible_v; + ASSERT_FALSE(x); +} + +TEST(Snowflake, DoesntParseFromEmptyInput) +{ + auto s = Snowflake::parse(""); + + ASSERT_FALSE(s.has_value()); + + ASSERT_TRUE(s.error().generic_parse_error().source.empty()); + ASSERT_EQ(s.error().generic_parse_error().offset, 0); + ASSERT_TRUE(s.error().is()); +} + +TEST(Snowflake, DoesntParseFromInvalidInput) +{ + { + static auto const source = "asd123"; + auto s = Snowflake::parse(source); + + ASSERT_FALSE(s.has_value()); + + ASSERT_EQ(s.error().generic_parse_error().source, source); + ASSERT_EQ(s.error().generic_parse_error().offset, 0); + ASSERT_TRUE(s.error().is()); + } + { + static auto const source = "123asd"; + auto s = Snowflake::parse(source); + + ASSERT_FALSE(s.has_value()); + + ASSERT_EQ(s.error().generic_parse_error().source, source); + ASSERT_EQ(s.error().generic_parse_error().offset, 2); + ASSERT_TRUE(s.error().is()); + } +} + +TEST(Snowflake, ParsesProperU64) +{ + { + static auto const source = "327036485033365851"; + auto s = Snowflake::parse(source); + ASSERT_TRUE(s.has_value()) << s.error(); + } + { + static auto const source = "912554373094252554"; + auto s = Snowflake::parse(source); + ASSERT_TRUE(s.has_value()) << s.error(); + } +} + +TEST(Snowflake, StringifiesProperly) +{ + static auto const source = "327036485033365851"; + auto s = Snowflake::parse(source); + ASSERT_TRUE(s.has_value()) << s.error(); + ASSERT_EQ(s->string(), source); + ASSERT_EQ(fmt::format("{}", *s), source); +} + +TEST(Snowflake, DoesntParseTooShortValues) +{ + { + static auto const source = std::string_view("32703648503336585"); + auto s = Snowflake::parse(source); + ASSERT_FALSE(s.has_value()); + + ASSERT_EQ(s.error().generic_parse_error().offset, source.size() - 1); + ASSERT_TRUE(s.error().is()); + ASSERT_TRUE(s.error().as()->reason == SPE_InvalidRange::Reason::TooSmall); + } +} + +TEST(Snowflake, DoesntParseTooLongValues) +{ + { + static auto const source = std::string_view("32703648503336585123"); + auto s = Snowflake::parse(source); + ASSERT_FALSE(s.has_value()); + ASSERT_EQ(s.error().generic_parse_error().offset, source.size() - 1); + ASSERT_TRUE(s.error().as()->reason == SPE_InvalidRange::Reason::TooBig); + } +} -- 2.51.2