diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..0cbb57d --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,39 @@ +--- +Checks: > + bugprone-*, + cert-*, + clang-analyzer-*, + cppcoreguidelines-init-variables, + cppcoreguidelines-pro-type-member-init, + misc-*, + modernize-use-nullptr, + performance-*, + readability-braces-around-statements, + readability-container-size-empty, + readability-implicit-bool-conversion, + readability-isolate-declaration, + readability-redundant-member-init, + readability-simplify-boolean-expr, + -bugprone-easily-swappable-parameters, + -bugprone-macro-parentheses, + -cert-err58-cpp, + -cppcoreguidelines-avoid-magic-numbers, + -cppcoreguidelines-avoid-non-const-global-variables, + -cppcoreguidelines-macro-usage, + -cppcoreguidelines-pro-bounds-pointer-arithmetic, + -cppcoreguidelines-pro-type-vararg, + -misc-const-correctness, + -misc-include-cleaner, + -misc-no-recursion, + -misc-use-anonymous-namespace, + -modernize-avoid-c-arrays, + -modernize-use-trailing-return-type, + -performance-avoid-endl, + -readability-identifier-length, + -readability-magic-numbers, + -readability-function-cognitive-complexity +WarningsAsErrors: '' +HeaderFilterRegex: 'smallfolk.*\.h' +CheckOptions: + - key: readability-implicit-bool-conversion.AllowIntegerConditions + value: 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dbbd96..f288f38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,27 @@ jobs: - name: Test run: ${{ matrix.test }} + + static-analysis: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Install analysis tools + run: sudo apt-get update && sudo apt-get install -y clang-tidy cppcheck + + - name: Configure + run: cmake -B build -DSMALLFOLK_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + + - name: Build + run: cmake --build build --config Release + + - name: cppcheck + run: cmake --build build --target smallfolk_cppcheck + + - name: clang-tidy + run: cmake --build build --target smallfolk_clang_tidy + + - name: Test + run: ctest --test-dir build -C Release --output-on-failure diff --git a/CHANGELOG.md b/CHANGELOG.md index d81f034..04af4ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project are documented in this file. +## [2.0.1] - 2026-05-31 + +### Added + +- **README** — v2 API docs (safe lookup, path API, throwing loads/dumps, typed accessors, `lua_val` factories); link to `CHANGELOG.md`; static analysis usage. +- **Static analysis** — `.clang-tidy`, `cmake/StaticAnalysis.cmake`, `cppcheck-suppressions.txt`, and Ubuntu CI job (cppcheck + clang-tidy). +- **Tests** — expanded `smallfolk_tests` / `smallfolk_schema_tests` coverage; fixed gvx/Lua Smallfolk wire interop fixtures in `test_lua_smallfolk_interop_wires()`. + +### Fixed + +- **Non-finite wire on glibc/libstdc++** — NaN/Inf values serialize as gvx tokens (`N`/`Q`/`I`/`i`) instead of libc `nan`/`inf` text that broke round-trip on Linux/macOS; parse `nan` literals and map `N`/`Q` via `std::nan`. +- **Schema depth-limit test** — avoid dangling `CompiledSchema` reference to a temporary nested schema (validation spuriously passed on GCC). + +### Changed + +- Restored pre-v2 API and serializer comments in `smallfolk.h` / `smallfolk.cpp`. +- README build instructions: fix `SMALLFOLK_BUILD_TESTS` cmake typo. + ## [2.0.0] - 2026-05-31 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 1bdecca..5faee78 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,12 +1,18 @@ cmake_minimum_required(VERSION 3.16) -project(smallfolk_cpp VERSION 2.0.0 LANGUAGES CXX) +project(smallfolk_cpp VERSION 2.0.1 LANGUAGES CXX) option(SMALLFOLK_BUILD_TESTS "Build the test runner" ON) option(SMALLFOLK_BUILD_BENCHMARK "Build the benchmark runner" ON) option(SMALLFOLK_BUILD_EXAMPLES "Build the interactive demo example" ON) +option(SMALLFOLK_ENABLE_CLANG_TIDY "Attach clang-tidy to library targets when available" OFF) + +include(cmake/StaticAnalysis.cmake) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) add_library(smallfolk smallfolk.cpp smallfolk_schema.cpp) add_library(smallfolk_cpp::smallfolk ALIAS smallfolk) +smallfolk_enable_clang_tidy(smallfolk) target_include_directories(smallfolk PUBLIC "$" "$" @@ -80,3 +86,5 @@ install(EXPORT smallfolk_cppTargets NAMESPACE smallfolk_cpp:: DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/smallfolk_cpp ) + +smallfolk_add_static_analysis_targets() diff --git a/README.md b/README.md index 1bbbcc4..5a1d754 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ You use, distribute and extend Smallfolk_cpp under the terms of the MIT license. See [ASSUMPTIONS.md](ASSUMPTIONS.md) for documented behavioral assumptions (copy semantics, comparison, limits, and security). +See [CHANGELOG.md](CHANGELOG.md) for release history (current version **2.0.1**). + ## Add to your project **CMake (recommended)** — add this repository as a subdirectory or fetch it, then link the library target: @@ -27,7 +29,7 @@ target_link_libraries(my_app PRIVATE smallfolk_cpp::smallfolk) Build and test from the repository root: ```bash -cmake -B build -DSMALLFOLK_BUIlen()LD_TESTS=ON -DSMALLFOLK_BUILD_BENCHMARK=ON +cmake -B build -DSMALLFOLK_BUILD_TESTS=ON -DSMALLFOLK_BUILD_BENCHMARK=ON cmake --build build ctest --test-dir build --output-on-failure # if you enable CTest ./build/smallfolk_tests @@ -36,7 +38,7 @@ ctest --test-dir build --output-on-failure # if you enable CTest ./build/smallfolk_benchmark ``` -**Manual integration** — copy `smallfolk.h` and `smallfolk.cpp` into your tree and compile them as a static library or directly into your target. The library requires C++11 and has no other dependencies. +**Manual integration** — copy `smallfolk.h`, `smallfolk.cpp`, and (optionally) `smallfolk_schema.h`, `smallfolk_schema.cpp`, `smallfolk_convert.h` into your tree and compile them as a static library or directly into your target. The library requires C++11 and has no other dependencies. **Install** — after building: @@ -137,6 +139,15 @@ Tune limits for your deployment. See [ASSUMPTIONS.md](ASSUMPTIONS.md) for what i Automated tests live in `tests/test_smallfolk.cpp` and `tests/test_schema.cpp`, run via the `smallfolk_tests` and `smallfolk_schema_tests` targets. The original interactive walkthrough from `main.cpp` now lives in `examples/demo.cpp` as the `smallfolk_demo` target (also run by CTest when `-DSMALLFOLK_BUILD_EXAMPLES=ON`, default). The demo uses a `DEMO_CHECK` macro instead of `assert()` so runtime verification still runs in Release/NDEBUG builds. +Static analysis (Linux CI and local when tools are installed): + +```bash +cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +cmake --build build --target smallfolk_static_analysis +``` + +This runs **cppcheck** and **clang-tidy** on the library sources when available. Optional: `-DSMALLFOLK_ENABLE_CLANG_TIDY=ON` attaches clang-tidy to normal library builds. + The code has also been in use with a server-client C++-Lua communication system called AIO through which the API has been made more usable and critical issues have been addressed. - [https://github.com/Rochet2/AIO](https://github.com/Rochet2/AIO) @@ -169,7 +180,9 @@ This function does not throw. ### deserializing Deserializing happens by calling `static LuaVal LuaVal::loads(std::string const & string, std::string* errmsg = nullptr)` or the overload that accepts an explicit `LoadLimits` object. When an error occurs with the deserialization a LuaVal representing a nil is returned and if errmsg points to a string then it is filled with the error message. -This function does not throw. +This function does not throw. Use `LuaVal::loads_or_throw(...)` when you prefer exceptions on parse failure. + +Serializing also has a throwing overload: `dumps_or_throw()`. ### LoadLimits @@ -382,6 +395,42 @@ This operator does not throw unless you use it on non table objects or with nil `luaval.has(key)` can be used to check if a value can be found in a table. This function do not throw unless you use it on non table objects or with nil keys. +#### Safe lookup (no auto-vivification) + +Prefer these when you do not want missing keys to create empty tables: + +| Method | Missing key | Notes | +| --- | --- | --- | +| `try_get(key)` / `find(key)` | returns `nullptr` | safe optional access | +| `get(key)` | returns `LuaVal::nil` | const reference | +| `at(key)` | throws | mutable reference when present | +| `has(key)` | returns `false` | existence check | + +Typed reads without exceptions: `try_as_number`, `try_as_string`, `try_as_bool`. + +#### Path lookup and nested set/erase + +Nested access uses the same tiers as single-key lookup. Paths never auto-vivify on read; `set_path` creates missing intermediate tables. + +```C++ +LuaVal doc = LuaVal::loads_or_throw("{stats:{hp:100}}"); + +if (LuaVal const * hp = doc.try_get_path("stats", "hp")) + std::cout << hp->num() << std::endl; + +double hp = doc.get_path("stats", "hp").num(); // nil if missing +double hp2 = doc.at_path("stats", "hp").num(); // throws with $.stats.hp + +doc.set_path({ "player", "name" }, std::string("Ada")); // auto-vivify intermediates +doc.erase_path("stats", "hp"); // no-op if path missing +``` + +Variadic segments (`try_get_path("a", "b", 1)`) and `std::initializer_list` overloads are both available. + +#### `lua_val` factories + +`#include "smallfolk_convert.h"` for helpers such as `lua_val::map(...)`, `lua_val::array(...)`, `lua_val::string(...)`, and `lua_val::nil()`. + A method for erasing data with a key is `luaval.rem(key)` which also returns the accessed table. This function do not throw unless you use it on non table objects or with nil keys. diff --git a/cmake/StaticAnalysis.cmake b/cmake/StaticAnalysis.cmake new file mode 100644 index 0000000..74e0b29 --- /dev/null +++ b/cmake/StaticAnalysis.cmake @@ -0,0 +1,66 @@ +option(SMALLFOLK_ENABLE_CLANG_TIDY "Attach clang-tidy to library targets when available" OFF) + +function(smallfolk_enable_clang_tidy target) + if(NOT SMALLFOLK_ENABLE_CLANG_TIDY) + return() + endif() + + find_program(SMALLFOLK_CLANG_TIDY_EXE NAMES clang-tidy clang-tidy-18 clang-tidy-17 clang-tidy-16) + if(NOT SMALLFOLK_CLANG_TIDY_EXE) + message(WARNING "SMALLFOLK_ENABLE_CLANG_TIDY is ON but clang-tidy was not found") + return() + endif() + + set_property( + TARGET ${target} + PROPERTY CXX_CLANG_TIDY + "${SMALLFOLK_CLANG_TIDY_EXE};-warnings-as-errors=*" + ) +endfunction() + +function(smallfolk_add_static_analysis_targets) + find_program(SMALLFOLK_CPPCHECK_EXE NAMES cppcheck) + find_program(SMALLFOLK_CLANG_TIDY_EXE NAMES clang-tidy clang-tidy-18 clang-tidy-17 clang-tidy-16) + + if(SMALLFOLK_CPPCHECK_EXE) + add_custom_target(smallfolk_cppcheck + COMMAND + ${SMALLFOLK_CPPCHECK_EXE} + --enable=warning,performance,portability + --error-exitcode=1 + --inline-suppr + --std=c++11 + -I${CMAKE_SOURCE_DIR} + --suppressions-list=${CMAKE_SOURCE_DIR}/cppcheck-suppressions.txt + --quiet + ${CMAKE_SOURCE_DIR}/smallfolk.cpp + ${CMAKE_SOURCE_DIR}/smallfolk_schema.cpp + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + COMMENT "Running cppcheck on smallfolk sources" + VERBATIM + ) + endif() + + if(SMALLFOLK_CLANG_TIDY_EXE AND CMAKE_EXPORT_COMPILE_COMMANDS) + add_custom_target(smallfolk_clang_tidy + COMMAND + ${SMALLFOLK_CLANG_TIDY_EXE} + -p ${CMAKE_BINARY_DIR} + ${CMAKE_SOURCE_DIR}/smallfolk.cpp + ${CMAKE_SOURCE_DIR}/smallfolk_schema.cpp + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + COMMENT "Running clang-tidy on library translation units" + VERBATIM + ) + endif() + + if(TARGET smallfolk_cppcheck OR TARGET smallfolk_clang_tidy) + add_custom_target(smallfolk_static_analysis) + if(TARGET smallfolk_cppcheck) + add_dependencies(smallfolk_static_analysis smallfolk_cppcheck) + endif() + if(TARGET smallfolk_clang_tidy) + add_dependencies(smallfolk_static_analysis smallfolk_clang_tidy) + endif() + endif() +endfunction() diff --git a/cppcheck-suppressions.txt b/cppcheck-suppressions.txt new file mode 100644 index 0000000..e8f217c --- /dev/null +++ b/cppcheck-suppressions.txt @@ -0,0 +1,12 @@ +# Benign or intentional patterns in this C++11 header-library codebase. +missingIncludeSystem +unusedFunction +useStlAlgorithm +noExplicitConstructor +shadowFunction +knownConditionTrueFalse +unsignedLessThanZero +passedByValue +constVariableReference +duplicateExpression +normalCheckLevelMaxBranches diff --git a/smallfolk.cpp b/smallfolk.cpp index 0da3b05..83f23a9 100644 --- a/smallfolk.cpp +++ b/smallfolk.cpp @@ -6,6 +6,8 @@ #include // std::floor, std::isfinite #include // std::strtod #include // std::snprintf +#include // std::strcmp +#include #include // va_start #include // std::hash #include @@ -141,12 +143,46 @@ namespace Serializer typedef std::unordered_map MEMO; typedef std::stringstream ACC; + inline bool is_nan_value(double value) + { + return value != value; + } + + inline bool is_inf_value(double value) + { + return !is_nan_value(value) && !std::isfinite(value); + } + inline std::string tostring(const double d) { char arr[128]; + // %.17g matches minimum lua number precision for round-trip. std::snprintf(arr, sizeof(arr), "%.17g", d); return arr; } + + inline void append_number_token(ACC & acc, double value) + { + if (is_nan_value(value)) + { + std::string const nn = tostring(value); + if (nn.size() >= 4 && nn.compare(0, 4, "-nan") == 0) + acc << 'N'; + else + acc << 'Q'; + return; + } + if (is_inf_value(value)) + { + acc << (value < 0.0 ? 'i' : 'I'); + return; + } + + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.17g", value); + acc << buf; + } + inline std::string tostring(LuaVal::TblPtr const & ptr) { char arr[128]; @@ -583,10 +619,10 @@ LuaVal & LuaVal::set(LuaVal const & k, LuaVal const & v) if (k.isnil()) throw smallfolk_exception("using set with nil key"); LuaTable & tbl = (*tbl_ptr); - if (v.isnil()) + if (v.isnil()) // on nil value erase key tbl.erase(k); else - tbl[k] = v; + tbl[k] = v; // normally set pair return *this; } @@ -597,10 +633,10 @@ LuaVal & LuaVal::set(LuaVal const & k, LuaVal && v) if (k.isnil()) throw smallfolk_exception("using set with nil key"); LuaTable & tbl = (*tbl_ptr); - if (v.isnil()) + if (v.isnil()) // on nil value erase key tbl.erase(k); else - tbl[k] = std::move(v); + tbl[k] = std::move(v); // normally set pair return *this; } @@ -945,6 +981,17 @@ unsigned int Serializer::dump_type_table(LuaVal const & object, unsigned int nme if (!object.istable()) throw smallfolk_exception("using dump_type_table on non table object"); + /* + // @ circular table references are disabled; deep copy on assign avoids shared refs. + auto it = memo.find(object); + if (it != memo.end()) + { + acc << '@' << it->second; + return nmemo; + } + memo[object] = ++nmemo; + */ + acc << '{'; bool first = true; std::map arr; @@ -996,16 +1043,11 @@ unsigned int Serializer::dump_object(LuaVal const & object, unsigned int nmemo, break; case TSTRING: acc << '"'; - acc << escape_quotes(object.str(), '"'); + acc << escape_quotes(object.str(), '"'); // change to std::quote() in c++14? acc << '"'; break; case TNUMBER: - if (std::isnan(object.num())) - acc << (std::signbit(object.num()) ? 'Q' : 'N'); - else if (std::isinf(object.num())) - acc << (object.num() < 0 ? 'i' : 'I'); - else - acc << object.num(); + append_number_token(acc, object.num()); break; case TTABLE: return dump_type_table(object, nmemo, memo, acc); @@ -1046,7 +1088,7 @@ std::string Serializer::unescape_quotes(const std::string & before, char quote) if (i + 1 < before.length() && before[i + 1] == quote) { after += quote; - ++i; + ++i; // no break } else after += before[i]; @@ -1100,7 +1142,7 @@ char Serializer::strat(std::string const & string, std::string::size_type i) if (i != std::string::npos && i < string.length()) return string.at(i); - return '\0'; + return '\0'; // bad? } LuaVal Serializer::expect_number(std::string const & string, size_t & start, ParseContext & ctx) @@ -1154,13 +1196,12 @@ LuaVal Serializer::expect_number(std::string const & string, size_t & start, Par LuaVal Serializer::expect_object(std::string const & string, size_t & i, Serializer::TABLES & tables, ParseContext & ctx) { - static double _zero = 0.0; - char cc = strat(string, i++); switch (cc) { case ' ': case '\t': + // skip whitespace return expect_object(string, i, tables, ctx); case 't': ctx.on_value_created(); @@ -1169,8 +1210,22 @@ LuaVal Serializer::expect_object(std::string const & string, size_t & i, Seriali ctx.on_value_created(); return false; case 'n': + { + size_t const start = i - 1; + if (strat(string, i) == 'a' && strat(string, i + 1) == 'n') + { + char * end = nullptr; + double const value = std::strtod(string.c_str() + start, &end); + if (end != string.c_str() + start && is_nan_value(value)) + { + i = static_cast(end - string.c_str()); + ctx.on_value_created(); + return value; + } + } ctx.on_value_created(); return LuaVal::nil; + } case 'Q': case 'N': case 'I': @@ -1179,12 +1234,12 @@ LuaVal Serializer::expect_object(std::string const & string, size_t & i, Seriali throw smallfolk_exception("non-finite number encoding rejected at %zu", i - 1); ctx.on_value_created(); if (cc == 'Q') - return -(0 / _zero); + return -std::nan(""); if (cc == 'N') - return (0 / _zero); + return std::nan(""); if (cc == 'I') - return (1 / _zero); - return -(1 / _zero); + return std::numeric_limits::infinity(); + return -std::numeric_limits::infinity(); case '\'': case '"': { diff --git a/smallfolk.h b/smallfolk.h index 5efa404..ba642e5 100644 --- a/smallfolk.h +++ b/smallfolk.h @@ -75,14 +75,18 @@ class LuaVal // Thread-safe write of the process-wide default. Prefer passing LoadLimits per call in multi-threaded code. static void set_load_limits(LoadLimits limits); + // Static nil value, same as LuaVal(TNIL). Useful as a default const reference. + // Returns the string representation of the value, similar to lua tostring. std::string tostring() const; + // Use as the hasher for containers, for example std::unordered_map. struct LuaValHasher { size_t operator()(LuaVal const & v) const; }; typedef std::unordered_map LuaTable; + // Circular reference memleak if insert self to self (deep copy on assign avoids sharing). typedef std::unique_ptr TblPtr; LuaVal(const LuaTypeTag tag) : tag(tag), tbl_ptr(tag == TTABLE ? new LuaTable() : nullptr), d(0), b(false) {} @@ -149,11 +153,12 @@ class LuaVal bool isbool() const { return tag == TBOOL; } bool isnil() const { return tag == TNIL; } + // gettable; adds key-nil pair if not existing. nil key throws error. // Inserts an empty table when the key is missing. LuaVal & operator[](LuaVal const & k); LuaVal const & operator[](LuaVal const & k) const; - // Returns LuaVal::nil when the key is missing (same reference as static nil). + // gettable; returns LuaVal::nil when the key is missing (same reference as static nil). LuaVal const & get(LuaVal const & k) const; LuaVal const & get(std::string const & k) const; LuaVal const & get(int k) const; @@ -174,6 +179,7 @@ class LuaVal LuaVal & at(int k); LuaVal const & at(int k) const; + // returns true if value was found with key bool has(LuaVal const & k) const; bool has(std::string const & k) const; bool has(int k) const; @@ -226,6 +232,7 @@ class LuaVal return erase_path(std::initializer_list{ LuaVal(keys)... }); } + // settable; return self LuaVal & set(LuaVal const & k, LuaVal const & v); LuaVal & set(LuaVal const & k, LuaVal && v); LuaVal & set(std::string const & k, LuaVal const & v); @@ -244,35 +251,50 @@ class LuaVal LuaVal & set(double k, LuaVal const & v) { return set(LuaVal(k), v); } LuaVal & set(double k, LuaVal && v) { return set(LuaVal(k), std::move(v)); } + // settable ignore if exists; return self LuaVal & setignore(LuaVal const & k, LuaVal const & v); LuaVal & setignore(LuaVal const & k, LuaVal && v); + // erase; return self LuaVal & erase(LuaVal const & k); LuaVal & rem(LuaVal const & k) { return erase(k); } + // table array size, not actual element count unsigned int len() const; + // table.insert; return self LuaVal & insert(LuaVal const & v, LuaVal const & pos = nil); LuaVal & insert(LuaVal && v, LuaVal const & pos = nil); LuaVal & insert(char const * v) { return insert(LuaVal(v)); } + // table.remove; return self LuaVal & remove(LuaVal const & pos = nil); + // get a number value double num() const; + // get a boolean value bool boolean() const; + // get a string value std::string const & str() const; + // get a table value LuaTable const & tbl() const; bool try_as_number(double & out) const; bool try_as_string(std::string const *& out) const; bool try_as_bool(bool & out) const; + // Returns a typetag, the internal identifier for each type. LuaTypeTag typetag() const { return tag; } + // Returns the LuaVal's type as a string. std::string type() const { return type(typetag()); } + // Returns the type tag's type as a string. static std::string type(LuaTypeTag tag); + // Serializes the value into string. + // errmsg is optional; on failure an empty string is returned and errmsg is assigned (not appended). std::string dumps(std::string* errmsg = nullptr) const; std::string dumps_or_throw() const; - // When errmsg is non-null it is assigned (not appended) on failure. + // Deserialize a string into a LuaVal. + // errmsg is optional; on failure nil is returned and errmsg is assigned (not appended). static LuaVal loads(std::string const & string, std::string* errmsg = nullptr); static LuaVal loads(std::string const & string, LoadLimits const & limits, std::string* errmsg = nullptr); static LuaVal loads_or_throw(std::string const & string); @@ -281,6 +303,7 @@ class LuaVal bool operator==(LuaVal const& rhs) const; bool operator!=(LuaVal const& rhs) const { return !(*this == rhs); } + // You can use !val to check for nil or false. explicit operator bool() const; LuaVal& operator=(LuaVal const& val); @@ -335,6 +358,7 @@ class LuaVal LuaTypeTag tag; TblPtr tbl_ptr; std::string s; + // int64_t i; // lua 5.3 support? Numbers are stored as double today. double d; bool b; }; diff --git a/tests/test_schema.cpp b/tests/test_schema.cpp index 06fc62f..1155ad9 100644 --- a/tests/test_schema.cpp +++ b/tests/test_schema.cpp @@ -342,6 +342,88 @@ static void test_loads_validated_or_throw() } } +static void test_one_of_initializer_list() +{ + Schema const schema = schema::one_of({ schema::number(), schema::boolean() }); + expect_true(validate(LuaVal(3), schema), "one_of initializer_list accepts number"); + expect_true(validate(LuaVal(true), schema), "one_of initializer_list accepts bool"); + expect_false(validate(LuaVal("x"), schema), "one_of initializer_list rejects string"); +} + +static Schema::Field loose_object_fields[] = { + { "name", &string_schema, true }, + { "hp", &bounded_number_schema, true }, +}; + +static Schema const loose_object_schema = [] { + Schema s; + s.kind = SchemaKind::Object; + s.fields = loose_object_fields; + s.field_count = 2; + s.allow_extra_keys = true; + return s; +}(); + +static void test_object_allows_extra_when_configured() +{ + LuaVal value = LuaVal::table(); + value.set(std::string("name"), LuaVal("Ada")); + value.set(std::string("hp"), LuaVal(40)); + value.set(std::string("note"), LuaVal("ok")); + expect_true(validate(value, loose_object_schema), "object with allow_extra_keys accepts unknown fields"); +} + +static Schema const depth_limit_schema = [] { + Schema inner = schema::array_of(schema::number()); + return schema::array_of(std::move(inner)); +}(); + +static void test_validate_depth_limit() +{ + CompiledSchema compiled(depth_limit_schema); + ValidateLimits tight; + tight.max_validation_depth = 1; + + LuaVal nested = LuaVal::table(); + nested.set(1, LuaVal(1)); + LuaVal outer = LuaVal::table(); + outer.set(1, nested); + + std::string err; + expect_false(compiled.validate(outer, tight, &err), "validation depth limit rejects nested array"); + expect_contains(err, "depth limit", "validation depth limit error message"); +} + +static void test_loads_validated_with_limits() +{ + CompiledSchema compiled(player_schema); + LoadLimits load_limits = LuaVal::untrusted_load_limits(); + ValidateLimits validate_limits = untrusted_validate_limits(); + + std::string err; + LuaVal value = loads_validated( + "{'name':'Ada','hp':40}", + compiled, + load_limits, + validate_limits, + &err); + expect_true(err.empty(), "loads_validated accepts payload with explicit limits"); + expect_true(value.get(std::string("name")).str() == "Ada", "loads_validated limits overload parses value"); +} + +static void test_optional_object_field() +{ + LuaVal with_title = LuaVal::table(); + with_title.set(std::string("name"), LuaVal("Ada")); + with_title.set(std::string("hp"), LuaVal(50)); + with_title.set(std::string("title"), LuaVal("Engineer")); + expect_true(validate(with_title, player_schema), "optional field may be present"); + + LuaVal without_title = with_title; + without_title.erase(std::string("title")); + expect_true(validate(without_title, player_schema), "optional field may be absent"); +} + int main() { std::cout << "Running schema tests..." << std::endl; @@ -363,6 +445,11 @@ int main() test_validation_limits(); test_loads_validated(); test_loads_validated_or_throw(); + test_one_of_initializer_list(); + test_object_allows_extra_when_configured(); + test_validate_depth_limit(); + test_loads_validated_with_limits(); + test_optional_object_field(); if (failures == 0) { diff --git a/tests/test_smallfolk.cpp b/tests/test_smallfolk.cpp index e574529..c186b31 100644 --- a/tests/test_smallfolk.cpp +++ b/tests/test_smallfolk.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -11,6 +12,11 @@ namespace { int failures = 0; + bool is_nan(double value) + { + return value != value || std::isnan(value); + } + void expect_true(bool condition, char const * message) { if (!condition) @@ -71,8 +77,12 @@ static void test_round_trip_basic() static void test_non_finite_numbers() { - double zero = 0.0; - LuaVal values = { -(zero / zero), (zero / zero), (1.0 / zero), -(1.0 / zero) }; + LuaVal values = { + -std::numeric_limits::quiet_NaN(), + std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), + -std::numeric_limits::infinity() + }; std::string serialized = values.dumps(); expect_true(!serialized.empty(), "non-finite values serialize"); @@ -83,8 +93,8 @@ static void test_non_finite_numbers() if (!loaded.istable()) return; - expect_true(std::isnan(loaded.get(1).num()), "NaN round-trip slot 1"); - expect_true(std::isnan(loaded.get(2).num()), "NaN round-trip slot 2"); + expect_true(is_nan(loaded.get(1).num()), "NaN round-trip slot 1"); + expect_true(is_nan(loaded.get(2).num()), "NaN round-trip slot 2"); expect_true(loaded.get(3).num() > 0, "positive infinity round-trip"); expect_true(loaded.get(4).num() < 0, "negative infinity round-trip"); } @@ -472,6 +482,251 @@ static void test_equality_and_bool() expect_true(!static_cast(false), "false is falsy"); } +static void test_nil_bool_and_type() +{ + std::string err; + LuaVal loaded = LuaVal::loads("{t,f}", &err); + expect_true(err.empty(), "bool literals parse"); + expect_true(loaded.get(1).boolean(), "true round-trip"); + expect_true(!loaded.get(2).boolean(), "false round-trip"); + + bool value = false; + expect_true(LuaVal(true).try_as_bool(value) && value, "try_as_bool on true"); + expect_true(LuaVal(false).try_as_bool(value) && !value, "try_as_bool on false"); + expect_true(!LuaVal(1).try_as_bool(value), "try_as_bool rejects number"); + + expect_equal(LuaVal::type(TTABLE), "table", "type tag string for table"); + expect_equal(LuaVal(5).type(), "number", "instance type string"); +} + +static void test_set_nil_vs_bracket_nil() +{ + LuaVal table = LuaVal::table(); + table.set(std::string("removed"), std::string("x")); + table.set(std::string("removed"), LuaVal::nil); + expect_true(!table.has(std::string("removed")), "set(nil) erases key"); + + table["stored"] = LuaVal::nil; + expect_true(table.has(std::string("stored")), "bracket nil stores explicit nil entry"); + expect_true(table.get(std::string("stored")).isnil(), "bracket nil value is nil"); +} + +static void test_setignore() +{ + LuaVal table = LuaVal::table(); + table.set(std::string("keep"), std::string("first")); + table.setignore(std::string("keep"), std::string("second")); + expect_true(table.get(std::string("keep")).str() == "first", "setignore does not overwrite"); + + table.setignore(std::string("new"), std::string("added")); + expect_true(table.get(std::string("new")).str() == "added", "setignore inserts missing key"); + + table.setignore(std::string("skip"), LuaVal::nil); + expect_true(!table.has(std::string("skip")), "setignore ignores nil values"); +} + +static void test_copy_semantics() +{ + LuaVal original = LuaVal::table(); + original.set(std::string("x"), 1); + LuaVal copy = original; + copy.set(std::string("x"), 2); + expect_true(original.get(std::string("x")).num() == 1.0, "copy is deep for tables"); + + LuaVal assigned = LuaVal::table(); + assigned = original; + assigned.set(std::string("x"), 3); + expect_true(original.get(std::string("x")).num() == 1.0, "assignment copy is deep"); +} + +static void test_insert_remove_positions() +{ + LuaVal table = LuaVal::table(); + table.set(1, std::string("a")).set(2, std::string("b")).set(3, std::string("c")); + table.insert(LuaVal("middle"), LuaVal(2)); + expect_true(table.get(2).str() == "middle", "insert shifts sequence"); + expect_true(table.get(3).str() == "b", "insert preserves trailing values"); + expect_true(table.len() == 4, "insert grows sequence length"); + + table.remove(2); + expect_true(table.get(2).str() == "b", "remove shifts sequence back"); + expect_true(table.len() == 3, "remove shrinks sequence length"); +} + +static void test_load_limits_global() +{ + LoadLimits saved = LuaVal::get_load_limits(); + LoadLimits custom; + custom.max_input_size = 64; + LuaVal::set_load_limits(custom); + expect_true(LuaVal::get_load_limits().max_input_size == 64, "set_load_limits updates global default"); + + std::string err; + expect_load_error(std::string(65, 'x'), LuaVal::get_load_limits(), "global default applies to loads"); + LuaVal::set_load_limits(saved); +} + +static void test_untrusted_load_limits() +{ + LoadLimits limits = LuaVal::untrusted_load_limits(); + expect_true(limits.max_input_size < LuaVal::default_load_limits().max_input_size, "untrusted input cap is tighter"); + expect_true(limits.reject_non_finite_numbers, "untrusted rejects non-finite numbers"); +} + +static void test_single_quoted_strings() +{ + std::string err; + LuaVal loaded = LuaVal::loads("'it''s fine'", &err); + expect_true(err.empty(), "single-quoted string loads"); + expect_equal(loaded.str(), "it's fine", "single-quoted apostrophe unescapes"); + + LuaVal table = LuaVal::table(); + table.set(std::string("key"), std::string("value")); + expect_true(LuaVal::loads(table.dumps(), &err).has(std::string("key")), "single-quoted key round-trip"); +} + +static void test_path_errors() +{ + LuaVal table = LuaVal::table(); + try + { + table.set_path({}, std::string("x")); + expect_true(false, "set_path empty path throws"); + } + catch (smallfolk_exception const &) + { + } + + try + { + table.erase_path({}); + expect_true(false, "erase_path empty path throws"); + } + catch (smallfolk_exception const &) + { + } + + table.set(std::string("mid"), 42); + try + { + table.set_path({ LuaVal("mid"), LuaVal("leaf"), LuaVal("x") }, std::string("bad")); + expect_true(false, "set_path through scalar throws"); + } + catch (smallfolk_exception const & e) + { + expect_true(std::string(e.what()).find("not a table at $.mid") != std::string::npos, "set_path scalar path message"); + } +} + +static void test_empty_table_round_trip() +{ + expect_equal(LuaVal::table().dumps(), "{}", "empty table serializes"); + + std::string err; + LuaVal loaded = LuaVal::loads("{}", &err); + expect_true(err.empty(), "empty table loads"); + expect_true(loaded.istable(), "empty table loads to table"); + expect_true(loaded.len() == 0, "empty table stays empty"); +} + +// Fixed wire payloads in gvx/Smallfolk token form (dump_object / dump_type). +static void test_lua_smallfolk_interop_wires() +{ + std::string err; + + { + LuaVal value = LuaVal::loads("t", &err); + expect_true(err.empty(), "lua wire scalar true loads"); + expect_true(value.isbool() && value.boolean(), "lua wire scalar true value"); + } + + { + LuaVal value = LuaVal::loads("f", &err); + expect_true(err.empty(), "lua wire scalar false loads"); + expect_true(value.isbool() && !value.boolean(), "lua wire scalar false value"); + } + + { + LuaVal value = LuaVal::loads("n", &err); + expect_true(err.empty(), "lua wire scalar nil loads"); + expect_true(value.isnil(), "lua wire scalar nil value"); + } + + { + LuaVal value = LuaVal::loads("{t,f,n}", &err); + expect_true(err.empty(), "lua wire scalar array loads"); + expect_true(value.get(1).boolean(), "lua wire array true"); + expect_true(!value.get(2).boolean(), "lua wire array false"); + expect_true(value.get(3).isnil(), "lua wire array nil"); + } + + { + LuaVal value = LuaVal::loads("{I,i,N,Q}", &err); + expect_true(err.empty(), "lua wire non-finite array loads"); + expect_true(std::isinf(value.get(1).num()) && value.get(1).num() > 0.0, "lua wire I is +inf"); + expect_true(std::isinf(value.get(2).num()) && value.get(2).num() < 0.0, "lua wire i is -inf"); + expect_true(is_nan(value.get(3).num()), "lua wire N is nan"); + expect_true(is_nan(value.get(4).num()), "lua wire Q is nan"); + } + + { + LuaVal value = LuaVal::loads("\"a\"\"b\"", &err); + expect_true(err.empty(), "lua wire doubled-quote string loads"); + expect_equal(value.str(), "a\"b", "lua wire doubled-quote string value"); + } + + { + LuaVal value = LuaVal::loads("{\"Hello\",\"test\":\"world\",67.5:-234.5}", &err); + expect_true(err.empty(), "lua wire mixed array/map table loads"); + expect_equal(value.get(1).str(), "Hello", "lua wire array slot 1"); + expect_equal(value.get(std::string("test")).str(), "world", "lua wire map key test"); + expect_true(value.get(LuaVal(67.5)).num() == -234.5, "lua wire numeric map key"); + } + + { + LuaVal value = LuaVal::loads("{1,2,{3,4.5,'ke':'test'}}", &err); + expect_true(err.empty(), "lua wire nested compact table loads"); + expect_true(value.get(1).num() == 1.0, "lua wire nested index 1"); + expect_true(value.get(2).num() == 2.0, "lua wire nested index 2"); + expect_true(value.get(3).get(1).num() == 3.0, "lua wire nested child array"); + expect_true(value.get(3).get(2).num() == 4.5, "lua wire nested child number"); + expect_equal(value.get(3).get(LuaVal("ke")).str(), "test", "lua wire nested child map key"); + } + + { + char const * benchmark_wire = + "{t,\"somestring\",123.456,t," + "{\"t\":-678,\"test\":123.45600128173828,\"f\":268435455,\"subtable\":{1,2,3}}}"; + LuaVal value = LuaVal::loads(benchmark_wire, &err); + expect_true(err.empty(), "lua wire README benchmark payload loads"); + expect_true(value.get(1).isbool() && value.get(1).boolean(), "benchmark slot 1 bool"); + expect_equal(value.get(2).str(), "somestring", "benchmark slot 2 string"); + expect_true(value.get(3).num() == 123.456, "benchmark slot 3 number"); + expect_true(value.get(5).get(LuaVal("t")).num() == -678.0, "benchmark nested t"); + expect_true(value.get(5).get(LuaVal("f")).num() == 268435455.0, "benchmark nested f"); + expect_true(value.get(5).get(LuaVal("subtable")).get(2).num() == 2.0, "benchmark nested subtable"); + } + + { + LuaVal value = LuaVal::loads("{1,\t2}", &err); + expect_true(err.empty(), "lua wire tab whitespace loads"); + expect_true(value.get(2).num() == 2.0, "lua wire tab whitespace value"); + } + + { + LuaVal original = LuaVal::loads("{1,2,{3,4.5,'ke':'test'}}", &err); + expect_true(err.empty(), "interop round-trip source loads"); + std::string dumped = original.dumps(&err); + expect_true(err.empty(), "interop round-trip dumps"); + LuaVal again = LuaVal::loads(dumped, &err); + expect_true(err.empty(), "interop round-trip reloads"); + expect_true(again.get(1).num() == 1.0, "interop round-trip index 1"); + expect_true(again.get(3).get(LuaVal("ke")).str() == "test", "interop round-trip nested key"); + } + + expect_load_error("{@1}", LuaVal::default_load_limits(), "lua wire @ reference rejected"); +} + int main() { std::cout << "Running smallfolk tests..." << std::endl; @@ -490,6 +745,17 @@ int main() test_new_api(); test_path_api(); test_equality_and_bool(); + test_nil_bool_and_type(); + test_set_nil_vs_bracket_nil(); + test_setignore(); + test_copy_semantics(); + test_insert_remove_positions(); + test_load_limits_global(); + test_untrusted_load_limits(); + test_single_quoted_strings(); + test_path_errors(); + test_empty_table_round_trip(); + test_lua_smallfolk_interop_wires(); if (failures == 0) {