From b69794bd8007786c8a85f08ba25e01172ae55880 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Sun, 27 Sep 2026 17:14:00 +0200 Subject: [PATCH] Look up the locale decimal point at conversion time, not lexer construction The lexer read localeconv()->decimal_point once in its constructor and wrote that character into token_buffer in place of '.'. The strtod fallback then used the locale current at conversion time, so an LC_NUMERIC change in between (parser callback, SAX handler, another thread) truncated the value in release builds and fired the endptr assertion in debug builds. token_buffer now always holds '.'. Only the strtof/strtod/strtold fallback depends on the locale: it looks up the decimal point right before the call, restores '.' afterwards, and repeats the conversion if the locale changed in between. As a side effect, std::from_chars and Clinger's fast path now also apply under locales whose decimal point is not '.'. Fixes #5198 Signed-off-by: Niels Lohmann --- include/nlohmann/detail/input/lexer.hpp | 103 +++++++---- .../nlohmann/detail/input/number_parse.hpp | 21 +-- single_include/nlohmann/json.hpp | 124 +++++++------ tests/src/unit-class_lexer.cpp | 2 +- tests/src/unit-locale-cpp.cpp | 170 ++++++++++++++++++ 5 files changed, 315 insertions(+), 105 deletions(-) diff --git a/include/nlohmann/detail/input/lexer.hpp b/include/nlohmann/detail/input/lexer.hpp index 98c0fd76a..4f85f8132 100644 --- a/include/nlohmann/detail/input/lexer.hpp +++ b/include/nlohmann/detail/input/lexer.hpp @@ -206,7 +206,6 @@ class lexer : public lexer_base explicit lexer(InputAdapterType&& adapter, bool ignore_comments_ = false, bool discard_number_values_ = false) noexcept : ia(std::move(adapter)) , ignore_comments(ignore_comments_) - , decimal_point_char(static_cast(get_decimal_point())) , discard_number_values(discard_number_values_) {} @@ -222,8 +221,7 @@ class lexer : public lexer_base // locales ///////////////////// - /// return the locale-dependent decimal point - JSON_HEDLEY_PURE + /// return the decimal point of the current locale static char get_decimal_point() noexcept { const auto* loc = localeconv(); @@ -1092,9 +1090,10 @@ class lexer : public lexer_base token_type::value_float if number could be successfully scanned, token_type::parse_error otherwise - @note The scanner is independent of the current locale. Internally, the - locale's decimal point is used instead of `.` to work with the - locale-dependent converters. + @note The scanner is independent of the current locale: token_buffer + always holds `.`. Only the std::strtod fallback of convert_number() + depends on the locale, and it looks up the decimal point right + before converting (see convert_float_locale_aware()). */ token_type scan_number() // lgtm [cpp/use-of-goto] `goto` is used in this function to implement the number-parsing state machine described above. By design, any finite input will eventually reach the "done" state or return token_type::parse_error. In each intermediate state, 1 byte of the input is appended to the token_buffer vector, and only the already initialized variables token_buffer, number_type, and error_message are manipulated. { @@ -1183,7 +1182,7 @@ scan_number_zero: { case '.': { - add(decimal_point_char); + add(current); decimal_point_position = token_buffer.size() - 1; goto scan_number_decimal1; } @@ -1220,7 +1219,7 @@ scan_number_any1: case '.': { - add(decimal_point_char); + add(current); decimal_point_position = token_buffer.size() - 1; goto scan_number_decimal1; } @@ -1462,9 +1461,9 @@ scan_number_done: // Only a number below 1 can carry further insignificant zeros, and only // while the count stays at the limit does removing them change the - // answer - so this loop is skipped for all but a few tokens. Note - // token_buffer holds the locale's decimal point, so the fraction is - // located through decimal_point_position rather than by searching '.'. + // answer - so this loop is skipped for all but a few tokens. The + // fraction is located through decimal_point_position rather than by + // searching '.'. if (lead_zero != 0) { JSON_ASSERT(has_dot != 0); // an integer "0" cannot reach the limit @@ -1482,8 +1481,8 @@ scan_number_done: @brief convert the number text in token_buffer to its value and token type The digit sequence in token_buffer has already been validated (by the - scan_number() state machine or by the contiguous fast path) and holds the - locale decimal point in place of '.'. Integers are parsed first and fall + scan_number() state machine or by the contiguous fast path) and holds '.' + as decimal point, independent of the locale. Integers are parsed first and fall back to floating point on overflow. This is shared so both scanners produce identical results. @@ -1563,7 +1562,7 @@ scan_number_done: // integer conversion above overflowed. Prefer std::from_chars // (Eisel-Lemire, locale-independent, correctly rounded) when available; // otherwise the exact Clinger fast path (double only); otherwise the - // locale-aware strtof/strtod. + // locale-aware strtof/strtod/strtold. if (parse_float_from_chars(num_begin, num_end, value_float)) { return token_type::value_float; @@ -1572,26 +1571,63 @@ scan_number_done: // extra pass over the token's bytes, which otherwise shows up on // high-precision inputs such as canada.json if (mantissa_fits_clinger(mantissa_end) - && parse_float_fast(num_begin, num_end, decimal_point_char, value_float)) + && parse_float_fast(num_begin, num_end, value_float)) { return token_type::value_float; } - char* endptr = nullptr; // NOLINT(misc-const-correctness,cppcoreguidelines-pro-type-vararg,hicpp-vararg) - strtof(value_float, token_buffer.data(), &endptr); - - // we checked the number format before - JSON_ASSERT(endptr == token_buffer.data() + token_buffer.size()); - + convert_float_locale_aware(); return token_type::value_float; } + /*! + @brief convert the float in token_buffer with strtof/strtod/strtold + + These functions expect the decimal point of the *current* locale, so it is + looked up right before the conversion instead of once when the lexer is + constructed: a locale change in between (by a parser callback, a SAX + handler, or another thread) must not truncate the value (#5198). The + token has been validated before, so if the conversion still stops early, + the locale changed between the lookup and the call, and the conversion is + repeated with the new decimal point. + + Note that changing the locale in another thread *while* strtod runs is + undefined behavior of the C library, which this function cannot prevent. + */ + void convert_float_locale_aware() + { + const bool has_dot = decimal_point_position != std::string::npos; + for (;;) + { + const char decimal_point = get_decimal_point(); + const bool substitute = has_dot && decimal_point != '.'; + if (substitute) + { + token_buffer[decimal_point_position] = static_cast(decimal_point); + } + + char* endptr = nullptr; // NOLINT(misc-const-correctness,cppcoreguidelines-pro-type-vararg,hicpp-vararg) + strtof(value_float, token_buffer.data(), &endptr); + + if (substitute) + { + // get_string() hands the token to the SAX interface with '.' + token_buffer[decimal_point_position] = '.'; + } + + if (JSON_HEDLEY_LIKELY(endptr == token_buffer.data() + token_buffer.size())) + { + return; + } + } + } + /*! @brief contiguous fast path for scanning a number Parses the whole number token straight from the input buffer, avoiding the per-character get()/add() of scan_number(). On success it fills token_buffer - (with the locale decimal point substituted, as scan_number() does) and + (as scan_number() does) and returns the token type. On anything it does not fully recognize as a well-formed number it makes no state change and returns token_type::uninitialized, so the caller falls back to scan_number(), which @@ -1707,16 +1743,11 @@ scan_number_done: } #endif - // materialize the token exactly as scan_number() would, substituting the - // locale decimal point so convert_number()'s strtof fallback stays valid. - // reset() already cleared token_buffer, so append() fills it (assign() is - // avoided because custom string_t types need not provide it) + // materialize the token exactly as scan_number() would. reset() already + // cleared token_buffer, so append() fills it (assign() is avoided + // because custom string_t types need not provide it) token_buffer.append(reinterpret_cast(data), len); - if (dot_index != std::string::npos) - { - token_buffer[dot_index] = static_cast(decimal_point_char); - decimal_point_position = dot_index; - } + decimal_point_position = dot_index; ia.bulk_skip(len - 1); position.chars_read_total += (len - 1); @@ -1983,11 +2014,7 @@ scan_number_done: /// return current string value (implicitly resets the token; useful only once) string_t& get_string() { - // translate decimal points from locale back to '.' (#4084) - if (decimal_point_char != '.' && decimal_point_position != std::string::npos) - { - token_buffer[decimal_point_position] = '.'; - } + // a number token holds '.' regardless of the locale (#4084) return token_buffer; } @@ -2283,9 +2310,7 @@ scan_number_done: number_unsigned_t value_unsigned = 0; number_float_t value_float = 0; - /// the decimal point - const char_int_type decimal_point_char = '.'; - /// the position of the decimal point in the input + /// the position of the decimal point in token_buffer std::size_t decimal_point_position = std::string::npos; /// whether the caller (e.g. accept()/json_sax_acceptor) only needs the diff --git a/include/nlohmann/detail/input/number_parse.hpp b/include/nlohmann/detail/input/number_parse.hpp index e50c3f67f..25f6cac91 100644 --- a/include/nlohmann/detail/input/number_parse.hpp +++ b/include/nlohmann/detail/input/number_parse.hpp @@ -118,14 +118,12 @@ std::strtod. The parser only activates for number_float_t == double; float and long double keep the std::strtof/std::strtold paths (see the templated overload below). -@param[in] first pointer to the first character of the number -@param[in] last pointer past the last character -@param[in] decimal_point the (locale-dependent) decimal point character -@param[out] out the parsed value on success +@param[in] first pointer to the first character of the number +@param[in] last pointer past the last character +@param[out] out the parsed value on success @return true if the value was parsed exactly; false to fall back to strtod */ -template -bool parse_float_fast(const char* first, const char* last, DecimalPointType decimal_point, double& out) noexcept +inline bool parse_float_fast(const char* first, const char* last, double& out) noexcept { #if defined(FLT_EVAL_METHOD) && FLT_EVAL_METHOD != 0 // Clinger's fast path is only exact when double operations are evaluated in @@ -136,7 +134,6 @@ bool parse_float_fast(const char* first, const char* last, DecimalPointType deci // std::from_chars / std::strtod path. static_cast(first); static_cast(last); - static_cast(decimal_point); static_cast(out); return false; #else @@ -175,7 +172,7 @@ bool parse_float_fast(const char* first, const char* last, DecimalPointType deci ++num_digits; fractional_digits += static_cast(seen_dot); } - else if (static_cast(c) == decimal_point) + else if (c == '.') { if (JSON_HEDLEY_UNLIKELY(seen_dot)) { @@ -260,8 +257,8 @@ bool parse_float_fast(const char* first, const char* last, DecimalPointType deci } /// fast float path is only exact for `double`; decline for float/long double -template -bool parse_float_fast(const char* /*first*/, const char* /*last*/, DecimalPointType /*decimal_point*/, FloatType& /*out*/) noexcept +template +bool parse_float_fast(const char* /*first*/, const char* /*last*/, FloatType& /*out*/) noexcept { return false; } @@ -273,9 +270,7 @@ std::from_chars is locale-independent, correctly rounded, and - via the Eisel-Lemire algorithm in modern standard libraries - much faster than strtod over the whole value range (not just the Clinger subset). It is used only when __cpp_lib_to_chars indicates full floating-point support and only when it -consumes the entire token ([first, last)); a partial parse means the buffer -uses a non-'.' locale decimal point, in which case the caller falls back to the -locale-aware path. An under-/overflow (result_out_of_range) also declines, so +consumes the entire token ([first, last)). An under-/overflow (result_out_of_range) also declines, so the caller's strtod fallback supplies the well-defined ±inf/0 result the parser expects (side-stepping the P4168 divergence between implementations). diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index 0e3cae486..4975ac2a0 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -8605,14 +8605,12 @@ std::strtod. The parser only activates for number_float_t == double; float and long double keep the std::strtof/std::strtold paths (see the templated overload below). -@param[in] first pointer to the first character of the number -@param[in] last pointer past the last character -@param[in] decimal_point the (locale-dependent) decimal point character -@param[out] out the parsed value on success +@param[in] first pointer to the first character of the number +@param[in] last pointer past the last character +@param[out] out the parsed value on success @return true if the value was parsed exactly; false to fall back to strtod */ -template -bool parse_float_fast(const char* first, const char* last, DecimalPointType decimal_point, double& out) noexcept +inline bool parse_float_fast(const char* first, const char* last, double& out) noexcept { #if defined(FLT_EVAL_METHOD) && FLT_EVAL_METHOD != 0 // Clinger's fast path is only exact when double operations are evaluated in @@ -8623,7 +8621,6 @@ bool parse_float_fast(const char* first, const char* last, DecimalPointType deci // std::from_chars / std::strtod path. static_cast(first); static_cast(last); - static_cast(decimal_point); static_cast(out); return false; #else @@ -8662,7 +8659,7 @@ bool parse_float_fast(const char* first, const char* last, DecimalPointType deci ++num_digits; fractional_digits += static_cast(seen_dot); } - else if (static_cast(c) == decimal_point) + else if (c == '.') { if (JSON_HEDLEY_UNLIKELY(seen_dot)) { @@ -8747,8 +8744,8 @@ bool parse_float_fast(const char* first, const char* last, DecimalPointType deci } /// fast float path is only exact for `double`; decline for float/long double -template -bool parse_float_fast(const char* /*first*/, const char* /*last*/, DecimalPointType /*decimal_point*/, FloatType& /*out*/) noexcept +template +bool parse_float_fast(const char* /*first*/, const char* /*last*/, FloatType& /*out*/) noexcept { return false; } @@ -8760,9 +8757,7 @@ std::from_chars is locale-independent, correctly rounded, and - via the Eisel-Lemire algorithm in modern standard libraries - much faster than strtod over the whole value range (not just the Clinger subset). It is used only when __cpp_lib_to_chars indicates full floating-point support and only when it -consumes the entire token ([first, last)); a partial parse means the buffer -uses a non-'.' locale decimal point, in which case the caller falls back to the -locale-aware path. An under-/overflow (result_out_of_range) also declines, so +consumes the entire token ([first, last)). An under-/overflow (result_out_of_range) also declines, so the caller's strtod fallback supplies the well-defined ±inf/0 result the parser expects (side-stepping the P4168 divergence between implementations). @@ -9303,7 +9298,6 @@ class lexer : public lexer_base explicit lexer(InputAdapterType&& adapter, bool ignore_comments_ = false, bool discard_number_values_ = false) noexcept : ia(std::move(adapter)) , ignore_comments(ignore_comments_) - , decimal_point_char(static_cast(get_decimal_point())) , discard_number_values(discard_number_values_) {} @@ -9319,8 +9313,7 @@ class lexer : public lexer_base // locales ///////////////////// - /// return the locale-dependent decimal point - JSON_HEDLEY_PURE + /// return the decimal point of the current locale static char get_decimal_point() noexcept { const auto* loc = localeconv(); @@ -10189,9 +10182,10 @@ class lexer : public lexer_base token_type::value_float if number could be successfully scanned, token_type::parse_error otherwise - @note The scanner is independent of the current locale. Internally, the - locale's decimal point is used instead of `.` to work with the - locale-dependent converters. + @note The scanner is independent of the current locale: token_buffer + always holds `.`. Only the std::strtod fallback of convert_number() + depends on the locale, and it looks up the decimal point right + before converting (see convert_float_locale_aware()). */ token_type scan_number() // lgtm [cpp/use-of-goto] `goto` is used in this function to implement the number-parsing state machine described above. By design, any finite input will eventually reach the "done" state or return token_type::parse_error. In each intermediate state, 1 byte of the input is appended to the token_buffer vector, and only the already initialized variables token_buffer, number_type, and error_message are manipulated. { @@ -10280,7 +10274,7 @@ scan_number_zero: { case '.': { - add(decimal_point_char); + add(current); decimal_point_position = token_buffer.size() - 1; goto scan_number_decimal1; } @@ -10317,7 +10311,7 @@ scan_number_any1: case '.': { - add(decimal_point_char); + add(current); decimal_point_position = token_buffer.size() - 1; goto scan_number_decimal1; } @@ -10559,9 +10553,9 @@ scan_number_done: // Only a number below 1 can carry further insignificant zeros, and only // while the count stays at the limit does removing them change the - // answer - so this loop is skipped for all but a few tokens. Note - // token_buffer holds the locale's decimal point, so the fraction is - // located through decimal_point_position rather than by searching '.'. + // answer - so this loop is skipped for all but a few tokens. The + // fraction is located through decimal_point_position rather than by + // searching '.'. if (lead_zero != 0) { JSON_ASSERT(has_dot != 0); // an integer "0" cannot reach the limit @@ -10579,8 +10573,8 @@ scan_number_done: @brief convert the number text in token_buffer to its value and token type The digit sequence in token_buffer has already been validated (by the - scan_number() state machine or by the contiguous fast path) and holds the - locale decimal point in place of '.'. Integers are parsed first and fall + scan_number() state machine or by the contiguous fast path) and holds '.' + as decimal point, independent of the locale. Integers are parsed first and fall back to floating point on overflow. This is shared so both scanners produce identical results. @@ -10660,7 +10654,7 @@ scan_number_done: // integer conversion above overflowed. Prefer std::from_chars // (Eisel-Lemire, locale-independent, correctly rounded) when available; // otherwise the exact Clinger fast path (double only); otherwise the - // locale-aware strtof/strtod. + // locale-aware strtof/strtod/strtold. if (parse_float_from_chars(num_begin, num_end, value_float)) { return token_type::value_float; @@ -10669,26 +10663,63 @@ scan_number_done: // extra pass over the token's bytes, which otherwise shows up on // high-precision inputs such as canada.json if (mantissa_fits_clinger(mantissa_end) - && parse_float_fast(num_begin, num_end, decimal_point_char, value_float)) + && parse_float_fast(num_begin, num_end, value_float)) { return token_type::value_float; } - char* endptr = nullptr; // NOLINT(misc-const-correctness,cppcoreguidelines-pro-type-vararg,hicpp-vararg) - strtof(value_float, token_buffer.data(), &endptr); - - // we checked the number format before - JSON_ASSERT(endptr == token_buffer.data() + token_buffer.size()); - + convert_float_locale_aware(); return token_type::value_float; } + /*! + @brief convert the float in token_buffer with strtof/strtod/strtold + + These functions expect the decimal point of the *current* locale, so it is + looked up right before the conversion instead of once when the lexer is + constructed: a locale change in between (by a parser callback, a SAX + handler, or another thread) must not truncate the value (#5198). The + token has been validated before, so if the conversion still stops early, + the locale changed between the lookup and the call, and the conversion is + repeated with the new decimal point. + + Note that changing the locale in another thread *while* strtod runs is + undefined behavior of the C library, which this function cannot prevent. + */ + void convert_float_locale_aware() + { + const bool has_dot = decimal_point_position != std::string::npos; + for (;;) + { + const char decimal_point = get_decimal_point(); + const bool substitute = has_dot && decimal_point != '.'; + if (substitute) + { + token_buffer[decimal_point_position] = static_cast(decimal_point); + } + + char* endptr = nullptr; // NOLINT(misc-const-correctness,cppcoreguidelines-pro-type-vararg,hicpp-vararg) + strtof(value_float, token_buffer.data(), &endptr); + + if (substitute) + { + // get_string() hands the token to the SAX interface with '.' + token_buffer[decimal_point_position] = '.'; + } + + if (JSON_HEDLEY_LIKELY(endptr == token_buffer.data() + token_buffer.size())) + { + return; + } + } + } + /*! @brief contiguous fast path for scanning a number Parses the whole number token straight from the input buffer, avoiding the per-character get()/add() of scan_number(). On success it fills token_buffer - (with the locale decimal point substituted, as scan_number() does) and + (as scan_number() does) and returns the token type. On anything it does not fully recognize as a well-formed number it makes no state change and returns token_type::uninitialized, so the caller falls back to scan_number(), which @@ -10804,16 +10835,11 @@ scan_number_done: } #endif - // materialize the token exactly as scan_number() would, substituting the - // locale decimal point so convert_number()'s strtof fallback stays valid. - // reset() already cleared token_buffer, so append() fills it (assign() is - // avoided because custom string_t types need not provide it) + // materialize the token exactly as scan_number() would. reset() already + // cleared token_buffer, so append() fills it (assign() is avoided + // because custom string_t types need not provide it) token_buffer.append(reinterpret_cast(data), len); - if (dot_index != std::string::npos) - { - token_buffer[dot_index] = static_cast(decimal_point_char); - decimal_point_position = dot_index; - } + decimal_point_position = dot_index; ia.bulk_skip(len - 1); position.chars_read_total += (len - 1); @@ -11080,11 +11106,7 @@ scan_number_done: /// return current string value (implicitly resets the token; useful only once) string_t& get_string() { - // translate decimal points from locale back to '.' (#4084) - if (decimal_point_char != '.' && decimal_point_position != std::string::npos) - { - token_buffer[decimal_point_position] = '.'; - } + // a number token holds '.' regardless of the locale (#4084) return token_buffer; } @@ -11380,9 +11402,7 @@ scan_number_done: number_unsigned_t value_unsigned = 0; number_float_t value_float = 0; - /// the decimal point - const char_int_type decimal_point_char = '.'; - /// the position of the decimal point in the input + /// the position of the decimal point in token_buffer std::size_t decimal_point_position = std::string::npos; /// whether the caller (e.g. accept()/json_sax_acceptor) only needs the diff --git a/tests/src/unit-class_lexer.cpp b/tests/src/unit-class_lexer.cpp index cb8ceed6b..5d52179d7 100644 --- a/tests/src/unit-class_lexer.cpp +++ b/tests/src/unit-class_lexer.cpp @@ -666,7 +666,7 @@ TEST_CASE("parse_float_fast declines what it cannot convert exactly") // always safe: the caller then falls back to a slower, exact conversion. const auto fast = [](const std::string & s, double & out) { - return nlohmann::detail::parse_float_fast(s.data(), s.data() + s.size(), '.', out); + return nlohmann::detail::parse_float_fast(s.data(), s.data() + s.size(), out); }; double out = 0; diff --git a/tests/src/unit-locale-cpp.cpp b/tests/src/unit-locale-cpp.cpp index c2a113d06..ef52b9ba4 100644 --- a/tests/src/unit-locale-cpp.cpp +++ b/tests/src/unit-locale-cpp.cpp @@ -12,7 +12,12 @@ #include using nlohmann::json; +#include #include +#include +#include +#include +#include struct ParserImpl final: public nlohmann::json_sax { @@ -175,3 +180,168 @@ TEST_CASE("locale-dependent test (LC_NUMERIC=de_DE)") MESSAGE("locale de_DE is not usable"); } } + +namespace +{ +// records the numbers of a flat array and switches LC_NUMERIC to the given +// locale once the array opens - after the lexer was constructed, but before +// any number in the array is lexed +struct LocaleSwitchingSax final: public nlohmann::json_sax +{ + explicit LocaleSwitchingSax(const char* switch_to) + : locale_after_open(switch_to) + {} + + bool null() override + { + return true; + } + bool boolean(bool /*val*/) override + { + return true; + } + bool number_integer(json::number_integer_t /*val*/) override + { + return true; + } + bool number_unsigned(json::number_unsigned_t /*val*/) override + { + return true; + } + bool number_float(json::number_float_t val, const json::string_t& s) override + { + values.push_back(val); + strings.push_back(s); + return true; + } + bool string(json::string_t& /*val*/) override + { + return true; + } + bool binary(json::binary_t& /*val*/) override + { + return true; + } + bool start_object(std::size_t /*val*/) override + { + return true; + } + bool key(json::string_t& /*val*/) override + { + return true; + } + bool end_object() override + { + return true; + } + bool start_array(std::size_t /*val*/) override + { + switched = std::setlocale(LC_NUMERIC, locale_after_open) != nullptr; + return true; + } + bool end_array() override + { + return true; + } + bool parse_error(std::size_t /*val*/, const std::string& /*val*/, const nlohmann::detail::exception& /*val*/) override + { + return false; + } + + const char* locale_after_open; + bool switched = false; + std::vector values; + std::vector strings; +}; +} // namespace + +TEST_CASE("locale changes between lexer construction and number conversion (#5198)") +{ + // The numbers are chosen so that the conversion also takes the strtod + // fallback, which honors the locale that is current at conversion time: + // too many significant digits for Clinger's fast path, an underflow that + // std::from_chars rejects, and a plain value. + const std::vector numbers = {"3.14159265358979323846", "1.5e-400", "12.34", "-0.000123456789012345678"}; + std::string text = "["; + for (const auto& n : numbers) + { + text += (text.size() == 1 ? "" : ",") + n; + } + text += "]"; + + using long_double_json = nlohmann::basic_json; + + // reference values, parsed without a locale switch + REQUIRE(std::setlocale(LC_NUMERIC, "C") != nullptr); + const json expected = json::parse(text); + const long_double_json expected_ld = long_double_json::parse(text); + + const std::array, 2> transitions = + { + { + {"C", "de_DE"}, + {"de_DE", "C"} + } + }; + + for (const auto& transition : transitions) + { + CAPTURE(transition.first); + CAPTURE(transition.second); + + if (std::setlocale(LC_NUMERIC, transition.first) == nullptr) + { + MESSAGE("locale is not usable"); + continue; + } + + // SAX parsing + { + LocaleSwitchingSax sax(transition.second); + CHECK(json::sax_parse(text, &sax)); + if (sax.switched) + { + CHECK(sax.values == expected.get>()); + CHECK(sax.strings == numbers); + } + } + + // DOM parsing with a callback + { + bool switched = false; + const auto cb = [&](int /*depth*/, json::parse_event_t event, json& /*parsed*/) + { + if (event == json::parse_event_t::array_start) + { + switched = std::setlocale(LC_NUMERIC, transition.second) != nullptr; + } + return true; + }; + const json j = json::parse(text, cb); + if (switched) + { + CHECK(j == expected); + } + } + + // a long double goes through std::strtold unless std::from_chars supports it + { + bool switched = false; + const auto cb = [&](int /*depth*/, long_double_json::parse_event_t event, long_double_json& /*parsed*/) + { + if (event == long_double_json::parse_event_t::array_start) + { + switched = std::setlocale(LC_NUMERIC, transition.second) != nullptr; + } + return true; + }; + const long_double_json j = long_double_json::parse(text, cb); + if (switched) + { + CHECK(j == expected_ld); + } + } + } + + std::setlocale(LC_NUMERIC, "C"); +}