diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index b3e511f86f60..c24bb8a48aeb 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -46,6 +46,11 @@ jobs: std: 23 os: ubuntu-24.04 gen: Ninja + - cxx: g++-16 + build_type: Debug + std: 26 + os: ubuntu-24.04 + install: sudo apt install g++-16 - cxx: clang++-3.6 - cxx: clang++-11 build_type: Debug @@ -160,6 +165,11 @@ jobs: | sudo tee /etc/apt/sources.list.d/llvm.list if: ${{ matrix.cxx == 'clang++-20' }} + - name: Install GCC 16 + run: | + sudo add-apt-repository --no-update --yes ppa:ubuntu-toolchain-r/test + if: ${{ matrix.cxx == 'g++-16' }} + - name: Add Ubuntu mirrors run: | # GitHub Actions caching proxy is at times unreliable diff --git a/CMakeLists.txt b/CMakeLists.txt index 5179ce6b3ee0..5b5274a092c0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -275,6 +275,7 @@ foreach ( color.h compile.h core.h + enum.h format.h format-inl.h os.h @@ -408,9 +409,41 @@ function (add_module_library name) target_sources(${name} PRIVATE ${sources}) endfunction () +# Code that compiles only if C++26 reflection, required by fmt/enum.h, is +# available. Also used by test/CMakeLists.txt. +set(FMT_REFLECTION_TEST_CODE + " + #include + enum class [[=fmt::as_identifiers]] color { red }; + static_assert(fmt::is_formattable::value, \"\"); + int main() {} +") + if (FMT_MODULE) + # Unlike with headers, whether fmt/enum.h provides anything is decided when + # the module is compiled, so detect reflection with the configured standard + # and enable it if possible. Reflection needs C++26 and, in some compilers + # such as GCC, an extra flag. + if (NOT MSVC AND CMAKE_CXX_STANDARD GREATER_EQUAL 26) + include(CheckCXXSourceCompiles) + set(CMAKE_REQUIRED_INCLUDES ${PROJECT_SOURCE_DIR}/include) + check_cxx_source_compiles("${FMT_REFLECTION_TEST_CODE}" + FMT_MODULE_HAVE_REFLECTION) + if (NOT FMT_MODULE_HAVE_REFLECTION) + set(CMAKE_REQUIRED_FLAGS -freflection) + check_cxx_source_compiles("${FMT_REFLECTION_TEST_CODE}" + FMT_MODULE_HAVE_REFLECTION_FLAG) + unset(CMAKE_REQUIRED_FLAGS) + endif () + unset(CMAKE_REQUIRED_INCLUDES) + endif () + add_module_library(fmt-module src/fmt.cc USE_CMAKE_MODULES ${FMT_USE_CMAKE_MODULES}) + if (FMT_MODULE_HAVE_REFLECTION_FLAG) + # PUBLIC because importers need the flag to use annotations. + target_compile_options(fmt-module PUBLIC -freflection) + endif () setup_target(fmt-module PUBLIC) endif () diff --git a/doc/api.md b/doc/api.md index 7ea03e260656..fb39083ec7c5 100644 --- a/doc/api.md +++ b/doc/api.md @@ -9,6 +9,7 @@ The {fmt} library API consists of the following components: - [`fmt/ranges.h`](#ranges-api): formatting of ranges and tuples - [`fmt/chrono.h`](#chrono-api): date and time formatting - [`fmt/std.h`](#std-api): formatters for standard library types +- [`fmt/enum.h`](#enum-api): formatting of annotated enums - [`fmt/compile.h`](#compile-api): format string compilation - [`fmt/color.h`](#color-api): terminal colors and text styles - [`fmt/os.h`](#os-api): system APIs @@ -556,6 +557,55 @@ fmt::print("{}", +s.bit); This is a known limitation of "perfect" forwarding in C++. + +## Enum Formatting + +`fmt/enum.h` provides formatting of enums annotated with +`fmt::as_identifiers`. Such an enum is formatted as the identifier of the +enumerator matching the formatted value: + + #include + + enum class [[=fmt::as_identifiers]] color { red, green, blue }; + + fmt::print("{}", color::green); + // Output: green + +Such enums are formatted using the string [Format Specification]( +syntax.md#format-specification), for example: + + fmt::print("[{:>7}]", color::red); + // Output: [ red] + +Identifiers are only available as `char` strings so annotated enums are not +formattable with other character types. + +If several enumerators have the same value, the first one in the order of +declaration is used. A value that doesn't match any enumerator is represented +as its underlying value in decimal before applying string formatting: + + fmt::print("{}", static_cast(42)); + // Output: 42 + +Enums without the annotation are not affected and are formatted as before, i.e. +scoped enums require `format_as` or a `formatter` specialization, see +[Formatting User-Defined Types](#udt). + +Identifiers are retrieved with C++26 reflection ([P2996]( +https://wg21.link/p2996)) and the annotation with [P3394]( +https://wg21.link/p3394), so this requires a compiler with reflection support, +which may need an extra flag such as `-freflection` in GCC. The macro +`FMT_USE_REFLECTION` is set to 1 if reflection is available and to 0 otherwise. +It can also be defined by the user to disable the use of reflection, in which +case `fmt/enum.h` is empty. + +When {fmt} is built as a module, reflection support is detected when the module +itself is compiled, so this API is only available to importers if the module was +built with reflection enabled. An importing translation unit may also have to +include `` itself: some compilers, such as GCC 16, fail to look up +implementation details of `std::define_static_string` when instantiating the +formatter otherwise. + ## Compile-Time Support diff --git a/include/fmt/enum.h b/include/fmt/enum.h new file mode 100644 index 000000000000..841f8daef3af --- /dev/null +++ b/include/fmt/enum.h @@ -0,0 +1,187 @@ +// Formatting library for C++ - formatting of enums +// +// Copyright (c) 2012 - present, Victor Zverovich and {fmt} contributors +// All rights reserved. +// +// For the license information refer to format.h. + +#ifndef FMT_ENUM_H_ +#define FMT_ENUM_H_ + +#include "format.h" + +#if FMT_HAS_INCLUDE() +# include +#endif + +#ifdef FMT_USE_REFLECTION +// Use the provided definition. +#elif defined(__cpp_impl_reflection) && defined(__cpp_lib_reflection) +# define FMT_USE_REFLECTION 1 +#else +# define FMT_USE_REFLECTION 0 +#endif + +#if FMT_USE_REFLECTION && !defined(FMT_MODULE) +# include +# include +# include // std::pair +#endif + +FMT_BEGIN_NAMESPACE + +#if FMT_USE_REFLECTION + +/// The type of the `fmt::as_identifiers` annotation. +FMT_EXPORT struct as_identifiers_t {}; + +/** + * An annotation that makes an enum format as identifiers of its enumerators. + * + * **Example**: + * + * enum class [[=fmt::as_identifiers]] color { red, green, blue }; + * auto s = fmt::format("{}", color::green); // s == "green" + * + * A value that doesn't match any enumerator is represented as its underlying + * value in decimal before applying string formatting. + */ +FMT_EXPORT inline constexpr auto as_identifiers = as_identifiers_t(); + +namespace detail { + +// Returns true if T is an enum annotated with fmt::as_identifiers. +template > +consteval auto use_identifiers() -> bool { + if constexpr (!std::is_enum::value) { + return false; + } else { + return !std::meta::annotations_of_with_type(^^U, ^^as_identifiers_t) + .empty(); + } +} + +// Returns the identifier of e. identifier_of returns a view of a string with +// static storage duration so it doesn't need to be copied. +inline consteval auto identifier(std::meta::info e) -> string_view { + auto id = std::meta::identifier_of(e); + return string_view(id.data(), id.size()); +} + +// Returns the underlying value of `value` converted to uint64_t. Negative +// values wrap around, so the difference of two such values is the distance +// between them. +template constexpr auto to_uint64(E value) -> uint64_t { + return static_cast(static_cast>(value)); +} + +template consteval auto count_enumerators() -> size_t { + return std::meta::enumerators_of(^^E).size(); +} + +// Returns the smallest enumerator value of E or 0 if E has no enumerators. +template consteval auto min_enumerator() -> E { + auto enumerators = std::meta::enumerators_of(^^E); + if (enumerators.empty()) return E(); + auto min = std::meta::extract(enumerators[0]); + for (std::meta::info e : enumerators) { + auto value = std::meta::extract(e); + if (value < min) min = value; + } + return min; +} + +// Returns the size of a table that maps the distance from the smallest +// enumerator value of E to an identifier, or 0 if the values are too sparse +// for such a table to be worthwhile. +template consteval auto identifier_table_size() -> size_t { + auto enumerators = std::meta::enumerators_of(^^E); + if (enumerators.empty()) return 0; + auto min = to_uint64(min_enumerator()); + auto span = uint64_t(); + for (std::meta::info e : enumerators) + span = max_of(span, to_uint64(std::meta::extract(e)) - min); + // Require the density of at least 70% to limit the size of the table. + // Comparing with the limit instead of adding one to span avoids overflow. + auto limit = uint64_t(enumerators.size()) * 10 / 7; + return span < limit ? static_cast(span) + 1 : 0; +} + +template ()> +consteval auto make_identifier_table() -> std::array { + auto ids = std::array(); + auto min = to_uint64(min_enumerator()); + for (std::meta::info e : std::meta::enumerators_of(^^E)) { + auto i = static_cast(to_uint64(std::meta::extract(e)) - min); + // Keep the identifier of the first enumerator with this value. + if (ids[i].size() == 0) ids[i] = identifier(e); + } + return ids; +} + +// Identifiers of enumerators of E indexed by the distance from the smallest +// enumerator value with empty string views in the holes. +template +inline constexpr auto identifier_table = make_identifier_table(); + +template ()> +consteval auto make_identifiers() -> std::array, N> { + auto ids = std::array, N>(); + auto i = size_t(); + for (std::meta::info e : std::meta::enumerators_of(^^E)) + ids[i++] = {std::meta::extract(e), identifier(e)}; + return ids; +} + +// Identifiers of enumerators of E in the order of declaration. +template inline constexpr auto identifiers = make_identifiers(); + +// Returns the identifier of the first enumerator of E equal to value or an +// empty string view if there is no such enumerator. +template constexpr auto identifier_of(E value) -> string_view { + constexpr size_t table_size = identifier_table_size(); + if constexpr (table_size != 0) { + // Values outside of the table wrap around and are rejected by the check. + auto i = to_uint64(value) - to_uint64(min_enumerator()); + return i < table_size ? identifier_table[static_cast(i)] + : string_view(); + } else { + for (const auto& id : identifiers) { + if (id.first == value) return id.second; + } + return {}; + } +} + +} // namespace detail + +// A formatter for enums annotated with fmt::as_identifiers. +template +struct formatter()>> { + private: + formatter impl_; + + public: + FMT_CONSTEXPR auto parse(parse_context& ctx) -> const char* { + return impl_.parse(ctx); + } + + template + auto format(E value, FormatContext& ctx) const -> decltype(ctx.out()) { + auto id = detail::identifier_of(value); + if (id.size() != 0) return impl_.format(id, ctx); + // Fall back to the underlying value if there is no matching enumerator. + // Unary plus applies integral promotion so that `char` and `bool` + // underlying types are written in decimal instead of as a character or + // "true"/"false". + auto buf = memory_buffer(); + detail::write(appender(buf), +underlying(value)); + return impl_.format(string_view(buf.data(), buf.size()), ctx); + } +}; + +#endif // FMT_USE_REFLECTION + +FMT_END_NAMESPACE + +#endif // FMT_ENUM_H_ diff --git a/src/fmt.cc b/src/fmt.cc index 6d1d73b59f78..7f51c356c95d 100644 --- a/src/fmt.cc +++ b/src/fmt.cc @@ -66,6 +66,12 @@ module; #include #include +// fmt/enum.h uses C++26 reflection if it is available. +#if defined(__cpp_impl_reflection) && __has_include() +# include +# include +#endif + #if __has_include() # include #endif @@ -128,6 +134,7 @@ extern "C++" { #include "fmt/chrono.h" #include "fmt/color.h" #include "fmt/compile.h" +#include "fmt/enum.h" #include "fmt/format.h" #if FMT_OS # include "fmt/os.h" diff --git a/support/python/mkdocstrings_handlers/cxx/__init__.py b/support/python/mkdocstrings_handlers/cxx/__init__.py index d83811d8a764..a55e85b2622c 100644 --- a/support/python/mkdocstrings_handlers/cxx/__init__.py +++ b/support/python/mkdocstrings_handlers/cxx/__init__.py @@ -249,6 +249,7 @@ def __init__( "chrono.h", "color.h", "compile.h", + "enum.h", "format.h", "os.h", "ostream.h", diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a294d9da819b..ce1c1f15ad60 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -50,6 +50,11 @@ add_fmt_test(format-test mock-allocator.h) if (MSVC) target_compile_options(format-test PRIVATE /bigobj) endif () +if (CMAKE_COMPILER_IS_GNUCXX AND NOT CMAKE_CXX_COMPILER_VERSION VERSION_LESS 16) + # format-test completes a type after it failed to be complete in a SFINAE + # context, which is exactly what it is testing. + target_compile_options(format-test PRIVATE -Wno-sfinae-incomplete) +endif () if (NOT (MSVC AND BUILD_SHARED_LIBS)) add_fmt_test(format-impl-test HEADER_ONLY header-only-test.cc) endif () @@ -77,6 +82,36 @@ add_fmt_test(enforce-checks-test) target_compile_definitions(enforce-checks-test PRIVATE -DFMT_ENFORCE_COMPILE_STRING) +# Enum formatting requires C++26 reflection, which needs compiler support and, +# in some compilers such as GCC, an extra flag to enable it. +if (NOT MSVC) + include(CheckCXXSourceCompiles) + set(CMAKE_REQUIRED_INCLUDES ${PROJECT_SOURCE_DIR}/include) + set(CMAKE_REQUIRED_FLAGS "-std=c++26") + check_cxx_source_compiles("${FMT_REFLECTION_TEST_CODE}" FMT_HAVE_REFLECTION) + if (NOT FMT_HAVE_REFLECTION) + set(CMAKE_REQUIRED_FLAGS "-std=c++26 -freflection") + check_cxx_source_compiles("${FMT_REFLECTION_TEST_CODE}" + FMT_HAVE_REFLECTION_FLAG) + endif () + unset(CMAKE_REQUIRED_FLAGS) + unset(CMAKE_REQUIRED_INCLUDES) + + if (FMT_HAVE_REFLECTION OR FMT_HAVE_REFLECTION_FLAG) + add_fmt_test(enum-test) + set_target_properties( + enum-test + PROPERTIES CXX_STANDARD 26 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF) + if (FMT_HAVE_REFLECTION_FLAG) + target_compile_options(enum-test PRIVATE -freflection) + endif () + else () + message(STATUS "Reflection is not supported, skipping enum-test.") + endif () +endif () + add_executable(perf-sanity perf-sanity.cc) target_link_libraries(perf-sanity fmt::fmt) diff --git a/test/enum-test.cc b/test/enum-test.cc new file mode 100644 index 000000000000..662399e3e5c3 --- /dev/null +++ b/test/enum-test.cc @@ -0,0 +1,166 @@ +// Formatting library for C++ - enum formatting tests +// +// Copyright (c) 2012 - present, Victor Zverovich and {fmt} contributors +// All rights reserved. +// +// For the license information refer to format.h. + +#include "fmt/enum.h" + +#include +#include + +#include "fmt/ranges.h" +#include "gtest/gtest.h" + +#if !FMT_USE_REFLECTION +TEST(enum_test, no_reflection) { + fmt::print("Reflection is not supported.\n"); +} +#else + +// clang-format doesn't support annotations yet. +// clang-format off +enum class [[=fmt::as_identifiers]] color { red, green, blue }; +enum class color_without_annotation { red, green, blue }; +enum [[=fmt::as_identifiers]] unscoped_color { unscoped_red, unscoped_green }; +enum class [[=fmt::as_identifiers]] level : unsigned char { low = 1, high = 2 }; +enum class [[=fmt::as_identifiers]] byte_enum : char { one = 1 }; +enum class [[=fmt::as_identifiers]] signed_byte_enum : signed char { minus_one = -1 }; +enum class [[=fmt::as_identifiers]] bool_enum : bool { off = false }; +enum class [[=fmt::as_identifiers]] alias { one = 1, uno = 1 }; +enum class [[=fmt::as_identifiers]] empty_enum {}; + +// Dense values: formatted via a lookup table. +enum class [[=fmt::as_identifiers]] dense { d0, d1, d2, d3, d4 }; +// 3 holes out of 10: the sparsest case that still uses a lookup table. +enum class [[=fmt::as_identifiers]] holey { + h0, h1, h2, h3, h4, h5, h6 = 9 +}; +// 4 holes out of 11: just too sparse, formatted via a linear search. +enum class [[=fmt::as_identifiers]] sparse { + s0, s1, s2, s3, s4, s5, s6 = 10 +}; +// Values spanning both signs and the extremes of the underlying type. +enum class [[=fmt::as_identifiers]] signed_enum { + minus_two = -2, + minus_one = -1, + one = 1 +}; +enum class [[=fmt::as_identifiers]] extremes : int { + lowest = INT_MIN, + highest = INT_MAX +}; +enum class [[=fmt::as_identifiers]] big : unsigned long long { + huge = ULLONG_MAX +}; +// clang-format on + +TEST(enum_test, format_enum) { + EXPECT_EQ(fmt::format("{}", color::red), "red"); + EXPECT_EQ(fmt::format("{}", color::green), "green"); + EXPECT_EQ(fmt::format("{}", color::blue), "blue"); +} + +TEST(enum_test, format_unscoped_enum) { + EXPECT_EQ(fmt::format("{}", unscoped_green), "unscoped_green"); +} + +TEST(enum_test, format_enum_with_underlying_type) { + EXPECT_EQ(fmt::format("{}", level::high), "high"); + EXPECT_EQ(fmt::format("{}", byte_enum::one), "one"); + EXPECT_EQ(fmt::format("{}", signed_byte_enum::minus_one), "minus_one"); + EXPECT_EQ(fmt::format("{}", bool_enum::off), "off"); +} + +TEST(enum_test, format_dense_enum) { + // A dense enum is formatted using a lookup table. + static_assert(fmt::detail::identifier_table_size() == 5); + EXPECT_EQ(fmt::format("{}", dense::d0), "d0"); + EXPECT_EQ(fmt::format("{}", dense::d4), "d4"); + EXPECT_EQ(fmt::format("{}", static_cast(5)), "5"); +} + +TEST(enum_test, format_holey_enum) { + // Up to 30% of holes are still formatted using a lookup table. + static_assert(fmt::detail::identifier_table_size() == 10); + EXPECT_EQ(fmt::format("{}", holey::h0), "h0"); + EXPECT_EQ(fmt::format("{}", holey::h5), "h5"); + EXPECT_EQ(fmt::format("{}", holey::h6), "h6"); + // Values in the holes and outside of the table fall back to the number. + EXPECT_EQ(fmt::format("{}", static_cast(6)), "6"); + EXPECT_EQ(fmt::format("{}", static_cast(8)), "8"); + EXPECT_EQ(fmt::format("{}", static_cast(10)), "10"); + EXPECT_EQ(fmt::format("{}", static_cast(-1)), "-1"); +} + +TEST(enum_test, format_sparse_enum) { + // One more hole than holey, which is too many for a lookup table. + static_assert(fmt::detail::identifier_table_size() == 0); + EXPECT_EQ(fmt::format("{}", sparse::s0), "s0"); + EXPECT_EQ(fmt::format("{}", sparse::s5), "s5"); + EXPECT_EQ(fmt::format("{}", sparse::s6), "s6"); + EXPECT_EQ(fmt::format("{}", static_cast(6)), "6"); + EXPECT_EQ(fmt::format("{}", static_cast(11)), "11"); +} + +TEST(enum_test, format_enum_with_negative_values) { + static_assert(fmt::detail::identifier_table_size() == 4); + EXPECT_EQ(fmt::format("{}", signed_enum::minus_two), "minus_two"); + EXPECT_EQ(fmt::format("{}", signed_enum::minus_one), "minus_one"); + EXPECT_EQ(fmt::format("{}", signed_enum::one), "one"); + // A hole and values below and above the range of the table. + EXPECT_EQ(fmt::format("{}", static_cast(0)), "0"); + EXPECT_EQ(fmt::format("{}", static_cast(-3)), "-3"); + EXPECT_EQ(fmt::format("{}", static_cast(2)), "2"); +} + +TEST(enum_test, format_enum_with_extreme_values) { + // The span of the values overflows the underlying type, so no table is used. + static_assert(fmt::detail::identifier_table_size() == 0); + EXPECT_EQ(fmt::format("{}", extremes::lowest), "lowest"); + EXPECT_EQ(fmt::format("{}", extremes::highest), "highest"); + EXPECT_EQ(fmt::format("{}", static_cast(0)), "0"); + + static_assert(fmt::detail::identifier_table_size() == 1); + EXPECT_EQ(fmt::format("{}", big::huge), "huge"); + EXPECT_EQ(fmt::format("{}", static_cast(0)), "0"); +} + +TEST(enum_test, format_enum_alias) { + // The first enumerator with a matching value is used. + EXPECT_EQ(fmt::format("{}", alias::uno), "one"); +} + +TEST(enum_test, format_unknown_value) { + EXPECT_EQ(fmt::format("{}", static_cast(42)), "42"); + EXPECT_EQ(fmt::format("{}", static_cast(42)), "42"); + EXPECT_EQ(fmt::format("{}", static_cast(0)), "0"); +} + +TEST(enum_test, format_unknown_char_value) { + // A char underlying type is written in decimal, not as a character. + EXPECT_EQ(fmt::format("{}", static_cast(65)), "65"); + EXPECT_EQ(fmt::format("{}", static_cast(-65)), "-65"); + EXPECT_EQ(fmt::format("{}", static_cast(65)), "65"); + // A bool underlying type is written as 0 or 1, not as "true"/"false". + EXPECT_EQ(fmt::format("{}", static_cast(true)), "1"); +} + +TEST(enum_test, format_enum_specs) { + EXPECT_EQ(fmt::format("{:>7}", color::red), " red"); + EXPECT_EQ(fmt::format("{:*^7}", color::red), "**red**"); + EXPECT_EQ(fmt::format("{:.2}", color::green), "gr"); + EXPECT_EQ(fmt::format("{:>4}", static_cast(42)), " 42"); +} + +TEST(enum_test, format_enum_range) { + auto v = std::vector{color::red, color::blue}; + EXPECT_EQ(fmt::format("{}", v), "[red, blue]"); +} + +TEST(enum_test, annotation_is_required) { + EXPECT_TRUE(fmt::is_formattable::value); + EXPECT_FALSE(fmt::is_formattable::value); +} +#endif // FMT_USE_REFLECTION diff --git a/test/module-test.cc b/test/module-test.cc index a7b67a4a3623..e266a8797e98 100644 --- a/test/module-test.cc +++ b/test/module-test.cc @@ -15,6 +15,10 @@ #include #include #include +#include +#if defined(__cpp_impl_reflection) && __has_include() +# include // to instantiate the formatter for annotated enums +#endif #include #if (__has_include() || defined(__APPLE__) || \ @@ -366,3 +370,18 @@ TEST(module_test, compile_format_string) { EXPECT_EQ(L" 42", fmt::format(L"{arg:>3}"_cf, L"arg"_a = L"42")); #endif } + +// Enum formatting is only exported if the module was built with C++26 +// reflection enabled. +#if defined(__cpp_impl_reflection) && defined(__cpp_lib_reflection) && \ + defined(__cpp_lib_define_static) +// clang-format doesn't support annotations yet. +// clang-format off +enum class [[=fmt::as_identifiers]] color { red, green, blue }; +// clang-format on + +TEST(module_test, format_enum) { + EXPECT_EQ("green", fmt::format("{}", color::green)); + EXPECT_EQ(" red", fmt::format("{:>5}", color::red)); +} +#endif