From 0a048c0e2d5d20baa6addf3a7dfd1e27723958b7 Mon Sep 17 00:00:00 2001 From: Serhii P <22973227+serpro69@users.noreply.github.com> Date: Wed, 17 Apr 2024 08:09:58 +0200 Subject: [PATCH] Add extension module for kotest property testing (#234) --- .github/workflows/publish_snapshot.yml | 4 + CHANGELOG.adoc | 1 + README.md | 4 + build.gradle.kts | 19 +- buildSrc/build.gradle.kts | 19 +- buildSrc/repositories.settings.gradle.kts | 18 ++ buildSrc/settings.gradle.kts | 13 + .../kotlin/faker-ext-conventions.gradle.kts | 190 ++++++++++++++ .../kotlin/faker-lib-conventions.gradle.kts | 11 +- .../faker-provider-conventions.gradle.kts | 13 +- core/build.gradle.kts | 7 +- docs/build.gradle.kts | 3 +- .../extensions/kotest-property-extension.md | 224 +++++++++++++++++ docs/src/orchid/resources/wiki/extensions.md | 31 +++ docs/src/orchid/resources/wiki/summary.md | 1 + extension/README.md | 8 + extension/build.gradle.kts | 31 +++ extension/kotest-property-ksp/README.md | 10 + .../api/kotest-property-ksp.api | 21 ++ .../kotest-property-ksp/build.gradle.kts | 13 + .../github/serpro69/kfaker/kotest/ArbExt.kt | 27 ++ .../kfaker/kotest/FileCompileScope.kt | 34 +++ .../kfaker/kotest/KotestArbProcessor.kt | 46 ++++ .../kfaker/kotest/KotestArbProvider.kt | 15 ++ .../kotest/extensions/GeneratedMarker.kt | 19 ++ .../kfaker/kotest/extensions/HasAnnotation.kt | 12 + .../kfaker/kotest/extensions/KspExtensions.kt | 15 ++ .../kotest/extensions/TypeCompileScope.kt | 231 ++++++++++++++++++ .../lang/SequenceScopedOperators.kt | 13 + .../kotest/poet/KotlinPoetExtensions.kt | 125 ++++++++++ .../serpro69/kfaker/kotest/poet/TypeName.kt | 8 + .../serpro69/kfaker/kotest/poet/TypeSpec.kt | 12 + ...ols.ksp.processing.SymbolProcessorProvider | 1 + extension/kotest-property-test/README.md | 7 + .../kotest-property-test/build.gradle.kts | 102 ++++++++ .../kfaker/kotest/KotestArbProviderIT.kt | 47 ++++ .../kfaker/tests/KotestPropertyArbsTest.kt | 136 +++++++++++ extension/kotest-property/README.md | 10 + .../kotest-property/api/kotest-property.api | 12 + extension/kotest-property/build.gradle.kts | 19 ++ .../github/serpro69/kfaker/ArbExtensions.kt | 14 ++ .../github/serpro69/kfaker/kotest/FakerArb.kt | 8 + .../serpro69/kfaker/ArbExtensionsTest.kt | 54 ++++ faker/README.md | 41 ++++ faker/api/faker.api | 0 faker/build.gradle.kts | 32 +++ gradle/libs.versions.toml | 60 +++++ settings.gradle.kts | 75 +++--- test/build.gradle.kts | 26 +- 49 files changed, 1752 insertions(+), 90 deletions(-) create mode 100644 buildSrc/repositories.settings.gradle.kts create mode 100644 buildSrc/src/main/kotlin/faker-ext-conventions.gradle.kts create mode 100644 docs/src/orchid/resources/pages/extensions/kotest-property-extension.md create mode 100644 docs/src/orchid/resources/wiki/extensions.md create mode 100644 extension/README.md create mode 100644 extension/build.gradle.kts create mode 100644 extension/kotest-property-ksp/README.md create mode 100644 extension/kotest-property-ksp/api/kotest-property-ksp.api create mode 100644 extension/kotest-property-ksp/build.gradle.kts create mode 100644 extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/ArbExt.kt create mode 100644 extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/FileCompileScope.kt create mode 100644 extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/KotestArbProcessor.kt create mode 100644 extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/KotestArbProvider.kt create mode 100644 extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/GeneratedMarker.kt create mode 100644 extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/HasAnnotation.kt create mode 100644 extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/KspExtensions.kt create mode 100644 extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/TypeCompileScope.kt create mode 100644 extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/lang/SequenceScopedOperators.kt create mode 100644 extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/poet/KotlinPoetExtensions.kt create mode 100644 extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/poet/TypeName.kt create mode 100644 extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/poet/TypeSpec.kt create mode 100644 extension/kotest-property-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider create mode 100644 extension/kotest-property-test/README.md create mode 100644 extension/kotest-property-test/build.gradle.kts create mode 100644 extension/kotest-property-test/src/integration/kotlin/io/github/serpro69/kfaker/kotest/KotestArbProviderIT.kt create mode 100644 extension/kotest-property-test/src/test/kotlin/io/github/serpro69/kfaker/tests/KotestPropertyArbsTest.kt create mode 100644 extension/kotest-property/README.md create mode 100644 extension/kotest-property/api/kotest-property.api create mode 100644 extension/kotest-property/build.gradle.kts create mode 100644 extension/kotest-property/src/main/kotlin/io/github/serpro69/kfaker/ArbExtensions.kt create mode 100644 extension/kotest-property/src/main/kotlin/io/github/serpro69/kfaker/kotest/FakerArb.kt create mode 100644 extension/kotest-property/src/test/kotlin/io/github/serpro69/kfaker/ArbExtensionsTest.kt create mode 100644 faker/README.md delete mode 100644 faker/api/faker.api create mode 100644 faker/build.gradle.kts create mode 100644 gradle/libs.versions.toml diff --git a/.github/workflows/publish_snapshot.yml b/.github/workflows/publish_snapshot.yml index a59e302e6..0d6695b88 100644 --- a/.github/workflows/publish_snapshot.yml +++ b/.github/workflows/publish_snapshot.yml @@ -24,8 +24,12 @@ jobs: filters: | core: - '.github/workflows/**' + - 'bom/**' + - 'buildSrc/**' - 'core/**' + - 'extension/**' - 'faker/**' + - 'gradle/**' - '*gradle*' - 'set-version.sh' diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index b36f704fb..4470f87ba 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -9,6 +9,7 @@ [discrete] === Added +* https://github.com/serpro69/kotlin-faker/pull/234[#234] (:extension) Add extension module for kotest property testing * https://github.com/serpro69/kotlin-faker/pull/232[#232] (:core) Add support for alternative primary key when resolving values * https://github.com/serpro69/kotlin-faker/pull/227[#227] Add BOM to manage faker versions * https://github.com/serpro69/kotlin-faker/issues/222[#222] (:faker:databases) Create new Databases faker module diff --git a/README.md b/README.md index c3f83348e..edd869afa 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,10 @@ See [bom/README.md](bom/README.md) for more details. Extra fakers covering a wide range of domains are available as separate dependencies. See [faker](faker) submodules in this repo for more details about each faker. +### Third-Party Extensions + +Faker provides extensions for some third-party testing libraries. See [extension](extension) submodules in this repo for more details about each extension. + ## Usage Full usage documentation for kotlin-faker is available at [serpro69.github.io/kotlin-faker/](https://serpro69.github.io/kotlin-faker/). diff --git a/build.gradle.kts b/build.gradle.kts index 9e6bb97ff..07e444bac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,19 +14,13 @@ plugins { id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.15.0-Beta.1" } -repositories { - mavenCentral() -} - group = "io.github.serpro69" +val lib = project.libs + subprojects { group = rootProject.group.toString() - repositories { - mavenCentral() - } - apply { plugin("com.github.ben-manes.versions") } @@ -44,12 +38,9 @@ subprojects { val testImplementation by configurations val testRuntimeOnly by configurations // common-for-all dependencies go here - platform(kotlin("bom")) - implementation(kotlin("stdlib-jdk8")) - implementation(kotlin("reflect")) - testImplementation("io.kotest:kotest-runner-junit5:5.7.2") - testImplementation("io.kotest:kotest-assertions-core:5.7.2") - testImplementation("io.kotest:kotest-property-jvm:5.7.2") + implementation(platform(lib.kotlin.bom)) + implementation(lib.bundles.kotlin) + testImplementation(lib.bundles.test.kotest) } configure { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 20512cf95..0270ad0d2 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,22 +1,17 @@ plugins { `kotlin-dsl` -} - -repositories { - mavenCentral() - gradlePluginPortal() + kotlin("jvm") version embeddedKotlinVersion } dependencies { + //https://github.com/gradle/gradle/issues/15383#issuecomment-779893192 + implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) + implementation(platform(libs.kotlin.bom)) // needed to be able to apply external plugin // https://docs.gradle.org/current/userguide/custom_plugins.html#applying_external_plugins_in_precompiled_script_plugins - implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20") - implementation("org.jetbrains.dokka:dokka-gradle-plugin:1.9.10") - implementation("com.github.johnrengelman:shadow:8.1.1") + implementation(libs.bundles.gradle.plugins) // used by yaml-to-json buildSrc plugin - implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3") + implementation(libs.jackson.databind) // use snakeyaml instead of jackson-dataformat-yaml to properly handle yaml anchors and write them as actual values to json - implementation("org.yaml:snakeyaml:2.2") - // NB! remember to set same version in settings.gradle.kts:13 - implementation("io.github.serpro69:semantic-versioning:0.13.0") + implementation(libs.snakeyaml) } diff --git a/buildSrc/repositories.settings.gradle.kts b/buildSrc/repositories.settings.gradle.kts new file mode 100644 index 000000000..1a39ae37d --- /dev/null +++ b/buildSrc/repositories.settings.gradle.kts @@ -0,0 +1,18 @@ +// shared repository definitions for both the main project and buildSrc + +@Suppress("UnstableApiUsage") // Central declaration of repositories is an incubating feature +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + + repositories { + mavenCentral() + gradlePluginPortal() + } + + pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } + } +} diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts index e69de29bb..3ad754021 100644 --- a/buildSrc/settings.gradle.kts +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,13 @@ +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +rootProject.name = "buildSrc" + +apply(from = "./repositories.settings.gradle.kts") + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/buildSrc/src/main/kotlin/faker-ext-conventions.gradle.kts b/buildSrc/src/main/kotlin/faker-ext-conventions.gradle.kts new file mode 100644 index 000000000..650a9a898 --- /dev/null +++ b/buildSrc/src/main/kotlin/faker-ext-conventions.gradle.kts @@ -0,0 +1,190 @@ +import io.github.serpro69.semverkt.gradle.plugin.tasks.TagTask +import org.gradle.accessors.dm.LibrariesForLibs +import org.jetbrains.dokka.gradle.DokkaTask +import java.util.Locale + +plugins { + base + kotlin("jvm") + id("org.jetbrains.dokka") + `maven-publish` + signing +} + +val libs = the() + +/** + * For additional providers, use a combination of rootProject and subproject names for artifact name and similar things. + * i.e. kotlin-faker-books, kotlin-faker-movies, kotlin-faker-tv, ... + * + * The "core" lib retains the same name as before: kotlin-faker + */ +private val fullName: String = "${rootProject.name}-${project.name}" + +val isDev = provider { version.toString().startsWith("0.0.0") } +val isSnapshot = provider { + // QUESTION do we need to check if rootProject is also set to snapshot? + // Likely not, since "isRelease" will not just check for the version, but also for the actual tag creation + // rootProject.version.toString().endsWith("SNAPSHOT") && + version.toString().endsWith("SNAPSHOT") +} +val isRelease = provider { + val tag = project.tasks.getByName("tag", TagTask::class) + /* all fakers have their own tags, so checking if tag.didWork is enough for them, + ':core' shares the tag with 'root', ':bom' and ':cli-bot' modules, + and hence the tag might already exist and didWork will return false for ':core' */ + val tagCreated = if (project.name != "core") tag.didWork else tag.didWork || tag.tagExists + !isDev.get() && !isSnapshot.get() && tagCreated +} + +configurations { + create("integrationImplementation") { extendsFrom(configurations.getByName("testImplementation")) } + create("integrationRuntimeOnly") { + extendsFrom( + configurations.getByName("testRuntimeOnly"), + ) + } +} + +// configure sourceSets as extension since it's not available here as `sourceSets` is an extension on `Project` +// https://docs.gradle.org/current/userguide/kotlin_dsl.html#project_extensions_and_conventions +configure { + create("integration") { + resources.srcDir("src/integration/resources") + compileClasspath += main.get().compileClasspath + test.get().compileClasspath + runtimeClasspath += main.get().runtimeClasspath + test.get().runtimeClasspath + } + main { + resources { + this.srcDir("build/generated/src/main/resources") + } + } +} + +dependencies { + val implementation by configurations + implementation(libs.bundles.kotlin) +} + +val integrationTest by tasks.creating(Test::class) { + testClassesDirs = sourceSets["integration"].output.classesDirs + classpath = sourceSets["integration"].runtimeClasspath + dependsOn(tasks.test) +} + +tasks.withType { + archiveBaseName.set(fullName) + + manifest { + attributes( + mapOf( + "Implementation-Title" to fullName, + "Implementation-Version" to project.version, + /* + * We can't add this here because this resolves the configuration, + * after which it effectively becomes read-only and we'll get an error + * Cannot change dependencies of dependency configuration ':core:implementation' after it has been included in dependency resolution + * if we try to add more dependencies in the module's build.gradle file directly + */ + // "Class-Path" to project.configurations.compileClasspath.get().joinToString(" ") { it.name } + ) + ) + } + + dependsOn(integrationTest) +} + +val sourcesJar by tasks.creating(Jar::class) { + archiveClassifier.set("sources") + from(sourceSets.getByName("main").allSource) + from("${rootProject.rootDir.resolve("LICENSE.adoc")}") { + into("META-INF") + } +} + +val dokkaJavadocJar by tasks.creating(Jar::class) { + archiveClassifier.set("javadoc") + dependsOn(tasks.dokkaJavadoc) + from(tasks.dokkaJavadoc.get().outputDirectory.orNull) +} + +artifacts { + archives(sourcesJar) + archives(dokkaJavadocJar) +} + +val publicationName = + "faker${project.name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }}" + +publishing { + publications { + create(publicationName) { + groupId = project.group.toString() + artifactId = fullName + version = project.version.toString() + from(components["java"]) + artifact(sourcesJar) + artifact(dokkaJavadocJar) //TODO configure dokka or use defaults? + + pom { + packaging = "jar" + name.set(fullName) + description.set("Generate realistically looking fake data such as names, addresses, banking details, and many more, that can be used for testing and data anonymization purposes.") + url.set("https://github.com/serpro69/kotlin-faker") + scm { + connection.set("scm:git:https://github.com/serpro69/kotlin-faker") + developerConnection.set("scm:git:https://github.com/serpro69") + url.set("https://github.com/serpro69/kotlin-faker") + } + issueManagement { + url.set("https://github.com/serpro69/kotlin-faker/issues") + } + licenses { + license { + name.set("MIT") + url.set("https://opensource.org/licenses/mit-license.php") + } + } + developers { + developer { + id.set("serpro69") + name.set("Serhii Prodan") + } + } + } + } + } +} + +tasks { + assemble { + dependsOn(integrationTest) + dependsOn(jar) + } +} + +signing { + sign(publishing.publications[publicationName]) +} + +tasks.withType().configureEach { + onlyIf("Not dev") { !isDev.get() } + onlyIf("Release or snapshot") { isRelease.get() || isSnapshot.get() } +} + +tasks.withType().configureEach { + dependsOn(project.tasks.getByName("tag")) + dependsOn(project.tasks.withType(Sign::class.java)) + onlyIf("Not dev") { !isDev.get() } + onlyIf("Release or snapshot") { isRelease.get() || isSnapshot.get() } +} + +tasks.withType().configureEach { + onlyIf("In development") { isDev.get() || isSnapshot.get() } +} + +tasks.withType().configureEach { + dependsOn(project.tasks.getByName("tag")) + onlyIf("Not dev and snapshot") { !isDev.get() && !isSnapshot.get() } + onlyIf("Release") { isRelease.get() } +} diff --git a/buildSrc/src/main/kotlin/faker-lib-conventions.gradle.kts b/buildSrc/src/main/kotlin/faker-lib-conventions.gradle.kts index 140fe28db..1a825e1f1 100644 --- a/buildSrc/src/main/kotlin/faker-lib-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/faker-lib-conventions.gradle.kts @@ -1,10 +1,12 @@ import com.github.jengelman.gradle.plugins.shadow.ShadowExtension import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import io.github.serpro69.semverkt.gradle.plugin.tasks.TagTask +import org.gradle.accessors.dm.LibrariesForLibs import org.jetbrains.dokka.gradle.DokkaTask import java.util.* plugins { + base `java-library` kotlin("jvm") id("org.jetbrains.dokka") @@ -13,6 +15,8 @@ plugins { signing } +val libs = the() + /** * For additional providers, use a combination of rootProject and subproject names for artifact name and similar things. * i.e. kotlin-faker-books, kotlin-faker-movies, kotlin-faker-tv, ... @@ -69,8 +73,7 @@ dependencies { val testImplementation by configurations val testRuntimeOnly by configurations val integrationImplementation by configurations - shadow(kotlin("stdlib-jdk8")) - shadow(kotlin("reflect")) + shadow(libs.bundles.kotlin) testRuntimeOnly("ch.qos.logback:logback-core:1.3.4") { version { strictly("1.3.4") /* last stable for java 8 */ } } @@ -79,8 +82,8 @@ dependencies { } testRuntimeOnly("org.codehaus.groovy:groovy:3.0.19") // we're shadowing these so they need to be available for test runtime - testRuntimeOnly("com.ibm.icu:icu4j:73.2") - testRuntimeOnly("com.github.mifmif:generex:1.0.2") + testRuntimeOnly(libs.icu4j) + testRuntimeOnly(libs.generex) } val integrationTest by tasks.creating(Test::class) { diff --git a/buildSrc/src/main/kotlin/faker-provider-conventions.gradle.kts b/buildSrc/src/main/kotlin/faker-provider-conventions.gradle.kts index 9eb1b96d6..bc598a126 100644 --- a/buildSrc/src/main/kotlin/faker-provider-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/faker-provider-conventions.gradle.kts @@ -1,9 +1,12 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar -import io.github.serpro69.semverkt.spec.Semver +import gradle.kotlin.dsl.accessors._067c47cd6494f88dd357c9f02a8ec86e.assemble +import org.gradle.accessors.dm.LibrariesForLibs plugins { } +val libs = the() + val core = rootProject.subprojects.first { it.path == ":core" } ?: throw GradleException(":core project not found") @@ -22,9 +25,9 @@ dependencies { OR a project-type dependency on the :core submodule */ // In order to use an additional fake data provider, core faker needs to be on the classpath. // Don't add it as transitive dependency to each faker provider - compileOnly(project(path = ":core", configuration = "shadow")) + compileOnly(project(path = core.path, configuration = "shadow")) // we need implementation dependency for tests to be able to access 'core' functionality - testImplementation(project(path = ":core", configuration = "shadow")) + testImplementation(project(path = core.path, configuration = "shadow")) // provides helpers for integration tests integrationImplementation(project(":test", "testHelper")) } @@ -32,11 +35,11 @@ dependencies { // we have a dependency on :core, // hence we also need to make sure ShadowJar tasks depend on core having been built val shadowJar by tasks.getting(ShadowJar::class) { - dependsOn(":core:assemble") + dependsOn(core.tasks.assemble) } // since we're adding :core as implementation dependency, and effectively testImplementation // we also need to make sure Test tasks depend on core having been built tasks.withType { - dependsOn(":core:assemble") + dependsOn(core.tasks.assemble) } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index f4d10dac9..e437a50ad 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -8,10 +8,9 @@ plugins { } dependencies { - implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.3") - shadow("com.ibm.icu:icu4j:73.2") - shadow("com.github.mifmif:generex:1.0.2") + implementation(libs.bundles.jackson) + shadow(libs.icu4j) + shadow(libs.generex) } apply() // this shouldn't really be needed since the plugin is supposed to be applied in the plugins{} block diff --git a/docs/build.gradle.kts b/docs/build.gradle.kts index 9031b1fbd..0cf2f022e 100644 --- a/docs/build.gradle.kts +++ b/docs/build.gradle.kts @@ -25,8 +25,7 @@ plugins { } repositories { - mavenCentral() - jcenter() + jcenter() // orchid... } dependencies { diff --git a/docs/src/orchid/resources/pages/extensions/kotest-property-extension.md b/docs/src/orchid/resources/pages/extensions/kotest-property-extension.md new file mode 100644 index 000000000..a29f5d21a --- /dev/null +++ b/docs/src/orchid/resources/pages/extensions/kotest-property-extension.md @@ -0,0 +1,224 @@ +--- +--- + +# Kotest Property Extension + +## TOC + +- [About](#about) +- [Usage](#usage) + - [Installation](#installation) + - [Generate Arb Extensions](#generate-arb-extensions) + - [Random Class Instance ARBs](#random-class-instance-arb) + +## About + +Kotlin-faker `kotest-property` and `kotest-property-ksp` artifacts provide faker-based [`Arb` generators](https://kotest.io/docs/proptest/property-test-generators.html) extensions via [KSP](https://kotlinlang.org/docs/ksp-overview.html) compiler plugin for [kotest property testing](https://kotest.io/docs/proptest/property-based-testing.html). + +## Usage + +### Installation + +`kotest-property` extension builds upon [KSP](https://kotlinlang.org/docs/ksp-overview.html), from which it inherits easy integration with Gradle. To use this extension, add the following in your `build.gradle.kts`: + +- ① add the ksp plugin (You can check the latest version in their [releases](https://github.com/google/ksp/releases/).) +- ② add the core `kotlin-faker` dependency to the test classpath +- ③ add the `testImplementation` dependency for the `kotest-property` extension +- ④ add the `kspTest` dependency for the `kotest-property-ksp` extension + - This will generate the code for test sources. If you're using kotlin-faker for something other than testing (e.g. data anonymization) and want to generate extension code for main source instead, use `ksp` configuration for this dependency. +- ⑤ the core `kotlin-faker` dependency also needs to be added to `kspTest` configuration + +{% tabs %} + +{% kotlin "Kotlin" %} +{% filter compileAs('md') %} + +```kotlin +plugins { + id("com.google.devtools.ksp") version "$kspVersion" // ① +} + +dependencies { + testImplementation("io.github.serpro69:kotlin-faker:$fakerVersion") // ② + testImplementation("io.github.serpro69:kotlin-faker-kotest-property:$fakerExtVersion") // ③ + kspTest("io.github.serpro69:kotlin-faker-kotest-property-ksp:$fakerExtVersion") // ④ + kspTest("io.github.serpro69:kotlin-faker:$fakerVersion") // ⑤ +} +``` + +{% endfilter %} +{% endkotlin %} + +{% endtabs %} + +{% btc %}{% endbtc %} + +
+ +### Generate Arb Extensions + +① To generate `Arb` extensions for Fakers, use the `FakerArb` annotation on the test file and provide the "Faker" classes to generate extensions for: + +```kotlin +@file:FakerArb(Faker::class, BooksFaker::class, EduFaker::class) // ① + +package com.example +``` + +{% info %} +{% filter compileAs('md') %} +The annotation only needs to be used once, and you can even use it in a separate empty file in your test sources +{% endfilter %} +{% endinfo %} + +
+ +{% warn %} +{% filter compileAs('md') %} +For any additional fakers that you want to generate `Arb`s for, e.g. `BooksFaker` or `EduFaker`, make sure to add the corresponding dependency to both `testImplementation` and `kspTest` configurations: + +```kotlin +dependencies { + testImplementation("io.github.serpro69:kotlin-faker-books:$fakerVersion") + testImplementation("io.github.serpro69:kotlin-faker-edu:$fakerVersion") + kspTest("io.github.serpro69:kotlin-faker-books:$fakerVersion") + kspTest("io.github.serpro69:kotlin-faker-edu:$fakerVersion") +} +``` + +{% endfilter %} +{% endwarn %} + +The plugin will generate [`Arb` generator](https://kotest.io/docs/proptest/property-test-generators.html) extensions for all specified faker classes and their data providers. + +① Each `Faker` instance will have an `arb` property that provides access to standard faker data generators, but which return data wrapped in `Arb` instances. + +② Generated code will also include `faker` extension properties for `Arb.Companion` that exposes the same functionality. + +③ Each generated `ArbFaker` instance will include all the standard Faker data generator properties, i.e. `address`, `color`, `currency`, etc. for the "core" Faker. + +④ Each Arb-based data provider, e.g. `ArbAddress` that "implements" [`Address` data provider]({{ link(collectionType='pages', collectionId='data-provider', itemId='Address') }}), will include all functions for that given data provider, but returned as parameterized `Arb` types. + +```kotlin +public val Faker.arb: ArbFaker // ① + get() = ArbFaker(this) +public val Arb.Companion.faker: ArbFaker // ② + get() = io.github.serpro69.kfaker.ArbFaker(Faker()) + +public val BooksFaker.arb: ArbBooksFaker // ① + get() = ArbBooksFaker(this) +public val Arb.Companion.booksFaker: ArbBooksFaker // ② + get() = io.github.serpro69.kfaker.books.ArbBooksFaker(BooksFaker()) + +public class ArbFaker(private val faker: Faker) { // ③ + public val address: ArbAddress by lazy { ArbAddress(faker.address) } + + public val color: ArbColor by lazy { ArbColor(faker.color) } + + public val currency: ArbCurrency by lazy { ArbCurrency(faker.currency) } + + // ... +} + +public class ArbAddress internal constructor(private val address: Address) { // ④ + public fun city(): Arb = arbitrary { address.city() } + + public fun country(): Arb = arbitrary { address.country() } + + // ... +} +``` + +
+ +This can then be used with standard [Kotest property testing](https://kotest.io/docs/proptest/property-based-testing.html) functionality, just like the built-in Arbs, e.g. with [property test functions](https://kotest.io/docs/proptest/property-test-functions.html) like `forAll`: + +```kotlin +package com.example + +import io.github.serpro69.kfaker.Faker +import io.github.serpro69.kfaker.arb +import io.github.serpro69.kfaker.books.BooksFaker +import io.github.serpro69.kfaker.books.arb +import io.github.serpro69.kfaker.books.booksFaker +import io.github.serpro69.kfaker.edu.EduFaker +import io.github.serpro69.kfaker.edu.arb +import io.github.serpro69.kfaker.faker +import io.github.serpro69.kfaker.kotest.FakerArb +import io.github.serpro69.kfaker.randomClass +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.property.Arb +import io.kotest.property.forAll + +class KotestPropertyArbsTest : DescribeSpec({ + describe("Custom kotlin-faker Arbs") { + it("should generate quotes from the bible") { + val b = BooksFaker() + forAll(b.arb.bible.quote()) { q: String -> + q.isNotBlank() + } + } + it("should generate addresses") { + val f = Faker() + forAll(f.arb.address.city()) { q -> + q.isNotBlank() + } + forAll(f.arb.address.city(), f.arb.address.streetName()) { city, street -> + city.isNotBlank() + street.isNotBlank() + } + } + it("should generate quotes from companion object") { + forAll(Arb.booksFaker.bible.quote()) { q: String -> + q.isNotBlank() + } + } + it("should generate addresses from companion object") { + class Address(val city: String, val state: String) { + fun isValid() = city.isNotBlank() && state.isNotBlank() + } + forAll(Arb.faker.address.city(), Arb.faker.address.state()) { city, state -> + Address(city, state).isValid() + } + } + } +}) +``` + +{% btc %}{% endbtc %} + +
+ +### Random Class Instance ARB + +The `kotlin-faker-kotest-property` extension additionally adds a `randomClass` extension property to `Arb.Compaion` for generating a random instance of any class, which provides the same functionality as the default [Random Class Instance]({{ link(collectionType='wiki', collectionId='', itemId='Extras') }}##random-instance-of-any-class) faker functionality, but wrapped in `Arb` type to be used with kotest property testing. + +```kotlin +it("should generate person with address") { + val f = Faker() + val person: () -> Arb = { + Arb.randomClass.instance { + namedParameterGenerator("name") { f.name.name() } + namedParameterGenerator("age") { f.random.nextInt(20, 30) } + } + } + val address: () -> Arb
= { + Arb.randomClass.instance
{ + namedParameterGenerator("city") { f.address.city() } + namedParameterGenerator("streetName") { f.address.streetName() } + namedParameterGenerator("streetAddress") { f.address.streetAddress() } + } + } + forAll(person(), address()) { p: Person, a: Address -> + p.name.isNotBlank() + p.age in 20..30 + a.city.isNotBlank() + a.streetName.isNotBlank() + a.streetAddress.isNotBlank() + } +} +``` + +{% btc %}{% endbtc %} + +
diff --git a/docs/src/orchid/resources/wiki/extensions.md b/docs/src/orchid/resources/wiki/extensions.md new file mode 100644 index 000000000..205bf7ddb --- /dev/null +++ b/docs/src/orchid/resources/wiki/extensions.md @@ -0,0 +1,31 @@ +--- +--- + +# Extensions + +`kotlin-faker` provides "Faker extensions" for popular third-party testing-related libraries and frameworks. + +{% info %} +{% filter compileAs('md') %} +The extension modules require the [main `kotlin-faker` dependency]({{ link(collectionType='wiki', collectionId='', itemId='Getting Started') }}#installing) to be on the classpath, unless otherwise specified in the given extension's documentation. +{% endfilter %} +{% endinfo %} + +## ToC + +- [Kotest Property](#kotest-property) + +
+ +## Kotest Property + +Kotlin-faker `kotest-property` and `kotest-property-ksp` artifacts provide faker-based [`Arb` generators](https://kotest.io/docs/proptest/property-test-generators.html) extensions via [KSP](https://kotlinlang.org/docs/ksp-overview.html) compiler plugin for [kotest property testing](https://kotest.io/docs/proptest/property-based-testing.html). + +See the [Kotest Property Extension]({{ link(collectionType='pages', collectionId='extensions', itemId='Kotest Property Extension') }}) page for usage details. + +A full working example can also be found in the [kotest-property-test](https://github.com/serpro69/kotlin-faker/tree/master/extension/kotest-property-test) project. + +{% btc %}{% endbtc %} + +
+ diff --git a/docs/src/orchid/resources/wiki/summary.md b/docs/src/orchid/resources/wiki/summary.md index e677a78c8..78ba3852a 100644 --- a/docs/src/orchid/resources/wiki/summary.md +++ b/docs/src/orchid/resources/wiki/summary.md @@ -10,3 +10,4 @@ * [Available Locales](available-locales.md) * [Java Interop](java-interop.md) * [Faker Bot CLI](faker-cli.md) +* [Extensions](extensions.md) diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 000000000..4019129cb --- /dev/null +++ b/extension/README.md @@ -0,0 +1,8 @@ +# `:extension` modules + +These modules provide Faker "extensions" for popular third-party testing-related libraries. + +- [kotest-property](kotest-property) and [kotest-property-ksp](kotest-property-ksp) - kotlin-faker extension for [kotest property testing](https://kotest.io/docs/proptest/property-based-testing.html), provides faker-based [`Arb` generators](https://kotest.io/docs/proptest/property-test-generators.html) via [KSP](https://kotlinlang.org/docs/ksp-overview.html) compiler plugin. + - [kotest-property-test](kotest-property-test) - example test project with additional usage details. + +_NB! The extension modules require the main `kotlin-faker` dependency to be on the classpath, unless otherwise specified in the given extension's documentation._ diff --git a/extension/build.gradle.kts b/extension/build.gradle.kts new file mode 100644 index 000000000..87fb185a4 --- /dev/null +++ b/extension/build.gradle.kts @@ -0,0 +1,31 @@ +import io.github.serpro69.semverkt.gradle.plugin.tasks.TagTask + +plugins { +} + +// no sources for this module +sourceSets { + main.configure { + java { setSrcDirs(emptySet()) } + kotlin { setSrcDirs(emptySet()) } + resources { setSrcDirs(emptySet()) } + } + test.configure { + java { setSrcDirs(emptySet()) } + kotlin { setSrcDirs(emptySet()) } + resources { setSrcDirs(emptySet()) } + } +} + +// disable api validation tasks +tasks.apiBuild { enabled = false } +tasks.apiCheck { enabled = false } +tasks.apiDump { enabled = false } +// disable the default jar task +tasks.withType { enabled = false } +// never publish +tasks.withType { enabled = false } +// nothing to test in this module +tasks.withType { enabled = false } +// disable tag +tasks.withType { enabled = false } diff --git a/extension/kotest-property-ksp/README.md b/extension/kotest-property-ksp/README.md new file mode 100644 index 000000000..c45eb60ab --- /dev/null +++ b/extension/kotest-property-ksp/README.md @@ -0,0 +1,10 @@ +# `kotlin-faker-kotest-property-ksp` + +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-kotest-property-ksp?style=for-the-badge)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-kotest-property-ksp) +[![Sonatype Nexus (Snapshots)](https://img.shields.io/nexus/s/io.github.serpro69/kotlin-faker-kotest-property-ksp?label=snapshot-version&server=https%3A%2F%2Foss.sonatype.org&style=for-the-badge&color=yellow)](#downloading) + +Kotlin-faker [`kotest-property`](../kotest-property) and `kotest-property-ksp` artifacts provide faker-based [`Arb` generators](https://kotest.io/docs/proptest/property-test-generators.html) extensions via [KSP](https://kotlinlang.org/docs/ksp-overview.html) compiler plugin for [kotest property testing](https://kotest.io/docs/proptest/property-based-testing.html). + +## Usage + +Documentation for this extension is available at [serpro69.github.io/kotlin-faker/](https://serpro69.github.io/kotlin-faker/extensions/kotest-property-extension). diff --git a/extension/kotest-property-ksp/api/kotest-property-ksp.api b/extension/kotest-property-ksp/api/kotest-property-ksp.api new file mode 100644 index 000000000..179cb986f --- /dev/null +++ b/extension/kotest-property-ksp/api/kotest-property-ksp.api @@ -0,0 +1,21 @@ +public final class io/github/serpro69/kfaker/kotest/KotestArbProvider : com/google/devtools/ksp/processing/SymbolProcessorProvider { + public fun ()V + public fun create (Lcom/google/devtools/ksp/processing/SymbolProcessorEnvironment;)Lcom/google/devtools/ksp/processing/SymbolProcessor; +} + +public final class io/github/serpro69/kfaker/kotest/poet/KotlinPoetExtensionsKt { + public static final fun append (Lcom/squareup/kotlinpoet/ClassName;Ljava/lang/String;)Lcom/squareup/kotlinpoet/ClassName; + public static final fun asStringQuoted (Lcom/google/devtools/ksp/symbol/KSName;)Ljava/lang/String; + public static final fun getClassName (Lcom/google/devtools/ksp/symbol/KSDeclaration;)Lcom/squareup/kotlinpoet/ClassName; + public static final fun parameterizedWhenNotEmpty (Lcom/squareup/kotlinpoet/ClassName;Ljava/util/List;)Lcom/squareup/kotlinpoet/TypeName; + public static final fun writeTo (Lcom/squareup/kotlinpoet/FileSpec;Lcom/google/devtools/ksp/processing/CodeGenerator;)V +} + +public final class io/github/serpro69/kfaker/kotest/poet/TypeNameKt { + public static final fun makeInvariant (Lcom/squareup/kotlinpoet/TypeVariableName;)Lcom/squareup/kotlinpoet/TypeVariableName; +} + +public final class io/github/serpro69/kfaker/kotest/poet/TypeSpecKt { + public static final fun addClass (Lcom/squareup/kotlinpoet/FileSpec$Builder;Lcom/squareup/kotlinpoet/ClassName;Lkotlin/jvm/functions/Function1;)V +} + diff --git a/extension/kotest-property-ksp/build.gradle.kts b/extension/kotest-property-ksp/build.gradle.kts new file mode 100644 index 000000000..be696af53 --- /dev/null +++ b/extension/kotest-property-ksp/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + `faker-ext-conventions` +} + +dependencies { + compileOnly(projects.extension.kotestProperty) + implementation(libs.ksp) + api(libs.bundles.kotlinpoet) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/ArbExt.kt b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/ArbExt.kt new file mode 100644 index 000000000..ce582e1bd --- /dev/null +++ b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/ArbExt.kt @@ -0,0 +1,27 @@ +package io.github.serpro69.kfaker.kotest + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import io.github.serpro69.kfaker.kotest.extensions.TypeCompileScope +import io.github.serpro69.kfaker.kotest.extensions.addGeneratedMarker +import io.github.serpro69.kfaker.kotest.poet.append +import java.util.Locale + +internal val TypeCompileScope.arbExtensions: FileSpec + get() = + buildFile(fileName = target.append("Arb").reflectionName()) { + val parameterized = target.parameterized + val arbClassName = ClassName(file.packageName, "Arb${target.simpleName}") + addGeneratedMarker() + addPropertyWithGetter("arb", parameterized, arbClassName) { _ -> + addCode("return ${arbClassName.simpleName}(this)") + } + addPropertyWithGetter( + target.simpleName.replaceFirstChar { if (it.isUpperCase()) it.lowercase(Locale.getDefault()) else it.toString() }, + ClassName("io.kotest.property", "Arb", "Companion"), + arbClassName, + ) { _ -> + addCode("return $arbClassName(${target.simpleName}())") + } + addArbFakerClass(arbClassName, classDeclaration) + } diff --git a/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/FileCompileScope.kt b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/FileCompileScope.kt new file mode 100644 index 000000000..20dab8550 --- /dev/null +++ b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/FileCompileScope.kt @@ -0,0 +1,34 @@ +package io.github.serpro69.kfaker.kotest + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFile +import com.squareup.kotlinpoet.FileSpec +import io.github.serpro69.kfaker.kotest.extensions.ClassCompileScope +import io.github.serpro69.kfaker.kotest.extensions.hasGeneratedMarker +import io.github.serpro69.kfaker.kotest.poet.writeTo + +internal fun ProcessorScope.processFiles( + resolver: Resolver, + block: FileCompileScope.() -> Unit, +) { + val files = resolver.getAllFiles() + if (files.none(KSFile::hasGeneratedMarker)) { + block(FileCompileScope(files, this)) + } +} + +internal class FileCompileScope( + val files: Sequence, + scope: ProcessorScope, +) : LoggerScope by scope, OptionsScope by scope { + private val codegen: CodeGenerator = scope.codegen + + val KSClassDeclaration.classScope: ClassCompileScope + get() = ClassCompileScope(this, logger) + + fun FileSpec.write() { + writeTo(codegen) + } +} diff --git a/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/KotestArbProcessor.kt b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/KotestArbProcessor.kt new file mode 100644 index 000000000..8fa2bf081 --- /dev/null +++ b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/KotestArbProcessor.kt @@ -0,0 +1,46 @@ +package io.github.serpro69.kfaker.kotest + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getAnnotationsByType +import com.google.devtools.ksp.getClassDeclarationByName +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.symbol.KSAnnotated +import io.github.serpro69.kfaker.kotest.extensions.lang.forEachRun +import io.github.serpro69.kfaker.kotest.extensions.lang.mapRun + +internal interface LoggerScope { + val logger: KSPLogger +} + +internal interface OptionsScope + +internal class ProcessorScope(environment: SymbolProcessorEnvironment) : LoggerScope, OptionsScope { + val codegen = environment.codeGenerator + override val logger = environment.logger +} + +internal class KotestArbProcessor(private val scope: ProcessorScope) : SymbolProcessor { + @OptIn(KspExperimental::class) + override fun process(resolver: Resolver): List { + scope.processFiles(resolver) { + scope.logger.logging("FileCompileScope: $this") + files.forEachRun { + scope.logger.logging("Processing file: $this", this) + getAnnotationsByType(FakerArb::class).forEach { a -> + scope.logger.logging("Found Annotation: $a", this) + a.fakers.asSequence() + .mapRun { qualifiedName?.let { resolver.getClassDeclarationByName(it) } } + .filterNotNull() + .forEachRun { + scope.logger.logging("Processing Faker: $this", this) + classScope.arbExtensions.write() + } + } + } + } + return emptyList() + } +} diff --git a/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/KotestArbProvider.kt b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/KotestArbProvider.kt new file mode 100644 index 000000000..862f57c5f --- /dev/null +++ b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/KotestArbProvider.kt @@ -0,0 +1,15 @@ +package io.github.serpro69.kfaker.kotest + +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +/** + * [SymbolProcessorProvider] implementation for kotlin-faker Arbs generators + * to use with [kotest property testing](https://kotest.io/docs/proptest/property-based-testing.html). + */ +class KotestArbProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return KotestArbProcessor(ProcessorScope(environment)) + } +} diff --git a/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/GeneratedMarker.kt b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/GeneratedMarker.kt new file mode 100644 index 000000000..b4f47d9d8 --- /dev/null +++ b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/GeneratedMarker.kt @@ -0,0 +1,19 @@ +package io.github.serpro69.kfaker.kotest.extensions + +import com.google.devtools.ksp.symbol.KSFile +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.squareup.kotlinpoet.KModifier.PRIVATE +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.asTypeName + +internal fun FileCompilerScope.addGeneratedMarker() { + file.addProperty( + PropertySpec.builder(MARKER, unitTypeName).addModifiers(PRIVATE).initializer("Unit").build(), + ) +} + +internal fun KSFile.hasGeneratedMarker(): Boolean = declarations.filterIsInstance().any { it.baseName == MARKER } + +private const val MARKER = "generatedByKotlinFaker" + +private val unitTypeName = Unit::class.asTypeName() diff --git a/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/HasAnnotation.kt b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/HasAnnotation.kt new file mode 100644 index 000000000..c016a2dbf --- /dev/null +++ b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/HasAnnotation.kt @@ -0,0 +1,12 @@ +package io.github.serpro69.kfaker.kotest.extensions + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getAnnotationsByType +import com.google.devtools.ksp.symbol.KSAnnotated + +@OptIn(KspExperimental::class) +internal inline fun KSAnnotated.hasAnnotation(predicate: (T) -> Boolean = { true }): Boolean { + val a = getAnnotationsByType(T::class).firstOrNull() + return a != null && predicate(a) +} + diff --git a/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/KspExtensions.kt b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/KspExtensions.kt new file mode 100644 index 000000000..c7d5c717b --- /dev/null +++ b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/KspExtensions.kt @@ -0,0 +1,15 @@ +package io.github.serpro69.kfaker.kotest.extensions + +import com.google.devtools.ksp.symbol.KSDeclaration +import io.github.serpro69.kfaker.kotest.poet.KOTLIN_KEYWORDS + +internal val KSDeclaration.baseName: String + get() = simpleName.asString().sanitize() + +/** + * Sanitizes each delimited section if it matches with Kotlin reserved keywords. + */ +private fun String.sanitize( + delimiter: String = ".", + prefix: String = "", +) = splitToSequence(delimiter).joinToString(delimiter, prefix) { if (it in KOTLIN_KEYWORDS) "`$it`" else it } diff --git a/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/TypeCompileScope.kt b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/TypeCompileScope.kt new file mode 100644 index 000000000..e484b56ea --- /dev/null +++ b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/TypeCompileScope.kt @@ -0,0 +1,231 @@ +package io.github.serpro69.kfaker.kotest.extensions + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.getAnnotationsByType +import com.google.devtools.ksp.getDeclaredFunctions +import com.google.devtools.ksp.getDeclaredProperties +import com.google.devtools.ksp.getVisibility +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.symbol.FunctionKind +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.Visibility +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.TypeVariableName +import com.squareup.kotlinpoet.ksp.toClassName +import com.squareup.kotlinpoet.ksp.toTypeName +import com.squareup.kotlinpoet.ksp.toTypeVariableName +import io.github.serpro69.kfaker.kotest.LoggerScope +import io.github.serpro69.kfaker.kotest.poet.addClass +import io.github.serpro69.kfaker.kotest.poet.className +import io.github.serpro69.kfaker.kotest.poet.makeInvariant +import io.github.serpro69.kfaker.kotest.poet.parameterizedWhenNotEmpty + +/** + * Represents a compile scope for a [KSDeclaration]. + * + * The [TypeCompileScope] interface defines a compile scope specific to a [KSDeclaration]. + * + * @property classDeclaration The KSClassDeclaration associated with this compile scope. + * @property typeVariableNames The type variable names associated with this compile scope. + * @property target The target class name to which this compile-scope is associated. + */ +internal sealed interface TypeCompileScope : KSDeclaration, LoggerScope { + val classDeclaration: KSClassDeclaration + val typeVariableNames: List + val target: ClassName + + val ClassName.parameterized: TypeName + + /** + * Returns a [FileSpec] with the given [fileName] and the [block] function applied to it. + */ + fun buildFile( + fileName: String, + block: FileCompilerScope.() -> Unit, + ): FileSpec { + return FileSpec.builder(packageName.asString(), fileName) + .also { toFileScope(it).block() } + .build() + } + + /** + * Converts this [TypeCompileScope] to a [FileCompilerScope] for a given [file] input. + */ + fun toFileScope(file: FileSpec.Builder): FileCompilerScope +} + +/** + * Represents a compile scope for a [KSClassDeclaration]. + * + * The `ClassCompileScope` class defines a compile scope specific to a [KSClassDeclaration]. + * It encapsulates information about the [classDeclaration], and [logger] used for logging. + * + * @property classDeclaration The KSClassDeclaration associated with this compile scope. + * @property logger The KSPLogger instance used for logging within the compile scope. + */ +internal class ClassCompileScope( + override val classDeclaration: KSClassDeclaration, + override val logger: KSPLogger, +) : TypeCompileScope, KSClassDeclaration by classDeclaration { + override val typeVariableNames: List = + classDeclaration.typeParameters.map { it.toTypeVariableName() } + override val target: ClassName = classDeclaration.className + + override val ClassName.parameterized + get() = parameterizedWhenNotEmpty(typeVariableNames.map { it.makeInvariant() }) + + override fun toFileScope(file: FileSpec.Builder): FileCompilerScope = FileCompilerScope(this, file = file) +} + +/** + * Represents a compile scope of an [element] in a given [file]. + */ +@OptIn(KspExperimental::class) +internal class FileCompilerScope( + private val element: TypeCompileScope, + val file: FileSpec.Builder, +) { + fun addArbFakerClass( + type: ClassName, + faker: KSClassDeclaration, + ) { + file.addClass(type) { + val constructor = + FunSpec.constructorBuilder() + .addParameter(ParameterSpec.builder("faker", faker.className).build()) + .addCode("this.faker = faker\n") + .build() + primaryConstructor(constructor) + addProperty("faker", faker.className, KModifier.PRIVATE) + val providers = addProviders(faker) + providers.forEach { addArbProviderClass(it) } + } + } + + private fun TypeSpec.Builder.addProviders(faker: KSClassDeclaration): Sequence> { + val props = + faker.getDeclaredProperties() + .mapNotNull { + val ksClass = it.type.resolve().declaration as KSClassDeclaration + element.logger.logging("ksclass: $ksClass", ksClass) + val isFakeDataProvider = + ksClass.getAllSuperTypes().any { r -> + element.logger.logging("Provider: $r") + r.toClassName() == ClassName("io.github.serpro69.kfaker.provider", "FakeDataProvider") + } + // exclude misc providers because they're mostly "special cases" that don't make much sense here + val excluded = ksClass.packageName.asString() == "io.github.serpro69.kfaker.provider.misc" + if (isFakeDataProvider && !excluded) it to ksClass else null + } + props.forEach { (ksProp, ksClass) -> + val providerClassName = ksClass.simpleName.asString() + val className = ClassName(ksProp.packageName.asString(), "Arb$providerClassName") + val prop = + PropertySpec.builder(ksProp.baseName, className) + .delegate("lazy { ${className.simpleName}(faker.${ksProp.simpleName.asString()}) }") + .build() + addProperty(prop) + } + return props + } + + private fun addArbProviderClass(provider: Pair) { + val (prop, type) = provider + element.logger.logging("prop: $prop, type: $type") + val className = ClassName(type.packageName.asString(), "Arb${type.simpleName.asString()}") + file.addImport("io.kotest.property.arbitrary", "arbitrary") + file.addClass(className) { + primaryConstructor( + FunSpec.constructorBuilder() + .addParameter(ParameterSpec.builder(prop.baseName, type.className).build()) + .addCode("this.${prop.baseName} = ${prop.baseName}\n") + .addModifiers(KModifier.INTERNAL) + .build(), + ) + addProperty( + PropertySpec.builder(prop.baseName, type.className) + .addModifiers(KModifier.PRIVATE) + .build(), + ) + type.getDeclaredFunctions() + .filter { + it.functionKind == FunctionKind.MEMBER && + it.getVisibility() == Visibility.PUBLIC && + !it.hasAnnotation { a -> a.level == DeprecationLevel.ERROR } + } + .forEach { + element.logger.logging("function: $it") + element.logger.logging("type params: ${it.typeParameters}") + // filter out generics via typeParameters + val returnTypeName = if (it.typeParameters.isEmpty()) it.returnType?.toTypeName() else null + element.logger.logging("returnTypeName: $returnTypeName") + val functionParams = + it.parameters.map { p -> + ParameterSpec.builder(p.name?.asString() ?: "noname", p.type.toTypeName()) + .build() + } + addFunction( + FunSpec.builder(it.baseName).apply { + addParameters(functionParams) + val typeArgs = returnTypeName?.let { t -> listOf(t) } ?: emptyList() + returns( + ClassName("io.kotest.property", "Arb") + .parameterizedWhenNotEmpty(typeArgs) + ) + val params = if (functionParams.isNotEmpty()) { + functionParams.joinToString(", ") { p -> p.name } + } else { + "" + } + addCode("return arbitrary·{·${prop.baseName}.${it.baseName}($params)·}") + // handle non-ERROR level annotations + it.getAnnotationsByType(Deprecated::class).firstOrNull()?.let { a -> + addAnnotation( + AnnotationSpec + .builder(ClassName("kotlin", "Deprecated")) + .addMember( + "level = %L, message = %S, replaceWith = %L", + "DeprecationLevel.${a.level}", + a.message, + "ReplaceWith(\"${a.replaceWith.expression}\")", + ) + .build() + ) + } + }.build() + ) + } + } + } + + fun addPropertyWithGetter( + name: String, + receives: TypeName, + returns: ClassName, + block: FunSpec.Builder.(PropertySpec.Builder) -> Unit = {}, + ) { + val prop = + PropertySpec.builder(name, returns).apply ps@{ + receiver(receives) + addTypeVariables(element.typeVariableNames.map { it.makeInvariant() }) + val getter = + FunSpec.getterBuilder() + .apply { addTypeVariables(element.typeVariableNames.map { it.makeInvariant() }) } + .apply fs@{ block(this@fs, this@ps) } + .build() + getter(getter) + }.build() + file.addProperty(prop) + } +} diff --git a/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/lang/SequenceScopedOperators.kt b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/lang/SequenceScopedOperators.kt new file mode 100644 index 000000000..2559203f9 --- /dev/null +++ b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/extensions/lang/SequenceScopedOperators.kt @@ -0,0 +1,13 @@ +package io.github.serpro69.kfaker.kotest.extensions.lang + +/** + * Alias of [forEach] with the item as the receiver. + */ +internal inline fun Sequence.forEachRun(block: A.() -> Unit) { + forEach(block) +} + +/** + * Alias of [map] with the item as the receiver. + */ +internal fun Sequence.mapRun(block: A.() -> R) = map(block) diff --git a/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/poet/KotlinPoetExtensions.kt b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/poet/KotlinPoetExtensions.kt new file mode 100644 index 000000000..5412a0cf8 --- /dev/null +++ b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/poet/KotlinPoetExtensions.kt @@ -0,0 +1,125 @@ +package io.github.serpro69.kfaker.kotest.poet + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSName +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.ksp.writeTo + +fun ClassName.parameterizedWhenNotEmpty(typeArguments: List): TypeName = + takeIf { typeArguments.isNotEmpty() }?.parameterizedBy(typeArguments)?.copy(nullable = this.isNullable) ?: this + +fun FileSpec.writeTo(codeGenerator: CodeGenerator) { + writeTo(codeGenerator = codeGenerator, aggregating = false) +} + +fun ClassName.append(name: String): ClassName = ClassName(packageName, simpleNames + name) + +@Suppress("RecursivePropertyAccessor") +val KSDeclaration.className: ClassName + get() = + when (val parent = parentDeclaration) { + is KSClassDeclaration -> parent.className.append(simpleName.asString()) + else -> ClassName(packageName = packageName.asStringQuoted(), simpleName.asString()) + } + +fun KSName.asStringQuoted(): String = + asString().split('.').joinToString(separator = ".") { + when (it) { + in KOTLIN_KEYWORDS -> "`$it`" + else -> it + } + } + +// https://kotlinlang.org/docs/reference/keyword-reference.html +internal val KOTLIN_KEYWORDS = + setOf( + // Hard keywords + "as", + "break", + "class", + "continue", + "do", + "else", + "false", + "for", + "fun", + "if", + "in", + "interface", + "is", + "null", + "object", + "package", + "return", + "super", + "this", + "throw", + "true", + "try", + "typealias", + "typeof", + "val", + "var", + "when", + "while", + // Soft keywords + "by", + "catch", + "constructor", + "delegate", + "dynamic", + "field", + "file", + "finally", + "get", + "import", + "init", + "param", + "property", + "receiver", + "set", + "setparam", + "where", + // Modifier keywords + "actual", + "abstract", + "annotation", + "companion", + "const", + "crossinline", + "data", + "enum", + "expect", + "external", + "final", + "infix", + "inline", + "inner", + "internal", + "lateinit", + "noinline", + "open", + "operator", + "out", + "override", + "private", + "protected", + "public", + "reified", + "sealed", + "suspend", + "tailrec", + "value", + "vararg", + // These aren't keywords anymore but still break some code if unescaped. + // https://youtrack.jetbrains.com/issue/KT-52315 + "header", + "impl", + // Other reserved keywords + "yield", + ) diff --git a/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/poet/TypeName.kt b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/poet/TypeName.kt new file mode 100644 index 000000000..b7b5647a4 --- /dev/null +++ b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/poet/TypeName.kt @@ -0,0 +1,8 @@ +package io.github.serpro69.kfaker.kotest.poet + +import com.squareup.kotlinpoet.TypeVariableName + +/** + * Returns this [TypeVariableName] receiver as invariant instance. + */ +fun TypeVariableName.makeInvariant(): TypeVariableName = TypeVariableName(name, bounds, null) diff --git a/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/poet/TypeSpec.kt b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/poet/TypeSpec.kt new file mode 100644 index 000000000..76c389d53 --- /dev/null +++ b/extension/kotest-property-ksp/src/main/kotlin/io/github/serpro69/kfaker/kotest/poet/TypeSpec.kt @@ -0,0 +1,12 @@ +package io.github.serpro69.kfaker.kotest.poet + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.TypeSpec + +fun FileSpec.Builder.addClass( + className: ClassName, + block: TypeSpec.Builder.() -> Unit, +) { + addType(TypeSpec.classBuilder(className).apply(block).build()) +} diff --git a/extension/kotest-property-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/extension/kotest-property-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 000000000..0002b233c --- /dev/null +++ b/extension/kotest-property-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +io.github.serpro69.kfaker.kotest.KotestArbProvider diff --git a/extension/kotest-property-test/README.md b/extension/kotest-property-test/README.md new file mode 100644 index 000000000..468f7cdc6 --- /dev/null +++ b/extension/kotest-property-test/README.md @@ -0,0 +1,7 @@ +# `kotlin-faker-kotest-test` + +An example project that uses [`kotest-property`](../kotest-property) and [`kotest-property-ksp`](../kotest-property-ksp) artifacts to generate faker-based [`Arb` generators](https://kotest.io/docs/proptest/property-test-generators.html) extensions via [KSP](https://kotlinlang.org/docs/ksp-overview.html) compiler plugin for [kotest property testing](https://kotest.io/docs/proptest/property-based-testing.html). + +## Usage + +To run tests in this module execute `./gradlew :extension:kotest-property-test:test` from the root of the kotlin-faker repository. diff --git a/extension/kotest-property-test/build.gradle.kts b/extension/kotest-property-test/build.gradle.kts new file mode 100644 index 000000000..b290a9173 --- /dev/null +++ b/extension/kotest-property-test/build.gradle.kts @@ -0,0 +1,102 @@ +import com.google.devtools.ksp.gradle.KspTaskJvm +import io.github.serpro69.semverkt.gradle.plugin.tasks.TagTask + +plugins { + kotlin("jvm") + id("com.google.devtools.ksp") version "1.9.21-1.0.15" +} + +val fakers = listOf( + "books", + "commerce", + "creatures", + "databases", + "edu", + "games", + "humor", + "japmedia", + "lorem", + "misc", + "movies", + "music", + "sports", + "tech", + "travel", + "tvshows", +) + +configurations { + create("integrationImplementation") { extendsFrom(configurations.getByName("testImplementation")) } + create("integrationRuntimeOnly") { extendsFrom(configurations.getByName("testRuntimeOnly")) } + create("kspIntegration") { extendsFrom(configurations.getByName("kspTest")) } +} + +// configure sourceSets as extension since it's not available here as `sourceSets` is an extension on `Project` +// https://docs.gradle.org/current/userguide/kotlin_dsl.html#project_extensions_and_conventions +configure { + create("integration") { + resources.srcDir("src/integration/resources") + compileClasspath += main.get().compileClasspath + test.get().compileClasspath + runtimeClasspath += main.get().runtimeClasspath + test.get().runtimeClasspath + } + main { + resources { + this.srcDir("build/generated/src/main/resources") + } + } +} + +dependencies { + testImplementation(libs.kotlin.stdlib.jdk8) + testImplementation(libs.ksp) + testImplementation(projects.core) + testImplementation(projects.faker.books) + testImplementation(projects.faker.edu) + testImplementation(projects.extension.kotestProperty) + testImplementation(libs.bundles.test.kotest) + kspTest(projects.extension.kotestProperty) + kspTest(projects.extension.kotestPropertyKsp) + kspTest(projects.core) + kspTest(projects.faker.books) + kspTest(projects.faker.edu) + // integrationTest + val integrationImplementation by configurations + val kspIntegration by configurations + integrationImplementation(project(path = ":core", configuration = "shadow")) + kspIntegration(projects.core) + fakers.forEach { + integrationImplementation(project(path = ":faker:$it", configuration = "shadow")) + kspIntegration(project(":faker:$it")) + } +} + +tasks.test { + dependsOn(":core:shadowJar") + dependsOn(":faker:books:shadowJar") + dependsOn(":faker:edu:shadowJar") + useJUnitPlatform() +} + +val integrationTest by tasks.creating(Test::class) { + testClassesDirs = sourceSets["integration"].output.classesDirs + classpath = sourceSets["integration"].runtimeClasspath + dependsOn(tasks.test) + dependsOn(":core:shadowJar") + fakers.forEach { dependsOn(":faker:$it:shadowJar") } +} + +tasks.withType(KspTaskJvm::class.java).configureEach { + dependsOn(":core:shadowJar") + fakers.forEach { dependsOn(":faker:$it:shadowJar") } +} + +// disable api validation tasks +tasks.apiBuild { enabled = false } +tasks.apiCheck { enabled = false } +tasks.apiDump { enabled = false } +// disable the default jar task +tasks.withType { enabled = false } +// never publish +tasks.withType { enabled = false } +// disable tag +tasks.withType { enabled = false } diff --git a/extension/kotest-property-test/src/integration/kotlin/io/github/serpro69/kfaker/kotest/KotestArbProviderIT.kt b/extension/kotest-property-test/src/integration/kotlin/io/github/serpro69/kfaker/kotest/KotestArbProviderIT.kt new file mode 100644 index 000000000..eefc7bee7 --- /dev/null +++ b/extension/kotest-property-test/src/integration/kotlin/io/github/serpro69/kfaker/kotest/KotestArbProviderIT.kt @@ -0,0 +1,47 @@ +@file:FakerArb( + Faker::class, + BooksFaker::class, + CommerceFaker::class, + CreaturesFaker::class, + DatabasesFaker::class, + EduFaker::class, + GamesFaker::class, + HumorFaker::class, + JapaneseMediaFaker::class, + LoremFaker::class, + MiscFaker::class, + MoviesFaker::class, + MusicFaker::class, + SportsFaker::class, + TechFaker::class, + TravelFaker::class, + TvShowsFaker::class, +) + +package io.github.serpro69.kfaker.kotest + +import io.github.serpro69.kfaker.Faker +import io.github.serpro69.kfaker.books.BooksFaker +import io.github.serpro69.kfaker.commerce.CommerceFaker +import io.github.serpro69.kfaker.creatures.CreaturesFaker +import io.github.serpro69.kfaker.databases.DatabasesFaker +import io.github.serpro69.kfaker.edu.EduFaker +import io.github.serpro69.kfaker.games.GamesFaker +import io.github.serpro69.kfaker.humor.HumorFaker +import io.github.serpro69.kfaker.japmedia.JapaneseMediaFaker +import io.github.serpro69.kfaker.lorem.LoremFaker +import io.github.serpro69.kfaker.misc.MiscFaker +import io.github.serpro69.kfaker.movies.MoviesFaker +import io.github.serpro69.kfaker.music.MusicFaker +import io.github.serpro69.kfaker.sports.SportsFaker +import io.github.serpro69.kfaker.tech.TechFaker +import io.github.serpro69.kfaker.travel.TravelFaker +import io.github.serpro69.kfaker.tv.TvShowsFaker +import io.kotest.core.spec.style.DescribeSpec + +class KotestArbProviderIT : DescribeSpec({ + + it("should generate arb extensions for each faker") { + // test code generation can compile, nothing else + } +}) diff --git a/extension/kotest-property-test/src/test/kotlin/io/github/serpro69/kfaker/tests/KotestPropertyArbsTest.kt b/extension/kotest-property-test/src/test/kotlin/io/github/serpro69/kfaker/tests/KotestPropertyArbsTest.kt new file mode 100644 index 000000000..5375d8641 --- /dev/null +++ b/extension/kotest-property-test/src/test/kotlin/io/github/serpro69/kfaker/tests/KotestPropertyArbsTest.kt @@ -0,0 +1,136 @@ +@file:FakerArb(Faker::class, BFaker::class, EduFaker::class) + +package io.github.serpro69.kfaker.tests + +import io.github.serpro69.kfaker.Faker +import io.github.serpro69.kfaker.arb +import io.github.serpro69.kfaker.books.BooksFaker +import io.github.serpro69.kfaker.books.arb +import io.github.serpro69.kfaker.books.booksFaker +import io.github.serpro69.kfaker.edu.EduFaker +import io.github.serpro69.kfaker.edu.arb +import io.github.serpro69.kfaker.faker +import io.github.serpro69.kfaker.kotest.FakerArb +import io.github.serpro69.kfaker.randomClass +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.property.Arb +import io.kotest.property.forAll +import io.github.serpro69.kfaker.books.Faker as BFaker + +class KotestPropertyArbsTest : DescribeSpec({ + describe("Custom kotlin-faker Arbs") { + it("should generate quotes from annotated local variable") { + val b = BooksFaker() + forAll(b.arb.bible.quote()) { q: String -> + q.isNotBlank() + } + val f = Faker() + forAll(f.arb.address.city()) { q -> + q.isNotBlank() + } + forAll(f.arb.address.city(), f.arb.address.streetName()) { city, street -> + city.isNotBlank() + street.isNotBlank() + } +// forAll(f.arb.randomClass.randomInstance()) { q -> +// q.isNotBlank() +// } + // TODO support secondary providers via property, like educator.tertiary + val e = EduFaker() + forAll(e.arb.educator.campus()) { q -> + q.isNotBlank() + } + } + it("should generate quotes from companion object") { + forAll(Arb.booksFaker.bible.quote()) { q: String -> + q.isNotBlank() + } + } + it("should generate addresses from companion object") { + class Address(val city: String, val state: String) { + fun isValid() = city.isNotBlank() && state.isNotBlank() + } + forAll(Arb.faker.address.city(), Arb.faker.address.state()) { city, state -> + Address(city, state).isValid() + } + } + it("should generate random class instance") { + forAll(Arb.randomClass.instance()) { foo: Foo -> foo.bar.s.isNotBlank() } + forAll( + Arb.randomClass.instance { + typeGenerator { "hello faker" } + }, + ) { + it.bar.s == "hello faker" + } + } + it("should generate person with address") { + val f = Faker() + val person: () -> Arb = { + Arb.randomClass.instance { + namedParameterGenerator("name") { f.name.name() } + namedParameterGenerator("age") { f.random.nextInt(20, 30) } + } + } + val address: () -> Arb
= { + Arb.randomClass.instance
{ + namedParameterGenerator("city") { f.address.city() } + namedParameterGenerator("streetName") { f.address.streetName() } + namedParameterGenerator("streetAddress") { f.address.streetAddress() } + } + } + forAll(person(), address()) { p: Person, a: Address -> + p.name.isNotBlank() + p.age in 20..30 + a.city.isNotBlank() + a.streetName.isNotBlank() + a.streetAddress.isNotBlank() + } + } + } +}) + +class Foo(val bar: Bar) + +class Bar(val s: String) + +class Person(val name: String, val age: Int) + +class Address(val city: String, val streetName: String, val streetAddress: String) + +/* +// pseudo-generated code below this line +// core faker +val Arb.Companion.faker get() = ArbFaker(Faker()) +val Faker.arb: ArbFaker get() = ArbFaker(this) + +class ArbFaker(faker: Faker) { + val address: ArbAddress by lazy { ArbAddress(faker.address) } + val name: ArbName by lazy { ArbName(faker.name) } +} + +class ArbAddress(private val address: Address) { + fun city(): Arb = arbitrary { address.city() } +} + +class ArbName(private val name: Name) { + fun name(): Arb = arbitrary { name.name() } +} + +// books faker +val Arb.Companion.booksFaker get() = ArbBooks(BooksFaker()) +val BooksFaker.arb: ArbBooks get() = ArbBooks(this) + +class ArbBooks(booksFaker: BooksFaker) { + val bible: ArbBible by lazy { ArbBible(booksFaker.bible) } +} + +class ArbBible(private val bible: Bible) { + fun character(): Arb = arbitrary { bible.character() } + + fun location(): Arb = arbitrary { bible.location() } + + fun quote(): Arb = arbitrary { bible.quote() } +} + +*/ diff --git a/extension/kotest-property/README.md b/extension/kotest-property/README.md new file mode 100644 index 000000000..4294a7ad7 --- /dev/null +++ b/extension/kotest-property/README.md @@ -0,0 +1,10 @@ +# `kotlin-faker-kotest-property` + +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-kotest-property?style=for-the-badge)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-kotest-property) +[![Sonatype Nexus (Snapshots)](https://img.shields.io/nexus/s/io.github.serpro69/kotlin-faker-kotest-property?label=snapshot-version&server=https%3A%2F%2Foss.sonatype.org&style=for-the-badge&color=yellow)](#downloading) + +Kotlin-faker `kotest-property` and [`kotest-property-ksp`](../kotest-property-ksp) artifacts provide faker-based [`Arb` generators](https://kotest.io/docs/proptest/property-test-generators.html) extensions via [KSP](https://kotlinlang.org/docs/ksp-overview.html) compiler plugin for [kotest property testing](https://kotest.io/docs/proptest/property-based-testing.html). + +## Usage + +Documentation for this extension is available at [serpro69.github.io/kotlin-faker/](https://serpro69.github.io/kotlin-faker/extensions/kotest-property-extension). diff --git a/extension/kotest-property/api/kotest-property.api b/extension/kotest-property/api/kotest-property.api new file mode 100644 index 000000000..019de3209 --- /dev/null +++ b/extension/kotest-property/api/kotest-property.api @@ -0,0 +1,12 @@ +public final class io/github/serpro69/kfaker/ArbExtensionsKt { + public static final fun getRandomClass (Lio/kotest/property/Arb$Companion;)Lio/github/serpro69/kfaker/RandomClassFaker; +} + +public final class io/github/serpro69/kfaker/RandomClassFaker { + public final fun getFaker ()Lio/github/serpro69/kfaker/Faker; +} + +public abstract interface annotation class io/github/serpro69/kfaker/kotest/FakerArb : java/lang/annotation/Annotation { + public abstract fun fakers ()[Ljava/lang/Class; +} + diff --git a/extension/kotest-property/build.gradle.kts b/extension/kotest-property/build.gradle.kts new file mode 100644 index 000000000..3f8a5c365 --- /dev/null +++ b/extension/kotest-property/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + `faker-ext-conventions` +} + +dependencies { + // used in FakerArb annotation (compileOnly so that we don't bring the transitive dependency) + compileOnly(projects.core) + implementation(libs.test.kotest.property) + // test + testImplementation(projects.core) // needed for tests since we have compileOnly dependency + testImplementation(libs.bundles.test.kotest) +} + +tasks.test { + useJUnitPlatform() + dependsOn(":core:shadowJar") + dependsOn(":faker:books:shadowJar") + dependsOn(":faker:edu:shadowJar") +} diff --git a/extension/kotest-property/src/main/kotlin/io/github/serpro69/kfaker/ArbExtensions.kt b/extension/kotest-property/src/main/kotlin/io/github/serpro69/kfaker/ArbExtensions.kt new file mode 100644 index 000000000..92d62a36b --- /dev/null +++ b/extension/kotest-property/src/main/kotlin/io/github/serpro69/kfaker/ArbExtensions.kt @@ -0,0 +1,14 @@ +package io.github.serpro69.kfaker + +import io.github.serpro69.kfaker.provider.misc.RandomProviderConfig +import io.kotest.property.Arb +import io.kotest.property.arbitrary.arbitrary + +val Arb.Companion.randomClass: RandomClassFaker + get() = RandomClassFaker(Faker()) + +class RandomClassFaker internal constructor(val faker: Faker) { + inline fun instance(crossinline configurator: RandomProviderConfig.() -> Unit = {}): Arb { + return arbitrary { faker.randomClass.randomClassInstance(configurator) } + } +} diff --git a/extension/kotest-property/src/main/kotlin/io/github/serpro69/kfaker/kotest/FakerArb.kt b/extension/kotest-property/src/main/kotlin/io/github/serpro69/kfaker/kotest/FakerArb.kt new file mode 100644 index 000000000..826a2d7df --- /dev/null +++ b/extension/kotest-property/src/main/kotlin/io/github/serpro69/kfaker/kotest/FakerArb.kt @@ -0,0 +1,8 @@ +package io.github.serpro69.kfaker.kotest + +import io.github.serpro69.kfaker.AbstractFaker +import kotlin.reflect.KClass + +@Retention(AnnotationRetention.SOURCE) +@Target(AnnotationTarget.PROPERTY, AnnotationTarget.FILE) +annotation class FakerArb(vararg val fakers: KClass) diff --git a/extension/kotest-property/src/test/kotlin/io/github/serpro69/kfaker/ArbExtensionsTest.kt b/extension/kotest-property/src/test/kotlin/io/github/serpro69/kfaker/ArbExtensionsTest.kt new file mode 100644 index 000000000..7e1968400 --- /dev/null +++ b/extension/kotest-property/src/test/kotlin/io/github/serpro69/kfaker/ArbExtensionsTest.kt @@ -0,0 +1,54 @@ +package io.github.serpro69.kfaker + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.property.Arb +import io.kotest.property.forAll + +class ArbExtensionsTest : DescribeSpec({ + describe("RandomClassProvider Arb extensions") { + context("Arb.randomClass.instance") { + it("should generate random class instance") { + forAll(Arb.randomClass.instance()) { foo: Foo -> foo.bar.s.isNotBlank() } + forAll( + Arb.randomClass.instance { + typeGenerator { "hello faker" } + }, + ) { + it.bar.s == "hello faker" + } + } + + it("should generate person with address") { + val f = Faker() + val person: () -> Arb = { + Arb.randomClass.instance { + namedParameterGenerator("name") { f.name.name() } + namedParameterGenerator("age") { f.random.nextInt(20, 30) } + } + } + val address: () -> Arb
= { + Arb.randomClass.instance
{ + namedParameterGenerator("city") { f.address.city() } + namedParameterGenerator("streetName") { f.address.streetName() } + namedParameterGenerator("streetAddress") { f.address.streetAddress() } + } + } + forAll(person(), address()) { p: Person, a: Address -> + p.name.isNotBlank() + p.age in 20..30 + a.city.isNotBlank() + a.streetName.isNotBlank() + a.streetAddress.isNotBlank() + } + } + } + } +}) + +class Foo(val bar: Bar) + +class Bar(val s: String) + +class Person(val name: String, val age: Int) + +class Address(val city: String, val streetName: String, val streetAddress: String) diff --git a/faker/README.md b/faker/README.md new file mode 100644 index 000000000..1bffafc1e --- /dev/null +++ b/faker/README.md @@ -0,0 +1,41 @@ +# `:faker` modules + +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-books?style=for-the-badge&label=kotlin-faker-books)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-books) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-commerce?style=for-the-badge&label=kotlin-faker-commerce)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-commerce) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-creatures?style=for-the-badge&label=kotlin-faker-creatures)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-creatures) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-databases?style=for-the-badge&label=kotlin-faker-databases)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-databases) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-edu?style=for-the-badge&label=kotlin-faker-edu)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-edu) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-games?style=for-the-badge&label=kotlin-faker-games)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-games) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-humor?style=for-the-badge&label=kotlin-faker-humor)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-humor) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-japmedia?style=for-the-badge&label=kotlin-faker-japmedia)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-japmedia) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-lorem?style=for-the-badge&label=kotlin-faker-lorem)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-lorem) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-misc?style=for-the-badge&label=kotlin-faker-misc)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-misc) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-movies?style=for-the-badge&label=kotlin-faker-movies)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-movies) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-music?style=for-the-badge&label=kotlin-faker-music)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-music) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-sports?style=for-the-badge&label=kotlin-faker-sports)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-sports) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-tech?style=for-the-badge&label=kotlin-faker-tech)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-tech) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-travel?style=for-the-badge&label=kotlin-faker-travel)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-travel) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.serpro69/kotlin-faker-tvshows?style=for-the-badge&label=kotlin-faker-tvshows)](https://search.maven.org/artifact/io.github.serpro69/kotlin-faker-tvshows) + +These modules contain "additional faker implementations" with fake data providers that generate domain-specific data which does not fit within the [core](../core) Faker. + +They are roughly divided into the following domains: + +- [books](books) +- [commerce](commerce) +- [creatures](creatures) +- [databases](databases) +- [edu](edu) +- [games](games) +- [humor](humor) +- [japmedia](japmedia) +- [lorem](lorem) +- [misc](misc) +- [movies](movies) +- [music](music) +- [sports](sports) +- [tech](tech) +- [travel](travel) +- [tvshows](tvshows) + +_NB! An additional faker module like `kotlin-faker-books` or `kotlin-faker-tvshows` requires the main `kotlin-faker` dependency to be on the classpath._ diff --git a/faker/api/faker.api b/faker/api/faker.api deleted file mode 100644 index e69de29bb..000000000 diff --git a/faker/build.gradle.kts b/faker/build.gradle.kts new file mode 100644 index 000000000..bf23338a5 --- /dev/null +++ b/faker/build.gradle.kts @@ -0,0 +1,32 @@ +import io.github.serpro69.semverkt.gradle.plugin.tasks.TagTask +import org.gradle.jvm.tasks.Jar + +plugins { +} + +// no sources for this module +sourceSets { + main.configure { + java { setSrcDirs(emptySet()) } + kotlin { setSrcDirs(emptySet()) } + resources { setSrcDirs(emptySet()) } + } + test.configure { + java { setSrcDirs(emptySet()) } + kotlin { setSrcDirs(emptySet()) } + resources { setSrcDirs(emptySet()) } + } +} + +// disable api validation tasks +tasks.apiBuild { enabled = false } +tasks.apiCheck { enabled = false } +tasks.apiDump { enabled = false } +// disable the default jar task +tasks.withType { enabled = false } +// never publish +tasks.withType { enabled = false } +// nothing to test in this module +tasks.withType { enabled = false } +// disable tag +tasks.withType { enabled = false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..2cd00af1d --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,60 @@ +[versions] +classgraph = "4.8.165" +commons-io = "2.15.1" +generex = "1.0.2" +icu4j = "73.2" +jackson = "2.15.3" +kotlin = "1.9.21" +kotlinpoet = "1.15.3" +ksp = "1.9.21-1.0.16" +# buildsrc and plugins +snakeyaml = "2.2" +plugin-dokka = "1.9.10" +plugin-shadow = "8.1.1" +plugin-semantic-versioning = "0.13.0" +# test +kotest = "5.8.1" +junit = "5.10.1" +kctfork = "0.4.1" + +[libraries] +commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" } +classgraph = { module = "io.github.classgraph:classgraph", version.ref = "classgraph" } +generex = { module = "com.github.mifmif:generex", version.ref = "generex" } +icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } +jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } +jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } +kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } +kotlin-stdlib-common = { module = "org.jetbrains.kotlin:kotlin-stdlib-common" } +kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" } +kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } +kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlinpoet" } +ksp = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } + +# Plugins and dependencies for use in buildSrc/build.gradle.kts +# Plugins are the *Maven coodinates* of Gradle plugins. +gradle-plugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +#gradlePlugin-ktLint = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint" } +gradle-plugin-dokka = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "plugin-dokka" } +gradle-plugin-semantic-versioning = { module = "io.github.serpro69:semantic-versioning", version.ref = "plugin-semantic-versioning" } +gradle-plugin-shadow = { module = "com.github.johnrengelman:shadow", version.ref = "plugin-shadow" } +snakeyaml = { module = "org.yaml:snakeyaml", version.ref = "snakeyaml" } + +# Test +test-junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } +test-kctfork = { module = "dev.zacsweers.kctfork:core", version.ref = "kctfork" } +test-kctfork-ksp = { module = "dev.zacsweers.kctfork:ksp", version.ref = "kctfork" } +test-kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } +test-kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" } +test-kotest-runner = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } + +[bundles] +gradle-plugins = ["gradle-plugin-kotlin", "gradle-plugin-dokka", "gradle-plugin-semantic-versioning", "gradle-plugin-shadow"] +jackson = ["jackson-databind", "jackson-module-kotlin"] +kotlin = ["kotlin-stdlib-common", "kotlin-stdlib-jdk8", "kotlin-reflect"] +kotlinpoet = ["kotlinpoet", "kotlinpoet-ksp"] +test-kotest = ["test-kotest-runner", "test-kotest-assertions-core", "test-kotest-property"] + +[plugins] +# import plugins using Maven coordinates (see above), not the Gradle plugin ID diff --git a/settings.gradle.kts b/settings.gradle.kts index 84d611d3f..685cad9c1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,51 +1,55 @@ import io.github.serpro69.semverkt.gradle.plugin.SemverPluginExtension -import io.github.serpro69.semverkt.release.configuration.CleanRule import io.github.serpro69.semverkt.release.configuration.TagPrefix -pluginManagement { - repositories { - mavenCentral() - gradlePluginPortal() - } -} +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +apply(from = "./buildSrc/repositories.settings.gradle.kts") plugins { - // NB! remember to set same version in buildSrc/build.gradle.kts:20 + // NB! remember to set same version in gradle/libs.versions.toml:10 id("io.github.serpro69.semantic-versioning") version "0.13.0" } rootProject.name = "kotlin-faker" include( - "bom", - "core", - "cli-bot", - "docs", + ":bom", + ":core", + ":cli-bot", + ":docs", ) -val fakers = listOf( - "books", - "commerce", - "creatures", - "databases", - "edu", - "games", - "humor", - "japmedia", - "lorem", - "misc", - "movies", - "music", - "sports", - "tech", - "travel", - "tvshows", -) +val extensions = + listOf( + "kotest-property", + "kotest-property-ksp", + "kotest-property-test", + ) +extensions.forEach { include(":extension:$it") } -fakers.forEach { include("faker:$it") } +val fakers = + listOf( + "books", + "commerce", + "creatures", + "databases", + "edu", + "games", + "humor", + "japmedia", + "lorem", + "misc", + "movies", + "music", + "sports", + "tech", + "travel", + "tvshows", + ) +fakers.forEach { include(":faker:$it") } // helpers for integration tests -include("test") +include(":test") settings.extensions.configure("semantic-versioning") { git { @@ -65,5 +69,12 @@ settings.extensions.configure("semantic-versioning") { } } } + extensions.filter { !it.endsWith("-test") }.forEach { e -> + module(":extension:$e") { + tag { + prefix = TagPrefix("ext-$e-v") + } + } + } } } diff --git a/test/build.gradle.kts b/test/build.gradle.kts index 676875318..79ba7fa2d 100644 --- a/test/build.gradle.kts +++ b/test/build.gradle.kts @@ -1,6 +1,4 @@ import io.github.serpro69.semverkt.gradle.plugin.tasks.TagTask -import kotlinx.validation.KotlinApiBuildTask -import kotlinx.validation.KotlinApiCompareTask plugins { } @@ -22,22 +20,16 @@ artifacts { add(testHelper.name, testJar) } +// disable api validation tasks +tasks.apiBuild { enabled = false } +tasks.apiCheck { enabled = false } +tasks.apiDump { enabled = false } // disable the default jar task -tasks.jar { - enabled = false -} - +tasks.jar { enabled = false } // never publish -tasks.withType { - enabled = false -} - +tasks.withType { enabled = false } // nothing to test in this module yet, // and we use test sources to produce artifacts... -tasks.withType { - enabled = false -} - -tasks.withType { - enabled = false -} +tasks.withType { enabled = false } +// disable tag +tasks.withType { enabled = false }