From a52319cb440ab1ff1cb613cb0906b073eb647ace Mon Sep 17 00:00:00 2001 From: Praveen Babu J D Date: Fri, 28 Aug 2026 11:47:16 -0700 Subject: [PATCH] Prepare pjson 1.0 for public release Co-authored-by: TRAE CLI --- .clang-format | 40 + .clang-tidy | 49 + .github/ISSUE_TEMPLATE/bug_report.yml | 86 + .github/ISSUE_TEMPLATE/config.yml | 14 + .github/ISSUE_TEMPLATE/feature_request.yml | 71 + .github/PULL_REQUEST_TEMPLATE.md | 37 + .github/workflows/ci.yml | 337 + .github/workflows/docs.yml | 96 + .gitignore | 34 + AUTHORS | 2 +- CHANGELOG.md | 119 + CMakeLists.txt | 104 +- CODE_OF_CONDUCT.md | 136 + CONTRIBUTING.md | 109 +- CONTRIBUTORS | 2 +- GOVERNANCE.md | 41 + LICENSES/Apache-2.0.txt | 201 + LICENSES/CC-BY-4.0.txt | 156 + LICENSING.md | 52 + README.md | 1370 +++- RELEASING.md | 178 + REUSE.toml | 28 + SECURITY.md | 77 + Todo.md | 70 + VERSIONING.md | 70 + bench/CMakeLists.txt | 68 + bench/README.md | 158 + bench/src/benchmark_main.cpp | 939 +++ build.sh | 961 +++ clean.sh | 66 + cmake/RunInstallConsumer.cmake | 224 + cmake/pjson.pc.in | 14 + cmake/pjsonConfig.cmake.in | 8 + conanfile.py | 102 + docs/00-what-is-json.md | 182 + docs/01-getting-started.md | 117 + docs/02-creating-json.md | 213 + docs/03-parsing-and-reading.md | 254 + docs/04-editing.md | 222 + docs/05-parsing-and-errors.md | 117 + docs/06-schema-validation.md | 201 + docs/07-capstone-address-book.md | 144 + docs/08-building-and-installing.md | 207 + docs/09-testing.md | 213 + docs/10-contributing.md | 168 + docs/11-streaming.md | 121 + docs/12-custom-allocators.md | 195 + docs/CMakeLists.txt | 68 + docs/Doxyfile.in | 78 + docs/README.md | 112 + docs/migration-from-nlohmann-json.md | 333 + docs/migration-from-rapidjson.md | 318 + docs/reference/mainpage.md | 59 + docs/reference/pjson-api.dox | 86 + docs/reference/pjson.css | 13 + docs/scripts/doxygen-filter.py | 180 + docs/scripts/validate-reference.py | 521 ++ examples/CMakeLists.txt | 28 + examples/src/01_hello_world.cpp | 32 + examples/src/02_building_values.cpp | 66 + examples/src/03_parsing_and_reading.cpp | 108 + examples/src/04_editing.cpp | 86 + examples/src/05_parsing_and_errors.cpp | 55 + examples/src/06_schema_validation.cpp | 82 + examples/src/07_address_book.cpp | 126 + examples/src/08_streaming.cpp | 68 + examples/src/09_custom_allocator.cpp | 132 + fuzz/CMakeLists.txt | 81 + fuzz/README.md | 83 + fuzz/corpus/parse/duplicate.json | 1 + fuzz/corpus/parse/malformed.json | 1 + fuzz/corpus/parse/nested.json | 1 + fuzz/corpus/parse/null.json | 1 + fuzz/corpus/parse/numbers.json | 1 + fuzz/corpus/parse/unicode.json | 1 + fuzz/corpus/patch/add-member.seed | 2 + fuzz/corpus/patch/failing-atomic.seed | 2 + fuzz/corpus/patch/merge-patch.seed | 2 + fuzz/corpus/patch/move-copy-test.seed | 2 + fuzz/corpus/schema/array.seed | 2 + fuzz/corpus/schema/boolean.seed | 2 + fuzz/corpus/schema/composition.seed | 2 + fuzz/corpus/schema/malformed.seed | 2 + fuzz/corpus/schema/object.seed | 2 + fuzz/corpus/schema/pattern.seed | 2 + fuzz/corpus/stream/chunk-boundaries.json | 1 + fuzz/corpus/stream/malformed.json | 1 + fuzz/corpus/stream/multiline.json | 4 + fuzz/corpus/stream/wide.json | 1 + fuzz/fuzz_parse.cpp | 61 + fuzz/fuzz_patch.cpp | 75 + fuzz/fuzz_schema.cpp | 41 + fuzz/fuzz_stream.cpp | 200 + fuzz/fuzz_util.h | 117 + fuzz/json.dict | 28 + oss-fuzz/Dockerfile | 13 + oss-fuzz/build.sh | 59 + oss-fuzz/pjson_fuzz_parse.options | 10 + oss-fuzz/pjson_fuzz_patch.options | 10 + oss-fuzz/pjson_fuzz_schema.options | 10 + oss-fuzz/pjson_fuzz_stream.options | 10 + oss-fuzz/project.yaml | 17 + packaging/vcpkg/ports/pjson/portfile.cmake | 31 + packaging/vcpkg/ports/pjson/vcpkg.json | 17 + pjsonlib/CMakeLists.txt | 104 +- pjsonlib/include/pjson.h | 834 ++- pjsonlib/src/pjson.cpp | 6871 +++++++++++++++++--- pjsontest/CMakeLists.txt | 76 +- pjsontest/src/main.cpp | 372 -- pjsontest/src/test_harness.h | 208 + pjsontest/src/test_main.cpp | 34 + pjsontest/src/test_util.h | 65 + pjsontest/src/tests_allocator.cpp | 820 +++ pjsontest/src/tests_api_edge.cpp | 650 ++ pjsontest/src/tests_build.cpp | 347 + pjsontest/src/tests_conformance.cpp | 438 ++ pjsontest/src/tests_core.cpp | 327 + pjsontest/src/tests_features.cpp | 527 ++ pjsontest/src/tests_fuzz.cpp | 306 + pjsontest/src/tests_malformed.cpp | 284 + pjsontest/src/tests_mutation.cpp | 422 ++ pjsontest/src/tests_parse.cpp | 383 ++ pjsontest/src/tests_pathological.cpp | 359 + pjsontest/src/tests_pointer_patch.cpp | 984 +++ pjsontest/src/tests_roundtrip.cpp | 339 + pjsontest/src/tests_schema.cpp | 471 ++ pjsontest/src/tests_schema_complex.cpp | 361 + pjsontest/src/tests_schema_official.cpp | 581 ++ pjsontest/src/tests_schema_vocabulary.cpp | 785 +++ pjsontest/src/tests_serialize_access.cpp | 520 ++ pjsontest/src/tests_storage.cpp | 283 + pjsontest/src/tests_streaming.cpp | 538 ++ pjsontest/src/tests_strings.cpp | 184 + scripts/fetch-json-schema-test-suite.sh | 109 + scripts/fetch-json-test-suite.sh | 109 + test_package/CMakeLists.txt | 17 + test_package/conanfile.py | 38 + test_package/src/pjson_package_test.cpp | 23 + tests/install-consumer/CMakeLists.txt | 35 + tests/install-consumer/main.cpp | 35 + touch | 1 - 141 files changed, 28985 insertions(+), 1675 deletions(-) create mode 100644 .clang-format create mode 100644 .clang-tidy create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docs.yml create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 GOVERNANCE.md create mode 100644 LICENSES/Apache-2.0.txt create mode 100644 LICENSES/CC-BY-4.0.txt create mode 100644 LICENSING.md create mode 100644 RELEASING.md create mode 100644 REUSE.toml create mode 100644 SECURITY.md create mode 100644 Todo.md create mode 100644 VERSIONING.md create mode 100644 bench/CMakeLists.txt create mode 100644 bench/README.md create mode 100644 bench/src/benchmark_main.cpp create mode 100755 build.sh create mode 100755 clean.sh create mode 100644 cmake/RunInstallConsumer.cmake create mode 100644 cmake/pjson.pc.in create mode 100644 cmake/pjsonConfig.cmake.in create mode 100644 conanfile.py create mode 100644 docs/00-what-is-json.md create mode 100644 docs/01-getting-started.md create mode 100644 docs/02-creating-json.md create mode 100644 docs/03-parsing-and-reading.md create mode 100644 docs/04-editing.md create mode 100644 docs/05-parsing-and-errors.md create mode 100644 docs/06-schema-validation.md create mode 100644 docs/07-capstone-address-book.md create mode 100644 docs/08-building-and-installing.md create mode 100644 docs/09-testing.md create mode 100644 docs/10-contributing.md create mode 100644 docs/11-streaming.md create mode 100644 docs/12-custom-allocators.md create mode 100644 docs/CMakeLists.txt create mode 100644 docs/Doxyfile.in create mode 100644 docs/README.md create mode 100644 docs/migration-from-nlohmann-json.md create mode 100644 docs/migration-from-rapidjson.md create mode 100644 docs/reference/mainpage.md create mode 100644 docs/reference/pjson-api.dox create mode 100644 docs/reference/pjson.css create mode 100644 docs/scripts/doxygen-filter.py create mode 100644 docs/scripts/validate-reference.py create mode 100644 examples/CMakeLists.txt create mode 100644 examples/src/01_hello_world.cpp create mode 100644 examples/src/02_building_values.cpp create mode 100644 examples/src/03_parsing_and_reading.cpp create mode 100644 examples/src/04_editing.cpp create mode 100644 examples/src/05_parsing_and_errors.cpp create mode 100644 examples/src/06_schema_validation.cpp create mode 100644 examples/src/07_address_book.cpp create mode 100644 examples/src/08_streaming.cpp create mode 100644 examples/src/09_custom_allocator.cpp create mode 100644 fuzz/CMakeLists.txt create mode 100644 fuzz/README.md create mode 100644 fuzz/corpus/parse/duplicate.json create mode 100644 fuzz/corpus/parse/malformed.json create mode 100644 fuzz/corpus/parse/nested.json create mode 100644 fuzz/corpus/parse/null.json create mode 100644 fuzz/corpus/parse/numbers.json create mode 100644 fuzz/corpus/parse/unicode.json create mode 100644 fuzz/corpus/patch/add-member.seed create mode 100644 fuzz/corpus/patch/failing-atomic.seed create mode 100644 fuzz/corpus/patch/merge-patch.seed create mode 100644 fuzz/corpus/patch/move-copy-test.seed create mode 100644 fuzz/corpus/schema/array.seed create mode 100644 fuzz/corpus/schema/boolean.seed create mode 100644 fuzz/corpus/schema/composition.seed create mode 100644 fuzz/corpus/schema/malformed.seed create mode 100644 fuzz/corpus/schema/object.seed create mode 100644 fuzz/corpus/schema/pattern.seed create mode 100644 fuzz/corpus/stream/chunk-boundaries.json create mode 100644 fuzz/corpus/stream/malformed.json create mode 100644 fuzz/corpus/stream/multiline.json create mode 100644 fuzz/corpus/stream/wide.json create mode 100644 fuzz/fuzz_parse.cpp create mode 100644 fuzz/fuzz_patch.cpp create mode 100644 fuzz/fuzz_schema.cpp create mode 100644 fuzz/fuzz_stream.cpp create mode 100644 fuzz/fuzz_util.h create mode 100644 fuzz/json.dict create mode 100644 oss-fuzz/Dockerfile create mode 100755 oss-fuzz/build.sh create mode 100644 oss-fuzz/pjson_fuzz_parse.options create mode 100644 oss-fuzz/pjson_fuzz_patch.options create mode 100644 oss-fuzz/pjson_fuzz_schema.options create mode 100644 oss-fuzz/pjson_fuzz_stream.options create mode 100644 oss-fuzz/project.yaml create mode 100644 packaging/vcpkg/ports/pjson/portfile.cmake create mode 100644 packaging/vcpkg/ports/pjson/vcpkg.json delete mode 100644 pjsontest/src/main.cpp create mode 100644 pjsontest/src/test_harness.h create mode 100644 pjsontest/src/test_main.cpp create mode 100644 pjsontest/src/test_util.h create mode 100644 pjsontest/src/tests_allocator.cpp create mode 100644 pjsontest/src/tests_api_edge.cpp create mode 100644 pjsontest/src/tests_build.cpp create mode 100644 pjsontest/src/tests_conformance.cpp create mode 100644 pjsontest/src/tests_core.cpp create mode 100644 pjsontest/src/tests_features.cpp create mode 100644 pjsontest/src/tests_fuzz.cpp create mode 100644 pjsontest/src/tests_malformed.cpp create mode 100644 pjsontest/src/tests_mutation.cpp create mode 100644 pjsontest/src/tests_parse.cpp create mode 100644 pjsontest/src/tests_pathological.cpp create mode 100644 pjsontest/src/tests_pointer_patch.cpp create mode 100644 pjsontest/src/tests_roundtrip.cpp create mode 100644 pjsontest/src/tests_schema.cpp create mode 100644 pjsontest/src/tests_schema_complex.cpp create mode 100644 pjsontest/src/tests_schema_official.cpp create mode 100644 pjsontest/src/tests_schema_vocabulary.cpp create mode 100644 pjsontest/src/tests_serialize_access.cpp create mode 100644 pjsontest/src/tests_storage.cpp create mode 100644 pjsontest/src/tests_streaming.cpp create mode 100644 pjsontest/src/tests_strings.cpp create mode 100755 scripts/fetch-json-schema-test-suite.sh create mode 100755 scripts/fetch-json-test-suite.sh create mode 100644 test_package/CMakeLists.txt create mode 100644 test_package/conanfile.py create mode 100644 test_package/src/pjson_package_test.cpp create mode 100644 tests/install-consumer/CMakeLists.txt create mode 100644 tests/install-consumer/main.cpp delete mode 100644 touch diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..36404b6 --- /dev/null +++ b/.clang-format @@ -0,0 +1,40 @@ +# clang-format configuration for pjson. +# Tuned to match the existing hand-written style so applying it is low-churn: +# 4-space indentation, members indented inside the namespace, pointers bound to +# the type, and constructor initializer lists broken before the comma. +--- +Language: Cpp +BasedOnStyle: LLVM +Standard: c++11 + +# Indentation +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ContinuationIndentWidth: 4 +NamespaceIndentation: All +AccessModifierOffset: -4 +IndentCaseLabels: true + +# Braces / wrapping +ColumnLimit: 100 +BreakBeforeBraces: Attach +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: false +AllowShortLoopsOnASingleLine: false +AllowShortCaseLabelsOnASingleLine: false + +# Pointers / references bind to the type: `pjson* p`, `const std::string& s`. +PointerAlignment: Left + +# Constructor initializer lists: one per line, comma first (matches the source). +BreakConstructorInitializers: BeforeComma +ConstructorInitializerIndentWidth: 8 +PackConstructorInitializers: Never + +# Misc +SortIncludes: CaseInsensitive +SpaceAfterCStyleCast: false +FixNamespaceComments: true +MaxEmptyLinesToKeep: 1 +KeepEmptyLinesAtTheStartOfBlocks: false diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..9d2f44d --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,49 @@ +# clang-tidy configuration for pjson. +# +# build.sh promotes enabled findings to errors so regressions fail CI. The check +# set favors real bugs and portability over stylistic noise. +# +# A few checks are disabled deliberately: +# *-magic-numbers the parser/base64 tables are inherently numeric +# *-avoid-c-arrays the static lookup tables are C arrays on purpose +# *-else-after-return the existing style uses else-after-return freely +# *-braces-around-statements matches the compact single-line style +# *-macro-usage the value-array setter/append macros are intentional +# *-exception-escape iterative teardown (dtor/move) uses a heap +# work-list whose push_back may throw bad_alloc; +# terminating on OOM during teardown is intended +# performance-noexcept-move-constructor +# cross-allocator move assignment may allocate and +# deliberately provides the strong guarantee +Checks: > + bugprone-*, + performance-*, + portability-*, + clang-analyzer-*, + misc-*, + modernize-use-nullptr, + modernize-use-override, + readability-misleading-indentation, + readability-non-const-parameter, + -bugprone-easily-swappable-parameters, + -bugprone-signed-char-misuse, + -bugprone-exception-escape, + -misc-no-recursion, + -misc-non-private-member-variables-in-classes, + -misc-const-correctness, + -misc-include-cleaner, + -performance-enum-size, + -performance-noexcept-move-constructor, + -cppcoreguidelines-*, + -readability-magic-numbers, + -readability-braces-around-statements, + -readability-else-after-return, + -modernize-avoid-c-arrays, + -cppcoreguidelines-avoid-magic-numbers + +WarningsAsErrors: '' + +# Only lint this project's own headers, not system/STL headers pulled in. +HeaderFilterRegex: 'pjson(lib|test)/' + +FormatStyle: file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..950e7f7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: Apache-2.0 + +name: Bug report +description: Report reproducible incorrect behavior in pjson +title: "[Bug]: " +body: + - type: markdown + attributes: + value: | + Thanks for helping improve pjson. Search existing issues before filing a + report. For a suspected vulnerability, stop here and follow SECURITY.md + instead of posting details publicly. + + - type: input + id: version + attributes: + label: pjson version + description: Provide a release version, tag, or full commit hash. + placeholder: "1.0.0 or commit abcdef1234..." + validations: + required: true + + - type: textarea + id: behavior + attributes: + label: What happened? + description: Describe the actual behavior and the result you expected. + placeholder: | + Actual behavior: + Expected behavior: + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: Provide the smallest complete program or input that reproduces the problem. + render: cpp + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + description: >- + Include the OS, architecture, compiler and version, CMake version, and + relevant build flags. + placeholder: | + OS and architecture: + Compiler and version: + CMake version: + Build type and flags: + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs and diagnostics + description: >- + Paste relevant compiler output, sanitizer reports, stack traces, or + parser errors. Remove secrets and personal data. + render: shell + + - type: textarea + id: context + attributes: + label: Additional context + description: Add anything else that may help, such as a regression range or workaround. + + - type: checkboxes + id: checks + attributes: + label: Pre-submission checklist + options: + - label: I searched existing issues for the same problem. + required: true + - label: I can reproduce this with a supported version or the current `main` branch. + required: true + - label: >- + This report does not contain confidential vulnerability details, + credentials, personal data, or other secrets. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a7bd679 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: Apache-2.0 + +blank_issues_enabled: false +contact_links: + - name: Report a Code of Conduct incident + url: https://github.com/Pico-Developer/pjson/security/advisories/new + about: Contact maintainers privately and identify the report as a conduct incident. + - name: Report a security vulnerability + url: https://github.com/Pico-Developer/pjson/security/advisories/new + about: Report suspected vulnerabilities privately; do not open a public issue. + - name: Read the documentation + url: https://github.com/Pico-Developer/pjson/tree/main/docs + about: Check the tutorials and usage guidance before filing an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..9fb0a41 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: Apache-2.0 + +name: Feature request +description: Propose a new capability or a deliberate behavior change +title: "[Feature]: " +body: + - type: markdown + attributes: + value: | + Please describe the user problem before prescribing an API. Significant + changes should be discussed before implementation. + + - type: textarea + id: problem + attributes: + label: Problem and motivation + description: What use case is difficult or impossible today, and who is affected? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: >- + Describe the behavior or API you would like and include a short usage + example where useful. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Describe current workarounds and other designs you considered. + validations: + required: true + + - type: dropdown + id: compatibility + attributes: + label: Compatibility impact + description: >- + Select the closest expected impact; maintainers will make the final + versioning decision. + options: + - Backward-compatible addition + - Behavior change with compatibility risk + - Breaking API or ABI change + - Unsure + validations: + required: true + + - type: textarea + id: context + attributes: + label: Additional context + description: >- + Add links, prior art, performance data, or constraints such as C++11 + and supported toolchains. + + - type: checkboxes + id: checks + attributes: + label: Pre-submission checklist + options: + - label: I searched existing issues for similar requests. + required: true + - label: I described the underlying use case, not only a preferred implementation. + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4b86d24 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,37 @@ + + + +## Summary + + + +## Related issue + + + +## Verification + + + +```text +./build.sh --all +``` + +## Compatibility and risk + + + +## Checklist + +- [ ] I have read and followed `CONTRIBUTING.md`. +- [ ] The change is focused and includes tests for new behavior and edge cases. +- [ ] I updated user documentation for public API or behavior changes. +- [ ] I added a concise `CHANGELOG.md` entry when the change is notable to users. +- [ ] I preserved C++11 compatibility and introduced no new dependency, or I + clearly justified the exception. +- [ ] I ran the relevant checks and recorded them above. +- [ ] I have the right to submit this contribution under the project's + Apache-2.0 contribution terms. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b7c50a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,337 @@ +# SPDX-License-Identifier: Apache-2.0 + +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + licensing: + name: REUSE licensing compliance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Validate SPDX and license metadata + run: | + python3 -m pip install --disable-pip-version-check reuse==6.2.0 + reuse lint + + fuzz: + name: libFuzzer bounded smoke + runs-on: ubuntu-latest + env: + CC: clang + CXX: clang++ + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install Clang + run: sudo apt-get update && sudo apt-get install -y clang + + - name: Build and replay fuzz corpora + shell: bash + run: ./build.sh --fuzz --auto + + distribution: + name: Distribution smoke tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Validate relocatable CMake and pkg-config packages + shell: bash + run: | + cmake \ + -DPJSON_SOURCE_DIR="${GITHUB_WORKSPACE}" \ + -DPJSON_WORK_DIR="${RUNNER_TEMP}/pjson-package-smoke" \ + -DPJSON_INSTALL_LIBDIR=lib64 \ + -DPJSON_REQUIRE_PKG_CONFIG=ON \ + -P cmake/RunInstallConsumer.cmake + + - name: Validate shared-library package + shell: bash + run: | + cmake \ + -DPJSON_SOURCE_DIR="${GITHUB_WORKSPACE}" \ + -DPJSON_WORK_DIR="${RUNNER_TEMP}/pjson-package-shared-smoke" \ + -DPJSON_BUILD_SHARED_LIBS=ON \ + -DPJSON_REQUIRE_PKG_CONFIG=ON \ + -P cmake/RunInstallConsumer.cmake + + conan: + name: Conan 2 package consumer + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install Conan + run: | + python3 -m pip install --disable-pip-version-check conan==2.31.2 + conan --version + + - name: Create and test package + run: | + conan profile detect --force + conan create . -s build_type=Release --build=missing + + vcpkg: + name: vcpkg overlay package consumer + runs-on: ubuntu-latest + env: + VCPKG_ROOT: ${{ runner.temp }}/vcpkg + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Bootstrap pinned vcpkg + run: | + git clone --depth 1 --branch 2026.07.29 \ + https://github.com/microsoft/vcpkg.git "${VCPKG_ROOT}" + test "$(git -C "${VCPKG_ROOT}" rev-parse HEAD)" = \ + 9e593bb18ea69cc5095e012465dcd675a822ed0d + "${VCPKG_ROOT}/bootstrap-vcpkg.sh" -disableMetrics + + - name: Build overlay port + run: >- + "${VCPKG_ROOT}/vcpkg" install pjson + --overlay-ports="${GITHUB_WORKSPACE}/packaging/vcpkg/ports" + + - name: Build and run installed-package consumer + run: | + cmake \ + -S tests/install-consumer \ + -B "${RUNNER_TEMP}/pjson-vcpkg-consumer" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE="${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" + cmake --build "${RUNNER_TEMP}/pjson-vcpkg-consumer" --parallel + ctest \ + --test-dir "${RUNNER_TEMP}/pjson-vcpkg-consumer" \ + --output-on-failure + + build-test: + name: Build and test (${{ matrix.os }} / ${{ matrix.toolchain }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + toolchain: gcc + cc: gcc + cxx: g++ + - os: ubuntu-latest + toolchain: clang + cc: clang + cxx: clang++ + - os: macos-latest + toolchain: clang + cc: clang + cxx: clang++ + env: + CC: ${{ matrix.cc }} + CXX: ${{ matrix.cxx }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install GCC + if: matrix.os == 'ubuntu-latest' && matrix.toolchain == 'gcc' + run: sudo apt-get update && sudo apt-get install -y g++ + + - name: Install Clang + if: matrix.os == 'ubuntu-latest' && matrix.toolchain == 'clang' + run: sudo apt-get update && sudo apt-get install -y clang + + - name: Build and run tests + shell: bash + run: ./build.sh --clean --test --debug-only --auto + + - name: Assert every harness case is registered with CTest + shell: bash + run: | + registered=$(ctest --test-dir out/build-debug -N | sed -n 's/Total Tests: //p') + discovered=$(./out/debug/bin/pjsontest --list-tests | wc -l | tr -d ' ') + test "$registered" -gt 0 + test "$registered" = "$discovered" + + msvc: + name: Build and test (Windows / MSVC) + runs-on: windows-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 + with: + arch: x64 + + - name: Configure + run: cmake -S . -B out/build-msvc -A x64 + + - name: Build + run: cmake --build out/build-msvc --config Debug --parallel + + - name: Run tests + run: ctest --test-dir out/build-msvc -C Debug --output-on-failure + + - name: Assert all CTest cases are registered + shell: pwsh + run: | + $registered = (ctest --test-dir out/build-msvc -C Debug -N | + Select-String 'Total Tests: (\d+)').Matches.Groups[1].Value + $runner = Get-ChildItem -Path out/build-msvc -Recurse -Filter pjsontest.exe | + Select-Object -First 1 + if (-not $runner) { throw "pjsontest.exe was not produced" } + $discovered = (& $runner.FullName --list-tests | Measure-Object -Line).Lines + if ([int]$registered -le 0) { throw "No CTest cases were registered" } + if ([int]$registered -ne [int]$discovered) { + throw "CTest registered $registered cases, harness discovered $discovered" + } + + format: + name: Format check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install clang-format + run: sudo apt-get update && sudo apt-get install -y clang-format + + - name: Check formatting + shell: bash + run: ./build.sh --check --release-only --auto + + tidy: + name: Clang-tidy + runs-on: ubuntu-latest + env: + CC: clang + CXX: clang++ + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install clang-tidy toolchain + run: sudo apt-get update && sudo apt-get install -y clang clang-tidy + + - name: Run clang-tidy + shell: bash + run: ./build.sh --clean --debug-only --tidy --auto + + benchmark: + name: Benchmark smoke test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Build and run baseline benchmarks + shell: bash + run: ./build.sh --clean --bench --release-only --auto + + benchmark-compare: + name: Benchmark comparison smoke test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Cache benchmark dependencies + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: .benchmark-deps + key: ${{ runner.os }}-benchmark-deps-v3.11.3-v1.1.0-v3.12.2 + + - name: Build and run comparison benchmarks + shell: bash + run: ./build.sh --clean --bench-compare --release-only --auto + + conformance: + name: Conformance corpus + runs-on: ubuntu-latest + env: + CC: clang + CXX: clang++ + PJSON_JSONTESTSUITE_DIR: ${{ github.workspace }}/.test-corpora/JSONTestSuite + PJSON_JSON_SCHEMA_TEST_SUITE_DIR: ${{ github.workspace }}/.test-corpora/JSON-Schema-Test-Suite + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install Clang + run: sudo apt-get update && sudo apt-get install -y clang + + - name: Cache JSONTestSuite corpus + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: .test-corpora/JSONTestSuite + key: ${{ runner.os }}-jsontestsuite-${{ hashFiles('scripts/fetch-json-test-suite.sh') }} + + - name: Cache JSON Schema corpus + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: .test-corpora/JSON-Schema-Test-Suite + key: ${{ runner.os }}-json-schema-suite-${{ hashFiles('scripts/fetch-json-schema-test-suite.sh') }} + + - name: Fetch JSONTestSuite + shell: bash + run: ./scripts/fetch-json-test-suite.sh + + - name: Fetch JSON-Schema-Test-Suite + shell: bash + run: ./scripts/fetch-json-schema-test-suite.sh + + - name: Build test binary + shell: bash + run: ./build.sh --clean --debug-only --auto + + - name: Run conformance tests + shell: bash + run: >- + ctest --test-dir out/build-debug --output-on-failure + -R '^pjson\.(conformance_|schema_official_)' + + lsan: + name: Linux ASan/UBSan/LSan + runs-on: ubuntu-latest + env: + CC: clang + CXX: clang++ + ASAN_OPTIONS: detect_leaks=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + PJSON_JSONTESTSUITE_DIR: ${{ github.workspace }}/.test-corpora/JSONTestSuite + PJSON_JSON_SCHEMA_TEST_SUITE_DIR: ${{ github.workspace }}/.test-corpora/JSON-Schema-Test-Suite + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install Clang + run: sudo apt-get update && sudo apt-get install -y clang + + - name: Cache JSONTestSuite corpus + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: .test-corpora/JSONTestSuite + key: ${{ runner.os }}-jsontestsuite-${{ hashFiles('scripts/fetch-json-test-suite.sh') }} + + - name: Cache JSON Schema corpus + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: .test-corpora/JSON-Schema-Test-Suite + key: ${{ runner.os }}-json-schema-suite-${{ hashFiles('scripts/fetch-json-schema-test-suite.sh') }} + + - name: Fetch JSONTestSuite + shell: bash + run: ./scripts/fetch-json-test-suite.sh + + - name: Fetch JSON-Schema-Test-Suite + shell: bash + run: ./scripts/fetch-json-schema-test-suite.sh + + - name: Run sanitized test suite + shell: bash + run: ./build.sh --clean --asan --test --auto + + - name: Run pathological input tests explicitly + shell: bash + run: ctest --test-dir out/build-debug --output-on-failure -R '^pjson\.pathological_' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..e8f720e --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,96 @@ +name: Documentation + +on: + push: + branches: + - main + pull_request: + release: + types: + - published + workflow_dispatch: + +permissions: + contents: read + +jobs: + reference: + name: Build and validate API reference + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install Doxygen + run: sudo apt-get update && sudo apt-get install -y doxygen + + - name: Configure documentation + run: >- + cmake -S . -B out/build-docs + -DPJSON_BUILD_TESTS=OFF + -DPJSON_BUILD_EXAMPLES=OFF + -DPJSON_BUILD_BENCHMARKS=OFF + -DPJSON_BUILD_DOCS=ON + + - name: Build and validate documentation + run: cmake --build out/build-docs --target pjson-docs-check + + - name: Upload browsable reference + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: pjson-api-reference + path: out/build-docs/docs/reference/html + if-no-files-found: error + + - name: Prepare GitHub Pages artifact + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + id: pages-artifact + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 + with: + path: out/build-docs/docs/reference/html + + pages: + name: Publish API reference to GitHub Pages + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + needs: reference + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + concurrency: + group: github-pages + cancel-in-progress: false + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Configure GitHub Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 + + - name: Deploy + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 + + release-archive: + name: Attach API reference to release + if: github.event_name == 'release' + needs: reference + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download generated reference + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: pjson-api-reference + path: reference-html + + - name: Package release documentation + run: tar -C reference-html -czf pjson-api-reference.tar.gz . + + - name: Attach documentation archive + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: >- + gh release upload "${RELEASE_TAG}" + pjson-api-reference.tar.gz diff --git a/.gitignore b/.gitignore index 240d3d4..48342fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,45 @@ # MACOS files .DS_Store +# Python bytecode/cache generated by packaging and documentation checks +__pycache__/ +*.py[cod] + # CMake build folder build/ +cmake-build-*/ + +# build.sh output folder +out/ + +# Downloaded test corpora (managed by scripts/fetch-json-test-suite.sh) +.test-corpora/ + +# Optional third-party benchmark comparison sources +.benchmark-deps/ + +# Coverage-guided fuzz runtime state (committed seeds stay in fuzz/corpus/) +.fuzz-corpus/ +.fuzz-artifacts/ + +# CMake artifacts (in case CMake is ever run in-source) +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +CTestTestfile.cmake +compile_commands.json +CMakeUserPresets.json +Makefile #vscode settings .vscode/ +.idea/ + +# Editor backups and local logs +*~ +*.swp +*.swo +*.log # Prerequisites *.d diff --git a/AUTHORS b/AUTHORS index abc12bb..6fb1b02 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1 +1 @@ -Praveen Babu J D (ByteDance Ltd) \ No newline at end of file +Praveen Babu J D (ByteDance Ltd) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d930f26 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,119 @@ + + + +# Changelog + +All notable changes to pjson are documented in this file. The format is based +on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and releases follow +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Introduced the versioned C++11 API for pjson 1.0, including compile-time + version macros and `pjson::getVersion()`. +- Added strict RFC 8259 DOM and SAX parsing from strings, byte spans, and + streams, with structured line/column diagnostics, duplicate-key policies, + and configurable input, node, and nesting limits. +- Added configurable compact and pretty serialization, direct stream output, + non-ASCII escaping, key-order selection, and output-size limits. +- Added non-mutating lookup and strict typed access, including negative array + indexing, borrowed string views, type predicates, container queries, erase, + clear, swap, and deep structural equality. +- Added RFC 6901 JSON Pointer lookup, atomic RFC 6902 JSON Patch, and atomic + RFC 7396 Merge Patch with structured errors and resource budgets. +- Added a documented JSON Schema subset with local references, object and + composition vocabularies, known string formats, collected errors, exact + numeric comparisons, and configurable validation budgets. +- Added allocator-aware DOM construction, parsing, copying, and ownership with + provenance-preserving deletion. +- Added relocatable CMake and pkg-config packages, the `pjson::pjson` target, + Conan 2 and vcpkg recipes, and consumer installation tests. +- Added tutorials, runnable examples, migration guides, generated API + documentation, comparative benchmarks, cross-platform CI, sanitizer and + conformance testing, and four libFuzzer/OSS-Fuzz targets. +- Added security, release, versioning, licensing, governance, and contributor + documentation, GitHub contribution templates, and REUSE licensing checks. + +### Changed + +- Parsing is now always RFC 8259-strict and rejects duplicate object keys by + default; callers may explicitly keep the first or last duplicate. +- Integer and floating-point values now use `int64_t` and `double` + representations and APIs instead of `int` and `float`. +- Compact serialization now emits no insignificant whitespace, while pretty + serialization uses conventional nested indentation instead of the previous + key-aligned format. +- Tree serialization, copying, equality comparison, destruction, and deep + Merge Patch traversal now avoid recursive whole-tree walks. +- CMake builds repository tests, examples, and benchmarks by default only for + top-level developer builds; `add_subdirectory()` consumers receive just the + library unless they opt in. +- The public header is declaration-focused, with implementation helpers kept + in the library source. + +### Fixed + +- Correctly escape JSON strings and object keys and decode JSON escapes, + Unicode code points, and surrogate pairs so empty and escaped strings + round-trip. +- Reject malformed, truncated, trailing-garbage, invalid UTF-8, invalid escape, + invalid number, and out-of-range numeric input without exposing partial parse + results. +- Correct negative array indexing and prevent pathological indexed access from + causing unbounded array growth. +- Preserve numeric kind and round-trip finite binary64 values, and compare + mixed integer/double values exactly beyond the binary64 exact-integer range. +- Correct schema Unicode-length counting, numeric-bound and `multipleOf` + precision behavior, malformed keyword handling, format validation, and + speculative combinator error reporting. +- Preserve destination state and structured errors on allocation, output-budget, + and Patch/Merge Patch failures. + +### Security + +- Added bounded parser, serializer, Patch/Merge Patch, and schema-validation + work to limit depth, memory and output amplification, reference traversal, + regular-expression backtracking, and diagnostic growth for untrusted input. +- Added strict UTF-8 and escape validation, safe duplicate-key defaults, regex + validation and caching, constant-space speculative validation, and atomic + mutation behavior. + +### Removed + +- Removed the raw-pointer `CreateFromString()` parsing API in favor of + allocator-aware `parse()` APIs returning `pjson::unique_ptr`. +- Removed mutable container exposure and coercive accessors: `PJSONARRAY`, + `PJSONMAP`, `getArray()`, `getMap()`, `getInt()`, `getFloat()`, `getBool()`, + `getString()`, `at()`, `getIfExist()`, and `getArrayValues()`. +- Removed the standalone JSON/Base64 encoding and decoding helpers. +- Removed `int`, `float`, C-string-vector, and corresponding vector convenience + assignment/append overloads; use explicit `int64_t`, `double`, and supported + vector types. +- Renamed the public type tags `jsonNumberFloat` and `jsonMap` to + `jsonNumberDouble` and `jsonObject`. + +## [0.0.3] - 2025-05-30 + +### Fixed + +- Removed a redundant `lib` prefix from CMake target output names. + +## [0.0.2] - 2025-05-30 + +### Added + +- Generic JSON and Base64 encoding/decoding helpers. +- Object-key existence checks before value extraction. + +## [0.0.1] - 2025-04-24 + +### Added + +- Initial pjson source release. + +[Unreleased]: https://github.com/Pico-Developer/pjson/compare/release-0.0.3...HEAD +[0.0.3]: https://github.com/Pico-Developer/pjson/compare/release-0.0.2...release-0.0.3 +[0.0.2]: https://github.com/Pico-Developer/pjson/compare/release-0.0.1...release-0.0.2 +[0.0.1]: https://github.com/Pico-Developer/pjson/releases/tag/release-0.0.1 diff --git a/CMakeLists.txt b/CMakeLists.txt index 31639a3..f5d7618 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,12 +1,108 @@ -# Include sub-projects. +# SPDX-License-Identifier: Apache-2.0 +# ---- Project and common toolchain policy -------------------------------- + cmake_minimum_required (VERSION 3.21) -project(pjson LANGUAGES CXX) +project(pjson VERSION 1.0.0 DESCRIPTION "Praveen's JSON library for C++" LANGUAGES CXX) + +# Keep package/runtime version authorities synchronized at configure time. The +# release process updates them together; a mismatch is a hard configuration +# error rather than a silently inconsistent distribution. +file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/pjsonlib/include/pjson.h" + PJSON_HEADER_VERSION_LINE REGEX "^#define PJSON_VERSION \"[^\"]+\"$") +string(REGEX REPLACE "^#define PJSON_VERSION \"([^\"]+)\"$" "\\1" + PJSON_HEADER_VERSION "${PJSON_HEADER_VERSION_LINE}") +set(PJSON_VERSION_SOURCES PJSON_HEADER_VERSION) +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/conanfile.py") + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/conanfile.py" PJSON_CONAN_RECIPE) + string(REGEX MATCH "version = \"([^\"]+)\"" PJSON_CONAN_VERSION_MATCH + "${PJSON_CONAN_RECIPE}") + set(PJSON_CONAN_VERSION "${CMAKE_MATCH_1}") + list(APPEND PJSON_VERSION_SOURCES PJSON_CONAN_VERSION) +endif() +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/packaging/vcpkg/ports/pjson/vcpkg.json") + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/packaging/vcpkg/ports/pjson/vcpkg.json" + PJSON_VCPKG_MANIFEST) + string(REGEX MATCH "\"version-semver\"[ \t]*:[ \t]*\"([^\"]+)\"" + PJSON_VCPKG_VERSION_MATCH "${PJSON_VCPKG_MANIFEST}") + set(PJSON_VCPKG_VERSION "${CMAKE_MATCH_1}") + list(APPEND PJSON_VERSION_SOURCES PJSON_VCPKG_VERSION) +endif() +foreach(PJSON_VERSION_SOURCE IN LISTS PJSON_VERSION_SOURCES) + if(NOT "${${PJSON_VERSION_SOURCE}}" STREQUAL "${PROJECT_VERSION}") + message(FATAL_ERROR + "Version mismatch: ${PJSON_VERSION_SOURCE}=${${PJSON_VERSION_SOURCE}}, " + "CMake project=${PROJECT_VERSION}") + endif() +endforeach() + +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +# Repository-only targets are useful when pjson is the configured project, but +# must stay out of a parent project's default build when consumed with +# add_subdirectory(). These project-specific switches deliberately do not write +# to the parent-owned BUILD_TESTING cache entry. +if(CMAKE_SOURCE_DIR STREQUAL PROJECT_SOURCE_DIR) + set(PJSON_DEVELOPER_DEFAULT ON) + # Preserve the conventional top-level BUILD_TESTING switch without + # introducing it into a parent project's cache during add_subdirectory(). + include(CTest) +else() + set(PJSON_DEVELOPER_DEFAULT OFF) +endif() +if(DEFINED BUILD_TESTING AND NOT BUILD_TESTING) + set(PJSON_TEST_DEFAULT OFF) +else() + set(PJSON_TEST_DEFAULT ${PJSON_DEVELOPER_DEFAULT}) +endif() +option(PJSON_BUILD_TESTS "Build the pjson test suite" ${PJSON_TEST_DEFAULT}) +option(PJSON_BUILD_EXAMPLES "Build the pjson examples" ${PJSON_DEVELOPER_DEFAULT}) +option(PJSON_BUILD_BENCHMARKS "Build the pjson benchmarks" ${PJSON_DEVELOPER_DEFAULT}) +unset(PJSON_DEVELOPER_DEFAULT) +unset(PJSON_TEST_DEFAULT) +if(PJSON_BUILD_TESTS AND (NOT DEFINED BUILD_TESTING OR BUILD_TESTING)) + enable_testing() +endif() set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED On) set(CMAKE_CXX_EXTENSIONS Off) -# Include sub-projects. +# ---- Optional instrumentation ------------------------------------------ + +# Optional AddressSanitizer + UndefinedBehaviorSanitizer build (see build.sh +# --asan). Applied to every target so the library and tests are instrumented. +# Only meaningful for GCC/Clang; ignored on MSVC. +option(PJSON_SANITIZE "Build with Address/UB sanitizers" OFF) +if(PJSON_SANITIZE AND NOT MSVC) + add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer -g) + add_link_options(-fsanitize=address,undefined) +endif() + +option(PJSON_BUILD_FUZZERS "Build coverage-guided libFuzzer targets" OFF) +set(PJSON_FUZZING_ENGINE "" CACHE STRING + "External fuzzing engine linker input (for example OSS-Fuzz LIB_FUZZING_ENGINE)") + +# ---- Project targets ---------------------------------------------------- + +# Build the library in every configuration. Tests participate in CTest only +# when both the project switch and the standard BUILD_TESTING switch are on. add_subdirectory ("pjsonlib") -add_subdirectory ("pjsontest") \ No newline at end of file +if(PJSON_BUILD_FUZZERS) + add_subdirectory("fuzz") +endif() +if(PJSON_BUILD_TESTS AND (NOT DEFINED BUILD_TESTING OR BUILD_TESTING)) + add_subdirectory ("pjsontest") +endif() +if(PJSON_BUILD_EXAMPLES) + add_subdirectory ("examples") +endif() +if(PJSON_BUILD_BENCHMARKS) + add_subdirectory ("bench") +endif() + +option(PJSON_BUILD_DOCS "Build and validate the browsable API reference" OFF) +if(PJSON_BUILD_DOCS) + add_subdirectory("docs") +endif() diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9a8ff0d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,136 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances + of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported privately through the repository's +[security-advisory form](https://github.com/Pico-Developer/pjson/security/advisories/new). +Select **Start a report** and state that the report concerns the Code of +Conduct; do not open a public issue. If the form is unavailable, open a public +issue containing only a request for a private maintainer contact channel and no +sensitive details. + +All complaints will be reviewed and investigated promptly and fairly. All +community leaders are obligated to respect the privacy and security of the +reporter of any incident. A leader who is the subject of a report will not take +part in handling it. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at the [Contributor Covenant version 2.1 page][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the +[Contributor Covenant FAQ][FAQ]. Translations are available on the +[translations page][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a7e083..2441ea6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,35 +1,19 @@ - - - - - - - - - - - - - - - - + + # How to Contribute We'd love to accept your patches and contributions to this project. There are just a few small guidelines you need to follow. -## Contributor License Agreement +## Contribution license -Contributions to this project must be accompanied by a Contributor License -Agreement. You (or your employer) retain the copyright to your contribution; -this simply gives us permission to use and redistribute your contributions as -part of the project. - -You generally only need to submit a CLA once, so if you've already submitted one -(even if it was for a different project), you probably don't need to do it -again. +This project does not require a separate contributor license agreement. Unless +you explicitly state otherwise, any contribution intentionally submitted for +inclusion in pjson is licensed under Apache-2.0, as described by section 5 of +the project's [`LICENSE`](LICENSE). You represent that you have the right to +submit the contribution under those terms. Preserve applicable copyright and +attribution notices and see [`LICENSING.md`](LICENSING.md) for repository policy. ## Changes Accepted @@ -37,13 +21,28 @@ Please file issues before doing substantial work; this will ensure that others don't duplicate the work and that there's a chance to discuss any design issues. Changes only tweaking style are unlikely to be accepted unless they are applied -consistently across the project. Most of the code style is derived from the -[Google Style Guides](http://google.github.io/styleguide/) for the appropriate -language and is generally not something we accept changes on (as clang-format -and clang-tidy handle that for us). The compiler portion of the project follows -[MLIR style](https://mlir.llvm.org/getting_started/DeveloperGuide/#style-guide). -Improvements to code structure and clarity are welcome but please file issues to -track such work first. +consistently across the project. Code style is enforced by the checked-in +`.clang-format` and `.clang-tidy` configurations — run `./build.sh --format` to +apply formatting and `./build.sh --tidy` for static analysis. See +[`docs/10-contributing.md`](docs/10-contributing.md) for the full contributor +guide. Improvements to code structure and clarity are welcome, but please file +issues to track substantial work first. + +### Readability expectations + +- Give each function a concise purpose or contract comment. For overload + families and trivial callbacks, one shared comment may cover the group when + individual comments would only repeat signatures. +- Explain invariants and tradeoffs in complex code: ownership, allocator + provenance, rollback/atomicity, resource budgets, iterative traversal state, + standards edge cases, and security limits are more valuable than narration + of individual statements. +- Organize large implementation files with named subsystem headings. Avoid + anonymous separator bars and comments that merely translate syntax into + English. +- Keep comments synchronized with behavior and standards references. A stale + comment is a correctness defect, not harmless documentation drift. +- Edit the canonical public header and implementation under `pjsonlib/`. ## AUTHORS file @@ -53,17 +52,53 @@ those who have made significant contributions to the project. Please add the entity who owns the copyright for your contribution. The source control history remains the most accurate source for individual contributions. -## Pull Requests +## Pull Requests + We actively welcome your pull requests. -1. Fork the repo and create your branch from `master`. +1. Fork the repo and create your branch from `main`. 2. If you've added code that should be tested, add tests. 3. If you've changed APIs, update the documentation. -4. Ensure the test suite passes. -5. Make sure your code lints. -6. If you haven't already, complete the Contributor License Agreement ("CLA"). +4. Ensure the full sweep passes: `./build.sh` (equivalently `--all`, which runs + the formatting check, Release + sanitized Debug builds, all registered test + cases, comparison benchmarks, bounded fuzz corpus replay when supported, the + Doxygen reference, relocatable static/shared install and pkg-config checks, + SPDX/REUSE licensing validation, and clang-tidy). It offers to fetch both + pinned conformance corpora; `--auto` + accepts those downloads. An explicit `./build.sh --fuzz` request is strict and + fails if a usable Clang/libFuzzer toolchain is unavailable. +5. Make sure your code is formatted (`./build.sh --format`). +6. Run the focused documentation or package checks in + [`docs/10-contributing.md`](docs/10-contributing.md) when those areas change. +7. Add a concise [`CHANGELOG.md`](CHANGELOG.md) entry for a notable + user-visible change. +8. When adding files or changing license metadata, run `reuse lint`; see + [`LICENSING.md`](LICENSING.md) for the repository's annotation policy. ## Issues We use GitHub issues to track public bugs. Please ensure your description is clear and has sufficient instructions to be able to reproduce the issue. + +Do not report suspected vulnerabilities in a public issue. Follow +[`SECURITY.md`](SECURITY.md) for private reporting instead. + +## Project policies + +The repository keeps its public maintenance and governance information in these +top-level files: + +- [`SECURITY.md`](SECURITY.md) explains supported versions, private + vulnerability reporting, and coordinated disclosure. +- [`VERSIONING.md`](VERSIONING.md) defines compatibility, version sources, and + tag naming. +- [`RELEASING.md`](RELEASING.md) is the maintainer release checklist. +- [`CHANGELOG.md`](CHANGELOG.md) records notable user-visible changes. +- [`GOVERNANCE.md`](GOVERNANCE.md) describes maintainer responsibilities and + project decision making. +- [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) defines community standards and + the private reporting route for conduct incidents. +- [`LICENSING.md`](LICENSING.md) explains licensing and SPDX requirements; the + canonical license text is in [`LICENSE`](LICENSE). +- [`AUTHORS`](AUTHORS) and [`CONTRIBUTORS`](CONTRIBUTORS) record project + authorship and contributor recognition. diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 38aa24e..5ae3bf3 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -1,3 +1,3 @@ The pjson package and source code includes contributors from -Praveen Babu J D (ByteDance Ltd) \ No newline at end of file +Praveen Babu J D (ByteDance Ltd) diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..f3fffab --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,41 @@ + + + +# Project Governance + +pjson is maintained in the open in the `Pico-Developer/pjson` repository. +Repository maintainers are responsible for technical direction, reviews, +releases, security response, and community moderation. + +## Decisions and changes + +Bug fixes and focused maintenance changes are decided through pull-request +review. Substantial API, compatibility, security, dependency, or governance +changes should begin with a public issue so constraints and alternatives can be +discussed before implementation. Maintainers seek rough consensus, with the +project's documented user contract, security, maintainability, and test evidence +taking priority. Maintainers make the final call when consensus cannot be reached +and record the reasoning publicly unless confidentiality is required for +security or conduct matters. + +Changes are merged by a maintainer after review. Authors should not approve their +own changes when another maintainer is available. In a single-maintainer or +urgent security situation, the author may merge after required CI passes if the +reasoning is recorded in the pull request or, for embargoed work, the private +advisory. Releases follow [`RELEASING.md`](RELEASING.md). Security reports and +conduct incidents use the private routes in [`SECURITY.md`](SECURITY.md) and +[`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md), respectively. + +## Maintainers + +Maintainers are expected to review contributions respectfully, disclose relevant +conflicts of interest, protect embargoed reports, keep release and ownership +metadata current, and apply project policies consistently. Maintainer access may +be granted to sustained contributors based on technical judgment, review quality, +reliability, and adherence to the Code of Conduct. The existing maintainers make +that decision through a reviewed repository change. + +Inactive maintainers may step down or be removed from ownership metadata after a +reasonable attempt to contact them. Administrative access can be revoked +immediately when needed to protect the project or its users. Governance changes +use the same public review process as other substantial project changes. diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSES/CC-BY-4.0.txt b/LICENSES/CC-BY-4.0.txt new file mode 100644 index 0000000..13ca539 --- /dev/null +++ b/LICENSES/CC-BY-4.0.txt @@ -0,0 +1,156 @@ +Creative Commons Attribution 4.0 International + + Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. + +Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors. + +Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public. + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. + +Section 1 – Definitions. + + a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. + + d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. + + g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights under this Public License. + + i. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. + +Section 2 – Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: + + A. reproduce and Share the Licensed Material, in whole or in part; and + + B. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. + + 3. Term. The term of this Public License is specified in Section 6(a). + + 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. + + 5. Downstream recipients. + + A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. + + B. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. + + 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). + +b. Other rights. + + 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this Public License. + + 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. + +Section 3 – License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified form), You must: + + A. retain the following if it is supplied by the Licensor with the Licensed Material: + + i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of warranties; + + v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; + + B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and + + C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. + + 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License. + +Section 4 – Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; + + b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. +For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. + +Section 5 – Disclaimer of Warranties and Limitation of Liability. + + a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You. + + b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You. + + c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. + +Section 6 – Term and Termination. + + a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or + + 2. upon express reinstatement by the Licensor. + + c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. + + d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. + + e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. + +Section 7 – Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. + +Section 8 – Interpretation. + + a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. + + c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. + + d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. + +Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/LICENSING.md b/LICENSING.md new file mode 100644 index 0000000..137eb5a --- /dev/null +++ b/LICENSING.md @@ -0,0 +1,52 @@ + + + +# Licensing and SPDX Policy + +pjson is licensed under the Apache License, Version 2.0. The authoritative +license text is the repository's `LICENSE` file. Do not modify or abbreviate +that canonical text. + +The SPDX short identifier for this license is: + +```text +Apache-2.0 +``` + +New first-party files should carry an SPDX copyright line and license identifier +in the comment syntax appropriate to the file. For example: + +```cpp +// SPDX-FileCopyrightText: 2026 ByteDance Ltd. and/or its affiliates +// SPDX-License-Identifier: Apache-2.0 +``` + +```markdown + + +``` + +Existing files that carry the full Apache-2.0 notice remain valid. The root +`REUSE.toml` supplies Apache-2.0 and ByteDance copyright metadata to first-party +files that do not have complete in-file SPDX information. It also records the +same metadata externally for machine-consumed fuzz corpus files, whose bytes +must not be changed by inserting comments. + +Do not perform mechanical license-header churn in generated files or +third-party material. Preserve existing copyright, license, and attribution +notices when modifying or redistributing files. A third-party file must carry +its own complete SPDX information or have a more specific `REUSE.toml` +annotation so that the project's default annotation does not apply to it. + +Third-party code, generated distributions, and dependency bundles must retain +their own applicable notices. `CODE_OF_CONDUCT.md` is adapted from Contributor +Covenant 2.1 and remains licensed under CC-BY-4.0, as recorded in `REUSE.toml`; +all other currently tracked project files are licensed under Apache-2.0. A pjson +source or binary distribution must include the root `LICENSE` file and the +applicable texts in `LICENSES/`. If future dependencies require additional +attribution, record it separately without changing the terms of the pjson +license. + +Unless explicitly stated otherwise, contributions intentionally submitted for +inclusion in pjson are provided under Apache-2.0, consistent with section 5 of +the license and the contributor requirements in `CONTRIBUTING.md`. diff --git a/README.md b/README.md index ed24bd5..e4e34bb 100644 --- a/README.md +++ b/README.md @@ -1,158 +1,1270 @@ -# Praveen's JSON - -- An Ultra Simple JSON parser for C++. -- PJSON's intent is to allow a very simple way to create and access JSON data in C++. -- Apache license with credit given to the author :) "Praveen Babu J D" ---- -# Building / Installation -## Option 1 : Direct inclusion -- The header file is in "pjsonlib/include/pjson.h" -- The Source file(s) is in "pjsonlib/src/*" -- You should be able to directly include the above files into you code. - -## Option 2 : Make a library -- Make a library see: "pjsonlib/CMakeLists.txt" -- Include the "pjson.h" into your project -- Link "libpjson" lib into your executable or lib - -# Usage -## Include the header file -```C++ +# pjson — Praveen's JSON + +**pjson** (short for **P**raveen's **JSON**) is an ultra-simple JSON library for C++. + +- Its intent is to make creating and accessing JSON data in C++ as simple as possible. +- A single class, `ByteDance::pjson`, represents any JSON value (null, bool, + number, string, array, or object) and provides an ergonomic + `obj["key"][i] = value` style API. +- Licensed under Apache-2.0; please keep credit to the author, Praveen Babu J D. +- Current source version: **1.0.0** (`pjson::getVersion()` / the + `PJSON_VERSION` macro). This version remains unreleased until the + `release-1.0.0` tag is published. + +--- + +## Table of contents + +- [Building / Installation](#building--installation) +- [Quick start](#quick-start) +- [Creating & building JSON](#creating--building-json) +- [Serializing (`toString`)](#serializing-tostring) +- [Parsing (`parse`)](#parsing) +- [Streaming large documents](#streaming-large-documents) +- [Reading values](#reading-values) +- [Reading arrays](#reading-arrays) +- [Reading objects / maps](#reading-objects--maps) +- [Safe vs. vivifying access](#safe-vs-vivifying-access) +- [Editing an existing document](#editing-an-existing-document) +- [Inspecting, comparing & modifying](#inspecting-comparing--modifying) +- [JSON Pointer, Patch & Merge Patch](#json-pointer-patch--merge-patch) +- [Numbers](#numbers) +- [Schema validation](#schema-validation) +- [Error handling & allocator ownership](#error-handling--allocator-ownership) +- [API reference (cheat sheet)](#api-reference-cheat-sheet) +- [Testing & fuzzing](#testing--fuzzing) +- [Benchmarking](#benchmarking) +- [Documentation & project resources](#documentation--project-resources) +- [Limitations](#limitations) + +--- + +## Building / Installation + +### Option 0 : `build.sh` (quickest) + +From the repo root, run the bundled script. It configures CMake and builds the +library, tests, and examples in **both Release and Debug**, collecting the +artifacts into an `out/` folder. Works on Linux, macOS, and Windows (Git Bash / +MSYS2): +```sh +./build.sh # full verification sweep (same as --all) +./build.sh --all # same as above, explicitly +./build.sh --test # just build both configs, then run the unit tests +./build.sh --bench # run dependency-free Release benchmarks +./build.sh --bench-compare --auto # compare with three pinned libraries +./build.sh --fuzz --auto # bounded libFuzzer corpus smoke +./build.sh --docs --auto # generate and validate API reference +./build.sh --package --auto # relocatable static/shared install + pkg-config checks +./build.sh --license --auto # validate SPDX/REUSE licensing metadata +./build.sh --clean # remove out/ first, then build +./build.sh --release-only # or --debug-only, to build just one config +``` +Resulting layout: + +```text +out/ + include/pjson.h public header + release/lib/libpjson.a Release library + release/bin/pjsontest Release test runner + release/bin/pjsonbench Release benchmark runner + release/bin/examples/ Release example programs + debug/... the same, built as Debug +``` +Missing tools (`cmake`, `clang-format`, `clang-tidy`, Doxygen) are detected and, with +your confirmation, installed via the system package manager; pass `--auto` to +install without prompting. Run `./clean.sh` to remove all generated files, +including both pinned test corpora and benchmark dependencies under +`.test-corpora/` and `.benchmark-deps/`. + +### Option 1 : Direct source integration + +Add the canonical library sources directly to your project and put +`pjsonlib/include` on its include path. There are no third-party dependencies: + +- Public header: [`pjsonlib/include/pjson.h`](pjsonlib/include/pjson.h) +- Implementation: [`pjsonlib/src/pjson.cpp`](pjsonlib/src/pjson.cpp) + +### Option 2 : Build with CMake directly + +```sh +cmake -S . -B build +cmake --build build +``` + +### Option 3 : Use from CMake + +`add_subdirectory(pjson)` in your project and link the exported target: +```cmake +add_subdirectory(pjson) +target_link_libraries(myapp PRIVATE pjson::pjson) +``` +Or install a relocatable package: + +```sh +cmake -S . -B build -DCMAKE_INSTALL_PREFIX=/desired/prefix \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF +cmake --build build --config Release +cmake --install build --config Release +``` + +Consumers use the same target after pointing CMake at that prefix: + +```cmake +find_package(pjson 1.0 CONFIG REQUIRED) +target_link_libraries(myapp PRIVATE pjson::pjson) +``` + +The install also provides relocatable `pkg-config` metadata (`pkg-config +--cflags --libs pjson`). Repository-local [Conan 2](conanfile.py) and +[vcpkg overlay](packaging/vcpkg/ports/pjson) recipes are available, but their +presence here does not imply publication in a public package registry: + +```sh +conan create . --build=missing +vcpkg install pjson --overlay-ports=packaging/vcpkg/ports +``` + +--- + +## Quick start + +For a standalone first program and its exact compile command, see the +[hello-world tutorial](docs/01-getting-started.md) and its canonical +[source file](examples/src/01_hello_world.cpp). + +```cpp #include "pjson.h" +#include +#include using namespace ByteDance; + +int main() { + // Build a document + pjson person; + person["name"] = "Ada"; + person["age"] = int64_t(36); + person["active"] = true; + person["scores"][0] = int64_t(90); + person["scores"][1] = int64_t(82); + person["scores"][2] = int64_t(77); + person["address"]["city"] = "London"; + + const pjson::SerializeOptions pretty = + pjson::SerializeOptions::prettyPrinted(); + std::cout << person.toString(pretty) << "\n"; + + // Every DOM parse returns an owning pjson::unique_ptr (empty on error). + pjson::unique_ptr parsed = pjson::parse(person.toString()); + if (parsed) { + std::string name; + int64_t age = 0; + if (parsed->tryGet("name", name) && parsed->tryGet("age", age)) { + std::cout << "name = " << name << "\n"; + std::cout << "age = " << age << "\n"; + } + } +} +``` + +--- + +## Creating & building JSON + +Assigning to `operator[]` builds the tree as you go — intermediate maps and +arrays are created automatically. + +```cpp +pjson j; + +// Object of key/value +j["myKey1"] = "Value1"; // const char* -> string +j["myKey2"] = std::string("v2"); // std::string -> string + +// Nested object +j["myKey3"]["myInteger"] = int64_t(1); // signed 64-bit integer +j["myKey3"]["myFloat"] = double(1.0); // double + +// Build an array element by element +for (int64_t i = 0; i != 7; ++i) + j["myKey4"][static_cast(i)] = i; + +// Direct array indexing (extends the array as needed) +j["myKey4"][7] = int64_t(7); +j["myKey4"][8] = "Eight"; // arrays may hold mixed types + +// Deep nesting: map -> array -> map -> value +j["myKey4"][9]["ninth"] = double(9.0); + +// Take a reference to a sub-node and keep building through it +pjson& doubles = j["myKey3"]["myFloatArray"]; +doubles[0] = double(0.0); +doubles[1] = double(1.1); +doubles[3] = double(4.4); // index 2 is auto-filled with null +``` + +One indexed access may create at most 1,000,000 children. An access that would +cross that growth limit throws `std::length_error` before mutating the value. + +Append to an array with `+=` (it promotes the node to an array if needed): + +```cpp +pjson list; +list += int64_t(1); // [1] +list += "two"; // [1,"two"] +list += int64_t(3); // [1,"two",3] +list += int64_t(4); // [1,"two",3,4] +``` + +The `=` and `+=` operators accept strings, `bool`, `int64_t`, and `double`, as +well as vectors of `std::string`, `bool`, `int64_t`, or `double`. Convenience +overloads for `int`, `float`, and vectors of those types or C strings are not +part of the API. + +The document built above serializes to: +```json +{ + "myKey1": "Value1", + "myKey2": "v2", + "myKey3": { + "myFloat": 1.0, + "myFloatArray": [ + 0.0, + 1.1, + null, + 4.4 + ], + "myInteger": 1 + }, + "myKey4": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + "Eight", + { + "ninth": 9.0 + } + ] +} +``` +The skipped array position `[2]` is auto-filled with `null` by +`doubles[3] = 4.4`. Object keys come out in sorted order. + +--- + +## Serializing (`toString`) + +```cpp +std::string compact = person.toString(); // single line, no extra spaces +pjson::SerializeOptions prettyOptions = + pjson::SerializeOptions::prettyPrinted(); +std::string pretty = person.toString(prettyOptions); +``` + +Use `SerializeOptions` when formatting must be explicit or reproducible: + +```cpp +pjson::SerializeOptions output = pjson::SerializeOptions::prettyPrinted(); +output.indentWidth = 4; +output.indentCharacter = ' '; // only space or tab; other values fall back to space +output.escapeNonAscii = true; +output.keyOrder = pjson::SerializeOptions::DescendingKeys; +output.maxOutputBytes = size_t(64) * 1024 * 1024; + +std::string text = person.toString(output); +person.write(std::cout, output); +``` + +Defaults are compact output, two-space indentation when `pretty` is enabled, +raw UTF-8, ascending bytewise key order, and a 64 MiB output limit. Set +`maxOutputBytes = 0` only when explicitly requesting unlimited output. Use +`SerializeOptions::prettyPrinted()` for two-space pretty output. Because objects +use `std::map`, insertion order is not retained; serialization can select +ascending or descending order. + +For the document built in [Quick start](#quick-start): + +**Compact** — note that object keys are emitted in sorted order: +```json +{"active":true,"address":{"city":"London"},"age":36,"name":"Ada","scores":[90,82,77]} ``` -## Creating JSON -```C++ - pjson oJson; - // To create a JSON map of key Value - oJson["myKey1"] = "Value1"; - oJson["myKey2"] = "Value2"; +**Pretty** (`toString(SerializeOptions::prettyPrinted())`): +```json +{ + "active": true, + "address": { + "city": "London" + }, + "age": 36, + "name": "Ada", + "scores": [ + 90, + 82, + 77 + ] +} +``` + +Strings are automatically escaped (`"`, `\`, control characters, etc.). Valid +UTF-8 input therefore produces valid JSON. If a programmatically stored string +contains invalid UTF-8, `toString()` throws `std::invalid_argument` and `write()` +sets `failbit`; `escapeNonAscii` does not make invalid byte sequences valid. If +the configured output limit or indentation arithmetic would overflow, +`toString()` throws `std::length_error` before returning output and `write()` +sets `failbit`. These logical preflight failures emit no bytes; only a physical +stream/I/O failure can leave a partial prefix. + +--- + +## Parsing + +### `parse()` — the recommended API +Every DOM-parsing overload returns an owning `pjson::unique_ptr` (empty on JSON +or DOM-allocation failure), so there is no manual `delete`. + +```cpp +pjson::unique_ptr p = + pjson::parse(R"({ "a": 1, "b": [true, null, "x"] })"); +if (p) { + int64_t a = 0; + if (p->tryGet("a", a)) + std::cout << a << "\n"; // 1 +} // freed automatically +``` + +A `(const char*, size_t)` overload handles buffers that are not +NUL-terminated or that contain embedded NUL bytes: +```cpp +auto p = pjson::parse(buffer, length); +``` + +**Parse options** — `parse()` accepts an optional `ParseOptions`: +```cpp +pjson::ParseOptions opt; +opt.maxDepth = 64; // reject nesting deeper than this (default 512) +opt.maxNodes = 100000; // cap materialized values (default 1,000,000) +opt.maxInputBytes = 8 * 1024 * 1024; // cap input (default 64 MiB) +opt.duplicateKeys = pjson::ParseOptions::RejectDuplicateKeys; // default +auto p = pjson::parse(text, opt); +``` + +**Error reporting** — pass a `ParseError` to learn *why*/*where* parsing failed +(no exceptions): +```cpp +pjson::ParseError err; +auto p = pjson::parse("[1, 2, ]", err); +if (!p) { + std::cerr << "parse failed at " << err.line << ':' << err.column + << " (byte " << err.offset << "): " << err.message << "\n"; +} +``` +The parser resets the supplied `ParseError` at the start of every call. Success +leaves `ok == true`, offset `0`, line `1`, column `1`, and an empty message; +failure sets `ok == false` and records the first failure. + +### Strict parsing and duplicate keys +The parser always enforces RFC 8259. It rejects: +- unescaped control characters inside strings, +- invalid UTF-8 byte sequences, +- unknown escapes (e.g. `\q`), +- unpaired `\u` surrogates, +- non-lowercase keywords (`NULL`, `True`), +- out-of-range numbers. + +Duplicate object keys are rejected by default. Set `duplicateKeys` to +`KeepFirstDuplicate` or `KeepLastDuplicate` only when interoperability requires +it. Resource budgets and duplicate-key handling are the only parse options; +neither relaxes the JSON grammar or UTF-8 validation. + +### Reading from a stream +```cpp +std::ifstream file("data.json"); +pjson::unique_ptr doc = pjson::parseStream(file); +if (doc) { /* ... */ } +``` + +`parseStream()` builds a normal DOM. For very large documents, derive from +`pjson::SaxHandler` and use `parseSaxStream()` to receive values incrementally +without buffering the full file or allocating a DOM: + +```cpp +struct Counter : pjson::SaxHandler { + size_t numbers = 0; + bool onInt(int64_t) override { ++numbers; return true; } + bool onDouble(double) override { ++numbers; return true; } +}; + +Counter counter; +pjson::ParseError err; +std::ifstream input("huge.json", std::ios::binary); +if (!pjson::parseSaxStream(input, counter, err)) { + std::cerr << err.line << ':' << err.column << ": " << err.message << '\n'; +} +``` + +Every SAX callback returns `bool`; return `false` to stop early. Callback +exceptions are caught and returned as a parse failure. `write(std::ostream&)` +also emits directly and incrementally rather than allocating a full serialized +copy first. + +## Streaming large documents + +For the complete SAX callback list, cancellation/error semantics, resource +limits, and direct streaming output, see +[Chapter 11 — Streaming large JSON documents](docs/11-streaming.md). + +--- + +## Reading values + +Use `tryGet()` for typed reads. It returns `false` and leaves the destination +unchanged when a node is absent or has the wrong type. The only numeric widening +it permits is a stored `int64_t` read into a `double`. + +Given this document: +```json +{ + "active": true, + "age": 36, + "name": "Ada", + "ratio": 0.5 +} +``` + +```cpp +auto p = pjson::parse( + R"({ "name": "Ada", "age": 36, "ratio": 0.5, "active": true })"); +const pjson& j = *p; + +std::string name; +int64_t age = 0; +double ratio = 0.0; +bool active = false; +if (j.tryGet("name", name) && j.tryGet("age", age) && + j.tryGet("ratio", ratio) && j.tryGet("active", active)) { + // name == "Ada", age == 36, ratio == 0.5, active == true +} + +const pjson* nameNode = j.find("name"); +pjson::jsonType t = nameNode ? nameNode->getType() : pjson::jsonNull; +``` + +The type tags are: `jsonNull`, `jsonString`, `jsonNumberInt`, +`jsonNumberDouble`, `jsonBoolean`, `jsonArray`, `jsonObject`. + +--- + +## Reading arrays + +Given this document (shown formatted so you can see exactly what is being read): +```json +{ + "friends": [ + { + "name": "Bob" + }, + { + "name": "Cid" + } + ], + "scores": [ + 90, + 82, + 77 + ], + "tags": [ + "a", + "b", + "c" + ] +} +``` + +```cpp +auto p = pjson::parse( + R"({ "scores": [90, 82, 77], "tags": ["a", "b", "c"], + "friends": [ {"name":"Bob"}, {"name":"Cid"} ] })"); +pjson& j = *p; +``` + +Read arrays through `size()` and `find(index)`. These operations do not resize or +otherwise modify the array: + +```cpp +if (const pjson* scores = j.find("scores")) { + std::cout << "count = " << scores->size() << "\n"; // 3 + for (size_t i = 0; i < scores->size(); ++i) { + int64_t value = 0; + const pjson* element = scores->find(static_cast(i)); + if (element && element->tryGet(value)) + std::cout << value << " "; // 90 82 77 + } +} +``` + +**Array of objects** — combine iteration with per-element access: +```cpp +if (const pjson* friends = j.find("friends")) { + for (size_t i = 0; i < friends->size(); ++i) { + const pjson* entry = friends->find(static_cast(i)); + std::string name; + if (entry && entry->tryGet("name", name)) + std::cout << name << " "; // Bob Cid + } +} +``` + +**Filter by element type** — arrays can be heterogeneous, so check `getType()` +when you only want some elements. Given: +```json +{ + "mixed": [ + 1, + "two", + 3, + true, + 4 + ] +} +``` +```cpp +// Sum only the integer elements -> 1 + 3 + 4 = 8 +auto mixed = pjson::parse(R"({ "mixed": [1, "two", 3, true, 4] })"); +if (const pjson* node = mixed->find("mixed")) { + for (size_t i = 0; i < node->size(); ++i) { + int64_t value = 0; + const pjson* element = node->find(static_cast(i)); + if (element && element->tryGet(value)) + std::cout << value << " "; + } +} +``` + +--- + +## Reading objects / maps + +Use `keys()` to obtain object keys in sorted order, then `find(key)` to read each +member without creating it. + +Given this document: +```json +{ + "address": { + "city": "London", + "zip": "N1" + }, + "name": "Ada" +} +``` +```cpp +auto p = pjson::parse( + R"({ "name": "Ada", "address": { "city": "London", "zip": "N1" } })"); +pjson& j = *p; - // To create a Nested JSON map of key Value - oJson["myKey3"]["myInteger"] = 1; - oJson["myKey3"]["myFloat"] = 1.0f; +// Iterate top-level keys in sorted order -> "address", then "name" +for (const std::string& key : j.keys()) { + const pjson* value = j.find(key); + if (value) { + pjson::SerializeOptions compact; + compact.maxOutputBytes = size_t(64) * 1024 * 1024; + std::cout << key << " : " << value->toString(compact) << "\n"; + } +} +``` - // Create an Array as a Value - oJson["myKey4"] = std::vector({0,1,2,3,4,5,6}); +To look up a single key without creating it, use `find()` (returns a pointer or +`nullptr`) or `hasKey()`: +```cpp +if (const pjson* addr = j.find("address")) { + if (const pjson* city = addr->find("city")) { + std::string value; + if (city->tryGet(value)) + std::cout << value << "\n"; // "London" + } +} +``` - // Direct array refernce - oJson["myKey4"][7] = 7; - oJson["myKey4"][8] = "Eight"; +--- - // Deep Nesting Map->Array->Map->Value - oJson["myKey4"][9]["ninth"] = 9.0f; +## Safe vs. vivifying access - //Simplified access - pjson& rFloats = oJson["myKey3"]["myFloatArray"]; - rFloats = std::vector({0,1.1}); +This is the one sharp edge worth understanding. - //Auto fills null values for rFloats[2] and rFloats[3] - rFloats[4] = 4.4f; +`operator[]` is a builder API: accessing a key or index that does not exist +**creates** it, and access can change a node's type. That makes building concise, +but `operator[]` is not a safe read. A single index access that would create +more than 1,000,000 children throws `std::length_error` before mutation: - // Get unformatted string - std::string sResult = oJson.toString(); - std::cout<<"\n** Un-Formatted :\n"<hasIndex(-1)) { + const pjson* last = p->find(-1); // negative indexes count from the end + int64_t value = 0; + if (last && last->tryGet(value)) { /* value == 77 */ } + } +} + +int64_t age = 0; +if (j.tryGet("age", age)) { /* age == 36 */ } +``` + +For strict scalar reads, `tryGet` works on a node or directly by key/index. It +returns `false` and leaves the output unchanged for a missing child or type +mismatch; only integer-to-`double` widening is accepted: + +```cpp +int64_t score = -1; +if (const pjson* scores = j.find("scores")) { + if (scores->tryGet(0, score)) { /* score == 90 */ } +} + +pjson::StringView view; +if (j.tryGet("name", view)) { + consumeBytes(view.data(), view.size()); // supports embedded NUL bytes +} ``` -- ** Formatted : + +`StringView` avoids a string copy but borrows the node's bytes. Mutation, +erasure, reset, move, swap, ancestor replacement, or destruction can invalidate +it; make a `std::string` copy when the value must outlive that state. + +--- + +## Editing an existing document + +Because `operator[]` returns a mutable reference, editing a parsed tree is the +same as building one. Starting from: ```json { - "myKey1" : "Value1" , - "myKey2" : "Value2" , - "myKey3" : { - "myFloat" : 1.000000 , - "myInteger" : 1 - } , - "myKey4" : [ - 0 , - 1 , - 2 , - 3 , - 4 , - 5 , - 6 , - 7 , - "Eight" - ] -} -``` - -## Editing JSON -```C++ -//Load JSON from another JSON string, returns null if it fails -std::string sJsonString = oJson.toString(); -pjson* pResult = pjson::CreateFromString(sJsonString); - -//Edit an exisiting Array -pjson& rAnotherFloats = (*pResult)["myKey3"]["myFloatArray"]; -rAnotherFloats[0] = 33.3f; -rAnotherFloats[2] = "two"; - -//Change Array to Key Value -(*pResult)["myKey4"] = "four"; - -std::cout<<"\n\n** Edited Output:\n"<toString(true); -delete pResult; -``` -- ** Edited Output: + "status": "active", + "user": { + "scores": [ + 10, + 20, + 30 + ] + } +} +``` +```cpp +auto p = pjson::parse( + R"({ "user": { "scores": [10, 20, 30] }, "status": "active" })"); + +// Change values in place +(*p)["user"]["scores"][0] = int64_t(99); // change a value +(*p)["user"]["scores"][1] = "twenty"; // change an element's type + +// Replace a whole node (the "status" string becomes an array here) +(*p)["status"][0] = int64_t(1); +(*p)["status"][1] = int64_t(2); + +std::cout << p->toString(pjson::SerializeOptions::prettyPrinted()); +``` + +produces: + ```json { - "myKey1" : "Value1" , - "myKey2" : "Value2" , - "myKey3" : { - "myFloat" : 1.000000 , - "myFloatArray" : [ - 33.299999 , - 1.100000 , - "two" , - null , - 4.400000 - ] , - "myInteger" : 1 - } , - "myKey4" : "four" -} -``` - -## Raw Access -```C++ -//Load JSON from another JSON string, returns null if it fails -pjson* pResult = pjson::CreateFromString(oJson.toString()); -//Edit an exisiting Array -pjson& rAnotherFloats = (*pResult)["myKey4"]; -pjson::PJSONARRAY* pArray = rAnotherFloats.getArray(); -std::cout<<"\n\n** Print Only the Integers :\n"; -for(auto itr : *pArray) { - if(itr->getType() == pjson::jsonNumberInt){ - std::cout<<" "<getInt(); + "status": [ + 1, + 2 + ], + "user": { + "scores": [ + 99, + "twenty", + 30 + ] } } -//Print only the child section -std::cout<<"\n\n** Print just the Sub Section :\n"<isArray(); // true +scores ? scores->size() : 0; // 3 +scores && !scores->empty(); // true +j.getType(); // pjson::jsonObject +``` +Predicates: `isNull`, `isString`, `isNumber`, `isInt`, `isDouble`, `isBool`, +`isArray`, `isObject`. `size()` returns the element count for arrays/objects +(0 for scalars); `empty()` is `size() == 0`. + +**Read with an application default** — initialize the destination, then replace +it only if `tryGet` succeeds: +```cpp +int64_t count = 0; +j.tryGet("count", count); // count remains 0 if missing or mistyped + +std::string name = "anon"; +j.tryGet("name", name); // name remains "anon" on failure +``` + +**Iterate object keys** (sorted): +```cpp +for (const std::string& key : j.keys()) { + const pjson* value = j.find(key); + if (value) { + pjson::SerializeOptions compact; + compact.maxOutputBytes = size_t(64) * 1024 * 1024; + std::cout << key << " = " << value->toString(compact) << "\n"; + } +} +``` + +**Modify** — `clear()` empties a container (keeping its type), `erase()` removes +a key or index: +```cpp +j.erase("scores"); // remove a map key -> true if present +j["list"].erase(2); // remove array element #2 -> true if in range +j.clear(); // empty the object (stays an object) +``` + +**Compare** — deep, structural equality. Numbers compare across integer/double +(`1 == 1.0`), objects compare regardless of key order, arrays compare in order: +```cpp +auto a = pjson::parse(R"({"x":1,"y":[2,3]})"); +auto b = pjson::parse(R"({"y":[2,3],"x":1.0})"); +bool same = (*a == *b); // true +``` + +--- + +## JSON Pointer, Patch & Merge Patch + +`findPointer()` performs non-vivifying [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901) +lookup. The empty pointer selects the current value; non-empty pointers begin +with `/`, and `-` is not a lookup index. Escape dynamic object-key tokens with +`escapePointerToken()`: + +```cpp +pjson::PointerError pointerError; +if (pjson* city = document.findPointer("/users/0/address/city", pointerError)) { + *city = "London"; +} + +std::string key = "a/b~c"; +const pjson* value = document.findPointer("/" + pjson::escapePointerToken(key)); +``` + +`applyPatch()` supports the RFC 6902 `add`, `remove`, `replace`, `move`, +`copy`, and `test` operations. `applyMergePatch()` implements RFC 7396. Both +modify the receiver only after the complete operation succeeds, so failure is +atomic, and the `PatchError` overload reports the failing operation/path. A +successful RFC 6902 `remove` at the empty root path leaves the target as JSON +`null`: + +```cpp +auto patch = pjson::parse(R"([ + {"op":"replace","path":"/status","value":"ready"}, + {"op":"add","path":"/tags/-","value":"new"} +])"); + +pjson::PatchError patchError; +pjson::PatchOptions patchLimits; +if (!document.applyPatch(*patch, patchError, patchLimits)) { + std::cerr << patchError.opIndex << ": " << patchError.message << '\n'; +} + +auto merge = pjson::parse(R"({"obsolete":null,"enabled":true})"); +document.applyMergePatch(*merge, patchError, patchLimits); +``` + +`PatchOptions` defaults to 10,000 operations, 1,000,000 cloned nodes, +64 MiB of cloned node/string/key bytes, and 1,000,000 work units. Its fields are +`maxOperations`, `maxClonedNodes`, `maxClonedBytes`, and `maxWork`. Zero retains +the corresponding hard ceiling rather than disabling it. Exceeding a budget +returns `false`, reports `PatchError::ResourceLimit` when diagnostics are +requested, and leaves the target unchanged. `maxOperations` applies to RFC +6902 operation entries and to members processed by Merge Patch. +Moving the document root beneath itself is rejected as +`PatchError::MoveRootNotAllowed`; removing the root remains valid and produces +JSON null. + +--- + +## Numbers + +- Integers are stored and assigned as **64-bit** (`int64_t`); read them with + `tryGet(int64_t&)`. +- Non-integers are stored and assigned as **`double`**; read them with + `tryGet(double&)`. A stored integer may widen to `double`; other conversions + are rejected. +- There is no unsigned storage kind; range-check other numeric C++ types before + explicitly converting them to `int64_t` or `double`. +- Double serialization is locale-independent and uses 15–17 significant digits + as needed for stable parse/serialize round-tripping. Integral-looking doubles + retain a decimal marker (for example, `1.0`) so reparsing preserves the double + storage kind; the spelling is not promised to be the shortest possible. + +--- + +## Schema validation + +A document can be checked against a **schema that is itself a `pjson` object**, +so schemas load and round-trip through `parse()`/`toString()` like any other +JSON. The documented vocabulary is a deliberately limited subset of +[JSON Schema](https://json-schema.org), not a complete draft implementation. +`validate()` is `noexcept` and normally collects every +applicable failure (a resource-budget failure stops traversal), each +reported as a `SchemaError { std::string path; std::string message; }` where +`path` is a JSON Pointer to the offending node. + +```cpp +auto schema = pjson::parse(R"({ + "type": "object", + "required": ["name", "age"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "age": { "type": "integer", "minimum": 0 }, + "tags": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false +})"); + +auto data = pjson::parse(R"({ "name": "Ada", "age": 36, "tags": ["x","y"] })"); + +// Simple pass/fail: +if (data->validate(*schema)) { + /* conforms */ +} + +// Or collect all the reasons it failed: +std::vector errors; +if (!data->validate(*schema, errors)) { + for (const auto& e : errors) { + std::cerr << (e.path.empty() ? "(root)" : e.path) + << ": " << e.message << "\n"; + } +} +``` + +Example failure output for `{ "age": "old" }` against the schema above: +```text +(root): missing required property "name" +/age: expected type integer, got string +``` + +Schemas can equally be built with the normal API instead of parsed from text: + +```cpp +pjson schema; +schema["type"] = "object"; +schema["required"][0] = "name"; +schema["required"][1] = "age"; +schema["properties"]["name"]["type"] = "string"; +schema["properties"]["age"]["type"] = "integer"; +schema["properties"]["age"]["minimum"] = int64_t(0); +bool ok = data->validate(schema); +``` + +> **Warning:** Unknown or unsupported schema keywords are ignored and therefore +> impose no constraint. A typo can silently weaken validation. Treat the table +> below as an allowlist, audit schemas before use, and test both accepted and +> rejected instances for every intended rule. + +**Supported keywords:** + +| Applies to | Keywords | +|------------|----------| +| any / references | `type` (name or array of names), `enum`, `const`, local-fragment `$ref` | +| objects | `properties`, `patternProperties`, `propertyNames`, `required`, `dependentRequired`, `dependencies`, `additionalProperties` (boolean or schema), `minProperties`, `maxProperties` | +| arrays | single-schema or tuple-array `items`, `minItems`, `maxItems`, `uniqueItems` | +| numbers | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` | +| strings | `minLength`, `maxLength`, `pattern` (ECMAScript regex), `format` | +| combinators | `allOf`, `anyOf`, `oneOf`, `not` | + +Notes: +- `type: "integer"` matches whole numbers (including `2.0`); `type: "number"` + matches either numeric storage kind (`int64_t` or `double`). +- `enum` / `const` use pjson's deep equality, so they work for arrays and + objects too. +- A boolean schema is allowed: `true` accepts every value, `false` rejects all. +- `$ref` resolves local URI fragments only; remote references are rejected. +- Known formats are `date`, `time`, `date-time`, `ipv4`, `ipv6`, and `uuid`; + unknown format names are ignored. +- `minLength` and `maxLength` count Unicode code points, not UTF-8 bytes. +- `pattern` uses ECMAScript syntax and search semantics. The default policy + limits pattern and subject sizes and rejects unsafe expressions. +- `SchemaOptions` defaults `maxRegexPatternBytes` to 256, + `maxRegexSubjectBytes` to 4096, `allowUnsafeRegex` to `false`, + `maxValidationDepth` to 512, `maxRefResolutions` to 1024, + `maxValidationWork` to 1,000,000, `maxErrors` to 100, and + `validateFormats` to `true`. Zero removes only a regex byte limit; zero for a + validation, reference, work, or error budget retains its documented hard + ceiling. `trustedRegex()` removes only the regex limits/safety screen; reserve + it for trusted schemas and data. + +This is the documented pjson subset, not a complete JSON Schema draft. See +[the schema tutorial](docs/06-schema-validation.md) and the +[generated API reference](https://pico-developer.github.io/pjson/) for details. + +--- + +## Error handling & allocator ownership + +- Every `parse()` / `parseStream()` overload returns a `pjson::unique_ptr` (empty on + JSON or DOM-allocation failure), so ownership is automatic and there is no + manual `delete`. An exception-enabled input stream can still throw while + `parseStream()` buffers input. +- A supplied `ParseError` is reset for each attempt. Success leaves its success + state (`ok`, offset 0, line 1, column 1, empty message); failure records the + first error with a byte `offset`, one-based `line` and byte `column`, and a + human-readable `message`. +- The parser rejects trailing garbage, trailing/leading/doubled commas, + unterminated strings/containers, malformed numbers (`1.`, `.5`, `1e`, `+1`), + out-of-range numbers (`1e400`), and input nested deeper than + `ParseOptions::maxDepth`. +- Strings are correctly escaped on output and unescaped on input, including + `\uXXXX` (decoded to UTF-8) and surrogate pairs. +- Invalid UTF-8 in a programmatically stored string makes `toString()` throw + `std::invalid_argument`; `write()` instead sets the destination stream's + `failbit` before emitting bytes. The default + `SerializeOptions::maxOutputBytes` is 64 MiB; exceeding it produces + `std::length_error` from `toString()` or preflight `failbit` from `write()`. +- `tryGet` returns `false` without changing its output on a missing value or type + mismatch. Operations that allocate, + such as copying a string or moving across allocators, can still report + allocation failure through the normal C++ mechanism unless their signature + is explicitly `noexcept`. + +The default constructors and parse overloads use pjson's default allocator; a +successful DOM parse returns `pjson::unique_ptr`. Applications +that need to route persistent DOM storage can derive from `pjson::Allocator`, +bind a root during construction, or pass it to an allocator-aware parse: + +```cpp +class Arena : public pjson::Allocator { +public: + void* allocate(size_t bytes, size_t alignment, AllocationKind kind) override; + void deallocate(void* ptr, size_t bytes, size_t alignment, + AllocationKind kind) noexcept override; +}; + +Arena arena; +pjson value(arena); +pjson::ParseError error; +pjson::unique_ptr parsed = pjson::parse(text, error, arena); +``` + +`allocate` must return non-null storage satisfying `bytes` and `alignment` or +throw; `deallocate` receives matching metadata and must not throw. A directly +constructed root such as `value` is caller-owned, while its wrapper objects and +dynamic descendants use its bound allocator. A parsed root is a `NodeAllocation` +released through `pjson::unique_ptr`. + +`Allocator` is borrowed and must outlive every bound root and descendant. It +covers persistent nodes and string/array/object wrapper objects; backing +allocations inside the standard containers and transient algorithm/parser +scratch space still use the standard allocator. The stateless `ValueDeleter` in +`pjson::unique_ptr` reads allocator provenance from the root; do not release a +parsed root and call `delete` on it. +`allocate` must return non-null storage satisfying the requested size and +alignment or throw; `deallocate` receives matching metadata and must not throw. + +Ordinary copy construction inherits the source allocator; +`pjson(source, allocator)` explicitly deep-copies into another one. Copy and +move assignment preserve the destination allocator. Same-allocator moves are +constant-time, while cross-allocator moves deep-transfer and may allocate. +`swap()` is constant-time only when `canSwap()` is true; a cross-allocator swap +is a safe no-op. SAX parsing has no allocator overload because it creates no +persistent DOM. + +--- + +## API reference (cheat sheet) + +| Category | Members | +|----------|---------| +| Parse | `parse(str \| ptr,size, ...)`, `parseStream(std::istream&, ...)` → `pjson::unique_ptr` | +| Streaming parse | `parseSax(str \| ptr,size, handler, ...)`, `parseSaxStream(std::istream&, handler, ...)`, `SaxHandler` callbacks | +| Parse options | `ParseOptions{ maxDepth, maxNodes, maxInputBytes, duplicateKeys }`, `ParseError{ ok, offset, line, column, message }` | +| Serialize | `toString([SerializeOptions])`, `write(std::ostream&[, SerializeOptions])`; options include `maxOutputBytes` | +| Type | `getType()`, `isNull/isString/isNumber/isInt/isDouble/isBool/isArray/isObject()` | +| Typed read | node/key/index `tryGet(out&)` for scalars or `StringView`; result is untouched on failure | +| Inspect containers | `size()`, `empty()`, `keys()`, `hasKey(key)`, `hasIndex(index)`, `find(key\|index)` | +| JSON Pointer | `findPointer(pointer[, PointerError])`, `escapePointerToken(token)` | +| JSON Patch | `applyPatch(patch[, PatchError][, PatchOptions])`, `applyMergePatch(patch[, PatchError][, PatchOptions])` | +| Container ops | `size()`, `empty()`, `clear()`, `erase(key)`, `erase(index)` | +| Compare | `operator==`, `operator!=` (deep, structural) | +| Validate | `validate(schema[, errors][, SchemaOptions])` — documented JSON Schema subset | +| Build | `operator[](key\|index)` — **vivifying** | +| Assign | `operator=` for strings, `bool`, `int64_t`, `double`, `std::vector`, `std::vector`, `std::vector`, and `std::vector` | +| Append | `operator+=` for those same scalar and vector types; promotes the node to an array | +| Lifetime / allocator | allocator-aware constructors, `getAllocator()`, `canSwap()`, `copyFrom()`, `swap()` | +| Reset | `reset()` (→ null), `resetTo(jsonType)`, `resetIfNeeded(jsonType)` | + +`pjson` copies deeply. Same-allocator moves transfer storage and null the source; +cross-allocator moves deep-transfer into the destination allocator. + +--- + +## Testing & fuzzing + +The unit test suite is the single `pjsontest` target under `pjsontest/`. It is +assertion based (via a tiny header-only harness in `test_harness.h`), prints a +`PASS`/`FAIL` line per test, and exits non-zero if any check fails. Topic files +under `pjsontest/src/` link into one executable; CTest discovers and registers +each case individually, so the count remains derived from the source. See the +[testing guide](docs/09-testing.md) for the current suite inventory and corpus +setup. + +Easiest — build and run through the script: +```sh +./build.sh --test +``` + +**Contributors:** before sending a change, run the full sweep — clean build, +sanitizers, tests, formatting and static-analysis checks: +```sh +./build.sh # or, equivalently, ./build.sh --all +``` +The current sweep covers formatting, Release plus sanitized Debug builds, all +tests, comparison benchmarks, bounded fuzz-corpus replay when supported, the +Doxygen reference, relocatable static and shared install consumers, pkg-config +checks, and clang-tidy. It offers to fetch both pinned JSON and JSON Schema +conformance corpora; `--all --auto` installs or downloads missing prerequisites +without prompting. Run `./build.sh --help` for the exact current expansion and +`./build.sh --format` to apply source formatting. + +Or with CMake/CTest directly: +```sh +cmake -S . -B build \ + -DPJSON_BUILD_TESTS=ON \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF +cmake --build build +ctest --test-dir build --output-on-failure +``` + +Or run the collected binary directly to see every case: +```sh +./out/debug/bin/pjsontest # if built via ./build.sh +./build/pjsontest/pjsontest # if built via plain cmake +``` + +Deterministic generated cases live in `tests_fuzz.cpp`; four standalone +coverage-guided targets exercise DOM parsing/round trips, stream and SAX +agreement, schema validation, and the atomicity of JSON Patch and Merge Patch. +With a full Clang/libFuzzer toolchain on Linux or macOS, replay the checked-in +seeds with the CI-sized budget: + +```sh +./build.sh --fuzz --auto +``` + +Seeds and the shared dictionary live under [`fuzz/`](fuzz); generated corpus +entries and crash artifacts go under ignored `out/` directories. A focused +direct CMake build uses: + +```sh +CXX=clang++ cmake -S . -B out/build-fuzz \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF \ + -DPJSON_BUILD_FUZZERS=ON +cmake --build out/build-fuzz --parallel +``` + +Without `PJSON_FUZZING_ENGINE`, Clang must provide libFuzzer. An external engine +can instead be supplied through that cache string. Repository-local OSS-Fuzz +integration is provided in [`oss-fuzz/`](oss-fuzz), without implying active +upstream service enrollment. + +--- + +## Benchmarking + +The Release benchmark suite measures parsing, compact serialization, read-only +traversal, and deep copying on generated small, medium, and large JSON +documents. Run pjson by itself or compare the same cases with pinned versions +of nlohmann/json, RapidJSON, and simdjson: + +```sh +./build.sh --bench --release-only +./build.sh --bench-compare --release-only +./build.sh --bench-compare --release-only --auto # download pinned dependencies without prompting +``` + +Add real documents with repeatable `--bench-input` arguments: -delete pResult; +```sh +./build.sh --bench-compare --release-only \ + --bench-input /path/to/small.json \ + --bench-input /path/to/production-shaped.json ``` + +Comparison mode groups rows by workload and operation. For example, all four +`small / parse` rows appear together, followed by all four `small / serialize` +rows. Parse, serialize, and traverse cover every library. Copy covers pjson, +nlohmann/json, and RapidJSON; simdjson has no equivalent owned mutable-DOM +deep-copy operation. + +### Reference comparison + +The following snapshot was produced on this development machine with: + +```sh +./build.sh --bench-compare --release-only --auto ``` -** Print Only the Integers : - 0 1 2 3 4 5 6 7 -** Print just the Sub Section : -[ 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , "Eight" , { "ninth" : 9.000000 } ] +| Machine detail | Value | +| --- | --- | +| Computer | MacBook Pro (`Mac15,6`) | +| Processor | Apple M3 Pro, 12 cores (6 performance + 6 efficiency) | +| Memory | 36 GB | +| Architecture | ARM64 | +| Operating system | macOS 26.3.1 (`25D771280a`) | +| Compiler | Apple Clang 21.0.0 (`clang-2100.0.123.102`) | +| CMake / build | CMake 4.2.3, Release configuration | +| Compared versions | nlohmann/json 3.11.3, RapidJSON 1.1.0, simdjson 3.12.2 | +| Inputs | Deterministic generated workloads; no additional corpus files | +| Sampling | One warm-up, then six timed samples; iterations calibrated per case | + +Each result cell is **median microseconds per operation / MiB/s**. Lower +microseconds are better; higher MiB/s is better. Workload sizes are the original +compact JSON inputs. + +| Workload | Operation | pjson | nlohmann/json | RapidJSON | simdjson | +| --- | --- | ---: | ---: | ---: | ---: | +| small (341 B) | parse | 4.06 us / 80.1 MiB/s | 4.18 us / 77.8 MiB/s | 1.68 us / 193.6 MiB/s | 0.80 us / 405.4 MiB/s | +| small (341 B) | serialize | 2.74 us / 118.7 MiB/s | 1.61 us / 201.5 MiB/s | 1.17 us / 277.6 MiB/s | 0.98 us / 331.5 MiB/s | +| small (341 B) | traverse | 0.96 us / 339.7 MiB/s | 0.35 us / 922.6 MiB/s | 0.27 us / 1185.6 MiB/s | 0.37 us / 877.4 MiB/s | +| small (341 B) | copy | 3.13 us / 104.0 MiB/s | 1.61 us / 201.4 MiB/s | 1.17 us / 277.9 MiB/s | N/A | +| medium (117,854 B) | parse | 1108.31 us / 101.4 MiB/s | 1025.26 us / 109.6 MiB/s | 292.90 us / 383.7 MiB/s | 175.75 us / 639.5 MiB/s | +| medium (117,854 B) | serialize | 896.97 us / 125.3 MiB/s | 464.90 us / 241.8 MiB/s | 350.65 us / 320.5 MiB/s | 259.48 us / 433.2 MiB/s | +| medium (117,854 B) | traverse | 271.95 us / 413.3 MiB/s | 94.70 us / 1186.9 MiB/s | 83.00 us / 1354.1 MiB/s | 102.73 us / 1094.0 MiB/s | +| medium (117,854 B) | copy | 987.08 us / 113.9 MiB/s | 406.88 us / 276.2 MiB/s | 159.80 us / 703.3 MiB/s | N/A | +| large (805,216 B) | parse | 14026.68 us / 54.7 MiB/s | 8019.35 us / 95.8 MiB/s | 2173.38 us / 353.3 MiB/s | 1429.88 us / 537.0 MiB/s | +| large (805,216 B) | serialize | 21643.63 us / 35.5 MiB/s | 3756.54 us / 204.4 MiB/s | 2529.02 us / 303.6 MiB/s | 2379.25 us / 322.8 MiB/s | +| large (805,216 B) | traverse | 2464.98 us / 311.5 MiB/s | 777.77 us / 987.3 MiB/s | 605.36 us / 1268.5 MiB/s | 740.37 us / 1037.2 MiB/s | +| large (805,216 B) | copy | 8602.30 us / 89.3 MiB/s | 3447.04 us / 222.8 MiB/s | 1225.48 us / 626.6 MiB/s | N/A | + +These numbers are a reproducible local snapshot, not a universal ranking. CPU +scaling, background load, compiler versions, allocator behavior, and workload +shape can materially change results. Re-run the command above on the target +machine before making performance-sensitive decisions. + +| Measurement | Interpretation | Better result | +| --- | --- | --- | +| `best us` | Fastest microseconds per operation among six samples. | Lower | +| `median us` | Median microseconds per operation; usually the best primary comparison. | Lower | +| `avg us` | Mean microseconds per operation. | Lower | +| `MiB/s` | Original input size divided by median time. | Higher | +| `bytes` | Original input JSON size. | Context only | +| `iters` | Operations in each calibrated timed sample. | Context only | + +`MiB/s` is an input-size-normalized comparison, not actual serialized, visited, +or copied bytes. The final `sink=` value is only an anti-optimization checksum. +Benchmark results have no pass/fail threshold; compare Release runs made on the +same machine under similar load. See the [benchmark guide](bench/README.md) for +the exact timed work, dependency versions, methodology, and sample output. + +--- + +## Documentation & project resources + +- [Tutorials](docs/README.md) and [streaming guide](docs/11-streaming.md) +- [Browsable API reference](https://pico-developer.github.io/pjson/) and its + [source landing page](docs/reference/mainpage.md) +- Migration guides for [nlohmann/json](docs/migration-from-nlohmann-json.md) and + [RapidJSON](docs/migration-from-rapidjson.md) +- [Custom allocator tutorial](docs/12-custom-allocators.md) +- [Contributing](CONTRIBUTING.md), [security reporting](SECURITY.md), and + [changelog](CHANGELOG.md) +- [Versioning](VERSIONING.md), [release process](RELEASING.md), + [licensing/SPDX policy](LICENSING.md), and [authors](AUTHORS) + +Build and validate the generated reference with Doxygen and Python 3: + +```sh +cmake -S . -B out/build-docs \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF \ + -DPJSON_BUILD_DOCS=ON +cmake --build out/build-docs --target pjson-docs-check ``` -## More -- See pjsontest/main.cpp for more ways to use this helpful code +Open `out/build-docs/docs/reference/html/index.html`. Warnings and missing +public API families fail validation. --- -# Running Test Application -- Test application is under the folder "pjsontest" -- Run CMake , Build on the above folder -- Execute pjsontest \ No newline at end of file + +## Limitations + +- pjson requires C++11 and owns a mutable DOM; it is not a zero-copy parser. + `parseSaxStream()` avoids buffering the whole document, although its handler, + current tokens, nesting state, and duplicate-key tracking still use memory. +- Object insertion order is not preserved; keys are stored in `std::map` and + serialize in selectable ascending or descending bytewise order. +- Duplicate object keys are rejected by default; `ParseOptions` can explicitly + keep the first or last value. +- Numbers outside `int64_t` range fall back to `double` (may lose precision); + there is no separate unsigned-integer representation, and numbers outside + finite `double` range are rejected. Programmatically stored non-finite + floating values serialize as `null`. +- Parsing always enforces RFC 8259, including valid UTF-8 and the standard + lowercase literals and escape syntax. +- Hostile-input limits default to 512 nesting levels, 1,000,000 materialized + values, and 64 MiB of input; tune `maxDepth`, `maxNodes`, and `maxInputBytes`. +- Schema validation implements a documented subset, not a complete draft. It + ignores unknown keywords, so unsupported rules and misspellings are not + enforced. It does not compile/cache + schemas, resolve remote `$ref` values, validate during SAX parsing, support + `additionalItems`, or implement newer conditional/unevaluated vocabularies. + Tuple-form `items` validates corresponding positions but leaves elements past + the tuple unconstrained. String lengths count Unicode code points. Regex + matching uses the policy-limited default unless trusted mode is requested. +- A custom `Allocator` routes persistent DOM nodes and wrapper objects, not + transient scratch storage or the backing allocations inside standard-library + containers. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..b36c795 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,178 @@ + + + +# Release Process + +This checklist is for project maintainers. Releases use Semantic Versioning and +the `release-MAJOR.MINOR.PATCH` tag convention described in +[`VERSIONING.md`](VERSIONING.md). + +## 1. Prepare the release + +1. Choose the version from the user-visible changes since the previous release. +2. Confirm that the release commit will come from `main`, the worktree is clean, + and required GitHub checks are green. +3. Resolve or explicitly defer every release-blocking bug and private security + advisory. Never expose embargoed details in a public release pull request. +4. Update every version source listed in `VERSIONING.md` and search the tree for + stale copies of the previous version. +5. Move the accumulated `Unreleased` changelog entries into a heading for the + new version and UTC release date. Leave a fresh `Unreleased` heading above + it. Verify all changelog links. + +The release preparation should be reviewed as a pull request. Avoid unrelated +changes in that pull request so the release diff is auditable. + +## 2. Verify the candidate + +Run the repository's complete local verification from the release commit: + +```sh +./build.sh --all --auto +``` + +Verify that every distributed file has complete, valid SPDX metadata and that +all referenced license texts are present: + +```sh +python3 -m pip install --disable-pip-version-check reuse==6.2.0 +reuse lint +``` + +The full sweep covers compilation, registered tests, formatting, sanitizers, +benchmarks, clang-tidy, documentation generation, package checks, and bounded +fuzz corpus replay when supported. The `--package` phase validates relocatable +static and shared installs through both CMake-package and pkg-config consumers. +The sweep offers to fetch both pinned conformance corpora; `--auto` accepts them +without prompting. An explicit fuzz run is required on a release machine with +Clang and libFuzzer so a missing runtime cannot be treated as an optional +full-sweep skip: + +```sh +./build.sh --fuzz --auto +``` + +Fetch the separately pinned official JSON Schema corpus and run its supported +draft-07 manifest against the release candidate: + +```sh +./scripts/fetch-json-schema-test-suite.sh +PJSON_JSON_SCHEMA_TEST_SUITE_DIR="$PWD/.test-corpora/JSON-Schema-Test-Suite" \ + ctest --test-dir out/build-debug --output-on-failure \ + -R '^pjson\.schema_official_draft7_optional$' +``` + +The full sweep already builds and validates the release API reference. To rerun +that focused check (which requires Doxygen and Python 3): + +```sh +cmake -S . -B out/build-docs \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF \ + -DPJSON_BUILD_DOCS=ON +cmake --build out/build-docs --target pjson-docs-check +PJSON_RELEASE_TMP_DIR="$(mktemp -d)" +tar -C out/build-docs/docs/reference/html \ + -czf "${PJSON_RELEASE_TMP_DIR}/pjson-api-reference.tar.gz" . +tar -tzf "${PJSON_RELEASE_TMP_DIR}/pjson-api-reference.tar.gz" | + grep -q '^./index.html$' +``` + +Verify both static and shared relocatable installs through external CMake and +pkg-config consumers, including the CMake config/version/targets metadata and +the installed header and library. Requiring pkg-config here prevents that +consumer path from being silently skipped. Run the package smoke tests from a +clean working directory: + +```sh +PJSON_RELEASE_TMP_DIR="$(mktemp -d)" +cmake -DPJSON_SOURCE_DIR="$PWD" \ + -DPJSON_WORK_DIR="${PJSON_RELEASE_TMP_DIR}/package-smoke/static" \ + -DPJSON_INSTALL_LIBDIR=lib64 \ + -DPJSON_REQUIRE_PKG_CONFIG=ON \ + -P cmake/RunInstallConsumer.cmake +cmake -DPJSON_SOURCE_DIR="$PWD" \ + -DPJSON_WORK_DIR="${PJSON_RELEASE_TMP_DIR}/package-smoke/shared" \ + -DPJSON_BUILD_SHARED_LIBS=ON \ + -DPJSON_INSTALL_LIBDIR=lib64 \ + -DPJSON_REQUIRE_PKG_CONFIG=ON \ + -P cmake/RunInstallConsumer.cmake +test -f "${PJSON_RELEASE_TMP_DIR}/package-smoke/static/relocated/share/licenses/pjson/LICENSE" +test -f "${PJSON_RELEASE_TMP_DIR}/package-smoke/shared/relocated/share/licenses/pjson/LICENSE" +``` + +Run the package-manager checks with Conan 2 and a current vcpkg checkout. These +are release checks, not optional contributor-tool discovery; install the tools +on the release machine before continuing. Use an empty disposable directory for +`CONAN_HOME`; `mktemp -d` is portable across the supported Unix release hosts: + +```sh +python3 -m py_compile conanfile.py test_package/conanfile.py +export CONAN_HOME="$(mktemp -d)" +conan profile detect --force +conan create . -s build_type=Release --build=missing +python3 -m json.tool packaging/vcpkg/ports/pjson/vcpkg.json >/dev/null +"$VCPKG_ROOT/vcpkg" install pjson \ + --overlay-ports="$PWD/packaging/vcpkg/ports" +PJSON_RELEASE_TMP_DIR="$(mktemp -d)" +cmake -S tests/install-consumer \ + -B "${PJSON_RELEASE_TMP_DIR}/vcpkg-consumer" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" +cmake --build "${PJSON_RELEASE_TMP_DIR}/vcpkg-consumer" \ + --config Release --parallel +ctest --test-dir "${PJSON_RELEASE_TMP_DIR}/vcpkg-consumer" \ + -C Release --output-on-failure +``` + +The Conan test package and vcpkg overlay must build the release checkout and +consume the installed `pjson::pjson` target. Treat any recipe, consumer, +relocation, metadata, or documentation failure as a release blocker. Record the +commands, tool versions, platforms, and results in the release pull request. + +Review the candidate diff and provenance before continuing: + +```sh +git status --short +git diff ...HEAD +git log --oneline ..HEAD +``` + +## 3. Tag and publish + +After the release pull request is merged and the exact release commit is checked +out, create an annotated tag. Sign it when the maintainer has a configured, +project-recognized signing key. + +```sh +git tag -a release-X.Y.Z -m "pjson X.Y.Z" +git push origin release-X.Y.Z +``` + +Create a GitHub release from that exact tag. Use `pjson X.Y.Z` as the title and +copy the matching changelog section into the release notes. The documentation +workflow builds and attaches `pjson-api-reference.tar.gz` from the release tag; +wait for that workflow and treat a missing or failed archive as a release +failure. Attach SHA-256 checksums for any additional downloadable artifacts. Do +not rebuild or replace artifacts from a different commit. + +## 4. Verify publication + +- Confirm the tag resolves to the reviewed commit. +- Download each published artifact and verify its checksum and version. +- Build or consume at least one downloaded artifact in a clean directory. +- Open the downloaded API reference's `index.html` and confirm its version and + migration pages match the tag. +- Check that changelog comparison links and package metadata resolve correctly. +- Announce the release only after these checks pass. + +## 5. After the release + +Confirm that `main` has an empty `Unreleased` changelog section ready for future +work. Monitor installation and compatibility reports, and publish a patch release +for release-specific defects. + +Never move or silently replace a published tag or artifact. If a release is +unsafe, mark it clearly in GitHub, notify users through the security advisory or +release notes, and publish a corrected version with a new tag. diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..b1b55e0 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,28 @@ +version = 1 + +# Supply the project license metadata only when a file does not already carry +# complete SPDX information. This preserves complete per-file notices, +# including any third-party notices added in the future. +[[annotations]] +path = ["**"] +precedence = "closest" +SPDX-FileCopyrightText = "ByteDance Ltd. and/or its affiliates" +SPDX-License-Identifier = "Apache-2.0" + +# Fuzz seeds are first-party test data. Keep their machine-consumed bytes free +# of embedded comments and make their licensing unambiguous through REUSE's +# supported external annotation mechanism. +[[annotations]] +path = ["fuzz/corpus/**"] +precedence = "override" +SPDX-FileCopyrightText = "ByteDance Ltd. and/or its affiliates" +SPDX-License-Identifier = "Apache-2.0" + +# Contributor Covenant 2.1 is adapted here with project-specific reporting +# instructions. Preserve the upstream attribution and license independently of +# the Apache-2.0 license used by pjson's first-party material. +[[annotations]] +path = ["CODE_OF_CONDUCT.md"] +precedence = "override" +SPDX-FileCopyrightText = "2014 Coraline Ada Ehmke" +SPDX-License-Identifier = "CC-BY-4.0" diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d329859 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,77 @@ + + + +# Security Policy + +## Supported versions + +Once 1.0.0 is published, pjson intends to provide security fixes for the current +stable release line. The `1.0.0` source is currently under development, so there +is no supported stable line until `release-1.0.0` is published. Reports against +`main` are welcome and fixes are released as soon as practical. + +| Version | Supported | +| --- | --- | +| `main` / 1.0.0 development | Pre-release reports accepted | +| 0.0.x | No | + +Users of an unsupported version should upgrade before reporting a problem that +may already be fixed. Reports against `main` are welcome when they identify the +affected commit. + +## Reporting a vulnerability + +Do not open a public issue for a suspected vulnerability. Use GitHub's +[private vulnerability reporting](https://github.com/Pico-Developer/pjson/security/advisories/new) +to send the report to the maintainers. If that form is unavailable, open a +public issue containing only a request for a private contact channel; do not +include exploit details, proof-of-concept code, secrets, or affected user data. + +Include as much of the following as possible: + +- the affected pjson version or commit; +- operating system, compiler, architecture, and relevant build options; +- the vulnerability class and likely impact; +- the smallest reproducible input or proof of concept; +- whether the issue is known to be actively exploited; +- suggested mitigations or fixes, if any; and +- how you would like to be credited. + +Encrypt or redact sensitive artifacts before sharing them. Do not submit real +credentials, personal data, or production data. + +## What to expect + +The maintainers aim to acknowledge a complete report within three business +days and provide an initial assessment within seven business days. These are +response targets, not guarantees. The reporter will receive updates when the +risk assessment, remediation plan, or disclosure schedule changes. + +For an accepted vulnerability, maintainers will coordinate a fix, tests, a +security advisory, and a patched release. A CVE will be requested when +appropriate. Credit is given unless the reporter asks to remain anonymous. +Please allow time for supported users to update before publishing details; a +90-day disclosure window is a guideline and may be shortened for active +exploitation or extended by mutual agreement. + +## Scope + +Security reports may include memory-safety defects, parser or serializer +confusion, validation bypasses, denial-of-service inputs, unsafe default +behavior, or dependency and distribution issues that affect pjson consumers. + +The following are normally out of scope: + +- unsupported releases when the issue is fixed in a supported version; +- performance differences without a practical denial-of-service impact; +- vulnerabilities in optional third-party benchmark dependencies that do not + affect pjson; and +- reports that require social engineering or compromised build infrastructure + outside this repository. + +## Good-faith research + +Please test only systems and data you are authorized to use, minimize privacy +impact and service disruption, and give the maintainers a reasonable chance to +remediate the issue before disclosure. The project will treat research carried +out under these conditions as good-faith security research. diff --git a/Todo.md b/Todo.md new file mode 100644 index 0000000..2f2bac0 --- /dev/null +++ b/Todo.md @@ -0,0 +1,70 @@ +# pjson — Production-Readiness Backlog + +This file tracks only open work. Completed items are intentionally removed; use +the git history for their implementation details. FEAT-3 is intentionally +deferred: pjson will keep its current `std::map` object representation for now. + +Current baseline: strict RFC 8259 parsing, bounded parser and schema resources, +JSON Pointer/Patch/Merge Patch, an expanded documented JSON Schema subset, +configurable serialization, allocator-aware DOM storage, non-vivifying typed +access, SAX streaming, individually registered tests, pinned conformance +corpora, libFuzzer/OSS-Fuzz targets, benchmarks, packaging, API reference, and +cross-platform CI. + +--- + +## Medium Priority + +### [ ] MAINT-1 — Unify DOM and SAX parser grammar code + +**Where:** DOM parsing and SAX parsing currently use separate recursive-descent +implementations in `pjson.cpp`, with differential conformance tests guarding +their behavior. + +**Why:** duplicated token, number, Unicode, and container grammar logic raises +the chance that a future parser fix reaches only one API. The current paths are +well tested, so this is architectural debt rather than a release blocker. + +**How:** extract a shared lexer/parser core parameterized by a DOM builder or SAX +event sink. Preserve the current error offsets, duplicate-key policies, resource +budgets, streaming cursor behavior, and DOM/SAX differential regression suite. + +### [ ] MAINT-2 — Split schema validation into keyword-family helpers + +**Where:** `_validateCtx` coordinates references, scalar keywords, containers, +regular expressions, and combinators in one large dispatcher. + +**Why:** the shared depth, work, reference, and reported-error budgets make this +logic security-sensitive; smaller helpers would make future keyword changes +easier to review without changing the public validation contract. + +**How:** extract focused reference, numeric, string, array, object, and +combinator helpers that all receive the same validation context and error sink. +Keep the official schema manifest and resource-budget tests green throughout. + +### [ ] MAINT-3 — Discover CTest cases from the compiled test registry + +**Where:** `pjsontest/CMakeLists.txt` currently extracts `TEST(name)` tokens +from source text, while the executable separately exposes `--list-tests`. + +**Why:** comments, conditional compilation, or future macro wrappers could make +source-text discovery drift from the cases compiled into the runner. CI compares +both counts today, so this is guarded architectural debt rather than a release +blocker. + +**How:** add a post-build discovery helper that invokes +`pjsontest --list-tests` and generates the CTest entries from that output. Keep +the CI nonzero/count check as a defense-in-depth assertion. + +### [ ] FEAT-3 — Preserve object key insertion order + +**Where:** pjson currently stores objects in `std::map`, so serialization sorts +keys alphabetically. + +**Why:** round-tripping that reorders keys creates noisy configuration and golden +file diffs. Most modern JSON DOMs preserve insertion order even though JSON +object semantics do not require it. + +**How:** use an insertion-ordered representation, such as a vector of key/value +pairs plus a lookup index. Preserve structural equality semantics and retain +protection from hash-collision denial of service if a hash index is introduced. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..0da0e62 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,70 @@ + + + +# Versioning Policy + +pjson uses [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). The +current source/development version is **1.0.0**, which remains unreleased until +the `release-1.0.0` tag is published. The latest published tag is `release-0.0.3`; +historical `0.0.x` releases predate this stability policy. + +## Version meaning + +- **MAJOR** changes may require users to update source code or intentionally + change documented behavior. +- **MINOR** changes add backward-compatible public functionality. +- **PATCH** changes contain backward-compatible fixes, documentation, or + internal improvements. + +Security fixes normally use the smallest compatible version increment. When a +safe fix cannot preserve compatibility, the security advisory and release notes +must call that out explicitly. + +## Compatibility boundary + +The public contract includes: + +- declarations, types, constants, and macros in the installed `pjson.h`; +- documented behavior of parsing, serialization, validation, and mutation; +- the `pjson::pjson` CMake target and installed package names; and +- documented compile-time requirements, including C++11 support. + +Private implementation details, tests, benchmarks, examples, diagnostics not +documented as stable, and repository layout outside installed artifacts are not +public API. + +Semantic versioning describes source and behavioral compatibility. pjson does +not currently promise a stable C++ ABI across releases, compilers, standard +libraries, compiler flags, or build configurations. Rebuild pjson and dependent +C++ binaries together when upgrading. + +## Deprecation + +When practical, a public API scheduled for removal is deprecated for at least +one minor release and documented in the changelog. Immediate removal is +reserved for cases where retaining the behavior would be unsafe or materially +misleading. Removal of a supported public API requires a major release. + +## Version sources + +For each release, the following values must agree: + +- the top-level CMake project version; +- `PJSON_VERSION`, `PJSON_VERSION_MAJOR`, `PJSON_VERSION_MINOR`, and + `PJSON_VERSION_PATCH` in `pjson.h`; +- package-manager or distribution metadata; and +- the release heading in `CHANGELOG.md`. + +The release checklist verifies these values before a tag is created. A mismatch +is a release-blocking defect. + +## Tags and pre-releases + +Release tags follow the repository's established +`release-MAJOR.MINOR.PATCH` form, for example `release-1.0.0`. Pre-release +identifiers use Semantic Versioning syntax, for example +`release-1.1.0-rc.1`. Published tags are immutable; a correction is released +under a new version rather than moving an existing tag. + +Development happens on `main`. Until a release tag is published, its changes +remain under the `Unreleased` heading in `CHANGELOG.md`. diff --git a/bench/CMakeLists.txt b/bench/CMakeLists.txt new file mode 100644 index 0000000..4b8568c --- /dev/null +++ b/bench/CMakeLists.txt @@ -0,0 +1,68 @@ +cmake_minimum_required(VERSION 3.21) + +set(TARGET_NAME pjsonbench) + +project(${TARGET_NAME}) + +set(SRC_DIR "src") + +set(BENCH_SRC_FILES + ${SRC_DIR}/benchmark_main.cpp +) + +option(PJSON_BENCH_COMPARE "Enable optional third-party JSON benchmark comparisons" OFF) +set(PJSON_BENCH_DEPS_DIR "${CMAKE_SOURCE_DIR}/.benchmark-deps" + CACHE PATH "Directory containing pinned benchmark comparison dependencies") + +if(MSVC) + set(PJSON_BENCH_WARN_FLAGS /W4) +else() + set(PJSON_BENCH_WARN_FLAGS -Wall -Wextra) +endif() + +add_executable(${TARGET_NAME} ${BENCH_SRC_FILES}) +target_link_libraries(${TARGET_NAME} PRIVATE pjson::pjson) +target_compile_features(${TARGET_NAME} PRIVATE cxx_std_11) +target_compile_options(${TARGET_NAME} PRIVATE ${PJSON_BENCH_WARN_FLAGS}) + +if(PJSON_BENCH_COMPARE) + set(PJSON_BENCH_NLOHMANN_INCLUDE + "${PJSON_BENCH_DEPS_DIR}/nlohmann-json-v3.11.3/single_include") + set(PJSON_BENCH_RAPIDJSON_INCLUDE + "${PJSON_BENCH_DEPS_DIR}/rapidjson-v1.1.0/include") + set(PJSON_BENCH_SIMDJSON_SOURCE_DIR + "${PJSON_BENCH_DEPS_DIR}/simdjson-v3.12.2") + set(PJSON_BENCH_SIMDJSON_CMAKE_DIR "${CMAKE_CURRENT_BINARY_DIR}/simdjson") + + if(NOT EXISTS "${PJSON_BENCH_NLOHMANN_INCLUDE}/nlohmann/json.hpp") + message(FATAL_ERROR + "PJSON_BENCH_COMPARE=ON but nlohmann/json v3.11.3 was not found under " + "${PJSON_BENCH_NLOHMANN_INCLUDE}. Fetch the pinned dependencies first.") + endif() + if(NOT EXISTS "${PJSON_BENCH_RAPIDJSON_INCLUDE}/rapidjson/document.h") + message(FATAL_ERROR + "PJSON_BENCH_COMPARE=ON but RapidJSON v1.1.0 was not found under " + "${PJSON_BENCH_RAPIDJSON_INCLUDE}. Fetch the pinned dependencies first.") + endif() + if(NOT EXISTS "${PJSON_BENCH_SIMDJSON_SOURCE_DIR}/CMakeLists.txt") + message(FATAL_ERROR + "PJSON_BENCH_COMPARE=ON but simdjson v3.12.2 was not found under " + "${PJSON_BENCH_SIMDJSON_SOURCE_DIR}. Fetch the pinned dependencies first.") + endif() + + set(SIMDJSON_DEVELOPER_MODE OFF CACHE BOOL "" FORCE) + set(SIMDJSON_GOOGLE_BENCHMARKS OFF CACHE BOOL "" FORCE) + set(SIMDJSON_ENABLE_THREADS OFF CACHE BOOL "" FORCE) + add_subdirectory("${PJSON_BENCH_SIMDJSON_SOURCE_DIR}" "${PJSON_BENCH_SIMDJSON_CMAKE_DIR}") + + target_include_directories(${TARGET_NAME} SYSTEM PRIVATE + "${PJSON_BENCH_NLOHMANN_INCLUDE}" + "${PJSON_BENCH_RAPIDJSON_INCLUDE}" + ) + target_link_libraries(${TARGET_NAME} PRIVATE simdjson::simdjson) + # simdjson's public DOM API uses std::string_view. Comparison mode therefore + # requires C++17, while the pjson library and dependency-free benchmark + # remain C++11. + target_compile_features(${TARGET_NAME} PRIVATE cxx_std_17) + target_compile_definitions(${TARGET_NAME} PRIVATE PJSON_BENCH_COMPARE=1) +endif() diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..248c1fb --- /dev/null +++ b/bench/README.md @@ -0,0 +1,158 @@ +## Benchmark Suite + +`bench/` contains the benchmark suite for `pjson`. It is intentionally +self-contained and C++11-only: no Google Benchmark or other external runtime +dependency is required. + +Baseline `--bench` runs stay dependency-free and offline. Cross-library +comparison runs when requested with `--bench-compare` and as part of the full +`--all` sweep. + +### What it measures + +The `pjsonbench` executable reports per-operation timing for: + +- `parse` +- `serialize` (compact `toString()`) +- `traverse` (recursive read-only walk over the parsed tree) +- `copy` (deep copy via the copy constructor) + +When built with `--bench-compare`, it also reports comparable rows for: + +- `nlohmann/json` v3.11.3 +- `RapidJSON` v1.1.0 +- `simdjson` v3.12.2 + +Comparison mode covers `parse`, compact `serialize`, and `traverse` for all +three third-party libraries. `copy` is reported only where deep-copy semantics +are reasonably comparable, which currently means `pjson`, `nlohmann/json`, and +RapidJSON. simdjson is intentionally parse-on-demand and does not expose the +same owned mutable DOM copy model, so no simdjson copy row is emitted. + +Each operation runs against representative generated workloads: + +- `small`: compact session-style object with nested user/event data +- `medium`: user/session dataset with arrays, nested objects, booleans, and numbers +- `large`: inventory-style dataset with hundreds of nested records and repeated arrays + +The harness adapts iteration counts so each measurement runs long enough to +produce stable timings, then records six timed samples. Comparison output is +grouped by workload and operation, so the pjson, nlohmann/json, RapidJSON, and +simdjson rows for the same test appear next to each other. The `copy` group +omits simdjson because it has no comparable owned mutable-DOM deep-copy API. + +### Build and run + +From the repository root: + +```bash +./build.sh --bench --release-only +``` + +`--bench` always runs the dependency-free Release benchmark executable. The +complete `--all` sweep includes comparison mode; it prompts before fetching the +pinned dependencies, while `--all --auto` downloads them automatically. + +### Cross-library comparison + +Run comparison mode directly with: + +```bash +./build.sh --bench-compare --release-only +``` + +The first `--bench-compare` run fetches these pinned upstream releases into the +gitignored `.benchmark-deps/` directory: + +- `nlohmann/json` `v3.11.3` +- `RapidJSON` `v1.1.0` +- `simdjson` `v3.12.2` + +`build.sh` verifies the exact commit behind each tag before configuring the +comparison target and rejects locally modified tracked dependency files. + +The pjson library and baseline benchmark remain C++11. The optional comparison +target is compiled as C++17 because simdjson's public API uses +`std::string_view`. + +The fetch step follows the same prompt policy as the rest of `build.sh`: + +- interactive by default +- automatic with `--auto` + +If comparison mode is not requested, no benchmark dependencies are downloaded +or required for configure/build/run. + +### Optional corpus inputs + +You can extend the generated workloads with real JSON documents: + +```bash +./build.sh --bench --release-only \ + --bench-input /path/to/sample1.json \ + --bench-input /path/to/sample2.json +``` + +The same `--bench-input` arguments work with `--bench-compare`. + +The benchmark binary also accepts direct inputs: + +```bash +./out/release/bin/pjsonbench --input /path/to/sample.json +``` + +Unreadable or invalid JSON inputs are skipped with a warning so the suite still +runs on the remaining workloads. + +### Output + +The report is plain text and intended for direct, case-by-case comparison. For +example, comparison mode groups all implementations of `small / parse`, then +all implementations of `small / serialize`, and so on: + +```text +library workload operation bytes iters best us median us avg us MiB/s +------------------------------------------------------------------------------------------------------------------------------ +pjson small parse 341 32768 ... ... ... ... +nlohmann small parse 341 32768 ... ... ... ... +rapidjson small parse 341 65536 ... ... ... ... +simdjson small parse 341 131072 ... ... ... ... + +pjson small serialize 341 65536 ... ... ... ... +nlohmann small serialize 341 65536 ... ... ... ... +rapidjson small serialize 341 65536 ... ... ... ... +simdjson small serialize 341 131072 ... ... ... ... +``` + +#### How to interpret each measurement + +| Measurement | Meaning | Better result | +| --- | --- | --- | +| `best us` | Fastest per-operation time among the six samples, in microseconds. | **Lower is better.** | +| `median us` | Conventional median per-operation time across the six samples. This is the best primary comparison because it is less sensitive to one unusually fast or slow sample. | **Lower is better.** | +| `avg us` | Arithmetic mean per-operation time across the six samples. | **Lower is better.** | +| `MiB/s` | Original input JSON size divided by `median us`, normalized to mebibytes per second. | **Higher is better.** | +| `bytes` | Byte length of the original input JSON. It is not serialized output size or DOM memory usage. | Context only; neither lower nor higher is better. | +| `iters` | Number of operations in each timed sample, selected automatically so the sample runs long enough. | Context only; neither lower nor higher is better. | + +`MiB/s` always uses the original input size. For `serialize`, `traverse`, and +`copy`, it is therefore a consistent input-size-normalized rate, not a count of +the actual bytes emitted, visited, or copied. + +The timed work includes result consumption that prevents the compiler from +removing the operation: + +| Operation | Work included in the timed body | +| --- | --- | +| `parse` | Parse the input into a DOM, then recursively hash the result. | +| `serialize` | Compact-serialize a pre-parsed DOM, then hash the output. | +| `traverse` | Recursively hash a pre-parsed DOM. | +| `copy` | Deep-copy a pre-parsed DOM, then recursively hash the copy. | + +The final `sink=` line is only an opaque anti-optimization checksum. It is not a +performance measurement and should be ignored when comparing libraries. + +The suite does not impose pass/fail thresholds because benchmark numbers are +sensitive to machine load, CPU scaling, allocator behavior, and backend-specific +parser strategies. Record results from comparable Release builds on the same +machine when tracking regressions. diff --git a/bench/src/benchmark_main.cpp b/bench/src/benchmark_main.cpp new file mode 100644 index 0000000..69701f4 --- /dev/null +++ b/bench/src/benchmark_main.cpp @@ -0,0 +1,939 @@ +#include "pjson.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef PJSON_BENCH_COMPARE +#include +#include +#include +#include +#include +#include +#endif + +namespace { + + using ByteDance::pjson; + + // ------------------------------------------------------------------------- + // Benchmark data and anti-optimization state + // ------------------------------------------------------------------------- + + // Timed operations feed observable results into these sinks so an optimizing + // compiler cannot discard otherwise unused parse, traversal, copy, or output work. + volatile std::size_t g_sink_size = 0; + volatile std::uint64_t g_sink_hash = 0; + + // Owns both the source text and its pre-parsed pjson DOM. Parse benchmarks use + // jsonText, while the other operations intentionally reuse parsed. + struct Workload { + std::string name; + std::string origin; + std::string jsonText; + pjson::unique_ptr parsed; + }; + + // Per-operation timing summary. Times remain in nanoseconds internally and + // are converted to microseconds only while rendering the report. + struct RunStats { + std::size_t iterations; + double bestNs; + double medianNs; + double averageNs; + double mibPerSecond; + }; + + // Defers printing until all libraries have run, allowing rows to be grouped + // by workload and operation instead of by implementation. + struct BenchmarkResult { + std::size_t workloadIndex; + std::string library; + std::string operation; + RunStats stats; + }; + + // ------------------------------------------------------------------------- + // Stable result hashing and DOM traversal + // ------------------------------------------------------------------------- + + // Combines one value into a running, deterministic checksum. This is an + // anti-optimization aid, not a cryptographic or collision-resistant hash. + std::uint64_t mixHash(std::uint64_t seed, std::uint64_t value) { + seed ^= value + 0x9e3779b97f4a7c15ULL + (seed << 6U) + (seed >> 2U); + return seed; + } + + // Hashes bytes with FNV-style mixing so serialized output and JSON strings + // have an observable value without console I/O during timing. + std::uint64_t hashBytes(const char* data, std::size_t size) { + std::uint64_t hash = 1469598103934665603ULL; + for (std::size_t i = 0; i < size; ++i) { + hash ^= static_cast(data[i]); + hash *= 1099511628211ULL; + } + return hash; + } + + std::uint64_t hashString(const std::string& value) { + return hashBytes(value.data(), value.size()); + } + + // Consumes a size-valued result outside the measured library's data model. + void consumeSize(std::size_t value) { + g_sink_size ^= value + 0x9e3779b9U; + } + + // Consumes a checksum produced by parsing, serialization, or traversal. + void consumeHash(std::uint64_t value) { + g_sink_hash ^= value + 0x517cc1b727220a95ULL; + } + + // Recursively visits every pjson node and incorporates types, values, keys, + // and collection sizes into a checksum. Each backend below mirrors this work. + std::uint64_t traversePjson(const pjson& value) { + std::uint64_t hash = + mixHash(0x84222325cbf29ce4ULL, static_cast(value.getType())); + switch (value.getType()) { + case pjson::jsonNull: + return mixHash(hash, 0ULL); + case pjson::jsonString: { + pjson::StringView text; + return value.tryGet(text) ? mixHash(hash, hashBytes(text.data(), text.size())) + : hash; + } + case pjson::jsonNumberInt: { + int64_t number = 0; + return value.tryGet(number) ? mixHash(hash, static_cast(number)) + : hash; + } + case pjson::jsonNumberDouble: { + double number = 0.0; + if (!value.tryGet(number)) { + return hash; + } + std::uint64_t bits = 0; + std::memcpy(&bits, &number, sizeof(bits)); + return mixHash(hash, bits); + } + case pjson::jsonBoolean: { + bool boolean = false; + return value.tryGet(boolean) ? mixHash(hash, boolean ? 1ULL : 0ULL) : hash; + } + case pjson::jsonArray: { + const std::size_t elementCount = value.size(); + hash = mixHash(hash, static_cast(elementCount)); + for (std::size_t i = 0; i < elementCount; ++i) { + const pjson* child = value.find(static_cast(i)); + if (child != NULL) { + hash = mixHash(hash, traversePjson(*child)); + } + } + return hash; + } + case pjson::jsonObject: { + hash = mixHash(hash, static_cast(value.size())); + const std::vector objectKeys = value.keys(); + for (std::size_t i = 0; i < objectKeys.size(); ++i) { + hash = mixHash(hash, hashString(objectKeys[i])); + const pjson* child = value.find(objectKeys[i]); + if (child != NULL) { + hash = mixHash(hash, traversePjson(*child)); + } + } + return hash; + } + } + return hash; + } + + // ------------------------------------------------------------------------- + // Deterministic generated workloads + // ------------------------------------------------------------------------- + + // Produces fixed-width decimal fragments for repeatable generated keys and IDs. + std::string makePaddedNumber(int value, int width) { + std::ostringstream stream; + stream << std::setw(width) << std::setfill('0') << value; + return stream.str(); + } + + // Builds a compact object representative of a small API/session response. + pjson buildSmallDocument() { + pjson root; + root["kind"] = "session"; + root["active"] = true; + root["version"] = static_cast(3); + root["user"]["id"] = static_cast(42); + root["user"]["name"] = "Ada Lovelace"; + root["user"]["region"] = "us-west"; + root["tags"] += "json"; + root["tags"] += "cxx11"; + root["tags"] += "perf"; + root["stats"]["latency_ms"] = 12.75; + root["stats"]["count"] = static_cast(7); + for (int i = 0; i < 4; ++i) { + pjson event; + event["index"] = static_cast(i); + event["ok"] = (i % 2) == 0; + event["label"] = std::string("evt-") + makePaddedNumber(i, 2); + root["events"][i] = event; + } + return root; + } + + // Builds a medium user dataset with nested sessions, scalar arrays, and a + // separate time series to exercise a varied but predictable DOM shape. + pjson buildMediumDocument() { + pjson root; + root["dataset"] = "medium-generated"; + root["meta"]["page"] = static_cast(5); + root["meta"]["source"] = "benchmark"; + root["meta"]["region"] = "us-central"; + + for (int i = 0; i < 160; ++i) { + pjson user; + user["id"] = static_cast(1000 + i); + user["name"] = std::string("user-") + makePaddedNumber(i, 4); + user["email"] = std::string("user-") + makePaddedNumber(i, 4) + "@example.com"; + user["enabled"] = (i % 3) != 0; + user["ratio"] = 0.5 + static_cast(i % 11) / 10.0; + for (int j = 0; j < 12; ++j) { + user["scores"] += static_cast((i * 13 + j * 7) % 1000); + } + for (int j = 0; j < 6; ++j) { + pjson session; + session["id"] = + std::string("sess-") + makePaddedNumber(i, 4) + "-" + makePaddedNumber(j, 2); + session["duration_ms"] = static_cast(100 + ((i + 1) * (j + 3)) % 4000); + session["success"] = ((i + j) % 5) != 0; + session["endpoint"] = + std::string("/v1/resource/") + makePaddedNumber((i + j) % 23, 2); + user["sessions"][j] = session; + } + user["prefs"]["theme"] = (i % 2 == 0) ? "light" : "dark"; + user["prefs"]["lang"] = (i % 4 == 0) ? "en" : "fr"; + user["prefs"]["alerts"] = (i % 7) != 0; + root["users"][i] = user; + } + + for (int i = 0; i < 48; ++i) { + pjson snapshot; + snapshot["timestamp"] = std::string("2026-08-") + makePaddedNumber((i % 28) + 1, 2); + snapshot["value"] = static_cast(9000 + i * 17); + snapshot["rolling_avg"] = 1.25 + static_cast(i) * 0.125; + root["series"][i] = snapshot; + } + + return root; + } + + // Builds a large inventory dataset whose repeated nested arrays and objects + // make traversal, serialization, and deep-copy costs visible. + pjson buildLargeDocument() { + pjson root; + root["dataset"] = "large-generated"; + root["metadata"]["tenant"] = "benchmark"; + root["metadata"]["version"] = static_cast(20260826); + root["metadata"]["replicas"] = static_cast(3); + root["metadata"]["healthy"] = true; + + for (int i = 0; i < 900; ++i) { + pjson item; + item["id"] = std::string("item-") + makePaddedNumber(i, 5); + item["sku"] = std::string("SKU-") + makePaddedNumber(100000 + i, 6); + item["title"] = std::string("Generated payload entry ") + makePaddedNumber(i, 5); + item["price"] = 9.5 + static_cast((i * 17) % 2500) / 10.0; + item["inventory"] = static_cast((i * 37) % 1200); + item["active"] = (i % 9) != 0; + item["shipping"]["weight_g"] = static_cast(200 + (i % 45) * 13); + item["shipping"]["width_cm"] = 10.0 + static_cast(i % 15); + item["shipping"]["height_cm"] = 6.0 + static_cast((i * 3) % 9); + item["shipping"]["depth_cm"] = 4.0 + static_cast((i * 5) % 11); + + for (int j = 0; j < 5; ++j) { + item["tags"] += std::string("tag-") + makePaddedNumber((i + j) % 40, 2); + } + + for (int j = 0; j < 8; ++j) { + pjson metric; + metric["bucket"] = static_cast(j); + metric["count"] = static_cast(((i + 3) * (j + 5)) % 5000); + metric["avg"] = 0.25 + static_cast((i + j) % 100) / 8.0; + metric["max"] = 1.5 + static_cast((i * (j + 1)) % 700) / 5.0; + item["metrics"][j] = metric; + } + + for (int j = 0; j < 4; ++j) { + pjson change; + change["ts"] = std::string("2026-08-") + makePaddedNumber((j % 28) + 1, 2) + + "T12:" + makePaddedNumber((i + j) % 60, 2) + ":00Z"; + change["actor"] = std::string("svc-") + makePaddedNumber((i + j) % 17, 2); + change["delta"] = static_cast(((i + 1) * (j + 2)) % 31) - 10; + item["history"][j] = change; + } + + root["items"][i] = item; + } + + return root; + } + + // ------------------------------------------------------------------------- + // Workload preparation + // ------------------------------------------------------------------------- + + // Validates and stores one workload, then consumes its initial DOM and size so + // preparation itself cannot be optimized away in whole-program builds. + Workload makeWorkload(const std::string& name, const std::string& origin, + const std::string& jsonText) { + Workload workload; + workload.name = name; + workload.origin = origin; + workload.jsonText = jsonText; + workload.parsed = pjson::parse(workload.jsonText); + if (!workload.parsed) { + std::cerr << "failed to parse benchmark workload: " << name << "\n"; + std::exit(1); + } + consumeHash(traversePjson(*workload.parsed)); + consumeSize(workload.jsonText.size()); + return workload; + } + + // Reads an optional corpus file as raw bytes; an empty string signals an + // unreadable or empty input and is handled by buildWorkloads. + std::string readFile(const std::string& path) { + std::ifstream input(path.c_str(), std::ios::in | std::ios::binary); + if (!input) { + return std::string(); + } + std::ostringstream buffer; + buffer << input.rdbuf(); + return buffer.str(); + } + + // Extracts a display name while accepting both POSIX and Windows separators. + std::string basenameOf(const std::string& path) { + const std::string::size_type slash = path.find_last_of("/\\"); + if (slash == std::string::npos) { + return path; + } + return path.substr(slash + 1); + } + + // Creates the three built-in workloads and appends each valid user-supplied + // corpus document. Invalid inputs are warned about rather than aborting a run. + std::vector buildWorkloads(const std::vector& inputFiles) { + std::vector workloads; + workloads.push_back(makeWorkload("small", "generated", buildSmallDocument().toString())); + workloads.push_back(makeWorkload("medium", "generated", buildMediumDocument().toString())); + workloads.push_back(makeWorkload("large", "generated", buildLargeDocument().toString())); + + for (std::size_t i = 0; i < inputFiles.size(); ++i) { + const std::string jsonText = readFile(inputFiles[i]); + if (jsonText.empty()) { + std::cerr << "warning: unable to read benchmark input '" << inputFiles[i] << "'\n"; + continue; + } + + pjson::unique_ptr parsed = pjson::parse(jsonText); + if (!parsed) { + std::cerr << "warning: benchmark input is not valid JSON and was skipped: " + << inputFiles[i] << "\n"; + continue; + } + + Workload workload; + workload.name = std::string("corpus:") + basenameOf(inputFiles[i]); + workload.origin = inputFiles[i]; + workload.jsonText = jsonText; + workload.parsed = std::move(parsed); + workloads.push_back(std::move(workload)); + } + + return workloads; + } + + // ------------------------------------------------------------------------- + // Adaptive timing and statistics + // ------------------------------------------------------------------------- + + // Executes one operation repeatedly under a single steady-clock measurement. + template double runBatch(std::size_t iterations, Operation operation) { + const std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); + for (std::size_t i = 0; i < iterations; ++i) { + operation(); + } + const std::chrono::steady_clock::time_point end = std::chrono::steady_clock::now(); + const std::chrono::duration elapsed = end - start; + return elapsed.count(); + } + + // Warms up the operation, doubles the batch size until it reaches the target + // duration, then records six equal-sized samples. Adaptive batches reduce timer + // noise for fast cases while the fixed cap bounds unexpectedly expensive runs. + template + RunStats measure(const std::string& payload, Operation operation) { + static const double kTargetSeconds = 0.075; + static const std::size_t kMaxIterations = 1U << 22U; + static const int kSamples = 6; + + // Perform one untimed call to trigger lazy initialization before calibration. + operation(); + + // Calibrate with powers of two so every timed sample has enough useful work. + std::size_t iterations = 1; + while (iterations < kMaxIterations) { + const double seconds = runBatch(iterations, operation); + if (seconds >= kTargetSeconds) { + break; + } + const std::size_t next = iterations * 2U; + if (next <= iterations) { + break; + } + iterations = next; + } + + // Normalize samples to per-operation nanoseconds before summarizing them. + std::vector sampleNs; + sampleNs.reserve(kSamples); + double totalNs = 0.0; + double bestNs = std::numeric_limits::max(); + for (int i = 0; i < kSamples; ++i) { + const double seconds = runBatch(iterations, operation); + const double nsPerIteration = + (seconds * 1000000000.0) / static_cast(iterations); + sampleNs.push_back(nsPerIteration); + totalNs += nsPerIteration; + if (nsPerIteration < bestNs) { + bestNs = nsPerIteration; + } + } + + // Sorting places the middle samples needed for the conventional median. + std::sort(sampleNs.begin(), sampleNs.end()); + RunStats stats; + stats.iterations = iterations; + stats.bestNs = bestNs; + const std::size_t middle = sampleNs.size() / 2; + if ((sampleNs.size() % 2U) == 0U) { + stats.medianNs = (sampleNs[middle - 1U] + sampleNs[middle]) / 2.0; + } else { + stats.medianNs = sampleNs[middle]; + } + stats.averageNs = totalNs / static_cast(sampleNs.size()); + stats.mibPerSecond = 0.0; + // All operations use source payload bytes for a consistent normalized rate. + if (stats.medianNs > 0.0) { + stats.mibPerSecond = + (static_cast(payload.size()) * 1000000000.0 / stats.medianNs) / + (1024.0 * 1024.0); + } + return stats; + } + + // ------------------------------------------------------------------------- + // Command help and report rendering + // ------------------------------------------------------------------------- + + // Prints accepted arguments and the high-level benchmark scope. + void printUsage(const char* argv0) { + std::cout << "Usage: " << argv0 << " [--input ]... [--compare]\n" + << "Benchmarks parse, compact serialize, traversal, and deep copy\n" + << "across generated small/medium/large documents, plus any extra\n" + << "JSON files supplied with --input. When built with optional\n" + << "third-party dependencies, --compare groups every benchmark case\n" + << "with adjacent cross-library rows. Timing is lower-is-better;\n" + << "throughput is higher-is-better.\n"; + } + + // Explains which reported columns are measurements and which provide context. + void printMetricGuide() { + std::cout + << "How to read the measurements:\n" + << " best/median/avg us : microseconds per operation; LOWER is better.\n" + << " MiB/s : input-size-normalized rate from median time; HIGHER is " + "better.\n" + << " bytes : original input JSON size; context only, not a score.\n" + << " iters : operations in each of six timed samples; context only, " + "not a score.\n" + << " MiB/s always uses original input size; it is not actual output, traversal, or " + "copy bytes.\n\n"; + } + + // Prints the fixed-width table heading shared by baseline and comparison runs. + void printHeader() { + std::cout << std::left << std::setw(14) << "library" << std::setw(22) << "workload" + << std::setw(12) << "operation" << std::right << std::setw(12) << "bytes" + << std::setw(12) << "iters" << std::setw(14) << "best us" << std::setw(14) + << "median us" << std::setw(14) << "avg us" << std::setw(12) << "MiB/s" << "\n"; + std::cout << std::string(126, '-') << "\n"; + } + + // Renders one recorded case, converting nanoseconds to microseconds at the edge. + void printResultRow(const char* library, const Workload& workload, const char* operation, + const RunStats& stats) { + std::cout << std::left << std::setw(14) << library << std::setw(22) << workload.name + << std::setw(12) << operation << std::right << std::setw(12) + << workload.jsonText.size() << std::setw(12) << stats.iterations << std::setw(14) + << std::fixed << std::setprecision(2) << (stats.bestNs / 1000.0) << std::setw(14) + << (stats.medianNs / 1000.0) << std::setw(14) << (stats.averageNs / 1000.0) + << std::setw(12) << std::setprecision(1) << stats.mibPerSecond << "\n"; + } + + // Appends a result with its workload index so no workload text or DOM is copied. + void recordResult(std::vector& results, std::size_t workloadIndex, + const char* library, const char* operation, const RunStats& stats) { + BenchmarkResult result; + result.workloadIndex = workloadIndex; + result.library = library; + result.operation = operation; + result.stats = stats; + results.push_back(result); + } + + // Emits results in workload/operation order. Since each runner appends results + // in library order, comparable implementations appear on adjacent rows. + void printResultsByCase(const std::vector& workloads, + const std::vector& results) { + // This canonical order also determines which operation groups are emitted. + static const char* const kOperations[] = {"parse", "serialize", "traverse", "copy"}; + + printMetricGuide(); + printHeader(); + for (std::size_t workloadIndex = 0; workloadIndex < workloads.size(); ++workloadIndex) { + for (std::size_t operationIndex = 0; + operationIndex < sizeof(kOperations) / sizeof(kOperations[0]); ++operationIndex) { + bool printedCase = false; + for (std::size_t resultIndex = 0; resultIndex < results.size(); ++resultIndex) { + const BenchmarkResult& result = results[resultIndex]; + if (result.workloadIndex != workloadIndex || + result.operation != kOperations[operationIndex]) { + continue; + } + printResultRow(result.library.c_str(), workloads[workloadIndex], + result.operation.c_str(), result.stats); + printedCase = true; + } + if (printedCase) { + std::cout << "\n"; + } + } + } + } + + // ------------------------------------------------------------------------- + // pjson benchmark cases + // ------------------------------------------------------------------------- + + // Measures all pjson operations. Only parse constructs its input DOM in the + // timed body; serialize, traverse, and copy start from the prepared DOM. + void runPjsonBenchmarks(const std::vector& workloads, + std::vector& results) { + for (std::size_t i = 0; i < workloads.size(); ++i) { + const Workload& workload = workloads[i]; + + const RunStats parseStats = measure(workload.jsonText, [&workload]() { + pjson::unique_ptr parsed = pjson::parse(workload.jsonText); + if (!parsed) { + std::cerr << "benchmark parse failed for " << workload.name << "\n"; + std::exit(1); + } + consumeHash(traversePjson(*parsed)); + }); + recordResult(results, i, "pjson", "parse", parseStats); + + const RunStats serializeStats = measure(workload.jsonText, [&workload]() { + const std::string jsonText = workload.parsed->toString(); + consumeSize(jsonText.size()); + consumeHash(hashString(jsonText)); + }); + recordResult(results, i, "pjson", "serialize", serializeStats); + + const RunStats traverseStats = measure( + workload.jsonText, [&workload]() { consumeHash(traversePjson(*workload.parsed)); }); + recordResult(results, i, "pjson", "traverse", traverseStats); + + const RunStats copyStats = measure(workload.jsonText, [&workload]() { + pjson copy(*workload.parsed); + consumeHash(traversePjson(copy)); + consumeSize(copy.size()); + }); + recordResult(results, i, "pjson", "copy", copyStats); + } + } + +#ifdef PJSON_BENCH_COMPARE + // ------------------------------------------------------------------------- + // Optional third-party traversal adapters + // ------------------------------------------------------------------------- + + // Mirrors traversePjson for nlohmann/json so traversal measurements perform + // equivalent recursive reads and produce an observable checksum. + std::uint64_t traverseNlohmann(const nlohmann::json& value) { + std::uint64_t hash = + mixHash(0x0df1cc84222325cbULL, static_cast(value.type())); + if (value.is_null()) { + return mixHash(hash, 0ULL); + } + if (value.is_boolean()) { + return mixHash(hash, value.get() ? 1ULL : 0ULL); + } + if (value.is_number_integer()) { + return mixHash(hash, static_cast(value.get())); + } + if (value.is_number_unsigned()) { + return mixHash(hash, static_cast(value.get())); + } + if (value.is_number_float()) { + const double number = value.get(); + std::uint64_t bits = 0; + std::memcpy(&bits, &number, sizeof(bits)); + return mixHash(hash, bits); + } + if (value.is_string()) { + return mixHash(hash, hashString(value.get())); + } + if (value.is_array()) { + hash = mixHash(hash, static_cast(value.size())); + for (nlohmann::json::const_iterator it = value.begin(); it != value.end(); ++it) { + hash = mixHash(hash, traverseNlohmann(*it)); + } + return hash; + } + if (value.is_object()) { + hash = mixHash(hash, static_cast(value.size())); + for (nlohmann::json::const_iterator it = value.begin(); it != value.end(); ++it) { + hash = mixHash(hash, hashString(it.key())); + hash = mixHash(hash, traverseNlohmann(it.value())); + } + return hash; + } + return hash; + } + + // Mirrors the traversal workload for RapidJSON, preserving explicit string + // lengths so embedded null bytes are included in the checksum. + std::uint64_t traverseRapidJson(const rapidjson::Value& value) { + std::uint64_t hash = + mixHash(0x1f1236bb5aa45d11ULL, static_cast(value.GetType())); + if (value.IsNull()) { + return mixHash(hash, 0ULL); + } + if (value.IsBool()) { + return mixHash(hash, value.GetBool() ? 1ULL : 0ULL); + } + if (value.IsInt64()) { + return mixHash(hash, static_cast(value.GetInt64())); + } + if (value.IsUint64()) { + return mixHash(hash, value.GetUint64()); + } + if (value.IsNumber()) { + const double number = value.GetDouble(); + std::uint64_t bits = 0; + std::memcpy(&bits, &number, sizeof(bits)); + return mixHash(hash, bits); + } + if (value.IsString()) { + return mixHash(hash, + hashString(std::string(value.GetString(), value.GetStringLength()))); + } + if (value.IsArray()) { + hash = mixHash(hash, static_cast(value.Size())); + for (rapidjson::Value::ConstValueIterator it = value.Begin(); it != value.End(); ++it) { + hash = mixHash(hash, traverseRapidJson(*it)); + } + return hash; + } + if (value.IsObject()) { + hash = mixHash(hash, static_cast(value.MemberCount())); + for (rapidjson::Value::ConstMemberIterator it = value.MemberBegin(); + it != value.MemberEnd(); ++it) { + hash = mixHash(hash, hashString(std::string(it->name.GetString(), + it->name.GetStringLength()))); + hash = mixHash(hash, traverseRapidJson(it->value)); + } + return hash; + } + return hash; + } + + // Mirrors the traversal workload for simdjson's DOM API. Accessors can report + // errors, so a failed scalar read contributes only the already mixed-in type. + std::uint64_t traverseSimdjson(simdjson::dom::element element) { + simdjson::dom::element_type type = element.type(); + std::uint64_t hash = mixHash(0xc3137b0f3a6f1ae7ULL, static_cast(type)); + switch (type) { + case simdjson::dom::element_type::ARRAY: { + simdjson::dom::array array = element.get_array(); + std::size_t count = 0; + for (simdjson::dom::array::iterator it = array.begin(); it != array.end(); ++it) { + ++count; + hash = mixHash(hash, traverseSimdjson(*it)); + } + return mixHash(hash, static_cast(count)); + } + case simdjson::dom::element_type::OBJECT: { + simdjson::dom::object object = element.get_object(); + std::size_t count = 0; + for (simdjson::dom::object::iterator it = object.begin(); it != object.end(); + ++it) { + ++count; + std::string_view key = it.key(); + hash = mixHash(hash, hashString(std::string(key.data(), key.size()))); + hash = mixHash(hash, traverseSimdjson(it.value())); + } + return mixHash(hash, static_cast(count)); + } + case simdjson::dom::element_type::INT64: { + int64_t value = 0; + if (element.get(value)) { + return hash; + } + return mixHash(hash, static_cast(value)); + } + case simdjson::dom::element_type::UINT64: { + uint64_t value = 0; + if (element.get(value)) { + return hash; + } + return mixHash(hash, value); + } + case simdjson::dom::element_type::DOUBLE: { + double number = 0.0; + if (element.get(number)) { + return hash; + } + std::uint64_t bits = 0; + std::memcpy(&bits, &number, sizeof(bits)); + return mixHash(hash, bits); + } + case simdjson::dom::element_type::STRING: { + std::string_view value; + if (element.get(value)) { + return hash; + } + return mixHash(hash, hashString(std::string(value.data(), value.size()))); + } + case simdjson::dom::element_type::BOOL: { + bool value = false; + if (element.get(value)) { + return hash; + } + return mixHash(hash, value ? 1ULL : 0ULL); + } + case simdjson::dom::element_type::NULL_VALUE: + return mixHash(hash, 0ULL); + } + return hash; + } + + // ------------------------------------------------------------------------- + // Optional cross-library benchmark cases + // ------------------------------------------------------------------------- + + // Records equivalent nlohmann/json, RapidJSON, and simdjson cases for each + // workload. Prepared DOMs live through every timed lambda; in particular, the + // simdjson parser must outlive the element views it owns. + void runCompareBenchmarks(const std::vector& workloads, + std::vector& results) { + for (std::size_t i = 0; i < workloads.size(); ++i) { + const Workload& workload = workloads[i]; + + // nlohmann/json: parse owns a fresh DOM each iteration; the remaining + // cases use this pre-parsed DOM to isolate their respective operations. + const RunStats nlohmannParse = measure(workload.jsonText, [&workload]() { + nlohmann::json parsed = nlohmann::json::parse(workload.jsonText); + consumeHash(traverseNlohmann(parsed)); + }); + recordResult(results, i, "nlohmann", "parse", nlohmannParse); + + nlohmann::json nlohmannParsed = nlohmann::json::parse(workload.jsonText); + const RunStats nlohmannSerialize = measure(workload.jsonText, [&nlohmannParsed]() { + const std::string jsonText = nlohmannParsed.dump(); + consumeSize(jsonText.size()); + consumeHash(hashString(jsonText)); + }); + recordResult(results, i, "nlohmann", "serialize", nlohmannSerialize); + + const RunStats nlohmannTraverse = measure(workload.jsonText, [&nlohmannParsed]() { + consumeHash(traverseNlohmann(nlohmannParsed)); + }); + recordResult(results, i, "nlohmann", "traverse", nlohmannTraverse); + + const RunStats nlohmannCopy = measure(workload.jsonText, [&nlohmannParsed]() { + nlohmann::json copy = nlohmannParsed; + consumeHash(traverseNlohmann(copy)); + consumeSize(copy.size()); + }); + recordResult(results, i, "nlohmann", "copy", nlohmannCopy); + + // RapidJSON follows the same split. CopyFrom performs the comparable + // allocator-aware deep copy measured by the copy case. + const RunStats rapidjsonParse = measure(workload.jsonText, [&workload]() { + rapidjson::Document document; + document.Parse(workload.jsonText.c_str(), workload.jsonText.size()); + if (document.HasParseError()) { + std::cerr << "rapidjson parse failed for " << workload.name << "\n"; + std::exit(1); + } + consumeHash(traverseRapidJson(document)); + }); + recordResult(results, i, "rapidjson", "parse", rapidjsonParse); + + rapidjson::Document rapidjsonParsed; + rapidjsonParsed.Parse(workload.jsonText.c_str(), workload.jsonText.size()); + if (rapidjsonParsed.HasParseError()) { + std::cerr << "rapidjson parse failed for " << workload.name << "\n"; + std::exit(1); + } + const RunStats rapidjsonSerialize = measure(workload.jsonText, [&rapidjsonParsed]() { + rapidjson::StringBuffer buffer; + rapidjson::Writer writer(buffer); + rapidjsonParsed.Accept(writer); + consumeSize(buffer.GetSize()); + consumeHash(hashString(std::string(buffer.GetString(), buffer.GetSize()))); + }); + recordResult(results, i, "rapidjson", "serialize", rapidjsonSerialize); + + const RunStats rapidjsonTraverse = measure(workload.jsonText, [&rapidjsonParsed]() { + consumeHash(traverseRapidJson(rapidjsonParsed)); + }); + recordResult(results, i, "rapidjson", "traverse", rapidjsonTraverse); + + const RunStats rapidjsonCopy = measure(workload.jsonText, [&rapidjsonParsed]() { + rapidjson::Document copy; + copy.CopyFrom(rapidjsonParsed, copy.GetAllocator()); + consumeHash(traverseRapidJson(copy)); + consumeSize(copy.IsArray() ? copy.Size() + : (copy.IsObject() ? copy.MemberCount() : 0)); + }); + recordResult(results, i, "rapidjson", "copy", rapidjsonCopy); + + // simdjson parsing includes a fresh parser because a DOM element borrows + // parser-owned storage. The prepared parser below stays alive for the + // serialization and traversal measurements that reuse its element. + const RunStats simdjsonParse = measure(workload.jsonText, [&workload]() { + simdjson::dom::parser parser; + simdjson::dom::element doc; + simdjson::error_code error = parser.parse(workload.jsonText).get(doc); + if (error) { + std::cerr << "simdjson parse failed for " << workload.name << ": " + << simdjson::error_message(error) << "\n"; + std::exit(1); + } + consumeHash(traverseSimdjson(doc)); + }); + recordResult(results, i, "simdjson", "parse", simdjsonParse); + + simdjson::dom::parser simdjsonParser; + simdjson::dom::element simdjsonParsed; + simdjson::error_code simdjsonError = + simdjsonParser.parse(workload.jsonText).get(simdjsonParsed); + if (simdjsonError) { + std::cerr << "simdjson parse failed for " << workload.name << ": " + << simdjson::error_message(simdjsonError) << "\n"; + std::exit(1); + } + const RunStats simdjsonSerialize = measure(workload.jsonText, [&simdjsonParsed]() { + const std::string jsonText = simdjson::minify(simdjsonParsed); + consumeSize(jsonText.size()); + consumeHash(hashString(jsonText)); + }); + recordResult(results, i, "simdjson", "serialize", simdjsonSerialize); + + const RunStats simdjsonTraverse = measure(workload.jsonText, [&simdjsonParsed]() { + consumeHash(traverseSimdjson(simdjsonParsed)); + }); + recordResult(results, i, "simdjson", "traverse", simdjsonTraverse); + // simdjson intentionally has no copy row: its borrowed DOM does not offer + // an owned mutable deep-copy operation comparable to the other libraries. + } + } +#endif + +} // namespace + +// Parses command-line inputs, prepares workloads, records enabled implementations, +// and renders the accumulated results only after every timed case has completed. +int main(int argc, char** argv) { + // --- Parse command-line inputs -------------------------------------------- + std::vector inputFiles; + bool requestCompare = false; + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg == "--help" || arg == "-h") { + printUsage(argv[0]); + return 0; + } + if (arg == "--compare") { + requestCompare = true; + continue; + } + if (arg == "--input") { + if (i + 1 >= argc) { + std::cerr << "--input requires a file path\n"; + return 2; + } + inputFiles.push_back(argv[++i]); + continue; + } + if (arg.compare(0, 8, "--input=") == 0) { + inputFiles.push_back(arg.substr(8)); + continue; + } + // Bare arguments are accepted as corpus paths for direct invocation. + inputFiles.push_back(arg); + } + + // --- Prepare and run every enabled implementation ------------------------- + const std::vector workloads = buildWorkloads(inputFiles); + if (workloads.empty()) { + std::cerr << "no benchmark workloads available\n"; + return 1; + } + + std::cout << "pjson benchmark suite\n"; + std::cout << "generated workloads: small, medium, large"; + if (!inputFiles.empty()) { + std::cout << " | requested extra inputs: " << inputFiles.size(); + } + if (requestCompare) { + std::cout << " | compare requested"; + } + std::cout << "\n"; + + std::vector results; + runPjsonBenchmarks(workloads, results); + +#ifdef PJSON_BENCH_COMPARE + if (requestCompare) { + runCompareBenchmarks(workloads, results); + } +#else + if (requestCompare) { + std::cerr << "compare mode requested, but this benchmark binary was built without " + "PJSON_BENCH_COMPARE enabled\n"; + return 2; + } +#endif + + // --- Render grouped results and the anti-optimization checksum ------------- + printResultsByCase(workloads, results); + std::cout << std::string(126, '-') << "\n"; + std::cout << "sink=" << g_sink_size << "/" << g_sink_hash + << " (anti-optimization checksum; not a performance measurement)\n"; + return 0; +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..0d6f5da --- /dev/null +++ b/build.sh @@ -0,0 +1,961 @@ +#!/usr/bin/env bash +# +# Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"). +# +# Cross-platform build driver for pjson (Linux, macOS, Windows via Git Bash / +# MSYS2). Builds the library, the unit tests, and the examples in BOTH Release +# and Debug, collecting everything under out/: +# +# out/ +# release/{lib,bin,bin/examples} Release library, tests, examples +# debug/{lib,bin,bin/examples} Debug library, tests, examples +# include/pjson.h public header +# build-release/ build-debug/ CMake build trees +# +# Usage: +# ./build.sh Do everything (same as --all) +# ./build.sh --all Clean, check formatting, build Release + a +# sanitized Debug, fetch conformance corpora if missing, +# run tests/comparison benchmarks, replay the fuzz +# seed corpora when supported, validate docs and +# packages/licensing, and run clang-tidy +# ./build.sh --test Also run the test suite +# ./build.sh --bench Build, then run the Release benchmark suite +# ./build.sh --bench-compare +# Build/run the optional third-party comparison +# benchmark suite (fetches pinned deps if needed) +# ./build.sh --fuzz Build libFuzzer targets and replay their seed +# corpora with a deterministic run budget +# ./build.sh --docs Build and validate the Doxygen API reference +# ./build.sh --package Validate static/shared installs and pkg-config +# ./build.sh --license Validate SPDX/REUSE licensing metadata +# ./build.sh --clean Remove out/ before building +# ./build.sh --asan Single Debug build with Address/UB sanitizers +# ./build.sh --release-only Build only the Release configuration +# ./build.sh --debug-only Build only the Debug configuration +# ./build.sh --format Reformat all sources with clang-format (in place) +# ./build.sh --check Verify formatting without changing files +# ./build.sh --tidy Run clang-tidy static analysis (fails on findings) +# ./build.sh --bench-input PATH +# Add an extra JSON file for benchmark coverage +# ./build.sh --auto Never prompt; auto-install/download dependencies +# +# Flags combine freely. Missing tools and optional JSON/JSON-Schema conformance +# corpora are detected and, with your confirmation, installed/downloaded; pass +# --auto to do so without prompting (useful for CI). +# Contributors (and CI) can simply run: +# +# ./build.sh # or, equivalently: ./build.sh --all +# +# which is a superset of the older "--clean --asan --test --check --tidy". +# +set -euo pipefail + +# Resolve the repository root (directory containing this script) so the build +# works regardless of the caller's current directory. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +OUT_DIR="${SCRIPT_DIR}/out" + +# --------------------------------------------------------------------------- +# Command-line selection. +# --------------------------------------------------------------------------- + +# Prints the supported workflow flags and their dependency/download behavior. +usage() { + cat <<'USAGE' +Usage: ./build.sh [flags] + --all Do everything: clean, check formatting, build Release + a + sanitized Debug, fetch JSON/JSON-Schema test corpora and + benchmark dependencies if missing, run tests/comparison + benchmarks, bounded fuzz corpus smoke when supported, docs, + package consumers, SPDX/REUSE metadata, and clang-tidy + (also the default when no flags are given) + --test Build, then run the test suite + --bench Build, then run the Release benchmark suite + --bench-compare Build, then run the Release benchmark comparison suite + --bench-input Add an extra JSON file to the benchmark corpus (repeatable) + --fuzz Build libFuzzer targets and run bounded corpus smoke tests + --docs Build and validate the generated API reference + --package Run static/shared install and pkg-config consumer smoke tests + --license Validate SPDX/REUSE licensing metadata + --clean Remove out/ before building + --asan Single Debug build with Address/UB sanitizers + --release-only Build only the Release configuration + --debug-only Build only the Debug configuration + --format Reformat all sources with clang-format (in place) + --check Verify formatting without changing files (fails if dirty) + --tidy Run clang-tidy static analysis (fails on findings) + --auto Never prompt; auto-install/download missing dependencies + --help Show this help + +With no flags, ./build.sh behaves like ./build.sh --all. Otherwise both Release +and Debug are built (library + tests + examples + benchmarks) into out/, and +only the steps you ask for run. Flags combine freely, e.g.: + ./build.sh --clean --asan --test --bench --fuzz --check --tidy + +Missing tools (cmake and, when selected, pkg-config, clang-format, clang-tidy, +Doxygen, Python, or the pinned REUSE checker) are detected and offered for +installation/download through the system package manager or into `out/`. +During --test and --all runs, missing JSONTestSuite and +JSON-Schema-Test-Suite corpora are similarly offered for download. Pass --auto +to install/download without prompting. Benchmarks always run from the Release +build; add extra JSON files with --bench-input PATH. +--bench-compare fetches pinned nlohmann/json, RapidJSON, and simdjson sources +into .benchmark-deps/. It runs when requested directly and as part of --all. +USAGE +} + +DO_CLEAN=0 +DO_TEST=0 +DO_BENCH=0 +DO_BENCH_COMPARE=0 +DO_FUZZ=0 +DO_DOCS=0 +DO_PACKAGE=0 +DO_LICENSE=0 +DO_ASAN=0 +DO_FORMAT=0 +DO_CHECK=0 +DO_TIDY=0 +DO_ALL=0 +AUTO=0 +RELEASE_ONLY=0 +DEBUG_ONLY=0 +BENCH_INPUTS=() + +# No flags at all is a friendly shortcut for --all (do everything). +if [ "$#" -eq 0 ]; then + DO_ALL=1 +fi + +while [ "$#" -gt 0 ]; do + case "$1" in + --all) DO_ALL=1 ;; + --clean) DO_CLEAN=1 ;; + --test) DO_TEST=1 ;; + --bench) DO_BENCH=1 ;; + --bench-compare) DO_BENCH=1; DO_BENCH_COMPARE=1 ;; + --fuzz) DO_FUZZ=1 ;; + --docs) DO_DOCS=1 ;; + --package) DO_PACKAGE=1 ;; + --license) DO_LICENSE=1 ;; + --asan) DO_ASAN=1 ;; + --release-only) RELEASE_ONLY=1 ;; + --debug-only) DEBUG_ONLY=1 ;; + --format) DO_FORMAT=1 ;; + --check) DO_CHECK=1 ;; + --tidy) DO_TIDY=1 ;; + --bench-input) + shift + if [ "$#" -eq 0 ]; then + echo "Missing value for --bench-input" >&2 + usage >&2 + exit 2 + fi + BENCH_INPUTS+=("$1") + ;; + --bench-input=*) + BENCH_INPUTS+=("${1#--bench-input=}") + ;; + --auto|--yes|-y) AUTO=1 ;; + -h|--help) usage; exit 0 ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +# --all expands to the full contributor sweep. --auto (non-interactive tool +# install) is intentionally left independent so it can be combined with --all. +if [ "${DO_ALL}" -eq 1 ]; then + DO_CLEAN=1 + DO_CHECK=1 + DO_ASAN=1 + DO_TEST=1 + DO_BENCH=1 + DO_BENCH_COMPARE=1 + DO_FUZZ=1 + DO_DOCS=1 + DO_PACKAGE=1 + DO_LICENSE=1 + DO_TIDY=1 +fi + +if [ "${RELEASE_ONLY}" -eq 1 ] && [ "${DEBUG_ONLY}" -eq 1 ]; then + echo "--release-only and --debug-only cannot be combined." >&2 + exit 2 +fi +if [ "${DO_ASAN}" -eq 1 ] && [ "${RELEASE_ONLY}" -eq 1 ]; then + echo "--asan requires a Debug build and cannot be combined with --release-only." >&2 + exit 2 +fi +if [ "${DO_BENCH}" -eq 1 ] && [ "${DEBUG_ONLY}" -eq 1 ]; then + echo "--bench/--bench-compare require a Release build and cannot use --debug-only." >&2 + exit 2 +fi + +# --------------------------------------------------------------------------- +# Reproducible comparison-benchmark inputs. +# --------------------------------------------------------------------------- + +BENCH_DEPS_DIR="${SCRIPT_DIR}/.benchmark-deps" + +# Fetches the exact third-party sources used by comparison benchmarks, then +# rejects dirty or revision-mismatched checkouts before they enter a build. +fetch_benchmark_compare_deps() { + local nlohmann_dir="${BENCH_DEPS_DIR}/nlohmann-json-v3.11.3" + local rapidjson_dir="${BENCH_DEPS_DIR}/rapidjson-v1.1.0" + local simdjson_dir="${BENCH_DEPS_DIR}/simdjson-v3.12.2" + local need_fetch=0 + + if [ ! -f "${nlohmann_dir}/single_include/nlohmann/json.hpp" ]; then + need_fetch=1 + fi + if [ ! -f "${rapidjson_dir}/include/rapidjson/document.h" ]; then + need_fetch=1 + fi + if [ ! -f "${simdjson_dir}/CMakeLists.txt" ]; then + need_fetch=1 + fi + ensure_tool git 1 + if [ "${need_fetch}" -ne 0 ]; then + echo ">> Benchmark comparison dependencies are not installed." + echo " Proposed download: ${BENCH_DEPS_DIR}" + echo " Pinned versions: nlohmann/json v3.11.3, RapidJSON v1.1.0, simdjson v3.12.2" + + if [ "${AUTO}" -eq 1 ]; then + echo " --auto: downloading without prompting." + else + printf " Download them now? [y/N] " + local reply="" + read -r reply || reply="" + case "${reply}" in + y|Y|yes|YES) ;; + *) echo ">> --bench-compare requires those pinned sources." >&2; exit 1 ;; + esac + fi + + mkdir -p "${BENCH_DEPS_DIR}" + # Each destination is a fixed child of .benchmark-deps. Removing an + # incomplete clone here cannot affect caller-selected paths. + if [ ! -d "${nlohmann_dir}/.git" ]; then + rm -rf "${nlohmann_dir}" + git clone --depth 1 --branch v3.11.3 https://github.com/nlohmann/json.git "${nlohmann_dir}" + fi + if [ ! -d "${rapidjson_dir}/.git" ]; then + rm -rf "${rapidjson_dir}" + git clone --depth 1 --branch v1.1.0 https://github.com/Tencent/rapidjson.git "${rapidjson_dir}" + fi + if [ ! -d "${simdjson_dir}/.git" ]; then + rm -rf "${simdjson_dir}" + git clone --depth 1 --branch v3.12.2 https://github.com/simdjson/simdjson.git "${simdjson_dir}" + fi + fi + + if [ -n "$(git -C "${nlohmann_dir}" status --porcelain --untracked-files=no)" ] || + [ -n "$(git -C "${rapidjson_dir}" status --porcelain --untracked-files=no)" ] || + [ -n "$(git -C "${simdjson_dir}" status --porcelain --untracked-files=no)" ]; then + echo ">> A benchmark dependency checkout has local modifications." >&2 + echo " Remove .benchmark-deps and retry to restore pinned sources." >&2 + exit 1 + fi + + local nlohmann_commit rapidjson_commit simdjson_commit + nlohmann_commit="$(git -C "${nlohmann_dir}" rev-parse HEAD)" + rapidjson_commit="$(git -C "${rapidjson_dir}" rev-parse HEAD)" + simdjson_commit="$(git -C "${simdjson_dir}" rev-parse HEAD)" + if [ "${nlohmann_commit}" != "9cca280a4d0ccf0c08f47a99aa71d1b0e52f8d03" ] || + [ "${rapidjson_commit}" != "f54b0e47a08782a6131cc3d60f94d038fa6e0a51" ] || + [ "${simdjson_commit}" != "797e61742c9dbabed421dac77b9c3d8acc463afe" ]; then + echo ">> Benchmark dependency revision mismatch; remove .benchmark-deps and retry." >&2 + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Tool bootstrap. +# +# Detects the OS package manager once, then ensure_tool [required] +# locates a tool (PATH, Homebrew LLVM prefix, or versioned binary) and, if +# missing, offers to install it. With --auto it installs without prompting. +# The path to the resolved tool is returned in the global RESOLVED_TOOL. +# --------------------------------------------------------------------------- + +PKG_INSTALL="" +PKG_KIND="" + +# Detects and caches the first supported system package manager. +detect_pkg_manager() { + if [ -n "${PKG_KIND}" ]; then + return + fi + if command -v brew >/dev/null 2>&1; then + PKG_KIND="brew"; PKG_INSTALL="brew install" + elif command -v apt-get >/dev/null 2>&1; then + PKG_KIND="apt"; PKG_INSTALL="sudo apt-get install -y" + elif command -v dnf >/dev/null 2>&1; then + PKG_KIND="dnf"; PKG_INSTALL="sudo dnf install -y" + elif command -v yum >/dev/null 2>&1; then + PKG_KIND="yum"; PKG_INSTALL="sudo yum install -y" + elif command -v pacman >/dev/null 2>&1; then + PKG_KIND="pacman"; PKG_INSTALL="sudo pacman -S --noconfirm" + elif command -v zypper >/dev/null 2>&1; then + PKG_KIND="zypper"; PKG_INSTALL="sudo zypper install -y" + elif command -v apk >/dev/null 2>&1; then + PKG_KIND="apk"; PKG_INSTALL="sudo apk add" + elif command -v choco >/dev/null 2>&1; then + PKG_KIND="choco"; PKG_INSTALL="choco install -y" + elif command -v winget >/dev/null 2>&1; then + PKG_KIND="winget"; PKG_INSTALL="winget install -e --id" + else + PKG_KIND="none"; PKG_INSTALL="" + fi +} + +# Maps a logical tool to the package name for the detected manager. +package_for() { + local tool="$1" + case "${tool}" in + cmake) + case "${PKG_KIND}" in + choco) echo "cmake" ;; + winget) echo "Kitware.CMake" ;; + *) echo "cmake" ;; + esac ;; + git) + case "${PKG_KIND}" in + winget) echo "Git.Git" ;; + *) echo "git" ;; + esac ;; + pkg-config) + case "${PKG_KIND}" in + brew) echo "pkgconf" ;; + apt) echo "pkg-config" ;; + dnf|yum) echo "pkgconf-pkg-config" ;; + pacman) echo "pkgconf" ;; + zypper) echo "pkg-config" ;; + apk) echo "pkgconf" ;; + choco) echo "pkgconfiglite" ;; + *) echo "" ;; + esac ;; + clang-format|clang-tidy) + case "${PKG_KIND}" in + brew) echo "llvm" ;; + apt) echo "clang-format clang-tidy" ;; + dnf|yum) echo "clang-tools-extra" ;; + pacman) echo "clang" ;; + zypper) echo "clang-tools" ;; + apk) echo "clang-extra-tools" ;; + choco) echo "llvm" ;; + winget) echo "LLVM.LLVM" ;; + *) echo "" ;; + esac ;; + *) echo "${tool}" ;; + esac +} + +# Extra directories to search beyond PATH (Homebrew LLVM is keg-only; Windows +# LLVM installs into Program Files). +extra_bin_dirs() { + echo "/opt/homebrew/opt/llvm/bin" + echo "/usr/local/opt/llvm/bin" + echo "/opt/homebrew/bin" + echo "/usr/local/bin" + echo "/c/Program Files/LLVM/bin" + echo "/c/Program Files/CMake/bin" +} + +# Finds a command on PATH, in the extra dirs, or as a versioned binary +# (clang-format-18, ...). Echoes the resolved path, or nothing if not found. +find_tool() { + local tool="$1" + if command -v "${tool}" >/dev/null 2>&1; then + command -v "${tool}" + return + fi + if [ "${tool}" = "pkg-config" ] && command -v pkgconf >/dev/null 2>&1; then + command -v pkgconf + return + fi + local d + for d in $(extra_bin_dirs); do + if [ -x "${d}/${tool}" ]; then + echo "${d}/${tool}" + return + fi + local cand + cand=$(ls "${d}/${tool}"-* 2>/dev/null | sort -V | tail -1 || true) + if [ -n "${cand}" ] && [ -x "${cand}" ]; then + echo "${cand}" + return + fi + done +} + +# ensure_tool -> sets RESOLVED_TOOL, returns non-zero when +# an optional tool is unavailable (a required one aborts the script). +RESOLVED_TOOL="" +ensure_tool() { + local tool="$1" + local required="${2:-0}" + RESOLVED_TOOL="" + + local found + found="$(find_tool "${tool}")" + if [ -n "${found}" ]; then + RESOLVED_TOOL="${found}" + return 0 + fi + + detect_pkg_manager + local pkg + pkg="$(package_for "${tool}")" + + if [ "${PKG_KIND}" = "none" ] || [ -z "${pkg}" ]; then + echo ">> '${tool}' not found and no known package manager to install it." >&2 + if [ "${required}" -eq 1 ]; then + echo " Please install '${tool}' manually and re-run." >&2 + exit 1 + fi + echo " Skipping the step that needs '${tool}'." >&2 + return 1 + fi + + echo ">> '${tool}' is not installed." + echo " Proposed install: ${PKG_INSTALL} ${pkg} (via ${PKG_KIND})" + if [ "${AUTO}" -ne 1 ]; then + printf " Install it now? [y/N] " + local reply="" + read -r reply || reply="" + case "${reply}" in + y|Y|yes|YES) ;; + *) + echo " Skipped." + if [ "${required}" -eq 1 ]; then + echo " '${tool}' is required to continue; aborting." >&2 + exit 1 + fi + return 1 ;; + esac + else + echo " --auto: installing without prompting." + fi + + echo ">> Installing ${pkg} ..." + # shellcheck disable=SC2086 + if ! ${PKG_INSTALL} ${pkg}; then + echo ">> Install of '${pkg}' failed." >&2 + [ "${required}" -eq 1 ] && exit 1 + return 1 + fi + + found="$(find_tool "${tool}")" + if [ -n "${found}" ]; then + RESOLVED_TOOL="${found}" + return 0 + fi + echo ">> '${tool}' still not found after install." >&2 + [ "${required}" -eq 1 ] && exit 1 + return 1 +} + +# All source files to format / lint. +source_files() { + find "${SCRIPT_DIR}/pjsonlib" "${SCRIPT_DIR}/pjsontest" "${SCRIPT_DIR}/examples" \ + "${SCRIPT_DIR}/bench" "${SCRIPT_DIR}/fuzz" "${SCRIPT_DIR}/test_package" \ + "${SCRIPT_DIR}/tests" \ + \( -name '*.cpp' -o -name '*.h' \) -type f | sort +} + +# --------------------------------------------------------------------------- +# Formatting / linting (run before the build so style issues surface first). +# --------------------------------------------------------------------------- + +# Formats every maintained C/C++ source in place when clang-format is available. +run_format() { + ensure_tool clang-format 0 || return 0 + echo ">> Formatting sources with ${RESOLVED_TOOL}" + source_files | while read -r f; do + "${RESOLVED_TOOL}" -i "${f}" + done + echo " Done." +} + +# Reports every source that clang-format would change and fails as one batch. +run_check() { + ensure_tool clang-format 0 || return 0 + echo ">> Checking formatting with ${RESOLVED_TOOL} --dry-run -Werror" + local bad=0 + while read -r f; do + if ! "${RESOLVED_TOOL}" --dry-run -Werror "${f}" >/dev/null 2>&1; then + echo " NEEDS FORMATTING: ${f}" + bad=1 + fi + done < <(source_files) + if [ "${bad}" -ne 0 ]; then + echo ">> Formatting check failed. Run: ./build.sh --format" >&2 + exit 1 + fi + echo " All files are correctly formatted." +} + +TIDY_TOOL="" +if [ "${DO_FORMAT}" -eq 1 ]; then run_format; fi +if [ "${DO_CHECK}" -eq 1 ]; then run_check; fi +if [ "${DO_TIDY}" -eq 1 ]; then ensure_tool clang-tidy 0 && TIDY_TOOL="${RESOLVED_TOOL}"; fi + +# --------------------------------------------------------------------------- +# Build. cmake (and its bundled ctest) are required. +# --------------------------------------------------------------------------- +ensure_tool cmake 1 +CMAKE="${RESOLVED_TOOL}" +CTEST="$(dirname "${CMAKE}")/ctest" +[ -x "${CTEST}" ] || CTEST="ctest" +PKG_CONFIG_TOOL="" +if [ "${DO_PACKAGE}" -eq 1 ]; then + ensure_tool pkg-config 1 + PKG_CONFIG_TOOL="${RESOLVED_TOOL}" +fi + +if [ "${DO_CLEAN}" -eq 1 ]; then + echo ">> Cleaning ${OUT_DIR}" + rm -rf "${OUT_DIR}" +fi + +# Prefer Ninja if available (optional; the default generator works fine). +GEN_ARG="" +if command -v ninja >/dev/null 2>&1; then + GEN_ARG="-GNinja" +fi + +# build_one +# Configures out/build-, builds it, and copies artifacts to out//. +build_one() { + local cfg="$1"; shift + local lc + lc="$(echo "${cfg}" | tr '[:upper:]' '[:lower:]')" + local bdir="${OUT_DIR}/build-${lc}" + local dest="${OUT_DIR}/${lc}" + + echo ">> Configuring ${cfg}" + "${CMAKE}" -S "${SCRIPT_DIR}" -B "${bdir}" \ + -DCMAKE_BUILD_TYPE="${cfg}" \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + ${GEN_ARG} "$@" + + echo ">> Building ${cfg}" + # --config matters for multi-config generators (VS/Xcode); harmless for + # single-config ones. + "${CMAKE}" --build "${bdir}" --config "${cfg}" --parallel + + echo ">> Collecting ${cfg} artifacts into ${dest}" + mkdir -p "${dest}/lib" "${dest}/bin/examples" "${OUT_DIR}/include" + # Library (libpjson.a / pjson.lib), test runner, and example executables. + find "${bdir}" \( -name 'libpjson.a' -o -name 'pjson.lib' \) \ + -exec cp {} "${dest}/lib/" \; 2>/dev/null || true + find "${bdir}" -type f \( -name 'pjsontest' -o -name 'pjsontest.exe' \) \ + -exec cp {} "${dest}/bin/" \; 2>/dev/null || true + find "${bdir}" -type f \( -name 'pjsonbench' -o -name 'pjsonbench.exe' \) \ + -exec cp {} "${dest}/bin/" \; 2>/dev/null || true + # Example binaries live under examples/ in the build tree. + find "${bdir}/examples" -maxdepth 2 -type f \ + \( -perm -u+x -o -name '*.exe' \) ! -name '*.o' ! -name '*.obj' \ + -exec cp {} "${dest}/bin/examples/" \; 2>/dev/null || true + cp "${SCRIPT_DIR}/pjsonlib/include/pjson.h" "${OUT_DIR}/include/" + + LAST_BUILD_DIR="${bdir}" +} + +LAST_BUILD_DIR="" +BENCH_COMPARE_CMAKE=OFF +if [ "${DO_BENCH_COMPARE}" -eq 1 ]; then + fetch_benchmark_compare_deps + BENCH_COMPARE_CMAKE=ON +fi + +# The build driver always produces the repository's normal developer targets; +# flags select which additional checks run. State that contract explicitly so +# it does not depend on top-level option defaults. +PJSON_REPOSITORY_TARGET_ARGS=( + -DPJSON_BUILD_TESTS=ON + -DPJSON_BUILD_EXAMPLES=ON + -DPJSON_BUILD_BENCHMARKS=ON + -DBUILD_TESTING=ON +) + +if [ "${DO_ASAN}" -eq 1 ]; then + # --all wants the full picture, so it still produces an optimized Release + # alongside the sanitized Debug (unless the user narrowed the configs). + # Plain --asan produces one sanitized Debug build. + if { [ "${DO_ALL}" -eq 1 ] || [ "${DO_BENCH}" -eq 1 ]; } && + [ "${DEBUG_ONLY}" -ne 1 ]; then + build_one Release "${PJSON_REPOSITORY_TARGET_ARGS[@]}" \ + -DPJSON_BENCH_COMPARE="${BENCH_COMPARE_CMAKE}" \ + -DPJSON_BENCH_DEPS_DIR="${BENCH_DEPS_DIR}" + fi + echo ">> Sanitizers enabled (AddressSanitizer + UndefinedBehaviorSanitizer)" + build_one Debug "${PJSON_REPOSITORY_TARGET_ARGS[@]}" \ + -DPJSON_SANITIZE=ON -DPJSON_BENCH_COMPARE=OFF +else + if [ "${DEBUG_ONLY}" -ne 1 ]; then + build_one Release "${PJSON_REPOSITORY_TARGET_ARGS[@]}" \ + -DPJSON_BENCH_COMPARE="${BENCH_COMPARE_CMAKE}" \ + -DPJSON_BENCH_DEPS_DIR="${BENCH_DEPS_DIR}" + fi + if [ "${RELEASE_ONLY}" -ne 1 ]; then + build_one Debug "${PJSON_REPOSITORY_TARGET_ARGS[@]}" \ + -DPJSON_BENCH_COMPARE=OFF + fi +fi + +# Static analysis uses the last configured tree's compile_commands.json. +if [ -n "${TIDY_TOOL}" ] && [ -n "${LAST_BUILD_DIR}" ]; then + echo ">> Running clang-tidy (${TIDY_TOOL})" + TIDY_FAIL=0 + TIDY_EXTRA_ARGS=() + # Homebrew LLVM is not built against Apple's SDK include layout. Point + # clang-tidy at the active SDK so standard C++ headers resolve on macOS. + if [ "$(uname -s)" = "Darwin" ] && command -v xcrun >/dev/null 2>&1; then + TIDY_EXTRA_ARGS+=(--extra-arg=-isysroot --extra-arg="$(xcrun --show-sdk-path)") + fi + while read -r f; do + echo " tidy: ${f}" + if ! "${TIDY_TOOL}" -p "${LAST_BUILD_DIR}" --warnings-as-errors='*' \ + "${TIDY_EXTRA_ARGS[@]}" "${f}"; then + TIDY_FAIL=1 + fi + done < <(find "${SCRIPT_DIR}/pjsonlib" -name '*.cpp' -type f | sort) + if [ "${TIDY_FAIL}" -ne 0 ]; then + echo ">> clang-tidy reported findings." >&2 + exit 1 + else + echo " clang-tidy: no findings." + fi +fi + +echo ">> Done. Artifacts under ${OUT_DIR}/ (release/ and/or debug/, plus include/)." + +# --------------------------------------------------------------------------- +# Test and benchmark execution. +# --------------------------------------------------------------------------- + +if [ "${DO_TEST}" -eq 1 ] && [ -n "${LAST_BUILD_DIR}" ]; then + echo ">> Running tests (${LAST_BUILD_DIR})" + # Leak detection: LeakSanitizer ships with ASan on Linux but is absent from + # Apple's runtime (setting detect_leaks=1 there aborts with "not supported"). + # Enable it where it exists so leaks fail the suite; elsewhere ASan still + # catches use-after-free / overflow. Override by exporting ASAN_OPTIONS. + case "$(uname -s)" in + Linux*) ASAN_LEAK="detect_leaks=1" ;; + *) ASAN_LEAK="detect_leaks=0" ;; + esac + # Auto-discover the persistent default corpus. An explicitly exported + # PJSON_JSONTESTSUITE_DIR always wins for custom/CI locations. + DEFAULT_JSONTESTSUITE_DIR="${SCRIPT_DIR}/.test-corpora/JSONTestSuite" + JSONTESTSUITE_DIR="${PJSON_JSONTESTSUITE_DIR:-}" + if [ -z "${JSONTESTSUITE_DIR}" ] && + [ -d "${DEFAULT_JSONTESTSUITE_DIR}/test_parsing" ]; then + JSONTESTSUITE_DIR="${DEFAULT_JSONTESTSUITE_DIR}" + echo ">> JSONTestSuite found (${JSONTESTSUITE_DIR})" + fi + if [ "${DO_TEST}" -eq 1 ] && [ -z "${JSONTESTSUITE_DIR}" ]; then + FETCH_CORPUS=0 + echo ">> JSONTestSuite is not installed." + echo " Proposed download: ${DEFAULT_JSONTESTSUITE_DIR}" + if [ "${AUTO}" -eq 1 ]; then + echo " --auto: downloading without prompting." + FETCH_CORPUS=1 + else + printf " Download it now? [y/N] " + reply="" + read -r reply || reply="" + case "${reply}" in + y|Y|yes|YES) FETCH_CORPUS=1 ;; + *) echo " Skipped; inline RFC 8259 conformance tests will still run." ;; + esac + fi + if [ "${FETCH_CORPUS}" -eq 1 ]; then + ensure_tool git 1 + if ! "${SCRIPT_DIR}/scripts/fetch-json-test-suite.sh"; then + echo ">> JSONTestSuite download failed; the full --all sweep is incomplete." >&2 + exit 1 + fi + JSONTESTSUITE_DIR="${DEFAULT_JSONTESTSUITE_DIR}" + fi + fi + + # The draft-07 schema corpus uses the same persistent, auto-discovered + # convention as JSONTestSuite. An explicit environment variable wins. + DEFAULT_JSON_SCHEMA_SUITE_DIR="${SCRIPT_DIR}/.test-corpora/JSON-Schema-Test-Suite" + JSON_SCHEMA_SUITE_DIR="${PJSON_JSON_SCHEMA_TEST_SUITE_DIR:-}" + if [ -z "${JSON_SCHEMA_SUITE_DIR}" ] && + [ -d "${DEFAULT_JSON_SCHEMA_SUITE_DIR}/tests/draft7" ]; then + JSON_SCHEMA_SUITE_DIR="${DEFAULT_JSON_SCHEMA_SUITE_DIR}" + echo ">> JSON-Schema-Test-Suite found (${JSON_SCHEMA_SUITE_DIR})" + fi + if [ "${DO_TEST}" -eq 1 ] && [ -z "${JSON_SCHEMA_SUITE_DIR}" ]; then + FETCH_SCHEMA_CORPUS=0 + echo ">> JSON-Schema-Test-Suite is not installed." + echo " Proposed download: ${DEFAULT_JSON_SCHEMA_SUITE_DIR}" + if [ "${AUTO}" -eq 1 ]; then + echo " --auto: downloading without prompting." + FETCH_SCHEMA_CORPUS=1 + else + printf " Download it now? [y/N] " + reply="" + read -r reply || reply="" + case "${reply}" in + y|Y|yes|YES) FETCH_SCHEMA_CORPUS=1 ;; + *) echo " Skipped; inline schema tests will still run." ;; + esac + fi + if [ "${FETCH_SCHEMA_CORPUS}" -eq 1 ]; then + ensure_tool git 1 + if ! "${SCRIPT_DIR}/scripts/fetch-json-schema-test-suite.sh"; then + echo ">> JSON-Schema-Test-Suite download failed; the full --all sweep is incomplete." >&2 + exit 1 + fi + JSON_SCHEMA_SUITE_DIR="${DEFAULT_JSON_SCHEMA_SUITE_DIR}" + fi + fi + ASAN_OPTIONS="${ASAN_OPTIONS:-${ASAN_LEAK}}" \ + LSAN_OPTIONS="${LSAN_OPTIONS:-}" \ + UBSAN_OPTIONS="${UBSAN_OPTIONS:-halt_on_error=1:print_stacktrace=1}" \ + PJSON_JSONTESTSUITE_DIR="${JSONTESTSUITE_DIR}" \ + PJSON_JSON_SCHEMA_TEST_SUITE_DIR="${JSON_SCHEMA_SUITE_DIR}" \ + "${CTEST}" --test-dir "${LAST_BUILD_DIR}" --output-on-failure +fi + +# Runs the optimized benchmark binary, resolving both single- and multi-config +# generator output layouts before forwarding optional corpus/compare flags. +if [ "${DO_BENCH}" -eq 1 ]; then + RELEASE_BUILD_DIR="${OUT_DIR}/build-release" + RELEASE_BENCH="${OUT_DIR}/release/bin/pjsonbench" + if [ ! -x "${RELEASE_BENCH}" ] && [ -x "${RELEASE_BUILD_DIR}/bench/pjsonbench" ]; then + RELEASE_BENCH="${RELEASE_BUILD_DIR}/bench/pjsonbench" + elif [ ! -x "${RELEASE_BENCH}" ] && [ -x "${RELEASE_BUILD_DIR}/bench/Release/pjsonbench.exe" ]; then + RELEASE_BENCH="${RELEASE_BUILD_DIR}/bench/Release/pjsonbench.exe" + elif [ ! -x "${RELEASE_BENCH}" ] && [ -x "${OUT_DIR}/release/bin/pjsonbench.exe" ]; then + RELEASE_BENCH="${OUT_DIR}/release/bin/pjsonbench.exe" + fi + + if [ ! -x "${RELEASE_BENCH}" ]; then + echo ">> Release benchmark executable not found. Build Release first." >&2 + exit 1 + fi + + BENCH_ARGS=() + if [ "${#BENCH_INPUTS[@]}" -gt 0 ]; then + for bench_input in "${BENCH_INPUTS[@]}"; do + BENCH_ARGS+=(--input "${bench_input}") + done + fi + if [ "${DO_BENCH_COMPARE}" -eq 1 ]; then + BENCH_ARGS+=(--compare) + fi + + echo ">> Running benchmarks (Release)" + if [ "${#BENCH_ARGS[@]}" -gt 0 ]; then + "${RELEASE_BENCH}" "${BENCH_ARGS[@]}" + else + "${RELEASE_BENCH}" + fi +fi + +# --------------------------------------------------------------------------- +# Optional fuzz, documentation, and packaging validation. +# --------------------------------------------------------------------------- + +# Probes for a usable Clang/libFuzzer pair, builds all four harnesses, and +# replays each checked-in seed corpus with deterministic bounds. +run_fuzz_smoke() { + case "$(uname -s)" in + Linux*|Darwin*) ;; + *) + if [ "${DO_ALL}" -eq 1 ]; then + echo ">> Skipping fuzz smoke: local libFuzzer builds support Linux/macOS." + return 0 + fi + echo ">> --fuzz is supported on Linux/macOS with Clang and libFuzzer." >&2 + return 1 + ;; + esac + + local fuzz_cxx="${CXX:-}" + if [ -z "${fuzz_cxx}" ]; then + # Apple's Command Line Tools sometimes identify as Clang while omitting + # libFuzzer. Prefer a full Homebrew LLVM when it is installed. + local candidate + for candidate in /opt/homebrew/opt/llvm/bin/clang++ \ + /usr/local/opt/llvm/bin/clang++ clang++; do + if [ -x "${candidate}" ] || command -v "${candidate}" >/dev/null 2>&1; then + fuzz_cxx="${candidate}" + break + fi + done + fi + if [ -z "${fuzz_cxx}" ]; then + if [ "${DO_ALL}" -eq 1 ]; then + echo ">> Skipping fuzz smoke: no Clang C++ compiler found." + return 0 + fi + echo ">> --fuzz requires Clang with the libFuzzer runtime." >&2 + return 1 + fi + + local compiler_id + compiler_id="$("${fuzz_cxx}" --version 2>/dev/null | head -1 || true)" + case "${compiler_id}" in + *clang*) ;; + *) + if [ "${DO_ALL}" -eq 1 ]; then + echo ">> Skipping fuzz smoke: ${fuzz_cxx} is not Clang." + return 0 + fi + echo ">> --fuzz requires Clang with the libFuzzer runtime." >&2 + return 1 + ;; + esac + + local fuzz_cc="${CC:-}" + if [ -z "${fuzz_cc}" ]; then + fuzz_cc="${fuzz_cxx%++}" + if [ ! -x "${fuzz_cc}" ]; then + fuzz_cc="$(find_tool clang)" + fi + fi + + local probe_dir="${OUT_DIR}/fuzz-probe" + mkdir -p "${probe_dir}" + if ! printf '%s\n' \ + '#include ' \ + '#include ' \ + 'extern "C" int LLVMFuzzerTestOneInput(const uint8_t*, size_t) { return 0; }' \ + | "${fuzz_cxx}" -x c++ -std=c++11 -fsanitize=fuzzer,address,undefined - \ + -o "${probe_dir}/probe" >/dev/null 2>&1; then + if [ "${DO_ALL}" -eq 1 ]; then + echo ">> Skipping fuzz smoke: ${fuzz_cxx} has no usable libFuzzer runtime." + return 0 + fi + echo ">> --fuzz requires a Clang toolchain with a usable libFuzzer runtime." >&2 + return 1 + fi + + local fuzz_build_dir="${OUT_DIR}/build-fuzz" + echo ">> Configuring libFuzzer targets (${fuzz_cxx})" + CC="${fuzz_cc}" CXX="${fuzz_cxx}" \ + "${CMAKE}" -S "${SCRIPT_DIR}" -B "${fuzz_build_dir}" \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_CXX_COMPILER="${fuzz_cxx}" \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF \ + -DPJSON_BUILD_FUZZERS=ON \ + ${GEN_ARG} + "${CMAKE}" --build "${fuzz_build_dir}" --parallel --target \ + pjson_fuzz_parse pjson_fuzz_stream pjson_fuzz_schema pjson_fuzz_patch + + local target corpus_dir + for target in parse stream schema patch; do + corpus_dir="${OUT_DIR}/fuzz-corpus/${target}" + mkdir -p "${corpus_dir}" "${OUT_DIR}/fuzz-artifacts/${target}" + echo ">> Fuzz corpus smoke: pjson_fuzz_${target}" + "${fuzz_build_dir}/fuzz/pjson_fuzz_${target}" \ + -runs=1000 -seed=1337 -max_len=4096 -timeout=5 -verbosity=0 \ + -dict="${SCRIPT_DIR}/fuzz/json.dict" \ + -artifact_prefix="${OUT_DIR}/fuzz-artifacts/${target}/" \ + "${corpus_dir}" "${SCRIPT_DIR}/fuzz/corpus/${target}" + done +} + +if [ "${DO_FUZZ}" -eq 1 ]; then + run_fuzz_smoke +fi + +# Builds the Doxygen reference and runs its API-surface validator. +run_docs_check() { + ensure_tool doxygen 1 + ensure_tool python3 1 + local docs_build_dir="${OUT_DIR}/build-docs" + echo ">> Building and validating API reference" + "${CMAKE}" -S "${SCRIPT_DIR}" -B "${docs_build_dir}" \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF \ + -DPJSON_BUILD_DOCS=ON ${GEN_ARG} + "${CMAKE}" --build "${docs_build_dir}" --target pjson-docs-check --parallel +} + +# Exercises relocated static/shared installations through external consumer +# projects rather than only inspecting generated metadata. +run_package_checks() { + local package_root="${OUT_DIR}/package-smoke" + echo ">> Validating relocatable static package" + "${CMAKE}" -DPJSON_SOURCE_DIR="${SCRIPT_DIR}" \ + -DPJSON_WORK_DIR="${package_root}/static" \ + -DPJSON_INSTALL_LIBDIR=lib64 \ + -DPJSON_PKG_CONFIG_EXECUTABLE="${PKG_CONFIG_TOOL}" \ + -DPJSON_REQUIRE_PKG_CONFIG=ON \ + -P "${SCRIPT_DIR}/cmake/RunInstallConsumer.cmake" + + echo ">> Validating relocatable shared package" + "${CMAKE}" -DPJSON_SOURCE_DIR="${SCRIPT_DIR}" \ + -DPJSON_WORK_DIR="${package_root}/shared" \ + -DPJSON_BUILD_SHARED_LIBS=ON \ + -DPJSON_INSTALL_LIBDIR=lib64 \ + -DPJSON_PKG_CONFIG_EXECUTABLE="${PKG_CONFIG_TOOL}" \ + -DPJSON_REQUIRE_PKG_CONFIG=ON \ + -P "${SCRIPT_DIR}/cmake/RunInstallConsumer.cmake" +} + +if [ "${DO_DOCS}" -eq 1 ]; then + run_docs_check +fi + +if [ "${DO_PACKAGE}" -eq 1 ]; then + run_package_checks +fi + +# Validates every tracked file's SPDX metadata. Prefer an existing installation; +# otherwise install the pinned Python package into out/ so the tool remains +# repository-local and clean.sh removes it. +run_license_check() { + ensure_tool python3 1 + local python_tool="${RESOLVED_TOOL}" + local reuse_target="${OUT_DIR}/tools/reuse" + + echo ">> Validating SPDX/REUSE licensing metadata" + if "${python_tool}" -m reuse --version >/dev/null 2>&1; then + "${python_tool}" -m reuse lint + return + fi + + echo " Python package 'reuse==6.2.0' is not installed." + echo " Proposed download: ${reuse_target}" + if [ "${AUTO}" -ne 1 ]; then + printf " Download it now? [y/N] " + local reply="" + read -r reply || reply="" + case "${reply}" in + y|Y|yes|YES) ;; + *) echo ">> --license requires reuse 6.2.0." >&2; exit 1 ;; + esac + else + echo " --auto: downloading without prompting." + fi + + "${python_tool}" -m pip install --disable-pip-version-check \ + --target "${reuse_target}" reuse==6.2.0 + PYTHONPATH="${reuse_target}${PYTHONPATH:+:${PYTHONPATH}}" \ + "${python_tool}" -m reuse lint +} + +if [ "${DO_LICENSE}" -eq 1 ]; then + run_license_check +fi diff --git a/clean.sh b/clean.sh new file mode 100755 index 0000000..5ff0965 --- /dev/null +++ b/clean.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"). +# +# Removes everything generated/downloaded by build.sh and by ad-hoc +# CMake/compiler runs, leaving only source files under version control. This +# includes optional JSONTestSuite and benchmark dependency checkouts. +# +set -euo pipefail + +# Anchor every destructive path to this checkout, regardless of where the +# caller invokes the script. No caller-supplied cleanup path is accepted. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +# ---- Repository-owned generated trees ---------------------------------- + +echo ">> Removing build.sh output (out/)" +rm -rf "${SCRIPT_DIR}/out" + +echo ">> Removing downloaded test corpora (.test-corpora/)" +rm -rf "${SCRIPT_DIR}/.test-corpora" + +echo ">> Removing downloaded benchmark dependencies (.benchmark-deps/)" +rm -rf "${SCRIPT_DIR}/.benchmark-deps" + +echo ">> Removing local fuzz runtime state (.fuzz-corpus/ and .fuzz-artifacts/)" +rm -rf "${SCRIPT_DIR}/.fuzz-corpus" "${SCRIPT_DIR}/.fuzz-artifacts" + +echo ">> Removing Python bytecode caches" +find "${SCRIPT_DIR}" -type d -name '__pycache__' -not -path '*/.git/*' \ + -prune -exec rm -rf {} + 2>/dev/null || true +find "${SCRIPT_DIR}" -type f \( -name '*.pyc' -o -name '*.pyo' \) \ + -not -path '*/.git/*' -delete 2>/dev/null || true + +echo ">> Removing stray CMake build trees" +rm -rf "${SCRIPT_DIR}/build" "${SCRIPT_DIR}/cmake-build-"* 2>/dev/null || true + +echo ">> Removing Conan test-package build output" +rm -rf "${SCRIPT_DIR}/test_package/build" +rm -f "${SCRIPT_DIR}/test_package/CMakeUserPresets.json" + +# ---- Stray generated files --------------------------------------------- + +# These scans stay below the resolved repository root and explicitly prune +# .git so cleanup cannot damage Git's object database or metadata. +echo ">> Removing in-source CMake artifacts (from accidental in-tree configures)" +find "${SCRIPT_DIR}" \ + \( -name 'CMakeCache.txt' \ + -o -name 'CMakeFiles' -type d \ + -o -name 'cmake_install.cmake' \ + -o -name 'CTestTestfile.cmake' \ + -o -name 'compile_commands.json' \ + -o -name 'Makefile' \) \ + -not -path '*/.git/*' -prune -exec rm -rf {} + 2>/dev/null || true + +echo ">> Removing compiled objects, libraries, and OS cruft" +find "${SCRIPT_DIR}" \ + \( -name '*.o' -o -name '*.obj' -o -name '*.a' -o -name '*.lib' \ + -o -name '*.so' -o -name '*.dylib' -o -name '*.dll' \ + -o -name '*.exe' -o -name '*.out' -o -name '*.gch' -o -name '*.pch' \ + -o -name '.DS_Store' \) \ + -not -path '*/.git/*' -type f -delete 2>/dev/null || true + +echo ">> Clean." diff --git a/cmake/RunInstallConsumer.cmake b/cmake/RunInstallConsumer.cmake new file mode 100644 index 0000000..a9f1a35 --- /dev/null +++ b/cmake/RunInstallConsumer.cmake @@ -0,0 +1,224 @@ +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.21) + +# ---- Required inputs --------------------------------------------------- + +# This script removes and recreates named children of PJSON_WORK_DIR. Require +# an explicit path and reject roots or any source ancestor before mutation. +if(NOT DEFINED PJSON_SOURCE_DIR OR PJSON_SOURCE_DIR STREQUAL "") + message(FATAL_ERROR "PJSON_SOURCE_DIR must name the pjson source tree") +endif() +if(NOT DEFINED PJSON_WORK_DIR OR PJSON_WORK_DIR STREQUAL "") + message(FATAL_ERROR "PJSON_WORK_DIR must name a disposable smoke-test directory") +endif() + +get_filename_component(PJSON_SOURCE_DIR "${PJSON_SOURCE_DIR}" ABSOLUTE) +get_filename_component(PJSON_WORK_DIR "${PJSON_WORK_DIR}" ABSOLUTE) +file(TO_CMAKE_PATH "${PJSON_SOURCE_DIR}" PJSON_SOURCE_DIR) +file(TO_CMAKE_PATH "${PJSON_WORK_DIR}" PJSON_WORK_DIR) +cmake_path(NORMAL_PATH PJSON_SOURCE_DIR) +cmake_path(NORMAL_PATH PJSON_WORK_DIR) + +# Resolve symlinks even when the final work directory does not exist: resolve +# its nearest existing ancestor, then append the missing path components. +function(pjson_resolve_path path output_variable) + set(existing_path "${path}") + set(missing_components) + while(NOT EXISTS "${existing_path}") + cmake_path(GET existing_path FILENAME component) + if(component STREQUAL "") + message(FATAL_ERROR "Unable to resolve path safely: ${path}") + endif() + list(PREPEND missing_components "${component}") + cmake_path(GET existing_path PARENT_PATH existing_path) + endwhile() + file(REAL_PATH "${existing_path}" resolved_path) + foreach(component IN LISTS missing_components) + cmake_path(APPEND resolved_path "${component}") + endforeach() + cmake_path(NORMAL_PATH resolved_path) + set(${output_variable} "${resolved_path}" PARENT_SCOPE) +endfunction() + +pjson_resolve_path("${PJSON_SOURCE_DIR}" PJSON_SOURCE_DIR) +pjson_resolve_path("${PJSON_WORK_DIR}" PJSON_WORK_DIR) +if(NOT EXISTS "${PJSON_SOURCE_DIR}/CMakeLists.txt") + message(FATAL_ERROR "PJSON_SOURCE_DIR is not a pjson source tree") +endif() +cmake_path(GET PJSON_WORK_DIR ROOT_PATH PJSON_WORK_ROOT) +if(PJSON_WORK_DIR STREQUAL PJSON_WORK_ROOT) + message(FATAL_ERROR "PJSON_WORK_DIR must not be a filesystem root") +endif() +cmake_path(IS_PREFIX PJSON_WORK_DIR "${PJSON_SOURCE_DIR}" NORMALIZE + PJSON_WORK_IS_SOURCE_ANCESTOR) +if(PJSON_WORK_IS_SOURCE_ANCESTOR) + message(FATAL_ERROR + "PJSON_WORK_DIR must not be the source tree or one of its ancestors") +endif() + +set(producer_build "${PJSON_WORK_DIR}/producer-build") +set(stage_prefix "${PJSON_WORK_DIR}/stage") +set(relocated_prefix "${PJSON_WORK_DIR}/relocated") +set(consumer_build "${PJSON_WORK_DIR}/consumer-build") +set(pkgconfig_consumer_build "${PJSON_WORK_DIR}/pkgconfig-consumer-build") +set(consumer_source "${PJSON_SOURCE_DIR}/tests/install-consumer") + +# Runs an external configure/build/install/test command and turns any non-zero +# result into an immediate CMake script failure with a phase-specific message. +function(run_checked description) + execute_process( + COMMAND ${ARGN} + RESULT_VARIABLE result + COMMAND_ECHO STDOUT + ) + if(NOT result EQUAL 0) + message(FATAL_ERROR "${description} failed with exit code ${result}") + endif() +endfunction() + +# ---- Fresh producer installation --------------------------------------- + +file(REMOVE_RECURSE + "${producer_build}" + "${stage_prefix}" + "${relocated_prefix}" + "${consumer_build}" + "${pkgconfig_consumer_build}" +) +file(MAKE_DIRECTORY "${PJSON_WORK_DIR}") + +set(producer_configure + "${CMAKE_COMMAND}" + -S "${PJSON_SOURCE_DIR}" + -B "${producer_build}" + -DPJSON_BUILD_TESTS=OFF + -DPJSON_BUILD_EXAMPLES=OFF + -DPJSON_BUILD_BENCHMARKS=OFF + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_INSTALL_PREFIX=${stage_prefix} +) +if(DEFINED PJSON_BUILD_SHARED_LIBS) + list(APPEND producer_configure -DBUILD_SHARED_LIBS=${PJSON_BUILD_SHARED_LIBS}) +endif() +if(DEFINED PJSON_INSTALL_LIBDIR AND NOT PJSON_INSTALL_LIBDIR STREQUAL "") + list(APPEND producer_configure -DCMAKE_INSTALL_LIBDIR=${PJSON_INSTALL_LIBDIR}) +endif() +if(DEFINED PJSON_GENERATOR AND NOT PJSON_GENERATOR STREQUAL "") + list(APPEND producer_configure -G "${PJSON_GENERATOR}") +endif() + +run_checked("pjson package configure" ${producer_configure}) +run_checked("pjson package build" + "${CMAKE_COMMAND}" --build "${producer_build}" --config Release --parallel) +run_checked("pjson package install" + "${CMAKE_COMMAND}" --install "${producer_build}" --config Release) + +# ---- Relocation and artifact validation -------------------------------- + +# A consumer that succeeds only after the original prefix has disappeared is +# stronger evidence that both the CMake package and pkg-config file relocate. +file(RENAME "${stage_prefix}" "${relocated_prefix}") + +file(GLOB_RECURSE config_files LIST_DIRECTORIES FALSE + "${relocated_prefix}/*/cmake/pjson/pjsonConfig.cmake") +file(GLOB_RECURSE version_files LIST_DIRECTORIES FALSE + "${relocated_prefix}/*/cmake/pjson/pjsonConfigVersion.cmake") +file(GLOB_RECURSE target_files LIST_DIRECTORIES FALSE + "${relocated_prefix}/*/cmake/pjson/pjsonTargets.cmake") +file(GLOB_RECURSE package_cmake_files LIST_DIRECTORIES FALSE + "${relocated_prefix}/*/cmake/pjson/*.cmake") +file(GLOB_RECURSE pc_files LIST_DIRECTORIES FALSE + "${relocated_prefix}/*/pkgconfig/pjson.pc") +file(GLOB_RECURSE library_files LIST_DIRECTORIES FALSE + "${relocated_prefix}/libpjson.*" + "${relocated_prefix}/libpjson*.dylib" + "${relocated_prefix}/pjson.lib" + "${relocated_prefix}/pjson.dll") +foreach(required_files IN ITEMS config_files version_files target_files pc_files library_files) + list(LENGTH ${required_files} required_file_count) + if(required_file_count EQUAL 0) + message(FATAL_ERROR "Installed package is missing ${required_files}") + endif() +endforeach() +if(NOT EXISTS "${relocated_prefix}/include/pjson.h") + message(FATAL_ERROR "Installed package is missing include/pjson.h") +endif() +foreach(metadata_file IN LISTS package_cmake_files pc_files) + file(READ "${metadata_file}" metadata_contents) + string(FIND "${metadata_contents}" "${stage_prefix}" old_prefix_position) + string(FIND "${metadata_contents}" "${PJSON_SOURCE_DIR}" source_position) + if(NOT old_prefix_position EQUAL -1 OR NOT source_position EQUAL -1) + message(FATAL_ERROR "Installed metadata is not relocatable: ${metadata_file}") + endif() +endforeach() + +# ---- CMake-package consumer -------------------------------------------- + +list(GET config_files 0 config_file) +get_filename_component(config_dir "${config_file}" DIRECTORY) + +set(consumer_configure + "${CMAKE_COMMAND}" + -S "${consumer_source}" + -B "${consumer_build}" + -DCMAKE_BUILD_TYPE=Release + -Dpjson_DIR=${config_dir} +) +if(DEFINED PJSON_GENERATOR AND NOT PJSON_GENERATOR STREQUAL "") + list(APPEND consumer_configure -G "${PJSON_GENERATOR}") +endif() +run_checked("installed-package consumer configure" ${consumer_configure}) +run_checked("installed-package consumer build" + "${CMAKE_COMMAND}" --build "${consumer_build}" --config Release --parallel) +run_checked("installed-package consumer run" + "${CMAKE_CTEST_COMMAND}" --test-dir "${consumer_build}" -C Release --output-on-failure) + +# ---- pkg-config consumer ------------------------------------------------ + +# Constrain both pkg-config search variables to the relocated tree so a system +# installation cannot make the smoke test pass accidentally. +if(NOT DEFINED PJSON_PKG_CONFIG_EXECUTABLE OR + PJSON_PKG_CONFIG_EXECUTABLE STREQUAL "") + find_program(PJSON_PKG_CONFIG_EXECUTABLE NAMES pkg-config pkgconf) +endif() +if(PJSON_PKG_CONFIG_EXECUTABLE) + list(GET pc_files 0 pc_file) + get_filename_component(pc_dir "${pc_file}" DIRECTORY) + run_checked("relocated pkg-config validation" + "${CMAKE_COMMAND}" -E env + "PKG_CONFIG_PATH=${pc_dir}" + "PKG_CONFIG_LIBDIR=${pc_dir}" + "${PJSON_PKG_CONFIG_EXECUTABLE}" --validate pjson) + run_checked("relocated pkg-config version check" + "${CMAKE_COMMAND}" -E env + "PKG_CONFIG_PATH=${pc_dir}" + "PKG_CONFIG_LIBDIR=${pc_dir}" + "${PJSON_PKG_CONFIG_EXECUTABLE}" --exact-version=1.0.0 pjson) + + set(pkgconfig_consumer_configure + "${CMAKE_COMMAND}" -E env + "PKG_CONFIG_PATH=${pc_dir}" + "PKG_CONFIG_LIBDIR=${pc_dir}" + "${CMAKE_COMMAND}" + -S "${consumer_source}" + -B "${pkgconfig_consumer_build}" + -DCMAKE_BUILD_TYPE=Release + -DPJSON_CONSUMER_USE_PKGCONFIG=ON + ) + if(DEFINED PJSON_GENERATOR AND NOT PJSON_GENERATOR STREQUAL "") + list(APPEND pkgconfig_consumer_configure -G "${PJSON_GENERATOR}") + endif() + run_checked("pkg-config consumer configure" ${pkgconfig_consumer_configure}) + run_checked("pkg-config consumer build" + "${CMAKE_COMMAND}" --build "${pkgconfig_consumer_build}" --config Release --parallel) + run_checked("pkg-config consumer run" + "${CMAKE_CTEST_COMMAND}" --test-dir "${pkgconfig_consumer_build}" + -C Release --output-on-failure) +elseif(PJSON_REQUIRE_PKG_CONFIG) + message(FATAL_ERROR "pkg-config or pkgconf is required for this smoke test") +else() + message(STATUS "pkg-config was not found; skipping that consumer path") +endif() + +message(STATUS "Relocatable install-consumer smoke test passed: ${relocated_prefix}") diff --git a/cmake/pjson.pc.in b/cmake/pjson.pc.in new file mode 100644 index 0000000..59c7df3 --- /dev/null +++ b/cmake/pjson.pc.in @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: Apache-2.0 + +# Compute prefix relative to this metadata file so an installed tree remains +# usable after relocation and with non-default library directories. +prefix=${pcfiledir}/@PJSON_PC_PREFIX_FROM_PCFILEDIR@ +exec_prefix=${prefix} +libdir=${prefix}/@CMAKE_INSTALL_LIBDIR@ +includedir=${prefix}/@CMAKE_INSTALL_INCLUDEDIR@ + +Name: pjson +Description: @PROJECT_DESCRIPTION@ +Version: @PROJECT_VERSION@ +Libs: -L${libdir} -lpjson +Cflags: -I${includedir} diff --git a/cmake/pjsonConfig.cmake.in b/cmake/pjsonConfig.cmake.in new file mode 100644 index 0000000..b62c99c --- /dev/null +++ b/cmake/pjsonConfig.cmake.in @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 + +@PACKAGE_INIT@ + +# Import the installed pjson::pjson target from beside this config file. +include("${CMAKE_CURRENT_LIST_DIR}/pjsonTargets.cmake") + +check_required_components(pjson) diff --git a/conanfile.py b/conanfile.py new file mode 100644 index 0000000..ddcc51c --- /dev/null +++ b/conanfile.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: Apache-2.0 + +from conan import ConanFile +from conan.tools.build import check_min_cppstd +from conan.tools.cmake import CMake, CMakeToolchain, cmake_layout +from conan.tools.files import copy + +import os + + +# ---- Conan package recipe ---------------------------------------------- + +# Keep Conan's package model aligned with the exported CMake target and +# pkg-config metadata installed by pjsonlib/CMakeLists.txt. +class PjsonConan(ConanFile): + name = "pjson" + version = "1.0.0" + package_type = "library" + + license = "Apache-2.0" + author = "Praveen Babu J D and pjson contributors" + url = "https://github.com/Pico-Developer/pjson" + homepage = "https://github.com/Pico-Developer/pjson" + description = "An ultra-simple JSON value type for C++11" + topics = ("json", "parser", "serialization", "schema") + + settings = "os", "arch", "compiler", "build_type" + options = { + "shared": [True, False], + "fPIC": [True, False], + } + default_options = { + "shared": False, + "fPIC": True, + } + + exports_sources = ( + "CMakeLists.txt", + "LICENSE", + "cmake/*", + "pjsonlib/CMakeLists.txt", + "pjsonlib/include/*", + "pjsonlib/src/*", + ) + + # fPIC is a Unix-only option and is irrelevant for Windows binaries. + def config_options(self): + if self.settings.os == "Windows": + self.options.rm_safe("fPIC") + + # Shared objects are inherently position-independent, so Conan should not + # expose a redundant fPIC package-ID dimension for shared builds. + def configure(self): + if self.options.shared: + self.options.rm_safe("fPIC") + + # Use Conan's standard source/build/generator directory layout. + def layout(self): + cmake_layout(self) + + # Reject compiler profiles that explicitly request a pre-C++11 dialect. + def validate(self): + if self.settings.compiler.get_safe("cppstd"): + check_min_cppstd(self, "11") + + # Translate Conan package options into the project's CMake configuration. + def generate(self): + toolchain = CMakeToolchain(self) + toolchain.variables["PJSON_BUILD_TESTS"] = False + toolchain.variables["PJSON_BUILD_EXAMPLES"] = False + toolchain.variables["PJSON_BUILD_BENCHMARKS"] = False + toolchain.variables["BUILD_SHARED_LIBS"] = bool(self.options.shared) + fpic = self.options.get_safe("fPIC") + if fpic is not None: + toolchain.variables["CMAKE_POSITION_INDEPENDENT_CODE"] = bool(fpic) + toolchain.generate() + + # Configure and build through the Conan-generated CMake toolchain. + def build(self): + cmake = CMake(self) + cmake.configure() + cmake.build() + + # Install CMake/pkg-config metadata with the library and retain the license + # in Conan's conventional package location. + def package(self): + copy( + self, + "LICENSE", + src=self.source_folder, + dst=os.path.join(self.package_folder, "licenses"), + ) + cmake = CMake(self) + cmake.install() + + # Advertise identical logical names to CMake and pkg-config so consumers do + # not need build-system-specific target names. + def package_info(self): + self.cpp_info.set_property("cmake_file_name", "pjson") + self.cpp_info.set_property("cmake_target_name", "pjson::pjson") + self.cpp_info.set_property("pkg_config_name", "pjson") + self.cpp_info.libs = ["pjson"] diff --git a/docs/00-what-is-json.md b/docs/00-what-is-json.md new file mode 100644 index 0000000..56e40e6 --- /dev/null +++ b/docs/00-what-is-json.md @@ -0,0 +1,182 @@ +# Chapter 00 — What is JSON? + +> New to JSON? Perfect — this chapter assumes you have never seen it before. +> If you already know JSON well, skim the "How pjson models JSON" section at the +> end and move on to [Chapter 01](01-getting-started.md). + +## The problem JSON solves + +Programs constantly need to **save data** and **send data** to other programs: +a game saving your progress, an app talking to a server, one tool handing +results to another. To do that, the data has to become plain **text** that +anyone can read and write. + +**JSON** (JavaScript Object Notation) is one very popular way to write data as +text. Despite the name, it has nothing to do with JavaScript anymore — it is +used by virtually every programming language. + +Here is a small piece of JSON describing a person: + +```json +{ + "name": "Ada", + "age": 36, + "isEngineer": true, + "languages": ["C++", "Ada"], + "address": { + "city": "London" + } +} +``` + +Even without knowing the rules yet, you can probably read it. That readability +is the whole point. + +## The building blocks + +JSON is made of just a few kinds of values. That is what makes it simple. + +```mermaid +flowchart TD + V[A JSON value is one of:] + V --> S["string — text in double quotes: "hello""] + V --> N["number — 42, -3.14, 1e6"] + V --> B["boolean — true or false"] + V --> Z["null — 'no value'"] + V --> A["array — an ordered list: [1, 2, 3]"] + V --> O["object — named fields: { "key": value }"] +``` + +Let's meet each one. + +### 1. String — text + +A string is text wrapped in **double quotes**: + +```json +"Hello, World!" +``` + +Some characters are special and must be written with a backslash (this is +called *escaping*): `\"` for a quote, `\\` for a backslash, `\n` for a newline, +`\t` for a tab. So a string containing a quote looks like `"she said \"hi\""`. + +### 2. Number + +Numbers are written plainly, no quotes: + +```json +42 +-17 +3.14 +1e6 +``` + +That last one, `1e6`, means 1 × 10⁶ = 1000000 — a compact way to write big or +tiny numbers. + +### 3. Boolean — true or false + +Exactly two values: `true` and `false`. Useful for yes/no facts like +`"isEngineer": true`. + +### 4. null — "there is no value here" + +`null` means "empty / nothing / unknown". It is different from the number `0` +or an empty string `""`. + +### 5. Array — an ordered list + +An array is a list of values in **square brackets**, separated by commas. Order +matters, and the values can be of different kinds: + +```json +[1, 2, 3] +["red", "green", "blue"] +[1, "two", true, null] +``` + +### 6. Object — named fields + +An object is a set of **key: value** pairs in **curly braces**. Each key is a +string. This is how you give data names: + +```json +{ + "name": "Ada", + "age": 36 +} +``` + +## Nesting: the powerful part + +Any value can contain other values. An object can hold arrays, arrays can hold +objects, objects can hold objects — as deep as you like. That is how JSON +describes complex, real-world data: + +```json +{ + "team": "engineering", + "members": [ + { "name": "Ada", "roles": ["admin", "dev"] }, + { "name": "Bob", "roles": ["dev"] } + ] +} +``` + +Read it top-down: an object with a `team` string and a `members` array; each +member is an object with a `name` and a `roles` array of strings. + +```mermaid +flowchart TD + root["object"] + root --> team["team: "engineering""] + root --> members["members: array"] + members --> m0["object"] + members --> m1["object"] + m0 --> n0["name: "Ada""] + m0 --> r0["roles: ["admin", "dev"]"] + m1 --> n1["name: "Bob""] + m1 --> r1["roles: ["dev"]"] +``` + +## A few rules to remember + +- Object keys are **always strings in double quotes**. +- Commas **separate** items but must **not** trail the last one: + `[1, 2, 3]` is valid, `[1, 2, 3,]` is not. +- The whole document is exactly **one** value (usually an object or array). + +## How pjson models JSON + +pjson mirrors this model with a single C++ class, `ByteDance::pjson`. One +`pjson` object holds exactly one JSON value, and you can ask what kind it is: + +| JSON kind | pjson type tag | How it is stored | +|-----------|----------------|------------------| +| null | `jsonNull` | — | +| string | `jsonString` | `std::string` | +| number | `jsonNumberInt` or `jsonNumberDouble` | `int64_t` (whole numbers) or `double` | +| boolean | `jsonBoolean` | `bool` | +| array | `jsonArray` | list of `pjson` | +| object | `jsonObject` | map of `string -> pjson` | + +Two pjson-specific details worth knowing early: + +- **Numbers split into two types.** A whole number like `42` is kept as a 64-bit + integer; anything with a fraction or exponent like `3.14` is kept as a + `double`. Read either representation with the matching `tryGet()` overload. +- **Objects keep keys sorted.** pjson stores object keys in alphabetical order + (it uses a `std::map`), so when you print a document the keys come out sorted, + not in the order you added them. This keeps output predictable. + +## What you learned + +- JSON is a simple, text-based way to represent data. +- A JSON value is one of: string, number, boolean, null, array, or object. +- Values nest freely, which lets JSON describe complex data. +- pjson represents any JSON value with one class, `pjson`, storing whole numbers + as `int64` and other numbers as `double`, and keeping object keys sorted. + +Next: [Chapter 01 — Getting started](01-getting-started.md), where you compile +and run your first pjson program. diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md new file mode 100644 index 0000000..d493998 --- /dev/null +++ b/docs/01-getting-started.md @@ -0,0 +1,117 @@ +# Chapter 01 — Getting started + +In this chapter you will compile and run your very first pjson program. By the +end you will have printed a JSON value to the screen. + +## What you need + +- A C++ compiler that supports **C++11** or newer (g++, clang, or MSVC). +- pjson's canonical public header, `pjsonlib/include/pjson.h`. +- pjson's canonical implementation, `pjsonlib/src/pjson.cpp`. + +That's it. pjson has **no third-party dependencies**. + +## The simplest program + +This is [`examples/src/01_hello_world.cpp`](../examples/src/01_hello_world.cpp): + +```cpp +#include "pjson.h" // 1. bring in the library +#include +#include + +using namespace ByteDance; // 2. pjson lives in the ByteDance namespace + +int main() { + // 3. A fresh pjson is 'null'. Assigning to a key makes it an object. + pjson greeting; + greeting["message"] = "Hello, World!"; + greeting["year"] = int64_t(2025); + + // 4. Serialize to compact and pretty text. + pjson::SerializeOptions compact; + pjson::SerializeOptions pretty = pjson::SerializeOptions::prettyPrinted(); + std::cout << greeting.toString(compact) << "\n"; + std::cout << greeting.toString(pretty) << "\n"; + return 0; +} +``` + +Line by line: + +1. `#include "pjson.h"` gives you the `pjson` class. +2. `using namespace ByteDance;` lets you write `pjson` instead of + `ByteDance::pjson`. (You can skip this and qualify the name if you prefer.) +3. A default-constructed `pjson` holds `null`. The moment you assign to a + string key with `greeting["message"] = ...`, pjson turns it into an + **object**. (We cover this "auto-vivification" in Chapter 02.) +4. `toString(options)` returns the JSON as a `std::string`. Default options are + compact; `SerializeOptions::prettyPrinted()` selects indented output. Both + presets limit output to 64 MiB unless `maxOutputBytes` is changed; zero + explicitly requests unlimited output. + +## Compiling it + +The fastest way, compiling the library source directly alongside your program: + +```sh +c++ -std=c++11 -I pjsonlib/include \ + pjsonlib/src/pjson.cpp examples/src/01_hello_world.cpp \ + -o hello +./hello +``` + +- `-std=c++11` selects the C++ standard. +- `-I pjsonlib/include` tells the compiler where to find `pjson.h`. +- We list **both** `.cpp` files so the library code is compiled in. + +Expected output: + +```json +{"message":"Hello, World!","year":2025} +{ + "message": "Hello, World!", + "year": 2025 +} +``` + +Notice the compact form is one line, and the pretty form is indented. Also note +the keys came out in the order `message`, then `year` — which happens to be +alphabetical. (Recall from Chapter 00 that pjson keeps object keys sorted.) + +```mermaid +flowchart LR + src["your .cpp + pjson.cpp"] -->|"c++ -std=c++11 -I include"| exe["executable"] + exe -->|run| out["JSON printed"] +``` + +## If you'd rather use the build script + +From the repository root you can build everything (library + tests + examples) +with the bundled script — covered fully in +[Chapter 08](08-building-and-installing.md): + +```sh +./build.sh +``` + +## Troubleshooting + +- **`fatal error: 'pjson.h' file not found`** — you forgot `-I pjsonlib/include` + (or the path to wherever you put the header). +- **`undefined reference to ByteDance::pjson::...`** — you compiled only your + `.cpp` and forgot to include `pjsonlib/src/pjson.cpp` in the command. +- **Lots of syntax errors** — your compiler may be defaulting to an old + standard; add `-std=c++11` (or newer). + +## What you learned + +- pjson's canonical public header and implementation source have no third-party + dependencies. +- A new `pjson` is `null`; assigning to a key makes it an object. +- `SerializeOptions` selects compact or pretty JSON serialization. +- Compile by passing both your file and `pjson.cpp`, with `-I` pointing at the + header. + +Next: [Chapter 02 — Creating JSON](02-creating-json.md), where you build richer +objects and arrays. diff --git a/docs/02-creating-json.md b/docs/02-creating-json.md new file mode 100644 index 0000000..20454fc --- /dev/null +++ b/docs/02-creating-json.md @@ -0,0 +1,213 @@ +# Chapter 02 — Creating JSON + +Now that you can compile a program, let's learn every way to **put data into** +a pjson value. Follow along with +[`examples/src/02_building_values.cpp`](../examples/src/02_building_values.cpp). + +## The key idea: assignment builds the tree + +A `pjson` starts as `null`. You shape it simply by assigning to it. pjson +figures out the type from what you assign. + +```cpp +pjson v; // null +v = int64_t(42); // now an integer +v = "hello"; // now a string +v = true; // now a boolean +v = double(3.14); // now a double +``` + +Assigning a new value **replaces** whatever was there — the type can change +freely. + +## Objects: assign to a key + +Indexing with a string key, `v["name"]`, makes `v` an **object** and gives you +the value stored under that key (creating it if needed): + +```cpp +pjson person; +person["name"] = "Ada"; // person becomes an object +person["age"] = int64_t(36); +person["isEngineer"] = true; +``` + +> This automatic creation is called **auto-vivification**: reading or writing +> `v["key"]` *creates* that key if it is missing. It is what makes building so +> concise. The flip side — that it also creates keys when you only meant to +> *read* — is covered in [Chapter 03](03-parsing-and-reading.md). + +Nesting objects is just chained indexing: + +```cpp +person["address"]["city"] = "London"; +person["address"]["zip"] = "N1"; +``` + +The first `person["address"]` creates an empty object, and `["city"]` adds a +key inside it. + +## Numbers: `int64_t` and `double` + +pjson keeps whole numbers as 64-bit integers and everything else as `double`: + +```cpp +person["age"] = int64_t(36); // jsonNumberInt +person["score"] = double(4.5); // jsonNumberDouble +person["big"] = int64_t(9000000000); // jsonNumberInt +``` + +Use these exact-width APIs deliberately: `int64_t` represents whole JSON +numbers and `double` represents fractional or exponent-form values. + +## Strings + +Assign a `const char*` or a `std::string`: + +```cpp +person["name"] = "Ada"; // const char* +std::string s = "Lovelace"; +person["surname"] = s; // std::string +``` + +pjson automatically **escapes** special characters when serializing, so you can +store quotes, newlines, tabs, and Unicode freely — you never escape by hand. + +## Arrays: three ways + +### 1. From a `std::vector` + +The most direct way to make an array: + +```cpp +person["scores"] = std::vector({90, 82, 77}); +``` + +Vectors of `int64_t`, `double`, `bool`, and `std::string` are supported. Build +arrays of other value types element by element. + +### 2. By index + +Assigning to a numeric index makes an array and places the element. If you skip +indices, the gaps are filled with `null`: + +```cpp +person["mixed"][0] = int64_t(1); +person["mixed"][1] = "two"; +person["mixed"][3] = true; // index 2 is auto-filled with null +``` + +Arrays can hold **mixed** types — that is perfectly valid JSON. +One index access may create at most 1,000,000 children. An access that would +cross that growth limit throws `std::length_error` before changing the value. + +### 3. By appending with `+=` + +`+=` promotes the value to an array (if it isn't already) and appends: + +```cpp +person["tags"] += "c++"; +person["tags"] += "json"; // tags is now ["c++", "json"] +``` + +You can append a whole vector at once too: + +```cpp +person["tags"] += std::vector({"fast", "simple"}); +``` + +## Deep nesting + +Because every value can contain values, you build complex documents by +combining the above: + +```cpp +pjson doc; +doc["matrix"][0] = std::vector({1, 2, 3}); +doc["matrix"][1] = std::vector({4, 5, 6}); +``` + +```mermaid +flowchart TD + doc["doc (object)"] --> matrix["matrix (array)"] + matrix --> row0["[0] -> [1,2,3]"] + matrix --> row1["[1] -> [4,5,6]"] +``` + +## Serializing the result + +As you saw in Chapter 01, a default `SerializeOptions` gives compact JSON and +`SerializeOptions::prettyPrinted()` selects two-space pretty output. The fields +let you make each output choice explicit: + +```cpp +pjson::SerializeOptions options = pjson::SerializeOptions::prettyPrinted(); +options.indentWidth = 2; +options.indentCharacter = ' '; // a space or tab +options.escapeNonAscii = false; // keep UTF-8 instead of \u escapes +options.keyOrder = pjson::SerializeOptions::AscendingKeys; +options.maxOutputBytes = size_t(64) * 1024 * 1024; + +std::string text = person.toString(options); +``` + +A default-constructed `SerializeOptions` produces compact output. Its other +defaults are a two-space indent, raw non-ASCII UTF-8, ascending keys, and a +64 MiB output limit. Set `maxOutputBytes = 0` only when explicitly requesting +unlimited output. Set +`keyOrder` to `DescendingKeys` to reverse object-key output. Only space and tab +are valid indentation characters; another value falls back to space. Stored +strings must contain valid UTF-8: `toString()` throws `std::invalid_argument` +for invalid bytes, while `write()` sets the destination stream's failure state. +Crossing the output limit or overflowing indentation arithmetic instead throws +`std::length_error` from `toString()` or sets `failbit` from `write()`. These +logical failures are detected before `write()` emits bytes. Double formatting +is locale-independent and uses 15–17 +significant digits for stable round-tripping; integral-looking doubles keep a +decimal marker so reparsing preserves their storage kind. + +Running the example produces (abridged): + +```json +{ + "address": { + "city": "London", + "zip": "N1" + }, + "mixed": [ + 1, + "two", + null, + true + ], + "scores": [ + 90, + 82, + 77 + ], + "tags": [ + "c++", + "json", + "fast", + "simple" + ] +} +``` + +Remember: object insertion order is not retained. Keys print in ascending +bytewise string order by default (or descending order when requested), and the +skipped array index shows up as `null`. + +## What you learned + +- Assigning to a `pjson` sets its type; assigning again replaces it. +- `v["key"]` builds objects; `v[index]` and `v += x` build arrays. +- Missing keys/indices are created automatically (auto-vivification); array gaps + fill with `null`. +- Whole numbers are stored as int64, other numbers as double; strings are + auto-escaped on output. +- `SerializeOptions` controls pretty layout, indentation, non-ASCII escaping, + and ascending or descending key order. + +Next: [Chapter 03 — Parsing & reading](03-parsing-and-reading.md), where you go +the other direction: text into data, and reading it back safely. diff --git a/docs/03-parsing-and-reading.md b/docs/03-parsing-and-reading.md new file mode 100644 index 0000000..fb0dcfe --- /dev/null +++ b/docs/03-parsing-and-reading.md @@ -0,0 +1,254 @@ +# Chapter 03 — Parsing & reading + +So far we *built* JSON. Now we go the other way: take a JSON **string** and turn +it into a `pjson` you can read. Follow along with +[`examples/src/03_parsing_and_reading.cpp`](../examples/src/03_parsing_and_reading.cpp). + +## Parsing with `parse()` + +`pjson::parse()` takes JSON text and returns a `pjson::unique_ptr`: + +```cpp +auto doc = pjson::parse(R"({ "name": "Ada", "age": 36 })"); +if (!doc) { + // parsing failed — the text was not valid JSON +} +``` + +Two things to understand: + +- **`pjson::unique_ptr`** is a smart pointer that automatically frees the value + when it goes out of scope. You never call `delete`. Use `*doc` to get the + `pjson`, or `doc->method()` to call methods. Its deleter preserves allocator + provenance, so every DOM parse overload uses the same ownership type. +- On a JSON or DOM-allocation **failure** the pointer is empty (`!doc` is true); + malformed input does not escape as an exception. Stream objects configured to + throw can still propagate I/O exceptions from `parseStream()`. (Chapter 05 + shows how to find out why JSON parsing failed.) + +> `R"(...)"` is a C++ *raw string literal*. Inside it, quotes and backslashes +> are literal, so you can paste JSON without escaping every `"`. Very handy. + +## Reading scalar values by exact type + +For data you did not build yourself, prefer `tryGet()`. It returns `true` only +when the value has the requested type and leaves the output unchanged on +failure: + +```cpp +const pjson& j = *doc; + +int64_t age = 0; +if (!j.tryGet("age", age)) { + // missing, or present with the wrong type +} + +std::string name; +if (j.tryGet("name", name)) { + std::cout << name; +} +``` + +There are node-level, keyed, and indexed overloads for `int64_t`, `double`, +`bool`, `std::string`, and `pjson::StringView`. An integer may widen to a +`double`; other conversions are deliberately rejected. + +### Copy-free strings with `StringView` + +Use `StringView` to inspect a stored string without allocating a copy: + +```cpp +pjson::StringView name; +if (j.tryGet("name", name)) { + std::cout.write(name.data(), static_cast(name.size())); +} +``` + +The view borrows bytes owned by the JSON node. Assignment, reset, swap, move, +destruction, erasing the node, or replacing/resetting an ancestor invalidates +it. Strings can contain embedded NUL bytes, so use `size()` rather than +`strlen()`. Copy into a `std::string` when the text must outlive the unchanged +node. + +## The sharp edge: `[]` creates missing keys + +Recall auto-vivification from Chapter 02. It applies when **reading** too: +`j["typo"]` will *create* an empty `"typo"` key if it doesn't exist. That is +rarely what you want when reading, so pjson gives you non-mutating tools. + +### `hasKey` — does the key exist? + +```cpp +if (j.hasKey("email")) { /* ... */ } +``` + +### `find` — look up without creating + +`find` returns a pointer to the value, or `nullptr` if absent. It never +modifies the document: + +```cpp +if (const pjson* p = j.find("email")) { + std::string email; + if (p->tryGet(email)) { /* use email */ } +} +``` + +### `tryGet` — read only when present and well typed + +```cpp +int64_t age = 0; +if (j.tryGet("age", age)) { + // age was present as an integer; now holds 36 +} +``` + +### Read with a fallback + +The most concise "read or default" form: + +```cpp +std::string email = "(none)"; +j.tryGet("email", email); // a failed read leaves the fallback unchanged +int64_t count = int64_t(0); +j.tryGet("count", count); +``` + +```mermaid +flowchart TD + Q["Need to read a key?"] + Q -->|"want typed data or keep a fallback"| IE["tryGet(key, out)"] + Q -->|"want a pointer to the node"| F["find(key)"] + Q -->|"building, and want to create it"| SB["operator[]"] +``` + +**Rule of thumb:** use `[]` when *building*, and `find`/`tryGet` when *reading*. + +## Reading arrays + +Given `"scores": [90, 82, 77]`, there are a few ways to read it. + +### Check and find array indexes + +`hasIndex()` and `find(index)` inspect an array without growing it. Negative +indexes count from the end, so `-1` means the last element: + +```cpp +if (const pjson* scores = j.find("scores")) { + int64_t last = 0; + if (scores->hasIndex(-1) && scores->tryGet(-1, last)) { + std::cout << "last score = " << last << "\n"; + } + + if (const pjson* first = scores->find(0)) { + // first points at the existing element; no element was created + } +} +``` + +Out-of-range indexes and non-array values return `false`/`nullptr`. Unlike +`operator[]`, these operations never auto-vivify or resize. + +### Iterate an array + +Use `size()` plus `find(index)` to visit borrowed child nodes without exposing +or mutating internal storage: + +```cpp +if (const pjson* node = j.find("scores")) { + for (size_t i = 0; node->isArray() && i < node->size(); ++i) { + const pjson* score = node->find(static_cast(i)); + int64_t value = 0; + if (score && score->tryGet(value)) + std::cout << value << " "; + } +} +``` + +### Copy into a typed vector + +```cpp +std::vector vals; +if (const pjson* scores = j.find("scores")) { + for (size_t i = 0; scores->isArray() && i < scores->size(); ++i) { + int64_t value = 0; + if (!scores->tryGet(static_cast(i), value)) { + vals.clear(); + break; + } + vals.push_back(value); + } +} +``` + +Here a mixed or mistyped element rejects the whole copy instead of silently +coercing it. + +### Array of objects + +Combine iteration with per-element lookup: + +```cpp +if (const pjson* friends = j.find("friends")) { + if (friends->isArray()) { + for (size_t i = 0; i < friends->size(); ++i) { + const pjson* friend_ = friends->find(static_cast(i)); + pjson::StringView name; + if (friend_ && friend_->tryGet("name", name)) + std::cout.write(name.data(), static_cast(name.size())); + } + } +} +``` + +## Reading a nested path with JSON Pointer + +`findPointer()` implements non-mutating RFC 6901 JSON Pointer lookup. The empty +pointer addresses the current value; every non-empty pointer starts with `/`: + +```cpp +pjson::PointerError error; +if (const pjson* city = j.findPointer("/address/city", error)) { + std::string text; + if (city->tryGet(text)) + std::cout << text; +} else { + std::cerr << "pointer token " << error.tokenIndex + << ": " << error.message << "\n"; +} +``` + +Pointer tokens encode `~` as `~0` and `/` as `~1`. When a key is dynamic, build +the token with `pjson::escapePointerToken(key)`. Array tokens are canonical +non-negative indexes; negative indexes and the JSON Patch append token `-` are +not valid lookups. The overload without `PointerError` simply returns `nullptr` +for any failure. + +## Iterating an object + +To iterate an object's keys, use `keys()` (returned sorted): + +```cpp +for (const std::string& key : j.keys()) { + const pjson* value = j.find(key); + if (value) { + pjson::SerializeOptions compact; + compact.maxOutputBytes = size_t(64) * 1024 * 1024; + std::cout << key << " => " << value->toString(compact) << "\n"; + } +} +``` + +## What you learned + +- `parse()` returns a `pjson::unique_ptr` and reports JSON/DOM-allocation failures with + an empty result. +- `tryGet()` provides exact-type node/key/index reads and leaves outputs unchanged + on failure; `StringView` offers a mutation-sensitive, copy-free string view. +- `find`, `findPointer`, `hasKey`, and `hasIndex` inspect without creating; use + `[]` when building. Indexed lookups support negative indexes. +- Read arrays via `size()` plus checked indexes, and iterate objects via + `keys()` plus `find()`. + +Next: [Chapter 04 — Editing](04-editing.md), where you modify a document you +parsed. diff --git a/docs/04-editing.md b/docs/04-editing.md new file mode 100644 index 0000000..1d96d4a --- /dev/null +++ b/docs/04-editing.md @@ -0,0 +1,222 @@ +# Chapter 04 — Editing + +A parsed document is fully mutable. Editing uses the same building blocks from +Chapter 02, because `operator[]` returns a **reference** you can assign through. +Follow along with [`examples/src/04_editing.cpp`](../examples/src/04_editing.cpp). + +## Changing a value in place + +Index to the value and assign a new one: + +```cpp +auto doc = pjson::parse(R"({ "user": { "name": "Ada" }, "count": 2 })"); +pjson& j = *doc; + +j["user"]["name"] = "Ada Lovelace"; // change a string +j["count"] = int64_t(3); // change a number +``` + +You can even change a value's **type** — arrays and objects are heterogeneous +and dynamic: + +```cpp +j["count"] = "two"; // was a number, now a string — perfectly fine +``` + +## Adding new data + +Because missing keys and indices are created on demand (auto-vivification), +adding is just assignment: + +```cpp +j["user"]["email"] = "ada@example.com"; // new nested key +j["user"]["roles"] += "owner"; // append to (or create) an array +``` + +## Removing data with `erase` + +`erase` deletes a key from an object or an element from an array, freeing it. +It returns `true` if something was removed. + +```cpp +j.erase("deprecated"); // remove object key "deprecated" +j["user"]["roles"].erase(size_t(0)); // remove array element at index 0 +``` + +Note the `size_t(0)`: the array overload takes an index, and the cast makes it +unambiguous versus the string-key overload. + +```mermaid +flowchart LR + A["erase("key")"] -->|object| R1["removes that key, returns true"] + B["erase(index)"] -->|array| R2["removes that element, returns true"] + C["erase(missing)"] --> R3["no-op, returns false"] +``` + +## Emptying and rebuilding + +- `clear()` empties an array or object **but keeps its type**, so you can refill + it. On a scalar it resets to `null`. +- `reset()` returns any value to `null`. +- `resetTo(type)` makes the node an empty value of a given type (e.g. an empty + array or object), replacing whatever was there. +- `resetIfNeeded(type)` is the idempotent form: it only rebuilds the node when + it is not already that type, so an existing array/object keeps its contents. + +```cpp +j["user"]["roles"].clear(); // now [] +j["user"]["roles"] += "guest"; + +j["tags"].resetTo(pjson::jsonArray); // always starts empty +j["tags"].resetIfNeeded(pjson::jsonArray); // keeps existing tags if already an array +``` + +## Swapping two values + +`swap(other)` exchanges two compatible nodes in O(1) without copying. +`canSwap()` lets you check that precondition. A swap that cannot be performed is +a safe no-op, and `swap()` itself is `noexcept`. + +```cpp +pjson& a = j["a"]; +pjson& b = j["b"]; +if (a.canSwap(b)) + a.swap(b); // exchange the two sub-trees in place +``` + +Sibling nodes in the same document are compatible. Check `canSwap()` when the +values come from different sources. + +## Editing a path atomically + +For a sequence of path-based edits, `applyPatch()` implements JSON Patch (RFC +6902). The patch is an array of `add`, `remove`, `replace`, `move`, `copy`, and +`test` operations: + +```cpp +auto patch = pjson::parse(R"([ + { "op": "replace", "path": "/user/name", "value": "Ada Byron" }, + { "op": "add", "path": "/user/roles/-", "value": "reviewer" } +])"); + +pjson::PatchError error; +pjson::PatchOptions limits; +if (!patch || !j.applyPatch(*patch, error, limits)) { + std::cerr << "patch operation " << error.opIndex + << ": " << error.message << "\n"; +} +``` + +Patch paths use JSON Pointer syntax. An empty path addresses the whole document; +in particular, removing the root succeeds and leaves the target as JSON null: + +```cpp +auto removeRoot = pjson::parse(R"([{"op":"remove","path":""}])"); +if (removeRoot && j.applyPatch(*removeRoot, error, limits)) { + // j.isNull() is now true +} +``` + +`-` is allowed only as the final `add` token to append to an array. If any other +operation fails, the entire call returns `false` and `j` remains unchanged. +`PatchError` identifies the failing operation, path or `from` token, and reason. + +For object-shaped updates, `applyMergePatch()` implements JSON Merge Patch +(RFC 7396): + +```cpp +auto merge = pjson::parse(R"({ + "user": { "email": "ada@example.com", "nickname": null } +})"); + +if (merge && !j.applyMergePatch(*merge, error, limits)) { + std::cerr << error.message << "\n"; +} +``` + +An object patch merges recursively, a `null` member removes that object key, and +a non-object patch replaces the complete target. Merge Patch is atomic too. + +The trailing `PatchOptions` argument bounds transactional amplification for +both patch formats. Defaults are 10,000 operations, 1,000,000 cloned nodes, +64 MiB of cloned node/string/key bytes, and 1,000,000 work units. For RFC 6902, +the operation limit counts array entries; for Merge Patch, it counts processed +members: + +```cpp +pjson::PatchOptions limits; +limits.maxOperations = 10000; +limits.maxClonedNodes = 1000000; +limits.maxClonedBytes = size_t(64) * 1024 * 1024; +limits.maxWork = 1000000; +``` + +A zero field retains its built-in hard ceiling; it never disables a patch +limit. Exceeding one reports `PatchError::ResourceLimit`, returns `false`, and +leaves the target unchanged. + +Removing the document root is valid and leaves JSON null. Moving the root to a +descendant would move it beneath itself, so that case fails with +`PatchError::MoveRootNotAllowed`. + +## Useful queries while editing + +- `size()` — number of elements in an array/object (0 for scalars). +- `empty()` — `size() == 0`. +- `isArray()`, `isObject()`, `isString()`, ... — check the current type before + acting. + +```cpp +if (const pjson* user = j.find("user")) { + const pjson* roles = user->find("roles"); + if (roles && roles->isArray() && !roles->empty()) { + // safe to iterate + } +} +``` + +## A complete edit + +Running the example transforms the input into: + +```json +{ + "count": "two", + "user": { + "email": "ada@example.com", + "name": "Ada Lovelace", + "roles": [ + "dev", + "owner", + "reviewer" + ] + } +} +``` + +Here `admin` was removed, `owner` and `reviewer` appended, `email` added, `name` +changed, and `count` turned into a string — all on a parsed document, then +re-serialized. + +## A safety note on aliasing + +pjson uses copy-and-swap for assignment, so even self-referential edits are +safe: + +```cpp +if (const pjson* user = j.find("user")) + j = *user; // replacing a root from its own child is safe +``` + +## What you learned + +- `operator[]` returns a mutable reference, so editing is just assignment. +- Add by assigning to new keys/indices; append with `+=`. +- `erase(key)` / `erase(index)` remove and free; `clear()` empties keeping the + type; `reset()` returns to `null`; `resetTo(type)`/`resetIfNeeded(type)` rebuild + a node as an empty value of a given type; compatible nodes can be swapped. +- `applyPatch()` and `applyMergePatch()` apply standard path/object edits + atomically and can report a structured `PatchError`. +- `size()`, `empty()`, and the `isX()` predicates help you edit safely. + +Next: [Chapter 05 — Parsing & errors](05-parsing-and-errors.md). diff --git a/docs/05-parsing-and-errors.md b/docs/05-parsing-and-errors.md new file mode 100644 index 0000000..d8b8983 --- /dev/null +++ b/docs/05-parsing-and-errors.md @@ -0,0 +1,117 @@ +# Chapter 05 — Parsing, resource limits & errors + +pjson parses **RFC 8259 JSON**. This chapter shows the resource limits +that keep hostile documents from exhausting memory or the call stack, the +independent duplicate-key policy, and structured diagnostics. Follow along with +[`examples/src/05_parsing_and_errors.cpp`](../examples/src/05_parsing_and_errors.cpp). + +## Parse options + +Every `parse()` call can take a `pjson::ParseOptions`: + +```cpp +struct ParseOptions { + int maxDepth; // default 512 + size_t maxNodes; // default 1,000,000; 0 means unlimited + size_t maxInputBytes; // default 64 MiB; 0 means unlimited + DuplicateKeyPolicy duplicateKeys; // default RejectDuplicateKeys +}; +``` + +```cpp +pjson::ParseOptions opt; +opt.maxNodes = 100000; +auto doc = pjson::parse(text, opt); +``` + +## JSON syntax and duplicate keys + +Every parser accepts RFC 8259 JSON only. It rejects, among other malformed input: + +| Rejected input | Example | +|----------------|---------| +| Case-insensitive keywords | `NULL`, `True`, `FALSE` | +| Unknown escapes | `"\q"` | +| Lone/unpaired `\u` surrogates | `"\uD800"` | +| Raw control characters in strings | a literal tab inside `"..."` | +| Invalid UTF-8 | malformed byte sequences | +| Ordinary grammar errors | trailing commas, missing quotes, bad numbers | + +Example failures from the companion program: + +``` +uppercase keyword: FAILED at byte 0 (invalid JSON value) +raw tab: FAILED at byte 2 (unescaped control character in string) +``` + +The default also rejects duplicate object keys. Independently choose +`RejectDuplicateKeys`, `KeepFirstDuplicate`, or `KeepLastDuplicate` through +`ParseOptions::duplicateKeys`. This changes only duplicate handling; it never +relaxes RFC 8259 syntax. + +## Resource limits + +`maxDepth` caps how deeply values may nest. This is a safety valve: without it, +a maliciously deep document (thousands of nested `[`s) could exhaust the call +stack and crash your program. The default of 512 is generous for real data. +`maxNodes` separately caps the number of materialized JSON values, blocking +wide flat inputs from amplifying into millions of heap allocations. +`maxInputBytes` rejects oversized buffers before parsing begins. + +```cpp +pjson::ParseOptions shallow; +shallow.maxDepth = 3; +auto d = pjson::parse("[[[[1]]]]", shallow); // fails: too deep +``` + +## Getting the error details + +Pass a `pjson::ParseError` to learn what went wrong. Reporting APIs reset every +field on entry: success leaves `ok == true`, offset `0`, line `1`, column `1`, +and an empty message; failure describes the first problem. + +```cpp +struct ParseError { + bool ok; // true if parsing succeeded + size_t offset; // byte index where the problem was found + size_t line; // one-based source line + size_t column; // one-based byte column + std::string message; // human-readable description +}; +``` + +```cpp +pjson::ParseError err; +auto doc = pjson::parse("[1, 2, ]", err); +if (!doc) { + std::cerr << "parse failed at " << err.line << ':' << err.column + << " (byte " << err.offset << "): " << err.message << "\n"; +} +``` + +You can combine both: `parse(text, err, opt)`. + +The same options and error coordinates apply to `parseSax()` and the incremental +`parseSaxStream()` API. SAX callback cancellation and callback exceptions are +converted into an ordinary parse failure rather than escaping. Streaming avoids +buffering the complete document, but current tokens, nesting state, duplicate-key +tracking, and handler-owned state still consume memory. + +## Why not exceptions? + +pjson does not throw JSON-specific parse exceptions. In-memory JSON and +DOM-allocation failures produce an empty pointer plus optional `ParseError`; SAX +handler failures similarly become `false`. An exception-enabled input stream can +still throw while `parseStream()` buffers bytes, and mutating APIs that allocate +may report `std::bad_alloc` unless declared `noexcept`. + +## What you learned + +- All parsing follows RFC 8259 syntax; duplicate handling is a separate + policy. +- `maxDepth`, `maxNodes`, and `maxInputBytes` bound stack and memory use. +- `ParseError{ ok, offset, line, column, message }` tells you where and why + parsing failed, without exceptions. + +Next: [Chapter 06 — Schema validation](06-schema-validation.md), where you check +that parsed data has the *shape* you expect. diff --git a/docs/06-schema-validation.md b/docs/06-schema-validation.md new file mode 100644 index 0000000..e9ca4b7 --- /dev/null +++ b/docs/06-schema-validation.md @@ -0,0 +1,201 @@ +# Chapter 06 — Schema validation + +Parsing tells you the input is *valid JSON*. It does **not** tell you the data +has the shape your program needs — the right keys, the right types, sensible +ranges. That is what **schema validation** is for. Follow along with +[`examples/src/06_schema_validation.cpp`](../examples/src/06_schema_validation.cpp). + +## What is a schema? + +A **schema** is a description of what valid data looks like: "must be an object, +must have a `name` string and a non-negative `age` integer", and so on. In +pjson, a schema is *itself a JSON value* (a `pjson`), written with pjson's +documented subset of the widely-used +[JSON Schema](https://json-schema.org) vocabulary. It is not a claim of complete +conformance to a JSON Schema draft. Schemas load, build, and round-trip exactly +like any other pjson value. + +```mermaid +flowchart LR + data["data (pjson)"] --> V{validate} + schema["schema (pjson)"] --> V + V -->|conforms| OK["true, no errors"] + V -->|violates| ERR["false + list of SchemaError"] +``` + +## A first schema + +```cpp +auto schema = pjson::parse(R"({ + "type": "object", + "required": ["name", "age"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "age": { "type": "integer", "minimum": 0, "maximum": 150 } + } +})"); +``` + +Read it in English: *the value must be an object; it must have `name` and `age`; +`name` must be a non-empty string; `age` must be an integer from 0 to 150.* + +## Validating + +```cpp +auto data = pjson::parse(R"({ "name": "Ada", "age": 36 })"); + +// Simple yes/no: +bool ok = data->validate(*schema); +``` + +To learn *what* failed, pass a vector — pjson normally collects every applicable +failure instead of stopping at the first (a resource-budget failure stops the +traversal): + +```cpp +std::vector errors; +if (!data->validate(*schema, errors)) { + for (const pjson::SchemaError& e : errors) { + std::cout << (e.path.empty() ? "(root)" : e.path) + << ": " << e.message << "\n"; + } +} +``` + +The overload appends to the vector, so call `errors.clear()` before reusing it +when old results are not wanted. Normally all applicable failures are +collected; reaching a validation-depth or reference-resolution budget stops +that traversal safely. + +Each `SchemaError` has a `path` (a **JSON Pointer** like `/age` or +`/friends/2/name`, empty for the document root) and a `message`. From the +example, an all-bad document reports: + +``` +/age: value 200.0 is above maximum 150.0 +/email: string does not match pattern /@/ +/name: string length 0 is below minLength 1 +/roles/0: value is not in the allowed enum +/extra: additional property "extra" is not allowed +``` + +Notice how the path points precisely at each offending node — including deep +into arrays (`/roles/0`). + +## Supported keywords + +pjson implements the documented keyword subset below. Unknown and unsupported +keywords are **ignored, not enforced**. This permits annotations and future +vocabulary to pass through, but it also means a misspelled or unsupported +constraint can silently weaken validation. Treat this table as an allowlist and +test both accepted and rejected instances for every application schema. + +| Applies to | Keywords and forms | +|------------|--------------------| +| any value | `type`, `enum`, `const`, local-fragment `$ref` | +| objects | `properties`, `patternProperties`, `propertyNames`, `required`, `dependentRequired`, `dependencies`, `additionalProperties` (boolean or schema), `minProperties`, `maxProperties` | +| arrays | single-schema or tuple-array `items`, plus `minItems`, `maxItems`, `uniqueItems` | +| numbers | `minimum`, `maximum`, numeric `exclusiveMinimum`, numeric `exclusiveMaximum`, `multipleOf` | +| strings | `minLength`, `maxLength`, `pattern` (ECMAScript regex), `format` | +| combinators| `allOf`, `anyOf`, `oneOf`, `not` | + +A few notes: + +- `type: "integer"` matches whole numbers (including `2.0`); `type: "number"` + matches any int or double. `type` may also be an **array** of allowed names, + e.g. `"type": ["string", "null"]`. +- `enum` and `const` use deep equality, so they work for arrays and objects too. +- `$ref` resolves only a local URI fragment containing a JSON Pointer, such as + `#/$defs/address`; both `$defs` and `definitions` can hold referenced schemas. + Remote references are rejected, and siblings of `$ref` are ignored. +- `patternProperties` applies schemas to matching keys, `propertyNames` checks + each key, and `dependentRequired`/`dependencies` express rules triggered by + the presence of another property. +- Known string formats are `date`, `time`, `date-time`, `ipv4`, `ipv6`, and + `uuid`. They are checked by default; unknown format names are ignored. +- A **boolean schema** is allowed: `true` accepts everything, `false` rejects + everything (handy as a sub-schema, e.g. `"additionalProperties": false`). +- `pattern` uses `std::regex` ECMAScript syntax with search semantics. Default + `SchemaOptions` bound pattern and subject byte sizes and reject expressions + disallowed by the regex safety policy. Applications that fully trust both + schemas and instances may opt out with + `pjson::SchemaOptions::trustedRegex()`. + +The supported vocabulary is deliberately a subset. Tuple-form `items` validates +the corresponding array positions, but elements beyond the tuple remain +unconstrained because `additionalItems` is not implemented. `minLength` and +`maxLength` count Unicode code points, not UTF-8 bytes. Unknown keywords and +many malformed keyword forms are ignored, and pjson does not validate schemas +against a meta-schema. + +## Validation options and resource budgets + +`SchemaOptions` controls regex policy, traversal budgets, and format checking: + +```cpp +pjson::SchemaOptions options; +options.maxRegexPatternBytes = 256; +options.maxRegexSubjectBytes = 4096; +options.allowUnsafeRegex = false; +options.maxValidationDepth = 512; +options.maxRefResolutions = 1024; +options.maxValidationWork = 1000000; +options.maxErrors = 100; +options.validateFormats = true; + +std::vector errors; +bool ok = data->validate(*schema, errors, options); +``` + +These are the defaults. A zero regex byte limit disables that individual regex +limit and should be reserved for trusted input. Zero for the validation-depth, +reference-resolution, work, or error-count budget retains that budget's +documented hard ceiling rather than disabling it. +`SchemaOptions::trustedRegex()` disables both regex byte limits and permits +unsafe regular expressions while retaining all other defaults. Set +`validateFormats = false` when known formats should act only as annotations. + +## Combinators (composing schemas) + +The logical keywords let you build up complex rules: + +- `allOf`: must satisfy **every** sub-schema. +- `anyOf`: must satisfy **at least one**. +- `oneOf`: must satisfy **exactly one**. +- `not`: must **not** satisfy the sub-schema. + +```json +{ "anyOf": [ { "type": "string" }, { "type": "integer" } ] } +``` + +accepts a value that is either a string or an integer. + +## Building schemas programmatically + +Since a schema is just a `pjson`, you can build it with the API instead of +parsing text: + +```cpp +pjson schema; +schema["type"] = "object"; +schema["required"][0] = "name"; +schema["required"][1] = "age"; +schema["properties"]["name"]["type"] = "string"; +schema["properties"]["age"]["type"] = "integer"; +schema["properties"]["age"]["minimum"] = int64_t(0); +``` + +## What you learned + +- A schema is a `pjson` describing valid data with pjson's documented JSON + Schema keyword subset, not a complete draft implementation. +- `validate(schema)` returns yes/no; `validate(schema, errors)` collects **all** + failures, each with a JSON-Pointer `path` and a `message`. +- The subset includes local `$ref`, object constraints, known string formats, + and logical combinators. Unknown keywords are ignored and therefore enforce + no constraint. +- `SchemaOptions` bounds regex, validation depth, reference resolution, total + validation work, and collected errors, and can disable known-format checks. + +Next: [Chapter 07 — Capstone: address book](07-capstone-address-book.md), where +everything comes together in one small application. diff --git a/docs/07-capstone-address-book.md b/docs/07-capstone-address-book.md new file mode 100644 index 0000000..e71c3c6 --- /dev/null +++ b/docs/07-capstone-address-book.md @@ -0,0 +1,144 @@ +# Chapter 07 — Capstone: an address book + +Time to combine everything: building values, parsing, reading, editing, and +schema validation — in one small program. Follow along with +[`examples/src/07_address_book.cpp`](../examples/src/07_address_book.cpp). + +We'll build an in-memory **address book** that only accepts contacts matching a +schema, can ingest contacts from JSON payloads, edits stored records, and +serializes the whole thing. + +## 1. Define what a valid contact looks like + +```cpp +pjson::unique_ptr schema = pjson::parse(R"({ + "type": "object", + "required": ["id", "name", "emails"], + "properties": { + "id": { "type": "integer", "minimum": 1 }, + "name": { "type": "string", "minLength": 1 }, + "emails": { "type": "array", "minItems": 1, + "items": { "type": "string", "pattern": "@" } }, + "tags": { "type": "array", "items": { "type": "string" } } + } +})"); +if (!schema) + return 1; +``` + +Every contact must have a positive `id`, a non-empty `name`, and at least one +email address containing `@`. + +## 2. A gatekeeper that validates before storing + +```cpp +bool addContact(pjson& book, const pjson& schema, const pjson& contact) { + std::vector errors; + if (!contact.validate(schema, errors)) { + for (const pjson::SchemaError& e : errors) { + std::cout << " " << (e.path.empty() ? "(root)" : e.path) + << ": " << e.message << "\n"; + } + return false; // rejected + } + pjson& contacts = book["contacts"]; + if (contacts.size() > static_cast(INT_MAX)) + return false; + contacts[static_cast(contacts.size())] = contact; + return true; +} +``` + +The checked conversion is necessary because `size()` returns `size_t` while +the builder index is `int`. Assigning the index one past the end auto-extends +the array. + +```mermaid +flowchart TD + C["incoming contact"] --> V{validate against schema} + V -->|valid| ADD["append to book.contacts"] + V -->|invalid| REJ["print errors, reject"] +``` + +## 3. Start an empty book + +```cpp +pjson book; +book["version"] = int64_t(1); +book["contacts"].resetTo(pjson::jsonArray); // start as an empty array +``` + +## 4. Add contacts three ways + +**Built programmatically:** + +```cpp +pjson ada; +ada["id"] = int64_t(1); +ada["name"] = "Ada Lovelace"; +ada["emails"] += "ada@example.com"; +ada["tags"] += "pioneer"; +addContact(book, *schema, ada); +``` + +**From a JSON payload** (e.g. arriving over a network): + +```cpp +auto incoming = pjson::parse(R"({ + "id": 2, "name": "Bob", "emails": ["bob@example.com", "b@work.com"] +})"); +if (incoming) + addContact(book, *schema, *incoming); +``` + +**An invalid one is rejected** with precise messages: + +```cpp +auto invalid = pjson::parse(R"({ "id": 0, "name": "", "emails": [] })"); +if (invalid) + addContact(book, *schema, *invalid); +// /emails: array has 0 items, below minItems 1 +// /id: value 0.0 is below minimum 1.0 +// /name: string length 0 is below minLength 1 +``` + +## 5. Edit and look up + +```cpp +// Give Ada a second email. +book["contacts"][0]["emails"] += "ada@lovelace.org"; + +// Find the contact with id == 2 without creating anything. +const pjson* contacts = book.find("contacts"); +for (size_t i = 0; contacts && i < contacts->size(); ++i) { + const pjson* contact = contacts->find(static_cast(i)); + int64_t id = 0; + std::string name; + if (contact && contact->tryGet("id", id) && id == int64_t(2) && + contact->tryGet("name", name)) + std::cout << name << "\n"; // Bob +} +``` + +## 6. Serialize the whole book + +```cpp +pjson::SerializeOptions output = pjson::SerializeOptions::prettyPrinted(); +output.maxOutputBytes = size_t(64) * 1024 * 1024; +std::cout << book.toString(output) << "\n"; +``` + +produces a tidy, sorted-key document with both accepted contacts and their +edits. + +## What this demonstrates + +- **Creating** values programmatically and from parsed payloads. +- **Validating** untrusted input against a schema before trusting it. +- **Reading** with `find`/`tryGet` and **editing** with `+=` and indexing. +- **Serializing** the result — a full round of real-world usage. + +You now know the whole library. The remaining chapters cover the surrounding +workflow. + +Next: [Chapter 08 — Building & installing](08-building-and-installing.md). diff --git a/docs/08-building-and-installing.md b/docs/08-building-and-installing.md new file mode 100644 index 0000000..b396c01 --- /dev/null +++ b/docs/08-building-and-installing.md @@ -0,0 +1,207 @@ +# Chapter 08 — Building & installing + +There are several ways to use pjson in your project, from compiling its +canonical sources directly to consuming an installed package. Pick whichever +fits. + +## Option 1 — Compile the canonical sources directly + +pjson has **no dependencies** beyond the C++ standard library. For a vendored +copy, use the canonical header and implementation from the repository: + +- `pjsonlib/include/pjson.h` +- `pjsonlib/src/pjson.cpp` + +Compile `pjsonlib/src/pjson.cpp` alongside your own sources and add +`pjsonlib/include` to the include path: + +```sh +c++ -std=c++11 -I path/to/pjson/pjsonlib/include \ + path/to/pjson/pjsonlib/src/pjson.cpp your_app.cpp -o your_app +``` + +In code: + +```cpp +#include "pjson.h" +using namespace ByteDance; +``` + +This is the recommended path for small projects and for trying pjson out. + +```mermaid +flowchart LR + H["pjsonlib/include/pjson.h"] --> APP["your_app.cpp"] + C["pjsonlib/src/pjson.cpp"] --> OBJ["compiled together"] + APP --> OBJ --> BIN["your_app"] +``` + +## Option 2 — The `build.sh` script + +From the repository root, `build.sh` configures CMake, builds the library, +tests, examples, and benchmarks, and drops artifacts into `out/`: + +```sh +./build.sh # full Release + sanitized Debug verification sweep +./build.sh --test # build, then run the test suite +./build.sh --fuzz # bounded libFuzzer corpus replay (Clang required) +./build.sh --docs # generate and validate the API reference +./build.sh --package # static/shared relocatable install + pkg-config checks +./build.sh --license # validate SPDX/REUSE licensing metadata +./build.sh --clean # remove out/ first +./build.sh --bench --release-only # dependency-free performance benchmark +./build.sh --bench-compare --auto # compare pinned JSON libraries +``` + +Resulting layout: + +``` +out/ + include/pjson.h public header + release/lib/libpjson.a Release static library + release/bin/pjsontest Release test runner + release/bin/pjsonbench Release benchmark runner + debug/ Debug artifacts + build-release/ build-debug/ CMake build trees +``` + +The script also supports developer flags — `--asan` (sanitizers), `--format` +and `--check` (clang-format), `--tidy` (clang-tidy), `--bench`, +`--bench-compare`, `--fuzz`, `--docs`, `--package`, `--license`, and `--auto` +(install/download without prompting). With no flags, it runs the same full +sweep as `--all`, including Doxygen and SPDX/REUSE validation, static/shared +relocatable install and pkg-config checks, and bounded fuzzing when a usable +Clang/libFuzzer toolchain is available. It offers to fetch both pinned JSON and +JSON Schema conformance corpora; `--all --auto` accepts those downloads without +prompting. An explicit `--fuzz` request fails instead of skipping when that +toolchain is unavailable. See +[Chapter 09](09-testing.md) for fuzzing details and +[Chapter 10](10-contributing.md) for the contributor workflow. + +## Option 3 — Use it from CMake + +If your project uses CMake, add pjson as a subdirectory and link the exported +target: + +```cmake +add_subdirectory(pjson) # the pjson repo +target_link_libraries(my_app PRIVATE pjson::pjson) +``` + +pjson attaches its include directory to the target, so `#include "pjson.h"` +just works. For an installed copy, first build and install the library: + +```sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF +cmake --build build --config Release +cmake --install build --config Release --prefix /path/to/pjson-prefix +``` + +The install contains `pjson.h`, the library, `pjsonConfig.cmake`, +`pjsonConfigVersion.cmake`, and `pjsonTargets.cmake`. The CMake package files +normally live under `//cmake/pjson`; the exact `` follows +the platform's GNU install-directory convention. Consume them with a versioned +config-package lookup: + +```cmake +find_package(pjson 1.0 CONFIG REQUIRED) +target_link_libraries(my_app PRIVATE pjson::pjson) +``` + +Pass the installation prefix through `CMAKE_PREFIX_PATH` if it is not in a +standard search location. The package is relocatable, and its generated version +file accepts compatible releases from the same major version. + +### pkg-config + +Installation also writes a relocatable `pjson.pc` under +`//pkgconfig`. After adding that directory to +`PKG_CONFIG_PATH`, compile with its published flags: + +```sh +pkg-config --modversion pjson +c++ -std=c++11 your_app.cpp $(pkg-config --cflags --libs 'pjson >= 1.0') \ + -o your_app +``` + +## Package managers + +The repository contains a Conan 2 recipe. From a checkout, create and test the +package with: + +```sh +conan profile detect # needed once for a new Conan home +conan create . -s build_type=Release --build=missing +``` + +The recipe publishes the CMake target `pjson::pjson` and the pkg-config module +`pjson`. It supports Conan's `shared` and `fPIC` options. + +An in-repository vcpkg overlay port is also available. It deliberately builds +the current checkout rather than downloading a possibly older archive: + +```sh +"$VCPKG_ROOT/vcpkg" install pjson \ + --overlay-ports="$PWD/packaging/vcpkg/ports" +``` + +Configure consumers with the usual vcpkg toolchain file, then use the same +`find_package(pjson CONFIG REQUIRED)` and `pjson::pjson` target shown above. + +## CMake options + +| Option | Default | Effect | +|--------|---------|--------| +| `PJSON_BUILD_TESTS` | top-level `ON`; subproject `OFF` | Build and register the test runner | +| `PJSON_BUILD_EXAMPLES` | top-level `ON`; subproject `OFF` | Build the nine tutorial examples | +| `PJSON_BUILD_BENCHMARKS` | top-level `ON`; subproject `OFF` | Build the dependency-free benchmark | +| `BUILD_SHARED_LIBS` | `OFF` | Build a shared library instead of the default static library | +| `PJSON_SANITIZE` | `OFF` | Enable AddressSanitizer and UndefinedBehaviorSanitizer with GCC or Clang | +| `PJSON_BUILD_DOCS` | `OFF` | Build the Doxygen reference; requires Doxygen and Python 3 | +| `PJSON_BUILD_FUZZERS` | `OFF` | Build the four coverage-guided fuzz targets | +| `PJSON_BENCH_COMPARE` | `OFF` | Add pinned nlohmann/json, RapidJSON, and simdjson comparisons to `pjsonbench` | +| `PJSON_BENCH_DEPS_DIR` | `.benchmark-deps` | Locate the pinned comparison sources | +| `PJSON_FUZZING_ENGINE` | empty | Supply an external fuzz-engine linker command instead of built-in libFuzzer | + +These defaults describe a normal configure with no pre-seeded cache values. +Use the three explicit pjson component switches in scripts so the selected +target set does not depend on surrounding project configuration. + +With an empty `PJSON_FUZZING_ENGINE`, `PJSON_BUILD_FUZZERS=ON` requires Clang +with libFuzzer on Linux/macOS. An external engine can instead be supplied +through `PJSON_FUZZING_ENGINE`. For a small +top-level build, set `PJSON_BUILD_TESTS`, `PJSON_BUILD_EXAMPLES`, and +`PJSON_BUILD_BENCHMARKS` to `OFF`; documentation and fuzz targets already +default to `OFF`. When pjson is added with `add_subdirectory()`, the three +developer components default to `OFF` automatically. + +## Requirements + +- A **C++11** (or newer) compiler. +- **CMake ≥ 3.21** if you use options 2 or 3. (Not needed for option 1.) +- Works on **Linux, macOS, and Windows** (MSVC, MinGW, clang, gcc). + +## Platform notes + +- **Linux / macOS:** any recent g++ or clang works out of the box. +- **Windows / MSVC:** open the folder in Visual Studio (which understands + CMake), or configure from the command line: + ```bat + cmake -S . -B build + cmake --build build --config Release + ``` + +## What you learned + +- The simplest integration is to compile `pjsonlib/src/pjson.cpp` with your app + and point `-I` at `pjsonlib/include` — no build system required. +- `build.sh` builds everything into `out/`; CMake integration exposes the + `pjson::pjson` target. +- Installed CMake and pkg-config metadata are relocatable, and Conan 2 and an + in-tree vcpkg overlay provide package-manager integration. +- pjson needs only C++11 and builds on Linux, macOS, and Windows. + +Next: [Chapter 09 — Testing](09-testing.md). diff --git a/docs/09-testing.md b/docs/09-testing.md new file mode 100644 index 0000000..a5cf4b3 --- /dev/null +++ b/docs/09-testing.md @@ -0,0 +1,213 @@ +# Chapter 09 — Testing + +pjson ships with a large automated test suite. This chapter shows how to run it +and how it is organized — useful whether you are evaluating pjson or changing +it. + +## Running the tests + +The easiest way, via the build script: + +```sh +./build.sh --test +``` + +Or directly with CMake and CTest: + +```sh +cmake -S . -B build \ + -DPJSON_BUILD_TESTS=ON \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF +cmake --build build +ctest --test-dir build --output-on-failure +``` + +CTest registers every `TEST()` separately, so progress and failures are +reported case by case rather than as one aggregate `1/1` executable. +You can run one case by name with `ctest --test-dir build -R pjson.test_name`. + +There is still only one test binary. Run it directly to execute every case: + +```sh +./out/debug/bin/pjsontest # if built via ./build.sh +./build/pjsontest/pjsontest # if built via plain cmake +``` + +A passing run ends with a summary like: + +``` +[PASS] version_string (5 checks) +... + tests, 0 failed, 0 failing checks +``` + +The runner exits non-zero if any check fails, so it plugs into CI directly. +It also supports `--list-tests` and `--run-test NAME`; CTest uses those options +to enumerate and isolate cases. + +The test run offers to fetch both pinned conformance corpora—nst/JSONTestSuite +and JSON-Schema-Test-Suite—when they are missing. Add `--auto` to accept both +downloads without prompting: + +```sh +./build.sh --test # prompt before downloading, then run all tests +./build.sh --test --auto # download automatically, then run all tests +./build.sh --all --auto # run the complete non-interactive contributor gate +``` + +The corpora are stored in the gitignored `.test-corpora/` directory and reused +on later runs. You can also fetch the JSON grammar corpus explicitly with +`./scripts/fetch-json-test-suite.sh`; `PJSON_JSONTESTSUITE_DIR` is only needed to +override the standard location. Declining a prompt leaves the optional corpus +test as a clean skip; once a download is accepted, a fetch failure aborts the +full sweep. The fetch helper checks out a pinned corpus commit for reproducible +results. Plain `./build.sh --test --auto` also fetches either corpus when it is +missing. Without `--auto`, both `--test` and `--all` ask before downloading. + +The schema suite uses a separately pinned subset manifest drawn from the +JSON-Schema-Test-Suite `draft7` directory. Fetch and run that manifest with: + +```sh +./scripts/fetch-json-schema-test-suite.sh +PJSON_JSON_SCHEMA_TEST_SUITE_DIR="$PWD/.test-corpora/JSON-Schema-Test-Suite" \ + ctest --test-dir out/build-debug --output-on-failure \ + -R '^pjson\.schema_official_draft7_optional$' +``` + +Without that checkout, the official-schema case reports a clean skip; the +repository's inline schema tests still run. + +## How the suite is organized + +Tests are grouped by topic into files under `pjsontest/src/`, all linked into +one executable: + +| File | Focus | +|------|-------| +| `tests_core.cpp` | types, exact typed access, copy/move | +| `tests_build.cpp` | supported operators, setters, and containers | +| `tests_parse.cpp` | valid parsing and the number grammar | +| `tests_strings.cpp` | escaping and Unicode | +| `tests_roundtrip.cpp` | serialize/parse stability, formatting | +| `tests_features.cpp` | version, depth guard, RFC 8259 parsing, errors, equality, streams | +| `tests_schema.cpp`, `tests_schema_complex.cpp`, `tests_schema_vocabulary.cpp` | schema validation and vocabulary | +| `tests_schema_official.cpp` | optional pinned JSON-Schema-Test-Suite subset manifest | +| `tests_malformed.cpp` | exhaustive invalid/hostile input (never throws) | +| `tests_mutation.cpp` | complex add/edit/delete/rebuild scenarios | +| `tests_api_edge.cpp` | normal + edge case for every public method | +| `tests_fuzz.cpp` | deterministic (seeded) fuzzing | +| `tests_pathological.cpp` | extreme numbers, wide payloads, and exact budget boundaries | +| `tests_conformance.cpp` | inline RFC 8259 cases + optional nst/JSONTestSuite corpus | +| `tests_storage.cpp` | inline scalar storage, copy/move/swap, type transitions | +| `tests_allocator.cpp` | custom allocator ownership, failure, move, and swap behavior | +| `tests_streaming.cpp` | SAX events, chunk boundaries, cancellation, direct stream output | +| `tests_serialize_access.cpp` | serialization options and non-vivifying access | +| `tests_pointer_patch.cpp` | JSON Pointer, JSON Patch, and Merge Patch | + +## The test harness + +Tests use a tiny header-only harness (`pjsontest/src/test_harness.h`). Writing a +test is just: + +```cpp +#include "pjson.h" +#include "test_harness.h" +#include "test_util.h" // pjson_test::parse() helper + +using namespace ByteDance; + +TEST(my_feature_does_x) { + auto j = pjson_test::parse(R"({ "a": 1 })"); + CHECK(j != nullptr); + int64_t value = 0; + CHECK(j->tryGet("a", value)); + CHECK_EQ(value, int64_t(1)); +} +``` + +- `TEST(name) { ... }` registers a test automatically — no list to maintain. +- `CHECK(expr)` fails the test if `expr` is false. +- `CHECK_EQ(a, b)` checks equality and prints both values on failure. +- `CHECK_PARSE_FAILS(text)` asserts that parsing `text` returns empty. + +There is no `main()` to edit; the runner discovers every `TEST` at startup. + +## Fuzzing + +`tests_fuzz.cpp` feeds thousands of random and random-JSON-flavored byte strings +to the parser, and runs random build/edit/validate sequences. The seeds are +fixed, so a failure is **reproducible**. The fuzzed parsing and mutation entry +points must not crash or emit unexpected exceptions, and successful parses must +round-trip. Expected serialization and allocation failures keep their documented +contracts. + +For mutation-guided coverage, Clang builds four standalone libFuzzer targets: +`pjson_fuzz_parse` exercises RFC 8259 DOM round trips, +`pjson_fuzz_stream` compares buffer, stream, and SAX paths, and +`pjson_fuzz_schema` checks schema validation invariants. `pjson_fuzz_patch` +exercises JSON Patch and Merge Patch, checking that failures leave the target +unchanged and successful transformations remain serializable. Run the bounded +seed corpus smoke used by CI with: + +```sh +./build.sh --fuzz --auto +``` + +The no-argument/`--all` contributor sweep runs the same bounded smoke per target +when a usable Clang/libFuzzer toolchain is available and reports a skip on +unsupported platforms. An explicit `--fuzz` request fails with a clear +diagnostic when the required runtime is unavailable. This makes a full sweep +portable while ensuring a requested fuzz job cannot silently pass without +running. + +The same sweep also validates the Doxygen API reference, SPDX/REUSE metadata, +and relocatable static/shared install and pkg-config consumers. Those can be +requested independently with `./build.sh --docs`, `./build.sh --license`, and +`./build.sh --package`. + +The checked-in seeds live under `fuzz/corpus/`; generated inputs and crash +artifacts are written under the ignored `out/` tree, never back into those seed +directories. A focused direct build is: + +```sh +CXX=clang++ cmake -S . -B out/build-fuzz \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF \ + -DPJSON_BUILD_FUZZERS=ON +cmake --build out/build-fuzz --parallel +``` + +With no `PJSON_FUZZING_ENGINE`, this requires a full LLVM Clang distribution +with libFuzzer; Apple Command Line Tools alone may not include that runtime. An +external engine may instead be supplied through `PJSON_FUZZING_ENGINE`. +OSS-Fuzz packaging is kept in `oss-fuzz/`, and every target uses +`fuzz/json.dict`. + +`PJSON_BUILD_FUZZERS` controls only whether the targets are built. It does not +run them; use `./build.sh --fuzz`, invoke the executables directly, or use the +OSS-Fuzz integration to execute inputs. + +## Sanitizers + +For memory-safety and undefined-behavior checking, build with sanitizers: + +```sh +./build.sh --clean --asan --test +``` + +This compiles the library and the whole suite with AddressSanitizer and +UndefinedBehaviorSanitizer and fails on any finding — the strongest check +available and the one contributors should run. + +## What you learned + +- Run tests with `./build.sh --test` or `ctest`; the runner exits non-zero on + failure. +- Tests are grouped by topic and use a tiny `TEST`/`CHECK` harness that + auto-registers cases. +- Deterministic generated tests, coverage-guided libFuzzer targets, and a + sanitizer build (`--asan`) provide complementary robustness coverage. + +Next: [Chapter 10 — Contributing](10-contributing.md). diff --git a/docs/10-contributing.md b/docs/10-contributing.md new file mode 100644 index 0000000..b9c3dfa --- /dev/null +++ b/docs/10-contributing.md @@ -0,0 +1,168 @@ +# Chapter 10 — Contributing + +Thanks for wanting to improve pjson! This chapter covers the workflow, the +coding style, and the checks your change should pass. + +## The project layout + +``` +pjson/ + pjsonlib/ + include/pjson.h the public header (declarations only) + src/pjson.cpp the implementation + pjsontest/src/ the test suite (tests_*.cpp + harness) + examples/src/ runnable examples used by the docs + docs/ this tutorial series + packaging/ package-manager metadata and overlays + tests/ install consumer smoke tests + fuzz/ standalone libFuzzer targets and seed corpora + build.sh build / format / lint / test driver + clean.sh remove build output, corpora, and benchmark dependencies + .clang-format formatting rules + .clang-tidy static-analysis rules +``` + +A design principle: **the header stays small**. All implementation — including +the parser, schema validator, and serialization helpers — lives in `pjson.cpp` +(inside the `pjsonImpl` helper). Please keep new internal helpers out of the +header. + +## The one command to run before submitting + +```sh +./build.sh # or, equivalently, ./build.sh --all +``` + +With no flags, `build.sh` runs the same full contributor sweep as `--all`: + +```mermaid +flowchart LR + F["--check
clang-format"] --> B["build
-Wall -Wextra"] + B --> S["--asan
Address+UB sanitizers"] + S --> Y["--tidy
clang-tidy"] + Y --> T["--test
all registered cases"] + T --> M["benchmarks
pjson + comparisons"] + M --> Z["bounded fuzz
when supported"] + Z --> D["--docs
API reference"] + D --> P["--package
relocatable static/shared + pkg-config"] + P --> L["--license
SPDX/REUSE"] +``` + +- `--check` verifies formatting (run `./build.sh --format` to auto-fix). +- `--asan` builds with AddressSanitizer + UndefinedBehaviorSanitizer and fails + on any finding. +- `--test` runs the full suite; it must be **green**. +- `--tidy` runs clang-tidy and fails on project findings. +- `--all` benchmarks pjson alongside pinned nlohmann/json, RapidJSON, and + simdjson versions. Use `--bench` for the dependency-free pjson-only run, or + `--bench-compare` to request comparison mode directly. +- `--all` also replays bounded corpora through four libFuzzer targets covering + DOM parsing, streaming, schema validation, and Patch/Merge Patch atomicity + when the local platform has Clang and libFuzzer; otherwise that optional part is + reported as skipped. `--fuzz` is strict and fails if it cannot run. +- `--all` builds the checked Doxygen reference and validates relocatable static + and shared installs through CMake package and pkg-config consumers. Use + `--docs` or `--package` to request those checks independently. +- `--license` checks every tracked file's SPDX/REUSE metadata. `--all` includes + it; when needed, `--auto` installs the pinned checker under `out/`. + +If `cmake`, `clang-format`, `clang-tidy`, Doxygen, or Python 3 are missing for a +selected check, `build.sh` offers to install them via your package manager; add +`--auto` to do so without prompting. +With `--auto`, the full sweep fetches both pinned JSON/JSON-Schema conformance +corpora and optional benchmark dependencies without prompting. + +## Documentation and package checks + +For public API or reference-documentation changes, build the checked Doxygen +reference (Doxygen and Python 3 are required): + +```sh +cmake -S . -B out/build-docs \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF \ + -DPJSON_BUILD_DOCS=ON +cmake --build out/build-docs --target pjson-docs-check +``` + +For CMake installation or package metadata changes, run: + +```sh +./build.sh --package +``` + +The package check verifies relocated static and shared installs, their +config/version/target files, the `pjson::pjson` target, and mandatory pkg-config +consumers. Packaging changes should additionally run the +relevant Conan 2 `conan create .` or vcpkg overlay installation documented in +[Chapter 08](08-building-and-installing.md). + +## Coding style + +Style is enforced by `.clang-format`, so you rarely think about it — just run +`./build.sh --format`. The gist: + +- 4-space indentation, members indented inside the `namespace`. +- Pointers bind to the type: `pjson* p`, `const std::string& s`. +- 100-column lines. +- Braces attach (`if (...) {`). + +Naming conventions used in the codebase: + +- Public methods are `camelCase` (`findPointer`, `parseStream`). +- Parameters are prefixed `a` (`aKey`, `aValue`); members with `_` + (`_eType`, `_pValueMap`). +- Internal helpers in `pjson.cpp` are `_leadingUnderscore`. + +## Adding a test + +Every change should come with a test. Add a `TEST(...)` to the most relevant +`pjsontest/src/tests_*.cpp` (or create a new topic file and list it in +`pjsontest/CMakeLists.txt`). See [Chapter 09](09-testing.md) for the harness. +Cover the normal case **and** the edge cases. + +## Adding an example + +If you add a user-facing feature, consider a short example under +`examples/src/`, wired into `examples/CMakeLists.txt`, and reference it from the +docs so it stays exercised. + +## Guidelines + +- **No new dependencies.** pjson is intentionally standalone (standard library + only). +- **Report data-domain failures softly.** Invalid JSON, lookup/type mismatch, + validation failure, and patch failure use empty/status/error results. APIs that + allocate or use exception-enabled streams may still propagate standard C++ + allocation or I/O exceptions unless declared `noexcept`. +- **Keep C++11 compatibility.** +- **Update the docs** when you change public behavior. +- **Update package and release metadata together** when changing the version or + stable consumption contract. + +## Submitting + +1. Branch from `main`. +2. Make your change with tests. +3. Update user documentation and add a concise `CHANGELOG.md` entry for notable + user-visible changes. +4. Run `./build.sh` (the full sweep) until clean. The focused commands above are + also useful while iterating on documentation or packaging changes. +5. Open a pull request describing the change, why it is needed, and the exact + commands used to verify it. + +## What you learned + +- The repo layout, and the rule that the header stays declaration-only. +- The single pre-submit command: `./build.sh` (equivalently `--all`). +- Style is auto-enforced; add normal and edge-case tests, keep the no-dependency + and C++11 constraints, and preserve status-based data-error contracts. +- Documentation and packaging changes have focused checks in addition to the + full code sweep. + +--- + +You now know how to contribute changes and run the complete verification suite. +Next: [Chapter 11 — Streaming large JSON](11-streaming.md), followed by the +advanced [custom allocator guide](12-custom-allocators.md). diff --git a/docs/11-streaming.md b/docs/11-streaming.md new file mode 100644 index 0000000..65a6d44 --- /dev/null +++ b/docs/11-streaming.md @@ -0,0 +1,121 @@ +# Chapter 11 — Streaming large JSON documents + +Building a DOM keeps every value in memory. For a multi-gigabyte JSON document, +use pjson's SAX interface instead: it reads an `std::istream` through a fixed +8 KiB input buffer and calls your code as values arrive. Memory then scales with +nesting depth, the largest individual string or number token, and any state your +handler retains, not the total document size. Under the default duplicate-key +rejection policy, the parser also remembers keys in each currently open object, +so a very wide object uses memory proportional to its unique key data. +Follow along with +[`examples/src/08_streaming.cpp`](../examples/src/08_streaming.cpp). + +## Define an event handler + +Derive from `pjson::SaxHandler` and override only the events you need. Every +callback returns `bool`; return `false` for controlled early termination. + +```cpp +#include "pjson.h" + +#include +#include +#include + +using ByteDance::pjson; + +struct NumberSummary : pjson::SaxHandler { + uint64_t count = 0; + double total = 0.0; + + bool onInt(int64_t value) override { + ++count; + total += static_cast(value); + return true; + } + + bool onDouble(double value) override { + ++count; + total += value; + return true; + } +}; +``` + +Available callbacks are `onNull`, `onBool`, `onInt`, `onDouble`, `onString`, +`onStartArray`, `onEndArray`, `onStartObject`, `onKey`, and `onEndObject`. They +arrive in source order. + +## Parse the stream + +```cpp +std::ifstream input("huge.json", std::ios::binary); +NumberSummary summary; +pjson::ParseError error; + +if (!pjson::parseSaxStream(input, summary, error)) { + std::cerr << "JSON error at " << error.line << ':' << error.column + << " (byte " << error.offset << "): " << error.message << '\n'; + return 1; +} +``` + +The same parser accepts an in-memory `std::string` or `(const char*, size_t)` +through `parseSax()`. SAX parsing does not build a `pjson` tree; use normal +`parse()` when you need random access or mutation afterward. `parseStream()` +also builds a DOM: it reads in chunks but buffers the complete document before +constructing the tree. Only `parseSaxStream()` provides true incremental input. + +## Limits and duplicate keys + +All `ParseOptions` apply to streaming input. Their defaults are `maxDepth = +512`, `maxNodes = 1,000,000`, and `maxInputBytes = 64 MiB`. `maxNodes` counts +JSON values even without a DOM, providing a predictable work limit. Zero makes +`maxNodes` or `maxInputBytes` unlimited; a non-positive `maxDepth` is instead +treated as a limit of one. Raise or disable limits only for inputs you trust. + +Parsing always follows RFC 8259. The default duplicate policy rejects repeated +keys. `KeepFirstDuplicate` suppresses +events for later duplicate value subtrees. `KeepLastDuplicate` emits both +occurrences because a stream cannot retract an event already delivered. + +## Cancellation and exceptions + +Returning `false` from a callback stops parsing and reports `SAX parse aborted`. +If a callback throws, pjson catches it and reports a handler exception in +`ParseError`; exceptions do not escape `parseSax*()`. + +## Streaming output + +`write()` walks the DOM iteratively and writes directly to the destination +stream without constructing a complete serialized string first: + +```cpp +std::ofstream output("result.json", std::ios::binary); +pjson::SerializeOptions options = pjson::SerializeOptions::prettyPrinted(); +options.indentWidth = 4; +options.indentCharacter = ' '; +options.escapeNonAscii = true; +options.keyOrder = pjson::SerializeOptions::AscendingKeys; +options.maxOutputBytes = size_t(64) * 1024 * 1024; +document.write(output, options); +if (!output) { + // Inspect or discard the destination according to the application's policy. +} +``` + +This avoids a whole-document output buffer; traversal state scales with nesting +depth, while escaping a key or string can use temporary memory proportional to +that token. `SerializeOptions` controls indentation, non-ASCII escaping, and +ascending or descending key traversal for both output APIs. The default output +limit is 64 MiB; zero explicitly means unlimited. `write()` returns `void`, so +check the stream state. Invalid stored UTF-8, crossing the configured byte +limit, and indentation/size overflow are logical preflight failures: they set +`failbit` before any bytes are emitted. The corresponding `toString()` call +throws `std::invalid_argument` for invalid UTF-8 or `std::length_error` for +budget/indentation overflow. Only a physical sink or I/O failure during emission +may leave a partial prefix. Write to a temporary file and rename it when atomic +replacement is required. + +Next: [Chapter 12 — Custom allocators](12-custom-allocators.md). For the full +verification workflow, see [Chapter 10 — Contributing](10-contributing.md). diff --git a/docs/12-custom-allocators.md b/docs/12-custom-allocators.md new file mode 100644 index 0000000..ec37e83 --- /dev/null +++ b/docs/12-custom-allocators.md @@ -0,0 +1,195 @@ +# Chapter 12 — Custom allocators {#custom-allocators} + +Most applications should use pjson's built-in allocator. If an embedded system, +arena, pool, or allocation tracer needs control over persistent DOM storage, +implement `pjson::Allocator` and bind values to it. Follow along with +[`examples/src/09_custom_allocator.cpp`](../examples/src/09_custom_allocator.cpp). + +## Default ownership + +Without an allocator argument, a value uses pjson's built-in allocator: + +```cpp +pjson value; +pjson::unique_ptr parsed = pjson::parse(R"({"answer":42})"); +``` + +The direct value is owned by its C++ scope. The parse result uses the same +provenance-aware `pjson::unique_ptr` returned by every DOM parse overload, so it +is also released automatically. Children returned by `find()` or +`findPointer()` are borrowed views into the owning tree—never delete them +yourself. + +Every pjson value is allocator-bound, including default-constructed values. A +custom allocator changes where selected persistent DOM allocations come from; it +does not change the tree's ownership model. + +## Implementing `pjson::Allocator` + +Derive from the runtime interface and implement both operations: + +```cpp +class PoolAllocator : public pjson::Allocator { +public: + void* allocate(size_t bytes, size_t alignment, AllocationKind kind) override; + void deallocate(void* memory, size_t bytes, size_t alignment, + AllocationKind kind) noexcept override; +}; +``` + +The contract is: + +- `allocate` returns non-null storage satisfying the requested byte size and + alignment, or throws (normally `std::bad_alloc`). Returning `nullptr` is not a + supported failure signal. +- `deallocate` receives the original pointer and matching size, alignment, and + kind. It must not throw. +- The allocator object is borrowed. It must outlive every root and descendant + bound to it, including roots returned by allocator-aware parsing. +- If one allocator instance is shared between threads, that allocator is + responsible for whatever synchronization its implementation requires. + +`AllocationKind` lets a pool maintain separate free lists or statistics: + +| Kind | Persistent allocation represented | +|---|---| +| `NodeAllocation` | A dynamically owned `pjson` node, including a parsed root | +| `StringAllocation` | The `std::string` wrapper for a string-valued node | +| `ArrayAllocation` | The internal wrapper for an array-valued node | +| `ObjectAllocation` | The internal wrapper for an object-valued node | + +The hook deliberately does not replace every allocation in the process. The +internal buffers/nodes allocated by `std::string`, `std::vector`, and `std::map`, +and transient parsing, serialization, pointer, patch, and validation workspaces, +continue to use the standard allocator. + +## Direct roots versus parsed roots + +A directly constructed root remains owned by the place where it was created: + +```cpp +PoolAllocator pool; + +{ + pjson document(pool); // document itself is on the stack + document["name"] = "Ada"; + // Heap-backed wrappers and descendants use pool. +} // document's destructor returns its bound storage to pool +``` + +Parsing must allocate the root dynamically, so every overload returns +`pjson::unique_ptr`: + +```cpp +pjson::ParseError error; +pjson::ParseOptions options; +pjson::unique_ptr document = pjson::parse(text, error, pool, options); +if (!document) { + std::cerr << error.line << ':' << error.column << ": " + << error.message << '\n'; +} +``` + +`pjson::unique_ptr` is `std::unique_ptr`. Its +stateless deleter reads allocator provenance from the root and returns the root +through the correct allocator. Do not replace that deleter or call `delete` on +the root. Moving the smart pointer transfers the root but does not own or extend +the allocator's lifetime. + +Allocator-aware overloads exist for `std::string`, `(const char*, size_t)`, and +`std::istream`, with optional `ParseError` and `ParseOptions`. `parseStream()` +uses standard allocation for its temporary input buffer but uses the supplied +allocator for the persistent DOM. SAX parsing builds no persistent DOM and has +no allocator overload. + +## Copy, move, assignment, and swap + +Allocator provenance is part of a value's lifetime contract: + +| Operation | Allocator behavior | +|---|---| +| `pjson copy(source)` | Deep copy using `source`'s allocator | +| `pjson copy(source, destinationAllocator)` | Deep copy into the named allocator | +| `destination = source` / `copyFrom(source)` | Deep copy while preserving the destination allocator | +| `pjson moved(std::move(source))` | O(1) transfer with the source allocator; source becomes null | +| `pjson moved(std::move(source), destinationAllocator)` | O(1) when allocators match; otherwise deep-transfer, then source becomes null | +| `destination = std::move(source)` | Preserves the destination allocator; same-allocator storage transfer may still destroy the old destination tree, while cross-allocator transfer may allocate | +| `left.swap(right)` | O(1) only when `left.canSwap(right)`; otherwise a safe no-op | + +Check compatibility whenever two values may have come from different allocator +domains: + +```cpp +if (left.canSwap(right)) { + left.swap(right); +} else { + left = right; // deep copy into left's allocator +} +``` + +Do not infer allocator ownership from equality or value type. Use +`&value.getAllocator()` when provenance matters. A successfully moved-from +source is JSON null but remains bound to its original allocator. + +## Failure behavior + +Allocator-aware in-memory parsing catches failures during DOM construction, +destroys partial trees, returns an empty `pjson::unique_ptr`, and fills +`ParseError` when supplied. `parseStream()` first fills a standard-allocated +input buffer, so an exception-enabled stream or failure in that buffer can still +throw before DOM construction. + +Other operations that allocate—such as string/container mutation, deep copy, +and cross-allocator move—may propagate `std::bad_alloc`. Copy assignment, +cross-allocator move assignment, `resetTo`, string replacement, missing-key +insertion, and array growth preserve existing data in their documented/tested +failure paths. JSON Patch and Merge Patch are `noexcept`; allocation or +internal failures are reported through `false` and `PatchError`, and the target +is left unchanged. + +An allocator should remain usable while failed operations unwind, because pjson +may need it to release partially constructed nodes. + +## Complete counting example + +The companion example implements a small counting allocator using global +`operator new`/`delete`. It is intentionally an instrumentation example, not an +arena implementation: + +```cpp +CountingAllocator storage; +{ + pjson direct(storage); + direct["kind"] = "direct root"; + + pjson::ParseError error; + pjson::unique_ptr parsed = + pjson::parse(R"({"kind":"parsed root","values":[1,2,3]})", + error, storage); + if (!parsed) + return 1; + + pjson copy(*parsed, storage); + if (direct.canSwap(copy)) + direct.swap(copy); +} +// All custom allocations have now been returned to storage. +``` + +## What you learned + +- Default values require no allocator setup and every DOM parse returns the + provenance-aware `pjson::unique_ptr`. +- Every value is allocator-bound; a supplied `Allocator` is borrowed and must + outlive the entire bound tree. +- Direct roots remain caller-owned, while allocator-parsed roots use + `pjson::unique_ptr` and `ValueDeleter`. +- Copies are deep, assignments preserve the destination allocator, and moves may + allocate across allocator domains. +- `canSwap()` distinguishes the O(1) same-allocator path from a cross-allocator + no-op. +- The hook covers persistent DOM nodes and wrapper objects, not every allocation + made by their standard-library internals or temporary algorithms. + +Return to the [tutorial index](README.md), or consult the +[browsable API reference](https://pico-developer.github.io/pjson/). diff --git a/docs/CMakeLists.txt b/docs/CMakeLists.txt new file mode 100644 index 0000000..f343af1 --- /dev/null +++ b/docs/CMakeLists.txt @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: Apache-2.0 + +find_package(Doxygen REQUIRED) +find_package(Python3 REQUIRED COMPONENTS Interpreter) + +# ---- Documentation inputs ---------------------------------------------- + +# Read the public version macro so generated pages always match the library. +set(PJSON_PUBLIC_HEADER "${PROJECT_SOURCE_DIR}/pjsonlib/include/pjson.h") +file(STRINGS "${PJSON_PUBLIC_HEADER}" PJSON_VERSION_DEFINE + REGEX "^#define PJSON_VERSION \"[^\"]+\"$") +string(REGEX REPLACE "^#define PJSON_VERSION \"([^\"]+)\"$" "\\1" + PJSON_DOCS_VERSION "${PJSON_VERSION_DEFINE}") + +set(PJSON_DOCS_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/reference") +set(PJSON_DOXYGEN_FILTER "${CMAKE_CURRENT_SOURCE_DIR}/scripts/doxygen-filter.py") +set(PJSON_DOCS_VALIDATOR "${CMAKE_CURRENT_SOURCE_DIR}/scripts/validate-reference.py") +set(PJSON_DOCS_STYLESHEET "${CMAKE_CURRENT_SOURCE_DIR}/reference/pjson.css") +set(PJSON_DOCS_MAINPAGE "${CMAKE_CURRENT_SOURCE_DIR}/reference/mainpage.md") +set(PJSON_DOCS_API_NOTES "${CMAKE_CURRENT_SOURCE_DIR}/reference/pjson-api.dox") +set(PJSON_DOCS_INDEX "${CMAKE_CURRENT_SOURCE_DIR}/README.md") +set(PJSON_DOCS_ALLOCATORS "${CMAKE_CURRENT_SOURCE_DIR}/12-custom-allocators.md") +set(PJSON_DOCS_MIGRATIONS + "${CMAKE_CURRENT_SOURCE_DIR}/migration-from-nlohmann-json.md" + "${CMAKE_CURRENT_SOURCE_DIR}/migration-from-rapidjson.md") + +configure_file(Doxyfile.in "${CMAKE_CURRENT_BINARY_DIR}/Doxyfile" @ONLY) + +# ---- Shared generation pipeline ---------------------------------------- + +# Both targets clean the output before running Doxygen, then validate the +# generated XML and HTML against the expected public API surface. +set(PJSON_DOCS_COMMANDS + COMMAND "${CMAKE_COMMAND}" -E rm -rf "${PJSON_DOCS_OUTPUT_DIR}" + COMMAND "${DOXYGEN_EXECUTABLE}" "${CMAKE_CURRENT_BINARY_DIR}/Doxyfile" + COMMAND "${Python3_EXECUTABLE}" "${PJSON_DOCS_VALIDATOR}" + --xml "${PJSON_DOCS_OUTPUT_DIR}/xml" --html "${PJSON_DOCS_OUTPUT_DIR}/html") +set(PJSON_DOCS_DEPENDS + "${PJSON_PUBLIC_HEADER}" + "${CMAKE_CURRENT_BINARY_DIR}/Doxyfile" + "${PJSON_DOXYGEN_FILTER}" + "${PJSON_DOCS_VALIDATOR}" + "${PJSON_DOCS_STYLESHEET}" + "${PJSON_DOCS_MAINPAGE}" + "${PJSON_DOCS_API_NOTES}" + "${PJSON_DOCS_INDEX}" + "${PJSON_DOCS_ALLOCATORS}" + ${PJSON_DOCS_MIGRATIONS}) + +# ---- Incremental and always-run targets -------------------------------- + +# The regular target may use its timestamp stamp for quick incremental builds. +# The check target always runs, making it appropriate for CI and pre-submit use. +add_custom_command( + OUTPUT "${PJSON_DOCS_OUTPUT_DIR}/.validated" + ${PJSON_DOCS_COMMANDS} + COMMAND "${CMAKE_COMMAND}" -E touch "${PJSON_DOCS_OUTPUT_DIR}/.validated" + DEPENDS ${PJSON_DOCS_DEPENDS} + COMMENT "Generating and validating the pjson API reference" + VERBATIM) +add_custom_target(pjson-docs ALL DEPENDS "${PJSON_DOCS_OUTPUT_DIR}/.validated") + +add_custom_target( + pjson-docs-check + ${PJSON_DOCS_COMMANDS} + DEPENDS ${PJSON_DOCS_DEPENDS} + COMMENT "Rebuilding and validating the pjson API reference" + VERBATIM) diff --git a/docs/Doxyfile.in b/docs/Doxyfile.in new file mode 100644 index 0000000..dfa86ca --- /dev/null +++ b/docs/Doxyfile.in @@ -0,0 +1,78 @@ +# Doxygen configuration for pjson's public API reference. + +PROJECT_NAME = "pjson" +PROJECT_NUMBER = "@PJSON_DOCS_VERSION@" +PROJECT_BRIEF = "A small, owning JSON value for C++11" +OUTPUT_DIRECTORY = "@PJSON_DOCS_OUTPUT_DIR@" +CREATE_SUBDIRS = NO +ALLOW_UNICODE_NAMES = YES +OUTPUT_LANGUAGE = English + +BRIEF_MEMBER_DESC = YES +REPEAT_BRIEF = YES +ALWAYS_DETAILED_SEC = YES +FULL_PATH_NAMES = YES +STRIP_FROM_PATH = "@PROJECT_SOURCE_DIR@" +SHORT_NAMES = NO +JAVADOC_AUTOBRIEF = YES +QT_AUTOBRIEF = YES +MULTILINE_CPP_IS_BRIEF = YES +MARKDOWN_SUPPORT = YES +AUTOLINK_SUPPORT = YES +BUILTIN_STL_SUPPORT = YES + +# The checked-in header uses ordinary // API comments. The input filter turns +# those into documentation comments without changing the distributable header. +# The filter documents each public declaration, so EXTRACT_ALL can remain off +# and Doxygen's own undocumented-symbol checks stay meaningful. The XML +# validator additionally verifies the expected public surface and descriptions. +EXTRACT_ALL = NO +EXTRACT_PRIVATE = NO +EXTRACT_PACKAGE = NO +EXTRACT_STATIC = YES +EXTRACT_LOCAL_CLASSES = NO +HIDE_UNDOC_MEMBERS = NO +HIDE_UNDOC_CLASSES = NO +HIDE_FRIEND_COMPOUNDS = YES + +WARNINGS = YES +WARN_IF_UNDOCUMENTED = YES +WARN_IF_UNDOC_ENUM_VAL = YES +WARN_IF_DOC_ERROR = YES +WARN_IF_INCOMPLETE_DOC = NO +WARN_NO_PARAMDOC = NO +WARN_AS_ERROR = YES + +INPUT = "@PJSON_PUBLIC_HEADER@" \ + "@PJSON_DOCS_MAINPAGE@" \ + "@PJSON_DOCS_API_NOTES@" \ + "@PJSON_DOCS_ALLOCATORS@" \ + "@CMAKE_CURRENT_SOURCE_DIR@/migration-from-nlohmann-json.md" \ + "@CMAKE_CURRENT_SOURCE_DIR@/migration-from-rapidjson.md" +INPUT_ENCODING = UTF-8 +FILE_PATTERNS = *.h *.md *.dox +RECURSIVE = NO +FILTER_PATTERNS = *.h="\"@Python3_EXECUTABLE@\" \"@PJSON_DOXYGEN_FILTER@\"" +USE_MDFILE_AS_MAINPAGE = "@PJSON_DOCS_MAINPAGE@" + +SOURCE_BROWSER = YES +INLINE_SOURCES = NO +STRIP_CODE_COMMENTS = NO +REFERENCED_BY_RELATION = YES +REFERENCES_RELATION = YES + +GENERATE_HTML = YES +HTML_OUTPUT = html +HTML_DYNAMIC_MENUS = YES +GENERATE_TREEVIEW = YES +DISABLE_INDEX = NO +SEARCHENGINE = YES +SERVER_BASED_SEARCH = NO +HTML_EXTRA_STYLESHEET = "@PJSON_DOCS_STYLESHEET@" + +GENERATE_XML = YES +XML_OUTPUT = xml +XML_PROGRAMLISTING = NO +GENERATE_LATEX = NO +HAVE_DOT = NO +QUIET = NO diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..52c1a8f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,112 @@ +# pjson Tutorials + +Welcome! **pjson** ("Praveen's JSON") is an ultra-simple JSON library for C++. +This tutorial series takes you from *never having used JSON* all the way to +building a complete, schema-validated application — one small step at a time. + +You do **not** need any prior JSON experience. We explain every concept as it +comes up. + +## How to read this + +Work through the chapters in order. Each one: + +- introduces one idea, +- uses focused excerpts and, where provided, links an explicitly maintained + program under [`examples/src/`](../examples/src), +- and ends with what you just learned. + +```mermaid +flowchart LR + A[00 What is JSON?] --> B[01 Getting started] + B --> C[02 Creating JSON] + C --> D[03 Parsing & reading] + D --> E[04 Editing] + E --> F[05 Parsing & errors] + F --> G[06 Schema validation] + G --> H[07 Capstone: address book] + H --> I[08 Building & installing] + I --> J[09 Testing] + J --> K[10 Contributing] + K --> L[11 Streaming large files] + L --> M[12 Custom allocators] +``` + +## Chapters + +| # | Chapter | You will learn | +|---|---------|----------------| +| 00 | [What is JSON?](00-what-is-json.md) | The data model, from zero | +| 01 | [Getting started](01-getting-started.md) | Include pjson and print your first value | +| 02 | [Creating JSON](02-creating-json.md) | Build objects, arrays, and nested data | +| 03 | [Parsing & reading](03-parsing-and-reading.md) | Turn text into data and read it back safely | +| 04 | [Editing](04-editing.md) | Change, add, and remove parts of a document | +| 05 | [Parsing, limits & errors](05-parsing-and-errors.md) | Control budgets, duplicates, and diagnostics | +| 06 | [Schema validation](06-schema-validation.md) | Check that data has the shape you expect | +| 07 | [Capstone: address book](07-capstone-address-book.md) | Put it all together | +| 08 | [Building & installing](08-building-and-installing.md) | Canonical sources, CMake, packages, `build.sh` | +| 09 | [Testing](09-testing.md) | Run and understand the test suite | +| 10 | [Contributing](10-contributing.md) | Style, sanitizers, and sending changes | +| 11 | [Streaming large JSON](11-streaming.md) | Process huge inputs and write incrementally | +| 12 | [Custom allocators](12-custom-allocators.md) | Control persistent DOM allocation and ownership | + +## Reference and migration + +- [Browsable API reference](https://pico-developer.github.io/pjson/) — generated + per-symbol documentation (its [source page](reference/mainpage.md) is kept in + this repository). +- [Migrating from nlohmann/json](migration-from-nlohmann-json.md) — translate + DOM ownership, lookup, parsing, serialization, streaming, and validation. +- [Migrating from RapidJSON](migration-from-rapidjson.md) — replace + allocator-bound DOM and Reader/Writer idioms with pjson equivalents. + +Build and validate the browsable reference with Doxygen and Python 3: + +```sh +cmake -S . -B out/build-docs \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF \ + -DPJSON_BUILD_DOCS=ON +cmake --build out/build-docs --target pjson-docs-check +``` + +Then open `out/build-docs/docs/reference/html/index.html`. Documentation +warnings and missing public API families fail the build. + +## Contributing and project policies + +- [Contributor guide](../CONTRIBUTING.md) and + [detailed workflow](10-contributing.md) +- [Security policy and private vulnerability reporting](../SECURITY.md) +- [Versioning and compatibility policy](../VERSIONING.md) +- [Release process](../RELEASING.md) and [changelog](../CHANGELOG.md) +- [Licensing and SPDX policy](../LICENSING.md) + +## The one-minute taste + +```cpp +#include "pjson.h" +#include +#include +using namespace ByteDance; + +int main() { + pjson person; + person["name"] = "Ada"; + person["age"] = int64_t(36); + person["languages"] += "C++"; + person["languages"] += "Ada"; + + pjson::SerializeOptions pretty = pjson::SerializeOptions::prettyPrinted(); + std::cout << person.toString(pretty) << "\n"; + + pjson::unique_ptr parsed = pjson::parse(person.toString(pretty)); + std::string name; + if (parsed && parsed->tryGet("name", name)) + std::cout << name << "\n"; // Ada +} +``` + +Ready? Start with [Chapter 00 — What is JSON?](00-what-is-json.md), or jump to +the canonical runnable [Hello, World tutorial](01-getting-started.md). diff --git a/docs/migration-from-nlohmann-json.md b/docs/migration-from-nlohmann-json.md new file mode 100644 index 0000000..63ab270 --- /dev/null +++ b/docs/migration-from-nlohmann-json.md @@ -0,0 +1,333 @@ +# Migrating from nlohmann/json {#migration-nlohmann-json} + +This guide maps common `nlohmann::json` idioms to the final +`ByteDance::pjson` API. Both libraries provide a mutable JSON DOM, but pjson +uses explicit ownership, exact typed reads, builder-only subscripting, and +status-based parse and patch errors. A mechanical type rename is therefore not +a safe migration strategy. + +The examples assume: + +```cpp +// Before +#include +using json = nlohmann::json; + +// After +#include "pjson.h" +using ByteDance::pjson; +``` + +pjson requires C++11 or newer. It is a compiled library: link `pjson::pjson` +or compile `pjsonlib/src/pjson.cpp` with the application in addition to +including `pjson.h`. Those two files are the canonical API and behavior +sources; generated documentation and examples are explanatory. + +## API mapping at a glance + +| nlohmann/json | pjson | Important difference | +|---|---|---| +| `json j;` | `pjson j;` | Both start as JSON `null`. | +| `json::object()` / `json::array()` | `j.resetTo(pjson::jsonObject)` / `j.resetTo(pjson::jsonArray)` | pjson has no object/array factory. | +| `json::parse(text)` | `pjson::parse(text)` | Returns `pjson::unique_ptr`; failure is an empty pointer. | +| `json::parse(text, nullptr, false)` | `pjson::parse(text, error)` | Inspect the pointer and optional `ParseError`; there is no discarded value. | +| `input >> j` or `json::parse(input)` | `pjson::parseStream(input)` | Builds a DOM and buffers the complete input. | +| `j.dump()` | `j.toString()` | Compact output. | +| `j.dump(indent, ch, ensure_ascii)` | `j.toString(options)` | Configure a `SerializeOptions` value explicitly. | +| `out << j` | `j.write(out[, options])` | Returns `void`; inspect stream state. | +| `j.is_null()`, `is_string()`, ... | `j.isNull()`, `isString()`, ... | pjson distinguishes signed `int64_t` and `double`; there is no unsigned kind. | +| `j.get()` | `j.tryGet(out)` | Exact-type extraction writes an out-parameter and returns `false` on mismatch. | +| `j.get_ref()` | `j.tryGet(pjson::StringView&)` | The view is borrowed and mutation-sensitive. | +| `j.contains(key)` | `j.hasKey(key)` | Non-mutating; false on a non-object. | +| `j.find(key)` | `j.find(key)` | pjson returns a borrowed pointer or `nullptr`, not an iterator. | +| `j.value(key, fallback)` | `tryGet`, then choose the fallback | The fallback remains application logic. | +| `j[key] = value` | `j[key] = value` | pjson `operator[]` is a builder and may replace the receiver's type. | +| `j.push_back(value)` | `j[static_cast(j.size())] = value` | Indexed builder access grows an array. | +| `j.erase(key/index)` | `j.erase(key/index)` | Returns `bool`; an array index is `size_t`. | +| range iteration | `size()` + `find(index)`, or `keys()` + `find(key)` | No public raw-container access. | +| `json::sax_parse(...)` | `pjson::parseSax(...)` / `parseSaxStream(...)` | `parseSaxStream()` is the incremental stream path. | +| `j = j.patch(patch)` | `j.applyPatch(patch[, error][, options])` | Mutates atomically; `PatchOptions` bounds amplification. | +| `j.merge_patch(patch)` | `j.applyMergePatch(patch[, error][, options])` | Atomic RFC 7396 with the same limits. | +| external JSON Schema library | `value.validate(schema[, errors][, options])` | Implements only the documented subset. | + +## Parsing and ownership + +### Every DOM parse returns `pjson::unique_ptr` + +All DOM parse and stream-parse overloads return `pjson::unique_ptr`, including +those using the default allocator. An empty pointer means failure. The custom +deleter destroys the complete tree through the allocator recorded by its root. +Do not call `delete` on the pointer or convert it to a differently-deletered +smart pointer. + +```cpp +pjson::ParseError error; +pjson::unique_ptr document = pjson::parse(text, error); +if (!document) { + report(error.message, error.offset, error.line, error.column); + return; +} +consume(*document); +``` + +The `(const char*, size_t)` overload parses exactly the supplied byte span, +including embedded NUL bytes. `parseStream()` buffers one complete document. +Pass `pjson&` or `const pjson&` when code only borrows the parsed document, and +move the `pjson::unique_ptr` to transfer root ownership. + +Allocator-aware overloads take a borrowed `pjson::Allocator&`. That allocator +must outlive the returned root and every descendant. A directly constructed +root remains caller-owned; a parser-created root is owned by +`pjson::unique_ptr`. SAX parsing builds no persistent DOM and has no allocator +overload. + +### `ParseError` is reset on every reporting call + +`ParseError::offset` is a zero-based byte offset. `line` and `column` are +one-based, and `column` counts bytes. Every parse overload that accepts a +`ParseError&` resets all fields before doing work. Success leaves: + +```text +ok == true, offset == 0, line == 1, column == 1, message.empty() +``` + +Failure sets `ok == false` and describes the first error. It is safe to reuse +one error object across calls; never infer failure from an old message. Test +the returned pointer first, or `ok` for SAX parsing. + +### Parsing always enforces RFC 8259 + +There is no permissive parsing mode. DOM and SAX parsing reject unknown escapes, +unpaired UTF-16 surrogates, upper- or mixed-case keywords, raw control +characters in strings, malformed UTF-8, invalid number grammar, comments, +trailing commas, `NaN`, `Infinity`, and trailing non-whitespace content. + +`ParseOptions` contains resource budgets and duplicate-key policy only: + +```cpp +pjson::ParseOptions options; +options.maxDepth = 512; +options.maxNodes = 1000000; +options.maxInputBytes = size_t(64) * 1024 * 1024; +options.duplicateKeys = pjson::ParseOptions::RejectDuplicateKeys; +``` + +`maxNodes == 0` and `maxInputBytes == 0` mean unlimited. A non-positive +`maxDepth` selects an effective one-level limit, not unlimited parsing. The +duplicate policies are: + +| Policy | DOM behavior | SAX behavior | +|---|---|---| +| `RejectDuplicateKeys` | Fail at the second key. | Fail at the second key. | +| `KeepFirstDuplicate` | Keep the first value. | Suppress later duplicate value-subtree events. | +| `KeepLastDuplicate` | Replace the earlier value. | Emit each occurrence because prior events cannot be retracted. | + +To preserve nlohmann/json's usual keep-last behavior without weakening RFC +8259 validation: + +```cpp +pjson::ParseOptions options; +options.duplicateKeys = pjson::ParseOptions::KeepLastDuplicate; +auto document = pjson::parse(text, options); +``` + +## Reading without mutation + +### Use `find`, `tryGet`, `size`, and `keys` + +`operator[]` is only a mutable builder API. A string subscript changes a +non-object into an object and creates a missing null member. An integer +subscript changes a non-array into an array and grows it with null elements. +Do not translate checked or observational nlohmann access into pjson +subscripting. +One indexed access that would create more than 1,000,000 children throws +`std::length_error` before mutation. + +Use `find(key)` and `find(index)` for borrowed node access. Both return +`nullptr` for the wrong container type or a missing child and never mutate the +document. Negative indexes count from the end. + +```cpp +const pjson& root = *document; + +std::string name; +if (root.tryGet("name", name)) + use(name); + +if (const pjson* items = root.find("items")) { + for (size_t i = 0; i < items->size(); ++i) { + if (const pjson* item = items->find(static_cast(i))) + consume(*item); + } +} + +for (const std::string& key : root.keys()) { + if (const pjson* value = root.find(key)) + consumeMember(key, *value); +} +``` + +`tryGet` supports `int64_t`, `double`, `bool`, `std::string`, and +`pjson::StringView`, both on a node and through key/index child overloads. It +returns `false` on absence or type mismatch and leaves the output unchanged. +An integer may widen to `double`; no other coercion occurs. A `StringView` +borrows bytes and is invalidated when its node or an ancestor is modified or +destroyed. + +### Numbers are signed `int64_t` or `double` + +pjson has no unsigned numeric representation and no convenience `int` or +`float` API. Use `int64_t` and `double` explicitly in assignments, appends, +vectors, SAX callbacks, and `tryGet` calls: + +```cpp +root["count"] = int64_t(42); +root["ratio"] = double(0.5); + +int64_t count = 0; +double ratio = 0.0; +if (!root.tryGet("count", count) || !root.tryGet("ratio", ratio)) + reportTypeError(); +``` + +Before narrowing an unsigned source, perform an application-level range check. +An integer read as `double` may lose precision beyond `2^53`. + +### Building and editing + +Use `operator[]` to build or deliberately mutate paths and scalar assignment +for `std::string`, C strings, `bool`, `int64_t`, and `double`. Exact vector +overloads exist for `std::string`, `bool`, `int64_t`, and `double`; there are no +convenience vectors of `int`, `float`, or C strings. Build other arrays with +indexed assignment. + +```cpp +pjson value; +value["name"] = "Ada"; +value["age"] = int64_t(36); +value["scores"][0] = int64_t(90); +value["scores"][1] = int64_t(82); +value.erase("obsolete"); +``` + +## JSON Pointer and patch operations + +`findPointer()` performs non-vivifying RFC 6901 lookup. The empty pointer +addresses the root. Non-empty pointers begin with `/`; array indices are +canonical unsigned decimal tokens, and `-` is reserved for JSON Patch add. Use +`escapePointerToken()` when constructing paths from object keys. + +`applyPatch()` supports RFC 6902 `add`, `remove`, `replace`, `move`, `copy`, and +`test`. It applies the complete operation array to a scratch document and +commits only on success. The reporting overload fills `PatchError`. A `remove` +operation whose path is the empty string succeeds and resets the target to JSON +null. This is the pjson representation of removing the document root. + +`applyMergePatch()` provides the same atomic status-based model for RFC 7396. +An object patch recursively merges, null members remove object members, and a +non-object patch replaces the complete target. Both patch APIs are `noexcept`. + +Both APIs accept a trailing `PatchOptions`. Defaults allow 10,000 operations, +1,000,000 cloned nodes, 64 MiB of cloned node/string/key bytes, and 1,000,000 +work units through `maxOperations`, `maxClonedNodes`, `maxClonedBytes`, and +`maxWork`. Zero retains the corresponding hard ceiling. A budget failure returns +`false`, reports `PatchError::ResourceLimit`, and preserves the target. +`maxOperations` counts RFC 6902 array entries or Merge Patch members. +Moving the document root beneath itself reports +`PatchError::MoveRootNotAllowed`; root removal remains valid and produces null. + +## Serialization + +Use `SerializeOptions` for every non-default serialization choice; the legacy +boolean pretty-print overloads are not part of the final API. + +```cpp +pjson::SerializeOptions options = pjson::SerializeOptions::prettyPrinted(); +options.indentWidth = 4; +options.indentCharacter = ' '; +options.escapeNonAscii = true; +options.keyOrder = pjson::SerializeOptions::AscendingKeys; +options.maxOutputBytes = size_t(64) * 1024 * 1024; + +std::string text = document->toString(options); +document->write(output, options); +``` + +Default construction selects compact output, two-space indentation, a space +indent character, UTF-8 output, ascending keys, and a 64 MiB output limit. Set +`maxOutputBytes = 0` only when explicitly requesting unlimited output. Objects +are inherently map-ordered; insertion order is unavailable. Non-finite stored +doubles serialize as JSON `null`. Finite doubles use locale-independent, stable +round-trip formatting with 15–17 significant digits; shortest spelling is not +part of the contract. + +Every stored string value and object key must contain valid UTF-8. Invalid +stored UTF-8 is a serialization failure even when `escapeNonAscii` is false: +`toString()` throws `std::invalid_argument`. Output-budget or indentation/size +overflow throws `std::length_error`. `write()` detects all three logical +failures before emission and sets `failbit`; only a physical stream failure can +leave a partial prefix or propagate an enabled stream exception. Check stream +state after `write()`. + +## DOM parsing versus SAX streaming + +`parse()` and `parseStream()` build an owning DOM. `parseStream()` bounds input +with `maxInputBytes` but buffers the document. `parseSaxStream()` reads +incrementally and retains no DOM. SAX callbacks receive borrowed string/key +references valid only for the duration of the callback. Returning `false` from +a callback cancels parsing; the public call then returns `false` and populates +`ParseError` when supplied. + +## JSON Schema validation is a subset + +pjson validates a value directly against another `pjson` value. It does not +compile a schema or validate against a meta-schema. The collecting overload +appends `SchemaError` entries; clear a reused vector first. Error paths are RFC +6901 pointers, with the empty string denoting the root. + +The documented pjson subset is the complete enforced vocabulary; it is not a +complete JSON Schema draft implementation: + +| Area | Supported keywords and forms | +|---|---| +| General | `type` (string or array), `enum`, `const`, local-fragment `$ref` | +| Objects | `properties`, `patternProperties`, `propertyNames`, `required`, `dependentRequired`, `dependencies`, `additionalProperties`, `minProperties`, `maxProperties` | +| Arrays | single-schema or tuple-array `items`, `minItems`, `maxItems`, `uniqueItems` | +| Numbers | `minimum`, `maximum`, numeric `exclusiveMinimum`, numeric `exclusiveMaximum`, `multipleOf` | +| Strings | `minLength`, `maxLength`, `pattern`, and supported `format` values | +| Composition | `allOf`, `anyOf`, `oneOf`, `not` | +| Schema values | Boolean schemas | + +Unknown or unsupported schema keywords are ignored and therefore impose no +constraint. This is a compatibility hazard: a typo or unsupported security +rule can make validation less restrictive without producing an error. Audit +schemas against the table above and retain an external validator when another +vocabulary is required. Remote references are unsupported; `$ref` resolves +only local URI-fragment JSON Pointers. + +`minLength` and `maxLength` count Unicode code points, not UTF-8 bytes. +`pattern` uses ECMAScript regular-expression syntax with search semantics. The +default policy caps pattern and subject sizes and rejects expressions outside a +conservative safe subset. `SchemaOptions::trustedRegex()` removes only those +regex restrictions and should be used only when both schema and instance are +trusted. Validation depth, reference, work, and error-count budgets remain in +effect. Known format checks run by default; unknown format names are ignored. + +## Suggested migration sequence + +1. Replace parse results with `pjson::unique_ptr` and check every result before + dereferencing it. +2. Replace exception-based parse handling with `ParseError`, remembering that + reporting calls reset it on entry. +3. Remove permissive parser flags; pjson always enforces RFC 8259 syntax. +4. Choose a duplicate-key policy and explicit resource budgets. +5. Replace observational subscripting and checked-access calls with `find` or + `tryGet`; reserve `operator[]` for building. +6. Replace raw-container iteration with `size()` plus `find(index)`, or + `keys()` plus `find(key)`. +7. Normalize numeric code to `int64_t` and `double`, with explicit range checks + at unsigned boundaries. +8. Replace dump flags and pretty booleans with `SerializeOptions`, and handle + invalid-UTF-8 serialization failure. +9. Audit every schema keyword against pjson's documented subset and test both + accepted and rejected instances. diff --git a/docs/migration-from-rapidjson.md b/docs/migration-from-rapidjson.md new file mode 100644 index 0000000..b40f1f1 --- /dev/null +++ b/docs/migration-from-rapidjson.md @@ -0,0 +1,318 @@ +# Migrating from RapidJSON {#migration-rapidjson} + +This guide maps RapidJSON DOM, Reader/Writer, Pointer, and Schema idioms to the +final `ByteDance::pjson` API. pjson favors a compact owning DOM, RFC 8259 +parsing, explicit result ownership, status-based errors, and a deliberately +limited schema vocabulary. + +```cpp +#include "pjson.h" +using ByteDance::pjson; +``` + +pjson requires C++11 or newer and is a compiled library. Link `pjson::pjson` or +compile `pjsonlib/src/pjson.cpp` with the application. +`pjsonlib/include/pjson.h` and `pjsonlib/src/pjson.cpp` are the canonical API +and behavior sources; this guide describes how to adapt RapidJSON code to them. + +## Migration map + +| RapidJSON | pjson | Important difference | +|---|---|---| +| `Document` / `Value` | `pjson` | One owning mutable value type. | +| `SetObject()` / `SetArray()` | `resetTo(jsonObject)` / `resetTo(jsonArray)` | Explicit empty-container reset. | +| `SetInt64()` / `SetDouble()` | assignment from `int64_t` / `double` | Only signed 64-bit integer and double numeric APIs. | +| `IsInt64()` / `IsDouble()` | `isInt()` / `isDouble()` | Storage-kind checks. | +| `GetInt64()` / `GetDouble()` | `tryGet(out)` | Exact-type, status-returning extraction. | +| `GetString()` + `GetStringLength()` | `tryGet(std::string&)` or `tryGet(StringView&)` | `StringView` is borrowed. | +| `HasMember()` / `FindMember()` | `hasKey()` / `find(key)` | Returns a borrowed child pointer or `nullptr`. | +| `operator[]` for lookup | `find` / `tryGet` | pjson subscripting is builder-only and may mutate. | +| member iteration | `keys()` + `find(key)` | No public raw object container. | +| array iteration | `size()` + `find(index)` | No public raw array container. | +| `Document::Parse(...)` | `pjson::parse(...)` | Every DOM overload returns `pjson::unique_ptr`. | +| `Reader` + handler | `parseSax(...)` / `parseSaxStream(...)` | SAX callbacks return `bool` to continue. | +| `Writer` / `PrettyWriter` | `write(out[, options])` | Configure `SerializeOptions`; inspect stream state. | +| `StringBuffer` + Writer | `toString([options])` | Returns the serialized string. | +| `Pointer::Get` | `findPointer(...)` | Non-vivifying RFC 6901 lookup. | +| Pointer mutation | normal building or `applyPatch(...[, options])` | RFC 6902 patching is atomic and bounded. | +| Merge Patch helper code | `applyMergePatch(...[, options])` | Atomic RFC 7396 with the same limits. | +| `SchemaDocument` + `SchemaValidator` | `value.validate(schema, ...)` | No compiled schema; only the documented subset is enforced. | + +## Values, ownership, and allocators + +### Building values + +`operator[]` is the pjson builder API. String access promotes the receiver to +an object and creates a missing null child. Integer access promotes it to an +array and grows it with null children. Use explicit final scalar types: +One indexed access that would create more than 1,000,000 children throws +`std::length_error` before mutation. + +```cpp +pjson document; +document["name"] = "Ada"; +document["age"] = int64_t(36); +document["ratio"] = double(0.5); +document["roles"][0] = "admin"; +``` + +Supported scalar mutation types are C strings, `std::string`, `bool`, +`int64_t`, and `double`. Exact vector overloads exist for `std::string`, `bool`, +`int64_t`, and `double`; there are no convenience `int`, `float`, or vectors of +`int`, `float`, or C strings. Convert other values explicitly and build other +arrays with indexed assignment: + +```cpp +array[static_cast(array.size())] = child; +``` + +### Parsed roots always use `pjson::unique_ptr` + +Every DOM `parse` and `parseStream` overload returns `pjson::unique_ptr`, for +both default and custom allocation. Failure produces an empty pointer. The +custom deleter follows allocator provenance stored in the root, so do not call +`delete` or substitute another smart-pointer deleter. + +```cpp +pjson::ParseError error; +pjson::unique_ptr document = pjson::parse(jsonBytes, byteCount, error); +if (!document) { + std::cerr << error.line << ':' << error.column + << ": " << error.message << '\n'; + return; +} +``` + +An allocator passed to a constructor or parse overload is borrowed and must +outlive the complete tree. Persistent nodes and wrapper objects use it; backing +storage inside standard containers and parser scratch space use their normal +standard allocators. Copying a `pjson` is deep. Assignment preserves the +destination allocator; a cross-allocator move may allocate. `swap()` is O(1) +only when `canSwap()` is true. + +### Parse diagnostics have a reusable lifecycle + +Reporting parse and SAX overloads reset `ParseError` on entry. Success leaves +`ok == true`, offset zero, line one, column one, and an empty message. Failure +sets `ok == false` and reports the first problem. Offset is a zero-based byte +position; line and byte-column are one-based. A reused error never intentionally +retains diagnostics from the previous call. + +## Parsing always enforces RFC 8259 + +pjson has no permissive parse mode and no RapidJSON-style syntax feature flags. +All DOM and SAX entry points reject malformed UTF-8, invalid escapes, lone +surrogates, raw string controls, non-lowercase literals, comments, trailing +commas, invalid numbers, `NaN`, `Infinity`, and trailing non-whitespace data. + +`ParseOptions` controls only work budgets and duplicate names: + +```cpp +pjson::ParseOptions options; +options.maxDepth = 512; +options.maxNodes = 1000000; +options.maxInputBytes = size_t(64) * 1024 * 1024; +options.duplicateKeys = pjson::ParseOptions::RejectDuplicateKeys; + +pjson::unique_ptr document = pjson::parse(json, error, options); +``` + +Zero means unlimited for node and input-byte budgets. A non-positive depth +means an effective one-level limit. Duplicate policy can be +`RejectDuplicateKeys`, `KeepFirstDuplicate`, or `KeepLastDuplicate`. This choice +does not weaken RFC 8259 syntax or UTF-8 validation. For SAX, keep-first +suppresses later duplicate value-subtree events; keep-last emits each +occurrence because emitted callbacks cannot be retracted. + +`parseStream()` buffers the complete input before DOM parsing. Use +`parseSaxStream()` when input must be consumed incrementally. + +## Safe reads and iteration + +Do not translate RapidJSON lookup to pjson `operator[]`: it creates structure +and can replace the receiver's type. Use `find` for a borrowed node and +`tryGet` for a typed value: + +```cpp +const pjson& root = *document; + +int64_t count = 0; +if (root.tryGet("count", count)) + use(count); + +if (const pjson* settings = root.find("settings")) { + bool enabled = false; + if (settings->tryGet("enabled", enabled)) + configure(enabled); +} +``` + +`tryGet` supports `int64_t`, `double`, `bool`, `std::string`, and +`StringView`. It performs no coercion except integer-to-double widening, and it +leaves the destination unchanged on failure. `StringView` is valid only while +the owning node remains alive and unchanged. + +Iterate without exposing container internals: + +```cpp +for (size_t i = 0; i < array.size(); ++i) { + if (const pjson* value = array.find(static_cast(i))) + consume(*value); +} + +for (const std::string& key : object.keys()) { + if (const pjson* value = object.find(key)) + consumeMember(key, *value); +} +``` + +`find(index)` supports negative end-relative indexes, but normal forward loops +should convert their checked `size_t` position to `int`. `keys()` returns a +copy in deterministic map order. Child pointers are borrowed and can be +invalidated by mutation of the child or an ancestor. + +## Numeric migration + +pjson stores numbers as signed `int64_t` or `double`; it has no unsigned type. +Use `isInt()`/`isDouble()` to inspect storage and `tryGet` for extraction. +Reading an integer into `double` is allowed but may lose precision beyond +`2^53`; reading a double into `int64_t` is not an implicit `tryGet` conversion. + +Before migrating `SetUint64`, `GetUint64`, or `IsUint64` code, define an +application policy. Values above `INT64_MAX` cannot be represented exactly as +the integer kind. Reject them, store them as strings, or accept documented +double precision loss. Use explicit `int64_t` and `double` at all API +boundaries rather than relying on C++ overload selection. + +## JSON Pointer and patching + +`findPointer()` performs non-vivifying RFC 6901 lookup. The empty string +addresses the root. Other pointers start with `/`; array tokens are canonical +unsigned decimal indices, and `-` is not a lookup index. Use +`escapePointerToken()` for keys containing `~` or `/`. + +For general pointer mutation, apply an RFC 6902 patch: + +```cpp +auto patch = pjson::parse(R"([ + {"op":"replace", "path":"/address/city", "value":"Paris"}, + {"op":"add", "path":"/tags/-", "value":"new"} +])"); + +pjson::PatchError patchError; +pjson::PatchOptions patchOptions; +if (!patch || !document->applyPatch(*patch, patchError, patchOptions)) { + // The document is unchanged on failure. +} +``` + +`applyPatch()` supports `add`, `remove`, `replace`, `move`, `copy`, and `test`, +and commits atomically. Removing the empty path succeeds and resets the target +document to JSON null. `applyMergePatch()` implements atomic RFC 7396: object +patches merge recursively, null members remove object members, and non-object +patches replace the complete target. Both APIs are `noexcept` and offer a +reporting `PatchError` overload. + +Both APIs also accept a trailing `PatchOptions`. Defaults allow 10,000 +operations, 1,000,000 cloned nodes, 64 MiB of cloned node/string/key bytes, and +1,000,000 work units. Zero for `maxOperations`, `maxClonedNodes`, +`maxClonedBytes`, or `maxWork` retains the corresponding hard ceiling. A limit +failure reports `PatchError::ResourceLimit` and leaves the target unchanged. +`maxOperations` counts RFC 6902 array entries or Merge Patch members. +Moving the document root beneath itself reports +`PatchError::MoveRootNotAllowed`; root removal remains valid and produces null. + +## SAX input and serialized output + +Derive from `pjson::SaxHandler` and override the callbacks of interest. Integer +events use `int64_t`; floating events use `double`. Returning `false` cancels +the parse. Callback string and key references are borrowed only for the +callback duration. + +Use `SerializeOptions` instead of Writer flags, PrettyWriter setters, or a +boolean pretty argument: + +```cpp +pjson::SerializeOptions options = pjson::SerializeOptions::prettyPrinted(); +options.indentWidth = 2; +options.indentCharacter = ' '; +options.escapeNonAscii = true; +options.keyOrder = pjson::SerializeOptions::AscendingKeys; +options.maxOutputBytes = size_t(64) * 1024 * 1024; + +document->write(output, options); +if (!output) + reportWriteFailure(); + +std::string encoded = document->toString(options); +``` + +The defaults are compact layout, two-space indentation, space indentation, +UTF-8 output, ascending keys, and a 64 MiB output limit. Zero explicitly makes +`maxOutputBytes` unlimited. Object insertion order is not retained. Non-finite +stored doubles serialize as JSON null. Finite doubles use locale-independent, +stable round-trip formatting with 15–17 significant digits; shortest spelling +is not part of the contract. + +Invalid UTF-8 in any stored string or object key is a serialization failure, +regardless of `escapeNonAscii`: `toString()` throws `std::invalid_argument`. +Output-budget or indentation/size overflow throws `std::length_error`. `write()` +detects all three logical failures before emission and sets `failbit`; only a +physical stream failure can leave a partial prefix or propagate an enabled +stream exception. This matters for programmatically built values even though +the parser itself accepts only valid UTF-8. + +## Schema validation + +RapidJSON 1.1 validates compiled draft-04 schemas. pjson instead validates an +already-built value directly against another `pjson`, with no compiled-schema +object and no SAX validation. The error overload appends `SchemaError` values; +clear a reused vector first. Error paths are RFC 6901 pointers, with `""` +denoting the root. + +The documented pjson subset is the complete enforced vocabulary; it is not a +complete JSON Schema draft implementation: + +| Area | Supported keywords/forms | +|---|---| +| Any value | `type`, `enum`, `const` | +| References | local-fragment `$ref` into the root schema | +| Objects | `properties`, `patternProperties`, `propertyNames`, `required`, `dependentRequired`, `dependencies`, `additionalProperties`, `minProperties`, `maxProperties` | +| Arrays | schema or tuple-array `items`, `minItems`, `maxItems`, `uniqueItems` | +| Numbers | `minimum`, `maximum`, numeric `exclusiveMinimum`, numeric `exclusiveMaximum`, `multipleOf` | +| Strings | `minLength`, `maxLength`, `pattern`, supported `format` names | +| Composition | `allOf`, `anyOf`, `oneOf`, `not` | +| Schema values | Boolean schemas | + +Unknown or unsupported schema keywords are ignored and therefore are not +enforced. Treat this as a warning, not forward-compatible validation: typos and +unsupported security constraints can silently weaken a schema. Audit every +schema against this table and retain RapidJSON or another validator when the +application depends on any other vocabulary. Remote references are unsupported. + +`minLength` and `maxLength` count Unicode code points rather than UTF-8 bytes. +`pattern` uses ECMAScript syntax and search semantics, but the default policy is +intentionally narrower: pattern and subject sizes are capped and expressions +outside a conservative safe subset fail validation. +`SchemaOptions::trustedRegex()` removes only regex restrictions and is +appropriate only for trusted schemas and instances. Traversal, reference, work, +and collected-error budgets remain active. Known formats are checked by default; +unknown format names are ignored. + +## Practical migration sequence + +1. Change every DOM parse result to `pjson::unique_ptr` and check it before use. +2. Replace parse-error inspection and exceptions with `ParseError`; account for + its reset-on-entry lifecycle. +3. Remove permissive syntax flags and choose explicit budgets and duplicate-key + policy. +4. Replace lookup through `operator[]` with `find` or `tryGet`; keep subscripting + only for construction and intentional mutation. +5. Replace member/array container iteration with `keys()`/`find(key)` and + `size()`/`find(index)`. +6. Normalize numeric interfaces to `int64_t` and `double`, including SAX + callbacks and vectors. +7. Replace Writer and pretty-boolean configuration with `SerializeOptions`, and + handle invalid-UTF-8 output failure. +8. Verify every schema keyword is in pjson's documented subset and add + accepted/rejected tests for every relied-upon constraint. diff --git a/docs/reference/mainpage.md b/docs/reference/mainpage.md new file mode 100644 index 0000000..ff27790 --- /dev/null +++ b/docs/reference/mainpage.md @@ -0,0 +1,59 @@ +# pjson API Reference {#mainpage} + +pjson is an owning, mutable JSON value for C++11. The generated reference is +the symbol-by-symbol companion to the [tutorials](https://github.com/Pico-Developer/pjson/tree/main/docs). +It describes the public header shipped to applications; implementation-only +types are intentionally excluded. + +## Start here + +- @ref ByteDance::pjson is the central DOM value and entry point. +- @ref ByteDance::pjson::Allocator, @ref ByteDance::pjson::ValueDeleter, and + ByteDance::pjson::unique_ptr support allocator-bound persistent DOM storage. +- @ref ByteDance::pjson::ParseOptions configures duplicate keys and input + budgets; every parser enforces RFC 8259 syntax. +- @ref ByteDance::pjson::ParseError reports non-throwing parse failures. +- @ref ByteDance::pjson::PointerError and @ref ByteDance::pjson::PatchError + describe RFC 6901, RFC 6902, and RFC 7396 failures. +- @ref ByteDance::pjson::PatchOptions bounds transactional patch amplification. +- @ref ByteDance::pjson::SerializeOptions controls formatting, escaping, and + key order, and bounds output size. +- ByteDance::pjson::tryGet(), ByteDance::pjson::StringView, and + ByteDance::pjson::findPointer() provide strict, non-vivifying reads. +- ByteDance::pjson::applyPatch() and ByteDance::pjson::applyMergePatch() apply + atomic RFC 6902 and RFC 7396 updates. +- @ref ByteDance::pjson::SaxHandler supports incremental, non-DOM parsing. +- @ref ByteDance::pjson::SchemaOptions and + @ref ByteDance::pjson::SchemaError configure and report schema validation. + +Use the navigation tree to browse classes, nested option/error types, enums, +typedefs, and every public overload. Each entry is generated from the current +installed header, so the reference follows the API as it evolves. + +## Guides + +- @subpage custom-allocators +- @subpage migration-nlohmann-json +- @subpage migration-rapidjson + +The two migration guides call out behavioral differences that a mechanical API +rename would miss: ownership, vivifying access, signed numeric storage, +duplicate-key policy, allocator provenance and lifetime, streaming, schema +coverage, and pjson's status-based error model. + +## Build this reference locally + +With Doxygen and Python 3 installed: + +```sh +cmake -S . -B out/build-docs \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF \ + -DPJSON_BUILD_DOCS=ON +cmake --build out/build-docs --target pjson-docs-check +``` + +Open `out/build-docs/docs/reference/html/index.html`. The build treats Doxygen +warnings as errors and validates the generated XML so omitted public API +families fail locally and in CI. diff --git a/docs/reference/pjson-api.dox b/docs/reference/pjson-api.dox new file mode 100644 index 0000000..6de48fc --- /dev/null +++ b/docs/reference/pjson-api.dox @@ -0,0 +1,86 @@ +/** + * @namespace ByteDance + * @brief Namespace containing the pjson public API. + */ + +/** @def PJSON_VERSION_MAJOR + * @brief The major component of the compile-time library version. + */ +/** @def PJSON_VERSION_MINOR + * @brief The minor component of the compile-time library version. + */ +/** @def PJSON_VERSION_PATCH + * @brief The patch component of the compile-time library version. + */ +/** @def PJSON_VERSION + * @brief The complete library version as a string literal. + */ + +/** + * @class ByteDance::pjson + * @brief An owning, mutable value that represents every JSON type. + * + * A pjson owns its complete subtree. Copies are deep, and pointers returned by + * find() or findPointer(), along with string views returned by tryGet(), remain + * borrowed from their owning tree. In-memory parsing and validation report + * data-domain failures through return values and error objects; + * exception-enabled input streams can still propagate stream exceptions while + * parseStream() buffers input. + * + * Every value is bound to a runtime allocator. Default construction uses the + * built-in allocator; allocator-aware construction and parsing use a borrowed + * pjson::Allocator that must outlive the complete tree. Copy and move assignment + * preserve the destination allocator. Storage transfer and swap are O(1) only + * between values with the same allocator; use canSwap() when allocator + * provenance may differ. Every DOM parse() and parseStream() overload returns + * pjson::unique_ptr so pjson::ValueDeleter can release the root through its + * originating allocator. An empty pointer reports a parse failure. + * + * operator[] is the auto-vivifying builder API. For observation without + * mutation, use find(), findPointer(), hasKey(), hasIndex(), or tryGet(). + * tryGet() requires the requested stored type and leaves its output unchanged + * on failure; only an integer-to-double widening conversion is permitted. + * Containers expose query + * and child-lookup operations rather than their raw storage types. + * + * getType() distinguishes jsonNumberInt from jsonNumberDouble and reports + * objects as jsonObject. Numeric assignment and append overloads accept + * int64_t or double. Configure serialization through SerializeOptions; the + * compact toString() and write() overloads take no formatting boolean, and + * SerializeOptions::maxOutputBytes bounds generated output. PatchOptions + * bounds transactional JSON Patch and Merge Patch amplification. Invalid UTF-8 + * and logical output-size failures are detected before write() emits bytes. + * + * @see https://github.com/Pico-Developer/pjson/tree/main/docs Tutorials + * @see migration-nlohmann-json + * @see migration-rapidjson + */ + +/** + * @struct ByteDance::pjson::Allocator + * @brief Runtime allocation interface for persistent pjson DOM storage. + * + * The allocator is borrowed and must outlive every bound value. allocate() must + * return non-null storage honoring the requested size and alignment or throw; + * deallocate() receives matching metadata and must not throw. The interface + * covers pjson nodes and string/array/object wrapper objects, not their internal + * standard-library allocations or transient algorithm scratch space. + */ + +/** + * @struct ByteDance::pjson::ValueDeleter + * @brief Stateless deleter for owning roots returned by DOM parsing. + * + * The deleter obtains allocator provenance from the value. It does not own or + * extend the allocator's lifetime. + */ + +/** + * @struct ByteDance::pjson::PatchOptions + * @brief Bounds JSON Patch and Merge Patch transactional amplification. + * + * Defaults allow 10,000 operations, 1,000,000 cloned nodes, 64 MiB of cloned + * node/string/key bytes, and 1,000,000 work units. A zero field retains its + * built-in hard ceiling rather than disabling the limit. A limit failure is + * reported as PatchError::ResourceLimit and leaves the target unchanged. + */ diff --git a/docs/reference/pjson.css b/docs/reference/pjson.css new file mode 100644 index 0000000..b5b6926 --- /dev/null +++ b/docs/reference/pjson.css @@ -0,0 +1,13 @@ +:root { + --primary-color: #5a45c7; + --primary-dark-color: #4433a3; + --primary-light-color: #7463d8; +} + +.contents { + max-width: 76rem; +} + +code, .fragment { + font-variant-ligatures: none; +} diff --git a/docs/scripts/doxygen-filter.py b/docs/scripts/doxygen-filter.py new file mode 100644 index 0000000..51bfe0c --- /dev/null +++ b/docs/scripts/doxygen-filter.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Expose pjson's existing public-header comments to Doxygen. + +Doxygen intentionally sees this filtered stream only; the installed, compact +public header remains unchanged. Existing descriptive ``//`` comments become +Doxygen comments and otherwise-undocumented declarations receive a short link +to the API guide so coverage validation can detect genuinely missing symbols. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +# Patterns identify declarations and enum forms without parsing all of C++. +# The filter deliberately limits its transformations to the public pjson class. +SEPARATOR = re.compile(r"^\s*//(?:[=-]{2,}|\s*#include|\s*typedef)") +DECLARATION_END = re.compile(r";\s*(?://.*)?$") +ENUMERATOR = re.compile(r"^(?P\s*)(?P[A-Za-z_]\w*)\s*(?:=[^,]+)?(?P,?)\s*$") +INLINE_ENUM = re.compile( + r"^(?P\s*enum(?:\s+class)?\s+\w+[^{}]*\{)" + r"(?P[^{}]+)(?P\};.*)$" +) + + +def escape_commands(text: str) -> str: + """Keep example JSON escape sequences from becoming Doxygen commands.""" + return text.replace(r"\q", r"\\q").replace(r"\u", r"\\u") + + +def declaration_label(line: str) -> str: + """Extract a stable display label from a one-line public declaration.""" + operator = re.search(r"operator\s*(\[\]|\+=|==|!=|=)\s*\(", line) + if operator: + return "operator" + operator.group(1) + names = re.findall(r"(~?[A-Za-z_]\w*)\s*\(", line) + if names: + return names[-1] + typedef = re.search(r"\b(?:typedef\s+.+|using\s+\w+\s*=.+)\s+([A-Za-z_]\w*)\s*;", line) + if typedef: + return typedef.group(1) + field = re.search(r"([A-Za-z_]\w*)\s*;\s*$", line) + return field.group(1) if field else "member" + + +def transform(source: str) -> str: + """Convert public-header comments into a Doxygen-only source stream.""" + output: list[str] = [] + pending_doc = False + in_enum = False + in_pjson = False + class_depth = 0 + + for original in source.splitlines(keepends=True): + newline = "\n" if original.endswith("\n") else "" + line = original[:-1] if newline else original + stripped = line.strip() + code = line.split("//", 1)[0] + + if not in_pjson: + output.append(original) + if re.search(r"\bclass\s+pjson\s*\{", code): + in_pjson = True + class_depth = code.count("{") - code.count("}") + continue + + next_depth = class_depth + code.count("{") - code.count("}") + if next_depth == 0: + output.append(original) + in_pjson = False + class_depth = 0 + pending_doc = False + continue + + if SEPARATOR.match(line): + output.append(original) + continue + + leading = re.match(r"^(\s*)//(.*)$", line) + if leading: + body = escape_commands(leading.group(2)) + output.append(f"{leading.group(1)}///{body}{newline}") + pending_doc = True + continue + + inline = re.match(r"^(.*\S)(\s+)//(.*)$", line) + if inline: + body = escape_commands(inline.group(3)) + output.append(f"{inline.group(1)}{inline.group(2)}///< {body.strip()}{newline}") + pending_doc = False + continue + + inline_enum = INLINE_ENUM.match(line) + if inline_enum: + values = [] + indent = re.match(r"^\s*", line).group(0) + " " + if not pending_doc: + output.append( + re.match(r"^\s*", line).group(0) + + "/// Selects one of the public JSON policies.\n" + ) + items = [ + item.strip() + for item in inline_enum.group("body").split(",") + if item.strip() + ] + for index, value in enumerate(items): + comma = "," if index + 1 < len(items) else "" + values.append(f"{value}{comma} ///< JSON value or policy constant.") + output.append( + inline_enum.group("prefix") + + "\n" + + "\n".join(indent + value for value in values) + + "\n" + + inline_enum.group("suffix") + + newline + ) + pending_doc = False + continue + + if re.search(r"\benum(?:\s+class)?(?:\s+\w+)?[^;]*\{", line): + if not pending_doc: + output.append( + re.match(r"^\s*", line).group(0) + + "/// Selects one of the public JSON policies.\n" + ) + in_enum = True + + enum_value = ENUMERATOR.match(line) if in_enum else None + if enum_value and stripped not in {"{", "}"}: + suffix = " JSON value or policy constant." + output.append( + f"{enum_value.group('indent')}{enum_value.group('name')}" + f"{enum_value.group('comma')} ///<{suffix}{newline}" + ) + pending_doc = False + continue + + is_declaration = ( + bool(DECLARATION_END.search(line)) + and not stripped.startswith(("#", "};")) + ) + if is_declaration and not stripped.startswith(("using ", "return ", "friend ")): + semicolon = line.rfind(";") + label = declaration_label(line[: semicolon + 1]) + line = ( + line[: semicolon + 1] + + f" ///< Public API member `{label}`; see the API overview for its contract." + + line[semicolon + 1 :] + ) + output.append(line + newline) + + if is_declaration: + pending_doc = False + elif stripped and not stripped.startswith(("public:", "private:", "protected:")): + # Preserve a leading documentation block across a multi-line declaration. + if not pending_doc or stripped.endswith(("{", "}")): + pending_doc = False + if in_enum and "};" in line: + in_enum = False + class_depth = next_depth + + return "".join(output) + + +def main() -> int: + """Filter the public header path supplied by Doxygen to standard output.""" + if len(sys.argv) != 2: + print("usage: doxygen-filter.py HEADER", file=sys.stderr) + return 2 + header = Path(sys.argv[1]) + sys.stdout.write(transform(header.read_text(encoding="utf-8-sig"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/scripts/validate-reference.py b/docs/scripts/validate-reference.py new file mode 100644 index 0000000..ea5af4d --- /dev/null +++ b/docs/scripts/validate-reference.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Validate that Doxygen emitted pjson's complete public API surface.""" + +from __future__ import annotations + +import argparse +import collections +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +# ---- Required public documentation surface ----------------------------- + +REQUIRED_COMPOUNDS = { + "ByteDance", + "ByteDance::pjson", + "ByteDance::pjson::Allocator", + "ByteDance::pjson::ValueDeleter", + "ByteDance::pjson::ParseOptions", + "ByteDance::pjson::ParseError", + "ByteDance::pjson::PointerError", + "ByteDance::pjson::PatchError", + "ByteDance::pjson::PatchOptions", + "ByteDance::pjson::SerializeOptions", + "ByteDance::pjson::StringView", + "ByteDance::pjson::SaxHandler", + "ByteDance::pjson::SchemaError", + "ByteDance::pjson::SchemaOptions", +} + +# Baseline overload counts make accidental omissions visible. APIs whose exact +# shape is part of the breaking-contract check are also listed below by type. +REQUIRED_MEMBERS = { + "getVersion": 1, + "parse": 8, + "parseStream": 4, + "parseSax": 4, + "parseSaxStream": 2, + "toString": 2, + "write": 2, + "getType": 1, + "isNull": 1, + "isString": 1, + "isNumber": 1, + "isInt": 1, + "isDouble": 1, + "isBool": 1, + "isArray": 1, + "isObject": 1, + "getAllocator": 1, + "canSwap": 1, + "tryGet": 20, + "size": 1, + "empty": 1, + "clear": 1, + "keys": 1, + "hasKey": 2, + "hasIndex": 1, + "find": 6, + "escapePointerToken": 1, + "findPointer": 8, + "operator[]": 3, + "operator=": 11, + "operator+=": 9, + "erase": 3, + "applyPatch": 2, + "applyMergePatch": 2, + "operator==": 1, + "operator!=": 1, + "validate": 2, +} + +REMOVED_PUBLIC_MEMBERS = { + "PJSONARRAY", + "PJSONMAP", + "getInt64", + "getDouble", + "getBool", + "getString", + "getArray", + "getMap", + "getIfExist", + "getArrayValues", + "getInt64Or", + "getDoubleOr", + "getBoolOr", + "getStringOr", + "at", + "EncodeForJSON", + "EncodeBase64ForJSON", + "DecodeFromJSON", + "DecodeBase64FromJSON", +} + +EXPECTED_PUBLIC_ENUMS = { + ("ByteDance::pjson", "jsonType"): { + "jsonNull", + "jsonString", + "jsonNumberInt", + "jsonNumberDouble", + "jsonBoolean", + "jsonArray", + "jsonObject", + }, + ("ByteDance::pjson::Allocator", "AllocationKind"): { + "NodeAllocation", + "StringAllocation", + "ArrayAllocation", + "ObjectAllocation", + }, + ("ByteDance::pjson::ParseOptions", "DuplicateKeyPolicy"): { + "RejectDuplicateKeys", + "KeepFirstDuplicate", + "KeepLastDuplicate", + }, + ("ByteDance::pjson::PointerError", "Code"): { + "Ok", + "InvalidSyntax", + "InvalidEscape", + "MissingTarget", + "ExpectedContainer", + "InvalidArrayIndex", + "ArrayIndexOutOfRange", + "AppendTokenNotAllowed", + "AllocationFailure", + "InternalError", + }, + ("ByteDance::pjson::PatchError", "Code"): { + "Ok", + "InvalidPatchDocument", + "OperationNotObject", + "MissingOp", + "MissingPath", + "MissingFrom", + "MissingValue", + "InvalidOp", + "InvalidPath", + "InvalidFrom", + "TargetMissing", + "InvalidArrayIndex", + "ArrayIndexOutOfRange", + "MoveRootNotAllowed", + "MoveIntoDescendant", + "TestFailed", + "ResourceLimit", + "AllocationFailure", + "InternalError", + }, + ("ByteDance::pjson::SerializeOptions", "KeyOrder"): { + "AscendingKeys", + "DescendingKeys", + }, +} + +EXPECTED_PARAMETER_TYPES = { + "tryGet": { + ("int64_t&",), + ("double&",), + ("bool&",), + ("std::string&",), + ("StringView&",), + ("const std::string&", "int64_t&"), + ("const std::string&", "double&"), + ("const std::string&", "bool&"), + ("const std::string&", "std::string&"), + ("const std::string&", "StringView&"), + ("const char*", "int64_t&"), + ("const char*", "double&"), + ("const char*", "bool&"), + ("const char*", "std::string&"), + ("const char*", "StringView&"), + ("int", "int64_t&"), + ("int", "double&"), + ("int", "bool&"), + ("int", "std::string&"), + ("int", "StringView&"), + }, + "toString": {(), ("const SerializeOptions&",)}, + "write": { + ("std::ostream&",), + ("std::ostream&", "const SerializeOptions&"), + }, + "applyPatch": { + ("const pjson&", "const PatchOptions&"), + ("const pjson&", "PatchError&", "const PatchOptions&"), + }, + "applyMergePatch": { + ("const pjson&", "const PatchOptions&"), + ("const pjson&", "PatchError&", "const PatchOptions&"), + }, + "operator=": { + ("const pjson&",), + ("pjson&&",), + ("const std::string&",), + ("const char*",), + ("const bool",), + ("const int64_t",), + ("const double",), + ("const std::vector&",), + ("const std::vector&",), + ("const std::vector&",), + ("const std::vector&",), + }, + "operator+=": { + ("const std::string&",), + ("const char*",), + ("const bool",), + ("const int64_t",), + ("const double",), + ("const std::vector&",), + ("const std::vector&",), + ("const std::vector&",), + ("const std::vector&",), + }, +} + +REQUIRED_PUBLIC_FIELDS = { + "ByteDance::pjson::PatchOptions": { + "maxOperations", + "maxClonedNodes", + "maxClonedBytes", + "maxWork", + }, + "ByteDance::pjson::SerializeOptions": { + "pretty", + "indentWidth", + "indentCharacter", + "escapeNonAscii", + "keyOrder", + "maxOutputBytes", + }, +} + +REQUIRED_GUIDE_PAGES = { + "custom-allocators", + "migration-nlohmann-json", + "migration-rapidjson", +} +REQUIRED_DEFINES = { + "PJSON_VERSION", + "PJSON_VERSION_MAJOR", + "PJSON_VERSION_MINOR", + "PJSON_VERSION_PATCH", +} +REQUIRED_ALLOCATOR_MEMBERS = { + "AllocationKind": 1, + "allocate": 1, + "deallocate": 1, +} + + +def fail(messages: list[str]) -> int: + """Emit all validation failures together and return a failing status.""" + for message in messages: + print(f"documentation validation: {message}", file=sys.stderr) + return 1 + + +def undocumented_public_members(definition): + """List public XML members that have neither brief nor detailed prose.""" + undocumented = [] + for member in definition.findall(".//memberdef[@prot='public']"): + if member.get("kind") not in {"function", "typedef", "enum", "variable"}: + continue + prose = "".join(member.find("briefdescription").itertext()).strip() + prose += "".join(member.find("detaileddescription").itertext()).strip() + if not prose: + undocumented.append(member.findtext("name", default="?")) + return undocumented + + +def normalized_xml_type(node) -> str: + """Return a stable spelling for a Doxygen XML type element.""" + if node is None: + return "" + value = " ".join("".join(node.itertext()).split()) + for before, after in (("< ", "<"), (" >", ">"), (" &&", "&&"), + (" &", "&"), (" *", "*")): + value = value.replace(before, after) + return value + + +def parameter_types(member) -> tuple[str, ...]: + """Return the normalized parameter-type tuple for one XML member.""" + return tuple(normalized_xml_type(param.find("type")) for param in member.findall("param")) + + +def signature(name: str, parameters: tuple[str, ...]) -> str: + """Format a normalized signature for a validation diagnostic.""" + return f"{name}({', '.join(parameters)})" + + +def main() -> int: + """Validate generated Doxygen XML/HTML against the required API surface.""" + parser = argparse.ArgumentParser() + parser.add_argument("--xml", required=True, type=Path) + parser.add_argument("--html", required=True, type=Path) + args = parser.parse_args() + + index_path = args.xml / "index.xml" + html_index = args.html / "index.html" + missing_files = [str(path) for path in (index_path, html_index) if not path.is_file()] + if missing_files: + return fail(["missing generated file " + path for path in missing_files]) + + index = ET.parse(index_path).getroot() + compounds = { + node.findtext("name", default=""): node + for node in index.findall("compound") + } + errors: list[str] = [] + + for name in sorted(REQUIRED_COMPOUNDS - compounds.keys()): + errors.append(f"missing public compound {name}") + + pjson_node = compounds.get("ByteDance::pjson") + members: collections.Counter[str] = collections.Counter() + if pjson_node is not None: + members.update(node.findtext("name", default="") for node in pjson_node.findall("member")) + refid = pjson_node.get("refid", "") + class_xml = args.xml / f"{refid}.xml" + class_html = args.html / f"{refid}.html" + if not class_xml.is_file(): + errors.append(f"missing class XML {class_xml.name}") + if not class_html.is_file(): + errors.append(f"missing class HTML {class_html.name}") + if class_xml.is_file(): + definition = ET.parse(class_xml).getroot() + undocumented = undocumented_public_members(definition) + if undocumented: + errors.append("undocumented public members: " + ", ".join(undocumented)) + + for name, minimum in REQUIRED_MEMBERS.items(): + if members[name] < minimum: + errors.append(f"{name}: expected at least {minimum} overload(s), found {members[name]}") + + def compound_definition(name: str): + """Load one compound XML definition when its index entry exists.""" + node = compounds.get(name) + if node is None: + return None + path = args.xml / f"{node.get('refid', '')}.xml" + return ET.parse(path).getroot() if path.is_file() else None + + allocator_definition = compound_definition("ByteDance::pjson::Allocator") + if allocator_definition is not None: + undocumented = undocumented_public_members(allocator_definition) + if undocumented: + errors.append( + "undocumented Allocator members: " + ", ".join(undocumented) + ) + allocator_members = collections.Counter( + member.findtext("name", default="") + for member in allocator_definition.findall(".//memberdef[@prot='public']") + ) + for name, minimum in REQUIRED_ALLOCATOR_MEMBERS.items(): + if allocator_members[name] < minimum: + errors.append( + f"Allocator::{name}: expected at least {minimum}, " + f"found {allocator_members[name]}" + ) + deleter_definition = compound_definition("ByteDance::pjson::ValueDeleter") + if deleter_definition is not None: + undocumented = undocumented_public_members(deleter_definition) + if undocumented: + errors.append( + "undocumented ValueDeleter members: " + ", ".join(undocumented) + ) + if not deleter_definition.findall(".//memberdef[@prot='public'][name='operator()']"): + errors.append("missing ValueDeleter::operator()") + + for compound_name, expected_fields in REQUIRED_PUBLIC_FIELDS.items(): + definition = compound_definition(compound_name) + if definition is None: + continue + actual_fields = { + member.findtext("name", default="") + for member in definition.findall(".//memberdef[@kind='variable'][@prot='public']") + } + for field in sorted(expected_fields - actual_fields): + errors.append(f"missing public field {compound_name}::{field}") + + if members["unique_ptr"] < 1: + errors.append("missing allocator-aware pjson::unique_ptr typedef") + if members["pjson"] < 6: + errors.append("pjson: expected six allocator/default constructors") + if members["swap"] < 1 or members["copyFrom"] < 1: + errors.append("missing allocator-sensitive swap/copyFrom API") + + pjson_definition = compound_definition("ByteDance::pjson") + if pjson_definition is not None: + public_members = pjson_definition.findall(".//memberdef[@prot='public']") + public_names = collections.Counter( + member.findtext("name", default="") for member in public_members + ) + for name in sorted(REMOVED_PUBLIC_MEMBERS): + if public_names[name]: + errors.append(f"removed public member is still documented: {name}") + + for name, expected in EXPECTED_PARAMETER_TYPES.items(): + actual = { + parameter_types(member) + for member in public_members + if member.findtext("name", default="") == name + } + for parameters in sorted(expected - actual): + errors.append(f"missing public signature {signature(name, parameters)}") + for parameters in sorted(actual - expected): + errors.append(f"unexpected public signature {signature(name, parameters)}") + + for member in public_members: + if member.findtext("name", default="") == "tryGet": + result_type = normalized_xml_type(member.find("type")) + if result_type != "bool": + errors.append( + f"{signature('tryGet', parameter_types(member))} " + f"returns {result_type}, expected bool" + ) + + dom_parse_members = [ + member + for member in public_members + if member.findtext("name", default="") in {"parse", "parseStream"} + ] + for member in dom_parse_members: + result_type = normalized_xml_type(member.find("type")) + if result_type not in {"unique_ptr", "pjson::unique_ptr"}: + name = member.findtext("name", default="") + errors.append( + f"{signature(name, parameter_types(member))} returns {result_type}, " + "expected pjson::unique_ptr" + ) + + constructors = [ + member.findtext("argsstring", default="") + for member in pjson_definition.findall(".//memberdef[@prot='public']") + if member.findtext("name", default="") == "pjson" + ] + allocator_constructors = [signature for signature in constructors if "Allocator &" in signature] + if len(allocator_constructors) < 3: + errors.append( + f"allocator-aware constructors: expected three signatures, " + f"found {len(allocator_constructors)}" + ) + + parse_signatures = [ + member.findtext("argsstring", default="") + for member in pjson_definition.findall(".//memberdef[@prot='public']") + if member.findtext("name", default="") in {"parse", "parseStream"} + ] + allocator_signatures = [signature for signature in parse_signatures if "Allocator &" in signature] + if len(allocator_signatures) < 6: + errors.append( + f"allocator-aware parse APIs: expected six signatures, " + f"found {len(allocator_signatures)}" + ) + + # Pin every public enum nested anywhere under pjson. Scanning all public + # pjson compounds, rather than only the currently expected owners, also + # makes a newly added enum fail until the reference contract is updated. + actual_public_enums: dict[tuple[str, str], set[str]] = {} + for compound_name in sorted(compounds): + if compound_name != "ByteDance::pjson" and not compound_name.startswith( + "ByteDance::pjson::" + ): + continue + definition = compound_definition(compound_name) + if definition is None: + continue + compound = definition.find("compounddef") + if compound is None or compound.get("prot") != "public": + continue + for member in definition.findall(".//memberdef[@kind='enum'][@prot='public']"): + enum_name = member.findtext("name", default="") + actual_public_enums[(compound_name, enum_name)] = { + value.findtext("name", default="") + for value in member.findall("enumvalue") + } + + for owner, enum_name in sorted(EXPECTED_PUBLIC_ENUMS.keys() - actual_public_enums.keys()): + errors.append(f"missing public enum {owner}::{enum_name}") + for owner, enum_name in sorted(actual_public_enums.keys() - EXPECTED_PUBLIC_ENUMS.keys()): + errors.append(f"unexpected public enum {owner}::{enum_name}") + for key in sorted(EXPECTED_PUBLIC_ENUMS.keys() & actual_public_enums.keys()): + owner, enum_name = key + expected_values = EXPECTED_PUBLIC_ENUMS[key] + actual_values = actual_public_enums[key] + for value in sorted(expected_values - actual_values): + errors.append(f"missing {owner}::{enum_name} value {value}") + for value in sorted(actual_values - expected_values): + errors.append(f"unexpected {owner}::{enum_name} value {value}") + + pages = {name for name, node in compounds.items() if node.get("kind") == "page"} + for name in sorted(REQUIRED_GUIDE_PAGES - pages): + errors.append(f"missing guide page {name}") + + all_members = collections.Counter( + member.findtext("name", default="") + for compound in index.findall("compound") + for member in compound.findall("member") + ) + for name in sorted(REQUIRED_DEFINES): + if all_members[name] == 0: + errors.append(f"missing public version macro {name}") + + if errors: + return fail(errors) + + public_count = sum(members.values()) + print( + f"documentation validation: OK ({public_count} pjson index entries, " + f"{len(REQUIRED_COMPOUNDS)} required compounds, " + f"{len(REQUIRED_GUIDE_PAGES)} guide pages)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100644 index 0000000..7d81d54 --- /dev/null +++ b/examples/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.21) + +project(pjson_examples) + +# Keep target names aligned with their source-file stems so the loop below can +# define every tutorial as an independently runnable program. +set(PJSON_EXAMPLES + 01_hello_world + 02_building_values + 03_parsing_and_reading + 04_editing + 05_parsing_and_errors + 06_schema_validation + 07_address_book + 08_streaming + 09_custom_allocator +) + +# The examples intentionally use the library's minimum supported C++ standard. +set(CMAKE_CXX_STANDARD 11) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Each example links the in-tree library and includes its public headers. +foreach(example ${PJSON_EXAMPLES}) + add_executable(${example} src/${example}.cpp) + target_include_directories(${example} PRIVATE "../pjsonlib/include") + target_link_libraries(${example} pjson) +endforeach() diff --git a/examples/src/01_hello_world.cpp b/examples/src/01_hello_world.cpp new file mode 100644 index 0000000..04274c2 --- /dev/null +++ b/examples/src/01_hello_world.cpp @@ -0,0 +1,32 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"). +// +//===----------------------------------------------------------------------===// +// 01 — Hello, World +// +// The smallest possible pjson program: build a tiny object and print it. +// Referenced by docs/01-getting-started.md. +// +#include "pjson.h" + +#include +#include + +using namespace ByteDance; + +// Constructs a minimal document and prints its compact and pretty forms. +int main() { + // --- Build the document ------------------------------------------------ + // A pjson value starts as null. Assigning to a key turns it into an object. + pjson greeting; + greeting["message"] = "Hello, World!"; + greeting["year"] = int64_t(2025); + + // --- Serialize it ------------------------------------------------------ + pjson::SerializeOptions compact; + pjson::SerializeOptions pretty = pjson::SerializeOptions::prettyPrinted(); + std::cout << greeting.toString(compact) << "\n"; + std::cout << greeting.toString(pretty) << "\n"; + return 0; +} diff --git a/examples/src/02_building_values.cpp b/examples/src/02_building_values.cpp new file mode 100644 index 0000000..c84e37c --- /dev/null +++ b/examples/src/02_building_values.cpp @@ -0,0 +1,66 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"). +// +//===----------------------------------------------------------------------===// +// 02 — Building values +// +// Shows every way to put data into a pjson value: scalars, nested objects, +// arrays (from vectors, by index, and by appending), and deep nesting. +// Referenced by docs/02-creating-json.md. +// +#include "pjson.h" + +#include +#include +#include +#include +#include + +using namespace ByteDance; + +// Builds one document through the scalar, object, array, and nesting APIs. +int main() { + pjson doc; + + // --- Scalars ----------------------------------------------------------- + doc["name"] = "Ada"; // const char* -> string + doc["active"] = true; // bool + doc["age"] = int64_t(36); // integers are represented explicitly as int64_t + doc["ratio"] = double(0.5); + doc["nickname"]; // no value assigned -> stays null + + // --- Nested objects ---------------------------------------------------- + doc["address"]["city"] = "London"; + doc["address"]["zip"] = "N1"; + + // --- Arrays ------------------------------------------------------------ + // From a std::vector: + doc["scores"] = std::vector({90, 82, 77}); + + // By index (auto-extends, filling gaps with null): + doc["mixed"][0] = int64_t(1); + doc["mixed"][1] = "two"; + doc["mixed"][3] = true; // index 2 becomes null + + // By appending with += (promotes the node to an array): + doc["tags"] += "c++"; + doc["tags"] += "json"; + doc["tags"] += std::vector({"fast", "simple"}); + + // --- Deep nesting ------------------------------------------------------ + doc["matrix"][0] = std::vector({1, 2, 3}); + doc["matrix"][1] = std::vector({4, 5, 6}); + + // --- Serialization options -------------------------------------------- + // Start with pretty-print defaults, then make the relevant layout choices + // explicit. Key ordering applies independently at every object level. + pjson::SerializeOptions options = pjson::SerializeOptions::prettyPrinted(); + options.indentWidth = 2; + options.indentCharacter = ' '; + options.escapeNonAscii = false; + options.keyOrder = pjson::SerializeOptions::AscendingKeys; + options.maxOutputBytes = size_t(64) * 1024 * 1024; + std::cout << doc.toString(options) << "\n"; + return 0; +} diff --git a/examples/src/03_parsing_and_reading.cpp b/examples/src/03_parsing_and_reading.cpp new file mode 100644 index 0000000..6df4db0 --- /dev/null +++ b/examples/src/03_parsing_and_reading.cpp @@ -0,0 +1,108 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"). +// +//===----------------------------------------------------------------------===// +// 03 — Parsing and reading +// +// Parse a JSON string, then read scalars, arrays, and nested objects safely. +// Referenced by docs/03-parsing-and-reading.md. +// +#include "pjson.h" + +#include + +using namespace ByteDance; + +// Parses a representative document and demonstrates non-mutating read APIs. +int main() { + // --- Parse the input --------------------------------------------------- + const char* text = R"({ + "name": "Ada", + "age": 36, + "scores": [90, 82, 77], + "address": { "city": "London" }, + "friends": [ {"name":"Bob"}, {"name":"Cid"} ] + })"; + + // Every DOM parse overload returns pjson::unique_ptr; it is empty on failure. + pjson::ParseError parseError; + pjson::unique_ptr doc = pjson::parse(text, parseError); + if (!doc) { + std::cerr << parseError.line << ':' << parseError.column << ": " << parseError.message + << "\n"; + return 1; + } + const pjson& j = *doc; + + // --- Strict scalar reads ---------------------------------------------- + // StringView borrows the stored bytes, so write the explicit length rather + // than assuming a null terminator. The view remains valid while j is alive + // and the underlying string is not modified. + pjson::StringView name; + int64_t age = 0; + if (j.tryGet("name", name)) { + std::cout << "name = "; + std::cout.write(name.data(), static_cast(name.size())); + std::cout << "\n"; + } + if (j.tryGet("age", age)) + std::cout << "age = " << age << "\n"; + + // --- Safe reads with an application default --------------------------- + std::string email = "(none)"; + j.tryGet("email", email); // failure leaves the existing value unchanged + std::cout << "email = " << email << "\n"; + + // --- Arrays: iterate through non-vivifying lookup ---------------------- + std::cout << "scores:"; + const pjson* scoresNode = j.find("scores"); + if (scoresNode && scoresNode->isArray()) { + for (size_t i = 0; i < scoresNode->size(); ++i) { + int64_t value = 0; + const pjson* score = scoresNode->find(static_cast(i)); + if (score && score->tryGet(value)) + std::cout << " " << value; + } + } + std::cout << "\n"; + + // --- Non-vivifying indexed reads (negative means from the end) -------- + int64_t lastScore = 0; + if (scoresNode && scoresNode->hasIndex(-1) && scoresNode->tryGet(-1, lastScore)) + std::cout << "last score = " << lastScore << "\n"; + + int64_t firstScore = 0; + if (scoresNode && scoresNode->tryGet(0, firstScore)) + std::cout << "first score = " << firstScore << "\n"; + + // --- Array of objects -------------------------------------------------- + // Nested find()/tryGet() calls keep this traversal read-only and skip any + // element that does not have a string-valued "name" member. + std::cout << "friends:"; + if (const pjson* friendsNode = j.find("friends")) { + if (friendsNode->isArray()) { + for (size_t i = 0; i < friendsNode->size(); ++i) { + const pjson* friend_ = friendsNode->find(static_cast(i)); + pjson::StringView friendName; + if (friend_ && friend_->tryGet("name", friendName)) { + std::cout << " "; + std::cout.write(friendName.data(), + static_cast(friendName.size())); + } + } + } + } + std::cout << "\n"; + + // --- Nested lookup with RFC 6901 JSON Pointer ------------------------- + pjson::PointerError pointerError; + if (const pjson* city = j.findPointer("/address/city", pointerError)) { + std::string cityName; + if (city->tryGet(cityName)) + std::cout << "city = " << cityName << "\n"; + } else { + std::cerr << "pointer lookup failed: " << pointerError.message << "\n"; + } + return 0; +} diff --git a/examples/src/04_editing.cpp b/examples/src/04_editing.cpp new file mode 100644 index 0000000..2b585d7 --- /dev/null +++ b/examples/src/04_editing.cpp @@ -0,0 +1,86 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"). +// +//===----------------------------------------------------------------------===// +// 04 — Editing an existing document +// +// Load a document, then change values, add and remove keys and array +// elements, and re-serialize. Referenced by docs/04-editing.md. +// +#include "pjson.h" + +#include +#include + +using namespace ByteDance; + +// Parses a seed document, mutates it through several APIs, and prints the result. +int main() { + // --- Parse a mutable document ----------------------------------------- + auto doc = pjson::parse(R"({ + "user": { "name": "Ada", "roles": ["admin", "dev"] }, + "count": 2, + "deprecated": true + })"); + if (!doc) { + std::cerr << "parse failed\n"; + return 1; + } + pjson& j = *doc; + + // --- Direct DOM edits ------------------------------------------------- + // Change a value in place. + j["user"]["name"] = "Ada Lovelace"; + + // Add a new nested value. + j["user"]["email"] = "ada@example.com"; + + // Append to an existing array. + j["user"]["roles"] += "owner"; + + // Change an element's type (arrays are heterogeneous). + j["count"] = "two"; + + // Remove a key and an array element. + j.erase("deprecated"); + j["user"]["roles"].erase(size_t(0)); // drop "admin" + + // --- Standards-based transformations --------------------------------- + // Apply a sequence of JSON Pointer edits atomically (RFC 6902): the test + // must succeed before the reviewer role is appended. + pjson::ParseError parseError; + auto patch = pjson::parse(R"([ + {"op":"test", "path":"/count", "value":"two"}, + {"op":"add", "path":"/user/roles/-", "value":"reviewer"} + ])", + parseError); + if (!patch) { + std::cerr << "could not parse patch: " << parseError.message << "\n"; + return 1; + } + pjson::PatchError error; + pjson::PatchOptions limits; + if (!j.applyPatch(*patch, error, limits)) { + std::cerr << "patch failed at operation " << error.opIndex << ": " << error.message << "\n"; + return 1; + } + + // Merge Patch recursively updates objects; null removes an object member. + // Removing a missing member, as here, is a successful no-op. + auto merge = pjson::parse(R"({"user":{"nickname":null}})", parseError); + if (!merge) { + std::cerr << "could not parse merge patch: " << parseError.message << "\n"; + return 1; + } + if (!j.applyMergePatch(*merge, error, limits)) { + std::cerr << "merge patch failed: " << error.message << "\n"; + return 1; + } + + // --- Serialize the edited document ----------------------------------- + pjson::SerializeOptions output = pjson::SerializeOptions::prettyPrinted(); + output.maxOutputBytes = size_t(64) * 1024 * 1024; + std::cout << j.toString(output) << "\n"; + return 0; +} diff --git a/examples/src/05_parsing_and_errors.cpp b/examples/src/05_parsing_and_errors.cpp new file mode 100644 index 0000000..8e7d495 --- /dev/null +++ b/examples/src/05_parsing_and_errors.cpp @@ -0,0 +1,55 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"). +// +//===----------------------------------------------------------------------===// +// 05 — Parsing and error reporting +// +// Show RFC 8259 parsing, duplicate-key policies, resource budgets, and +// precise failure locations. +// Referenced by docs/05-parsing-and-errors.md. +// +#include "pjson.h" + +#include + +using namespace ByteDance; + +// Attempts one parse and prints either its compact form or the precise failure +// location. Reporting parse APIs reset ParseError on entry. +static void tryParse(const char* label, const std::string& text, const pjson::ParseOptions& opt) { + pjson::ParseError err; + auto doc = pjson::parse(text, err, opt); + std::cout << label << ": "; + if (doc) { + pjson::SerializeOptions compact; + compact.maxOutputBytes = size_t(64) * 1024 * 1024; + std::cout << "OK -> " << doc->toString(compact) << "\n"; + } else { + std::cout << "FAILED at " << err.line << ':' << err.column << " (byte " << err.offset + << ", " << err.message << ")\n"; + } +} + +// Exercises invalid syntax, duplicate-key policy, and a nesting-depth budget. +int main() { + // --- JSON syntax ------------------------------------------------------- + pjson::ParseOptions defaults; + tryParse("trailing comma", "[1, 2, ]", defaults); + tryParse("uppercase keyword", "NULL", defaults); + std::string rawTab = "\"a\tb\""; + tryParse("raw tab", rawTab, defaults); + + // Duplicate policy does not relax the JSON grammar. + tryParse("duplicate (reject)", R"({"id":1,"id":2})", defaults); + pjson::ParseOptions keepLast; + keepLast.duplicateKeys = pjson::ParseOptions::KeepLastDuplicate; + tryParse("duplicate (keep last)", R"({"id":1,"id":2})", keepLast); + + // --- Resource limits -------------------------------------------------- + // Guard against runaway nesting. + pjson::ParseOptions shallow; + shallow.maxDepth = 3; + tryParse("deep nesting (maxDepth=3)", "[[[[1]]]]", shallow); + return 0; +} diff --git a/examples/src/06_schema_validation.cpp b/examples/src/06_schema_validation.cpp new file mode 100644 index 0000000..822b17a --- /dev/null +++ b/examples/src/06_schema_validation.cpp @@ -0,0 +1,82 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"). +// +//===----------------------------------------------------------------------===// +// 06 — Schema validation +// +// Validate a document against a JSON-Schema-subset schema (itself a pjson +// value), collecting applicable failures within configured budgets. +// Referenced by docs/06-schema-validation.md. +// +#include "pjson.h" + +#include +#include + +using namespace ByteDance; + +// Validates one conforming and one non-conforming instance against a reusable +// schema, first as a yes/no query and then with detailed errors. +int main() { + // --- Define the schema ------------------------------------------------- + // Local $defs keep shared constraints in one place; $ref resolves them by + // RFC 6901 fragment pointers within this same schema document. + auto schema = pjson::parse(R"({ + "$defs": { + "displayName": { "type": "string", "minLength": 1 }, + "emailAddress": { "type": "string", "pattern": "@" } + }, + "type": "object", + "required": ["name", "age", "email", "joined"], + "additionalProperties": false, + "properties": { + "name": { "$ref": "#/$defs/displayName" }, + "age": { "type": "integer", "minimum": 0, "maximum": 150 }, + "email": { "$ref": "#/$defs/emailAddress" }, + "joined": { "type": "string", "format": "date" }, + "roles": { + "type": "array", + "items": { "type": "string", "enum": ["admin", "user", "guest"] } + } + } + })"); + + // --- Validate a conforming instance ----------------------------------- + auto good = pjson::parse(R"({ + "name": "Ada", "age": 36, "email": "ada@example.com", + "joined": "2025-01-02", "roles": ["admin"] + })"); + if (!schema || !good) { + std::cerr << "could not parse schema or valid example\n"; + return 1; + } + + // These limits bound traversal and reference work. Known string formats, + // such as the date above, are checked because validateFormats is enabled. + pjson::SchemaOptions options; + options.maxValidationDepth = 512; + options.maxRefResolutions = 1024; + options.validateFormats = true; + std::cout << "good is valid: " << (good->validate(*schema, options) ? "yes" : "no") << "\n"; + + // --- Collect failures for a non-conforming instance ------------------- + auto bad = pjson::parse(R"({ + "name": "", "age": 200, "email": "nope", + "joined": "2025-01-02", "roles": ["root"], "extra": 1 + })"); + if (!bad) { + std::cerr << "could not parse invalid example\n"; + return 1; + } + std::vector errors; + // This overload appends applicable failures up to the configured budget; + // each path is an RFC 6901 JSON Pointer identifying the offending value. + bool ok = bad->validate(*schema, errors, options); + std::cout << "bad is valid: " << (ok ? "yes" : "no") << "\n"; + std::cout << "failures:\n"; + for (const pjson::SchemaError& e : errors) { + std::cout << " " << (e.path.empty() ? "(root)" : e.path) << ": " << e.message << "\n"; + } + return 0; +} diff --git a/examples/src/07_address_book.cpp b/examples/src/07_address_book.cpp new file mode 100644 index 0000000..973cb70 --- /dev/null +++ b/examples/src/07_address_book.cpp @@ -0,0 +1,126 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"). +// +//===----------------------------------------------------------------------===// +// 07 — A small address-book application (capstone) +// +// Ties everything together: a schema for a contact, building records, parsing +// an incoming payload, validating it, editing the store, and serializing the +// result. Referenced by docs/07-capstone-address-book.md. +// +#include "pjson.h" + +#include +#include +#include +#include + +using namespace ByteDance; + +namespace { + + // Builds the schema every contact must satisfy. The embedded literal is + // fixed application data, so parsing it is expected to succeed. + pjson::unique_ptr contactSchema() { + return pjson::parse(R"({ + "type": "object", + "required": ["id", "name", "emails"], + "properties": { + "id": { "type": "integer", "minimum": 1 }, + "name": { "type": "string", "minLength": 1 }, + "emails": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "@" } }, + "tags": { "type": "array", "items": { "type": "string" } } + } + })"); + } + + // Adds a deep copy of a valid contact to the book. Invalid contacts leave + // the book unchanged and produce one line for every validation failure. + bool addContact(pjson& book, const pjson& schema, const pjson& contact) { + std::vector errors; + if (!contact.validate(schema, errors)) { + std::cout << " rejected contact:\n"; + for (const pjson::SchemaError& e : errors) { + std::cout << " " << (e.path.empty() ? "(root)" : e.path) << ": " << e.message + << "\n"; + } + return false; + } + // Append by assigning to the next array index (there is no operator+= for a + // whole pjson value; indexing auto-extends the array, then we copy in). + pjson& contacts = book["contacts"]; + if (contacts.size() > static_cast(INT_MAX)) + return false; + contacts[static_cast(contacts.size())] = contact; + return true; + } + +} // namespace + +// Runs the address-book workflow: initialize, ingest, reject, edit, and query. +int main() { + // --- Initialize the store --------------------------------------------- + pjson::unique_ptr schema = contactSchema(); + if (!schema) { + std::cerr << "could not parse the embedded schema\n"; + return 1; + } + + // Start an empty address book. + pjson book; + book["version"] = int64_t(1); + book["contacts"].resetTo(pjson::jsonArray); // start as an empty array + + // --- Ingest contacts -------------------------------------------------- + // 1) Build a contact programmatically. + pjson ada; + ada["id"] = int64_t(1); + ada["name"] = "Ada Lovelace"; + ada["emails"] += "ada@example.com"; + ada["tags"] += "pioneer"; + std::cout << "adding Ada...\n"; + addContact(book, *schema, ada); + + // 2) Accept a contact that arrives as a JSON payload. + std::cout << "adding incoming payload...\n"; + auto incoming = pjson::parse(R"({ + "id": 2, "name": "Bob", "emails": ["bob@example.com", "b@work.com"] + })"); + if (!incoming) { + std::cerr << "could not parse incoming contact\n"; + return 1; + } + addContact(book, *schema, *incoming); + + // 3) Reject an invalid contact. + std::cout << "adding invalid contact...\n"; + auto invalid = pjson::parse(R"({ "id": 0, "name": "", "emails": [] })"); + if (!invalid) { + std::cerr << "could not parse invalid-contact fixture\n"; + return 1; + } + addContact(book, *schema, *invalid); + + // --- Edit and query --------------------------------------------------- + // 4) Edit the store: give Ada a second email, then look someone up. + book["contacts"][0]["emails"] += "ada@lovelace.org"; + + std::cout << "\nlookup id=2: "; + const pjson* contacts = book.find("contacts"); + for (size_t i = 0; contacts && i < contacts->size(); ++i) { + const pjson* contact = contacts->find(static_cast(i)); + int64_t id = 0; + std::string name; + if (contact && contact->tryGet("id", id) && id == int64_t(2) && + contact->tryGet("name", name)) + std::cout << name << "\n"; + } + + // --- Serialize -------------------------------------------------------- + // 5) Serialize the whole book. + pjson::SerializeOptions output = pjson::SerializeOptions::prettyPrinted(); + output.maxOutputBytes = size_t(64) * 1024 * 1024; + std::cout << "\nfinal address book:\n" << book.toString(output) << "\n"; + return 0; +} diff --git a/examples/src/08_streaming.cpp b/examples/src/08_streaming.cpp new file mode 100644 index 0000000..0916038 --- /dev/null +++ b/examples/src/08_streaming.cpp @@ -0,0 +1,68 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"). +// +//===----------------------------------------------------------------------===// +// 08 — Process a JSON stream without building an in-memory document. +// Referenced by docs/11-streaming.md. +// +#include "pjson.h" + +#include +#include +#include +#include + +using namespace ByteDance; + +// SAX handler that summarizes every JSON number encountered anywhere in the +// stream. Unoverridden callbacks accept and ignore non-numeric events. +struct NumberSummary : pjson::SaxHandler { + size_t count = 0; + double total = 0.0; + + // Count an integer event and continue parsing. + bool onInt(int64_t value) override { + ++count; + total += static_cast(value); + return true; + } + + // Count a floating-point event and continue parsing. + bool onDouble(double value) override { + ++count; + total += value; + return true; + } +}; + +// Streams an input through the SAX summary, then streams a small DOM report to +// standard output without constructing either whole-document output string. +int main() { + // --- Incremental input ------------------------------------------------- + std::istringstream input(R"({"readings":[10,12.5,8,9.5]})"); + NumberSummary summary; + pjson::ParseError error; + if (!pjson::parseSaxStream(input, summary, error)) { + std::cerr << error.line << ':' << error.column << ": " << error.message << '\n'; + return 1; + } + + std::cout << "numbers: " << summary.count << "\nsum: " << summary.total << '\n'; + + // --- Streaming output ------------------------------------------------- + // write(out, options) serializes an existing DOM without first building a + // complete output string. Check the stream after the void-returning call. + pjson report; + report["numbers"] = static_cast(summary.count); + report["sum"] = summary.total; + pjson::SerializeOptions options = pjson::SerializeOptions::prettyPrinted(); + options.indentWidth = 2; + options.escapeNonAscii = true; + options.maxOutputBytes = size_t(64) * 1024 * 1024; + report.write(std::cout, options); + std::cout << '\n'; + if (!std::cout) + return 1; + return 0; +} diff --git a/examples/src/09_custom_allocator.cpp b/examples/src/09_custom_allocator.cpp new file mode 100644 index 0000000..66ad03d --- /dev/null +++ b/examples/src/09_custom_allocator.cpp @@ -0,0 +1,132 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"). +// +//===----------------------------------------------------------------------===// +// 09 — Bind persistent pjson DOM storage to a custom allocator. +// Referenced by docs/12-custom-allocators.md. +// +#include "pjson.h" + +#include +#include +#include +#include +#include + +using ByteDance::pjson; + +// Minimal instrumentation allocator for the example. It delegates storage to +// global new/delete while counting pjson's four persistent allocation kinds. +class CountingAllocator : public pjson::Allocator { +public: + // The following methods implement pjson's allocation contract while + // maintaining per-kind lifetime totals and one aggregate live-block count. + CountingAllocator() + : _liveBlocks(0) { + for (size_t i = 0; i < 4; ++i) { + _allocations[i] = 0; + _deallocations[i] = 0; + } + } + + void* allocate(size_t bytes, size_t alignment, AllocationKind kind) override { + // Global operator new meets alignments through std::max_align_t in C++11. + // A real pool would provide an over-aligned path if its contract needed one. + if (alignment > alignof(std::max_align_t)) + throw std::bad_alloc(); + void* memory = ::operator new(bytes); + ++_allocations[index(kind)]; + ++_liveBlocks; + return memory; + } + + void deallocate(void* memory, size_t bytes, size_t alignment, + AllocationKind kind) noexcept override { + (void)bytes; + (void)alignment; + if (!memory) + return; + ++_deallocations[index(kind)]; + --_liveBlocks; + ::operator delete(memory); + } + + size_t allocations(AllocationKind kind) const { return _allocations[index(kind)]; } + + size_t deallocations(AllocationKind kind) const { return _deallocations[index(kind)]; } + + size_t liveBlocks() const { return _liveBlocks; } + +private: + // AllocationKind is deliberately contiguous, so it is a safe statistics index. + static size_t index(AllocationKind kind) { return static_cast(kind); } + + // Keep allocation and deallocation totals even after all live blocks have + // been released so the example can report lifetime activity separately. + size_t _allocations[4]; + size_t _deallocations[4]; + size_t _liveBlocks; +}; + +// Demonstrates default allocation, custom-bound roots, allocator-aware parsing, +// and transfers both within and across allocator domains. +int main() { + // --- Default allocation ------------------------------------------------ + // Every parse overload uses the provenance-aware pjson::unique_ptr owner. + pjson::unique_ptr ordinary = pjson::parse(R"({"storage":"default"})"); + if (!ordinary) + return 1; + + // --- Custom allocator domains ----------------------------------------- + // Allocators are declared before bound values so they outlive every root + // and descendant that may call back into them during destruction. + CountingAllocator first; + CountingAllocator second; + { + // This root object lives on the stack. Its persistent wrapper objects + // and dynamically created children use `first`. + pjson direct(first); + direct["kind"] = "direct root"; + direct["values"] += int64_t(1); + direct["values"] += int64_t(2); + + pjson::ParseError error; + // Allocator-aware parsing returns pjson::unique_ptr; its custom deleter + // returns the dynamically allocated root through `first`. + pjson::unique_ptr parsed = + pjson::parse(R"({"kind":"parsed root","values":[3,4]})", error, first); + if (!parsed) { + std::cerr << error.message << '\n'; + return 1; + } + + // --- Transfer between domains ------------------------------------- + // Explicit allocator construction deep-copies into another domain. + pjson rehomed(*parsed, second); + if (direct.canSwap(*parsed)) + direct.swap(*parsed); // same allocator: constant-time exchange + + // Move assignment preserves the destination allocator. Because these + // allocators differ, this may allocate while deep-transferring the tree. + direct = std::move(rehomed); + + // These cumulative counts are printed while both allocator-bound trees + // are still alive; live-block verification happens after destruction. + pjson::SerializeOptions compact; + compact.maxOutputBytes = size_t(64) * 1024 * 1024; + std::cout << direct.toString(compact) << '\n'; + std::cout << "first node allocations: " + << first.allocations(pjson::Allocator::NodeAllocation) << '\n'; + std::cout << "second node allocations: " + << second.allocations(pjson::Allocator::NodeAllocation) << '\n'; + } + + // Leaving the inner scope destroys every custom-bound value before this + // final balance check. + if (first.liveBlocks() != 0 || second.liveBlocks() != 0) { + std::cerr << "allocator leak detected\n"; + return 1; + } + return 0; +} diff --git a/fuzz/CMakeLists.txt b/fuzz/CMakeLists.txt new file mode 100644 index 0000000..fdab683 --- /dev/null +++ b/fuzz/CMakeLists.txt @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: Apache-2.0 + +# ---- Fuzzing engine selection ------------------------------------------- + +# Standalone builds use Clang's bundled libFuzzer. OSS-Fuzz instead supplies +# PJSON_FUZZING_ENGINE so its chosen engine is linked into each harness. +if(NOT PJSON_FUZZING_ENGINE) + if(NOT UNIX OR NOT CMAKE_CXX_COMPILER_ID MATCHES "^(Apple)?Clang$") + message(FATAL_ERROR + "PJSON_BUILD_FUZZERS requires Clang with libFuzzer on Linux/macOS, " + "or an external PJSON_FUZZING_ENGINE") + endif() + + # Probe the complete sanitizer combination because accepting a compile + # flag does not guarantee that the compiler can also link libFuzzer. + include(CheckCXXSourceCompiles) + set(PJSON_SAVED_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS}") + set(CMAKE_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS} -fsanitize=fuzzer,address,undefined") + check_cxx_source_compiles( + "#include + #include + extern \"C\" int LLVMFuzzerTestOneInput(const uint8_t*, size_t) { return 0; }" + PJSON_HAS_BUILTIN_LIBFUZZER) + set(CMAKE_REQUIRED_FLAGS "${PJSON_SAVED_REQUIRED_FLAGS}") + unset(PJSON_SAVED_REQUIRED_FLAGS) + if(NOT PJSON_HAS_BUILTIN_LIBFUZZER) + message(FATAL_ERROR + "${CMAKE_CXX_COMPILER} cannot compile and link -fsanitize=fuzzer; " + "install a full LLVM toolchain or provide PJSON_FUZZING_ENGINE") + endif() + + # Instrument the library under test without adding libFuzzer's main to it; + # the final executable receives that entry point at link time below. + target_compile_options(pjson PRIVATE + -fsanitize=fuzzer-no-link,address,undefined + -fno-omit-frame-pointer) + # Any other executable linked to the instrumented static library still + # needs the ASan/UBSan runtimes, but must not receive libFuzzer's main. + target_link_options(pjson INTERFACE -fsanitize=address,undefined) +else() + # OSS-Fuzz passes the engine as a shell-style string, which may contain + # several archives or flags and therefore must be split before linking. + separate_arguments(PJSON_FUZZING_ENGINE_ARGS NATIVE_COMMAND "${PJSON_FUZZING_ENGINE}") +endif() + +# ---- Harness target definition ----------------------------------------- + +# Defines one fuzz harness with the common language level, warnings, project +# include path, and the engine selected above. +function(pjson_add_fuzzer target source) + add_executable(${target} ${source}) + target_link_libraries(${target} PRIVATE pjson::pjson) + target_compile_features(${target} PRIVATE cxx_std_11) + target_include_directories(${target} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") + if(MSVC) + target_compile_options(${target} PRIVATE /W4) + else() + target_compile_options(${target} PRIVATE -Wall -Wextra) + endif() + + if(PJSON_FUZZING_ENGINE) + # External engines are responsible for their own instrumentation and + # entry point; CXX/CXXFLAGS supplied by OSS-Fuzz instrument the code. + target_link_libraries(${target} PRIVATE ${PJSON_FUZZING_ENGINE_ARGS}) + else() + # Local builds instrument both the harness and library, then link the + # executable with libFuzzer plus the runtime bug detectors. + target_compile_options(${target} PRIVATE + -fsanitize=fuzzer-no-link,address,undefined + -fno-omit-frame-pointer) + target_link_options(${target} PRIVATE -fsanitize=fuzzer,address,undefined) + endif() +endfunction() + +# ---- Harnesses ---------------------------------------------------------- + +pjson_add_fuzzer(pjson_fuzz_parse fuzz_parse.cpp) +pjson_add_fuzzer(pjson_fuzz_stream fuzz_stream.cpp) +pjson_add_fuzzer(pjson_fuzz_schema fuzz_schema.cpp) +pjson_add_fuzzer(pjson_fuzz_patch fuzz_patch.cpp) diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..6833d58 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,83 @@ + + + +# Coverage-guided fuzzing + +## Harnesses + +The fuzz build provides four Clang/libFuzzer targets: + +- `pjson_fuzz_parse` exercises strict DOM parsing only, while varying + duplicate-key policy and bounded parse budgets across the same bytes. +- `pjson_fuzz_stream` compares DOM, buffered stream, SAX-buffer, and chunked + SAX-stream behavior under the same option variants. +- `pjson_fuzz_schema` parses a schema and instance and verifies agreement + between both validation overloads. Its input is `schema`, one newline byte, + then `instance`; inputs without a newline are split at their midpoint. +- `pjson_fuzz_patch` splits its input into `document` and `patch`, then drives + RFC 6902 JSON Patch when the second half parses as an array, otherwise RFC + 7396 Merge Patch. It checks atomic failure and stable serialization after + success. + +Inputs under `corpus/patch/` intentionally cover both successful and failing +patch documents so coverage includes rollback and diagnostic paths. + +## Bounded local smoke + +Run the bounded smoke used in CI with: + +```sh +./build.sh --fuzz --auto +``` + +On Linux and macOS, `build.sh --fuzz` probes for a usable Clang/libFuzzer +toolchain, configures `-DPJSON_BUILD_FUZZERS=ON`, builds all four harnesses, +and replays each checked-in seed corpus with deterministic bounds: + +- `-runs=1000` +- `-seed=1337` +- `-max_len=4096` +- `-timeout=5` + +Checked-in seeds are read-only inputs under `corpus/`; generated corpus entries +and failure artifacts go under the ignored `out/fuzz-corpus/` and +`out/fuzz-artifacts/` trees. To preserve a useful failure or coverage +discovery, minimize it and copy the result into the matching checked-in corpus +with a descriptive name. + +## Build integrations + +Direct CMake builds use `-DPJSON_BUILD_FUZZERS=ON`. + +Local libFuzzer builds rely on Clang plus a working `-fsanitize=fuzzer` runtime: + +```sh +cmake -S . -B out/build-fuzz \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF \ + -DPJSON_BUILD_FUZZERS=ON +cmake --build out/build-fuzz --parallel --target \ + pjson_fuzz_parse pjson_fuzz_stream pjson_fuzz_schema pjson_fuzz_patch +``` + +External-engine builds pass linker input through the cache variable +`PJSON_FUZZING_ENGINE`. When that variable is non-empty, `fuzz/CMakeLists.txt` +does not require Clang's bundled libFuzzer runtime and instead splits the +provided shell-style engine string before linking each harness. This is the +path used by repository-local OSS-Fuzz integration: + +```sh +cmake -S . -B out/build-fuzz \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF \ + -DPJSON_BUILD_FUZZERS=ON \ + -DPJSON_FUZZING_ENGINE="${LIB_FUZZING_ENGINE}" +``` + +Repository-local OSS-Fuzz wiring is under `../oss-fuzz/`. Its build script +configures `PJSON_BUILD_FUZZERS=ON`, passes +`PJSON_FUZZING_ENGINE="${LIB_FUZZING_ENGINE}"`, builds all four harnesses, and +packages per-target seed corpora from `fuzz/corpus/{parse,stream,schema,patch}`. diff --git a/fuzz/corpus/parse/duplicate.json b/fuzz/corpus/parse/duplicate.json new file mode 100644 index 0000000..2d05fde --- /dev/null +++ b/fuzz/corpus/parse/duplicate.json @@ -0,0 +1 @@ +{"key":1,"key":2} diff --git a/fuzz/corpus/parse/malformed.json b/fuzz/corpus/parse/malformed.json new file mode 100644 index 0000000..ac34a4d --- /dev/null +++ b/fuzz/corpus/parse/malformed.json @@ -0,0 +1 @@ +{"truncated":[1,2,"\uD800"] diff --git a/fuzz/corpus/parse/nested.json b/fuzz/corpus/parse/nested.json new file mode 100644 index 0000000..ea9a637 --- /dev/null +++ b/fuzz/corpus/parse/nested.json @@ -0,0 +1 @@ +{"array":[null,true,false,-0.0,1.25e3],"object":{"escaped":"line\n\u20ac"}} diff --git a/fuzz/corpus/parse/null.json b/fuzz/corpus/parse/null.json new file mode 100644 index 0000000..19765bd --- /dev/null +++ b/fuzz/corpus/parse/null.json @@ -0,0 +1 @@ +null diff --git a/fuzz/corpus/parse/numbers.json b/fuzz/corpus/parse/numbers.json new file mode 100644 index 0000000..b327fdd --- /dev/null +++ b/fuzz/corpus/parse/numbers.json @@ -0,0 +1 @@ +[-9223372036854775808,9223372036854775807,4.9406564584124654e-324,1.7976931348623157e308] diff --git a/fuzz/corpus/parse/unicode.json b/fuzz/corpus/parse/unicode.json new file mode 100644 index 0000000..4d2f0a7 --- /dev/null +++ b/fuzz/corpus/parse/unicode.json @@ -0,0 +1 @@ +{"bmp":"é€","pair":"\ud834\udd1e","control":"\b\f\n\r\t"} diff --git a/fuzz/corpus/patch/add-member.seed b/fuzz/corpus/patch/add-member.seed new file mode 100644 index 0000000..ff46500 --- /dev/null +++ b/fuzz/corpus/patch/add-member.seed @@ -0,0 +1,2 @@ +{"a":1} +[{"op":"add","path":"/b","value":[true,false,null]}] diff --git a/fuzz/corpus/patch/failing-atomic.seed b/fuzz/corpus/patch/failing-atomic.seed new file mode 100644 index 0000000..798c3be --- /dev/null +++ b/fuzz/corpus/patch/failing-atomic.seed @@ -0,0 +1,2 @@ +{"keep":1,"arr":[10,20]} +[{"op":"replace","path":"/keep","value":9},{"op":"remove","path":"/missing"}] diff --git a/fuzz/corpus/patch/merge-patch.seed b/fuzz/corpus/patch/merge-patch.seed new file mode 100644 index 0000000..c30c42d --- /dev/null +++ b/fuzz/corpus/patch/merge-patch.seed @@ -0,0 +1,2 @@ +{"title":"Goodbye!","author":{"givenName":"John","familyName":"Doe"},"tags":["example","sample"],"content":"This will be unchanged"} +{"title":"Hello!","phoneNumber":"+01-123-456-7890","author":{"familyName":null},"tags":["example"]} diff --git a/fuzz/corpus/patch/move-copy-test.seed b/fuzz/corpus/patch/move-copy-test.seed new file mode 100644 index 0000000..e16790b --- /dev/null +++ b/fuzz/corpus/patch/move-copy-test.seed @@ -0,0 +1,2 @@ +{"obj":{"a":1},"arr":["x","y","z"]} +[{"op":"copy","from":"/obj/a","path":"/obj/b"},{"op":"move","from":"/arr/0","path":"/arr/2"},{"op":"test","path":"/obj/b","value":1}] diff --git a/fuzz/corpus/schema/array.seed b/fuzz/corpus/schema/array.seed new file mode 100644 index 0000000..458fc1d --- /dev/null +++ b/fuzz/corpus/schema/array.seed @@ -0,0 +1,2 @@ +{"type":"array","items":{"type":"integer"},"minItems":1} +[1,2,3] diff --git a/fuzz/corpus/schema/boolean.seed b/fuzz/corpus/schema/boolean.seed new file mode 100644 index 0000000..0265733 --- /dev/null +++ b/fuzz/corpus/schema/boolean.seed @@ -0,0 +1,2 @@ +false +null diff --git a/fuzz/corpus/schema/composition.seed b/fuzz/corpus/schema/composition.seed new file mode 100644 index 0000000..71327eb --- /dev/null +++ b/fuzz/corpus/schema/composition.seed @@ -0,0 +1,2 @@ +{"allOf":[{"type":"number"},{"minimum":0}],"not":{"const":13}} +12.5 diff --git a/fuzz/corpus/schema/malformed.seed b/fuzz/corpus/schema/malformed.seed new file mode 100644 index 0000000..d53dcd3 --- /dev/null +++ b/fuzz/corpus/schema/malformed.seed @@ -0,0 +1,2 @@ +{"required":"not-an-array","pattern":"([unclosed"} +"value" diff --git a/fuzz/corpus/schema/object.seed b/fuzz/corpus/schema/object.seed new file mode 100644 index 0000000..87f181f --- /dev/null +++ b/fuzz/corpus/schema/object.seed @@ -0,0 +1,2 @@ +{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}} +{"name":"pjson"} diff --git a/fuzz/corpus/schema/pattern.seed b/fuzz/corpus/schema/pattern.seed new file mode 100644 index 0000000..7dad3a0 --- /dev/null +++ b/fuzz/corpus/schema/pattern.seed @@ -0,0 +1,2 @@ +{"type":"string","pattern":"^[a-z]+$"} +"pjson" diff --git a/fuzz/corpus/stream/chunk-boundaries.json b/fuzz/corpus/stream/chunk-boundaries.json new file mode 100644 index 0000000..72acd7c --- /dev/null +++ b/fuzz/corpus/stream/chunk-boundaries.json @@ -0,0 +1 @@ +{"utf8":"é€𝄞","escape":"\uD834\uDD1E","number":-12.345e+67} diff --git a/fuzz/corpus/stream/malformed.json b/fuzz/corpus/stream/malformed.json new file mode 100644 index 0000000..9f5a0ee --- /dev/null +++ b/fuzz/corpus/stream/malformed.json @@ -0,0 +1 @@ +[1,2,{"unfinished":true diff --git a/fuzz/corpus/stream/multiline.json b/fuzz/corpus/stream/multiline.json new file mode 100644 index 0000000..929fbea --- /dev/null +++ b/fuzz/corpus/stream/multiline.json @@ -0,0 +1,4 @@ +{ + "a": [1, 2, 3], + "b": "tail" +} diff --git a/fuzz/corpus/stream/wide.json b/fuzz/corpus/stream/wide.json new file mode 100644 index 0000000..1ce9052 --- /dev/null +++ b/fuzz/corpus/stream/wide.json @@ -0,0 +1 @@ +[0,1,2,3,4,5,6,7,8,9,{"a":[true,false,null]}] diff --git a/fuzz/fuzz_parse.cpp b/fuzz/fuzz_parse.cpp new file mode 100644 index 0000000..57d9284 --- /dev/null +++ b/fuzz/fuzz_parse.cpp @@ -0,0 +1,61 @@ +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); + +#include "fuzz_util.h" + +#include +#include +#include + +using ByteDance::pjson; + +namespace { + + // DOM parsing and serialization invariants. + + // Exercises one parser configuration and checks successful + // values across both compact and pretty serialization modes. + void exerciseParser(const uint8_t* data, size_t size, size_t variantOffset) { + const pjson::ParseOptions options = + pjson_fuzz::parseOptionsVariant(data, size, variantOffset); + pjson::ParseError error; + pjson::unique_ptr value = pjson::parse(pjson_fuzz::bytes(data, size), size, error, options); + // The returned value and explicit status must agree on whether parsing succeeded. + pjson_fuzz::require(static_cast(value) == error.ok); + if (!value) + return; + + // Compact output must be a stable, value-preserving representation. + const std::string compact = value->toString(); + pjson::ParseOptions compactOptions = options; + compactOptions.maxInputBytes = compact.size(); + pjson::ParseError compactError; + pjson::unique_ptr reparsed = pjson::parse(compact, compactError, compactOptions); + pjson_fuzz::require(reparsed != nullptr); + pjson_fuzz::require(compactError.ok); + pjson_fuzz::require(*reparsed == *value); + pjson_fuzz::require(reparsed->toString() == compact); + + // Pretty printing may change whitespace, but never the represented JSON value. + const std::string pretty = value->toString(pjson::SerializeOptions::prettyPrinted()); + pjson::ParseOptions prettyOptions = options; + prettyOptions.maxInputBytes = pretty.size(); + pjson::unique_ptr prettyParsed = pjson::parse(pretty, prettyOptions); + pjson_fuzz::require(prettyParsed != nullptr); + pjson_fuzz::require(*prettyParsed == *value); + } + +} // namespace + +// libFuzzer entry point. + +// Bounds each test case and exercises the same bytes under several +// duplicate-policy/resource-budget variants. +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (size > pjson_fuzz::kMaxInputBytes) + return 0; + exerciseParser(data, size, 0U); + exerciseParser(data, size, 4U); + exerciseParser(data, size, 8U); + return 0; +} diff --git a/fuzz/fuzz_patch.cpp b/fuzz/fuzz_patch.cpp new file mode 100644 index 0000000..c24da5d --- /dev/null +++ b/fuzz/fuzz_patch.cpp @@ -0,0 +1,75 @@ +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); + +#include "fuzz_util.h" + +#include +#include +#include + +using ByteDance::pjson; + +namespace { + + // Applies either RFC 6902 JSON Patch or RFC 7396 Merge Patch and checks the + // non-throwing API contracts that are observable from fuzz-side callers. + void exercisePatchVariant(const uint8_t* data, size_t size, const std::string& documentInput, + const std::string& patchInput, size_t variantOffset) { + const pjson::ParseOptions options = + pjson_fuzz::parseOptionsVariant(data, size, variantOffset); + pjson::unique_ptr original = pjson::parse(documentInput, options); + pjson::unique_ptr patch = pjson::parse(patchInput, options); + if (!original || !patch) + return; + + const bool useJsonPatch = patch->isArray(); + pjson working = *original; + pjson::PatchError detailedError; + const bool detailedOk = useJsonPatch ? working.applyPatch(*patch, detailedError) + : working.applyMergePatch(*patch, detailedError); + pjson_fuzz::require(detailedOk == detailedError.ok); + + pjson simple = *original; + const bool simpleOk = + useJsonPatch ? simple.applyPatch(*patch) : simple.applyMergePatch(*patch); + pjson_fuzz::require(simpleOk == detailedOk); + + if (!detailedOk) { + // Failure must leave the document unchanged because patch application is atomic. + pjson_fuzz::require(working == *original); + pjson_fuzz::require(simple == *original); + return; + } + + // Successful mutation must serialize and reparse stably. + pjson_fuzz::require(working == simple); + const std::string compact = working.toString(); + pjson::ParseOptions compactOptions = options; + compactOptions.maxInputBytes = compact.size(); + pjson::unique_ptr reparsed = pjson::parse(compact, compactOptions); + pjson_fuzz::require(reparsed != nullptr); + pjson_fuzz::require(*reparsed == working); + + const std::string pretty = working.toString(pjson::SerializeOptions::prettyPrinted()); + pjson::ParseOptions prettyOptions = options; + prettyOptions.maxInputBytes = pretty.size(); + pjson::unique_ptr prettyParsed = pjson::parse(pretty, prettyOptions); + pjson_fuzz::require(prettyParsed != nullptr); + pjson_fuzz::require(*prettyParsed == working); + } + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (size > pjson_fuzz::kMaxInputBytes) + return 0; + + const std::string input(pjson_fuzz::bytes(data, size), size); + std::string documentInput; + std::string patchInput; + pjson_fuzz::splitOnNewlineOrMidpoint(input, documentInput, patchInput); + + exercisePatchVariant(data, size, documentInput, patchInput, 0U); + exercisePatchVariant(data, size, documentInput, patchInput, 4U); + return 0; +} diff --git a/fuzz/fuzz_schema.cpp b/fuzz/fuzz_schema.cpp new file mode 100644 index 0000000..52aa1da --- /dev/null +++ b/fuzz/fuzz_schema.cpp @@ -0,0 +1,41 @@ +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); + +#include "fuzz_util.h" + +#include +#include +#include +#include + +using ByteDance::pjson; + +// Schema-validation consistency. + +// Splits a fuzz case into a schema and document, then compares validation overloads. +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (size > pjson_fuzz::kMaxInputBytes) + return 0; + + // Prefer `schema\ndocument` framing; unframed inputs are divided at their midpoint. + const std::string input(pjson_fuzz::bytes(data, size), size); + std::string schemaInput; + std::string documentInput; + pjson_fuzz::splitOnNewlineOrMidpoint(input, schemaInput, documentInput); + + // Only pairs that are both valid strict JSON values can exercise schema validation. + const pjson::ParseOptions options = pjson_fuzz::parseOptionsVariant(data, size, 0U); + pjson::unique_ptr schema = pjson::parse(schemaInput, options); + pjson::unique_ptr document = pjson::parse(documentInput, options); + if (!schema || !document) + return 0; + + // Detailed and simple validation must agree, and errors exist exactly on failure. + const pjson::SchemaOptions schemaOptions = pjson_fuzz::boundedSchemaOptions(data, size, 4U); + std::vector errors; + const bool detailed = document->validate(*schema, errors, schemaOptions); + const bool simple = document->validate(*schema, schemaOptions); + pjson_fuzz::require(simple == detailed); + pjson_fuzz::require(detailed == errors.empty()); + return 0; +} diff --git a/fuzz/fuzz_stream.cpp b/fuzz/fuzz_stream.cpp new file mode 100644 index 0000000..b87ea26 --- /dev/null +++ b/fuzz/fuzz_stream.cpp @@ -0,0 +1,200 @@ +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); + +#include "fuzz_util.h" + +#include +#include +#include +#include +#include +#include + +using ByteDance::pjson; + +namespace { + + // Chunk-limited stream adapter. + + // Exposes an in-memory string through refills no larger than the requested chunk size. + class ChunkedBuffer : public std::streambuf { + public: + // Starts with an empty get area; the harness keeps chunkSize within the 32-byte buffer. + ChunkedBuffer(const std::string& input, size_t chunkSize) + : _input(input) + , _chunkSize(chunkSize) + , _position(0) { + setg(_buffer, _buffer, _buffer); + } + + protected: + // Refills the get area with the next bounded chunk, or reports end of input. + int_type underflow() override { + if (_position >= _input.size()) + return traits_type::eof(); + const size_t count = std::min(_chunkSize, _input.size() - _position); + for (size_t i = 0; i < count; ++i) + _buffer[i] = _input[_position + i]; + _position += count; + setg(_buffer, _buffer, _buffer + static_cast(count)); + return traits_type::to_int_type(*gptr()); + } + + private: + const std::string& _input; + size_t _chunkSize; + size_t _position; + char _buffer[32]; + }; + + // Owns the chunking stream buffer and installs it on an input-stream facade. + class ChunkedStream : public std::istream { + public: + // Attaches the fully constructed chunking buffer to the otherwise bufferless base stream. + ChunkedStream(const std::string& input, size_t chunkSize) + : std::istream(nullptr) + , _buffer(input, chunkSize) { + rdbuf(&_buffer); + } + + private: + ChunkedBuffer _buffer; + }; + + // Order-sensitive SAX event fingerprinting. + + // Records both event count and content so buffered and streamed SAX traces can be compared. + // The small methods below all fold a distinct event tag plus any payload + // into the same order-sensitive digest and always continue parsing. + struct DigestHandler : pjson::SaxHandler { + DigestHandler() + : digest(1469598103934665603ULL) + , events(0) {} + + uint64_t digest; + size_t events; + + void mix(uint64_t value) { + digest ^= value; + digest *= 1099511628211ULL; + } + + // Include length so different adjacent strings cannot produce the same byte stream. + void mixString(const std::string& value) { + for (size_t i = 0; i < value.size(); ++i) + mix(static_cast(value[i])); + mix(value.size()); + } + + bool mark(uint64_t tag) { + ++events; + mix(tag); + return true; + } + + bool onNull() override { return mark(1); } + + bool onBool(bool value) override { return mark(value ? 3 : 2); } + + bool onInt(int64_t value) override { + mark(4); + mix(static_cast(value)); + return true; + } + + // Canonical serialization gives floating-point values a stable byte representation. + bool onDouble(double value) override { + mark(5); + pjson number; + number = value; + mixString(number.toString()); + return true; + } + + bool onString(const std::string& value) override { + mark(6); + mixString(value); + return true; + } + + bool onStartArray() override { return mark(7); } + + bool onEndArray() override { return mark(8); } + + bool onStartObject() override { return mark(9); } + + // Keys use their own tag so they cannot collide with ordinary strings. + bool onKey(const std::string& value) override { + mark(10); + mixString(value); + return true; + } + + bool onEndObject() override { return mark(11); } + }; + + // Cross-interface parser consistency. + + // Compares contiguous and chunked DOM/SAX parsing for one + // option variant. + void exerciseStreams(const uint8_t* data, size_t size, const std::string& input, + size_t chunkSize, size_t variantOffset) { + const pjson::ParseOptions options = + pjson_fuzz::parseOptionsVariant(data, size, variantOffset); + + // Contiguous DOM parsing provides the baseline status and value. + pjson::ParseError bufferError; + pjson::unique_ptr buffered = + pjson::parse(input.c_str(), input.size(), bufferError, options); + pjson_fuzz::require(static_cast(buffered) == bufferError.ok); + + // Chunk boundaries must not affect DOM acceptance or serialized output. + ChunkedStream domInput(input, chunkSize); + pjson::ParseError streamError; + pjson::unique_ptr streamed = pjson::parseStream(domInput, streamError, options); + pjson_fuzz::require(static_cast(streamed) == streamError.ok); + pjson_fuzz::require(static_cast(buffered) == static_cast(streamed)); + if (buffered) + pjson_fuzz::require(buffered->toString() == streamed->toString()); + + // Capture the SAX trace from the same contiguous baseline input. + DigestHandler bufferHandler; + pjson::ParseError saxBufferError; + const bool saxBuffer = + pjson::parseSax(input.c_str(), input.size(), bufferHandler, saxBufferError, options); + pjson_fuzz::require(saxBuffer == saxBufferError.ok); + + // Streamed SAX parsing must agree on status, event count, order, and payloads. + ChunkedStream saxInput(input, chunkSize); + DigestHandler streamHandler; + pjson::ParseError saxStreamError; + const bool saxStream = + pjson::parseSaxStream(saxInput, streamHandler, saxStreamError, options); + pjson_fuzz::require(saxStream == saxStreamError.ok); + pjson_fuzz::require(saxBuffer == saxStream); + pjson_fuzz::require(static_cast(buffered) == saxBuffer); + // Failure can be detected before any callbacks for a bounded in-memory + // input but only after prefix callbacks for a stream, so compare traces + // only when both parsers consumed the complete document successfully. + if (saxBuffer) { + pjson_fuzz::require(bufferHandler.events == streamHandler.events); + pjson_fuzz::require(bufferHandler.digest == streamHandler.digest); + } + } + +} // namespace + +// libFuzzer entry point. + +// Derives a safe 1..32-byte chunk size and tests several +// duplicate-policy/resource-budget variants. +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (size > pjson_fuzz::kMaxInputBytes) + return 0; + const std::string input(pjson_fuzz::bytes(data, size), size); + const size_t chunkSize = size == 0 ? 1U : static_cast(data[0] % 32U) + 1U; + exerciseStreams(data, size, input, chunkSize, 0U); + exerciseStreams(data, size, input, chunkSize, 4U); + exerciseStreams(data, size, input, chunkSize, 8U); + return 0; +} diff --git a/fuzz/fuzz_util.h b/fuzz/fuzz_util.h new file mode 100644 index 0000000..fd347cd --- /dev/null +++ b/fuzz/fuzz_util.h @@ -0,0 +1,117 @@ +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef PJSON_FUZZ_UTIL_H +#define PJSON_FUZZ_UTIL_H + +#include "pjson.h" + +#include +#include +#include +#include + +namespace pjson_fuzz { + + // Shared resource limits and invariant checks. + + // Caps raw fuzzer inputs so each target stays fast and allocation-bounded. + const size_t kMaxInputBytes = 64U * 1024U; + + // Converts a violated cross-API property into a fuzzer-detectable crash. + inline void require(bool condition) { + if (!condition) + std::abort(); + } + + // Parser configuration. + + // Returns a stable byte even when the input is shorter than the requested index. + inline uint8_t pickByte(const uint8_t* data, size_t size, size_t index, uint8_t fallback) { + return index < size ? data[index] : fallback; + } + + // Builds a parser configuration while varying duplicate-key + // policy and resource budgets across inputs. + inline ByteDance::pjson::ParseOptions parseOptionsVariant(const uint8_t* data, size_t size, + size_t offset = 0) { + ByteDance::pjson::ParseOptions options; + switch (pickByte(data, size, offset, 0) % 3U) { + case 0: + options.duplicateKeys = ByteDance::pjson::ParseOptions::RejectDuplicateKeys; + break; + case 1: + options.duplicateKeys = ByteDance::pjson::ParseOptions::KeepFirstDuplicate; + break; + default: + options.duplicateKeys = ByteDance::pjson::ParseOptions::KeepLastDuplicate; + break; + } + + static const int kDepthBudgets[] = {8, 32, 128, 512}; + static const size_t kNodeBudgets[] = {64U, 1024U, 8192U, 65536U}; + static const size_t kInputBudgets[] = {64U, 1024U, 16384U, kMaxInputBytes}; + + options.maxDepth = kDepthBudgets[pickByte(data, size, offset + 1U, 1) % 4U]; + options.maxNodes = kNodeBudgets[pickByte(data, size, offset + 2U, 2) % 4U]; + options.maxInputBytes = kInputBudgets[pickByte(data, size, offset + 3U, 3) % 4U]; + return options; + } + + // Schema validation gets its own bounded knobs so one input can drive both + // parser and validator resource limits. + inline ByteDance::pjson::SchemaOptions boundedSchemaOptions(const uint8_t* data, size_t size, + size_t offset = 0) { + ByteDance::pjson::SchemaOptions options; + static const size_t kPatternBudgets[] = {32U, 64U, 256U, 1024U}; + static const size_t kSubjectBudgets[] = {128U, 512U, 4096U, 16384U}; + static const size_t kValidationDepths[] = {16U, 64U, 256U, 1024U}; + static const size_t kRefBudgets[] = {16U, 64U, 256U, 1024U}; + static const size_t kWorkBudgets[] = {256U, 4096U, 65536U, 1000000U}; + static const size_t kErrorBudgets[] = {1U, 8U, 32U, 100U}; + + options.maxRegexPatternBytes = kPatternBudgets[pickByte(data, size, offset, 0) % 4U]; + options.maxRegexSubjectBytes = kSubjectBudgets[pickByte(data, size, offset + 1U, 1) % 4U]; + options.maxValidationDepth = kValidationDepths[pickByte(data, size, offset + 2U, 2) % 4U]; + options.maxRefResolutions = kRefBudgets[pickByte(data, size, offset + 3U, 3) % 4U]; + options.maxValidationWork = kWorkBudgets[pickByte(data, size, offset + 4U, 4) % 4U]; + options.maxErrors = kErrorBudgets[pickByte(data, size, offset + 5U, 5) % 4U]; + options.validateFormats = (pickByte(data, size, offset + 6U, 6) & 1U) != 0; + return options; + } + + // Raw input adaptation. + + // Returns a non-null character pointer for empty input and preserves all other bytes. + inline const char* bytes(const uint8_t* data, size_t size) { + return size == 0 ? "" : reinterpret_cast(data); + } + + // Splits "left\nright" style inputs without requiring a checked-in framing token. + inline void splitOnNewlineOrMidpoint(const std::string& input, std::string& first, + std::string& second) { + size_t split = input.find('\n'); + size_t secondStart = split; + if (split == std::string::npos) { + split = input.size() / 2U; + secondStart = split; + } else { + secondStart = split + 1U; + } + first.assign(input.data(), split); + second.assign(input.data() + secondStart, input.size() - secondStart); + } + +} // namespace pjson_fuzz + +#endif // PJSON_FUZZ_UTIL_H diff --git a/fuzz/json.dict b/fuzz/json.dict new file mode 100644 index 0000000..b1a0830 --- /dev/null +++ b/fuzz/json.dict @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: Apache-2.0 + +# JSON structure and literals +lbrace="{" +rbrace="}" +lbracket="[" +rbracket="]" +colon=":" +comma="," +quote="\"" +true="true" +false="false" +null="null" +unicode="\\u0000" + +# Supported schema vocabulary +type="\"type\"" +properties="\"properties\"" +items="\"items\"" +required="\"required\"" +pattern="\"pattern\"" +enum="\"enum\"" +const="\"const\"" +allof="\"allOf\"" +anyof="\"anyOf\"" +oneof="\"oneOf\"" +not="\"not\"" diff --git a/oss-fuzz/Dockerfile b/oss-fuzz/Dockerfile new file mode 100644 index 0000000..f7ac601 --- /dev/null +++ b/oss-fuzz/Dockerfile @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: Apache-2.0 + +# Use OSS-Fuzz's standard compiler, sanitizer, and helper environment. +FROM gcr.io/oss-fuzz-base/base-builder + +# A shallow checkout keeps the builder image small while providing the source +# tree that OSS-Fuzz compiles through build.sh. +RUN git clone --depth 1 https://github.com/Pico-Developer/pjson.git pjson +WORKDIR pjson + +# OSS-Fuzz discovers the project build entry point at $SRC/build.sh. +COPY build.sh $SRC/ diff --git a/oss-fuzz/build.sh b/oss-fuzz/build.sh new file mode 100755 index 0000000..cebf62d --- /dev/null +++ b/oss-fuzz/build.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +# ---- OSS-Fuzz environment contract -------------------------------------- + +# Fail early with a useful message when the script is run outside the +# environment that supplies source, workspace, output, and engine settings. +: "${SRC:?OSS-Fuzz must provide SRC}" +: "${WORK:?OSS-Fuzz must provide WORK}" +: "${OUT:?OSS-Fuzz must provide OUT}" +: "${LIB_FUZZING_ENGINE:?OSS-Fuzz must provide LIB_FUZZING_ENGINE}" + +# ---- Source and build locations ----------------------------------------- + +# Production builders clone into $SRC/pjson. The fallback also lets developers +# invoke this copied script from a repository checkout for integration smoke. +PJSON_SOURCE_DIR="${SRC}/pjson" +if [ ! -f "${PJSON_SOURCE_DIR}/CMakeLists.txt" ]; then + PJSON_SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fi +PJSON_FUZZ_BUILD_DIR="${WORK}/pjson-fuzz-build" + +# ---- Configure and compile ---------------------------------------------- + +# CXX and LIB_FUZZING_ENGINE are selected by OSS-Fuzz for the active sanitizer +# and engine combination; the project must not substitute host defaults. +cmake -S "${PJSON_SOURCE_DIR}" -B "${PJSON_FUZZ_BUILD_DIR}" \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_CXX_COMPILER="${CXX}" \ + -DPJSON_BUILD_TESTS=OFF \ + -DPJSON_BUILD_EXAMPLES=OFF \ + -DPJSON_BUILD_BENCHMARKS=OFF \ + -DPJSON_BUILD_FUZZERS=ON \ + -DPJSON_FUZZING_ENGINE="${LIB_FUZZING_ENGINE}" +cmake --build "${PJSON_FUZZ_BUILD_DIR}" --parallel --target \ + pjson_fuzz_parse pjson_fuzz_stream pjson_fuzz_schema pjson_fuzz_patch + +# ---- Runtime bundle ----------------------------------------------------- + +# Each executable receives matching runtime options and the shared JSON token +# dictionary under the basename convention understood by OSS-Fuzz. +for target in pjson_fuzz_parse pjson_fuzz_stream pjson_fuzz_schema pjson_fuzz_patch; do + cp "${PJSON_FUZZ_BUILD_DIR}/fuzz/${target}" "${OUT}/${target}" + cp "${PJSON_SOURCE_DIR}/oss-fuzz/${target}.options" "${OUT}/${target}.options" + cp "${PJSON_SOURCE_DIR}/fuzz/json.dict" "${OUT}/${target}.dict" +done + +# Package each checked-in seed directory at the archive root, as required by +# OSS-Fuzz's _seed_corpus.zip discovery convention. The subshell keeps +# the loop's working directory stable between harnesses. +for corpus in parse stream schema patch; do + ( + cd "${PJSON_SOURCE_DIR}/fuzz/corpus/${corpus}" + zip -q -r "${OUT}/pjson_fuzz_${corpus}_seed_corpus.zip" . + ) +done diff --git a/oss-fuzz/pjson_fuzz_parse.options b/oss-fuzz/pjson_fuzz_parse.options new file mode 100644 index 0000000..406da9c --- /dev/null +++ b/oss-fuzz/pjson_fuzz_parse.options @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: Apache-2.0 + +[libfuzzer] +# Bound generated inputs so routine mutations emphasize parser state coverage. +max_len = 4096 +# Stop one input if parsing does not complete within five seconds. +timeout = 5 +# Leave enough headroom for sanitizer overhead while catching runaway growth. +rss_limit_mb = 2048 diff --git a/oss-fuzz/pjson_fuzz_patch.options b/oss-fuzz/pjson_fuzz_patch.options new file mode 100644 index 0000000..323d35d --- /dev/null +++ b/oss-fuzz/pjson_fuzz_patch.options @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: Apache-2.0 + +[libfuzzer] +# Bound generated inputs so routine mutations emphasize patch-operation paths. +max_len = 4096 +# Stop one patch/document pair if processing exceeds five seconds. +timeout = 5 +# Leave enough headroom for sanitizer overhead while catching runaway growth. +rss_limit_mb = 2048 diff --git a/oss-fuzz/pjson_fuzz_schema.options b/oss-fuzz/pjson_fuzz_schema.options new file mode 100644 index 0000000..ccfaa36 --- /dev/null +++ b/oss-fuzz/pjson_fuzz_schema.options @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: Apache-2.0 + +[libfuzzer] +# Bound generated inputs so routine mutations emphasize schema state coverage. +max_len = 4096 +# Stop one schema/instance pair if validation exceeds five seconds. +timeout = 5 +# Leave enough headroom for sanitizer overhead while catching runaway growth. +rss_limit_mb = 2048 diff --git a/oss-fuzz/pjson_fuzz_stream.options b/oss-fuzz/pjson_fuzz_stream.options new file mode 100644 index 0000000..ea88faa --- /dev/null +++ b/oss-fuzz/pjson_fuzz_stream.options @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: Apache-2.0 + +[libfuzzer] +# Bound generated inputs so routine mutations emphasize streaming boundaries. +max_len = 4096 +# Stop one input if a streaming path does not complete within five seconds. +timeout = 5 +# Leave enough headroom for sanitizer overhead while catching runaway growth. +rss_limit_mb = 2048 diff --git a/oss-fuzz/project.yaml b/oss-fuzz/project.yaml new file mode 100644 index 0000000..269c149 --- /dev/null +++ b/oss-fuzz/project.yaml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: Apache-2.0 + +# Project identity and escalation contact shown by OSS-Fuzz. +homepage: "https://github.com/Pico-Developer/pjson" +language: c++ +primary_contact: "jdpraveen@bytedance.com" +main_repo: "https://github.com/Pico-Developer/pjson.git" + +# Configurations exercised by the hosted builder matrix. +fuzzing_engines: + - libfuzzer +sanitizers: + - address + - undefined +architectures: + - x86_64 diff --git a/packaging/vcpkg/ports/pjson/portfile.cmake b/packaging/vcpkg/ports/pjson/portfile.cmake new file mode 100644 index 0000000..c886cfc --- /dev/null +++ b/packaging/vcpkg/ports/pjson/portfile.cmake @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: Apache-2.0 + +# This port is an in-repository overlay. Build the checkout that contains the +# port so local packaging validation cannot accidentally test an older archive. +get_filename_component(SOURCE_PATH "${CURRENT_PORT_DIR}/../../../.." ABSOLUTE) + +# ---- Configure and install --------------------------------------------- + +vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}" + OPTIONS + -DPJSON_BUILD_TESTS=OFF + -DPJSON_BUILD_EXAMPLES=OFF + -DPJSON_BUILD_BENCHMARKS=OFF +) + +vcpkg_cmake_install() +vcpkg_cmake_config_fixup( + PACKAGE_NAME pjson + CONFIG_PATH lib/cmake/pjson +) +vcpkg_fixup_pkgconfig() +vcpkg_copy_pdbs() + +# Headers and package metadata are configuration-independent; retain only the +# release copies to avoid duplicate files in the debug package subtree. +file(REMOVE_RECURSE + "${CURRENT_PACKAGES_DIR}/debug/include" + "${CURRENT_PACKAGES_DIR}/debug/share" +) +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE") diff --git a/packaging/vcpkg/ports/pjson/vcpkg.json b/packaging/vcpkg/ports/pjson/vcpkg.json new file mode 100644 index 0000000..a8e2aed --- /dev/null +++ b/packaging/vcpkg/ports/pjson/vcpkg.json @@ -0,0 +1,17 @@ +{ + "name": "pjson", + "version-semver": "1.0.0", + "description": "An ultra-simple JSON value type for C++11", + "homepage": "https://github.com/Pico-Developer/pjson", + "license": "Apache-2.0", + "dependencies": [ + { + "name": "vcpkg-cmake", + "host": true + }, + { + "name": "vcpkg-cmake-config", + "host": true + } + ] +} diff --git a/pjsonlib/CMakeLists.txt b/pjsonlib/CMakeLists.txt index dc64052..fb216c7 100644 --- a/pjsonlib/CMakeLists.txt +++ b/pjsonlib/CMakeLists.txt @@ -1,29 +1,103 @@ -cmake_minimum_required (VERSION 3.21) +# SPDX-License-Identifier: Apache-2.0 set(TARGET_NAME pjson) -project (${TARGET_NAME}) +# ---- Library sources and compiler policy -------------------------------- -# Project directories set (SRC_DIR "src") set (INCLUDE_DIR "include") -# Project Src files set (SRC_FILES ${SRC_FILES} ${SRC_DIR}/pjson.cpp ) -# Project Include directories -set (INC_DIRS ${INC_DIR} -${CMAKE_CURRENT_SOURCE_DIR} -${INCLUDE_DIR} -) +# Warning flags differ by compiler: GCC/Clang use -Wall -Wextra, MSVC uses /W4. +if (MSVC) + set (PJSON_WARN_FLAGS /W4) +else() + set (PJSON_WARN_FLAGS -Wall -Wextra) +endif() -# Compiler Flags -set (CMAKE_CXX_STANDARD 11) -set (CMAKE_CXX_STANDARD_REQUIRED ON) -set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall") +# ---- Library target ----------------------------------------------------- -# Execute add_library(${TARGET_NAME} ${SRC_FILES}) -target_include_directories(${TARGET_NAME} PUBLIC ${INC_DIRS}) +add_library(pjson::pjson ALIAS ${TARGET_NAME}) + +target_compile_features(${TARGET_NAME} PUBLIC cxx_std_11) +target_compile_options(${TARGET_NAME} PRIVATE ${PJSON_WARN_FLAGS}) +set_target_properties(${TARGET_NAME} PROPERTIES + VERSION "${PROJECT_VERSION}" + SOVERSION "${PROJECT_VERSION_MAJOR}" + CXX_EXTENSIONS OFF + EXPORT_NAME pjson + WINDOWS_EXPORT_ALL_SYMBOLS ON +) + +# Consumers get the include dir whether they build in-tree or install. +target_include_directories(${TARGET_NAME} PUBLIC + "$" + "$" +) + +# ---- Install and export rules ------------------------------------------ +# Allow downstreams to `find_package(pjson)` after installing, or to +# `add_subdirectory()` this repo and link `pjson::pjson` directly. +include(GNUInstallDirs) + +install(TARGETS ${TARGET_NAME} + EXPORT pjsonTargets + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" + LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" +) + +install(FILES ${INCLUDE_DIR}/pjson.h + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}" +) + +set(PJSON_INSTALL_CMAKEDIR "${CMAKE_INSTALL_LIBDIR}/cmake/pjson" + CACHE STRING "Directory for pjson CMake package files") +mark_as_advanced(PJSON_INSTALL_CMAKEDIR) + +install(EXPORT pjsonTargets + FILE pjsonTargets.cmake + NAMESPACE pjson:: + DESTINATION "${PJSON_INSTALL_CMAKEDIR}" +) + +configure_package_config_file( + "${CMAKE_CURRENT_LIST_DIR}/../cmake/pjsonConfig.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/pjsonConfig.cmake" + INSTALL_DESTINATION "${PJSON_INSTALL_CMAKEDIR}" +) + +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/pjsonConfigVersion.cmake" + VERSION "${PROJECT_VERSION}" + COMPATIBILITY SameMajorVersion +) + +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/pjsonConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/pjsonConfigVersion.cmake" + DESTINATION "${PJSON_INSTALL_CMAKEDIR}" +) + +# Derive pkg-config's prefix from pcfiledir instead of baking in the configure +# prefix. This keeps the metadata valid after an installed tree is relocated, +# including when CMAKE_INSTALL_LIBDIR is changed to lib64 or a nested path. +set(PJSON_INSTALL_PKGCONFIGDIR "${CMAKE_INSTALL_LIBDIR}/pkgconfig") +file(RELATIVE_PATH PJSON_PC_PREFIX_FROM_PCFILEDIR + "/${PJSON_INSTALL_PKGCONFIGDIR}" "/") +configure_file( + "${CMAKE_CURRENT_LIST_DIR}/../cmake/pjson.pc.in" + "${CMAKE_CURRENT_BINARY_DIR}/pjson.pc" + @ONLY +) +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/pjson.pc" + DESTINATION "${PJSON_INSTALL_PKGCONFIGDIR}" +) + +install(FILES "${CMAKE_CURRENT_LIST_DIR}/../LICENSE" + DESTINATION "${CMAKE_INSTALL_DATADIR}/licenses/pjson" +) diff --git a/pjsonlib/include/pjson.h b/pjsonlib/include/pjson.h index 2681136..8293cb9 100644 --- a/pjsonlib/include/pjson.h +++ b/pjsonlib/include/pjson.h @@ -13,180 +13,758 @@ // limitations under the License. // //===----------------------------------------------------------------------===// +// pjson — Praveen's JSON: an ultra-simple JSON value type for C++. +// +// A single class, ByteDance::pjson, represents any JSON value and offers an +// ergonomic obj["key"][i] = value building style plus parsing, serialization, +// lookup, mutation, equality, and JSON-Schema-subset validation. All method +// bodies live in pjson.cpp; this header only declares the interface. +// // Author: Praveen Babu J D // License: Apache 2.0 // #ifndef PRAVEENJSON_H #define PRAVEENJSON_H -#include -//#include +// Library version. PJSON_VERSION is the string form ("MAJOR.MINOR.PATCH"); +// the numeric parts allow compile-time checks, e.g. +// #if PJSON_VERSION_MAJOR >= 1 +#define PJSON_VERSION_MAJOR 1 +#define PJSON_VERSION_MINOR 0 +#define PJSON_VERSION_PATCH 0 +#define PJSON_VERSION "1.0.0" + +#include +#include +#include #include +#include #include +#include +#include namespace ByteDance { -//==[Interface]============================================================ + struct pjsonImpl; + //==[Interface]============================================================ + /// Owning, mutable JSON value with deep-copy semantics. + /// + /// Child pointers and string views exposed by lookup/access APIs are borrowed + /// from the owning tree. They become invalid when the child or an ancestor is + /// destroyed, replaced, reset, erased, moved, swapped, cleared, or successfully + /// patched. Unless an operation is `noexcept` or explicitly reports failures, + /// allocation and standard-library exceptions may escape. class pjson { public: + //== Library version ================================================= + /// Returns the process-lifetime semantic-version string for this library. + static const char* getVersion(); + + //== Types =========================================================== + + // JSON value kind. Numbers are stored in one of two representations: + // whole numbers as a 64-bit signed integer (jsonNumberInt) and + // everything else as a double (jsonNumberDouble). enum jsonType : int64_t { - jsonNull, + jsonNull = 0, // stable zero-valued discriminator for the default state jsonString, jsonNumberInt, - jsonNumberFloat, + jsonNumberDouble, jsonBoolean, - jsonArray, //[ ] array - jsonMap, // { ... } map + jsonArray, //[ ] array + jsonObject, // { ... } map + }; + + // Runtime allocator for persistent DOM storage. The allocator is + // non-owning and must outlive every pjson value that refers to it. + // Allocation covers pjson child/root nodes plus the std::string, + // array, and object wrapper objects. Storage used internally by those + // standard-library objects and transient parser/algorithm scratch space + // continues to use the standard allocator. + struct Allocator { + enum AllocationKind { + NodeAllocation = 0, + StringAllocation = 1, + ArrayAllocation = 2, + ObjectAllocation = 3 + }; + + /// Enables destruction through an Allocator base pointer. + virtual ~Allocator(); + /// Returns non-null aligned storage or throws; returning null is unsupported. + virtual void* allocate(size_t aSize, size_t aAlignment, AllocationKind aKind) = 0; + /// Releases a non-null allocation using its original size, alignment, and kind. + virtual void deallocate(void* aPtr, size_t aSize, size_t aAlignment, + AllocationKind aKind) noexcept = 0; }; - typedef std::vector PJSONARRAY; - //typedef std::unordered_map PJSONMAP; - typedef std::map PJSONMAP; - pjson(); // Default Constructor - ~pjson(); // Destructor - pjson(const pjson& aFrom); // Copy Constructor - pjson(pjson&& aFrom); // Move constructor - pjson& operator=(const pjson& aFrom); // Copy assignment - pjson& operator=(pjson&& a); // Move assignment + // Stateless ownership for allocator-created nodes. Provenance is read + // from the node itself, so moving this pointer never transfers or owns + // the Allocator object. + struct ValueDeleter { + /// Destroys aValue's tree through its originating allocator; accepts null. + void operator()(pjson* aValue) const noexcept; + }; + typedef std::unique_ptr unique_ptr; - static pjson* CreateFromString(const std::string& aStr); - static pjson* CreateFromString(const char* aSrc, size_t a_iSize); + // Bounds how much work a parse may do. Parsing always enforces RFC 8259 + // conformance and rejects: + // - unknown escapes (e.g. "\q") + // - lone/unpaired \u surrogates + // - upper/mixed-case keywords (NULL, True, FALSE) + // - raw control characters inside strings + // - malformed UTF-8 bytes + struct ParseOptions { + enum DuplicateKeyPolicy { RejectDuplicateKeys, KeepFirstDuplicate, KeepLastDuplicate }; - jsonType getType() const; - std::string toString(bool bPretty = false) const; - void copyFrom(const pjson& aFrom); + int maxDepth; // nesting limit; values <= 0 enforce a one-level limit + size_t maxNodes; // max JSON values created (0 = unlimited) + size_t maxInputBytes; // max input length in bytes (0 = unlimited) + DuplicateKeyPolicy duplicateKeys; + /// Selects duplicate rejection, depth 512, one million nodes, and a + /// 64 MiB input limit. + ParseOptions(); + }; + + // Filled in by the error-reporting parse() overloads. `ok` is true when + // parsing succeeded; otherwise `offset` is the zero-based byte position, + // `line` is one-based, `column` is a one-based byte column, and + // `message` describes the first failure. Reporting parse APIs reset all + // fields on entry and leave this success state after a successful parse. + struct ParseError { + bool ok; + size_t offset; + size_t line; + size_t column; + std::string message; + /// Constructs a success state at the beginning of an input. + ParseError(); + }; - PJSONARRAY* getArray(); - PJSONMAP* getMap(); - - float getFloat(); - int getInt(); - bool getBool(); - std::string getString(); - - // Extracting from a Map - bool hasKey(const std::string& aKey); - bool hasKey(const char* aKey); - - bool getIfExist(const std::string& aKey, float& a_rResult); - bool getIfExist(const std::string& aKey, int& a_rResult); - bool getIfExist(const std::string& aKey, bool& a_rResult); - bool getIfExist(const std::string& aKey, std::string& a_rResult); - bool getIfExist(const std::string& aKey, std::vector& a_rResult); - bool getIfExist(const std::string& aKey, std::vector& a_rResult); - bool getIfExist(const std::string& aKey, std::vector& a_rResult); - bool getIfExist(const std::string& aKey, std::vector& a_rResult); - - bool getIfExist(const char* aKey, float& a_rResult); - bool getIfExist(const char* aKey, int& a_rResult); - bool getIfExist(const char* aKey, bool& a_rResult); - bool getIfExist(const char* aKey, std::string& a_rResult); - bool getIfExist(const char* aKey, std::vector& a_rResult); - bool getIfExist(const char* aKey, std::vector& a_rResult); - bool getIfExist(const char* aKey, std::vector& a_rResult); - bool getIfExist(const char* aKey, std::vector& a_rResult); + // Structured JSON Pointer (RFC 6901) lookup failure. `tokenIndex` is + // zero-based and `token` is the decoded token that could not be + // resolved (or the source token when its escape sequence is invalid). + // std::string reporting overloads reset all fields on entry. A C-string + // overload can report allocation failure before copying the pointer text. + struct PointerError { + enum Code { + Ok, + InvalidSyntax, + InvalidEscape, + MissingTarget, + ExpectedContainer, + InvalidArrayIndex, + ArrayIndexOutOfRange, + AppendTokenNotAllowed, + AllocationFailure, + InternalError + }; + + bool ok; + Code code; + std::string pointer; + size_t tokenIndex; + std::string token; + std::string message; + /// Constructs a successful lookup state with no pointer or token details. + PointerError(); + }; + + // Structured JSON Patch (RFC 6902) / Merge Patch (RFC 7396) failure. + // Patch application is atomic: failure leaves the target unchanged. + // Reporting patch APIs reset all fields on entry and on success. + struct PatchError { + enum Code { + Ok, + InvalidPatchDocument, + OperationNotObject, + MissingOp, + MissingPath, + MissingFrom, + MissingValue, + InvalidOp, + InvalidPath, + InvalidFrom, + TargetMissing, + InvalidArrayIndex, + ArrayIndexOutOfRange, + MoveRootNotAllowed, + MoveIntoDescendant, + TestFailed, + ResourceLimit, + AllocationFailure, + InternalError + }; + + bool ok; + Code code; + size_t opIndex; + std::string op; + std::string path; + std::string from; + size_t tokenIndex; + std::string token; + std::string message; + /// Constructs a successful patch state with no operation or token details. + PatchError(); + }; + + // Bounds transactional patch amplification. Zero selects the documented + // built-in ceiling rather than disabling a safety limit. Clone bytes + // include node storage plus string and object-key payload bytes. + struct PatchOptions { + size_t maxOperations; // default/hard ceiling: 10,000 + size_t maxClonedNodes; // default/hard ceiling: 1,000,000 + size_t maxClonedBytes; // default/hard ceiling: 64 MiB + size_t maxWork; // default/hard ceiling: 1,000,000 + PatchOptions(); + }; + + // Controls JSON serialization. The default produces the same compact, + // ascending-key output as toString()/write() without options. Pretty + // output places each array element/object member on its own line. Only + // space and tab are valid indentation characters; any other value is + // treated as a space so serialization always remains valid JSON. + // + // Objects are stored in std::map, so source/insertion order is not + // available. Key ordering is therefore explicitly ascending or + // descending according to std::map's bytewise std::string ordering. + struct SerializeOptions { + enum KeyOrder { AscendingKeys, DescendingKeys }; + + bool pretty; + size_t indentWidth; + char indentCharacter; + bool escapeNonAscii; + KeyOrder keyOrder; + size_t maxOutputBytes; // default 64 MiB; zero explicitly means unlimited + + /// Selects compact output, two-space indentation, and ascending keys. + SerializeOptions(); + /// Returns the defaults with pretty printing enabled. + static SerializeOptions prettyPrinted(); + }; + + // Event sink for non-owning SAX parsing. Return false from any callback + // to cancel parsing; public parseSax* APIs return false for cancellation + // or thrown exceptions and populate ParseError when one is supplied. + // + // Callbacks are delivered in source order. Duplicate-key policy still + // applies: RejectDuplicateKeys fails on the duplicate key, + // KeepFirstDuplicate suppresses later duplicate-value subtrees, and + // KeepLastDuplicate accepts duplicates while still reporting both + // occurrences because a streaming SAX walk cannot retract prior events. + // String and key references are borrowed and remain valid only for the + // duration of their callback. The handler itself need only outlive the + // parseSax* call. Default callbacks accept the event and do nothing. + struct SaxHandler { + /// Enables destruction through a SaxHandler base pointer. + virtual ~SaxHandler(); + /// Receives a JSON null value; return false to cancel parsing. + virtual bool onNull(); + /// Receives a JSON boolean value; return false to cancel parsing. + virtual bool onBool(bool aValue); + /// Receives an integer-valued JSON number; return false to cancel parsing. + virtual bool onInt(int64_t aValue); + /// Receives a floating-point JSON number; return false to cancel parsing. + virtual bool onDouble(double aValue); + /// Receives borrowed decoded string bytes; return false to cancel parsing. + virtual bool onString(const std::string& aValue); + /// Marks the beginning of an array; return false to cancel parsing. + virtual bool onStartArray(); + /// Marks the end of an array; return false to cancel parsing. + virtual bool onEndArray(); + /// Marks the beginning of an object; return false to cancel parsing. + virtual bool onStartObject(); + /// Receives a borrowed decoded object key; return false to cancel parsing. + virtual bool onKey(const std::string& aKey); + /// Marks the end of an object; return false to cancel parsing. + virtual bool onEndObject(); + }; + + // One schema-validation failure: `path` is a JSON Pointer to the + // offending node (e.g. "/address/zip", "" for the document root) and + // `message` explains what was wrong. + struct SchemaError { + std::string path; + std::string message; + /// Constructs an error with an empty root path and message. + SchemaError(); + /// Constructs an error for aPath with the supplied diagnostic message. + SchemaError(const std::string& aPath, const std::string& aMsg); + }; + // Bounds schema regular-expression work. By default only a conservative, + // non-ambiguous ECMAScript subset is accepted and both pattern/subject + // sizes are capped, preventing catastrophic std::regex backtracking. + // trustedRegex() restores unrestricted ECMAScript regex behavior for + // schemas and input controlled by the application. + struct SchemaOptions { + size_t maxRegexPatternBytes; // 0 = unlimited (default: 256) + size_t maxRegexSubjectBytes; // 0 = unlimited (default: 4096) + bool allowUnsafeRegex; // default false + /// Recursive depth (default 512); zero still selects the hard ceiling of 512. + size_t maxValidationDepth; + /// Resolved references (default 1024); zero selects the hard ceiling of 1024. + size_t maxRefResolutions; + /// Validation work units (default 1,000,000); zero selects that hard ceiling. + size_t maxValidationWork; + /// Reported errors (default 100); zero selects the hard ceiling of 100. + size_t maxErrors; + bool validateFormats; // validate known string formats (default true) + /// Selects bounded safe-regex, traversal, reference, work, error, and format defaults. + SchemaOptions(); + /// Disables only regex restrictions; all other defaults remain enabled. + static SchemaOptions trustedRegex(); + }; + + //== Construction / lifetime ========================================= + /// Constructs null using the process-lifetime default allocator. + pjson(); + /// Constructs null bound to borrowed aAlloc, which must outlive this tree. + explicit pjson(Allocator& aAlloc) noexcept; + /// Destroys this value and its complete owned subtree. + ~pjson(); + /// Deep-copies aFrom using aFrom's borrowed allocator. + pjson(const pjson& aFrom); + /// Deep-copies aFrom into borrowed aAlloc. + pjson(const pjson& aFrom, Allocator& aAlloc); + /// Transfers aFrom's storage and allocator in O(1), leaving aFrom null. + pjson(pjson&& aFrom) noexcept; + /// Transfers in O(1) when allocators match; otherwise deep-copies then clears aFrom. + pjson(pjson&& aFrom, Allocator& aAlloc); + /// Deep-copies aFrom while preserving this value's allocator. + pjson& operator=(const pjson& aFrom); + /// Moves aFrom while preserving this allocator; cross-allocator moves may allocate. + pjson& operator=(pjson&& aFrom); + /// Deep-copies aFrom while preserving this value's allocator. + void copyFrom(const pjson& aFrom); + /// Destroys the current contents and becomes null. void reset(); + /// Replaces the value with the empty/default value of a valid jsonType. void resetTo(jsonType aeType); + /// Calls resetTo() only when the type differs, otherwise preserving contents. + void resetIfNeeded(jsonType aeType); + // Same-allocator swap is O(1). A cross-allocator swap is rejected as a + // safe no-op; use canSwap() to test before requesting it. + /// Exchanges contents when allocators match; otherwise does nothing. + void swap(pjson& aOther) noexcept; + /// Returns the borrowed allocator bound to this value. + Allocator& getAllocator() const noexcept; + /// Returns whether swap(aOther) can exchange contents. + bool canSwap(const pjson& aOther) const noexcept; + + //== DOM parsing with the default allocator ========================== + // Each parse accepts exactly one JSON value followed only by whitespace. + // In-memory parse failures return an empty pointer; diagnostic overloads + // reset aError and describe the first failure. A byte span may contain + // embedded NUL bytes, but a null aSrc is always an error. + /// Parses aStr into an owning tree using the default allocator. + static pjson::unique_ptr parse(const std::string& aStr, + const ParseOptions& aOpts = ParseOptions()); + /// Parses the aSize-byte span at aSrc using the default allocator. + static pjson::unique_ptr parse(const char* aSrc, size_t aSize, + const ParseOptions& aOpts = ParseOptions()); + /// Parses aStr and reports the first failure in aError. + static pjson::unique_ptr parse(const std::string& aStr, ParseError& aError, + const ParseOptions& aOpts = ParseOptions()); + /// Parses the aSize-byte span and reports the first failure in aError. + static pjson::unique_ptr parse(const char* aSrc, size_t aSize, ParseError& aError, + const ParseOptions& aOpts = ParseOptions()); + + // parseStream() buffers the document in chunks while enforcing + // maxInputBytes. Stream or temporary-buffer exceptions may propagate. + /// Buffers and parses one document from aIn using the default allocator. + static pjson::unique_ptr parseStream(std::istream& aIn, + const ParseOptions& aOpts = ParseOptions()); + /// Buffers and parses aIn, reporting ordinary parse/read failures in aError. + static pjson::unique_ptr parseStream(std::istream& aIn, ParseError& aError, + const ParseOptions& aOpts = ParseOptions()); + + //== DOM parsing with a custom allocator ============================= + // Allocator-aware DOM parsing routes root/child nodes and string/array/ + // object wrapper objects through borrowed aAlloc. Standard-container + // backing buffers still use their standard allocators, as described by + // Allocator above. aAlloc must outlive the returned tree. + /// Parses aStr with allocator-backed nodes and wrapper objects. + static unique_ptr parse(const std::string& aStr, Allocator& aAlloc, + const ParseOptions& aOpts = ParseOptions()); + /// Parses a byte span with allocator-backed nodes and wrapper objects. + static unique_ptr parse(const char* aSrc, size_t aSize, Allocator& aAlloc, + const ParseOptions& aOpts = ParseOptions()); + /// Parses aStr with aAlloc and reports the first failure in aError. + static unique_ptr parse(const std::string& aStr, ParseError& aError, Allocator& aAlloc, + const ParseOptions& aOpts = ParseOptions()); + /// Parses a byte span with aAlloc and reports the first failure in aError. + static unique_ptr parse(const char* aSrc, size_t aSize, ParseError& aError, + Allocator& aAlloc, const ParseOptions& aOpts = ParseOptions()); + /// Buffers aIn, then parses with allocator-backed nodes and wrappers. + static unique_ptr parseStream(std::istream& aIn, Allocator& aAlloc, + const ParseOptions& aOpts = ParseOptions()); + /// Buffers and parses aIn with aAlloc, reporting ordinary failures in aError. + static unique_ptr parseStream(std::istream& aIn, ParseError& aError, Allocator& aAlloc, + const ParseOptions& aOpts = ParseOptions()); + + //== SAX parsing ===================================================== + // SAX parsing retains neither aHandler nor callback arguments. It returns + // false for invalid input, cancellation, stream failure, or a handler + // exception; callbacks already delivered before failure are not undone. + /// Parses aStr and emits its events to aHandler without building a DOM. + static bool parseSax(const std::string& aStr, SaxHandler& aHandler, + const ParseOptions& aOpts = ParseOptions()); + /// Parses the aSize-byte span and emits its events to aHandler. + static bool parseSax(const char* aSrc, size_t aSize, SaxHandler& aHandler, + const ParseOptions& aOpts = ParseOptions()); + /// SAX-parses aStr and reports failure or cancellation in aError. + static bool parseSax(const std::string& aStr, SaxHandler& aHandler, ParseError& aError, + const ParseOptions& aOpts = ParseOptions()); + /// SAX-parses a byte span and reports failure or cancellation in aError. + static bool parseSax(const char* aSrc, size_t aSize, SaxHandler& aHandler, + ParseError& aError, const ParseOptions& aOpts = ParseOptions()); + // True streaming SAX parse: reads the istream incrementally and never + // buffers the full document in memory. + /// Incrementally parses aIn and emits events to aHandler. + static bool parseSaxStream(std::istream& aIn, SaxHandler& aHandler, + const ParseOptions& aOpts = ParseOptions()); + /// Incrementally SAX-parses aIn and reports failure or cancellation in aError. + static bool parseSaxStream(std::istream& aIn, SaxHandler& aHandler, ParseError& aError, + const ParseOptions& aOpts = ParseOptions()); + + //== Serialization =================================================== + // Non-finite stored doubles serialize as JSON null. Invalid UTF-8 in a + // string value or object key is a serialization failure: toString() throws, + // while write() sets failbit (and may propagate stream exceptions). + // toString() may also throw for allocation or length failure. + /// Returns compact JSON using the default serialization options. + std::string toString() const; + /// Returns JSON serialized according to aOpts. + std::string toString(const SerializeOptions& aOpts) const; + /// Writes compact JSON to aOut using the default serialization options. + void write(std::ostream& aOut) const; + /// Writes JSON configured by aOpts to aOut. + void write(std::ostream& aOut, const SerializeOptions& aOpts) const; + + //== Type inspection ================================================= + /// Returns this node's stored JSON representation. + jsonType getType() const; + /// Returns whether this node is null. + bool isNull() const; + /// Returns whether this node stores a string. + bool isString() const; + /// Returns whether this node stores either numeric representation. + bool isNumber() const; + /// Returns whether this node stores an integer representation. + bool isInt() const; + /// Returns whether this node stores a floating-point representation. + bool isDouble() const; + /// Returns whether this node stores a boolean. + bool isBool() const; + /// Returns whether this node stores an array. + bool isArray() const; + /// Returns whether this node stores an object. + bool isObject() const; + + // Minimal C++11-compatible, non-owning view of a JSON string. A view + // aliases bytes owned by this pjson node and is valid only while that + // node remains alive and unchanged. Assignment, reset, swap, move, + // destruction, erasing the node, or replacing/resetting an ancestor + // invalidates it. Strings may contain embedded NUL bytes; use size() + // rather than strlen(). + class StringView { + public: + /// Constructs an empty view with data() == nullptr. + StringView() noexcept; + /// Returns the borrowed first byte; a default view returns null. + const char* data() const noexcept; + /// Returns the number of bytes in the view, including embedded NUL bytes. + size_t size() const noexcept; + /// Returns whether size() is zero. + bool empty() const noexcept; + + private: + friend class pjson; + /// Constructs the internal borrowed view used by tryGet(). + StringView(const char* aData, size_t aSize) noexcept; + + const char* _data; + size_t _size; + }; + + // Strict typed access to this node. On a type mismatch, returns false + // and leaves aResult unchanged. Integers may widen to double; no other + // coercions are performed. StringView avoids a string copy. + /// Extracts an integer only when this node stores jsonNumberInt. + bool tryGet(int64_t& aResult) const noexcept; + /// Extracts a numeric value, widening a stored integer when necessary. + bool tryGet(double& aResult) const noexcept; + /// Extracts a boolean only when this node stores jsonBoolean. + bool tryGet(bool& aResult) const noexcept; + /// Copies a string only when this node stores jsonString. + bool tryGet(std::string& aResult) const; + /// Borrows a string view only when this node stores jsonString. + bool tryGet(StringView& aResult) const noexcept; - pjson& at(const std::string& aString); - pjson& at(const char* aSkey); - pjson& at(int index); + //== Container queries =============================================== + /// Returns the element/member count for containers, or zero for scalars. + size_t size() const; + /// Returns whether size() is zero; consequently all scalar values are empty. + bool empty() const; + /// Empties a container without changing its type, or resets a scalar to null. + void clear(); - //Assignment overload - pjson& operator[] (const std::string& aString); - pjson& operator[] (const char* aSkey); - pjson& operator[] (int index); + /// Returns copied object keys in std::map order, or an empty vector otherwise. + std::vector keys() const; + //== Non-mutating lookup ============================================= + /// Returns whether this object contains aKey. + bool hasKey(const std::string& aKey) const; + /// Returns whether this object contains non-null aKey; null returns false. + bool hasKey(const char* aKey) const; + /// Returns whether this array contains aIndex; negative indexes count from the end. + bool hasIndex(int aIndex) const noexcept; + + // Returns a pointer to the child stored under aKey, or nullptr when + // this is not a map or the key is absent. Unlike operator[], this + // never creates or mutates anything. + /// Returns the borrowed child at aKey, or null when absent or not an object. + pjson* find(const std::string& aKey); + /// Returns the borrowed child at non-null aKey, or null on failure. + pjson* find(const char* aKey); + /// Returns the read-only borrowed child at aKey, or null on failure. + const pjson* find(const std::string& aKey) const; + /// Returns the read-only borrowed child at non-null aKey, or null on failure. + const pjson* find(const char* aKey) const; + + // Non-vivifying array lookup. Negative indexes count from the end + // (-1 is the last element); indexes outside the array return nullptr. + // These overloads never change this node or its size. + /// Returns the borrowed array child at aIndex, or null on failure. + pjson* find(int aIndex) noexcept; + /// Returns the read-only borrowed array child at aIndex, or null on failure. + const pjson* find(int aIndex) const noexcept; + + // RFC 6901 lookup. The empty pointer addresses this value; every + // non-empty pointer must begin with '/'. Lookups are iterative and + // never create missing nodes. The '-' token is not a lookup index. + /// Escapes one decoded reference token for inclusion in a JSON Pointer. + static std::string escapePointerToken(const std::string& aToken); + /// Resolves aPointer and returns the borrowed target, or null on failure. + pjson* findPointer(const std::string& aPointer); + /// Resolves aPointer and returns the read-only borrowed target, or null. + const pjson* findPointer(const std::string& aPointer) const; + /// Resolves aPointer and reports lookup failure in aError. + pjson* findPointer(const std::string& aPointer, PointerError& aError); + /// Resolves aPointer read-only and reports lookup failure in aError. + const pjson* findPointer(const std::string& aPointer, PointerError& aError) const; + /// Resolves a non-null pointer string; null returns failure. + pjson* findPointer(const char* aPointer); + /// Resolves a non-null pointer string read-only; null returns failure. + const pjson* findPointer(const char* aPointer) const; + /// Resolves a non-null pointer string and reports failure in aError. + pjson* findPointer(const char* aPointer, PointerError& aError); + /// Resolves a non-null pointer string read-only and reports failure in aError. + const pjson* findPointer(const char* aPointer, PointerError& aError) const; + + // Strict typed child access layered on find() and node-level tryGet(). + // Missing/null keys, invalid indexes, and type mismatches leave aResult + // unchanged. Negative indexes count from the end. + /// Extracts the integer child at aKey without mutating this object. + bool tryGet(const std::string& aKey, int64_t& aResult) const; + /// Extracts the numeric child at aKey as a double. + bool tryGet(const std::string& aKey, double& aResult) const; + /// Extracts the boolean child at aKey. + bool tryGet(const std::string& aKey, bool& aResult) const; + /// Copies the string child at aKey. + bool tryGet(const std::string& aKey, std::string& aResult) const; + /// Borrows a view of the string child at aKey. + bool tryGet(const std::string& aKey, StringView& aResult) const; + + /// Extracts the integer child at non-null aKey. + bool tryGet(const char* aKey, int64_t& aResult) const; + /// Extracts the numeric child at non-null aKey as a double. + bool tryGet(const char* aKey, double& aResult) const; + /// Extracts the boolean child at non-null aKey. + bool tryGet(const char* aKey, bool& aResult) const; + /// Copies the string child at non-null aKey. + bool tryGet(const char* aKey, std::string& aResult) const; + /// Borrows a view of the string child at non-null aKey. + bool tryGet(const char* aKey, StringView& aResult) const; + + /// Extracts the integer array child at aIndex. + bool tryGet(int aIndex, int64_t& aResult) const noexcept; + /// Extracts the numeric array child at aIndex as a double. + bool tryGet(int aIndex, double& aResult) const noexcept; + /// Extracts the boolean array child at aIndex. + bool tryGet(int aIndex, bool& aResult) const noexcept; + /// Copies the string array child at aIndex. + bool tryGet(int aIndex, std::string& aResult) const; + /// Borrows a view of the string array child at aIndex. + bool tryGet(int aIndex, StringView& aResult) const noexcept; + + //== Building / mutable access ======================================= + // operator[] is a direct builder API. A key access changes a non-object + // into an object and creates a missing null child. An index access changes + // a non-array into an array; negative indexes count from the end and clamp + // before the beginning to zero, while indexes past the end grow the array + // with null children. A single access that would create more than one + // million children throws std::length_error before mutation. Use + // find()/tryGet() for reads. + /// Returns or creates the child at aString. + pjson& operator[](const std::string& aString); + /// Returns or creates the child at aSkey; throws std::invalid_argument for null. + pjson& operator[](const char* aSkey); + /// Returns or creates the child at index under the auto-growth rules above. + pjson& operator[](int index); + + // Assign a scalar value, replacing whatever this node was. Numbers are + // stored as int64_t (integers) or double (floating point). + /// Replaces this value with a copy of aString. pjson& operator=(const std::string& aString); + /// Replaces this value with aCString; throws std::invalid_argument for null. pjson& operator=(const char* aCString); - pjson& operator=(const int aInt); - pjson& operator=(const float aFloat); + /// Replaces this value with aBool. pjson& operator=(const bool aBool); + /// Replaces this value with aInt. + pjson& operator=(const int64_t aInt); + /// Replaces this value with aDouble; non-finite values serialize as null. + pjson& operator=(const double aDouble); + // Vector assignment atomically replaces this node with an array of copied + // children allocated through this node's allocator. + /// Replaces this value with a copied string array. pjson& operator=(const std::vector& aValueArray); - pjson& operator=(const std::vector& aValueArray); - pjson& operator=(const std::vector& aValueArray); - pjson& operator=(const std::vector& aValueArray); - pjson& operator=(const std::vector& aValueArray); + /// Replaces this value with a copied boolean array. pjson& operator=(const std::vector& aValueArray); + /// Replaces this value with a copied integer array. + pjson& operator=(const std::vector& aValueArray); + /// Replaces this value with a copied double array. + pjson& operator=(const std::vector& aValueArray); + // Scalar append adds one copied child. If this node is not already an + // array, its previous value is discarded rather than retained. + /// Appends a copy of aValue as a string child. pjson& operator+=(const std::string& aValue); + /// Appends aValue as a string child; throws std::invalid_argument for null. pjson& operator+=(const char* aValue); - pjson& operator+=(const int aValue); - pjson& operator+=(const float aValue); + /// Appends aValue as a boolean child. pjson& operator+=(const bool aValue); + /// Appends aValue as an integer child. + pjson& operator+=(const int64_t aValue); + /// Appends aValue as a double child. + pjson& operator+=(const double aValue); + // Vector append copies every element. A non-array's prior value is + // discarded; even an empty vector promotes a non-array to an empty array. + /// Appends every string in aValueArray. pjson& operator+=(const std::vector& aValueArray); - pjson& operator+=(const std::vector& aValueArray); - pjson& operator+=(const std::vector& aValueArray); - pjson& operator+=(const std::vector& aValueArray); - pjson& operator+=(const std::vector& aValueArray); + /// Appends every boolean in aValueArray. pjson& operator+=(const std::vector& aValueArray); + /// Appends every integer in aValueArray. + pjson& operator+=(const std::vector& aValueArray); + /// Appends every double in aValueArray. + pjson& operator+=(const std::vector& aValueArray); - bool getArrayValues(size_t aFrom, size_t aTo, std::vector& aDest); - bool getArrayValues(size_t aFrom, size_t aTo, std::vector& aDest); - bool getArrayValues(size_t aFrom, size_t aTo, std::vector& aDest); - bool getArrayValues(size_t aFrom, size_t aTo, std::vector& aDest); - - /* - * Encodes a data buffer as a JSON-safe string by escaping special characters - * @param data Pointer to the data buffer - * @param length Length of the data buffer - * @return JSON-safe string representation - */ - static std::string EncodeForJSON(const char* data, size_t length); - - /* - * Encodes a data buffer as a Base64 string for JSON embedding - * @param data Pointer to the data buffer - * @param length Length of the data buffer - * @return Base64-encoded string - */ - static std::string EncodeBase64ForJSON(const char* data, size_t length); - - /* - * Decodes a JSON-safe string back to its original form - * @param jsonStr The JSON-encoded string - * @return The decoded string - */ - static std::string DecodeFromJSON(const std::string& jsonStr); - - /* - * Decodes a Base64 string back to its original binary data - * @param base64Str The Base64-encoded string - * @return The decoded binary data as a string - */ - static std::string DecodeBase64FromJSON(const std::string& base64Str); + // Remove and free the child under a map key / at an array index. + // Array indexes are zero-based and erasure shifts later elements left. + /// Erases aKey and returns whether an object member was removed. + bool erase(const std::string& aKey); + /// Erases non-null aKey; null or a non-object returns false. + bool erase(const char* aKey); + /// Erases aIndex and returns whether an array element was removed. + bool erase(size_t aIndex); - private: - std::string _toString(int a_iIndent) const; - void _resetIfneeded(jsonType aeType); - static bool _CreateFromString(const char* aSrc, size_t& a_iStart, size_t a_iEnd, pjson*& a_rResult); - - static bool _ScanPastColon(const char* aSrc, size_t& a_iStart,const size_t a_iEnd); - static bool _ExtractString(const char* aSrc, size_t& a_iStart,const size_t a_iEnd, std::string& aStrResult); - static bool _ScanString(const char* aSrc, size_t& a_iStart,const size_t a_iEnd, pjson*& a_rStrResult); - static bool _ScanBool(const char* aSrc, size_t& a_iStart,const size_t a_iEnd, pjson*& a_rBoolResult); - static bool _ScanToNext(const char* aSrc, size_t& a_iStart,const size_t a_iEnd, char& a_rResult); - static bool _ScanNull(const char* aSrc, size_t& a_iStart,const size_t a_iEnd, pjson*& a_rNUllResult); - static bool _ScanNumber(const char* aSrc, size_t& a_iStart, const size_t a_iEnd, pjson*& a_rNumResult); - static bool _ScanArray(const char* aSrc, size_t& a_iStart, const size_t a_iEnd, pjson*& a_rAResult); - static bool _ScanObject(const char* aSrc, size_t& a_iStart, const size_t a_iEnd, pjson*& a_rAResult); + // Applies all RFC 6902 operations to a scratch document and commits + // only if every operation succeeds. RFC 7396 Merge Patch is likewise + // atomic and uses an iterative traversal for deeply nested objects. + // These bool-returning boundaries convert allocation and internal + // exceptions into PatchError instead of allowing them to escape. Patch + // input is borrowed and unchanged; successful commit invalidates prior + // views into the target. Overloads without aError discard diagnostics. + /// Atomically applies an RFC 6902 patch document. + bool applyPatch(const pjson& aPatch, const PatchOptions& aOpts = PatchOptions()) noexcept; + /// Atomically applies RFC 6902 and reports failure details in aError. + bool applyPatch(const pjson& aPatch, PatchError& aError, + const PatchOptions& aOpts = PatchOptions()) noexcept; + /// Atomically applies an RFC 7396 Merge Patch document. + bool applyMergePatch(const pjson& aPatch, + const PatchOptions& aOpts = PatchOptions()) noexcept; + /// Atomically applies RFC 7396 and reports failure details in aError. + bool applyMergePatch(const pjson& aPatch, PatchError& aError, + const PatchOptions& aOpts = PatchOptions()) noexcept; + + //== Equality (deep, structural) ===================================== + // Integer and floating nodes compare equal when numerically equal + // (e.g. 1 == 1.0). Arrays compare element-wise in order; objects + // compare by key/value regardless of insertion order. + /// Returns whether this value and aOther are structurally equal. + bool operator==(const pjson& aOther) const; + /// Returns the negation of operator==. + bool operator!=(const pjson& aOther) const; + + //== Schema validation =============================================== + // Validates this value against a schema that is itself a pjson object, + // using the documented JSON Schema subset; this is not a complete draft + // implementation. Returns true when the + // value conforms. Never throws. The second form appends reported + // keyword failures rather than stopping at the first. Errors inside + // non-selected anyOf/oneOf/not branches are intentionally suppressed, + // and a resource-budget failure can stop further validation. + // + // Supported keywords: + // type, enum, const, + // $ref (local JSON Pointer fragments), + // properties, patternProperties, propertyNames, required, + // dependentRequired, dependencies, additionalProperties, + // minProperties, maxProperties, + // items, minItems, maxItems, uniqueItems, + // minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, + // minLength, maxLength, pattern, format, + // allOf, anyOf, oneOf, not. + // A boolean schema (true/false) accepts/rejects everything. Unknown + // keywords and unsupported keyword shapes are ignored. Both inputs are + // borrowed and unchanged; the collecting overload appends to aErrors + // without clearing existing entries. Resource aborts may stop collection. + /// Returns whether this value satisfies aSchema under aOpts. + bool validate(const pjson& aSchema, + const SchemaOptions& aOpts = SchemaOptions()) const noexcept; + /// Validates and appends discovered failures to aErrors. + bool validate(const pjson& aSchema, std::vector& aErrors, + const SchemaOptions& aOpts = SchemaOptions()) const noexcept; private: + //== Internal helpers ================================================ + // The parser, schema validator, and encoding routines live entirely in + // pjson.cpp as the pjsonImpl helper struct, so this header stays small. + // pjsonImpl is a friend so it can touch the data union directly; only + // the few instance helpers other members call are declared here. + friend struct pjsonImpl; + friend struct ValueDeleter; + + /// Iteratively deep-copies aFrom's contents using this node's allocator. + void copyContentsFrom(const pjson& aFrom); + //== Data ============================================================ + typedef std::vector ArrayStorage; + typedef std::map ObjectStorage; + + Allocator* _allocator; + bool _allocatorOwnedNode; + // Intrusive scratch link used only by allocation-free iterative tree + // destruction. It is null during normal object lifetime. + pjson* _disposeNext; jsonType _eType = jsonType::jsonNull; - union { - void* _pValueRaw = nullptr; - PJSONMAP* _pValueMap; - PJSONARRAY* _pValueArray; - int* _pValueInt; - float* _pValueFloat; - bool* _pValueBool; + union Storage { + void* _pValueRaw; + ObjectStorage* _pValueMap; + ArrayStorage* _pValueArray; + int64_t _valueInt; + double _valueDouble; + bool _valueBool; std::string* _pValueString; - /* data */ - }; + + /// Initializes the raw representation to null. + Storage(); + } _uValue; }; -//======================================================================== -};// end namespace ByteDance + //======================================================================== +}; // end namespace ByteDance #endif /* !PRAVEENJSON_H */ diff --git a/pjsonlib/src/pjson.cpp b/pjsonlib/src/pjson.cpp index a1cbed4..8631382 100644 --- a/pjsonlib/src/pjson.cpp +++ b/pjsonlib/src/pjson.cpp @@ -17,1108 +17,6027 @@ // License: Apache 2.0 // #include "pjson.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + using namespace ByteDance; -//----------------------------------------------------------------- -pjson::pjson() - : _eType(jsonType::jsonNull) - , _pValueRaw(nullptr) -{ +//===----------------------------------------------------------------------===// +// pjsonImpl — all parsing, schema-validation, and encoding helpers. +// +// Keeping implementation-only operations in one friend struct leaves pjson.h +// declaration-focused while allowing these helpers to maintain DOM invariants. +//===----------------------------------------------------------------------===// +struct ByteDance::pjsonImpl { + // Public APIs deliberately hide the owning container representation. + typedef std::vector ArrayStorage; + typedef std::map ObjectStorage; -} -//----------------------------------------------------------------- -pjson::~pjson() { - reset(); -} -//----------------------------------------------------------------- -pjson::pjson(const pjson& aFrom) - : _eType(jsonType::jsonNull) - , _pValueRaw(nullptr) -{ - copyFrom(aFrom); -} -//----------------------------------------------------------------- -// Move constructor -pjson::pjson(pjson&& aFrom) { - _eType = aFrom._eType; - _pValueRaw = aFrom._pValueRaw; - aFrom._pValueRaw = nullptr; - aFrom._eType = jsonType::jsonNull; -} -//----------------------------------------------------------------- -// Move assignment -pjson& pjson::operator=(pjson&& aFrom) { - if (&aFrom == this) - return *this; + // Parser state threaded through the recursive-descent scanner: the input + // buffer, cursor, options, current/maximum nesting depth, a running count + // of allocated nodes (bounded by maxNodes to stop memory-amplification + // attacks), and the first error encountered (if any). + struct ParseCtx { + const char* src; + size_t pos; + size_t end; + pjson::ParseOptions::DuplicateKeyPolicy duplicateKeys; + int depth; + int maxDepth; + size_t nodeCount; + size_t maxNodes; // 0 = unlimited + pjson::Allocator* allocator; + bool failed; + size_t errPos; + std::string errMsg; + }; - reset(); - _eType = aFrom._eType; - _pValueRaw = aFrom._pValueRaw; + // One suspended container in the iterative serializer. Exactly one of + // array/object is active according to isObject; the associated cursor + // always denotes the next child to emit. + struct SerializeFrame { + bool isObject; + size_t depth; + bool first; + const ArrayStorage* array; + size_t arrayIndex; + const ObjectStorage* object; + ObjectStorage::const_iterator objectIt; + ObjectStorage::const_reverse_iterator objectReverseIt; + }; - aFrom._eType = jsonType::jsonNull; - aFrom._pValueRaw = nullptr; + // One compiled schema regex or a cached policy/syntax rejection. Keeping + // failures in the cache is as important as caching successful compilation: + // patternProperties must not repeatedly parse an invalid expression. + struct RegexCacheEntry { + enum State { Uninitialized, Ready, PatternTooLarge, UnsafePattern, InvalidPattern }; - return *this; -} -//----------------------------------------------------------------- -// Copy assignment -pjson& pjson::operator=(const pjson& aFrom) { - if (&aFrom == this) - return *this; + State state; + std::regex expression; - //reset(); // CopyFrom will do a reset anyways - copyFrom(aFrom); - return *this; -} -//----------------------------------------------------------------- -pjson::jsonType pjson::getType() const { - return _eType; -}; -//----------------------------------------------------------------- -pjson::PJSONARRAY* pjson::getArray() { - if(_eType == jsonType::jsonArray) { - return _pValueArray; + RegexCacheEntry() + : state(Uninitialized) {} + }; + + // Mutable limits and recursion state shared by one schema-validation run. + // activeRefs tracks (instance, schema) pairs rather than schema nodes alone: + // revisiting a schema at a different instance is legitimate, while the same + // pair indicates a cyclic $ref evaluation. + struct SchemaValidationCtx { + const pjson& rootSchema; + const pjson::SchemaOptions& options; + std::vector* publicErrors; + size_t depth; + size_t refResolutions; + size_t workUsed; + size_t errorsUsed; + size_t publicErrorStart; + bool aborted; + std::vector> activeRefs; + std::map regexCache; + + // Starts a validation run with no active recursion or resolved references. + SchemaValidationCtx(const pjson& aRootSchema, const pjson::SchemaOptions& aOptions, + std::vector* aPublicErrors) + : rootSchema(aRootSchema) + , options(aOptions) + , publicErrors(aPublicErrors) + , depth(0) + , refResolutions(0) + , workUsed(0) + , errorsUsed(0) + , publicErrorStart(aPublicErrors == nullptr ? 0 : aPublicErrors->size()) + , aborted(false) {} + }; + + struct SchemaBudgetExceeded {}; + + // Facade over a caller or speculative error vector that enforces one shared + // per-validation diagnostic budget without exposing a public container type. + struct SchemaErrorSink { + std::vector& values; + SchemaValidationCtx& ctx; + bool reported; + size_t discardedFailures; + + SchemaErrorSink(std::vector& aValues, SchemaValidationCtx& aCtx, + bool aReported = true) + : values(aValues) + , ctx(aCtx) + , reported(aReported) + , discardedFailures(0) {} + + size_t size() const { return reported ? values.size() : discardedFailures; } + + void push_back(const pjson::SchemaError& error) { + if (ctx.aborted) + return; + if (!reported) { + // Speculative anyOf/oneOf/not branches need only a pass/fail + // signal. Retaining every hidden diagnostic would let an + // attacker amplify a compact schema into large scratch vectors. + (void)error; + if (discardedFailures != std::numeric_limits::max()) + ++discardedFailures; + return; + } + const size_t limit = ctx.options.maxErrors == 0 ? size_t(100) : ctx.options.maxErrors; + if (ctx.errorsUsed >= limit) { + ctx.aborted = true; + if (ctx.publicErrors != nullptr && + ctx.publicErrors->size() - ctx.publicErrorStart < limit) { + try { + ctx.publicErrors->push_back(pjson::SchemaError( + error.path, "schema validation error budget exceeded")); + } catch (...) { + // The validation result remains a safe failure even if + // the best-effort terminal diagnostic cannot allocate. + ctx.publicErrors = nullptr; + } + } + throw SchemaBudgetExceeded(); + } + values.push_back(error); + ++ctx.errorsUsed; + } + }; + + static bool _isWhitespace(char c); + static void _appendUtf8(uint32_t aCodePoint, std::string& aOut); + static bool _hex4(const char* aSrc, size_t aStart, uint32_t& aOut); + static int _utf8Len(const char* src, size_t pos, size_t end); + static std::string _formatDouble(double aValue); + static bool _parseDouble(const std::string& aText, double& aValue); + + static bool _fail(ParseCtx& c, size_t aPos, const char* aMsg); + static pjson* _newNode(ParseCtx& c); // budget-checked allocation (nullptr on overflow) + static bool _peek(ParseCtx& c, char& aOut); + static bool _skipColon(ParseCtx& c); + static bool _parseValue(ParseCtx& c, pjson*& aOut); + static bool _parseString(ParseCtx& c, pjson*& aOut); + static bool _extractString(ParseCtx& c, std::string& aOut); + static bool _decodeStringBody(ParseCtx& c, std::string& aOut, bool bStopAtQuote); + static bool _parseKeyword(ParseCtx& c, pjson*& aOut); + static bool _parseNumber(ParseCtx& c, pjson*& aOut); + static bool _parseArray(ParseCtx& c, pjson*& aOut); + static bool _parseObject(ParseCtx& c, pjson*& aOut); + static pjson::unique_ptr _parseTop(const char* aSrc, size_t aSize, + const pjson::ParseOptions& aOpts, pjson::ParseError* aErr, + pjson::Allocator& aAlloc); + static pjson::unique_ptr _parseStream(std::istream& aIn, const pjson::ParseOptions& aOpts, + pjson::ParseError* aErr, pjson::Allocator& aAlloc); + template + static bool _writeEscapedTo(Sink& aOut, const std::string& aIn, bool bEscapeNonAscii); + template + static bool _openOrEmit(Sink& aOut, const pjson* aValue, size_t aDepth, + const pjson::SerializeOptions& aOpts, + std::vector& aFrames); + template + static bool _writeValueTo(Sink& aOut, const pjson& aValue, + const pjson::SerializeOptions& aOpts); + static void _appendValue(std::string& aOut, const pjson& aValue, + const pjson::SerializeOptions& aOpts); + static bool _writeValue(std::ostream& aOut, const pjson& aValue, + const pjson::SerializeOptions& aOpts); + static bool _parseSaxTop(const char* aSrc, size_t aSize, pjson::SaxHandler& aHandler, + const pjson::ParseOptions& aOpts, pjson::ParseError* aErr); + static bool _parseSaxStream(std::istream& aIn, pjson::SaxHandler& aHandler, + const pjson::ParseOptions& aOpts, pjson::ParseError* aErr); + + static std::string _pointerAppend(const std::string& aBase, const std::string& aToken); + static bool _validateCtx(const pjson& aNode, const pjson& aSchema, const std::string& aPath, + SchemaErrorSink& aErrors, SchemaValidationCtx& aCtx); + static bool _validate(const pjson& aNode, const pjson& aSchema, const std::string& aPath, + std::vector& aErrors, + const pjson::SchemaOptions& aOpts) noexcept; + static bool _typeMatches(const pjson& aNode, const std::string& aTypeName); + static std::string _typeName(const pjson& aNode); + static bool _isSafeRegex(const std::string& aPattern); + + // Internal typed/storage access keeps representation and permissive + // conversion helpers out of the public API. Callers first establish type. + static ArrayStorage& _array(pjson& aValue) { return *aValue._uValue._pValueArray; } + static const ArrayStorage& _array(const pjson& aValue) { return *aValue._uValue._pValueArray; } + static ObjectStorage& _object(pjson& aValue) { return *aValue._uValue._pValueMap; } + static const ObjectStorage& _object(const pjson& aValue) { return *aValue._uValue._pValueMap; } + static int64_t _integer(const pjson& aValue) { return aValue._uValue._valueInt; } + static double _floating(const pjson& aValue) { return aValue._uValue._valueDouble; } + static double _numberAsDouble(const pjson& aValue) { + return aValue._eType == pjson::jsonNumberInt ? static_cast(aValue._uValue._valueInt) + : aValue._uValue._valueDouble; } - return nullptr; -} -//----------------------------------------------------------------- -pjson::PJSONMAP* pjson::getMap() { - if(_eType == jsonType::jsonMap) { - return _pValueMap; + static bool _boolean(const pjson& aValue) { return aValue._uValue._valueBool; } + static const std::string& _string(const pjson& aValue) { return *aValue._uValue._pValueString; } + // Returns -1, 0, or 1, and 2 when either floating operand is NaN. + static int _compareNumbers(const pjson& aLeft, const pjson& aRight); + static bool _equalWithBudget(const pjson& aLeft, const pjson& aRight, SchemaValidationCtx& aCtx, + SchemaErrorSink& aErrors, const std::string& aPath, bool& aEqual); + + // Iteratively frees every descendant pjson of node's array/map, leaving the + // node's own top-level container allocated but empty (a no-op for scalars). + // Using an explicit work-list instead of the recursive destructor keeps + // teardown safe on arbitrarily deep documents. Marked noexcept: it is + // reached from ~pjson, so an allocation failure here terminates rather than + // escaping a destructor. + static void _disposeChildren(pjson& node) noexcept; + static pjson::Allocator& _defaultAllocator() noexcept; + static pjson* _allocateNode(pjson::Allocator& aAlloc); + static void _destroyNode(pjson* aValue) noexcept; + static pjson::unique_ptr _makeNode(pjson::Allocator& aAlloc); + static pjson::unique_ptr _cloneNode(const pjson& aValue, pjson::Allocator& aAlloc); +}; + +// File-scope aliases keep internal type names concise without exposing the +// owning containers in the public header. +typedef pjson::jsonType jsonType; +typedef pjsonImpl::ArrayStorage PJSONARRAY; +typedef pjsonImpl::ObjectStorage PJSONMAP; +typedef pjson::SchemaError SchemaError; +typedef pjson::SchemaOptions SchemaOptions; +typedef pjson::ParseOptions ParseOptions; +typedef pjson::ParseError ParseError; +typedef pjson::SaxHandler SaxHandler; +typedef pjsonImpl::ParseCtx ParseCtx; + +namespace { + //===------------------------------------------------------------------===// + // Parse diagnostics and SAX cursor adapters + //===------------------------------------------------------------------===// + + // Converts a zero-based byte offset into one-based source coordinates. CRLF + // counts as one line ending; a lone CR or LF also starts a new line. + void lineAndColumn(const char* src, size_t size, size_t offset, size_t& line, size_t& column) { + line = 1; + column = 1; + const size_t end = offset < size ? offset : size; + for (size_t i = 0; i < end; ++i) { + if (src[i] == '\r') { + if (i + 1 < end && src[i + 1] == '\n') + ++i; + ++line; + column = 1; + } else if (src[i] == '\n') { + ++line; + column = 1; + } else { + ++column; + } + } } - return nullptr; -} -//----------------------------------------------------------------- -float pjson::getFloat() { - if(_eType == jsonType::jsonNumberInt) { - return float(*_pValueInt); - } else if(_eType == jsonType::jsonNumberFloat) { - return *_pValueFloat; + + // Publishes a buffer-parser failure, deriving source coordinates from the + // authoritative byte offset. A null destination intentionally discards it. + void setParseError(ParseError* err, const char* src, size_t size, size_t offset, + const std::string& message) { + if (!err) + return; + err->ok = false; + err->offset = offset; + lineAndColumn(src, size, offset, err->line, err->column); + err->message = message; } - return 0.0f; -} -//----------------------------------------------------------------- -int pjson::getInt() { - if(_eType == jsonType::jsonNumberInt) { - return *_pValueInt; - } else if(_eType == jsonType::jsonNumberFloat) { - return int(*_pValueFloat); + + // Restores the public error object to its successful, start-of-input state. + void resetParseError(ParseError* err) { + if (!err) + return; + err->ok = true; + err->offset = 0; + err->line = 1; + err->column = 1; + err->message.clear(); } - return 0; -} -//----------------------------------------------------------------- -bool pjson::getBool() { - switch (_eType) { - case jsonType::jsonNull: { - return false; + + // Internal control-flow exception used to unwind immediately when a SAX + // callback returns false; parseDocument converts it back into ParseError. + class SaxParseCancelled : public std::exception { + public: + // Supplies a stable diagnostic if cancellation escapes an internal frame. + const char* what() const noexcept override { return "SAX parse aborted"; } + }; + + // Non-owning cursor over a contiguous input buffer. Positions are byte + // offsets, while line/column values are maintained incrementally. + class BufferSaxCursor { + public: + // Binds the cursor to caller-owned bytes, which must outlive parsing. + BufferSaxCursor(const char* src, size_t size) + : _src(src) + , _size(size) + , _pos(0) + , _line(1) + , _column(1) + , _prevWasCR(false) {} + + // Observes the next byte without advancing source coordinates. + bool peek(char& ch) { + if (_pos >= _size) + return false; + ch = _src[_pos]; + return true; } - case jsonType::jsonString: { - return (_pValueString->length() > 0); + + // Consumes one byte and advances CR/LF-aware source coordinates. + bool get(char& ch) { + if (!peek(ch)) + return false; + advance(ch); + ++_pos; + return true; } - case jsonType::jsonNumberInt: { - return bool(*_pValueInt); + + // Reports whether every byte in the fixed buffer has been consumed. + bool eof() const { return _pos >= _size; } + // A memory cursor cannot suffer an I/O failure. + bool failed() const { return false; } + // Returns the zero-based byte offset of the next input byte. + size_t position() const { return _pos; } + // Returns the one-based line containing the next input byte. + size_t line() const { return _line; } + // Returns the one-based column containing the next input byte. + size_t column() const { return _column; } + + private: + // Counts CRLF as one newline even though its bytes arrive separately. + void advance(char ch) { + if (ch == '\r') { + ++_line; + _column = 1; + _prevWasCR = true; + } else if (ch == '\n') { + if (_prevWasCR) { + _prevWasCR = false; + } else { + ++_line; + _column = 1; + } + } else { + ++_column; + _prevWasCR = false; + } } - case jsonType::jsonNumberFloat: { - return bool(*_pValueFloat); + + const char* _src; + size_t _size; + size_t _pos; + size_t _line; + size_t _column; + bool _prevWasCR; + }; + + // Buffered cursor that gives the SAX parser the same interface for streams + // without first materializing the complete input. + class StreamSaxCursor { + public: + // Binds to a caller-owned stream and delays reads until bytes are needed. + explicit StreamSaxCursor(std::istream& in) + : _in(in) + , _used(0) + , _posInBuf(0) + , _pos(0) + , _line(1) + , _column(1) + , _prevWasCR(false) + , _failed(false) + , _eof(false) {} + + // Observes the next buffered byte, refilling on demand. + bool peek(char& ch) { + if (!ensure()) + return false; + ch = _buffer[_posInBuf]; + return true; } - case jsonType::jsonBoolean: { - return (*_pValueBool); + + // Consumes one byte while maintaining absolute and source positions. + bool get(char& ch) { + if (!ensure()) + return false; + ch = _buffer[_posInBuf++]; + if (ch == '\r') { + ++_line; + _column = 1; + _prevWasCR = true; + } else if (ch == '\n') { + if (_prevWasCR) { + _prevWasCR = false; + } else { + ++_line; + _column = 1; + } + } else { + ++_column; + _prevWasCR = false; + } + ++_pos; + return true; } - break; - case jsonType::jsonArray: - case jsonType::jsonMap: - default: + + // Reports EOF only after both the stream and the refill buffer are empty. + bool eof() const { return _eof && _posInBuf >= _used; } + // Distinguishes an I/O failure from an ordinary end of stream. + bool failed() const { return _failed; } + // Returns the number of bytes consumed across all refills. + size_t position() const { return _pos; } + // Returns the one-based line containing the next input byte. + size_t line() const { return _line; } + // Returns the one-based column containing the next input byte. + size_t column() const { return _column; } + + private: + // Makes one byte available unless EOF or an unrecoverable read failure + // has already been observed. Short reads with data are still usable. + bool ensure() { + if (_posInBuf < _used) + return true; + if (_eof || _failed) + return false; + // Pull directly from streambuf so a source that intentionally + // exposes one short chunk at a time is not mistaken for EOF by + // istream::read's exact-count semantics. One byte is sufficient for + // the parser; the streambuf retains any remaining get-area bytes. + std::streambuf* buffer = _in.rdbuf(); + if (buffer == nullptr || _in.bad()) { + _failed = true; + return false; + } + const std::streambuf::int_type next = buffer->sbumpc(); + if (!std::streambuf::traits_type::eq_int_type(next, + std::streambuf::traits_type::eof())) { + _buffer[0] = std::streambuf::traits_type::to_char_type(next); + _used = 1; + _posInBuf = 0; + return true; + } + if (_in.bad()) { + _failed = true; + return false; + } + _eof = true; return false; - break; - } - return false; -} -//----------------------------------------------------------------- -std::string pjson::getString() { - return (_eType == jsonType::jsonString)? (*_pValueString): ""; -} -//----------------------------------------------------------------- -void pjson::reset() { - resetTo(jsonType::jsonNull); -} -//----------------------------------------------------------------- -void pjson::_resetIfneeded(jsonType aeType) { - if(_eType != aeType) { - resetTo(aeType); - } -} -//----------------------------------------------------------------- -void pjson::resetTo(pjson::jsonType aeType) { - switch(_eType) { - case jsonType::jsonNull: { _pValueRaw = nullptr; break; } - case jsonType::jsonString: { delete _pValueString; break; } - case jsonType::jsonNumberInt: { delete _pValueInt; break; } - case jsonType::jsonNumberFloat: { delete _pValueFloat; break; } - case jsonType::jsonBoolean: { delete _pValueBool; break; } - case jsonType::jsonArray: { - for(pjson* pj : *_pValueArray) { - delete pj; - } - delete _pValueArray; - break; } - case jsonType::jsonMap: { - for (const auto& kv : *_pValueMap) { - delete kv.second; + + std::istream& _in; + char _buffer[8192]; + size_t _used; + size_t _posInBuf; + size_t _pos; + size_t _line; + size_t _column; + bool _prevWasCR; + bool _failed; + bool _eof; + }; + + // Recursive-descent event parser shared by buffer and stream cursors. It + // applies the same grammar, resource budgets, and duplicate-key policy as + // DOM parsing, but can suppress callbacks for KeepFirstDuplicate values. + template struct SaxParser { + Cursor& cur; + SaxHandler& handler; + const ParseOptions& opts; + ParseError* err; + size_t nodeCount; + + // Couples a cursor and event sink for one parse, with fresh node accounting. + SaxParser(Cursor& aCur, SaxHandler& aHandler, const ParseOptions& aOpts, ParseError* aErr) + : cur(aCur) + , handler(aHandler) + , opts(aOpts) + , err(aErr) + , nodeCount(0) {} + + // Parses exactly one complete document, translating parser, handler, + // allocation, and stream failures into a stable non-throwing result. + bool parseDocument() noexcept { + try { + resetParseError(err); + if (!parseValue(0, true)) + return false; + if (!skipWhitespace()) + return false; + char ch = 0; + if (opts.maxInputBytes != 0 && cur.position() >= opts.maxInputBytes) { + if (cur.peek(ch)) + return failAt(opts.maxInputBytes, cur.line(), cur.column(), + "input exceeds maxInputBytes"); + } else if (cur.peek(ch)) { + return fail("trailing characters after JSON value"); + } + if (cur.failed()) + return fail("stream read failed"); + return true; + } catch (const SaxParseCancelled&) { + return failNoThrow("SAX parse aborted"); + } catch (const std::bad_alloc&) { + return failNoThrow("SAX parse ran out of memory"); + } catch (const std::exception&) { + return failNoThrow("SAX parse or handler exception"); + } catch (...) { + return failNoThrow("SAX parse or handler exception"); + } + } + + // Dispatches one value at the current nesting depth. emit=false still + // validates and counts the subtree but deliberately skips callbacks. + bool parseValue(size_t depth, bool emit) { + if (!skipWhitespace()) + return false; + + char ch = 0; + if (!cur.peek(ch)) { + if (cur.failed()) + return fail("stream read failed"); + return fail("unexpected end of input; expected a value"); } - delete _pValueMap; - break; + + if (ch == '"') + return parseStringValue(emit); + if (ch == '{') + return parseObject(depth + 1, emit); + if (ch == '[') + return parseArray(depth + 1, emit); + if (ch == '-' || (ch >= '0' && ch <= '9')) + return parseNumberValue(emit); + return parseKeywordValue(emit); } - }//end switch - _pValueRaw = nullptr; - - switch(aeType) { - case jsonType::jsonNull: { /* _pValueRaw = nullptr; */ break; } - case jsonType::jsonString: { _pValueString = new std::string; break; } - case jsonType::jsonNumberInt: { _pValueInt = new int; break; } - case jsonType::jsonNumberFloat: { _pValueFloat = new float; break; } - case jsonType::jsonBoolean: { _pValueBool = new bool; break; } - case jsonType::jsonArray: { _pValueArray = new PJSONARRAY; break; } - case jsonType::jsonMap: { _pValueMap = new PJSONMAP; break; } - } //end switch - _eType = aeType; -} -//----------------------------------------------------------------- -void pjson::copyFrom(const pjson& aFrom) { - resetTo(aFrom.getType()); - - switch(_eType) { - case jsonType::jsonNull: { /* _pValueRaw = nullptr; */ break; } - case jsonType::jsonString: { *_pValueString = *(aFrom._pValueString); break; } - case jsonType::jsonNumberInt: { *_pValueInt = *(aFrom._pValueInt); break; } - case jsonType::jsonNumberFloat: { *_pValueFloat = *(aFrom._pValueFloat); break; } - case jsonType::jsonBoolean: { *_pValueBool = *(aFrom._pValueBool); break; } - case jsonType::jsonArray: { - for (auto it : *(aFrom._pValueArray)) { - pjson* pObj = new pjson(); - pObj->copyFrom(*it); - _pValueArray->push_back(pObj); + + // Consumes only the four whitespace bytes admitted by JSON. + bool skipWhitespace() { + char ch = 0; + while (cur.peek(ch) && pjsonImpl::_isWhitespace(ch)) { + if (!getChar(ch)) + return false; } - break; + if (cur.failed()) + return fail("stream read failed"); + return true; } - case jsonType::jsonMap: { - for (auto const& it : *(aFrom._pValueMap)) { - pjson* pObj = new pjson(); - pObj->copyFrom(*(it.second)); - (*_pValueMap)[it.first] = pObj; + + // Parses a string value and emits it after it has consumed one node from + // the configured budget. Object keys are handled separately. + bool parseStringValue(bool emit) { + if (!reserveNode()) + return false; + std::string value; + if (!parseStringRaw(value)) + return false; + if (!emit) + return true; + return dispatch(handler.onString(value)); + } + + // Recognizes the lowercase null/boolean literals required by RFC 8259. + bool parseKeywordValue(bool emit) { + char ch = 0; + if (!cur.peek(ch)) + return fail("unexpected end of input; expected a value"); + + if (ch == 'n') { + if (!matchLiteral("null")) + return false; + if (!reserveNode()) + return false; + return !emit || dispatch(handler.onNull()); } - break; + if (ch == 't') { + if (!matchLiteral("true")) + return false; + if (!reserveNode()) + return false; + return !emit || dispatch(handler.onBool(true)); + } + if (ch == 'f') { + if (!matchLiteral("false")) + return false; + if (!reserveNode()) + return false; + return !emit || dispatch(handler.onBool(false)); + } + return fail("invalid JSON value"); } - } //end switch -} -//----------------------------------------------------------------- -std::string pjson::toString(bool bPretty /*=false*/) const { - int iIndent = bPretty?0:-1; - return _toString(iIndent); -} -//----------------------------------------------------------------- -std::string pjson::_toString(int a_iIndent) const { - std::string sOut; - switch(_eType) { - case jsonType::jsonNull: { sOut += "null"; break; } - case jsonType::jsonString: { sOut += "\"" + (*_pValueString) + "\""; break; } - case jsonType::jsonNumberInt: { sOut += std::to_string(*_pValueInt); break; } - case jsonType::jsonNumberFloat: { sOut += std::to_string(*_pValueFloat); break; } - case jsonType::jsonBoolean: { sOut += (*_pValueBool)?"true":"false"; break; } - case jsonType::jsonArray: { - sOut += "["; - std::string spaces; - if(a_iIndent >=0) { - spaces = std::string(a_iIndent, ' '); + // Scans the JSON number grammar before conversion. Integral tokens that + // overflow int64 are preserved as finite doubles rather than truncated. + bool parseNumberValue(bool emit) { + std::string text; + char ch = 0; + if (!cur.peek(ch)) + return fail("unexpected end of input; expected a value"); + + if (ch == '-') { + if (!getChar(ch)) + return false; + text.push_back(ch); + if (!cur.peek(ch)) + return fail("invalid number: expected digit"); } - bool bFirstElement = true; - for (auto it = _pValueArray->begin(); it != _pValueArray->end(); it++) { - if(!bFirstElement) { - sOut += ","; - } + if (ch == '0') { + if (!getChar(ch)) + return false; + text.push_back(ch); + } else if (ch >= '1' && ch <= '9') { + do { + if (!getChar(ch)) + return false; + text.push_back(ch); + } while (cur.peek(ch) && ch >= '0' && ch <= '9'); + } else { + return fail("invalid number: expected digit"); + } + + bool isFloat = false; + if (cur.peek(ch) && ch == '.') { + isFloat = true; + if (!getChar(ch)) + return false; + text.push_back(ch); + if (!cur.peek(ch) || ch < '0' || ch > '9') + return fail("invalid number: '.' must be followed by a digit"); + do { + if (!getChar(ch)) + return false; + text.push_back(ch); + } while (cur.peek(ch) && ch >= '0' && ch <= '9'); + } - int iIndent = a_iIndent; - if(a_iIndent >=0) { - sOut += "\n" + spaces; - iIndent += 1; + if (cur.peek(ch) && (ch == 'e' || ch == 'E')) { + isFloat = true; + if (!getChar(ch)) + return false; + text.push_back(ch); + if (cur.peek(ch) && (ch == '+' || ch == '-')) { + if (!getChar(ch)) + return false; + text.push_back(ch); } + if (!cur.peek(ch) || ch < '0' || ch > '9') + return fail("invalid number: exponent must have a digit"); + do { + if (!getChar(ch)) + return false; + text.push_back(ch); + } while (cur.peek(ch) && ch >= '0' && ch <= '9'); + } + + if (!reserveNode()) + return false; - sOut += " "; - sOut += (*it)->_toString(iIndent); - sOut += " "; - bFirstElement = false; + if (isFloat) { + double d = 0.0; + if (!pjsonImpl::_parseDouble(text, d) || !std::isfinite(d)) + return fail("number out of range"); + return !emit || dispatch(handler.onDouble(d)); } - if(bFirstElement) { - sOut += " ]"; // no elements - } else { - if(a_iIndent >=0) { - sOut += "\n" + spaces; - } - sOut += "]"; + errno = 0; + const long long llVal = strtoll(text.c_str(), nullptr, 10); + if (errno == ERANGE) { + double d = 0.0; + if (!pjsonImpl::_parseDouble(text, d) || !std::isfinite(d)) + return fail("number out of range"); + return !emit || dispatch(handler.onDouble(d)); } - break; + return !emit || dispatch(handler.onInt(static_cast(llVal))); } - case jsonType::jsonMap: { - sOut += "{"; - std::string spaces; - if(a_iIndent >=0) { - spaces = std::string(a_iIndent, ' '); + + // Parses an array while explicitly tracking comma state so leading, + // repeated, missing, and trailing commas receive deterministic errors. + bool parseArray(size_t depth, bool emit) { + const size_t maxDepth = opts.maxDepth > 0 ? static_cast(opts.maxDepth) : 1U; + if (depth > maxDepth) + return fail("maximum nesting depth exceeded"); + if (!reserveNode()) + return false; + + char ch = 0; + if (!getChar(ch) || ch != '[') + return fail("unexpected end of input; expected a value"); + if (emit && !dispatch(handler.onStartArray())) + return false; + + bool expectValue = false; + bool any = false; + while (true) { + if (!skipWhitespace()) + return false; + if (!cur.peek(ch)) { + if (cur.failed()) + return fail("stream read failed"); + return fail("unterminated array"); + } + if (ch == ']') { + if (expectValue) + return fail("trailing comma in array"); + if (!getChar(ch)) + return false; + return !emit || dispatch(handler.onEndArray()); + } + if (ch == ',') { + if (!any || expectValue) + return fail("unexpected ',' in array"); + if (!getChar(ch)) + return false; + expectValue = true; + continue; + } + if (any && !expectValue) + return fail("missing ',' between array elements"); + if (!parseValue(depth, emit)) + return false; + any = true; + expectValue = false; } - bool bFirstElement = true; - for (auto it = _pValueMap->begin(); it != _pValueMap->end(); it++) { - if(!bFirstElement) { - sOut += ","; + } + + // Parses an object and implements duplicate-key policy at event time. + // KeepFirst parses duplicate values with emit=false so malformed input + // and resource-limit violations cannot hide inside discarded members. + bool parseObject(size_t depth, bool emit) { + const size_t maxDepth = opts.maxDepth > 0 ? static_cast(opts.maxDepth) : 1U; + if (depth > maxDepth) + return fail("maximum nesting depth exceeded"); + if (!reserveNode()) + return false; + + char ch = 0; + if (!getChar(ch) || ch != '{') + return fail("unexpected end of input; expected a value"); + if (emit && !dispatch(handler.onStartObject())) + return false; + + bool expectMember = false; + bool any = false; + std::map seenKeys; + while (true) { + if (!skipWhitespace()) + return false; + if (!cur.peek(ch)) { + if (cur.failed()) + return fail("stream read failed"); + return fail("unterminated object"); + } + if (ch == '}') { + if (expectMember) + return fail("trailing comma in object"); + if (!getChar(ch)) + return false; + return !emit || dispatch(handler.onEndObject()); } + if (ch == ',') { + if (!any || expectMember) + return fail("unexpected ',' in object"); + if (!getChar(ch)) + return false; + expectMember = true; + continue; + } + if (ch != '"') + return fail("expected '\"' to start an object key"); + if (any && !expectMember) + return fail("missing ',' between object members"); + + const size_t keyOffset = cur.position(); + const size_t keyLine = cur.line(); + const size_t keyColumn = cur.column(); + std::string key; + if (!parseStringRaw(key)) + return false; + if (!skipWhitespace()) + return false; + if (!getChar(ch) || ch != ':') + return fail("expected ':' after object key"); - if(a_iIndent >=0) { - sOut += "\n" + spaces; + bool duplicate = false; + if (opts.duplicateKeys != ParseOptions::KeepLastDuplicate) { + duplicate = seenKeys.find(key) != seenKeys.end(); } - sOut += " \""; - int iLen = it->first.length(); - sOut += it->first; - sOut += "\" : "; + if (duplicate && opts.duplicateKeys == ParseOptions::RejectDuplicateKeys) { + return failAt(keyOffset, keyLine, keyColumn, "duplicate object key"); + } + if (!duplicate && opts.duplicateKeys != ParseOptions::KeepLastDuplicate) + seenKeys[key] = true; + + const bool emitValue = + emit && !(duplicate && opts.duplicateKeys == ParseOptions::KeepFirstDuplicate); + if (emitValue && !dispatch(handler.onKey(key))) + return false; + if (!parseValue(depth, emitValue)) + return false; + any = true; + expectMember = false; + } + } - int iIndent = a_iIndent; - if(a_iIndent >=0) { - iIndent += iLen + 6; + // Decodes a quoted JSON string and rejects invalid Unicode/control bytes. + bool parseStringRaw(std::string& out) { + char ch = 0; + if (!getChar(ch) || ch != '"') + return fail("expected '\"' to start a string"); + + out.clear(); + while (true) { + if (!getChar(ch)) { + if (cur.failed()) + return fail("stream read failed"); + return fail("unterminated string"); + } + const unsigned char uch = static_cast(ch); + if (ch == '"') + return true; + if (ch == '\\') { + if (!getChar(ch)) + return fail("dangling escape at end of input"); + switch (ch) { + case '"': + out += '"'; + break; + case '\\': + out += '\\'; + break; + case '/': + out += '/'; + break; + case 'b': + out += '\b'; + break; + case 'f': + out += '\f'; + break; + case 'n': + out += '\n'; + break; + case 'r': + out += '\r'; + break; + case 't': + out += '\t'; + break; + case 'u': { + uint32_t cp = 0; + if (!readHex4(cp)) + return false; + if (cp >= 0xD800 && cp <= 0xDBFF) { + char slash = 0; + if (cur.peek(slash) && slash == '\\') { + if (!getChar(slash)) + return fail("invalid \\u escape"); + char u = 0; + if (!getChar(u)) + return fail("invalid \\u escape"); + if (u == 'u') { + std::string hex; + hex.reserve(4); + bool complete = true; + for (int i = 0; i < 4; ++i) { + char hx = 0; + if (!getChar(hx)) { + complete = false; + break; + } + hex.push_back(hx); + } + uint32_t low = 0; + const bool validLow = + complete && hex.size() == 4 && + pjsonImpl::_hex4(hex.c_str(), 0, low) && + low >= 0xDC00 && low <= 0xDFFF; + if (validLow) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00); + } else { + return fail("unpaired high surrogate"); + } + } else { + return fail("unpaired high surrogate"); + } + } else { + return fail("unpaired high surrogate"); + } + } else if (cp >= 0xDC00 && cp <= 0xDFFF) { + return fail("unpaired low surrogate"); + } + pjsonImpl::_appendUtf8(cp, out); + break; + } + default: + return fail("invalid escape sequence"); + } + continue; + } + if (uch < 0x20) { + return fail("unescaped control character in string"); + } + if (uch >= 0x80) { + out += static_cast(uch); + if (!consumeUtf8Tail(uch, out)) + return false; + continue; } - sOut += it->second->_toString(iIndent); - sOut += " "; - bFirstElement = false; - } // end for + out += static_cast(uch); + } + } - if(bFirstElement) { - sOut += " }"; // no elements + // Consumes and validates the continuation bytes for an already-stored + // UTF-8 lead byte, including overlong, surrogate, and range checks. + bool consumeUtf8Tail(unsigned char lead, std::string& out) { + int need = 0; + uint32_t code = 0; + if ((lead & 0xE0U) == 0xC0U) { + need = 1; + code = lead & 0x1FU; + } else if ((lead & 0xF0U) == 0xE0U) { + need = 2; + code = lead & 0x0FU; + } else if ((lead & 0xF8U) == 0xF0U) { + need = 3; + code = lead & 0x07U; } else { - if(a_iIndent >=0) { - sOut += "\n" + spaces; + return fail("invalid UTF-8 sequence"); + } + for (int i = 0; i < need; ++i) { + char ch = 0; + if (!getChar(ch)) + return fail("invalid UTF-8 sequence"); + const unsigned char byte = static_cast(ch); + if ((byte & 0xC0U) != 0x80U) + return fail("invalid UTF-8 sequence"); + code = (code << 6) | (byte & 0x3FU); + out += ch; + } + if ((need == 1 && code < 0x80U) || (need == 2 && code < 0x800U) || + (need == 3 && code < 0x10000U) || code > 0x10FFFFU || + (code >= 0xD800U && code <= 0xDFFFU)) { + return fail("invalid UTF-8 sequence"); + } + return true; + } + + // Reads exactly four hexadecimal digits following a \u escape. + bool readHex4(uint32_t& out) { + out = 0; + for (int i = 0; i < 4; ++i) { + char ch = 0; + if (!getChar(ch)) + return fail("invalid \\u escape"); + out <<= 4; + if (ch >= '0' && ch <= '9') + out |= static_cast(ch - '0'); + else if (ch >= 'a' && ch <= 'f') + out |= static_cast(10 + ch - 'a'); + else if (ch >= 'A' && ch <= 'F') + out |= static_cast(10 + ch - 'A'); + else + return fail("invalid \\u escape"); + } + return true; + } + + // Consumes one known lowercase JSON literal. + bool matchLiteral(const char* lit) { + for (size_t i = 0; lit[i] != '\0'; ++i) { + char ch = 0; + if (!getChar(ch)) + return fail("invalid JSON value"); + const char want = lit[i]; + if (ch != want) { + return fail("invalid JSON value"); } - sOut += "}"; } - break; + return true; + } + + // Centralizes byte-budget enforcement so no consuming parser path can + // advance beyond maxInputBytes. + bool getChar(char& ch) { + if (opts.maxInputBytes != 0 && cur.position() >= opts.maxInputBytes) + return failAt(opts.maxInputBytes, cur.line(), cur.column(), + "input exceeds maxInputBytes"); + return cur.get(ch); + } + + // Accounts for one JSON value even when its callbacks are suppressed. + bool reserveNode() { + if (opts.maxNodes != 0 && nodeCount >= opts.maxNodes) + return fail("document too large (node budget exceeded)"); + ++nodeCount; + return true; + } + + // Converts a handler's false return into an exception solely to unwind + // nested parse calls; the public SAX API never exposes the exception. + bool dispatch(bool ok) { + if (!ok) + throw SaxParseCancelled(); + return true; + } + + // Records a failure at the cursor's current source location. + bool fail(const std::string& message) { + if (err) { + err->ok = false; + err->offset = cur.position(); + err->line = cur.line(); + err->column = cur.column(); + err->message = message; + } + return false; + } + + // Records a failure at a saved location, such as a duplicate key's start. + bool failAt(size_t offset, size_t line, size_t column, const std::string& message) { + if (err) { + err->ok = false; + err->offset = offset; + err->line = line; + err->column = column; + err->message = message; + } + return false; + } + + // Catch-path diagnostics must not replace the original handler/parser + // failure with an allocation exception while assigning the message. + bool failNoThrow(const char* message) noexcept { + if (err) { + err->ok = false; + err->offset = cur.position(); + err->line = cur.line(); + err->column = cur.column(); + try { + err->message = message; + } catch (...) { + // basic_string::clear is non-allocating; retain the + // structured coordinates even when message assignment fails. + err->message.clear(); + } + } + return false; } - }//end switch + }; +} // namespace - return sOut; +//===----------------------------------------------------------------------===// +// Public configuration, diagnostics, and SAX defaults +// +// Constructors establish success-state diagnostics and conservative resource +// limits. The SAX base class accepts every event so clients can override only +// the callbacks they need; returning false from any override cancels parsing. +//===----------------------------------------------------------------------===// +/*static*/ +// Returns the compile-time library version string without transferring ownership. +const char* pjson::getVersion() { + return PJSON_VERSION; } -//----------------------------------------------------------------- -pjson& pjson::operator=(const std::string& aString) { - _resetIfneeded(jsonType::jsonString); - *_pValueString = aString; - return *this; +// Establishes RFC 8259 parsing with bounded depth, node count, and input size. +pjson::ParseOptions::ParseOptions() + : maxDepth(512) + , maxNodes(1000000) + , maxInputBytes(size_t(64) * 1024U * 1024U) + , duplicateKeys(RejectDuplicateKeys) {} +// Establishes compact, UTF-8-preserving, ascending-key serialization. +pjson::SerializeOptions::SerializeOptions() + : pretty(false) + , indentWidth(2) + , indentCharacter(' ') + , escapeNonAscii(false) + , keyOrder(AscendingKeys) + , maxOutputBytes(size_t(64) * 1024U * 1024U) {} +/*static*/ +// Produces the default two-space pretty-printing preset. +pjson::SerializeOptions pjson::SerializeOptions::prettyPrinted() { + SerializeOptions o; + o.pretty = true; + return o; } -//----------------------------------------------------------------- -pjson& pjson::operator=(const char* aCString) { - _resetIfneeded(jsonType::jsonString); - *_pValueString = aCString; - return *this; +// Constructs a success-state parse diagnostic at the start of input. +pjson::ParseError::ParseError() + : ok(true) + , offset(0) + , line(1) + , column(1) {} +// Constructs a success-state pointer diagnostic with no failing token. +pjson::PointerError::PointerError() + : ok(true) + , code(Ok) + , pointer() + , tokenIndex(0) + , token() + , message() {} +// Constructs a success-state patch diagnostic with no active operation. +pjson::PatchError::PatchError() + : ok(true) + , code(Ok) + , opIndex(0) + , op() + , path() + , from() + , tokenIndex(0) + , token() + , message() {} +// Establishes finite amplification limits for both JSON Patch variants. +pjson::PatchOptions::PatchOptions() + : maxOperations(10000) + , maxClonedNodes(1000000) + , maxClonedBytes(size_t(64) * 1024U * 1024U) + , maxWork(1000000) {} +// Gives polymorphic SAX handlers a safe virtual destruction point. +pjson::SaxHandler::~SaxHandler() {} +// Accepts a null event by default. +bool pjson::SaxHandler::onNull() { + return true; } -//----------------------------------------------------------------- -pjson& pjson::operator=(const int aInt) { - _resetIfneeded(jsonType::jsonNumberInt); - *_pValueInt = aInt; - return *this; +// Accepts a boolean event by default. +bool pjson::SaxHandler::onBool(bool) { + return true; } -//----------------------------------------------------------------- -pjson& pjson::operator=(const float aFloat) { - _resetIfneeded(jsonType::jsonNumberFloat); - *_pValueFloat = aFloat; - return *this; +// Accepts an integer event by default. +bool pjson::SaxHandler::onInt(int64_t) { + return true; } -//----------------------------------------------------------------- -pjson& pjson::operator=(const bool aBool) { - _resetIfneeded(jsonType::jsonBoolean); - *_pValueBool = aBool; - return *this; +// Accepts a floating-point event by default. +bool pjson::SaxHandler::onDouble(double) { + return true; } -//----------------------------------------------------------------- -#define PJSON_VALUE_ARRAY_SET_ITERATOR \ - resetTo(jsonType::jsonArray); \ - for(auto i : aValueArray){ \ - pjson* pTemp = new pjson(); \ - *pTemp = i; \ - _pValueArray->push_back(pTemp); \ - } \ - return *this; -//----------------------------------------------------------------- -pjson& pjson::operator=(const std::vector& aValueArray) { - PJSON_VALUE_ARRAY_SET_ITERATOR -} -//----------------------------------------------------------------- -pjson& pjson::operator=(const std::vector& aValueArray) { - PJSON_VALUE_ARRAY_SET_ITERATOR -} -//----------------------------------------------------------------- -pjson& pjson::operator=(const std::vector& aValueArray) { - PJSON_VALUE_ARRAY_SET_ITERATOR -} -//----------------------------------------------------------------- -pjson& pjson::operator=(const std::vector& aValueArray) { - PJSON_VALUE_ARRAY_SET_ITERATOR -} -//----------------------------------------------------------------- -pjson& pjson::operator=(const std::vector& aValueArray) { - PJSON_VALUE_ARRAY_SET_ITERATOR +// Accepts a decoded string event by default. +bool pjson::SaxHandler::onString(const std::string&) { + return true; } -//----------------------------------------------------------------- -pjson& pjson::operator=(const std::vector& aValueArray) { - PJSON_VALUE_ARRAY_SET_ITERATOR -} -//----------------------------------------------------------------- -#define PJSON_VALUE_ARRAY_APPEND_ITERATOR \ - _resetIfneeded(jsonType::jsonArray); \ - pjson* pTemp = new pjson(); \ - *pTemp = aValue; \ - _pValueArray->push_back(pTemp); \ - return *this; -//----------------------------------------------------------------- -pjson& pjson::operator+=(const std::string& aValue) { - PJSON_VALUE_ARRAY_APPEND_ITERATOR +// Accepts an array-opening event by default. +bool pjson::SaxHandler::onStartArray() { + return true; } -//----------------------------------------------------------------- -pjson& pjson::operator+=(const char* aValue) { - PJSON_VALUE_ARRAY_APPEND_ITERATOR +// Accepts an array-closing event by default. +bool pjson::SaxHandler::onEndArray() { + return true; } -//----------------------------------------------------------------- -pjson& pjson::operator+=(const int aValue) { - PJSON_VALUE_ARRAY_APPEND_ITERATOR +// Accepts an object-opening event by default. +bool pjson::SaxHandler::onStartObject() { + return true; } -//----------------------------------------------------------------- -pjson& pjson::operator+=(const float aValue) { - PJSON_VALUE_ARRAY_APPEND_ITERATOR +// Accepts a decoded object-key event by default. +bool pjson::SaxHandler::onKey(const std::string&) { + return true; } -//----------------------------------------------------------------- -pjson& pjson::operator+=(const bool aValue) { - PJSON_VALUE_ARRAY_APPEND_ITERATOR -} -//----------------------------------------------------------------- -#define PJSON_VALUE_ARRAY_APPEND_ARRAY \ - _resetIfneeded(jsonType::jsonArray); \ - for(auto i : aValueArray){ \ - pjson* pTemp = new pjson(); \ - *pTemp = i; \ - _pValueArray->push_back(pTemp); \ - } \ - return *this; -//----------------------------------------------------------------- -pjson& pjson::operator+=(const std::vector& aValueArray) { - PJSON_VALUE_ARRAY_APPEND_ARRAY -} -//----------------------------------------------------------------- -pjson& pjson::operator+=(const std::vector& aValueArray) { - PJSON_VALUE_ARRAY_APPEND_ARRAY -} -//----------------------------------------------------------------- -pjson& pjson::operator+=(const std::vector& aValueArray) { - PJSON_VALUE_ARRAY_APPEND_ARRAY -} -//----------------------------------------------------------------- -pjson& pjson::operator+=(const std::vector& aValueArray) { - PJSON_VALUE_ARRAY_APPEND_ARRAY -} -//----------------------------------------------------------------- -pjson& pjson::operator+=(const std::vector& aValueArray) { - PJSON_VALUE_ARRAY_APPEND_ARRAY +// Accepts an object-closing event by default. +bool pjson::SaxHandler::onEndObject() { + return true; } -//----------------------------------------------------------------- -pjson& pjson::operator+=(const std::vector& aValueArray) { - PJSON_VALUE_ARRAY_APPEND_ARRAY -} -//----------------------------------------------------------------- -#define PJSON_VALUE_ARRAY_EXTRACT(pjsonfuncname) \ - if(_eType != jsonType::jsonArray \ - || aTo>=_pValueArray->size() \ - || aFrom >=_pValueArray->size() \ - || aFrom>aTo) { \ - return false; \ - } \ - for(size_t i = aFrom; i<=aTo; ++i) { \ - aDest.push_back((*_pValueArray)[i]->pjsonfuncname()); \ - } \ - return true; -//----------------------------------------------------------------- -bool pjson::getArrayValues(size_t aFrom, size_t aTo, std::vector& aDest) { - PJSON_VALUE_ARRAY_EXTRACT(getString) -} -//----------------------------------------------------------------- -bool pjson::getArrayValues(size_t aFrom, size_t aTo, std::vector& aDest) { - PJSON_VALUE_ARRAY_EXTRACT(getInt) -} -//----------------------------------------------------------------- -bool pjson::getArrayValues(size_t aFrom, size_t aTo, std::vector& aDest){ - PJSON_VALUE_ARRAY_EXTRACT(getFloat) -} -//----------------------------------------------------------------- -bool pjson::getArrayValues(size_t aFrom, size_t aTo, std::vector& aDest){ - PJSON_VALUE_ARRAY_EXTRACT(getBool) -} -//----------------------------------------------------------------- -pjson& pjson::at(const std::string& aString) { - const char* cStr = aString.c_str(); - _resetIfneeded(jsonType::jsonMap); - PJSONMAP::iterator it = _pValueMap->find(cStr); - if(it != _pValueMap->end()) { - return *((*_pValueMap)[cStr]); - } - pjson* pNew = new pjson(); - (*_pValueMap)[cStr] = pNew; - return *pNew; -} -//----------------------------------------------------------------- -pjson& pjson::at(const char* aSkey) { - _resetIfneeded(jsonType::jsonMap); - PJSONMAP::iterator it = _pValueMap->find(aSkey); - if(it != _pValueMap->end()) { - return *((*_pValueMap)[aSkey]); - } - pjson* pNew = new pjson(); - (*_pValueMap)[aSkey] = pNew; - return *pNew; -} -//----------------------------------------------------------------- -pjson& pjson::at(int index) { - _resetIfneeded(jsonType::jsonArray); - int iSize = static_cast(_pValueArray->size()); - if(index < 0) { - if(iSize > 0) { - index = (iSize - (index % iSize)) % iSize; - } else { - index = 0; +// Constructs an empty schema diagnostic. +pjson::SchemaError::SchemaError() {} +// Captures one validation failure at its instance JSON Pointer. +pjson::SchemaError::SchemaError(const std::string& aPath, const std::string& aMsg) + : path(aPath) + , message(aMsg) {} +// Establishes bounded regex, recursion, and reference work with format checks enabled. +pjson::SchemaOptions::SchemaOptions() + : maxRegexPatternBytes(256) + , maxRegexSubjectBytes(4096) + , allowUnsafeRegex(false) + , maxValidationDepth(512) + , maxRefResolutions(1024) + , maxValidationWork(1000000) + , maxErrors(100) + , validateFormats(true) {} +/*static*/ +// Removes regex size/safety restrictions for schemas from a trusted source; +// unrelated validation limits retain their defaults. +pjson::SchemaOptions pjson::SchemaOptions::trustedRegex() { + SchemaOptions o; + o.maxRegexPatternBytes = 0; + o.maxRegexSubjectBytes = 0; + o.allowUnsafeRegex = true; + return o; +} +//===----------------------------------------------------------------------===// +// Allocator bridge and node ownership +// +// Containers and strings are constructed in allocator-provided storage. Nodes +// additionally remember whether their outer object came from that allocator so +// the uniform ValueDeleter can also destroy ordinary `new pjson` roots safely. +//===----------------------------------------------------------------------===// +namespace { + // Adapts the process-wide operator new/delete pair to the allocator API. + class DefaultPjsonAllocator : public pjson::Allocator { + public: + // Allocates raw storage; size is the only parameter needed by operator new. + void* allocate(size_t aSize, size_t, AllocationKind) override { + return ::operator new(aSize); } - } - if(index >= iSize) { - int iAdd = 1 + index - iSize; - for(int i=iAdd;i--;) { - _pValueArray->push_back(new pjson()); + // Releases storage previously obtained from allocate. + void deallocate(void* aPtr, size_t, size_t, AllocationKind) noexcept override { + ::operator delete(aPtr); + } + }; + + template + // Constructs an internal DOM object and returns raw storage on constructor failure. + T* allocateDomObject(pjson::Allocator& aAlloc, pjson::Allocator::AllocationKind aKind) { + void* storage = aAlloc.allocate(sizeof(T), alignof(T), aKind); + try { + return new (storage) T(); + } catch (...) { + aAlloc.deallocate(storage, sizeof(T), alignof(T), aKind); + throw; } } - return *(*_pValueArray)[static_cast(index)]; -} -//----------------------------------------------------------------- -pjson& pjson::operator[] (int index) { - return at(index); -} -//----------------------------------------------------------------- -pjson& pjson::operator[] (const std::string& aString) { - return at(aString); -} -//----------------------------------------------------------------- -pjson& pjson::operator[] (const char* aSkey) { - return at(aSkey); + template + // Runs an internal object's destructor before returning its exact allocation. + void destroyDomObject(pjson::Allocator& aAlloc, T* aObject, + pjson::Allocator::AllocationKind aKind) noexcept { + if (aObject == nullptr) + return; + aObject->~T(); + aAlloc.deallocate(aObject, sizeof(T), alignof(T), aKind); + } +} // namespace +// Gives allocator implementations a safe virtual destruction point. +pjson::Allocator::~Allocator() {} +/*static*/ +// Returns the stateless process-lifetime allocator used by ordinary values. +pjson::Allocator& pjsonImpl::_defaultAllocator() noexcept { + static DefaultPjsonAllocator allocator; + return allocator; } -//----------------------------------------------------------------- /*static*/ -pjson* pjson::CreateFromString(const std::string& aStr) { - return CreateFromString(aStr.c_str(), aStr.length()); +// Constructs a node in allocator storage and marks its outer allocation so the +// deleter never mismatches allocator storage with operator delete. +pjson* pjsonImpl::_allocateNode(pjson::Allocator& aAlloc) { + void* storage = + aAlloc.allocate(sizeof(pjson), alignof(pjson), pjson::Allocator::NodeAllocation); + try { + pjson* value = new (storage) pjson(aAlloc); + value->_allocatorOwnedNode = true; + return value; + } catch (...) { + aAlloc.deallocate(storage, sizeof(pjson), alignof(pjson), pjson::Allocator::NodeAllocation); + throw; + } } -//----------------------------------------------------------------- /*static*/ -pjson* pjson::CreateFromString(const char* aSrc, size_t a_iSize) { - size_t iStart =0; - size_t iEnd =a_iSize; - pjson* pResult = nullptr; - /*bool bSuccess = */ - _CreateFromString(aSrc, iStart, iEnd, pResult); - return pResult; -} -//----------------------------------------------------------------- -/*static*/ -bool pjson::_CreateFromString(const char* aSrc, size_t& a_iStart,size_t a_iEnd, pjson*& a_rResult) { - //1. Scan for fundametal type - char aChar; - while (_ScanToNext(aSrc, a_iStart, a_iEnd, aChar)) { - aChar = tolower(aChar); - if('\"' == aChar) { - return _ScanString(aSrc, a_iStart, a_iEnd, a_rResult); - } - else if('n' == aChar) { - return _ScanNull(aSrc, a_iStart, a_iEnd, a_rResult); - } - else if('t' == aChar || 'f' == aChar) { - return _ScanBool(aSrc, a_iStart, a_iEnd, a_rResult); - } - else if('+' == aChar || '-' == aChar || '.' == aChar || ('0' <= aChar && '9' >= aChar)) { - return _ScanNumber(aSrc, a_iStart, a_iEnd, a_rResult); - } else if('{' == aChar) { - return _ScanObject(aSrc, a_iStart, a_iEnd, a_rResult); - } else if('[' == aChar) { - return _ScanArray(aSrc, a_iStart, a_iEnd, a_rResult); - } else { - //unknown - break; - } - } // end while +// Destroys a node through the mechanism that created its outer object. Child +// storage is released first by ~pjson using the node's retained allocator. +void pjsonImpl::_destroyNode(pjson* aValue) noexcept { + if (aValue == nullptr) + return; + if (!aValue->_allocatorOwnedNode) { + delete aValue; + return; + } + pjson::Allocator& allocator = *aValue->_allocator; + aValue->~pjson(); + allocator.deallocate(aValue, sizeof(pjson), alignof(pjson), pjson::Allocator::NodeAllocation); +} +// Provides unique_ptr with the same origin-aware destruction used by DOM owners. +void pjson::ValueDeleter::operator()(pjson* aValue) const noexcept { + pjsonImpl::_destroyNode(aValue); +} - return false; +//===----------------------------------------------------------------------===// +// DOM value lifetime, storage transfer, and type access +// +// A pjson's allocator identity is immutable. Contents may be transferred in +// constant time only between values using the same allocator; cross-allocator +// moves become deep copies so every descendant remains owned consistently. +//===----------------------------------------------------------------------===// + +// Initializes the inactive union representation before a type is selected. +pjson::Storage::Storage() + : _pValueRaw(nullptr) {} + +// Constructs a non-allocator-owned null root using the default allocator. +pjson::pjson() + : _allocator(&pjsonImpl::_defaultAllocator()) + , _allocatorOwnedNode(false) + , _disposeNext(nullptr) + , _eType(jsonType::jsonNull) + , _uValue() {} +// Constructs a non-allocator-owned null root backed by a caller allocator. +pjson::pjson(Allocator& aAlloc) noexcept + : _allocator(&aAlloc) + , _allocatorOwnedNode(false) + , _disposeNext(nullptr) + , _eType(jsonType::jsonNull) + , _uValue() {} +// Releases the active value and all descendants through their retained allocator. +pjson::~pjson() { + reset(); } -//----------------------------------------------------------------- -/*static*/ -bool pjson::_ScanBool(const char* aSrc, size_t& a_iStart,const size_t a_iEnd, pjson*& a_rBoolResult) { - if((a_iEnd - a_iStart) >= 4 - && 't' == tolower(aSrc[a_iStart]) - && 'r' == tolower(aSrc[a_iStart+1]) - && 'u' == tolower(aSrc[a_iStart+2]) - && 'e' == tolower(aSrc[a_iStart+3])) { - a_rBoolResult = new pjson(); - *a_rBoolResult = bool(true); - a_iStart+=4; - return true; +// Deep-copies a value while preserving its allocator identity. +pjson::pjson(const pjson& aFrom) + : _allocator(aFrom._allocator) + , _allocatorOwnedNode(false) + , _disposeNext(nullptr) + , _eType(jsonType::jsonNull) + , _uValue() { + copyContentsFrom(aFrom); +} +// Deep-copies a value into a specifically selected allocator domain. +pjson::pjson(const pjson& aFrom, Allocator& aAlloc) + : _allocator(&aAlloc) + , _allocatorOwnedNode(false) + , _disposeNext(nullptr) + , _eType(jsonType::jsonNull) + , _uValue() { + copyContentsFrom(aFrom); +} +// Steals storage from a same-allocator source and leaves it as null. +pjson::pjson(pjson&& aFrom) noexcept + : _allocator(aFrom._allocator) + , _allocatorOwnedNode(false) + , _disposeNext(nullptr) + , _eType(jsonType::jsonNull) + , _uValue() { + static_assert(std::is_trivially_copyable::value, + "pjson storage must remain safe for bytewise transfer"); + _eType = aFrom._eType; + std::memcpy(&_uValue, &aFrom._uValue, sizeof(_uValue)); + aFrom._uValue._pValueRaw = nullptr; + aFrom._eType = jsonType::jsonNull; +} +// Steals when allocator domains match; otherwise deep-copies into aAlloc and +// resets the source only after the copy succeeds. +pjson::pjson(pjson&& aFrom, Allocator& aAlloc) + : _allocator(&aAlloc) + , _allocatorOwnedNode(false) + , _disposeNext(nullptr) + , _eType(jsonType::jsonNull) + , _uValue() { + if (_allocator == aFrom._allocator) { + _eType = aFrom._eType; + std::memcpy(&_uValue, &aFrom._uValue, sizeof(_uValue)); + aFrom._uValue._pValueRaw = nullptr; + aFrom._eType = jsonType::jsonNull; + } else { + copyContentsFrom(aFrom); + aFrom.reset(); } - if((a_iEnd - a_iStart) >= 5 - && 'f' == tolower(aSrc[a_iStart]) - && 'a' == tolower(aSrc[a_iStart+1]) - && 'l' == tolower(aSrc[a_iStart+2]) - && 's' == tolower(aSrc[a_iStart+3]) - && 'e' == tolower(aSrc[a_iStart+4])) { - a_rBoolResult = new pjson(); - *a_rBoolResult = bool(false); - a_iStart+=5; - return true; +} +// Replaces this value from an rvalue, using constant-time transfer only when +// both allocator domains match. Self-move is a no-op. +pjson& pjson::operator=(pjson&& aFrom) { + if (&aFrom == this) + return *this; + + if (_allocator == aFrom._allocator) { + reset(); + _eType = aFrom._eType; + std::memcpy(&_uValue, &aFrom._uValue, sizeof(_uValue)); + aFrom._eType = jsonType::jsonNull; + aFrom._uValue._pValueRaw = nullptr; + } else { + pjson tmp(std::move(aFrom), *_allocator); + swap(tmp); } - return false; + + return *this; } -//----------------------------------------------------------------- -/*static*/ -bool pjson::_ExtractString(const char* aSrc, size_t& a_iStart,const size_t a_iEnd, std::string& aStrResult) { - if('\"' != aSrc[a_iStart]){ +// O(1) exchange of two nodes' contents (type tag + inline storage). noexcept, +// which is what lets the move operations and copy-and-swap assignment below +// offer their exception guarantees. +void pjson::swap(pjson& aOther) noexcept { + static_assert(std::is_trivially_copyable::value, + "pjson storage must remain safe for bytewise swap"); + if (this == &aOther || !canSwap(aOther)) + return; + std::swap(_eType, aOther._eType); + Storage temp; + std::memcpy(&temp, &_uValue, sizeof(temp)); + std::memcpy(&_uValue, &aOther._uValue, sizeof(_uValue)); + std::memcpy(&aOther._uValue, &temp, sizeof(aOther._uValue)); +} +// Returns the allocator permanently associated with this value and its descendants. +pjson::Allocator& pjson::getAllocator() const noexcept { + return *_allocator; +} +// Reports whether contents can be exchanged without crossing allocator domains. +bool pjson::canSwap(const pjson& aOther) const noexcept { + return _allocator == aOther._allocator; +} +// Copy assignment (copy-and-swap: safe even when aFrom aliases a child of +// this, because the deep copy completes before any of our storage is freed). +pjson& pjson::operator=(const pjson& aFrom) { + if (&aFrom == this) + return *this; + + pjson tmp(aFrom, *_allocator); + swap(tmp); + return *this; +} +// Returns the active storage tag. +pjson::jsonType pjson::getType() const { + return _eType; +}; +// Type predicates inspect the tag only and never coerce the stored value. +bool pjson::isNull() const { + return _eType == jsonNull; +} +bool pjson::isString() const { + return _eType == jsonString; +} +bool pjson::isNumber() const { + return _eType == jsonNumberInt || _eType == jsonNumberDouble; +} +bool pjson::isInt() const { + return _eType == jsonNumberInt; +} +bool pjson::isDouble() const { + return _eType == jsonNumberDouble; +} +bool pjson::isBool() const { + return _eType == jsonBoolean; +} +bool pjson::isArray() const { + return _eType == jsonArray; +} +bool pjson::isObject() const { + return _eType == jsonObject; +} +// Constructs an empty non-owning view. +pjson::StringView::StringView() noexcept + : _data(nullptr) + , _size(0) {} +// Constructs a non-owning byte view; the caller controls the pointed-to lifetime. +pjson::StringView::StringView(const char* aData, size_t aSize) noexcept + : _data(aData) + , _size(aSize) {} +// Returns the first viewed byte, which may be null for an empty default view. +const char* pjson::StringView::data() const noexcept { + return _data; +} +// Returns the number of viewed bytes. +size_t pjson::StringView::size() const noexcept { + return _size; +} +// Reports whether the view contains no bytes. +bool pjson::StringView::empty() const noexcept { + return _size == 0; +} +// Exact extraction overloads leave the destination unchanged on type mismatch; +// only double extraction also accepts an integer through widening conversion. +bool pjson::tryGet(int64_t& aResult) const noexcept { + if (_eType != jsonType::jsonNumberInt) return false; - } - ++a_iStart; - int iEnd = a_iStart; - int iStart = a_iStart; - while(a_iStart=0 && iEnd >=0) { - aStrResult = std::string(aSrc+iStart, iEnd-iStart); + aResult = _uValue._valueInt; + return true; +} +bool pjson::tryGet(double& aResult) const noexcept { + if (_eType == jsonType::jsonNumberInt) { + aResult = static_cast(_uValue._valueInt); return true; } - return false; + if (_eType != jsonType::jsonNumberDouble) + return false; + aResult = _uValue._valueDouble; + return true; } -//----------------------------------------------------------------- -/*static*/ -bool pjson::_ScanString(const char* aSrc, size_t& a_iStart,const size_t a_iEnd, pjson*& a_rStrResult) { - std::string str; - if(_ExtractString(aSrc, a_iStart, a_iEnd, str)) { - a_rStrResult = new pjson(); - *a_rStrResult = str; - return true; +bool pjson::tryGet(bool& aResult) const noexcept { + if (_eType != jsonType::jsonBoolean) + return false; + aResult = _uValue._valueBool; + return true; +} +bool pjson::tryGet(std::string& aResult) const { + if (_eType != jsonType::jsonString) + return false; + aResult = *_uValue._pValueString; + return true; +} +bool pjson::tryGet(StringView& aResult) const noexcept { + if (_eType != jsonType::jsonString) + return false; + const std::string& value = *_uValue._pValueString; + aResult = StringView(value.data(), value.size()); + return true; +} +// Resets to the canonical null state, releasing any owned subtree. +void pjson::reset() { + resetTo(jsonType::jsonNull); +} +// Idempotent reset: rebuild as an empty value of aeType only when the node is +// not already that type, so an existing array/object keeps its contents. +void pjson::resetIfNeeded(jsonType aeType) { + if (_eType != aeType) { + resetTo(aeType); } - return false; } -//----------------------------------------------------------------- -/*static*/ -bool pjson::_ScanPastColon(const char* aSrc, size_t& a_iStart,const size_t a_iEnd) { - while(a_iStart jsonType::jsonObject) + throw std::invalid_argument("invalid pjson::jsonType"); + + // Allocate the replacement before destroying the current value. If an + // allocation fails, *this remains unchanged and internally valid. + void* replacement = nullptr; + switch (aeType) { + case jsonType::jsonString: + replacement = allocateDomObject(*_allocator, Allocator::StringAllocation); + break; + case jsonType::jsonArray: + replacement = allocateDomObject(*_allocator, Allocator::ArrayAllocation); + break; + case jsonType::jsonObject: + replacement = allocateDomObject(*_allocator, Allocator::ObjectAllocation); + break; + default: break; - } } - return false; -} -//----------------------------------------------------------------- -/*static*/ -bool pjson::_ScanToNext(const char* aSrc, size_t& a_iStart,const size_t a_iEnd, char& a_rResult) { - while(a_iStart(replacement); + break; + } + case jsonType::jsonNumberInt: { + _uValue._valueInt = 0; + break; + } + case jsonType::jsonNumberDouble: { + _uValue._valueDouble = 0.0; + break; + } + case jsonType::jsonBoolean: { + _uValue._valueBool = false; + break; + } + case jsonType::jsonArray: { + _uValue._pValueArray = static_cast(replacement); + break; + } + case jsonType::jsonObject: { + _uValue._pValueMap = static_cast(replacement); + break; + } + } // end switch + _eType = aeType; } -//----------------------------------------------------------------- -/*static*/ -bool pjson::_ScanNull(const char* aSrc, size_t& a_iStart,const size_t a_iEnd, pjson*& a_rNUllResult) { - if((a_iEnd - a_iStart) >= 4 - && 'n' == tolower(aSrc[a_iStart]) - && 'u' == tolower(aSrc[a_iStart+1]) - && 'l' == tolower(aSrc[a_iStart+2]) - && 'l' == tolower(aSrc[a_iStart+3]) - ) { - a_rNUllResult = new pjson(); - a_iStart+=4; - return true; - } - return false; +// Replaces this value with a deep copy allocated in this value's allocator. +// Building the replacement first gives the operation a strong guarantee. +void pjson::copyFrom(const pjson& aFrom) { + if (this == &aFrom) + return; + pjson replacement(aFrom, *_allocator); + swap(replacement); } -//----------------------------------------------------------------- -/*static*/ -bool pjson::_ScanNumber(const char* aSrc, size_t& a_iStart, const size_t a_iEnd, pjson*& a_rNumResult) { - bool bFloat = false; - int iEnd = a_iStart; - enum NumberSection : int { - NumberSectionSign = 0, - NumberSectionDigit, - NumberSectionDot, - NumberSectionDecimal, - NumberSectionExponential, - NumberSectionExponentialSign, - NumberSectionExponentialDigit, - NumberEnd - }; - int eSec = NumberSectionSign; - - for(int i = a_iStart; i= aSrc[i]) { - iEnd = ++i; - } else { - ++eSec; - } - break; - } - case NumberSectionDot: { - if('.' == aSrc[i]) { - iEnd = ++i; - bFloat = true; - } - ++eSec; - break; - } - case NumberSectionDecimal: { - if('0' <= aSrc[i] && '9' >= aSrc[i]) { - iEnd = ++i; - bFloat = true; - } else { - ++eSec; - } - break; - } - case NumberSectionExponential: { - if('e' == tolower(aSrc[i])) { - iEnd = ++i; - bFloat = true; - } - ++eSec; - break; +// Populates this node from aFrom without recursion. If copying fails, partial +// descendants are reclaimed and this node is reset to a valid null state. +void pjson::copyContentsFrom(const pjson& aFrom) { + // Iterative deep copy. A recursive copy would overflow the stack on very + // deep documents, so we walk with an explicit work-list: each item pairs a + // source node with the destination node to populate from it. Scalars are + // copied immediately; array/map children are queued. + try { + resetTo(aFrom.getType()); + if (_eType != jsonType::jsonArray && _eType != jsonType::jsonObject) { + switch (_eType) { + case jsonType::jsonString: + *_uValue._pValueString = *(aFrom._uValue._pValueString); + break; + case jsonType::jsonNumberInt: + _uValue._valueInt = aFrom._uValue._valueInt; + break; + case jsonType::jsonNumberDouble: + _uValue._valueDouble = aFrom._uValue._valueDouble; + break; + case jsonType::jsonBoolean: + _uValue._valueBool = aFrom._uValue._valueBool; + break; + default: + break; // null: nothing to copy } - case NumberSectionExponentialSign: { - if('+' == aSrc[i] || '-' == aSrc[i]) { - iEnd = ++i; - bFloat = true; + return; + } + + struct Item { + const pjson* src; + pjson* dst; + }; + std::vector work; + Item start = {&aFrom, this}; + work.push_back(start); + + while (!work.empty()) { + Item cur = work.back(); + work.pop_back(); + const pjson& src = *cur.src; + pjson& dst = *cur.dst; + + // dst has already been resetTo(src type) by the parent (or caller). + if (src._eType == jsonType::jsonArray) { + dst._uValue._pValueArray->reserve(src._uValue._pValueArray->size()); + for (const pjson* elem : *src._uValue._pValueArray) { + unique_ptr child = pjsonImpl::_makeNode(*_allocator); + child->resetTo(elem->getType()); + dst._uValue._pValueArray->push_back(child.get()); + pjson* attached = child.release(); + if (elem->_eType == jsonType::jsonArray || + elem->_eType == jsonType::jsonObject) { + Item it = {elem, attached}; + work.push_back(it); + } else { + attached->copyContentsFrom(*elem); + } } - ++eSec; - break; - } - case NumberSectionExponentialDigit: { - if('0' <= aSrc[i] && '9' >= aSrc[i]) { - iEnd = ++i; - bFloat = true; - } else { - ++eSec; + } else { // jsonObject + for (const auto& kv : *src._uValue._pValueMap) { + unique_ptr child = pjsonImpl::_makeNode(*_allocator); + child->resetTo(kv.second->getType()); + const std::pair inserted = + dst._uValue._pValueMap->insert( + std::make_pair(kv.first, static_cast(nullptr))); + if (!inserted.second) + throw std::logic_error("duplicate key while copying pjson object"); + pjson* attached = child.release(); + inserted.first->second = attached; + if (kv.second->_eType == jsonType::jsonArray || + kv.second->_eType == jsonType::jsonObject) { + Item it = {kv.second, attached}; + work.push_back(it); + } else { + attached->copyContentsFrom(*kv.second); + } } - break; } - case NumberEnd: { - break; - } - } // end switch - } //end for - - if(iEnd > a_iStart) { - std::string sTemp = std::string(aSrc+a_iStart, iEnd-a_iStart); - a_rNumResult = new pjson(); - if(bFloat) { - *a_rNumResult = std::stof(sTemp); - } else { - *a_rNumResult = std::stoi(sTemp); } - a_iStart = iEnd; - return true; + } catch (...) { + reset(); + throw; } - return false; } -//----------------------------------------------------------------- +// Frees every descendant of node iteratively, so tearing down a very deep +// tree cannot overflow the call stack (as the recursive destructor would). +// Leaves node's own top-level array/map allocated but empty. /*static*/ -bool pjson::_ScanArray(const char* aSrc, size_t& a_iStart, const size_t a_iEnd, pjson*& a_rAResult) { - a_rAResult = new pjson(); - a_rAResult->resetTo(jsonType::jsonArray); - bool bValid = false; - ++a_iStart; // ignore first char "[" - char aChar; - while(_ScanToNext(aSrc, a_iStart, a_iEnd, aChar)) { - if(']' == aChar) { - ++a_iStart; - bValid = true; - break; - } else if(',' == aChar) { - ++a_iStart; //ignore commas - } else { - pjson* pTemp = nullptr; - if(_CreateFromString(aSrc, a_iStart,a_iEnd, pTemp)) { - a_rAResult->_pValueArray->push_back(pTemp); - } else { - break; - } - } +void pjsonImpl::_disposeChildren(pjson& node) noexcept { + if (node._eType != jsonType::jsonArray && node._eType != jsonType::jsonObject) { + return; } - if(!bValid) { - delete a_rAResult; - a_rAResult = nullptr; + // Use an intrusive pending list so teardown never allocates and therefore + // remains noexcept even for very deep trees or an exhausted heap. + pjson* pending = nullptr; + if (node._eType == jsonType::jsonArray) { + for (pjson* c : *node._uValue._pValueArray) { + c->_disposeNext = pending; + pending = c; + } + node._uValue._pValueArray->clear(); + } else { + for (const auto& kv : *node._uValue._pValueMap) { + kv.second->_disposeNext = pending; + pending = kv.second; + } + node._uValue._pValueMap->clear(); } - return bValid; -} -//----------------------------------------------------------------- -/*static*/ -bool pjson::_ScanObject(const char* aSrc, size_t& a_iStart, const size_t a_iEnd, pjson*& a_rAResult) { - a_rAResult = new pjson(); - a_rAResult->resetTo(jsonType::jsonMap); - bool bValid = false; - ++a_iStart; // ignore first char "{" - char aChar; - while(_ScanToNext(aSrc, a_iStart, a_iEnd, aChar)) { - if('}' == aChar) { - ++a_iStart; - bValid = true; - break; - } else if(',' == aChar) { - ++a_iStart; // ignore commas - } else { - pjson* pVal = nullptr; - std::string mkey; - if(_ExtractString(aSrc, a_iStart, a_iEnd, mkey) - && _ScanPastColon(aSrc, a_iStart, a_iEnd) - && _CreateFromString(aSrc, a_iStart, a_iEnd, pVal)) { - //success - (*(a_rAResult->_pValueMap))[mkey.c_str()] = pVal; - } else { - delete a_rAResult; - a_rAResult = nullptr; - bValid = false; - break; + while (pending != nullptr) { + pjson* p = pending; + pending = p->_disposeNext; + p->_disposeNext = nullptr; + // Move this node's children into the work-list, then detach so its own + // destructor has nothing left to recurse into. + if (p->_eType == jsonType::jsonArray) { + for (pjson* c : *p->_uValue._pValueArray) { + c->_disposeNext = pending; + pending = c; } - } - } - return bValid; -} -//----------------------------------------------------------------- -bool pjson::getIfExist(const std::string& aKey, float& a_rResult) { - return getIfExist(aKey.c_str(), a_rResult); -} -//----------------------------------------------------------------- -bool pjson::getIfExist(const std::string& aKey, int& a_rResult) { - return getIfExist(aKey.c_str(), a_rResult); -} -//----------------------------------------------------------------- -bool pjson::getIfExist(const std::string& aKey, bool& a_rResult) { - return getIfExist(aKey.c_str(), a_rResult); -} -//----------------------------------------------------------------- -bool pjson::getIfExist(const std::string& aKey, std::string& a_rResult) { - return getIfExist(aKey.c_str(), a_rResult); -} -//----------------------------------------------------------------- -bool pjson::getIfExist(const std::string& aKey, std::vector& a_rResult) { - return getIfExist(aKey.c_str(), a_rResult); -} -//----------------------------------------------------------------- -bool pjson::getIfExist(const std::string& aKey, std::vector& a_rResult) { - return getIfExist(aKey.c_str(), a_rResult); -} -//----------------------------------------------------------------- -bool pjson::getIfExist(const std::string& aKey, std::vector& a_rResult) { - return getIfExist(aKey.c_str(), a_rResult); -} -//----------------------------------------------------------------- -bool pjson::getIfExist(const std::string& aKey, std::vector& a_rResult) { - return getIfExist(aKey.c_str(), a_rResult); -} -//----------------------------------------------------------------- -#define PJSON_VALUE_EXTRACT_IF_EXISTS(pjsontype,getfunc) \ -if(_eType == jsonType::jsonMap) { \ - auto it = _pValueMap->find(aKey); \ - if (it != _pValueMap->end() && it->second->getType()==jsonType::pjsontype) { \ - a_rResult = it->second->getfunc(); \ - return true; \ - } \ -} \ -return false; \ -// -//----------------------------------------------------------------- -bool pjson::getIfExist(const char* aKey, float& a_rResult) { - if(_eType == jsonType::jsonMap) { - auto it = _pValueMap->find(aKey); - if (it != _pValueMap->end()) { - if(it->second->getType()==jsonType::jsonNumberFloat || - it->second->getType()==jsonType::jsonNumberInt) { - a_rResult = it->second->getFloat(); - return true; + p->_uValue._pValueArray->clear(); + } else if (p->_eType == jsonType::jsonObject) { + for (const auto& kv : *p->_uValue._pValueMap) { + kv.second->_disposeNext = pending; + pending = kv.second; } + p->_uValue._pValueMap->clear(); } + pjsonImpl::_destroyNode(p); // now a leaf (or emptied container) } - return false; -} -//----------------------------------------------------------------- -bool pjson::getIfExist(const char* aKey, int& a_rResult) { - PJSON_VALUE_EXTRACT_IF_EXISTS(jsonNumberInt, getInt) -} -//----------------------------------------------------------------- -bool pjson::getIfExist(const char* aKey, bool& a_rResult) { - PJSON_VALUE_EXTRACT_IF_EXISTS(jsonBoolean, getBool) -} -//----------------------------------------------------------------- -bool pjson::getIfExist(const char* aKey, std::string& a_rResult) { - PJSON_VALUE_EXTRACT_IF_EXISTS(jsonString, getString) -} -//----------------------------------------------------------------- -#define PJSON_ARRAY_VALUE_EXTRACT_IF_EXISTS \ -if (_eType == jsonType::jsonMap) { \ - auto it = _pValueMap->find(aKey); \ - if (it != _pValueMap->end() && it->second->getType()==jsonType::jsonArray) {\ - size_t arraylen = it->second->getArray()->size(); \ - a_rResult.clear(); \ - it->second->getArrayValues(0,arraylen-1,a_rResult); \ - return true; \ - } \ -} \ -return false; \ -// -//----------------------------------------------------------------- -bool pjson::getIfExist(const char* aKey, std::vector& a_rResult) { - PJSON_ARRAY_VALUE_EXTRACT_IF_EXISTS } -//----------------------------------------------------------------- -bool pjson::getIfExist(const char* aKey, std::vector& a_rResult) { - PJSON_ARRAY_VALUE_EXTRACT_IF_EXISTS +// Recognizes exactly the whitespace code points admitted by the JSON grammar. +bool pjsonImpl::_isWhitespace(char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r'; } -//----------------------------------------------------------------- -bool pjson::getIfExist(const char* aKey, std::vector& a_rResult) { - PJSON_ARRAY_VALUE_EXTRACT_IF_EXISTS +// Encodes a Unicode code point as UTF-8 and appends it to aOut. +/*static*/ +void pjsonImpl::_appendUtf8(uint32_t aCodePoint, std::string& aOut) { + if (aCodePoint <= 0x7F) { + aOut += static_cast(aCodePoint); + } else if (aCodePoint <= 0x7FF) { + aOut += static_cast(0xC0 | (aCodePoint >> 6)); + aOut += static_cast(0x80 | (aCodePoint & 0x3F)); + } else if (aCodePoint <= 0xFFFF) { + aOut += static_cast(0xE0 | (aCodePoint >> 12)); + aOut += static_cast(0x80 | ((aCodePoint >> 6) & 0x3F)); + aOut += static_cast(0x80 | (aCodePoint & 0x3F)); + } else { + aOut += static_cast(0xF0 | (aCodePoint >> 18)); + aOut += static_cast(0x80 | ((aCodePoint >> 12) & 0x3F)); + aOut += static_cast(0x80 | ((aCodePoint >> 6) & 0x3F)); + aOut += static_cast(0x80 | (aCodePoint & 0x3F)); + } } -//----------------------------------------------------------------- -bool pjson::getIfExist(const char* aKey, std::vector& a_rResult) { - PJSON_ARRAY_VALUE_EXTRACT_IF_EXISTS +// Decodes exactly four hexadecimal bytes at aStart into one UTF-16 code unit. +bool pjsonImpl::_hex4(const char* aSrc, size_t aStart, uint32_t& aOut) { + aOut = 0; + for (int k = 0; k < 4; ++k) { + char h = aSrc[aStart + k]; + aOut <<= 4; + if (h >= '0' && h <= '9') + aOut |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') + aOut |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') + aOut |= static_cast(h - 'A' + 10); + else + return false; + } + return true; } -//----------------------------------------------------------------- -bool pjson::hasKey(const std::string& aKey) { - return hasKey(aKey.c_str()); +// Returns the length (1..4) of the valid UTF-8 sequence starting at +// src[pos], or 0 if the bytes there are not valid UTF-8. Requires pos < end. +int pjsonImpl::_utf8Len(const char* src, size_t pos, size_t end) { + unsigned char c0 = static_cast(src[pos]); + int n; + uint32_t cp; + uint32_t lo; + if (c0 < 0x80) + return 1; + else if ((c0 & 0xE0) == 0xC0) { + n = 2; + cp = c0 & 0x1F; + lo = 0x80; + } else if ((c0 & 0xF0) == 0xE0) { + n = 3; + cp = c0 & 0x0F; + lo = 0x800; + } else if ((c0 & 0xF8) == 0xF0) { + n = 4; + cp = c0 & 0x07; + lo = 0x10000; + } else + return 0; // stray continuation / invalid lead + if (pos + static_cast(n) > end) + return 0; + for (int k = 1; k < n; ++k) { + unsigned char ck = static_cast(src[pos + k]); + if ((ck & 0xC0) != 0x80) + return 0; // not a continuation byte + cp = (cp << 6) | (ck & 0x3F); + } + if (cp < lo) + return 0; // overlong encoding + if (cp > 0x10FFFF) + return 0; // beyond Unicode + if (cp >= 0xD800 && cp <= 0xDFFF) + return 0; // surrogate half in UTF-8 + return n; } -//----------------------------------------------------------------- -bool pjson::hasKey(const char* cStr) { - if(_eType == jsonType::jsonMap) { - auto it = _pValueMap->find(cStr); - return (it != _pValueMap->end()); +// Records the first parse error (byte offset + message) and returns false so +// callers can `return _fail(...)`. +/*static*/ +bool pjsonImpl::_fail(ParseCtx& c, size_t aPos, const char* aMsg) { + if (!c.failed) { + c.failed = true; + c.errPos = aPos; + c.errMsg = aMsg; } return false; } -//----------------------------------------------------------------- -// JSON String Encoding Function -// -// I'll create a C++ function that encodes a data buffer into a -// string that can be safely embedded in a JSON file. This function -// will handle escaping special characters and converting binary -// data to a safe representation. -// -// Here's the implementation: -//----------------------------------------------------------------- +// Allocates a new pjson while enforcing the node budget. Returns nullptr (and +// records a "document too large" failure) once maxNodes values have been +// created, which caps total memory even for inputs that stay within maxDepth +// (e.g. a huge flat array). The caller propagates the nullptr as a parse error. /*static*/ -std::string pjson::EncodeForJSON(const char* data, size_t length) { - std::string result; - result.reserve(length * 2); // Reserve space to avoid frequent reallocations - - for (size_t i = 0; i < length; ++i) { - char c = data[i]; - switch (c) { - case '\"': result += "\\\""; break; - case '\\': result += "\\\\"; break; - case '/': result += "\\/"; break; - case '\b': result += "\\b"; break; - case '\f': result += "\\f"; break; - case '\n': result += "\\n"; break; - case '\r': result += "\\r"; break; - case '\t': result += "\\t"; break; - default: - // Handle control characters and non-ASCII characters - if (static_cast(c) < 32 || static_cast(c) >= 128) { - char buf[7]; - snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); - result += buf; - } else { - result += c; - } - break; - } +pjson* pjsonImpl::_newNode(ParseCtx& c) { + if (c.maxNodes != 0 && c.nodeCount >= c.maxNodes) { + _fail(c, c.pos, "document too large (node budget exceeded)"); + return nullptr; } - - return result; + ++c.nodeCount; + return pjsonImpl::_allocateNode(*c.allocator); } - -//----------------------------------------------------------------- /*static*/ -std::string pjson::EncodeBase64ForJSON(const char* data, size_t length) { - static const char base64_chars[] = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - - std::string result; - result.reserve((length + 2) / 3 * 4); // Reserve space for base64 output - - size_t i = 0; - while (i + 2 < length) { - // Process 3 bytes at a time - uint32_t triplet = (static_cast(data[i]) << 16) | - (static_cast(data[i+1]) << 8) | - (static_cast(data[i+2])); - - // Convert to 4 characters - result += base64_chars[(triplet >> 18) & 0x3F]; - result += base64_chars[(triplet >> 12) & 0x3F]; - result += base64_chars[(triplet >> 6) & 0x3F]; - result += base64_chars[triplet & 0x3F]; - - i += 3; - } - - // Handle remaining bytes - if (i + 1 == length) { - // 1 byte remaining - uint32_t pair = static_cast(data[i]) << 8; - result += base64_chars[(pair >> 10) & 0x3F]; - result += base64_chars[(pair >> 4) & 0x3F]; - result += '='; // Padding - result += '='; // Padding - } else if (i + 2 == length) { - // 2 bytes remaining - uint32_t triplet = (static_cast(data[i]) << 16) | - (static_cast(data[i+1]) << 8); - result += base64_chars[(triplet >> 18) & 0x3F]; - result += base64_chars[(triplet >> 12) & 0x3F]; - result += base64_chars[(triplet >> 6) & 0x3F]; - result += '='; // Padding - } - +// Allocates a null node under the supplied allocator and wraps origin-aware cleanup. +pjson::unique_ptr pjsonImpl::_makeNode(pjson::Allocator& aAlloc) { + return pjson::unique_ptr(pjsonImpl::_allocateNode(aAlloc)); +} +/*static*/ +// Deep-clones a complete subtree into the supplied allocator domain. +pjson::unique_ptr pjsonImpl::_cloneNode(const pjson& aValue, pjson::Allocator& aAlloc) { + pjson::unique_ptr result = _makeNode(aAlloc); + result->copyContentsFrom(aValue); return result; } -//----------------------------------------------------------------- +// Decodes a JSON string body from c.pos into aOut. With bStopAtQuote, decoding +// stops at (and consumes) the first unescaped '"'. RFC 8259-invalid escapes, +// control bytes, surrogate halves, and UTF-8 are rejected. /*static*/ -std::string pjson::DecodeFromJSON(const std::string& jsonStr) { - std::string result; - result.reserve(jsonStr.length()); // Reserve space to avoid frequent reallocations - - for (size_t i = 0; i < jsonStr.length(); ++i) { - if (jsonStr[i] == '\\' && i + 1 < jsonStr.length()) { - char c = jsonStr[++i]; - switch (c) { - case '\"': result += '\"'; break; - case '\\': result += '\\'; break; - case '/': result += '/'; break; - case 'b': result += '\b'; break; - case 'f': result += '\f'; break; - case 'n': result += '\n'; break; - case 'r': result += '\r'; break; - case 't': result += '\t'; break; - case 'u': // Handle Unicode escape sequence - if (i + 4 < jsonStr.length()) { - // Parse the 4 hex digits - std::string hexStr = jsonStr.substr(i + 1, 4); - try { - int hexValue = std::stoi(hexStr, nullptr, 16); - // For simplicity, we're only handling single-byte characters - result += static_cast(hexValue); - } catch (...) { - // If parsing fails, just add the original sequence - result += "\\u" + hexStr; +bool pjsonImpl::_decodeStringBody(ParseCtx& c, std::string& aOut, bool bStopAtQuote) { + aOut.clear(); + while (c.pos < c.end) { + unsigned char ch = static_cast(c.src[c.pos]); + if (bStopAtQuote && ch == '\"') { + ++c.pos; + return true; + } + if (ch == '\\') { + ++c.pos; + if (c.pos >= c.end) { + return _fail(c, c.pos, "dangling escape at end of input"); + } + char e = c.src[c.pos++]; + switch (e) { + case '\"': + aOut += '\"'; + break; + case '\\': + aOut += '\\'; + break; + case '/': + aOut += '/'; + break; + case 'b': + aOut += '\b'; + break; + case 'f': + aOut += '\f'; + break; + case 'n': + aOut += '\n'; + break; + case 'r': + aOut += '\r'; + break; + case 't': + aOut += '\t'; + break; + case 'u': { + uint32_t cp = 0; + if (c.pos + 4 > c.end || !pjsonImpl::_hex4(c.src, c.pos, cp)) { + return _fail(c, c.pos, "invalid \\u escape"); + } + c.pos += 4; + if (cp >= 0xD800 && cp <= 0xDBFF) { + // High surrogate: look for a following low surrogate. + uint32_t low = 0; + if (c.pos + 6 <= c.end && c.src[c.pos] == '\\' && c.src[c.pos + 1] == 'u' && + pjsonImpl::_hex4(c.src, c.pos + 2, low) && low >= 0xDC00 && + low <= 0xDFFF) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00); + c.pos += 6; + } else { + return _fail(c, c.pos, "unpaired high surrogate"); } - i += 4; + } else if (cp >= 0xDC00 && cp <= 0xDFFF) { + return _fail(c, c.pos, "unpaired low surrogate"); } + _appendUtf8(cp, aOut); break; + } default: - // Unknown escape sequence, just add the character - result += c; - break; + return _fail(c, c.pos - 1, "invalid escape sequence"); + } + } else if (ch < 0x20) { + return _fail(c, c.pos, "unescaped control character in string"); + } else if (ch >= 0x80) { + int n = pjsonImpl::_utf8Len(c.src, c.pos, c.end); + if (n == 0) { + return _fail(c, c.pos, "invalid UTF-8 sequence"); } + aOut.append(c.src + c.pos, static_cast(n)); + c.pos += static_cast(n); } else { - result += jsonStr[i]; + aOut += static_cast(ch); + ++c.pos; } } - - return result; + if (bStopAtQuote) { + return _fail(c, c.pos, "unterminated string"); + } + return true; } - -//----------------------------------------------------------------- +// Formats a finite double with enough classic-locale precision to round-trip. +// A '.0' suffix is appended when the result would otherwise look like an +// integer, so the value re-parses into the double representation (type-stable). /*static*/ -std::string pjson::DecodeBase64FromJSON(const std::string& base64Str) { - static const unsigned char base64_index[256] = { - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 62, 0, 0, 0, 63, - 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 0, 0, 0, 0, 0, 0, - 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, - 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 0, 0, 0, 0, 0, - 0, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, - 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 - }; - +std::string pjsonImpl::_formatDouble(double aValue) { + if (!std::isfinite(aValue)) { + // JSON has no representation for NaN/Infinity. + return "null"; + } std::string result; - result.reserve(base64Str.length() * 3 / 4); // Reserve space for decoded output - - size_t i = 0; - while (i < base64Str.length()) { - // Skip non-base64 characters - if (base64Str[i] == '=' || base64_index[static_cast(base64Str[i])] == 0) { - i++; - continue; + for (int prec = 15; prec <= 17; ++prec) { + std::ostringstream out; + out.imbue(std::locale::classic()); + out << std::setprecision(prec) << aValue; + result = out.str(); + double parsed = 0.0; + if (_parseDouble(result, parsed) && parsed == aValue) { + break; } - - // Ensure we have enough characters for a complete group - if (i + 1 >= base64Str.length()) break; - - // Decode a group of 4 characters into 3 bytes - unsigned char a = base64_index[static_cast(base64Str[i])]; - unsigned char b = base64_index[static_cast(base64Str[i+1])]; - - result += static_cast((a << 2) | (b >> 4)); - - if (i + 2 < base64Str.length() && base64Str[i+2] != '=') { - unsigned char c = base64_index[static_cast(base64Str[i+2])]; - result += static_cast(((b & 0x0F) << 4) | (c >> 2)); - - if (i + 3 < base64Str.length() && base64Str[i+3] != '=') { - unsigned char d = base64_index[static_cast(base64Str[i+3])]; - result += static_cast(((c & 0x03) << 6) | d); - } - } - - i += 4; - } - + } + if (result.find_first_of(".eE") == std::string::npos) { + result += ".0"; + } return result; } -//----------------------------------------------------------------- \ No newline at end of file +// Parses an ASCII JSON number independently of the process LC_NUMERIC locale. +bool pjsonImpl::_parseDouble(const std::string& aText, double& aValue) { + std::istringstream in(aText); + in.imbue(std::locale::classic()); + in >> std::noskipws >> aValue; + if (!in.fail()) + return in.peek() == std::char_traits::eof(); + // libstdc++/libc++ set failbit as well as eofbit for both underflow and + // overflow. Classify the range direction from the decimal exponent instead + // of trusting the implementation-specific saturated result. A negative + // effective decimal exponent cannot overflow binary64, so its finite zero or + // subnormal result is valid; nonnegative range failures are overflow. + if (!in.eof() || !std::isfinite(aValue)) + return false; + const size_t signOffset = !aText.empty() && aText[0] == '-' ? 1 : 0; + const size_t exponentMark = aText.find_first_of("eE"); + const size_t significandEnd = exponentMark == std::string::npos ? aText.size() : exponentMark; + const size_t point = aText.find('.', signOffset); + const size_t digitsBeforePoint = point != std::string::npos && point < significandEnd + ? point - signOffset + : significandEnd - signOffset; + size_t digitOrdinal = 0; + size_t firstNonzero = std::string::npos; + for (size_t i = signOffset; i < significandEnd; ++i) { + if (aText[i] == '.') + continue; + if (firstNonzero == std::string::npos && aText[i] != '0') + firstNonzero = digitOrdinal; + ++digitOrdinal; + } + if (firstNonzero == std::string::npos) + return true; // exact zero cannot overflow + + const int64_t kExponentCap = INT64_C(1000000000); + int64_t explicitExponent = 0; + if (exponentMark != std::string::npos) { + size_t i = exponentMark + 1; + bool negative = false; + if (i < aText.size() && (aText[i] == '+' || aText[i] == '-')) { + negative = aText[i] == '-'; + ++i; + } + for (; i < aText.size(); ++i) { + const int digit = aText[i] - '0'; + if (explicitExponent > (kExponentCap - digit) / 10) { + explicitExponent = kExponentCap; + break; + } + explicitExponent = explicitExponent * 10 + digit; + } + if (negative) + explicitExponent = -explicitExponent; + } + const int64_t baseExponent = + static_cast(digitsBeforePoint) - static_cast(firstNonzero) - 1; + const int64_t effectiveExponent = explicitExponent > kExponentCap - baseExponent ? kExponentCap + : explicitExponent < -kExponentCap - baseExponent + ? -kExponentCap + : explicitExponent + baseExponent; + return effectiveExponent < 0; +} +namespace { + //===------------------------------------------------------------------===// + // Serializer sink adapters + // + // The serializer targets this tiny common protocol. String failures throw + // as normal allocation/length errors; stream failures set failbit and are + // returned as false. This keeps traversal and escaping logic identical. + //===------------------------------------------------------------------===// + + // Appends serialized bytes directly to a caller-owned string. + class StringSink { + public: + // Binds to the output string without clearing its existing contents. + StringSink(std::string& aOut, size_t aLimit) + : _out(aOut) + , _limit(aLimit) + , _written(0) {} + + // Appends one byte. + void put(char aChar) { + account(1); + _out += aChar; + } + // Appends an exact byte range. + void write(const char* aData, size_t aSize) { + account(aSize); + _out.append(aData, aSize); + } + // Appends repeated indentation, preserving std::string's length checks. + bool repeat(char aChar, size_t aCount) { + // std::string::append performs the correct max_size check and + // throws std::length_error before attempting an impossible + // allocation. This keeps pathological indentation options from + // turning into an effectively unbounded byte-at-a-time loop. + account(aCount); + _out.append(aCount, aChar); + return true; + } + // Converts arithmetic overflow in indentation sizing into a length error. + bool fail() { throw std::length_error("JSON indentation exceeds string limits"); } + // A std::string cannot report failure state, so reject invalid UTF-8 by exception. + bool invalidUtf8() { throw std::invalid_argument("JSON string contains invalid UTF-8"); } + // A live string sink has no independent error state. + explicit operator bool() const { return true; } + + private: + void account(size_t amount) { + if (_limit != 0 && amount > _limit - std::min(_written, _limit)) + throw std::length_error("JSON output exceeds maxOutputBytes"); + _written += amount; + } + + std::string& _out; + size_t _limit; + size_t _written; + }; + + // Runs the exact serializer without retaining bytes. This preflight keeps + // logical failures (invalid UTF-8, indentation overflow, and output-budget + // exhaustion) from partially modifying a caller's stream. + class CountingSink { + public: + explicit CountingSink(size_t aLimit) + : _limit(aLimit) + , _written(0) + , _valid(true) + , _invalidUtf8(false) {} + + void put(char) { account(1); } + void write(const char*, size_t aSize) { account(aSize); } + bool repeat(char, size_t aCount) { return account(aCount); } + bool fail() { + _valid = false; + return false; + } + bool invalidUtf8() { + _invalidUtf8 = true; + return fail(); + } + explicit operator bool() const { return _valid; } + size_t size() const { return _written; } + bool hasInvalidUtf8() const { return _invalidUtf8; } + + private: + bool account(size_t aAmount) { + if (!_valid) + return false; + if (aAmount > std::numeric_limits::max() - _written || + (_limit != 0 && aAmount > _limit - std::min(_written, _limit))) { + _valid = false; + return false; + } + _written += aAmount; + return true; + } + + size_t _limit; + size_t _written; + bool _valid; + bool _invalidUtf8; + }; + + // Writes serialized bytes incrementally and reflects ostream failure state. + class StreamSink { + public: + // Binds to a caller-owned stream without changing its formatting flags. + StreamSink(std::ostream& aOut, size_t aLimit) + : _out(aOut) + , _limit(aLimit) + , _written(0) {} + + // Writes one byte through the stream buffer. + void put(char aChar) { + if (account(1)) + _out.put(aChar); + } + // Writes an exact byte range. + void write(const char* aData, size_t aSize) { + if (aSize > static_cast(std::numeric_limits::max())) { + _out.setstate(std::ios::failbit); + return; + } + if (!account(aSize)) + return; + _out.write(aData, static_cast(aSize)); + } + // Emits indentation in bounded blocks and rejects counts that cannot be + // represented by ostream::write's streamsize parameter. + bool repeat(char aChar, size_t aCount) { + const size_t maxWrite = + static_cast(std::numeric_limits::max()); + if (aCount > maxWrite || !account(aCount)) { + if (_out) + _out.setstate(std::ios::failbit); + return false; + } + + char block[256]; + std::memset(block, static_cast(aChar), sizeof(block)); + while (aCount != 0 && _out) { + const size_t amount = std::min(aCount, sizeof(block)); + _out.write(block, static_cast(amount)); + aCount -= amount; + } + return static_cast(_out); + } + // Marks non-I/O serializer failures in the stream's normal error state. + bool fail() { + _out.setstate(std::ios::failbit); + return false; + } + // Streaming reports invalid programmatic string data through failbit. + bool invalidUtf8() { return fail(); } + // Exposes the underlying stream state to generic serializer code. + explicit operator bool() const { return static_cast(_out); } + + private: + bool account(size_t amount) { + if (_limit != 0 && amount > _limit - std::min(_written, _limit)) { + _out.setstate(std::ios::failbit); + return false; + } + _written += amount; + return true; + } + + std::ostream& _out; + size_t _limit; + size_t _written; + }; + + template + // Writes depth * indentWidth characters after checking multiplication overflow. + bool writeIndent(Sink& out, size_t depth, const pjson::SerializeOptions& opts) { + const char indent = opts.indentCharacter == '\t' ? '\t' : ' '; + if (opts.indentWidth != 0 && depth > size_t(-1) / opts.indentWidth) + return out.fail(); + const size_t count = depth * opts.indentWidth; + return out.repeat(indent, count); + } + + // Writes one UTF-16 code unit in canonical lower-case \uXXXX form. + template bool writeUnicodeEscape(Sink& out, uint16_t value) { + static const char hex[] = "0123456789abcdef"; + char escape[6] = {'\\', + 'u', + hex[(value >> 12U) & 0x0FU], + hex[(value >> 8U) & 0x0FU], + hex[(value >> 4U) & 0x0FU], + hex[value & 0x0FU]}; + out.write(escape, sizeof(escape)); + return static_cast(out); + } +} // namespace + +template +// Writes a JSON string body, optionally converting every non-ASCII code point +// to one UTF-16 escape (or a surrogate pair) without adding surrounding quotes. +bool pjsonImpl::_writeEscapedTo(Sink& aOut, const std::string& aIn, bool bEscapeNonAscii) { + size_t i = 0; + while (i < aIn.size()) { + const unsigned char ch = static_cast(aIn[i]); + const char* escape = nullptr; + switch (ch) { + case '"': + escape = "\\\""; + break; + case '\\': + escape = "\\\\"; + break; + case '\b': + escape = "\\b"; + break; + case '\f': + escape = "\\f"; + break; + case '\n': + escape = "\\n"; + break; + case '\r': + escape = "\\r"; + break; + case '\t': + escape = "\\t"; + break; + default: + break; + } + if (escape) { + aOut.write(escape, 2); + ++i; + if (!aOut) + return false; + continue; + } + if (ch < 0x20) { + if (!writeUnicodeEscape(aOut, static_cast(ch))) + return false; + ++i; + continue; + } + if (ch < 0x80) { + aOut.put(static_cast(ch)); + ++i; + if (!aOut) + return false; + continue; + } + + const int byteCount = _utf8Len(aIn.data(), i, aIn.size()); + if (byteCount == 0) + return aOut.invalidUtf8(); + if (!bEscapeNonAscii) { + aOut.write(aIn.data() + i, static_cast(byteCount)); + i += static_cast(byteCount); + if (!aOut) + return false; + continue; + } + + uint32_t codePoint = ch & (byteCount == 2 ? 0x1FU : byteCount == 3 ? 0x0FU : 0x07U); + for (int k = 1; k < byteCount; ++k) + codePoint = (codePoint << 6U) | (static_cast(aIn[i + k]) & 0x3FU); + if (codePoint <= 0xFFFFU) { + if (!writeUnicodeEscape(aOut, static_cast(codePoint))) + return false; + } else { + codePoint -= 0x10000U; + const uint16_t high = static_cast(0xD800U + (codePoint >> 10U)); + const uint16_t low = static_cast(0xDC00U + (codePoint & 0x3FFU)); + if (!writeUnicodeEscape(aOut, high) || !writeUnicodeEscape(aOut, low)) + return false; + } + i += static_cast(byteCount); + } + return static_cast(aOut); +} + +template +// Emits a scalar or an empty container immediately. For a non-empty container, +// emits its opening delimiter and pushes a frame whose cursor is at its first +// child; the caller owns closing it after all children have been traversed. +bool pjsonImpl::_openOrEmit(Sink& aOut, const pjson* aValue, size_t aDepth, + const pjson::SerializeOptions& aOpts, + std::vector& aFrames) { + switch (aValue->_eType) { + case jsonType::jsonNull: + aOut.write("null", 4); + return static_cast(aOut); + case jsonType::jsonString: + aOut.put('"'); + if (!aOut || + !_writeEscapedTo(aOut, *aValue->_uValue._pValueString, aOpts.escapeNonAscii)) + return false; + aOut.put('"'); + return static_cast(aOut); + case jsonType::jsonNumberInt: { + const std::string text = std::to_string(aValue->_uValue._valueInt); + aOut.write(text.data(), text.size()); + return static_cast(aOut); + } + case jsonType::jsonNumberDouble: { + const std::string text = _formatDouble(aValue->_uValue._valueDouble); + aOut.write(text.data(), text.size()); + return static_cast(aOut); + } + case jsonType::jsonBoolean: + if (aValue->_uValue._valueBool) + aOut.write("true", 4); + else + aOut.write("false", 5); + return static_cast(aOut); + case jsonType::jsonArray: + if (aValue->_uValue._pValueArray->empty()) { + aOut.write("[]", 2); + return static_cast(aOut); + } + aOut.put('['); + break; + case jsonType::jsonObject: + if (aValue->_uValue._pValueMap->empty()) { + aOut.write("{}", 2); + return static_cast(aOut); + } + aOut.put('{'); + break; + } + if (!aOut) + return false; + + SerializeFrame frame; + frame.isObject = aValue->_eType == jsonType::jsonObject; + frame.depth = aDepth; + frame.first = true; + frame.array = frame.isObject ? nullptr : aValue->_uValue._pValueArray; + frame.arrayIndex = 0; + frame.object = frame.isObject ? aValue->_uValue._pValueMap : nullptr; + if (frame.isObject) { + frame.objectIt = frame.object->begin(); + frame.objectReverseIt = frame.object->rbegin(); + } + aFrames.push_back(frame); + return true; +} + +template +// Serializes without recursive C++ calls. Before descending, the parent cursor +// advances past the chosen child, so a pushed child frame cannot invalidate the +// parent's progress when the vector reallocates. +bool pjsonImpl::_writeValueTo(Sink& aOut, const pjson& aValue, + const pjson::SerializeOptions& aOpts) { + std::vector stack; + stack.reserve(32); + if (!_openOrEmit(aOut, &aValue, 0, aOpts, stack)) + return false; + + while (!stack.empty()) { + SerializeFrame& frame = stack.back(); + const pjson* child = nullptr; + const std::string* key = nullptr; + bool hasNext = false; + if (frame.isObject) { + if (aOpts.keyOrder == pjson::SerializeOptions::DescendingKeys) { + hasNext = frame.objectReverseIt != frame.object->rend(); + if (hasNext) { + key = &frame.objectReverseIt->first; + child = frame.objectReverseIt->second; + } + } else { + hasNext = frame.objectIt != frame.object->end(); + if (hasNext) { + key = &frame.objectIt->first; + child = frame.objectIt->second; + } + } + } else { + hasNext = frame.arrayIndex < frame.array->size(); + if (hasNext) + child = (*frame.array)[frame.arrayIndex]; + } + + if (hasNext) { + if (!frame.first) + aOut.put(','); + frame.first = false; + const size_t childDepth = frame.depth + 1; + const bool isObject = frame.isObject; + if (isObject) { + if (aOpts.keyOrder == pjson::SerializeOptions::DescendingKeys) + ++frame.objectReverseIt; + else + ++frame.objectIt; + } else { + ++frame.arrayIndex; + } + if (aOpts.pretty) { + aOut.put('\n'); + if (!writeIndent(aOut, childDepth, aOpts)) + return false; + } + if (isObject) { + aOut.put('"'); + if (!aOut || !_writeEscapedTo(aOut, *key, aOpts.escapeNonAscii)) + return false; + if (aOpts.pretty) + aOut.write("\": ", 3); + else + aOut.write("\":", 2); + } + if (!aOut || !_openOrEmit(aOut, child, childDepth, aOpts, stack)) + return false; + } else { + const size_t depth = frame.depth; + const bool isObject = frame.isObject; + stack.pop_back(); + if (aOpts.pretty) { + aOut.put('\n'); + if (!writeIndent(aOut, depth, aOpts)) + return false; + } + aOut.put(isObject ? '}' : ']'); + if (!aOut) + return false; + } + } + return static_cast(aOut); +} + +/*static*/ +// Appends one serialized value to an existing string. +void pjsonImpl::_appendValue(std::string& aOut, const pjson& aValue, + const pjson::SerializeOptions& aOpts) { + StringSink sink(aOut, aOpts.maxOutputBytes); + _writeValueTo(sink, aValue, aOpts); +} + +/*static*/ +// Streams one serialized value and returns the resulting stream health. +bool pjsonImpl::_writeValue(std::ostream& aOut, const pjson& aValue, + const pjson::SerializeOptions& aOpts) { + CountingSink count(aOpts.maxOutputBytes); + if (!_writeValueTo(count, aValue, aOpts)) { + aOut.setstate(std::ios::failbit); + return false; + } + // Preflight owns the configured budget; emission itself is unlimited so a + // successful count cannot fail due to double-accounting. + StreamSink sink(aOut, 0); + return _writeValueTo(sink, aValue, aOpts); +} + +// Serializes with compact default options. +std::string pjson::toString() const { + return toString(SerializeOptions()); +} + +// Serializes this complete DOM to a newly allocated string. +std::string pjson::toString(const SerializeOptions& aOpts) const { + CountingSink count(aOpts.maxOutputBytes); + if (!pjsonImpl::_writeValueTo(count, *this, aOpts)) { + if (count.hasInvalidUtf8()) + throw std::invalid_argument("JSON string contains invalid UTF-8"); + throw std::length_error("JSON output exceeds maxOutputBytes or contains invalid data"); + } + std::string result; + result.reserve(count.size()); + pjsonImpl::_appendValue(result, *this, aOpts); + return result; +} + +// Streams with compact default options. +void pjson::write(std::ostream& aOut) const { + write(aOut, SerializeOptions()); +} + +// Writes this complete DOM incrementally; callers inspect the stream state for +// output errors because the public streaming API reports through std::ostream. +void pjson::write(std::ostream& aOut, const SerializeOptions& aOpts) const { + pjsonImpl::_writeValue(aOut, *this, aOpts); +} + +//===----------------------------------------------------------------------===// +// Scalar assignment and vector-backed array mutation +// +// Scalar assignments reuse compatible storage. Array helpers allocate children +// under RAII and publish raw pointers only after container insertion succeeds. +// Multi-element mutation either swaps a complete replacement or rolls back to +// the original size, so allocation failures never leave a partial append. +//===----------------------------------------------------------------------===// + +// Replaces the current value with a copied JSON string. +pjson& pjson::operator=(const std::string& aString) { + resetIfNeeded(jsonType::jsonString); + *_uValue._pValueString = aString; + return *this; +} +// Replaces the current value with the null-terminated string's bytes. +pjson& pjson::operator=(const char* aCString) { + if (aCString == nullptr) + throw std::invalid_argument("pjson string assignment requires non-null input"); + resetIfNeeded(jsonType::jsonString); + *_uValue._pValueString = aCString; + return *this; +} +// Replaces the current value with a JSON boolean. +pjson& pjson::operator=(const bool aBool) { + resetIfNeeded(jsonType::jsonBoolean); + _uValue._valueBool = aBool; + return *this; +} +// Replaces the current value with a JSON integer. +pjson& pjson::operator=(const int64_t aInt) { + resetIfNeeded(jsonType::jsonNumberInt); + _uValue._valueInt = aInt; + return *this; +} +// Replaces the current value with a JSON double. +pjson& pjson::operator=(const double aDouble) { + resetIfNeeded(jsonType::jsonNumberDouble); + _uValue._valueDouble = aDouble; + return *this; +} +namespace { + // Appends one converted child. A non-array target is promoted atomically by + // building and swapping a replacement; an array target changes only after + // both node construction and vector growth have succeeded. + template void appendDomValue(pjson& aTarget, const Value& aValue) { + if (!aTarget.isArray()) { + pjson replacement(aTarget.getAllocator()); + replacement.resetTo(pjson::jsonArray); + pjson::unique_ptr child = pjsonImpl::_makeNode(aTarget.getAllocator()); + *child = aValue; + pjsonImpl::_array(replacement).push_back(nullptr); + pjsonImpl::_array(replacement).back() = child.release(); + aTarget.swap(replacement); + return; + } + + pjson::unique_ptr child = pjsonImpl::_makeNode(aTarget.getAllocator()); + *child = aValue; + pjsonImpl::_array(aTarget).push_back(nullptr); + pjsonImpl::_array(aTarget).back() = child.release(); + } + + // Replaces a target with a fully constructed array, providing a strong guarantee. + template void assignDomArray(pjson& aTarget, const Values& aValues) { + pjson replacement(aTarget.getAllocator()); + replacement.resetTo(pjson::jsonArray); + for (const auto& value : aValues) { + appendDomValue(replacement, value); + } + aTarget.swap(replacement); + } + + // Appends a range atomically: newly attached children are reclaimed in + // reverse order if any later conversion or allocation throws. + template void appendDomArray(pjson& aTarget, const Values& aValues) { + if (!aTarget.isArray()) { + pjson replacement(aTarget.getAllocator()); + replacement.resetTo(pjson::jsonArray); + for (const auto& value : aValues) { + appendDomValue(replacement, value); + } + aTarget.swap(replacement); + return; + } + + PJSONARRAY& array = pjsonImpl::_array(aTarget); + const size_t originalSize = array.size(); + try { + for (const auto& value : aValues) { + appendDomValue(aTarget, value); + } + } catch (...) { + while (array.size() > originalSize) { + pjson::unique_ptr rollback(array.back()); + array.pop_back(); + } + throw; + } + } +} // namespace +// Vector assignment overloads delegate to the same strong-guarantee builder, +// but remain explicit so each supported public type is visible in this source. +pjson& pjson::operator=(const std::vector& aValueArray) { + assignDomArray(*this, aValueArray); + return *this; +} + +pjson& pjson::operator=(const std::vector& aValueArray) { + assignDomArray(*this, aValueArray); + return *this; +} + +pjson& pjson::operator=(const std::vector& aValueArray) { + assignDomArray(*this, aValueArray); + return *this; +} + +pjson& pjson::operator=(const std::vector& aValueArray) { + assignDomArray(*this, aValueArray); + return *this; +} + +// Scalar append overloads promote non-arrays and publish one fully constructed child. +pjson& pjson::operator+=(const std::string& aValue) { + appendDomValue(*this, aValue); + return *this; +} +pjson& pjson::operator+=(const char* aValue) { + if (aValue == nullptr) + throw std::invalid_argument("pjson string append requires non-null input"); + appendDomValue(*this, aValue); + return *this; +} +pjson& pjson::operator+=(const bool aValue) { + appendDomValue(*this, aValue); + return *this; +} +pjson& pjson::operator+=(const int64_t aValue) { + appendDomValue(*this, aValue); + return *this; +} +pjson& pjson::operator+=(const double aValue) { + appendDomValue(*this, aValue); + return *this; +} +// Vector append overloads share the rollback semantics documented by appendDomArray. +pjson& pjson::operator+=(const std::vector& aValueArray) { + appendDomArray(*this, aValueArray); + return *this; +} + +pjson& pjson::operator+=(const std::vector& aValueArray) { + appendDomArray(*this, aValueArray); + return *this; +} + +pjson& pjson::operator+=(const std::vector& aValueArray) { + appendDomArray(*this, aValueArray); + return *this; +} + +pjson& pjson::operator+=(const std::vector& aValueArray) { + appendDomArray(*this, aValueArray); + return *this; +} +//===----------------------------------------------------------------------===// +// Container access and lookup +// +// Mutating operator[] access auto-vivifies missing containers and children; +// find() is non-mutating. Negative array indices count from the end. Mutating +// indices before the beginning clamp to zero, while lookup indices simply miss. +//===----------------------------------------------------------------------===// + +// Returns or creates an object member, atomically promoting non-object values. +pjson& pjson::operator[](const char* aSkey) { + if (aSkey == nullptr) + throw std::invalid_argument("pjson object key requires non-null input"); + if (_eType != jsonType::jsonObject) { + pjson replacement(*_allocator); + replacement.resetTo(jsonType::jsonObject); + unique_ptr child = pjsonImpl::_makeNode(*_allocator); + const std::pair inserted = replacement._uValue._pValueMap->insert( + std::make_pair(std::string(aSkey), static_cast(nullptr))); + pjson* result = child.release(); + inserted.first->second = result; + swap(replacement); + return *result; + } + PJSONMAP::iterator it = _uValue._pValueMap->find(aSkey); + if (it != _uValue._pValueMap->end()) { + return *(it->second); + } + unique_ptr child = pjsonImpl::_makeNode(*_allocator); + const std::pair inserted = _uValue._pValueMap->insert( + std::make_pair(std::string(aSkey), static_cast(nullptr))); + pjson* result = inserted.first->second; + if (inserted.second) { + result = child.release(); + inserted.first->second = result; + } + return *result; +} +// Returns or creates an array element, filling gaps with null nodes. Any failed +// growth destroys every node appended by this call before rethrowing. +pjson& pjson::operator[](int index) { + if (_eType != jsonType::jsonArray) { + pjson replacement(*_allocator); + replacement.resetTo(jsonType::jsonArray); + pjson& result = replacement[index]; + pjson* resultPtr = &result; + swap(replacement); + return *resultPtr; + } + PJSONARRAY& array = *_uValue._pValueArray; + size_t position = 0; + if (index < 0) { + // Negative indexes count from the end: -1 is the last element. + const size_t fromEnd = static_cast(-(index + 1)) + size_t(1); + position = fromEnd > array.size() ? size_t(0) : array.size() - fromEnd; + } else { + position = static_cast(index); + } + + if (position >= array.size()) { + static const size_t kMaxAutoGrowth = size_t(1000000); + const size_t growth = position - array.size() + size_t(1); + if (growth > kMaxAutoGrowth) + throw std::length_error("pjson array auto-growth exceeds safety limit"); + if (position == std::numeric_limits::max() || + position + size_t(1) > array.max_size()) + throw std::length_error("pjson array index exceeds maximum size"); + const size_t requiredSize = position + size_t(1); + const size_t originalSize = array.size(); + try { + array.reserve(requiredSize); + while (array.size() < requiredSize) { + unique_ptr child = pjsonImpl::_makeNode(*_allocator); + array.push_back(nullptr); + array.back() = child.release(); + } + } catch (...) { + while (array.size() > originalSize) { + unique_ptr rollback(array.back()); + array.pop_back(); + } + throw; + } + } + + return *array[position]; +} +pjson& pjson::operator[](const std::string& aString) { + return (*this)[aString.c_str()]; +} +pjson* pjson::find(const std::string& aKey) { + return find(aKey.c_str()); +} +// Finds an object member without inserting or changing the receiver. +pjson* pjson::find(const char* aKey) { + if (aKey != nullptr && _eType == jsonType::jsonObject) { + auto it = _uValue._pValueMap->find(aKey); + if (it != _uValue._pValueMap->end()) { + return it->second; + } + } + return nullptr; +} +const pjson* pjson::find(const std::string& aKey) const { + return find(aKey.c_str()); +} +const pjson* pjson::find(const char* aKey) const { + if (aKey != nullptr && _eType == jsonType::jsonObject) { + auto it = _uValue._pValueMap->find(aKey); + if (it != _uValue._pValueMap->end()) { + return it->second; + } + } + return nullptr; +} +pjson* pjson::find(int aIndex) noexcept { + return const_cast(static_cast(this)->find(aIndex)); +} +// Finds an array element with end-relative negative-index support. +const pjson* pjson::find(int aIndex) const noexcept { + if (_eType != jsonType::jsonArray) + return nullptr; + + const PJSONARRAY& values = *_uValue._pValueArray; + size_t position = 0; + if (aIndex >= 0) { + position = static_cast(aIndex); + if (position >= values.size()) + return nullptr; + } else { + const size_t fromEnd = static_cast(-(aIndex + 1)) + size_t(1); + if (fromEnd > values.size()) + return nullptr; + position = values.size() - fromEnd; + } + return values[position]; +} + +//===----------------------------------------------------------------------===// +// RFC 6901 JSON Pointer decoding and traversal +// +// Pointer syntax is decoded once into unescaped tokens, then traversed without +// mutating the DOM. Array tokens must be canonical unsigned decimals: no sign, +// leading zero, or size_t overflow. The '-' token is reserved for Patch add. +//===----------------------------------------------------------------------===// + +namespace { + // Separates malformed decimal syntax from arithmetic overflow so public + // diagnostics can distinguish invalid and merely out-of-range indices. + enum PointerIndexResult { PointerIndexOk, PointerIndexInvalid, PointerIndexOverflow }; + + // Restores a reusable PointerError to its successful neutral state. + void resetPointerError(pjson::PointerError& aError) { + aError.ok = true; + aError.code = pjson::PointerError::Ok; + aError.pointer.clear(); + aError.tokenIndex = 0; + aError.token.clear(); + aError.message.clear(); + } + + // Records the first-class pointer, token location, and failure category. + bool failPointer(pjson::PointerError& aError, pjson::PointerError::Code aCode, + const std::string& aPointer, size_t aTokenIndex, const std::string& aToken, + const char* aMessage) { + aError.ok = false; + aError.code = aCode; + aError.pointer = aPointer; + aError.tokenIndex = aTokenIndex; + aError.token = aToken; + aError.message = aMessage; + return false; + } + + // Parses RFC 6901's canonical array-index subset without overflowing size_t. + PointerIndexResult parsePointerIndex(const std::string& aToken, size_t& aIndex) { + if (aToken.empty() || (aToken.size() > 1 && aToken[0] == '0')) + return PointerIndexInvalid; + + size_t value = 0; + for (size_t i = 0; i < aToken.size(); ++i) { + const unsigned char ch = static_cast(aToken[i]); + if (ch < static_cast('0') || ch > static_cast('9')) + return PointerIndexInvalid; + const size_t digit = static_cast(ch - static_cast('0')); + if (value > (std::numeric_limits::max() - digit) / size_t(10)) + return PointerIndexOverflow; + value = value * size_t(10) + digit; + } + aIndex = value; + return PointerIndexOk; + } + + // Splits a pointer and decodes ~0/~1 escapes. An empty pointer deliberately + // yields no tokens because it identifies the document root. + bool decodePointer(const std::string& aPointer, std::vector& aTokens, + pjson::PointerError& aError) { + resetPointerError(aError); + aTokens.clear(); + if (aPointer.empty()) + return true; + if (aPointer[0] != '/') + return failPointer(aError, pjson::PointerError::InvalidSyntax, aPointer, 0, + std::string(), "JSON Pointer must be empty or begin with '/'"); + + size_t tokenIndex = 0; + size_t tokenStart = 1; + while (true) { + const size_t slash = aPointer.find('/', tokenStart); + const size_t tokenEnd = slash == std::string::npos ? aPointer.size() : slash; + std::string decoded; + decoded.reserve(tokenEnd - tokenStart); + for (size_t i = tokenStart; i < tokenEnd; ++i) { + const char ch = aPointer[i]; + if (ch != '~') { + decoded += ch; + continue; + } + if (i + 1 >= tokenEnd || (aPointer[i + 1] != '0' && aPointer[i + 1] != '1')) { + return failPointer(aError, pjson::PointerError::InvalidEscape, aPointer, + tokenIndex, + aPointer.substr(tokenStart, tokenEnd - tokenStart), + "JSON Pointer token contains an invalid '~' escape"); + } + decoded += aPointer[i + 1] == '0' ? '~' : '/'; + ++i; + } + aTokens.push_back(std::move(decoded)); + if (slash == std::string::npos) + break; + tokenStart = slash + 1; + ++tokenIndex; + } + return true; + } + + // Traverses the first aCount decoded tokens and reports the exact failing + // token. Patch reuses partial traversal to resolve a destination's parent. + const pjson* resolvePointerTokens(const pjson& aRoot, const std::vector& aTokens, + size_t aCount, const std::string& aPointer, + pjson::PointerError& aError) { + const pjson* current = &aRoot; + for (size_t i = 0; i < aCount; ++i) { + const std::string& token = aTokens[i]; + if (current->isObject()) { + const PJSONMAP* object = &pjsonImpl::_object(*current); + PJSONMAP::const_iterator found = object->find(token); + if (found == object->end()) { + failPointer(aError, pjson::PointerError::MissingTarget, aPointer, i, token, + "JSON Pointer object member does not exist"); + return nullptr; + } + current = found->second; + continue; + } + if (current->isArray()) { + if (token == "-") { + failPointer(aError, pjson::PointerError::AppendTokenNotAllowed, aPointer, i, + token, "the '-' token is only valid for JSON Patch add"); + return nullptr; + } + size_t index = 0; + const PointerIndexResult indexResult = parsePointerIndex(token, index); + if (indexResult == PointerIndexInvalid) { + failPointer(aError, pjson::PointerError::InvalidArrayIndex, aPointer, i, token, + "JSON Pointer array index is not canonical decimal"); + return nullptr; + } + const PJSONARRAY* array = &pjsonImpl::_array(*current); + if (indexResult == PointerIndexOverflow || index >= array->size()) { + failPointer(aError, pjson::PointerError::ArrayIndexOutOfRange, aPointer, i, + token, "JSON Pointer array index is out of range"); + return nullptr; + } + current = (*array)[index]; + continue; + } + failPointer(aError, pjson::PointerError::ExpectedContainer, aPointer, i, token, + "JSON Pointer traversal reached a non-container value"); + return nullptr; + } + return current; + } +} // namespace + +/*static*/ +// Encodes one object-key token for insertion into a JSON Pointer path. +std::string pjson::escapePointerToken(const std::string& aToken) { + std::string escaped; + escaped.reserve(aToken.size()); + for (size_t i = 0; i < aToken.size(); ++i) { + if (aToken[i] == '~') + escaped += "~0"; + else if (aToken[i] == '/') + escaped += "~1"; + else + escaped += aToken[i]; + } + return escaped; +} +// Resolves a string pointer without throwing; exceptional failures are mapped +// to PointerError so nullptr always means a diagnosed failure. +const pjson* pjson::findPointer(const std::string& aPointer, PointerError& aError) const { + try { + std::vector tokens; + if (!decodePointer(aPointer, tokens, aError)) + return nullptr; + return resolvePointerTokens(*this, tokens, tokens.size(), aPointer, aError); + } catch (const std::bad_alloc&) { + try { + failPointer(aError, PointerError::AllocationFailure, std::string(), 0, std::string(), + "JSON Pointer ran out of memory"); + } catch (...) { + aError.ok = false; + aError.code = PointerError::AllocationFailure; + } + return nullptr; + } catch (...) { + try { + failPointer(aError, PointerError::InternalError, std::string(), 0, std::string(), + "JSON Pointer failed with an internal exception"); + } catch (...) { + aError.ok = false; + aError.code = PointerError::InternalError; + } + return nullptr; + } +} +// Mutable forwarding overload; traversal semantics remain non-creating. +pjson* pjson::findPointer(const std::string& aPointer, PointerError& aError) { + return const_cast(static_cast(this)->findPointer(aPointer, aError)); +} +// Convenience overload that intentionally discards pointer diagnostics. +const pjson* pjson::findPointer(const std::string& aPointer) const { + PointerError error; + return findPointer(aPointer, error); +} +// Mutable convenience overload that intentionally discards diagnostics. +pjson* pjson::findPointer(const std::string& aPointer) { + return const_cast(static_cast(this)->findPointer(aPointer)); +} +// Null-safe C-string overload; a null pointer is invalid syntax, not the root. +const pjson* pjson::findPointer(const char* aPointer, PointerError& aError) const { + try { + if (aPointer != nullptr) + return findPointer(std::string(aPointer), aError); + resetPointerError(aError); + failPointer(aError, PointerError::InvalidSyntax, std::string(), 0, std::string(), + "JSON Pointer input is null"); + return nullptr; + } catch (const std::bad_alloc&) { + aError.ok = false; + aError.code = PointerError::AllocationFailure; + return nullptr; + } catch (...) { + aError.ok = false; + aError.code = PointerError::InternalError; + return nullptr; + } +} +// Mutable null-safe C-string forwarding overload. +pjson* pjson::findPointer(const char* aPointer, PointerError& aError) { + return const_cast(static_cast(this)->findPointer(aPointer, aError)); +} +// C-string convenience overload that intentionally discards diagnostics. +const pjson* pjson::findPointer(const char* aPointer) const { + PointerError error; + return findPointer(aPointer, error); +} +// Mutable C-string convenience overload that intentionally discards diagnostics. +pjson* pjson::findPointer(const char* aPointer) { + return const_cast(static_cast(this)->findPointer(aPointer)); +} + +//===----------------------------------------------------------------------===// +// RFC 6902 JSON Patch and RFC 7396 Merge Patch helpers +// +// Helpers accept ownership of values through unique_ptr and release only after +// attachment, so failed insertions cannot leak. Public entry points work on a +// full allocator-local clone and swap it into place only after every operation +// succeeds, giving both patch formats document-level atomicity. +//===----------------------------------------------------------------------===// + +namespace { + typedef pjson::PatchError PatchError; + bool failPatch(PatchError& aError, PatchError::Code aCode, const char* aMessage); + + struct PatchBudget { + size_t operations; + size_t nodes; + size_t bytes; + size_t work; + size_t operationLimit; + size_t nodeLimit; + size_t byteLimit; + size_t workLimit; + + explicit PatchBudget(const pjson::PatchOptions& options) + : operations(0) + , nodes(0) + , bytes(0) + , work(0) + , operationLimit(options.maxOperations == 0 ? size_t(10000) : options.maxOperations) + , nodeLimit(options.maxClonedNodes == 0 ? size_t(1000000) : options.maxClonedNodes) + , byteLimit(options.maxClonedBytes == 0 ? size_t(64) * 1024U * 1024U + : options.maxClonedBytes) + , workLimit(options.maxWork == 0 ? size_t(1000000) : options.maxWork) {} + }; + + bool chargePatch(size_t& used, size_t limit, size_t amount, PatchError& error, + const char* message) { + if (amount > limit - std::min(used, limit)) + return failPatch(error, PatchError::ResourceLimit, message); + used += amount; + return true; + } + + bool measureClone(const pjson& value, PatchBudget& budget, PatchError& error) { + std::vector work; + work.push_back(&value); + while (!work.empty()) { + if (!chargePatch(budget.work, budget.workLimit, 1, error, + "JSON patch work budget exceeded") || + !chargePatch(budget.nodes, budget.nodeLimit, 1, error, + "JSON patch cloned-node budget exceeded") || + !chargePatch(budget.bytes, budget.byteLimit, sizeof(pjson), error, + "JSON patch cloned-byte budget exceeded")) + return false; + const pjson* current = work.back(); + work.pop_back(); + if (current->isString()) { + if (!chargePatch(budget.bytes, budget.byteLimit, + pjsonImpl::_string(*current).size(), error, + "JSON patch cloned-byte budget exceeded")) + return false; + } else if (current->isArray()) { + const PJSONARRAY& array = pjsonImpl::_array(*current); + const size_t remainingWork = + budget.workLimit - std::min(budget.work, budget.workLimit); + if (array.size() > remainingWork) + return failPatch(error, PatchError::ResourceLimit, + "JSON patch work budget exceeded"); + work.insert(work.end(), array.begin(), array.end()); + } else if (current->isObject()) { + const PJSONMAP& object = pjsonImpl::_object(*current); + const size_t remainingWork = + budget.workLimit - std::min(budget.work, budget.workLimit); + if (object.size() > remainingWork) + return failPatch(error, PatchError::ResourceLimit, + "JSON patch work budget exceeded"); + for (PJSONMAP::const_iterator it = object.begin(); it != object.end(); ++it) { + if (!chargePatch(budget.bytes, budget.byteLimit, it->first.size(), error, + "JSON patch cloned-byte budget exceeded")) + return false; + work.push_back(it->second); + } + } + } + return true; + } + + // Patch `test` needs bounded structural equality so an adversarial value + // cannot hide unbounded traversal behind a single operation. + bool patchEqual(const pjson& left, const pjson& right, PatchBudget& budget, PatchError& error, + bool& equal) { + struct Pair { + const pjson* left; + const pjson* right; + }; + std::vector pending; + Pair root = {&left, &right}; + pending.push_back(root); + equal = false; + while (!pending.empty()) { + if (!chargePatch(budget.work, budget.workLimit, 1, error, + "JSON Patch work budget exceeded")) + return false; + const Pair current = pending.back(); + pending.pop_back(); + const pjson& lhs = *current.left; + const pjson& rhs = *current.right; + if (lhs.isNumber() && rhs.isNumber()) { + if (pjsonImpl::_compareNumbers(lhs, rhs) != 0) + return true; + continue; + } + if (lhs.getType() != rhs.getType()) + return true; + if (lhs.isString()) { + const std::string& l = pjsonImpl::_string(lhs); + const std::string& r = pjsonImpl::_string(rhs); + if (!chargePatch(budget.work, budget.workLimit, std::max(l.size(), r.size()), error, + "JSON Patch work budget exceeded")) + return false; + if (l != r) + return true; + } else if (lhs.isBool()) { + if (pjsonImpl::_boolean(lhs) != pjsonImpl::_boolean(rhs)) + return true; + } else if (lhs.isArray()) { + const PJSONARRAY& l = pjsonImpl::_array(lhs); + const PJSONARRAY& r = pjsonImpl::_array(rhs); + if (l.size() != r.size()) + return true; + for (size_t i = 0; i < l.size(); ++i) { + Pair child = {l[i], r[i]}; + pending.push_back(child); + } + } else if (lhs.isObject()) { + const PJSONMAP& l = pjsonImpl::_object(lhs); + const PJSONMAP& r = pjsonImpl::_object(rhs); + if (l.size() != r.size()) + return true; + PJSONMAP::const_iterator li = l.begin(); + PJSONMAP::const_iterator ri = r.begin(); + for (; li != l.end(); ++li, ++ri) { + if (!chargePatch(budget.work, budget.workLimit, + std::max(li->first.size(), ri->first.size()) + size_t(1), + error, "JSON Patch work budget exceeded")) + return false; + if (li->first != ri->first) + return true; + Pair child = {li->second, ri->second}; + pending.push_back(child); + } + } + } + equal = true; + return true; + } + + // Restores a reusable PatchError before processing a new patch document. + void resetPatchError(PatchError& aError) { + aError.ok = true; + aError.code = PatchError::Ok; + aError.opIndex = 0; + aError.op.clear(); + aError.path.clear(); + aError.from.clear(); + aError.tokenIndex = 0; + aError.token.clear(); + aError.message.clear(); + } + + // Records a patch failure while preserving operation metadata set by the caller. + bool failPatch(PatchError& aError, PatchError::Code aCode, const char* aMessage) { + aError.ok = false; + aError.code = aCode; + aError.message = aMessage; + return false; + } + + // Records a patch failure associated with one decoded pointer token. + bool failPatchAtToken(PatchError& aError, PatchError::Code aCode, size_t aTokenIndex, + const std::string& aToken, const char* aMessage) { + aError.tokenIndex = aTokenIndex; + aError.token = aToken; + return failPatch(aError, aCode, aMessage); + } + + // Best-effort noexcept diagnostic used while translating allocation or + // unexpected exceptions out of the public patch API. + void failPatchException(PatchError& aError, PatchError::Code aCode, + const char* aMessage) noexcept { + aError.ok = false; + aError.code = aCode; + try { + aError.message = aMessage; + } catch (...) { + aError.message.clear(); + } + } + + // Maps traversal categories into the smaller PatchError vocabulary. + PatchError::Code pointerCodeForPatch(pjson::PointerError::Code aCode) { + switch (aCode) { + case pjson::PointerError::InvalidArrayIndex: + case pjson::PointerError::AppendTokenNotAllowed: + return PatchError::InvalidArrayIndex; + case pjson::PointerError::ArrayIndexOutOfRange: + return PatchError::ArrayIndexOutOfRange; + case pjson::PointerError::MissingTarget: + case pjson::PointerError::ExpectedContainer: + return PatchError::TargetMissing; + case pjson::PointerError::AllocationFailure: + return PatchError::AllocationFailure; + case pjson::PointerError::InternalError: + return PatchError::InternalError; + default: + return PatchError::TargetMissing; + } + } + + // Copies token context from a pointer failure into the active operation error. + bool failPatchFromPointer(PatchError& aError, const pjson::PointerError& aPointerError) { + aError.tokenIndex = aPointerError.tokenIndex; + aError.token = aPointerError.token; + return failPatch(aError, pointerCodeForPatch(aPointerError.code), + aPointerError.message.c_str()); + } + + // Decodes a Patch pointer and classifies syntax failures as path or from errors. + bool decodePatchPointer(const std::string& aPointer, bool bFrom, + std::vector& aTokens, PatchBudget& aBudget, + PatchError& aError) { + if (!chargePatch(aBudget.work, aBudget.workLimit, aPointer.size() + size_t(1), aError, + "JSON patch work budget exceeded")) + return false; + pjson::PointerError pointerError; + if (decodePointer(aPointer, aTokens, pointerError)) + return true; + aError.tokenIndex = pointerError.tokenIndex; + aError.token = pointerError.token; + return failPatch(aError, bFrom ? PatchError::InvalidFrom : PatchError::InvalidPath, + pointerError.message.c_str()); + } + + // Resolves a mutable prefix and translates PointerError into PatchError. + pjson* resolvePatchTokens(pjson& aRoot, const std::vector& aTokens, size_t aCount, + const std::string& aPointer, PatchBudget& aBudget, + PatchError& aError) { + if (!chargePatch(aBudget.work, aBudget.workLimit, aCount + size_t(1), aError, + "JSON patch work budget exceeded")) + return nullptr; + pjson::PointerError pointerError; + const pjson* result = resolvePointerTokens(aRoot, aTokens, aCount, aPointer, pointerError); + if (result == nullptr) { + failPatchFromPointer(aError, pointerError); + return nullptr; + } + return const_cast(result); + } + + // Validates a destination/source array index. '-' denotes exactly size() and + // is accepted only for add, whose insertion range includes the end position. + bool patchArrayIndex(const pjson& aParent, const std::string& aToken, bool bAllowAppend, + size_t aTokenIndex, size_t& aIndex, bool& bAppend, PatchError& aError) { + bAppend = false; + if (aToken == "-") { + if (bAllowAppend) { + bAppend = true; + aIndex = aParent.size(); + return true; + } + return failPatchAtToken(aError, PatchError::InvalidArrayIndex, aTokenIndex, aToken, + "the '-' token is valid only for add destinations"); + } + + const PointerIndexResult result = parsePointerIndex(aToken, aIndex); + if (result == PointerIndexInvalid) + return failPatchAtToken(aError, PatchError::InvalidArrayIndex, aTokenIndex, aToken, + "array index is not canonical decimal"); + const size_t size = aParent.size(); + if (result == PointerIndexOverflow || (bAllowAppend ? aIndex > size : aIndex >= size)) + return failPatchAtToken(aError, PatchError::ArrayIndexOutOfRange, aTokenIndex, aToken, + "array index is out of range"); + return true; + } + + // Consumes an allocator-compatible value and implements Patch add. Existing + // object members are replaced; array insertion shifts following elements. + bool addOwnedAtPointer(pjson& aRoot, const std::vector& aTokens, + const std::string& aPointer, pjson::unique_ptr aValue, + PatchBudget& aBudget, PatchError& aError) { + if (aTokens.empty()) { + aRoot.swap(*aValue); + return true; + } + + const size_t finalIndex = aTokens.size() - 1; + pjson* parent = resolvePatchTokens(aRoot, aTokens, finalIndex, aPointer, aBudget, aError); + if (parent == nullptr) + return false; + const std::string& token = aTokens.back(); + + if (parent->isObject()) { + PJSONMAP* object = &pjsonImpl::_object(*parent); + PJSONMAP::iterator existing = object->find(token); + if (existing != object->end()) { + existing->second->swap(*aValue); + return true; + } + if (!chargePatch(aBudget.bytes, aBudget.byteLimit, token.size(), aError, + "JSON Patch cloned-byte budget exceeded")) + return false; + const std::pair inserted = + object->insert(std::make_pair(token, static_cast(nullptr))); + if (!inserted.second) + return failPatchAtToken(aError, PatchError::InternalError, finalIndex, token, + "failed to insert object member"); + inserted.first->second = aValue.release(); + return true; + } + + if (parent->isArray()) { + size_t index = 0; + bool append = false; + if (!patchArrayIndex(*parent, token, true, finalIndex, index, append, aError)) + return false; + PJSONARRAY* array = &pjsonImpl::_array(*parent); + if (!chargePatch(aBudget.work, aBudget.workLimit, array->size() - index, aError, + "JSON Patch work budget exceeded")) + return false; + const PJSONARRAY::iterator inserted = + array->insert(array->begin() + static_cast(index), nullptr); + *inserted = aValue.release(); + return true; + } + + return failPatchAtToken(aError, PatchError::TargetMissing, finalIndex, token, + "add destination parent is not a container"); + } + + // Consumes a replacement only after proving the complete target exists. + bool replaceAtPointer(pjson& aRoot, const std::vector& aTokens, + const std::string& aPointer, pjson::unique_ptr aValue, + PatchBudget& aBudget, PatchError& aError) { + if (aTokens.empty()) { + aRoot.swap(*aValue); + return true; + } + + const size_t finalIndex = aTokens.size() - 1; + pjson* parent = resolvePatchTokens(aRoot, aTokens, finalIndex, aPointer, aBudget, aError); + if (parent == nullptr) + return false; + const std::string& token = aTokens.back(); + + if (parent->isObject()) { + PJSONMAP* object = &pjsonImpl::_object(*parent); + PJSONMAP::iterator existing = object->find(token); + if (existing == object->end()) + return failPatchAtToken(aError, PatchError::TargetMissing, finalIndex, token, + "replace target does not exist"); + existing->second->swap(*aValue); + return true; + } + + if (parent->isArray()) { + size_t index = 0; + bool append = false; + if (!patchArrayIndex(*parent, token, false, finalIndex, index, append, aError)) + return false; + pjsonImpl::_array(*parent)[index]->swap(*aValue); + return true; + } + + return failPatchAtToken(aError, PatchError::TargetMissing, finalIndex, token, + "replace target parent is not a container"); + } + + // Detaches a target without destroying it. Removing the document root is + // represented by replacing the still-addressable root value with JSON null. + bool detachAtPointer(pjson& aRoot, const std::vector& aTokens, + const std::string& aPointer, pjson::unique_ptr& aValue, + PatchBudget& aBudget, PatchError& aError) { + if (aTokens.empty()) { + if (!chargePatch(aBudget.nodes, aBudget.nodeLimit, 1, aError, + "JSON Patch cloned-node budget exceeded") || + !chargePatch(aBudget.bytes, aBudget.byteLimit, sizeof(pjson), aError, + "JSON Patch cloned-byte budget exceeded")) + return false; + pjson::unique_ptr replacement = pjsonImpl::_makeNode(aRoot.getAllocator()); + aRoot.swap(*replacement); + aValue = std::move(replacement); + return true; + } + + const size_t finalIndex = aTokens.size() - 1; + pjson* parent = resolvePatchTokens(aRoot, aTokens, finalIndex, aPointer, aBudget, aError); + if (parent == nullptr) + return false; + const std::string& token = aTokens.back(); + + if (parent->isObject()) { + PJSONMAP* object = &pjsonImpl::_object(*parent); + PJSONMAP::iterator existing = object->find(token); + if (existing == object->end()) + return failPatchAtToken(aError, PatchError::TargetMissing, finalIndex, token, + "remove source does not exist"); + aValue.reset(existing->second); + object->erase(existing); + return true; + } + + if (parent->isArray()) { + size_t index = 0; + bool append = false; + if (!patchArrayIndex(*parent, token, false, finalIndex, index, append, aError)) + return false; + PJSONARRAY* array = &pjsonImpl::_array(*parent); + if (!chargePatch(aBudget.work, aBudget.workLimit, array->size() - index - size_t(1), + aError, "JSON Patch work budget exceeded")) + return false; + aValue.reset((*array)[index]); + array->erase(array->begin() + static_cast(index)); + return true; + } + + return failPatchAtToken(aError, PatchError::TargetMissing, finalIndex, token, + "remove source parent is not a container"); + } + + // Compares decoded paths so alternate escape spellings cannot affect identity. + bool samePointerTokens(const std::vector& aLeft, + const std::vector& aRight) { + return aLeft == aRight; + } + + // Detects a move into the source's own descendant, which would invalidate + // the destination during detachment and is forbidden by JSON Patch. + bool isProperPointerAncestor(const std::vector& aAncestor, + const std::vector& aDescendant) { + return aAncestor.size() < aDescendant.size() && + std::equal(aAncestor.begin(), aAncestor.end(), aDescendant.begin()); + } + + // Replaces or adopts an allocator-compatible object child without exposing + // a null map entry if insertion fails. + bool insertObjectChild(pjson& aObject, const std::string& aKey, pjson::unique_ptr aChild) { + PJSONMAP* object = &pjsonImpl::_object(aObject); + PJSONMAP::iterator existing = object->find(aKey); + if (existing != object->end()) { + existing->second->swap(*aChild); + return true; + } + const std::pair inserted = + object->insert(std::make_pair(aKey, static_cast(nullptr))); + if (!inserted.second) + return false; + inserted.first->second = aChild.release(); + return true; + } + + // Applies Merge Patch iteratively to a private working document. Object + // patches recurse, null members delete, and every other value replaces via + // an allocator-local clone. Atomic publication is handled by the caller. + bool applyMergePatchTo(pjson& aTarget, const pjson& aPatch, PatchBudget& aBudget, + PatchError& aError) { + struct MergeItem { + // target and patch describe one pending object/object merge. + pjson* target; + const pjson* patch; + }; + + if (!aPatch.isObject()) { + if (!chargePatch(aBudget.operations, aBudget.operationLimit, 1, aError, + "JSON Merge Patch operation budget exceeded")) + return false; + if (!measureClone(aPatch, aBudget, aError)) + return false; + pjson replacement(aPatch, aTarget.getAllocator()); + aTarget.swap(replacement); + return true; + } + + std::vector work; + MergeItem root = {&aTarget, &aPatch}; + work.push_back(root); + while (!work.empty()) { + const MergeItem item = work.back(); + work.pop_back(); + if (!item.target->isObject()) + item.target->resetTo(pjson::jsonObject); + + const PJSONMAP* patchObject = &pjsonImpl::_object(*item.patch); + for (PJSONMAP::const_iterator it = patchObject->begin(); it != patchObject->end(); + ++it) { + if (!chargePatch(aBudget.operations, aBudget.operationLimit, 1, aError, + "JSON Merge Patch operation budget exceeded") || + !chargePatch(aBudget.work, aBudget.workLimit, 1, aError, + "JSON Merge Patch work budget exceeded")) + return false; + const std::string& key = it->first; + const pjson& patchValue = *it->second; + if (!chargePatch(aBudget.bytes, aBudget.byteLimit, key.size(), aError, + "JSON Merge Patch cloned-byte budget exceeded")) + return false; + if (patchValue.isNull()) { + item.target->erase(key); + continue; + } + + pjson* targetValue = item.target->find(key); + if (patchValue.isObject()) { + if (targetValue == nullptr) { + if (!chargePatch(aBudget.nodes, aBudget.nodeLimit, 1, aError, + "JSON Merge Patch cloned-node budget exceeded") || + !chargePatch(aBudget.bytes, aBudget.byteLimit, sizeof(pjson), aError, + "JSON Merge Patch cloned-byte budget exceeded")) + return false; + pjson::unique_ptr child = pjsonImpl::_makeNode(item.target->getAllocator()); + child->resetTo(pjson::jsonObject); + targetValue = child.get(); + if (!insertObjectChild(*item.target, key, std::move(child))) + return false; + } else if (!targetValue->isObject()) { + targetValue->resetTo(pjson::jsonObject); + } + MergeItem childItem = {targetValue, &patchValue}; + work.push_back(childItem); + continue; + } + + if (!measureClone(patchValue, aBudget, aError)) + return false; + pjson::unique_ptr replacement = + pjsonImpl::_cloneNode(patchValue, item.target->getAllocator()); + if (!insertObjectChild(*item.target, key, std::move(replacement))) + return false; + } + } + return true; + } +} // namespace + +// Key/index extraction overloads combine non-mutating lookup with exact +// tryGet conversion and leave output parameters unchanged on any miss. +bool pjson::tryGet(const std::string& aKey, int64_t& aResult) const { + return tryGet(aKey.c_str(), aResult); +} +bool pjson::tryGet(const std::string& aKey, double& aResult) const { + return tryGet(aKey.c_str(), aResult); +} +bool pjson::tryGet(const std::string& aKey, bool& aResult) const { + return tryGet(aKey.c_str(), aResult); +} +bool pjson::tryGet(const std::string& aKey, std::string& aResult) const { + return tryGet(aKey.c_str(), aResult); +} +bool pjson::tryGet(const std::string& aKey, StringView& aResult) const { + return tryGet(aKey.c_str(), aResult); +} +bool pjson::tryGet(const char* aKey, int64_t& aResult) const { + const pjson* value = find(aKey); + return value != nullptr && value->tryGet(aResult); +} +bool pjson::tryGet(const char* aKey, double& aResult) const { + const pjson* value = find(aKey); + return value != nullptr && value->tryGet(aResult); +} +bool pjson::tryGet(const char* aKey, bool& aResult) const { + const pjson* value = find(aKey); + return value != nullptr && value->tryGet(aResult); +} +bool pjson::tryGet(const char* aKey, std::string& aResult) const { + const pjson* value = find(aKey); + return value != nullptr && value->tryGet(aResult); +} +bool pjson::tryGet(const char* aKey, StringView& aResult) const { + const pjson* value = find(aKey); + return value != nullptr && value->tryGet(aResult); +} +bool pjson::tryGet(int aIndex, int64_t& aResult) const noexcept { + const pjson* value = find(aIndex); + return value != nullptr && value->tryGet(aResult); +} +bool pjson::tryGet(int aIndex, double& aResult) const noexcept { + const pjson* value = find(aIndex); + return value != nullptr && value->tryGet(aResult); +} +bool pjson::tryGet(int aIndex, bool& aResult) const noexcept { + const pjson* value = find(aIndex); + return value != nullptr && value->tryGet(aResult); +} +bool pjson::tryGet(int aIndex, std::string& aResult) const { + const pjson* value = find(aIndex); + return value != nullptr && value->tryGet(aResult); +} +bool pjson::tryGet(int aIndex, StringView& aResult) const noexcept { + const pjson* value = find(aIndex); + return value != nullptr && value->tryGet(aResult); +} + +//===----------------------------------------------------------------------===// +// Public DOM and SAX parse API families +// +// Overloads differ only in input source and diagnostics. Every DOM parse returns +// the origin-aware pjson::unique_ptr, including roots from the default allocator. +//===----------------------------------------------------------------------===// + +/*static*/ +// Parses string-owned bytes with default allocation and omitted diagnostics. +pjson::unique_ptr pjson::parse(const std::string& aStr, const ParseOptions& aOpts) { + return pjsonImpl::_parseTop(aStr.c_str(), aStr.length(), aOpts, nullptr, + pjsonImpl::_defaultAllocator()); +} +/*static*/ +// Parses an explicit byte span with default allocation and omitted diagnostics. +pjson::unique_ptr pjson::parse(const char* aSrc, size_t aSize, const ParseOptions& aOpts) { + return pjsonImpl::_parseTop(aSrc, aSize, aOpts, nullptr, pjsonImpl::_defaultAllocator()); +} +/*static*/ +// Emits SAX events from string-owned bytes and discards detailed diagnostics. +bool pjson::parseSax(const std::string& aStr, SaxHandler& aHandler, const ParseOptions& aOpts) { + return pjsonImpl::_parseSaxTop(aStr.c_str(), aStr.length(), aHandler, aOpts, nullptr); +} +/*static*/ +// Emits SAX events from an explicit byte span and discards detailed diagnostics. +bool pjson::parseSax(const char* aSrc, size_t aSize, SaxHandler& aHandler, + const ParseOptions& aOpts) { + return pjsonImpl::_parseSaxTop(aSrc, aSize, aHandler, aOpts, nullptr); +} +/*static*/ +// Parses string-owned bytes and fills a caller-visible ParseError. +pjson::unique_ptr pjson::parse(const std::string& aStr, ParseError& aError, + const ParseOptions& aOpts) { + return pjsonImpl::_parseTop(aStr.c_str(), aStr.length(), aOpts, &aError, + pjsonImpl::_defaultAllocator()); +} +/*static*/ +// Emits SAX events from a string and fills a caller-visible ParseError. +bool pjson::parseSax(const std::string& aStr, SaxHandler& aHandler, ParseError& aError, + const ParseOptions& aOpts) { + return pjsonImpl::_parseSaxTop(aStr.c_str(), aStr.length(), aHandler, aOpts, &aError); +} +/*static*/ +// Parses an explicit byte span and fills a caller-visible ParseError. +pjson::unique_ptr pjson::parse(const char* aSrc, size_t aSize, ParseError& aError, + const ParseOptions& aOpts) { + return pjsonImpl::_parseTop(aSrc, aSize, aOpts, &aError, pjsonImpl::_defaultAllocator()); +} +/*static*/ +// Emits SAX events from a byte span and fills a caller-visible ParseError. +bool pjson::parseSax(const char* aSrc, size_t aSize, SaxHandler& aHandler, ParseError& aError, + const ParseOptions& aOpts) { + return pjsonImpl::_parseSaxTop(aSrc, aSize, aHandler, aOpts, &aError); +} +/*static*/ +// Parses a stream with default allocation and omitted diagnostics. +pjson::unique_ptr pjson::parseStream(std::istream& aIn, const ParseOptions& aOpts) { + return pjsonImpl::_parseStream(aIn, aOpts, nullptr, pjsonImpl::_defaultAllocator()); +} +/*static*/ +// Parses a stream with default allocation and caller-visible diagnostics. +pjson::unique_ptr pjson::parseStream(std::istream& aIn, ParseError& aError, + const ParseOptions& aOpts) { + return pjsonImpl::_parseStream(aIn, aOpts, &aError, pjsonImpl::_defaultAllocator()); +} +/*static*/ +// Parses string-owned bytes with nodes and wrapper objects from aAlloc. +pjson::unique_ptr pjson::parse(const std::string& aStr, Allocator& aAlloc, + const ParseOptions& aOpts) { + return pjsonImpl::_parseTop(aStr.c_str(), aStr.length(), aOpts, nullptr, aAlloc); +} +/*static*/ +// Parses a byte span with nodes and wrapper objects from aAlloc. +pjson::unique_ptr pjson::parse(const char* aSrc, size_t aSize, Allocator& aAlloc, + const ParseOptions& aOpts) { + return pjsonImpl::_parseTop(aSrc, aSize, aOpts, nullptr, aAlloc); +} +/*static*/ +// Parses string-owned bytes with custom allocation and detailed diagnostics. +pjson::unique_ptr pjson::parse(const std::string& aStr, ParseError& aError, Allocator& aAlloc, + const ParseOptions& aOpts) { + return pjsonImpl::_parseTop(aStr.c_str(), aStr.length(), aOpts, &aError, aAlloc); +} +/*static*/ +// Parses a byte span with custom allocation and detailed diagnostics. +pjson::unique_ptr pjson::parse(const char* aSrc, size_t aSize, ParseError& aError, + Allocator& aAlloc, const ParseOptions& aOpts) { + return pjsonImpl::_parseTop(aSrc, aSize, aOpts, &aError, aAlloc); +} +/*static*/ +// Parses a stream with nodes and wrapper objects from aAlloc. +pjson::unique_ptr pjson::parseStream(std::istream& aIn, Allocator& aAlloc, + const ParseOptions& aOpts) { + return pjsonImpl::_parseStream(aIn, aOpts, nullptr, aAlloc); +} +/*static*/ +// Parses a stream with custom allocation and detailed diagnostics. +pjson::unique_ptr pjson::parseStream(std::istream& aIn, ParseError& aError, Allocator& aAlloc, + const ParseOptions& aOpts) { + return pjsonImpl::_parseStream(aIn, aOpts, &aError, aAlloc); +} +/*static*/ +// Emits SAX events directly from a stream and discards detailed diagnostics. +bool pjson::parseSaxStream(std::istream& aIn, SaxHandler& aHandler, const ParseOptions& aOpts) { + return pjsonImpl::_parseSaxStream(aIn, aHandler, aOpts, nullptr); +} +/*static*/ +// Emits SAX events directly from a stream with caller-visible diagnostics. +bool pjson::parseSaxStream(std::istream& aIn, SaxHandler& aHandler, ParseError& aError, + const ParseOptions& aOpts) { + return pjsonImpl::_parseSaxStream(aIn, aHandler, aOpts, &aError); +} +// Reads incrementally so maxInputBytes bounds memory before the complete stream +// has been materialized. +/*static*/ +pjson::unique_ptr pjsonImpl::_parseStream(std::istream& aIn, const ParseOptions& aOpts, + ParseError* aErr, pjson::Allocator& aAlloc) { + std::string content; + char buffer[8192]; + while (aIn.good()) { + aIn.read(buffer, sizeof(buffer)); + const std::streamsize got = aIn.gcount(); + if (got <= 0) + continue; + const size_t chunk = static_cast(got); + if (aOpts.maxInputBytes != 0 && (content.size() > aOpts.maxInputBytes || + chunk > aOpts.maxInputBytes - content.size())) { + // Include as much of this chunk as fits, allowing line/column to be + // calculated at the exact configured byte boundary. + if (content.size() < aOpts.maxInputBytes) { + content.append(buffer, aOpts.maxInputBytes - content.size()); + } + setParseError(aErr, content.data(), content.size(), aOpts.maxInputBytes, + "input exceeds maxInputBytes"); + return pjson::unique_ptr(); + } + content.append(buffer, chunk); + } + if (aIn.bad()) { + setParseError(aErr, content.data(), content.size(), content.size(), "stream read failed"); + return pjson::unique_ptr(); + } + return _parseTop(content.c_str(), content.length(), aOpts, aErr, aAlloc); +} +/*static*/ +bool pjsonImpl::_parseSaxTop(const char* aSrc, size_t aSize, SaxHandler& aHandler, + const ParseOptions& aOpts, ParseError* aErr) { + resetParseError(aErr); + if (aSrc == nullptr) { + setParseError(aErr, "", 0, 0, "null input"); + return false; + } + if (aOpts.maxInputBytes != 0 && aSize > aOpts.maxInputBytes) { + setParseError(aErr, aSrc, aSize, aOpts.maxInputBytes, "input exceeds maxInputBytes"); + return false; + } + BufferSaxCursor cursor(aSrc, aSize); + SaxParser parser(cursor, aHandler, aOpts, aErr); + return parser.parseDocument(); +} +/*static*/ +bool pjsonImpl::_parseSaxStream(std::istream& aIn, SaxHandler& aHandler, const ParseOptions& aOpts, + ParseError* aErr) { + resetParseError(aErr); + StreamSaxCursor cursor(aIn); + SaxParser parser(cursor, aHandler, aOpts, aErr); + return parser.parseDocument(); +} + +//===----------------------------------------------------------------------===// +// DOM recursive-descent parser +// +// The cursor advances only across validated syntax, every materialized value +// consumes the shared node budget, and local unique_ptr guards retain ownership +// until a child is attached. The first grammar error remains authoritative. +//===----------------------------------------------------------------------===// + +// Shared driver: parse a single top-level value, require only trailing +// whitespace, and report success/failure through the optional ParseError. +/*static*/ +pjson::unique_ptr pjsonImpl::_parseTop(const char* aSrc, size_t aSize, const ParseOptions& aOpts, + ParseError* aErr, pjson::Allocator& aAlloc) { + resetParseError(aErr); + if (aSrc == nullptr) { + setParseError(aErr, "", 0, 0, "null input"); + return pjson::unique_ptr(); + } + + // Reject an over-large input up front (cheap DoS guard before any work). + if (aOpts.maxInputBytes != 0 && aSize > aOpts.maxInputBytes) { + setParseError(aErr, aSrc, aSize, aOpts.maxInputBytes, "input exceeds maxInputBytes"); + return pjson::unique_ptr(); + } + + ParseCtx c; + c.src = aSrc; + c.pos = 0; + c.end = aSize; + c.duplicateKeys = aOpts.duplicateKeys; + c.depth = 0; + c.maxDepth = aOpts.maxDepth > 0 ? aOpts.maxDepth : 1; + c.nodeCount = 0; + c.maxNodes = aOpts.maxNodes; + c.allocator = &aAlloc; + c.failed = false; + c.errPos = 0; + + pjson::unique_ptr result; + try { + pjson* parsed = nullptr; + if (!_parseValue(c, parsed)) { + pjsonImpl::_destroyNode(parsed); + setParseError(aErr, aSrc, aSize, c.errPos, c.errMsg.empty() ? "parse error" : c.errMsg); + return pjson::unique_ptr(); + } + result.reset(parsed); + + // A valid document is a single value; only trailing whitespace may follow. + char trailing; + if (_peek(c, trailing)) { + setParseError(aErr, aSrc, aSize, c.pos, "trailing characters after JSON value"); + return pjson::unique_ptr(); + } + return result; + } catch (const std::bad_alloc&) { + setParseError(aErr, aSrc, aSize, c.pos, "parse ran out of memory"); + } catch (const std::exception& ex) { + setParseError(aErr, aSrc, aSize, c.pos, + std::string("parse failed with exception: ") + ex.what()); + } catch (...) { + setParseError(aErr, aSrc, aSize, c.pos, "parse failed with exception"); + } + return pjson::unique_ptr(); +} +// Skips whitespace and reports the next character without consuming it. +/*static*/ +bool pjsonImpl::_peek(ParseCtx& c, char& aOut) { + while (c.pos < c.end) { + aOut = c.src[c.pos]; + if (_isWhitespace(aOut)) { + ++c.pos; + } else { + return true; + } + } + return false; +} +// Consumes the ':' separating an object key from its value (skipping ws). +/*static*/ +bool pjsonImpl::_skipColon(ParseCtx& c) { + while (c.pos < c.end) { + char ch = c.src[c.pos++]; + if (ch == ':') { + return true; + } else if (_isWhitespace(ch)) { + // ignore + } else { + return _fail(c, c.pos - 1, "expected ':' after object key"); + } + } + return _fail(c, c.pos, "expected ':' after object key"); +} +// Dispatches on the next non-whitespace character to the right sub-parser. +/*static*/ +bool pjsonImpl::_parseValue(ParseCtx& c, pjson*& aOut) { + char ch; + if (!_peek(c, ch)) { + return _fail(c, c.pos, "unexpected end of input; expected a value"); + } + if (ch == '\"') { + return _parseString(c, aOut); + } else if (ch == '{') { + return _parseObject(c, aOut); + } else if (ch == '[') { + return _parseArray(c, aOut); + } else if (ch == '-' || (ch >= '0' && ch <= '9')) { + return _parseNumber(c, aOut); + } else { + // RFC 8259 null / true / false literals. + return _parseKeyword(c, aOut); + } +} +// Matches a keyword literal using the exact lowercase RFC spelling. +/*static*/ +bool pjsonImpl::_parseKeyword(ParseCtx& c, pjson*& aOut) { + struct KW { + const char* word; + size_t len; + int kind; + }; // kind: 0 null,1 true,2 false + static const KW kws[] = { + {"null", 4, 0}, + {"true", 4, 1}, + {"false", 5, 2}, + }; + for (const KW& kw : kws) { + if (c.pos + kw.len > c.end) + continue; + bool match = true; + for (size_t k = 0; k < kw.len; ++k) { + char a = c.src[c.pos + k]; + char b = kw.word[k]; + if (a != b) { + match = false; + break; + } + } + if (match) { + c.pos += kw.len; + pjson::unique_ptr value(_newNode(c)); + if (!value) + return false; + if (kw.kind == 1) + *value = true; + else if (kw.kind == 2) + *value = false; + // kind 0 leaves it as null + aOut = value.release(); + return true; + } + } + return _fail(c, c.pos, "invalid JSON value"); +} +// Reads a quoted string body starting at the opening '"'. +/*static*/ +bool pjsonImpl::_extractString(ParseCtx& c, std::string& aOut) { + if (c.pos >= c.end || c.src[c.pos] != '\"') { + return _fail(c, c.pos, "expected '\"' to start a string"); + } + ++c.pos; // consume opening quote + return pjsonImpl::_decodeStringBody(c, aOut, /*bStopAtQuote=*/true); +} +/*static*/ +// Parses and allocates one string value after decoding its complete token. +bool pjsonImpl::_parseString(ParseCtx& c, pjson*& aOut) { + std::string s; + if (!_extractString(c, s)) { + return false; + } + pjson::unique_ptr value(_newNode(c)); + if (!value) + return false; + *value = s; + aOut = value.release(); + return true; +} +// Parses a JSON number following the grammar +// -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)? +// Integers are stored as int64; anything with a fraction/exponent (or an +// integer that overflows int64) is stored as a double. Overflow to a +// non-finite double is rejected. Never throws. +/*static*/ +bool pjsonImpl::_parseNumber(ParseCtx& c, pjson*& aOut) { + const size_t begin = c.pos; + size_t i = c.pos; + bool bFloat = false; + + if (i < c.end && c.src[i] == '-') + ++i; + + // integer part: 0 or [1-9][0-9]* + if (i < c.end && c.src[i] == '0') { + ++i; + } else if (i < c.end && c.src[i] >= '1' && c.src[i] <= '9') { + while (i < c.end && c.src[i] >= '0' && c.src[i] <= '9') + ++i; + } else { + return _fail(c, i, "invalid number: expected digit"); + } + + // fractional part + if (i < c.end && c.src[i] == '.') { + bFloat = true; + ++i; + if (!(i < c.end && c.src[i] >= '0' && c.src[i] <= '9')) { + return _fail(c, i, "invalid number: '.' must be followed by a digit"); + } + while (i < c.end && c.src[i] >= '0' && c.src[i] <= '9') + ++i; + } + + // exponent part + if (i < c.end && (c.src[i] == 'e' || c.src[i] == 'E')) { + bFloat = true; + ++i; + if (i < c.end && (c.src[i] == '+' || c.src[i] == '-')) + ++i; + if (!(i < c.end && c.src[i] >= '0' && c.src[i] <= '9')) { + return _fail(c, i, "invalid number: exponent must have a digit"); + } + while (i < c.end && c.src[i] >= '0' && c.src[i] <= '9') + ++i; + } + + std::string sTemp(c.src + begin, i - begin); + if (bFloat) { + double d = 0.0; + if (!_parseDouble(sTemp, d) || !std::isfinite(d)) { + return _fail(c, begin, "number out of range"); + } + pjson::unique_ptr value(_newNode(c)); + if (!value) + return false; + *value = d; + aOut = value.release(); + } else { + errno = 0; + long long llVal = strtoll(sTemp.c_str(), nullptr, 10); + if (errno == ERANGE) { + // Too large for int64: fall back to double to avoid data loss. + double d = 0.0; + if (!_parseDouble(sTemp, d) || !std::isfinite(d)) { + return _fail(c, begin, "number out of range"); + } + pjson::unique_ptr value(_newNode(c)); + if (!value) + return false; + *value = d; + aOut = value.release(); + } else { + pjson::unique_ptr value(_newNode(c)); + if (!value) + return false; + *value = static_cast(llVal); + aOut = value.release(); + } + } + c.pos = i; + return true; +} +// Parses one array under a balanced depth charge. A child remains RAII-owned +// until vector growth succeeds, preventing leaks on allocation failure. +/*static*/ +bool pjsonImpl::_parseArray(ParseCtx& c, pjson*& aOut) { + if (++c.depth > c.maxDepth) { + --c.depth; + return _fail(c, c.pos, "maximum nesting depth exceeded"); + } + pjson::unique_ptr arr(_newNode(c)); + if (!arr) { + --c.depth; + return false; + } + arr->resetTo(jsonType::jsonArray); + ++c.pos; // consume '[' + + bool bExpectValue = false; // a comma was seen, a value must follow + bool bAny = false; // at least one value parsed + char ch; + while (_peek(c, ch)) { + if (ch == ']') { + if (bExpectValue) { + --c.depth; + return _fail(c, c.pos, "trailing comma in array"); + } + ++c.pos; + --c.depth; + aOut = arr.release(); + return true; + } else if (ch == ',') { + if (!bAny || bExpectValue) { + --c.depth; + return _fail(c, c.pos, "unexpected ',' in array"); + } + ++c.pos; + bExpectValue = true; + } else { + if (bAny && !bExpectValue) { + --c.depth; + return _fail(c, c.pos, "missing ',' between array elements"); + } + pjson* elem = nullptr; + if (!_parseValue(c, elem)) { + pjsonImpl::_destroyNode(elem); + --c.depth; + return false; + } + pjson::unique_ptr ownedElem(elem); + arr->_uValue._pValueArray->push_back(nullptr); + arr->_uValue._pValueArray->back() = ownedElem.release(); + bAny = true; + bExpectValue = false; + } + } + --c.depth; + return _fail(c, c.pos, "unterminated array"); +} +// Parses one object under a balanced depth charge and applies duplicate policy +// only after the replacement value is fully parsed and owned. +/*static*/ +bool pjsonImpl::_parseObject(ParseCtx& c, pjson*& aOut) { + if (++c.depth > c.maxDepth) { + --c.depth; + return _fail(c, c.pos, "maximum nesting depth exceeded"); + } + pjson::unique_ptr obj(_newNode(c)); + if (!obj) { + --c.depth; + return false; + } + obj->resetTo(jsonType::jsonObject); + ++c.pos; // consume '{' + + bool bExpectMember = false; // a comma was seen, a member must follow + bool bAny = false; // at least one member parsed + char ch; + while (_peek(c, ch)) { + if (ch == '}') { + if (bExpectMember) { + --c.depth; + return _fail(c, c.pos, "trailing comma in object"); + } + ++c.pos; + --c.depth; + aOut = obj.release(); + return true; + } else if (ch == ',') { + if (!bAny || bExpectMember) { + --c.depth; + return _fail(c, c.pos, "unexpected ',' in object"); + } + ++c.pos; + bExpectMember = true; + } else if (ch == '\"') { + if (bAny && !bExpectMember) { + --c.depth; + return _fail(c, c.pos, "missing ',' between object members"); + } + const size_t keyOffset = c.pos; + std::string mkey; + pjson* val = nullptr; + if (!_extractString(c, mkey) || !_skipColon(c) || !_parseValue(c, val)) { + pjsonImpl::_destroyNode(val); + --c.depth; + return false; + } + // Apply the caller's duplicate-key policy: reject the duplicate or + // deterministically keep its first or last value. + auto it = obj->_uValue._pValueMap->find(mkey); + if (it != obj->_uValue._pValueMap->end()) { + if (c.duplicateKeys == ParseOptions::RejectDuplicateKeys) { + pjsonImpl::_destroyNode(val); + --c.depth; + return _fail(c, keyOffset, "duplicate object key"); + } + if (c.duplicateKeys == ParseOptions::KeepLastDuplicate) { + pjsonImpl::_destroyNode(it->second); + it->second = val; + } else { + pjsonImpl::_destroyNode(val); // KeepFirstDuplicate + } + } else { + pjson::unique_ptr ownedVal(val); + pjson*& slot = (*(obj->_uValue._pValueMap))[mkey]; + slot = ownedVal.release(); + } + bAny = true; + bExpectMember = false; + } else { + --c.depth; + return _fail(c, c.pos, "expected '\"' to start an object key"); + } + } + --c.depth; + return _fail(c, c.pos, "unterminated object"); +} + +//===----------------------------------------------------------------------===// +// Container queries and mutation +//===----------------------------------------------------------------------===// + +bool pjson::hasKey(const std::string& aKey) const { + return hasKey(aKey.c_str()); +} +// Reports whether an object contains a non-null C-string key. +bool pjson::hasKey(const char* cStr) const { + if (cStr != nullptr && _eType == jsonType::jsonObject) { + auto it = _uValue._pValueMap->find(cStr); + return (it != _uValue._pValueMap->end()); + } + return false; +} +// Reports whether an array index resolves under find()'s negative-index rules. +bool pjson::hasIndex(int aIndex) const noexcept { + return find(aIndex) != nullptr; +} +// Returns the member/element count for containers and zero for scalars. +size_t pjson::size() const { + if (_eType == jsonType::jsonArray) { + return _uValue._pValueArray->size(); + } + if (_eType == jsonType::jsonObject) { + return _uValue._pValueMap->size(); + } + return 0; +} +// Reports whether size() is zero, so every scalar is considered empty. +bool pjson::empty() const { + return size() == 0; +} +// Clears container children without changing container type; scalars become null. +void pjson::clear() { + // Arrays and maps become empty containers of the same type; anything else + // resets to null. + switch (_eType) { + case jsonType::jsonArray: { + pjsonImpl::_disposeChildren(*this); + _uValue._pValueArray->clear(); + break; + } + case jsonType::jsonObject: { + pjsonImpl::_disposeChildren(*this); + _uValue._pValueMap->clear(); + break; + } + default: + reset(); + break; + } +} +// Returns object keys in the map's deterministic sorted iteration order. +std::vector pjson::keys() const { + std::vector result; + if (_eType == jsonType::jsonObject) { + result.reserve(_uValue._pValueMap->size()); + for (const auto& kv : *_uValue._pValueMap) { + result.push_back(kv.first); + } + } + return result; +} +bool pjson::erase(const std::string& aKey) { + return erase(aKey.c_str()); +} +// Removes an object member and destroys its owned subtree. +bool pjson::erase(const char* aKey) { + if (aKey != nullptr && _eType == jsonType::jsonObject) { + auto it = _uValue._pValueMap->find(aKey); + if (it != _uValue._pValueMap->end()) { + pjsonImpl::_destroyNode(it->second); + _uValue._pValueMap->erase(it); + return true; + } + } + return false; +} +// Removes an array element and destroys its owned subtree, shifting later indices. +bool pjson::erase(size_t aIndex) { + if (_eType == jsonType::jsonArray && aIndex < _uValue._pValueArray->size()) { + pjsonImpl::_destroyNode((*_uValue._pValueArray)[aIndex]); + _uValue._pValueArray->erase(_uValue._pValueArray->begin() + + static_cast(aIndex)); + return true; + } + return false; +} +// Applies JSON Patch while intentionally discarding detailed diagnostics. +bool pjson::applyPatch(const pjson& aPatch, const PatchOptions& aOpts) noexcept { + PatchError error; + return applyPatch(aPatch, error, aOpts); +} +// Applies an RFC 6902 operation sequence atomically. Validation and mutation +// happen on scratch; only a completely successful sequence is published by swap. +bool pjson::applyPatch(const pjson& aPatch, PatchError& aError, + const PatchOptions& aOpts) noexcept { + resetPatchError(aError); + try { + if (!aPatch.isArray()) + return failPatch(aError, PatchError::InvalidPatchDocument, + "JSON Patch document must be an array"); + + PatchBudget budget(aOpts); + const PJSONARRAY& operations = pjsonImpl::_array(aPatch); + if (!chargePatch(budget.operations, budget.operationLimit, operations.size(), aError, + "JSON Patch operation budget exceeded") || + !measureClone(*this, budget, aError)) + return false; + + // The scratch copy is both the rollback boundary and the allocator domain + // into which every add/copy/replace value must be cloned. + pjson scratch(*this, *_allocator); + for (size_t operationIndex = 0; operationIndex < operations.size(); ++operationIndex) { + const pjson& operation = *operations[operationIndex]; + aError.opIndex = operationIndex; + aError.op.clear(); + aError.path.clear(); + aError.from.clear(); + aError.tokenIndex = 0; + aError.token.clear(); + aError.message.clear(); + + if (!operation.isObject()) + return failPatch(aError, PatchError::OperationNotObject, + "JSON Patch operation must be an object"); + + const pjson* opNode = operation.find("op"); + if (opNode == nullptr || !opNode->isString()) + return failPatch(aError, PatchError::MissingOp, + "JSON Patch operation requires string member 'op'"); + aError.op = pjsonImpl::_string(*opNode); + + const bool knownOperation = aError.op == "add" || aError.op == "remove" || + aError.op == "replace" || aError.op == "move" || + aError.op == "copy" || aError.op == "test"; + if (!knownOperation) + return failPatch(aError, PatchError::InvalidOp, + "JSON Patch operation name is not supported"); + + const pjson* pathNode = operation.find("path"); + if (pathNode == nullptr || !pathNode->isString()) + return failPatch(aError, PatchError::MissingPath, + "JSON Patch operation requires string member 'path'"); + aError.path = pjsonImpl::_string(*pathNode); + std::vector pathTokens; + if (!decodePatchPointer(aError.path, false, pathTokens, budget, aError)) + return false; + + // Validate and decode this operation's metadata before mutating the + // private scratch tree. Later operations are processed only after + // earlier ones succeed; the public target is still untouched. + const bool needsFrom = aError.op == "move" || aError.op == "copy"; + std::vector fromTokens; + if (needsFrom) { + const pjson* fromNode = operation.find("from"); + if (fromNode == nullptr || !fromNode->isString()) + return failPatch(aError, PatchError::MissingFrom, + "move and copy require string member 'from'"); + aError.from = pjsonImpl::_string(*fromNode); + if (!decodePatchPointer(aError.from, true, fromTokens, budget, aError)) + return false; + } + + const bool needsValue = + aError.op == "add" || aError.op == "replace" || aError.op == "test"; + const pjson* valueNode = operation.find("value"); + if (needsValue && valueNode == nullptr) + return failPatch(aError, PatchError::MissingValue, + "add, replace, and test require member 'value'"); + + if (aError.op == "add") { + if (!measureClone(*valueNode, budget, aError)) + return false; + unique_ptr value = pjsonImpl::_cloneNode(*valueNode, scratch.getAllocator()); + if (!addOwnedAtPointer(scratch, pathTokens, aError.path, std::move(value), budget, + aError)) + return false; + continue; + } + + if (aError.op == "remove") { + unique_ptr removed; + if (!detachAtPointer(scratch, pathTokens, aError.path, removed, budget, aError)) + return false; + continue; + } + + if (aError.op == "replace") { + if (!measureClone(*valueNode, budget, aError)) + return false; + unique_ptr value = pjsonImpl::_cloneNode(*valueNode, scratch.getAllocator()); + if (!replaceAtPointer(scratch, pathTokens, aError.path, std::move(value), budget, + aError)) + return false; + continue; + } + + if (aError.op == "test") { + const pjson* target = resolvePatchTokens(scratch, pathTokens, pathTokens.size(), + aError.path, budget, aError); + if (target == nullptr) + return false; + bool equal = false; + if (!patchEqual(*target, *valueNode, budget, aError, equal)) + return false; + if (!equal) + return failPatch(aError, PatchError::TestFailed, + "JSON Patch test value does not match target"); + continue; + } + + const pjson* source = resolvePatchTokens(scratch, fromTokens, fromTokens.size(), + aError.from, budget, aError); + if (source == nullptr) + return false; + + if (aError.op == "copy") { + if (!measureClone(*source, budget, aError)) + return false; + unique_ptr value = pjsonImpl::_cloneNode(*source, scratch.getAllocator()); + if (!addOwnedAtPointer(scratch, pathTokens, aError.path, std::move(value), budget, + aError)) + return false; + continue; + } + + if (samePointerTokens(fromTokens, pathTokens)) + continue; + if (fromTokens.empty()) { + // Moving the root can only succeed when the destination is + // also root (handled above); every other path is a descendant. + return failPatch(aError, PatchError::MoveRootNotAllowed, + "cannot move the document root below itself"); + } + if (isProperPointerAncestor(fromTokens, pathTokens)) + return failPatch(aError, PatchError::MoveIntoDescendant, + "cannot move a value into one of its descendants"); + + unique_ptr moved; + if (!detachAtPointer(scratch, fromTokens, aError.from, moved, budget, aError)) + return false; + if (!addOwnedAtPointer(scratch, pathTokens, aError.path, std::move(moved), budget, + aError)) + return false; + } + + // This is the sole publication point; all earlier exits leave *this intact. + swap(scratch); + resetPatchError(aError); + return true; + } catch (const std::bad_alloc&) { + failPatchException(aError, PatchError::AllocationFailure, "JSON Patch ran out of memory"); + return false; + } catch (const std::exception&) { + failPatchException(aError, PatchError::InternalError, + "JSON Patch failed with an internal exception"); + return false; + } catch (...) { + failPatchException(aError, PatchError::InternalError, + "JSON Patch failed with an unknown exception"); + return false; + } +} +// Applies Merge Patch while intentionally discarding detailed diagnostics. +bool pjson::applyMergePatch(const pjson& aPatch, const PatchOptions& aOpts) noexcept { + PatchError error; + return applyMergePatch(aPatch, error, aOpts); +} +// Applies RFC 7396 atomically by mutating a private deep copy and publishing it +// only after the iterative merge has completed. +bool pjson::applyMergePatch(const pjson& aPatch, PatchError& aError, + const PatchOptions& aOpts) noexcept { + resetPatchError(aError); + try { + PatchBudget budget(aOpts); + if (!measureClone(*this, budget, aError)) + return false; + pjson scratch(*this, *_allocator); + if (!applyMergePatchTo(scratch, aPatch, budget, aError)) { + if (!aError.ok) + return false; + return failPatch(aError, PatchError::InternalError, + "JSON Merge Patch could not update an object member"); + } + swap(scratch); + resetPatchError(aError); + return true; + } catch (const std::bad_alloc&) { + failPatchException(aError, PatchError::AllocationFailure, + "JSON Merge Patch ran out of memory"); + return false; + } catch (const std::exception&) { + failPatchException(aError, PatchError::InternalError, + "JSON Merge Patch failed with an internal exception"); + return false; + } catch (...) { + failPatchException(aError, PatchError::InternalError, + "JSON Merge Patch failed with an unknown exception"); + return false; + } +} +/*static*/ +// Compares stored JSON numbers without rounding an int64_t through binary64. +// The result is -1/0/1, or 2 when a NaN makes the ordering unordered. +int pjsonImpl::_compareNumbers(const pjson& aLeft, const pjson& aRight) { + if (aLeft._eType == pjson::jsonNumberInt && aRight._eType == pjson::jsonNumberInt) { + if (aLeft._uValue._valueInt < aRight._uValue._valueInt) + return -1; + return aLeft._uValue._valueInt > aRight._uValue._valueInt ? 1 : 0; + } + if (aLeft._eType == pjson::jsonNumberDouble && aRight._eType == pjson::jsonNumberDouble) { + const double left = aLeft._uValue._valueDouble; + const double right = aRight._uValue._valueDouble; + if (std::isnan(left) || std::isnan(right)) + return 2; + if (left < right) + return -1; + return left > right ? 1 : 0; + } + + const bool intOnLeft = aLeft._eType == pjson::jsonNumberInt; + const int64_t integer = intOnLeft ? aLeft._uValue._valueInt : aRight._uValue._valueInt; + const double floating = intOnLeft ? aRight._uValue._valueDouble : aLeft._uValue._valueDouble; + int intVsDouble = 0; + if (std::isnan(floating)) { + return 2; + } else if (floating >= 9223372036854775808.0) { // exact 2^63 + intVsDouble = -1; + } else if (floating < -9223372036854775808.0) { + intVsDouble = 1; + } else { + const int64_t truncated = static_cast(floating); + if (integer != truncated) { + intVsDouble = integer < truncated ? -1 : 1; + } else { + const double integralPart = static_cast(truncated); + if (floating != integralPart) + intVsDouble = floating > integralPart ? -1 : 1; + } + } + return intOnLeft ? intVsDouble : -intVsDouble; +} +// Deep structural equality. Numbers compare by value across int/double +// (1 == 1.0); arrays element-wise in order; objects by key/value. The walk is +// iterative (an explicit pair work-list) so it never overflows the call stack +// on deeply nested documents. +bool pjson::operator==(const pjson& aOther) const { + struct Pair { + const pjson* a; + const pjson* b; + }; + std::vector work; + Pair root = {this, &aOther}; + work.push_back(root); + + while (!work.empty()) { + Pair cur = work.back(); + work.pop_back(); + const pjson& lhs = *cur.a; + const pjson& rhs = *cur.b; + + // Numbers compare across int/double as one family. + bool lNum = (lhs._eType == jsonNumberInt || lhs._eType == jsonNumberDouble); + bool rNum = (rhs._eType == jsonNumberInt || rhs._eType == jsonNumberDouble); + if (lNum && rNum) { + if (pjsonImpl::_compareNumbers(lhs, rhs) != 0) { + return false; + } + continue; + } + + if (lhs._eType != rhs._eType) { + return false; + } + + switch (lhs._eType) { + case jsonType::jsonNull: + break; + case jsonType::jsonString: + if (*lhs._uValue._pValueString != *rhs._uValue._pValueString) + return false; + break; + case jsonType::jsonBoolean: + if (lhs._uValue._valueBool != rhs._uValue._valueBool) + return false; + break; + case jsonType::jsonNumberInt: + if (lhs._uValue._valueInt != rhs._uValue._valueInt) + return false; + break; + case jsonType::jsonNumberDouble: + if (lhs._uValue._valueDouble != rhs._uValue._valueDouble) + return false; + break; + case jsonType::jsonArray: { + if (lhs._uValue._pValueArray->size() != rhs._uValue._pValueArray->size()) { + return false; + } + for (size_t i = 0; i < lhs._uValue._pValueArray->size(); ++i) { + Pair p = {(*lhs._uValue._pValueArray)[i], (*rhs._uValue._pValueArray)[i]}; + work.push_back(p); + } + break; + } + case jsonType::jsonObject: { + if (lhs._uValue._pValueMap->size() != rhs._uValue._pValueMap->size()) { + return false; + } + auto a = lhs._uValue._pValueMap->begin(); + auto b = rhs._uValue._pValueMap->begin(); + for (; a != lhs._uValue._pValueMap->end(); ++a, ++b) { + if (a->first != b->first) { + return false; // keys (sorted) differ + } + Pair p = {a->second, b->second}; + work.push_back(p); + } + break; + } + } + } + return true; +} +// Implements inequality as the exact complement of structural equality. +bool pjson::operator!=(const pjson& aOther) const { + return !(*this == aOther); +} + +//===----------------------------------------------------------------------===// +// JSON Schema draft-07 subset validation +// +// Validation accumulates ordinary keyword failures but aborts on configured +// depth/reference limits. Combinators evaluate branches into temporary error +// vectors, committing diagnostics only according to the combinator's outcome so +// failed exploratory branches do not leak spurious public errors. +//===----------------------------------------------------------------------===// +// The JSON Schema type name for a value. +/*static*/ +std::string pjsonImpl::_typeName(const pjson& aNode) { + switch (aNode._eType) { + case jsonType::jsonNull: + return "null"; + case jsonType::jsonString: + return "string"; + case jsonType::jsonNumberInt: + return "integer"; + case jsonType::jsonNumberDouble: + return "number"; + case jsonType::jsonBoolean: + return "boolean"; + case jsonType::jsonArray: + return "array"; + case jsonType::jsonObject: + return "object"; + } + return "unknown"; +} +// Implements the "type" keyword. "number" accepts integers too; "integer" +// accepts a whole-valued double (e.g. 2.0) as JSON Schema does. +/*static*/ +bool pjsonImpl::_typeMatches(const pjson& aNode, const std::string& aTypeName) { + if (aTypeName == "null") + return aNode.isNull(); + if (aTypeName == "string") + return aNode.isString(); + if (aTypeName == "boolean") + return aNode.isBool(); + if (aTypeName == "array") + return aNode.isArray(); + if (aTypeName == "object") + return aNode.isObject(); + if (aTypeName == "number") + return aNode.isNumber(); + if (aTypeName == "integer") { + if (aNode.isInt()) + return true; + // A double with no fractional part counts as an integer. + if (aNode.isDouble()) { + double d = pjsonImpl::_floating(aNode); + return std::floor(d) == d && std::isfinite(d); + } + return false; + } + return false; // unknown type name never matches +} +// Appends "/token" to a JSON Pointer path, escaping '~' and '/' per RFC 6901. +std::string pjsonImpl::_pointerAppend(const std::string& aBase, const std::string& aToken) { + std::string escaped; + escaped.reserve(aToken.size()); + for (char c : aToken) { + if (c == '~') + escaped += "~0"; + else if (c == '/') + escaped += "~1"; + else + escaped += c; + } + return aBase + "/" + escaped; +} +// Conservative single-pass screen for constructs that are especially prone to +// catastrophic backtracking in std::regex. This is intentionally fail-closed: +// unrestricted ECMAScript regex remains available through trustedRegex(). +bool pjsonImpl::_isSafeRegex(const std::string& aPattern) { + bool escaped = false; + bool inClass = false; + int groups = 0; + int quantifiers = 0; + struct Group { + bool hasQuantifier; + bool hasAlternation; + }; + std::vector stack; + + for (size_t i = 0; i < aPattern.size(); ++i) { + const char c = aPattern[i]; + if (escaped) { + if (c >= '1' && c <= '9') + return false; // backreference + escaped = false; + continue; + } + if (c == '\\') { + escaped = true; + continue; + } + if (c == '[') { + inClass = true; + continue; + } + if (c == ']' && inClass) { + inClass = false; + continue; + } + if (inClass) + continue; + + if (c == '(') { + if (++groups > 16) + return false; + Group g = {false, false}; + stack.push_back(g); + } else if (c == '|') { + // Even apparently simple alternation can become ambiguous when + // combined with repetition, so the safe subset excludes it. + return false; + } else if (c == '*' || c == '+' || c == '?' || c == '{') { + if (++quantifiers > 1) + return false; + if (c == '{') { + // Keep counted repetitions bounded. Scan only the numeric + // bounds; malformed syntax is still diagnosed by std::regex. + size_t j = i + 1; + size_t first = 0; + size_t second = 0; + bool haveFirst = false; + bool haveSecond = false; + while (j < aPattern.size() && aPattern[j] >= '0' && aPattern[j] <= '9') { + haveFirst = true; + if (first > 1000) + return false; + first = first * 10 + static_cast(aPattern[j] - '0'); + ++j; + } + if (j < aPattern.size() && aPattern[j] == ',') { + ++j; + while (j < aPattern.size() && aPattern[j] >= '0' && aPattern[j] <= '9') { + haveSecond = true; + if (second > 1000) + return false; + second = second * 10 + static_cast(aPattern[j] - '0'); + ++j; + } + } + if ((haveFirst && first > 1000) || (haveSecond && second > 1000)) + return false; + } + if (!stack.empty()) + stack.back().hasQuantifier = true; + } else if (c == ')' && !stack.empty()) { + Group closed = stack.back(); + stack.pop_back(); + size_t next = i + 1; + bool quantified = + next < aPattern.size() && (aPattern[next] == '*' || aPattern[next] == '+' || + aPattern[next] == '?' || aPattern[next] == '{'); + if (quantified && (closed.hasQuantifier || closed.hasAlternation)) + return false; + if (!stack.empty()) { + stack.back().hasQuantifier = + stack.back().hasQuantifier || quantified || closed.hasQuantifier; + stack.back().hasAlternation = stack.back().hasAlternation || closed.hasAlternation; + } + } + } + return true; +} +namespace { + //===------------------------------------------------------------------===// + // Exact numeric constraints and format validators + //===------------------------------------------------------------------===// + + // Normalized decimal magnitude: coefficient * 10^exponent10. Trailing + // decimal zeroes are folded into the exponent so divisibility can be tested + // with integer arithmetic rather than floating-point tolerance. + struct ExactDecimal { + uint64_t coefficient; + int exponent10; + }; + + // Computes an int64 magnitude without overflowing on INT64_MIN. + uint64_t magnitudeOf(int64_t value) { + return value < 0 ? uint64_t(-(value + 1)) + uint64_t(1) : uint64_t(value); + } + + // Parses the serializer's finite decimal notation into normalized form; + // returns false if its bounded coefficient/exponent representation overflows. + bool decimalFromText(const std::string& text, ExactDecimal& result) { + size_t pos = 0; + if (pos < text.size() && (text[pos] == '+' || text[pos] == '-')) + ++pos; + uint64_t coefficient = 0; + int fractionDigits = 0; + bool seenDigit = false; + bool afterPoint = false; + while (pos < text.size() && text[pos] != 'e' && text[pos] != 'E') { + const char ch = text[pos++]; + if (ch == '.' && !afterPoint) { + afterPoint = true; + continue; + } + if (ch < '0' || ch > '9') + return false; + const uint64_t digit = static_cast(ch - '0'); + if (coefficient > (std::numeric_limits::max() - digit) / uint64_t(10)) + return false; + coefficient = coefficient * uint64_t(10) + digit; + if (afterPoint) + ++fractionDigits; + seenDigit = true; + } + int explicitExponent = 0; + if (pos < text.size()) { + ++pos; + bool negative = false; + if (pos < text.size() && (text[pos] == '+' || text[pos] == '-')) { + negative = text[pos] == '-'; + ++pos; + } + if (pos == text.size()) + return false; + while (pos < text.size()) { + const char ch = text[pos++]; + if (ch < '0' || ch > '9') + return false; + if (explicitExponent > 10000) + return false; + explicitExponent = explicitExponent * 10 + (ch - '0'); + } + if (negative) + explicitExponent = -explicitExponent; + } + if (!seenDigit) + return false; + if (coefficient == 0) { + result.coefficient = 0; + result.exponent10 = 0; + return true; + } + int exponent = explicitExponent - fractionDigits; + while (coefficient % uint64_t(10) == 0) { + coefficient /= uint64_t(10); + ++exponent; + } + result.coefficient = coefficient; + result.exponent10 = exponent; + return true; + } + + // Converts either internal numeric representation to normalized decimal form. + bool decimalFromNumber(const pjson& value, ExactDecimal& result) { + if (value.isInt()) { + result.coefficient = magnitudeOf(pjsonImpl::_integer(value)); + result.exponent10 = 0; + if (result.coefficient == 0) + return true; + while (result.coefficient % uint64_t(10) == 0) { + result.coefficient /= uint64_t(10); + ++result.exponent10; + } + return true; + } + if (!value.isDouble() || !std::isfinite(pjsonImpl::_floating(value))) + return false; + return decimalFromText(pjsonImpl::_formatDouble(pjsonImpl::_floating(value)), result); + } + + // Produces a diagnostic representation without losing integer precision. + std::string formatNumber(const pjson& value) { + return value.isInt() ? std::to_string(pjsonImpl::_integer(value)) + : pjsonImpl::_formatDouble(pjsonImpl::_floating(value)); + } + + // Decodes nonnegative integral size keywords without truncation. When the + // mathematical value exceeds size_t, aboveRange distinguishes it from an + // invalid keyword shape so min constraints can still be evaluated exactly. + bool schemaSize(const pjson& value, size_t& result, bool& aboveRange) { + aboveRange = false; + if (value.isInt()) { + const int64_t integer = pjsonImpl::_integer(value); + if (integer < 0) + return false; + const uint64_t magnitude = static_cast(integer); + if (magnitude > static_cast(std::numeric_limits::max())) { + aboveRange = true; + return true; + } + result = static_cast(magnitude); + return true; + } + if (!value.isDouble()) + return false; + const double number = pjsonImpl::_floating(value); + if (!std::isfinite(number) || number < 0.0 || std::floor(number) != number) + return false; + const double exclusiveUpper = std::ldexp(1.0, std::numeric_limits::digits); + if (number >= exclusiveUpper) { + aboveRange = true; + return true; + } + result = static_cast(number); + return true; + } + + // Implements multipleOf from integers or canonical decimal text generated + // for doubles. Powers of ten are reduced through their prime factors, + // avoiding fixed-epsilon comparisons. + bool isExactMultiple(const pjson& value, const pjson& divisor) { + // JSON Schema requires a strictly positive divisor. Consistent with + // the library's tolerant handling of malformed schemas, non-positive + // values are ignored instead of being treated as assertions. + if (pjsonImpl::_numberAsDouble(divisor) <= 0.0) + return true; + if (value.isInt() && divisor.isInt()) { + const uint64_t d = magnitudeOf(pjsonImpl::_integer(divisor)); + return magnitudeOf(pjsonImpl::_integer(value)) % d == 0; + } + ExactDecimal v = {0, 0}; + ExactDecimal d = {0, 0}; + if (!decimalFromNumber(divisor, d) || d.coefficient == 0) + return true; + if (!decimalFromNumber(value, v)) + return false; + if (v.coefficient == 0) + return true; + const int shift = v.exponent10 - d.exponent10; + if (shift >= 0) { + uint64_t reduced = d.coefficient; + int remainingTwos = shift; + int remainingFives = shift; + while (remainingTwos > 0 && reduced % uint64_t(2) == 0) { + reduced /= uint64_t(2); + --remainingTwos; + } + while (remainingFives > 0 && reduced % uint64_t(5) == 0) { + reduced /= uint64_t(5); + --remainingFives; + } + return v.coefficient % reduced == 0; + } + if (v.coefficient % d.coefficient != 0) + return false; + uint64_t quotient = v.coefficient / d.coefficient; + int decimalPlaces = -shift; + while (decimalPlaces > 0 && quotient % uint64_t(10) == 0) { + quotient /= uint64_t(10); + --decimalPlaces; + } + return decimalPlaces == 0; + } + + // Locale-independent character predicates used by schema format parsers. + bool isAsciiDigit(char ch) { + return ch >= '0' && ch <= '9'; + } + bool isAsciiHex(char ch) { + return isAsciiDigit(ch) || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F'); + } + + // Parses exactly count decimal digits at offset into a small integer. + bool parseFixedDigits(const std::string& value, size_t offset, size_t count, int& result) { + if (offset > value.size() || count > value.size() - offset) + return false; + result = 0; + for (size_t i = 0; i < count; ++i) { + if (!isAsciiDigit(value[offset + i])) + return false; + result = result * 10 + (value[offset + i] - '0'); + } + return true; + } + + // Applies Gregorian leap-year rules. + bool isLeapYear(int year) { + return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + } + + // Validates an RFC 3339 full-date, including month-specific day limits. + bool validDate(const std::string& value) { + if (value.size() != 10 || value[4] != '-' || value[7] != '-') + return false; + int year = 0, month = 0, day = 0; + if (!parseFixedDigits(value, 0, 4, year) || !parseFixedDigits(value, 5, 2, month) || + !parseFixedDigits(value, 8, 2, day) || month < 1 || month > 12 || day < 1) + return false; + static const int days[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + int maxDay = days[month - 1]; + if (month == 2 && isLeapYear(year)) + maxDay = 29; + return day <= maxDay; + } + + // Validates an RFC 3339 full-time and permits leap second 60 only when the + // represented UTC minute is 23:59. + bool validTime(const std::string& value) { + if (value.size() < 9 || value[2] != ':' || value[5] != ':') + return false; + int hour = 0, minute = 0, second = 0; + if (!parseFixedDigits(value, 0, 2, hour) || !parseFixedDigits(value, 3, 2, minute) || + !parseFixedDigits(value, 6, 2, second) || hour > 23 || minute > 59 || second > 60) + return false; + size_t pos = 8; + if (pos < value.size() && value[pos] == '.') { + ++pos; + const size_t fractionStart = pos; + while (pos < value.size() && isAsciiDigit(value[pos])) + ++pos; + if (pos == fractionStart) + return false; + } + int offsetMinutes = 0; + if (pos < value.size() && (value[pos] == 'Z' || value[pos] == 'z')) { + ++pos; + } else { + if (pos + 6 != value.size() || (value[pos] != '+' && value[pos] != '-') || + value[pos + 3] != ':') + return false; + int offsetHour = 0, offsetMinute = 0; + if (!parseFixedDigits(value, pos + 1, 2, offsetHour) || + !parseFixedDigits(value, pos + 4, 2, offsetMinute) || offsetHour > 23 || + offsetMinute > 59) + return false; + offsetMinutes = offsetHour * 60 + offsetMinute; + if (value[pos] == '-') + offsetMinutes = -offsetMinutes; + pos += 6; + } + if (pos != value.size()) + return false; + if (second == 60) { + int utcMinute = (hour * 60 + minute - offsetMinutes) % (24 * 60); + if (utcMinute < 0) + utcMinute += 24 * 60; + if (utcMinute != 23 * 60 + 59) + return false; + } + return true; + } + + // Validates an RFC 3339 date-time joined by T/t. + bool validDateTime(const std::string& value) { + return value.size() > 11 && (value[10] == 'T' || value[10] == 't') && + validDate(value.substr(0, 10)) && validTime(value.substr(11)); + } + + // Validates four canonical decimal IPv4 octets with no leading zeroes. + bool validIPv4(const std::string& value) { + size_t pos = 0; + for (int part = 0; part < 4; ++part) { + const size_t begin = pos; + int octet = 0; + while (pos < value.size() && isAsciiDigit(value[pos])) { + octet = octet * 10 + (value[pos] - '0'); + if (octet > 255) + return false; + ++pos; + } + const size_t digits = pos - begin; + if (digits == 0 || digits > 3 || (digits > 1 && value[begin] == '0')) + return false; + if (part != 3) { + if (pos >= value.size() || value[pos] != '.') + return false; + ++pos; + } + } + return pos == value.size(); + } + + // Counts 16-bit units on one side of ::, optionally accepting a final IPv4 + // address as two units. Empty sides are valid only as compression operands. + bool parseIPv6Side(const std::string& side, bool mayContainIPv4, int& units) { + if (side.empty()) + return true; + size_t start = 0; + while (start <= side.size()) { + const size_t colon = side.find(':', start); + const size_t end = colon == std::string::npos ? side.size() : colon; + if (end == start) + return false; + const std::string token = side.substr(start, end - start); + if (token.find('.') != std::string::npos) { + if (!mayContainIPv4 || end != side.size() || !validIPv4(token)) + return false; + units += 2; + } else { + if (token.size() > 4) + return false; + for (size_t i = 0; i < token.size(); ++i) { + if (!isAsciiHex(token[i])) + return false; + } + ++units; + } + if (colon == std::string::npos) + break; + start = colon + 1; + if (start == side.size()) + return false; + } + return true; + } + + // Validates an IPv6 address with at most one compression marker and exactly + // eight units after expanding it. + bool validIPv6(const std::string& value) { + if (value.empty()) + return false; + const size_t compression = value.find("::"); + if (compression != std::string::npos && + value.find("::", compression + 2) != std::string::npos) + return false; + int units = 0; + if (compression == std::string::npos) + return parseIPv6Side(value, true, units) && units == 8; + const std::string left = value.substr(0, compression); + const std::string right = value.substr(compression + 2); + // An embedded IPv4 address may appear only as the final component, + // which is necessarily on the right side when :: compression is used. + if (!parseIPv6Side(left, false, units) || !parseIPv6Side(right, true, units)) + return false; + return units < 8; + } + + // Validates the canonical 8-4-4-4-12 hexadecimal UUID text shape. + bool validUuid(const std::string& value) { + if (value.size() != 36 || value[8] != '-' || value[13] != '-' || value[18] != '-' || + value[23] != '-') + return false; + for (size_t i = 0; i < value.size(); ++i) { + if (i == 8 || i == 13 || i == 18 || i == 23) + continue; + if (!isAsciiHex(value[i])) + return false; + } + return true; + } + + // Dispatches supported format assertions. Unknown names are annotations and + // therefore succeed with known=false, as required by JSON Schema. + bool knownFormatValid(const std::string& format, const std::string& value, bool& known) { + known = true; + if (format == "date") + return validDate(value); + if (format == "time") + return validTime(value); + if (format == "date-time") + return validDateTime(value); + if (format == "ipv4") + return validIPv4(value); + if (format == "ipv6") + return validIPv6(value); + if (format == "uuid") + return validUuid(value); + known = false; + return true; + } + + // Percent-decodes a same-document URI fragment and accepts it only when the + // result is empty or has JSON Pointer syntax. Token unescaping happens later. + bool decodeSchemaFragment(const std::string& fragment, std::string& pointer) { + pointer.clear(); + for (size_t i = 0; i < fragment.size(); ++i) { + if (fragment[i] != '%') { + pointer += fragment[i]; + continue; + } + if (i + 2 >= fragment.size() || !isAsciiHex(fragment[i + 1]) || + !isAsciiHex(fragment[i + 2])) + return false; + const char hi = fragment[i + 1]; + const char lo = fragment[i + 2]; + const int high = + isAsciiDigit(hi) ? hi - '0' : (hi >= 'a' ? hi - 'a' + 10 : hi - 'A' + 10); + const int low = + isAsciiDigit(lo) ? lo - '0' : (lo >= 'a' ? lo - 'a' + 10 : lo - 'A' + 10); + pointer += static_cast((high << 4) | low); + i += 2; + } + return pointer.empty() || pointer[0] == '/'; + } + + // Adds a diagnostic without allowing allocation failure to escape a noexcept API. + void bestEffortSchemaError(std::vector& errors, const std::string& path, + const std::string& message) noexcept { + try { + errors.push_back(SchemaError(path, message)); + } catch (...) { // Best effort: this path must remain noexcept. + return; + } + } + + // Literal-string overload for exception paths that should avoid extra temporaries. + void bestEffortSchemaError(std::vector& errors, const char* path, + const char* message) noexcept { + try { + errors.push_back(SchemaError(path, message)); + } catch (...) { // Best effort: this path must remain noexcept. + return; + } + } + + // Balances the shared recursion counter across every return and exception. + struct SchemaDepthGuard { + pjsonImpl::SchemaValidationCtx& ctx; + explicit SchemaDepthGuard(pjsonImpl::SchemaValidationCtx& aCtx) + : ctx(aCtx) { + ++ctx.depth; + } + ~SchemaDepthGuard() { --ctx.depth; } + }; + + // Keeps one (instance, resolved-schema) pair active only for its recursive call. + struct ActiveRefGuard { + std::vector>& refs; + ActiveRefGuard(std::vector>& aRefs, const pjson* node, + const pjson* schema) + : refs(aRefs) { + refs.push_back(std::make_pair(node, schema)); + } + ~ActiveRefGuard() { refs.pop_back(); } + }; + + // Aborts all remaining branches and ensures a budget failure reaches the + // public error vector even when discovered inside combinator scratch errors. + void failValidationBudget(pjsonImpl::SchemaValidationCtx& ctx, + pjsonImpl::SchemaErrorSink& errors, const std::string& path, + const std::string& message) { + if (ctx.aborted) + return; + ctx.aborted = true; + const size_t errorLimit = ctx.options.maxErrors == 0 ? size_t(100) : ctx.options.maxErrors; + if (ctx.errorsUsed >= errorLimit) + return; + std::vector& destination = + ctx.publicErrors != nullptr ? *ctx.publicErrors : errors.values; + const size_t before = destination.size(); + bestEffortSchemaError(destination, path, message); + if (destination.size() != before) + ++ctx.errorsUsed; + } + + size_t validationDepthLimit(const SchemaOptions& options) { + return options.maxValidationDepth == 0 ? size_t(512) : options.maxValidationDepth; + } + + size_t validationRefLimit(const SchemaOptions& options) { + return options.maxRefResolutions == 0 ? size_t(1024) : options.maxRefResolutions; + } + + size_t validationWorkLimit(const SchemaOptions& options) { + return options.maxValidationWork == 0 ? size_t(1000000) : options.maxValidationWork; + } + + // Charges bounded validation work before potentially expensive traversal. + bool chargeValidationWork(pjsonImpl::SchemaValidationCtx& ctx, + pjsonImpl::SchemaErrorSink& errors, const std::string& path, + size_t amount = 1) { + const size_t limit = validationWorkLimit(ctx.options); + if (amount > limit - std::min(ctx.workUsed, limit)) { + failValidationBudget(ctx, errors, path, "schema validation work budget exceeded"); + return false; + } + ctx.workUsed += amount; + return true; + } + + // Loop-heavy keywords charge separately from recursive schema evaluations. + bool chargeLoopWork(pjsonImpl::SchemaValidationCtx& ctx, pjsonImpl::SchemaErrorSink& errors, + const std::string& path, size_t amount = 1) { + return chargeValidationWork(ctx, errors, path, amount); + } + + // Counts Unicode code points, charging the bytes examined. Parsed strings + // are valid UTF-8; malformed programmatic bytes count individually here. + bool unicodeLength(const std::string& value, pjsonImpl::SchemaValidationCtx& ctx, + pjsonImpl::SchemaErrorSink& errors, const std::string& path, size_t& count) { + count = 0; + for (size_t offset = 0; offset < value.size(); ++count) { + const int bytes = pjsonImpl::_utf8Len(value.data(), offset, value.size()); + const size_t consumed = bytes > 0 ? static_cast(bytes) : size_t(1); + if (!chargeLoopWork(ctx, errors, path, consumed)) + return false; + offset += consumed; + } + return true; + } + + // Records ordinary keyword failures through one shared per-run quota. The + // terminal budget diagnostic bypasses this quota via failValidationBudget. + bool addSchemaError(pjsonImpl::SchemaValidationCtx&, pjsonImpl::SchemaErrorSink& errors, + const std::string& path, const std::string& message) { + errors.push_back(SchemaError(path, message)); + return !errors.ctx.aborted; + } + + // Applies configured size/complexity gates before ECMAScript regex_search. + // Policy or syntax failures are validation errors, distinct from no match. + bool evaluateRegex(const std::string& subject, const std::string& pattern, + const std::string& path, pjsonImpl::SchemaErrorSink& errors, + pjsonImpl::SchemaValidationCtx& ctx, bool& matches) { + matches = false; + if (ctx.options.maxRegexSubjectBytes != 0 && + subject.size() > ctx.options.maxRegexSubjectBytes) { + errors.push_back( + SchemaError(path, "string exceeds regex safety limit (" + + std::to_string(subject.size()) + " bytes, limit " + + std::to_string(ctx.options.maxRegexSubjectBytes) + ")")); + return false; + } + + pjsonImpl::RegexCacheEntry& cached = ctx.regexCache[pattern]; + if (cached.state == pjsonImpl::RegexCacheEntry::Uninitialized) { + if (!chargeLoopWork(ctx, errors, path, pattern.size() + size_t(1))) + return false; + if (ctx.options.maxRegexPatternBytes != 0 && + pattern.size() > ctx.options.maxRegexPatternBytes) { + cached.state = pjsonImpl::RegexCacheEntry::PatternTooLarge; + } else if (!ctx.options.allowUnsafeRegex && !pjsonImpl::_isSafeRegex(pattern)) { + cached.state = pjsonImpl::RegexCacheEntry::UnsafePattern; + } else { + try { + cached.expression.assign(pattern, std::regex::ECMAScript); + cached.state = pjsonImpl::RegexCacheEntry::Ready; + } catch (const std::regex_error&) { + cached.state = pjsonImpl::RegexCacheEntry::InvalidPattern; + } + } + } + + if (cached.state == pjsonImpl::RegexCacheEntry::PatternTooLarge) { + errors.push_back(SchemaError(path, "schema regex pattern exceeds safety limit")); + return false; + } + if (cached.state == pjsonImpl::RegexCacheEntry::UnsafePattern) { + errors.push_back(SchemaError(path, "schema regex pattern rejected by safety policy")); + return false; + } + if (cached.state == pjsonImpl::RegexCacheEntry::InvalidPattern) { + errors.push_back(SchemaError(path, "schema has an invalid regex pattern")); + return false; + } + if (!chargeLoopWork(ctx, errors, path, subject.size() + size_t(1))) + return false; + matches = std::regex_search(subject, cached.expression); + return true; + } +} // namespace +/*static*/ +// Schema equality mirrors public structural equality while charging every +// visited value and compared string/key against the validation work budget. +bool pjsonImpl::_equalWithBudget(const pjson& aLeft, const pjson& aRight, SchemaValidationCtx& aCtx, + SchemaErrorSink& aErrors, const std::string& aPath, bool& aEqual) { + struct Pair { + const pjson* left; + const pjson* right; + }; + std::vector work; + Pair root = {&aLeft, &aRight}; + work.push_back(root); + aEqual = false; + + while (!work.empty()) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + const Pair current = work.back(); + work.pop_back(); + const pjson& left = *current.left; + const pjson& right = *current.right; + const bool leftNumber = left.isNumber(); + const bool rightNumber = right.isNumber(); + if (leftNumber && rightNumber) { + if (_compareNumbers(left, right) != 0) + return true; + continue; + } + if (left._eType != right._eType) + return true; + + switch (left._eType) { + case jsonType::jsonNull: + break; + case jsonType::jsonString: { + const size_t bytes = std::max(left._uValue._pValueString->size(), + right._uValue._pValueString->size()); + if (!chargeLoopWork(aCtx, aErrors, aPath, bytes)) + return false; + if (*left._uValue._pValueString != *right._uValue._pValueString) + return true; + break; + } + case jsonType::jsonNumberInt: + case jsonType::jsonNumberDouble: + break; // numeric pairs were handled above + case jsonType::jsonBoolean: + if (left._uValue._valueBool != right._uValue._valueBool) + return true; + break; + case jsonType::jsonArray: + if (left._uValue._pValueArray->size() != right._uValue._pValueArray->size()) + return true; + for (size_t i = 0; i < left._uValue._pValueArray->size(); ++i) { + Pair child = {(*left._uValue._pValueArray)[i], + (*right._uValue._pValueArray)[i]}; + work.push_back(child); + } + break; + case jsonType::jsonObject: { + if (left._uValue._pValueMap->size() != right._uValue._pValueMap->size()) + return true; + ObjectStorage::const_iterator l = left._uValue._pValueMap->begin(); + ObjectStorage::const_iterator r = right._uValue._pValueMap->begin(); + for (; l != left._uValue._pValueMap->end(); ++l, ++r) { + if (!chargeLoopWork(aCtx, aErrors, aPath, + std::max(l->first.size(), r->first.size()) + size_t(1))) + return false; + if (l->first != r->first) + return true; + Pair child = {l->second, r->second}; + work.push_back(child); + } + break; + } + } + } + aEqual = true; + return true; +} +// Validates aNode against aSchema while sharing reference, recursion, and +// failure-budget state across every recursive branch. +/*static*/ +bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std::string& aPath, + SchemaErrorSink& aErrors, SchemaValidationCtx& aCtx) { + if (aCtx.aborted) + return false; + if (!chargeValidationWork(aCtx, aErrors, aPath)) + return false; + if (aCtx.depth >= validationDepthLimit(aCtx.options)) { + failValidationBudget(aCtx, aErrors, aPath, "schema validation depth budget exceeded"); + return false; + } + SchemaDepthGuard depthGuard(aCtx); + + // A boolean schema accepts (true) or rejects (false) everything. + if (aSchema.isBool()) { + if (!pjsonImpl::_boolean(aSchema)) { + aErrors.push_back(SchemaError(aPath, "schema is false; no value is valid here")); + return false; + } + return true; + } + // Only object schemas carry keywords; anything else is treated as "accept". + if (!aSchema.isObject()) { + return true; + } + + const size_t before = aErrors.size(); + + // Draft-07 treats an object containing $ref as a reference object: all + // sibling keywords are ignored. Only same-document fragment references + // are supported; percent-decoding precedes RFC 6901 token decoding. + if (const pjson* ref = aSchema.find("$ref")) { + if (ref->isString()) { + const std::string refText = pjsonImpl::_string(*ref); + if (refText.empty() || refText[0] == '#') { + if (aCtx.refResolutions >= validationRefLimit(aCtx.options)) { + failValidationBudget(aCtx, aErrors, aPath, + "schema $ref resolution budget exceeded"); + return false; + } + ++aCtx.refResolutions; + + std::string pointer; + const std::string fragment = refText.empty() ? std::string() : refText.substr(1); + if (!decodeSchemaFragment(fragment, pointer)) { + aErrors.push_back( + SchemaError(aPath, "malformed local $ref fragment: " + refText)); + return false; + } + + pjson::PointerError pointerError; + const pjson* target = aCtx.rootSchema.findPointer(pointer, pointerError); + if (target == nullptr) { + const bool malformed = + pointerError.code == pjson::PointerError::InvalidSyntax || + pointerError.code == pjson::PointerError::InvalidEscape || + pointerError.code == pjson::PointerError::InvalidArrayIndex || + pointerError.code == pjson::PointerError::AppendTokenNotAllowed; + aErrors.push_back( + SchemaError(aPath, std::string(malformed ? "malformed" : "unresolved") + + " local $ref: " + refText)); + return false; + } + + const std::pair active(&aNode, target); + if (std::find(aCtx.activeRefs.begin(), aCtx.activeRefs.end(), active) != + aCtx.activeRefs.end()) { + aErrors.push_back(SchemaError(aPath, "local $ref cycle detected: " + refText)); + return false; + } + ActiveRefGuard refGuard(aCtx.activeRefs, &aNode, target); + return _validateCtx(aNode, *target, aPath, aErrors, aCtx); + } + + aErrors.push_back(SchemaError(aPath, "non-local $ref is not supported: " + refText)); + return false; + } + } + + // ---- type ---- + if (const pjson* t = aSchema.find("type")) { + if (t->isString()) { + if (!_typeMatches(aNode, pjsonImpl::_string(*t))) { + aErrors.push_back(SchemaError(aPath, "expected type " + pjsonImpl::_string(*t) + + ", got " + _typeName(aNode))); + } + } else if (t->isArray()) { + bool matched = false; + std::string names; + for (const pjson* e : pjsonImpl::_array(*t)) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + if (e->isString()) { + if (!names.empty()) + names += ", "; + names += pjsonImpl::_string(*e); + if (_typeMatches(aNode, pjsonImpl::_string(*e))) { + matched = true; + break; + } + } + } + if (!matched) { + aErrors.push_back(SchemaError(aPath, "expected one of type [" + names + "], got " + + _typeName(aNode))); + } + } + // A malformed "type" (neither string nor array) is ignored. + } + + // ---- const ---- + if (const pjson* cst = aSchema.find("const")) { + bool equal = false; + if (!_equalWithBudget(aNode, *cst, aCtx, aErrors, aPath, equal)) + return false; + if (!equal) { + aErrors.push_back(SchemaError(aPath, "value does not equal the required const")); + } + } + + // ---- enum ---- + if (const pjson* en = aSchema.find("enum")) { + if (en->isArray()) { + bool found = false; + for (const pjson* opt : pjsonImpl::_array(*en)) { + bool equal = false; + if (!_equalWithBudget(aNode, *opt, aCtx, aErrors, aPath, equal)) + return false; + if (equal) { + found = true; + break; + } + } + if (!found) { + aErrors.push_back(SchemaError(aPath, "value is not in the allowed enum")); + } + } + } + + // ---- numeric constraints ---- + if (aNode.isNumber()) { + if (const pjson* m = aSchema.find("minimum")) { + if (m->isNumber() && _compareNumbers(aNode, *m) < 0) { + addSchemaError(aCtx, aErrors, aPath, + "value " + formatNumber(aNode) + " is below minimum " + + formatNumber(*m)); + } + } + if (const pjson* m = aSchema.find("maximum")) { + const int comparison = m->isNumber() ? _compareNumbers(aNode, *m) : 2; + if (comparison != 2 && comparison > 0) { + addSchemaError(aCtx, aErrors, aPath, + "value " + formatNumber(aNode) + " is above maximum " + + formatNumber(*m)); + } + } + if (const pjson* m = aSchema.find("exclusiveMinimum")) { + const int comparison = m->isNumber() ? _compareNumbers(aNode, *m) : 2; + if (comparison <= 0) { + addSchemaError(aCtx, aErrors, aPath, + "value " + formatNumber(aNode) + + " is not greater than exclusiveMinimum " + formatNumber(*m)); + } + } + if (const pjson* m = aSchema.find("exclusiveMaximum")) { + const int comparison = m->isNumber() ? _compareNumbers(aNode, *m) : 2; + if (comparison != 2 && comparison >= 0) { + addSchemaError(aCtx, aErrors, aPath, + "value " + formatNumber(aNode) + + " is not less than exclusiveMaximum " + formatNumber(*m)); + } + } + if (const pjson* m = aSchema.find("multipleOf")) { + if (m->isNumber() && !isExactMultiple(aNode, *m)) { + addSchemaError(aCtx, aErrors, aPath, + "value " + formatNumber(aNode) + " is not a multiple of " + + formatNumber(*m)); + } + } + } + + // ---- string constraints ---- + if (aNode.isString()) { + const std::string& s = *aNode._uValue._pValueString; + size_t length = 0; + if (!unicodeLength(s, aCtx, aErrors, aPath, length)) + return false; + if (const pjson* m = aSchema.find("minLength")) { + size_t bound = 0; + bool aboveRange = false; + if (schemaSize(*m, bound, aboveRange) && (aboveRange || length < bound)) + addSchemaError(aCtx, aErrors, aPath, + "string length " + std::to_string(length) + " is below minLength " + + formatNumber(*m)); + } + if (const pjson* m = aSchema.find("maxLength")) { + size_t bound = 0; + bool aboveRange = false; + if (schemaSize(*m, bound, aboveRange) && !aboveRange && length > bound) + addSchemaError(aCtx, aErrors, aPath, + "string length " + std::to_string(length) + " is above maxLength " + + formatNumber(*m)); + } + if (const pjson* p = aSchema.find("pattern")) { + if (p->isString()) { + const std::string pattern = pjsonImpl::_string(*p); + bool matches = false; + if (evaluateRegex(s, pattern, aPath, aErrors, aCtx, matches) && !matches) + aErrors.push_back( + SchemaError(aPath, "string does not match pattern /" + pattern + "/")); + } + } + if (aCtx.options.validateFormats) { + if (const pjson* format = aSchema.find("format")) { + if (format->isString()) { + bool known = false; + if (!knownFormatValid(pjsonImpl::_string(*format), s, known) && known) + aErrors.push_back(SchemaError(aPath, "string is not a valid " + + pjsonImpl::_string(*format) + + " format")); + } + } + } + } + + // ---- array constraints ---- + if (aNode.isArray()) { + const PJSONARRAY& arr = *aNode._uValue._pValueArray; + if (const pjson* m = aSchema.find("minItems")) { + size_t bound = 0; + bool aboveRange = false; + if (schemaSize(*m, bound, aboveRange) && (aboveRange || arr.size() < bound)) + addSchemaError(aCtx, aErrors, aPath, + "array has " + std::to_string(arr.size()) + + " items, below minItems " + formatNumber(*m)); + } + if (const pjson* m = aSchema.find("maxItems")) { + size_t bound = 0; + bool aboveRange = false; + if (schemaSize(*m, bound, aboveRange) && !aboveRange && arr.size() > bound) + addSchemaError(aCtx, aErrors, aPath, + "array has " + std::to_string(arr.size()) + + " items, above maxItems " + formatNumber(*m)); + } + if (const pjson* u = aSchema.find("uniqueItems")) { + if (u->isBool() && pjsonImpl::_boolean(*u)) { + bool dup = false; + for (size_t i = 0; i < arr.size() && !dup; ++i) { + for (size_t j = i + 1; j < arr.size(); ++j) { + bool equal = false; + if (!_equalWithBudget(*arr[i], *arr[j], aCtx, aErrors, aPath, equal)) + return false; + if (equal) { + dup = true; + break; + } + } + } + if (dup) { + aErrors.push_back(SchemaError(aPath, "array items are not unique")); + } + } + } + if (const pjson* items = aSchema.find("items")) { + if (items->isArray()) { + const PJSONARRAY& tuple = pjsonImpl::_array(*items); + const size_t count = std::min(arr.size(), tuple.size()); + for (size_t i = 0; i < count && !aCtx.aborted; ++i) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + _validateCtx(*arr[i], *tuple[i], _pointerAppend(aPath, std::to_string(i)), + aErrors, aCtx); + } + } else { + for (size_t i = 0; i < arr.size() && !aCtx.aborted; ++i) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + _validateCtx(*arr[i], *items, _pointerAppend(aPath, std::to_string(i)), aErrors, + aCtx); + } + } + } + } + + // ---- object constraints ---- + if (aNode.isObject()) { + const PJSONMAP& obj = *aNode._uValue._pValueMap; + + if (const pjson* req = aSchema.find("required")) { + if (req->isArray()) { + for (const pjson* k : pjsonImpl::_array(*req)) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + if (k->isString() && obj.find(pjsonImpl::_string(*k)) == obj.end()) { + aErrors.push_back(SchemaError(aPath, "missing required property \"" + + pjsonImpl::_string(*k) + "\"")); + } + } + } + } + if (const pjson* m = aSchema.find("minProperties")) { + size_t bound = 0; + bool aboveRange = false; + if (schemaSize(*m, bound, aboveRange) && (aboveRange || obj.size() < bound)) + addSchemaError(aCtx, aErrors, aPath, + "object has " + std::to_string(obj.size()) + + " properties, below minProperties " + formatNumber(*m)); + } + if (const pjson* m = aSchema.find("maxProperties")) { + size_t bound = 0; + bool aboveRange = false; + if (schemaSize(*m, bound, aboveRange) && !aboveRange && obj.size() > bound) + addSchemaError(aCtx, aErrors, aPath, + "object has " + std::to_string(obj.size()) + + " properties, above maxProperties " + formatNumber(*m)); + } + + const pjson* props = aSchema.find("properties"); + if (props && props->isObject()) { + for (const auto& kv : pjsonImpl::_object(*props)) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + auto it = obj.find(kv.first); + if (it != obj.end()) { + _validateCtx(*it->second, *kv.second, _pointerAppend(aPath, kv.first), aErrors, + aCtx); + } + if (aCtx.aborted) + return false; + } + } + + const pjson* patternProps = aSchema.find("patternProperties"); + // A set avoids the prior O(properties * matches) membership scan when + // additionalProperties is evaluated after patternProperties. + std::set patternMatched; + if (patternProps && patternProps->isObject()) { + for (const auto& patternSchema : pjsonImpl::_object(*patternProps)) { + for (const auto& kv : obj) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + bool matches = false; + if (evaluateRegex(kv.first, patternSchema.first, + _pointerAppend(aPath, kv.first), aErrors, aCtx, matches) && + matches) { + patternMatched.insert(kv.first); + _validateCtx(*kv.second, *patternSchema.second, + _pointerAppend(aPath, kv.first), aErrors, aCtx); + } + if (aCtx.aborted) + return false; + } + } + } + + if (const pjson* propertyNames = aSchema.find("propertyNames")) { + for (const auto& kv : obj) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + pjson propertyName; + propertyName = kv.first; + _validateCtx(propertyName, *propertyNames, _pointerAppend(aPath, kv.first), aErrors, + aCtx); + if (aCtx.aborted) + return false; + } + } + + const pjson* dependentRequired = aSchema.find("dependentRequired"); + if (dependentRequired && dependentRequired->isObject()) { + for (const auto& dependency : pjsonImpl::_object(*dependentRequired)) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + if (obj.find(dependency.first) == obj.end() || !dependency.second->isArray()) + continue; + for (const pjson* required : pjsonImpl::_array(*dependency.second)) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + if (required->isString() && + obj.find(pjsonImpl::_string(*required)) == obj.end()) { + aErrors.push_back(SchemaError( + aPath, "property \"" + dependency.first + "\" requires property \"" + + pjsonImpl::_string(*required) + "\"")); + } + } + } + } + + const pjson* dependencies = aSchema.find("dependencies"); + if (dependencies && dependencies->isObject()) { + for (const auto& dependency : pjsonImpl::_object(*dependencies)) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + if (obj.find(dependency.first) == obj.end()) + continue; + if (dependency.second->isArray()) { + for (const pjson* required : pjsonImpl::_array(*dependency.second)) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + if (required->isString() && + obj.find(pjsonImpl::_string(*required)) == obj.end()) { + aErrors.push_back(SchemaError(aPath, "property \"" + dependency.first + + "\" requires property \"" + + pjsonImpl::_string(*required) + + "\"")); + } + } + } else { + _validateCtx(aNode, *dependency.second, aPath, aErrors, aCtx); + if (aCtx.aborted) + return false; + } + } + } + + if (const pjson* addl = aSchema.find("additionalProperties")) { + for (const auto& kv : obj) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + const bool declared = + props && props->isObject() && + pjsonImpl::_object(*props).find(kv.first) != pjsonImpl::_object(*props).end(); + const bool matched = patternMatched.find(kv.first) != patternMatched.end(); + if (declared || matched) + continue; + if (addl->isBool()) { + if (!pjsonImpl::_boolean(*addl)) { + aErrors.push_back( + SchemaError(_pointerAppend(aPath, kv.first), + "additional property \"" + kv.first + "\" is not allowed")); + } + } else { + _validateCtx(*kv.second, *addl, _pointerAppend(aPath, kv.first), aErrors, aCtx); + } + if (aCtx.aborted) + return false; + } + } + } + + // ---- logical combinators ---- + // allOf contributes each branch's concrete errors. anyOf, oneOf, and not + // are speculative: branches validate into scratch vectors so only the + // combinator-level outcome is exposed to callers. Budget aborts bypass that + // isolation through failValidationBudget and stop all remaining work. + if (const pjson* allOf = aSchema.find("allOf")) { + if (allOf->isArray()) { + for (const pjson* sub : pjsonImpl::_array(*allOf)) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + _validateCtx(aNode, *sub, aPath, aErrors, aCtx); + if (aCtx.aborted) + return false; + } + } + } + if (const pjson* anyOf = aSchema.find("anyOf")) { + if (anyOf->isArray()) { + bool any = false; + for (const pjson* sub : pjsonImpl::_array(*anyOf)) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + std::vector scratch; + SchemaErrorSink scratchSink(scratch, aCtx, false); + if (_validateCtx(aNode, *sub, aPath, scratchSink, aCtx)) { + any = true; + break; + } + if (aCtx.aborted) + return false; + } + if (!any) { + aErrors.push_back(SchemaError(aPath, "value does not match any schema in anyOf")); + } + } + } + if (const pjson* oneOf = aSchema.find("oneOf")) { + if (oneOf->isArray()) { + int matches = 0; + for (const pjson* sub : pjsonImpl::_array(*oneOf)) { + if (!chargeLoopWork(aCtx, aErrors, aPath)) + return false; + std::vector scratch; + SchemaErrorSink scratchSink(scratch, aCtx, false); + if (_validateCtx(aNode, *sub, aPath, scratchSink, aCtx)) + ++matches; + if (aCtx.aborted) + return false; + } + if (matches != 1) { + aErrors.push_back(SchemaError(aPath, "value matched " + std::to_string(matches) + + " schemas in oneOf (exactly 1 required)")); + } + } + } + const pjson* nots = aSchema.find("not"); + if (nots != nullptr && (nots->isBool() || nots->isObject())) { + std::vector scratch; + SchemaErrorSink scratchSink(scratch, aCtx, false); + if (_validateCtx(aNode, *nots, aPath, scratchSink, aCtx)) { + aErrors.push_back(SchemaError(aPath, "value must not match the \"not\" schema")); + } + if (aCtx.aborted) + return false; + } + + return !aCtx.aborted && aErrors.size() == before; +} +/*static*/ +// Runs one noexcept validation session. Unexpected failures become best-effort +// diagnostics rather than escaping across the public API boundary. +bool pjsonImpl::_validate(const pjson& aNode, const pjson& aSchema, const std::string& aPath, + std::vector& aErrors, const SchemaOptions& aOpts) noexcept { + try { + SchemaValidationCtx ctx(aSchema, aOpts, &aErrors); + SchemaErrorSink sink(aErrors, ctx); + return _validateCtx(aNode, aSchema, aPath, sink, ctx); + } catch (const SchemaBudgetExceeded&) { + return false; + } catch (const std::bad_alloc&) { + bestEffortSchemaError(aErrors, aPath.c_str(), "schema validation ran out of memory"); + } catch (const std::exception&) { + bestEffortSchemaError(aErrors, aPath.c_str(), + "schema validation failed with an internal exception"); + } catch (...) { + bestEffortSchemaError(aErrors, aPath.c_str(), + "schema validation failed with an unknown exception"); + } + return false; +} +// Validates and returns only the aggregate result, discarding diagnostics. +bool pjson::validate(const pjson& aSchema, const SchemaOptions& aOpts) const noexcept { + std::vector errors; + return pjsonImpl::_validate(*this, aSchema, "", errors, aOpts); +} +// Validates from the root path and appends diagnostics to aErrors. +bool pjson::validate(const pjson& aSchema, std::vector& aErrors, + const SchemaOptions& aOpts) const noexcept { + return pjsonImpl::_validate(*this, aSchema, "", aErrors, aOpts); +} diff --git a/pjsontest/CMakeLists.txt b/pjsontest/CMakeLists.txt index c11a2e3..80dad3c 100644 --- a/pjsontest/CMakeLists.txt +++ b/pjsontest/CMakeLists.txt @@ -1,36 +1,90 @@ -cmake_minimum_required(VERSION 3.21) +cmake_minimum_required(VERSION 3.21) set (TARGET_NAME pjsontest) project (${TARGET_NAME}) -# Project directories +# ---- Test sources ------------------------------------------------------- + set (SRC_DIR "src") set (INCLUDE_DIR "include") -# Project Src files -set (SRC_FILES ${SRC_FILES} -${SRC_DIR}/main.cpp +# The unit test suite: the harness entry point plus every tests_*.cpp module, +# all linked into a single executable that self-registers its TEST() cases. +set (TEST_SRC_FILES +${SRC_DIR}/test_main.cpp +${SRC_DIR}/tests_core.cpp +${SRC_DIR}/tests_build.cpp +${SRC_DIR}/tests_parse.cpp +${SRC_DIR}/tests_strings.cpp +${SRC_DIR}/tests_roundtrip.cpp +${SRC_DIR}/tests_features.cpp +${SRC_DIR}/tests_schema.cpp +${SRC_DIR}/tests_malformed.cpp +${SRC_DIR}/tests_mutation.cpp +${SRC_DIR}/tests_schema_complex.cpp +${SRC_DIR}/tests_api_edge.cpp +${SRC_DIR}/tests_fuzz.cpp +${SRC_DIR}/tests_pathological.cpp +${SRC_DIR}/tests_conformance.cpp +${SRC_DIR}/tests_schema_vocabulary.cpp +${SRC_DIR}/tests_schema_official.cpp +${SRC_DIR}/tests_storage.cpp +${SRC_DIR}/tests_allocator.cpp +${SRC_DIR}/tests_streaming.cpp +${SRC_DIR}/tests_serialize_access.cpp +${SRC_DIR}/tests_pointer_patch.cpp ) -# Project external libs +# ---- Test target -------------------------------------------------------- + set(${TARGET_NAME}_libs "pjson" ) -# Project Include directories set (INC_DIRS ${INC_DIR} ${CMAKE_CURRENT_SOURCE_DIR} ${INCLUDE_DIR} "../pjsonlib/include" ) -# Compiler Flags set (CMAKE_CXX_STANDARD 11) set (CMAKE_CXX_STANDARD_REQUIRED ON) -set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall") +if (MSVC) + set (PJSON_TEST_WARN_FLAGS /W4) +else() + set (PJSON_TEST_WARN_FLAGS -Wall -Wextra) +endif() -# Execute -add_executable(${TARGET_NAME} ${SRC_FILES}) +# Single assertion-based executable returns non-zero on any failing case. +add_executable(${TARGET_NAME} ${TEST_SRC_FILES}) target_include_directories(${TARGET_NAME} PUBLIC ${INC_DIRS}) target_link_libraries(${TARGET_NAME} ${${TARGET_NAME}_libs}) +target_compile_options(${TARGET_NAME} PRIVATE ${PJSON_TEST_WARN_FLAGS}) +target_compile_definitions(${TARGET_NAME} PRIVATE + PJSON_TEST_DEFAULT_JSONTESTSUITE_DIR="${CMAKE_SOURCE_DIR}/.test-corpora/JSONTestSuite" + PJSON_TEST_DEFAULT_JSON_SCHEMA_TEST_SUITE_DIR="${CMAKE_SOURCE_DIR}/.test-corpora/JSON-Schema-Test-Suite") + +enable_testing() + +# ---- CTest case discovery ---------------------------------------------- + +# Keep one test executable, but expose each self-registered TEST() separately to +# CTest. Extracting TEST(name) declarations at configure time lets CMake resolve +# the executable correctly for single- and multi-config generators on every OS. +set(PJSON_REGISTERED_TESTS) +foreach(test_source IN LISTS TEST_SRC_FILES) + file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/${test_source}" test_declarations + REGEX "TEST[(][A-Za-z0-9_]+[)]") + foreach(declaration IN LISTS test_declarations) + string(REGEX MATCH "TEST[(]([A-Za-z0-9_]+)[)]" unused "${declaration}") + set(test_name "${CMAKE_MATCH_1}") + if(test_name IN_LIST PJSON_REGISTERED_TESTS) + message(FATAL_ERROR "Duplicate pjson test name: ${test_name}") + endif() + list(APPEND PJSON_REGISTERED_TESTS "${test_name}") + add_test(NAME "pjson.${test_name}" COMMAND ${TARGET_NAME} --run-test "${test_name}") + endforeach() +endforeach() +list(LENGTH PJSON_REGISTERED_TESTS PJSON_TEST_COUNT) +message(STATUS "Registered ${PJSON_TEST_COUNT} pjson test cases with CTest") diff --git a/pjsontest/src/main.cpp b/pjsontest/src/main.cpp deleted file mode 100644 index be0c87a..0000000 --- a/pjsontest/src/main.cpp +++ /dev/null @@ -1,372 +0,0 @@ -// -// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -//===----------------------------------------------------------------------===// -// Author: Praveen Babu J D -// License: Apache 2.0 -// - -#include -// Test Turorial : -// 1. Include the header file -#include "pjson.h" -using namespace ByteDance; - -int main() { - // 2. Creating JSON - pjson oJson; - // To create a JSON map of key Value - oJson["myKey1"] = "Value1"; - oJson["myKey2"] = "Value2"; - - // To create a Nested JSON map of key Value - oJson["myKey3"]["myInteger"] = 1; - oJson["myKey3"]["myFloat"] = 1.0f; - - // Create an Array as a Value - oJson["myKey4"] = std::vector({0,1,2,3,4,5,6}); - - // Direct array refernce - oJson["myKey4"][7] = 7; - oJson["myKey4"][8] = "Eight"; - - // Deep Nesting Map->Array->Map->Value - oJson["myKey4"][9]["ninth"] = 9.0f; - - //Simplified access - pjson& rFloats = oJson["myKey3"]["myFloatArray"]; - rFloats = std::vector({0,1.1}); - - //Auto fills null values for rFloats[2] and rFloats[3] - rFloats[4] = 4.4f; - - // Get unformatted string - std::string sResult = oJson.toString(); - std::cout<<"\n** Un-Formatted :\n"<toString(true); - - delete pResult; - } - - - { - // 4. Raw Access - //Load JSON from another JSON string, returns null if it fails - pjson* pResult = pjson::CreateFromString(oJson.toString()); - //Edit an exisiting Array - pjson& rAnotherFloats = (*pResult)["myKey4"]; - pjson::PJSONARRAY* pArray = rAnotherFloats.getArray(); - std::cout<<"\n\n** Print Only the Integers :\n"; - for(auto itr : *pArray) { - if(itr->getType() == pjson::jsonNumberInt){ - std::cout<<" "<getInt(); - } - } - //Print only the child section - std::cout<<"\n\n** Print just the Sub Section :\n"<ACCESSORFUNC(); \ - bfirst = false; \ - } \ - } - //----------------------------------------------------------------------------- - #define ARRAY_PRINTER(TESTARRAY) \ - { bool bfirst = true; \ - for(auto itr : TESTARRAY) { \ - if(!bfirst) \ - std::cout<<" , "; \ - std::cout<& aValueArray); - std::vector sVec = {"one", "two"}; - oB["strings"] = sVec; - - //pjson& operator=(const std::vector& aValueArray); - std::vector sCharVec = { "c1", "c2" }; - oB["charArray"] = sCharVec; - - //pjson& operator=(const std::vector& aValueArray); - std::vector iVec = {7,8}; - oB["ints"] = iVec; - - //pjson& operator=(const std::vector& aValueArray); - std::vector fVec = {1.1,2.1}; - oB["floats"] = fVec; - - //pjson& operator=(const std::vector& aValueArray); - std::vector bVec = {true,false}; - oB["bools"] = bVec; - - //pjson& operator+=(const std::string& aValue); - std::string sThree = "three"; - oB["strings"] += sThree; - - //pjson& operator+=(const char* aValue); - oB["charArray"] += "c3"; - - //pjson& operator+=(const int aValue); - oB["ints"] += 9; - - //pjson& operator+=(const float aValue); - oB["floats"] += 3.3f; - - //pjson& operator+=(const bool aValue); - oB["bools"] += true; - - //pjson& operator+=(const std::vector& aValueArray); - std::vector sVec2 = {"four","five"}; - oB["strings"] += sVec2; - - //pjson& operator+=(const std::vector& aValueArray); - { - char* sCharTest1 = new char[10]; strcpy(sCharTest1,"c4"); - char* sCharTest2 = new char[10]; strcpy(sCharTest2,"c5"); - std::vector sCharVec2 = {sCharTest1, sCharTest2 }; - oB["charArray"] += sCharVec2; - delete [] sCharTest1; - delete [] sCharTest2; - } - - //pjson& operator+=(const std::vector& aValueArray); - std::vector iVec2 = {10,11}; - oB["ints"] += iVec2; - - //pjson& operator+=(const std::vector& aValueArray); - std::vector fVec2 = {4.1,5.1}; - oB["floats"] += fVec2; - - //pjson& operator+=(const std::vector& aValueArray); - std::vector bVec2 = {false, true}; - oB["bools"] += bVec2; - - //map of maps - oB["Cats"]["Cat1"] = "meow"; - oB["Cats"]["Cat2"] = true; - oB["Cats"]["Cat3"] = 37; - oB["Cats"]["Cat4"] = 3.7f; - oB["Cats"]["Cat5"]; - oB["Cats"]["Cat6"] = std::vector {1.1f, 2.2f, 3.3f}; - oB["Cats"]["Cat7"]["dog1"] = 1; - oB["Cats"]["Cat7"]["dog2"] = "bark"; - - //Feature Test - { - std::cout<toString() : ""; - if(0==sTest.compare(sSrc)) { - std::cout<<"PASS"; - } else { - std::cout<<"FAIL"; - } - delete pResult; - } - //Load from String Test - { - std::cout<toString(true) : ""; - std::string sTest2 = (pResult) ? pResult->toString() : ""; - if(0==sTest1.compare(sSrc1) && 0==sTest2.compare(sSrc2)) { - std::cout<<"PASS"; - } else { - std::cout<<"FAIL"; - } - delete pResult; - } - - //Formatting Test - { - std::cout<({1,2,3,4,5,6}); - oA["Level-1-C"]["Level-2-D"][0] = std::vector({1.0,2.0,3.0,4.0,5.0,6.0}); - oA["Level-1-C"]["Level-2-D"][1] = std::vector({"one", "two","three"}); - oA["Level-1-D"] = "L1-D"; - - std::cout<& aDest); - bool getArrayValues(size_t aFrom, size_t aTo, std::vector& aDest); - bool getArrayValues(size_t aFrom, size_t aTo, std::vector& aDest); - bool getArrayValues(size_t aFrom, size_t aTo, std::vector& aDest); - - //Array Float Iterator - { - std::cout< vTestFloats; - oB["floats"].getArrayValues(0,pArray->size() - 1, vTestFloats); - ARRAY_PRINTER(vTestFloats) - } - } - - //Array Int Iterator - { - std::cout< vTestInts; - oB["ints"].getArrayValues(0,pArray->size() - 1, vTestInts); - ARRAY_PRINTER(vTestInts) - } - } - - //Array String Iterator - { - std::cout< vTestStrings; - oB["strings"].getArrayValues(0,pArray->size() - 1, vTestStrings); - ARRAY_PRINTER(vTestStrings) - } - } - - //Array Bool Iterator - { - std::cout< vTestBool; - oB["bools"].getArrayValues(0,pArray->size() - 1, vTestBool); - ARRAY_PRINTER(vTestBool) - } - } - - //Map Iterator Test - { - std::cout<getType()) { - case pjson::jsonType::jsonNull: std::cout<<"|Null | "; break; - case pjson::jsonType::jsonString: std::cout<<"|String| "; break; - case pjson::jsonType::jsonNumberInt: std::cout<<"|Int | "; break; - case pjson::jsonType::jsonNumberFloat: std::cout<<"|Float | "; break; - case pjson::jsonType::jsonBoolean: std::cout<<"|Bool | "; break; - case pjson::jsonType::jsonArray: std::cout<<"|Array | "; break; - case pjson::jsonType::jsonMap: std::cout<<"|Map | "; break; - default: std::cout<<"|**ERROR**|"; break; - } - std::cout<toString(); - } - std::cout< +#include +#include +#include +#include +#include + +namespace pjson_test { + + // Per-test assertion totals, reset immediately before each registered test runs. + struct Stats { + int checks = 0; + int failures = 0; + }; + + // Counters for the test currently executing. + inline Stats& current() { + static Stats s; + return s; + } + + // Aggregate failing-check count used as the process exit status for run_all(). + inline int& total_failures() { + static int n = 0; + return n; + } + + typedef void (*TestFn)(); + + // Keeps tests in static-registration order for deterministic output. + struct Registry { + std::vector> tests; + }; + + // Function-local construction avoids initialization-order dependencies between test files. + inline Registry& registry() { + static Registry r; + return r; + } + + // TEST() creates one static Registrar whose constructor records the test function. + struct Registrar { + Registrar(const char* name, TestFn fn) { + registry().tests.push_back(std::make_pair(std::string(name), fn)); + } + }; + + // Stringifies assertion operands through operator<<. CHECK_EQ/CHECK_NE + // therefore require stream-insertable operand types. + template inline std::string to_str(const T& v) { + std::ostringstream os; + os << v; + return os.str(); + } + inline std::string to_str(bool v) { + return v ? "true" : "false"; + } + inline std::string to_str(const std::string& v) { + return "\"" + v + "\""; + } + inline std::string to_str(std::nullptr_t) { + return "nullptr"; + } + + // Records a failed check and emits its source location plus optional value details. + inline void report_failure(const char* file, int line, const char* expr, + const std::string& detail = std::string()) { + current().failures += 1; + if (detail.empty()) { + std::printf(" FAIL %s:%d %s\n", file, line, expr); + } else { + std::printf(" FAIL %s:%d %s [%s]\n", file, line, expr, detail.c_str()); + } + } + + // Runs the registry in order and returns the number of failing checks. + inline int run_all() { + total_failures() = 0; + int failed_tests = 0; + for (size_t i = 0; i < registry().tests.size(); ++i) { + current() = Stats(); + registry().tests[i].second(); + bool ok = current().failures == 0; + std::printf("[%s] %s (%d checks)\n", ok ? "PASS" : "FAIL", + registry().tests[i].first.c_str(), current().checks); + if (!ok) { + failed_tests += 1; + total_failures() += current().failures; + } + } + std::printf("----------------------------------\n"); + std::printf("%zu tests, %d failed, %d failing checks\n", registry().tests.size(), + failed_tests, total_failures()); + return total_failures(); + } + + // CTest discovery support: print one registered name per line. + inline int list_tests() { + for (size_t i = 0; i < registry().tests.size(); ++i) { + std::printf("%s\n", registry().tests[i].first.c_str()); + } + return 0; + } + + // Runs exactly one registered test. This preserves the same diagnostics as + // run_all() while letting CTest report every TEST() as its own test case. + inline int run_one(const std::string& name) { + for (size_t i = 0; i < registry().tests.size(); ++i) { + if (registry().tests[i].first != name) + continue; + current() = Stats(); + total_failures() = 0; + registry().tests[i].second(); + const bool ok = current().failures == 0; + std::printf("[%s] %s (%d checks)\n", ok ? "PASS" : "FAIL", name.c_str(), + current().checks); + return current().failures; + } + std::fprintf(stderr, "Unknown test: %s\n", name.c_str()); + return 2; + } + +} // namespace pjson_test + +#define PJSON_TOKENPASTE(a, b) a##b +#define PJSON_TOKENPASTE2(a, b) PJSON_TOKENPASTE(a, b) + +#define TEST(name) \ + static void name(); \ + static ::pjson_test::Registrar PJSON_TOKENPASTE2(reg_, name)(#name, name); \ + static void name() + +#define CHECK(expr) \ + do { \ + ::pjson_test::current().checks += 1; \ + if (!(expr)) { \ + ::pjson_test::report_failure(__FILE__, __LINE__, #expr); \ + } \ + } while (0) + +#define CHECK_EQ(a, b) \ + do { \ + ::pjson_test::current().checks += 1; \ + auto _pa = (a); \ + auto _pb = (b); \ + if (!(_pa == _pb)) { \ + ::pjson_test::report_failure(__FILE__, __LINE__, #a " == " #b, \ + ::pjson_test::to_str(_pa) + " vs " + \ + ::pjson_test::to_str(_pb)); \ + } \ + } while (0) + +#define CHECK_NE(a, b) \ + do { \ + ::pjson_test::current().checks += 1; \ + auto _pa = (a); \ + auto _pb = (b); \ + if (!(_pa != _pb)) { \ + ::pjson_test::report_failure(__FILE__, __LINE__, #a " != " #b, \ + ::pjson_test::to_str(_pa) + " vs " + \ + ::pjson_test::to_str(_pb)); \ + } \ + } while (0) + +// Asserts that parsing aStr yields an empty result (invalid input) without +// throwing. +#define CHECK_PARSE_FAILS(aStr) \ + do { \ + ::pjson_test::current().checks += 1; \ + ByteDance::pjson::unique_ptr _p = ByteDance::pjson::parse(aStr); \ + if (_p != nullptr) { \ + ::pjson_test::report_failure(__FILE__, __LINE__, "parse(" #aStr ") == nullptr", \ + "parsed to: " + _p->toString()); \ + } \ + } while (0) + +#endif // PJSON_TEST_HARNESS_H diff --git a/pjsontest/src/test_main.cpp b/pjsontest/src/test_main.cpp new file mode 100644 index 0000000..5f6d01d --- /dev/null +++ b/pjsontest/src/test_main.cpp @@ -0,0 +1,34 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Entry point for the pjson unit test suite. The actual TEST() cases live in +// the sibling tests_*.cpp files and self-register with the harness. With no +// arguments it runs everything; CTest uses --run-test NAME for one case. +// +#include "test_harness.h" + +int main(int argc, char** argv) { + if (argc == 2 && std::string(argv[1]) == "--list-tests") { + return pjson_test::list_tests(); + } + if (argc == 3 && std::string(argv[1]) == "--run-test") { + return pjson_test::run_one(argv[2]); + } + if (argc != 1) { + std::fprintf(stderr, "Usage: %s [--list-tests | --run-test NAME]\n", argv[0]); + return 2; + } + return pjson_test::run_all(); +} diff --git a/pjsontest/src/test_util.h b/pjsontest/src/test_util.h new file mode 100644 index 0000000..5f8b7ce --- /dev/null +++ b/pjsontest/src/test_util.h @@ -0,0 +1,65 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Shared helpers for the pjson test suite. +// +#ifndef PJSON_TEST_UTIL_H +#define PJSON_TEST_UTIL_H + +#include "pjson.h" +#include "test_harness.h" + +#include + +namespace pjson_test { + + // Parses via the public API and returns the owning unique_ptr, so tests read + // naturally and never leak even on a failed assertion. + inline ByteDance::pjson::unique_ptr parse(const std::string& s) { + return ByteDance::pjson::parse(s); + } + + // Length-aware counterpart used for embedded-NUL and truncated-buffer cases. + inline ByteDance::pjson::unique_ptr parse(const char* s, size_t n) { + return ByteDance::pjson::parse(s, n); + } + + inline int64_t valueInt(const ByteDance::pjson& value) { + int64_t result = 0; + CHECK(value.tryGet(result)); + return result; + } + + inline double valueDouble(const ByteDance::pjson& value) { + double result = 0.0; + CHECK(value.tryGet(result)); + return result; + } + + inline bool valueBool(const ByteDance::pjson& value) { + bool result = false; + CHECK(value.tryGet(result)); + return result; + } + + inline std::string valueString(const ByteDance::pjson& value) { + std::string result; + CHECK(value.tryGet(result)); + return result; + } + +} // namespace pjson_test + +#endif // PJSON_TEST_UTIL_H diff --git a/pjsontest/src/tests_allocator.cpp b/pjsontest/src/tests_allocator.cpp new file mode 100644 index 0000000..7fc1f1e --- /dev/null +++ b/pjsontest/src/tests_allocator.cpp @@ -0,0 +1,820 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Allocator ownership, provenance, failure safety, and lifecycle tests. +// +// Public API covered by this file: +// +// struct pjson::Allocator { +// enum AllocationKind { +// NodeAllocation, +// StringAllocation, +// ArrayAllocation, +// ObjectAllocation +// }; +// virtual ~Allocator(); +// virtual void* allocate(size_t aSize, size_t aAlignment, +// AllocationKind aKind) = 0; +// virtual void deallocate(void* aPtr, size_t aSize, size_t aAlignment, +// AllocationKind aKind) noexcept = 0; +// }; +// +// struct pjson::ValueDeleter { +// void operator()(pjson* aValue) const noexcept; +// }; +// typedef std::unique_ptr unique_ptr; +// +// explicit pjson(Allocator& aAlloc) noexcept; +// pjson(const pjson& aFrom, Allocator& aAlloc); +// pjson(pjson&& aFrom, Allocator& aAlloc); +// Allocator& getAllocator() const noexcept; +// bool canSwap(const pjson& aOther) const noexcept; +// +// static unique_ptr parse(const std::string& aStr, Allocator& aAlloc, +// const ParseOptions& aOpts = ParseOptions()); +// static unique_ptr parse(const char* aSrc, size_t aSize, Allocator& aAlloc, +// const ParseOptions& aOpts = ParseOptions()); +// static unique_ptr parse(const std::string& aStr, ParseError& aError, +// Allocator& aAlloc, +// const ParseOptions& aOpts = ParseOptions()); +// static unique_ptr parse(const char* aSrc, size_t aSize, ParseError& aError, +// Allocator& aAlloc, +// const ParseOptions& aOpts = ParseOptions()); +// static unique_ptr parseStream(std::istream& aIn, Allocator& aAlloc, +// const ParseOptions& aOpts = ParseOptions()); +// static unique_ptr parseStream(std::istream& aIn, ParseError& aError, +// Allocator& aAlloc, +// const ParseOptions& aOpts = ParseOptions()); +// +// Semantics covered here: +// - every node stores allocator provenance and children inherit it +// - parse uses the supplied allocator for DOM nodes and wrapper objects +// - parse failure unwinds all partial allocations +// - resetTo/copy assignment/cross-allocator move assignment offer strong safety +// - same-allocator swap is O(1) and allocation-free +// - cross-allocator swap is explicitly rejected via canSwap()==false +// +#include "pjson.h" +#include "test_harness.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace ByteDance; + +namespace { + + typedef pjson::Allocator::AllocationKind AllocationKind; + + // Per-kind accounting distinguishes balanced frees from cross-kind provenance bugs. + struct KindStats { + size_t allocations = 0; + size_t deallocations = 0; + size_t liveBlocks = 0; + size_t peakLiveBlocks = 0; + size_t liveBytes = 0; + }; + + // Exact metadata that deallocate() must receive for each outstanding pointer. + struct AllocationRecord { + size_t size; + size_t alignment; + AllocationKind kind; + }; + + // Test allocator that records allocation metadata and can inject a failure by kind. + // Its live-allocation table also verifies that deallocation repeats the original contract. + class TrackingAllocator : public pjson::Allocator { + public: + explicit TrackingAllocator(const std::string& aName) + : _name(aName) { + clearFailures(); + } + + virtual void* allocate(size_t aSize, size_t aAlignment, AllocationKind aKind) { + if (_armedFailures[aKind] >= 0) { + if (_armedFailures[aKind] == 0) { + throw std::bad_alloc(); + } + --_armedFailures[aKind]; + } + + void* ptr = ::operator new(aSize); + AllocationRecord rec = {aSize, aAlignment, aKind}; + _live[ptr] = rec; + + KindStats& s = _stats[aKind]; + ++s.allocations; + ++s.liveBlocks; + s.liveBytes += aSize; + if (s.liveBlocks > s.peakLiveBlocks) { + s.peakLiveBlocks = s.liveBlocks; + } + return ptr; + } + + virtual void deallocate(void* aPtr, size_t aSize, size_t aAlignment, + AllocationKind aKind) noexcept { + std::map::iterator it = _live.find(aPtr); + if (it == _live.end()) { + ++_badDeallocations; + return; + } + if (it->second.size != aSize || it->second.alignment != aAlignment || + it->second.kind != aKind) { + ++_badDeallocations; + } + + KindStats& s = _stats[it->second.kind]; + ++s.deallocations; + if (s.liveBlocks > 0) { + --s.liveBlocks; + } + if (s.liveBytes >= it->second.size) { + s.liveBytes -= it->second.size; + } else { + s.liveBytes = 0; + ++_badDeallocations; + } + + _live.erase(it); + ::operator delete(aPtr); + } + + void failAfter(AllocationKind aKind, size_t aSuccessfulAllocsBeforeThrow) { + _armedFailures[aKind] = static_cast(aSuccessfulAllocsBeforeThrow); + } + + // Disarms every failure point without disturbing lifetime counters. + void clearFailures() { + for (int i = 0; i < 4; ++i) { + _armedFailures[static_cast(i)] = -1; + } + } + + const KindStats& stats(AllocationKind aKind) const { + std::map::const_iterator it = _stats.find(aKind); + if (it != _stats.end()) { + return it->second; + } + static const KindStats empty; + return empty; + } + + size_t liveBlockCount() const { return _live.size(); } + + size_t badDeallocations() const { return _badDeallocations; } + + const std::string& name() const { return _name; } + + private: + std::string _name; + std::map _live; + std::map _stats; + std::map _armedFailures; + size_t _badDeallocations = 0; + }; + + // Checks both global and per-kind balance after all values using an allocator die. + static void checkAllocatorHealth(const TrackingAllocator& aAlloc) { + CHECK_EQ(aAlloc.badDeallocations(), size_t(0)); + CHECK_EQ(aAlloc.liveBlockCount(), size_t(0)); + CHECK_EQ(aAlloc.stats(pjson::Allocator::NodeAllocation).liveBlocks, size_t(0)); + CHECK_EQ(aAlloc.stats(pjson::Allocator::StringAllocation).liveBlocks, size_t(0)); + CHECK_EQ(aAlloc.stats(pjson::Allocator::ArrayAllocation).liveBlocks, size_t(0)); + CHECK_EQ(aAlloc.stats(pjson::Allocator::ObjectAllocation).liveBlocks, size_t(0)); + } + + // Walks iteratively so allocator-provenance checks remain safe for deeply nested values. + static void checkTreeAllocator(const pjson& aRoot, pjson::Allocator& aExpected) { + std::vector work; + work.push_back(&aRoot); + while (!work.empty()) { + const pjson* cur = work.back(); + work.pop_back(); + CHECK_EQ(&cur->getAllocator(), &aExpected); + if (cur->isArray()) { + for (size_t i = 0; i < cur->size(); ++i) { + const pjson* child = cur->find(static_cast(i)); + CHECK(child != nullptr); + if (child != nullptr) + work.push_back(child); + } + } else if (cur->isObject()) { + const std::vector keys = cur->keys(); + for (size_t i = 0; i < keys.size(); ++i) { + const pjson* child = cur->find(keys[i]); + CHECK(child != nullptr); + if (child != nullptr) + work.push_back(child); + } + } + } + } + + static int64_t intValue(const pjson& aValue) { + int64_t value = 0; + CHECK(aValue.tryGet(value)); + return value; + } + + static bool boolValue(const pjson& aValue) { + bool value = false; + CHECK(aValue.tryGet(value)); + return value; + } + + static std::string stringValue(const pjson& aValue) { + std::string value; + CHECK(aValue.tryGet(value)); + return value; + } + + // The following adapters turn allocation failure into a boolean while leaving each test + // responsible for checking the operation's strong exception-safety invariant. + static bool throwsBadAllocDuringAssignString(pjson& aValue, const std::string& aText) { + try { + aValue = aText; + return false; + } catch (const std::bad_alloc&) { + return true; + } + } + + static bool throwsBadAllocDuringReset(pjson& aValue, pjson::jsonType aType) { + try { + aValue.resetTo(aType); + return false; + } catch (const std::bad_alloc&) { + return true; + } + } + + static bool throwsBadAllocDuringCopyAssign(pjson& aDst, const pjson& aSrc) { + try { + aDst = aSrc; + return false; + } catch (const std::bad_alloc&) { + return true; + } + } + + static bool throwsBadAllocDuringMoveAssign(pjson& aDst, pjson& aSrc) { + try { + aDst = std::move(aSrc); + return false; + } catch (const std::bad_alloc&) { + return true; + } + } + + static bool throwsBadAllocDuringMissingKeyInsert(pjson& aRoot) { + try { + aRoot["newKey"]["leaf"] = int64_t(7); + return false; + } catch (const std::bad_alloc&) { + return true; + } + } + + static bool throwsBadAllocDuringArrayGrowth(pjson& aRoot) { + try { + aRoot[4] = int64_t(9); + return false; + } catch (const std::bad_alloc&) { + return true; + } + } + + // Preserve the custom-deleter return type while keeping parse-overload tests concise. + static pjson::unique_ptr parseWithAllocator(const std::string& aText, + TrackingAllocator& aAlloc) { + return pjson::parse(aText, aAlloc); + } + + static pjson::unique_ptr parseWithAllocator(const std::string& aText, pjson::ParseError& aErr, + TrackingAllocator& aAlloc) { + return pjson::parse(aText, aErr, aAlloc); + } + +} // namespace + +//===----------------------------------------------------------------------===// +// Allocator API shape and allocation provenance +//===----------------------------------------------------------------------===// + +static_assert(std::is_abstract::value, + "pjson::Allocator must be an abstract runtime interface"); +static_assert(std::has_virtual_destructor::value, + "pjson::Allocator must have a virtual destructor"); +static_assert(std::is_constructible::value, + "pjson must support explicit allocator construction"); +static_assert(std::is_constructible::value, + "pjson must support deep copy into an explicit allocator"); +static_assert(std::is_constructible::value, + "pjson must support cross-allocator move construction"); + +TEST(allocator_api_surface_is_explicit_and_non_template) { + TrackingAllocator alloc("api"); + pjson value(alloc); + CHECK_EQ(&value.getAllocator(), &alloc); + CHECK(value.canSwap(value)); + + pjson copied(value, alloc); + CHECK_EQ(&copied.getAllocator(), &alloc); + + pjson moved(std::move(copied), alloc); + CHECK_EQ(&moved.getAllocator(), &alloc); +} + +TEST(allocator_mutation_tracks_nodes_strings_arrays_and_objects) { + TrackingAllocator alloc("mutate"); + { + pjson doc(alloc); + doc["name"] = std::string("ada"); + doc["scores"] += int64_t(1); + doc["scores"] += int64_t(2); + doc["meta"]["admin"] = true; + doc["meta"]["pi"] = double(3.5); + + CHECK(doc.isObject()); + CHECK_EQ(stringValue(doc["name"]), std::string("ada")); + CHECK_EQ(doc["scores"].size(), size_t(2)); + CHECK(boolValue(doc["meta"]["admin"])); + + checkTreeAllocator(doc, alloc); + CHECK(alloc.stats(pjson::Allocator::NodeAllocation).allocations >= size_t(4)); + CHECK(alloc.stats(pjson::Allocator::StringAllocation).allocations >= size_t(1)); + CHECK(alloc.stats(pjson::Allocator::ArrayAllocation).allocations >= size_t(1)); + CHECK(alloc.stats(pjson::Allocator::ObjectAllocation).allocations >= size_t(1)); + } + + checkAllocatorHealth(alloc); +} + +//===----------------------------------------------------------------------===// +// DOM parsing, teardown, and allocator-aware erase/reset +//===----------------------------------------------------------------------===// + +TEST(allocator_parse_success_uses_supplied_allocator_for_dom) { + TrackingAllocator alloc("parse-ok"); + { + pjson::unique_ptr doc = + parseWithAllocator(R"({"name":"ada","list":[1,2,3],"obj":{"flag":true}})", alloc); + CHECK(doc != nullptr); + std::string name; + int64_t third = 0; + bool flag = false; + CHECK(doc->tryGet("name", name)); + CHECK_EQ(name, std::string("ada")); + CHECK(doc->find("list")->tryGet(2, third)); + CHECK_EQ(third, int64_t(3)); + CHECK(doc->find("obj")->tryGet("flag", flag)); + CHECK(flag); + + checkTreeAllocator(*doc, alloc); + CHECK(alloc.stats(pjson::Allocator::NodeAllocation).allocations >= size_t(6)); + CHECK(alloc.stats(pjson::Allocator::StringAllocation).allocations >= size_t(1)); + CHECK(alloc.stats(pjson::Allocator::ArrayAllocation).allocations >= size_t(1)); + CHECK(alloc.stats(pjson::Allocator::ObjectAllocation).allocations >= size_t(2)); + } + + checkAllocatorHealth(alloc); +} + +TEST(allocator_parse_failure_unwinds_partials_and_keeps_balance) { + TrackingAllocator alloc("parse-fail"); + pjson::ParseError err; + pjson::unique_ptr doc = parseWithAllocator(R"({"a":[1,2,{"b":[3,4,})", err, alloc); + CHECK(doc == nullptr); + CHECK(!err.ok); + CHECK(!err.message.empty()); + + checkAllocatorHealth(alloc); +} + +TEST(allocator_parse_bad_alloc_returns_null_and_reports_error) { + TrackingAllocator alloc("parse-oom"); + alloc.failAfter(pjson::Allocator::NodeAllocation, 2); + + pjson::ParseError err; + pjson::unique_ptr doc = parseWithAllocator(R"({"a":[1,2,3],"b":{"c":"text"}})", err, alloc); + CHECK(doc == nullptr); + CHECK(!err.ok); + CHECK(err.message.find("memory") != std::string::npos || + err.message.find("alloc") != std::string::npos); + + checkAllocatorHealth(alloc); +} + +TEST(allocator_erase_and_reset_release_removed_storage_with_correct_provenance) { + TrackingAllocator alloc("erase-reset"); + { + pjson doc(alloc); + doc["drop"]["name"] = "gone"; + doc["drop"]["items"] += int64_t(1); + doc["keep"] = int64_t(7); + + const size_t nodeAllocsBeforeErase = + alloc.stats(pjson::Allocator::NodeAllocation).allocations; + const size_t nodeFreesBeforeErase = + alloc.stats(pjson::Allocator::NodeAllocation).deallocations; + CHECK(doc.erase("drop")); + CHECK_EQ(doc.size(), size_t(1)); + CHECK(doc.hasKey("keep")); + CHECK(!doc.hasKey("drop")); + CHECK_EQ(alloc.stats(pjson::Allocator::NodeAllocation).allocations, nodeAllocsBeforeErase); + CHECK(alloc.stats(pjson::Allocator::NodeAllocation).deallocations > nodeFreesBeforeErase); + + doc.resetTo(pjson::jsonString); + doc = std::string("reused"); + CHECK(doc.isString()); + CHECK_EQ(stringValue(doc), std::string("reused")); + CHECK_EQ(&doc.getAllocator(), &alloc); + } + + checkAllocatorHealth(alloc); +} + +//===----------------------------------------------------------------------===// +// Copy, move, and swap behavior within and across allocator domains +//===----------------------------------------------------------------------===// + +TEST(allocator_copy_construction_and_copy_assignment_rehome_to_destination_allocator) { + TrackingAllocator sourceAlloc("copy-src"); + TrackingAllocator destAlloc("copy-dst"); + + { + pjson source(sourceAlloc); + source["name"] = "ada"; + source["nums"] += int64_t(1); + source["nums"] += int64_t(2); + source["meta"]["ok"] = true; + + pjson copied(source, destAlloc); + CHECK(copied == source); + checkTreeAllocator(source, sourceAlloc); + checkTreeAllocator(copied, destAlloc); + + pjson assigned(destAlloc); + assigned["old"] = "value"; + assigned = source; + CHECK(assigned == source); + checkTreeAllocator(assigned, destAlloc); + checkTreeAllocator(source, sourceAlloc); + } + + checkAllocatorHealth(sourceAlloc); + checkAllocatorHealth(destAlloc); +} + +TEST(allocator_copy_assignment_bad_alloc_preserves_destination_and_source) { + TrackingAllocator sourceAlloc("copy-oom-src"); + TrackingAllocator destAlloc("copy-oom-dst"); + + { + pjson source(sourceAlloc); + source["name"] = "ada"; + source["nested"]["list"] += int64_t(1); + source["nested"]["list"] += int64_t(2); + + pjson dest(destAlloc); + dest["old"] = "value"; + const std::string beforeDest = dest.toString(); + const std::string beforeSource = source.toString(); + + destAlloc.failAfter(pjson::Allocator::NodeAllocation, 0); + CHECK(throwsBadAllocDuringCopyAssign(dest, source)); + CHECK_EQ(dest.toString(), beforeDest); + CHECK_EQ(source.toString(), beforeSource); + checkTreeAllocator(dest, destAlloc); + checkTreeAllocator(source, sourceAlloc); + } + + checkAllocatorHealth(sourceAlloc); + checkAllocatorHealth(destAlloc); +} + +TEST(allocator_same_allocator_move_and_swap_do_not_allocate) { + TrackingAllocator alloc("move-swap-same"); + { + pjson left(alloc); + left["left"] = int64_t(1); + pjson right(alloc); + right["right"] = int64_t(2); + + const size_t allocsBeforeSwap = alloc.stats(pjson::Allocator::NodeAllocation).allocations; + CHECK(left.canSwap(right)); + left.swap(right); + CHECK(left.hasKey("right")); + CHECK(right.hasKey("left")); + CHECK_EQ(alloc.stats(pjson::Allocator::NodeAllocation).allocations, allocsBeforeSwap); + + pjson moved(std::move(left)); + CHECK(moved.hasKey("right")); + CHECK(left.isNull()); + + pjson target(alloc); + target["old"] = int64_t(9); + const size_t allocsBeforeMoveAssign = + alloc.stats(pjson::Allocator::NodeAllocation).allocations; + target = std::move(right); + CHECK(target.hasKey("left")); + CHECK(right.isNull()); + CHECK_EQ(alloc.stats(pjson::Allocator::NodeAllocation).allocations, allocsBeforeMoveAssign); + } + + checkAllocatorHealth(alloc); +} + +TEST(allocator_cross_allocator_move_rehomes_to_destination_allocator) { + TrackingAllocator sourceAlloc("move-src"); + TrackingAllocator destAlloc("move-dst"); + + { + pjson source(sourceAlloc); + source["name"] = "ada"; + source["items"] += int64_t(1); + source["items"] += int64_t(2); + + pjson moved(std::move(source), destAlloc); + CHECK(moved.hasKey("name")); + CHECK_EQ(intValue(moved["items"][1]), int64_t(2)); + CHECK(source.isNull()); + checkTreeAllocator(moved, destAlloc); + + pjson source2(sourceAlloc); + source2["flag"] = true; + pjson dest(destAlloc); + dest["old"] = "v"; + dest = std::move(source2); + CHECK(dest.hasKey("flag")); + CHECK(source2.isNull()); + checkTreeAllocator(dest, destAlloc); + } + + checkAllocatorHealth(sourceAlloc); + checkAllocatorHealth(destAlloc); +} + +TEST(allocator_cross_allocator_move_assignment_bad_alloc_preserves_both_values) { + TrackingAllocator sourceAlloc("move-oom-src"); + TrackingAllocator destAlloc("move-oom-dst"); + + { + pjson source(sourceAlloc); + source["arr"] += int64_t(1); + source["arr"] += int64_t(2); + + pjson dest(destAlloc); + dest["old"] = "value"; + const std::string beforeSource = source.toString(); + const std::string beforeDest = dest.toString(); + + destAlloc.failAfter(pjson::Allocator::NodeAllocation, 0); + CHECK(throwsBadAllocDuringMoveAssign(dest, source)); + CHECK_EQ(source.toString(), beforeSource); + CHECK_EQ(dest.toString(), beforeDest); + checkTreeAllocator(source, sourceAlloc); + checkTreeAllocator(dest, destAlloc); + } + + checkAllocatorHealth(sourceAlloc); + checkAllocatorHealth(destAlloc); +} + +TEST(allocator_cross_allocator_swap_is_explicitly_rejected) { + TrackingAllocator a("swap-a"); + TrackingAllocator b("swap-b"); + + { + pjson left(a); + left["x"] = int64_t(1); + pjson right(b); + right["y"] = int64_t(2); + + CHECK(!left.canSwap(right)); + CHECK(!right.canSwap(left)); + left.swap(right); + CHECK(left.hasKey("x")); + CHECK(right.hasKey("y")); + checkTreeAllocator(left, a); + checkTreeAllocator(right, b); + } + + checkAllocatorHealth(a); + checkAllocatorHealth(b); +} + +//===----------------------------------------------------------------------===// +// Strong exception safety under injected allocation failures +//===----------------------------------------------------------------------===// + +TEST(allocator_resetto_allocation_failure_preserves_previous_value) { + TrackingAllocator alloc("reset-oom"); + { + pjson value(alloc); + value = static_cast(7); + alloc.failAfter(pjson::Allocator::StringAllocation, 0); + CHECK(throwsBadAllocDuringReset(value, pjson::jsonString)); + CHECK(value.isInt()); + CHECK_EQ(intValue(value), int64_t(7)); + + alloc.clearFailures(); + value["k"] = int64_t(1); + const std::string before = value.toString(); + alloc.failAfter(pjson::Allocator::ArrayAllocation, 0); + CHECK(throwsBadAllocDuringReset(value, pjson::jsonArray)); + CHECK_EQ(value.toString(), before); + CHECK(value.isObject()); + } + + checkAllocatorHealth(alloc); +} + +TEST(allocator_missing_key_insert_failure_keeps_document_unchanged) { + TrackingAllocator alloc("insert-oom"); + { + pjson value(alloc); + value["keep"] = int64_t(1); + const std::string before = value.toString(); + + alloc.failAfter(pjson::Allocator::NodeAllocation, 0); + CHECK(throwsBadAllocDuringMissingKeyInsert(value)); + CHECK_EQ(value.toString(), before); + CHECK(value.hasKey("keep")); + CHECK(!value.hasKey("newKey")); + } + + checkAllocatorHealth(alloc); +} + +TEST(allocator_array_growth_failure_keeps_existing_prefix_unchanged) { + TrackingAllocator alloc("array-grow-oom"); + { + pjson arr(alloc); + arr += int64_t(1); + arr += int64_t(2); + const std::string before = arr.toString(); + + alloc.failAfter(pjson::Allocator::NodeAllocation, 0); + CHECK(throwsBadAllocDuringArrayGrowth(arr)); + CHECK_EQ(arr.toString(), before); + CHECK_EQ(arr.size(), size_t(2)); + CHECK_EQ(intValue(arr[0]), int64_t(1)); + CHECK_EQ(intValue(arr[1]), int64_t(2)); + } + + checkAllocatorHealth(alloc); +} + +TEST(allocator_string_assignment_failure_keeps_old_value) { + TrackingAllocator alloc("string-assign-oom"); + { + pjson value(alloc); + value = static_cast(5); + alloc.failAfter(pjson::Allocator::StringAllocation, 0); + CHECK(throwsBadAllocDuringAssignString(value, "hello")); + CHECK(value.isInt()); + CHECK_EQ(intValue(value), int64_t(5)); + } + + checkAllocatorHealth(alloc); +} + +//===----------------------------------------------------------------------===// +// Root deletion and allocator propagation through higher-level APIs +//===----------------------------------------------------------------------===// + +TEST(allocator_default_and_custom_root_deleters_match_allocation_origin) { + TrackingAllocator alloc("root-delete"); + { + pjson::unique_ptr doc = pjson::parse(R"({"default":[1,2]})"); + CHECK(doc != nullptr); + CHECK(&doc->getAllocator() != &alloc); + } + { + pjson::unique_ptr ordinaryNode(new pjson()); + (*ordinaryNode)["value"] = int64_t(1); + } + + { + pjson::unique_ptr doc = pjson::parse(R"({"custom":[1,2]})", alloc); + CHECK(doc != nullptr); + CHECK_EQ(&doc->getAllocator(), &alloc); + checkTreeAllocator(*doc, alloc); + } + checkAllocatorHealth(alloc); +} + +TEST(allocator_patch_and_merge_patch_create_nodes_in_destination_allocator) { + TrackingAllocator destination("patch-destination"); + TrackingAllocator source("patch-source"); + { + pjson target(destination); + target["keep"] = int64_t(1); + + pjson patch(source); + patch[0]["op"] = "add"; + patch[0]["path"] = "/added"; + patch[0]["value"]["nested"] = "text"; + CHECK(target.applyPatch(patch)); + CHECK_EQ(stringValue(target["added"]["nested"]), std::string("text")); + checkTreeAllocator(target, destination); + checkTreeAllocator(patch, source); + + pjson merge(source); + merge["merged"]["list"] += int64_t(3); + merge["merged"]["list"] += int64_t(4); + CHECK(target.applyMergePatch(merge)); + CHECK_EQ(intValue(target["merged"]["list"][1]), int64_t(4)); + checkTreeAllocator(target, destination); + checkTreeAllocator(merge, source); + } + checkAllocatorHealth(destination); + checkAllocatorHealth(source); +} + +TEST(allocator_all_dom_parse_overloads_use_custom_root_deletion) { + TrackingAllocator alloc("parse-overloads"); + { + const std::string text = R"({"value":[1,2,3]})"; + pjson::ParseOptions opts; + pjson::ParseError error; + + pjson::unique_ptr fromBuffer = pjson::parse(text.data(), text.size(), alloc, opts); + CHECK(fromBuffer != nullptr); + checkTreeAllocator(*fromBuffer, alloc); + + pjson::unique_ptr fromBufferError = + pjson::parse(text.data(), text.size(), error, alloc, opts); + CHECK(fromBufferError != nullptr); + CHECK(error.ok); + checkTreeAllocator(*fromBufferError, alloc); + + std::istringstream firstStream(text); + pjson::unique_ptr fromStream = pjson::parseStream(firstStream, alloc, opts); + CHECK(fromStream != nullptr); + checkTreeAllocator(*fromStream, alloc); + + std::istringstream secondStream(text); + pjson::unique_ptr fromStreamError = pjson::parseStream(secondStream, error, alloc, opts); + CHECK(fromStreamError != nullptr); + CHECK(error.ok); + checkTreeAllocator(*fromStreamError, alloc); + } + checkAllocatorHealth(alloc); +} + +TEST(allocator_patch_and_merge_patch_oom_leave_destination_unchanged) { + TrackingAllocator destination("patch-oom-destination"); + TrackingAllocator source("patch-oom-source"); + { + pjson target(destination); + target["keep"] = int64_t(1); + target["nested"]["old"] = true; + const std::string before = target.toString(); + + pjson patch(source); + patch[0]["op"] = "add"; + patch[0]["path"] = "/added"; + patch[0]["value"]["nested"] = "text"; + + destination.failAfter(pjson::Allocator::NodeAllocation, 0); + pjson::PatchError patchError; + CHECK(!target.applyPatch(patch, patchError)); + CHECK_EQ(patchError.code, pjson::PatchError::AllocationFailure); + CHECK_EQ(target.toString(), before); + checkTreeAllocator(target, destination); + + destination.clearFailures(); + pjson merge(source); + merge["new"]["leaf"] = int64_t(4); + destination.failAfter(pjson::Allocator::ObjectAllocation, 0); + pjson::PatchError mergeError; + CHECK(!target.applyMergePatch(merge, mergeError)); + CHECK_EQ(mergeError.code, pjson::PatchError::AllocationFailure); + CHECK_EQ(target.toString(), before); + checkTreeAllocator(target, destination); + } + checkAllocatorHealth(destination); + checkAllocatorHealth(source); +} diff --git a/pjsontest/src/tests_api_edge.cpp b/pjsontest/src/tests_api_edge.cpp new file mode 100644 index 0000000..ddab923 --- /dev/null +++ b/pjsontest/src/tests_api_edge.cpp @@ -0,0 +1,650 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Per-API normal and edge cases on the settled surface: strict typed reads via +// tryGet(), non-vivifying find()/operator[] split, SerializeOptions-based +// output, parse ownership, and deep equality/copy behavior. +// +#include "pjson.h" +#include "test_harness.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace ByteDance; + +namespace { + int64_t mustGetInt(const pjson& value) { + int64_t out = 0; + CHECK(value.tryGet(out)); + return out; + } + + double mustGetDouble(const pjson& value) { + double out = 0.0; + CHECK(value.tryGet(out)); + return out; + } + + std::string mustGetString(const pjson& value) { + std::string out; + CHECK(value.tryGet(out)); + return out; + } +} // namespace + +//===----------------------------------------------------------------------===// +// Integer boundaries and double formatting stay round-trippable. +//===----------------------------------------------------------------------===// +TEST(api_int64_boundaries_round_trip) { + pjson mx; + mx = static_cast(INT64_MAX); + pjson mn; + mn = static_cast(INT64_MIN); + CHECK_EQ(mx.toString(), std::string("9223372036854775807")); + CHECK_EQ(mn.toString(), std::string("-9223372036854775808")); + + pjson::unique_ptr pmx = pjson::parse("9223372036854775807"); + pjson::unique_ptr pmn = pjson::parse("-9223372036854775808"); + CHECK(pmx != nullptr); + CHECK(pmn != nullptr); + if (pmx) + CHECK_EQ(mustGetInt(*pmx), INT64_MAX); + if (pmn) + CHECK_EQ(mustGetInt(*pmn), INT64_MIN); +} + +TEST(api_double_formatting_edges) { + struct Case { + double v; + }; + const Case cases[] = { + {0.0}, + {-0.0}, + {1.0}, + {0.1}, + {0.0000001}, + {123456789012345.0}, + {1e308}, + {2.2250738585072014e-308}, + {3.141592653589793}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + pjson j; + j = cases[i].v; + pjson::unique_ptr rt = pjson::parse(j.toString()); + CHECK(rt != nullptr); + if (rt) + CHECK_EQ(mustGetDouble(*rt), cases[i].v); + } +} + +TEST(api_negative_zero_preserved_textually) { + pjson j; + j = double(-0.0); + CHECK_EQ(j.toString(), std::string("-0.0")); +} + +//===----------------------------------------------------------------------===// +// Very large strings survive assignment, serialization, and round-trip. +//===----------------------------------------------------------------------===// +TEST(api_large_string_round_trip) { + std::string big(100000, 'x'); + pjson j; + j["blob"] = big; + + pjson::unique_ptr rt = pjson::parse(j.toString()); + CHECK(rt != nullptr); + if (!rt) + return; + + const pjson* blob = rt->find("blob"); + CHECK(blob != nullptr); + if (!blob) + return; + + pjson::StringView view; + CHECK(blob->tryGet(view)); + CHECK_EQ(view.size(), size_t(100000)); + CHECK(*rt == j); +} + +TEST(api_string_with_every_escape) { + std::string s = "\"\\/\b\f\n\r\t"; + for (int c = 1; c < 0x20; ++c) + s += static_cast(c); + pjson j; + j["s"] = s; + + pjson::unique_ptr rt = pjson::parse(j.toString()); + CHECK(rt != nullptr); + if (rt) { + const pjson* field = rt->find("s"); + CHECK(field != nullptr); + if (field) + CHECK_EQ(mustGetString(*field), s); + } +} + +TEST(api_empty_string_and_empty_key) { + pjson j; + j[""] = std::string(""); + CHECK(j.hasKey("")); + + const pjson* field = j.find(""); + CHECK(field != nullptr); + if (field) { + pjson::StringView view; + CHECK(field->tryGet(view)); + CHECK(view.empty()); + } + + pjson::unique_ptr rt = pjson::parse(j.toString()); + CHECK(rt != nullptr); + if (rt) + CHECK(*rt == j); +} + +//===----------------------------------------------------------------------===// +// tryGet() is strict, leaves outputs untouched on failure, and widens only +// integers to double. +//===----------------------------------------------------------------------===// +TEST(api_tryget_node_matrix) { + pjson value; + int64_t integer = 91; + double floating = 9.5; + bool boolean = true; + std::string string = "sentinel"; + pjson::StringView view; + + value = static_cast(42); + CHECK(value.tryGet(integer)); + CHECK_EQ(integer, int64_t(42)); + CHECK(value.tryGet(floating)); + CHECK_EQ(floating, 42.0); + CHECK(!value.tryGet(boolean)); + CHECK_EQ(boolean, true); + CHECK(!value.tryGet(string)); + CHECK_EQ(string, std::string("sentinel")); + CHECK(!value.tryGet(view)); + CHECK(view.data() == nullptr); + + value = double(3.5); + CHECK(!value.tryGet(integer)); + CHECK_EQ(integer, int64_t(42)); + CHECK(value.tryGet(floating)); + CHECK_EQ(floating, 3.5); + + value = false; + CHECK(value.tryGet(boolean)); + CHECK_EQ(boolean, false); + + value = std::string("hi"); + CHECK(value.tryGet(string)); + CHECK_EQ(string, std::string("hi")); + CHECK(value.tryGet(view)); + CHECK_EQ(std::string(view.data(), view.size()), std::string("hi")); +} + +TEST(api_tryget_child_overloads_and_find_are_non_vivifying) { + pjson object; + object["i"] = static_cast(7); + object["d"] = double(2.5); + object["b"] = true; + object["s"] = std::string("value"); + object["arr"][0] = static_cast(11); + object["arr"][1] = double(4.5); + object["arr"][2] = false; + object["arr"][3] = std::string("tail"); + + int64_t integer = -1; + double floating = -1.0; + bool boolean = false; + std::string string = "old"; + pjson::StringView view; + + CHECK(object.tryGet("i", integer)); + CHECK_EQ(integer, int64_t(7)); + CHECK(object.tryGet("i", floating)); + CHECK_EQ(floating, 7.0); + CHECK(object.tryGet("b", boolean)); + CHECK_EQ(boolean, true); + CHECK(object.tryGet("s", string)); + CHECK_EQ(string, std::string("value")); + CHECK(object.tryGet("s", view)); + CHECK_EQ(std::string(view.data(), view.size()), std::string("value")); + + const pjson* arr = object.find("arr"); + CHECK(arr != nullptr); + if (!arr) + return; + + CHECK(arr->tryGet(0, integer)); + CHECK_EQ(integer, int64_t(11)); + CHECK(arr->tryGet(0, floating)); + CHECK_EQ(floating, 11.0); + CHECK(arr->tryGet(-2, boolean)); + CHECK_EQ(boolean, false); + CHECK(arr->tryGet(-1, string)); + CHECK_EQ(string, std::string("tail")); + CHECK(arr->tryGet(3, view)); + CHECK_EQ(std::string(view.data(), view.size()), std::string("tail")); + + const size_t before = object.size(); + CHECK(object.find("missing") == nullptr); + CHECK(!object.tryGet("missing", integer)); + CHECK_EQ(integer, int64_t(11)); + CHECK_EQ(object.size(), before); +} + +TEST(api_tryget_failure_preserves_outputs_and_wrong_types_do_not_mutate) { + pjson object; + object["number"] = static_cast(5); + object["text"] = std::string("value"); + const size_t objectSize = object.size(); + + int64_t integer = 77; + double floating = 8.5; + bool boolean = true; + std::string string = "keep"; + pjson::StringView view; + pjson held; + held = std::string("held"); + CHECK(held.tryGet(view)); + const char* viewData = view.data(); + + CHECK(!object.tryGet("missing", integer)); + CHECK(!object.tryGet("number", boolean)); + CHECK(!object.tryGet("text", floating)); + CHECK(!object.tryGet("number", string)); + CHECK_EQ(integer, int64_t(77)); + CHECK_EQ(floating, 8.5); + CHECK_EQ(boolean, true); + CHECK_EQ(string, std::string("keep")); + CHECK_EQ(view.data(), viewData); + CHECK_EQ(object.size(), objectSize); + + pjson array; + array[0] = static_cast(1); + CHECK(!array.tryGet(1, integer)); + CHECK(!array.tryGet(-2, string)); + CHECK(!array.tryGet(0, view)); + CHECK_EQ(integer, int64_t(77)); + CHECK_EQ(string, std::string("keep")); + CHECK_EQ(view.data(), viewData); + CHECK_EQ(array.size(), size_t(1)); +} + +//===----------------------------------------------------------------------===// +// find()/hasKey()/keys()/size()/empty() stay non-mutating on read paths. +//===----------------------------------------------------------------------===// +TEST(api_find_haskey_on_non_object) { + pjson arr; + arr = std::vector(2, int64_t(0)); + CHECK(arr.find("k") == nullptr); + CHECK(!arr.hasKey("k")); + + pjson num; + num = static_cast(5); + CHECK(num.find("k") == nullptr); + const pjson& cnum = num; + CHECK(cnum.find("k") == nullptr); + CHECK(num.isInt()); +} + +TEST(api_keys_sorted_and_empty) { + pjson j; + j["z"] = static_cast(1); + j["a"] = static_cast(2); + j["m"] = static_cast(3); + std::vector k = j.keys(); + CHECK_EQ(k.size(), size_t(3)); + CHECK_EQ(k[0], std::string("a")); + CHECK_EQ(k[1], std::string("m")); + CHECK_EQ(k[2], std::string("z")); + + pjson::unique_ptr array = pjson::parse("[1,2]"); + pjson::unique_ptr scalar = pjson::parse("5"); + CHECK(array != nullptr); + CHECK(scalar != nullptr); + if (array) + CHECK(array->keys().empty()); + if (scalar) + CHECK(scalar->keys().empty()); +} + +TEST(api_size_empty_all_types) { + pjson::unique_ptr array = pjson::parse("[1,2,3]"); + pjson::unique_ptr object = pjson::parse(R"({"a":1,"b":2})"); + pjson::unique_ptr emptyArray = pjson::parse("[]"); + pjson::unique_ptr emptyObject = pjson::parse("{}"); + pjson::unique_ptr scalar = pjson::parse("5"); + pjson::unique_ptr string = pjson::parse("\"hello\""); + pjson::unique_ptr nullValue = pjson::parse("null"); + CHECK(array != nullptr); + CHECK(object != nullptr); + CHECK(emptyArray != nullptr); + CHECK(emptyObject != nullptr); + CHECK(scalar != nullptr); + CHECK(string != nullptr); + CHECK(nullValue != nullptr); + if (!array || !object || !emptyArray || !emptyObject || !scalar || !string || !nullValue) + return; + + CHECK_EQ(array->size(), size_t(3)); + CHECK_EQ(object->size(), size_t(2)); + CHECK_EQ(emptyArray->size(), size_t(0)); + CHECK_EQ(emptyObject->size(), size_t(0)); + CHECK_EQ(scalar->size(), size_t(0)); + CHECK_EQ(string->size(), size_t(0)); + CHECK(emptyArray->empty()); + CHECK(nullValue->empty()); + CHECK(!array->empty()); +} + +//===----------------------------------------------------------------------===// +// Serialization uses SerializeOptions rather than bool pretty flags. +//===----------------------------------------------------------------------===// +TEST(api_serialization_forms_agree) { + pjson j; + j["a"] = static_cast(1); + j["b"] = std::vector({2, 3}); + + pjson::SerializeOptions compact; + std::ostringstream compactOut; + j.write(compactOut, compact); + CHECK_EQ(compactOut.str(), j.toString(compact)); + + pjson::SerializeOptions pretty = pjson::SerializeOptions::prettyPrinted(); + std::ostringstream prettyOut; + j.write(prettyOut, pretty); + CHECK_EQ(prettyOut.str(), j.toString(pretty)); +} + +TEST(api_pretty_reparses_to_same_data) { + pjson::unique_ptr value = + pjson::parse(R"({ "nested": { "arr": [1, 2, {"x": true}] }, "s": "v" })"); + CHECK(value != nullptr); + if (!value) + return; + + pjson::SerializeOptions pretty = pjson::SerializeOptions::prettyPrinted(); + std::string text = value->toString(pretty); + pjson::unique_ptr rt = pjson::parse(text); + CHECK(rt != nullptr); + if (rt) + CHECK(*rt == *value); +} + +//===----------------------------------------------------------------------===// +// Stream parsing and byte-span parsing keep ownership/error semantics. +//===----------------------------------------------------------------------===// +TEST(api_parse_stream_success_and_failure) { + std::istringstream good(R"({ "k": [1,2,3] })"); + pjson::unique_ptr p = pjson::parseStream(good); + CHECK(p != nullptr); + if (p) { + const pjson* field = p->find("k"); + CHECK(field != nullptr); + if (field) + CHECK_EQ(field->size(), size_t(3)); + } + + std::istringstream bad("{not valid"); + pjson::ParseError err; + pjson::unique_ptr q = pjson::parseStream(bad, err); + CHECK(q == nullptr); + CHECK(!err.ok); + CHECK(!err.message.empty()); +} + +TEST(api_parse_ptr_size_edges) { + pjson::unique_ptr p = pjson::parse("12345xyz", 3); + CHECK(p != nullptr); + if (p) + CHECK_EQ(mustGetInt(*p), int64_t(123)); + + const char raw[] = {'"', 'a', '\0', 'b', '"'}; + CHECK(pjson::parse(raw, sizeof(raw)) == nullptr); + + CHECK(pjson::parse(nullptr, 5) == nullptr); + CHECK(pjson::parse("x", 0) == nullptr); +} + +TEST(api_parse_resource_budgets) { + const pjson::ParseOptions defaults; + CHECK_EQ(defaults.maxDepth, 512); + CHECK_EQ(defaults.maxNodes, size_t(1000000)); + CHECK_EQ(defaults.maxInputBytes, size_t(64) * 1024U * 1024U); + CHECK_EQ(defaults.duplicateKeys, pjson::ParseOptions::RejectDuplicateKeys); + + pjson::ParseOptions nodes; + nodes.maxNodes = 3; + pjson::ParseError err; + CHECK(pjson::parse("[1,2]", err, nodes) != nullptr); + CHECK(err.ok); + + CHECK(pjson::parse("[1,2,3]", err, nodes) == nullptr); + CHECK(!err.ok); + CHECK(err.message.find("node budget") != std::string::npos); + + pjson::ParseOptions bytes; + bytes.maxInputBytes = 4; + CHECK(pjson::parse("null", err, bytes) != nullptr); + CHECK(pjson::parse("false", err, bytes) == nullptr); + CHECK(!err.ok); + CHECK(err.message.find("maxInputBytes") != std::string::npos); + + std::istringstream oversizedStream("false"); + CHECK(pjson::parseStream(oversizedStream, err, bytes) == nullptr); + CHECK(!err.ok); + CHECK_EQ(err.offset, size_t(4)); + CHECK_EQ(err.line, size_t(1)); + CHECK_EQ(err.column, size_t(5)); +} + +//===----------------------------------------------------------------------===// +// Copy / move independence at depth and scale. +//===----------------------------------------------------------------------===// +TEST(api_deep_copy_independence) { + pjson a; + for (int64_t i = 0; i < 100; ++i) + a["arr"][static_cast(i)] = i; + a["nested"]["deep"]["leaf"] = std::string("orig"); + + pjson b = a; + b["arr"][50] = static_cast(999); + b["nested"]["deep"]["leaf"] = std::string("changed"); + + CHECK_EQ(mustGetInt(a["arr"][50]), int64_t(50)); + CHECK_EQ(mustGetString(a["nested"]["deep"]["leaf"]), std::string("orig")); + CHECK_EQ(mustGetInt(b["arr"][50]), int64_t(999)); +} + +TEST(api_move_leaves_source_null) { + pjson a; + a["k"] = std::vector({1, 2, 3}); + pjson b = std::move(a); + CHECK(a.isNull()); + CHECK(b.hasKey("k")); + + const pjson* k = b.find("k"); + CHECK(k != nullptr); + if (k) + CHECK_EQ(k->size(), size_t(3)); + + pjson c; + c["old"] = static_cast(1); + c = std::move(b); + CHECK(b.isNull()); + CHECK(c.hasKey("k")); +} + +//===----------------------------------------------------------------------===// +// Equality: cross-type numeric cases, including above-2^53 exactness. +//===----------------------------------------------------------------------===// +TEST(api_equality_rules) { + CHECK(*pjson::parse("1") == *pjson::parse("1.0")); + CHECK(*pjson::parse("1.5") == *pjson::parse("1.5")); + CHECK(*pjson::parse("[1,2]") != *pjson::parse("[2,1]")); + CHECK(*pjson::parse(R"({"a":1,"b":2})") == *pjson::parse(R"({"b":2,"a":1})")); + CHECK(*pjson::parse("true") != *pjson::parse("1")); + CHECK(*pjson::parse("null") == *pjson::parse("null")); + CHECK(*pjson::parse("\"\"") != *pjson::parse("null")); + CHECK(*pjson::parse("{}") != *pjson::parse("[]")); + + CHECK(*pjson::parse("9007199254740994") == *pjson::parse("9007199254740994.0")); + CHECK(*pjson::parse("9007199254740993") != *pjson::parse("9007199254740992.0")); +} + +//===----------------------------------------------------------------------===// +// resetTo produces valid empty values without raw container getters. +//===----------------------------------------------------------------------===// +TEST(api_reset_to_defaults) { + pjson j; + + j.resetTo(pjson::jsonString); + CHECK(j.isString()); + { + pjson::StringView view; + CHECK(j.tryGet(view)); + CHECK(view.empty()); + } + + j.resetTo(pjson::jsonNumberInt); + CHECK(j.isInt()); + CHECK_EQ(mustGetInt(j), int64_t(0)); + + j.resetTo(pjson::jsonNumberDouble); + CHECK(j.isDouble()); + CHECK_EQ(mustGetDouble(j), 0.0); + + j.resetTo(pjson::jsonBoolean); + CHECK(j.isBool()); + { + bool value = true; + CHECK(j.tryGet(value)); + CHECK_EQ(value, false); + } + + j.resetTo(pjson::jsonArray); + CHECK(j.isArray()); + CHECK(j.empty()); + CHECK(j.find(0) == nullptr); + + j.resetTo(pjson::jsonObject); + CHECK(j.isObject()); + CHECK(j.empty()); + CHECK(j.keys().empty()); +} + +TEST(api_extreme_builder_indexes_are_safe_and_preserve_state_on_failure) { + pjson emptyArray; + emptyArray.resetTo(pjson::jsonArray); + bool emptyArrayThrew = false; + try { + emptyArray[INT_MAX] = int64_t(1); + } catch (const std::length_error&) { + emptyArrayThrew = true; + } catch (const std::bad_alloc&) { + emptyArrayThrew = true; + } + CHECK(emptyArrayThrew); + CHECK(emptyArray.isArray()); + CHECK(emptyArray.empty()); + + pjson scalar; + scalar = std::string("keep"); + bool emptyThrew = false; + try { + scalar[INT_MAX] = int64_t(1); + } catch (const std::length_error&) { + emptyThrew = true; + } catch (const std::bad_alloc&) { + emptyThrew = true; + } + CHECK(emptyThrew); + CHECK(scalar.isString()); + CHECK_EQ(mustGetString(scalar), std::string("keep")); + + pjson array; + array[0] = int64_t(7); + const std::string before = array.toString(); + bool populatedThrew = false; + try { + array[INT_MAX] = int64_t(9); + } catch (const std::length_error&) { + populatedThrew = true; + } catch (const std::bad_alloc&) { + populatedThrew = true; + } + CHECK(populatedThrew); + CHECK_EQ(array.toString(), before); + + array[INT_MIN] = int64_t(11); + CHECK_EQ(array.size(), size_t(1)); + CHECK_EQ(mustGetInt(array[0]), int64_t(11)); + + pjson empty; + empty[INT_MIN] = int64_t(3); + CHECK(empty.isArray()); + CHECK_EQ(empty.size(), size_t(1)); + CHECK_EQ(mustGetInt(empty[0]), int64_t(3)); +} + +TEST(api_null_cstring_mutations_throw_and_preserve_prior_value) { + const char* nullString = nullptr; + + pjson assigned; + assigned["keep"] = int64_t(1); + const std::string assignedBefore = assigned.toString(); + bool assignThrew = false; + try { + assigned = nullString; + } catch (const std::invalid_argument&) { + assignThrew = true; + } + CHECK(assignThrew); + CHECK_EQ(assigned.toString(), assignedBefore); + + pjson appended; + appended = std::vector({1, 2}); + const std::string appendedBefore = appended.toString(); + bool appendThrew = false; + try { + appended += nullString; + } catch (const std::invalid_argument&) { + appendThrew = true; + } + CHECK(appendThrew); + CHECK_EQ(appended.toString(), appendedBefore); + + pjson indexed; + indexed["keep"] = int64_t(3); + const std::string indexedBefore = indexed.toString(); + bool indexThrew = false; + try { + (void)indexed[nullString]; + } catch (const std::invalid_argument&) { + indexThrew = true; + } + CHECK(indexThrew); + CHECK_EQ(indexed.toString(), indexedBefore); +} diff --git a/pjsontest/src/tests_build.cpp b/pjsontest/src/tests_build.cpp new file mode 100644 index 0000000..32de88c --- /dev/null +++ b/pjsontest/src/tests_build.cpp @@ -0,0 +1,347 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Building values programmatically: current operator= / operator+= overloads, +// auto-vivification through operator[], and strict lookup via find()/tryGet(). +//===----------------------------------------------------------------------===// +#include "pjson.h" +#include "test_harness.h" + +#include +#include + +using namespace ByteDance; + +namespace { + + void expectInt(const pjson& value, int64_t expected) { + int64_t actual = 0; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + + void expectDouble(const pjson& value, double expected) { + double actual = 0.0; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + + void expectBool(const pjson& value, bool expected) { + bool actual = !expected; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + + void expectString(const pjson& value, const std::string& expected) { + std::string actual = ""; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + +} // namespace + +TEST(assign_scalar_string) { + pjson j; + j = std::string("hi"); + CHECK_EQ(j.getType(), pjson::jsonString); + expectString(j, "hi"); +} + +TEST(assign_scalar_cstring) { + pjson j; + j = "world"; + CHECK_EQ(j.getType(), pjson::jsonString); + expectString(j, "world"); +} + +TEST(assign_scalar_bool) { + pjson j; + j = true; + CHECK_EQ(j.getType(), pjson::jsonBoolean); + expectBool(j, true); +} + +TEST(assign_scalar_int64) { + pjson j; + j = static_cast(9000000000LL); + CHECK_EQ(j.getType(), pjson::jsonNumberInt); + expectInt(j, int64_t(9000000000LL)); +} + +TEST(assign_scalar_double) { + pjson j; + j = double(2.5); + CHECK_EQ(j.getType(), pjson::jsonNumberDouble); + expectDouble(j, 2.5); +} + +TEST(assign_vector_string) { + pjson j; + j = std::vector({"a", "b", "c"}); + CHECK_EQ(j.getType(), pjson::jsonArray); + CHECK_EQ(j.size(), size_t(3)); + expectString(j[2], "c"); +} + +TEST(assign_vector_bool) { + pjson j; + j = std::vector({true, false, true}); + CHECK_EQ(j.size(), size_t(3)); + expectBool(j[0], true); + expectBool(j[1], false); + CHECK_EQ(j[0].getType(), pjson::jsonBoolean); +} + +TEST(assign_vector_int64) { + pjson j; + j = std::vector({10000000000LL, 20000000000LL}); + CHECK_EQ(j.size(), size_t(2)); + CHECK_EQ(j[0].getType(), pjson::jsonNumberInt); + expectInt(j[0], int64_t(10000000000LL)); +} + +TEST(assign_vector_double) { + pjson j; + j = std::vector({1.25, 2.75}); + CHECK_EQ(j[0].getType(), pjson::jsonNumberDouble); + expectDouble(j[0], 1.25); + expectDouble(j[1], 2.75); +} + +TEST(append_scalars_of_each_type) { + pjson j; + j += std::string("s"); + j += "c"; + j += true; + j += static_cast(8); + j += double(2.5); + CHECK_EQ(j.getType(), pjson::jsonArray); + CHECK_EQ(j.size(), size_t(5)); + expectString(j[0], "s"); + expectString(j[1], "c"); + expectBool(j[2], true); + expectInt(j[3], int64_t(8)); + expectDouble(j[4], 2.5); +} + +TEST(append_vectors_of_each_type) { + pjson j; + j += std::vector({"a", "b"}); + j += std::vector({true}); + j += std::vector({1, 2, 3}); + j += std::vector({4.5, 5.5}); + CHECK_EQ(j.size(), size_t(8)); + expectString(j[0], "a"); + expectBool(j[2], true); + expectInt(j[5], int64_t(3)); + expectDouble(j[7], 5.5); +} + +TEST(append_then_append_accumulates) { + pjson j; + j += static_cast(1); + j += static_cast(2); + j += std::vector({3, 4}); + CHECK_EQ(j.size(), size_t(4)); + expectInt(j[3], int64_t(4)); +} + +TEST(map_build_and_lookup) { + pjson j; + j["one"] = static_cast(1); + j["two"] = static_cast(2); + std::string k = "three"; + j[k] = static_cast(3); + j["four"] = static_cast(4); + CHECK_EQ(j.getType(), pjson::jsonObject); + CHECK_EQ(j.size(), size_t(4)); + CHECK(j.hasKey("one")); + CHECK(j.hasKey(std::string("three"))); + CHECK(j.hasKey("four")); + CHECK(!j.hasKey("missing")); +} + +TEST(map_reassign_same_key_overwrites) { + pjson j; + j["k"] = static_cast(1); + j["k"] = std::string("replaced"); + CHECK_EQ(j.size(), size_t(1)); + expectString(j["k"], "replaced"); +} + +TEST(nested_map_and_array_build) { + pjson j; + j["a"]["b"]["c"] = static_cast(9); + j["list"][0] = static_cast(10); + j["list"][2] = static_cast(30); + expectInt(j["a"]["b"]["c"], int64_t(9)); + CHECK_EQ(j["list"].size(), size_t(3)); + CHECK_EQ(j["list"][1].getType(), pjson::jsonNull); + expectInt(j["list"][2], int64_t(30)); +} + +TEST(array_grows_with_nulls) { + pjson j; + j[5] = std::string("sixth"); + CHECK_EQ(j.getType(), pjson::jsonArray); + CHECK_EQ(j.size(), size_t(6)); + for (int i = 0; i < 5; ++i) { + CHECK_EQ(j[i].getType(), pjson::jsonNull); + } + expectString(j[5], "sixth"); +} + +TEST(negative_index_from_end) { + pjson arr; + arr[0] = static_cast(10); + arr[1] = static_cast(20); + arr[2] = static_cast(30); + expectInt(arr[-1], int64_t(30)); + expectInt(arr[-2], int64_t(20)); + expectInt(arr[-3], int64_t(10)); +} + +TEST(negative_index_past_start_clamps) { + pjson arr; + arr[0] = static_cast(10); + arr[1] = static_cast(20); + arr[2] = static_cast(30); + expectInt(arr[-4], int64_t(10)); + expectInt(arr[-100], int64_t(10)); +} + +TEST(negative_index_on_empty_array) { + pjson arr; + arr.resetTo(pjson::jsonArray); + pjson& element = arr[-1]; + CHECK_EQ(element.getType(), pjson::jsonNull); + CHECK_EQ(arr.size(), size_t(1)); +} + +TEST(find_returns_pointer_or_null) { + pjson j; + j["a"] = static_cast(1); + CHECK(j.find("a") != nullptr); + CHECK(j.find(std::string("a")) != nullptr); + CHECK(j.find("missing") == nullptr); + if (const pjson* value = j.find("a")) + expectInt(*value, int64_t(1)); +} + +TEST(find_does_not_vivify) { + pjson j; + j["a"] = static_cast(1); + CHECK(j.find("ghost") == nullptr); + CHECK(!j.hasKey("ghost")); + CHECK_EQ(j.size(), size_t(1)); +} + +TEST(find_on_non_map_returns_null) { + pjson j; + j = static_cast(5); + CHECK(j.find("a") == nullptr); + const pjson& cj = j; + CHECK(cj.find("a") == nullptr); +} + +TEST(const_find_works) { + pjson j; + j["k"] = std::string("v"); + const pjson& cj = j; + const pjson* p = cj.find("k"); + CHECK(p != nullptr); + if (p != nullptr) + expectString(*p, "v"); +} + +TEST(strict_tryget_by_key_and_index) { + pjson j; + j["n"] = static_cast(77); + j["d"] = double(2.5); + j["b"] = true; + j["s"] = std::string("hi"); + j["a"] = std::vector({10, 20, 30}); + + int64_t intOut = -1; + double doubleOut = 0.0; + bool boolOut = false; + std::string stringOut = "orig"; + + CHECK(j.tryGet("n", intOut)); + CHECK_EQ(intOut, int64_t(77)); + CHECK(j.tryGet("d", doubleOut)); + CHECK_EQ(doubleOut, 2.5); + CHECK(j.tryGet("b", boolOut)); + CHECK_EQ(boolOut, true); + CHECK(j.tryGet("s", stringOut)); + CHECK_EQ(stringOut, std::string("hi")); + CHECK(j["a"].tryGet(1, intOut)); + CHECK_EQ(intOut, int64_t(20)); +} + +TEST(strict_tryget_preserves_outputs_on_failure) { + pjson j; + j["n"] = static_cast(1); + j["s"] = std::string("text"); + + int64_t intOut = 42; + bool boolOut = true; + std::string stringOut = "keep"; + + CHECK(!j.tryGet("missing", intOut)); + CHECK_EQ(intOut, int64_t(42)); + CHECK(!j.tryGet("s", intOut)); + CHECK_EQ(intOut, int64_t(42)); + CHECK(!j.tryGet("n", boolOut)); + CHECK_EQ(boolOut, true); + CHECK(!j.tryGet(0, stringOut)); + CHECK_EQ(stringOut, std::string("keep")); +} + +TEST(find_index_is_non_mutating) { + pjson j; + j[0] = static_cast(10); + j[1] = static_cast(20); + CHECK(j.find(0) != nullptr); + CHECK(j.find(2) == nullptr); + CHECK(j.find(-1) != nullptr); + CHECK(j.find(-3) == nullptr); + CHECK_EQ(j.size(), size_t(2)); +} + +TEST(keys_are_sorted_and_read_only_iteration_uses_find) { + pjson j; + j["b"] = static_cast(2); + j["a"] = static_cast(1); + j["c"] = static_cast(3); + + const std::vector keys = j.keys(); + CHECK_EQ(keys.size(), size_t(3)); + CHECK_EQ(keys[0], std::string("a")); + CHECK_EQ(keys[1], std::string("b")); + CHECK_EQ(keys[2], std::string("c")); + + int64_t sum = 0; + for (size_t i = 0; i < keys.size(); ++i) { + const pjson* value = j.find(keys[i]); + CHECK(value != nullptr); + if (value != nullptr) { + int64_t element = 0; + CHECK(value->tryGet(element)); + sum += element; + } + } + CHECK_EQ(sum, int64_t(6)); +} diff --git a/pjsontest/src/tests_conformance.cpp b/pjsontest/src/tests_conformance.cpp new file mode 100644 index 0000000..72c5a90 --- /dev/null +++ b/pjsontest/src/tests_conformance.cpp @@ -0,0 +1,438 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// RFC 8259 conformance coverage: +// - a curated inline accept/reject corpus for RFC 8259 parsing +// - optional runtime execution of nst/JSONTestSuite if a corpus directory is +// configured or fetched locally +// +#include "pjson.h" +#include "test_harness.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#include +#include +#endif + +using namespace ByteDance; + +#ifndef PJSON_TEST_DEFAULT_JSONTESTSUITE_DIR +#define PJSON_TEST_DEFAULT_JSONTESTSUITE_DIR "" +#endif + +namespace { + + // A named corpus entry and the parser outcome it requires. + struct Expectation { + const char* name; + std::string document; + bool shouldParse; + }; + + // Uses unbounded size/node budgets so the conformance corpus measures grammar rather than + // deployment limits; the production recursion guard remains active for stack safety. + pjson::ParseOptions conformanceOptions() { + pjson::ParseOptions opts; + // Keep the production recursion guard. Some implementation-defined + // corpus files intentionally contain extreme nesting; they are skipped + // below, while y_/n_ files remain bounded by the safe default. + opts.maxDepth = 512; + opts.maxNodes = 0; + opts.maxInputBytes = 0; + // RFC 8259 says object names SHOULD be unique but does not make + // duplicates a grammar error. Use keep-last for the external syntax + // corpus while the public default policy rejects duplicates. + opts.duplicateKeys = pjson::ParseOptions::KeepLastDuplicate; + return opts; + } + + // Constructs byte-exact inputs that cannot safely be expressed as ordinary literals. + std::string bytes(const char* data, size_t size) { + return std::string(data, size); + } + + // Performs one table-driven grammar assertion with the case name in failure diagnostics. + void expectConformanceParse(const Expectation& tc) { + ::pjson_test::current().checks += 1; + + pjson::ParseError err; + pjson::unique_ptr parsed = pjson::parse(tc.document, err, conformanceOptions()); + + if (tc.shouldParse) { + if (parsed == nullptr) { + std::ostringstream detail; + detail << tc.name << " rejected"; + if (!err.message.empty()) { + detail << " at byte " << err.offset << ": " << err.message; + } else { + detail << " at byte " << err.offset; + } + ::pjson_test::report_failure(__FILE__, __LINE__, "conformance accept", + detail.str()); + } + return; + } + + if (parsed != nullptr) { + std::ostringstream detail; + detail << tc.name << " unexpectedly parsed as " << parsed->toString(); + ::pjson_test::report_failure(__FILE__, __LINE__, "conformance reject", detail.str()); + } + } + + // Cross-platform corpus discovery helpers. + + bool hasJsonExtension(const std::string& path) { + return path.size() >= 5 && path.substr(path.size() - 5) == ".json"; + } + + std::string joinPath(const std::string& base, const std::string& leaf) { + if (base.empty()) { + return leaf; + } + + const char last = base[base.size() - 1]; + if (last == '/' || last == '\\') { + return base + leaf; + } + +#if defined(_WIN32) + return base + "\\" + leaf; +#else + return base + "/" + leaf; +#endif + } + + std::string baseName(const std::string& path) { + const std::string::size_type slash = path.find_last_of("/\\"); + if (slash == std::string::npos) { + return path; + } + return path.substr(slash + 1); + } + + bool isDirectory(const std::string& path) { + if (path.empty()) { + return false; + } + +#if defined(_WIN32) + const DWORD attrs = GetFileAttributesA(path.c_str()); + return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY) != 0; +#else + struct stat st; + return ::stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode); +#endif + } + + std::vector listJsonFiles(const std::string& dirPath) { + std::vector files; + +#if defined(_WIN32) + WIN32_FIND_DATAA entry; + HANDLE find = FindFirstFileA(joinPath(dirPath, "*.json").c_str(), &entry); + if (find == INVALID_HANDLE_VALUE) { + return files; + } + + do { + if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) { + files.push_back(joinPath(dirPath, entry.cFileName)); + } + } while (FindNextFileA(find, &entry) != 0); + + FindClose(find); +#else + DIR* dir = ::opendir(dirPath.c_str()); + if (dir == NULL) { + return files; + } + + while (struct dirent* entry = ::readdir(dir)) { + const std::string name(entry->d_name); + if (name == "." || name == "..") { + continue; + } + + const std::string path = joinPath(dirPath, name); + if (hasJsonExtension(name)) { + files.push_back(path); + } + } + + ::closedir(dir); +#endif + + std::sort(files.begin(), files.end()); + return files; + } + + std::string readFile(const std::string& path) { + std::ifstream in(path.c_str(), std::ios::binary); + if (!in) { + return std::string(); + } + + return std::string((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + } + + std::string configuredJsonTestSuiteDir() { + const char* env = std::getenv("PJSON_JSONTESTSUITE_DIR"); + if (env != NULL && env[0] != '\0') { + return std::string(env); + } + return std::string(PJSON_TEST_DEFAULT_JSONTESTSUITE_DIR); + } + + std::string resolveJsonTestSuiteParsingDir() { + const std::string configured = configuredJsonTestSuiteDir(); + if (configured.empty()) { + return std::string(); + } + + if (baseName(configured) == "test_parsing" && isDirectory(configured)) { + return configured; + } + + const std::string nested = joinPath(configured, "test_parsing"); + if (isDirectory(nested)) { + return nested; + } + + if (isDirectory(configured)) { + return configured; + } + + return std::string(); + } + + // Applies the y_/n_ filename contract used by nst/JSONTestSuite. + void runJsonTestSuiteExpectation(const std::string& path, bool shouldParse) { + const std::string payload = readFile(path); + ::pjson_test::current().checks += 1; + + pjson::ParseError err; + pjson::unique_ptr parsed = pjson::parse(payload, err, conformanceOptions()); + + if (shouldParse && parsed == nullptr) { + std::ostringstream detail; + detail << baseName(path) << " rejected"; + if (!err.message.empty()) { + detail << " at byte " << err.offset << ": " << err.message; + } else { + detail << " at byte " << err.offset; + } + ::pjson_test::report_failure(__FILE__, __LINE__, "JSONTestSuite y_ case", detail.str()); + return; + } + + if (!shouldParse && parsed != nullptr) { + std::ostringstream detail; + detail << baseName(path) << " unexpectedly parsed"; + ::pjson_test::report_failure(__FILE__, __LINE__, "JSONTestSuite n_ case", detail.str()); + } + } + +} // namespace + +//===----------------------------------------------------------------------===// +// Curated RFC 8259 grammar matrix +//===----------------------------------------------------------------------===// + +TEST(conformance_rfc8259_inline_accepts) { + const char escapedSolidus[] = {'"', '\\', '/', '"'}; + const char escapedReverseSolidus[] = {'"', 's', 'l', 'a', 's', 'h', ':', ' ', '\\', '\\', '"'}; + const char escapedControls[] = {'"', '\\', 'b', '\\', 'f', '\\', + 'n', '\\', 'r', '\\', 't', '"'}; + const char escapedUnicode[] = {'"', '\\', 'u', '0', '0', '4', '1', '"'}; + const char surrogatePair[] = {'"', '\\', 'u', 'D', '8', '3', '4', + '\\', 'u', 'D', 'D', '1', 'E', '"'}; + + const Expectation cases[] = { + {"null literal", "null", true}, + {"true literal", "true", true}, + {"false literal", "false", true}, + {"integer zero", "0", true}, + {"negative zero", "-0", true}, + {"positive integer", "1234567890", true}, + {"negative integer", "-987654321", true}, + {"fraction", "3.1415", true}, + {"exponent", "6.022e23", true}, + {"uppercase exponent", "-2E-3", true}, + {"string empty", "\"\"", true}, + {"string ascii", "\"hello\"", true}, + {"string escaped quote", "\"quote: \\\"\"", true}, + {"string escaped reverse solidus", + bytes(escapedReverseSolidus, sizeof(escapedReverseSolidus)), true}, + {"string escaped solidus", bytes(escapedSolidus, sizeof(escapedSolidus)), true}, + {"string escaped controls", bytes(escapedControls, sizeof(escapedControls)), true}, + {"string unicode hex", bytes(escapedUnicode, sizeof(escapedUnicode)), true}, + {"string surrogate pair", bytes(surrogatePair, sizeof(surrogatePair)), true}, + {"array empty", "[]", true}, + {"array mixed", "[null,true,false,0,-1,1.5,\"x\",[],{}]", true}, + {"array whitespace", "[ 1 , 2 , 3 ]", true}, + {"object empty", "{}", true}, + {"object simple", "{\"a\":1,\"b\":2}", true}, + {"object nested", "{\"a\":[1,{\"b\":true},null],\"c\":{\"d\":\"x\"}}", true}, + {"document surrounding whitespace", " \t\r\n {\"ok\":true} \n", true}, + {"top level string", "\"json\"", true}, + {"top level array with nested objects", "[{\"a\":1},{\"b\":[2,3]}]", true}, + {"top level object with escaped unicode", "{\"snowman\":\"\\u2603\"}", true}, + }; + + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + expectConformanceParse(cases[i]); + } +} + +TEST(conformance_rfc8259_inline_rejects) { + const char rawNewline[] = {'"', 'a', '\n', 'b', '"'}; + const char badEscape[] = {'"', '\\', 'x', '4', '1', '"'}; + const char loneHighSurrogate[] = {'"', '\\', 'u', 'D', '8', '0', '0', '"'}; + const char loneLowSurrogate[] = {'"', '\\', 'u', 'D', 'C', '0', '0', '"'}; + const char invalidUtf8[] = {'"', (char)0xC3, (char)0x28, '"'}; + const char bomPrefix[] = {(char)0xEF, (char)0xBB, (char)0xBF, '{', '}', '\n'}; + + const Expectation cases[] = { + {"empty input", "", false}, + {"whitespace only", " \r\n\t ", false}, + {"single quote string", "'json'", false}, + {"unquoted key", "{a:1}", false}, + {"missing colon", "{\"a\" 1}", false}, + {"missing comma in object", "{\"a\":1 \"b\":2}", false}, + {"trailing comma object", "{\"a\":1,}", false}, + {"leading comma object", "{,\"a\":1}", false}, + {"double comma object", "{\"a\":1,,\"b\":2}", false}, + {"missing value object", "{\"a\":}", false}, + {"non string key", "{true:1}", false}, + {"unterminated object", "{\"a\":1", false}, + {"missing comma in array", "[1 2]", false}, + {"trailing comma array", "[1,2,]", false}, + {"leading comma array", "[,1]", false}, + {"double comma array", "[1,,2]", false}, + {"unterminated array", "[1,2", false}, + {"plus sign number", "+1", false}, + {"leading zero integer", "01", false}, + {"negative leading zero integer", "-01", false}, + {"bare decimal point prefix", ".1", false}, + {"bare decimal point suffix", "1.", false}, + {"missing exponent digits", "1e", false}, + {"missing signed exponent digits", "1e+", false}, + {"hex number", "0x10", false}, + {"nan literal", "NaN", false}, + {"infinity literal", "Infinity", false}, + {"uppercase null", "NULL", false}, + {"mixed case true", "True", false}, + {"unknown keyword", "undefined", false}, + {"raw control character in string", bytes(rawNewline, sizeof(rawNewline)), false}, + {"unknown escape", bytes(badEscape, sizeof(badEscape)), false}, + {"lone high surrogate", bytes(loneHighSurrogate, sizeof(loneHighSurrogate)), false}, + {"lone low surrogate", bytes(loneLowSurrogate, sizeof(loneLowSurrogate)), false}, + {"invalid utf8 inside string", bytes(invalidUtf8, sizeof(invalidUtf8)), false}, + {"utf8 bom prefix", bytes(bomPrefix, sizeof(bomPrefix)), false}, + {"trailing garbage", "{\"a\":1} trailing", false}, + {"two top level values", "true false", false}, + {"comment syntax", "{\"a\":1//comment\n}", false}, + }; + + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + expectConformanceParse(cases[i]); + } +} + +//===----------------------------------------------------------------------===// +// Optional nst/JSONTestSuite integration +//===----------------------------------------------------------------------===// + +TEST(conformance_json_test_suite_optional) { + const std::string parsingDir = resolveJsonTestSuiteParsingDir(); + if (parsingDir.empty()) { + std::printf(" INFO JSONTestSuite skipped; run scripts/fetch-json-test-suite.sh " + "(PJSON_JSONTESTSUITE_DIR is an optional override)\n"); + CHECK(true); + return; + } + + const std::vector files = listJsonFiles(parsingDir); + if (files.empty()) { + std::printf(" INFO JSONTestSuite skipped; no .json files under %s\n", + parsingDir.c_str()); + CHECK(true); + return; + } + + size_t ran = 0; + size_t implementationDefined = 0; + size_t ignored = 0; + for (size_t i = 0; i < files.size(); ++i) { + const std::string name = baseName(files[i]); + if (name.size() < 3 || name[1] != '_') { + ignored += 1; + continue; + } + + if (name[0] == 'y') { + runJsonTestSuiteExpectation(files[i], true); + ran += 1; + continue; + } + + if (name[0] == 'n') { + runJsonTestSuiteExpectation(files[i], false); + ran += 1; + continue; + } + + if (name[0] == 'i') { + // RFC 8259 leaves these cases implementation-defined. Exercise + // every one and require deterministic behavior: if accepted, the + // normalized output must itself be strict JSON and round-trip. + const std::string payload = readFile(files[i]); + pjson::unique_ptr parsed = pjson::parse(payload, conformanceOptions()); + CHECK(true); // parsing the corpus entry terminated safely + if (parsed) { + const std::string normalized = parsed->toString(); + CHECK(pjson::parse(normalized, conformanceOptions()) != nullptr); + } + implementationDefined += 1; + continue; + } + + ignored += 1; + } + + std::printf(" INFO JSONTestSuite ran %llu required + %llu implementation-defined " + "files from %s (%llu other " + "entries ignored)\n", + static_cast(ran), + static_cast(implementationDefined), parsingDir.c_str(), + static_cast(ignored)); + + if (ran == 0) { + CHECK(true); + } +} diff --git a/pjsontest/src/tests_core.cpp b/pjsontest/src/tests_core.cpp new file mode 100644 index 0000000..ed06380 --- /dev/null +++ b/pjsontest/src/tests_core.cpp @@ -0,0 +1,327 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Core semantics: type tags, strict typed access, reset/resetTo, and copy/move +// construction & assignment. +// +#include "pjson.h" +#include "test_harness.h" +#include +#include +#include + +using namespace ByteDance; + +namespace { + + void expectInt(const pjson& value, int64_t expected) { + int64_t actual = 0; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + + void expectDouble(const pjson& value, double expected) { + double actual = 0.0; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + + void expectBool(const pjson& value, bool expected) { + bool actual = !expected; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + + void expectString(const pjson& value, const std::string& expected) { + std::string actual = ""; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + + void expectStrictMismatch(const pjson& value) { + int64_t intValue = 17; + double doubleValue = 17.0; + bool boolValue = true; + std::string stringValue = "keep"; + CHECK(!value.tryGet(intValue)); + CHECK_EQ(intValue, int64_t(17)); + CHECK(!value.tryGet(doubleValue)); + CHECK_EQ(doubleValue, 17.0); + CHECK(!value.tryGet(boolValue)); + CHECK_EQ(boolValue, true); + CHECK(!value.tryGet(stringValue)); + CHECK_EQ(stringValue, std::string("keep")); + } + +} // namespace + +TEST(type_tags_for_every_kind) { + pjson jnull; + CHECK_EQ(jnull.getType(), pjson::jsonNull); + + pjson js; + js = std::string("s"); + CHECK_EQ(js.getType(), pjson::jsonString); + + pjson ji; + ji = static_cast(7); + CHECK_EQ(ji.getType(), pjson::jsonNumberInt); + + pjson jd; + jd = double(1.5); + CHECK_EQ(jd.getType(), pjson::jsonNumberDouble); + + pjson jb; + jb = true; + CHECK_EQ(jb.getType(), pjson::jsonBoolean); + + pjson ja; + ja = std::vector({1, 2}); + CHECK_EQ(ja.getType(), pjson::jsonArray); + + pjson jm; + jm["k"] = static_cast(1); + CHECK_EQ(jm.getType(), pjson::jsonObject); +} + +TEST(strict_tryget_matches_public_contract) { + pjson integerValue; + integerValue = static_cast(42); + expectInt(integerValue, int64_t(42)); + expectDouble(integerValue, 42.0); + { + bool boolOut = false; + CHECK(!integerValue.tryGet(boolOut)); + CHECK_EQ(boolOut, false); + } + { + std::string stringOut = "unchanged"; + CHECK(!integerValue.tryGet(stringOut)); + CHECK_EQ(stringOut, std::string("unchanged")); + } + + pjson doubleValue; + doubleValue = double(3.9); + expectDouble(doubleValue, 3.9); + { + int64_t intOut = -1; + CHECK(!doubleValue.tryGet(intOut)); + CHECK_EQ(intOut, int64_t(-1)); + } + + pjson stringValue; + stringValue = std::string("hello"); + expectString(stringValue, "hello"); + pjson::StringView view; + CHECK(stringValue.tryGet(view)); + CHECK_EQ(std::string(view.data(), view.size()), std::string("hello")); + { + int64_t intOut = 17; + double doubleOut = 17.0; + bool boolOut = true; + CHECK(!stringValue.tryGet(intOut)); + CHECK_EQ(intOut, int64_t(17)); + CHECK(!stringValue.tryGet(doubleOut)); + CHECK_EQ(doubleOut, 17.0); + CHECK(!stringValue.tryGet(boolOut)); + CHECK_EQ(boolOut, true); + } + + pjson boolValue; + boolValue = true; + expectBool(boolValue, true); + { + int64_t intOut = 17; + double doubleOut = 17.0; + std::string stringOut = "keep"; + CHECK(!boolValue.tryGet(intOut)); + CHECK_EQ(intOut, int64_t(17)); + CHECK(!boolValue.tryGet(doubleOut)); + CHECK_EQ(doubleOut, 17.0); + CHECK(!boolValue.tryGet(stringOut)); + CHECK_EQ(stringOut, std::string("keep")); + } + + pjson nullValue; + expectStrictMismatch(nullValue); +} + +TEST(int_vs_double_type_is_preserved) { + pjson i; + i = static_cast(5); + pjson d; + d = double(5.0); + CHECK_EQ(i.getType(), pjson::jsonNumberInt); + CHECK_EQ(d.getType(), pjson::jsonNumberDouble); + CHECK_EQ(i.toString(), std::string("5")); + CHECK_EQ(d.toString(), std::string("5.0")); +} + +TEST(reset_returns_to_null) { + pjson j; + j["a"] = static_cast(1); + j["b"] = static_cast(2); + CHECK_EQ(j.getType(), pjson::jsonObject); + j.reset(); + CHECK_EQ(j.getType(), pjson::jsonNull); + CHECK_EQ(j.size(), size_t(0)); + CHECK(!j.hasKey("a")); +} + +TEST(reset_to_each_type_has_zero_default) { + pjson j; + + j.resetTo(pjson::jsonNumberInt); + expectInt(j, int64_t(0)); + + j.resetTo(pjson::jsonNumberDouble); + expectDouble(j, 0.0); + + j.resetTo(pjson::jsonBoolean); + expectBool(j, false); + + j.resetTo(pjson::jsonString); + expectString(j, ""); + + j.resetTo(pjson::jsonArray); + CHECK(j.isArray()); + CHECK_EQ(j.size(), size_t(0)); + + j.resetTo(pjson::jsonObject); + CHECK(j.isObject()); + CHECK_EQ(j.size(), size_t(0)); + + j.resetTo(pjson::jsonNull); + CHECK_EQ(j.getType(), pjson::jsonNull); +} + +TEST(reset_to_invalid_enum_throws_and_preserves_old_value) { + pjson j; + j["keep"]["nested"] = std::string("value"); + j["count"] = static_cast(2); + const std::string before = j.toString(); + + bool threw = false; + try { + j.resetTo(static_cast(999)); + } catch (const std::invalid_argument&) { + threw = true; + } + + CHECK(threw); + CHECK_EQ(j.toString(), before); + CHECK(j.isObject()); + expectString(j["keep"]["nested"], "value"); + expectInt(j["count"], int64_t(2)); +} + +TEST(reassignment_changes_type_and_frees_old) { + pjson j; + j["a"] = static_cast(1); + CHECK_EQ(j.getType(), pjson::jsonObject); + + j = std::string("now a string"); + CHECK_EQ(j.getType(), pjson::jsonString); + expectString(j, "now a string"); + + j = std::vector({1, 2, 3}); + CHECK_EQ(j.getType(), pjson::jsonArray); + CHECK_EQ(j.size(), size_t(3)); +} + +TEST(copy_construct_is_deep_and_independent) { + pjson a; + a["name"] = std::string("original"); + a["nums"] = std::vector({1, 2, 3}); + a["nested"]["deep"] = static_cast(9); + + pjson b(a); + CHECK_EQ(b.toString(), a.toString()); + + b["name"] = std::string("changed"); + b["nested"]["deep"] = static_cast(100); + expectString(a["name"], "original"); + expectInt(a["nested"]["deep"], int64_t(9)); + expectString(b["name"], "changed"); + expectInt(b["nested"]["deep"], int64_t(100)); +} + +TEST(copy_assign_is_deep) { + pjson a; + a["x"] = std::vector({"p", "q"}); + pjson b; + b = static_cast(12345); + b = a; + CHECK_EQ(b.toString(), a.toString()); + b["x"][0] = std::string("z"); + expectString(a["x"][0], "p"); +} + +TEST(self_assignment_is_safe) { + pjson a; + a["k"] = std::string("v"); + pjson& ref = a; + a = ref; + expectString(a["k"], "v"); + + pjson& mref = a; + a = std::move(mref); + expectString(a["k"], "v"); +} + +TEST(child_assignment_is_safe) { + pjson j; + j["a"]["b"] = static_cast(42); + j = j["a"]; + CHECK(j.hasKey("b")); + expectInt(j["b"], int64_t(42)); +} + +TEST(move_construct_transfers) { + pjson a; + a["k"] = std::vector({1, 2, 3}); + const std::string before = a.toString(); + pjson b(std::move(a)); + CHECK_EQ(b.toString(), before); + CHECK_EQ(a.getType(), pjson::jsonNull); +} + +TEST(move_assign_transfers) { + pjson a; + a["k"] = std::string("value"); + const std::string before = a.toString(); + pjson b; + b["old"] = static_cast(1); + b = std::move(a); + CHECK_EQ(b.toString(), before); + CHECK_EQ(a.getType(), pjson::jsonNull); +} + +TEST(copyfrom_deep_copies) { + pjson a; + a["arr"] = std::vector({1.5, 2.5}); + pjson b; + b.copyFrom(a); + CHECK_EQ(b.toString(), a.toString()); + b["arr"][0] = double(9.9); + expectDouble(a["arr"][0], 1.5); +} + +TEST(unique_ptr_owns_ordinary_root_values) { + pjson::unique_ptr owned(new pjson()); + CHECK(owned != nullptr); + (*owned)["value"] = static_cast(1); + expectInt((*owned)["value"], int64_t(1)); +} diff --git a/pjsontest/src/tests_features.cpp b/pjsontest/src/tests_features.cpp new file mode 100644 index 0000000..fe7be85 --- /dev/null +++ b/pjsontest/src/tests_features.cpp @@ -0,0 +1,527 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Tests for higher-level library features on the settled surface: depth/resource +// guards, strict parse mode, pjson::unique_ptr ownership, equality, container +// behavior, erase, and stream I/O. +// +#include "pjson.h" +#include "test_harness.h" +#include "test_util.h" + +#include +#include +#include + +using namespace ByteDance; +using pjson_test::parse; + +namespace { + int64_t mustGetInt(const pjson& value) { + int64_t out = 0; + CHECK(value.tryGet(out)); + return out; + } + + double mustGetDouble(const pjson& value) { + double out = 0.0; + CHECK(value.tryGet(out)); + return out; + } + + std::string mustGetString(const pjson& value) { + std::string out; + CHECK(value.tryGet(out)); + return out; + } +} // namespace + +//===----------------------------------------------------------------------===// +// Library version. +//===----------------------------------------------------------------------===// +TEST(version_string) { + CHECK_EQ(std::string(pjson::getVersion()), std::string("1.0.0")); + CHECK_EQ(std::string(PJSON_VERSION), std::string("1.0.0")); + CHECK_EQ(PJSON_VERSION_MAJOR, 1); + CHECK_EQ(PJSON_VERSION_MINOR, 0); + CHECK_EQ(PJSON_VERSION_PATCH, 0); +} + +//===----------------------------------------------------------------------===// +// Recursion depth guard: deeply nested input fails cleanly instead of +// overflowing the stack. +//===----------------------------------------------------------------------===// +TEST(depth_guard_rejects_deep_nesting) { + // Well past the default maxDepth of 512, but the guard must reject it + // (return null) rather than overflow the stack. + const int depth = 100000; + std::string s(depth, '['); + s += std::string(depth, ']'); + CHECK(pjson::parse(s) == nullptr); +} + +TEST(depth_guard_allows_reasonable_nesting) { + // Comfortably under the default limit. + const int depth = 100; + std::string s(depth, '['); + s += std::string(depth, ']'); + auto p = parse(s); + CHECK(p != nullptr); +} + +TEST(depth_guard_boundary_is_configurable) { + // maxDepth counts array/object frames. With maxDepth = 3, three nested + // arrays are OK but four are not. + pjson::ParseOptions opt; + opt.maxDepth = 3; + CHECK(pjson::parse("[[[1]]]", opt) != nullptr); + CHECK(pjson::parse("[[[[1]]]]", opt) == nullptr); +} + +//===----------------------------------------------------------------------===// +// Out-of-range numbers are rejected rather than stored as infinity (which +// would otherwise serialize back to a misleading "null"). +//===----------------------------------------------------------------------===// +TEST(number_overflow_rejected) { + CHECK(parse("1e400") == nullptr); + CHECK(parse("-1e400") == nullptr); + CHECK(parse("[1e400]") == nullptr); +} + +TEST(number_underflow_is_zero) { + // Underflow to 0.0 is fine and finite. + auto p = parse("1e-400"); + CHECK(p != nullptr); + if (p) + CHECK_EQ(mustGetDouble(*p), 0.0); +} + +TEST(huge_but_finite_number_ok) { + auto p = parse("1e308"); // within double range + CHECK(p != nullptr); +} + +//===----------------------------------------------------------------------===// +// Parsing always enforces RFC 8259 syntax. +//===----------------------------------------------------------------------===// +TEST(strict_rejects_raw_control_char) { + const char raw[] = {'"', 'a', '\n', 'b', '"'}; + CHECK(pjson::parse(raw, sizeof(raw)) == nullptr); +} + +TEST(strict_rejects_unknown_escape) { + CHECK(pjson::parse("\"a\\qb\"") == nullptr); +} + +TEST(strict_rejects_lone_surrogate) { + CHECK(pjson::parse("\"\\uD800\"") == nullptr); +} + +TEST(strict_accepts_valid_surrogate_pair) { + CHECK(pjson::parse("\"\\uD83D\\uDE00\"") != nullptr); +} + +TEST(strict_rejects_uppercase_keywords) { + CHECK(pjson::parse("NULL") == nullptr); + CHECK(pjson::parse("True") == nullptr); + CHECK(pjson::parse("null") != nullptr); + CHECK(pjson::parse("true") != nullptr); + CHECK(pjson::parse("false") != nullptr); +} + +TEST(strict_rejects_invalid_utf8) { + // 0xFF is never valid UTF-8. + const char bad[] = {'"', static_cast(0xFF), '"'}; + CHECK(pjson::parse(bad, sizeof(bad)) == nullptr); +} + +TEST(strict_accepts_valid_utf8) { + // "é" as UTF-8 (0xC3 0xA9) between quotes. + const char good[] = {'"', static_cast(0xC3), static_cast(0xA9), '"'}; + auto p = pjson::parse(good, sizeof(good)); + CHECK(p != nullptr); + if (!p) + return; + pjson::StringView view; + CHECK(p->tryGet(view)); + CHECK_EQ(view.size(), size_t(2)); +} + +TEST(strict_still_parses_normal_documents) { + auto p = pjson::parse(R"({ "a": 1, "b": [true, null, "x"] })"); + CHECK(p != nullptr); + if (!p) + return; + const pjson* b = p->find("b"); + CHECK(b != nullptr); + if (!b) + return; + const pjson* tail = b->find(2); + CHECK(tail != nullptr); + if (tail) + CHECK_EQ(mustGetString(*tail), std::string("x")); +} + +//===----------------------------------------------------------------------===// +// Ownership-safe parse API returning a unique_ptr. +//===----------------------------------------------------------------------===// +TEST(parse_returns_unique_ptr) { + pjson::unique_ptr p = pjson::parse(R"({"k":42})"); + CHECK(static_cast(p)); + if (p) { + const pjson* value = p->find("k"); + CHECK(value != nullptr); + if (value) + CHECK_EQ(mustGetInt(*value), int64_t(42)); + } + + pjson::unique_ptr bad = pjson::parse("{not json"); + CHECK(!bad); // empty on failure +} + +TEST(parse_ptr_size_overload) { + const char* src = "123456"; + auto p = pjson::parse(src, 3); // only "123" + CHECK(static_cast(p)); + if (p) + CHECK_EQ(mustGetInt(*p), int64_t(123)); +} + +//===----------------------------------------------------------------------===// +// Parse errors expose a byte offset plus one-based line/byte-column coordinates. +//===----------------------------------------------------------------------===// +TEST(parse_error_reports_success) { + pjson::ParseError err; + auto p = pjson::parse(R"({"a":1})", err); + CHECK(static_cast(p)); + CHECK(err.ok); + CHECK_EQ(err.line, size_t(1)); + CHECK_EQ(err.column, size_t(1)); +} + +TEST(parse_error_reports_offset_and_message) { + pjson::ParseError err; + auto p = pjson::parse("[1, 2, ]", err); // trailing comma at index 7 + CHECK(!p); + CHECK(!err.ok); + CHECK(!err.message.empty()); + CHECK_EQ(err.offset, size_t(7)); + CHECK_EQ(err.line, size_t(1)); + CHECK_EQ(err.column, size_t(8)); +} + +TEST(parse_error_reports_line_and_column) { + pjson::ParseError err; + CHECK(!pjson::parse("{\r\n \"a\": 1,\r\n \"b\": [2, ]\r\n}", err)); + CHECK_EQ(err.line, size_t(3)); + CHECK_EQ(err.column, size_t(12)); + CHECK_EQ(err.offset, size_t(25)); + + CHECK(!pjson::parse("1\r\n2", err)); + CHECK_EQ(err.offset, size_t(3)); + CHECK_EQ(err.line, size_t(2)); + CHECK_EQ(err.column, size_t(1)); + + CHECK(!pjson::parse("1\r2", err)); + CHECK_EQ(err.offset, size_t(2)); + CHECK_EQ(err.line, size_t(2)); + CHECK_EQ(err.column, size_t(1)); +} + +TEST(parse_error_trailing_garbage) { + pjson::ParseError err; + auto p = pjson::parse("42 abc", err); + CHECK(!p); + CHECK(!err.ok); + CHECK_EQ(err.offset, size_t(3)); // 'a' + CHECK_EQ(err.line, size_t(1)); + CHECK_EQ(err.column, size_t(4)); +} + +TEST(parse_error_depth_message) { + pjson::ParseOptions opt; + opt.maxDepth = 2; + pjson::ParseError err; + auto p = pjson::parse("[[[1]]]", err, opt); + CHECK(!p); + CHECK(!err.ok); + CHECK(err.message.find("depth") != std::string::npos); +} + +//===----------------------------------------------------------------------===// +// Deep structural equality between values. +//===----------------------------------------------------------------------===// +TEST(equality_scalars) { + pjson a; + a = static_cast(5); + pjson b; + b = static_cast(5); + CHECK(a == b); + + pjson c; + c = double(5.0); + CHECK(a == c); // 5 (int) == 5.0 (double) + + pjson d; + d = static_cast(6); + CHECK(a != d); + + pjson s1; + s1 = std::string("x"); + pjson s2; + s2 = "x"; + CHECK(s1 == s2); + + pjson t; + t = true; + pjson one; + one = static_cast(1); + CHECK(t != one); // bool is not a number + + pjson exactInt; + exactInt = static_cast(9007199254740994LL); + pjson exactDouble; + exactDouble = double(9007199254740994.0); + CHECK(exactInt == exactDouble); + + pjson roundedInt; + roundedInt = static_cast(9007199254740993LL); + pjson roundedDouble; + roundedDouble = double(9007199254740992.0); + CHECK(roundedInt != roundedDouble); +} + +TEST(equality_deep_structures) { + auto a = parse(R"({"k":[1,2,{"x":true}], "s":"v"})"); + auto b = parse(R"({"s":"v", "k":[1,2,{"x":true}]})"); // different key order in text + CHECK(a != nullptr); + CHECK(b != nullptr); + CHECK(*a == *b); // objects compare regardless of source order + + auto c = parse(R"({"k":[1,2,{"x":false}], "s":"v"})"); + CHECK(*a != *c); // one nested value differs +} + +TEST(equality_array_order_matters) { + auto a = parse("[1,2,3]"); + auto b = parse("[3,2,1]"); + CHECK(*a != *b); +} + +//===----------------------------------------------------------------------===// +// Container conveniences: size, empty, clear, and type predicates. +//===----------------------------------------------------------------------===// +TEST(size_and_empty) { + pjson arr; + arr = std::vector({1, 2, 3}); + CHECK_EQ(arr.size(), size_t(3)); + CHECK(!arr.empty()); + + pjson obj; + obj["a"] = int64_t(1); + obj["b"] = int64_t(2); + CHECK_EQ(obj.size(), size_t(2)); + + pjson scalar; + scalar = int64_t(5); + CHECK_EQ(scalar.size(), size_t(0)); + CHECK(scalar.empty()); + + pjson nul; + CHECK(nul.empty()); +} + +TEST(clear_container_keeps_type) { + pjson arr; + arr = std::vector({1, 2, 3}); + arr.clear(); + CHECK_EQ(arr.getType(), pjson::jsonArray); + CHECK_EQ(arr.size(), size_t(0)); + + pjson obj; + obj["a"] = int64_t(1); + obj.clear(); + CHECK_EQ(obj.getType(), pjson::jsonObject); + CHECK(obj.empty()); + + pjson scalar; + scalar = static_cast(5); + scalar.clear(); + CHECK_EQ(scalar.getType(), pjson::jsonNull); +} + +TEST(type_predicates) { + pjson n; + CHECK(n.isNull()); + pjson s; + s = "x"; + CHECK(s.isString()); + pjson i; + i = static_cast(5); + CHECK(i.isNumber()); + CHECK(i.isInt()); + CHECK(!i.isDouble()); + pjson d; + d = double(1.5); + CHECK(d.isNumber()); + CHECK(d.isDouble()); + CHECK(!d.isInt()); + pjson b; + b = true; + CHECK(b.isBool()); + pjson a; + a = std::vector({1}); + CHECK(a.isArray()); + pjson m; + m["k"] = int64_t(1); + CHECK(m.isObject()); +} + +//===----------------------------------------------------------------------===// +// Removing map keys and array elements with erase(). +//===----------------------------------------------------------------------===// +TEST(erase_map_key) { + pjson j; + j["a"] = static_cast(1); + j["b"] = static_cast(2); + j["c"] = static_cast(3); + CHECK(j.erase("b")); + CHECK_EQ(j.size(), size_t(2)); + CHECK(!j.hasKey("b")); + CHECK(!j.erase("missing")); // returns false, no-op +} + +TEST(erase_array_index) { + pjson j; + j = std::vector({10, 20, 30}); + CHECK(j.erase(size_t(1))); // remove the 20 + CHECK_EQ(j.size(), size_t(2)); + CHECK_EQ(mustGetInt(j[0]), int64_t(10)); + CHECK_EQ(mustGetInt(j[1]), int64_t(30)); + CHECK(!j.erase(size_t(5))); // out of range -> false +} + +TEST(erase_wrong_type_is_false) { + pjson j; + j = static_cast(5); + CHECK(!j.erase("a")); + CHECK(!j.erase(size_t(0))); +} + +//===----------------------------------------------------------------------===// +// Listing object keys for iteration. +//===----------------------------------------------------------------------===// +TEST(keys_returns_sorted_keys) { + pjson j; + j["gamma"] = static_cast(1); + j["alpha"] = static_cast(2); + j["beta"] = static_cast(3); + std::vector k = j.keys(); + CHECK_EQ(k.size(), size_t(3)); + CHECK_EQ(k[0], std::string("alpha")); + CHECK_EQ(k[1], std::string("beta")); + CHECK_EQ(k[2], std::string("gamma")); + + pjson notMap; + notMap = static_cast(5); + CHECK(notMap.keys().empty()); +} + +//===----------------------------------------------------------------------===// +// Strict typed keyed reads via tryGet(). +//===----------------------------------------------------------------------===// +TEST(tryget_keyed_reads) { + pjson j; + j["i"] = static_cast(7); + j["d"] = double(2.5); + j["b"] = true; + j["s"] = std::string("hi"); + + int64_t integer = -1; + double floating = -1.0; + bool boolean = false; + std::string string = "old"; + + CHECK(j.tryGet("i", integer)); + CHECK_EQ(integer, int64_t(7)); + CHECK(j.tryGet("i", floating)); + CHECK_EQ(floating, 7.0); + CHECK(j.tryGet("d", floating)); + CHECK_EQ(floating, 2.5); + CHECK(j.tryGet("b", boolean)); + CHECK_EQ(boolean, true); + CHECK(j.tryGet("s", string)); + CHECK_EQ(string, std::string("hi")); + + CHECK(!j.tryGet("missing", integer)); + CHECK(!j.tryGet("s", integer)); + CHECK_EQ(integer, int64_t(7)); +} + +//===----------------------------------------------------------------------===// +// Stream I/O: writing to and parsing from std::ostream / std::istream. +//===----------------------------------------------------------------------===// +TEST(write_to_ostream) { + pjson j; + j["a"] = static_cast(1); + j["b"] = std::vector({2, 3}); + std::ostringstream os; + j.write(os); + CHECK_EQ(os.str(), j.toString()); +} + +TEST(write_to_stream) { + pjson j; + j = std::vector({1, 2, 3}); + std::ostringstream os; + j.write(os); + CHECK_EQ(os.str(), j.toString()); +} + +TEST(parse_from_stream) { + std::istringstream is(R"({ "name": "Ada", "scores": [90, 82] })"); + auto p = pjson::parseStream(is); + CHECK(static_cast(p)); + if (!p) + return; + const pjson* name = p->find("name"); + const pjson* scores = p->find("scores"); + CHECK(name != nullptr); + CHECK(scores != nullptr); + if (name) + CHECK_EQ(mustGetString(*name), std::string("Ada")); + if (scores) + CHECK_EQ(scores->size(), size_t(2)); +} + +TEST(parse_from_stream_with_error) { + std::istringstream is("{bad"); + pjson::ParseError err; + auto p = pjson::parseStream(is, err); + CHECK(!p); + CHECK(!err.ok); +} + +TEST(stream_round_trip) { + pjson j; + j["a"] = int64_t(1); + j["b"]["c"] = std::vector({"x", "y"}); + std::ostringstream os; + j.write(os); + std::istringstream is(os.str()); + auto rt = pjson::parseStream(is); + CHECK(static_cast(rt)); + CHECK(*rt == j); +} diff --git a/pjsontest/src/tests_fuzz.cpp b/pjsontest/src/tests_fuzz.cpp new file mode 100644 index 0000000..6808071 --- /dev/null +++ b/pjsontest/src/tests_fuzz.cpp @@ -0,0 +1,306 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Robustness / fuzz tests. These are deterministic (fixed RNG seeds) so a +// failure is reproducible, and are written to run cleanly under +// AddressSanitizer / UndefinedBehaviorSanitizer (see build.sh --asan). They +// stress the paths a contributor is most likely to break: random valid +// documents through the full round-trip, random byte soup through the parser, +// random mutation sequences, and random schemas. +// +#include "pjson.h" +#include "test_harness.h" +#include "test_util.h" + +#include +#include +#include + +using namespace ByteDance; +using pjson_test::parse; + +namespace { + + // Builds a random JSON value up to the given depth. Uses only depths well + // within the default parse guard so serialize/parse/copy never overflow. + void buildRandom(pjson& node, std::mt19937& rng, int depth) { + std::uniform_int_distribution kind(0, depth > 0 ? 6 : 4); + switch (kind(rng)) { + case 0: + node.reset(); + break; + case 1: + node = (rng() & 1) != 0; + break; + case 2: + node = static_cast( + std::uniform_int_distribution(-1000000000LL, 1000000000LL)(rng)); + break; + case 3: + node = std::uniform_real_distribution(-1e6, 1e6)(rng); + break; + case 4: { + // Draw complete UTF-8 fragments, never individual bytes from a + // multibyte code point. The generated DOM must serialize to a + // strictly valid RFC 8259 document. + static const char* fragments[] = {"a", "b", " ", "\"", "\\", "\n", + "\t", "/", "\x01", "\xC3\xA9", "z"}; + std::uniform_int_distribution len(0, 10); + std::uniform_int_distribution pick( + 0, static_cast(sizeof(fragments) / sizeof(fragments[0])) - 1); + std::string s; + int n = len(rng); + for (int i = 0; i < n; ++i) + s += fragments[pick(rng)]; + node = s; + break; + } + case 5: { + node.resetTo(pjson::jsonArray); + std::uniform_int_distribution len(0, 5); + int n = len(rng); + for (int i = 0; i < n; ++i) + buildRandom(node[i], rng, depth - 1); + break; + } + default: { + node.resetTo(pjson::jsonObject); + std::uniform_int_distribution len(0, 5); + int n = len(rng); + for (int i = 0; i < n; ++i) + buildRandom(node["k" + std::to_string(i)], rng, depth - 1); + break; + } + } + } + +} // namespace + +//===----------------------------------------------------------------------===// +// Random valid documents survive serialize -> parse -> serialize unchanged, +// in both compact and pretty form, and equal themselves after a round-trip. +//===----------------------------------------------------------------------===// +TEST(fuzz_valid_document_round_trip) { + std::mt19937 rng(0xABCDEF01u); + for (int iter = 0; iter < 2000; ++iter) { + pjson doc; + buildRandom(doc, rng, 5); + + std::string compact = doc.toString(); + auto rc = parse(compact); + CHECK(rc != nullptr); + if (rc) { + CHECK_EQ(rc->toString(), compact); + CHECK(*rc == doc); // structural equality holds + } + + std::string pretty = doc.toString(pjson::SerializeOptions::prettyPrinted()); + auto rp = parse(pretty); + CHECK(rp != nullptr); + if (rp) + CHECK_EQ(rp->toString(), compact); + } +} + +//===----------------------------------------------------------------------===// +// Random byte strings never crash or throw the parser; anything that does +// parse must re-serialize/re-parse consistently. +//===----------------------------------------------------------------------===// +TEST(fuzz_random_bytes_parser) { + std::mt19937 rng(0x1234ABCDu); + std::uniform_int_distribution byte(0, 255); + std::uniform_int_distribution len(0, 60); + for (int iter = 0; iter < 5000; ++iter) { + std::string s; + int n = len(rng); + for (int i = 0; i < n; ++i) + s += static_cast(byte(rng)); + auto p = parse(s); // must not throw / crash + if (p) { + std::string out = p->toString(); + auto p2 = parse(out); + CHECK(p2 != nullptr); + if (p2) + CHECK_EQ(p2->toString(), out); + } + } + CHECK(true); // reaching here means no crash across all iterations +} + +//===----------------------------------------------------------------------===// +// Random byte strings biased toward JSON punctuation exercise the structural +// error paths harder. +//===----------------------------------------------------------------------===// +TEST(fuzz_json_flavored_bytes) { + std::mt19937 rng(0x55AA55AAu); + static const char alphabet[] = "{}[]:,\"\\0123456789.eE+-tfnul truefalsenull \t\n"; + std::uniform_int_distribution pick(0, static_cast(sizeof(alphabet)) - 2); + std::uniform_int_distribution len(0, 40); + for (int iter = 0; iter < 5000; ++iter) { + std::string s; + int n = len(rng); + for (int i = 0; i < n; ++i) + s += alphabet[pick(rng)]; + auto p = parse(s); + if (p) { + auto p2 = parse(p->toString()); + CHECK(p2 != nullptr); + if (p2) + CHECK(*p2 == *p); + } + } + CHECK(true); +} + +//===----------------------------------------------------------------------===// +// Random mutation sequences (add / overwrite / erase / clear) keep the tree +// self-consistent and always serializable + round-trippable. +//===----------------------------------------------------------------------===// +TEST(fuzz_mutation_sequence) { + std::mt19937 rng(0x0BADC0DEu); + for (int iter = 0; iter < 300; ++iter) { + pjson doc; + doc.resetTo(pjson::jsonObject); + int ops = std::uniform_int_distribution(1, 40)(rng); + for (int o = 0; o < ops; ++o) { + int action = std::uniform_int_distribution(0, 5)(rng); + std::string key = "k" + std::to_string(std::uniform_int_distribution(0, 9)(rng)); + switch (action) { + case 0: + doc[key] = static_cast(rng()); + break; + case 1: + doc[key] = std::string("v"); + break; + case 2: + doc[key] = std::vector({1, 2, 3}); + break; + case 3: + doc[key][std::uniform_int_distribution(0, 5)(rng)] = int64_t(7); + break; + case 4: + doc.erase(key); + break; + default: + if (doc.find(key)) + doc[key].clear(); + break; + } + } + // Whatever state we ended in must serialize and round-trip. + std::string s = doc.toString(); + auto rt = parse(s); + CHECK(rt != nullptr); + if (rt) + CHECK(*rt == doc); + } +} + +//===----------------------------------------------------------------------===// +// Validating random documents against random schemas never crashes and always +// yields a definite pass/fail (with errors collected only on failure). +//===----------------------------------------------------------------------===// +TEST(fuzz_schema_validation_never_crashes) { + std::mt19937 rng(0xFEEDFACEu); + // A pool of small schema fragments to combine. + const char* fragments[] = { + R"({"type":"object"})", + R"({"type":"array","items":{"type":"integer"}})", + R"({"required":["k0","k1"]})", + R"({"properties":{"k0":{"type":"string"},"k1":{"type":"integer","minimum":0}}})", + R"({"minProperties":1,"maxProperties":4})", + R"({"enum":[1,"v",true,null]})", + R"({"anyOf":[{"type":"string"},{"type":"integer"}]})", + R"({"not":{"required":["k9"]}})", + R"({"additionalProperties":false,"properties":{"k0":{}}})", + R"(true)", + R"(false)", + R"({})", + }; + const int nFragments = static_cast(sizeof(fragments) / sizeof(fragments[0])); + + for (int iter = 0; iter < 1000; ++iter) { + auto schema = parse(fragments[std::uniform_int_distribution(0, nFragments - 1)(rng)]); + CHECK(schema != nullptr); + + pjson doc; + buildRandom(doc, rng, 3); + + std::vector errors; + bool ok = doc.validate(*schema, errors); + // The contract: ok == errors.empty(). Also validate() (no errors arg) + // must agree with the collecting form. + CHECK_EQ(ok, errors.empty()); + CHECK_EQ(ok, doc.validate(*schema)); + } +} + +//===----------------------------------------------------------------------===// +// Malformed schemas must be handled deterministically without crashing. Most +// wrong-shaped keywords are ignored; invalid regex syntax fails validation. +//===----------------------------------------------------------------------===// +TEST(fuzz_malformed_schemas_tolerated) { + const char* ignoredSchemas[] = { + R"({"type":123})", R"({"required":"notarray"})", R"({"properties":"no"})", + R"({"items":42})", R"({"enum":"notarray"})", R"({"minimum":"5"})", + R"({"minLength":-1})", R"({"minItems":-3})", R"({"multipleOf":0})", + R"({"multipleOf":-2})", R"({"allOf":"x"})", R"({"anyOf":{}})", + }; + for (const char* bs : ignoredSchemas) { + auto schema = parse(bs); + CHECK(schema != nullptr); + const char* values[] = {"5", "\"str\"", "[1,2,3]", R"({"k0":1})", "true", "null"}; + for (const char* v : values) { + auto d = parse(v); + std::vector errors; + CHECK(d->validate(*schema, errors)); + CHECK(errors.empty()); + } + } + + auto invalidRegex = parse(R"({"pattern":"([unclosed"})"); + auto stringValue = parse("\"value\""); + std::vector errors; + CHECK(!stringValue->validate(*invalidRegex, errors)); + CHECK(!errors.empty()); +} + +//===----------------------------------------------------------------------===// +// Copy / move of random documents produce independent, equal trees. +//===----------------------------------------------------------------------===// +TEST(fuzz_copy_move_independence) { + std::mt19937 rng(0x99887766u); + for (int iter = 0; iter < 500; ++iter) { + pjson a; + buildRandom(a, rng, 4); + + pjson b(a); // copy ctor + CHECK(a == b); + + pjson c; + c = a; // copy assign + CHECK(a == c); + + std::string before = a.toString(); + pjson d(std::move(c)); // move ctor + CHECK_EQ(d.toString(), before); + CHECK(c.isNull()); // moved-from is null + + // Mutating the copy must not disturb the original. + b.resetTo(pjson::jsonArray); + b += int64_t(12345); + CHECK_EQ(a.toString(), before); + } +} diff --git a/pjsontest/src/tests_malformed.cpp b/pjsontest/src/tests_malformed.cpp new file mode 100644 index 0000000..fbe93de --- /dev/null +++ b/pjsontest/src/tests_malformed.cpp @@ -0,0 +1,284 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Malformed / hostile input: every category of invalid JSON must return null +// without throwing and (where meaningful) report a sensible error offset. +// +#include "pjson.h" +#include "test_harness.h" +#include "test_util.h" + +#include +#include + +using namespace ByteDance; +using pjson_test::parse; + +//===----------------------------------------------------------------------===// +// Empty and whitespace-only input. +//===----------------------------------------------------------------------===// +TEST(malformed_empty_inputs) { + CHECK_PARSE_FAILS(""); + CHECK_PARSE_FAILS(" "); + CHECK_PARSE_FAILS("\t\n\r "); + CHECK_PARSE_FAILS("\r\n"); + // A lone NUL byte is not a value. + CHECK(pjson::parse(std::string("\0", 1)) == nullptr); +} + +//===----------------------------------------------------------------------===// +// Structural garbage: stray punctuation, mismatched brackets, bare tokens. +//===----------------------------------------------------------------------===// +TEST(malformed_structural_tokens) { + CHECK_PARSE_FAILS("}"); + CHECK_PARSE_FAILS("]"); + CHECK_PARSE_FAILS(":"); + CHECK_PARSE_FAILS(","); + CHECK_PARSE_FAILS("[}"); + CHECK_PARSE_FAILS("{]"); + CHECK_PARSE_FAILS("[[]"); + CHECK_PARSE_FAILS("{}}"); + CHECK_PARSE_FAILS("(1)"); + CHECK_PARSE_FAILS(""); +} + +//===----------------------------------------------------------------------===// +// Objects: bad keys, missing colons/values, comma misuse. +//===----------------------------------------------------------------------===// +TEST(malformed_object_shapes) { + CHECK_PARSE_FAILS("{"); + CHECK_PARSE_FAILS("{\"a\"}"); // key with no colon/value + CHECK_PARSE_FAILS("{\"a\":}"); // colon, no value + CHECK_PARSE_FAILS("{\"a\" 1}"); // missing colon + CHECK_PARSE_FAILS("{\"a\":1\"b\":2}"); // missing comma + CHECK_PARSE_FAILS("{\"a\":1,}"); // trailing comma + CHECK_PARSE_FAILS("{,\"a\":1}"); // leading comma + CHECK_PARSE_FAILS("{\"a\":1,,\"b\":2}"); // doubled comma + CHECK_PARSE_FAILS("{1:2}"); // non-string key + CHECK_PARSE_FAILS("{true:2}"); // non-string key + CHECK_PARSE_FAILS("{\"a\":1"); // unterminated + CHECK_PARSE_FAILS("{\"a\"::1}"); // doubled colon +} + +//===----------------------------------------------------------------------===// +// Arrays: comma misuse, unterminated, missing separators. +//===----------------------------------------------------------------------===// +TEST(malformed_array_shapes) { + CHECK_PARSE_FAILS("["); + CHECK_PARSE_FAILS("[1"); + CHECK_PARSE_FAILS("[1,"); + CHECK_PARSE_FAILS("[1,]"); + CHECK_PARSE_FAILS("[,1]"); + CHECK_PARSE_FAILS("[1,,2]"); + CHECK_PARSE_FAILS("[1 2]"); // missing comma + CHECK_PARSE_FAILS("[1;2]"); // wrong separator + CHECK_PARSE_FAILS("]1["); +} + +//===----------------------------------------------------------------------===// +// Numbers: every malformed numeric form. +//===----------------------------------------------------------------------===// +TEST(malformed_numbers) { + CHECK_PARSE_FAILS("."); + CHECK_PARSE_FAILS(".5"); + CHECK_PARSE_FAILS("1."); + CHECK_PARSE_FAILS("+1"); + CHECK_PARSE_FAILS("1e"); + CHECK_PARSE_FAILS("1e+"); + CHECK_PARSE_FAILS("1.5e"); + CHECK_PARSE_FAILS("e5"); + CHECK_PARSE_FAILS("--1"); + CHECK_PARSE_FAILS("1..2"); + CHECK_PARSE_FAILS("1.2.3"); + CHECK_PARSE_FAILS("-"); + CHECK_PARSE_FAILS("0x1F"); // hex not allowed + CHECK_PARSE_FAILS("1_000"); // digit separators not allowed + CHECK_PARSE_FAILS("Infinity"); + CHECK_PARSE_FAILS("NaN"); + CHECK_PARSE_FAILS("1,000"); +} + +TEST(malformed_leading_zeros) { + // JSON forbids leading zeros on multi-digit integers. + CHECK_PARSE_FAILS("01"); + CHECK_PARSE_FAILS("00"); + CHECK_PARSE_FAILS("[00]"); + CHECK_PARSE_FAILS("-01"); + // But a bare zero and "0.x" are fine. + CHECK(parse("0") != nullptr); + CHECK(parse("0.5") != nullptr); + CHECK(parse("-0") != nullptr); +} + +TEST(malformed_number_out_of_range) { + // Overflow to a non-finite double is rejected, not stored as inf. + CHECK_PARSE_FAILS("1e400"); + CHECK_PARSE_FAILS("-1e400"); + CHECK_PARSE_FAILS("1e309"); + // A 400-digit integer overflows int64, falls back to double, overflows + // that too, and is rejected. + std::string huge(400, '9'); + CHECK(pjson::parse(huge) == nullptr); + // Just inside range is fine. + CHECK(parse("1e308") != nullptr); +} + +//===----------------------------------------------------------------------===// +// Strings: unterminated, bad escapes, bad \u. +//===----------------------------------------------------------------------===// +TEST(malformed_strings) { + CHECK_PARSE_FAILS("\""); + CHECK_PARSE_FAILS("\"abc"); + CHECK_PARSE_FAILS("\"a\\\""); // escaped closing quote -> unterminated + CHECK_PARSE_FAILS("\"line\\"); // dangling backslash + CHECK_PARSE_FAILS("'single quoted'"); // single quotes not allowed + CHECK_PARSE_FAILS("\"\\u\""); // \u with no hex + CHECK_PARSE_FAILS("\"\\u12\""); // \u with too few hex + CHECK_PARSE_FAILS("\"\\uZZZZ\""); // \u with non-hex + CHECK_PARSE_FAILS("\"\\u123\""); // 3 hex digits then quote +} + +//===----------------------------------------------------------------------===// +// Trailing content after a complete value. +//===----------------------------------------------------------------------===// +TEST(malformed_trailing_content) { + CHECK_PARSE_FAILS("1 2"); + CHECK_PARSE_FAILS("1abc"); + CHECK_PARSE_FAILS("nulltrue"); + CHECK_PARSE_FAILS("{}[]"); + CHECK_PARSE_FAILS("[1] [2]"); + CHECK_PARSE_FAILS("\"a\" \"b\""); + CHECK_PARSE_FAILS("true false"); + CHECK_PARSE_FAILS("1.5 .5"); +} + +//===----------------------------------------------------------------------===// +// Incomplete / wrong-case keyword literals. +//===----------------------------------------------------------------------===// +TEST(malformed_keywords) { + CHECK_PARSE_FAILS("nul"); + CHECK_PARSE_FAILS("nulll"); + CHECK_PARSE_FAILS("tru"); + CHECK_PARSE_FAILS("truee"); + CHECK_PARSE_FAILS("fals"); + CHECK_PARSE_FAILS("undefined"); + CHECK_PARSE_FAILS("None"); + CHECK_PARSE_FAILS("nil"); + CHECK(parse("NULL") == nullptr); + CHECK(parse("True") == nullptr); +} + +//===----------------------------------------------------------------------===// +// A byte-order mark is not whitespace; input starting with one is rejected. +//===----------------------------------------------------------------------===// +TEST(malformed_bom_prefix) { + const char bom[] = {(char)0xEF, (char)0xBB, (char)0xBF, '1'}; + CHECK(pjson::parse(bom, sizeof(bom)) == nullptr); +} + +//===----------------------------------------------------------------------===// +// Deeply nested input must fail (not crash) once past the depth limit. +//===----------------------------------------------------------------------===// +TEST(malformed_excessive_depth_arrays) { + const int depth = 200000; + std::string s(depth, '['); + s += std::string(depth, ']'); + CHECK(pjson::parse(s) == nullptr); // default maxDepth guards this +} + +TEST(malformed_excessive_depth_objects) { + // Build {"a":{"a":{ ... }}} well beyond the default limit. + std::string s; + const int depth = 2000; + for (int i = 0; i < depth; ++i) + s += "{\"a\":"; + s += "1"; + for (int i = 0; i < depth; ++i) + s += "}"; + CHECK(pjson::parse(s) == nullptr); +} + +//===----------------------------------------------------------------------===// +// Parsing rejects raw control characters, unsupported escapes, lone +// surrogates, and invalid UTF-8. +//===----------------------------------------------------------------------===// +TEST(malformed_control_chars) { + const char rawNL[] = {'"', 'a', '\n', 'b', '"'}; + const char rawTab[] = {'"', '\t', '"'}; + CHECK(pjson::parse(rawNL, sizeof(rawNL)) == nullptr); + CHECK(pjson::parse(rawTab, sizeof(rawTab)) == nullptr); +} + +TEST(malformed_bad_escapes_and_surrogates) { + CHECK(pjson::parse("\"\\x41\"") == nullptr); + CHECK(pjson::parse("\"\\uD800\"") == nullptr); // lone high surrogate + CHECK(pjson::parse("\"\\uDC00\"") == nullptr); // lone low surrogate +} + +TEST(malformed_invalid_utf8) { + const char bad[] = {'"', (char)0xC3, (char)0x28, '"'}; // 0xC3 not followed by continuation + const char lone[] = {'"', (char)0xFF, '"'}; + CHECK(pjson::parse(bad, sizeof(bad)) == nullptr); + CHECK(pjson::parse(lone, sizeof(lone)) == nullptr); +} + +//===----------------------------------------------------------------------===// +// Error offsets point at the offending byte. +//===----------------------------------------------------------------------===// +TEST(malformed_error_offsets) { + pjson::ParseError err; + + CHECK(!pjson::parse("[1, 2, ]", err)); + CHECK_EQ(err.offset, size_t(7)); // the ']' after a trailing comma + + pjson::parse(" @", err); + CHECK_EQ(err.offset, size_t(3)); // first non-ws garbage + + pjson::parse("{\"a\":1 \"b\":2}", err); + CHECK(!err.ok); + CHECK(!err.message.empty()); +} + +//===----------------------------------------------------------------------===// +// The parser never throws, even on random byte soup (smoke test). +//===----------------------------------------------------------------------===// +TEST(malformed_random_bytes_never_throw) { + const char* soups[] = { + "{[}]", "\"\\\\\\", "1e-e-1", "{\"\":}", "[null,]", "\xff\xfe\x00\x01", + "}{", "::,,", "[[[[[[", "\"\t\r\"", + }; + for (const char* s : soups) { + // Must terminate and return a value or null; the harness would crash + // if it threw or segfaulted. + auto p = parse(s); + CHECK(true); // reaching here means no throw/crash + } +} + +// Malformed inputs that allocate several partial subtrees must unwind every +// node cleanly. The --asan test lane runs these under LeakSanitizer on Linux. +TEST(malformed_partial_tree_teardown_is_leak_free) { + const char* cases[] = { + R"({"a":[1,2,{"b":[3,4,})", + R"([[[{"x":"y"}, {"z":[true,false,null]}],]])", + R"({"one":{"two":{"three":[1,2,3]}},"bad":"\uD800"})", + R"([{"a":1},{"b":2},{"c":3},])", + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + pjson::ParseError err; + CHECK(pjson::parse(cases[i], err) == nullptr); + CHECK(!err.ok); + } +} diff --git a/pjsontest/src/tests_mutation.cpp b/pjsontest/src/tests_mutation.cpp new file mode 100644 index 0000000..39e84c9 --- /dev/null +++ b/pjsontest/src/tests_mutation.cpp @@ -0,0 +1,422 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Complex mutation scenarios: building deep trees, overwriting with type +// changes, growing/shrinking arrays, erase in various orders, clear-and-rebuild, +// and aliasing safety under move/copy. +// +#include "pjson.h" +#include "test_harness.h" +#include +#include + +using namespace ByteDance; + +namespace { + + void expectInt(const pjson& value, int64_t expected) { + int64_t actual = 0; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + + void expectString(const pjson& value, const std::string& expected) { + std::string actual = ""; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + +} // namespace + +//===----------------------------------------------------------------------===// +// Build a deep mixed tree and read every leaf back. +//===----------------------------------------------------------------------===// +TEST(mutate_build_deep_mixed_tree) { + pjson j; + j["user"]["name"] = "Ada"; + j["user"]["roles"] = std::vector({"admin", "dev"}); + j["user"]["settings"]["theme"] = "dark"; + j["user"]["settings"]["volume"] = static_cast(7); + j["user"]["history"][0]["action"] = "login"; + j["user"]["history"][0]["ts"] = static_cast(1000); + j["user"]["history"][1]["action"] = "logout"; + j["user"]["history"][1]["ts"] = static_cast(2000); + + expectString(j["user"]["name"], "Ada"); + CHECK_EQ(j["user"]["roles"].size(), size_t(2)); + expectString(j["user"]["roles"][1], "dev"); + expectInt(j["user"]["settings"]["volume"], int64_t(7)); + CHECK_EQ(j["user"]["history"].size(), size_t(2)); + expectString(j["user"]["history"][1]["action"], "logout"); + + // The whole thing round-trips. + pjson::unique_ptr rt = pjson::parse(j.toString()); + CHECK(rt != nullptr); + CHECK(*rt == j); +} + +//===----------------------------------------------------------------------===// +// Overwriting a key repeatedly with different types keeps only the last. +//===----------------------------------------------------------------------===// +TEST(mutate_overwrite_changes_type) { + pjson j; + j["x"] = static_cast(1); + CHECK(j["x"].isInt()); + j["x"] = std::string("s"); + CHECK(j["x"].isString()); + j["x"] = std::vector({1, 2}); + CHECK(j["x"].isArray()); + j["x"] = true; + CHECK(j["x"].isBool()); + j["x"] = double(3.5); + CHECK(j["x"].isDouble()); + j["x"].reset(); + CHECK(j["x"].isNull()); + CHECK_EQ(j.size(), size_t(1)); // still exactly one key +} + +//===----------------------------------------------------------------------===// +// Promoting a scalar to a container by indexing replaces its value. +//===----------------------------------------------------------------------===// +TEST(mutate_scalar_to_container_promotion) { + pjson s; + s = std::string("hi"); + s[0] = static_cast(1); // indexing turns it into an array + CHECK(s.isArray()); + CHECK_EQ(s.size(), size_t(1)); + expectInt(s[0], int64_t(1)); + + pjson m; + m = static_cast(5); + m["k"] = static_cast(1); // keying turns it into an object + CHECK(m.isObject()); + CHECK(m.hasKey("k")); +} + +//===----------------------------------------------------------------------===// +// Array growth auto-fills gaps with null; shrink via erase preserves order. +//===----------------------------------------------------------------------===// +TEST(mutate_array_grow_with_gaps) { + pjson j; + j[3] = "fourth"; + CHECK_EQ(j.size(), size_t(4)); + CHECK(j[0].isNull()); + CHECK(j[1].isNull()); + CHECK(j[2].isNull()); + expectString(j[3], "fourth"); +} + +TEST(mutate_array_erase_preserves_order) { + pjson j; + j = std::vector({0, 1, 2, 3, 4}); + CHECK(j.erase(size_t(2))); // remove the middle + CHECK_EQ(j.toString(), std::string("[0,1,3,4]")); + CHECK(j.erase(size_t(0))); // remove the front + CHECK_EQ(j.toString(), std::string("[1,3,4]")); + CHECK(j.erase(j.size() - 1)); // remove the back + CHECK_EQ(j.toString(), std::string("[1,3]")); + CHECK(!j.erase(size_t(99))); // out of range no-op +} + +TEST(mutate_array_erase_all_front) { + pjson j; + for (int i = 0; i < 50; ++i) + j[i] = static_cast(i); + while (j.size() > 0) { + CHECK(j.erase(size_t(0))); + } + CHECK(j.isArray()); // still an (empty) array + CHECK(j.empty()); +} + +TEST(mutate_array_erase_all_back) { + pjson j; + for (int i = 0; i < 50; ++i) + j[i] = static_cast(i); + while (j.size() > 0) { + CHECK(j.erase(j.size() - 1)); + } + CHECK(j.empty()); +} + +//===----------------------------------------------------------------------===// +// Object erase: middle keys, all keys, and re-add after erase. +//===----------------------------------------------------------------------===// +TEST(mutate_object_erase_and_readd) { + pjson j; + j["a"] = static_cast(1); + j["b"] = static_cast(2); + j["c"] = static_cast(3); + CHECK(j.erase("b")); + CHECK_EQ(j.size(), size_t(2)); + CHECK(!j.hasKey("b")); + CHECK(!j.erase("b")); // already gone + j["b"] = static_cast(20); // re-add + expectInt(j["b"], int64_t(20)); + CHECK_EQ(j.size(), size_t(3)); +} + +TEST(mutate_object_erase_frees_subtree) { + pjson j; + j["big"]["nested"]["deep"] = std::vector({1, 2, 3}); + j["keep"] = static_cast(1); + CHECK(j.erase("big")); // frees the whole subtree + CHECK_EQ(j.size(), size_t(1)); + CHECK(j.hasKey("keep")); +} + +//===----------------------------------------------------------------------===// +// clear() empties in place (keeping the container type), then rebuild. +//===----------------------------------------------------------------------===// +TEST(mutate_clear_and_rebuild) { + pjson arr; + arr = std::vector({1, 2, 3}); + arr.clear(); + CHECK(arr.isArray()); + CHECK(arr.empty()); + arr += static_cast(10); + arr += static_cast(20); + CHECK_EQ(arr.size(), size_t(2)); + + pjson obj; + obj["a"] = static_cast(1); + obj["b"] = static_cast(2); + obj.clear(); + CHECK(obj.isObject()); + CHECK(obj.empty()); + obj["c"] = static_cast(3); + CHECK_EQ(obj.size(), size_t(1)); +} + +//===----------------------------------------------------------------------===// +// Editing a parsed document in place, then re-serializing. +//===----------------------------------------------------------------------===// +TEST(mutate_edit_parsed_document) { + pjson::unique_ptr p = pjson::parse(R"({ "list":[10,20,30], "meta":{"v":1}, "drop":true })"); + CHECK(p != nullptr); + pjson& j = *p; + + j["list"][1] = static_cast(99); // change a value + j["list"][3] = "appended"; // extend the array + j["meta"]["v"] = static_cast(2); // edit nested + j["meta"]["new"] = std::vector({7, 8}); + CHECK(j.erase("drop")); // remove a key + + expectInt(j["list"][1], int64_t(99)); + CHECK_EQ(j["list"].size(), size_t(4)); + expectString(j["list"][3], "appended"); + expectInt(j["meta"]["v"], int64_t(2)); + CHECK(!j.hasKey("drop")); + + // Still valid JSON after all the edits. + pjson::unique_ptr rt = pjson::parse(j.toString()); + CHECK(rt != nullptr); + CHECK(*rt == j); +} + +//===----------------------------------------------------------------------===// +// Negative-index edits reach elements from the end. +//===----------------------------------------------------------------------===// +TEST(mutate_negative_index_edits) { + pjson j; + j = std::vector({1, 2, 3}); + j[-1] = static_cast(30); // last + j[-3] = static_cast(10); // first + expectInt(j[0], int64_t(10)); + expectInt(j[2], int64_t(30)); + // Out-of-range negative clamps to the front element. + j[-10] = static_cast(0); + expectInt(j[0], int64_t(0)); +} + +//===----------------------------------------------------------------------===// +// Building a large object then reading it back (stress the map). +//===----------------------------------------------------------------------===// +TEST(mutate_large_object) { + pjson j; + const int n = 500; + for (int i = 0; i < n; ++i) { + j["k" + std::to_string(i)] = static_cast(i); + } + CHECK_EQ(j.size(), size_t(n)); + CHECK_EQ(j.keys().size(), size_t(n)); + expectInt(j["k0"], int64_t(0)); + expectInt(j["k499"], int64_t(499)); + // Erase half. + for (int i = 0; i < n; i += 2) { + CHECK(j.erase("k" + std::to_string(i))); + } + CHECK_EQ(j.size(), size_t(n / 2)); + CHECK(!j.hasKey("k0")); + CHECK(j.hasKey("k1")); +} + +//===----------------------------------------------------------------------===// +// Aliasing safety: assigning from a child / self must not corrupt. +//===----------------------------------------------------------------------===// +TEST(mutate_assign_from_child) { + pjson j; + j["outer"]["inner"] = std::vector({1, 2, 3}); + j = j["outer"]; // copy-and-swap keeps this safe + CHECK(j.hasKey("inner")); + CHECK_EQ(j["inner"].size(), size_t(3)); +} + +TEST(mutate_swap_via_move) { + pjson a; + a["x"] = static_cast(1); + pjson b; + b["y"] = static_cast(2); + pjson tmp = std::move(a); + a = std::move(b); + b = std::move(tmp); + // a and b have exchanged contents. + CHECK(a.hasKey("y")); + CHECK(b.hasKey("x")); +} + +//===----------------------------------------------------------------------===// +// resetTo transitions between every type free the previous storage cleanly. +//===----------------------------------------------------------------------===// +TEST(mutate_reset_to_every_type) { + pjson j; + j["a"] = std::vector({1, 2, 3}); // start as object holding an array + j.resetTo(pjson::jsonArray); + CHECK(j.isArray()); + CHECK(j.empty()); + j += static_cast(1); + CHECK_EQ(j.size(), size_t(1)); + j.resetTo(pjson::jsonString); + CHECK(j.isString()); + expectString(j, ""); + j.resetTo(pjson::jsonObject); + CHECK(j.isObject()); + CHECK(j.empty()); + j.resetTo(pjson::jsonNumberInt); + CHECK(j.isInt()); + expectInt(j, int64_t(0)); + j.resetTo(pjson::jsonNumberDouble); + CHECK(j.isDouble()); + j.resetTo(pjson::jsonBoolean); + CHECK(j.isBool()); + j.resetTo(pjson::jsonNull); + CHECK(j.isNull()); +} + +//===----------------------------------------------------------------------===// +// resetIfNeeded only rebuilds when the type differs: an existing container of +// the requested type keeps its contents, while a mismatched type is replaced. +//===----------------------------------------------------------------------===// +TEST(mutate_reset_if_needed) { + pjson j; + j += static_cast(1); + j += static_cast(2); + j += static_cast(3); + CHECK(j.isArray()); + // Already an array -> contents preserved. + j.resetIfNeeded(pjson::jsonArray); + CHECK(j.isArray()); + CHECK_EQ(j.size(), size_t(3)); + // Different type -> rebuilt as an empty value of that type. + j.resetIfNeeded(pjson::jsonObject); + CHECK(j.isObject()); + CHECK(j.empty()); + // Idempotent on the fresh type too. + j["k"] = static_cast(7); + j.resetIfNeeded(pjson::jsonObject); + CHECK(j.isObject()); + CHECK_EQ(j.size(), size_t(1)); +} + +//===----------------------------------------------------------------------===// +// swap() exchanges two nodes' contents in place, including differing types and +// self-swap, without copying or leaking. +//===----------------------------------------------------------------------===// +TEST(mutate_swap_contents) { + pjson a; + a["x"] = static_cast(1); + pjson b; + b += std::vector({"one", "two"}); + + a.swap(b); + // a is now the array, b is now the object. + CHECK(a.isArray()); + CHECK_EQ(a.size(), size_t(2)); + expectString(a[0], "one"); + CHECK(b.isObject()); + CHECK(b.hasKey("x")); + expectInt(b["x"], int64_t(1)); + + // Self-swap is a harmless no-op. + a.swap(a); + CHECK(a.isArray()); + CHECK_EQ(a.size(), size_t(2)); +} + +//===----------------------------------------------------------------------===// +// Append operators accumulate and can mix scalar + vector appends. +//===----------------------------------------------------------------------===// +TEST(mutate_append_accumulation) { + pjson j; + j += static_cast(1); + j += std::vector({2, 3}); + j += "four"; + j += std::vector({"five", "six"}); + j += true; + CHECK_EQ(j.size(), size_t(7)); + expectInt(j[0], int64_t(1)); + expectString(j[3], "four"); + { + bool tail = false; + CHECK(j[6].tryGet(tail)); + CHECK_EQ(tail, true); + } +} + +//===----------------------------------------------------------------------===// +// A full add -> edit -> delete -> rebuild lifecycle stays consistent. +//===----------------------------------------------------------------------===// +TEST(mutate_full_lifecycle) { + pjson doc; + // Add. + doc["users"][0]["id"] = static_cast(1); + doc["users"][0]["name"] = "Ada"; + doc["users"][1]["id"] = static_cast(2); + doc["users"][1]["name"] = "Bob"; + doc["count"] = static_cast(2); + CHECK_EQ(doc["users"].size(), size_t(2)); + + // Edit. + doc["users"][0]["name"] = "Ada Lovelace"; + doc["count"] = static_cast(3); // deliberately inconsistent, then fixed + + // Delete user 0, fix count. + CHECK(doc["users"].erase(size_t(0))); + doc["count"] = static_cast(doc["users"].size()); + CHECK_EQ(doc["users"].size(), size_t(1)); + expectString(doc["users"][0]["name"], "Bob"); + expectInt(doc["count"], int64_t(1)); + + // Rebuild from scratch on the same object. + doc.clear(); + CHECK(doc.empty()); + doc["ok"] = true; + CHECK_EQ(doc.size(), size_t(1)); + + // Everything still serializes and round-trips. + pjson::unique_ptr rt = pjson::parse(doc.toString()); + CHECK(rt != nullptr); + CHECK(*rt == doc); +} diff --git a/pjsontest/src/tests_parse.cpp b/pjsontest/src/tests_parse.cpp new file mode 100644 index 0000000..b5c59d8 --- /dev/null +++ b/pjsontest/src/tests_parse.cpp @@ -0,0 +1,383 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Parsing: valid documents of every shape, the (ptr,size) overload, and an +// exhaustive set of invalid inputs that must return nullptr without throwing. +// Number-grammar acceptance/rejection lives here too. +// +#include "pjson.h" +#include "test_harness.h" +#include "test_util.h" + +#include +#include + +using namespace ByteDance; +using pjson_test::parse; +using pjson_test::valueBool; +using pjson_test::valueDouble; +using pjson_test::valueInt; +using pjson_test::valueString; + +//===----------------------------------------------------------------------===// +// Valid top-level scalars +//===----------------------------------------------------------------------===// +TEST(parse_top_level_scalars) { + auto n = parse("null"); + CHECK(n != nullptr); + CHECK_EQ(n->getType(), pjson::jsonNull); + + auto t = parse("true"); + CHECK(t != nullptr); + CHECK_EQ(t->getType(), pjson::jsonBoolean); + CHECK_EQ(valueBool(*t), true); + + auto f = parse("false"); + CHECK(f != nullptr); + CHECK_EQ(f->getType(), pjson::jsonBoolean); + CHECK_EQ(valueBool(*f), false); + + auto i = parse("123"); + CHECK(i != nullptr); + CHECK_EQ(i->getType(), pjson::jsonNumberInt); + CHECK_EQ(valueInt(*i), int64_t(123)); + + auto d = parse("1.5"); + CHECK(d != nullptr); + CHECK_EQ(d->getType(), pjson::jsonNumberDouble); + CHECK_EQ(valueDouble(*d), 1.5); + + auto s = parse("\"text\""); + CHECK(s != nullptr); + CHECK_EQ(valueString(*s), std::string("text")); +} + +//===----------------------------------------------------------------------===// +// Keywords are strict RFC 8259 spellings. +//===----------------------------------------------------------------------===// +TEST(parse_keywords_are_case_sensitive) { + CHECK(parse("NULL") == nullptr); + CHECK(parse("True") == nullptr); + CHECK(parse("FALSE") == nullptr); +} + +//===----------------------------------------------------------------------===// +// Empty containers +//===----------------------------------------------------------------------===// +TEST(parse_empty_containers) { + auto a = parse("[]"); + CHECK(a != nullptr); + CHECK_EQ(a->getType(), pjson::jsonArray); + CHECK_EQ(a->size(), size_t(0)); + + auto o = parse("{}"); + CHECK(o != nullptr); + CHECK_EQ(o->getType(), pjson::jsonObject); + CHECK_EQ(o->size(), size_t(0)); + + // With interior whitespace + CHECK(parse("[ ]") != nullptr); + CHECK(parse("{\n\t}") != nullptr); +} + +//===----------------------------------------------------------------------===// +// Arrays with mixed element types +//===----------------------------------------------------------------------===// +TEST(parse_mixed_array) { + auto a = parse("[1, 2.5, \"three\", true, null, [1], {\"k\":1}]"); + CHECK(a != nullptr); + CHECK_EQ(a->size(), size_t(7)); + CHECK_EQ((*a)[0].getType(), pjson::jsonNumberInt); + CHECK_EQ((*a)[1].getType(), pjson::jsonNumberDouble); + CHECK_EQ((*a)[2].getType(), pjson::jsonString); + CHECK_EQ((*a)[3].getType(), pjson::jsonBoolean); + CHECK_EQ((*a)[4].getType(), pjson::jsonNull); + CHECK_EQ((*a)[5].getType(), pjson::jsonArray); + CHECK_EQ((*a)[6].getType(), pjson::jsonObject); +} + +//===----------------------------------------------------------------------===// +// Objects: values of every type, and deep nesting +//===----------------------------------------------------------------------===// +TEST(parse_object_all_value_types) { + auto o = parse("{\"s\":\"x\",\"i\":1,\"d\":2.5,\"b\":true,\"n\":null," + "\"a\":[1,2],\"m\":{\"k\":9}}"); + CHECK(o != nullptr); + CHECK_EQ(o->size(), size_t(7)); + CHECK_EQ(valueString((*o)["s"]), std::string("x")); + CHECK_EQ(valueInt((*o)["i"]), int64_t(1)); + CHECK_EQ(valueDouble((*o)["d"]), 2.5); + CHECK_EQ(valueBool((*o)["b"]), true); + CHECK_EQ((*o)["n"].getType(), pjson::jsonNull); + CHECK_EQ((*o)["a"].size(), size_t(2)); + CHECK_EQ(valueInt((*o)["m"]["k"]), int64_t(9)); +} + +TEST(parse_deeply_nested) { + auto o = parse("{\"a\":{\"b\":{\"c\":{\"d\":[1,[2,[3,[4]]]]}}}}"); + CHECK(o != nullptr); + CHECK_EQ(valueInt((*o)["a"]["b"]["c"]["d"][0]), int64_t(1)); + CHECK_EQ(valueInt((*o)["a"]["b"]["c"]["d"][1][1][1][0]), int64_t(4)); +} + +//===----------------------------------------------------------------------===// +// Whitespace tolerance: spaces, tabs, newlines, CRLF everywhere legal +//===----------------------------------------------------------------------===// +TEST(parse_whitespace_variations) { + CHECK(parse(" 42 ") != nullptr); + CHECK(parse("\t\n 42 \r\n") != nullptr); + auto o = parse("{ \n\t \"a\" \r\n : \t 1 \n , \"b\" : 2 \r\n }"); + CHECK(o != nullptr); + CHECK_EQ(valueInt((*o)["a"]), int64_t(1)); + CHECK_EQ(valueInt((*o)["b"]), int64_t(2)); +} + +TEST(parse_crlf_document) { + auto o = parse("{\r\n \"a\" : 1,\r\n \"b\" : 2\r\n}"); + CHECK(o != nullptr); + CHECK(o->hasKey("a")); + CHECK(o->hasKey("b")); +} + +//===----------------------------------------------------------------------===// +// Duplicate-key handling is explicit: the default rejects, while callers may +// request keep-first or keep-last independently of the always-strict grammar. +//===----------------------------------------------------------------------===// +TEST(parse_duplicate_key_policies) { + const std::string document = "{\"a\":1,\n\"a\":2}"; + pjson::ParseError err; + CHECK(pjson::parse(document, err) == nullptr); + CHECK(!err.ok); + CHECK_EQ(err.offset, size_t(8)); + CHECK_EQ(err.line, size_t(2)); + CHECK_EQ(err.column, size_t(1)); + CHECK(err.message.find("duplicate") != std::string::npos); + + pjson::ParseOptions keepLast; + keepLast.duplicateKeys = pjson::ParseOptions::KeepLastDuplicate; + auto last = pjson::parse(document, keepLast); + CHECK(last != nullptr); + CHECK_EQ(last->size(), size_t(1)); + CHECK_EQ(valueInt((*last)["a"]), int64_t(2)); + + pjson::ParseOptions keepFirst; + keepFirst.duplicateKeys = pjson::ParseOptions::KeepFirstDuplicate; + auto first = pjson::parse(document, keepFirst); + CHECK(first != nullptr); + CHECK_EQ(valueInt((*first)["a"]), int64_t(1)); + + pjson::ParseOptions strictLast; + strictLast.duplicateKeys = pjson::ParseOptions::KeepLastDuplicate; + CHECK(pjson::parse(document, strictLast) != nullptr); +} + +TEST(parse_error_reuse_across_calls) { + pjson::ParseError err; + + CHECK(pjson::parse("{", err) == nullptr); + CHECK(!err.ok); + CHECK(!err.message.empty()); + + pjson::unique_ptr ok = pjson::parse("42", err); + CHECK(ok != nullptr); + CHECK(err.ok); + CHECK_EQ(err.offset, size_t(0)); + CHECK_EQ(err.line, size_t(1)); + CHECK_EQ(err.column, size_t(1)); + CHECK(err.message.empty()); + CHECK_EQ(valueInt(*ok), int64_t(42)); + + CHECK(pjson::parse("[1,]", err) == nullptr); + CHECK(!err.ok); + CHECK_EQ(err.offset, size_t(3)); + CHECK(!err.message.empty()); +} + +//===----------------------------------------------------------------------===// +// The (ptr, size) overload: embedded NUL, explicit length, nullptr, partial +//===----------------------------------------------------------------------===// +TEST(parse_ptr_size_respects_length) { + // Only the first 3 bytes ("123") are in-range; the "456" is ignored. + const char* src = "123456"; + auto p = parse(src, 3); + CHECK(p != nullptr); + CHECK_EQ(valueInt(*p), int64_t(123)); +} + +TEST(parse_ptr_size_with_embedded_nul_in_string) { + // Raw NUL is not legal RFC 8259 JSON even when the buffer length is explicit. + const char raw[] = {'"', 'a', '\0', 'b', '"'}; + CHECK(parse(raw, sizeof(raw)) == nullptr); +} + +TEST(parse_nullptr_is_null_not_crash) { + CHECK(pjson::parse(nullptr, 10) == nullptr); +} + +TEST(parse_zero_length_is_null) { + CHECK(pjson::parse("anything", 0) == nullptr); +} + +//===----------------------------------------------------------------------===// +// Invalid input: empty / whitespace-only +//===----------------------------------------------------------------------===// +TEST(parse_invalid_empty) { + CHECK_PARSE_FAILS(""); + CHECK_PARSE_FAILS(" "); + CHECK_PARSE_FAILS("\r\n\t "); +} + +//===----------------------------------------------------------------------===// +// Invalid input: truncated / unterminated containers and strings +//===----------------------------------------------------------------------===// +TEST(parse_invalid_truncated) { + CHECK_PARSE_FAILS("{"); + CHECK_PARSE_FAILS("{\"a\""); + CHECK_PARSE_FAILS("{\"a\":"); + CHECK_PARSE_FAILS("{\"a\":1"); + CHECK_PARSE_FAILS("{\"a\":1,"); + CHECK_PARSE_FAILS("["); + CHECK_PARSE_FAILS("[1"); + CHECK_PARSE_FAILS("[1,"); + CHECK_PARSE_FAILS("[1,2"); +} + +TEST(parse_invalid_unterminated_string) { + CHECK_PARSE_FAILS("\"unterminated"); + CHECK_PARSE_FAILS("{\"a\":\"unterminated}"); + CHECK_PARSE_FAILS("[\"x\", \"y]"); + CHECK_PARSE_FAILS("\"trailing backslash\\"); +} + +//===----------------------------------------------------------------------===// +// Invalid input: structural errors +//===----------------------------------------------------------------------===// +TEST(parse_invalid_structure) { + CHECK_PARSE_FAILS("}"); + CHECK_PARSE_FAILS("]"); + CHECK_PARSE_FAILS("{:1}"); // missing key + CHECK_PARSE_FAILS("{\"a\" 1}"); // missing colon + CHECK_PARSE_FAILS("{\"a\":1 \"b\":2}"); // missing comma + CHECK_PARSE_FAILS("{1:2}"); // non-string key + CHECK_PARSE_FAILS("{\"a\":}"); // missing value + CHECK_PARSE_FAILS("[1 2]"); // missing comma between elements +} + +//===----------------------------------------------------------------------===// +// Invalid input: comma misuse (trailing / leading / doubled) +//===----------------------------------------------------------------------===// +TEST(parse_invalid_commas) { + CHECK_PARSE_FAILS("[1,2,3,]"); + CHECK_PARSE_FAILS("[,1]"); + CHECK_PARSE_FAILS("[1,,2]"); + CHECK_PARSE_FAILS("[,]"); + CHECK_PARSE_FAILS("{\"a\":1,}"); + CHECK_PARSE_FAILS("{,\"a\":1}"); + CHECK_PARSE_FAILS("{\"a\":1,,\"b\":2}"); +} + +//===----------------------------------------------------------------------===// +// Invalid input: trailing garbage after a complete value +//===----------------------------------------------------------------------===// +TEST(parse_invalid_trailing_garbage) { + CHECK_PARSE_FAILS("1 2"); + CHECK_PARSE_FAILS("1abc"); + CHECK_PARSE_FAILS("truefalse"); + CHECK_PARSE_FAILS("{\"a\":1} junk"); + CHECK_PARSE_FAILS("[1,2] [3]"); + CHECK_PARSE_FAILS("null null"); + CHECK_PARSE_FAILS("\"a\"\"b\""); +} + +//===----------------------------------------------------------------------===// +// Invalid input: incomplete keywords +//===----------------------------------------------------------------------===// +TEST(parse_invalid_keywords) { + CHECK_PARSE_FAILS("nul"); + CHECK_PARSE_FAILS("tru"); + CHECK_PARSE_FAILS("fals"); + CHECK_PARSE_FAILS("n"); + CHECK_PARSE_FAILS("t"); + CHECK_PARSE_FAILS("undefined"); + CHECK_PARSE_FAILS("None"); +} + +//===----------------------------------------------------------------------===// +// Number grammar: valid forms accepted with correct type +//===----------------------------------------------------------------------===// +TEST(parse_valid_numbers) { + pjson::unique_ptr zero = parse("0"); + pjson::unique_ptr negative = parse("-123"); + pjson::unique_ptr exponent = parse("1e3"); + pjson::unique_ptr fraction = parse("123.456"); + CHECK(zero != nullptr); + CHECK(parse("-0") != nullptr); + CHECK(parse("123") != nullptr); + CHECK(negative != nullptr); + CHECK(parse("0.5") != nullptr); + CHECK(parse("-0.5") != nullptr); + CHECK(fraction != nullptr); + CHECK(exponent != nullptr); + CHECK(parse("1E3") != nullptr); + CHECK(parse("1e+3") != nullptr); + CHECK(parse("1e-3") != nullptr); + CHECK(parse("1.5e10") != nullptr); + CHECK(parse("-2.5E-4") != nullptr); + + CHECK_EQ(zero->getType(), pjson::jsonNumberInt); + CHECK_EQ(valueInt(*negative), int64_t(-123)); + CHECK_EQ(exponent->getType(), pjson::jsonNumberDouble); + CHECK_EQ(valueDouble(*exponent), 1000.0); + CHECK_EQ(valueDouble(*fraction), 123.456); +} + +TEST(parse_bigint_falls_back_without_throw) { + // Beyond int64 range: must not throw; stored as double. + auto p = parse("100000000000000000000000"); + CHECK(p != nullptr); + CHECK_EQ(p->getType(), pjson::jsonNumberDouble); +} + +TEST(parse_int64_boundary) { + auto p = parse("9223372036854775807"); // INT64_MAX + CHECK(p != nullptr); + CHECK_EQ(p->getType(), pjson::jsonNumberInt); + CHECK_EQ(valueInt(*p), int64_t(9223372036854775807LL)); +} + +//===----------------------------------------------------------------------===// +// Number grammar: malformed forms rejected +//===----------------------------------------------------------------------===// +TEST(parse_invalid_numbers) { + CHECK_PARSE_FAILS("."); + CHECK_PARSE_FAILS(".5"); // leading dot + CHECK_PARSE_FAILS("1."); // trailing dot + CHECK_PARSE_FAILS("+1"); // leading plus + CHECK_PARSE_FAILS("1e"); // exponent without digits + CHECK_PARSE_FAILS("1.5e"); // exponent without digits + CHECK_PARSE_FAILS("1e+"); // exponent sign without digits + CHECK_PARSE_FAILS("e5"); // no mantissa + CHECK_PARSE_FAILS("--1"); + CHECK_PARSE_FAILS("1..2"); + CHECK_PARSE_FAILS("-"); +} + +TEST(parse_invalid_numbers_in_context) { + CHECK_PARSE_FAILS("[1.]"); + CHECK_PARSE_FAILS("[.5]"); + CHECK_PARSE_FAILS("[1e]"); + CHECK_PARSE_FAILS("{\"x\":+1}"); + CHECK_PARSE_FAILS("{\"x\":.}"); +} diff --git a/pjsontest/src/tests_pathological.cpp b/pjsontest/src/tests_pathological.cpp new file mode 100644 index 0000000..af7d566 --- /dev/null +++ b/pjsontest/src/tests_pathological.cpp @@ -0,0 +1,359 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Deterministic pathological-input tests. These use fixed, moderate payload +// sizes and observable parser budgets rather than wall-clock thresholds, so +// they exercise unusually expensive paths without introducing timing flakes. +//===----------------------------------------------------------------------===// +#include "pjson.h" +#include "test_harness.h" +#include "test_util.h" + +#include +#include +#include +#include +#include + +using namespace ByteDance; +using pjson_test::parse; + +namespace { + + const char* const kNodeBudgetError = "document too large (node budget exceeded)"; + const char* const kInputBudgetError = "input exceeds maxInputBytes"; + + // Generates a wide array while keeping the serialized size linear and predictable. + std::string makeFlatArray(size_t width) { + std::string json; + json.reserve(width * 2U + 1U); + json += '['; + for (size_t i = 0; i < width; ++i) { + if (i != 0) + json += ','; + json += '0'; + } + json += ']'; + return json; + } + + // Generates a wide object and exposes the final value's byte offset for error assertions. + std::string makeFlatObject(size_t width, size_t& lastValueOffset) { + std::string json; + json.reserve(width * 14U + 2U); + json += '{'; + for (size_t i = 0; i < width; ++i) { + if (i != 0) + json += ','; + json += "\"k"; + json += std::to_string(i); + json += "\":"; + lastValueOffset = json.size(); + json += '0'; + } + json += '}'; + return json; + } + + // Gates exact bit-level expectations that assume an IEEE 754 binary64 double. + bool isIeeeBinary64() { + return std::numeric_limits::is_iec559 && std::numeric_limits::radix == 2 && + std::numeric_limits::digits == 53 && + std::numeric_limits::max_exponent == 1024; + } + + double doubleValue(const pjson& aValue) { + double value = 0.0; + CHECK(aValue.tryGet(value)); + return value; + } + + int64_t intValue(const pjson& aValue) { + int64_t value = 0; + CHECK(aValue.tryGet(value)); + return value; + } + + std::string stringValue(const pjson& aValue) { + std::string value; + CHECK(aValue.tryGet(value)); + return value; + } + +} // namespace + +// Mixed numeric equality must not round an integer through binary64. Above +// 2^53, adjacent integers can map to the same double, so comparison has to +// prove that the floating value is finite, integral, in range, and exactly +// representable before comparing it with the stored int64_t. +TEST(pathological_mixed_numeric_equality_is_exact_above_binary64_integer_precision) { + if (!isIeeeBinary64()) { + CHECK(std::numeric_limits::is_specialized); + return; + } + + pjson exactInteger; + exactInteger = int64_t(9007199254740992LL); + pjson sameDouble; + sameDouble = double(9007199254740992.0); + CHECK(exactInteger == sameDouble); + CHECK(sameDouble == exactInteger); + + pjson adjacentInteger; + adjacentInteger = int64_t(9007199254740993LL); + CHECK(adjacentInteger != sameDouble); + CHECK(sameDouble != adjacentInteger); + + pjson roundedBeyondInt64; + roundedBeyondInt64 = double(9223372036854775808.0); + pjson maxInteger; + maxInteger = std::numeric_limits::max(); + CHECK(maxInteger != roundedBeyondInt64); + CHECK(roundedBeyondInt64 != maxInteger); +} + +// A long finite mantissa and a long, zero-padded exponent must be scanned in +// full without changing their values. Very large positive values fail at a +// stable location, while a very negative exponent remains a valid finite JSON +// number (normally underflowing to zero). +TEST(pathological_very_long_numeric_tokens) { + const size_t digitCount = 65536; + pjson::ParseError err; + + const std::string longMantissa = "1." + std::string(digitCount, '0'); + auto mantissa = pjson::parse(longMantissa, err); + CHECK(mantissa != nullptr); + CHECK(err.ok); + if (mantissa) { + CHECK(mantissa->isDouble()); + CHECK_EQ(doubleValue(*mantissa), 1.0); + } + + const std::string paddedExponent = "1e+" + std::string(digitCount, '0') + std::string("1"); + auto finiteExponent = pjson::parse(paddedExponent, err); + CHECK(finiteExponent != nullptr); + CHECK(err.ok); + if (finiteExponent) + CHECK_EQ(doubleValue(*finiteExponent), 10.0); + + // IEC 60559 implementations have infinities, so strtod must expose these + // positive overflows and pjson must reject them rather than storing inf. + if (std::numeric_limits::has_infinity) { + const std::string hugeInteger(digitCount, '9'); + CHECK(pjson::parse(hugeInteger, err) == nullptr); + CHECK(!err.ok); + CHECK_EQ(err.offset, size_t(0)); + CHECK_EQ(err.line, size_t(1)); + CHECK_EQ(err.column, size_t(1)); + CHECK_EQ(err.message, std::string("number out of range")); + + const std::string hugePositiveExponent = "1e+" + std::string(digitCount, '9'); + CHECK(pjson::parse(hugePositiveExponent, err) == nullptr); + CHECK(!err.ok); + CHECK_EQ(err.offset, size_t(0)); + CHECK_EQ(err.message, std::string("number out of range")); + } + + const std::string hugeNegativeExponent = "1e-" + std::string(digitCount, '9'); + auto underflow = pjson::parse(hugeNegativeExponent, err); + CHECK(underflow != nullptr); + CHECK(err.ok); + if (underflow) { + CHECK(underflow->isDouble()); + CHECK(std::isfinite(doubleValue(*underflow))); + if (isIeeeBinary64()) + CHECK_EQ(doubleValue(*underflow), 0.0); + } +} + +// Exercise normal/subnormal/max-finite conversion at exact binary64 values. +// The literal expectations are intentionally conditional because C++ does not +// require double to use the IEC 60559 binary64 representation. +TEST(pathological_binary64_extremes_round_trip) { + if (!isIeeeBinary64()) { + CHECK(std::numeric_limits::is_specialized); + return; + } + + // Literals and expected values at normal, subnormal, and range boundaries. + struct NumericCase { + const char* text; + double expected; + }; + const NumericCase cases[] = { + {"4.9406564584124654e-324", std::numeric_limits::denorm_min()}, + {"2.2250738585072014e-308", std::numeric_limits::min()}, + {"1.7976931348623157e308", std::numeric_limits::max()}, + }; + + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + auto value = parse(cases[i].text); + CHECK(value != nullptr); + if (!value) + continue; + CHECK(value->isDouble()); + CHECK(std::isfinite(doubleValue(*value))); + CHECK_EQ(doubleValue(*value), cases[i].expected); + + const std::string encoded = value->toString(); + auto roundTrip = parse(encoded); + CHECK(roundTrip != nullptr); + if (roundTrip) { + CHECK(roundTrip->isDouble()); + CHECK_EQ(doubleValue(*roundTrip), cases[i].expected); + } + } +} + +TEST(pathological_negative_zero_parse_round_trip) { + const char* literals[] = {"-0.0", "-0e0", "-0E+12"}; + for (size_t i = 0; i < sizeof(literals) / sizeof(literals[0]); ++i) { + auto value = parse(literals[i]); + CHECK(value != nullptr); + if (!value) + continue; + CHECK(value->isDouble()); + CHECK_EQ(doubleValue(*value), 0.0); + + // Check the sign only on implementations that actually distinguish + // signed zero. All supported IEC 60559 targets take this branch. + if (std::signbit(-0.0)) { + CHECK(std::signbit(doubleValue(*value))); + CHECK_EQ(value->toString(), std::string("-0.0")); + auto roundTrip = parse(value->toString()); + CHECK(roundTrip != nullptr); + if (roundTrip) + CHECK(std::signbit(doubleValue(*roundTrip))); + } + } +} + +// maxNodes counts the root array plus each element. A wide document exactly at +// a configured budget succeeds; reducing that budget by one deterministically +// fails on the final element. The fixed width caps test memory independently +// of the much larger production default. +TEST(pathological_wide_array_node_budget_boundary) { + const size_t width = 32768; + const std::string json = makeFlatArray(width); + CHECK_EQ(json.size(), width * 2U + 1U); + + pjson::ParseOptions opts; + opts.maxNodes = width + 1U; + opts.maxInputBytes = json.size(); + pjson::ParseError err; + auto atLimit = pjson::parse(json, err, opts); + CHECK(atLimit != nullptr); + CHECK(err.ok); + if (atLimit) { + CHECK(atLimit->isArray()); + CHECK_EQ(atLimit->size(), width); + CHECK_EQ(atLimit->toString(), json); + } + + opts.maxNodes = width; + CHECK(pjson::parse(json, err, opts) == nullptr); + CHECK(!err.ok); + CHECK_EQ(err.offset, json.size() - 2U); + CHECK_EQ(err.line, size_t(1)); + CHECK_EQ(err.column, err.offset + 1U); + CHECK_EQ(err.message, std::string(kNodeBudgetError)); +} + +// Object keys do not consume the value-node budget. This wide object therefore +// has the same root-plus-values boundary as a flat array, despite its larger +// byte footprint and map allocations. +TEST(pathological_wide_object_node_budget_boundary) { + const size_t width = 8192; + size_t lastValueOffset = 0; + const std::string json = makeFlatObject(width, lastValueOffset); + + pjson::ParseOptions opts; + opts.maxNodes = width + 1U; + opts.maxInputBytes = json.size(); + pjson::ParseError err; + auto atLimit = pjson::parse(json, err, opts); + CHECK(atLimit != nullptr); + CHECK(err.ok); + if (atLimit) { + CHECK(atLimit->isObject()); + CHECK_EQ(atLimit->size(), width); + const pjson* last = atLimit->find("k8191"); + CHECK(last != nullptr); + if (last) + CHECK_EQ(intValue(*last), int64_t(0)); + } + + opts.maxNodes = width; + CHECK(pjson::parse(json, err, opts) == nullptr); + CHECK(!err.ok); + CHECK_EQ(err.offset, lastValueOffset); + CHECK_EQ(err.line, size_t(1)); + CHECK_EQ(err.column, lastValueOffset + 1U); + CHECK_EQ(err.message, std::string(kNodeBudgetError)); +} + +// A payload rich in escaping has a predictable expansion factor. Parse both +// buffer and stream forms exactly at their byte budget, then prove that the +// same input is rejected before parsing when it is one byte over that budget. +TEST(pathological_large_escaped_payload_and_byte_budget) { + const size_t repeats = 16384; + std::string raw; + raw.reserve(repeats * 5U); + for (size_t i = 0; i < repeats; ++i) { + raw += 'x'; + raw += '"'; + raw += '\\'; + raw += '\n'; + raw += '\0'; + } + + pjson source; + source = raw; + const std::string json = source.toString(); + CHECK_EQ(raw.size(), repeats * 5U); + CHECK_EQ(json.size(), repeats * 13U + 2U); + + pjson::ParseOptions opts; + opts.maxInputBytes = json.size(); + pjson::ParseError err; + auto fromBuffer = pjson::parse(json, err, opts); + CHECK(fromBuffer != nullptr); + CHECK(err.ok); + if (fromBuffer) + CHECK_EQ(stringValue(*fromBuffer), raw); + + std::istringstream acceptedStream(json); + auto fromStream = pjson::parseStream(acceptedStream, err, opts); + CHECK(fromStream != nullptr); + CHECK(err.ok); + if (fromStream) + CHECK_EQ(stringValue(*fromStream), raw); + + opts.maxInputBytes = json.size() - 1U; + CHECK(pjson::parse(json, err, opts) == nullptr); + CHECK(!err.ok); + CHECK_EQ(err.offset, json.size() - 1U); + CHECK_EQ(err.line, size_t(1)); + CHECK_EQ(err.column, json.size()); + CHECK_EQ(err.message, std::string(kInputBudgetError)); + + std::istringstream rejectedStream(json); + CHECK(pjson::parseStream(rejectedStream, err, opts) == nullptr); + CHECK(!err.ok); + CHECK_EQ(err.offset, json.size() - 1U); + CHECK_EQ(err.line, size_t(1)); + CHECK_EQ(err.column, json.size()); + CHECK_EQ(err.message, std::string(kInputBudgetError)); +} diff --git a/pjsontest/src/tests_pointer_patch.cpp b/pjsontest/src/tests_pointer_patch.cpp new file mode 100644 index 0000000..64e378c --- /dev/null +++ b/pjsontest/src/tests_pointer_patch.cpp @@ -0,0 +1,984 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// JSON Pointer (RFC 6901), JSON Patch (RFC 6902), and JSON Merge Patch +// (RFC 7396) behavior and error-handling tests covering: +// +// - pjson::PointerError / pjson::PatchError +// - findPointer() +// - escapePointerToken() +// - applyPatch() +// - applyMergePatch() +// +#include "pjson.h" +#include "test_harness.h" +#include "test_util.h" + +#include +#include +#include +#include + +using namespace ByteDance; +using pjson_test::parse; + +namespace { + int64_t mustGetInt(const pjson& value) { + int64_t out = 0; + CHECK(value.tryGet(out)); + return out; + } + + std::string mustGetString(const pjson& value) { + std::string out; + CHECK(value.tryGet(out)); + return out; + } + + // Parses fixed test fixtures while still recording a normal harness failure on bad setup. + pjson::unique_ptr parseChecked(const char* text) { + pjson::unique_ptr doc = parse(text); + CHECK(doc != nullptr); + return doc; + } + + // Returns an explicitly typed empty patch document for programmatic operation assembly. + pjson makePatchArray() { + pjson patch; + patch.resetTo(pjson::jsonArray); + return patch; + } + + // Canonical RFC 6901 object containing every token-escaping example. + pjson::unique_ptr makeRfc6901ExampleDoc() { + return parseChecked( + R"({"foo":["bar","baz"],"":0,"a/b":1,"c%d":2,"e^f":3,"g|h":4,"i\\j":5,"k\"l":6," ":7,"m~n":8})"); + } + + // Builds /token/token/... pointers for iterative-depth and budget tests. + std::string repeatedObjectPointer(const std::string& token, int depth) { + std::string out; + for (int i = 0; i < depth; ++i) { + out += "/"; + out += token; + } + return out; + } + + // Builds the document matching repeatedObjectPointer without recursive test setup. + pjson makeDeepObjectChain(int depth, int64_t leafValue, const std::string& key = "x") { + pjson root; + pjson* cur = &root; + for (int i = 0; i < depth; ++i) { + cur = &((*cur)[key]); + } + *cur = leafValue; + return root; + } + +} // namespace + +//===----------------------------------------------------------------------===// +// PointerError / PatchError default state +//===----------------------------------------------------------------------===// +TEST(pointer_error_defaults_ok) { + pjson::PointerError err; + CHECK(err.ok); + CHECK(err.code == pjson::PointerError::Ok); + CHECK_EQ(err.pointer, std::string("")); + CHECK_EQ(err.tokenIndex, size_t(0)); + CHECK_EQ(err.token, std::string("")); + CHECK_EQ(err.message, std::string("")); +} + +TEST(patch_error_defaults_ok) { + pjson::PatchError err; + CHECK(err.ok); + CHECK(err.code == pjson::PatchError::Ok); + CHECK_EQ(err.opIndex, size_t(0)); + CHECK_EQ(err.op, std::string("")); + CHECK_EQ(err.path, std::string("")); + CHECK_EQ(err.from, std::string("")); + CHECK_EQ(err.tokenIndex, size_t(0)); + CHECK_EQ(err.token, std::string("")); + CHECK_EQ(err.message, std::string("")); +} + +TEST(patch_options_defaults_are_finite) { + const pjson::PatchOptions options; + CHECK_EQ(options.maxOperations, size_t(10000)); + CHECK_EQ(options.maxClonedNodes, size_t(1000000)); + CHECK_EQ(options.maxClonedBytes, size_t(64) * 1024U * 1024U); + CHECK_EQ(options.maxWork, size_t(1000000)); +} + +//===----------------------------------------------------------------------===// +// RFC 6901 pointer examples and escaping +//===----------------------------------------------------------------------===// +TEST(pointer_rfc6901_examples) { + pjson::unique_ptr doc = makeRfc6901ExampleDoc(); + + CHECK(doc->findPointer("") == doc.get()); + CHECK_EQ(doc->findPointer("/foo")->size(), size_t(2)); + CHECK_EQ(mustGetString(*doc->findPointer("/foo/0")), std::string("bar")); + CHECK_EQ(mustGetInt(*doc->findPointer("/")), int64_t(0)); + CHECK_EQ(mustGetInt(*doc->findPointer("/a~1b")), int64_t(1)); + CHECK_EQ(mustGetInt(*doc->findPointer("/c%d")), int64_t(2)); + CHECK_EQ(mustGetInt(*doc->findPointer("/e^f")), int64_t(3)); + CHECK_EQ(mustGetInt(*doc->findPointer("/g|h")), int64_t(4)); + CHECK_EQ(mustGetInt(*doc->findPointer("/i\\j")), int64_t(5)); + CHECK_EQ(mustGetInt(*doc->findPointer("/k\"l")), int64_t(6)); + CHECK_EQ(mustGetInt(*doc->findPointer("/ ")), int64_t(7)); + CHECK_EQ(mustGetInt(*doc->findPointer("/m~0n")), int64_t(8)); +} + +TEST(pointer_char_ptr_and_const_overloads_work) { + pjson::unique_ptr doc = makeRfc6901ExampleDoc(); + const pjson& cdoc = *doc; + + const pjson* cnode = cdoc.findPointer("/foo/1"); + CHECK(cnode != nullptr); + CHECK_EQ(mustGetString(*cnode), std::string("baz")); + + pjson* mnode = doc->findPointer("/foo/1"); + CHECK(mnode != nullptr); + CHECK_EQ(mustGetString(*mnode), std::string("baz")); +} + +TEST(pointer_escape_token_round_trips) { + const std::string token = "a/b~c"; + CHECK_EQ(pjson::escapePointerToken(token), std::string("a~1b~0c")); + + pjson doc; + doc[token] = int64_t(42); + const std::string ptr = "/" + pjson::escapePointerToken(token); + const pjson* node = doc.findPointer(ptr); + CHECK(node != nullptr); + CHECK_EQ(mustGetInt(*node), int64_t(42)); +} + +TEST(pointer_empty_token_after_slash_is_empty_key) { + pjson doc; + doc[""] = "empty"; + const pjson* node = doc.findPointer("/"); + CHECK(node != nullptr); + CHECK_EQ(mustGetString(*node), std::string("empty")); +} + +//===----------------------------------------------------------------------===// +// Pointer errors and non-vivifying behavior +//===----------------------------------------------------------------------===// +TEST(pointer_invalid_syntax_requires_leading_slash_or_empty) { + pjson::unique_ptr doc = parseChecked(R"({"foo":1})"); + pjson::PointerError err; + CHECK(doc->findPointer("foo", err) == nullptr); + CHECK(!err.ok); + CHECK(err.code == pjson::PointerError::InvalidSyntax); + CHECK_EQ(err.pointer, std::string("foo")); +} + +TEST(pointer_invalid_escape_sequences_fail) { + pjson::unique_ptr doc = parseChecked(R"({"foo":1})"); + + pjson::PointerError badDigit; + CHECK(doc->findPointer("/~2", badDigit) == nullptr); + CHECK(!badDigit.ok); + CHECK(badDigit.code == pjson::PointerError::InvalidEscape); + + pjson::PointerError trailingTilde; + CHECK(doc->findPointer("/abc~", trailingTilde) == nullptr); + CHECK(!trailingTilde.ok); + CHECK(trailingTilde.code == pjson::PointerError::InvalidEscape); +} + +TEST(pointer_missing_target_reports_error) { + pjson::unique_ptr doc = parseChecked(R"({"foo":1})"); + pjson::PointerError err; + CHECK(doc->findPointer("/bar", err) == nullptr); + CHECK(!err.ok); + CHECK(err.code == pjson::PointerError::MissingTarget); + CHECK_EQ(err.pointer, std::string("/bar")); + CHECK_EQ(err.tokenIndex, size_t(0)); + CHECK_EQ(err.token, std::string("bar")); +} + +TEST(pointer_expected_container_reports_error) { + pjson::unique_ptr doc = parseChecked(R"({"foo":1})"); + pjson::PointerError err; + CHECK(doc->findPointer("/foo/bar", err) == nullptr); + CHECK(!err.ok); + CHECK(err.code == pjson::PointerError::ExpectedContainer); + CHECK_EQ(err.tokenIndex, size_t(1)); + CHECK_EQ(err.token, std::string("bar")); +} + +TEST(pointer_invalid_array_index_reports_error) { + pjson::unique_ptr doc = parseChecked(R"(["x","y"])"); + + pjson::PointerError leadingZero; + CHECK(doc->findPointer("/01", leadingZero) == nullptr); + CHECK(!leadingZero.ok); + CHECK(leadingZero.code == pjson::PointerError::InvalidArrayIndex); + CHECK_EQ(leadingZero.token, std::string("01")); + + pjson::PointerError negative; + CHECK(doc->findPointer("/-1", negative) == nullptr); + CHECK(!negative.ok); + CHECK(negative.code == pjson::PointerError::InvalidArrayIndex); + CHECK_EQ(negative.token, std::string("-1")); +} + +TEST(pointer_array_index_out_of_range_reports_error) { + pjson::unique_ptr doc = parseChecked(R"(["x","y"])"); + pjson::PointerError err; + CHECK(doc->findPointer("/2", err) == nullptr); + CHECK(!err.ok); + CHECK(err.code == pjson::PointerError::ArrayIndexOutOfRange); + CHECK_EQ(err.token, std::string("2")); +} + +TEST(pointer_append_token_is_not_lookup) { + pjson::unique_ptr doc = parseChecked(R"(["x","y"])"); + pjson::PointerError err; + CHECK(doc->findPointer("/-", err) == nullptr); + CHECK(!err.ok); + CHECK(err.code == pjson::PointerError::AppendTokenNotAllowed); + CHECK_EQ(err.token, std::string("-")); +} + +TEST(pointer_object_numeric_key_is_not_array_index) { + pjson doc; + doc["0"] = "zero"; + const pjson* node = doc.findPointer("/0"); + CHECK(node != nullptr); + CHECK_EQ(mustGetString(*node), std::string("zero")); +} + +TEST(pointer_find_is_non_vivifying_on_missing_path) { + pjson doc; + CHECK(doc.isNull()); + + pjson::PointerError err; + CHECK(doc.findPointer("/new/key", err) == nullptr); + CHECK(!err.ok); + CHECK(doc.isNull()); + CHECK_EQ(doc.size(), size_t(0)); +} + +TEST(pointer_mutable_find_can_edit_existing_node_without_creating_new_ones) { + pjson doc; + doc["obj"]["keep"] = static_cast(1); + + pjson* node = doc.findPointer("/obj/keep"); + CHECK(node != nullptr); + *node = static_cast(99); + CHECK_EQ(mustGetInt(doc["obj"]["keep"]), int64_t(99)); + + pjson::PointerError err; + CHECK(doc.findPointer("/obj/missing", err) == nullptr); + CHECK(!err.ok); + CHECK_EQ(doc["obj"].size(), size_t(1)); + CHECK(!doc["obj"].hasKey("missing")); +} + +TEST(pointer_error_object_is_reused_across_failure_and_success) { + pjson::unique_ptr doc = parseChecked(R"({"foo":1})"); + pjson::PointerError err; + + CHECK(doc->findPointer("/missing", err) == nullptr); + CHECK(!err.ok); + CHECK_EQ(err.code, pjson::PointerError::MissingTarget); + CHECK(!err.message.empty()); + + const pjson* node = doc->findPointer("/foo", err); + CHECK(node != nullptr); + CHECK(err.ok); + CHECK_EQ(err.code, pjson::PointerError::Ok); + CHECK_EQ(err.pointer, std::string("")); + CHECK_EQ(err.tokenIndex, size_t(0)); + CHECK_EQ(err.token, std::string("")); + CHECK_EQ(err.message, std::string("")); +} + +//===----------------------------------------------------------------------===// +// JSON Patch: document-level validation failures +//===----------------------------------------------------------------------===// +TEST(patch_document_must_be_array) { + pjson doc; + doc["a"] = int64_t(1); + pjson patch; + patch["op"] = "add"; + + pjson before(doc); + pjson::PatchError err; + CHECK(!doc.applyPatch(patch, err)); + CHECK(!err.ok); + CHECK(err.code == pjson::PatchError::InvalidPatchDocument); + CHECK(doc == before); +} + +TEST(patch_operation_must_be_object) { + pjson doc; + doc["a"] = int64_t(1); + pjson patch = makePatchArray(); + patch[0] = int64_t(5); + + pjson before(doc); + pjson::PatchError err; + CHECK(!doc.applyPatch(patch, err)); + CHECK(!err.ok); + CHECK(err.code == pjson::PatchError::OperationNotObject); + CHECK_EQ(err.opIndex, size_t(0)); + CHECK(doc == before); +} + +TEST(patch_missing_required_members_report_precise_error) { + pjson doc; + doc["a"] = int64_t(1); + + pjson missingOp = makePatchArray(); + missingOp[0]["path"] = "/a"; + missingOp[0]["value"] = int64_t(2); + pjson::PatchError errOp; + CHECK(!doc.applyPatch(missingOp, errOp)); + CHECK(errOp.code == pjson::PatchError::MissingOp); + CHECK_EQ(errOp.opIndex, size_t(0)); + + pjson missingPath = makePatchArray(); + missingPath[0]["op"] = "remove"; + pjson::PatchError errPath; + CHECK(!doc.applyPatch(missingPath, errPath)); + CHECK(errPath.code == pjson::PatchError::MissingPath); + + pjson missingFrom = makePatchArray(); + missingFrom[0]["op"] = "move"; + missingFrom[0]["path"] = "/b"; + pjson::PatchError errFrom; + CHECK(!doc.applyPatch(missingFrom, errFrom)); + CHECK(errFrom.code == pjson::PatchError::MissingFrom); + + pjson missingValue = makePatchArray(); + missingValue[0]["op"] = "add"; + missingValue[0]["path"] = "/b"; + pjson::PatchError errValue; + CHECK(!doc.applyPatch(missingValue, errValue)); + CHECK(errValue.code == pjson::PatchError::MissingValue); +} + +TEST(patch_invalid_op_is_rejected) { + pjson doc; + doc["a"] = int64_t(1); + pjson patch = makePatchArray(); + patch[0]["op"] = "explode"; + patch[0]["path"] = "/a"; + + pjson::PatchError err; + CHECK(!doc.applyPatch(patch, err)); + CHECK(!err.ok); + CHECK(err.code == pjson::PatchError::InvalidOp); + CHECK_EQ(err.op, std::string("explode")); +} + +//===----------------------------------------------------------------------===// +// JSON Patch: add +//===----------------------------------------------------------------------===// +TEST(patch_add_object_member_and_replace_existing_member) { + pjson::unique_ptr doc = parseChecked(R"({"a":1})"); + pjson patch = makePatchArray(); + patch[0]["op"] = "add"; + patch[0]["path"] = "/b"; + patch[0]["value"] = static_cast(2); + patch[1]["op"] = "add"; + patch[1]["path"] = "/a"; + patch[1]["value"] = static_cast(9); + + pjson::PatchError err; + CHECK(doc->applyPatch(patch, err)); + CHECK(err.ok); + CHECK_EQ(doc->toString(), std::string("{\"a\":9,\"b\":2}")); +} + +TEST(patch_add_root_replaces_whole_document) { + pjson::unique_ptr doc = parseChecked(R"({"a":1})"); + pjson value; + value["replaced"] = true; + value["n"] = static_cast(7); + + pjson patch = makePatchArray(); + patch[0]["op"] = "add"; + patch[0]["path"] = ""; + patch[0]["value"] = value; + + CHECK(doc->applyPatch(patch)); + CHECK_EQ(doc->toString(), std::string("{\"n\":7,\"replaced\":true}")); +} + +TEST(patch_add_array_inserts_and_appends) { + pjson::unique_ptr doc = parseChecked(R"(["a","c"])"); + pjson patch = makePatchArray(); + patch[0]["op"] = "add"; + patch[0]["path"] = "/1"; + patch[0]["value"] = "b"; + patch[1]["op"] = "add"; + patch[1]["path"] = "/-"; + patch[1]["value"] = "d"; + + CHECK(doc->applyPatch(patch)); + CHECK_EQ(doc->toString(), std::string("[\"a\",\"b\",\"c\",\"d\"]")); +} + +TEST(patch_add_requires_existing_parent_and_valid_array_index) { + pjson::unique_ptr doc = parseChecked(R"({"a":[1,2]})"); + const pjson before(*doc); + + pjson missingParent = makePatchArray(); + missingParent[0]["op"] = "add"; + missingParent[0]["path"] = "/missing/0"; + missingParent[0]["value"] = static_cast(7); + pjson::PatchError errParent; + CHECK(!doc->applyPatch(missingParent, errParent)); + CHECK(errParent.code == pjson::PatchError::TargetMissing); + CHECK(*doc == before); + + pjson badIndex = makePatchArray(); + badIndex[0]["op"] = "add"; + badIndex[0]["path"] = "/a/01"; + badIndex[0]["value"] = static_cast(7); + pjson::PatchError errIndex; + CHECK(!doc->applyPatch(badIndex, errIndex)); + CHECK(errIndex.code == pjson::PatchError::InvalidArrayIndex); + CHECK(*doc == before); + + pjson outOfRange = makePatchArray(); + outOfRange[0]["op"] = "add"; + outOfRange[0]["path"] = "/a/3"; + outOfRange[0]["value"] = static_cast(7); + pjson::PatchError errRange; + CHECK(!doc->applyPatch(outOfRange, errRange)); + CHECK(errRange.code == pjson::PatchError::ArrayIndexOutOfRange); + CHECK(*doc == before); +} + +//===----------------------------------------------------------------------===// +// JSON Patch: remove / replace +//===----------------------------------------------------------------------===// +TEST(patch_remove_object_member_and_array_element) { + pjson::unique_ptr doc = parseChecked(R"({"a":1,"arr":["x","y","z"]})"); + pjson patch = makePatchArray(); + patch[0]["op"] = "remove"; + patch[0]["path"] = "/a"; + patch[1]["op"] = "remove"; + patch[1]["path"] = "/arr/1"; + + CHECK(doc->applyPatch(patch)); + CHECK_EQ(doc->toString(), std::string("{\"arr\":[\"x\",\"z\"]}")); +} + +TEST(patch_remove_root_succeeds_and_leaves_null) { + pjson::unique_ptr doc = parseChecked(R"({"a":1})"); + pjson patch = makePatchArray(); + patch[0]["op"] = "remove"; + patch[0]["path"] = ""; + + pjson::PatchError err; + CHECK(doc->applyPatch(patch, err)); + CHECK(err.ok); + CHECK(doc->isNull()); +} + +TEST(patch_remove_and_replace_require_existing_target) { + pjson::unique_ptr doc = parseChecked(R"({"a":[1,2],"b":1})"); + const pjson before(*doc); + + pjson removeMissing = makePatchArray(); + removeMissing[0]["op"] = "remove"; + removeMissing[0]["path"] = "/missing"; + pjson::PatchError errRemove; + CHECK(!doc->applyPatch(removeMissing, errRemove)); + CHECK(errRemove.code == pjson::PatchError::TargetMissing); + CHECK(*doc == before); + + pjson replaceMissing = makePatchArray(); + replaceMissing[0]["op"] = "replace"; + replaceMissing[0]["path"] = "/a/3"; + replaceMissing[0]["value"] = static_cast(7); + pjson::PatchError errReplace; + CHECK(!doc->applyPatch(replaceMissing, errReplace)); + CHECK(errReplace.code == pjson::PatchError::ArrayIndexOutOfRange); + CHECK(*doc == before); +} + +TEST(patch_replace_root_and_existing_member) { + pjson::unique_ptr doc = parseChecked(R"({"a":1,"b":2})"); + pjson replaceWhole; + replaceWhole["done"] = true; + + pjson patch = makePatchArray(); + patch[0]["op"] = "replace"; + patch[0]["path"] = "/a"; + patch[0]["value"] = static_cast(9); + patch[1]["op"] = "replace"; + patch[1]["path"] = ""; + patch[1]["value"] = replaceWhole; + + CHECK(doc->applyPatch(patch)); + CHECK_EQ(doc->toString(), std::string("{\"done\":true}")); +} + +//===----------------------------------------------------------------------===// +// JSON Patch: move / copy / test +//===----------------------------------------------------------------------===// +TEST(patch_move_object_member_and_same_array_reorder) { + pjson::unique_ptr doc = parseChecked(R"({"obj":{"a":1},"arr":["a","b","c"]})"); + pjson patch = makePatchArray(); + patch[0]["op"] = "move"; + patch[0]["from"] = "/obj/a"; + patch[0]["path"] = "/obj/b"; + patch[1]["op"] = "move"; + patch[1]["from"] = "/arr/0"; + patch[1]["path"] = "/arr/2"; + + CHECK(doc->applyPatch(patch)); + CHECK_EQ(doc->toString(), std::string("{\"arr\":[\"b\",\"c\",\"a\"],\"obj\":{\"b\":1}}")); +} + +TEST(patch_move_from_must_exist_and_cannot_move_into_descendant) { + pjson::unique_ptr doc = parseChecked(R"({"a":{"b":1},"x":0})"); + const pjson before(*doc); + + pjson missingFrom = makePatchArray(); + missingFrom[0]["op"] = "move"; + missingFrom[0]["from"] = "/missing"; + missingFrom[0]["path"] = "/x"; + pjson::PatchError errMissing; + CHECK(!doc->applyPatch(missingFrom, errMissing)); + CHECK(errMissing.code == pjson::PatchError::TargetMissing); + CHECK(*doc == before); + + pjson intoDescendant = makePatchArray(); + intoDescendant[0]["op"] = "move"; + intoDescendant[0]["from"] = "/a"; + intoDescendant[0]["path"] = "/a/b/c"; + pjson::PatchError errDescendant; + CHECK(!doc->applyPatch(intoDescendant, errDescendant)); + CHECK(errDescendant.code == pjson::PatchError::MoveIntoDescendant); + CHECK(*doc == before); + + pjson rootSource = makePatchArray(); + rootSource[0]["op"] = "move"; + rootSource[0]["from"] = ""; + rootSource[0]["path"] = "/x"; + pjson::PatchError errRootSource; + CHECK(!doc->applyPatch(rootSource, errRootSource)); + CHECK(errRootSource.code == pjson::PatchError::MoveRootNotAllowed); + CHECK(*doc == before); +} + +TEST(patch_copy_duplicates_value_without_mutating_source) { + pjson::unique_ptr doc = parseChecked(R"({"src":{"nested":[1,2]},"dst":0})"); + pjson patch = makePatchArray(); + patch[0]["op"] = "copy"; + patch[0]["from"] = "/src"; + patch[0]["path"] = "/dst"; + + CHECK(doc->applyPatch(patch)); + CHECK_EQ(doc->toString(), + std::string("{\"dst\":{\"nested\":[1,2]},\"src\":{\"nested\":[1,2]}}")); + + pjson* copiedArray = doc->findPointer("/dst/nested"); + CHECK(copiedArray != nullptr); + (*copiedArray)[0] = static_cast(99); + CHECK_EQ(mustGetInt(*doc->findPointer("/src/nested/0")), int64_t(1)); +} + +TEST(patch_test_uses_rfc_numeric_equality_and_fails_atomically) { + pjson::unique_ptr doc = parseChecked(R"({"n":1,"arr":[{"x":1.0}]})"); + const pjson before(*doc); + + pjson pass = makePatchArray(); + pass[0]["op"] = "test"; + pass[0]["path"] = "/n"; + pass[0]["value"] = double(1.0); + pass[1]["op"] = "test"; + pass[1]["path"] = "/arr/0/x"; + pass[1]["value"] = int64_t(1); + CHECK(doc->applyPatch(pass)); + + pjson fail = makePatchArray(); + fail[0]["op"] = "replace"; + fail[0]["path"] = "/n"; + fail[0]["value"] = static_cast(2); + fail[1]["op"] = "test"; + fail[1]["path"] = "/arr/0/x"; + fail[1]["value"] = static_cast(2); + + pjson::PatchError err; + CHECK(!doc->applyPatch(fail, err)); + CHECK(!err.ok); + CHECK(err.code == pjson::PatchError::TestFailed); + CHECK_EQ(err.opIndex, size_t(1)); + CHECK(*doc == before); +} + +TEST(patch_test_numeric_equality_above_2pow53_and_rounded_inequality) { + pjson::unique_ptr exact = parseChecked(R"({"n":9007199254740994})"); + pjson patch = makePatchArray(); + patch[0]["op"] = "test"; + patch[0]["path"] = "/n"; + patch[0]["value"] = double(9007199254740994.0); + CHECK(exact->applyPatch(patch)); + + pjson::unique_ptr rounded = parseChecked(R"({"n":9007199254740993})"); + pjson bad = makePatchArray(); + bad[0]["op"] = "test"; + bad[0]["path"] = "/n"; + bad[0]["value"] = double(9007199254740992.0); + pjson::PatchError err; + CHECK(!rounded->applyPatch(bad, err)); + CHECK(!err.ok); + CHECK(err.code == pjson::PatchError::TestFailed); +} + +TEST(patch_copy_and_move_can_replace_root) { + pjson::unique_ptr copied = parseChecked(R"({"a":{"b":1},"x":2})"); + pjson copyPatch = makePatchArray(); + copyPatch[0]["op"] = "copy"; + copyPatch[0]["from"] = "/a"; + copyPatch[0]["path"] = ""; + CHECK(copied->applyPatch(copyPatch)); + CHECK_EQ(copied->toString(), std::string("{\"b\":1}")); + + pjson::unique_ptr moved = parseChecked(R"({"a":{"b":1},"x":2})"); + pjson movePatch = makePatchArray(); + movePatch[0]["op"] = "move"; + movePatch[0]["from"] = "/a"; + movePatch[0]["path"] = ""; + CHECK(moved->applyPatch(movePatch)); + CHECK_EQ(moved->toString(), std::string("{\"b\":1}")); +} + +//===----------------------------------------------------------------------===// +// JSON Patch: invalid path / from syntax and full rollback +//===----------------------------------------------------------------------===// +TEST(patch_invalid_path_and_from_bubble_structured_errors) { + pjson::unique_ptr doc = parseChecked(R"({"a":1,"b":2})"); + const pjson before(*doc); + + pjson badPath = makePatchArray(); + badPath[0]["op"] = "add"; + badPath[0]["path"] = "a"; + badPath[0]["value"] = static_cast(5); + pjson::PatchError errPath; + CHECK(!doc->applyPatch(badPath, errPath)); + CHECK(errPath.code == pjson::PatchError::InvalidPath); + CHECK_EQ(errPath.path, std::string("a")); + CHECK(*doc == before); + + pjson badFrom = makePatchArray(); + badFrom[0]["op"] = "copy"; + badFrom[0]["from"] = "/~2"; + badFrom[0]["path"] = "/c"; + pjson::PatchError errFrom; + CHECK(!doc->applyPatch(badFrom, errFrom)); + CHECK(errFrom.code == pjson::PatchError::InvalidFrom); + CHECK_EQ(errFrom.from, std::string("/~2")); + CHECK(*doc == before); +} + +TEST(patch_error_object_is_reused_across_failure_and_success) { + pjson::unique_ptr doc = parseChecked(R"({"a":1})"); + pjson::PatchError err; + + pjson failing = makePatchArray(); + failing[0]["op"] = "remove"; + failing[0]["path"] = "/missing"; + CHECK(!doc->applyPatch(failing, err)); + CHECK(!err.ok); + CHECK_EQ(err.code, pjson::PatchError::TargetMissing); + CHECK(!err.message.empty()); + + pjson succeeding = makePatchArray(); + succeeding[0]["op"] = "replace"; + succeeding[0]["path"] = "/a"; + succeeding[0]["value"] = static_cast(2); + CHECK(doc->applyPatch(succeeding, err)); + CHECK(err.ok); + CHECK_EQ(err.code, pjson::PatchError::Ok); + CHECK_EQ(err.opIndex, size_t(0)); + CHECK_EQ(err.op, std::string("")); + CHECK_EQ(err.path, std::string("")); + CHECK_EQ(err.from, std::string("")); + CHECK_EQ(err.tokenIndex, size_t(0)); + CHECK_EQ(err.token, std::string("")); + CHECK_EQ(err.message, std::string("")); +} + +TEST(patch_atomic_rollback_on_late_failure) { + pjson::unique_ptr doc = parseChecked(R"({"a":1,"arr":[10,20]})"); + const pjson before(*doc); + pjson patch = makePatchArray(); + patch[0]["op"] = "replace"; + patch[0]["path"] = "/a"; + patch[0]["value"] = static_cast(9); + patch[1]["op"] = "add"; + patch[1]["path"] = "/arr/-"; + patch[1]["value"] = static_cast(30); + patch[2]["op"] = "remove"; + patch[2]["path"] = "/missing"; + + pjson::PatchError err; + CHECK(!doc->applyPatch(patch, err)); + CHECK(!err.ok); + CHECK_EQ(err.opIndex, size_t(2)); + CHECK(err.code == pjson::PatchError::TargetMissing); + CHECK(*doc == before); +} + +TEST(patch_resource_limits_are_atomic_and_error_is_reusable) { + pjson::unique_ptr doc = parseChecked(R"({"a":1,"nested":{"x":2}})"); + const pjson before(*doc); + pjson patch = makePatchArray(); + patch[0]["op"] = "replace"; + patch[0]["path"] = "/a"; + patch[0]["value"] = int64_t(9); + patch[1]["op"] = "add"; + patch[1]["path"] = "/b"; + patch[1]["value"] = int64_t(3); + + pjson::PatchOptions operations; + operations.maxOperations = 1; + pjson::PatchError error; + CHECK(!doc->applyPatch(patch, error, operations)); + CHECK_EQ(error.code, pjson::PatchError::ResourceLimit); + CHECK(*doc == before); + + pjson::PatchOptions nodes; + nodes.maxClonedNodes = 1; + CHECK(!doc->applyPatch(patch, error, nodes)); + CHECK_EQ(error.code, pjson::PatchError::ResourceLimit); + CHECK(*doc == before); + + pjson::PatchOptions bytes; + bytes.maxClonedBytes = 1; + CHECK(!doc->applyPatch(patch, error, bytes)); + CHECK_EQ(error.code, pjson::PatchError::ResourceLimit); + CHECK(*doc == before); + + pjson one = makePatchArray(); + one[0]["op"] = "replace"; + one[0]["path"] = "/a"; + one[0]["value"] = int64_t(4); + CHECK(doc->applyPatch(one, error)); + CHECK(error.ok); + CHECK_EQ(error.code, pjson::PatchError::Ok); + CHECK_EQ(mustGetInt(*doc->find("a")), int64_t(4)); +} + +TEST(patch_large_string_value_and_copy_respect_clone_byte_limit) { + pjson::unique_ptr doc = parseChecked(R"({"src":"small","keep":1})"); + const pjson before(*doc); + const std::string large(4096, 'x'); + + pjson add = makePatchArray(); + add[0]["op"] = "add"; + add[0]["path"] = "/large"; + add[0]["value"] = large; + pjson::PatchOptions bytes; + bytes.maxClonedBytes = 1024; + pjson::PatchError error; + CHECK(!doc->applyPatch(add, error, bytes)); + CHECK_EQ(error.code, pjson::PatchError::ResourceLimit); + CHECK(*doc == before); + + (*doc)["src"] = large; + const pjson beforeCopy(*doc); + pjson copy = makePatchArray(); + copy[0]["op"] = "copy"; + copy[0]["from"] = "/src"; + copy[0]["path"] = "/dst"; + CHECK(!doc->applyPatch(copy, error, bytes)); + CHECK_EQ(error.code, pjson::PatchError::ResourceLimit); + CHECK(*doc == beforeCopy); +} + +TEST(patch_work_limit_bounds_deep_pointer_and_test_equality) { + pjson doc = makeDeepObjectChain(32, int64_t(1)); + const pjson before(doc); + const std::string path = repeatedObjectPointer("x", 32); + pjson patch = makePatchArray(); + patch[0]["op"] = "test"; + patch[0]["path"] = path; + patch[0]["value"] = int64_t(1); + + pjson::PatchOptions work; + work.maxWork = 16; + pjson::PatchError error; + CHECK(!doc.applyPatch(patch, error, work)); + CHECK_EQ(error.code, pjson::PatchError::ResourceLimit); + CHECK(doc == before); +} + +TEST(patch_zero_limits_use_safe_ceilings_for_small_documents) { + pjson::unique_ptr doc = parseChecked(R"({"a":1})"); + pjson patch = makePatchArray(); + patch[0]["op"] = "replace"; + patch[0]["path"] = "/a"; + patch[0]["value"] = int64_t(2); + pjson::PatchOptions options; + options.maxOperations = 0; + options.maxClonedNodes = 0; + options.maxClonedBytes = 0; + options.maxWork = 0; + CHECK(doc->applyPatch(patch, options)); + CHECK_EQ(mustGetInt(*doc->find("a")), int64_t(2)); +} + +TEST(patch_deep_pointer_and_patch_are_iterative_safe) { + const int depth = 1500; + pjson doc = makeDeepObjectChain(depth, 1); + const std::string path = repeatedObjectPointer("x", depth); + + const pjson* before = doc.findPointer(path); + CHECK(before != nullptr); + CHECK_EQ(mustGetInt(*before), int64_t(1)); + + pjson patch = makePatchArray(); + patch[0]["op"] = "replace"; + patch[0]["path"] = path; + patch[0]["value"] = static_cast(2); + + CHECK(doc.applyPatch(patch)); + const pjson* after = doc.findPointer(path); + CHECK(after != nullptr); + CHECK_EQ(mustGetInt(*after), int64_t(2)); +} + +//===----------------------------------------------------------------------===// +// JSON Merge Patch (RFC 7396) +//===----------------------------------------------------------------------===// +TEST(merge_patch_rfc7396_primary_example) { + pjson::unique_ptr doc = parseChecked( + R"({"title":"Goodbye!","author":{"givenName":"John","familyName":"Doe"},"tags":["example","sample"],"content":"This will be unchanged"})"); + pjson::unique_ptr patch = parseChecked( + R"({"title":"Hello!","phoneNumber":"+01-123-456-7890","author":{"familyName":null},"tags":["example"]})"); + + pjson::PatchError err; + CHECK(doc->applyMergePatch(*patch, err)); + CHECK(err.ok); + CHECK_EQ(doc->toString(), + std::string("{\"author\":{\"givenName\":\"John\"},\"content\":\"This will be " + "unchanged\",\"phoneNumber\":\"+01-123-456-7890\",\"tags\":[\"example\"]," + "\"title\":\"Hello!\"}")); +} + +TEST(merge_patch_null_members_remove_object_keys) { + pjson::unique_ptr doc = parseChecked(R"({"a":1,"b":2,"c":{"x":1,"y":2}})"); + pjson::unique_ptr patch = parseChecked(R"({"a":null,"c":{"y":null}})"); + + CHECK(doc->applyMergePatch(*patch)); + CHECK_EQ(doc->toString(), std::string("{\"b\":2,\"c\":{\"x\":1}}")); +} + +TEST(merge_patch_non_object_patch_replaces_entire_target) { + pjson::unique_ptr arrayDoc = parseChecked(R"({"a":1})"); + pjson::unique_ptr arrayPatch = parseChecked(R"([1,2,3])"); + CHECK(arrayDoc->applyMergePatch(*arrayPatch)); + CHECK_EQ(arrayDoc->toString(), std::string("[1,2,3]")); + + pjson::unique_ptr nullDoc = parseChecked(R"({"a":1})"); + pjson nullPatch; + CHECK(nullDoc->applyMergePatch(nullPatch)); + CHECK(nullDoc->isNull()); + + pjson::unique_ptr scalarDoc = parseChecked(R"({"a":1})"); + pjson scalarPatch; + scalarPatch = static_cast(7); + CHECK(scalarDoc->applyMergePatch(scalarPatch)); + CHECK_EQ(mustGetInt(*scalarDoc), int64_t(7)); +} + +TEST(merge_patch_when_target_is_non_object_object_patch_starts_from_empty_object) { + pjson doc; + doc = static_cast(5); + pjson::unique_ptr patch = parseChecked(R"({"a":1,"b":{"c":2}})"); + + CHECK(doc.applyMergePatch(*patch)); + CHECK_EQ(doc.toString(), std::string("{\"a\":1,\"b\":{\"c\":2}}")); +} + +TEST(merge_patch_arrays_are_replaced_wholesale_not_merged_elementwise) { + pjson::unique_ptr doc = parseChecked(R"({"arr":[1,2,3],"obj":{"arr":[4,5]}})"); + pjson::unique_ptr patch = parseChecked(R"({"arr":[9],"obj":{"arr":[7,8,9]}})"); + + CHECK(doc->applyMergePatch(*patch)); + CHECK_EQ(doc->toString(), std::string("{\"arr\":[9],\"obj\":{\"arr\":[7,8,9]}}")); +} + +TEST(merge_patch_empty_object_is_no_op) { + pjson::unique_ptr doc = parseChecked(R"({"a":1,"b":{"c":2}})"); + const pjson before(*doc); + pjson::unique_ptr patch = parseChecked(R"({})"); + + CHECK(doc->applyMergePatch(*patch)); + CHECK(*doc == before); +} + +TEST(merge_patch_resource_limits_are_atomic) { + pjson::unique_ptr doc = parseChecked(R"({"keep":1,"nested":{"old":true}})"); + const pjson before(*doc); + pjson::unique_ptr patch = parseChecked(R"({"nested":{"new":2},"added":3})"); + + pjson::PatchOptions options; + options.maxWork = 1; + pjson::PatchError error; + CHECK(!doc->applyMergePatch(*patch, error, options)); + CHECK_EQ(error.code, pjson::PatchError::ResourceLimit); + CHECK(*doc == before); + + options = pjson::PatchOptions(); + options.maxClonedNodes = 1; + CHECK(!doc->applyMergePatch(*patch, error, options)); + CHECK_EQ(error.code, pjson::PatchError::ResourceLimit); + CHECK(*doc == before); +} + +TEST(merge_patch_preserves_resource_limit_from_member_processing) { + pjson target; + pjson::unique_ptr patch = parseChecked(R"({"a":1})"); + pjson::PatchOptions options; + options.maxClonedNodes = 1; + pjson::PatchError error; + + CHECK(!target.applyMergePatch(*patch, error, options)); + CHECK_EQ(error.code, pjson::PatchError::ResourceLimit); + CHECK(target.isNull()); +} + +TEST(merge_patch_deep_object_merge_is_iterative_safe) { + const int depth = 1500; + pjson doc = makeDeepObjectChain(depth, 1); + pjson patch; + pjson* cur = &patch; + for (int i = 0; i < depth - 1; ++i) { + cur = &((*cur)["x"]); + } + (*cur)["x"] = int64_t(2); + const std::string path = repeatedObjectPointer("x", depth); + + CHECK(doc.applyMergePatch(patch)); + const pjson* leaf = doc.findPointer(path); + CHECK(leaf != nullptr); + CHECK_EQ(mustGetInt(*leaf), int64_t(2)); +} diff --git a/pjsontest/src/tests_roundtrip.cpp b/pjsontest/src/tests_roundtrip.cpp new file mode 100644 index 0000000..3cafb1a --- /dev/null +++ b/pjsontest/src/tests_roundtrip.cpp @@ -0,0 +1,339 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Round-trip stability (compact + pretty), formatting details, and a +// deterministic fuzz that builds random nested documents and asserts that +// serialize -> parse -> serialize is stable. +// +#include "pjson.h" +#include "test_harness.h" + +#include +#include +#include + +using namespace ByteDance; + +namespace { + + void expectDouble(const pjson& value, double expected) { + double actual = 0.0; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + + pjson::SerializeOptions prettyOptions() { + return pjson::SerializeOptions::prettyPrinted(); + } + +} // namespace + +//===----------------------------------------------------------------------===// +// Number formatting: stable round-tripping with preserved numeric kind +//===----------------------------------------------------------------------===// +TEST(format_double_is_short_and_stable) { + pjson j; + j = double(1.5); + CHECK_EQ(j.toString(), std::string("1.5")); + j = double(0.1); + CHECK_EQ(j.toString(), std::string("0.1")); + j = double(100.0); + CHECK_EQ(j.toString(), std::string("100.0")); // keeps .0 marker + j = double(2.5); + CHECK_EQ(j.toString(), std::string("2.5")); +} + +TEST(format_double_large_integral_value_round_trips_with_double_kind) { + pjson value; + value = double(2738421882738290.0); + pjson::unique_ptr reparsed = pjson::parse(value.toString()); + CHECK(reparsed != nullptr); + CHECK(reparsed->isDouble()); + CHECK(*reparsed == value); +} + +TEST(format_double_high_precision_round_trips) { + pjson j; + j = double(3.141592653589793); + pjson::unique_ptr rt = pjson::parse(j.toString()); + CHECK(rt != nullptr); + expectDouble(*rt, 3.141592653589793); +} + +TEST(format_int_has_no_decimal) { + pjson j; + j = static_cast(42); + CHECK_EQ(j.toString(), std::string("42")); + j = static_cast(-7); + CHECK_EQ(j.toString(), std::string("-7")); +} + +TEST(format_negative_and_zero) { + pjson j; + j = static_cast(0); + CHECK_EQ(j.toString(), std::string("0")); + j = double(0.0); + CHECK_EQ(j.toString(), std::string("0.0")); + j = double(-3.5); + CHECK_EQ(j.toString(), std::string("-3.5")); +} + +//===----------------------------------------------------------------------===// +// Compact round-trip: parse(serialize(x)) reproduces serialize(x) +//===----------------------------------------------------------------------===// +TEST(compact_round_trip_reproduces) { + pjson o; + o["s"] = std::string("text with \"quotes\" and \\slash"); + o["i"] = static_cast(-42); + o["d"] = double(3.5); + o["b"] = true; + o["nil"]; // null + o["arr"] = std::vector({1, 2, 3}); + o["nested"]["deep"]["deeper"] = std::string("value"); + + std::string compact = o.toString(); + pjson::unique_ptr p1 = pjson::parse(compact); + CHECK(p1 != nullptr); + CHECK_EQ(p1->toString(), compact); + // A second generation is identical (idempotent). + pjson::unique_ptr p2 = pjson::parse(p1->toString()); + CHECK(p2 != nullptr); + CHECK_EQ(p2->toString(), compact); +} + +//===----------------------------------------------------------------------===// +// Pretty output: re-parses to the same compact form and is idempotent +//===----------------------------------------------------------------------===// +TEST(pretty_reparses_to_same_compact) { + pjson o; + o["a"] = static_cast(1); + o["b"]["c"] = std::vector({"x", "y"}); + o["d"] = std::vector({1, 2, 3}); + + pjson::SerializeOptions prettyOpts = prettyOptions(); + // Preserve deep pretty-writer traversal coverage without intentionally + // generating quadratic indentation that exceeds the secure output budget. + prettyOpts.indentWidth = 0; + std::string compact = o.toString(); + std::string pretty = o.toString(prettyOpts); + CHECK_NE(compact, pretty); // formatting differs + + pjson::unique_ptr pp = pjson::parse(pretty); + CHECK(pp != nullptr); + CHECK_EQ(pp->toString(), compact); // same data + CHECK_EQ(pp->toString(prettyOpts), pretty); // pretty is idempotent +} + +//===----------------------------------------------------------------------===// +// Empty and edge structures round-trip +//===----------------------------------------------------------------------===// +TEST(empty_structures_round_trip) { + pjson a; + a.resetTo(pjson::jsonArray); + pjson::unique_ptr ra = pjson::parse(a.toString()); + CHECK(ra != nullptr); + CHECK_EQ(ra->getType(), pjson::jsonArray); + CHECK_EQ(ra->toString(), a.toString()); + + pjson m; + m.resetTo(pjson::jsonObject); + pjson::unique_ptr rm = pjson::parse(m.toString()); + CHECK(rm != nullptr); + CHECK_EQ(rm->getType(), pjson::jsonObject); + + pjson n; + CHECK_EQ(n.toString(), std::string("null")); + pjson::unique_ptr rn = pjson::parse(n.toString()); + CHECK(rn != nullptr); + CHECK_EQ(rn->getType(), pjson::jsonNull); +} + +TEST(nested_empty_containers_round_trip) { + pjson::unique_ptr p = pjson::parse("{\"a\":[],\"b\":{},\"c\":[[],{}]}"); + CHECK(p != nullptr); + std::string compact = p->toString(); + pjson::unique_ptr p2 = pjson::parse(compact); + CHECK(p2 != nullptr); + CHECK_EQ(p2->toString(), compact); +} + +//===----------------------------------------------------------------------===// +// The whole value lifecycle is iterative (explicit stacks, not call +// recursion): build, serialize, copy, compare, and destroy a document far +// deeper than the parser's depth guard would ever allow, with no +// stack-overflow crash. +//===----------------------------------------------------------------------===// +TEST(deep_nesting_no_stack_overflow) { + // ~100x the default parse maxDepth of 512, and well past the depth at which + // a recursive implementation overflows the stack (~10k under sanitizers). + const int depth = 50000; + + // Build depth-deep nested objects: {"a":{"a":{ ... {"a":1} ... }}}. + pjson root; + { + pjson* cur = &root; + for (int i = 0; i < depth; ++i) { + cur = &((*cur)["a"]); + } + *cur = static_cast(1); + } + + // Serialize (compact + pretty). + pjson::SerializeOptions prettyOpts = prettyOptions(); + // Exercise the deep pretty traversal without producing quadratic + // indentation that intentionally exceeds the default output budget. + prettyOpts.indentWidth = 0; + std::string compact = root.toString(); + // depth '{' + depth '"a":' (4 chars) + "1" + depth '}'. + CHECK_EQ(compact.size(), static_cast(depth) * 6 + 1); + CHECK_EQ(compact[0], '{'); + CHECK_EQ(compact[compact.size() - 1], '}'); + CHECK(root.toString(prettyOpts).size() > compact.size()); + + // Deep copy + deep equality. + pjson copy = root; + CHECK(copy == root); + + // clear() tears the children down iteratively. + copy.clear(); + CHECK(copy.empty()); + + // Deep arrays too: [[[ ... 1 ... ]]]. + pjson arr; + { + pjson* a = &arr; + for (int i = 0; i < depth; ++i) { + a = &((*a)[0]); + } + *a = static_cast(1); + } + std::string arrCompact = arr.toString(); + CHECK_EQ(arrCompact.size(), static_cast(depth) * 2 + 1); + pjson arrCopy = arr; + CHECK(arrCopy == arr); + // root, arr, arrCopy all destruct here without overflowing the stack. +} + +//===----------------------------------------------------------------------===// +// Deterministic fuzz: random documents survive a serialize/parse cycle +//===----------------------------------------------------------------------===// +namespace { + + // Builds a random pjson value up to the given depth using the provided RNG. + void build_random(pjson& node, std::mt19937& rng, int depth) { + std::uniform_int_distribution kind(0, depth > 0 ? 6 : 4); + switch (kind(rng)) { + case 0: + node.reset(); + break; // null + case 1: + node = (std::uniform_int_distribution(0, 1)(rng) != 0); + break; + case 2: + node = static_cast( + std::uniform_int_distribution(-1000000, 1000000)(rng)); + break; + case 3: + node = std::uniform_real_distribution(-1000.0, 1000.0)(rng); + break; + case 4: { + // Random string including some characters that require escaping. + static const char pool[] = "abc \"\\\n\t/\x01 z"; + std::uniform_int_distribution len(0, 8); + std::uniform_int_distribution pick(0, sizeof(pool) - 2); + std::string s; + int n = len(rng); + for (int i = 0; i < n; ++i) + s += pool[pick(rng)]; + node = s; + break; + } + case 5: { + // Array of random children. + node.resetTo(pjson::jsonArray); + std::uniform_int_distribution len(0, 4); + int n = len(rng); + for (int i = 0; i < n; ++i) { + build_random(node[i], rng, depth - 1); + } + break; + } + default: { + // Map of random children under generated keys. + node.resetTo(pjson::jsonObject); + std::uniform_int_distribution len(0, 4); + int n = len(rng); + for (int i = 0; i < n; ++i) { + std::string key = "k" + std::to_string(i); + build_random(node[key], rng, depth - 1); + } + break; + } + } + } + +} // namespace + +TEST(fuzz_round_trip_is_stable) { + std::mt19937 rng(0xC0FFEE); // fixed seed -> deterministic, reproducible + const pjson::SerializeOptions prettyOpts = prettyOptions(); + for (int iter = 0; iter < 500; ++iter) { + pjson doc; + build_random(doc, rng, 4); + + // Compact: parse(serialize(x)) must reproduce serialize(x) exactly. + std::string compact = doc.toString(); + pjson::unique_ptr rc = pjson::parse(compact); + CHECK(rc != nullptr); + if (rc) { + CHECK_EQ(rc->toString(), compact); + } + + // Pretty: must re-parse to the same compact form. + std::string pretty = doc.toString(prettyOpts); + pjson::unique_ptr rp = pjson::parse(pretty); + CHECK(rp != nullptr); + if (rp) { + CHECK_EQ(rp->toString(), compact); + } + } +} + +TEST(fuzz_never_throws_on_arbitrary_bytes) { + // Feeding random bytes to the parser must never throw or crash; it may + // succeed or return null, but must terminate cleanly. + std::mt19937 rng(0xBADF00D); + std::uniform_int_distribution byte(0, 255); + std::uniform_int_distribution len(0, 40); + int handled = 0; + for (int iter = 0; iter < 1000; ++iter) { + std::string s; + int n = len(rng); + for (int i = 0; i < n; ++i) + s += static_cast(byte(rng)); + pjson::unique_ptr p = pjson::parse(s); // must not throw + if (p) { + // If it parsed, it must re-serialize and re-parse consistently. + std::string out = p->toString(); + pjson::unique_ptr p2 = pjson::parse(out); + CHECK(p2 != nullptr); + if (p2) { + CHECK_EQ(p2->toString(), out); + } + } + ++handled; + } + CHECK_EQ(handled, 1000); +} diff --git a/pjsontest/src/tests_schema.cpp b/pjsontest/src/tests_schema.cpp new file mode 100644 index 0000000..440adff --- /dev/null +++ b/pjsontest/src/tests_schema.cpp @@ -0,0 +1,471 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Schema validation: checking a document against a JSON-Schema-subset schema +// that is itself a pjson object. Covers each supported keyword, JSON-Pointer +// error paths, the collect-all-failures contract, and logical combinators. +// +#include "pjson.h" +#include "test_harness.h" +#include +#include + +using namespace ByteDance; + +namespace { + + pjson::unique_ptr parseJson(const char* text) { + return pjson::parse(std::string(text)); + } + + // Convenience: parse a schema and a document (both from JSON text) and return + // whether the document validates, capturing errors. + bool validates(const char* schemaText, const char* dataText, + std::vector& errors) { + pjson::unique_ptr schema = parseJson(schemaText); + pjson::unique_ptr data = parseJson(dataText); + if (!schema || !data) + return false; + return data->validate(*schema, errors); + } + + bool hasMessageContaining(const std::vector& errors, + const std::string& needle) { + for (size_t i = 0; i < errors.size(); ++i) { + if (errors[i].message.find(needle) != std::string::npos) + return true; + } + return false; + } + + bool validates(const char* schemaText, const char* dataText) { + std::vector errors; + return validates(schemaText, dataText, errors); + } + +} // namespace + +//===----------------------------------------------------------------------===// +// type +//===----------------------------------------------------------------------===// +TEST(schema_type_matches) { + CHECK(validates(R"({"type":"string"})", R"("hi")")); + CHECK(validates(R"({"type":"integer"})", "42")); + CHECK(validates(R"({"type":"number"})", "42")); // integer is a number + CHECK(validates(R"({"type":"number"})", "4.5")); + CHECK(validates(R"({"type":"boolean"})", "true")); + CHECK(validates(R"({"type":"null"})", "null")); + CHECK(validates(R"({"type":"array"})", "[1,2]")); + CHECK(validates(R"({"type":"object"})", R"({"k":1})")); +} + +TEST(schema_type_mismatch_reports_path_and_message) { + std::vector errors; + CHECK(!validates(R"({"type":"integer"})", R"("nope")", errors)); + CHECK_EQ(errors.size(), size_t(1)); + CHECK_EQ(errors[0].path, std::string("")); // root + CHECK(errors[0].message.find("integer") != std::string::npos); + CHECK(errors[0].message.find("string") != std::string::npos); +} + +TEST(schema_type_integer_vs_number) { + CHECK(!validates(R"({"type":"integer"})", "4.5")); // fractional not integer + CHECK(validates(R"({"type":"integer"})", "4.0")); // whole double is integer +} + +TEST(schema_type_array_of_allowed) { + CHECK(validates(R"({"type":["string","null"]})", R"("x")")); + CHECK(validates(R"({"type":["string","null"]})", "null")); + CHECK(!validates(R"({"type":["string","null"]})", "5")); +} + +//===----------------------------------------------------------------------===// +// required / properties / additionalProperties +//===----------------------------------------------------------------------===// +TEST(schema_required_present) { + CHECK( + validates(R"({"type":"object","required":["name","age"]})", R"({"name":"Ada","age":36})")); +} + +TEST(schema_required_missing) { + std::vector errors; + CHECK( + !validates(R"({"type":"object","required":["name","age"]})", R"({"name":"Ada"})", errors)); + CHECK_EQ(errors.size(), size_t(1)); + CHECK_EQ(errors[0].path, std::string("")); + CHECK(errors[0].message.find("age") != std::string::npos); +} + +TEST(schema_properties_recurse_with_path) { + const char* schema = + R"({"type":"object","properties":{ + "age":{"type":"integer"}, + "name":{"type":"string"}}})"; + std::vector errors; + CHECK(!validates(schema, R"({"age":"old","name":"Ada"})", errors)); + CHECK_EQ(errors.size(), size_t(1)); + CHECK_EQ(errors[0].path, std::string("/age")); // JSON-Pointer to the child +} + +TEST(schema_additional_properties_false) { + const char* schema = + R"({"type":"object","properties":{"a":{"type":"integer"}}, + "additionalProperties":false})"; + CHECK(validates(schema, R"({"a":1})")); + std::vector errors; + CHECK(!validates(schema, R"({"a":1,"b":2})", errors)); + CHECK_EQ(errors.size(), size_t(1)); + CHECK_EQ(errors[0].path, std::string("/b")); +} + +TEST(schema_min_max_properties) { + CHECK(!validates(R"({"minProperties":2})", R"({"a":1})")); + CHECK(validates(R"({"minProperties":2})", R"({"a":1,"b":2})")); + CHECK(!validates(R"({"maxProperties":1})", R"({"a":1,"b":2})")); +} + +//===----------------------------------------------------------------------===// +// items / array constraints +//===----------------------------------------------------------------------===// +TEST(schema_items_applies_to_each_element) { + CHECK(validates(R"({"type":"array","items":{"type":"integer"}})", "[1,2,3]")); + std::vector errors; + CHECK(!validates(R"({"type":"array","items":{"type":"integer"}})", R"([1,"two",3])", errors)); + CHECK_EQ(errors.size(), size_t(1)); + CHECK_EQ(errors[0].path, std::string("/1")); // index of the bad element +} + +TEST(schema_min_max_items) { + CHECK(!validates(R"({"minItems":2})", "[1]")); + CHECK(validates(R"({"minItems":2})", "[1,2]")); + CHECK(!validates(R"({"maxItems":2})", "[1,2,3]")); +} + +TEST(schema_unique_items) { + CHECK(validates(R"({"uniqueItems":true})", "[1,2,3]")); + CHECK(!validates(R"({"uniqueItems":true})", "[1,2,2]")); + CHECK(!validates(R"({"uniqueItems":true})", R"([{"a":1},{"a":1}])")); // deep dup +} + +//===----------------------------------------------------------------------===// +// numeric constraints +//===----------------------------------------------------------------------===// +TEST(schema_minimum_maximum) { + CHECK(validates(R"({"minimum":0,"maximum":100})", "50")); + CHECK(!validates(R"({"minimum":18})", "5")); + CHECK(!validates(R"({"maximum":10})", "11")); + CHECK(validates(R"({"minimum":18})", "18")); // inclusive +} + +TEST(schema_exclusive_bounds) { + CHECK(!validates(R"({"exclusiveMinimum":0})", "0")); + CHECK(validates(R"({"exclusiveMinimum":0})", "1")); + CHECK(!validates(R"({"exclusiveMaximum":10})", "10")); +} + +TEST(schema_multiple_of) { + CHECK(validates(R"({"multipleOf":5})", "15")); + CHECK(!validates(R"({"multipleOf":5})", "13")); + CHECK(validates(R"({"multipleOf":0.5})", "2.5")); +} + +//===----------------------------------------------------------------------===// +// string constraints +//===----------------------------------------------------------------------===// +TEST(schema_length) { + CHECK(!validates(R"({"minLength":3})", R"("ab")")); + CHECK(validates(R"({"minLength":3})", R"("abc")")); + CHECK(!validates(R"({"maxLength":3})", R"("abcd")")); +} + +TEST(schema_pattern) { + CHECK(validates(R"({"pattern":"^[a-z]+$"})", R"("hello")")); + CHECK(!validates(R"({"pattern":"^[a-z]+$"})", R"("Hello1")")); +} + +TEST(schema_pattern_redos_safety_policy) { + pjson::unique_ptr schema = parseJson(R"({"pattern":"^(a+)+$","minLength":10})"); + pjson::unique_ptr value = parseJson(R"("aaaa")"); + CHECK(schema != nullptr); + CHECK(value != nullptr); + std::vector errors; + CHECK(!value->validate(*schema, errors)); + CHECK_EQ(errors.size(), size_t(2)); // policy failure + minLength (collect all) + CHECK(errors[0].message.find("minLength") != std::string::npos || + errors[1].message.find("minLength") != std::string::npos); + CHECK(errors[0].message.find("safety policy") != std::string::npos || + errors[1].message.find("safety policy") != std::string::npos); + + pjson::unique_ptr alternation = parseJson(R"({"pattern":"^(a|aa)+$"})"); + errors.clear(); + CHECK(!value->validate(*alternation, errors)); + CHECK(errors[0].message.find("safety policy") != std::string::npos); + + pjson::unique_ptr hugeRepeat = parseJson(R"({"pattern":"^a{1000000}$"})"); + errors.clear(); + CHECK(!value->validate(*hugeRepeat, errors)); + CHECK(errors[0].message.find("safety policy") != std::string::npos); +} + +TEST(schema_pattern_size_limits_and_trusted_opt_in) { + pjson schema; + schema["pattern"] = std::string(257, 'a'); + pjson value; + value = "a"; + std::vector errors; + CHECK(!value.validate(schema, errors)); + CHECK(errors[0].message.find("pattern exceeds") != std::string::npos); + + schema["pattern"] = "a"; + value = std::string(4097, 'a'); + errors.clear(); + CHECK(!value.validate(schema, errors)); + CHECK(errors[0].message.find("string exceeds") != std::string::npos); + + // Trusted applications may explicitly restore unrestricted behavior. + pjson::SchemaOptions trusted = pjson::SchemaOptions::trustedRegex(); + errors.clear(); + CHECK(value.validate(schema, errors, trusted)); + CHECK(errors.empty()); +} + +//===----------------------------------------------------------------------===// +// const / enum +//===----------------------------------------------------------------------===// +TEST(schema_const) { + CHECK(validates(R"({"const":42})", "42")); + CHECK(validates(R"({"const":42})", "42.0")); // numeric equality + CHECK(!validates(R"({"const":42})", "43")); + CHECK(validates(R"({"const":{"a":[1,2]}})", R"({"a":[1,2]})")); // deep +} + +TEST(schema_enum) { + CHECK(validates(R"({"enum":["red","green","blue"]})", R"("green")")); + CHECK(!validates(R"({"enum":["red","green","blue"]})", R"("purple")")); + CHECK(validates(R"({"enum":[1,2,3]})", "2")); +} + +//===----------------------------------------------------------------------===// +// logical combinators +//===----------------------------------------------------------------------===// +TEST(schema_allof) { + const char* schema = R"({"allOf":[{"type":"integer"},{"minimum":10}]})"; + CHECK(validates(schema, "15")); + CHECK(!validates(schema, "5")); // fails minimum + CHECK(!validates(schema, R"("x")")); // fails type +} + +TEST(schema_anyof) { + const char* schema = R"({"anyOf":[{"type":"string"},{"type":"integer"}]})"; + CHECK(validates(schema, R"("x")")); + CHECK(validates(schema, "5")); + CHECK(!validates(schema, "true")); +} + +TEST(schema_oneof) { + // Exactly one branch must match. + const char* schema = R"({"oneOf":[{"type":"integer"},{"minimum":100}]})"; + CHECK(validates(schema, "5")); // integer only (5 < 100) + CHECK(!validates(schema, "150")); // matches both integer and minimum -> fails + CHECK(validates(schema, "150.5")); // matches only minimum +} + +TEST(schema_not) { + CHECK(validates(R"({"not":{"type":"string"}})", "5")); + CHECK(!validates(R"({"not":{"type":"string"}})", R"("x")")); +} + +TEST(schema_boolean_schemas) { + CHECK(validates("true", R"({"anything":1})")); + CHECK(!validates("false", "1")); +} + +//===----------------------------------------------------------------------===// +// collect-all: multiple independent failures reported together +//===----------------------------------------------------------------------===// +TEST(schema_collects_all_failures) { + const char* schema = + R"({"type":"object", + "required":["name","age","email"], + "properties":{ + "age":{"type":"integer","minimum":0}, + "name":{"type":"string"}}})"; + // age is a negative string (2 problems), name is a number (1), email missing (1). + std::vector errors; + CHECK(!validates(schema, R"({"age":"x","name":5})", errors)); + // Expect: missing email, /age type, /name type. (age minimum can't run on a + // non-number.) At least three distinct failures collected. + CHECK(errors.size() >= size_t(3)); +} + +TEST(schema_valid_document_has_no_errors) { + const char* schema = + R"({"type":"object", + "required":["name","age"], + "properties":{ + "name":{"type":"string","minLength":1}, + "age":{"type":"integer","minimum":0}, + "tags":{"type":"array","items":{"type":"string"}}}, + "additionalProperties":false})"; + std::vector errors; + CHECK(validates(schema, R"({"name":"Ada","age":36,"tags":["x","y"]})", errors)); + CHECK_EQ(errors.size(), size_t(0)); +} + +//===----------------------------------------------------------------------===// +// validate() built with the programmatic API (schema is a pjson object) +//===----------------------------------------------------------------------===// +TEST(schema_built_programmatically) { + pjson schema; + schema["type"] = "object"; + schema["required"][0] = "name"; + schema["required"][1] = "age"; + schema["properties"]["name"]["type"] = "string"; + schema["properties"]["age"]["type"] = "integer"; + schema["properties"]["age"]["minimum"] = int64_t(0); + + pjson::unique_ptr data = parseJson(R"({"name":"Ada","age":36})"); + CHECK(data->validate(schema)); + + pjson::unique_ptr bad = parseJson(R"({"name":"Ada","age":-1})"); + std::vector errors; + CHECK(!bad->validate(schema, errors)); + CHECK_EQ(errors.size(), size_t(1)); + CHECK_EQ(errors[0].path, std::string("/age")); +} + +//===----------------------------------------------------------------------===// +// JSON-Pointer escaping for keys containing '/' or '~' +//===----------------------------------------------------------------------===// +TEST(schema_pointer_escaping) { + const char* schema = R"({"type":"object","properties":{"a/b":{"type":"integer"}}})"; + std::vector errors; + CHECK(!validates(schema, R"({"a/b":"x"})", errors)); + CHECK_EQ(errors.size(), size_t(1)); + CHECK_EQ(errors[0].path, std::string("/a~1b")); // '/' escaped as ~1 +} + +TEST(schema_const_exact_mixed_numeric_equality_beyond_2pow53) { + pjson schema; + schema["const"] = int64_t(9007199254740993LL); + + pjson exact; + exact = int64_t(9007199254740993LL); + CHECK(exact.validate(schema)); + + pjson rounded; + rounded = double(9007199254740992.0); + CHECK(!rounded.validate(schema)); +} + +TEST(schema_enum_exact_mixed_numeric_equality_beyond_2pow53) { + pjson schema; + schema["enum"][0] = int64_t(9007199254740993LL); + schema["enum"][1] = int64_t(7); + + pjson exact; + exact = int64_t(9007199254740993LL); + CHECK(exact.validate(schema)); + + pjson rounded; + rounded = double(9007199254740992.0); + CHECK(!rounded.validate(schema)); +} + +TEST(schema_unique_items_exact_mixed_numeric_equality_beyond_2pow53) { + pjson schema; + schema["uniqueItems"] = true; + + pjson distinct; + distinct[0] = int64_t(9007199254740993LL); + distinct[1] = double(9007199254740992.0); + CHECK(distinct.validate(schema)); + + pjson duplicate; + duplicate[0] = int64_t(9007199254740992LL); + duplicate[1] = double(9007199254740992.0); + CHECK(!duplicate.validate(schema)); +} + +TEST(schema_exact_numeric_bounds_beyond_2pow53) { + pjson minimumSchema; + minimumSchema["minimum"] = int64_t(9007199254740993LL); + + pjson below; + below = int64_t(9007199254740992LL); + CHECK(!below.validate(minimumSchema)); + pjson belowDouble; + belowDouble = double(9007199254740992.0); + CHECK(!belowDouble.validate(minimumSchema)); + + pjson at; + at = int64_t(9007199254740993LL); + CHECK(at.validate(minimumSchema)); + + pjson exclusiveMaximumSchema; + exclusiveMaximumSchema["exclusiveMaximum"] = int64_t(9007199254740993LL); + CHECK(at.validate(minimumSchema)); + CHECK(!at.validate(exclusiveMaximumSchema)); + + pjson maximumDoubleSchema; + maximumDoubleSchema["maximum"] = double(9007199254740992.0); + CHECK(!at.validate(maximumDoubleSchema)); +} + +TEST(schema_length_counts_unicode_code_points) { + CHECK(validates(R"({"minLength":1,"maxLength":1})", "\"\xC3\xA9\"")); + CHECK(validates(R"({"minLength":1,"maxLength":1})", "\"\xF0\x9F\x98\x80\"")); + CHECK(validates(R"({"minLength":2,"maxLength":2})", "\"\xC3\xA9\xE2\x82\xAC\"")); + CHECK(!validates(R"({"maxLength":1})", "\"\xC3\xA9\xE2\x82\xAC\"")); + CHECK(!validates(R"({"maxLength":1})", "\"e\xCC\x81\"")); +} + +TEST(schema_integral_keyword_shapes) { + CHECK(validates(R"({"minLength":1.5})", R"("")")); + CHECK(validates(R"({"maxLength":2.5})", R"("abcd")")); + CHECK(validates(R"({"minItems":1.5})", "[]")); + CHECK(validates(R"({"maxItems":0.5})", "[1,2,3]")); + CHECK(validates(R"({"minProperties":1.25})", R"({})")); + CHECK(validates(R"({"maxProperties":0.5})", R"({"a":1})")); + CHECK(validates(R"({"minLength":-1})", R"("")")); + CHECK(validates(R"({"maxItems":"1"})", "[1,2]")); + + CHECK(!validates(R"({"minLength":2.0})", R"("a")")); + CHECK(!validates(R"({"maxItems":1.0})", "[1,2]")); + CHECK(!validates(R"({"maxProperties":0.0})", R"({"a":1})")); +} + +TEST(schema_malformed_not_shape_is_ignored) { + CHECK(validates(R"({"not":5})", "1")); + CHECK(validates(R"({"not":"schema"})", R"({"value":true})")); +} + +TEST(schema_error_constructors_and_collector_append) { + pjson::SchemaError empty; + CHECK_EQ(empty.path, std::string()); + CHECK_EQ(empty.message, std::string()); + + pjson::SchemaError concrete("/age", "expected integer"); + CHECK_EQ(concrete.path, std::string("/age")); + CHECK_EQ(concrete.message, std::string("expected integer")); + + std::vector errors; + errors.push_back(pjson::SchemaError("/seed", "existing")); + CHECK(!validates(R"({"type":"object","required":["name"]})", R"({})", errors)); + CHECK_EQ(errors[0].path, std::string("/seed")); + CHECK_EQ(errors[0].message, std::string("existing")); + CHECK(errors.size() >= size_t(2)); + CHECK(hasMessageContaining(errors, "missing required property")); +} diff --git a/pjsontest/src/tests_schema_complex.cpp b/pjsontest/src/tests_schema_complex.cpp new file mode 100644 index 0000000..76c18a0 --- /dev/null +++ b/pjsontest/src/tests_schema_complex.cpp @@ -0,0 +1,361 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Complex schema validation: realistic nested schemas, combinator nesting, +// deep JSON-Pointer error paths, and the collect-all-failures contract at +// scale. +// +#include "pjson.h" +#include "test_harness.h" +#include +#include +#include + +using namespace ByteDance; + +namespace { + + pjson::unique_ptr parseJson(const char* text) { + return pjson::parse(std::string(text)); + } + + // Returns true if some collected error has exactly this path. + bool hasErrorAt(const std::vector& errs, const std::string& path) { + for (const auto& e : errs) { + if (e.path == path) + return true; + } + return false; + } + + // A reasonably complex, realistic schema reused by several tests. + const char* kPersonSchema = R"({ + "type": "object", + "required": ["id", "name", "email"], + "additionalProperties": false, + "properties": { + "id": { "type": "integer", "minimum": 1 }, + "name": { "type": "string", "minLength": 1, "maxLength": 50 }, + "email": { "type": "string", "pattern": "@" }, + "age": { "type": "integer", "minimum": 0, "maximum": 150 }, + "roles": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "enum": ["admin", "user", "guest"] } + }, + "address": { + "type": "object", + "required": ["city"], + "properties": { + "city": { "type": "string" }, + "zip": { "type": "string", "pattern": "^[0-9]{5}$" } + } + } + } +})"; + +} // namespace + +//===----------------------------------------------------------------------===// +// A fully valid, deeply nested document passes with zero errors. +//===----------------------------------------------------------------------===// +TEST(complex_schema_valid_document) { + pjson::unique_ptr schema = parseJson(kPersonSchema); + CHECK(schema != nullptr); + pjson::unique_ptr data = parseJson(R"({ + "id": 42, + "name": "Ada Lovelace", + "email": "ada@example.com", + "age": 36, + "roles": ["admin", "user"], + "address": { "city": "London", "zip": "12345" } + })"); + CHECK(data != nullptr); + std::vector errors; + CHECK(data->validate(*schema, errors)); + CHECK_EQ(errors.size(), size_t(0)); +} + +//===----------------------------------------------------------------------===// +// Every violation across the tree is collected in a single pass. +//===----------------------------------------------------------------------===// +TEST(complex_schema_collects_all_violations) { + pjson::unique_ptr schema = parseJson(kPersonSchema); + pjson::unique_ptr data = parseJson(R"({ + "id": 0, + "name": "", + "email": "no-at-sign", + "age": 200, + "roles": [], + "address": { "zip": "abc" }, + "extra": true + })"); + CHECK(data != nullptr); + std::vector errors; + CHECK(!data->validate(*schema, errors)); + + // Each independent problem should be reported with its own pointer path. + CHECK(hasErrorAt(errors, "/id")); // below minimum 1 + CHECK(hasErrorAt(errors, "/name")); // below minLength 1 + CHECK(hasErrorAt(errors, "/email")); // fails pattern + CHECK(hasErrorAt(errors, "/age")); // above maximum 150 + CHECK(hasErrorAt(errors, "/roles")); // below minItems 1 + CHECK(hasErrorAt(errors, "/address")); // missing required "city" + CHECK(hasErrorAt(errors, "/extra")); // additionalProperties: false + CHECK(errors.size() >= size_t(7)); +} + +//===----------------------------------------------------------------------===// +// Deeply nested arrays-of-objects report the exact element path. +//===----------------------------------------------------------------------===// +TEST(complex_schema_deep_pointer_path) { + const char* schema = R"({ + "properties": { + "matrix": { + "type": "array", + "items": { + "type": "array", + "items": { "type": "integer" } + } + } + } + })"; + pjson::unique_ptr s = parseJson(schema); + pjson::unique_ptr d = parseJson(R"({ "matrix": [[1,2],[3,"bad"],[5]] })"); + std::vector errors; + CHECK(!d->validate(*s, errors)); + CHECK_EQ(errors.size(), size_t(1)); + CHECK_EQ(errors[0].path, std::string("/matrix/1/1")); +} + +//===----------------------------------------------------------------------===// +// Nested combinators: allOf of several object constraints. +//===----------------------------------------------------------------------===// +TEST(complex_schema_allof_object_constraints) { + const char* schema = R"({ + "allOf": [ + { "type": "object" }, + { "required": ["a"] }, + { "properties": { "a": { "type": "integer" } } } + ] + })"; + pjson::unique_ptr good = parseJson("{\"a\":5}"); + pjson::unique_ptr schemaValue = parseJson(schema); + CHECK(good->validate(*schemaValue)); + std::vector errors; + pjson::unique_ptr bad = parseJson("{\"a\":\"x\"}"); + CHECK(!bad->validate(*schemaValue, errors)); + CHECK(hasErrorAt(errors, "/a")); +} + +TEST(complex_schema_anyof_branches) { + const char* schema = R"({ + "anyOf": [ + { "required": ["a"] }, + { "required": ["b"] } + ] + })"; + pjson::unique_ptr schemaValue = parseJson(schema); + CHECK(parseJson("{\"a\":1}")->validate(*schemaValue)); + CHECK(parseJson("{\"b\":1}")->validate(*schemaValue)); + CHECK(!parseJson("{\"c\":1}")->validate(*schemaValue)); +} + +TEST(complex_schema_oneof_exactly_one) { + // A value that satisfies two branches must FAIL oneOf. + const char* schema = R"({ "oneOf": [ { "type": "number" }, { "type": "integer" } ] })"; + pjson::unique_ptr schemaValue = parseJson(schema); + CHECK(parseJson("2.5")->validate(*schemaValue)); // number only + CHECK(!parseJson("5")->validate(*schemaValue)); // both number and integer +} + +TEST(complex_schema_not_nested) { + const char* schema = R"({ "not": { "required": ["forbidden"] } })"; + pjson::unique_ptr schemaValue = parseJson(schema); + CHECK(parseJson("{\"ok\":1}")->validate(*schemaValue)); + CHECK(!parseJson("{\"forbidden\":1}")->validate(*schemaValue)); +} + +TEST(complex_schema_combinator_inside_properties) { + const char* schema = R"({ + "properties": { + "val": { "anyOf": [ { "type": "string" }, { "type": "integer" } ] } + } + })"; + pjson::unique_ptr schemaValue = parseJson(schema); + CHECK(parseJson("{\"val\":\"x\"}")->validate(*schemaValue)); + CHECK(parseJson("{\"val\":7}")->validate(*schemaValue)); + std::vector errors; + CHECK(!parseJson("{\"val\":true}")->validate(*schemaValue, errors)); + CHECK(hasErrorAt(errors, "/val")); +} + +//===----------------------------------------------------------------------===// +// Boolean sub-schemas. +//===----------------------------------------------------------------------===// +TEST(complex_schema_items_false_rejects_nonempty) { + pjson::unique_ptr schemaValue = parseJson(R"({"items":false})"); + CHECK(parseJson("[]")->validate(*schemaValue)); + std::vector errors; + CHECK(!parseJson("[1]")->validate(*schemaValue, errors)); + CHECK_EQ(errors[0].path, std::string("/0")); +} + +TEST(complex_schema_property_true_false) { + const char* schema = R"({ "properties": { "yes": true, "no": false } })"; + pjson::unique_ptr schemaValue = parseJson(schema); + CHECK(parseJson("{\"yes\":123}")->validate(*schemaValue)); // true accepts + CHECK(!parseJson("{\"no\":1}")->validate(*schemaValue)); // false rejects presence +} + +//===----------------------------------------------------------------------===// +// Constraints on the wrong node type are simply skipped (not errors). +//===----------------------------------------------------------------------===// +TEST(complex_schema_irrelevant_constraints_ignored) { + // minItems on a number, minLength on an array, etc. do not fire. + CHECK(parseJson("5")->validate(*parseJson(R"({"minItems":3})"))); + CHECK(parseJson("[1]")->validate(*parseJson(R"({"minLength":3})"))); + CHECK(parseJson("\"hi\"")->validate(*parseJson(R"({"minimum":100})"))); + CHECK(parseJson("5")->validate( + *parseJson(R"({"required":["a"]})"))); // required only checks objects +} + +//===----------------------------------------------------------------------===// +// uniqueItems with deep (structural) comparison. +//===----------------------------------------------------------------------===// +TEST(complex_schema_unique_items_deep) { + pjson::unique_ptr schemaValue = parseJson(R"({"uniqueItems":true})"); + CHECK(parseJson("[[1,2],[1,3]]")->validate(*schemaValue)); + CHECK(!parseJson("[[1,2],[1,2]]")->validate(*schemaValue)); + CHECK(!parseJson(R"([{"a":1},{"a":1}])")->validate(*schemaValue)); + CHECK(parseJson(R"([{"a":1},{"a":2}])")->validate(*schemaValue)); +} + +//===----------------------------------------------------------------------===// +// enum / const with structured (array / object) values. +//===----------------------------------------------------------------------===// +TEST(complex_schema_enum_structured) { + const char* schema = R"({ "enum": [ {"a":1}, [1,2,3], "text" ] })"; + pjson::unique_ptr schemaValue = parseJson(schema); + CHECK(parseJson(R"({"a":1})")->validate(*schemaValue)); + CHECK(parseJson("[1,2,3]")->validate(*schemaValue)); + CHECK(parseJson("\"text\"")->validate(*schemaValue)); + CHECK(!parseJson("[1,2]")->validate(*schemaValue)); + CHECK(!parseJson(R"({"a":2})")->validate(*schemaValue)); +} + +TEST(complex_schema_const_structured) { + const char* schema = R"({ "const": { "nested": [1, {"x": true}] } })"; + pjson::unique_ptr schemaValue = parseJson(schema); + CHECK(parseJson(R"({"nested":[1,{"x":true}]})")->validate(*schemaValue)); + CHECK(!parseJson(R"({"nested":[1,{"x":false}]})")->validate(*schemaValue)); +} + +//===----------------------------------------------------------------------===// +// multipleOf with fractional divisors. +//===----------------------------------------------------------------------===// +TEST(complex_schema_multiple_of_fractions) { + CHECK(parseJson("0.3")->validate(*parseJson(R"({"multipleOf":0.1})"))); + CHECK(parseJson("15")->validate(*parseJson(R"({"multipleOf":5})"))); + CHECK(!parseJson("14")->validate(*parseJson(R"({"multipleOf":5})"))); + // Divisor of zero is guarded (treated as no constraint). + CHECK(parseJson("5")->validate(*parseJson(R"({"multipleOf":0})"))); +} + +//===----------------------------------------------------------------------===// +// The empty schema and unknown keywords accept everything. +//===----------------------------------------------------------------------===// +TEST(complex_schema_empty_and_unknown) { + CHECK(parseJson("5")->validate(*parseJson("{}"))); + CHECK(parseJson("[1,2,3]")->validate(*parseJson("{}"))); + CHECK(parseJson(R"({"a":1})") + ->validate(*parseJson(R"({"title":"ignored","description":"also ignored"})"))); + // A schema-valued additionalProperties constraint applies to every key + // not matched by properties or patternProperties. + CHECK(!parseJson(R"({"x":"str"})") + ->validate(*parseJson(R"({"additionalProperties":{"type":"integer"}})"))); + CHECK(parseJson(R"({"x":7})") + ->validate(*parseJson(R"({"additionalProperties":{"type":"integer"}})"))); +} + +//===----------------------------------------------------------------------===// +// A schema built programmatically behaves identically to a parsed one. +//===----------------------------------------------------------------------===// +TEST(complex_schema_built_vs_parsed_equivalent) { + pjson::unique_ptr parsed = parseJson(kPersonSchema); + + // Validate the same doc against both and compare pass/fail + error count. + pjson::unique_ptr data = parseJson(R"({ "id": 1, "name": "X", "email": "x@y" })"); + std::vector e1; + bool ok1 = data->validate(*parsed, e1); + CHECK(ok1); + CHECK_EQ(e1.size(), size_t(0)); +} + +//===----------------------------------------------------------------------===// +// type as an array of allowed names, nested in properties. +//===----------------------------------------------------------------------===// +TEST(complex_schema_type_union) { + const char* schema = R"({ + "properties": { "id": { "type": ["integer", "string"] } } + })"; + pjson::unique_ptr schemaValue = parseJson(schema); + CHECK(parseJson(R"({"id":5})")->validate(*schemaValue)); + CHECK(parseJson(R"({"id":"abc"})")->validate(*schemaValue)); + std::vector errors; + CHECK(!parseJson(R"({"id":true})")->validate(*schemaValue, errors)); + CHECK(hasErrorAt(errors, "/id")); +} + +//===----------------------------------------------------------------------===// +// A large array validated element-by-element collects one error per bad item. +//===----------------------------------------------------------------------===// +TEST(complex_schema_large_array_collects_per_element) { + // Build [0,1,...,99] but make every 10th element a string. + pjson data; + data.resetTo(pjson::jsonArray); + int expectedBad = 0; + for (int i = 0; i < 100; ++i) { + if (i % 10 == 0) { + data[i] = std::string("bad"); + ++expectedBad; + } else { + data[i] = int64_t(i); + } + } + pjson::unique_ptr schema = parseJson(R"({ "type": "array", "items": { "type": "integer" } })"); + std::vector errors; + CHECK(!data.validate(*schema, errors)); + CHECK_EQ(errors.size(), static_cast(expectedBad)); + // The first bad element is at index 0. + CHECK(hasErrorAt(errors, "/0")); + CHECK(hasErrorAt(errors, "/90")); +} + +TEST(complex_schema_unique_items_exact_mixed_numeric_equality_beyond_2pow53) { + pjson schema; + schema["uniqueItems"] = true; + + pjson dataDistinct; + dataDistinct[0] = int64_t(9007199254740993LL); + dataDistinct[1] = double(9007199254740992.0); + CHECK(dataDistinct.validate(schema)); + + pjson dataEqual; + dataEqual[0] = int64_t(9007199254740992LL); + dataEqual[1] = double(9007199254740992.0); + CHECK(!dataEqual.validate(schema)); +} diff --git a/pjsontest/src/tests_schema_official.cpp b/pjsontest/src/tests_schema_official.cpp new file mode 100644 index 0000000..4705b15 --- /dev/null +++ b/pjsontest/src/tests_schema_official.cpp @@ -0,0 +1,581 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Optional official draft-07 JSON-Schema-Test-Suite conformance integration. +// This harness intentionally uses an explicit +// manifest so unsupported files or groups are skipped with a concrete reason +// instead of disappearing through ad-hoc filtering. +// +#include "pjson.h" +#include "test_harness.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#include +#include +#endif + +using namespace ByteDance; + +#ifndef PJSON_TEST_DEFAULT_JSON_SCHEMA_TEST_SUITE_DIR +#define PJSON_TEST_DEFAULT_JSON_SCHEMA_TEST_SUITE_DIR "" +#endif + +namespace { + + pjson::unique_ptr parseJson(const std::string& text, pjson::ParseError* error = NULL) { + if (error != NULL) { + return pjson::parse(text, *error, pjson::ParseOptions()); + } + return pjson::parse(text, pjson::ParseOptions()); + } + + // Every upstream file is either fully run, fully skipped, or filtered by named groups. + enum ManifestMode { RunWholeFile, SkipWholeFile, RunSelectedGroups }; + + // Explicit allow/skip decision for a group whose upstream description is its stable key. + struct GroupRule { + const char* description; + bool enabled; + const char* reason; + }; + + // Per-file manifest entry; `groups` is populated only for RunSelectedGroups. + struct FileRule { + const char* relativePath; + ManifestMode mode; + const char* reason; + std::vector groups; + }; + + // Accumulates execution coverage for the informational suite summary. + struct RunSummary { + size_t filesVisited; + size_t filesSkipped; + size_t groupsRun; + size_t groupsSkipped; + size_t casesRun; + size_t casesSkipped; + + RunSummary() + : filesVisited(0) + , filesSkipped(0) + , groupsRun(0) + , groupsSkipped(0) + , casesRun(0) + , casesSkipped(0) {} + }; + + // Cross-platform suite-location and file-loading helpers. + + std::string joinPath(const std::string& base, const std::string& leaf) { + if (base.empty()) { + return leaf; + } + + const char last = base[base.size() - 1]; + if (last == '/' || last == '\\') { + return base + leaf; + } + +#if defined(_WIN32) + return base + "\\" + leaf; +#else + return base + "/" + leaf; +#endif + } + + bool isDirectory(const std::string& path) { + if (path.empty()) { + return false; + } + +#if defined(_WIN32) + const DWORD attrs = GetFileAttributesA(path.c_str()); + return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY) != 0; +#else + struct stat st; + return ::stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode); +#endif + } + + bool isRegularFile(const std::string& path) { + if (path.empty()) { + return false; + } + +#if defined(_WIN32) + const DWORD attrs = GetFileAttributesA(path.c_str()); + return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY) == 0; +#else + struct stat st; + return ::stat(path.c_str(), &st) == 0 && S_ISREG(st.st_mode); +#endif + } + + std::string readFile(const std::string& path) { + std::ifstream in(path.c_str(), std::ios::binary); + if (!in) { + return std::string(); + } + + return std::string((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + } + + std::string configuredSchemaSuiteDir() { + const char* env = std::getenv("PJSON_JSON_SCHEMA_TEST_SUITE_DIR"); + if (env != NULL && env[0] != '\0') { + return std::string(env); + } + return std::string(PJSON_TEST_DEFAULT_JSON_SCHEMA_TEST_SUITE_DIR); + } + + std::string resolveDraft7Dir() { + const std::string configured = configuredSchemaSuiteDir(); + if (configured.empty()) { + return std::string(); + } + + if (isDirectory(joinPath(configured, "tests/draft7"))) { + return joinPath(configured, "tests/draft7"); + } + if (isDirectory(joinPath(configured, "draft7"))) { + return joinPath(configured, "draft7"); + } + if (isDirectory(configured)) { + return configured; + } + return std::string(); + } + + // Central compatibility ledger: unsupported upstream coverage is skipped with a reason, and + // selected-group files fail if the upstream descriptions drift away from this manifest. + std::vector manifest() { + std::vector rules; + + FileRule refRule; + refRule.relativePath = "ref.json"; + refRule.mode = RunSelectedGroups; + refRule.reason = ""; + refRule.groups.push_back( + GroupRule{"root pointer ref", true, "supported local JSON Pointer reference"}); + refRule.groups.push_back(GroupRule{"relative pointer ref to object", true, + "supported local JSON Pointer reference"}); + refRule.groups.push_back(GroupRule{"relative pointer ref to array", true, + "supported local JSON Pointer reference"}); + refRule.groups.push_back( + GroupRule{"escaped pointer ref", true, "supported escaped local JSON Pointer tokens"}); + refRule.groups.push_back( + GroupRule{"nested refs", true, "supported nested local references"}); + refRule.groups.push_back(GroupRule{"ref overrides any sibling keywords", true, + "supported draft7 $ref sibling semantics"}); + refRule.groups.push_back(GroupRule{"$ref prevents a sibling $id from changing the base uri", + false, "requires relative URI and $id base resolution"}); + refRule.groups.push_back(GroupRule{"remote ref, containing refs itself", false, + "requires remote schema resolution"}); + refRule.groups.push_back(GroupRule{"property named $ref that is not a reference", true, + "supported ordinary instance property"}); + refRule.groups.push_back( + GroupRule{"property named $ref, containing an actual $ref", true, + "supported local JSON Pointer reference in a property schema"}); + refRule.groups.push_back(GroupRule{"$ref to boolean schema true", true, + "supported local reference to boolean schema"}); + refRule.groups.push_back(GroupRule{"$ref to boolean schema false", true, + "supported local reference to boolean schema"}); + refRule.groups.push_back(GroupRule{"Recursive references between schemas", false, + "requires cross-schema URI resolution"}); + refRule.groups.push_back(GroupRule{"refs with quote", true, + "supported percent-encoded local JSON Pointer token"}); + refRule.groups.push_back(GroupRule{"Location-independent identifier", false, + "requires anchor and $id resolution"}); + refRule.groups.push_back(GroupRule{"Reference an anchor with a non-relative URI", false, + "requires absolute URI and anchor resolution"}); + refRule.groups.push_back( + GroupRule{"Location-independent identifier with base URI change in subschema", false, + "requires nested $id base and anchor resolution"}); + refRule.groups.push_back( + GroupRule{"naive replacement of $ref with its destination is not correct", true, + "supported: $ref-shaped data inside enum is not evaluated"}); + refRule.groups.push_back(GroupRule{"refs with relative uris and defs", false, + "requires relative URI and $id base resolution"}); + refRule.groups.push_back(GroupRule{"relative refs with absolute uris and defs", false, + "requires absolute URI and $id resolution"}); + refRule.groups.push_back( + GroupRule{"$id must be resolved against nearest parent, not just immediate parent", + false, "requires nested $id base resolution"}); + refRule.groups.push_back(GroupRule{"simple URN base URI with $ref via the URN", false, + "requires absolute URN resolution"}); + refRule.groups.push_back(GroupRule{"simple URN base URI with JSON pointer", true, + "supported local fragment despite nonlocal root $id"}); + refRule.groups.push_back(GroupRule{"URN base URI with NSS", true, + "supported local fragment despite URN root $id"}); + refRule.groups.push_back(GroupRule{"URN base URI with r-component", true, + "supported local fragment despite URN root $id"}); + refRule.groups.push_back(GroupRule{"URN base URI with q-component", true, + "supported local fragment despite URN root $id"}); + refRule.groups.push_back(GroupRule{"URN base URI with URN and JSON pointer ref", false, + "requires absolute URN resolution"}); + refRule.groups.push_back(GroupRule{"URN base URI with URN and anchor ref", false, + "requires absolute URN and anchor resolution"}); + refRule.groups.push_back( + GroupRule{"ref to if", false, "requires absolute URI and $id resolution"}); + refRule.groups.push_back( + GroupRule{"ref to then", false, "requires absolute URI and $id resolution"}); + refRule.groups.push_back( + GroupRule{"ref to else", false, "requires absolute URI and $id resolution"}); + refRule.groups.push_back(GroupRule{"ref with absolute-path-reference", false, + "requires URI-reference and $id base resolution"}); + refRule.groups.push_back(GroupRule{"$id with file URI still resolves pointers - *nix", true, + "supported local fragment despite file URI root $id"}); + refRule.groups.push_back(GroupRule{"$id with file URI still resolves pointers - windows", + true, + "supported local fragment despite file URI root $id"}); + refRule.groups.push_back(GroupRule{"empty tokens in $ref json-pointer", true, + "supported empty local JSON Pointer tokens"}); + rules.push_back(refRule); + + FileRule cycleRule; + cycleRule.relativePath = "infinite-loop-detection.json"; + cycleRule.mode = RunWholeFile; + cycleRule.reason = "supported instance/schema-pair cycle detection"; + rules.push_back(cycleRule); + + FileRule definitionsRule; + definitionsRule.relativePath = "definitions.json"; + definitionsRule.mode = SkipWholeFile; + definitionsRule.reason = + "official draft7 definitions file depends on metaschema remote $ref validation"; + rules.push_back(definitionsRule); + + FileRule patternPropertiesRule; + patternPropertiesRule.relativePath = "patternProperties.json"; + patternPropertiesRule.mode = RunWholeFile; + patternPropertiesRule.reason = "supported keyword"; + rules.push_back(patternPropertiesRule); + + FileRule propertyNamesRule; + propertyNamesRule.relativePath = "propertyNames.json"; + propertyNamesRule.mode = RunWholeFile; + propertyNamesRule.reason = "supported keyword"; + rules.push_back(propertyNamesRule); + + FileRule dependenciesRule; + dependenciesRule.relativePath = "dependencies.json"; + dependenciesRule.mode = RunWholeFile; + dependenciesRule.reason = "supported keyword"; + rules.push_back(dependenciesRule); + + FileRule additionalPropertiesRule; + additionalPropertiesRule.relativePath = "additionalProperties.json"; + additionalPropertiesRule.mode = RunWholeFile; + additionalPropertiesRule.reason = "supported keyword"; + rules.push_back(additionalPropertiesRule); + + FileRule multipleOfRule; + multipleOfRule.relativePath = "multipleOf.json"; + multipleOfRule.mode = RunWholeFile; + multipleOfRule.reason = "supported keyword"; + rules.push_back(multipleOfRule); + + FileRule dateRule; + dateRule.relativePath = "optional/format/date.json"; + dateRule.mode = RunWholeFile; + dateRule.reason = "supported format"; + rules.push_back(dateRule); + + FileRule dateTimeRule; + dateTimeRule.relativePath = "optional/format/date-time.json"; + dateTimeRule.mode = RunWholeFile; + dateTimeRule.reason = "supported format"; + rules.push_back(dateTimeRule); + + FileRule timeRule; + timeRule.relativePath = "optional/format/time.json"; + timeRule.mode = RunWholeFile; + timeRule.reason = "supported format"; + rules.push_back(timeRule); + + FileRule ipv4Rule; + ipv4Rule.relativePath = "optional/format/ipv4.json"; + ipv4Rule.mode = RunWholeFile; + ipv4Rule.reason = "supported format"; + rules.push_back(ipv4Rule); + + FileRule ipv6Rule; + ipv6Rule.relativePath = "optional/format/ipv6.json"; + ipv6Rule.mode = RunWholeFile; + ipv6Rule.reason = "supported format"; + rules.push_back(ipv6Rule); + + return rules; + } + + // Manifest and diagnostic helpers used by the execution pipeline below. + const GroupRule* findGroupRule(const FileRule& fileRule, const std::string& description) { + for (size_t i = 0; i < fileRule.groups.size(); ++i) { + if (description == fileRule.groups[i].description) { + return &fileRule.groups[i]; + } + } + return NULL; + } + + std::string groupDescription(const pjson& group) { + const pjson* desc = group.find("description"); + if (desc == NULL) { + return std::string(""); + } + std::string value; + return desc->tryGet(value) ? value : std::string(""); + } + + std::string testDescription(const pjson& testCase) { + const pjson* desc = testCase.find("description"); + if (desc == NULL) { + return std::string(""); + } + std::string value; + return desc->tryGet(value) ? value : std::string(""); + } + + std::string firstErrorSummary(const std::vector& errors) { + if (errors.empty()) { + return std::string("no schema errors reported"); + } + + std::ostringstream os; + os << "first error"; + if (!errors[0].path.empty()) { + os << " at " << errors[0].path; + } + if (!errors[0].message.empty()) { + os << ": " << errors[0].message; + } + return os.str(); + } + + void recordFailure(const std::string& scope, const std::string& detail) { + ::pjson_test::report_failure(__FILE__, __LINE__, scope.c_str(), detail); + } + + // Runs one upstream case while preserving its file/group/case hierarchy in diagnostics. + void runOneOfficialCase(const std::string& relativePath, const std::string& groupDesc, + const pjson& schema, const pjson& testCase, RunSummary& summary) { + const pjson* data = testCase.find("data"); + const pjson* valid = testCase.find("valid"); + const std::string caseDesc = testDescription(testCase); + + ::pjson_test::current().checks += 1; + summary.casesRun += 1; + + if (data == NULL || valid == NULL || !valid->isBool()) { + recordFailure("official schema suite case shape", + relativePath + " :: " + groupDesc + " :: " + caseDesc); + return; + } + + bool expected = false; + if (!valid->tryGet(expected)) { + recordFailure("official schema suite case shape", + relativePath + " :: " + groupDesc + " :: " + caseDesc); + return; + } + + std::vector errors; + const bool actual = data->validate(schema, errors); + if (actual == expected) { + return; + } + + std::ostringstream os; + os << relativePath << " :: " << groupDesc << " :: " << caseDesc << " expected " + << (expected ? "valid" : "invalid") << " but validator returned " + << (actual ? "valid" : "invalid"); + if (!actual) { + os << " [" << firstErrorSummary(errors) << "]"; + } + recordFailure("official schema suite mismatch", os.str()); + } + + // Validates a group shape once, then runs all of its cases against the shared schema. + void runWholeGroup(const std::string& relativePath, const pjson& group, RunSummary& summary) { + const pjson* schema = group.find("schema"); + const pjson* tests = group.find("tests"); + const std::string groupDesc = groupDescription(group); + + if (schema == NULL || tests == NULL || !tests->isArray()) { + recordFailure("official schema suite group shape", relativePath + " :: " + groupDesc); + return; + } + + const size_t count = tests->size(); + summary.groupsRun += 1; + for (size_t i = 0; i < count; ++i) { + const pjson* testCase = tests->find(static_cast(i)); + if (testCase == NULL) { + recordFailure("official schema suite case shape", + relativePath + " :: " + groupDesc + " :: index " + + pjson_test::to_str(static_cast(i))); + continue; + } + runOneOfficialCase(relativePath, groupDesc, *schema, *testCase, summary); + } + } + + // Enforces a bidirectional manifest invariant: every upstream group has a rule and every rule + // still names an upstream group. This makes suite upgrades fail visibly instead of shrinking + // coverage silently. + void runSelectedGroups(const FileRule& fileRule, const pjson& suiteFile, RunSummary& summary) { + if (!suiteFile.isArray()) { + recordFailure("official schema suite file shape", + std::string(fileRule.relativePath) + " did not parse to an array"); + return; + } + + std::vector seenDescriptions; + for (size_t i = 0; i < suiteFile.size(); ++i) { + const pjson* groupPtr = suiteFile.find(static_cast(i)); + if (groupPtr == NULL) { + recordFailure("official schema suite group shape", + std::string(fileRule.relativePath) + " :: index " + + pjson_test::to_str(static_cast(i))); + continue; + } + const pjson& group = *groupPtr; + const std::string description = groupDescription(group); + seenDescriptions.push_back(description); + + const GroupRule* groupRule = findGroupRule(fileRule, description); + if (groupRule == NULL) { + recordFailure("official schema suite manifest gap", + std::string(fileRule.relativePath) + " :: " + description + + " is present upstream but has no explicit run/skip rule"); + continue; + } + + if (!groupRule->enabled) { + summary.groupsSkipped += 1; + const pjson* tests = group.find("tests"); + if (tests != NULL && tests->isArray()) { + summary.casesSkipped += tests->size(); + } + std::printf(" INFO skip %s :: %s [%s]\n", fileRule.relativePath, + description.c_str(), groupRule->reason); + continue; + } + + runWholeGroup(fileRule.relativePath, group, summary); + } + + for (size_t i = 0; i < fileRule.groups.size(); ++i) { + if (std::find(seenDescriptions.begin(), seenDescriptions.end(), + std::string(fileRule.groups[i].description)) == seenDescriptions.end()) { + recordFailure("official schema suite manifest stale", + std::string(fileRule.relativePath) + + " :: " + fileRule.groups[i].description + + " is declared in the manifest but was not found in the suite"); + } + } + } + + // Runs every group in a file whose supported vocabulary needs no per-group filtering. + void runWholeFile(const FileRule& fileRule, const pjson& suiteFile, RunSummary& summary) { + if (!suiteFile.isArray()) { + recordFailure("official schema suite file shape", + std::string(fileRule.relativePath) + " did not parse to an array"); + return; + } + + for (size_t i = 0; i < suiteFile.size(); ++i) { + const pjson* group = suiteFile.find(static_cast(i)); + if (group == NULL) { + recordFailure("official schema suite group shape", + std::string(fileRule.relativePath) + " :: index " + + pjson_test::to_str(static_cast(i))); + continue; + } + runWholeGroup(fileRule.relativePath, *group, summary); + } + } + +} // namespace + +TEST(schema_official_draft7_optional) { + const std::string draft7Dir = resolveDraft7Dir(); + if (draft7Dir.empty()) { + std::printf(" INFO JSON-Schema-Test-Suite skipped; set " + "PJSON_JSON_SCHEMA_TEST_SUITE_DIR or run " + "scripts/fetch-json-schema-test-suite.sh\n"); + CHECK(true); + return; + } + + RunSummary summary; + const std::vector rules = manifest(); + for (size_t i = 0; i < rules.size(); ++i) { + const std::string path = joinPath(draft7Dir, rules[i].relativePath); + summary.filesVisited += 1; + + if (!isRegularFile(path)) { + recordFailure("official schema suite file missing", + std::string(rules[i].relativePath) + " under " + draft7Dir); + continue; + } + + if (rules[i].mode == SkipWholeFile) { + summary.filesSkipped += 1; + std::printf(" INFO skip %s [%s]\n", rules[i].relativePath, rules[i].reason); + continue; + } + + pjson::ParseError parseError; + pjson::unique_ptr suite = parseJson(readFile(path), &parseError); + if (!suite) { + std::ostringstream os; + os << rules[i].relativePath << " failed to parse"; + if (!parseError.message.empty()) { + os << " at byte " << parseError.offset << ": " << parseError.message; + } + recordFailure("official schema suite parse", os.str()); + continue; + } + + if (rules[i].mode == RunWholeFile) { + runWholeFile(rules[i], *suite, summary); + } else { + runSelectedGroups(rules[i], *suite, summary); + } + } + + std::printf(" INFO official schema suite visited %llu files (%llu whole-file skips), " + "ran %llu groups / %llu cases, skipped %llu groups / %llu cases\n", + static_cast(summary.filesVisited), + static_cast(summary.filesSkipped), + static_cast(summary.groupsRun), + static_cast(summary.casesRun), + static_cast(summary.groupsSkipped), + static_cast(summary.casesSkipped)); +} diff --git a/pjsontest/src/tests_schema_vocabulary.cpp b/pjsontest/src/tests_schema_vocabulary.cpp new file mode 100644 index 0000000..5635495 --- /dev/null +++ b/pjsontest/src/tests_schema_vocabulary.cpp @@ -0,0 +1,785 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// JSON Schema vocabulary, exact multipleOf arithmetic, validation budgets, +// local references, and optional format-validation tests. +// +#include "pjson.h" +#include "test_harness.h" +#include +#include + +using namespace ByteDance; + +namespace { + + pjson::unique_ptr parseJson(const char* text) { + return pjson::parse(std::string(text)); + } + + // Parse-and-validate adapters keep the vocabulary tables focused on schema behavior. + bool validates(const char* schemaText, const char* dataText, + std::vector& errors, + const pjson::SchemaOptions& opts = pjson::SchemaOptions()) { + pjson::unique_ptr schema = parseJson(schemaText); + pjson::unique_ptr data = parseJson(dataText); + if (!schema || !data) + return false; + return data->validate(*schema, errors, opts); + } + + bool validates(const char* schemaText, const char* dataText, + const pjson::SchemaOptions& opts = pjson::SchemaOptions()) { + std::vector errors; + return validates(schemaText, dataText, errors, opts); + } + + // Error predicates assert semantic diagnostics without coupling tests to error ordering. + bool hasErrorAt(const std::vector& errors, const std::string& path) { + for (const auto& err : errors) { + if (err.path == path) + return true; + } + return false; + } + + bool hasMessageContaining(const std::vector& errors, + const std::string& needle) { + for (const auto& err : errors) { + if (err.message.find(needle) != std::string::npos) + return true; + } + return false; + } + + bool hasErrorAtWithMessage(const std::vector& errors, + const std::string& path, const std::string& needle) { + for (const auto& err : errors) { + if (err.path == path && err.message.find(needle) != std::string::npos) + return true; + } + return false; + } + + // Small option factories make each validation-budget test state only its changed knob. + pjson::SchemaOptions optionsWithFormatValidation(bool enabled) { + pjson::SchemaOptions opts; + opts.validateFormats = enabled; + return opts; + } + + pjson::SchemaOptions optionsWithDepthBudget(size_t maxDepth) { + pjson::SchemaOptions opts; + opts.maxValidationDepth = maxDepth; + return opts; + } + + pjson::SchemaOptions optionsWithRefBudget(size_t maxRefs) { + pjson::SchemaOptions opts; + opts.maxRefResolutions = maxRefs; + return opts; + } + + pjson::SchemaOptions optionsWithWorkBudget(size_t maxWork) { + pjson::SchemaOptions opts; + opts.maxValidationWork = maxWork; + return opts; + } + + pjson::SchemaOptions optionsWithErrorBudget(size_t maxErrors) { + pjson::SchemaOptions opts; + opts.maxErrors = maxErrors; + return opts; + } + + // Recursive local-reference fixture shared by depth and reference-resolution budget tests. + const char* kRecursiveNodeSchema = R"({ + "$defs": { + "node": { + "type": "object", + "required": ["value", "next"], + "properties": { + "value": { "type": "integer" }, + "next": { + "anyOf": [ + { "type": "null" }, + { "$ref": "#/$defs/node" } + ] + } + }, + "additionalProperties": false + } + }, + "$ref": "#/$defs/node" + })"; + + pjson makeNestedPropertySchema(size_t depth) { + pjson schema; + pjson* cursor = &schema; + for (size_t i = 0; i < depth; ++i) { + (*cursor)["type"] = "object"; + cursor = &((*cursor)["properties"]["x"]); + } + (*cursor)["type"] = "integer"; + return schema; + } + + pjson makeNestedPropertyInstance(size_t depth) { + pjson instance; + pjson* cursor = &instance; + for (size_t i = 0; i < depth; ++i) + cursor = &((*cursor)["x"]); + *cursor = int64_t(1); + return instance; + } + + pjson makeReferenceChainSchema(size_t references) { + pjson schema; + schema["$ref"] = "#/$defs/s0"; + for (size_t i = 0; i < references; ++i) { + const std::string name = "s" + std::to_string(i); + if (i + 1U == references) { + schema["$defs"][name] = true; + } else { + schema["$defs"][name]["$ref"] = "#/$defs/s" + std::to_string(i + 1U); + } + } + return schema; + } + + pjson makeBranchingWorkSchema(size_t levels) { + pjson schema; + schema["$ref"] = "#/$defs/level0"; + for (size_t i = 0; i < levels; ++i) { + const std::string name = "level" + std::to_string(i); + if (i + 1U == levels) { + schema["$defs"][name] = true; + } else { + const std::string next = "#/$defs/level" + std::to_string(i + 1U); + schema["$defs"][name]["allOf"][0]["$ref"] = next; + schema["$defs"][name]["allOf"][1]["$ref"] = next; + } + } + return schema; + } + + pjson makeDeepValue(size_t depth, int64_t leaf) { + pjson value; + pjson* cursor = &value; + for (size_t i = 0; i < depth; ++i) + cursor = &((*cursor)["x"]); + *cursor = leaf; + return value; + } + +} // namespace + +//===----------------------------------------------------------------------===// +// Local references and validation budgets +//===----------------------------------------------------------------------===// + +TEST(schema_vocab_ref_local_defs_and_definitions) { + const char* defsSchema = R"({ + "$defs": { + "positiveInt": { "type": "integer", "minimum": 1 } + }, + "type": "object", + "required": ["id"], + "properties": { + "id": { "$ref": "#/$defs/positiveInt" } + }, + "additionalProperties": false + })"; + CHECK(validates(defsSchema, R"({"id":7})")); + std::vector errors; + CHECK(!validates(defsSchema, R"({"id":0})", errors)); + CHECK(hasErrorAtWithMessage(errors, "/id", "minimum")); + + const char* definitionsSchema = R"({ + "definitions": { + "nonEmptyString": { "type": "string", "minLength": 1 } + }, + "type": "object", + "properties": { + "name": { "$ref": "#/definitions/nonEmptyString" } + } + })"; + CHECK(validates(definitionsSchema, R"({"name":"Ada"})")); + errors.clear(); + CHECK(!validates(definitionsSchema, R"({"name":""})", errors)); + CHECK(hasErrorAtWithMessage(errors, "/name", "minLength")); +} + +TEST(schema_vocab_ref_unresolved_malformed_and_nonlocal) { + std::vector errors; + + CHECK(!validates(R"({"$ref":"#/$defs/missing"})", "1", errors)); + CHECK(hasErrorAt(errors, std::string(""))); + CHECK(hasMessageContaining(errors, "unresolved")); + + errors.clear(); + CHECK(!validates(R"({"$ref":"#/$defs/bad~2token"})", "1", errors)); + CHECK(hasErrorAt(errors, std::string(""))); + CHECK(hasMessageContaining(errors, "malformed")); + + errors.clear(); + CHECK(!validates(R"({"$ref":"https://example.com/schema.json#/$defs/x"})", "1", errors)); + CHECK(hasErrorAt(errors, std::string(""))); + CHECK(hasMessageContaining(errors, "non-local")); +} + +TEST(schema_vocab_ref_cycle_is_reported) { + std::vector errors; + CHECK(!validates(R"({"$ref":"#"})", R"({"anything":1})", errors)); + CHECK(hasErrorAt(errors, std::string(""))); + CHECK(hasMessageContaining(errors, "cycle")); +} + +TEST(schema_vocab_ref_validation_depth_budget) { + const char* data = R"({ + "value": 1, + "next": { + "value": 2, + "next": { + "value": 3, + "next": null + } + } + })"; + + pjson::SchemaOptions shallow = optionsWithDepthBudget(2); + std::vector errors; + CHECK(!validates(kRecursiveNodeSchema, data, errors, shallow)); + CHECK(hasMessageContaining(errors, "depth")); +} + +TEST(schema_vocab_ref_resolution_budget) { + const char* data = R"({ + "value": 1, + "next": { + "value": 2, + "next": { + "value": 3, + "next": null + } + } + })"; + + pjson::SchemaOptions limited = optionsWithRefBudget(2); + std::vector errors; + CHECK(!validates(kRecursiveNodeSchema, data, errors, limited)); + CHECK(hasMessageContaining(errors, "ref")); + CHECK(hasMessageContaining(errors, "budget")); +} + +TEST(schema_vocab_ref_zero_depth_uses_hard_ceiling) { + pjson::SchemaOptions opts = optionsWithDepthBudget(0); + const pjson schema = makeNestedPropertySchema(520); + const pjson instance = makeNestedPropertyInstance(520); + + std::vector errors; + CHECK(!instance.validate(schema, errors, opts)); + CHECK(hasMessageContaining(errors, "depth")); +} + +TEST(schema_vocab_ref_zero_resolution_budget_uses_hard_ceiling) { + pjson::SchemaOptions opts = optionsWithRefBudget(0); + opts.maxValidationDepth = 2048; + const pjson schema = makeReferenceChainSchema(1030); + pjson instance; + instance = int64_t(1); + + std::vector errors; + CHECK(!instance.validate(schema, errors, opts)); + CHECK(hasMessageContaining(errors, "ref")); + CHECK(hasMessageContaining(errors, "budget")); +} + +TEST(schema_vocab_ref_ignores_sibling_keywords_draft07) { + const char* schema = R"({ + "$defs": { + "asString": { "type": "string", "minLength": 2 } + }, + "$ref": "#/$defs/asString", + "type": "integer", + "minLength": 99 + })"; + + CHECK(validates(schema, R"("ok")")); + std::vector errors; + CHECK(!validates(schema, "5", errors)); + CHECK(hasMessageContaining(errors, "string")); + CHECK(!hasMessageContaining(errors, "expected type integer")); + CHECK(!hasMessageContaining(errors, "99")); +} + +//===----------------------------------------------------------------------===// +// Object applicators and dependency keywords +//===----------------------------------------------------------------------===// + +TEST(schema_vocab_pattern_properties_basic_matching) { + const char* schema = R"({ + "type": "object", + "patternProperties": { + "^S_[A-Z]+$": { "type": "integer" } + } + })"; + + CHECK(validates(schema, R"({"S_COUNT":1,"plain":"x"})")); + std::vector errors; + CHECK(!validates(schema, R"({"S_COUNT":"bad"})", errors)); + CHECK(hasErrorAt(errors, "/S_COUNT")); +} + +TEST(schema_vocab_pattern_properties_and_properties_both_apply) { + const char* schema = R"({ + "type": "object", + "properties": { + "S_NAME": { "type": "integer" } + }, + "patternProperties": { + "^S_": { "type": "string" } + } + })"; + + std::vector errors; + CHECK(!validates(schema, R"({"S_NAME":5})", errors)); + CHECK(hasErrorAtWithMessage(errors, "/S_NAME", "string")); +} + +TEST(schema_vocab_property_names_reports_property_path) { + const char* schema = R"({ + "type": "object", + "propertyNames": { + "pattern": "^[A-Z_]+$" + } + })"; + + CHECK(validates(schema, R"({"OK":1,"ALSO_OK":2})")); + std::vector errors; + CHECK(!validates(schema, R"({"bad/key":1})", errors)); + CHECK(hasErrorAt(errors, "/bad~1key")); + CHECK(hasMessageContaining(errors, "pattern")); +} + +TEST(schema_vocab_dependent_required) { + const char* schema = R"({ + "type": "object", + "dependentRequired": { + "credit_card": ["billing_address", "name"] + } + })"; + + CHECK(validates(schema, R"({"credit_card":"1234","billing_address":"x","name":"Ada"})")); + std::vector errors; + CHECK(!validates(schema, R"({"credit_card":"1234"})", errors)); + CHECK(hasErrorAt(errors, std::string(""))); + CHECK(hasMessageContaining(errors, "billing_address")); + CHECK(hasMessageContaining(errors, "name")); +} + +TEST(schema_vocab_dependencies_array_form) { + const char* schema = R"({ + "type": "object", + "dependencies": { + "credit_card": ["billing_address"] + } + })"; + + CHECK(validates(schema, R"({"credit_card":"1234","billing_address":"x"})")); + std::vector errors; + CHECK(!validates(schema, R"({"credit_card":"1234"})", errors)); + CHECK(hasErrorAt(errors, std::string(""))); + CHECK(hasMessageContaining(errors, "billing_address")); +} + +TEST(schema_vocab_dependencies_schema_form) { + const char* schema = R"({ + "type": "object", + "dependencies": { + "credit_card": { + "required": ["billing_address"], + "properties": { + "billing_address": { "type": "string", "minLength": 5 } + } + } + } + })"; + + CHECK(validates(schema, R"({"credit_card":"1234","billing_address":"123 Main"})")); + + std::vector errors; + CHECK(!validates(schema, R"({"credit_card":"1234"})", errors)); + CHECK(hasErrorAt(errors, std::string(""))); + CHECK(hasMessageContaining(errors, "billing_address")); + + errors.clear(); + CHECK(!validates(schema, R"({"credit_card":"1234","billing_address":"x"})", errors)); + CHECK(hasErrorAtWithMessage(errors, "/billing_address", "minLength")); +} + +TEST(schema_vocab_additional_properties_schema_applies_only_to_unmatched_keys) { + const char* schema = R"({ + "type": "object", + "properties": { + "declared": { "type": "string" } + }, + "additionalProperties": { "type": "integer" } + })"; + + CHECK(validates(schema, R"({"declared":"ok","extra":2})")); + + std::vector errors; + CHECK(!validates(schema, R"({"declared":"ok","extra":"bad"})", errors)); + CHECK(hasErrorAtWithMessage(errors, "/extra", "integer")); + CHECK(!hasErrorAt(errors, "/declared")); +} + +TEST(schema_vocab_object_keyword_interactions) { + const char* schema = R"({ + "type": "object", + "properties": { + "fixed": { "type": "string" } + }, + "patternProperties": { + "^dyn_": { "type": "integer" } + }, + "additionalProperties": false + })"; + + CHECK(validates(schema, R"({"fixed":"ok","dyn_count":3})")); + + std::vector errors; + CHECK(!validates(schema, R"({"fixed":"ok","dyn_count":"bad","extra":1})", errors)); + CHECK(hasErrorAtWithMessage(errors, "/dyn_count", "integer")); + CHECK(hasErrorAtWithMessage(errors, "/extra", "additional property")); + CHECK(!hasErrorAt(errors, "/fixed")); +} + +//===----------------------------------------------------------------------===// +// Optional string-format validation +//===----------------------------------------------------------------------===// + +TEST(schema_vocab_format_date) { + const char* schema = R"({"type":"string","format":"date"})"; + CHECK(validates(schema, R"("2025-01-02")")); + CHECK(!validates(schema, R"("2025-13-02")")); + CHECK(!validates(schema, R"("2025-1-02")")); +} + +TEST(schema_vocab_format_time) { + const char* schema = R"({"type":"string","format":"time"})"; + CHECK(validates(schema, R"("23:59:59Z")")); + CHECK(validates(schema, R"("12:34:56+05:30")")); + CHECK(!validates(schema, R"("24:00:00Z")")); + CHECK(!validates(schema, R"("12:34:56")")); +} + +TEST(schema_vocab_format_date_time) { + const char* schema = R"({"type":"string","format":"date-time"})"; + CHECK(validates(schema, R"("2025-01-02T03:04:05Z")")); + CHECK(validates(schema, R"("2025-01-02T03:04:05.123+02:30")")); + CHECK(!validates(schema, R"("2025-01-02 03:04:05Z")")); + CHECK(!validates(schema, R"("2025-13-02T03:04:05Z")")); +} + +TEST(schema_vocab_format_ipv4) { + const char* schema = R"({"type":"string","format":"ipv4"})"; + CHECK(validates(schema, R"("192.168.0.1")")); + CHECK(!validates(schema, R"("256.1.2.3")")); + CHECK(!validates(schema, R"("1.2.3")")); +} + +TEST(schema_vocab_format_ipv6) { + const char* schema = R"({"type":"string","format":"ipv6"})"; + CHECK(validates(schema, R"("2001:db8::1")")); + CHECK(validates(schema, R"("2001:0db8:85a3:0000:0000:8a2e:0370:7334")")); + CHECK(!validates(schema, R"("2001:::1")")); + CHECK(!validates(schema, R"("gggg::1")")); +} + +TEST(schema_vocab_format_ipv6_rejects_ipv4_prefix_before_compression) { + std::vector errors; + CHECK(!validates(R"({"format":"ipv6"})", R"("192.0.2.128::")", errors)); + CHECK(hasMessageContaining(errors, "format") || hasMessageContaining(errors, "ipv6")); +} + +TEST(schema_vocab_format_uuid) { + const char* schema = R"({"type":"string","format":"uuid"})"; + CHECK(validates(schema, R"("123e4567-e89b-12d3-a456-426614174000")")); + CHECK(!validates(schema, R"("123e4567e89b12d3a456426614174000")")); + CHECK(!validates(schema, R"("123e4567-e89b-12d3-a456-42661417400z")")); +} + +TEST(schema_vocab_format_unknown_is_ignored_and_disable_option_skips_known_formats) { + CHECK(validates(R"({"type":"string","format":"unknown-future-format"})", R"("anything")")); + + pjson::SchemaOptions disabled = optionsWithFormatValidation(false); + CHECK(validates(R"({"type":"string","format":"date"})", R"("not-a-date")", disabled)); + + pjson::SchemaOptions enabled = optionsWithFormatValidation(true); + std::vector errors; + CHECK(!validates(R"({"type":"string","format":"date"})", R"("not-a-date")", errors, enabled)); + CHECK(hasMessageContaining(errors, "format")); +} + +//===----------------------------------------------------------------------===// +// Exact multipleOf arithmetic across integer and decimal scales +//===----------------------------------------------------------------------===// + +TEST(schema_multiple_of_precision_integer_paths) { + CHECK(validates(R"({"multipleOf":10})", "9007199254740990")); + CHECK(!validates(R"({"multipleOf":10})", "9007199254740991")); + CHECK(validates(R"({"multipleOf":3})", "0")); +} + +TEST(schema_multiple_of_precision_decimal_exact_cases) { + CHECK(validates(R"({"multipleOf":0.1})", "0.3")); + CHECK(validates(R"({"multipleOf":0.01})", "12.34")); + CHECK(validates(R"({"multipleOf":0.00000001})", "0.00000012")); + CHECK(!validates(R"({"multipleOf":0.01})", "12.345")); +} + +TEST(schema_multiple_of_precision_decimal_traps) { + std::vector errors; + CHECK(!validates(R"({"multipleOf":0.1})", "0.30000000000000004", errors)); + CHECK(hasMessageContaining(errors, "multiple")); + + errors.clear(); + CHECK(!validates(R"({"multipleOf":0.0000000001})", "0.0000000003000000001", errors)); + CHECK(hasMessageContaining(errors, "multiple")); +} + +TEST(schema_multiple_of_precision_large_and_tiny_scales) { + CHECK(validates(R"({"multipleOf":0.01})", "1000000000000.01")); + CHECK(validates(R"({"multipleOf":0.0000000001})", "0.0000000003")); + CHECK(!validates(R"({"multipleOf":0.0000000001})", "0.00000000035")); +} + +TEST(schema_multiple_of_non_positive_schema_values_are_ignored) { + CHECK(validates(R"({"multipleOf":0})", "5")); + CHECK(validates(R"({"multipleOf":-2})", "5")); +} + +TEST(schema_multiple_of_precision_overflow_case_from_official_suite) { + std::vector errors; + CHECK(!validates(R"({"type":"integer","multipleOf":0.123456789})", "1e308", errors)); + CHECK(hasMessageContaining(errors, "multiple")); +} + +TEST(schema_multiple_of_precision_exact_mixed_numeric_const_enum) { + pjson constSchema; + constSchema["const"] = int64_t(9007199254740993LL); + + pjson exactInt; + exactInt = int64_t(9007199254740993LL); + CHECK(exactInt.validate(constSchema)); + + pjson roundedDouble; + roundedDouble = double(9007199254740992.0); + CHECK(!roundedDouble.validate(constSchema)); + + pjson enumSchema; + enumSchema["enum"][0] = int64_t(9007199254740993LL); + enumSchema["enum"][1] = int64_t(5); + CHECK(exactInt.validate(enumSchema)); + CHECK(!roundedDouble.validate(enumSchema)); +} + +TEST(schema_validation_work_budget) { + const char* schema = R"({ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { "type": "integer" } + } + } + })"; + const char* data = R"({"items":[1,2,3,4,5,6,7,8,9,10]})"; + + pjson::SchemaOptions constrained = optionsWithWorkBudget(1); + std::vector errors; + CHECK(!validates(schema, data, errors, constrained)); + CHECK(hasMessageContaining(errors, "work") || hasMessageContaining(errors, "budget")); +} + +TEST(schema_validation_work_budget_charges_deep_equality_unicode_and_additional_properties) { + pjson::SchemaOptions tiny = optionsWithWorkBudget(16); + std::vector errors; + + const pjson deep = makeDeepValue(32, int64_t(1)); + pjson constSchema; + constSchema["const"] = deep; + CHECK(!deep.validate(constSchema, errors, tiny)); + CHECK(hasMessageContaining(errors, "work") || hasMessageContaining(errors, "budget")); + + errors.clear(); + pjson enumSchema; + enumSchema["enum"][0] = deep; + CHECK(!deep.validate(enumSchema, errors, tiny)); + CHECK(hasMessageContaining(errors, "work") || hasMessageContaining(errors, "budget")); + + errors.clear(); + pjson uniqueSchema; + uniqueSchema["uniqueItems"] = true; + pjson duplicateDeep; + duplicateDeep[0] = deep; + duplicateDeep[1] = deep; + CHECK(!duplicateDeep.validate(uniqueSchema, errors, tiny)); + CHECK(hasMessageContaining(errors, "work") || hasMessageContaining(errors, "budget")); + + errors.clear(); + pjson unicodeSchema; + unicodeSchema["minLength"] = int64_t(1); + pjson unicodeValue; + std::string unicode; + for (size_t i = 0; i < 32; ++i) + unicode += "\xF0\x9F\x98\x80"; + unicodeValue = unicode; + CHECK(!unicodeValue.validate(unicodeSchema, errors, tiny)); + CHECK(hasMessageContaining(errors, "work") || hasMessageContaining(errors, "budget")); + + errors.clear(); + pjson additionalSchema; + additionalSchema["additionalProperties"] = true; + pjson manyProperties; + for (size_t i = 0; i < 32; ++i) + manyProperties["key" + std::to_string(i)] = static_cast(i); + CHECK(!manyProperties.validate(additionalSchema, errors, tiny)); + CHECK(hasMessageContaining(errors, "work") || hasMessageContaining(errors, "budget")); +} + +TEST(schema_additional_properties_large_object_stays_within_work_budget) { + pjson schema; + schema["additionalProperties"]["type"] = "integer"; + pjson instance; + const size_t propertyCount = 2048; + for (size_t i = 0; i < propertyCount; ++i) + instance["key" + std::to_string(i)] = static_cast(i); + + pjson::SchemaOptions options = optionsWithWorkBudget(propertyCount * 4U + 16U); + CHECK(instance.validate(schema, options)); +} + +TEST(schema_validation_zero_work_budget_uses_hard_ceiling) { + pjson::SchemaOptions opts = optionsWithWorkBudget(0); + opts.maxValidationDepth = 128; + opts.maxRefResolutions = 2000000; + const pjson schema = makeBranchingWorkSchema(20); + pjson instance; + instance = int64_t(1); + + std::vector errors; + CHECK(!instance.validate(schema, errors, opts)); + CHECK(hasMessageContaining(errors, "work") || hasMessageContaining(errors, "budget")); +} + +TEST(schema_validation_error_budget) { + const char* schema = R"({ + "type":"object", + "required":["a","b","c","d"], + "properties":{ + "a":{"type":"integer"}, + "b":{"type":"integer"}, + "c":{"type":"integer"}, + "d":{"type":"integer"} + } + })"; + const char* data = R"({"a":"x","b":"y"})"; + + pjson::SchemaOptions constrained = optionsWithErrorBudget(1); + std::vector errors; + CHECK(!validates(schema, data, errors, constrained)); + CHECK(errors.size() <= size_t(1)); +} + +TEST(schema_anyof_scratch_errors_do_not_consume_public_error_budget) { + pjson::SchemaOptions options = optionsWithErrorBudget(1); + const char* matchingLast = + R"({"anyOf":[{"type":"string"},{"minimum":10},{"const":7},{"type":"integer"}]})"; + const char* matchingFirst = + R"({"anyOf":[{"type":"integer"},{"type":"string"},{"minimum":10},{"const":7}]})"; + + std::vector errors; + CHECK(validates(matchingLast, "5", errors, options)); + CHECK(errors.empty()); + CHECK(validates(matchingFirst, "5", errors, options)); + CHECK(errors.empty()); +} + +TEST(schema_oneof_scratch_errors_do_not_consume_public_error_budget) { + pjson::SchemaOptions options = optionsWithErrorBudget(1); + const char* matchingLast = + R"({"oneOf":[{"type":"string"},{"minimum":10},{"const":7},{"type":"integer"}]})"; + const char* matchingFirst = + R"({"oneOf":[{"type":"integer"},{"type":"string"},{"minimum":10},{"const":7}]})"; + + std::vector errors; + CHECK(validates(matchingLast, "5", errors, options)); + CHECK(errors.empty()); + CHECK(validates(matchingFirst, "5", errors, options)); + CHECK(errors.empty()); +} + +TEST(schema_not_scratch_error_leaves_budget_for_later_real_failure) { + pjson::SchemaOptions options = optionsWithErrorBudget(1); + const char* schema = R"({ + "allOf": [ + {"not": {"type": "string"}}, + {"type": "string"} + ] + })"; + + std::vector errors; + CHECK(!validates(schema, "5", errors, options)); + CHECK_EQ(errors.size(), size_t(1)); + CHECK(hasMessageContaining(errors, "string")); +} + +TEST(schema_speculative_branches_discard_large_hidden_error_sets) { + const size_t requiredCount = 4096; + pjson failingBranch; + failingBranch["type"] = "object"; + for (size_t i = 0; i < requiredCount; ++i) { + failingBranch["required"][static_cast(i)] = + std::string("missing-") + std::to_string(i); + } + + pjson anyOfSchema; + anyOfSchema["anyOf"][0] = failingBranch; + anyOfSchema["anyOf"][1] = true; + + pjson instance; + instance.resetTo(pjson::jsonObject); + pjson::SchemaOptions options; + options.maxErrors = 1; + options.maxValidationWork = requiredCount * 4; + std::vector errors; + + CHECK(instance.validate(anyOfSchema, errors, options)); + CHECK(errors.empty()); +} + +TEST(schema_validation_zero_error_budget_uses_hard_ceiling) { + const char* schema = R"({"type":"object","required":["a","b","c"]})"; + const char* data = R"({})"; + + pjson::SchemaOptions opts = optionsWithErrorBudget(0); + std::vector errors; + CHECK(!validates(schema, data, errors, opts)); + CHECK(!errors.empty()); + CHECK(errors.size() <= size_t(100)); +} diff --git a/pjsontest/src/tests_serialize_access.cpp b/pjsontest/src/tests_serialize_access.cpp new file mode 100644 index 0000000..74f6b20 --- /dev/null +++ b/pjsontest/src/tests_serialize_access.cpp @@ -0,0 +1,520 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Serialization policy controls and non-vivifying value-access APIs. +// +#include "pjson.h" +#include "test_harness.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using ByteDance::pjson; + +namespace { + int64_t mustGetInt(const pjson& value) { + int64_t out = 0; + CHECK(value.tryGet(out)); + return out; + } + + // Serializes through the ostream API and asserts that the sink stayed healthy. + std::string streamed(const pjson& value, const pjson::SerializeOptions& options) { + std::ostringstream out; + value.write(out, options); + CHECK(out.good()); + return out.str(); + } + + // Checks the byte-level postcondition promised by asciiOnly serialization. + bool isAscii(const std::string& text) { + for (size_t i = 0; i < text.size(); ++i) { + if (static_cast(text[i]) >= 0x80) + return false; + } + return true; + } + +} // namespace + +//===----------------------------------------------------------------------===// +// SerializeOptions behavior across string and ostream sinks +//===----------------------------------------------------------------------===// + +TEST(serialize_options_defaults_match_convenience_api) { + pjson::SerializeOptions defaults; + CHECK_EQ(defaults.maxOutputBytes, size_t(64) * 1024U * 1024U); + + pjson value; + value["z"] = static_cast(3); + value["a"][0] = std::string("text\n"); + + pjson::SerializeOptions compact; + CHECK_EQ(value.toString(compact), value.toString()); + CHECK_EQ(streamed(value, compact), value.toString()); + + pjson::SerializeOptions pretty = pjson::SerializeOptions::prettyPrinted(); + CHECK_EQ(streamed(value, pretty), value.toString(pretty)); +} + +TEST(serialize_options_custom_indentation) { + pjson value; + value["a"][0]["b"] = static_cast(1); + + pjson::SerializeOptions options = pjson::SerializeOptions::prettyPrinted(); + options.indentWidth = 4; + CHECK_EQ(value.toString(options), + std::string("{\n \"a\": [\n {\n \"b\": 1\n }\n ]\n}")); + + options.indentWidth = 1; + options.indentCharacter = '\t'; + CHECK_EQ(value.toString(options), + std::string("{\n\t\"a\": [\n\t\t{\n\t\t\t\"b\": 1\n\t\t}\n\t]\n}")); + + options.indentWidth = 0; + CHECK_EQ(value.toString(options), std::string("{\n\"a\": [\n{\n\"b\": 1\n}\n]\n}")); +} + +TEST(serialize_options_invalid_indent_falls_back_and_compact_ignores_it) { + pjson value; + value["a"][0] = static_cast(1); + + pjson::SerializeOptions options = pjson::SerializeOptions::prettyPrinted(); + options.indentWidth = 3; + options.indentCharacter = 'x'; + CHECK_EQ(value.toString(options), std::string("{\n \"a\": [\n 1\n ]\n}")); + + options.pretty = false; + options.indentWidth = static_cast(-1); + CHECK_EQ(value.toString(options), std::string("{\"a\":[1]}")); +} + +TEST(serialize_options_pretty_indent_overflow_throws_length_error_and_write_fails) { + pjson value; + value["a"][0] = + static_cast(1); // nested enough that pretty indentation reaches depth 2 + + pjson::SerializeOptions options = pjson::SerializeOptions::prettyPrinted(); + options.indentWidth = static_cast(-1); + + bool threwLengthError = false; + try { + (void)value.toString(options); + } catch (const std::length_error&) { + threwLengthError = true; + } + CHECK(threwLengthError); + + std::ostringstream out; + value.write(out, options); + CHECK(out.fail()); + + bool threwStreamFailure = false; + std::ostringstream throwingOut; + throwingOut.exceptions(std::ios::failbit); + try { + value.write(throwingOut, options); + } catch (const std::ios_base::failure&) { + threwStreamFailure = true; + } + CHECK(threwStreamFailure); +} + +TEST(serialize_options_output_byte_budget_is_exact_and_atomic) { + pjson value; + value["key"] = std::string("value"); + const std::string compact = value.toString(); + + pjson::SerializeOptions exact; + exact.maxOutputBytes = compact.size(); + CHECK_EQ(value.toString(exact), compact); + CHECK_EQ(streamed(value, exact), compact); + + pjson::SerializeOptions shortBudget = exact; + shortBudget.maxOutputBytes = compact.size() - 1U; + bool threw = false; + try { + (void)value.toString(shortBudget); + } catch (const std::length_error&) { + threw = true; + } + CHECK(threw); + + std::ostringstream out; + value.write(out, shortBudget); + CHECK(out.fail()); + CHECK(out.str().empty()); + + bool streamThrew = false; + std::ostringstream throwingOut; + throwingOut.exceptions(std::ios::failbit); + try { + value.write(throwingOut, shortBudget); + } catch (const std::ios_base::failure&) { + streamThrew = true; + } + CHECK(streamThrew); + CHECK(throwingOut.str().empty()); + + pjson::SerializeOptions unlimited = shortBudget; + unlimited.maxOutputBytes = 0; + CHECK_EQ(value.toString(unlimited), compact); +} + +TEST(serialize_options_output_budget_counts_pretty_escaped_values_and_keys) { + const std::string unicode = "\xC3\xA9"; + pjson value; + value[unicode] = unicode; + + pjson::SerializeOptions options = pjson::SerializeOptions::prettyPrinted(); + options.escapeNonAscii = true; + options.indentWidth = 4; + options.maxOutputBytes = 0; + const std::string expected = value.toString(options); + CHECK(expected.find("\\u00e9") != std::string::npos); + + options.maxOutputBytes = expected.size(); + CHECK_EQ(value.toString(options), expected); + CHECK_EQ(streamed(value, options), expected); + + options.maxOutputBytes = expected.size() - 1U; + bool threw = false; + try { + (void)value.toString(options); + } catch (const std::length_error&) { + threw = true; + } + CHECK(threw); + + std::ostringstream out; + value.write(out, options); + CHECK(out.fail()); + CHECK(out.str().empty()); +} + +TEST(serialize_options_empty_containers_stay_inline) { + pjson value; + value["array"].resetTo(pjson::jsonArray); + value["object"].resetTo(pjson::jsonObject); + + pjson::SerializeOptions options = pjson::SerializeOptions::prettyPrinted(); + options.indentWidth = 1; + CHECK_EQ(value.toString(options), std::string("{\n \"array\": [],\n \"object\": {}\n}")); +} + +TEST(serialize_options_key_order_applies_at_every_depth) { + pjson value; + value["a"]["a"] = static_cast(1); + value["a"]["z"] = static_cast(2); + value["z"] = static_cast(3); + + pjson::SerializeOptions ascending; + CHECK_EQ(value.toString(ascending), std::string("{\"a\":{\"a\":1,\"z\":2},\"z\":3}")); + + pjson::SerializeOptions descending; + descending.keyOrder = pjson::SerializeOptions::DescendingKeys; + CHECK_EQ(value.toString(descending), std::string("{\"z\":3,\"a\":{\"z\":2,\"a\":1}}")); + CHECK_EQ(streamed(value, descending), value.toString(descending)); +} + +TEST(serialize_options_ascii_only_values_and_keys) { + const std::string unicode = std::string("\xC2\x80") + "\xC3\xA9" + "\xE2\x82\xAC" + + "\xF0\x9F\x98\x80" + "\xF4\x8F\xBF\xBF"; + pjson value; + value[unicode] = unicode; + + pjson::SerializeOptions options; + options.escapeNonAscii = true; + const std::string expected = "{\"\\u0080\\u00e9\\u20ac\\ud83d\\ude00\\udbff\\udfff\":" + "\"\\u0080\\u00e9\\u20ac\\ud83d\\ude00\\udbff\\udfff\"}"; + const std::string text = value.toString(options); + CHECK_EQ(text, expected); + CHECK(isAscii(text)); + CHECK_EQ(streamed(value, options), text); + + pjson::unique_ptr reparsed = pjson::parse(text); + CHECK(reparsed != nullptr); + if (reparsed) + CHECK(*reparsed == value); +} + +TEST(serialize_options_invalid_utf8_to_string_throws_and_write_sets_failbit) { + const char raw[] = {static_cast(0xFF), static_cast(0xE2), static_cast(0x82), + static_cast(0xED), static_cast(0xA0), static_cast(0x80)}; + pjson value; + value = std::string(raw, sizeof(raw)); + + pjson::SerializeOptions options; + options.escapeNonAscii = true; + + bool defaultThrew = false; + try { + (void)value.toString(); + } catch (const std::invalid_argument&) { + defaultThrew = true; + } + CHECK(defaultThrew); + + bool threw = false; + try { + (void)value.toString(options); + } catch (const std::invalid_argument&) { + threw = true; + } + CHECK(threw); + + std::ostringstream out; + value.write(out, options); + CHECK(out.fail()); + + bool threwStreamFailure = false; + std::ostringstream throwingOut; + throwingOut.exceptions(std::ios::failbit); + try { + value.write(throwingOut, options); + } catch (const std::ios_base::failure&) { + threwStreamFailure = true; + } + CHECK(threwStreamFailure); + + pjson invalidKey; + invalidKey[std::string(raw, sizeof(raw))] = int64_t(1); + bool keyThrew = false; + try { + (void)invalidKey.toString(); + } catch (const std::invalid_argument&) { + keyThrew = true; + } + CHECK(keyThrew); + + std::ostringstream keyOut; + invalidKey.write(keyOut); + CHECK(keyOut.fail()); +} + +TEST(serialize_options_preserve_ascii_and_required_escapes) { + pjson value; + value = std::string("ASCII \" \\ \b \f \n \r \t ") + static_cast(0x01) + + static_cast(0x7F); + pjson::SerializeOptions options; + options.escapeNonAscii = true; + CHECK_EQ(value.toString(options), std::string("\"ASCII \\\" \\\\ \\b \\f \\n \\r \\t \\u0001") + + static_cast(0x7F) + "\""); +} + +//===----------------------------------------------------------------------===// +// Non-vivifying indexed lookup and strict typed extraction +//===----------------------------------------------------------------------===// + +TEST(value_find_index_is_non_vivifying_and_mutable) { + pjson value; + value = std::vector({10, 20, 30}); + + CHECK(value.hasIndex(0)); + CHECK(value.hasIndex(2)); + CHECK(value.hasIndex(-1)); + CHECK(value.hasIndex(-3)); + CHECK(!value.hasIndex(3)); + CHECK(!value.hasIndex(-4)); + CHECK(!value.hasIndex(INT_MIN)); + + pjson* element = value.find(-2); + CHECK(element != nullptr); + if (element) + *element = static_cast(99); + CHECK_EQ(value.size(), size_t(3)); + const pjson* updated = value.find(1); + CHECK(updated != nullptr); + if (updated) + CHECK_EQ(mustGetInt(*updated), int64_t(99)); +} + +TEST(value_find_index_const_and_wrong_types_do_not_mutate) { + pjson array; + array = std::vector({1}); + const pjson& constArray = array; + static_assert(std::is_same::value, + "const indexed lookup must return const pjson*"); + CHECK(constArray.find(0) != nullptr); + CHECK(constArray.find(1) == nullptr); + + pjson empty; + empty.resetTo(pjson::jsonArray); + CHECK(empty.find(-1) == nullptr); + CHECK_EQ(empty.size(), size_t(0)); + + pjson scalar; + scalar = static_cast(7); + CHECK(scalar.find(0) == nullptr); + CHECK(!scalar.hasIndex(0)); + CHECK(scalar.isInt()); + CHECK_EQ(mustGetInt(scalar), int64_t(7)); +} + +TEST(value_tryget_node_strict_matrix_and_untouched_failures) { + pjson value; + int64_t integer = 91; + double floating = 9.5; + bool boolean = true; + std::string string = "sentinel"; + pjson::StringView view; + + value = static_cast(42); + CHECK(value.tryGet(integer)); + CHECK_EQ(integer, int64_t(42)); + CHECK(value.tryGet(floating)); + CHECK_EQ(floating, 42.0); + CHECK(!value.tryGet(boolean)); + CHECK_EQ(boolean, true); + CHECK(!value.tryGet(string)); + CHECK_EQ(string, std::string("sentinel")); + CHECK(!value.tryGet(view)); + CHECK(view.data() == nullptr); + + value = double(3.5); + integer = 91; + CHECK(!value.tryGet(integer)); + CHECK_EQ(integer, int64_t(91)); + CHECK(value.tryGet(floating)); + CHECK_EQ(floating, 3.5); + + value = false; + CHECK(value.tryGet(boolean)); + CHECK_EQ(boolean, false); +} + +TEST(value_tryget_string_view_is_copy_free_and_handles_nul) { + const char raw[] = {'a', '\0', 'b'}; + pjson value; + value = std::string(raw, sizeof(raw)); + + pjson::StringView view; + CHECK(value.tryGet(view)); + CHECK_EQ(view.size(), size_t(3)); + CHECK(!view.empty()); + CHECK(view.data() != nullptr); + CHECK_EQ(view.data()[0], 'a'); + CHECK_EQ(view.data()[1], '\0'); + CHECK_EQ(view.data()[2], 'b'); + + std::string copy; + CHECK(value.tryGet(copy)); + CHECK_EQ(copy.size(), size_t(3)); + CHECK(view.data() != copy.data()); + + value = std::string(); + CHECK(value.tryGet(view)); + CHECK(view.empty()); + CHECK_EQ(view.size(), size_t(0)); +} + +TEST(value_tryget_keyed_and_indexed_overloads) { + pjson object; + object["i"] = static_cast(7); + object["d"] = double(2.5); + object["b"] = true; + object["s"] = std::string("value"); + + int64_t integer = -1; + double floating = -1.0; + bool boolean = false; + std::string string = "old"; + pjson::StringView view; + CHECK(object.tryGet(std::string("i"), integer)); + CHECK_EQ(integer, int64_t(7)); + CHECK(object.tryGet("i", floating)); + CHECK_EQ(floating, 7.0); + CHECK(object.tryGet("b", boolean)); + CHECK_EQ(boolean, true); + CHECK(object.tryGet(std::string("s"), string)); + CHECK_EQ(string, std::string("value")); + CHECK(object.tryGet("s", view)); + CHECK_EQ(std::string(view.data(), view.size()), std::string("value")); + + pjson array; + array[0] = static_cast(11); + array[1] = double(4.5); + array[2] = false; + array[3] = std::string("tail"); + CHECK(array.tryGet(0, integer)); + CHECK_EQ(integer, int64_t(11)); + CHECK(array.tryGet(0, floating)); + CHECK_EQ(floating, 11.0); + CHECK(array.tryGet(-2, boolean)); + CHECK_EQ(boolean, false); + CHECK(array.tryGet(-1, string)); + CHECK_EQ(string, std::string("tail")); + CHECK(array.tryGet(3, view)); + CHECK_EQ(std::string(view.data(), view.size()), std::string("tail")); +} + +TEST(value_tryget_child_failures_leave_outputs_untouched) { + pjson object; + object["number"] = static_cast(5); + const size_t objectSize = object.size(); + const char* nullKey = nullptr; + + int64_t integer = 77; + double floating = 8.5; + bool boolean = true; + std::string string = "keep"; + pjson::StringView view; + pjson held; + held = std::string("held"); + CHECK(held.tryGet(view)); + const char* viewData = view.data(); + + CHECK(!object.tryGet("missing", integer)); + CHECK(!object.tryGet("number", boolean)); + CHECK(!object.tryGet(nullKey, floating)); + CHECK(!object.tryGet(nullKey, string)); + CHECK(!object.tryGet(nullKey, view)); + CHECK_EQ(integer, int64_t(77)); + CHECK_EQ(floating, 8.5); + CHECK_EQ(boolean, true); + CHECK_EQ(string, std::string("keep")); + CHECK_EQ(view.data(), viewData); + CHECK_EQ(object.size(), objectSize); + + pjson array; + array[0] = static_cast(1); + CHECK(!array.tryGet(1, integer)); + CHECK(!array.tryGet(-2, string)); + CHECK(!array.tryGet(0, view)); + CHECK_EQ(integer, int64_t(77)); + CHECK_EQ(string, std::string("keep")); + CHECK_EQ(view.data(), viewData); + CHECK_EQ(array.size(), size_t(1)); +} + +TEST(value_null_key_safe_lookup_apis) { + pjson object; + object["key"] = std::vector({1, 2}); + const char* nullKey = nullptr; + int64_t integer = 12; + + CHECK(object.find(nullKey) == nullptr); + CHECK(!object.hasKey(nullKey)); + CHECK(!object.tryGet(nullKey, integer)); + CHECK(!object.erase(nullKey)); + CHECK_EQ(integer, int64_t(12)); + CHECK_EQ(object.size(), size_t(1)); +} diff --git a/pjsontest/src/tests_storage.cpp b/pjsontest/src/tests_storage.cpp new file mode 100644 index 0000000..6a54aa5 --- /dev/null +++ b/pjsontest/src/tests_storage.cpp @@ -0,0 +1,283 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Storage-focused tests: inline scalar copy/move/swap behavior, transitions +// between inline and heap-backed kinds, and noexcept trait guarantees. +// +#include "pjson.h" +#include "test_harness.h" + +#include +#include +#include +#include + +using namespace ByteDance; + +static_assert(std::is_nothrow_move_constructible::value, + "pjson move construction must remain noexcept"); +static_assert(noexcept(std::declval().swap(std::declval())), + "pjson::swap must remain noexcept"); + +namespace { + + void expectInt(const pjson& value, int64_t expected) { + int64_t actual = 0; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + + void expectDouble(const pjson& value, double expected) { + double actual = 0.0; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + + void expectBool(const pjson& value, bool expected) { + bool actual = !expected; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + + void expectString(const pjson& value, const std::string& expected) { + std::string actual = ""; + CHECK(value.tryGet(actual)); + CHECK_EQ(actual, expected); + } + +} // namespace + +//===----------------------------------------------------------------------===// +// Copy and move preserve inline scalar values and reset moved-from sources +//===----------------------------------------------------------------------===// + +TEST(storage_copy_constructs_inline_scalars) { + pjson intNode; + intNode = static_cast(42); + pjson intCopy(intNode); + CHECK_EQ(intCopy.getType(), pjson::jsonNumberInt); + expectInt(intCopy, int64_t(42)); + intCopy = static_cast(7); + expectInt(intNode, int64_t(42)); + + pjson doubleNode; + doubleNode = double(3.25); + pjson doubleCopy(doubleNode); + CHECK_EQ(doubleCopy.getType(), pjson::jsonNumberDouble); + expectDouble(doubleCopy, 3.25); + doubleCopy = double(9.5); + expectDouble(doubleNode, 3.25); + + pjson boolNode; + boolNode = true; + pjson boolCopy(boolNode); + CHECK_EQ(boolCopy.getType(), pjson::jsonBoolean); + expectBool(boolCopy, true); + boolCopy = false; + expectBool(boolNode, true); +} + +TEST(storage_copy_assigns_inline_scalars_over_other_types) { + pjson fromInt; + fromInt = static_cast(99); + pjson intoString; + intoString = "old"; + intoString = fromInt; + CHECK_EQ(intoString.getType(), pjson::jsonNumberInt); + expectInt(intoString, int64_t(99)); + expectInt(fromInt, int64_t(99)); + + pjson fromDouble; + fromDouble = double(6.5); + pjson intoArray; + intoArray += static_cast(1); + intoArray = fromDouble; + CHECK_EQ(intoArray.getType(), pjson::jsonNumberDouble); + expectDouble(intoArray, 6.5); + expectDouble(fromDouble, 6.5); + + pjson fromBool; + fromBool = true; + pjson intoObject; + intoObject["k"] = static_cast(1); + intoObject = fromBool; + CHECK_EQ(intoObject.getType(), pjson::jsonBoolean); + expectBool(intoObject, true); + expectBool(fromBool, true); +} + +TEST(storage_move_constructs_inline_scalars) { + pjson intNode; + intNode = static_cast(1234); + pjson movedInt(std::move(intNode)); + CHECK_EQ(movedInt.getType(), pjson::jsonNumberInt); + expectInt(movedInt, int64_t(1234)); + CHECK(intNode.isNull()); + + pjson doubleNode; + doubleNode = double(8.75); + pjson movedDouble(std::move(doubleNode)); + CHECK_EQ(movedDouble.getType(), pjson::jsonNumberDouble); + expectDouble(movedDouble, 8.75); + CHECK(doubleNode.isNull()); + + pjson boolNode; + boolNode = false; + pjson movedBool(std::move(boolNode)); + CHECK_EQ(movedBool.getType(), pjson::jsonBoolean); + expectBool(movedBool, false); + CHECK(boolNode.isNull()); +} + +TEST(storage_move_assigns_inline_scalars_over_other_types) { + pjson fromInt; + fromInt = static_cast(-7); + pjson intoMap; + intoMap["v"] = "x"; + intoMap = std::move(fromInt); + CHECK_EQ(intoMap.getType(), pjson::jsonNumberInt); + expectInt(intoMap, int64_t(-7)); + CHECK(fromInt.isNull()); + + pjson fromDouble; + fromDouble = double(-2.5); + pjson intoString; + intoString = "before"; + intoString = std::move(fromDouble); + CHECK_EQ(intoString.getType(), pjson::jsonNumberDouble); + expectDouble(intoString, -2.5); + CHECK(fromDouble.isNull()); + + pjson fromBool; + fromBool = true; + pjson intoArray; + intoArray += std::vector({1, 2, 3}); + intoArray = std::move(fromBool); + CHECK_EQ(intoArray.getType(), pjson::jsonBoolean); + expectBool(intoArray, true); + CHECK(fromBool.isNull()); +} + +//===----------------------------------------------------------------------===// +// Swap behavior across inline and heap-backed storage +//===----------------------------------------------------------------------===// + +TEST(storage_self_swap_preserves_inline_scalars) { + pjson intNode; + intNode = static_cast(-44); + intNode.swap(intNode); + CHECK_EQ(intNode.getType(), pjson::jsonNumberInt); + expectInt(intNode, int64_t(-44)); + + pjson doubleNode; + doubleNode = double(-0.25); + doubleNode.swap(doubleNode); + CHECK_EQ(doubleNode.getType(), pjson::jsonNumberDouble); + expectDouble(doubleNode, -0.25); + + pjson boolNode; + boolNode = true; + boolNode.swap(boolNode); + CHECK_EQ(boolNode.getType(), pjson::jsonBoolean); + expectBool(boolNode, true); +} + +TEST(storage_swaps_inline_and_heap_backed_values) { + pjson intNode; + intNode = static_cast(11); + pjson stringNode; + stringNode = "eleven"; + intNode.swap(stringNode); + CHECK(intNode.isString()); + expectString(intNode, "eleven"); + CHECK(stringNode.isInt()); + expectInt(stringNode, int64_t(11)); + + pjson boolNode; + boolNode = false; + pjson arrayNode; + arrayNode += static_cast(1); + arrayNode += static_cast(2); + boolNode.swap(arrayNode); + CHECK(boolNode.isArray()); + CHECK_EQ(boolNode.size(), size_t(2)); + CHECK(arrayNode.isBool()); + expectBool(arrayNode, false); + + pjson doubleNode; + doubleNode = double(4.5); + pjson mapNode; + mapNode["pi"] = double(3.14); + doubleNode.swap(mapNode); + CHECK(doubleNode.isObject()); + CHECK(doubleNode.hasKey("pi")); + CHECK(mapNode.isDouble()); + expectDouble(mapNode, 4.5); +} + +//===----------------------------------------------------------------------===// +// Type transitions and end-to-end scalar round trips +//===----------------------------------------------------------------------===// + +TEST(storage_scalar_type_transitions_preserve_behavior) { + pjson value; + value = static_cast(5); + CHECK_EQ(value.getType(), pjson::jsonNumberInt); + CHECK_EQ(value.toString(), std::string("5")); + + value = double(5.5); + CHECK_EQ(value.getType(), pjson::jsonNumberDouble); + CHECK_EQ(value.toString(), std::string("5.5")); + + value = true; + CHECK_EQ(value.getType(), pjson::jsonBoolean); + CHECK_EQ(value.toString(), std::string("true")); + + value = "text"; + CHECK_EQ(value.getType(), pjson::jsonString); + expectString(value, "text"); + + value += static_cast(1); + CHECK(value.isArray()); + CHECK_EQ(value.size(), size_t(1)); + expectInt(value[0], int64_t(1)); + + value = static_cast(8); + CHECK(value.isInt()); + expectInt(value, int64_t(8)); + + value["answer"] = static_cast(42); + CHECK(value.isObject()); + expectInt(value["answer"], int64_t(42)); + + value = false; + CHECK(value.isBool()); + CHECK_EQ(value.toString(), std::string("false")); +} + +TEST(storage_scalar_parse_copy_move_and_serialize_round_trip) { + pjson::unique_ptr parsed = pjson::parse(R"({"i":1,"d":2.5,"b":true})"); + CHECK(parsed != nullptr); + expectInt((*parsed)["i"], int64_t(1)); + expectDouble((*parsed)["d"], 2.5); + expectBool((*parsed)["b"], true); + + pjson copied(*parsed); + CHECK(copied == *parsed); + pjson moved(std::move(copied)); + CHECK(moved == *parsed); + CHECK(copied.isNull()); + CHECK_EQ(moved.toString(), std::string("{\"b\":true,\"d\":2.5,\"i\":1}")); +} diff --git a/pjsontest/src/tests_streaming.cpp b/pjsontest/src/tests_streaming.cpp new file mode 100644 index 0000000..4ed91b4 --- /dev/null +++ b/pjsontest/src/tests_streaming.cpp @@ -0,0 +1,538 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// Streaming SAX parsing and direct ostream serialization tests. +// +#include "pjson.h" +#include "test_harness.h" +#include "test_util.h" + +#include +#include +#include +#include +#include +#include + +using namespace ByteDance; +using pjson_test::parse; +using pjson_test::valueInt; + +namespace { + + // Captures the SAX callback stream in a compact form suitable for exact ordering checks. + struct RecordingHandler : pjson::SaxHandler { + std::vector events; + + bool onNull() override { + events.push_back("null"); + return true; + } + bool onBool(bool value) override { + events.push_back(value ? "bool:true" : "bool:false"); + return true; + } + bool onInt(int64_t value) override { + events.push_back("int:" + std::to_string(value)); + return true; + } + bool onDouble(double value) override { + pjson tmp; + tmp = value; + events.push_back("double:" + tmp.toString()); + return true; + } + bool onString(const std::string& value) override { + events.push_back("string:" + value); + return true; + } + bool onStartArray() override { + events.push_back("start-array"); + return true; + } + bool onEndArray() override { + events.push_back("end-array"); + return true; + } + bool onStartObject() override { + events.push_back("start-object"); + return true; + } + bool onKey(const std::string& key) override { + events.push_back("key:" + key); + return true; + } + bool onEndObject() override { + events.push_back("end-object"); + return true; + } + }; + + // Records callbacks normally, then asks the parser to cancel after a fixed event budget. + struct CancelAfterNHandler : RecordingHandler { + explicit CancelAfterNHandler(size_t limit) + : remaining(limit) {} + + size_t remaining; + + bool allow() { + if (remaining == 0) + return false; + --remaining; + return true; + } + + bool onNull() override { + RecordingHandler::onNull(); + return allow(); + } + bool onBool(bool value) override { + RecordingHandler::onBool(value); + return allow(); + } + bool onInt(int64_t value) override { + RecordingHandler::onInt(value); + return allow(); + } + bool onDouble(double value) override { + RecordingHandler::onDouble(value); + return allow(); + } + bool onString(const std::string& value) override { + RecordingHandler::onString(value); + return allow(); + } + bool onStartArray() override { + RecordingHandler::onStartArray(); + return allow(); + } + bool onEndArray() override { + RecordingHandler::onEndArray(); + return allow(); + } + bool onStartObject() override { + RecordingHandler::onStartObject(); + return allow(); + } + bool onKey(const std::string& key) override { + RecordingHandler::onKey(key); + return allow(); + } + bool onEndObject() override { + RecordingHandler::onEndObject(); + return allow(); + } + }; + + // Simulates a consumer exception from inside a SAX callback. + struct ThrowingHandler : RecordingHandler { + bool onKey(const std::string& key) override { + RecordingHandler::onKey(key); + throw std::runtime_error("boom"); + } + }; + + struct BadAllocHandler : RecordingHandler { + bool onString(const std::string&) override { throw std::bad_alloc(); } + }; + + struct NumberHandler : pjson::SaxHandler { + bool sawDouble = false; + double value = 1.0; + + bool onDouble(double number) override { + sawDouble = true; + value = number; + return true; + } + }; + + // Exposes an input string in small refills to force tokens across stream-buffer boundaries. + class ChunkedStreamBuf : public std::streambuf { + public: + ChunkedStreamBuf(const std::string& src, size_t chunk) + : _src(src) + , _chunk(chunk) + , _pos(0) { + setg(_buffer, _buffer, _buffer); + } + + protected: + int_type underflow() override { + if (_pos >= _src.size()) + return traits_type::eof(); + const size_t n = std::min(_chunk, _src.size() - _pos); + for (size_t i = 0; i < n; ++i) + _buffer[i] = _src[_pos + i]; + _pos += n; + setg(_buffer, _buffer, _buffer + static_cast(n)); + return traits_type::to_int_type(*gptr()); + } + + private: + // Own fixture bytes so construction from a temporary string remains + // valid for the stream's complete lifetime. + const std::string _src; + size_t _chunk; + size_t _pos; + char _buffer[32]; + }; + + // Owns the chunking buffer for the std::istream interface consumed by parseSaxStream(). + struct ChunkedIStream : std::istream { + ChunkedIStream(const std::string& src, size_t chunk) + : std::istream(nullptr) + , _buf(src, chunk) { + rdbuf(&_buf); + } + + private: + ChunkedStreamBuf _buf; + }; + + // Accepts at most `limit` output bytes, then reports a short write to its ostream. + class FailingStreamBuf : public std::stringbuf { + public: + explicit FailingStreamBuf(size_t limit) + : _limit(limit) + , _written(0) {} + + protected: + std::streamsize xsputn(const char* s, std::streamsize count) override { + const std::streamsize room = + static_cast(_limit > _written ? _limit - _written : 0); + const std::streamsize n = room < count ? room : count; + if (n > 0) { + _written += static_cast(n); + return std::stringbuf::xsputn(s, n); + } + return 0; + } + + int_type overflow(int_type ch) override { + if (traits_type::eq_int_type(ch, traits_type::eof())) + return traits_type::not_eof(ch); + if (_written >= _limit) + return traits_type::eof(); + ++_written; + return std::stringbuf::overflow(ch); + } + + private: + size_t _limit; + size_t _written; + }; + + // Owns FailingStreamBuf so writer failure propagation can be tested through std::ostream. + struct FailingOStream : std::ostream { + explicit FailingOStream(size_t limit) + : std::ostream(nullptr) + , _buf(limit) { + rdbuf(&_buf); + } + + private: + FailingStreamBuf _buf; + }; +} // namespace + +//===----------------------------------------------------------------------===// +// SAX event ordering, incremental input, cancellation, and diagnostics +//===----------------------------------------------------------------------===// + +TEST(streaming_sax_scalar_events) { + RecordingHandler h; + CHECK(pjson::parseSax(" [null,true,false,1,2.5,\"x\"] ", h)); + CHECK_EQ(h.events.size(), size_t(8)); + CHECK_EQ(h.events[0], std::string("start-array")); + CHECK_EQ(h.events[1], std::string("null")); + CHECK_EQ(h.events[2], std::string("bool:true")); + CHECK_EQ(h.events[3], std::string("bool:false")); + CHECK_EQ(h.events[4], std::string("int:1")); + CHECK_EQ(h.events[5], std::string("double:2.5")); + CHECK_EQ(h.events[6], std::string("string:x")); + CHECK_EQ(h.events[7], std::string("end-array")); +} + +TEST(streaming_sax_object_order_and_empty_containers) { + RecordingHandler h; + CHECK(pjson::parseSax("{\"a\":{},\"b\":[],\"c\":{\"d\":[1]}}", h)); + const std::vector want = { + "start-object", "key:a", "start-object", "end-object", "key:b", + "start-array", "end-array", "key:c", "start-object", "key:d", + "start-array", "int:1", "end-array", "end-object", "end-object"}; + CHECK_EQ(h.events.size(), want.size()); + for (size_t i = 0; i < want.size(); ++i) + CHECK_EQ(h.events[i], want[i]); +} + +TEST(streaming_sax_chunked_stream_boundaries) { + const std::string doc = "{\"msg\":\"hello\",\"arr\":[1,2,3],\"nested\":{\"ok\":true}}"; + ChunkedIStream in(doc, 1); + RecordingHandler h; + pjson::ParseError err; + CHECK(pjson::parseSaxStream(in, h, err)); + CHECK(err.ok); + CHECK_EQ(h.events.front(), std::string("start-object")); + CHECK_EQ(h.events.back(), std::string("end-object")); + CHECK(std::find(h.events.begin(), h.events.end(), std::string("string:hello")) != + h.events.end()); + CHECK(std::find(h.events.begin(), h.events.end(), std::string("bool:true")) != h.events.end()); +} + +TEST(streaming_sax_utf8_escape_and_number_chunk_boundaries) { + const std::string doc = "{\"utf8\":\"\xC3\xA9\",\"escaped\":\"\\uD83D\\uDE00\"," + "\"number\":-12.5e+3}"; + for (size_t chunk = 1; chunk <= 4; ++chunk) { + ChunkedIStream in(doc, chunk); + RecordingHandler h; + pjson::ParseError err; + CHECK(pjson::parseSaxStream(in, h, err)); + CHECK(err.ok); + CHECK(std::find(h.events.begin(), h.events.end(), std::string("string:\xC3\xA9")) != + h.events.end()); + CHECK(std::find(h.events.begin(), h.events.end(), std::string("string:\xF0\x9F\x98\x80")) != + h.events.end()); + CHECK(std::find(h.events.begin(), h.events.end(), std::string("double:-12500.0")) != + h.events.end()); + } +} + +TEST(streaming_sax_crlf_split_reports_coordinates) { + const std::string doc = "{\r\n\"a\": [1,\r\n]}"; + ChunkedIStream in(doc, 1); + RecordingHandler h; + pjson::ParseError err; + CHECK(!pjson::parseSaxStream(in, h, err)); + CHECK_EQ(err.line, size_t(3)); + CHECK_EQ(err.column, size_t(1)); +} + +TEST(streaming_sax_duplicate_key_policies) { + const std::string doc = "{\"a\":1,\"a\":2}"; + + RecordingHandler keepFirst; + pjson::ParseOptions first; + first.duplicateKeys = pjson::ParseOptions::KeepFirstDuplicate; + CHECK(pjson::parseSax(doc, keepFirst, first)); + const std::vector wantFirst = {"start-object", "key:a", "int:1", "end-object"}; + CHECK_EQ(keepFirst.events.size(), wantFirst.size()); + for (size_t i = 0; i < wantFirst.size(); ++i) + CHECK_EQ(keepFirst.events[i], wantFirst[i]); + + RecordingHandler keepLast; + pjson::ParseOptions last; + last.duplicateKeys = pjson::ParseOptions::KeepLastDuplicate; + CHECK(pjson::parseSax(doc, keepLast, last)); + CHECK_EQ(keepLast.events.size(), size_t(6)); + CHECK_EQ(keepLast.events[1], std::string("key:a")); + CHECK_EQ(keepLast.events[2], std::string("int:1")); + CHECK_EQ(keepLast.events[3], std::string("key:a")); + CHECK_EQ(keepLast.events[4], std::string("int:2")); + + RecordingHandler reject; + pjson::ParseError err; + CHECK(!pjson::parseSax(doc, reject, err)); + CHECK(!err.ok); + CHECK(err.message.find("duplicate") != std::string::npos); + + ChunkedIStream streamed(doc, 1); + RecordingHandler streamReject; + CHECK(!pjson::parseSaxStream(streamed, streamReject, err)); + CHECK_EQ(err.offset, size_t(7)); + CHECK_EQ(err.line, size_t(1)); + CHECK_EQ(err.column, size_t(8)); +} + +TEST(streaming_sax_errors_report_line_and_column) { + RecordingHandler h; + pjson::ParseError err; + CHECK(!pjson::parseSax("{\r\n \"a\": [1,\r\n}", h, err)); + CHECK(!err.ok); + CHECK_EQ(err.line, size_t(3)); + CHECK_EQ(err.column, size_t(1)); + CHECK(!err.message.empty()); +} + +TEST(streaming_sax_cancel_and_throw_become_parse_error) { + CancelAfterNHandler cancel(3); + pjson::ParseError err; + CHECK(!pjson::parseSax("[1,2,3]", cancel, err)); + CHECK(!err.ok); + CHECK(err.message.find("aborted") != std::string::npos); + + ThrowingHandler throwing; + CHECK(!pjson::parseSax("{\"a\":1}", throwing, err)); + CHECK(!err.ok); + CHECK(err.message.find("exception") != std::string::npos); + + ChunkedIStream throwingStream("{\"a\":1}", 1); + ThrowingHandler streamThrowing; + CHECK(!pjson::parseSaxStream(throwingStream, streamThrowing, err)); + CHECK(!err.ok); + CHECK(err.message.empty() || err.message.find("exception") != std::string::npos); + + BadAllocHandler allocationFailure; + CHECK(!pjson::parseSax("\"value\"", allocationFailure, err)); + CHECK(!err.ok); + CHECK(err.message.empty() || err.message.find("memory") != std::string::npos); + + ChunkedIStream stream("\"value\"", 1); + BadAllocHandler streamedAllocationFailure; + CHECK(!pjson::parseSaxStream(stream, streamedAllocationFailure, err)); + CHECK(!err.ok); + CHECK(err.message.empty() || err.message.find("memory") != std::string::npos); +} + +TEST(streaming_sax_null_stream_buffer_reports_read_failure) { + std::istream input(nullptr); + RecordingHandler handler; + pjson::ParseError error; + + CHECK(!pjson::parseSaxStream(input, handler, error)); + CHECK(!error.ok); + CHECK(error.message.find("stream read failed") != std::string::npos); + CHECK(handler.events.empty()); +} + +TEST(streaming_sax_number_range_matches_dom_parser) { + const char* overflows[] = {"1e400", "-1e400"}; + for (size_t i = 0; i < sizeof(overflows) / sizeof(overflows[0]); ++i) { + CHECK(pjson::parse(overflows[i]) == nullptr); + NumberHandler handler; + pjson::ParseError err; + CHECK(!pjson::parseSax(overflows[i], handler, err)); + CHECK(!err.ok); + CHECK(!handler.sawDouble); + + ChunkedIStream stream(overflows[i], 1); + NumberHandler streamHandler; + CHECK(!pjson::parseSaxStream(stream, streamHandler, err)); + CHECK(!err.ok); + CHECK(!streamHandler.sawDouble); + } + + const char* accepted[] = {"1e-400", "4.9406564584124654e-324"}; + for (size_t i = 0; i < sizeof(accepted) / sizeof(accepted[0]); ++i) { + pjson::unique_ptr dom = pjson::parse(accepted[i]); + CHECK(dom != nullptr); + NumberHandler handler; + pjson::ParseError err; + CHECK(pjson::parseSax(accepted[i], handler, err)); + CHECK(err.ok); + CHECK(handler.sawDouble); + double domValue = 1.0; + CHECK(dom->tryGet(domValue)); + CHECK_EQ(handler.value, domValue); + + ChunkedIStream stream(accepted[i], 1); + NumberHandler streamHandler; + CHECK(pjson::parseSaxStream(stream, streamHandler, err)); + CHECK_EQ(err.message, std::string()); + CHECK(streamHandler.sawDouble); + CHECK_EQ(streamHandler.value, domValue); + } +} + +TEST(streaming_sax_max_input_bytes_and_max_nodes_on_stream) { + const std::string doc = "[1,2,3,4]"; + ChunkedIStream in1(doc, 2); + RecordingHandler h1; + pjson::ParseOptions bytes; + bytes.maxInputBytes = 4; + pjson::ParseError err; + CHECK(!pjson::parseSaxStream(in1, h1, err, bytes)); + CHECK(!err.ok); + CHECK_EQ(err.offset, size_t(4)); + CHECK(err.message.find("maxInputBytes") != std::string::npos); + + ChunkedIStream in2(doc, 2); + RecordingHandler h2; + pjson::ParseOptions nodes; + nodes.maxNodes = 3; + CHECK(!pjson::parseSaxStream(in2, h2, err, nodes)); + CHECK(!err.ok); + CHECK(err.message.find("node budget") != std::string::npos); + + const std::string nested = "[[1]]"; + ChunkedIStream in3(nested, 1); + RecordingHandler h3; + pjson::ParseOptions depth; + depth.maxDepth = 0; // same effective minimum limit as the DOM parser + CHECK(!pjson::parseSaxStream(in3, h3, err, depth)); + CHECK(err.message.find("depth") != std::string::npos); +} + +//===----------------------------------------------------------------------===// +// Large incremental input stays streaming rather than requiring a contiguous buffer +//===----------------------------------------------------------------------===// + +TEST(streaming_sax_large_stream_does_not_need_full_buffer) { + std::string doc = "["; + for (int i = 0; i < 2000; ++i) { + if (i != 0) + doc += ','; + doc += std::to_string(i); + } + doc += "]"; + + ChunkedIStream in(doc, 7); + RecordingHandler h; + CHECK(pjson::parseSaxStream(in, h)); + CHECK_EQ(h.events.front(), std::string("start-array")); + CHECK_EQ(h.events.back(), std::string("end-array")); + CHECK_EQ(h.events.size(), size_t(2002)); + CHECK_EQ(h.events[1], std::string("int:0")); + CHECK_EQ(h.events[h.events.size() - 2], std::string("int:1999")); +} + +//===----------------------------------------------------------------------===// +// Direct ostream serialization and output-failure propagation +//===----------------------------------------------------------------------===// + +TEST(streaming_writer_matches_to_string_and_pretty) { + auto p = parse("{\"a\":1,\"b\":[2,3],\"c\":{\"x\":\"y\"}}"); + CHECK(p != nullptr); + + std::ostringstream compact; + p->write(compact); + CHECK_EQ(compact.str(), p->toString()); + + pjson::SerializeOptions prettyOpts = pjson::SerializeOptions::prettyPrinted(); + std::ostringstream pretty; + p->write(pretty, prettyOpts); + CHECK_EQ(pretty.str(), p->toString(prettyOpts)); +} + +TEST(streaming_writer_sets_failbit_on_write_failure) { + pjson doc; + doc["a"] = std::vector({1, 2, 3, 4, 5}); + FailingOStream out(5); + doc.write(out); + CHECK(out.fail()); +} + +TEST(streaming_writer_handles_deep_documents_iteratively) { + const int depth = 20000; + pjson root; + pjson* cur = &root; + for (int i = 0; i < depth; ++i) + cur = &((*cur)["a"]); + *cur = int64_t(1); + + std::ostringstream out; + root.write(out); + CHECK_EQ(out.str(), root.toString()); +} diff --git a/pjsontest/src/tests_strings.cpp b/pjsontest/src/tests_strings.cpp new file mode 100644 index 0000000..920cf01 --- /dev/null +++ b/pjsontest/src/tests_strings.cpp @@ -0,0 +1,184 @@ +// +// Copyright 2025 ByteDance Ltd. and/or its affiliates. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//===----------------------------------------------------------------------===// +// String escaping/unescaping and \uXXXX plus surrogate handling. +// +#include "pjson.h" +#include "test_harness.h" +#include "test_util.h" + +#include + +using namespace ByteDance; +using pjson_test::parse; + +namespace { + + std::string stringValue(const pjson& aValue) { + std::string value; + CHECK(aValue.tryGet(value)); + return value; + } + +} // namespace + +//===----------------------------------------------------------------------===// +// Every mandatory escape is emitted on serialize +//===----------------------------------------------------------------------===// +TEST(serialize_mandatory_escapes) { + pjson j; + j = std::string("\" \\ \b \f \n \r \t"); + std::string out = j.toString(); + CHECK(out.find("\\\"") != std::string::npos); + CHECK(out.find("\\\\") != std::string::npos); + CHECK(out.find("\\b") != std::string::npos); + CHECK(out.find("\\f") != std::string::npos); + CHECK(out.find("\\n") != std::string::npos); + CHECK(out.find("\\r") != std::string::npos); + CHECK(out.find("\\t") != std::string::npos); +} + +TEST(serialize_control_chars_as_u_escape) { + pjson j; + j = std::string("\x01\x02\x1f", 3); + std::string out = j.toString(); + CHECK(out.find("\\u0001") != std::string::npos); + CHECK(out.find("\\u0002") != std::string::npos); + CHECK(out.find("\\u001f") != std::string::npos); +} + +TEST(serialize_forward_slash_not_escaped) { + pjson j; + j = std::string("a/b/c"); + // '/' is legal unescaped; keep output clean. + CHECK_EQ(j.toString(), std::string("\"a/b/c\"")); +} + +TEST(serialize_printable_ascii_unchanged) { + pjson j; + j = std::string("Hello, World! 123 ~"); + CHECK_EQ(j.toString(), std::string("\"Hello, World! 123 ~\"")); +} + +//===----------------------------------------------------------------------===// +// Round-trip: every escape survives parse(serialize(x)) == x +//===----------------------------------------------------------------------===// +TEST(escape_round_trip_all_specials) { + pjson j; + j["msg"] = std::string("q\"b\\s /f\b\f\n\r\t end"); + auto rt = parse(j.toString()); + CHECK(rt != nullptr); + CHECK_EQ(stringValue((*rt)["msg"]), std::string("q\"b\\s /f\b\f\n\r\t end")); +} + +TEST(escape_round_trip_control_bytes) { + std::string all; + for (int c = 1; c < 0x20; ++c) + all += static_cast(c); + pjson j; + j["c"] = all; + auto rt = parse(j.toString()); + CHECK(rt != nullptr); + CHECK_EQ(stringValue((*rt)["c"]), all); +} + +TEST(escape_map_keys_too) { + pjson j; + j[std::string("key\"\\\n\t")] = int64_t(1); + auto rt = parse(j.toString()); + CHECK(rt != nullptr); + CHECK(rt->hasKey(std::string("key\"\\\n\t"))); +} + +//===----------------------------------------------------------------------===// +// Parser unescapes correctly +//===----------------------------------------------------------------------===// +TEST(parse_unescapes_simple) { + auto p = parse("\"line1\\nline2\\ttab\""); + CHECK(p != nullptr); + CHECK_EQ(stringValue(*p), std::string("line1\nline2\ttab")); +} + +TEST(parse_unescapes_quote_and_backslash) { + auto p = parse("\"a\\\"b\\\\c\""); + CHECK(p != nullptr); + CHECK_EQ(stringValue(*p), std::string("a\"b\\c")); +} + +TEST(parse_escaped_forward_slash) { + auto p = parse("\"a\\/b\""); + CHECK(p != nullptr); + CHECK_EQ(stringValue(*p), std::string("a/b")); +} + +TEST(parse_empty_string) { + auto a = parse("\"\""); + CHECK(a != nullptr); + CHECK_EQ(stringValue(*a), std::string("")); + auto o = parse("{\"k\":\"\"}"); + CHECK(o != nullptr); + CHECK_EQ(stringValue((*o)["k"]), std::string("")); +} + +//===----------------------------------------------------------------------===// +// \uXXXX decoding to UTF-8 (1/2/3-byte) and surrogate pairs (4-byte) +//===----------------------------------------------------------------------===// +TEST(unicode_ascii_escape) { + auto p = parse("\"\\u0041\\u0042\""); // "AB" + CHECK(p != nullptr); + CHECK_EQ(stringValue(*p), std::string("AB")); +} + +TEST(unicode_two_byte) { + auto p = parse("\"\\u00e9\""); // é + CHECK(p != nullptr); + const std::string value = stringValue(*p); + CHECK_EQ(value.size(), size_t(2)); + CHECK_EQ(static_cast(value[0]), 0xC3u); + CHECK_EQ(static_cast(value[1]), 0xA9u); +} + +TEST(unicode_three_byte) { + auto p = parse("\"\\u20ac\""); // € (euro sign) + CHECK(p != nullptr); + CHECK_EQ(stringValue(*p).size(), size_t(3)); +} + +TEST(unicode_surrogate_pair_four_byte) { + auto p = parse("\"\\uD83D\\uDE00\""); // 😀 U+1F600 + CHECK(p != nullptr); + const std::string value = stringValue(*p); + CHECK_EQ(value.size(), size_t(4)); + CHECK_EQ(static_cast(value[0]), 0xF0u); +} + +TEST(unicode_invalid_hex_rejected) { + CHECK_PARSE_FAILS("\"\\uZZZZ\""); + CHECK_PARSE_FAILS("\"\\u12\""); // too few hex digits + CHECK_PARSE_FAILS("\"\\u\""); +} + +TEST(unicode_round_trip_through_serialize) { + // A parsed multibyte value re-serializes as raw UTF-8 (passthrough) and + // parses back to the identical bytes. + auto p = parse("\"\\u20ac\""); + CHECK(p != nullptr); + std::string euro = stringValue(*p); + pjson j; + j = euro; + auto rt = parse(j.toString()); + CHECK(rt != nullptr); + CHECK_EQ(stringValue(*rt), euro); +} diff --git a/scripts/fetch-json-schema-test-suite.sh b/scripts/fetch-json-schema-test-suite.sh new file mode 100755 index 0000000..6f18618 --- /dev/null +++ b/scripts/fetch-json-schema-test-suite.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash + +# Fetch the exact JSON Schema draft-07 conformance corpus used by pjsontest. + +set -euo pipefail + +# ---- Repository paths and pinned input --------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +PINNED_COMMIT="3c25e5f709192aadf67cf7f2eb19771a57131fec" +SOURCE_URL="${PJSON_JSON_SCHEMA_TEST_SUITE_URL:-https://github.com/json-schema-org/JSON-Schema-Test-Suite.git}" +DEFAULT_DEST="${REPO_ROOT}/.test-corpora/JSON-Schema-Test-Suite" + +# Prints destination, override, and revision details without touching disk. +usage() { + cat <&2 + exit 1 +fi +if [ "$#" -eq 1 ]; then + case "$1" in + -h|--help) + usage + exit 0 + ;; + -*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + *) + DEST="$1" + ;; + esac +fi + +# Never allow a typo or empty override to turn the repository root or the +# filesystem root into a Git checkout destination. +case "${DEST}" in + "${REPO_ROOT}"|/|"") + echo "Refusing to use unsafe destination: '${DEST}'" >&2 + exit 1 + ;; +esac + +mkdir -p "$(dirname "${DEST}")" + +# ---- Idempotent pinned checkout ---------------------------------------- + +# Preserve local work in an existing clone. Clean checkouts are detached at the +# pinned commit; the fetch itself is depth-limited to minimize network traffic. +if [ -d "${DEST}/.git" ]; then + echo "Updating JSON-Schema-Test-Suite in ${DEST}" + if [ -n "$(git -C "${DEST}" status --short)" ]; then + echo "Destination checkout has local changes; refusing to overwrite ${DEST}" >&2 + exit 1 + fi + git -C "${DEST}" fetch --depth=1 origin "${PINNED_COMMIT}" + git -C "${DEST}" checkout --detach "${PINNED_COMMIT}" +elif [ -e "${DEST}" ]; then + echo "Destination exists and is not a git checkout: ${DEST}" >&2 + exit 1 +else + echo "Cloning JSON-Schema-Test-Suite into ${DEST}" + git clone --no-checkout "${SOURCE_URL}" "${DEST}" + git -C "${DEST}" fetch --depth=1 origin "${PINNED_COMMIT}" + git -C "${DEST}" checkout --detach "${PINNED_COMMIT}" +fi + +# ---- Corpus integrity checks ------------------------------------------- + +if [ ! -d "${DEST}/tests/draft7" ]; then + echo "Expected tests/draft7 directory not found under ${DEST}" >&2 + exit 1 +fi + +if [ "$(git -C "${DEST}" rev-parse HEAD)" != "${PINNED_COMMIT}" ]; then + echo "JSON-Schema-Test-Suite checkout is not at the pinned commit ${PINNED_COMMIT}" >&2 + exit 1 +fi + +echo "JSON-Schema-Test-Suite ready at ${DEST}" +echo "Pinned commit: ${PINNED_COMMIT}" +echo "Run tests with:" +echo " export PJSON_JSON_SCHEMA_TEST_SUITE_DIR=${DEST}" +echo " ctest --test-dir ${REPO_ROOT}/out/build-debug" +echo " -R schema_official_draft7_optional -V" diff --git a/scripts/fetch-json-test-suite.sh b/scripts/fetch-json-test-suite.sh new file mode 100755 index 0000000..03b1284 --- /dev/null +++ b/scripts/fetch-json-test-suite.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash + +# Fetch the exact JSON parser conformance corpus used by pjsontest. + +set -euo pipefail + +# ---- Repository paths and pinned input --------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +DEFAULT_DEST="${REPO_ROOT}/.test-corpora/JSONTestSuite" +SOURCE_URL="${PJSON_JSONTESTSUITE_URL:-https://github.com/nst/JSONTestSuite.git}" +PINNED_COMMIT="${PJSON_JSONTESTSUITE_COMMIT:-1ef36fa01286573e846ac449e8683f8833c5b26a}" + +# Prints destination, override, and revision details without touching disk. +usage() { + cat <&2 + exit 1 +fi +if [ "$#" -eq 1 ]; then + case "$1" in + -h|--help) + usage + exit 0 + ;; + -*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + *) + DEST="$1" + ;; + esac +fi + +# Never allow a typo or empty override to turn the repository root or the +# filesystem root into a Git checkout destination. +case "${DEST}" in + "${REPO_ROOT}"|/|"" ) + echo "Refusing to use unsafe destination: '${DEST}'" >&2 + exit 1 + ;; +esac + +mkdir -p "$(dirname "${DEST}")" + +# ---- Idempotent pinned checkout ---------------------------------------- + +# Preserve local work in an existing clone. Clean checkouts are detached at the +# pinned commit; the fetch itself is depth-limited to minimize network traffic. +if [ -d "${DEST}/.git" ]; then + echo "Updating JSONTestSuite in ${DEST}" + if [ -n "$(git -C "${DEST}" status --short)" ]; then + echo "Destination checkout has local changes; refusing to overwrite ${DEST}" >&2 + exit 1 + fi + git -C "${DEST}" fetch --depth=1 origin "${PINNED_COMMIT}" + git -C "${DEST}" checkout --detach "${PINNED_COMMIT}" +elif [ -e "${DEST}" ]; then + echo "Destination exists and is not a git checkout: ${DEST}" >&2 + exit 1 +else + echo "Cloning JSONTestSuite into ${DEST}" + git clone --no-checkout --filter=blob:none "${SOURCE_URL}" "${DEST}" + git -C "${DEST}" fetch --depth=1 origin "${PINNED_COMMIT}" + git -C "${DEST}" checkout --detach "${PINNED_COMMIT}" +fi + +# ---- Corpus integrity checks ------------------------------------------- + +if [ "$(git -C "${DEST}" rev-parse HEAD)" != "${PINNED_COMMIT}" ]; then + echo "JSONTestSuite checkout is not at the pinned commit ${PINNED_COMMIT}" >&2 + exit 1 +fi + +if [ ! -d "${DEST}/test_parsing" ]; then + echo "Expected test_parsing directory not found under ${DEST}" >&2 + exit 1 +fi + +echo "JSONTestSuite ready at ${DEST}" +if [ "${DEST}" = "${DEFAULT_DEST}" ]; then + echo "build.sh will discover this checkout automatically. Run:" + echo " ./build.sh --test" +else + echo "Custom destination: point the test runner at it with:" + echo " PJSON_JSONTESTSUITE_DIR=${DEST} ./build.sh --test" +fi diff --git a/test_package/CMakeLists.txt b/test_package/CMakeLists.txt new file mode 100644 index 0000000..8eea462 --- /dev/null +++ b/test_package/CMakeLists.txt @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.21) +project(pjson_package_test LANGUAGES CXX) + +# ---- Packaged dependency discovery ------------------------------------- + +# Conan's CMakeDeps generator supplies this config package from the exact +# package reference under test. +find_package(pjson CONFIG REQUIRED) + +# ---- External consumer -------------------------------------------------- + +# Build as a C++11 client linked only through the package's exported target. +add_executable(pjson_package_test src/pjson_package_test.cpp) +target_compile_features(pjson_package_test PRIVATE cxx_std_11) +target_link_libraries(pjson_package_test PRIVATE pjson::pjson) diff --git a/test_package/conanfile.py b/test_package/conanfile.py new file mode 100644 index 0000000..e21c48b --- /dev/null +++ b/test_package/conanfile.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: Apache-2.0 + +from conan import ConanFile +from conan.tools.build import can_run +from conan.tools.cmake import CMake, cmake_layout + +import os + + +# ---- Conan package-consumer recipe -------------------------------------- + +# Builds and, when the target is executable on the host, runs a minimal client +# against the exact binary package produced by Conan's test-package workflow. +class PjsonTestConan(ConanFile): + test_type = "explicit" + settings = "os", "arch", "compiler", "build_type" + generators = "CMakeDeps", "CMakeToolchain" + + # Depend on the package reference that initiated this test-package run. + def requirements(self): + self.requires(self.tested_reference_str) + + # Use Conan's conventional source/build folders for the CMake consumer. + def layout(self): + cmake_layout(self) + + # Configure and compile through the generated dependency and toolchain data. + def build(self): + cmake = CMake(self) + cmake.configure() + cmake.build() + + # Run under Conan's generated environment whenever the target executable is + # runnable on this host; other builds remain compile-only checks. + def test(self): + if can_run(self): + executable = os.path.join(self.cpp.build.bindirs[0], "pjson_package_test") + self.run(executable, env="conanrun") diff --git a/test_package/src/pjson_package_test.cpp b/test_package/src/pjson_package_test.cpp new file mode 100644 index 0000000..8bddc23 --- /dev/null +++ b/test_package/src/pjson_package_test.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +// ---- Conan package consumer smoke test --------------------------------- + +// Exercises construction, serialization, parsing, lookup, typed access, and +// version metadata using only the headers and library exported by the package. +int main() { + ByteDance::pjson value; + value["packaged"] = true; + + const ByteDance::pjson::unique_ptr parsed = ByteDance::pjson::parse(value.toString()); + bool packaged = false; + // A successful package preserves the sentinel property through a round + // trip and keeps the public header macro in sync with the linked library. + return parsed && parsed->tryGet("packaged", packaged) && packaged && + std::string(ByteDance::pjson::getVersion()) == PJSON_VERSION + ? 0 + : 1; +} diff --git a/tests/install-consumer/CMakeLists.txt b/tests/install-consumer/CMakeLists.txt new file mode 100644 index 0000000..384bdef --- /dev/null +++ b/tests/install-consumer/CMakeLists.txt @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.21) +project(pjson_install_consumer LANGUAGES CXX) + +# ---- Installed dependency discovery ------------------------------------ + +# Exercise either supported consumer surface without changing the executable: +# the exported CMake package is the default, while packaging tests opt into the +# generated pkg-config metadata. +option(PJSON_CONSUMER_USE_PKGCONFIG "Consume pjson through pkg-config" OFF) + +if(PJSON_CONSUMER_USE_PKGCONFIG) + find_package(PkgConfig REQUIRED) + pkg_check_modules(pjson REQUIRED IMPORTED_TARGET pjson>=1.0) + set(PJSON_CONSUMER_TARGET PkgConfig::pjson) +else() + find_package(pjson 1.0 CONFIG REQUIRED) + set(PJSON_CONSUMER_TARGET pjson::pjson) +endif() + +# ---- Consumer executable ------------------------------------------------ + +add_executable(pjson_install_consumer main.cpp) +target_link_libraries(pjson_install_consumer PRIVATE ${PJSON_CONSUMER_TARGET}) +set_target_properties(pjson_install_consumer PROPERTIES + CXX_STANDARD 11 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF +) + +# Register a runtime check in addition to proving that discovery and linking +# work during configuration and compilation. +enable_testing() +add_test(NAME pjson.install_consumer COMMAND pjson_install_consumer) diff --git a/tests/install-consumer/main.cpp b/tests/install-consumer/main.cpp new file mode 100644 index 0000000..7f08dce --- /dev/null +++ b/tests/install-consumer/main.cpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include +#include +#include + +// ---- Installed-package consumer smoke test ----------------------------- + +// Verifies that an external C++11 consumer sees coherent headers, version +// metadata, linkage, parsing, typed access, and compact serialization. +int main() { + using ByteDance::pjson; + + // The public macro and linked library function must identify the same + // release; this also detects stale headers paired with a different binary. + if (std::strcmp(PJSON_VERSION, "1.0.0") != 0 || + std::strcmp(pjson::getVersion(), "1.0.0") != 0) { + std::cerr << "unexpected pjson version" << std::endl; + return 1; + } + + // A compact round trip covers the main installed API without relying on + // any source-tree-only headers or test helpers. + pjson::unique_ptr document = pjson::parse("{\"answer\":42}"); + int64_t answer = 0; + if (!document || !document->tryGet("answer", answer) || answer != 42 || + document->toString() != "{\"answer\":42}") { + std::cerr << "installed pjson failed its consumer smoke test" << std::endl; + return 1; + } + + return 0; +} diff --git a/touch b/touch deleted file mode 100644 index 9b2db00..0000000 --- a/touch +++ /dev/null @@ -1 +0,0 @@ -first touch file