diff --git a/docs/mkdocs/docs/api/basic_json/std_formatter.md b/docs/mkdocs/docs/api/basic_json/std_formatter.md index e7e889aee90..085c1f4513b 100644 --- a/docs/mkdocs/docs/api/basic_json/std_formatter.md +++ b/docs/mkdocs/docs/api/basic_json/std_formatter.md @@ -11,8 +11,8 @@ Specialization to make JSON values formattable with [`std::format`](https://en.c (and the other members of C++20's `` header, such as `std::format_to`). A subset of the [standard format spec grammar](https://en.cppreference.com/w/cpp/utility/format/spec) is -supported, repurposed for JSON pretty-printing; any other spec component (sign, the `0` flag, precision, -`L`, a dynamic width such as `#!cpp "{:{}}"`, or a trailing type character) throws +supported, repurposed for JSON pretty-printing and number formatting. Any other spec component (sign, the +`0` flag, `L`, a dynamic width or precision such as `#!cpp "{:{}}"`, or a trailing type character) throws [`std::format_error`](https://en.cppreference.com/w/cpp/utility/format/format_error): - `#!cpp "{}"` serializes the value the same way as [`dump()`](dump.md) (compact, no whitespace). @@ -25,10 +25,34 @@ supported, repurposed for JSON pretty-printing; any other spec component (sign, way as `#!cpp dump(indent, indent_char)`. The alignment direction itself (`#!cpp '<'`, `#!cpp '>'`, `#!cpp '^'`) has no separate meaning for JSON values — only the fill character before it is used, and any of the three directions is accepted. +- A precision (e.g. `#!cpp "{:.3}"`) writes floating-point numbers with that many significant digits, + exactly as `#!cpp std::format("{:.3}", x)` writes a floating-point number `x` (and as + `#!c printf("%.3g", x)` does). For example, π becomes `#!json 3.14`, `#!json 1.9999` becomes `#!json 2.0`, + and `#!json 12345.678` becomes `#!json 1.23e+04`. A number that would otherwise read back as an integer + gets a `.0`. Integers are always written exactly, and NaN and infinity are still written as + `#!json null`. It combines with the specs above, e.g. `#!cpp "{:#.3}"`. This specialization is only available for `#!cpp char`-based JSON values and only if the standard library provides ``, controlled by the [`JSON_HAS_STD_FORMAT`](../macros/json_has_std_format.md) macro. +!!! warning "Precision and round-tripping" + + Without a precision, floating-point numbers are written with the shortest representation that parses back to + the same value. With a precision, this guarantee is lost: `#!cpp json::parse(std::format("{:.3}", j))` can + differ from `j`. + +!!! note "Rounding" + + The digits are rounded from the exact binary value. `#!cpp 2.675` is stored as + 2.67499999999999982236431605997495353221893310546875, so `#!cpp std::format("{:.3}", json(2.675))` gives + `#!json 2.67`, the same as `#!cpp std::format("{:.3}", 2.675)`, even though [`dump()`](dump.md) writes + `#!json 2.675`. + +!!! hint "Streams" + + There is no stream manipulator for the precision, but a formatted value can be written to a stream: + `#!cpp os << std::format("{:.3}", j)`. + ## Examples ??? example diff --git a/docs/mkdocs/docs/examples/std_formatter.c++20.cpp b/docs/mkdocs/docs/examples/std_formatter.c++20.cpp index d249415d749..7345a153bf2 100644 --- a/docs/mkdocs/docs/examples/std_formatter.c++20.cpp +++ b/docs/mkdocs/docs/examples/std_formatter.c++20.cpp @@ -18,5 +18,9 @@ int main() std::cout << std::format("{:2}", j) << "\n\n"; // fill-and-align sets the indent character, like dump(4, '.') - std::cout << std::format("{:.>#}", j) << std::endl; + std::cout << std::format("{:.>#}", j) << "\n\n"; + + // a precision sets the significant digits of floating-point numbers + json k = {{"pi", 3.141592653589793}, {"answer", 42}}; + std::cout << std::format("{:.3}", k) << std::endl; } diff --git a/docs/mkdocs/docs/examples/std_formatter.c++20.output b/docs/mkdocs/docs/examples/std_formatter.c++20.output index 679c8be007c..c5c77f21d2e 100644 --- a/docs/mkdocs/docs/examples/std_formatter.c++20.output +++ b/docs/mkdocs/docs/examples/std_formatter.c++20.output @@ -14,3 +14,5 @@ ...."one": 1, ...."two": 2 } + +{"answer":42,"pi":3.14} diff --git a/docs/mkdocs/docs/features/serialization.md b/docs/mkdocs/docs/features/serialization.md index 5b916e6c860..b211b750bcb 100644 --- a/docs/mkdocs/docs/features/serialization.md +++ b/docs/mkdocs/docs/features/serialization.md @@ -107,7 +107,9 @@ std::println("{:#}", j); // pretty-printed with the default indent ``` The format spec mirrors the `dump` parameters: `#!cpp "{:#}"` pretty-prints, a width such as `#!cpp "{:2}"` sets the -indent, and a fill-and-align prefix such as `#!cpp "{:.>#}"` sets the indent character. +indent, and a fill-and-align prefix such as `#!cpp "{:.>#}"` sets the indent character. A precision such as +`#!cpp "{:.3}"` writes floating-point numbers with that many significant digits, like `std::format` writes a number, +which gives up round-tripping; see [number serialization](types/number_handling.md#number-serialization). For the [{fmt}](https://github.com/fmtlib/fmt) library, the library ships a [`format_as`](../api/basic_json/format_as.md) helper. Note its behavior depends on the `fmt` version; see the diff --git a/docs/mkdocs/docs/features/types/number_handling.md b/docs/mkdocs/docs/features/types/number_handling.md index cf37b044a46..805fdde7541 100644 --- a/docs/mkdocs/docs/features/types/number_handling.md +++ b/docs/mkdocs/docs/features/types/number_handling.md @@ -121,6 +121,10 @@ That is, `-0` is stored as a signed integer, but the serialization does not repr - Floating-point numbers are serialized as specified by the `#!c %g` printf modifier with [`std::numeric_limits::max_digits10`](https://en.cppreference.com/w/cpp/types/numeric_limits/max_digits10) significant digits. The rationale is to use the shortest representation while still allowing round-tripping. +- A precision can be chosen with [`std::format`](../../api/basic_json/std_formatter.md), e.g. + `#!cpp std::format("{:.3}", j)`. Floating-point numbers are then written with that many significant digits, with + the digits of `#!c printf("%.*g")` and `std::format`: π becomes `#!json 3.14` and `#!json 1.9999` becomes + `#!json 2.0`. The output no longer round-trips. Integers are always written exactly. !!! hint "Notes regarding precision of floating-point numbers" diff --git a/docs/mkdocs/docs/home/architecture.md b/docs/mkdocs/docs/home/architecture.md index 6c0892f1171..09bf9d139e0 100644 --- a/docs/mkdocs/docs/home/architecture.md +++ b/docs/mkdocs/docs/home/architecture.md @@ -231,6 +231,11 @@ The `serializer` (used by [`dump`](../api/basic_json/dump.md) and [`operator<<`] - `output_stream_adapter` writes to a `std::ostream`. - `output_string_adapter` appends to a string. +The `serializer` writes IEEE-754 `float` and `double` numbers with the Grisu2 algorithm in `to_chars.hpp`, and other +floating-point types with `snprintf("%.*g")` and `max_digits10` digits. With a precision (used by the +[`std::format`](../api/basic_json/std_formatter.md) spec `{:.N}`), every floating-point type takes the `snprintf` +path, with N digits instead of `max_digits10`. + ## Value conversion Values are converted from and to other types with the `JSONSerializer` template parameter. The default, diff --git a/include/nlohmann/detail/output/serializer.hpp b/include/nlohmann/detail/output/serializer.hpp index f968b60016d..205a56f5d42 100644 --- a/include/nlohmann/detail/output/serializer.hpp +++ b/include/nlohmann/detail/output/serializer.hpp @@ -71,6 +71,9 @@ class serializer result consists of ASCII characters only. @param[in] indent_step_ the indent level @param[in] error_handler_ how to react on decoding errors + @param[in] float_precision_ number of significant digits for + floating-point numbers, as for std::format("{:.N}"); a negative value (the + default) writes the shortest representation that round-trips None of @a pretty_print_, @a ensure_ascii_ and @a indent_step_ change over the life of the serializer, so they are captured once here instead of @@ -81,7 +84,8 @@ class serializer const bool pretty_print_ = false, const bool ensure_ascii_ = false, const std::size_t indent_step_ = 0, - error_handler_t error_handler_ = error_handler_t::strict) + error_handler_t error_handler_ = error_handler_t::strict, + const int float_precision_ = -1) : o(&s) , locale(std::localeconv()) , indent_char(ichar) @@ -89,6 +93,7 @@ class serializer , ensure_ascii(ensure_ascii_) , indent_step(indent_step_) , error_handler(error_handler_) + , float_precision(float_precision_) {} // deleted because of pointer members @@ -1507,6 +1512,12 @@ class serializer return; } + if (JSON_HEDLEY_UNLIKELY(float_precision >= 0)) + { + dump_float_with_precision(x); + return; + } + // If number_float_t is an IEEE-754 single or double precision number, // use the Grisu2 algorithm to produce short numbers which are // guaranteed to round-trip, using strtof and strtod, resp. @@ -1544,42 +1555,103 @@ class serializer void dump_float(number_float_t x, std::false_type /*is_ieee_single_or_double*/) { // get the number of digits for a float -> text -> float round-trip - static constexpr auto d = std::numeric_limits::max_digits10; + dump_float_snprintf(number_buffer, std::numeric_limits::max_digits10, x, false); + } + + /// the largest float precision; it exceeds the significant digits of any + /// double (at most 767), so only very long long doubles are cut short + static constexpr int max_float_precision = 1000; + + /*! + @brief dump a floating-point number with @ref float_precision significant digits + + This is the same snprintf("%.*g") path as for types that are not IEEE-754 + float or double, only with the given number of digits, so the digits are + those of printf and std::format("{:.N}", x). + */ + // not inlined, so that its large buffer does not grow the stack frame of + // the default path + JSON_HEDLEY_NEVER_INLINE + void dump_float_with_precision(number_float_t x) + { + // precision + sign, decimal point, "0.000" prefix, and exponent + std::array < char, max_float_precision + 32 > buffer{{}}; + // no std::min: it would odr-use max_float_precision, which C++11 does not define + const int d = float_precision < max_float_precision ? float_precision : max_float_precision; + dump_float_snprintf(buffer, d, x, true); + } + + /*! + @brief dump a floating-point number with @a d significant digits via snprintf + The output is made locale-independent, and ".0" is appended to values that + would otherwise read back as integers. + + With @a trim_zeros, trailing zeros of the fraction are removed, as "%g" + requires anyway. macOS's printf keeps them on exact ties in exponent + notation, e.g. "%.3g" of 5405000 gives "5.40e+06" instead of "5.4e+06", + which std::format does not. It is only set for an explicit precision, so + the default output is unchanged. + */ + template + void dump_float_snprintf(std::array& buffer, const int d, number_float_t x, const bool trim_zeros) + { // the actual conversion - std::ptrdiff_t len = snprintf_float(number_buffer.data(), number_buffer.size(), d, x); + std::ptrdiff_t len = snprintf_float(buffer.data(), buffer.size(), d, x); // negative value indicates an error JSON_ASSERT(len > 0); // check if the buffer was large enough - JSON_ASSERT(static_cast(len) < number_buffer.size()); + JSON_ASSERT(static_cast(len) < buffer.size()); // erase thousands separators if (locale.thousands_sep != '\0') { // NOLINTNEXTLINE(readability-qualified-auto,llvm-qualified-auto): std::remove returns an iterator, see https://github.com/nlohmann/json/issues/3081 - const auto end = std::remove(number_buffer.begin(), number_buffer.begin() + len, locale.thousands_sep); - std::fill(end, number_buffer.end(), '\0'); - JSON_ASSERT((end - number_buffer.begin()) <= len); - len = (end - number_buffer.begin()); + const auto end = std::remove(buffer.begin(), buffer.begin() + len, locale.thousands_sep); + std::fill(end, buffer.end(), '\0'); + JSON_ASSERT((end - buffer.begin()) <= len); + len = (end - buffer.begin()); } // convert decimal point to '.' if (locale.decimal_point != '\0' && locale.decimal_point != '.') { // NOLINTNEXTLINE(readability-qualified-auto,llvm-qualified-auto): std::find returns an iterator, see https://github.com/nlohmann/json/issues/3081 - const auto dec_pos = std::find(number_buffer.begin(), number_buffer.end(), locale.decimal_point); - if (dec_pos != number_buffer.end()) + const auto dec_pos = std::find(buffer.begin(), buffer.end(), locale.decimal_point); + if (dec_pos != buffer.end()) { *dec_pos = '.'; } } - put_buffer(number_buffer, static_cast(len)); + if (trim_zeros) + { + char* const begin = buffer.data(); + char* const end = begin + len; + const char* const dot = std::find(begin, end, '.'); + if (dot != end) + { + char* const exp = std::find(begin, end, 'e'); + char* last = exp; + while (*(last - 1) == '0') + { + --last; + } + if (last - 1 == dot) + { + --last; + } + // move the exponent (and the terminating '\0') behind the digits + len = (std::copy(exp, end + 1, last) - 1) - begin; + } + } + + put_buffer(buffer, static_cast(len)); // determine if we need to append ".0" const bool value_is_int_like = - std::none_of(number_buffer.begin(), number_buffer.begin() + len + 1, + std::none_of(buffer.begin(), buffer.begin() + len + 1, [](char c) { return c == '.' || c == 'e'; @@ -1659,6 +1731,10 @@ class serializer /// error_handler how to react on decoding errors const error_handler_t error_handler; + /// significant digits for floating-point numbers; negative for the + /// shortest round-trip representation + const int float_precision; + /// buffer collecting output before it is flushed to the output adapter, so /// that the many small structural writes become few bulk writes static constexpr std::size_t write_buffer_size = 1024; diff --git a/include/nlohmann/json.hpp b/include/nlohmann/json.hpp index 500fcddf272..4db6f3d979e 100644 --- a/include/nlohmann/json.hpp +++ b/include/nlohmann/json.hpp @@ -6537,6 +6537,10 @@ struct formatter // NOLINT(cert-dcl58-c // output with that many spaces (or indent_char) per level (dump(indent, indent_char)). int indent = -1; char indent_char = ' '; + // -1 means the shortest round-trip representation for floating-point + // numbers; any value >= 0 is a number of significant digits, as for + // std::format("{:.N}") on a floating-point number + int precision = -1; constexpr auto parse(format_parse_context& ctx) -> format_parse_context::iterator { @@ -6580,7 +6584,26 @@ struct formatter // NOLINT(cert-dcl58-c } } - // sign, the '0' flag, precision, locale-specific formatting ('L'), dynamic + // ['.' precision] - the number of significant digits for floating-point + // numbers, e.g. "{:.3}" or "{:#.3}"; integers are always written exactly. + // The value is saturated rather than overflowing, at the serializer's + // maximum of 1000, which covers every significant digit of a double. + if (it != end && *it == '.') + { + ++it; + if (it == end || *it < '0' || *it > '9') + { + JSON_THROW(format_error("missing precision for nlohmann::json")); + } + precision = 0; + while (it != end && *it >= '0' && *it <= '9') + { + precision = (std::min)((precision * 10) + (*it - '0'), 1000); + ++it; + } + } + + // sign, the '0' flag, locale-specific formatting ('L'), dynamic // width/precision ("{...}"), and type characters all have no meaning for // JSON values; none of them are consumed above, so they all end up rejected // by this single check along with any other unrecognized trailing spec. @@ -6595,9 +6618,23 @@ struct formatter // NOLINT(cert-dcl58-c template auto format(const nlohmann::NLOHMANN_BASIC_JSON_TPL& j, FormatContext& ctx) const -> decltype(ctx.out()) { + using json_t = nlohmann::NLOHMANN_BASIC_JSON_TPL; + // dump()'s own default (indent = -1) already means compact output, so this // covers both the compact and pretty-printed cases without a branch. - const auto dumped = j.dump(indent, indent_char); + if (precision < 0) + { + const auto dumped = j.dump(indent, indent_char); + return std::copy(dumped.begin(), dumped.end(), ctx.out()); + } + + // dump() has no precision parameter, so the serializer is used directly + typename json_t::string_t dumped; + ::nlohmann::detail::output_string_adapter adapter(dumped); + ::nlohmann::detail::serializer s(adapter, indent_char, indent >= 0, false, + indent >= 0 ? static_cast(indent) : 0, + ::nlohmann::detail::error_handler_t::strict, precision); + s.dump(j); return std::copy(dumped.begin(), dumped.end(), ctx.out()); } }; diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index 576498738e2..0beb54757fb 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -24152,6 +24152,9 @@ class serializer result consists of ASCII characters only. @param[in] indent_step_ the indent level @param[in] error_handler_ how to react on decoding errors + @param[in] float_precision_ number of significant digits for + floating-point numbers, as for std::format("{:.N}"); a negative value (the + default) writes the shortest representation that round-trips None of @a pretty_print_, @a ensure_ascii_ and @a indent_step_ change over the life of the serializer, so they are captured once here instead of @@ -24162,7 +24165,8 @@ class serializer const bool pretty_print_ = false, const bool ensure_ascii_ = false, const std::size_t indent_step_ = 0, - error_handler_t error_handler_ = error_handler_t::strict) + error_handler_t error_handler_ = error_handler_t::strict, + const int float_precision_ = -1) : o(&s) , locale(std::localeconv()) , indent_char(ichar) @@ -24170,6 +24174,7 @@ class serializer , ensure_ascii(ensure_ascii_) , indent_step(indent_step_) , error_handler(error_handler_) + , float_precision(float_precision_) {} // deleted because of pointer members @@ -25588,6 +25593,12 @@ class serializer return; } + if (JSON_HEDLEY_UNLIKELY(float_precision >= 0)) + { + dump_float_with_precision(x); + return; + } + // If number_float_t is an IEEE-754 single or double precision number, // use the Grisu2 algorithm to produce short numbers which are // guaranteed to round-trip, using strtof and strtod, resp. @@ -25625,42 +25636,103 @@ class serializer void dump_float(number_float_t x, std::false_type /*is_ieee_single_or_double*/) { // get the number of digits for a float -> text -> float round-trip - static constexpr auto d = std::numeric_limits::max_digits10; + dump_float_snprintf(number_buffer, std::numeric_limits::max_digits10, x, false); + } + + /// the largest float precision; it exceeds the significant digits of any + /// double (at most 767), so only very long long doubles are cut short + static constexpr int max_float_precision = 1000; + + /*! + @brief dump a floating-point number with @ref float_precision significant digits + + This is the same snprintf("%.*g") path as for types that are not IEEE-754 + float or double, only with the given number of digits, so the digits are + those of printf and std::format("{:.N}", x). + */ + // not inlined, so that its large buffer does not grow the stack frame of + // the default path + JSON_HEDLEY_NEVER_INLINE + void dump_float_with_precision(number_float_t x) + { + // precision + sign, decimal point, "0.000" prefix, and exponent + std::array < char, max_float_precision + 32 > buffer{{}}; + // no std::min: it would odr-use max_float_precision, which C++11 does not define + const int d = float_precision < max_float_precision ? float_precision : max_float_precision; + dump_float_snprintf(buffer, d, x, true); + } + + /*! + @brief dump a floating-point number with @a d significant digits via snprintf + The output is made locale-independent, and ".0" is appended to values that + would otherwise read back as integers. + + With @a trim_zeros, trailing zeros of the fraction are removed, as "%g" + requires anyway. macOS's printf keeps them on exact ties in exponent + notation, e.g. "%.3g" of 5405000 gives "5.40e+06" instead of "5.4e+06", + which std::format does not. It is only set for an explicit precision, so + the default output is unchanged. + */ + template + void dump_float_snprintf(std::array& buffer, const int d, number_float_t x, const bool trim_zeros) + { // the actual conversion - std::ptrdiff_t len = snprintf_float(number_buffer.data(), number_buffer.size(), d, x); + std::ptrdiff_t len = snprintf_float(buffer.data(), buffer.size(), d, x); // negative value indicates an error JSON_ASSERT(len > 0); // check if the buffer was large enough - JSON_ASSERT(static_cast(len) < number_buffer.size()); + JSON_ASSERT(static_cast(len) < buffer.size()); // erase thousands separators if (locale.thousands_sep != '\0') { // NOLINTNEXTLINE(readability-qualified-auto,llvm-qualified-auto): std::remove returns an iterator, see https://github.com/nlohmann/json/issues/3081 - const auto end = std::remove(number_buffer.begin(), number_buffer.begin() + len, locale.thousands_sep); - std::fill(end, number_buffer.end(), '\0'); - JSON_ASSERT((end - number_buffer.begin()) <= len); - len = (end - number_buffer.begin()); + const auto end = std::remove(buffer.begin(), buffer.begin() + len, locale.thousands_sep); + std::fill(end, buffer.end(), '\0'); + JSON_ASSERT((end - buffer.begin()) <= len); + len = (end - buffer.begin()); } // convert decimal point to '.' if (locale.decimal_point != '\0' && locale.decimal_point != '.') { // NOLINTNEXTLINE(readability-qualified-auto,llvm-qualified-auto): std::find returns an iterator, see https://github.com/nlohmann/json/issues/3081 - const auto dec_pos = std::find(number_buffer.begin(), number_buffer.end(), locale.decimal_point); - if (dec_pos != number_buffer.end()) + const auto dec_pos = std::find(buffer.begin(), buffer.end(), locale.decimal_point); + if (dec_pos != buffer.end()) { *dec_pos = '.'; } } - put_buffer(number_buffer, static_cast(len)); + if (trim_zeros) + { + char* const begin = buffer.data(); + char* const end = begin + len; + const char* const dot = std::find(begin, end, '.'); + if (dot != end) + { + char* const exp = std::find(begin, end, 'e'); + char* last = exp; + while (*(last - 1) == '0') + { + --last; + } + if (last - 1 == dot) + { + --last; + } + // move the exponent (and the terminating '\0') behind the digits + len = (std::copy(exp, end + 1, last) - 1) - begin; + } + } + + put_buffer(buffer, static_cast(len)); // determine if we need to append ".0" const bool value_is_int_like = - std::none_of(number_buffer.begin(), number_buffer.begin() + len + 1, + std::none_of(buffer.begin(), buffer.begin() + len + 1, [](char c) { return c == '.' || c == 'e'; @@ -25740,6 +25812,10 @@ class serializer /// error_handler how to react on decoding errors const error_handler_t error_handler; + /// significant digits for floating-point numbers; negative for the + /// shortest round-trip representation + const int float_precision; + /// buffer collecting output before it is flushed to the output adapter, so /// that the many small structural writes become few bulk writes static constexpr std::size_t write_buffer_size = 1024; @@ -32618,6 +32694,10 @@ struct formatter // NOLINT(cert-dcl58-c // output with that many spaces (or indent_char) per level (dump(indent, indent_char)). int indent = -1; char indent_char = ' '; + // -1 means the shortest round-trip representation for floating-point + // numbers; any value >= 0 is a number of significant digits, as for + // std::format("{:.N}") on a floating-point number + int precision = -1; constexpr auto parse(format_parse_context& ctx) -> format_parse_context::iterator { @@ -32661,7 +32741,26 @@ struct formatter // NOLINT(cert-dcl58-c } } - // sign, the '0' flag, precision, locale-specific formatting ('L'), dynamic + // ['.' precision] - the number of significant digits for floating-point + // numbers, e.g. "{:.3}" or "{:#.3}"; integers are always written exactly. + // The value is saturated rather than overflowing, at the serializer's + // maximum of 1000, which covers every significant digit of a double. + if (it != end && *it == '.') + { + ++it; + if (it == end || *it < '0' || *it > '9') + { + JSON_THROW(format_error("missing precision for nlohmann::json")); + } + precision = 0; + while (it != end && *it >= '0' && *it <= '9') + { + precision = (std::min)((precision * 10) + (*it - '0'), 1000); + ++it; + } + } + + // sign, the '0' flag, locale-specific formatting ('L'), dynamic // width/precision ("{...}"), and type characters all have no meaning for // JSON values; none of them are consumed above, so they all end up rejected // by this single check along with any other unrecognized trailing spec. @@ -32676,9 +32775,23 @@ struct formatter // NOLINT(cert-dcl58-c template auto format(const nlohmann::NLOHMANN_BASIC_JSON_TPL& j, FormatContext& ctx) const -> decltype(ctx.out()) { + using json_t = nlohmann::NLOHMANN_BASIC_JSON_TPL; + // dump()'s own default (indent = -1) already means compact output, so this // covers both the compact and pretty-printed cases without a branch. - const auto dumped = j.dump(indent, indent_char); + if (precision < 0) + { + const auto dumped = j.dump(indent, indent_char); + return std::copy(dumped.begin(), dumped.end(), ctx.out()); + } + + // dump() has no precision parameter, so the serializer is used directly + typename json_t::string_t dumped; + ::nlohmann::detail::output_string_adapter adapter(dumped); + ::nlohmann::detail::serializer s(adapter, indent_char, indent >= 0, false, + indent >= 0 ? static_cast(indent) : 0, + ::nlohmann::detail::error_handler_t::strict, precision); + s.dump(j); return std::copy(dumped.begin(), dumped.end(), ctx.out()); } }; diff --git a/tests/src/unit-float-precision.cpp b/tests/src/unit-float-precision.cpp new file mode 100644 index 00000000000..67901736186 --- /dev/null +++ b/tests/src/unit-float-precision.cpp @@ -0,0 +1,221 @@ +// __ _____ _____ _____ +// __| | __| | | | JSON for Modern C++ (supporting code) +// | | |__ | | | | | | version 3.12.0 +// |_____|_____|_____|_|___| https://github.com/nlohmann/json +// +// SPDX-FileCopyrightText: 2013-2026 Niels Lohmann +// SPDX-License-Identifier: MIT + +#include "doctest_compatibility.h" + +#include +using nlohmann::json; + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +template +std::string dump_with_precision(const BasicJsonType& j, int precision, bool pretty_print = false) +{ + std::stringstream ss; + nlohmann::detail::output_stream_adapter adapter(ss); + nlohmann::detail::serializer s(adapter, ' ', pretty_print, false, pretty_print ? 2 : 0, + nlohmann::detail::error_handler_t::strict, precision); + s.dump(j); + return ss.str(); +} + +// what an ostream writes with std::setprecision, which is specified as +// printf("%.*g"), plus the ".0" that marks a floating-point number in JSON +std::string expected_with_precision(double v, int precision) +{ + std::ostringstream ss; + ss.imbue(std::locale::classic()); + ss << std::setprecision(precision) << v; + std::string s = ss.str(); + + // "%g" removes trailing zeros of the fraction, but macOS's printf keeps + // them on exact ties in exponent notation ("5.40e+06" for 5405000) + const auto dot = s.find('.'); + if (dot != std::string::npos) + { + const auto exp = (std::min)(s.find('e'), s.size()); + auto last = exp; + while (s[last - 1] == '0') + { + --last; + } + if (last - 1 == dot) + { + --last; + } + s.erase(last, exp - last); + } + + if (s.find_first_of(".e") == std::string::npos) + { + s += ".0"; + } + return s; +} +} // namespace + +TEST_CASE("serializer with float precision") +{ + SECTION("significant digits, rounded as by %.*g") + { + CHECK(dump_with_precision(json(3.141592653589793), 3) == "3.14"); + CHECK(dump_with_precision(json(3.141592653589793), 1) == "3.0"); + CHECK(dump_with_precision(json(1.9999), 3) == "2.0"); + CHECK(dump_with_precision(json(-1.9999), 3) == "-2.0"); + CHECK(dump_with_precision(json(0.000123456), 3) == "0.000123"); + CHECK(dump_with_precision(json(12345.678), 3) == "1.23e+04"); + CHECK(dump_with_precision(json(12345.678), 5) == "12346.0"); + CHECK(dump_with_precision(json(1.23456e30), 3) == "1.23e+30"); + CHECK(dump_with_precision(json(1.23456e-30), 3) == "1.23e-30"); + CHECK(dump_with_precision(json(0.0000123456), 2) == "1.2e-05"); + CHECK(dump_with_precision(json(42.0), 3) == "42.0"); + CHECK(dump_with_precision(json(100.0), 1) == "1e+02"); + CHECK(dump_with_precision(json(0.0), 3) == "0.0"); + CHECK(dump_with_precision(json(-0.0), 3) == "-0.0"); + } + + SECTION("the carry propagates") + { + CHECK(dump_with_precision(json(9.99), 2) == "10.0"); + CHECK(dump_with_precision(json(99.5), 2) == "1e+02"); + CHECK(dump_with_precision(json(0.0999), 2) == "0.1"); + CHECK(dump_with_precision(json(-0.0999), 2) == "-0.1"); + CHECK(dump_with_precision(json(9.99e15), 2) == "1e+16"); + } + + SECTION("ties are decided on the exact value, as by %.*g") + { + // 0.15 and 2.675 are stored slightly below the written value + CHECK(dump_with_precision(json(0.15), 1) == "0.1"); + CHECK(dump_with_precision(json(2.675), 3) == "2.67"); + CHECK(dump_with_precision(json(-2.675), 3) == "-2.67"); + // exact ties round to even, and trailing zeros are removed + CHECK(dump_with_precision(json(0.125), 2) == "0.12"); + CHECK(dump_with_precision(json(5405000.0), 3) == "5.4e+06"); + CHECK(dump_with_precision(json(9905.0), 3) == "9.9e+03"); + CHECK(dump_with_precision(json(9915.0), 3) == "9.92e+03"); + } + + SECTION("the digits match std::setprecision (and so printf's %.*g)") + { + std::mt19937_64 gen(42); // NOLINT(cert-msc32-c,cert-msc51-cpp) + std::uniform_real_distribution mantissa(1.0, 10.0); + std::uniform_int_distribution exponent(-30, 30); + std::uniform_int_distribution digits(1, 20); + for (int i = 0; i < 100000; ++i) + { + // half of the values are short decimals, whose ties are the tricky case + double v = mantissa(gen) * std::pow(10.0, exponent(gen)); + if (i % 2 == 0) + { + v = std::stod(expected_with_precision(v, 4)); + } + const int p = digits(gen); + CAPTURE(v); + CAPTURE(p); + CHECK(dump_with_precision(json(v), p) == expected_with_precision(v, p)); + } + } + + SECTION("precision 0 is treated as 1, as by %.*g") + { + CHECK(dump_with_precision(json(3.141592653589793), 0) == "3.0"); + CHECK(dump_with_precision(json(0.000123456), 0) == "0.0001"); + } + + SECTION("large precisions write the exact value") + { + CHECK(dump_with_precision(json(0.1), 17) == "0.10000000000000001"); + CHECK(dump_with_precision(json(0.1), 20) == "0.10000000000000000555"); + CHECK(dump_with_precision(json(0.1), 1000) == "0.1000000000000000055511151231257827021181583404541015625"); + CHECK(dump_with_precision(json(0.1), (std::numeric_limits::max)()) == "0.1000000000000000055511151231257827021181583404541015625"); + + // the longest exact double: 751 significant digits + const std::string denorm_min = dump_with_precision(json((std::numeric_limits::denorm_min)()), 1000); + CHECK(denorm_min.size() == 757); + CHECK(denorm_min.substr(0, 20) == "4.940656458412465441"); + CHECK(denorm_min.substr(denorm_min.size() - 11) == "265625e-324"); + CHECK(json::parse(denorm_min).get() == (std::numeric_limits::denorm_min)()); + } + + SECTION("a negative precision keeps the default output") + { + for (const double v : + { + 0.0, -0.0, 1.0, 0.1, 0.15, 3.141592653589793, 1e-7, 1e21, 12345.678, + (std::numeric_limits::max)(), (std::numeric_limits::min)(), + (std::numeric_limits::denorm_min)() + }) + { + CAPTURE(v); + CHECK(dump_with_precision(json(v), -1) == json(v).dump()); + CHECK(dump_with_precision(json(v), -42) == json(v).dump()); + } + } + + SECTION("integers are written exactly") + { + CHECK(dump_with_precision(json(123456789), 3) == "123456789"); + CHECK(dump_with_precision(json(-123456789), 3) == "-123456789"); + CHECK(dump_with_precision(json((std::numeric_limits::max)()), 3) == "18446744073709551615"); + } + + SECTION("NaN and infinity are still written as null") + { + CHECK(dump_with_precision(json(std::numeric_limits::quiet_NaN()), 3) == "null"); + CHECK(dump_with_precision(json(std::numeric_limits::infinity()), 3) == "null"); + CHECK(dump_with_precision(json(-std::numeric_limits::infinity()), 3) == "null"); + } + + SECTION("applies to every number in a structure") + { + const json j = {{"pi", 3.141592653589793}, {"n", 12345}, {"a", {1.9999, 0.5, "2.71828"}}}; + CHECK(dump_with_precision(j, 3) == R"({"a":[2.0,0.5,"2.71828"],"n":12345,"pi":3.14})"); + CHECK(dump_with_precision(j, 3, true) == + "{\n" + " \"a\": [\n" + " 2.0,\n" + " 0.5,\n" + " \"2.71828\"\n" + " ],\n" + " \"n\": 12345,\n" + " \"pi\": 3.14\n" + "}"); + } + + SECTION("float as number_float_t") + { + // digits of the float's exact value + using float_json = nlohmann::basic_json; + CHECK(dump_with_precision(float_json(3.14159265f), 3) == "3.14"); + CHECK(dump_with_precision(float_json(2.675f), 3) == "2.67"); + CHECK(dump_with_precision(float_json(0.1f), 9) == "0.100000001"); + CHECK(dump_with_precision(float_json(0.1f), 100) == "0.100000001490116119384765625"); + CHECK(dump_with_precision(float_json(0.1f), -1) == float_json(0.1f).dump()); + } + + SECTION("long double as number_float_t") + { + using long_double_json = nlohmann::basic_json; + CHECK(dump_with_precision(long_double_json(3.141592653589793238L), 3) == "3.14"); + CHECK(dump_with_precision(long_double_json(1.9999L), 3) == "2.0"); + CHECK(dump_with_precision(long_double_json(12345.678L), 3) == "1.23e+04"); + CHECK(dump_with_precision(long_double_json(0.5L), 1000) == "0.5"); + CHECK(dump_with_precision(long_double_json(0.1L), -1) == long_double_json(0.1L).dump()); + } +} diff --git a/tests/src/unit-locale-cpp.cpp b/tests/src/unit-locale-cpp.cpp index 14f743a66c9..e5a855a50aa 100644 --- a/tests/src/unit-locale-cpp.cpp +++ b/tests/src/unit-locale-cpp.cpp @@ -15,6 +15,7 @@ using nlohmann::json; #include #include #include +#include #include #include #include @@ -174,6 +175,17 @@ TEST_CASE("locale-dependent test (LC_NUMERIC=de_DE)") CHECK(long_double_json(1.0L).dump() == "1.0"); CHECK(long_double_json(-0.25L).dump() == "-0.25"); } + + SECTION("serializing with a float precision") + { + // a float precision is written with snprintf, whose locale-specific + // decimal point is undone afterwards + std::stringstream ss; + nlohmann::detail::output_stream_adapter adapter(ss); + json::serializer s(adapter, ' ', false, false, 0, nlohmann::detail::error_handler_t::strict, 3); + s.dump(json::array({3.141592653589793, 1.9999, 12345.678, 5405000.0})); + CHECK(ss.str() == "[3.14,2.0,1.23e+04,5.4e+06]"); + } } else { diff --git a/tests/src/unit-std-format.cpp b/tests/src/unit-std-format.cpp index f2be8d5ec7a..5b86dad560d 100644 --- a/tests/src/unit-std-format.cpp +++ b/tests/src/unit-std-format.cpp @@ -22,7 +22,9 @@ using ordered_json = nlohmann::ordered_json; // JSON_HAS_CPP_20 (do not remove; see note at top of file) #if JSON_HAS_STD_FORMAT +#include #include +#include #include TEST_CASE("std::formatter") @@ -96,10 +98,95 @@ TEST_CASE("std::formatter") CHECK_THROWS_AS(std::vformat("{:-}", std::make_format_args(j)), std::format_error); // sign CHECK_THROWS_AS(std::vformat("{: }", std::make_format_args(j)), std::format_error); // sign CHECK_THROWS_AS(std::vformat("{:04}", std::make_format_args(j)), std::format_error); // '0' flag - CHECK_THROWS_AS(std::vformat("{:.2}", std::make_format_args(j)), std::format_error); // precision CHECK_THROWS_AS(std::vformat("{:L}", std::make_format_args(j)), std::format_error); // locale const int dynamic_width = 4; CHECK_THROWS_AS(std::vformat("{:{}}", std::make_format_args(j, dynamic_width)), std::format_error); // dynamic width + CHECK_THROWS_AS(std::vformat("{:.{}}", std::make_format_args(j, dynamic_width)), std::format_error); // dynamic precision + CHECK_THROWS_AS(std::vformat("{:.}", std::make_format_args(j)), std::format_error); // '.' without digits + CHECK_THROWS_AS(std::vformat("{:.3x}", std::make_format_args(j)), std::format_error); // type after precision + CHECK_THROWS_AS(std::vformat("{:.3#}", std::make_format_args(j)), std::format_error); // '#' after precision + } + + SECTION("a precision sets the significant digits of floating-point numbers") + { + // rounded, not truncated, with the digits of std::format on the number + CHECK(std::format("{:.3}", json(3.141592653589793)) == "3.14"); + CHECK(std::format("{:.3}", json(1.9999)) == "2.0"); + CHECK(std::format("{:.3}", json(0.000123456)) == "0.000123"); + CHECK(std::format("{:.3}", json(12345.678)) == "1.23e+04"); + CHECK(std::format("{:.1}", json(0.15)) == "0.1"); + CHECK(std::format("{:.3}", json(2.675)) == "2.67"); + CHECK(std::format("{:.3}", json(5405000.0)) == "5.4e+06"); + CHECK(std::format("{:.0}", json(3.141592653589793)) == "3.0"); + CHECK(std::format("{:.17}", json(0.1)) == "0.10000000000000001"); + CHECK(std::format("{:.99999999999}", json(0.1)) == "0.1000000000000000055511151231257827021181583404541015625"); + + // integers, strings, and NaN are unaffected + const json j = {{"pi", 3.141592653589793}, {"n", 123456789}, {"s", "3.14159"}, {"nan", NAN}}; + CHECK(std::format("{:.3}", j) == R"({"n":123456789,"nan":null,"pi":3.14,"s":"3.14159"})"); + } + + SECTION("a precision gives the digits of std::format on the number") + { + std::mt19937_64 gen(42); // NOLINT(cert-msc32-c,cert-msc51-cpp) + std::uniform_real_distribution mantissa(1.0, 10.0); + std::uniform_int_distribution exponent(-30, 30); + std::uniform_int_distribution digits(0, 20); + for (int i = 0; i < 100000; ++i) + { + // half of the values are short decimals, whose ties are the tricky case + double v = mantissa(gen) * std::pow(10.0, exponent(gen)); + if (i % 2 == 0) + { + v = std::stod(std::format("{:.4}", v)); + } + const int p = digits(gen); + std::string expected = std::vformat("{:." + std::to_string(p) + "}", std::make_format_args(v)); + if (expected.find_first_of(".e") == std::string::npos) + { + expected += ".0"; + } + CAPTURE(v); + CAPTURE(p); + const json j = v; + CHECK(std::vformat("{:." + std::to_string(p) + "}", std::make_format_args(j)) == expected); + } + } + + SECTION("a precision combines with the other specs") + { + const json j = {{"a", {1.9999, 2}}, {"pi", 3.141592653589793}}; + CHECK(std::format("{:#.3}", j) == + "{\n" + " \"a\": [\n" + " 2.0,\n" + " 2\n" + " ],\n" + " \"pi\": 3.14\n" + "}"); + CHECK(std::format("{:2.3}", j) == + "{\n" + " \"a\": [\n" + " 2.0,\n" + " 2\n" + " ],\n" + " \"pi\": 3.14\n" + "}"); + CHECK(std::format("{:.>#1.2}", j) == + "{\n" + ".\"a\": [\n" + "..2.0,\n" + "..2\n" + ".],\n" + ".\"pi\": 3.1\n" + "}"); + } + + SECTION("without a precision, the output is unchanged") + { + const json j = {{"pi", 3.141592653589793}, {"x", 0.1}}; + CHECK(std::format("{}", j) == j.dump()); + CHECK(std::format("{:#}", j) == j.dump(4)); } SECTION("a format spec may run to the end of the parse context") @@ -119,6 +206,9 @@ TEST_CASE("std::formatter") CHECK(parse("#").indent == 4); CHECK(parse("3").indent == 3); CHECK(parse("#12").indent == 12); + CHECK(parse("").precision == -1); + CHECK(parse(".3").precision == 3); + CHECK(parse("#2.10").precision == 10); const auto f = parse(".>"); CHECK(f.indent == -1); @@ -144,6 +234,7 @@ TEST_CASE("std::formatter") CHECK(std::format("{}", j) == j.dump()); CHECK(std::format("{:#}", j) == j.dump(4)); CHECK(std::format("{:2}", j) == j.dump(2)); + CHECK(std::format("{:.3}", ordered_json(3.141592653589793)) == "3.14"); } #endif