diff --git a/.github/workflows/publish-pages-only.yml b/.github/workflows/publish-pages-only.yml index 5095286..958b774 100644 --- a/.github/workflows/publish-pages-only.yml +++ b/.github/workflows/publish-pages-only.yml @@ -20,14 +20,14 @@ jobs: distribution: 'temurin' java-version: '17' - name: Build Dokka HTML - run: ./gradlew dokkaHtml + run: ./gradlew :jsonpath4k:dokkaGenerate - name: Setup Pages uses: actions/configure-pages@v3 - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: # Upload docs folder - path: './build/dokka' + path: './docs' - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 04613c0..a56d174 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,14 +36,14 @@ jobs: with: submodules: recursive - name: Build Dokka HTML - run: ./gradlew dokkaHtml + run: ./gradlew :jsonpath4k:dokkaGenerate - name: Setup Pages uses: actions/configure-pages@v3 - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: # Upload docs folder - path: './build/dokka' + path: './docs' - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 71da868..fde2543 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -13,7 +13,7 @@ jobs: distribution: 'temurin' java-version: '17' - name: Build klibs - run: ./gradlew iosArm64MainKlibrary iosX64MainKlibrary + run: ./gradlew iosArm64MainKlibrary - name: Run tests run: ./gradlew iosSimulatorArm64Test - name: Test Report diff --git a/.github/workflows/test-jvm.yml b/.github/workflows/test-jvm.yml index bae0124..ce61e32 100644 --- a/.github/workflows/test-jvm.yml +++ b/.github/workflows/test-jvm.yml @@ -1,4 +1,4 @@ -name: Test JVM implementation +name: Test all non-Apple implementations on: [push] jobs: build: @@ -13,7 +13,7 @@ jobs: distribution: 'temurin' java-version: '17' - name: Run tests - run: ./gradlew jvmTest + run: ./gradlew allTests - name: Test Report uses: dorny/test-reporter@v1 if: success() || failure() diff --git a/CHANGELOG.md b/CHANGELOG.md index 1946073..730575b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,19 @@ # CHANGELOG -# Release 3.2.0-SNAPSHOT - * TODO +# Release 4.0.0-SNAPSHOT +* Support all KMP targets + * Logging through Napier is only supported on + * Android (ART) + * JVM + * iOS + * tvOS + * macOS + * JVM + * Logging on other targets defaults to NOOP +* Kotlin 2.4.0 +* Fix race condition thanks to latest ANTLR 1.0.12 +* Remove deprecations +* Change Serializer names to work on web targets too # Release 3.1.0 * Add: structural equality check for `NormalizedJsonPath` and `NormalizedJsonPathSegment` diff --git a/README.md b/README.md index c81bdd7..707aa7a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![A-SIT Plus Official](https://raw.githubusercontent.com/a-sit-plus/a-sit-plus.github.io/709e802b3e00cb57916cbb254ca5e1a5756ad2a8/A-SIT%20Plus_%20official_opt.svg)](https://plus.a-sit.at/open-source.html) [![GitHub license](https://img.shields.io/badge/license-Apache%20License%202.0-brightgreen.svg?style=flat)](http://www.apache.org/licenses/LICENSE-2.0) [![Kotlin](https://img.shields.io/badge/kotlin-multiplatform-orange.svg?logo=kotlin)](http://kotlinlang.org) -[![Kotlin](https://img.shields.io/badge/kotlin-2.0.20-blue.svg?logo=kotlin)](http://kotlinlang.org) +[![Kotlin](https://img.shields.io/badge/kotlin-2.4.0-blue.svg?logo=kotlin)](http://kotlinlang.org) [![Java](https://img.shields.io/badge/java-17+-blue.svg?logo=OPENJDK)](https://www.oracle.com/java/technologies/downloads/#java17) [![Maven Central](https://img.shields.io/maven-central/v/at.asitplus/jsonpath4k)](https://mvnrepository.com/artifact/at.asitplus/jsonpath4k/) @@ -17,14 +17,15 @@ This is a Kotlin Multiplatform Library for using Json Paths as specified in [RFC ## Architecture -This library was built for [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) targeting JVM/Android and iOS. Other targets might work, but are -not tested or even built. +This library was built for [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html), supporting all KMP targets, except WASI. Notable features for multiplatform are: -- Use of [Napier](https://github.com/AAkira/Napier) as the logging framework for the default compiler instance -- Use of [Kotest](https://kotest.io/) for unit tests +- Use of [Napier](https://github.com/AAkira/Napier) for default compiler error logging on JVM/Android, iOS, macOS, tvOS, JS, and WasmJS. + - By default, no `Antilog` is registered. + - On watchOS, Linux, Windows (mingw), and Android Native, the default listener does not log, due to Napier not supporting those platforms; provide a custom error listener if logging is required on those platforms. - Use of [kotlinx-serialization](https://github.com/Kotlin/kotlinx.serialization) for serialization from/to JSON and to have JsonElement as evaluation target for JsonPathQuery +- Use of [TestBalloon](https://infix-de.github.io/testBalloon/latest/) with [TestBalloon Addons](https://github.com/a-sit-plus/testballoon-addons) for unit tests. ## Using the Library 1. Add JsonPath4K as a dependency in your project (`at.asitplus:jsonpath4k:$version`) @@ -47,10 +48,10 @@ val jsonValue = nodeList[0].value.jsonPrimitive val normalizedPath = nodeList[0].normalizedJsonPath ``` -## Function extensions +## Function Extensions This library supports the function extensions specified in [RFC9535](https://www.rfc-editor.org/rfc/rfc9535.html#name-function-extensions) by default. -### Custom function extensions +### Custom Function Extensions Custom function extensions can be added using `JsonPath.defaultFunctionExtensionRepository.addExtension`: ```kotlin // adding a logical type function extension with 1 parameter of type NodesType @@ -118,14 +119,14 @@ JsonPath.defaultFunctionExtensionRepository.addExtension("count") { } ``` -### Removing Function extensions +### Removing Function Extensions Function extensions can be removed from the default repository by setting the value of `JsonPath.defaultFunctionExtensionRepository` to a new repository. Existing functions can be preserved by exporting them using `JsonPath.defaultFunctionExtensionRepository.export()` and selectively importing them into the new repository. -### Testing custom function extensions +### Testing Custom Function Extensions In order to test custom function extensions without polluting the default function extension repository, it is recommended to make an export and use the resulting map to build a new function extension retriever. ```kotlin @@ -143,17 +144,22 @@ val jsonPath = JsonPath(jsonPathStatement, functionExtensionRetriever = testRetr jsonPath.query(buildJsonElement {}) ``` -## Error handeling -The default compiler uses Napier for reporting errors. -It is possible to implement a custom error listener by extending `AntlrJsonPathCompilerErrorListener` and setting a new default compiler: +## Error Handling +Invalid JSONPath expressions fail during `JsonPath` construction with a `JsonPathCompilerException`. The compiler's error listener only receives diagnostic details; it does not suppress or replace that exception. + +On JVM/Android, iOS, macOS, tvOS, JS, and WasmJS, the default listener sends diagnostics to Napier (provided the application has registered an `Antilog`). On watchOS, Linux, Windows (mingw), and Android Native, the default listener is a no-op. Compilation failures still throw on these platforms, but logging requires a custom implementation of `AntlrJsonPathCompilerErrorListener`. + +Pass a compiler with that listener to one `JsonPath`, or assign it as the global default: ```kotlin -JsonPath.defaultCompiler = AntlrJsonPathCompiler( - errorListener = object : AntlrJsonPathCompilerErrorListener { - //TODO: IMPLEMENT MEMBERS - }, -) +val compiler = AntlrJsonPathCompiler(errorListener = myErrorListener) +val jsonPath = JsonPath(jsonPathExpression, compiler = compiler) + +// Optional: use it for subsequent JsonPath instances that do not specify a compiler. +JsonPath.defaultCompiler = compiler ``` +Using a single, shared default works because operations only use an ANTLR cache as global state and this cache is guarded to enable concurrent access and mutation. + ## Contributing External contributions are greatly appreciated! Just be sure to observe the contribution guidelines (see [CONTRIBUTING.md](CONTRIBUTING.md)). diff --git a/build.gradle.kts b/build.gradle.kts index 9f264f0..082ca18 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,28 +1,19 @@ -plugins { - // this is necessary to avoid the plugins to be loaded multiple times - // in each subproject's classloader - alias(libs.plugins.gradle.nexus.publish) - alias(libs.plugins.android.library) apply (false) -} - -repositories { - maven("https://maven.pkg.jetbrains.space/kotlin/p/dokka/dev") - mavenCentral() - google() - gradlePluginPortal() -} +import org.gradle.kotlin.dsl.support.listFilesOrdered -nexusPublishing { - nexusPublishing { - repositories { - sonatype { - nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/")) - snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/")) - } - } - } +plugins { + kotlin("multiplatform") version libs.versions.kotlin.get() apply false + kotlin("plugin.serialization") version libs.versions.kotlin.get() apply false + alias(libs.plugins.android.kmp.library) apply false + alias(libs.plugins.asp.conventions) } val artifactVersion: String by extra group = "at.asitplus" -version = artifactVersion \ No newline at end of file +version = artifactVersion + +tasks.register("dokkaGenerateSite") { + dependsOn(":jsonpath4k:dokkaGeneratePublicationHtml") + from(project(":jsonpath4k").layout.buildDirectory.dir("dokka/html")) + from(rootDir.listFilesOrdered { it.extension.lowercase() == "png" || it.extension.lowercase() == "svg" }) + into(rootProject.layout.projectDirectory.dir("docs")) +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 1ae26fd..a7a8439 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,8 @@ #Gradle org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" +# Tests use TestBalloon + the matrix addon (wired in by the asp-conventions plugin). +# Keep the legacy Kotest helper disabled; TestBalloon supplies the Kotest assertion/property integration. +systemProp.KOTEST_NO_ASP_HELPER=true #Kotlin kotlin.code.style=official @@ -13,11 +16,7 @@ kotlin.mpp.stability.nowarn=true kotlin.native.ignoreDisabledTargets=true -# workaround dokka bug (need to wait for next snapshot build) -org.jetbrains.dokka.classpath.excludePlatformDependencyFiles=true - -artifactVersion = 3.2.0-SNAPSHOT +artifactVersion = 4.0.0-SNAPSHOT jdk.version=17 - -android.experimental.lint.version=8.5.0 -android.lint.useK2Uast=true +android.minSdk=30 +android.compileSdk=34 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f64890..d293079 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,33 +1,15 @@ [versions] -antlr-kotlin = "1.0.3" -kotest = "5.9.1" -kotest-plugin = "6.0.0.M1" -jetbrains-kotlin = "2.1.0" -jetbrains-kotlinx-serialization = "1.8.0" -jetbrains-dokka = "1.9.20" -napier = "2.7.1" -gradle-nexus-publish = "2.0.0" -agp = "8.2.2" +kotlin = "2.4.0" +asp-conventions = "20260722" +agp = "9.0.1" +antlr-kotlin = "1.0.12" +testballoon = "1.0.1-K2.4.0" [libraries] antlr-kotlin = { group = "com.strumenta", name = "antlr-kotlin-runtime", version.ref = "antlr-kotlin" } -napier = { module = "io.github.aakira:napier", version.ref = "napier" } - -kotest-common = { module = "io.kotest:kotest-common", version.ref = "kotest" } -kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" } -kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } -kotest-framework-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" } -kotest-framework-datatest = { module = "io.kotest:kotest-framework-datatest", version.ref = "kotest" } -kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } - -jetbrains-kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "jetbrains-kotlinx-serialization" } - [plugins] +asp-conventions = { id = "at.asitplus.gradle.conventions", version.ref = "asp-conventions" } +android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } antlr-kotlin-plugin = { id = "com.strumenta.antlr-kotlin", version.ref = "antlr-kotlin" } -kotest-multiplatform = { id = "io.kotest.multiplatform", version.ref = "kotest-plugin" } -jetbrains-kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "jetbrains-kotlin" } -jetbrains-kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "jetbrains-kotlin" } -jetbrains-dokka = { id = "org.jetbrains.dokka", version.ref = "jetbrains-dokka" } -gradle-nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "gradle-nexus-publish" } -android-library = {id = "com.android.library", version.ref = "agp"} \ No newline at end of file +testballoon = { id = "de.infix.testBalloon", version.ref = "testballoon" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 033e24c..f8e1ee3 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0..a351597 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index fcb6fca..adff685 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -83,7 +85,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -111,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -144,7 +146,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +154,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -169,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -201,16 +202,15 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 93e3f59..c4bdd3a 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -43,11 +45,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,22 +59,21 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/jsonpath4k/build.gradle.kts b/jsonpath4k/build.gradle.kts index 4208555..12a6002 100644 --- a/jsonpath4k/build.gradle.kts +++ b/jsonpath4k/build.gradle.kts @@ -1,36 +1,29 @@ +import at.asitplus.gradle.Logger +import at.asitplus.gradle.androidJvmMain +import at.asitplus.gradle.coroutines +import at.asitplus.gradle.exportXCFramework +import at.asitplus.gradle.napier +import at.asitplus.gradle.serialization +import at.asitplus.gradle.setupDokka +import at.asitplus.gradle.tbAddons import com.strumenta.antlrkotlin.gradle.AntlrKotlinTask -import org.jetbrains.dokka.gradle.DokkaTask -import org.jetbrains.dokka.gradle.DokkaTaskPartial -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension -import org.jetbrains.kotlin.gradle.dsl.kotlinExtension -import org.jetbrains.kotlin.gradle.plugin.mpp.BitcodeEmbeddingMode -import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget -import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFrameworkConfig -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.gradle.kotlin.dsl.dokka +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.plugin.extraProperties +import java.util.* plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.kotlin.multiplatform) - alias(libs.plugins.jetbrains.kotlinx.serialization) - alias(libs.plugins.kotest.multiplatform) + alias(libs.plugins.android.kmp.library) + kotlin("multiplatform") + kotlin("plugin.serialization") alias(libs.plugins.antlr.kotlin.plugin) - alias(libs.plugins.jetbrains.dokka) - id("maven-publish") + alias(libs.plugins.testballoon) id("signing") + alias(libs.plugins.asp.conventions) } -/* required for maven publication */ -val artifactVersion: String by extra -group = "at.asitplus" -version = artifactVersion - -repositories { - google() - maven("https://s01.oss.sonatype.org/content/repositories/snapshots") //KOTEST snapshot - mavenCentral() -} - +group = rootProject.group +version = rootProject.version val SRCDIR_ANTRL = "generated/antlr" val generateKotlinGrammarSource = tasks.register("generateKotlinGrammarSource") { @@ -53,97 +46,125 @@ val generateKotlinGrammarSource = tasks.register("generateKotli outputDirectory = layout.buildDirectory.dir(outDir).get().asFile } +val Project.disableNdkTargets + get() = ("true" == (System.getenv("disableNdkTargets") + ?.also { Logger.lifecycle(" > Property disableNdkTargets set to $it from environment") } + ?: runCatching { + (project.extraProperties["disableNdkTargets"] as String).also { + Logger.lifecycle(" > Property disableNdkTargets set to $it from extra properties") + } + }.getOrNull())) + kotlin { - jvmToolchain(17) - - jvm { - compilations.all { - kotlinOptions { - jvmTarget = "17" - freeCompilerArgs = listOf( - "-Xjsr305=strict" - ) - } - } + androidLibrary { + namespace = "at.asitplus.jsonpath4k" } - - androidTarget { - compilerOptions { - publishLibraryVariants("release") - jvmTarget = JvmTarget.JVM_1_8 + jvm() + macosArm64() + tvosArm64() + tvosSimulatorArm64() + iosArm64() + iosSimulatorArm64() + watchosSimulatorArm64() + watchosArm32() + watchosArm64() + tvosSimulatorArm64() + tvosArm64() + + if (project.hasAndroidSdk()) { + if (project.hasAndroidNdk() && !project.disableNdkTargets) { + androidNativeX64() + androidNativeX86() + androidNativeArm32() + androidNativeArm64() + } else { + Logger.lifecycle(" > Skipping Android native targets (NDK missing or disableNdkTargets=true)") } } + listOf( + js().apply { browser { testTask { enabled = false } } }, + @OptIn(ExperimentalWasmDsl::class) + wasmJs().apply { browser { testTask { enabled = false } } }, + // wasmWasi() + ).forEach { + it.nodejs() + } - iosArm64() - iosSimulatorArm64() - iosX64() - + linuxX64() + linuxArm64() + mingwX64() sourceSets { commonMain { kotlin.srcDir(layout.buildDirectory.dir(SRCDIR_ANTRL)) dependencies { implementation(libs.antlr.kotlin) - implementation(libs.jetbrains.kotlinx.serialization) - implementation(libs.napier) + implementation(serialization("json")) } } commonTest { dependencies { - implementation(kotlin("test")) - implementation(libs.kotest.common) - implementation(libs.kotest.property) - implementation(libs.kotest.assertions.core) - implementation(libs.kotest.framework.engine) - implementation(libs.kotest.framework.datatest) - implementation(libs.jetbrains.kotlinx.serialization) + // TestBalloon, the matrix addon, Kotest assertions and Kotest property are wired in + // automatically by the asp-conventions plugin (unless TESTBALLOON_NO_ASP_HELPER is set). + implementation(serialization("json")) + // Needed by the highly-concurrent native ANTLR stress test in nativeTest. + implementation(coroutines()) + implementation(tbAddons("matrix")) } } - - jvmTest { - dependencies { - implementation(libs.kotest.runner.junit5) - } + androidJvmMain { + dependencies { implementation(napier()) } + } + iosMain.dependencies { + implementation(napier()) } + webMain.dependencies { + implementation(napier()) + } + macosMain.dependencies { + implementation(napier()) + } + tvosMain.dependencies {implementation(napier())} + } } -tasks.withType> { - dependsOn(generateKotlinGrammarSource) -} -tasks.withType { +tasks.withType>().configureEach { dependsOn(generateKotlinGrammarSource) } tasks.withType { dependsOn(generateKotlinGrammarSource) } +// Note: the JVM test task (JUnit Platform + launcher) is configured by the TestBalloon Gradle plugin, +// which the asp-conventions plugin applies; do not call useJUnitPlatform() manually. -exportIosFramework("JsonPath4K") +exportXCFramework("JsonPath4K", transitiveExports = false) -val javadocJar = setupDokka( - baseUrl = "https://github.com/a-sit-plus/jsonpath4k/tree/main/", - multiModuleDoc = false +setupDokka( + baseUrl = "https://github.com/a-sit-plus/jsonpath4k/tree/main" ) - -android { - namespace = "at.asitplus.jsonpath4k" - compileSdk = 34 - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - defaultConfig { - minSdk = 30 +val moduleDesc = rootProject.file("dokka-tmp.md").apply { + writeText("# Module \n${rootProject.file("README.md").readText().replaceFirst("# ", "")}") +} +dokka { + moduleName.set("jsonpath4k") + dokkaSourceSets.configureEach { + includes.from(moduleDesc) } } +val javadocRedirectJar = tasks.register("javadocRedirectJar") { + archiveClassifier.set("javadoc") + from(project.rootDir.absolutePath + "/javadoc") +} + publishing { publications { withType { - if (this.name != "relocation") artifact(javadocJar) + if (this.name != "relocation") artifact(javadocRedirectJar) pom { name.set("JsonPath4K") description.set("Kotlin Multiplatform library for using Json Paths as specified in [RFC9535](https://datatracker.ietf.org/doc/rfc9535/)") @@ -201,86 +222,88 @@ signing { } -/** - * taken from vclib conventions plugin at https://github.com/a-sit-plus/gradle-conventions-plugin - */ -fun Project.exportIosFramework( - name: String, - vararg additionalExports: Any -) = exportIosFramework(name, bitcodeEmbeddingMode = BitcodeEmbeddingMode.BITCODE, additionalExports = additionalExports) - -fun Project.exportIosFramework( - name: String, - bitcodeEmbeddingMode: BitcodeEmbeddingMode, - vararg additionalExports: Any -) { - val iosTargets = kotlinExtension.let { - if (it is KotlinMultiplatformExtension) { - it.targets.filterIsInstance().filter { it.name.startsWith("ios") } - } else throw StopExecutionException("No iOS Targets found! Declare them explicitly before calling exportIosFramework!") - } - extensions.getByType().apply { - XCFrameworkConfig(project, name).also { xcf -> - logger.lifecycle(" \u001B[1mXCFrameworks will be exported for the following iOS targets: ${iosTargets.joinToString { it.name }}\u001B[0m") - iosTargets.forEach { - it.binaries.framework { - baseName = name - embedBitcode(bitcodeEmbeddingMode) - additionalExports.forEach { export(it) } - xcf.add(this) - } + +fun Project.hasAndroidSdk() = resolveAndroidSdk(this)?.let { it -> isValidAndroidSdk(it) } == true + +fun Project.hasAndroidNdk() = resolveAndroidNdk(this)?.let { it -> isValidAndroidNdk(it) } == true + +private fun resolveAndroidSdk(project: Project): File? { + // Highest precedence: ANDROID_SDK_ROOT (preferred), then ANDROID_HOME (legacy) + val env = System.getenv() + val fromEnv = listOf("ANDROID_SDK_ROOT", "ANDROID_HOME") + .asSequence() + .mapNotNull { env[it]?.takeIf { it.isNotBlank() } } + .map(::File) + .firstOrNull { it.exists() } + + if (fromEnv != null) return fromEnv + + // Fallback: local.properties (common on dev machines) + val localProps = File(project.rootDir, "local.properties") + if (localProps.exists()) { + Properties().apply { + localProps.inputStream().use(::load) + (getProperty("sdk.dir") ?: getProperty("android.sdk.path"))?.let { + val f = File(it) + if (f.exists()) return f } } } + return null } -fun Project.setupDokka( - outputDir: String = rootProject.layout.buildDirectory.dir("dokka").get().asFile.canonicalPath, - baseUrl: String, - multiModuleDoc: Boolean = false, - remoteLineSuffix: String = "#L" -): TaskProvider { - val dokkaHtml = (tasks["dokkaHtml"] as DokkaTask).apply { outputDirectory.set(file(outputDir)) } - - val deleteDokkaOutput = tasks.register("deleteDokkaOutputDirectory") { - delete(outputDir) - } - val sourceLinktToConfigure = if (multiModuleDoc) (tasks["dokkaHtmlPartial"] as DokkaTaskPartial) else dokkaHtml - sourceLinktToConfigure.dokkaSourceSets.configureEach { - sourceLink { - localDirectory.set(file("src/$name/kotlin")) - remoteUrl.set(uri("$baseUrl/${project.name}/src/$name/kotlin").toURL()) - this@sourceLink.remoteLineSuffix.set(remoteLineSuffix) +private fun resolveAndroidNdk(project: Project): File? { + val env = System.getenv() + val fromEnv = listOf("ANDROID_NDK_ROOT", "ANDROID_NDK_HOME", "ANDROID_NDK", "NDK_HOME") + .asSequence() + .mapNotNull { env[it]?.takeIf { it.isNotBlank() } } + .map(::File) + .firstOrNull { it.exists() } + if (fromEnv != null) return fromEnv + + val localProps = File(project.rootDir, "local.properties") + if (localProps.exists()) { + Properties().apply { + localProps.inputStream().use(::load) + (getProperty("ndk.dir") ?: getProperty("ndkDirectory"))?.let { + val f = File(it) + if (f.exists()) return f + } } } - return tasks.register("javadocJar") { - dependsOn(deleteDokkaOutput, dokkaHtml) - archiveClassifier.set("javadoc") - from(outputDir) + val sdk = resolveAndroidSdk(project) ?: return null + val bundled = listOf(File(sdk, "ndk-bundle"), File(sdk, "ndk")) + .firstOrNull { it.exists() } + + // $SDK/ndk is a folder containing versioned subfolders; pick the "latest" directory. + if (bundled?.name == "ndk") { + val candidates = bundled.listFiles()?.filter { it.isDirectory } ?: emptyList() + return candidates.maxWithOrNull { a, b -> + val ta = a.name.toVersionTuple() + val tb = b.name.toVersionTuple() + val n = maxOf(ta.size, tb.size) + (0 until n).asSequence() + .map { i -> (ta.getOrNull(i) ?: 0).compareTo(tb.getOrNull(i) ?: 0) } + .firstOrNull { it != 0 } ?: 0 + } ?: bundled } + + return bundled } +private fun String.toVersionTuple(): List = + split('.', '-', '_').mapNotNull { it.toIntOrNull() }.ifEmpty { listOf(0) } -afterEvaluate { - tasks.withType { - useJUnitPlatform() - } +private fun isValidAndroidNdk(ndk: File): Boolean { + val prebuilt = File(ndk, "toolchains/llvm/prebuilt") + return prebuilt.isDirectory && (prebuilt.listFiles()?.any { it.isDirectory } == true) +} - /** - * Makes all publishing tasks depend on all signing tasks. Hampers parallelization, but works around dodgy task dependencies - * which (more often than anticipated) makes the build process stumble over its own feet. - */ - - tasks.withType().also { signingTasks -> - if (signingTasks.isNotEmpty()) { - logger.lifecycle("> Making signing tasks of project \u001B[1m$name\u001B[0m run after publish tasks") - tasks.withType().configureEach { - mustRunAfter(*signingTasks.toTypedArray()) - logger.lifecycle(" * $name must now run after ${signingTasks.joinToString { it.name }}") - } - logger.lifecycle("") - } - } -} \ No newline at end of file + +private fun isValidAndroidSdk(sdk: File): Boolean { + val platformsOk = File(sdk, "platforms").listFiles()?.any { it.isDirectory } == true + val buildToolsOk = File(sdk, "build-tools").listFiles()?.any { it.isDirectory } == true + return platformsOk && buildToolsOk +} diff --git a/jsonpath4k/javadoc/README.txt b/jsonpath4k/javadoc/README.txt new file mode 100644 index 0000000..598d1f2 --- /dev/null +++ b/jsonpath4k/javadoc/README.txt @@ -0,0 +1 @@ +https://a-sit-plus.github.io/jsonpath4k/ \ No newline at end of file diff --git a/jsonpath4k/src/androidJvmMain/kotlin/at/asitplus/jsonpath/JsonPath.androidJvm.kt b/jsonpath4k/src/androidJvmMain/kotlin/at/asitplus/jsonpath/JsonPath.androidJvm.kt new file mode 100644 index 0000000..7f6eb54 --- /dev/null +++ b/jsonpath4k/src/androidJvmMain/kotlin/at/asitplus/jsonpath/JsonPath.androidJvm.kt @@ -0,0 +1,101 @@ +package at.asitplus.jsonpath + +import at.asitplus.jsonpath.core.JsonPathFilterExpressionType +import at.asitplus.jsonpath.core.JsonPathFunctionExtension +import at.asitplus.jsonpath.implementation.AntlrJsonPathCompilerErrorListener +import com.strumenta.antlrkotlin.runtime.BitSet +import io.github.aakira.napier.Napier +import org.antlr.v4.kotlinruntime.Parser +import org.antlr.v4.kotlinruntime.RecognitionException +import org.antlr.v4.kotlinruntime.Recognizer +import org.antlr.v4.kotlinruntime.atn.ATNConfigSet +import org.antlr.v4.kotlinruntime.dfa.DFA + +internal actual val defaultErrorListener = object : AntlrJsonPathCompilerErrorListener { + override fun unknownFunctionExtension(functionExtensionName: String) { + Napier.e { + "Unknown JSONPath function extension: \"$functionExtensionName\"" + } + } + + override fun invalidFunctionExtensionForTestExpression(functionExtensionName: String) { + Napier.e { + "Invalid JSONPath function extension return type for test expression: \"$functionExtensionName\"" + } + } + + override fun invalidFunctionExtensionForComparable(functionExtensionName: String) { + Napier.e { + "Invalid JSONPath function extension return type for comparable expression: \"$functionExtensionName\"" + } + } + + override fun invalidArglistForFunctionExtension( + functionExtensionName: String, + functionExtensionImplementation: JsonPathFunctionExtension, + coercedArgumentTypes: List> + ) { + Napier.e { + "Invalid arguments for function extension \"$functionExtensionName\": Expected: <${ + functionExtensionImplementation.argumentTypes.joinToString( + ", " + ) + }>, but received <${ + coercedArgumentTypes.map { it.first }.joinToString(", ") + }>: <${coercedArgumentTypes.map { it.second }.joinToString(", ")}>" + } + } + + override fun invalidTestExpression(testContextString: String) { + Napier.e { + "Invalid test expression: $testContextString" + } + } + + override fun reportAmbiguity( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + exact: Boolean, + ambigAlts: BitSet, + configs: ATNConfigSet + ) { + // noop + } + + override fun reportAttemptingFullContext( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + conflictingAlts: BitSet, + configs: ATNConfigSet + ) { + // noop + } + + override fun reportContextSensitivity( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + prediction: Int, + configs: ATNConfigSet + ) { + // noop + } + + override fun syntaxError( + recognizer: Recognizer<*, *>, + offendingSymbol: Any?, + line: Int, + charPositionInLine: Int, + msg: String, + e: RecognitionException? + ) { + Napier.e { + "Syntax error $line:$charPositionInLine $msg" + } + } +} \ No newline at end of file diff --git a/jsonpath4k/src/androidNativeMain/kotlin/at/asitplus/jsonpath/JsonPath.androidNative.kt b/jsonpath4k/src/androidNativeMain/kotlin/at/asitplus/jsonpath/JsonPath.androidNative.kt new file mode 100644 index 0000000..aa8bf94 --- /dev/null +++ b/jsonpath4k/src/androidNativeMain/kotlin/at/asitplus/jsonpath/JsonPath.androidNative.kt @@ -0,0 +1,3 @@ +package at.asitplus.jsonpath + +internal actual val defaultErrorListener = noopErrorListener \ No newline at end of file diff --git a/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/JsonPath.kt b/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/JsonPath.kt index 627c176..3a5c54e 100644 --- a/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/JsonPath.kt +++ b/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/JsonPath.kt @@ -4,15 +4,10 @@ import at.asitplus.jsonpath.core.JsonPathCompiler import at.asitplus.jsonpath.core.JsonPathFilterExpressionType import at.asitplus.jsonpath.core.JsonPathFunctionExtension import at.asitplus.jsonpath.core.NodeList -import at.asitplus.jsonpath.core.functionExtensions.countFunctionExtension -import at.asitplus.jsonpath.core.functionExtensions.lengthFunctionExtension -import at.asitplus.jsonpath.core.functionExtensions.matchFunctionExtension -import at.asitplus.jsonpath.core.functionExtensions.searchFunctionExtension -import at.asitplus.jsonpath.core.functionExtensions.valueFunctionExtension +import at.asitplus.jsonpath.core.functionExtensions.* import at.asitplus.jsonpath.implementation.AntlrJsonPathCompiler import at.asitplus.jsonpath.implementation.AntlrJsonPathCompilerErrorListener import com.strumenta.antlrkotlin.runtime.BitSet -import io.github.aakira.napier.Napier import kotlinx.serialization.json.JsonElement import org.antlr.v4.kotlinruntime.Parser import org.antlr.v4.kotlinruntime.RecognitionException @@ -38,112 +33,84 @@ class JsonPath( /** * Default json path compiler used when no compiler is explicitly chosen */ - var defaultCompiler: JsonPathCompiler = AntlrJsonPathCompiler( - errorListener = napierAntlrJsonPathCompilerErrorListener, - ) + var defaultCompiler: JsonPathCompiler = AntlrJsonPathCompiler(defaultErrorListener) /** * Function extension repository that may be extended with custom functions by the user of this library. */ - var defaultFunctionExtensionRepository: JsonPathFunctionExtensionRepository = JsonPathFunctionExtensionMapRepository( - listOf( - lengthFunctionExtension, - countFunctionExtension, - matchFunctionExtension, - searchFunctionExtension, - valueFunctionExtension, - ).toMap().toMutableMap() - ) + var defaultFunctionExtensionRepository: JsonPathFunctionExtensionRepository = + JsonPathFunctionExtensionMapRepository( + listOf( + lengthFunctionExtension, + countFunctionExtension, + matchFunctionExtension, + searchFunctionExtension, + valueFunctionExtension, + ).toMap().toMutableMap() + ) } } -private val napierAntlrJsonPathCompilerErrorListener by lazy { - object : AntlrJsonPathCompilerErrorListener { - override fun unknownFunctionExtension(functionExtensionName: String) { - Napier.e { - "Unknown JSONPath function extension: \"$functionExtensionName\"" - } - } +internal expect val defaultErrorListener: AntlrJsonPathCompilerErrorListener - override fun invalidFunctionExtensionForTestExpression(functionExtensionName: String) { - Napier.e { - "Invalid JSONPath function extension return type for test expression: \"$functionExtensionName\"" - } - } +internal val noopErrorListener = object : AntlrJsonPathCompilerErrorListener { + override fun unknownFunctionExtension(functionExtensionName: String) { + } - override fun invalidFunctionExtensionForComparable(functionExtensionName: String) { - Napier.e { - "Invalid JSONPath function extension return type for comparable expression: \"$functionExtensionName\"" - } - } + override fun invalidFunctionExtensionForTestExpression(functionExtensionName: String) { + } - override fun invalidArglistForFunctionExtension( - functionExtensionName: String, - functionExtensionImplementation: JsonPathFunctionExtension, - coercedArgumentTypes: List> - ) { - Napier.e { - "Invalid arguments for function extension \"$functionExtensionName\": Expected: <${ - functionExtensionImplementation.argumentTypes.joinToString( - ", " - ) - }>, but received <${ - coercedArgumentTypes.map { it.first }.joinToString(", ") - }>: <${coercedArgumentTypes.map { it.second }.joinToString(", ")}>" - } - } + override fun invalidFunctionExtensionForComparable(functionExtensionName: String) { + } - override fun invalidTestExpression(testContextString: String) { - Napier.e { - "Invalid test expression: $testContextString" - } - } + override fun invalidArglistForFunctionExtension( + functionExtensionName: String, + functionExtensionImplementation: JsonPathFunctionExtension, + coercedArgumentTypes: List> + ) { + } - override fun reportAmbiguity( - recognizer: Parser, - dfa: DFA, - startIndex: Int, - stopIndex: Int, - exact: Boolean, - ambigAlts: BitSet, - configs: ATNConfigSet - ) { - // noop - } + override fun invalidTestExpression(testContextString: String) { + } - override fun reportAttemptingFullContext( - recognizer: Parser, - dfa: DFA, - startIndex: Int, - stopIndex: Int, - conflictingAlts: BitSet, - configs: ATNConfigSet - ) { - // noop - } + override fun syntaxError( + recognizer: Recognizer<*, *>, + offendingSymbol: Any?, + line: Int, + charPositionInLine: Int, + msg: String, + e: RecognitionException? + ) { + } + + override fun reportAmbiguity( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + exact: Boolean, + ambigAlts: BitSet, + configs: ATNConfigSet + ) { + } - override fun reportContextSensitivity( - recognizer: Parser, - dfa: DFA, - startIndex: Int, - stopIndex: Int, - prediction: Int, - configs: ATNConfigSet - ) { - // noop - } + override fun reportAttemptingFullContext( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + conflictingAlts: BitSet, + configs: ATNConfigSet + ) { + } - override fun syntaxError( - recognizer: Recognizer<*, *>, - offendingSymbol: Any?, - line: Int, - charPositionInLine: Int, - msg: String, - e: RecognitionException? - ) { - Napier.e { - "Syntax error $line:$charPositionInLine $msg" - } - } + override fun reportContextSensitivity( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + prediction: Int, + configs: ATNConfigSet + ) { } } \ No newline at end of file diff --git a/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/JsonPathDependencyManager.kt b/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/JsonPathDependencyManager.kt deleted file mode 100644 index 376e047..0000000 --- a/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/JsonPathDependencyManager.kt +++ /dev/null @@ -1,26 +0,0 @@ -package at.asitplus.jsonpath - -import at.asitplus.jsonpath.core.JsonPathCompiler - -@Deprecated("Dependencies have been moved to JsonPath.Companion") -object JsonPathDependencyManager { - @Deprecated( - "To be removed in version 4. Dependencies have been moved to JsonPath.Companion", - ReplaceWith("JsonPath.defaultFunctionExtensionRepository"), - ) - var functionExtensionRepository: JsonPathFunctionExtensionRepository - get() = JsonPath.defaultFunctionExtensionRepository - set(value) { - JsonPath.defaultFunctionExtensionRepository = value - } - - @Deprecated( - "To be removed in version 4. Dependencies have been moved to JsonPath.Companion", - ReplaceWith("JsonPath.defaultCompiler"), - ) - var compiler: JsonPathCompiler - get() = JsonPath.defaultCompiler - set(value) { - JsonPath.defaultCompiler = value - } -} \ No newline at end of file diff --git a/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/core/JsonPathFunctionExtension.kt b/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/core/JsonPathFunctionExtension.kt index b7ba251..bb4b04f 100644 --- a/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/core/JsonPathFunctionExtension.kt +++ b/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/core/JsonPathFunctionExtension.kt @@ -12,9 +12,6 @@ sealed class JsonPathFunctionExtension( ) { abstract operator fun invoke(arguments: List): JsonPathFilterExpressionValue - @Deprecated("To be removed in version 4. Replaced with `invoke`.", ReplaceWith("invoke")) - fun evaluate(arguments: List): JsonPathFilterExpressionValue = invoke(arguments) - class ValueTypeFunctionExtension( vararg argumentTypes: JsonPathFilterExpressionType, private val evaluator: (arguments: List) -> JsonElement?, diff --git a/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/core/NormalizedJsonPath.kt b/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/core/NormalizedJsonPath.kt index 56594fb..81e38ea 100644 --- a/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/core/NormalizedJsonPath.kt +++ b/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/core/NormalizedJsonPath.kt @@ -11,8 +11,7 @@ import kotlin.jvm.JvmInline @Serializable @JvmInline value class NormalizedJsonPath( - @Deprecated("Access to this variable will be removed in the future, please use the path itself as list.") - val segments: List = listOf(), + private val segments: List = listOf(), ) : List by segments { constructor(vararg segments: NormalizedJsonPathSegment) : this(segments = segments.asList()) diff --git a/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/core/NormalizedJsonPathSegment.kt b/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/core/NormalizedJsonPathSegment.kt index 055f6ad..3b0cea6 100644 --- a/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/core/NormalizedJsonPathSegment.kt +++ b/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/core/NormalizedJsonPathSegment.kt @@ -77,7 +77,7 @@ sealed interface NormalizedJsonPathSegment { override val descriptor: SerialDescriptor get() = SerialDescriptor( original = JsonElement.serializer().descriptor, - serialName = JsonDistinguishableSerializer::class.qualifiedName!!, + serialName = JsonDistinguishableSerializer::class.simpleName!!, ) override fun serialize( @@ -92,7 +92,7 @@ sealed interface NormalizedJsonPathSegment { override fun deserialize(decoder: Decoder): NormalizedJsonPathSegment { require(decoder is JsonDecoder) { - "Expected decoder to be ${JsonDecoder::class.qualifiedName!!}, but was `$decoder`." + "Expected decoder to be ${JsonDecoder::class.simpleName!!}, but was `$decoder`." } val jsonElement = decoder.decodeJsonElement().jsonPrimitive return if (jsonElement.isString) { diff --git a/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/implementation/AntlrJsonPathSemanticAnalyzerVisitor.kt b/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/implementation/AntlrJsonPathSemanticAnalyzerVisitor.kt index 6eadbf8..c35823e 100644 --- a/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/implementation/AntlrJsonPathSemanticAnalyzerVisitor.kt +++ b/jsonpath4k/src/commonMain/kotlin/at/asitplus/jsonpath/implementation/AntlrJsonPathSemanticAnalyzerVisitor.kt @@ -25,7 +25,7 @@ import org.antlr.v4.kotlinruntime.tree.TerminalNode * This class builds an abstract syntax tree where the nodes contain the logic necessary to be evaluated against an input. */ internal class AntlrJsonPathSemanticAnalyzerVisitor( - private val errorListener: AntlrJsonPathSemanticAnalyzerErrorListener?, + private val errorListener: at.asitplus.jsonpath.implementation.AntlrJsonPathSemanticAnalyzerErrorListener?, private val functionExtensionRetriever: (String) -> JsonPathFunctionExtension?, ) : JsonPathParserBaseVisitor>() { override fun defaultResult(): AbstractSyntaxTree { diff --git a/jsonpath4k/src/commonTest/kotlin/KotestConfig.kt b/jsonpath4k/src/commonTest/kotlin/KotestConfig.kt deleted file mode 100644 index 91cf641..0000000 --- a/jsonpath4k/src/commonTest/kotlin/KotestConfig.kt +++ /dev/null @@ -1,9 +0,0 @@ -import io.github.aakira.napier.DebugAntilog -import io.github.aakira.napier.Napier -import io.kotest.core.config.AbstractProjectConfig - -object KotestConfig : AbstractProjectConfig() { - init { - Napier.base(DebugAntilog()) - } -} \ No newline at end of file diff --git a/jsonpath4k/src/commonTest/kotlin/TestConfig.kt b/jsonpath4k/src/commonTest/kotlin/TestConfig.kt new file mode 100644 index 0000000..07db777 --- /dev/null +++ b/jsonpath4k/src/commonTest/kotlin/TestConfig.kt @@ -0,0 +1,13 @@ +import at.asitplus.testballoon.matrix.ExecutionMode +import at.asitplus.testballoon.matrix.MatrixTestDefaults +import de.infix.testBalloon.framework.core.TestSession + +/** + * Single [TestSession] for this module. The RFC-conformance suites mutate process-global state + * (`JsonPath.defaultCompiler`, `JsonPath.defaultFunctionExtensionRepository`), so the default matrix + * execution is [ExecutionMode.Sequential]. The dedicated ANTLR concurrency stress suite opts into real + * parallelism explicitly (see the nativeTest source set). + */ +class TestConfig : TestSession( + testConfig = DefaultConfiguration.apply { MatrixTestDefaults { execution = ExecutionMode.Concurrent(24) } } +) \ No newline at end of file diff --git a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/DependencyManagementTest.kt b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/DependencyManagementTest.kt index 6393894..baa50f9 100644 --- a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/DependencyManagementTest.kt +++ b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/DependencyManagementTest.kt @@ -3,37 +3,43 @@ package at.asitplus.jsonpath import at.asitplus.jsonpath.core.JsonPathCompiler import at.asitplus.jsonpath.core.JsonPathFunctionExtension import at.asitplus.jsonpath.core.JsonPathQuery +import at.asitplus.testballoon.matrix.ExecutionMode +import at.asitplus.testballoon.matrix.matrixConfig +import at.asitplus.testballoon.matrix.matrixSuite +import de.infix.testBalloon.framework.core.TestConfig +import de.infix.testBalloon.framework.core.aroundEachTest import io.kotest.assertions.throwables.shouldNotThrowAny -import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.collections.shouldBeIn import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.nulls.shouldNotBeNull import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.buildJsonObject -@Suppress("unused") -class DependencyManagementTest : FreeSpec({ - // making sure that the dependencies are reset to their default for the next test - val defaultCompilerBuilderBackup = JsonPath.defaultCompiler - val defaultFunctionExtensionRepositoryBackup = - JsonPath.defaultFunctionExtensionRepository.export() +// Captured once, before any test mutates the process-global JsonPath dependencies. +private val defaultCompilerBuilderBackup = JsonPath.defaultCompiler +private val defaultFunctionExtensionRepositoryBackup = + JsonPath.defaultFunctionExtensionRepository.export() - beforeEach { - // prepare a dummy repository to be modified by the tests - JsonPath.defaultFunctionExtensionRepository = - JsonPathFunctionExtensionMapRepository( - defaultFunctionExtensionRepositoryBackup.toMutableMap() - ) - } - afterEach { - JsonPath.Companion.apply { - defaultCompiler = defaultCompilerBuilderBackup - defaultFunctionExtensionRepository = JsonPathFunctionExtensionMapRepository( +val DependencyManagementTest by matrixSuite( + matrixConfig { + execution = ExecutionMode.Sequential + // Reset the global dependencies to their defaults around every test (was beforeEach/afterEach). + testConfig = TestConfig.aroundEachTest { action -> + // prepare a dummy repository to be modified by the tests + JsonPath.defaultFunctionExtensionRepository = JsonPathFunctionExtensionMapRepository( defaultFunctionExtensionRepositoryBackup.toMutableMap() ) + try { + action() + } finally { + JsonPath.defaultCompiler = defaultCompilerBuilderBackup + JsonPath.defaultFunctionExtensionRepository = JsonPathFunctionExtensionMapRepository( + defaultFunctionExtensionRepositoryBackup.toMutableMap() + ) + } } } - +) { "dependency manager compiler should support the functions in the repo at the time of compilation, and query should be executable afterwards too" - { "compiler that was built when the repository supported a function extension before it was removed should succeed compilation before and query afterwards" { val jsonPathStatement = "$[?foo()]" @@ -73,4 +79,4 @@ class DependencyManagementTest : FreeSpec({ // this checks, whether the new compiler has indeed been used emptyQueryResult.shouldHaveSize(0) } -}) +} diff --git a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/JsonPathUnitTest.kt b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/JsonPathUnitTest.kt index 198712c..9d1e611 100644 --- a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/JsonPathUnitTest.kt +++ b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/JsonPathUnitTest.kt @@ -4,9 +4,14 @@ import at.asitplus.jsonpath.core.JsonPathFilterExpressionType import at.asitplus.jsonpath.core.JsonPathFunctionExtension import at.asitplus.jsonpath.implementation.JsonPathParserException import at.asitplus.jsonpath.implementation.JsonPathTypeCheckerException +import at.asitplus.testballoon.matrix.ExecutionMode +import at.asitplus.testballoon.matrix.MatrixSuiteScope +import at.asitplus.testballoon.matrix.matrixConfig +import at.asitplus.testballoon.matrix.matrixSuite +import de.infix.testBalloon.framework.core.TestConfig +import de.infix.testBalloon.framework.core.aroundEachTest import io.kotest.assertions.throwables.shouldNotThrowAny import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.collections.shouldBeIn import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.nulls.shouldNotBeNull @@ -20,8 +25,35 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -@Suppress("unused") -class JsonPathUnitTest : FreeSpec({ +/** + * Registers a FreeSpec-style test whose name IS the JSONPath expression, compiles that exact expression, + * queries [json] and hands the resulting value list to [assertions]. Mirrors the original Kotest idiom that + * recovered the expression from `testCase.name.originalName`. + */ +private fun MatrixSuiteScope.query( + path: String, + json: JsonElement, + assertions: (nodeList: List) -> Unit, +) = path { assertions(JsonPath(path).query(json).map { it.value }) } + +/** Test named after [path] asserting that compiling it succeeds. */ +private fun MatrixSuiteScope.compiles(path: String) = + path { shouldNotThrowAny { JsonPath(path) } } + +/** Test named after [path] asserting that compiling it fails type checking. */ +private fun MatrixSuiteScope.failsTypeCheck(path: String) = + path { shouldThrow { JsonPath(path) } } + +/** Test named after [path] asserting that compiling it fails parsing. */ +private fun MatrixSuiteScope.failsParsing(path: String) = + path { shouldThrow { JsonPath(path) } } + +// Captured once, before any test in section 2.4 mutates the process-global JsonPath dependencies. +private val defaultCompilerBuilderBackup = JsonPath.defaultCompiler +private val defaultFunctionExtensionRepositoryBackup = + JsonPath.defaultFunctionExtensionRepository.export() + +val JsonPathUnitTest by matrixSuite(matrixConfig { execution= ExecutionMode.Sequential }) { "Examples from https://datatracker.ietf.org/doc/rfc9535/" - { "1.5. JSONPath Examples" - { val bookStore = Json.decodeFromString( @@ -58,9 +90,7 @@ class JsonPathUnitTest : FreeSpec({ " }" ) - "$.store.book[*].author" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(bookStore).map { it.value } + query("$.store.book[*].author", bookStore) { nodeList -> nodeList shouldHaveSize 4 val bookArray = bookStore.jsonObject["store"].shouldNotBeNull().jsonObject["book"].shouldNotBeNull().jsonArray @@ -70,9 +100,7 @@ class JsonPathUnitTest : FreeSpec({ } } - "$..author" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(bookStore).map { it.value } + query("$..author", bookStore) { nodeList -> nodeList shouldHaveSize 4 val bookArray = bookStore.jsonObject["store"].shouldNotBeNull().jsonObject["book"].shouldNotBeNull().jsonArray @@ -82,9 +110,7 @@ class JsonPathUnitTest : FreeSpec({ } } - "\$.store.*" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(bookStore).map { it.value } + query("\$.store.*", bookStore) { nodeList -> nodeList shouldHaveSize 2 val store = bookStore.jsonObject["store"].shouldNotBeNull().jsonObject @@ -92,9 +118,7 @@ class JsonPathUnitTest : FreeSpec({ store.get("bicycle").shouldNotBeNull().shouldBeIn(nodeList) } - "\$.store..price" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(bookStore).map { it.value } + query("\$.store..price", bookStore) { nodeList -> nodeList shouldHaveSize 5 val store = bookStore.jsonObject["store"].shouldNotBeNull().jsonObject @@ -105,9 +129,7 @@ class JsonPathUnitTest : FreeSpec({ .shouldBeIn(nodeList) } - "\$..book[2]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(bookStore).map { it.value } + query("\$..book[2]", bookStore) { nodeList -> nodeList shouldHaveSize 1 bookStore.jsonObject["store"] .shouldNotBeNull().jsonObject["book"] @@ -115,9 +137,7 @@ class JsonPathUnitTest : FreeSpec({ .shouldNotBeNull().shouldBeIn(nodeList) } - "\$..book[2].author" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(bookStore).map { it.value } + query("\$..book[2].author", bookStore) { nodeList -> nodeList shouldHaveSize 1 bookStore.jsonObject["store"] .shouldNotBeNull().jsonObject["book"] @@ -126,15 +146,11 @@ class JsonPathUnitTest : FreeSpec({ .shouldNotBeNull().shouldBeIn(nodeList) } - "\$..book[2].publisher" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(bookStore).map { it.value } + query("\$..book[2].publisher", bookStore) { nodeList -> nodeList shouldHaveSize 0 } - "\$..book[-1]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(bookStore).map { it.value } + query("\$..book[-1]", bookStore) { nodeList -> nodeList shouldHaveSize 1 bookStore.jsonObject["store"] .shouldNotBeNull().jsonObject["book"] @@ -142,9 +158,7 @@ class JsonPathUnitTest : FreeSpec({ .shouldNotBeNull().shouldBeIn(nodeList) } - "\$..book[0,1]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(bookStore).map { it.value } + query("\$..book[0,1]", bookStore) { nodeList -> nodeList shouldHaveSize 2 bookStore.jsonObject["store"] .shouldNotBeNull().jsonObject["book"] @@ -155,9 +169,7 @@ class JsonPathUnitTest : FreeSpec({ } } - "\$..book[:2]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(bookStore).map { it.value } + query("\$..book[:2]", bookStore) { nodeList -> nodeList shouldHaveSize 2 bookStore.jsonObject["store"] .shouldNotBeNull().jsonObject["book"] @@ -168,9 +180,7 @@ class JsonPathUnitTest : FreeSpec({ } } - "\$..book[?@.isbn]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(bookStore).map { it.value } + query("\$..book[?@.isbn]", bookStore) { nodeList -> nodeList shouldHaveSize 2 bookStore.jsonObject["store"] .shouldNotBeNull().jsonObject["book"] @@ -181,9 +191,7 @@ class JsonPathUnitTest : FreeSpec({ } } - "\$..book[?@.price<10]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(bookStore).map { it.value } + query("\$..book[?@.price<10]", bookStore) { nodeList -> nodeList shouldHaveSize 2 bookStore.jsonObject["store"] .shouldNotBeNull().jsonObject["book"] @@ -194,9 +202,7 @@ class JsonPathUnitTest : FreeSpec({ } } - "\$..*" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(bookStore).map { it.value } + query("\$..*", bookStore) { nodeList -> bookStore.jsonObject["store"] .shouldNotBeNull().shouldBeIn(nodeList).jsonObject.let { store -> store["book"].shouldNotBeNull() @@ -216,29 +222,21 @@ class JsonPathUnitTest : FreeSpec({ "2.1. Overview" - { val jsonElement = Json.decodeFromString("{\"a\":[{\"b\":0},{\"b\":1},{\"c\":2}]}") - "\$" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement.shouldBeIn(nodeList) } - "\$.a" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$.a", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement.jsonObject["a"].shouldNotBeNull().shouldBeIn(nodeList) } - "\$.a[*]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$.a[*]", jsonElement) { nodeList -> nodeList shouldHaveSize 3 jsonElement.jsonObject["a"].shouldNotBeNull().jsonArray.forEach { it.shouldBeIn(nodeList) } } - "\$.a[*].b" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$.a[*].b", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement.jsonObject["a"].shouldNotBeNull().jsonArray.forEach { it.shouldBeInstanceOf()["b"]?.jsonPrimitive?.shouldBeIn(nodeList) @@ -248,9 +246,7 @@ class JsonPathUnitTest : FreeSpec({ "2.2. Root Identifier" - { val jsonElement = Json.decodeFromString("{\"k\": \"v\"}") - "$" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("$", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement.shouldBeIn(nodeList) } @@ -263,35 +259,27 @@ class JsonPathUnitTest : FreeSpec({ " \"'\": {\"@\": 2}\n" + " }" ) - "\$.o['j j']" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$.o['j j']", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement.jsonObject.get("o") .shouldNotBeNull().jsonObject["j j"] .shouldNotBeNull().shouldBeIn(nodeList) } - "\$.o['j j']['k.k']" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$.o['j j']['k.k']", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement.jsonObject.get("o") .shouldNotBeNull().jsonObject["j j"] .shouldNotBeNull().jsonObject["k.k"] .shouldNotBeNull().shouldBeIn(nodeList) } - "\$.o[\"j j\"][\"k.k\"]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$.o[\"j j\"][\"k.k\"]", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement.jsonObject.get("o") .shouldNotBeNull().jsonObject["j j"] .shouldNotBeNull().jsonObject["k.k"] .shouldNotBeNull().shouldBeIn(nodeList) } - "\$[\"'\"][\"@\"]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[\"'\"][\"@\"]", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement.jsonObject.get("'") .shouldNotBeNull().jsonObject["@"] @@ -306,18 +294,14 @@ class JsonPathUnitTest : FreeSpec({ " \"a\": [5, 3]\n" + " }" ) - "\$[*]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[*]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement.jsonObject.get("o") .shouldNotBeNull().shouldBeIn(nodeList) jsonElement.jsonObject.get("a") .shouldNotBeNull().shouldBeIn(nodeList) } - "\$.o[*]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$.o[*]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement.jsonObject["o"].shouldNotBeNull().let { it.jsonObject["j"] @@ -326,9 +310,7 @@ class JsonPathUnitTest : FreeSpec({ .shouldNotBeNull().shouldBeIn(nodeList) } } - "\$.o[*, *]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$.o[*, *]", jsonElement) { nodeList -> nodeList shouldHaveSize 4 jsonElement.jsonObject["o"].shouldNotBeNull().let { it.jsonObject["j"] @@ -345,9 +327,7 @@ class JsonPathUnitTest : FreeSpec({ } } } - "\$.a[*]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$.a[*]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement.jsonObject["a"].shouldNotBeNull().let { it.jsonArray[0] @@ -369,15 +349,11 @@ class JsonPathUnitTest : FreeSpec({ "2.3.3. Index Selector" - { val jsonElement = Json.decodeFromString("[\"a\",\"b\"]") - "\$[1]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[1]", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement.jsonArray[1].shouldNotBeNull().shouldBeIn(nodeList) } - "\$[-2]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[-2]", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement.jsonArray[0].shouldNotBeNull().shouldBeIn(nodeList) } @@ -387,9 +363,7 @@ class JsonPathUnitTest : FreeSpec({ val jsonElement = Json.decodeFromString("[\"a\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\"]") - "\$[1:3]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[1:3]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement.jsonArray[1].shouldNotBeNull().shouldBeIn(nodeList).let { nodeList[0].shouldBe(it) @@ -398,9 +372,7 @@ class JsonPathUnitTest : FreeSpec({ nodeList[1].shouldBe(it) } } - "\$[5:]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[5:]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement.jsonArray[5].shouldNotBeNull().shouldBeIn(nodeList).let { nodeList[0].shouldBe(it) @@ -409,9 +381,7 @@ class JsonPathUnitTest : FreeSpec({ nodeList[1].shouldBe(it) } } - "\$[1:5:2]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[1:5:2]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement.jsonArray[1].shouldNotBeNull().shouldBeIn(nodeList).let { nodeList[0].shouldBe(it) @@ -420,9 +390,7 @@ class JsonPathUnitTest : FreeSpec({ nodeList[1].shouldBe(it) } } - "\$[5:1:-2]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[5:1:-2]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement.jsonArray[5].shouldNotBeNull().shouldBeIn(nodeList).let { nodeList[0].shouldBe(it) @@ -431,9 +399,7 @@ class JsonPathUnitTest : FreeSpec({ nodeList[1].shouldBe(it) } } - "\$[::-1]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[::-1]", jsonElement) { nodeList -> nodeList shouldHaveSize 7 for (i in 6.downTo(0)) { jsonElement.jsonArray[i].shouldNotBeNull().shouldBeIn(nodeList).let { @@ -455,144 +421,88 @@ class JsonPathUnitTest : FreeSpec({ " }" ).jsonObject - "\$[?\$.absent1 == \$.absent2]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.absent1 == \$.absent2]", jsonElement) { nodeList -> nodeList shouldHaveSize jsonElement.size } - "\$[?\$.absent1 <= \$.absent2 ]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.absent1 <= \$.absent2 ]", jsonElement) { nodeList -> nodeList shouldHaveSize jsonElement.size } - "\$[?\$.absent == 'g']" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.absent == 'g']", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?\$.absent1 != \$.absent2]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.absent1 != \$.absent2]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?\$.absent != 'g']" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.absent != 'g']", jsonElement) { nodeList -> nodeList shouldHaveSize jsonElement.size } - "\$[?1 <= 2]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?1 <= 2]", jsonElement) { nodeList -> nodeList shouldHaveSize jsonElement.size } - "\$[?1 > 2]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?1 > 2]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?13 == '13']" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?13 == '13']", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?'a' <= 'b']" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?'a' <= 'b']", jsonElement) { nodeList -> nodeList shouldHaveSize jsonElement.size } - "\$[?'a' > 'b']" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?'a' > 'b']", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?\$.obj == \$.arr]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.obj == \$.arr]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?\$.obj != \$.arr]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.obj != \$.arr]", jsonElement) { nodeList -> nodeList shouldHaveSize jsonElement.size } - "\$[?\$.obj == \$.obj]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.obj == \$.obj]", jsonElement) { nodeList -> nodeList shouldHaveSize jsonElement.size } - "\$[?\$.obj != \$.obj]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.obj != \$.obj]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?\$.arr == \$.arr]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.arr == \$.arr]", jsonElement) { nodeList -> nodeList shouldHaveSize jsonElement.size } - "\$[?\$.arr != \$.arr]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.arr != \$.arr]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?\$.obj == 17]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.obj == 17]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?\$.obj != 17]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.obj != 17]", jsonElement) { nodeList -> nodeList shouldHaveSize jsonElement.size } - "\$[?\$.obj <= \$.arr]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.obj <= \$.arr]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?\$.obj < \$.arr]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.obj < \$.arr]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?\$.obj <= \$.obj]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.obj <= \$.obj]", jsonElement) { nodeList -> nodeList shouldHaveSize jsonElement.size } - "\$[?\$.arr <= \$.arr]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?\$.arr <= \$.arr]", jsonElement) { nodeList -> nodeList shouldHaveSize jsonElement.size } - "\$[?1 <= \$.arr]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?1 <= \$.arr]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?1 >= \$.arr]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?1 >= \$.arr]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?1 > \$.arr]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?1 > \$.arr]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?1 < \$.arr]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?1 < \$.arr]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?true <= true]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?true <= true]", jsonElement) { nodeList -> nodeList shouldHaveSize jsonElement.size } - "\$[?true > true]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?true > true]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } } @@ -610,27 +520,17 @@ class JsonPathUnitTest : FreeSpec({ " }" ).jsonObject - - "\$.a[?@.b == 'kilo']" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.a[?@.b == 'kilo']", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement.jsonObject["a"].shouldNotBeNull() .jsonArray.get(9).shouldNotBeNull().shouldBeIn(nodeList) } - "\$.a[?(@.b == 'kilo')]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.a[?(@.b == 'kilo')]", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement.jsonObject["a"].shouldNotBeNull() .jsonArray.get(9).shouldNotBeNull().shouldBeIn(nodeList) } - "\$.a[?@>3.5]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.a[?@>3.5]", jsonElement) { nodeList -> nodeList shouldHaveSize 3 jsonElement.jsonObject["a"].shouldNotBeNull().let { a -> a.jsonArray[1].shouldNotBeNull().shouldBeIn(nodeList) @@ -638,10 +538,7 @@ class JsonPathUnitTest : FreeSpec({ a.jsonArray[5].shouldNotBeNull().shouldBeIn(nodeList) } } - "\$.a[?@.b]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.a[?@.b]", jsonElement) { nodeList -> nodeList shouldHaveSize 4 jsonElement.jsonObject["a"].shouldNotBeNull().let { a -> a.jsonArray[6].shouldNotBeNull().shouldBeIn(nodeList) @@ -650,25 +547,16 @@ class JsonPathUnitTest : FreeSpec({ a.jsonArray[9].shouldNotBeNull().shouldBeIn(nodeList) } } - "\$[?@.*]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$[?@.*]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement.jsonObject["a"].shouldNotBeNull().shouldBeIn(nodeList) jsonElement.jsonObject["o"].shouldNotBeNull().shouldBeIn(nodeList) } - "\$[?@[?@.b]]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$[?@[?@.b]]", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement.jsonObject["a"].shouldNotBeNull().shouldBeIn(nodeList) } - "\$.o[?@<3, ?@<3]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.o[?@<3, ?@<3]", jsonElement) { nodeList -> nodeList shouldHaveSize 4 jsonElement.jsonObject["o"].shouldNotBeNull().jsonObject.let { o -> o["p"].shouldNotBeNull().shouldBeIn(nodeList).let { p -> @@ -683,30 +571,21 @@ class JsonPathUnitTest : FreeSpec({ } } } - "\$.a[?@<2 || @.b == \"k\"]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.a[?@<2 || @.b == \"k\"]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement.jsonObject["a"].shouldNotBeNull().let { a -> a.jsonArray.get(2).shouldNotBeNull().shouldBeIn(nodeList) a.jsonArray.get(7).shouldNotBeNull().shouldBeIn(nodeList) } } - "\$.a[?match(@.b, \"[jk]\")]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.a[?match(@.b, \"[jk]\")]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement.jsonObject["a"].shouldNotBeNull().let { a -> a.jsonArray.get(6).shouldNotBeNull().shouldBeIn(nodeList) a.jsonArray.get(7).shouldNotBeNull().shouldBeIn(nodeList) } } - "\$.a[?search(@.b, \"[jk]\")]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.a[?search(@.b, \"[jk]\")]", jsonElement) { nodeList -> nodeList shouldHaveSize 3 jsonElement.jsonObject["a"].shouldNotBeNull().let { a -> a.jsonArray.get(6).shouldNotBeNull().shouldBeIn(nodeList) @@ -714,28 +593,19 @@ class JsonPathUnitTest : FreeSpec({ a.jsonArray.get(9).shouldNotBeNull().shouldBeIn(nodeList) } } - "\$.o[?@>1 && @<4]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.o[?@>1 && @<4]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement.jsonObject["o"].shouldNotBeNull().jsonObject.let { o -> o["q"].shouldNotBeNull().shouldBeIn(nodeList) o["r"].shouldNotBeNull().shouldBeIn(nodeList) } } - "\$.o[?@.u || @.x]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.o[?@.u || @.x]", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement.jsonObject["o"].shouldNotBeNull().jsonObject .get("t").shouldNotBeNull().shouldBeIn(nodeList) } - "\$.a[?@.b == $.x]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.a[?@.b == $.x]", jsonElement) { nodeList -> nodeList shouldHaveSize 6 jsonElement.jsonObject["a"].shouldNotBeNull().jsonArray.let { a -> nodeList[0].shouldBe(a[0]) @@ -746,10 +616,7 @@ class JsonPathUnitTest : FreeSpec({ nodeList[5].shouldBe(a[5]) } } - "\$.a[?@ || @]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.a[?@ || @]", jsonElement) { nodeList -> nodeList shouldHaveSize 10 jsonElement.jsonObject["a"].shouldNotBeNull().jsonArray.let { a -> for (index in 0..9) { @@ -760,119 +627,133 @@ class JsonPathUnitTest : FreeSpec({ } } - "2.4. Function Extensions" - { - // making sure that the dependencies are reset to their default for the next test - val defaultCompilerBuilderBackup = JsonPath.defaultCompiler - val defaultFunctionExtensionRepositoryBackup = - JsonPath.defaultFunctionExtensionRepository.export() - beforeEach { + "2.4. Function Extensions"( + TestConfig.aroundEachTest { action -> // prepare a dummy repository to be modified by the tests - JsonPath.defaultFunctionExtensionRepository = - JsonPathFunctionExtensionMapRepository( + JsonPath.defaultFunctionExtensionRepository = JsonPathFunctionExtensionMapRepository( + defaultFunctionExtensionRepositoryBackup.toMutableMap() + ) + try { + action() + } finally { + JsonPath.defaultCompiler = defaultCompilerBuilderBackup + JsonPath.defaultFunctionExtensionRepository = JsonPathFunctionExtensionMapRepository( defaultFunctionExtensionRepositoryBackup.toMutableMap() ) - } - afterEach { - JsonPath.Companion.apply { - defaultCompiler = defaultCompilerBuilderBackup - defaultFunctionExtensionRepository = - JsonPathFunctionExtensionMapRepository( - defaultFunctionExtensionRepositoryBackup.toMutableMap() - ) - } - } - "\$[?length(@) < 3]" { - shouldNotThrowAny { - JsonPath(this.testScope.testCase.name.originalName) - } - } - "\$[?length(@.*) < 3]" { - shouldThrow { - JsonPath(this.testScope.testCase.name.originalName) - } - } - "\$[?count(@.*) == 1]" { - shouldNotThrowAny { - JsonPath(this.testScope.testCase.name.originalName) - } - } - "\$[?count(1) == 1]" { - shouldThrow { - JsonPath(this.testScope.testCase.name.originalName) - } - } - "\$[?count(foo(@.*)) == 1]" { - JsonPath.defaultFunctionExtensionRepository.addExtension("foo") { - JsonPathFunctionExtension.NodesTypeFunctionExtension( - JsonPathFilterExpressionType.NodesType, - ) { - listOf() - } - } - - shouldNotThrowAny { - JsonPath(this.testScope.testCase.name.originalName) } } - "\$[?match(@.timezone, 'Europe/.*') == true]" { - shouldThrow { - JsonPath(this.testScope.testCase.name.originalName) - } - } - "\$[?value(@..color) == 'red']" { - shouldNotThrowAny { - JsonPath(this.testScope.testCase.name.originalName) - } - } - "\$[?value(@..color)]" { - shouldThrow { - JsonPath(this.testScope.testCase.name.originalName) - } - } - "\$[?bar(@.a)]" - { - "logical type argument" { - JsonPath.defaultFunctionExtensionRepository.addExtension("bar") { - JsonPathFunctionExtension.LogicalTypeFunctionExtension( - JsonPathFilterExpressionType.LogicalType, + ) - { + compiles("\$[?length(@) < 3]") + failsTypeCheck("\$[?length(@.*) < 3]") + compiles("\$[?count(@.*) == 1]") + failsTypeCheck("\$[?count(1) == 1]") + "\$[?count(foo(@.*)) == 1]".let { path -> + path { + JsonPath.defaultFunctionExtensionRepository.addExtension("foo") { + JsonPathFunctionExtension.NodesTypeFunctionExtension( + JsonPathFilterExpressionType.NodesType, ) { - true + listOf() } } shouldNotThrowAny { - JsonPath(this.testScope.testCase.parent!!.name.originalName) + JsonPath(path) } } - "value type argument" { - JsonPath.defaultFunctionExtensionRepository.addExtension("bar") { - JsonPathFunctionExtension.LogicalTypeFunctionExtension( - JsonPathFilterExpressionType.ValueType, - ) { - true + } + failsTypeCheck("\$[?match(@.timezone, 'Europe/.*') == true]") + compiles("\$[?value(@..color) == 'red']") + failsTypeCheck("\$[?value(@..color)]") + "\$[?bar(@.a)]".let { path -> + path - { + "logical type argument" { + JsonPath.defaultFunctionExtensionRepository.addExtension("bar") { + JsonPathFunctionExtension.LogicalTypeFunctionExtension( + JsonPathFilterExpressionType.LogicalType, + ) { + true + } + } + + shouldNotThrowAny { + JsonPath(path) } } + "value type argument" { + JsonPath.defaultFunctionExtensionRepository.addExtension("bar") { + JsonPathFunctionExtension.LogicalTypeFunctionExtension( + JsonPathFilterExpressionType.ValueType, + ) { + true + } + } - shouldNotThrowAny { - JsonPath(this.testScope.testCase.parent!!.name.originalName) + shouldNotThrowAny { + JsonPath(path) + } + } + "nodes type argument" { + JsonPath.defaultFunctionExtensionRepository.addExtension("bar") { + JsonPathFunctionExtension.LogicalTypeFunctionExtension( + JsonPathFilterExpressionType.NodesType, + ) { + true + } + } + + shouldNotThrowAny { + JsonPath(path) + } } } - "nodes type argument" { - JsonPath.defaultFunctionExtensionRepository.addExtension("bar") { - JsonPathFunctionExtension.LogicalTypeFunctionExtension( - JsonPathFilterExpressionType.NodesType, - ) { - true + } + "\$[?bnl(@.*)]".let { path -> + path - { + "logical type argument" { + JsonPath.defaultFunctionExtensionRepository.addExtension("bnl") { + JsonPathFunctionExtension.LogicalTypeFunctionExtension( + JsonPathFilterExpressionType.LogicalType, + ) { + true + } + } + + shouldNotThrowAny { + JsonPath(path) } } + "value type argument" { + JsonPath.defaultFunctionExtensionRepository.addExtension("bnl") { + JsonPathFunctionExtension.LogicalTypeFunctionExtension( + JsonPathFilterExpressionType.ValueType, + ) { + true + } + } - shouldNotThrowAny { - JsonPath(this.testScope.testCase.parent!!.name.originalName) + shouldThrow { + JsonPath(path) + } + } + "nodes type argument" { + JsonPath.defaultFunctionExtensionRepository.addExtension("bnl") { + JsonPathFunctionExtension.LogicalTypeFunctionExtension( + JsonPathFilterExpressionType.NodesType + ) { + true + } + } + + shouldNotThrowAny { + JsonPath(path) + } } } } - "\$[?bnl(@.*)]" - { - "logical type argument" { - JsonPath.defaultFunctionExtensionRepository.addExtension("bnl") { + "\$[?blt(1==1)]".let { path -> + path { + JsonPath.defaultFunctionExtensionRepository.addExtension("blt") { JsonPathFunctionExtension.LogicalTypeFunctionExtension( JsonPathFilterExpressionType.LogicalType, ) { @@ -881,74 +762,39 @@ class JsonPathUnitTest : FreeSpec({ } shouldNotThrowAny { - JsonPath(this.testScope.testCase.parent!!.name.originalName) + JsonPath(path) } } - "value type argument" { - JsonPath.defaultFunctionExtensionRepository.addExtension("bnl") { + } + "\$[?blt(1)]".let { path -> + path { + JsonPath.defaultFunctionExtensionRepository.addExtension("blt") { JsonPathFunctionExtension.LogicalTypeFunctionExtension( - JsonPathFilterExpressionType.ValueType, + JsonPathFilterExpressionType.LogicalType, ) { true } } shouldThrow { - JsonPath(this.testScope.testCase.parent!!.name.originalName) + JsonPath(path) } } - "nodes type argument" { - JsonPath.defaultFunctionExtensionRepository.addExtension("bnl") { + } + "\$[?bal(1)]".let { path -> + path { + JsonPath.defaultFunctionExtensionRepository.addExtension("bal") { JsonPathFunctionExtension.LogicalTypeFunctionExtension( - JsonPathFilterExpressionType.NodesType + JsonPathFilterExpressionType.ValueType, ) { true } } shouldNotThrowAny { - JsonPath(this.testScope.testCase.parent!!.name.originalName) - } - } - } - "\$[?blt(1==1)]" { - JsonPath.defaultFunctionExtensionRepository.addExtension("blt") { - JsonPathFunctionExtension.LogicalTypeFunctionExtension( - JsonPathFilterExpressionType.LogicalType, - ) { - true + JsonPath(path) } } - - shouldNotThrowAny { - JsonPath(this.testScope.testCase.name.originalName) - } - } - "\$[?blt(1)]" { - JsonPath.defaultFunctionExtensionRepository.addExtension("blt") { - JsonPathFunctionExtension.LogicalTypeFunctionExtension( - JsonPathFilterExpressionType.LogicalType, - ) { - true - } - } - - shouldThrow { - JsonPath(this.testScope.testCase.name.originalName) - } - } - "\$[?bal(1)]" { - JsonPath.defaultFunctionExtensionRepository.addExtension("bal") { - JsonPathFunctionExtension.LogicalTypeFunctionExtension( - JsonPathFilterExpressionType.ValueType, - ) { - true - } - } - - shouldNotThrowAny { - JsonPath(this.testScope.testCase.name.originalName) - } } } @@ -956,27 +802,18 @@ class JsonPathUnitTest : FreeSpec({ val jsonElement = Json.decodeFromString( "[\"a\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\"]" ).jsonArray - "\$[0, 3]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$[0, 3]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 nodeList[0].shouldBe(jsonElement[0]) nodeList[1].shouldBe(jsonElement[3]) } - "\$[0:2,5]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$[0:2,5]", jsonElement) { nodeList -> nodeList shouldHaveSize 3 nodeList[0].shouldBe(jsonElement[0]) nodeList[1].shouldBe(jsonElement[1]) nodeList[2].shouldBe(jsonElement[5]) } - "\$[0,0]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$[0,0]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 nodeList[0].shouldBe(jsonElement[0]) nodeList[1].shouldBe(jsonElement[0]) @@ -990,10 +827,7 @@ class JsonPathUnitTest : FreeSpec({ " \"a\": [5, 3, [{\"j\": 4}, {\"k\": 6}]]\n" + " }" ).jsonObject - "\$..j" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$..j", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement["o"].shouldNotBeNull().jsonObject["j"].shouldBeIn(nodeList) jsonElement["a"].shouldNotBeNull() @@ -1001,20 +835,14 @@ class JsonPathUnitTest : FreeSpec({ .jsonArray[0].shouldNotBeNull() .jsonObject["j"].shouldNotBeNull().shouldBeIn(nodeList) } - "\$..[0]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$..[0]", jsonElement) { nodeList -> nodeList shouldHaveSize 2 jsonElement["a"].shouldNotBeNull().jsonArray.let { a -> a.jsonArray[0].shouldNotBeNull().shouldBe(nodeList[0]) a.jsonArray[2].shouldNotBeNull().jsonArray[0].shouldBe(nodeList[1]) } } - "\$..[*]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$..[*]", jsonElement) { nodeList -> nodeList shouldHaveSize 11 jsonElement["a"].shouldNotBeNull().shouldBeIn(nodeList).jsonArray.let { a -> a.forEach { item -> @@ -1035,10 +863,7 @@ class JsonPathUnitTest : FreeSpec({ } } } - "\$..*" { // same as the one before this - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$..*", jsonElement) { nodeList -> // same as the one before this nodeList shouldHaveSize 11 jsonElement["a"].shouldNotBeNull().shouldBeIn(nodeList).jsonArray.let { a -> a.forEach { item -> @@ -1059,17 +884,11 @@ class JsonPathUnitTest : FreeSpec({ } } } - "\$..o" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$..o", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement["o"].shouldNotBeNull().shouldBeIn(nodeList) } - "\$.o..[*, *]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.o..[*, *]", jsonElement) { nodeList -> nodeList shouldHaveSize 4 jsonElement["o"].shouldNotBeNull().jsonObject.let { o -> o.forEach { oDescendant -> @@ -1080,10 +899,7 @@ class JsonPathUnitTest : FreeSpec({ } } } - "\$.a..[0, 1]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.a..[0, 1]", jsonElement) { nodeList -> nodeList shouldHaveSize 4 jsonElement["a"].shouldNotBeNull().jsonArray.let { a -> listOf(a, a[2]).forEach { descendant -> @@ -1098,69 +914,42 @@ class JsonPathUnitTest : FreeSpec({ val jsonElement = Json.decodeFromString( " {\"a\": null, \"b\": [null], \"c\": [{}], \"null\": 1}" ).jsonObject - "\$.a" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.a", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement["a"].shouldNotBeNull().shouldBeIn(nodeList) } - "\$.a[0]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.a[0]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$.a.d" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.a.d", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$.b[0]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.b[0]", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement["b"].shouldNotBeNull().jsonArray[0].shouldBeIn(nodeList) } - "\$.b[*]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.b[*]", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement["b"].shouldNotBeNull().jsonArray.mapIndexed { index, value -> nodeList[index].shouldBe(value) } } - "\$.b[?@]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.b[?@]", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement["b"].shouldNotBeNull().jsonArray.mapIndexed { index, value -> nodeList[index].shouldBe(value) } } - "\$.b[?@==null]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.b[?@==null]", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement["b"].shouldNotBeNull().jsonArray.mapIndexed { index, value -> nodeList[index].shouldBe(value) } } - "\$.c[?@.d==null]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.c[?@.d==null]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$.null" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } - + query("\$.null", jsonElement) { nodeList -> nodeList shouldHaveSize 1 jsonElement["null"].shouldNotBeNull().shouldBeIn(nodeList) } @@ -1206,52 +995,36 @@ class JsonPathUnitTest : FreeSpec({ ) "working" - { - "$[?$.tag.id == 'ic2:geo_generator' || $.tag.id == 'ic2:generator']" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("$[?$.tag.id == 'ic2:geo_generator' || $.tag.id == 'ic2:generator']", jsonElement) { nodeList -> nodeList shouldHaveSize 5 } - "\$[?(\$.tag.id == 'ic2:geo_generator' || \$.tag.id == 'ic2:generator')]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?(\$.tag.id == 'ic2:geo_generator' || \$.tag.id == 'ic2:generator')]", jsonElement) { nodeList -> nodeList shouldHaveSize 5 } - "\$.tag[?(\$.tag.id == 'ic2:geo_generator' || \$.tag.id == 'ic2:generator')]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$.tag[?(\$.tag.id == 'ic2:geo_generator' || \$.tag.id == 'ic2:generator')]", jsonElement) { nodeList -> nodeList shouldHaveSize 10 } } "not working" - { - "\$.tag.id[?(\$.tag.id == 'ic2:geo_generator' || \$.tag.id == 'ic2:generator')]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$.tag.id[?(\$.tag.id == 'ic2:geo_generator' || \$.tag.id == 'ic2:generator')]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$[?(@.tag.id == 'ic2:geo_generator' || @.tag.id == 'ic2:generator')]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$[?(@.tag.id == 'ic2:geo_generator' || @.tag.id == 'ic2:generator')]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } - "\$.tag[?(@.id == 'ic2:geo_generator' || @.id == 'ic2:generator')]" { - val nodeList = JsonPath(this.testScope.testCase.name.originalName) - .query(jsonElement).map { it.value } + query("\$.tag[?(@.id == 'ic2:geo_generator' || @.id == 'ic2:generator')]", jsonElement) { nodeList -> nodeList shouldHaveSize 0 } } } "RMLTC0002g-JSON" - { - "$.students[*]]" { - shouldThrow { - JsonPath(this.testScope.testCase.name.originalName) - } - } + failsParsing("$.students[*]]") } } -}) +} diff --git a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/MemberNameShorthandSerializationTest.kt b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/MemberNameShorthandSerializationTest.kt index a6c2e10..8d2c3fc 100644 --- a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/MemberNameShorthandSerializationTest.kt +++ b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/MemberNameShorthandSerializationTest.kt @@ -1,35 +1,38 @@ package at.asitplus.jsonpath import at.asitplus.jsonpath.core.NormalizedJsonPathSegment +import at.asitplus.testballoon.matrix.matrixSuite import io.kotest.assertions.throwables.shouldNotThrowAny import io.kotest.assertions.throwables.shouldThrowAny -import io.kotest.core.spec.style.FreeSpec -import io.kotest.datatest.withData import io.kotest.matchers.shouldBe -class MemberNameShorthandSerializationTest : FreeSpec({ - "should be serializable as member name shorthand" - { - withData( +val MemberNameShorthandSerializationTest by matrixSuite { + data( + "should be serializable as member name shorthand", + listOf( "test", "test_123", "t1", - ) { - shouldNotThrowAny { - NormalizedJsonPathSegment.NameSegment(it).toShorthandNotation() - } shouldBe ".$it" - } + ) + ) test { + shouldNotThrowAny { + NormalizedJsonPathSegment.NameSegment(it).toShorthandNotation() + } shouldBe ".$it" } - "should not be serializable as member name shorthand" - { - withData( + + data( + "should not be serializable as member name shorthand", + listOf( "1", "*", "'", "\"", "test-data", - ) { - shouldThrowAny { - NormalizedJsonPathSegment.NameSegment(it).toShorthandNotation() shouldBe ".$it" - } + ) + ) test { + shouldThrowAny { + NormalizedJsonPathSegment.NameSegment(it).toShorthandNotation() shouldBe ".$it" } } -}) \ No newline at end of file +} + diff --git a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/NodeListSerializationTest.kt b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/NodeListSerializationTest.kt index 90d02d5..b0eeebc 100644 --- a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/NodeListSerializationTest.kt +++ b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/NodeListSerializationTest.kt @@ -3,7 +3,7 @@ package at.asitplus.jsonpath import at.asitplus.jsonpath.core.NodeListEntry import at.asitplus.jsonpath.core.NormalizedJsonPath import at.asitplus.jsonpath.core.NormalizedJsonPathSegment -import io.kotest.core.spec.style.FreeSpec +import at.asitplus.testballoon.matrix.matrixSuite import io.kotest.matchers.shouldBe import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -11,8 +11,7 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject -@Suppress("unused") -class NodeListSerializationTest : FreeSpec({ +val NodeListSerializationTest by matrixSuite { "NormalizedJsonPathSegment" - { "NameSegment" { val segment: NormalizedJsonPathSegment = NormalizedJsonPathSegment.NameSegment("test") @@ -93,7 +92,5 @@ class NodeListSerializationTest : FreeSpec({ reconstructed.normalizedJsonPath.toString() shouldBe entry.normalizedJsonPath.toString() } } - "NodeList" - { - // trusting the default serializer for list for this one - } -}) \ No newline at end of file + // "NodeList": trusting the default serializer for list for this one (no test) +} diff --git a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/NormalizedJsonPathSerializationTest.kt b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/NormalizedJsonPathSerializationTest.kt index 2dd7b8d..586471f 100644 --- a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/NormalizedJsonPathSerializationTest.kt +++ b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/NormalizedJsonPathSerializationTest.kt @@ -2,53 +2,57 @@ package at.asitplus.jsonpath import at.asitplus.jsonpath.core.NormalizedJsonPath import at.asitplus.jsonpath.core.NormalizedJsonPathSegment +import at.asitplus.testballoon.matrix.matrixSuite import io.kotest.assertions.throwables.shouldNotThrowAny import io.kotest.assertions.throwables.shouldThrowAny -import io.kotest.core.spec.style.FreeSpec -import io.kotest.datatest.withData import io.kotest.matchers.shouldBe -class NormalizedJsonPathSerializationTest : FreeSpec({ - "normalized json path string" - { - withData( +val NormalizedJsonPathSerializationTest by matrixSuite { + data( + "normalized json path string", + listOf( "test" to "$['test']", "test_123" to "$['test_123']", "t1" to "$['t1']", - ) { (it, expected) -> - shouldNotThrowAny { - val path = NormalizedJsonPath() + NormalizedJsonPathSegment.NameSegment(it) - path.toNormalizedJsonPathString() shouldBe expected - } + ) + ) test { (input, expected) -> + shouldNotThrowAny { + val path = NormalizedJsonPath() + NormalizedJsonPathSegment.NameSegment(input) + path.toNormalizedJsonPathString() shouldBe expected } } + "shorthand serialization" - { - "should be serializable as member name shorthand" - { - withData( + data( + "should be serializable as member name shorthand", + listOf( "test", "test_123", "t1", - ) { - shouldNotThrowAny { - val path = NormalizedJsonPath() + NormalizedJsonPathSegment.NameSegment(it) - path.toShorthandNameSegmentNotation() shouldBe "$.$it" - path.toShorthandNameSegmentNotationWherePossible() shouldBe "$.$it" - } + ) + ) test { + shouldNotThrowAny { + val path = NormalizedJsonPath() + NormalizedJsonPathSegment.NameSegment(it) + path.toShorthandNameSegmentNotation() shouldBe "$.$it" + path.toShorthandNameSegmentNotationWherePossible() shouldBe "$.$it" } } - "should not be serializable as member name shorthand" - { - withData( + + data( + listOf( + "should not be serializable as member name shorthand", "1", "*", "'", "\"", "test-data", - ) { - val path = NormalizedJsonPath() + NormalizedJsonPathSegment.NameSegment(it) - shouldThrowAny { - path.toShorthandNameSegmentNotation() - } - path.toShorthandNameSegmentNotationWherePossible() shouldBe path.toNormalizedJsonPathString() + ) + ) test { + val path = NormalizedJsonPath() + NormalizedJsonPathSegment.NameSegment(it) + shouldThrowAny { + path.toShorthandNameSegmentNotation() } + path.toShorthandNameSegmentNotationWherePossible() shouldBe path.toNormalizedJsonPathString() } } -}) \ No newline at end of file +} diff --git a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/Rfc9535UtilsUnitTest.kt b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/Rfc9535UtilsUnitTest.kt index de6c76b..5f962c5 100644 --- a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/Rfc9535UtilsUnitTest.kt +++ b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/Rfc9535UtilsUnitTest.kt @@ -1,223 +1,95 @@ package at.asitplus.jsonpath import at.asitplus.jsonpath.core.Rfc9535Utils -import io.kotest.core.spec.style.FreeSpec +import at.asitplus.testballoon.matrix.matrixSuite import io.kotest.matchers.shouldBe -@Suppress("unused") -class Rfc9535UtilsUnitTest : FreeSpec({ +val Rfc9535UtilsUnitTest by matrixSuite { "Rfc9535Utils.unpackStringLiteral Unit Tests" - { "rfc8259 conformance" - { "special escape characters" - { - "double quoted" - { - "unescapes backslash to backslash" { - Rfc9535Utils.unpackStringLiteral("\"\\\\\"") shouldBe "\\" - } - "unescapes slash to slash" { - Rfc9535Utils.unpackStringLiteral("\"\\/\"") shouldBe "/" - } - "unescapes quotation mark to quotation mark" { - Rfc9535Utils.unpackStringLiteral("\"\\\"\"") shouldBe "\"" - } - "unescapes b to backspace" { - Rfc9535Utils.unpackStringLiteral("\"\\b\"") shouldBe Char(0x0008).toString() - } - "unescapes f to form feed" { - Rfc9535Utils.unpackStringLiteral("\"\\f\"") shouldBe Char(0x000C).toString() - } - "unescapes n to newline" { - Rfc9535Utils.unpackStringLiteral("\"\\n\"") shouldBe "\n" - } - "unescapes r to carriage return" { - Rfc9535Utils.unpackStringLiteral("\"\\r\"") shouldBe "\r" - } - "unescapes t to horizontal tab" { - Rfc9535Utils.unpackStringLiteral("\"\\t\"") shouldBe "\t" - } + data( + "double quoted", + listOf( + "\"\\\\\"" to "\\", + "\"\\/\"" to "/", + "\"\\\"\"" to "\"", + "\"\\b\"" to Char(0x0008).toString(), + "\"\\f\"" to Char(0x000C).toString(), + "\"\\n\"" to "\n", + "\"\\r\"" to "\r", + "\"\\t\"" to "\t", + ), + nameFn = { (input, _) -> input }, + ) test { (input, expected) -> + Rfc9535Utils.unpackStringLiteral(input) shouldBe expected } - "single quoted" - { - "unescapes backslash to backslash" { - Rfc9535Utils.unpackStringLiteral("'\\\\'") shouldBe "\\" - } - "unescapes slash to slash" { - Rfc9535Utils.unpackStringLiteral("'\\/'") shouldBe "/" - } - "unescapes quotation mark to quotation mark" { - Rfc9535Utils.unpackStringLiteral("'\\''") shouldBe "'" - } - "unescapes b to backspace" { - Rfc9535Utils.unpackStringLiteral("'\\b'") shouldBe Char(0x0008).toString() - } - "unescapes f to form feed" { - Rfc9535Utils.unpackStringLiteral("'\\f'") shouldBe Char(0x000C).toString() - } - "unescapes n to newline" { - Rfc9535Utils.unpackStringLiteral("'\\n'") shouldBe "\n" - } - "unescapes r to carriage return" { - Rfc9535Utils.unpackStringLiteral("'\\r'") shouldBe "\r" - } - "unescapes t to horizontal tab" { - Rfc9535Utils.unpackStringLiteral("'\\t'") shouldBe "\t" - } + + data( + "single quoted", + listOf( + "'\\\\'" to "\\", + "'\\/'" to "/", + "'\\''" to "'", + "'\\b'" to Char(0x0008).toString(), + "'\\f'" to Char(0x000C).toString(), + "'\\n'" to "\n", + "'\\r'" to "\r", + "'\\t'" to "\t", + ), + nameFn = { (input, _) -> input }, + ) test { (input, expected) -> + Rfc9535Utils.unpackStringLiteral(input) shouldBe expected } } } } + "Rfc9535Utils.switchToSingleQuotedString Unit Tests" - { "rfc8259 conformance" - { - "special escape characters" - { - "\"\\\\\"" { - Rfc9535Utils.switchToSingleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "'\\\\'" - it shouldBe expectedResult - Rfc9535Utils.switchToSingleQuotedString(it) shouldBe expectedResult - } - } - "\"\\/\"" { - Rfc9535Utils.switchToSingleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "'\\/'" - it shouldBe expectedResult - Rfc9535Utils.switchToSingleQuotedString(it) shouldBe expectedResult - } - } - "\"\\\"\"" { - Rfc9535Utils.switchToSingleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "'\"'" - it shouldBe expectedResult - Rfc9535Utils.switchToSingleQuotedString(it) shouldBe expectedResult - } - } - "\"'\"" { - Rfc9535Utils.switchToSingleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "'\\''" - it shouldBe expectedResult - Rfc9535Utils.switchToSingleQuotedString(it) shouldBe expectedResult - } - } - "\"\\b\"" { - Rfc9535Utils.switchToSingleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "'\\b'" - it shouldBe expectedResult - Rfc9535Utils.switchToSingleQuotedString(it) shouldBe expectedResult - } - } - "\"\\f\"" { - Rfc9535Utils.switchToSingleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "'\\f'" - it shouldBe expectedResult - Rfc9535Utils.switchToSingleQuotedString(it) shouldBe expectedResult - } - } - "\"\\n\"" { - Rfc9535Utils.switchToSingleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "'\\n'" - it shouldBe expectedResult - Rfc9535Utils.switchToSingleQuotedString(it) shouldBe expectedResult - } - } - "\"\\r\"" { - Rfc9535Utils.switchToSingleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "'\\r'" - it shouldBe expectedResult - Rfc9535Utils.switchToSingleQuotedString(it) shouldBe expectedResult - } - } - "\"\\t\"" { - Rfc9535Utils.switchToSingleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "'\\t'" - it shouldBe expectedResult - Rfc9535Utils.switchToSingleQuotedString(it) shouldBe expectedResult - } - } + data( + "special escape characters", + listOf( + "\"\\\\\"" to "'\\\\'", + "\"\\/\"" to "'\\/'", + "\"\\\"\"" to "'\"'", + "\"'\"" to "'\\''", + "\"\\b\"" to "'\\b'", + "\"\\f\"" to "'\\f'", + "\"\\n\"" to "'\\n'", + "\"\\r\"" to "'\\r'", + "\"\\t\"" to "'\\t'", + ), + nameFn = { (input, _) -> input }, + ) test { (input, expected) -> + Rfc9535Utils.switchToSingleQuotedString(input) shouldBe expected + // switching an already single-quoted string is idempotent + Rfc9535Utils.switchToSingleQuotedString(expected) shouldBe expected } } } + "Rfc9535Utils.switchToDoubleQuotedString Unit Tests" - { "rfc8259 conformance" - { - "special escape characters" - { - "'\\\\'" { - Rfc9535Utils.switchToDoubleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "\"\\\\\"" - it shouldBe expectedResult - Rfc9535Utils.switchToDoubleQuotedString(it) shouldBe expectedResult - } - } - "'\\/'" { - Rfc9535Utils.switchToDoubleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "\"\\/\"" - it shouldBe expectedResult - Rfc9535Utils.switchToDoubleQuotedString(it) shouldBe expectedResult - } - } - "'\"'" { - Rfc9535Utils.switchToDoubleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "\"\\\"\"" - it shouldBe expectedResult - Rfc9535Utils.switchToDoubleQuotedString(it) shouldBe expectedResult - } - } - "'\\''" { - Rfc9535Utils.switchToDoubleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "\"'\"" - it shouldBe expectedResult - Rfc9535Utils.switchToDoubleQuotedString(it) shouldBe expectedResult - } - } - "'\\b'" { - Rfc9535Utils.switchToDoubleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "\"\\b\"" - it shouldBe expectedResult - Rfc9535Utils.switchToDoubleQuotedString(it) shouldBe expectedResult - } - } - "'\\f'" { - Rfc9535Utils.switchToDoubleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "\"\\f\"" - it shouldBe expectedResult - Rfc9535Utils.switchToDoubleQuotedString(it) shouldBe expectedResult - } - } - "'\\n'" { - Rfc9535Utils.switchToDoubleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "\"\\n\"" - it shouldBe expectedResult - Rfc9535Utils.switchToDoubleQuotedString(it) shouldBe expectedResult - } - } - "'\\r'" { - Rfc9535Utils.switchToDoubleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "\"\\r\"" - it shouldBe expectedResult - Rfc9535Utils.switchToDoubleQuotedString(it) shouldBe expectedResult - } - } - "'\\t'" { - Rfc9535Utils.switchToDoubleQuotedString(this.testScope.testCase.name.originalName) - .let { - val expectedResult = "\"\\t\"" - it shouldBe expectedResult - Rfc9535Utils.switchToDoubleQuotedString(it) shouldBe expectedResult - } - } + data( + "special escape characters", + listOf( + "'\\\\'" to "\"\\\\\"", + "'\\/'" to "\"\\/\"", + "'\"'" to "\"\\\"\"", + "'\\''" to "\"'\"", + "'\\b'" to "\"\\b\"", + "'\\f'" to "\"\\f\"", + "'\\n'" to "\"\\n\"", + "'\\r'" to "\"\\r\"", + "'\\t'" to "\"\\t\"", + ), + nameFn = { (input, _) -> input }, + ) test { (input, expected) -> + Rfc9535Utils.switchToDoubleQuotedString(input) shouldBe expected + // switching an already double-quoted string is idempotent + Rfc9535Utils.switchToDoubleQuotedString(expected) shouldBe expected } } } -}) \ No newline at end of file +} diff --git a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/core/NormalizedJsonPathTest.kt b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/core/NormalizedJsonPathTest.kt index 9c4a357..fd153ef 100644 --- a/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/core/NormalizedJsonPathTest.kt +++ b/jsonpath4k/src/commonTest/kotlin/at/asitplus/jsonpath/core/NormalizedJsonPathTest.kt @@ -1,15 +1,12 @@ package at.asitplus.jsonpath.core +import at.asitplus.testballoon.matrix.matrixSuite import io.kotest.matchers.maps.shouldHaveSize import io.kotest.matchers.shouldBe -import io.kotest.matchers.shouldNot import io.kotest.matchers.shouldNotBe -import kotlin.test.Test -@Suppress("unused") -class NormalizedJsonPathTest { - @Test - fun equality() { +val NormalizedJsonPathTest by matrixSuite { + "equality" { NormalizedJsonPath() shouldBe NormalizedJsonPath() NormalizedJsonPath("name") shouldBe NormalizedJsonPath() + "name" NormalizedJsonPath(1) shouldBe NormalizedJsonPath() + 1u @@ -19,8 +16,7 @@ class NormalizedJsonPathTest { NormalizedJsonPath() to "2", ) shouldHaveSize 1 } - @Test - fun inequality() { + "inequality" { NormalizedJsonPath(1) shouldNotBe NormalizedJsonPath() NormalizedJsonPath(1) shouldNotBe NormalizedJsonPath() + "1" NormalizedJsonPath(1) shouldNotBe NormalizedJsonPath() + 2 @@ -30,4 +26,4 @@ class NormalizedJsonPathTest { NormalizedJsonPath() shouldNotBe NormalizedJsonPath() + "1" NormalizedJsonPath() shouldNotBe NormalizedJsonPath() + 1 } -} \ No newline at end of file +} diff --git a/jsonpath4k/src/iosMain/kotlin/at/asitplus/jsonpath/JsonPath.ios.kt b/jsonpath4k/src/iosMain/kotlin/at/asitplus/jsonpath/JsonPath.ios.kt new file mode 100644 index 0000000..7f6eb54 --- /dev/null +++ b/jsonpath4k/src/iosMain/kotlin/at/asitplus/jsonpath/JsonPath.ios.kt @@ -0,0 +1,101 @@ +package at.asitplus.jsonpath + +import at.asitplus.jsonpath.core.JsonPathFilterExpressionType +import at.asitplus.jsonpath.core.JsonPathFunctionExtension +import at.asitplus.jsonpath.implementation.AntlrJsonPathCompilerErrorListener +import com.strumenta.antlrkotlin.runtime.BitSet +import io.github.aakira.napier.Napier +import org.antlr.v4.kotlinruntime.Parser +import org.antlr.v4.kotlinruntime.RecognitionException +import org.antlr.v4.kotlinruntime.Recognizer +import org.antlr.v4.kotlinruntime.atn.ATNConfigSet +import org.antlr.v4.kotlinruntime.dfa.DFA + +internal actual val defaultErrorListener = object : AntlrJsonPathCompilerErrorListener { + override fun unknownFunctionExtension(functionExtensionName: String) { + Napier.e { + "Unknown JSONPath function extension: \"$functionExtensionName\"" + } + } + + override fun invalidFunctionExtensionForTestExpression(functionExtensionName: String) { + Napier.e { + "Invalid JSONPath function extension return type for test expression: \"$functionExtensionName\"" + } + } + + override fun invalidFunctionExtensionForComparable(functionExtensionName: String) { + Napier.e { + "Invalid JSONPath function extension return type for comparable expression: \"$functionExtensionName\"" + } + } + + override fun invalidArglistForFunctionExtension( + functionExtensionName: String, + functionExtensionImplementation: JsonPathFunctionExtension, + coercedArgumentTypes: List> + ) { + Napier.e { + "Invalid arguments for function extension \"$functionExtensionName\": Expected: <${ + functionExtensionImplementation.argumentTypes.joinToString( + ", " + ) + }>, but received <${ + coercedArgumentTypes.map { it.first }.joinToString(", ") + }>: <${coercedArgumentTypes.map { it.second }.joinToString(", ")}>" + } + } + + override fun invalidTestExpression(testContextString: String) { + Napier.e { + "Invalid test expression: $testContextString" + } + } + + override fun reportAmbiguity( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + exact: Boolean, + ambigAlts: BitSet, + configs: ATNConfigSet + ) { + // noop + } + + override fun reportAttemptingFullContext( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + conflictingAlts: BitSet, + configs: ATNConfigSet + ) { + // noop + } + + override fun reportContextSensitivity( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + prediction: Int, + configs: ATNConfigSet + ) { + // noop + } + + override fun syntaxError( + recognizer: Recognizer<*, *>, + offendingSymbol: Any?, + line: Int, + charPositionInLine: Int, + msg: String, + e: RecognitionException? + ) { + Napier.e { + "Syntax error $line:$charPositionInLine $msg" + } + } +} \ No newline at end of file diff --git a/jsonpath4k/src/linuxMain/kotlin/at/asitplus/jsonpath/JsonPath.linux.kt b/jsonpath4k/src/linuxMain/kotlin/at/asitplus/jsonpath/JsonPath.linux.kt new file mode 100644 index 0000000..aa8bf94 --- /dev/null +++ b/jsonpath4k/src/linuxMain/kotlin/at/asitplus/jsonpath/JsonPath.linux.kt @@ -0,0 +1,3 @@ +package at.asitplus.jsonpath + +internal actual val defaultErrorListener = noopErrorListener \ No newline at end of file diff --git a/jsonpath4k/src/macosMain/kotlin/at/asitplus/jsonpath/JsonPath.macos.kt b/jsonpath4k/src/macosMain/kotlin/at/asitplus/jsonpath/JsonPath.macos.kt new file mode 100644 index 0000000..7f6eb54 --- /dev/null +++ b/jsonpath4k/src/macosMain/kotlin/at/asitplus/jsonpath/JsonPath.macos.kt @@ -0,0 +1,101 @@ +package at.asitplus.jsonpath + +import at.asitplus.jsonpath.core.JsonPathFilterExpressionType +import at.asitplus.jsonpath.core.JsonPathFunctionExtension +import at.asitplus.jsonpath.implementation.AntlrJsonPathCompilerErrorListener +import com.strumenta.antlrkotlin.runtime.BitSet +import io.github.aakira.napier.Napier +import org.antlr.v4.kotlinruntime.Parser +import org.antlr.v4.kotlinruntime.RecognitionException +import org.antlr.v4.kotlinruntime.Recognizer +import org.antlr.v4.kotlinruntime.atn.ATNConfigSet +import org.antlr.v4.kotlinruntime.dfa.DFA + +internal actual val defaultErrorListener = object : AntlrJsonPathCompilerErrorListener { + override fun unknownFunctionExtension(functionExtensionName: String) { + Napier.e { + "Unknown JSONPath function extension: \"$functionExtensionName\"" + } + } + + override fun invalidFunctionExtensionForTestExpression(functionExtensionName: String) { + Napier.e { + "Invalid JSONPath function extension return type for test expression: \"$functionExtensionName\"" + } + } + + override fun invalidFunctionExtensionForComparable(functionExtensionName: String) { + Napier.e { + "Invalid JSONPath function extension return type for comparable expression: \"$functionExtensionName\"" + } + } + + override fun invalidArglistForFunctionExtension( + functionExtensionName: String, + functionExtensionImplementation: JsonPathFunctionExtension, + coercedArgumentTypes: List> + ) { + Napier.e { + "Invalid arguments for function extension \"$functionExtensionName\": Expected: <${ + functionExtensionImplementation.argumentTypes.joinToString( + ", " + ) + }>, but received <${ + coercedArgumentTypes.map { it.first }.joinToString(", ") + }>: <${coercedArgumentTypes.map { it.second }.joinToString(", ")}>" + } + } + + override fun invalidTestExpression(testContextString: String) { + Napier.e { + "Invalid test expression: $testContextString" + } + } + + override fun reportAmbiguity( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + exact: Boolean, + ambigAlts: BitSet, + configs: ATNConfigSet + ) { + // noop + } + + override fun reportAttemptingFullContext( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + conflictingAlts: BitSet, + configs: ATNConfigSet + ) { + // noop + } + + override fun reportContextSensitivity( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + prediction: Int, + configs: ATNConfigSet + ) { + // noop + } + + override fun syntaxError( + recognizer: Recognizer<*, *>, + offendingSymbol: Any?, + line: Int, + charPositionInLine: Int, + msg: String, + e: RecognitionException? + ) { + Napier.e { + "Syntax error $line:$charPositionInLine $msg" + } + } +} \ No newline at end of file diff --git a/jsonpath4k/src/mingwMain/kotlin/at/asitplus/jsonpath/JsonPath.mingw.kt b/jsonpath4k/src/mingwMain/kotlin/at/asitplus/jsonpath/JsonPath.mingw.kt new file mode 100644 index 0000000..aa8bf94 --- /dev/null +++ b/jsonpath4k/src/mingwMain/kotlin/at/asitplus/jsonpath/JsonPath.mingw.kt @@ -0,0 +1,3 @@ +package at.asitplus.jsonpath + +internal actual val defaultErrorListener = noopErrorListener \ No newline at end of file diff --git a/jsonpath4k/src/nativeTest/kotlin/at/asitplus/jsonpath/AntlrConcurrencyNativeTest.kt b/jsonpath4k/src/nativeTest/kotlin/at/asitplus/jsonpath/AntlrConcurrencyNativeTest.kt new file mode 100644 index 0000000..d711b0b --- /dev/null +++ b/jsonpath4k/src/nativeTest/kotlin/at/asitplus/jsonpath/AntlrConcurrencyNativeTest.kt @@ -0,0 +1,116 @@ +package at.asitplus.jsonpath + +import at.asitplus.testballoon.matrix.matrixSuite +import kotlin.concurrent.AtomicInt +import kotlin.native.concurrent.TransferMode +import kotlin.native.concurrent.Worker + +/** + * Highly-concurrent stress tests that hammer the ANTLR-generated lexer/parser (via [JsonPath]) from many + * **real OS threads** at once, using Kotlin/Native [Worker]s (no coroutines — see + * [AntlrCoroutineConcurrencyNativeTest] for the coroutine-fan-out flavour). + * + * antlr-kotlin is **not** thread-safe: a generated `Lexer`/`Parser` shares process-global, mutable prediction + * state through the companion object — the decoded `ATN`, the `decisionToDFA` array, and the + * `PredictionContextCache`. `adaptivePredict` mutates those shared `DFA` structures (adding states/edges) with + * no locking, so compiling JSONPath expressions concurrently races on that shared state. On Kotlin/Native + * (strict, no forgiving JIT) this surfaces as thrown exceptions (index-out-of-bounds, NPE, illegal state) or, + * worse, silently wrong parses — and can even hard-crash the worker. + * + * These tests are therefore **expected to FAIL** against an unpatched antlr-kotlin. They exist to demonstrate + * the defect and to measure how far a thread-safety patch gets us. + */ + +// Sized per target (see threadStress). The race triggers by *oversubscription* — many more threads than cores +// forces heavy interleaving — so it reproduces regardless of a runner's core count; the ~8 MB stack per Worker +// is the memory ceiling that caps the fan-out on constrained runners. +private val WORKERS = threadStress.fanOut +private val ITERATIONS = threadStress.iterations +private val ROUNDS = threadStress.rounds + +/** Everything a worker thread needs, handed over via the [Worker.execute] producer (no captured state). */ +private class WorkerInput( + val worker: Int, + val gate: AtomicInt, + val ready: AtomicInt, + val block: (worker: Int, iteration: Int) -> Unit, +) + +/** + * Spawns [WORKERS] real threads, each parked on a spin-gate, then releases them all at once so they collide on + * the *cold* shared ANTLR state simultaneously (the tightest race window). Each thread runs [block] + * [ITERATIONS] times and returns the throwables it caught; those are aggregated after every thread joins. + */ +private fun stress(label: String, block: (worker: Int, iteration: Int) -> Unit) { + val gate = AtomicInt(0) // 0 = closed, 1 = released + val ready = AtomicInt(0) // number of threads parked at the gate + val workers = List(WORKERS) { Worker.start(name = "antlr-$label-$it") } + + val futures = workers.mapIndexed { index, worker -> + worker.execute(TransferMode.SAFE, { WorkerInput(index, gate, ready, block) }) { input -> + input.ready.incrementAndGet() + while (input.gate.value == 0) { /* spin until every thread is parked and the gate opens */ } + val local = ArrayList() + repeat(ITERATIONS) { iteration -> + try { + input.block(input.worker, iteration) + } catch (t: Throwable) { + local += t + } + } + local + } + } + + while (ready.value < WORKERS) { /* wait until all threads are parked */ } + gate.value = 1 // release all WORKERS at the same instant + + val failures = futures.flatMap { it.result } // joins each worker + workers.forEach { it.requestTermination().result } + + if (failures.isNotEmpty()) throw concurrencyFailure(label, WORKERS * ITERATIONS, failures) +} + +val AntlrConcurrencyNativeTest by matrixSuite { + + // 1) COLD DFA, SAME EXPRESSION: every thread compiles the identical complex expression with no warmup, so + // they all race to build the very same shared DFA decision states at once — the tightest contention. + test("concurrent compilation of the same expression on a cold parser", testConfig = realConcurrency) { + repeat(ROUNDS) { + val expr = "$.store.book[?@.price < 10 && @.category == 'fiction'].title" + stress("same-expr") { _, _ -> + JsonPath(expr) + } + } + } + + // 2) MANY DISTINCT EXPRESSIONS: threads pick different expressions, maximizing churn across many decisions + // and the shared PredictionContextCache simultaneously. + test("concurrent compilation of many distinct expressions", testConfig = realConcurrency) { + repeat(ROUNDS) { + stress("distinct-expr") { worker, iteration -> + val expr = compileOnlyExpressions[(worker + iteration) % compileOnlyExpressions.size] + JsonPath(expr) + } + } + } + + // 3) CORRECTNESS UNDER CONCURRENCY: compile + query in parallel and compare against a single-threaded + // baseline. Catches silent corruption (no exception, but a wrong/incomplete parse -> wrong node list). + test("concurrent compilation yields results consistent with a single-threaded baseline", testConfig = realConcurrency) { + // Baseline computed before any thread touches the shared ANTLR state. + val baseline: Map = bookstoreExpressions.associateWith { expr -> + JsonPath(expr).query(bookStore).map { it.value }.toString() + } + repeat(ROUNDS) { + stress("correctness") { worker, iteration -> + val expr = bookstoreExpressions[(worker + iteration) % bookstoreExpressions.size] + val actual = JsonPath(expr).query(bookStore).map { it.value }.toString() + val expected = baseline.getValue(expr) + if (actual != expected) { + throw WrongResult("expr <$expr>: expected $expected but got $actual") + } + } + } + } +} diff --git a/jsonpath4k/src/nativeTest/kotlin/at/asitplus/jsonpath/AntlrCoroutineConcurrencyNativeTest.kt b/jsonpath4k/src/nativeTest/kotlin/at/asitplus/jsonpath/AntlrCoroutineConcurrencyNativeTest.kt new file mode 100644 index 0000000..4df02bd --- /dev/null +++ b/jsonpath4k/src/nativeTest/kotlin/at/asitplus/jsonpath/AntlrCoroutineConcurrencyNativeTest.kt @@ -0,0 +1,118 @@ +package at.asitplus.jsonpath + +import at.asitplus.testballoon.matrix.matrixSuite +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout + +/** + * Coroutine-fan-out sibling of [AntlrConcurrencyNativeTest]. + * + * Instead of a bounded, dedicated thread pool, this fires **many coroutines at once** (see + * [coroutineStressProfile]) with `launch` onto [Dispatchers.Default] (multi-threaded on Kotlin/Native), gated so + * they start together. The enclosing `coroutineScope` suspends + * until all of them finish (structured concurrency joins the children for us — no manual gate/`joinAll`), so + * they slam the ANTLR-generated parser's process-global, mutable prediction state (decoded `ATN`, + * `decisionToDFA`, `PredictionContextCache`) concurrently. + * + * antlr-kotlin does no locking around that state, so concurrent `JsonPath(...)` compilation races on it. Like + * the thread-pool suite, these tests are **expected to FAIL** against an unpatched antlr-kotlin (thrown + * exceptions and/or a wrong parse vs. a single-threaded baseline) and serve to measure a thread-safety patch. + * + * `TestScope` (virtual time) is disabled per test: real concurrency cannot run under virtual time. + */ + +// Sized per target (see coroutineStressProfile). Coroutines are cheap, but true parallelism is capped at +// Dispatchers.Default's size (~CPU cores), so this is a strong reproducer only on many-core hosts and a light +// smoke test on constrained simulators — the thread-based suite is the reliable gate. +private val COROUTINES = coroutineStressProfile.fanOut +private val ITERATIONS = coroutineStressProfile.iterations +private val ROUNDS = coroutineStressProfile.rounds + +/** + * Launches [COROUTINES] coroutines on [Dispatchers.Default], each running [block] [ITERATIONS] times, and lets + * the surrounding `coroutineScope` join them all. Failures are funneled through an unlimited [Channel] + * (thread-safe `trySend`), then drained after the scope completes — no shared mutable collection. + */ +private suspend fun coroutineStress( + label: String, + block: (worker: Int, iteration: Int) -> Unit, +) { + val failures = Channel(Channel.UNLIMITED) + coroutineScope { + // Start barrier: every coroutine parks here (cheap, no parser work) until all are launched, then all + // are released at once so they collide on the *cold* shared ANTLR state simultaneously. Without it the + // first coroutines fully build the DFA before the rest start, closing the race window (test goes green). + val gate = CompletableDeferred() + repeat(COROUTINES) { worker -> + launch(Dispatchers.Default) { + gate.await() + repeat(ITERATIONS) { iteration -> + try { + block(worker, iteration) + } catch (t: Throwable) { + failures.trySend(t) + } + } + } + } + gate.complete(Unit) // release all COROUTINES at once + } // coroutineScope suspends here until all coroutines complete + failures.close() + + val collected = ArrayList() + while (true) { + collected += failures.tryReceive().getOrNull() ?: break + } + if (collected.isNotEmpty()) throw concurrencyFailure(label, COROUTINES * ITERATIONS, collected) +} + +val AntlrCoroutineConcurrencyNativeTest by matrixSuite { + + // 1) COLD DFA, SAME EXPRESSION: many coroutines compile the identical complex expression with no warmup. + test("$COROUTINES coroutines compiling the same expression on a cold parser", testConfig = realConcurrency) { + withTimeout(coroutineStressProfile.timeout) { + repeat(ROUNDS) { + val expr = "$.store.book[?@.price < 10 && @.category == 'fiction'].title" + coroutineStress("same-expr") { _, _ -> + JsonPath(expr) + } + } + } + } + + // 2) MANY DISTINCT EXPRESSIONS: coroutines spread across many expressions -> churn across many decisions. + test("$COROUTINES coroutines compiling many distinct expressions", testConfig = realConcurrency) { + withTimeout(coroutineStressProfile.timeout) { + repeat(ROUNDS) { + coroutineStress("distinct-expr") { worker, iteration -> + val expr = compileOnlyExpressions[(worker + iteration) % compileOnlyExpressions.size] + JsonPath(expr) + } + } + } + } + + // 3) CORRECTNESS UNDER CONCURRENCY: compile + query in parallel, compare against a single-threaded baseline + // to catch silent corruption (no exception, but a wrong/incomplete parse -> wrong node list). + test("$COROUTINES coroutines produce results consistent with a single-threaded baseline", testConfig = realConcurrency) { + val baseline: Map = bookstoreExpressions.associateWith { expr -> + JsonPath(expr).query(bookStore).map { it.value }.toString() + } + withTimeout(coroutineStressProfile.timeout) { + repeat(ROUNDS) { + coroutineStress("correctness") { worker, iteration -> + val expr = bookstoreExpressions[(worker + iteration) % bookstoreExpressions.size] + val actual = JsonPath(expr).query(bookStore).map { it.value }.toString() + val expected = baseline.getValue(expr) + if (actual != expected) { + throw WrongResult("expr <$expr>: expected $expected but got $actual") + } + } + } + } + } +} diff --git a/jsonpath4k/src/nativeTest/kotlin/at/asitplus/jsonpath/ConcurrencyStressFixtures.kt b/jsonpath4k/src/nativeTest/kotlin/at/asitplus/jsonpath/ConcurrencyStressFixtures.kt new file mode 100644 index 0000000..e6dbea3 --- /dev/null +++ b/jsonpath4k/src/nativeTest/kotlin/at/asitplus/jsonpath/ConcurrencyStressFixtures.kt @@ -0,0 +1,122 @@ +@file:OptIn(ExperimentalNativeApi::class) + +package at.asitplus.jsonpath + +import de.infix.testBalloon.framework.core.TestConfig +import de.infix.testBalloon.framework.core.testScope +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.OsFamily +import kotlin.native.Platform +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +/** + * Shared fixtures for the ANTLR concurrency stress suites ([AntlrConcurrencyNativeTest] uses real OS threads; + * [AntlrCoroutineConcurrencyNativeTest] launches many coroutines). Extracted so the two suites don't duplicate + * the data — and because private top-level *classes* (unlike vals/funs) share the package namespace, so + * declaring `WrongResult` in both files would collide. + */ + +/** Real (non-virtual-time) execution: real concurrency cannot run under TestBalloon's virtual-time `TestScope`. */ +internal val realConcurrency: TestConfig = TestConfig.testScope(isEnabled = false) + +/** + * How hard to hammer, per fan-out unit (OS thread or coroutine). [timeout] is a per-test safety net against a + * genuine deadlock — it must comfortably exceed the *expected* runtime (fanOut × iterations × rounds compiles), + * not just a hang, so it scales with the workload. + */ +internal data class StressProfile(val fanOut: Int, val iterations: Int, val rounds: Int, val timeout: Duration) + +/** + * Sizes the fan-out to the resources of a typical runner for the **current target** (via [Platform.osFamily]) — + * one shared `when` instead of an expect/actual per source set (this module has ~15 native targets, and the + * androidNative ones are conditional on the NDK). The whole suite still runs on every native target; it just + * scales down where resources are tight. + * + * Rationale: + * - LINUX: the dedicated full-throttle stress pipeline (`ubuntu-latest`: more cores + RAM) → go big. + * - MACOSX: run natively on dev Macs / the macOS CI host → heavy (this is what to run locally for a hard soak). + * - IOS/TVOS/WATCHOS/ANDROID: simulator/emulator on a constrained host (the macOS CI runner is ~3 cores / 7 GB, + * and every Kotlin/Native `Worker` costs an ~8 MB stack) → scale right down so the sim job stays fast/safe. + */ +internal val threadStress: StressProfile = when (Platform.osFamily) { + OsFamily.LINUX -> StressProfile(fanOut = 256, iterations = 200, rounds = 4, timeout = 10.minutes) + OsFamily.MACOSX -> StressProfile(fanOut = 128, iterations = 200, rounds = 4, timeout = 10.minutes) + OsFamily.WINDOWS -> StressProfile(fanOut = 64, iterations = 150, rounds = 3, timeout = 5.minutes) + else -> StressProfile(fanOut = 12, iterations = 60, rounds = 2, timeout = 2.minutes) +} + +/** + * Coroutines are cheap, but true parallelism is capped at `Dispatchers.Default` (~CPU cores), so this suite is a + * strong reproducer only on many-core hosts (LINUX/MACOSX) and a light smoke test on constrained simulators. + */ +internal val coroutineStressProfile: StressProfile = when (Platform.osFamily) { + OsFamily.LINUX -> StressProfile(fanOut = 20_000, iterations = 10, rounds = 2, timeout = 10.minutes) + OsFamily.MACOSX -> StressProfile(fanOut = 10_000, iterations = 10, rounds = 2, timeout = 10.minutes) + OsFamily.WINDOWS -> StressProfile(fanOut = 5_000, iterations = 10, rounds = 2, timeout = 5.minutes) + else -> StressProfile(fanOut = 1_000, iterations = 8, rounds = 2, timeout = 2.minutes) +} + +// Non-trivial, valid expressions. Filters, function extensions, logical operators, slices and descendant +// segments all force the parser through lots of ATN prediction / DFA construction — the racy hot path. +internal val bookstoreExpressions = listOf( + "$.store.book[*].author", + "$..author", + "$.store.*", + "$.store..price", + "$..book[2]", + "$..book[-1]", + "$..book[0,1]", + "$..book[:2]", + "$..book[?@.isbn]", + "$..book[?@.price<10]", + "$..*", + "$.store.book[?@.price < 10 && @.category == 'fiction'].title", + "$.store.book[?match(@.category, 'fic.*')].author", + "$.store.book[?search(@.title, 'the')].title", +) + +// Expressions that only need to *compile* (type-checkable standalone) — used for the pure-compile race. +internal val compileOnlyExpressions = bookstoreExpressions + listOf( + "$[?length(@) < 3]", + "$[?count(@.*) == 1]", + "$[?value(@..color) == 'red']", + "$[?match(@.timezone, 'Europe/.*')]", + "$[?@.a || @.b && @.c]", + "$[1:5:2]", + "$.o[?@<3, ?@<3]", +) + +internal val bookStore: JsonElement = Json.decodeFromString( + """ + { "store": { + "book": [ + { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }, + { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }, + { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }, + { "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 } + ], + "bicycle": { "color": "red", "price": 399 } + } + } + """.trimIndent() +) + +/** Thrown when a concurrent compile+query produced a different result than the single-threaded baseline. */ +internal class WrongResult(message: String) : Exception(message) + +/** Formats collected concurrent failures into an [AssertionError] message, grouped by kind. */ +internal fun concurrencyFailure(label: String, total: Int, failures: List): AssertionError { + val summary = failures + .groupingBy { "${it::class.simpleName}: ${it.message}" } + .eachCount() + .entries + .sortedByDescending { it.value } + .joinToString("\n") { (kind, count) -> " x$count $kind" } + return AssertionError( + "[$label] ${failures.size} of $total concurrent ANTLR operations failed " + + "(antlr-kotlin is not thread-safe). Distinct failures:\n$summary" + ) +} diff --git a/jsonpath4k/src/tvosMain/kotlin/at/asitplus/jsonpath/JsonPath.tvos.kt b/jsonpath4k/src/tvosMain/kotlin/at/asitplus/jsonpath/JsonPath.tvos.kt new file mode 100644 index 0000000..7f6eb54 --- /dev/null +++ b/jsonpath4k/src/tvosMain/kotlin/at/asitplus/jsonpath/JsonPath.tvos.kt @@ -0,0 +1,101 @@ +package at.asitplus.jsonpath + +import at.asitplus.jsonpath.core.JsonPathFilterExpressionType +import at.asitplus.jsonpath.core.JsonPathFunctionExtension +import at.asitplus.jsonpath.implementation.AntlrJsonPathCompilerErrorListener +import com.strumenta.antlrkotlin.runtime.BitSet +import io.github.aakira.napier.Napier +import org.antlr.v4.kotlinruntime.Parser +import org.antlr.v4.kotlinruntime.RecognitionException +import org.antlr.v4.kotlinruntime.Recognizer +import org.antlr.v4.kotlinruntime.atn.ATNConfigSet +import org.antlr.v4.kotlinruntime.dfa.DFA + +internal actual val defaultErrorListener = object : AntlrJsonPathCompilerErrorListener { + override fun unknownFunctionExtension(functionExtensionName: String) { + Napier.e { + "Unknown JSONPath function extension: \"$functionExtensionName\"" + } + } + + override fun invalidFunctionExtensionForTestExpression(functionExtensionName: String) { + Napier.e { + "Invalid JSONPath function extension return type for test expression: \"$functionExtensionName\"" + } + } + + override fun invalidFunctionExtensionForComparable(functionExtensionName: String) { + Napier.e { + "Invalid JSONPath function extension return type for comparable expression: \"$functionExtensionName\"" + } + } + + override fun invalidArglistForFunctionExtension( + functionExtensionName: String, + functionExtensionImplementation: JsonPathFunctionExtension, + coercedArgumentTypes: List> + ) { + Napier.e { + "Invalid arguments for function extension \"$functionExtensionName\": Expected: <${ + functionExtensionImplementation.argumentTypes.joinToString( + ", " + ) + }>, but received <${ + coercedArgumentTypes.map { it.first }.joinToString(", ") + }>: <${coercedArgumentTypes.map { it.second }.joinToString(", ")}>" + } + } + + override fun invalidTestExpression(testContextString: String) { + Napier.e { + "Invalid test expression: $testContextString" + } + } + + override fun reportAmbiguity( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + exact: Boolean, + ambigAlts: BitSet, + configs: ATNConfigSet + ) { + // noop + } + + override fun reportAttemptingFullContext( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + conflictingAlts: BitSet, + configs: ATNConfigSet + ) { + // noop + } + + override fun reportContextSensitivity( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + prediction: Int, + configs: ATNConfigSet + ) { + // noop + } + + override fun syntaxError( + recognizer: Recognizer<*, *>, + offendingSymbol: Any?, + line: Int, + charPositionInLine: Int, + msg: String, + e: RecognitionException? + ) { + Napier.e { + "Syntax error $line:$charPositionInLine $msg" + } + } +} \ No newline at end of file diff --git a/jsonpath4k/src/watchosMain/kotlin/at/asitplus/jsonpath/JsonPath.watchos.kt b/jsonpath4k/src/watchosMain/kotlin/at/asitplus/jsonpath/JsonPath.watchos.kt new file mode 100644 index 0000000..aa8bf94 --- /dev/null +++ b/jsonpath4k/src/watchosMain/kotlin/at/asitplus/jsonpath/JsonPath.watchos.kt @@ -0,0 +1,3 @@ +package at.asitplus.jsonpath + +internal actual val defaultErrorListener = noopErrorListener \ No newline at end of file diff --git a/jsonpath4k/src/webMain/kotlin/at/asitplus/jsonpath/JsonPath.web.kt b/jsonpath4k/src/webMain/kotlin/at/asitplus/jsonpath/JsonPath.web.kt new file mode 100644 index 0000000..bd52b3e --- /dev/null +++ b/jsonpath4k/src/webMain/kotlin/at/asitplus/jsonpath/JsonPath.web.kt @@ -0,0 +1,103 @@ +package at.asitplus.jsonpath + +import at.asitplus.jsonpath.core.JsonPathCompiler +import at.asitplus.jsonpath.core.JsonPathFilterExpressionType +import at.asitplus.jsonpath.core.JsonPathFunctionExtension +import at.asitplus.jsonpath.implementation.AntlrJsonPathCompiler +import at.asitplus.jsonpath.implementation.AntlrJsonPathCompilerErrorListener +import com.strumenta.antlrkotlin.runtime.BitSet +import io.github.aakira.napier.Napier +import org.antlr.v4.kotlinruntime.Parser +import org.antlr.v4.kotlinruntime.RecognitionException +import org.antlr.v4.kotlinruntime.Recognizer +import org.antlr.v4.kotlinruntime.atn.ATNConfigSet +import org.antlr.v4.kotlinruntime.dfa.DFA + +internal actual val defaultErrorListener= object : AntlrJsonPathCompilerErrorListener { + override fun unknownFunctionExtension(functionExtensionName: String) { + Napier.e { + "Unknown JSONPath function extension: \"$functionExtensionName\"" + } + } + + override fun invalidFunctionExtensionForTestExpression(functionExtensionName: String) { + Napier.e { + "Invalid JSONPath function extension return type for test expression: \"$functionExtensionName\"" + } + } + + override fun invalidFunctionExtensionForComparable(functionExtensionName: String) { + Napier.e { + "Invalid JSONPath function extension return type for comparable expression: \"$functionExtensionName\"" + } + } + + override fun invalidArglistForFunctionExtension( + functionExtensionName: String, + functionExtensionImplementation: JsonPathFunctionExtension, + coercedArgumentTypes: List> + ) { + Napier.e { + "Invalid arguments for function extension \"$functionExtensionName\": Expected: <${ + functionExtensionImplementation.argumentTypes.joinToString( + ", " + ) + }>, but received <${ + coercedArgumentTypes.map { it.first }.joinToString(", ") + }>: <${coercedArgumentTypes.map { it.second }.joinToString(", ")}>" + } + } + + override fun invalidTestExpression(testContextString: String) { + Napier.e { + "Invalid test expression: $testContextString" + } + } + + override fun reportAmbiguity( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + exact: Boolean, + ambigAlts: BitSet, + configs: ATNConfigSet + ) { + // noop + } + + override fun reportAttemptingFullContext( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + conflictingAlts: BitSet, + configs: ATNConfigSet + ) { + // noop + } + + override fun reportContextSensitivity( + recognizer: Parser, + dfa: DFA, + startIndex: Int, + stopIndex: Int, + prediction: Int, + configs: ATNConfigSet + ) { + // noop + } + + override fun syntaxError( + recognizer: Recognizer<*, *>, + offendingSymbol: Any?, + line: Int, + charPositionInLine: Int, + msg: String, + e: RecognitionException? + ) { + Napier.e { + "Syntax error $line:$charPositionInLine $msg" + } + } + } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 584f24e..b5c3900 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,11 +2,13 @@ rootProject.name = "JsonPath4K" pluginManagement { repositories { - maven("https://s01.oss.sonatype.org/content/repositories/snapshots") //KOTEST snapshot - maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + maven { + url = uri("https://raw.githubusercontent.com/a-sit-plus/gradle-conventions-plugin/mvn/repo") + name = "aspConventions" + } google() - gradlePluginPortal() mavenCentral() + gradlePluginPortal() } } @@ -17,4 +19,4 @@ dependencyResolutionManagement { } } -include(":jsonpath4k") \ No newline at end of file +include(":jsonpath4k")