From e651ae1575a28ae8694aae690680a722827228c7 Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Wed, 17 Jun 2026 18:57:54 -0500 Subject: [PATCH] feat(bindings): add modern C++ interface for iKv --- .gitea/workflows/bindings.yml | 56 +++++ .gitignore | 132 +++++++++--- .gitmodules | 3 + CMakeLists.txt | 52 +++++ README.md | 309 ++++++++++++++++++++++++++- cmake/ikvxxConfig.cmake.in | 5 + include/ikvxx/ikvxx.hpp | 113 ++++++++++ src/value.cpp | 254 +++++++++++++++++++++++ tests/run.bat | 48 +++++ tests/value_tests.cpp | 381 ++++++++++++++++++++++++++++++++++ third_party/iKv | 1 + 11 files changed, 1325 insertions(+), 29 deletions(-) create mode 100644 .gitea/workflows/bindings.yml create mode 100644 .gitmodules create mode 100644 CMakeLists.txt create mode 100644 cmake/ikvxxConfig.cmake.in create mode 100644 include/ikvxx/ikvxx.hpp create mode 100644 src/value.cpp create mode 100644 tests/run.bat create mode 100644 tests/value_tests.cpp create mode 160000 third_party/iKv diff --git a/.gitea/workflows/bindings.yml b/.gitea/workflows/bindings.yml new file mode 100644 index 0000000..0d02bac --- /dev/null +++ b/.gitea/workflows/bindings.yml @@ -0,0 +1,56 @@ +name: C++ bindings + +on: + push: + pull_request: + +jobs: + test: + name: ${{ matrix.compiler }} / ${{ matrix.build_type }} + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - compiler: gcc + cc: gcc + cxx: g++ + build_type: Debug + - compiler: gcc + cc: gcc + cxx: g++ + build_type: Release + - compiler: clang + cc: clang + cxx: clang++ + build_type: Debug + - compiler: clang + cc: clang + cxx: clang++ + build_type: Release + + steps: + - name: Checkout with submodules + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Configure + env: + CC: ${{ matrix.cc }} + CXX: ${{ matrix.cxx }} + run: >- + cmake -S . -B build + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} + -DIKVXX_BUILD_TESTS=ON + -DIKVXX_INSTALL=ON + + - name: Build + run: cmake --build build --parallel 2 + + - name: Test bindings + run: ctest --test-dir build --build-config ${{ matrix.build_type }} --output-on-failure + + - name: Verify installation + run: cmake --install build --prefix "${{ runner.temp }}/ikvxx-install" diff --git a/.gitignore b/.gitignore index e257658..c566834 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,110 @@ -# ---> C++ -# Prerequisites -*.d +# Build and installation trees +/build/ +/build-*/ +/cmake-build-*/ +/out/ +/dist/ +/install/ -# Compiled Object files -*.slo +# CMake-generated files from accidental in-source builds +/CMakeCache.txt +/CMakeFiles/ +/CTestTestfile.cmake +/DartConfiguration.tcl +/Makefile +/Testing/ +/_deps/ +/cmake_install.cmake +/compile_commands.json +/install_manifest.txt + +# Ninja and Make dependency data +.ninja_deps +.ninja_log +build.ninja +rules.ninja +*.d +*.dep + +# Compiler and linker output +*.a +*.app +*.dll +*.dylib +*.exe +*.exp +*.gch +*.idb +*.ilk +*.lai +*.lib *.lo +*.mod *.o *.obj - -# Precompiled Headers -*.gch -*.pch - -# Compiled Dynamic libraries -*.so -*.dylib -*.dll - -# Fortran module files -*.mod -*.smod - -# Compiled Static libraries -*.lai -*.la -*.a -*.lib - -# Executables -*.exe *.out -*.app +*.pch +*.pdb +*.slo +*.smod +*.so +*.so.* +# Test, benchmark, coverage, and profiling output +/ikvxx-test-* +*.gcda +*.gcno +*.gcov +*.profdata +*.profraw +*.trace +coverage/ +coverage.xml +lcov.info + +# Package-manager output +/.cache/ +/.conan/ +/vcpkg_installed/ +conan.lock +conanbuildinfo.* + +# IDE and editor metadata +/.idea/ +/.vs/ +/.vscode/ +/.fleet/ +*.code-workspace +*.sln +*.suo +*.user +*.userosscache +*.vcxproj +*.vcxproj.filters +*.vcxproj.user +*.swp +*.swo +*~ + +# Local CMake presets and environment files +CMakeUserPresets.json +.env +.env.* +!.env.example + +# Logs and crash dumps +*.core +*.dmp +*.log + +# Operating-system metadata +.DS_Store +.AppleDouble +.LSOverride +Icon? +Thumbs.db +Desktop.ini +$RECYCLE.BIN/ +.Spotlight-V100/ +.Trashes/ +._* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1b025a8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third_party/iKv"] + path = third_party/iKv + url = https://dock-it.dev/iDENTITY-Technology/iKv.git diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..6bb2c68 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,52 @@ +cmake_minimum_required(VERSION 3.16) + +project(ikvxx VERSION 0.1.0 LANGUAGES C CXX) + +option(IKVXX_BUILD_TESTS "Build iKvxx unit tests" ON) +option(IKVXX_INSTALL "Install iKvxx" ON) + +set(IKV_BUILD_DEMOS OFF CACHE BOOL "" FORCE) +set(IKV_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(IKV_INSTALL OFF CACHE BOOL "" FORCE) +add_subdirectory(third_party/iKv) +set_target_properties(ikv PROPERTIES + PUBLIC_HEADER "${CMAKE_CURRENT_SOURCE_DIR}/third_party/iKv/include/ikv.h") + +add_library(ikvxx src/value.cpp) +add_library(ikvxx::ikvxx ALIAS ikvxx) +target_compile_features(ikvxx PUBLIC cxx_std_17) +target_include_directories(ikvxx PUBLIC + $ + $) +target_link_libraries(ikvxx PUBLIC ikv::ikv) + +if(MSVC) + target_compile_options(ikvxx PRIVATE /W4 /permissive-) +else() + target_compile_options(ikvxx PRIVATE -Wall -Wextra -Wpedantic) +endif() + +if(IKVXX_BUILD_TESTS) + include(CTest) + add_executable(ikvxx_tests tests/value_tests.cpp) + target_link_libraries(ikvxx_tests PRIVATE ikvxx::ikvxx) + target_compile_features(ikvxx_tests PRIVATE cxx_std_17) + add_test(NAME ikvxx.value COMMAND ikvxx_tests) +endif() + +if(IKVXX_INSTALL) + include(GNUInstallDirs) + include(CMakePackageConfigHelpers) + install(TARGETS ikv ikvxx EXPORT ikvxxTargets) + install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + install(EXPORT ikvxxTargets NAMESPACE ikvxx:: DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ikvxx) + configure_package_config_file(cmake/ikvxxConfig.cmake.in + "${CMAKE_CURRENT_BINARY_DIR}/ikvxxConfig.cmake" + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ikvxx) + write_basic_package_version_file("${CMAKE_CURRENT_BINARY_DIR}/ikvxxConfigVersion.cmake" + VERSION ${PROJECT_VERSION} COMPATIBILITY SameMajorVersion) + install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/ikvxxConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/ikvxxConfigVersion.cmake" + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ikvxx) +endif() diff --git a/README.md b/README.md index c929428..42db8c6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,310 @@ # iKvxx -C++ bindings for iKv files \ No newline at end of file +iKvxx is a modern C++17 binding for [iKv](https://dock-it.dev/iDENTITY-Technology/iKv). +It provides automatic resource management, exceptions, type-safe accessors, and a +compact `Value` API modeled after JsonCpp while preserving iKv text and binary format +compatibility. + +## What it does + +- Parse iKv text from strings or files +- Parse iKv binary data from memory or files +- Build object and array trees with a JsonCpp-style API +- Read and write both `iKv1` and `iKv2` +- Write the current `iKv2` format by default +- Manage the underlying C tree automatically with RAII +- Report parse, type, bounds, allocation, and I/O failures with exceptions +- Provide a reusable `ikvxx::ikvxx` CMake target and installable package + +## Syntax preview + +```ikv +ikv2 "player_save" +{ + "title" "iKvxx demo" + "version" 2 + "player" { + "name" "jondoe" + "alive" true + "speed" 12.5 + } + "inventory" [ + "wrench" + "battery" + "map" + ] +} +``` + +## Library example + +```cpp +#include + +int main() +{ + ikv::Value root(ikv::objectValue, "player_save"); + root["title"] = "iKvxx demo"; + root["version"] = 2; + + auto player = root.makeObject("player"); + player["name"] = "jondoe"; + player["alive"] = true; + player["speed"] = 12.5; + + auto inventory = root.makeArray("inventory", ikv::stringValue); + inventory.append("wrench"); + inventory.append("battery"); + inventory.append("map"); + + root.write("player_save.ikv"); +} +``` + +The root and all child handles release their underlying iKv tree automatically. No +manual `ikv_free` call is required. + +## Reading values + +Use `load` for text files and the familiar `operator[]` syntax for object and array +access: + +```cpp +#include +#include + +int main() +{ + auto root = ikv::Value::load("player_save.ikv"); + + std::cout << root["title"].asString() << '\n'; + std::cout << root["version"].asInt() << '\n'; + std::cout << root["player"]["speed"].asDouble() << '\n'; + + auto inventory = root["inventory"]; + for (ikv::Value::ArrayIndex i = 0; i < inventory.size(); ++i) + std::cout << inventory[i].asString() << '\n'; +} +``` + +Missing object members produce a null `Value`. Invalid conversions and out-of-range +array indexes throw a standard exception: + +```cpp +if (!root.isMember("difficulty")) + root["difficulty"] = "normal"; + +if (root["optional"].isNull()) + root["optional"] = false; + +try +{ + int value = root["title"].asInt(); +} +catch (const ikv::Error& error) +{ + // "title" is a string, not an integer. +} +``` + +## Parsing text + +`Value::parse` auto-detects `iKv1` and `iKv2` input: + +```cpp +const std::string text = R"( +ikv2 "settings" +{ + "fullscreen" true + "width" 1920 + "height" 1080 +} +)"; + +auto settings = ikv::Value::parse(text); +bool fullscreen = settings["fullscreen"].asBool(); +``` + +## Arrays + +Arrays may be untyped or restricted to one iKv value type. Typed arrays reject values +of the wrong type: + +```cpp +ikv::Value root; + +auto scores = root.makeArray("scores", ikv::intValue); +scores.append(10); +scores.append(25); +scores.append(100); + +auto mixed = root.makeArray("mixed"); +mixed.append("ready"); +mixed.append(42); +mixed.append(true); + +auto players = root.makeArray("players", ikv::objectValue); +auto first = players.appendObject(); +first["name"] = "Ada"; +first["score"] = 100; +``` + +## Binary data + +Binary iKv can be written to a file or encoded directly into a byte vector: + +```cpp +ikv::Value root; +root["answer"] = 42; + +root.writeBinary("data.ikvb"); +auto from_file = ikv::Value::loadBinary("data.ikvb"); + +std::vector bytes = root.toBinary(); +auto from_memory = ikv::Value::fromBinary(bytes.data(), bytes.size()); +``` + +Pass an explicit version when compatibility with older data is required: + +```cpp +root.write("legacy.ikv", ikv::Version::v1); +root.writeBinary("legacy.ikvb", ikv::Version::v1); +auto legacy_bytes = root.toBinary(ikv::Version::v1); +``` + +## Refreshing a tree + +An existing root can be updated in place from a text or binary file. Other `Value` +handles that share that root observe the refreshed data: + +```cpp +auto settings = ikv::Value::load("settings.ikv"); +auto shared = settings; + +settings.refresh("updated-settings.ikv"); +// shared now refers to the refreshed tree as well. +``` + +`refresh` is only valid on a mutable root value. If refresh fails, the existing tree +is preserved. + +## Build + +Clone with the iKv submodule and build with CMake: + +```sh +git clone --recurse-submodules https://dock-it.dev/iDENTITY-Technology/iKvxx.git +cd iKvxx +cmake -S . -B build +cmake --build build +``` + +If the repository was cloned without submodules: + +```sh +git submodule update --init --recursive +``` + +This produces the reusable static library targets: + +- `ikvxx` +- `ikvxx::ikvxx` for CMake consumers + +### CMake consumer usage + +When iKvxx is included directly in another source tree: + +```cmake +add_subdirectory(path/to/iKvxx) +target_link_libraries(your_target PRIVATE ikvxx::ikvxx) +``` + +The library can also be installed and consumed with `find_package`: + +```sh +cmake --install build --prefix /path/to/ikvxx-install +``` + +```cmake +find_package(ikvxx 0.1 REQUIRED CONFIG) +target_link_libraries(your_target PRIVATE ikvxx::ikvxx) +``` + +Point `CMAKE_PREFIX_PATH` at the selected installation prefix when it is not in a +standard system location. + +## Tests + +Build and run the binding tests with CTest: + +```sh +cmake -S . -B build -DIKVXX_BUILD_TESTS=ON +cmake --build build +ctest --test-dir build --output-on-failure +``` + +The test suite covers construction, every value type, object and array access, +assignment and append overloads, conversion failures, bounds checks, text and binary +I/O, format versions, refresh behavior, and shared ownership. Gitea Actions runs the +suite on Ubuntu with GCC and Clang in both Debug and Release configurations. + +On Windows, the complete configure, build, and test sequence can be run with: + +```bat +tests\run.bat +tests\run.bat Debug +``` + +## Project layout + +- `include/ikvxx/ikvxx.hpp`: public C++ API +- `src/value.cpp`: C++ binding implementation +- `tests/value_tests.cpp`: unit and integration tests +- `third_party/iKv`: pinned iKv Git submodule +- `cmake/ikvxxConfig.cmake.in`: installed CMake package configuration +- `.gitea/workflows/bindings.yml`: Ubuntu compiler and build matrix + +## Format behavior + +- Generic parse APIs auto-detect `iKv1` and `iKv2` +- Generic write APIs default to `iKv2` +- Text and binary files remain compatible with the underlying iKv C library +- Explicit `ikv::Version::v1` and `ikv::Version::v2` output is supported +- `iKv2` binary input retains iKv's indexed, lazy root lookup behavior + +## Public API + +The public interface is declared in +[`include/ikvxx/ikvxx.hpp`](./include/ikvxx/ikvxx.hpp). Key areas include: + +- `ikv::Value` construction and automatic lifetime management +- JsonCpp-style object lookup with `operator[]` +- Array indexing and append operations +- Runtime type inspection with `isString`, `isInt`, `isObject`, and related methods +- Typed scalar access with `asString`, `asInt`, `asInt64`, `asDouble`, and `asBool` +- Text parsing, loading, writing, and in-place refresh +- Binary memory and file I/O +- Explicit format-version selection + +## API constraints + +iKvxx intentionally exposes errors where the underlying iKv model cannot safely +provide JsonCpp behavior: + +- Roots must be objects or arrays; scalar roots are not supported by iKv +- Arrays of arrays are not supported by the current iKv C API +- Existing array elements cannot be replaced in place +- Copied `Value` handles share ownership of the same tree +- Structural assignment between two `Value` objects is disabled because the public C + API cannot clone arbitrary subtrees +- A child handle must not be retained across replacement of that child or a root + refresh; acquire the child again after modifying the structure + +## License + +This repository ships with the license text in [`LICENSE`](./LICENSE): + +- Creative Commons Attribution-ShareAlike 4.0 International (`CC BY-SA 4.0`) + +The iKv submodule includes its own license file at +[`third_party/iKv/LICENSE`](./third_party/iKv/LICENSE). diff --git a/cmake/ikvxxConfig.cmake.in b/cmake/ikvxxConfig.cmake.in new file mode 100644 index 0000000..d1d08c7 --- /dev/null +++ b/cmake/ikvxxConfig.cmake.in @@ -0,0 +1,5 @@ +@PACKAGE_INIT@ + +include("${CMAKE_CURRENT_LIST_DIR}/ikvxxTargets.cmake") + +check_required_components(ikvxx) diff --git a/include/ikvxx/ikvxx.hpp b/include/ikvxx/ikvxx.hpp new file mode 100644 index 0000000..db8ebf8 --- /dev/null +++ b/include/ikvxx/ikvxx.hpp @@ -0,0 +1,113 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +struct ikv_node_t; + +namespace ikv { + +enum ValueType { + nullValue = 0, + stringValue, + intValue, + realValue, + booleanValue, + objectValue, + arrayValue +}; + +enum class Version : unsigned { v1 = 1, v2 = 2 }; + +class Error : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +class Value { +public: + using ArrayIndex = std::uint32_t; + + explicit Value(ValueType type = objectValue, std::string root_name = "root"); + Value(const Value&) = default; + Value& operator=(const Value&) = delete; + + static Value parse(const std::string& text); + static Value load(const std::string& path); + static Value fromBinary(const void* data, std::size_t size); + static Value loadBinary(const std::string& path); + + ValueType type() const noexcept; + bool isNull() const noexcept; + bool isString() const noexcept; + bool isInt() const noexcept; + bool isDouble() const noexcept; + bool isBool() const noexcept; + bool isObject() const noexcept; + bool isArray() const noexcept; + bool empty() const; + std::size_t size() const; + std::string name() const; + + bool isMember(const std::string& key) const; + Value operator[](const std::string& key); + Value operator[](const char* key); + Value operator[](const std::string& key) const; + Value operator[](const char* key) const; + Value operator[](ArrayIndex index); + Value operator[](int index); + Value operator[](ArrayIndex index) const; + Value operator[](int index) const; + Value get(const std::string& key, const Value& fallback) const; + + Value& operator=(const std::string& value); + Value& operator=(const char* value); + Value& operator=(std::int64_t value); + Value& operator=(int value); + Value& operator=(double value); + Value& operator=(bool value); + + Value makeObject(const std::string& key); + Value makeArray(const std::string& key, ValueType element_type = nullValue); + Value append(const std::string& value); + Value append(const char* value); + Value append(std::int64_t value); + Value append(int value); + Value append(double value); + Value append(bool value); + Value appendObject(); + + std::string asString() const; + std::int64_t asInt64() const; + int asInt() const; + double asDouble() const; + bool asBool() const; + + void write(const std::string& path, Version version = Version::v2) const; + void writeBinary(const std::string& path, Version version = Version::v2) const; + std::vector toBinary(Version version = Version::v2) const; + void refresh(const std::string& path); + +private: + struct Deleter { void operator()(ikv_node_t* node) const noexcept; }; + using Owner = std::shared_ptr; + + Value(Owner owner, ikv_node_t* node, ikv_node_t* parent, std::string key, bool read_only = false); + static Value adopt(ikv_node_t* node); + void require(ValueType expected, const char* operation) const; + void requireMutable(const char* operation) const; + void requireObjectParent(const char* operation) const; + Value appended(std::size_t old_size); + + Owner owner_; + ikv_node_t* node_ = nullptr; + ikv_node_t* parent_ = nullptr; + std::string key_; + bool read_only_ = false; +}; + +} // namespace ikv diff --git a/src/value.cpp b/src/value.cpp new file mode 100644 index 0000000..7407c36 --- /dev/null +++ b/src/value.cpp @@ -0,0 +1,254 @@ +#include "ikvxx/ikvxx.hpp" + +extern "C" { +#include "ikv.h" +} + +#include +#include +#include + +namespace ikv { +namespace { + +ikv_version_t nativeVersion(Version version) { + return version == Version::v1 ? IKV_VERSION_1 : IKV_VERSION_2; +} + +ikv_type_t nativeType(ValueType type) { + switch (type) { + case stringValue: return IKV_STRING; + case intValue: return IKV_INT; + case realValue: return IKV_FLOAT; + case booleanValue: return IKV_BOOL; + case objectValue: return IKV_OBJECT; + case arrayValue: return IKV_ARRAY; + default: return IKV_NULL; + } +} + +ValueType publicType(ikv_type_t type) noexcept { + switch (type) { + case IKV_STRING: return stringValue; + case IKV_INT: return intValue; + case IKV_FLOAT: return realValue; + case IKV_BOOL: return booleanValue; + case IKV_OBJECT: return objectValue; + case IKV_ARRAY: return arrayValue; + default: return nullValue; + } +} + +} // namespace + +void Value::Deleter::operator()(ikv_node_t* node) const noexcept { ikv_free(node); } + +Value::Value(ValueType type, std::string root_name) { + ikv_node_t* node = nullptr; + if (type == objectValue) node = ikv_create_object(root_name.c_str()); + else if (type == arrayValue) node = ikv_create_array(root_name.c_str(), IKV_NULL); + else throw Error("iKv roots must be objects or arrays"); + if (!node) throw Error("failed to allocate iKv root"); + owner_.reset(node, Deleter{}); + node_ = node; +} + +Value::Value(Owner owner, ikv_node_t* node, ikv_node_t* parent, std::string key, bool read_only) + : owner_(std::move(owner)), node_(node), parent_(parent), key_(std::move(key)), read_only_(read_only) {} + +Value Value::adopt(ikv_node_t* node) { + if (!node) throw Error("failed to parse iKv data"); + Owner owner(node, Deleter{}); + return Value(owner, node, nullptr, {}); +} + +Value Value::parse(const std::string& text) { return adopt(ikv_parse_string(text.c_str())); } +Value Value::load(const std::string& path) { return adopt(ikv_parse_file(path.c_str())); } +Value Value::fromBinary(const void* data, std::size_t size) { + if (!data && size != 0) throw Error("binary input is null"); + return adopt(ikvb_parse_memory(data, size)); +} +Value Value::loadBinary(const std::string& path) { return adopt(ikvb_parse_file(path.c_str())); } + +ValueType Value::type() const noexcept { return node_ ? publicType(ikv_node_type(node_)) : nullValue; } +bool Value::isNull() const noexcept { return node_ == nullptr; } +bool Value::isString() const noexcept { return type() == stringValue; } +bool Value::isInt() const noexcept { return type() == intValue; } +bool Value::isDouble() const noexcept { return type() == realValue; } +bool Value::isBool() const noexcept { return type() == booleanValue; } +bool Value::isObject() const noexcept { return type() == objectValue; } +bool Value::isArray() const noexcept { return type() == arrayValue; } +bool Value::empty() const { return size() == 0; } + +std::size_t Value::size() const { + if (isObject()) return ikv_object_size(node_); + if (isArray()) return ikv_array_size(node_); + return 0; +} + +std::string Value::name() const { + const char* key = node_ ? ikv_node_key(node_) : nullptr; + return key ? key : ""; +} + +bool Value::isMember(const std::string& key) const { + return isObject() && ikv_object_get(node_, key.c_str()) != nullptr; +} + +Value Value::operator[](const std::string& key) { + require(objectValue, "object lookup"); + return Value(owner_, ikv_object_get(node_, key.c_str()), node_, key); +} + +Value Value::operator[](const char* key) { + if (!key) throw Error("object key is null"); + return (*this)[std::string(key)]; +} + +Value Value::operator[](const std::string& key) const { + require(objectValue, "object lookup"); + return Value(owner_, ikv_object_get(node_, key.c_str()), nullptr, {}, true); +} + +Value Value::operator[](const char* key) const { + if (!key) throw Error("object key is null"); + return (*this)[std::string(key)]; +} + +Value Value::operator[](ArrayIndex index) { + require(arrayValue, "array lookup"); + if (index >= ikv_array_size(node_)) throw std::out_of_range("iKv array index out of range"); + return Value(owner_, ikv_array_get(node_, index), node_, {}); +} + +Value Value::operator[](int index) { + if (index < 0) throw std::out_of_range("iKv array index out of range"); + return (*this)[static_cast(index)]; +} + +Value Value::operator[](ArrayIndex index) const { + require(arrayValue, "array lookup"); + if (index >= ikv_array_size(node_)) throw std::out_of_range("iKv array index out of range"); + return Value(owner_, ikv_array_get(node_, index), node_, {}, true); +} + +Value Value::operator[](int index) const { + if (index < 0) throw std::out_of_range("iKv array index out of range"); + return (*this)[static_cast(index)]; +} + +Value Value::get(const std::string& key, const Value& fallback) const { + require(objectValue, "object lookup"); + ikv_node_t* child = ikv_object_get(node_, key.c_str()); + return child ? Value(owner_, child, nullptr, {}, true) : fallback; +} + +void Value::require(ValueType expected, const char* operation) const { + if (!node_ || type() != expected) throw Error(std::string(operation) + ": incompatible value type"); +} + +void Value::requireMutable(const char* operation) const { + if (read_only_) throw Error(std::string(operation) + ": value is read-only"); +} + +void Value::requireObjectParent(const char* operation) const { + requireMutable(operation); + if (!parent_ || ikv_node_type(parent_) != IKV_OBJECT || key_.empty()) + throw Error(std::string(operation) + ": assignment requires an object member"); +} + +Value& Value::operator=(const std::string& value) { + requireObjectParent("string assignment"); + ikv_object_set_string(parent_, key_.c_str(), value.c_str()); + node_ = ikv_object_get(parent_, key_.c_str()); + if (!node_) throw Error("string assignment failed"); + return *this; +} +Value& Value::operator=(const char* value) { + if (!value) throw Error("string value is null"); + return *this = std::string(value); +} +Value& Value::operator=(std::int64_t value) { + requireObjectParent("integer assignment"); + ikv_object_set_int(parent_, key_.c_str(), value); + node_ = ikv_object_get(parent_, key_.c_str()); + if (!node_) throw Error("integer assignment failed"); + return *this; +} +Value& Value::operator=(int value) { return *this = static_cast(value); } +Value& Value::operator=(double value) { + requireObjectParent("real assignment"); + ikv_object_set_float(parent_, key_.c_str(), value); + node_ = ikv_object_get(parent_, key_.c_str()); + if (!node_) throw Error("real assignment failed"); + return *this; +} +Value& Value::operator=(bool value) { + requireObjectParent("boolean assignment"); + ikv_object_set_bool(parent_, key_.c_str(), value); + node_ = ikv_object_get(parent_, key_.c_str()); + if (!node_) throw Error("boolean assignment failed"); + return *this; +} + +Value Value::makeObject(const std::string& key) { + requireMutable("object creation"); + require(objectValue, "object creation"); + ikv_node_t* child = ikv_object_add_object(node_, key.c_str()); + if (!child) throw Error("failed to create object member"); + return Value(owner_, child, node_, key); +} + +Value Value::makeArray(const std::string& key, ValueType element_type) { + requireMutable("array creation"); + require(objectValue, "array creation"); + if (element_type == arrayValue) throw Error("iKv does not support arrays of arrays"); + ikv_node_t* child = ikv_object_add_array(node_, key.c_str(), nativeType(element_type)); + if (!child) throw Error("failed to create array member"); + return Value(owner_, child, node_, key); +} + +Value Value::appended(std::size_t old_size) { + if (ikv_array_size(node_) != old_size + 1) throw Error("array append rejected by its element type"); + return Value(owner_, ikv_array_get(node_, static_cast(old_size)), node_, {}); +} +Value Value::append(const std::string& value) { requireMutable("append"); require(arrayValue, "append"); auto n=size(); ikv_array_add_string(node_,value.c_str()); return appended(n); } +Value Value::append(const char* value) { if(!value) throw Error("string value is null"); return append(std::string(value)); } +Value Value::append(std::int64_t value) { requireMutable("append"); require(arrayValue, "append"); auto n=size(); ikv_array_add_int(node_,value); return appended(n); } +Value Value::append(int value) { return append(static_cast(value)); } +Value Value::append(double value) { requireMutable("append"); require(arrayValue, "append"); auto n=size(); ikv_array_add_float(node_,value); return appended(n); } +Value Value::append(bool value) { requireMutable("append"); require(arrayValue, "append"); auto n=size(); ikv_array_add_bool(node_,value); return appended(n); } +Value Value::appendObject() { requireMutable("append object"); require(arrayValue, "append object"); auto n=size(); if(!ikv_array_add_object(node_)) throw Error("object append rejected by array type"); return appended(n); } + +std::string Value::asString() const { require(stringValue, "asString"); const char* s=ikv_as_string(node_); return s?s:""; } +std::int64_t Value::asInt64() const { require(intValue, "asInt64"); return ikv_as_int(node_); } +int Value::asInt() const { + auto value = asInt64(); + if (value < std::numeric_limits::min() || value > std::numeric_limits::max()) + throw std::out_of_range("iKv integer does not fit in int"); + return static_cast(value); +} +double Value::asDouble() const { require(realValue, "asDouble"); return ikv_as_float(node_); } +bool Value::asBool() const { require(booleanValue, "asBool"); return ikv_as_bool(node_); } + +void Value::write(const std::string& path, Version version) const { + if (!node_ || !ikv_write_file_version(path.c_str(), node_, nativeVersion(version))) throw Error("failed to write iKv file: " + path); +} +void Value::writeBinary(const std::string& path, Version version) const { + if (!node_ || !ikvb_write_file_version(path.c_str(), node_, nativeVersion(version))) throw Error("failed to write binary iKv file: " + path); +} +std::vector Value::toBinary(Version version) const { + std::uint8_t* data = nullptr; std::uint32_t size = 0; + if (!node_ || !ikvb_write_memory_version(node_, &data, &size, nativeVersion(version))) throw Error("failed to encode binary iKv data"); + std::unique_ptr memory(data, &std::free); + std::vector result; + if (size != 0) result.assign(data, data + size); + return result; +} +void Value::refresh(const std::string& path) { + requireMutable("refresh"); + if (!owner_ || node_ != owner_.get()) throw Error("refresh is only valid on a root value"); + if (!ikv_refresh_from_path(node_, path.c_str())) throw Error("failed to refresh iKv root from: " + path); +} + +} // namespace ikv diff --git a/tests/run.bat b/tests/run.bat new file mode 100644 index 0000000..43f076e --- /dev/null +++ b/tests/run.bat @@ -0,0 +1,48 @@ +@echo off +setlocal + +for %%I in ("%~dp0..") do set "ROOT_DIR=%%~fI" +set "BUILD_DIR=%ROOT_DIR%\build-tests" +set "CONFIG=%~1" + +if not defined CONFIG set "CONFIG=Release" + +where cmake >nul 2>nul +if errorlevel 1 ( + echo Error: CMake was not found in PATH. + exit /b 1 +) + +where ctest >nul 2>nul +if errorlevel 1 ( + echo Error: CTest was not found in PATH. + exit /b 1 +) + +echo Configuring iKvxx tests in "%BUILD_DIR%"... +cmake -S "%ROOT_DIR%" -B "%BUILD_DIR%" ^ + -DIKVXX_BUILD_TESTS=ON ^ + -DIKVXX_INSTALL=OFF +if errorlevel 1 ( + echo Error: CMake configuration failed. + exit /b 1 +) + +echo Building iKvxx tests using the %CONFIG% configuration... +cmake --build "%BUILD_DIR%" --config "%CONFIG%" --parallel +if errorlevel 1 ( + echo Error: Test build failed. + exit /b 1 +) + +echo Running iKvxx tests... +ctest --test-dir "%BUILD_DIR%" ^ + --build-config "%CONFIG%" ^ + --output-on-failure +if errorlevel 1 ( + echo Error: One or more tests failed. + exit /b 1 +) + +echo All iKvxx tests passed. +exit /b 0 diff --git a/tests/value_tests.cpp b/tests/value_tests.cpp new file mode 100644 index 0000000..9ad8656 --- /dev/null +++ b/tests/value_tests.cpp @@ -0,0 +1,381 @@ +#include "ikvxx/ikvxx.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failures = 0; +int exception_expectations = 0; + +void check(bool condition, const char* expression, int line) { + if (!condition) { + std::cerr << "line " << line << ": CHECK(" << expression << ") failed\n"; + ++failures; + } +} + +#define CHECK(expression) check(static_cast(expression), #expression, __LINE__) + +template +void expectThrow(Function&& function) { + const int current = ++exception_expectations; + try { + function(); + std::cerr << "expected exception case " << current << " did not throw\n"; + CHECK(false); + } catch (const std::exception&) { + } +} + +std::filesystem::path testPath(const std::string& name) { + return std::filesystem::current_path() / ("ikvxx-test-" + name); +} + +struct FileCleanup { + explicit FileCleanup(std::filesystem::path value) : path(std::move(value)) {} + ~FileCleanup() { std::error_code ignored; std::filesystem::remove(path, ignored); } + std::filesystem::path path; +}; + +ikv::Value makeScalarTree() { + ikv::Value root(ikv::objectValue, "types"); + root["string"] = std::string("value"); + root["int"] = std::int64_t{-9}; + root["real"] = 2.5; + root["bool"] = true; + root.makeObject("object"); + root.makeArray("array"); + return root; +} + +void testConstructionAndTypeInspection() { + for (const std::string& name : {"root", "", "save data"}) { + ikv::Value object(ikv::objectValue, name); + CHECK(object.type() == ikv::objectValue); + CHECK(object.isObject()); + CHECK(!object.isArray()); + CHECK(object.name() == name); + CHECK(object.empty()); + CHECK(object.size() == 0); + } + + for (const std::string& name : {"items", "", "typed values"}) { + ikv::Value array(ikv::arrayValue, name); + CHECK(array.type() == ikv::arrayValue); + CHECK(array.isArray()); + CHECK(!array.isObject()); + CHECK(array.name() == name); + CHECK(array.empty()); + CHECK(array.size() == 0); + } + + expectThrow([] { ikv::Value value(ikv::stringValue); }); + expectThrow([] { ikv::Value value(ikv::intValue); }); + expectThrow([] { ikv::Value value(ikv::nullValue); }); + + auto root = makeScalarTree(); + auto missing = root["missing"]; + CHECK(missing.type() == ikv::nullValue); + CHECK(missing.isNull()); + CHECK(!root.isNull()); + CHECK(!root["array"].isNull()); + CHECK(missing.empty()); + CHECK(missing.size() == 0); + + struct ExpectedType { const char* key; ikv::ValueType type; }; + const ExpectedType expected[] = { + {"string", ikv::stringValue}, {"int", ikv::intValue}, + {"real", ikv::realValue}, {"bool", ikv::booleanValue}, + {"object", ikv::objectValue}, {"array", ikv::arrayValue} + }; + for (const auto& entry : expected) CHECK(root[entry.key].type() == entry.type); + + CHECK(root["string"].isString()); + CHECK(!root["int"].isString()); + CHECK(!root["missing"].isString()); + CHECK(root["int"].isInt()); + CHECK(!root["real"].isInt()); + CHECK(!root["missing"].isInt()); + CHECK(root["real"].isDouble()); + CHECK(!root["int"].isDouble()); + CHECK(!root["missing"].isDouble()); + CHECK(root["bool"].isBool()); + CHECK(!root["string"].isBool()); + CHECK(!root["missing"].isBool()); + CHECK(root["object"].isObject()); + CHECK(!root["array"].isObject()); + CHECK(!root["missing"].isObject()); + CHECK(root["array"].isArray()); + CHECK(!root["object"].isArray()); + CHECK(!root["missing"].isArray()); +} + +void testAssignmentsAndConversions() { + ikv::Value root; + + const std::vector strings = {"", "hello", "line\nquote\""}; + for (std::size_t i = 0; i < strings.size(); ++i) { + root["std-string-" + std::to_string(i)] = strings[i]; + CHECK(root["std-string-" + std::to_string(i)].asString() == strings[i]); + } + for (const char* value : {"alpha", "", "unicode-ish"}) { + root[std::string("c-string-") + std::to_string(root.size())] = value; + } + CHECK(root["c-string-3"].asString() == "alpha"); + CHECK(root["c-string-4"].asString().empty()); + CHECK(root["c-string-5"].asString() == "unicode-ish"); + expectThrow([&] { root["bad"] = static_cast(nullptr); }); + + const std::int64_t wide_values[] = { + std::numeric_limits::min(), 0, std::numeric_limits::max() + }; + for (int i = 0; i < 3; ++i) { + root["wide-" + std::to_string(i)] = wide_values[i]; + CHECK(root["wide-" + std::to_string(i)].asInt64() == wide_values[i]); + } + for (int value : {-17, 0, 42}) { + const auto key = "int-" + std::to_string(value); + root[key] = value; + CHECK(root[key].asInt() == value); + } + + for (double value : {-3.5, 0.0, 1.0 / 3.0}) { + const auto key = "real-" + std::to_string(root.size()); + root[key] = value; + CHECK(std::abs(root[key].asDouble() - value) < 1e-12); + } + root["bool-false"] = false; + root["bool-true"] = true; + root["bool-replaced"] = false; + root["bool-replaced"] = true; + CHECK(!root["bool-false"].asBool()); + CHECK(root["bool-true"].asBool()); + CHECK(root["bool-replaced"].asBool()); + + root["replace"] = "first"; + root["replace"] = 2; + root["replace"] = 4.5; + CHECK(root["replace"].isDouble()); + CHECK(std::abs(root["replace"].asDouble() - 4.5) < 1e-12); + + root["too-large"] = std::numeric_limits::max(); + root["too-small"] = std::numeric_limits::min(); + expectThrow([&] { (void)root["too-large"].asInt(); }); + expectThrow([&] { (void)root["too-small"].asInt(); }); + expectThrow([&] { (void)root["bool-true"].asString(); }); + expectThrow([&] { (void)root["bool-true"].asInt64(); }); + expectThrow([&] { (void)root["bool-true"].asDouble(); }); + expectThrow([&] { (void)root["replace"].asBool(); }); +} + +void testObjectsMembersAndLookupOverloads() { + ikv::Value root; + for (const std::string& key : {"first", "", "with spaces"}) { + auto object = root.makeObject(key); + object["value"] = key; + CHECK(object.isObject()); + CHECK(object.name() == key); + } + CHECK(root.isMember("first")); + CHECK(root.isMember("")); + CHECK(root.isMember("with spaces")); + CHECK(!root.isMember("missing")); + CHECK(!root["first"].isMember("missing")); + + std::string first = "first"; + CHECK(root[first][std::string("value")].asString() == "first"); + CHECK(root["with spaces"]["value"].asString() == "with spaces"); + expectThrow([&] { (void)root[static_cast(nullptr)]; }); + + const ikv::Value& const_root = root; + CHECK(const_root[first][std::string("value")].asString() == "first"); + const std::string spaced = "with spaces"; + const std::string absent = "missing"; + CHECK(const_root[spaced].isObject()); + CHECK(const_root[absent].isNull()); + CHECK(const_root["with spaces"]["value"].asString() == "with spaces"); + CHECK(const_root["missing"].isNull()); + expectThrow([&] { (void)const_root[static_cast(nullptr)]; }); + + ikv::Value fallback(ikv::objectValue, "fallback"); + CHECK(const_root.get("first", fallback).name() == "first"); + CHECK(const_root.get("missing", fallback).name() == "fallback"); + CHECK(const_root.get("", fallback).name().empty()); + + expectThrow([&] { root["scalar"] = 1; root["scalar"].makeObject("bad"); }); + expectThrow([&] { root["scalar"].makeArray("bad"); }); + const ikv::Value& read_only = root; + expectThrow([&] { read_only["first"].makeObject("bad"); }); +} + +void testArraysAndIndexOverloads() { + ikv::Value root; + auto mixed = root.makeArray("mixed"); + auto strings = root.makeArray("strings", ikv::stringValue); + auto integers = root.makeArray("integers", ikv::intValue); + auto reals = root.makeArray("reals", ikv::realValue); + auto booleans = root.makeArray("booleans", ikv::booleanValue); + auto objects = root.makeArray("objects", ikv::objectValue); + + for (const std::string& value : {"", "two", "three"}) strings.append(value); + for (const char* value : {"four", "five", "six"}) mixed.append(value); + for (std::int64_t value : {-9000000000LL, 0LL, 9000000000LL}) integers.append(value); + for (int value : {-2, 0, 7}) mixed.append(value); + for (double value : {-1.5, 0.0, 9.25}) reals.append(value); + for (bool value : {false, true, false}) booleans.append(value); + for (int i = 0; i < 3; ++i) objects.appendObject()["index"] = i; + + CHECK(strings.size() == 3); + CHECK(strings[0].asString().empty()); + CHECK(strings[1].asString() == "two"); + CHECK(strings[static_cast(2)].asString() == "three"); + CHECK(strings[static_cast(0)].asString().empty()); + CHECK(strings[static_cast(1)].asString() == "two"); + CHECK(integers[0].asInt64() == -9000000000LL); + CHECK(integers[1].asInt64() == 0); + CHECK(integers[2].asInt64() == 9000000000LL); + CHECK(std::abs(reals[0].asDouble() + 1.5) < 1e-12); + CHECK(std::abs(reals[1].asDouble()) < 1e-12); + CHECK(std::abs(reals[2].asDouble() - 9.25) < 1e-12); + CHECK(!booleans[0].asBool()); + CHECK(booleans[1].asBool()); + CHECK(!booleans[2].asBool()); + CHECK(objects[0]["index"].asInt() == 0); + CHECK(objects[1]["index"].asInt() == 1); + CHECK(objects[2]["index"].asInt() == 2); + + const ikv::Value& const_strings = strings; + CHECK(const_strings[0].asString().empty()); + CHECK(const_strings[1].asString() == "two"); + CHECK(const_strings[2].asString() == "three"); + CHECK(const_strings[static_cast(0)].asString().empty()); + CHECK(const_strings[static_cast(1)].asString() == "two"); + CHECK(const_strings[static_cast(2)].asString() == "three"); + expectThrow([&] { (void)strings[-1]; }); + expectThrow([&] { (void)strings[3]; }); + expectThrow([&] { (void)const_strings[-1]; }); + expectThrow([&] { (void)const_strings[3]; }); + + expectThrow([&] { strings.append(7); }); + expectThrow([&] { integers.append("wrong"); }); + expectThrow([&] { objects.append(false); }); + expectThrow([&] { mixed.append(static_cast(nullptr)); }); + expectThrow([&] { root.makeArray("nested", ikv::arrayValue); }); + expectThrow([&] { strings.appendObject(); }); + + const ikv::Value& read_only = root; + expectThrow([&] { read_only["mixed"].append(1); }); + expectThrow([&] { read_only["mixed"].append("value"); }); + expectThrow([&] { read_only["mixed"].appendObject(); }); +} + +void testTextParsingAndFiles() { + const std::string documents[] = { + "ikv2 \"root\"\n{\n \"name\" \"test\"\n}\n", + "ikv1 \"legacy\"\n{\n \"n\" -7\n}\n", + "ikv2 \"nested\"\n{\n \"child\" { \"ok\" true }\n}\n" + }; + CHECK(ikv::Value::parse(documents[0])["name"].asString() == "test"); + CHECK(ikv::Value::parse(documents[1])["n"].asInt() == -7); + CHECK(ikv::Value::parse(documents[2])["child"]["ok"].asBool()); + expectThrow([] { (void)ikv::Value::parse("ikv2 \"root\"\n"); }); + expectThrow([] { (void)ikv::Value::parse("not ikv"); }); + expectThrow([] { (void)ikv::Value::parse("ikv2 \"root\"\n{\n \"value\" 1\n"); }); + + const FileCleanup v1(testPath("text-v1.ikv")); + const FileCleanup v2(testPath("text-v2.ikv")); + const FileCleanup bad(testPath("text-bad.ikv")); + ikv::Value source; + source["value"] = 11; + source.write(v1.path.string(), ikv::Version::v1); + source.write(v2.path.string()); + CHECK(ikv::Value::load(v1.path.string())["value"].asInt() == 11); + CHECK(ikv::Value::load(v2.path.string())["value"].asInt() == 11); + expectThrow([&] { (void)ikv::Value::load(testPath("missing.ikv").string()); }); + expectThrow([&] { source.write(testPath("missing-dir/file.ikv").string()); }); + + { std::ofstream output(bad.path); output << "broken"; } + expectThrow([&] { (void)ikv::Value::load(bad.path.string()); }); +} + +void testBinaryMemoryAndFiles() { + ikv::Value source(ikv::objectValue, "binary"); + source["number"] = 73; + source["text"] = "payload"; + source.makeArray("flags", ikv::booleanValue).append(true); + + const auto v1 = source.toBinary(ikv::Version::v1); + const auto v2 = source.toBinary(); + const auto v2_explicit = source.toBinary(ikv::Version::v2); + CHECK(!v1.empty()); + CHECK(!v2.empty()); + CHECK(v2_explicit == v2); + CHECK(ikv::Value::fromBinary(v1.data(), v1.size())["number"].asInt() == 73); + CHECK(ikv::Value::fromBinary(v2.data(), v2.size())["text"].asString() == "payload"); + CHECK(ikv::Value::fromBinary(v2.data(), v2.size())["flags"][0].asBool()); + const std::uint8_t invalid[] = {0, 1, 2, 3, 4}; + expectThrow([&] { (void)ikv::Value::fromBinary(invalid, sizeof(invalid)); }); + expectThrow([] { (void)ikv::Value::fromBinary(nullptr, 1); }); + expectThrow([] { (void)ikv::Value::fromBinary(nullptr, 0); }); + + const FileCleanup v1_file(testPath("binary-v1.ikvb")); + const FileCleanup v2_file(testPath("binary-v2.ikvb")); + const FileCleanup bad_file(testPath("binary-bad.ikvb")); + source.writeBinary(v1_file.path.string(), ikv::Version::v1); + source.writeBinary(v2_file.path.string()); + CHECK(ikv::Value::loadBinary(v1_file.path.string())["number"].asInt() == 73); + CHECK(ikv::Value::loadBinary(v2_file.path.string())["text"].asString() == "payload"); + expectThrow([&] { source.writeBinary(testPath("missing-dir/file.ikvb").string()); }); + expectThrow([&] { (void)ikv::Value::loadBinary(testPath("missing.ikvb").string()); }); + { std::ofstream output(bad_file.path, std::ios::binary); output << "broken"; } + expectThrow([&] { (void)ikv::Value::loadBinary(bad_file.path.string()); }); +} + +void testRefreshAndOwnership() { + const FileCleanup first(testPath("refresh-first.ikv")); + const FileCleanup second(testPath("refresh-second.ikvb")); + ikv::Value text_source; + text_source["value"] = 1; + text_source.write(first.path.string()); + ikv::Value binary_source; + binary_source["value"] = 2; + binary_source.writeBinary(second.path.string()); + + ikv::Value root; + root["value"] = 0; + auto shared = root; + root.refresh(first.path.string()); + CHECK(root["value"].asInt() == 1); + CHECK(shared["value"].asInt() == 1); + root.refresh(second.path.string()); + CHECK(root["value"].asInt() == 2); + expectThrow([&] { root.refresh(testPath("missing-refresh.ikv").string()); }); + CHECK(root["value"].asInt() == 2); + + auto child = root["value"]; + expectThrow([&] { child.refresh(first.path.string()); }); + const ikv::Value& read_only = root; + expectThrow([&] { read_only["value"] = 3; }); +} + +} // namespace + +int main() { + testConstructionAndTypeInspection(); + testAssignmentsAndConversions(); + testObjectsMembersAndLookupOverloads(); + testArraysAndIndexOverloads(); + testTextParsingAndFiles(); + testBinaryMemoryAndFiles(); + testRefreshAndOwnership(); + if (failures != 0) std::cerr << failures << " test(s) failed\n"; + return failures == 0 ? 0 : 1; +} diff --git a/third_party/iKv b/third_party/iKv new file mode 160000 index 0000000..d0b02f4 --- /dev/null +++ b/third_party/iKv @@ -0,0 +1 @@ +Subproject commit d0b02f473538d1ad89b9ada9e2560bd577f0e860