From e8877532d5e585f841df387418a4549082c61553 Mon Sep 17 00:00:00 2001 From: Chris Leishman Date: Fri, 7 Aug 2026 14:12:34 -0700 Subject: [PATCH 1/2] Make std::formatter usable with exceptions disabled; bump to 2.1.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The formatter specializations' parse() reported an unusable format spec (a precision with no SI prefix and an empty spec) with a bare throw, which is a hard compile error under -fno-exceptions for any use of the formatters — even valid ones — making them unusable on embedded targets such as ESP-IDF with CONFIG_COMPILER_CXX_EXCEPTIONS unset. The error branch is now discarded via `if constexpr` when the precision has an SI prefix, and otherwise reports through a _format_error() helper that throws std::format_error when exceptions are available and calls std::abort() when they are not. Because the helper is not constexpr, constant evaluation of the format string still rejects a bad spec at compile time in both modes. Adds a framework-free test target built with -fno-exceptions (Catch2 requires exceptions, so it cannot live in thermo_tests). --- CMakeLists.txt | 2 +- docs/Doxyfile | 2 +- idf_component.yml | 2 +- include/thermo/thermo.hpp | 28 +++++++++++++++--- tests/CMakeLists.txt | 11 +++++++ tests/no_exceptions_test.cpp | 56 ++++++++++++++++++++++++++++++++++++ vcpkg-port/vcpkg.json | 2 +- 7 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 tests/no_exceptions_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c7c2d2..0724296 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,7 @@ if(ESP_PLATFORM) endif() project(thermo - VERSION 2.1.0 + VERSION 2.1.1 DESCRIPTION "Type-safe temperature handling library modeled after std::chrono" HOMEPAGE_URL "https://github.com/cleishm/thermo-cpp" LANGUAGES CXX diff --git a/docs/Doxyfile b/docs/Doxyfile index 3723aee..495d485 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -6,7 +6,7 @@ DOXYFILE_ENCODING = UTF-8 PROJECT_NAME = "thermo" -PROJECT_NUMBER = "2.1.0" +PROJECT_NUMBER = "2.1.1" PROJECT_BRIEF = "Type-safe temperature handling library modeled after std::chrono" PROJECT_LOGO = OUTPUT_DIRECTORY = . diff --git a/idf_component.yml b/idf_component.yml index b5361f7..ea7cee3 100644 --- a/idf_component.yml +++ b/idf_component.yml @@ -1,4 +1,4 @@ -version: "2.1.0" +version: "2.1.1" description: "Type-safe temperature handling library modeled after std::chrono" url: "https://github.com/cleishm/thermo-cpp" repository: "https://github.com/cleishm/thermo-cpp.git" diff --git a/include/thermo/thermo.hpp b/include/thermo/thermo.hpp index a1adcd1..a795621 100644 --- a/include/thermo/thermo.hpp +++ b/include/thermo/thermo.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -533,6 +534,25 @@ constexpr OutputIt _format_append(OutputIt out, const char* s) { } return out; } + +#if CONFIG_THERMO_STD_FORMAT +// Reports an unusable format spec from a formatter's parse(). +// +// std::format constant-evaluates parse() to check the format string, so +// throwing there makes a bad spec a compile error rather than a runtime fault. +// Where exceptions are unavailable, calling a non-constexpr function fails that +// same constant evaluation and so reports the error at compile time too; a +// runtime parse (std::vformat with a runtime format string) has no way to +// report it and terminates. +[[noreturn]] inline void _format_error(const char* what) { +#if defined(__cpp_exceptions) && __cpp_exceptions + throw std::format_error(what); +#else + (void)what; + std::abort(); +#endif +} +#endif /** @endcond */ /** @@ -950,8 +970,8 @@ struct formatter>> { constexpr auto parse(format_parse_context& ctx) { auto it = ctx.begin(); if (it == ctx.end() || *it == '}') { - if (_prefix == nullptr) { - throw format_error("thermo: precision has no SI prefix; use an explicit format spec"); + if constexpr (_prefix == nullptr) { + thermo::_format_error("thermo: precision has no SI prefix; use an explicit format spec"); } return it; } @@ -990,8 +1010,8 @@ struct formatter> { constexpr auto parse(format_parse_context& ctx) { auto it = ctx.begin(); if (it == ctx.end() || *it == '}') { - if (_prefix == nullptr) { - throw format_error("thermo: precision has no SI prefix; use an explicit format spec"); + if constexpr (_prefix == nullptr) { + thermo::_format_error("thermo: precision has no SI prefix; use an explicit format spec"); } return it; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dbd117a..f3a5629 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -19,3 +19,14 @@ target_link_libraries(thermo_tests PRIVATE include(CTest) include(Catch) catch_discover_tests(thermo_tests) + +# The formatters must also build with exceptions disabled, as on embedded +# targets. Catch2 requires exceptions, so this is a standalone target. MSVC +# spells the option differently and still declares __cpp_exceptions, so the +# check is limited to compilers taking -fno-exceptions. +if(NOT MSVC) + add_executable(thermo_no_exceptions_test no_exceptions_test.cpp) + target_link_libraries(thermo_no_exceptions_test PRIVATE thermo::thermo) + target_compile_options(thermo_no_exceptions_test PRIVATE -fno-exceptions) + add_test(NAME no_exceptions COMMAND thermo_no_exceptions_test) +endif() diff --git a/tests/no_exceptions_test.cpp b/tests/no_exceptions_test.cpp new file mode 100644 index 0000000..cbf3ae1 --- /dev/null +++ b/tests/no_exceptions_test.cpp @@ -0,0 +1,56 @@ +// Regression test: the std::formatter specializations must be usable when +// compiled with exceptions disabled (-fno-exceptions), as on embedded targets +// such as ESP-IDF with CONFIG_COMPILER_CXX_EXCEPTIONS unset. +// +// This is a separate target from thermo_tests because Catch2 requires +// exceptions. It deliberately avoids any test framework so the whole +// translation unit builds without them. + +#include +#include +#include + +#ifdef __cpp_exceptions +#error "this test must be compiled with exceptions disabled" +#endif + +using namespace thermo; + +namespace { + +int failures = 0; + +void check(const std::string& actual, const std::string& expected, const char* what) { + if (actual != expected) { + std::printf("FAIL: %s: expected \"%s\", got \"%s\"\n", what, expected.c_str(), actual.c_str()); + ++failures; + } +} + +} // namespace + +int main() { + // Formatting with no spec: instantiating parse() must not pull in a throw. + check(std::format("{}", celsius(20)), "20°C", "{} celsius"); + check(std::format("{}", decicelsius(225)), "225d°C", "{} decicelsius"); + check(std::format("{}", millicelsius(22500)), "22500m°C", "{} millicelsius"); + check(std::format("{}", fahrenheit(72)), "72°F", "{} fahrenheit"); + check(std::format("{}", delta_celsius(20)), "20Δ°C", "{} delta_celsius"); + check(std::format("{}", delta_millicelsius(1500)), "1500Δm°C", "{} delta_millicelsius"); + + // Formatting with an explicit spec. + check(std::format("{:.1f}", decicelsius(225)), "22.5°C", "{:.1f} decicelsius"); + check(std::format("{:.1f}", millicelsius(22534)), "22.5°C", "{:.1f} millicelsius"); + check(std::format("{:.1f}", delta_millicelsius(1500)), "1.5Δ°C", "{:.1f} delta_millicelsius"); + + // A precision with no SI prefix is still usable with an explicit spec. + using centicelsius = temperature>; + check(std::format("{:.2f}", centicelsius(2250)), "22.50°C", "{:.2f} centicelsius"); + + if (failures != 0) { + std::printf("%d check(s) failed\n", failures); + return 1; + } + std::printf("all checks passed\n"); + return 0; +} diff --git a/vcpkg-port/vcpkg.json b/vcpkg-port/vcpkg.json index 8cbeb31..238cfe9 100644 --- a/vcpkg-port/vcpkg.json +++ b/vcpkg-port/vcpkg.json @@ -1,6 +1,6 @@ { "name": "cleishm-thermo-cpp", - "version": "2.1.0", + "version": "2.1.1", "description": "Type-safe temperature handling library modeled after std::chrono", "homepage": "https://github.com/cleishm/thermo-cpp", "license": "MIT", From c63ddeeff4c399886bf0ef538d5d34bb77430e60 Mon Sep 17 00:00:00 2001 From: Chris Leishman Date: Fri, 7 Aug 2026 14:18:42 -0700 Subject: [PATCH 2/2] Skip no_exceptions test when std::format is unavailable --- tests/no_exceptions_test.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/no_exceptions_test.cpp b/tests/no_exceptions_test.cpp index cbf3ae1..fcaae17 100644 --- a/tests/no_exceptions_test.cpp +++ b/tests/no_exceptions_test.cpp @@ -14,6 +14,17 @@ #error "this test must be compiled with exceptions disabled" #endif +#if !CONFIG_THERMO_STD_FORMAT + +// Without std::format the formatter is not compiled and there is nothing to +// test; older standard libraries (e.g. g++-12's) lack entirely. +int main() { + std::printf("std::format unavailable; nothing to test\n"); + return 0; +} + +#else + using namespace thermo; namespace { @@ -54,3 +65,5 @@ int main() { std::printf("all checks passed\n"); return 0; } + +#endif // CONFIG_THERMO_STD_FORMAT