Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ add_library(pj_internal_fmt INTERFACE)
target_compile_definitions(pj_internal_fmt INTERFACE FMT_HEADER_ONLY=1)
target_link_libraries(pj_internal_fmt INTERFACE ${PJ_FMT_TARGET})

# fast_float backs the floating-point branch of PJ::parseNumber. Header-only;
# linked PRIVATE under a BUILD_INTERFACE scope by pj_base so it never appears
# in the installed plotjuggler_coreTargets file. Conan ships the CMake
# package under the capitalised name `FastFloat`.
find_package(FastFloat REQUIRED)

if(PJ_BUILD_DATASTORE)
find_package(tsl-robin-map REQUIRED)
if(PJ_BUILD_TESTS)
Expand Down Expand Up @@ -196,7 +202,7 @@ endif()
if(PJ_INSTALL_SDK)
include(CMakePackageConfigHelpers)

set(PJ_PACKAGE_VERSION "0.4.1")
set(PJ_PACKAGE_VERSION "0.4.2")
set(PJ_PACKAGE_CMAKE_DIR ${CMAKE_INSTALL_LIBDIR}/cmake/plotjuggler_core)

install(EXPORT plotjuggler_coreTargets
Expand Down
16 changes: 14 additions & 2 deletions conanfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
plugin_sdk — umbrella for plugin authors (base + dialog SDK + parser SDK)
plugin_host — umbrella for host loaders (data_source/parser/toolbox/dialog)

A consuming Conan recipe declares e.g. `plotjuggler_core/0.4.1` and then:
A consuming Conan recipe declares e.g. `plotjuggler_core/0.4.2` and then:

find_package(plotjuggler_core REQUIRED COMPONENTS plugin_sdk)
target_link_libraries(my_plugin PRIVATE plotjuggler_core::plugin_sdk)
Expand All @@ -27,7 +27,7 @@

class PlotjugglerCoreConan(ConanFile):
name = "plotjuggler_core"
version = "0.4.1"
version = "0.4.2"
# Apache-2.0 covers pj_base + pj_plugins (the plugin-facing SDK);
# MPL-2.0 covers pj_datastore (the storage engine). See LICENSE.
license = "Apache-2.0 AND MPL-2.0"
Expand Down Expand Up @@ -98,6 +98,18 @@ def requirements(self):
transitive_libs=False,
)

# fast_float backs the floating-point branch of PJ::parseNumber. It
# is header-only and stays private: never appears in any public
# pj_base header, never propagated to downstream consumers.
self.requires(
"fast_float/8.1.0",
headers=True,
libs=False,
visible=False,
transitive_headers=False,
transitive_libs=False,
)

if self.options.with_datastore:
# tsl-robin-map is header-only; nanoarrow is in public headers.
self.requires("tsl-robin-map/1.4.0", transitive_headers=True)
Expand Down
10 changes: 10 additions & 0 deletions docs/cpp_design_recommendations.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,16 @@ See [Section 9](#9-error-handling) for the full policy and examples.

The project uses `int64_t` nanoseconds for timestamps. Use `std::chrono` types for durations when type safety is desired.

### String → Number Parsing

Use `PJ::parseNumber<T>(std::string_view)` from `pj_base/number_parse.hpp` for all string-to-number conversion. Returns `std::optional<T>` — `nullopt` unless the entire input is a valid, in-range value (empty, trailing characters, and overflow all yield `nullopt`).

| Pattern | Use |
|---|---|
| Whole-string parse, fallible | `PJ::parseNumber<T>(text)` |

**Do not** call `std::strtod`, `std::strtof`, `std::strtol`, `std::stoi`, `std::stod`, `std::atoi`, `std::atof`, or `QString::toDouble`/`toInt` directly in plotjuggler_core code. The standard `std::strto*` family respects `LC_NUMERIC` (a `de_DE` user silently parses `"1.5"` as 1); `parseNumber` is locale-independent (backed by `fast_float` as a private dependency, hidden from the public ABI).

### Hashing

Use `std::hash<T>` for custom hash support. Specialize `std::hash` in the same namespace as the type.
Expand Down
8 changes: 8 additions & 0 deletions pj_base/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ add_library(pj_base STATIC
src/builtin/point_cloud_codec.cpp
src/builtin/scene_entities_codec.cpp
src/builtin/video_frame_codec.cpp
src/number_parse.cpp
src/type_tree.cpp
)
target_include_directories(pj_base PUBLIC
Expand All @@ -27,6 +28,12 @@ target_compile_features(pj_base PUBLIC cxx_std_20)
target_compile_options(pj_base PRIVATE
${PJ_WARNING_FLAGS} ${PJ_SANITIZER_FLAGS}
)
# fast_float is an implementation detail of PJ::parseNumber. BUILD_INTERFACE
# scoping keeps it off the installed plotjuggler_coreTargets file so
# downstream consumers never inherit a fast_float include path or link req.
target_link_libraries(pj_base PRIVATE
$<BUILD_INTERFACE:FastFloat::fast_float>
)
set_target_properties(pj_base PROPERTIES
POSITION_INDEPENDENT_CODE ON
EXPORT_NAME base
Expand Down Expand Up @@ -62,6 +69,7 @@ if(PJ_BUILD_TESTS)
tests/type_tree_test.cpp
tests/span_test.cpp
tests/expected_test.cpp
tests/number_parse_test.cpp
tests/plugin_data_api_test.cpp
tests/settings_store_host_test.cpp
tests/data_source_protocol_test.cpp
Expand Down
44 changes: 21 additions & 23 deletions pj_base/include/pj_base/number_parse.hpp
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
#pragma once

#include <cerrno>
#include <charconv>
#include <cstdlib>
#include <optional>
#include <string>
#include <string_view>
#include <system_error>
#include <type_traits>

namespace PJ {

namespace detail {
// Floating-point parsing is defined out-of-line in number_parse.cpp and
// backed by fast_float. The implementation is a private dependency of
// pj_base — no fast_float header is visible from this file or any other
// installed pj_base header. Each helper returns nullopt unless the *entire*
// input is a valid, in-range value (no trailing characters, no overflow).
[[nodiscard]] std::optional<float> parseFloatImpl(std::string_view text);
[[nodiscard]] std::optional<double> parseDoubleImpl(std::string_view text);
[[nodiscard]] std::optional<long double> parseLongDoubleImpl(std::string_view text);
} // namespace detail

/// Parse the *entire* @p text as a number of type T, returning nullopt unless
/// the whole string is a valid, in-range value (empty input, trailing
/// characters, or overflow all yield nullopt).
///
/// Use this instead of std::from_chars when T may be floating-point: Apple
/// Clang's libc++ does not implement the std::from_chars floating-point
/// overloads, so those are routed through std::strto* here. Integral types go
/// straight to std::from_chars on every toolchain.
/// Floating-point parsing is locale-independent: "1.5" always parses as 1.5
/// regardless of the active C/C++ locale. (std::strto* respects LC_NUMERIC
/// and silently mis-parses numbers under non-C locales — fast_float backs
/// this branch precisely to remove that footgun.)
///
/// Integer parsing uses std::from_chars in base 10.
template <typename T>
[[nodiscard]] std::optional<T> parseNumber(std::string_view text) {
static_assert(
Expand All @@ -28,25 +38,13 @@ template <typename T>
return std::nullopt;
}
if constexpr (std::is_floating_point_v<T>) {
// std::strto* needs a null-terminated buffer and @p text may be a
// non-terminated view, so copy it. Number strings are short — the
// allocation is negligible for the config/settings paths this serves.
const std::string buffer(text);
const char* begin = buffer.c_str();
char* last = nullptr;
errno = 0;
T out{};
if constexpr (std::is_same_v<T, float>) {
out = std::strtof(begin, &last);
} else if constexpr (std::is_same_v<T, long double>) {
out = std::strtold(begin, &last);
return detail::parseFloatImpl(text);
} else if constexpr (std::is_same_v<T, double>) {
return detail::parseDoubleImpl(text);
} else {
out = std::strtod(begin, &last);
return detail::parseLongDoubleImpl(text);
}
if (errno != 0 || last != begin + buffer.size()) {
return std::nullopt;
}
return out;
} else {
T out{};
const char* begin = text.data();
Expand Down
50 changes: 50 additions & 0 deletions pj_base/src/number_parse.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#include "pj_base/number_parse.hpp"

#include <cerrno>
#include <cstdlib>
#include <string>
#include <system_error>

#include "fast_float/fast_float.h"

namespace PJ::detail {

namespace {
template <typename T>
[[nodiscard]] std::optional<T> parseWithFastFloat(std::string_view text) {
T out{};
const char* begin = text.data();
const char* end = begin + text.size();
const auto result = fast_float::from_chars(begin, end, out);
if (result.ec != std::errc{} || result.ptr != end) {
return std::nullopt;
}
return out;
}
} // namespace

std::optional<float> parseFloatImpl(std::string_view text) {
return parseWithFastFloat<float>(text);
}

std::optional<double> parseDoubleImpl(std::string_view text) {
return parseWithFastFloat<double>(text);
}

// fast_float does not implement long double, so this branch falls back to
// std::strtold. strtold is locale-dependent (LC_NUMERIC), but long double
// has no in-tree call sites today; the fallback exists only to keep the
// template's compile-time surface complete.
std::optional<long double> parseLongDoubleImpl(std::string_view text) {
const std::string buffer(text);
const char* begin = buffer.c_str();
char* last = nullptr;
errno = 0;
const long double out = std::strtold(begin, &last);
if (errno != 0 || last != begin + buffer.size()) {
return std::nullopt;
}
return out;
}

} // namespace PJ::detail
103 changes: 103 additions & 0 deletions pj_base/tests/number_parse_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright 2026 Davide Faconti
// SPDX-License-Identifier: Apache-2.0

#include "pj_base/number_parse.hpp"

#include <gtest/gtest.h>

#include <clocale>
#include <cmath>
#include <cstdint>
#include <limits>
#include <string>

namespace PJ {
namespace {

TEST(ParseNumber, IntegerSuccess) {
EXPECT_EQ(parseNumber<int>("0"), 0);
EXPECT_EQ(parseNumber<int>("42"), 42);
EXPECT_EQ(parseNumber<int>("-7"), -7);
EXPECT_EQ(parseNumber<std::int32_t>("-2147483648"), std::numeric_limits<std::int32_t>::min());
EXPECT_EQ(parseNumber<std::int32_t>("2147483647"), std::numeric_limits<std::int32_t>::max());
EXPECT_EQ(parseNumber<std::uint64_t>("18446744073709551615"), std::numeric_limits<std::uint64_t>::max());
}

TEST(ParseNumber, IntegerFailure) {
EXPECT_FALSE(parseNumber<int>("").has_value());
EXPECT_FALSE(parseNumber<int>("hello").has_value());
EXPECT_FALSE(parseNumber<int>("42xyz").has_value());
EXPECT_FALSE(parseNumber<int>(" 42").has_value());
EXPECT_FALSE(parseNumber<int>("42 ").has_value());
EXPECT_FALSE(parseNumber<int>("-").has_value());
// std::from_chars rejects a leading '+' for integers (it accepts only '-').
EXPECT_FALSE(parseNumber<int>("+13").has_value());
// Floats are not integers.
EXPECT_FALSE(parseNumber<int>("1.5").has_value());
// Out-of-range.
EXPECT_FALSE(parseNumber<std::int8_t>("200").has_value());
EXPECT_FALSE(parseNumber<std::uint32_t>("-1").has_value());
}

TEST(ParseNumber, FloatSuccess) {
EXPECT_DOUBLE_EQ(*parseNumber<double>("0"), 0.0);
EXPECT_DOUBLE_EQ(*parseNumber<double>("1.5"), 1.5);
EXPECT_DOUBLE_EQ(*parseNumber<double>("-2.5e3"), -2500.0);
EXPECT_DOUBLE_EQ(*parseNumber<double>(".5"), 0.5);
EXPECT_DOUBLE_EQ(*parseNumber<double>("5."), 5.0);
EXPECT_FLOAT_EQ(*parseNumber<float>("3.14"), 3.14f);
EXPECT_TRUE(std::isinf(*parseNumber<double>("inf")));
EXPECT_TRUE(std::isnan(*parseNumber<double>("nan")));
}

TEST(ParseNumber, FloatFailure) {
EXPECT_FALSE(parseNumber<double>("").has_value());
EXPECT_FALSE(parseNumber<double>("hello").has_value());
EXPECT_FALSE(parseNumber<double>("1.5xyz").has_value());
EXPECT_FALSE(parseNumber<double>(" 1.5").has_value());
EXPECT_FALSE(parseNumber<double>("1.5 ").has_value());
// Comma is NOT a valid decimal mark — locale-independent.
EXPECT_FALSE(parseNumber<double>("1,5").has_value());
}

TEST(ParseNumber, FloatLocaleIndependent) {
// Regression: std::strto* respects LC_NUMERIC and would parse "1.5" as
// 1 under de_DE.UTF-8. parseNumber<double> is locale-independent because
// fast_float backs the float path.
const char* prev = std::setlocale(LC_NUMERIC, nullptr);
const std::string saved = prev != nullptr ? std::string(prev) : std::string("C");
// Try common spellings; if none of them are installed, the test still
// passes because the assertion below should hold under "C" too — but
// the locale regression we care about only triggers when a non-C
// LC_NUMERIC is actually active.
const char* candidates[] = {"de_DE.UTF-8", "de_DE.utf8", "de_DE", "German_Germany.1252"};
bool changed = false;
for (const char* loc : candidates) {
if (std::setlocale(LC_NUMERIC, loc) != nullptr) {
changed = true;
break;
}
}
EXPECT_DOUBLE_EQ(*parseNumber<double>("1.5"), 1.5);
EXPECT_DOUBLE_EQ(*parseNumber<double>("-2.5e3"), -2500.0);
EXPECT_FALSE(parseNumber<double>("1,5").has_value());
std::setlocale(LC_NUMERIC, saved.c_str());
// We don't fail the test if no de_DE locale was installed — but we do
// want to be loud about it in CI so the regression check is meaningful.
if (!changed) {
GTEST_LOG_(WARNING) << "No German locale installed; locale regression check ran under C only.";
}
}

TEST(ParseNumber, LongDoubleSmoke) {
// long double falls back to strtold (fast_float does not implement it).
// Still must obey the whole-string contract.
auto v = parseNumber<long double>("1.5");
ASSERT_TRUE(v.has_value());
EXPECT_NEAR(static_cast<double>(*v), 1.5, 1e-9);
EXPECT_FALSE(parseNumber<long double>("1.5xyz").has_value());
EXPECT_FALSE(parseNumber<long double>("").has_value());
}

} // namespace
} // namespace PJ
10 changes: 6 additions & 4 deletions pj_datastore/examples/parquet_import.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <string>
#include <string_view>
#include <variant>
#include <vector>

#include "pj_base/number_parse.hpp"
#include "pj_base/span.hpp"
#include "pj_base/types.hpp"
#include "pj_datastore/arrow_import.hpp"
Expand Down Expand Up @@ -227,10 +227,12 @@ int main(int argc, char* argv[]) {
const std::string path = argv[1];
uint32_t chunk_rows = 8192;
if (argc >= 3) {
chunk_rows = static_cast<uint32_t>(std::atoi(argv[2]));
if (chunk_rows == 0) {
chunk_rows = 8192;
const auto parsed = PJ::parseNumber<uint32_t>(argv[2]);
if (!parsed.has_value() || *parsed == 0) {
std::cerr << "Invalid chunk_rows value: " << argv[2] << " (expected a positive integer)\n";
return 1;
}
chunk_rows = *parsed;
}

// ── 1. Open Parquet file ──────────────────────────────────────────────────
Expand Down
Loading
Loading