diff --git a/README.md b/README.md index 6ff4d2d..f329b16 100644 --- a/README.md +++ b/README.md @@ -737,8 +737,11 @@ sawtooth traversal. This uses the stored payload length without introducing an additional CAN or CAN FD payload limit. Factor, offset, minimum, and maximum accept signed decimal numbers, fractions, -and scientific notation. The entire numeric token must be valid; hexadecimal -numbers, NaN, infinity, conversion overflow, and conversion underflow are +and scientific notation. Conversion is independent of the application's C and +C++ locales: the decimal separator is always a dot (`.`), and comma decimals +and digit grouping are rejected. The entire numeric token must be valid; hexadecimal +numbers, NaN, infinity, conversion overflow, and conversion underflow (including +subnormal values) are rejected. Minimum must not exceed maximum. Zero and negative factors are allowed. Basic multiplexing markers `M` and `mN` are supported as described below. diff --git a/changelog.md b/changelog.md index 191e9e9..f2b6f9a 100644 --- a/changelog.md +++ b/changelog.md @@ -10,6 +10,16 @@ existing Git tag; subsequent changes are listed as unreleased. #### Fixed +- Issue #19: parse signal factor, offset, minimum, and maximum independently of + C/C++ application locales with `std::from_chars`, retaining signed decimals, + scientific notation, complete-token validation, and range/finite checks. +- Added custom C++ and named C comma-decimal locale regressions for valid physical + values and invalid syntax, grouping, overflow/underflow, and reversed ranges. + Documented portable test coverage and Linux locale generation. Verified with + `qmake6`, `make -j4`, and `LOCPATH=/tmp/dbc-issue19-locales make check`: all six + suites passed without skips. The named C locale regression fails with the old + `std::stod` converter. + - Issue #18: frame/signal comments share frame-definition CAN ID validation and resolve after definitions are read. Support tabs, escaped quotes/backslashes, and multiline text while preserving whitespace and starting source locations. diff --git a/core/dbc_statement_handler.cpp b/core/dbc_statement_handler.cpp index 7dcdd45..919a943 100644 --- a/core/dbc_statement_handler.cpp +++ b/core/dbc_statement_handler.cpp @@ -35,14 +35,18 @@ namespace { R"(^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$)"); if (!std::regex_match(text, number)) throw std::runtime_error(std::string("Invalid ") + field + ": " + text); - std::size_t consumed = 0U; - double value; - try { - value = std::stod(text, &consumed); - } catch (const std::exception &) { - throw std::runtime_error(std::string("Invalid ") + field + ": " + text); - } - if (consumed != text.size() || !std::isfinite(value)) + // Floating-point from_chars is locale-independent and does not accept + // a leading '+', which the DBC numeric grammar permits. + const char *begin = text.data(); + const char *end = begin + text.size(); + if (*begin == '+') + ++begin; + double value = 0.0; + const std::from_chars_result parsed = + std::from_chars(begin, end, value, std::chars_format::general); + // Keep the existing rejection of conversion underflow into subnormal values. + if (parsed.ec != std::errc() || parsed.ptr != end || !std::isfinite(value) || + std::fpclassify(value) == FP_SUBNORMAL) throw std::runtime_error(std::string("Invalid ") + field + ": " + text); return value; } diff --git a/tests/README.md b/tests/README.md index 35533bb..c0d1573 100644 --- a/tests/README.md +++ b/tests/README.md @@ -86,6 +86,22 @@ extended frames may share a numeric ID, and separate frames may reuse signal nam Signal tests cover complete syntax and numeric validation, scientific notation, finite physical parameters and ordered ranges, spaces and tabs, receiver lists, unsupported multiplexing, and Intel/Motorola declared-payload boundaries. +Physical-number tests apply a custom comma-decimal C++ locale and an available +named comma-decimal C numeric locale. They check all four physical fields, +leading signs, fractions, scientific notation, finite boundaries, zero, malformed +numbers, comma/grouped numbers, overflow/underflow, and reversed ranges. Locale +changes are restored after each test. The named C locale case skips if none of +the supported locale names are installed; the custom C++ locale case always runs. +On Linux with locale sources and `localedef`, run both cases without installing a +system locale: + +```sh +mkdir -p /tmp/dbc-test-locales +localedef --no-archive -i de_DE -f UTF-8 /tmp/dbc-test-locales/de_DE.UTF-8 +LOCPATH=/tmp/dbc-test-locales make check +``` + +Run `make check` from the build directory as usual. Value-description tests cover full `VAL_` syntax, whitespace, escaped quotes, empty text, exact integer boundaries, duplicate keys within and across statements, deferred and normalized frame references, missing targets, error locations, diff --git a/tests/dbc_parser_test.cpp b/tests/dbc_parser_test.cpp index 875a0f0..450d5e5 100644 --- a/tests/dbc_parser_test.cpp +++ b/tests/dbc_parser_test.cpp @@ -3,7 +3,9 @@ #include "dbc_parser.h" #include "dbc_statement_handler.h" #include "dbc_tree_builder.h" +#include #include +#include #include #include @@ -28,6 +30,27 @@ struct Fixture { } }; +class CommaDecimalPunctuation : public std::numpunct { +protected: + char do_decimal_point() const override { return ','; } + char do_thousands_sep() const override { return '.'; } + std::string do_grouping() const override { return "\3"; } +}; + +class ApplicationLocaleGuard { +public: + ApplicationLocaleGuard() + : previousCppLocale(std::locale()), previousCLocale(std::setlocale(LC_ALL, nullptr)) {} + ~ApplicationLocaleGuard() { + std::locale::global(previousCppLocale); + std::setlocale(LC_ALL, previousCLocale.c_str()); + } + +private: + std::locale previousCppLocale; + std::string previousCLocale; +}; + class CountingHandler : public DbcStatementHandler { public: inline static int instances = 0; @@ -50,6 +73,7 @@ std::string SignalLine(const std::string &start, const std::string &length, class DbcParserTest : public QObject { Q_OBJECT private: + void CheckPhysicalNumbers(); void RejectSignal(const Fixture &fixture, const std::string &line, unsigned payload = 8U) { fixture.Write("BO_ 1 Frame: " + std::to_string(payload) + " ECU\n\n" + line + "\n"); try { @@ -81,6 +105,8 @@ private Q_SLOTS: void integration(); void invalidSignals(); void validSignals(); + void physicalNumbersCppLocale(); + void physicalNumbersCLocale(); void dispatch(); void independentSessions(); void valueDescriptions(); @@ -824,6 +850,75 @@ void DbcParserTest::validSignals() { } catch (const std::exception &error) { CHECK(false, error.what()); } } +void DbcParserTest::CheckPhysicalNumbers() { + Fixture fixture; + struct PhysicalCase { + const char *factor; + const char *offset; + const char *minimum; + const char *maximum; + double expectedFactor; + double expectedOffset; + double expectedMinimum; + double expectedMaximum; + }; + const PhysicalCase valid[] = { + {"+1.25", "-0.5", "-2.75", "255.5", 1.25, -0.5, -2.75, 255.5}, + {"+.5e+1", "-2.5E-1", "-1.", "+2e2", 5.0, -0.25, -1.0, 200.0}, + {"0e-999", "+0.", "-.0", ".0e+999", 0.0, 0.0, 0.0, 0.0}, + {"1.7976931348623157e308", "2.2250738585072014e-308", "-1e308", "+1e308", + std::numeric_limits::max(), std::numeric_limits::min(), -1e308, 1e308}}; + for (const PhysicalCase &test: valid) { + fixture.Write("BO_ 1 Frame: 1 ECU\n" + + SignalLine("0", "8", "1+", test.factor, test.offset, test.minimum, test.maximum) + "\n"); + const DbcParseResult result = DbcParser().ParseFileWithDiagnostics(fixture.path.string()); + QVERIFY(!result.HasErrors()); + QVERIFY(result.diagnostics.empty()); + QCOMPARE(result.database.frames.size(), std::size_t(1)); + const SignalInfo &signal = result.database.frames[0].signals[0]; + QCOMPARE(signal.factor, test.expectedFactor); + QCOMPARE(signal.offset, test.expectedOffset); + QCOMPARE(signal.minimum, test.expectedMinimum); + QCOMPARE(signal.maximum, test.expectedMaximum); + } + + const char *invalid[] = {"1,5", "1.234,5", "1,234.5", "1junk", "nan", "inf", "-inf", + "1e999", "1e-999", "1e-320", "-1e-320", "0x1p2", "1e", "1.2.3"}; + for (const char *value: invalid) { + RejectSignal(fixture, SignalLine("0", "8", "1+", value, "0", "0", "255")); + RejectSignal(fixture, SignalLine("0", "8", "1+", "1", value, "0", "255")); + RejectSignal(fixture, SignalLine("0", "8", "1+", "1", "0", value, "255")); + RejectSignal(fixture, SignalLine("0", "8", "1+", "1", "0", "0", value)); + } + RejectSignal(fixture, SignalLine("0", "8", "1+", "1.25", "0", "2.5", "1.5")); +} + +void DbcParserTest::physicalNumbersCppLocale() { + const ApplicationLocaleGuard guard; + std::locale::global(std::locale(std::locale::classic(), new CommaDecimalPunctuation)); + QCOMPARE(std::use_facet>(std::locale()).decimal_point(), ','); + CheckPhysicalNumbers(); + QCOMPARE(std::use_facet>(std::locale()).decimal_point(), ','); +} + +void DbcParserTest::physicalNumbersCLocale() { + const ApplicationLocaleGuard guard; + bool found = false; + const char *names[] = {"de_DE.UTF-8", "de_DE.utf8", "fr_FR.UTF-8", "fr_FR.utf8", + "German_Germany.1252", "French_France.1252"}; + for (const char *name: names) { + if (std::setlocale(LC_NUMERIC, name) && std::string(std::localeconv()->decimal_point) == ",") { + found = true; + break; + } + } + if (!found) + QSKIP("No named comma-decimal C locale is installed; generate de_DE.UTF-8 and set LOCPATH."); + QCOMPARE(std::string(std::localeconv()->decimal_point), std::string(",")); + CheckPhysicalNumbers(); + QCOMPARE(std::string(std::localeconv()->decimal_point), std::string(",")); +} + void DbcParserTest::dispatch() { CountingHandler::instances = 0; CountingHandler::calls = 0;