From 8f085d77c1f9586093ad49d0b21245e5a99a75b7 Mon Sep 17 00:00:00 2001 From: Oleg Golberg Date: Mon, 11 May 2026 10:22:27 -0400 Subject: [PATCH 1/6] Move test project token replacement from copy task to extension The testkit plugin previously used a Copy task with Ant ReplaceTokens to filter test projects into build/test-projects before TestProjectExtension copied them again into a temporary directory. This commit removes the intermediate copy: TestProjectExtension now copies straight from the source tree and performs token substitution in place using Apache Commons Text's StringSubstitutor (keeping the existing @TOKEN@ syntax). Tokens are passed to the extension via a properties file written by the plugin's writeTestkitTokens task (testkit-tokens system property). Per-version @GradleVersion(properties = [...]) values are layered on top at test-execution time and used as additional replacement tokens. Generated with Claude Code --- gradle/libs.versions.toml | 1 + junit5/build.gradle.kts | 1 + .../gradle/testkit/TestProjectExtension.kt | 2 + .../toasttab/gradle/testkit/TokenReplacer.kt | 63 +++++++++++++++++++ .../GradleVersionPropertiesIntegrationTest.kt | 15 +++++ .../build.gradle.kts | 5 ++ .../settings.gradle.kts | 1 + .../toasttab/gradle/testkit/TestkitPlugin.kt | 42 +++++-------- .../testkit/WriteTokensPropertiesTask.kt | 44 +++++++++++++ .../testkit/TestkitPluginIntegrationTest.kt | 13 ++-- 10 files changed, 157 insertions(+), 30 deletions(-) create mode 100644 junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt create mode 100644 junit5/src/test/projects/GradleVersionPropertiesIntegrationTest/per-version properties are used as replacement tokens/build.gradle.kts create mode 100644 junit5/src/test/projects/GradleVersionPropertiesIntegrationTest/per-version properties are used as replacement tokens/settings.gradle.kts create mode 100644 testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/WriteTokensPropertiesTask.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c0ed627..3090dfa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ spotless = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.re gradle-publish = { module = "com.gradle.publish:plugin-publish-plugin", version = "1.2.0" } jacoco-core = { module = "org.jacoco:org.jacoco.core", version.ref = "jacoco" } +commons-text = { module = "org.apache.commons:commons-text", version = "1.14.0" } # test junit = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } diff --git a/junit5/build.gradle.kts b/junit5/build.gradle.kts index ea16c8c..a4b0504 100644 --- a/junit5/build.gradle.kts +++ b/junit5/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(libs.junit) implementation(gradleTestKit()) implementation(libs.jacoco.core) + implementation(libs.commons.text) testImplementation(libs.strikt.core) } diff --git a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProjectExtension.kt b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProjectExtension.kt index 9592ec9..079a7db 100644 --- a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProjectExtension.kt +++ b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProjectExtension.kt @@ -151,6 +151,8 @@ class TestProjectExtension : location.copyToRecursively(target = tempProjectDir, followLinks = false, overwrite = false) + TokenReplacer.replaceInPlace(tempProjectDir, gradleVersion.properties) + createProject( tempProjectDir, gradleVersion, diff --git a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt new file mode 100644 index 0000000..86d56fa --- /dev/null +++ b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Toast Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.toasttab.gradle.testkit + +import org.apache.commons.text.StringSubstitutor +import java.nio.charset.MalformedInputException +import java.nio.file.Files +import java.nio.file.Path +import java.util.Properties +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText +import kotlin.io.path.writeText + +object TokenReplacer { + private val fileTokens: Map by lazy { + val path = System.getProperty("testkit-tokens") ?: return@lazy emptyMap() + val file = Path.of(path) + if (!Files.exists(file)) return@lazy emptyMap() + + val props = Properties() + file.toFile().inputStream().use { props.load(it) } + props.stringPropertyNames().associateWith { props.getProperty(it) } + } + + fun replaceInPlace( + root: Path, + extraTokens: Map + ) { + val tokens = if (extraTokens.isEmpty()) fileTokens else fileTokens + extraTokens + if (tokens.isEmpty()) return + + val substitutor = StringSubstitutor(tokens, "@", "@") + + Files.walk(root).use { stream -> + stream.filter { it.isRegularFile() }.forEach { path -> + val original = + try { + path.readText() + } catch (_: MalformedInputException) { + return@forEach + } + + val replaced = substitutor.replace(original) + if (replaced != original) { + path.writeText(replaced) + } + } + } + } +} diff --git a/junit5/src/test/kotlin/com/toasttab/gradle/testkit/GradleVersionPropertiesIntegrationTest.kt b/junit5/src/test/kotlin/com/toasttab/gradle/testkit/GradleVersionPropertiesIntegrationTest.kt index b69b218..7a1f9c3 100644 --- a/junit5/src/test/kotlin/com/toasttab/gradle/testkit/GradleVersionPropertiesIntegrationTest.kt +++ b/junit5/src/test/kotlin/com/toasttab/gradle/testkit/GradleVersionPropertiesIntegrationTest.kt @@ -16,6 +16,7 @@ package com.toasttab.gradle.testkit import strikt.api.expectThat +import strikt.assertions.contains import strikt.assertions.isEqualTo @TestKit( @@ -42,4 +43,18 @@ class GradleVersionPropertiesIntegrationTest { expectThat(project.property("kotlin")).isEqualTo(expectedKotlin) } + + @ParameterizedWithGradleVersions + fun `per-version properties are used as replacement tokens`(project: TestProject) { + val expectedKotlin = + when (project.gradleVersion.version) { + "8.6" -> "1.9.24" + "8.7" -> "2.0.0" + else -> error("unexpected gradle version ${project.gradleVersion.version}") + } + + val buildFile = project.dir.resolve("build.gradle.kts").toFile().readText() + + expectThat(buildFile).contains("// kotlin token: $expectedKotlin") + } } diff --git a/junit5/src/test/projects/GradleVersionPropertiesIntegrationTest/per-version properties are used as replacement tokens/build.gradle.kts b/junit5/src/test/projects/GradleVersionPropertiesIntegrationTest/per-version properties are used as replacement tokens/build.gradle.kts new file mode 100644 index 0000000..821501b --- /dev/null +++ b/junit5/src/test/projects/GradleVersionPropertiesIntegrationTest/per-version properties are used as replacement tokens/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + java +} + +// kotlin token: @kotlin@ diff --git a/junit5/src/test/projects/GradleVersionPropertiesIntegrationTest/per-version properties are used as replacement tokens/settings.gradle.kts b/junit5/src/test/projects/GradleVersionPropertiesIntegrationTest/per-version properties are used as replacement tokens/settings.gradle.kts new file mode 100644 index 0000000..633b00b --- /dev/null +++ b/junit5/src/test/projects/GradleVersionPropertiesIntegrationTest/per-version properties are used as replacement tokens/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "per-version-tokens" diff --git a/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt index 8c80630..c588c88 100644 --- a/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt +++ b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt @@ -17,16 +17,13 @@ package com.toasttab.gradle.testkit import com.toasttab.gradle.testkit.shared.configureIntegrationPublishing import com.toasttab.gradle.testkit.shared.integrationDirectory -import org.apache.tools.ant.filters.ReplaceTokens import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.type.ArtifactTypeDefinition import org.gradle.api.file.FileSystemOperations -import org.gradle.api.tasks.Copy import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.testing.Test import org.gradle.kotlin.dsl.create -import org.gradle.kotlin.dsl.filter import org.gradle.kotlin.dsl.named import org.gradle.kotlin.dsl.register import org.gradle.testing.jacoco.tasks.JacocoReportBase @@ -40,34 +37,28 @@ class TestkitPlugin override fun apply(project: Project) { val extension = project.extensions.create("testkitTests") val destfile = project.layout.buildDirectory.file("jacoco/testkit.exec") - val testProjectDir = project.layout.buildDirectory.dir("test-projects") + val tokensFile = project.layout.buildDirectory.file("testkit/tokens.properties") + val testProjectsDir = + extension.testProjectsDir + .map { project.layout.projectDirectory.dir(it) } + .orElse(project.layout.projectDirectory.dir("src/test/projects")) - project.tasks.register("copyTestProjects") { - from(extension.testProjectsDir.getOrElse("src/test/projects")) - into(testProjectDir) - - val tokens = - mapOf( - "TESTKIT_PLUGIN_VERSION" to BuildConfig.VERSION, - "TESTKIT_INTEGRATION_REPO" to project.integrationDirectory().path, - "VERSION" to "${project.version}" - ) + extension.replaceTokens.get() - - // default up-to-date checks ignore the tokens - inputs.property("tokens", tokens) - - filter( - mapOf("tokens" to tokens) - ) - } + val writeTokens = + project.tasks.register("writeTestkitTokens") { + tokens.putAll(extension.replaceTokens) + tokens.put("TESTKIT_PLUGIN_VERSION", BuildConfig.VERSION) + tokens.put("TESTKIT_INTEGRATION_REPO", project.integrationDirectory().path) + tokens.put("VERSION", project.provider { "${project.version}" }) + outputFile.set(tokensFile) + } project.tasks.named("test") { - dependsOn("copyTestProjects") + dependsOn(writeTokens) doFirst(JacocoOutputCleanupTestTaskAction(fs, destfile)) inputs - .dir(testProjectDir) + .dir(testProjectsDir) .withPropertyName("testkit-projects-input") .withPathSensitivity(PathSensitivity.RELATIVE) @@ -76,7 +67,8 @@ class TestkitPlugin outputs.file(destfile).withPropertyName("testkit-coverage-output") systemProperty("testkit-coverage-output", "${destfile.get()}") - systemProperty("testkit-projects", "${testProjectDir.get()}") + systemProperty("testkit-projects", testProjectsDir.get().asFile.path) + systemProperty("testkit-tokens", tokensFile.get().asFile.path) systemProperty("testkit-integration-repo", project.integrationDirectory().path) systemProperty("testkit-plugin-version", BuildConfig.VERSION) } diff --git a/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/WriteTokensPropertiesTask.kt b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/WriteTokensPropertiesTask.kt new file mode 100644 index 0000000..d06b094 --- /dev/null +++ b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/WriteTokensPropertiesTask.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Toast Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.toasttab.gradle.testkit + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import java.util.Properties + +abstract class WriteTokensPropertiesTask : DefaultTask() { + @get:Input + abstract val tokens: MapProperty + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun write() { + val props = Properties() + for ((key, value) in tokens.get()) { + props.setProperty(key, value) + } + + val file = outputFile.get().asFile + file.parentFile.mkdirs() + file.outputStream().use { props.store(it, null) } + } +} diff --git a/testkit-plugin/src/test/kotlin/com/toasttab/gradle/testkit/TestkitPluginIntegrationTest.kt b/testkit-plugin/src/test/kotlin/com/toasttab/gradle/testkit/TestkitPluginIntegrationTest.kt index 875b8f0..fefcd31 100644 --- a/testkit-plugin/src/test/kotlin/com/toasttab/gradle/testkit/TestkitPluginIntegrationTest.kt +++ b/testkit-plugin/src/test/kotlin/com/toasttab/gradle/testkit/TestkitPluginIntegrationTest.kt @@ -21,9 +21,10 @@ import org.junit.jupiter.api.io.TempDir import strikt.api.expectThat import strikt.assertions.isEqualTo import java.nio.file.Path +import java.util.Properties import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.copyToRecursively -import kotlin.io.path.readText +import kotlin.io.path.inputStream class TestkitPluginIntegrationTest { @TempDir @@ -31,7 +32,7 @@ class TestkitPluginIntegrationTest { @OptIn(ExperimentalPathApi::class) @Test - fun filtering() { + fun `tokens properties file is written with user and built-in tokens`() { Path.of(System.getProperty("test-projects")).copyToRecursively(target = dir, followLinks = false, overwrite = false) val projectDir = dir.resolve("TestkitPluginIntegrationTest/filtering") @@ -40,11 +41,13 @@ class TestkitPluginIntegrationTest { .create() .withProjectDir(projectDir.toFile()) .withPluginClasspath() - .withArguments("test") + .withArguments("writeTestkitTokens") .build() - val data = projectDir.resolve("build/test-projects/test-project/foo").readText().trim() + val props = Properties() + projectDir.resolve("build/testkit/tokens.properties").inputStream().use { props.load(it) } - expectThat(data).isEqualTo("hello world!") + expectThat(props.getProperty("VALUE")).isEqualTo("world!") + expectThat(props.getProperty("VERSION")).isEqualTo("1.0") } } From e6604262a15f3e2e3b1bbb0c1597bf55c23fb797 Mon Sep 17 00:00:00 2001 From: Oleg Golberg Date: Tue, 12 May 2026 09:23:51 -0400 Subject: [PATCH 2/6] Stream token replacement via plexus-interpolation Commons Text's StringSubstitutor required reading each file fully into memory. Switch to plexus-interpolation's MultiDelimiterInterpolatorFilterReader, which streams through a FilterReader (the same shape Ant's ReplaceTokens and Gradle's filter use). Use ISO-8859-1 for read/write so binary files in test projects pass through byte-for-byte; ASCII @KEY@ tokens still match. Generated with Claude Code --- gradle/libs.versions.toml | 2 +- junit5/build.gradle.kts | 2 +- .../toasttab/gradle/testkit/TokenReplacer.kt | 58 ++++++++++++++----- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3090dfa..4357888 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ spotless = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.re gradle-publish = { module = "com.gradle.publish:plugin-publish-plugin", version = "1.2.0" } jacoco-core = { module = "org.jacoco:org.jacoco.core", version.ref = "jacoco" } -commons-text = { module = "org.apache.commons:commons-text", version = "1.14.0" } +plexus-interpolation = { module = "org.codehaus.plexus:plexus-interpolation", version = "1.29" } # test junit = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } diff --git a/junit5/build.gradle.kts b/junit5/build.gradle.kts index a4b0504..92ca28d 100644 --- a/junit5/build.gradle.kts +++ b/junit5/build.gradle.kts @@ -18,7 +18,7 @@ dependencies { implementation(libs.junit) implementation(gradleTestKit()) implementation(libs.jacoco.core) - implementation(libs.commons.text) + implementation(libs.plexus.interpolation) testImplementation(libs.strikt.core) } diff --git a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt index 86d56fa..bbb6715 100644 --- a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt +++ b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt @@ -15,14 +15,17 @@ package com.toasttab.gradle.testkit -import org.apache.commons.text.StringSubstitutor -import java.nio.charset.MalformedInputException +import org.codehaus.plexus.interpolation.MapBasedValueSource +import org.codehaus.plexus.interpolation.StringSearchInterpolator +import org.codehaus.plexus.interpolation.multi.MultiDelimiterInterpolatorFilterReader +import java.io.BufferedReader +import java.io.BufferedWriter +import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path +import java.nio.file.StandardCopyOption import java.util.Properties import kotlin.io.path.isRegularFile -import kotlin.io.path.readText -import kotlin.io.path.writeText object TokenReplacer { private val fileTokens: Map by lazy { @@ -42,22 +45,47 @@ object TokenReplacer { val tokens = if (extraTokens.isEmpty()) fileTokens else fileTokens + extraTokens if (tokens.isEmpty()) return - val substitutor = StringSubstitutor(tokens, "@", "@") + val valueSource = MapBasedValueSource(tokens) Files.walk(root).use { stream -> - stream.filter { it.isRegularFile() }.forEach { path -> - val original = - try { - path.readText() - } catch (_: MalformedInputException) { - return@forEach + stream.filter { it.isRegularFile() }.forEach { path -> replaceInFile(path, valueSource) } + } + } + + private fun replaceInFile( + path: Path, + valueSource: MapBasedValueSource + ) { + val tmp = path.resolveSibling("${path.fileName}.tokens.tmp") + + // ISO-8859-1 round-trips every byte as a distinct char, so binary files pass through + // untouched and UTF-8 text files are preserved byte-for-byte. Only ASCII @KEY@ tokens + // need to match, and those map identically in both encodings. + Files.newBufferedReader(path, StandardCharsets.ISO_8859_1).use { reader -> + val interpolator = StringSearchInterpolator("@", "@") + interpolator.addValueSource(valueSource) + + val filtered = + BufferedReader( + MultiDelimiterInterpolatorFilterReader(reader, interpolator).apply { + addDelimiterSpec("@*@") } + ) - val replaced = substitutor.replace(original) - if (replaced != original) { - path.writeText(replaced) - } + Files.newBufferedWriter(tmp, StandardCharsets.ISO_8859_1).use { writer -> + filtered.copyTo(writer) } } + + Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING) + } + + private fun BufferedReader.copyTo(writer: BufferedWriter) { + val buf = CharArray(8192) + while (true) { + val n = read(buf) + if (n < 0) break + writer.write(buf, 0, n) + } } } From a638a3d44ccb6f578401fe06c757d6986b6e482d Mon Sep 17 00:00:00 2001 From: Oleg Golberg Date: Tue, 12 May 2026 09:29:10 -0400 Subject: [PATCH 3/6] Annotate WriteTokensPropertiesTask @CacheableTask and wire tokens file as a test input Also declare tokensFile as an input of the test task so that a token change invalidates the test task on its own, even when the source projects tree is unchanged. Generated with Claude Code --- .../main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt | 5 +++++ .../com/toasttab/gradle/testkit/WriteTokensPropertiesTask.kt | 2 ++ 2 files changed, 7 insertions(+) diff --git a/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt index c588c88..06ec147 100644 --- a/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt +++ b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt @@ -62,6 +62,11 @@ class TestkitPlugin .withPropertyName("testkit-projects-input") .withPathSensitivity(PathSensitivity.RELATIVE) + inputs + .file(tokensFile) + .withPropertyName("testkit-tokens-input") + .withPathSensitivity(PathSensitivity.NONE) + // declare an additional jacoco output file so that the JUnit JVM and the TestKit JVM // do not try to write to the same file outputs.file(destfile).withPropertyName("testkit-coverage-output") diff --git a/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/WriteTokensPropertiesTask.kt b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/WriteTokensPropertiesTask.kt index d06b094..e9106df 100644 --- a/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/WriteTokensPropertiesTask.kt +++ b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/WriteTokensPropertiesTask.kt @@ -18,11 +18,13 @@ package com.toasttab.gradle.testkit import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.MapProperty +import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.Input import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction import java.util.Properties +@CacheableTask abstract class WriteTokensPropertiesTask : DefaultTask() { @get:Input abstract val tokens: MapProperty From a82a9df8a3509ffc5740888016a06ea66bc5b7ee Mon Sep 17 00:00:00 2001 From: Oleg Golberg Date: Tue, 12 May 2026 11:54:58 -0400 Subject: [PATCH 4/6] Apply spotless formatting Generated with Claude Code --- .../testkit/GradleVersionPropertiesIntegrationTest.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/junit5/src/test/kotlin/com/toasttab/gradle/testkit/GradleVersionPropertiesIntegrationTest.kt b/junit5/src/test/kotlin/com/toasttab/gradle/testkit/GradleVersionPropertiesIntegrationTest.kt index 7a1f9c3..6103730 100644 --- a/junit5/src/test/kotlin/com/toasttab/gradle/testkit/GradleVersionPropertiesIntegrationTest.kt +++ b/junit5/src/test/kotlin/com/toasttab/gradle/testkit/GradleVersionPropertiesIntegrationTest.kt @@ -53,7 +53,11 @@ class GradleVersionPropertiesIntegrationTest { else -> error("unexpected gradle version ${project.gradleVersion.version}") } - val buildFile = project.dir.resolve("build.gradle.kts").toFile().readText() + val buildFile = + project.dir + .resolve("build.gradle.kts") + .toFile() + .readText() expectThat(buildFile).contains("// kotlin token: $expectedKotlin") } From f62c35534678f53274a886efeafb04db730fd69a Mon Sep 17 00:00:00 2001 From: Oleg Golberg Date: Tue, 12 May 2026 20:53:01 -0400 Subject: [PATCH 5/6] Fold token replacement into the recursive copy Replace the copy-then-walk-then-rewrite pipeline with a single copyToRecursively pass whose copyAction streams each regular file through the InterpolatorFilterReader straight to its destination. Removes the second filesystem walk and the .tokens.tmp rename per file. Generated with Claude Code --- .../gradle/testkit/TestProjectExtension.kt | 7 +--- .../toasttab/gradle/testkit/TokenReplacer.kt | 38 ++++++++++++------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProjectExtension.kt b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProjectExtension.kt index 079a7db..536ba86 100644 --- a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProjectExtension.kt +++ b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProjectExtension.kt @@ -29,9 +29,7 @@ import java.nio.file.Path import java.util.Optional import java.util.concurrent.ConcurrentHashMap import java.util.stream.Stream -import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.appendText -import kotlin.io.path.copyToRecursively import kotlin.io.path.createFile import kotlin.io.path.createTempDirectory import kotlin.io.path.exists @@ -126,7 +124,6 @@ class TestProjectExtension : noinline f: (K) -> V ) = getOrComputeIfAbsent(key, f, V::class.java) - @OptIn(ExperimentalPathApi::class) private fun ExtensionContext.project(gradleVersion: GradleVersionArgument): TestProject { val parameters = requiredTestClass.getAnnotation(TestKit::class.java) ?: TestKit() @@ -149,9 +146,7 @@ class TestProjectExtension : error { "expected a test project in $location" } } - location.copyToRecursively(target = tempProjectDir, followLinks = false, overwrite = false) - - TokenReplacer.replaceInPlace(tempProjectDir, gradleVersion.properties) + TokenReplacer.copyAndReplace(location, tempProjectDir, gradleVersion.properties) createProject( tempProjectDir, diff --git a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt index bbb6715..7b2634a 100644 --- a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt +++ b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt @@ -23,8 +23,10 @@ import java.io.BufferedWriter import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path -import java.nio.file.StandardCopyOption import java.util.Properties +import kotlin.io.path.CopyActionResult +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.copyToRecursively import kotlin.io.path.isRegularFile object TokenReplacer { @@ -38,30 +40,40 @@ object TokenReplacer { props.stringPropertyNames().associateWith { props.getProperty(it) } } - fun replaceInPlace( - root: Path, + @OptIn(ExperimentalPathApi::class) + fun copyAndReplace( + source: Path, + target: Path, extraTokens: Map ) { val tokens = if (extraTokens.isEmpty()) fileTokens else fileTokens + extraTokens - if (tokens.isEmpty()) return + + if (tokens.isEmpty()) { + source.copyToRecursively(target = target, followLinks = false, overwrite = false) + return + } val valueSource = MapBasedValueSource(tokens) - Files.walk(root).use { stream -> - stream.filter { it.isRegularFile() }.forEach { path -> replaceInFile(path, valueSource) } + source.copyToRecursively(target = target, followLinks = false) { src, tgt -> + if (src.isRegularFile()) { + filterCopy(src, tgt, valueSource) + } else { + Files.createDirectories(tgt) + } + CopyActionResult.CONTINUE } } - private fun replaceInFile( - path: Path, + private fun filterCopy( + source: Path, + target: Path, valueSource: MapBasedValueSource ) { - val tmp = path.resolveSibling("${path.fileName}.tokens.tmp") - // ISO-8859-1 round-trips every byte as a distinct char, so binary files pass through // untouched and UTF-8 text files are preserved byte-for-byte. Only ASCII @KEY@ tokens // need to match, and those map identically in both encodings. - Files.newBufferedReader(path, StandardCharsets.ISO_8859_1).use { reader -> + Files.newBufferedReader(source, StandardCharsets.ISO_8859_1).use { reader -> val interpolator = StringSearchInterpolator("@", "@") interpolator.addValueSource(valueSource) @@ -72,12 +84,10 @@ object TokenReplacer { } ) - Files.newBufferedWriter(tmp, StandardCharsets.ISO_8859_1).use { writer -> + Files.newBufferedWriter(target, StandardCharsets.ISO_8859_1).use { writer -> filtered.copyTo(writer) } } - - Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING) } private fun BufferedReader.copyTo(writer: BufferedWriter) { From 3cd6db382701395e77694652c6aa187c4ceaa1b5 Mon Sep 17 00:00:00 2001 From: Oleg Golberg Date: Thu, 2 Jul 2026 12:44:43 -0400 Subject: [PATCH 6/6] Use stdlib copy and make token filtering configurable Replace the hand-rolled BufferedReader.copyTo with kotlin.io.copyTo and add a filterIncludes glob option so token replacement can be narrowed to the build scripts (e.g. *.gradle.kts), copying other files verbatim. Generated with Claude Code --- .../toasttab/gradle/testkit/TokenReplacer.kt | 63 ++++++++++----- .../testkit/TokenReplacerIntegrationTest.kt | 81 +++++++++++++++++++ .../gradle/testkit/TokenReplacerTest.kt | 61 ++++++++++++++ .../gradle/testkit/TestkitExtension.kt | 11 +++ .../toasttab/gradle/testkit/TestkitPlugin.kt | 6 ++ 5 files changed, 201 insertions(+), 21 deletions(-) create mode 100644 junit5/src/test/kotlin/com/toasttab/gradle/testkit/TokenReplacerIntegrationTest.kt create mode 100644 junit5/src/test/kotlin/com/toasttab/gradle/testkit/TokenReplacerTest.kt diff --git a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt index 7b2634a..5350d89 100644 --- a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt +++ b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt @@ -18,14 +18,15 @@ package com.toasttab.gradle.testkit import org.codehaus.plexus.interpolation.MapBasedValueSource import org.codehaus.plexus.interpolation.StringSearchInterpolator import org.codehaus.plexus.interpolation.multi.MultiDelimiterInterpolatorFilterReader -import java.io.BufferedReader -import java.io.BufferedWriter import java.nio.charset.StandardCharsets +import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.Path +import java.nio.file.PathMatcher import java.util.Properties import kotlin.io.path.CopyActionResult import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.copyTo import kotlin.io.path.copyToRecursively import kotlin.io.path.isRegularFile @@ -40,14 +41,45 @@ object TokenReplacer { props.stringPropertyNames().associateWith { props.getProperty(it) } } - @OptIn(ExperimentalPathApi::class) + // Globs restricting which files are interpolated. Empty means every file is filtered; + // otherwise files that match no glob are copied verbatim. Each glob is matched against both + // the path relative to the test project root and the bare file name, so "*.gradle.kts" filters + // build scripts at any depth. Usually narrowed to the build scripts, e.g. + // "*.gradle.kts,*.gradle". + private val filterMatchers: List by lazy { + parseMatchers(System.getProperty("testkit-filter-includes")) + } + + internal fun parseMatchers(spec: String?): List { + if (spec == null) return emptyList() + return spec + .split(',', '\n') + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { FileSystems.getDefault().getPathMatcher("glob:$it") } + } + + internal fun shouldFilter( + relative: Path, + matchers: List + ) = matchers.isEmpty() || matchers.any { it.matches(relative) || it.matches(relative.fileName) } + fun copyAndReplace( source: Path, target: Path, extraTokens: Map ) { val tokens = if (extraTokens.isEmpty()) fileTokens else fileTokens + extraTokens + copyAndReplace(source, target, tokens, filterMatchers) + } + @OptIn(ExperimentalPathApi::class) + internal fun copyAndReplace( + source: Path, + target: Path, + tokens: Map, + matchers: List + ) { if (tokens.isEmpty()) { source.copyToRecursively(target = target, followLinks = false, overwrite = false) return @@ -56,10 +88,10 @@ object TokenReplacer { val valueSource = MapBasedValueSource(tokens) source.copyToRecursively(target = target, followLinks = false) { src, tgt -> - if (src.isRegularFile()) { - filterCopy(src, tgt, valueSource) - } else { - Files.createDirectories(tgt) + when { + !src.isRegularFile() -> Files.createDirectories(tgt) + shouldFilter(source.relativize(src), matchers) -> filterCopy(src, tgt, valueSource) + else -> src.copyTo(tgt, overwrite = false) } CopyActionResult.CONTINUE } @@ -78,24 +110,13 @@ object TokenReplacer { interpolator.addValueSource(valueSource) val filtered = - BufferedReader( - MultiDelimiterInterpolatorFilterReader(reader, interpolator).apply { - addDelimiterSpec("@*@") - } - ) + MultiDelimiterInterpolatorFilterReader(reader, interpolator).apply { + addDelimiterSpec("@*@") + } Files.newBufferedWriter(target, StandardCharsets.ISO_8859_1).use { writer -> filtered.copyTo(writer) } } } - - private fun BufferedReader.copyTo(writer: BufferedWriter) { - val buf = CharArray(8192) - while (true) { - val n = read(buf) - if (n < 0) break - writer.write(buf, 0, n) - } - } } diff --git a/junit5/src/test/kotlin/com/toasttab/gradle/testkit/TokenReplacerIntegrationTest.kt b/junit5/src/test/kotlin/com/toasttab/gradle/testkit/TokenReplacerIntegrationTest.kt new file mode 100644 index 0000000..a172397 --- /dev/null +++ b/junit5/src/test/kotlin/com/toasttab/gradle/testkit/TokenReplacerIntegrationTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Toast Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.toasttab.gradle.testkit + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import strikt.api.expectThat +import strikt.assertions.containsExactly +import strikt.assertions.isEqualTo +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.readBytes +import kotlin.io.path.readText +import kotlin.io.path.writeBytes +import kotlin.io.path.writeText + +class TokenReplacerIntegrationTest { + @TempDir + lateinit var source: Path + + @TempDir + lateinit var target: Path + + // 0x00..0xFF, including bytes that are not valid UTF-8, so we prove binary files round-trip. + private val binary = ByteArray(256) { it.toByte() } + + @Test + fun `interpolates matching files while copying others verbatim`() { + source.resolve("build.gradle.kts").writeText("kotlin = \"@kotlin@\"") + source.resolve("nested").createDirectories() + source.resolve("nested/settings.gradle.kts").writeText("name = \"@name@\"") + source.resolve("gradle.properties").writeText("kept = @kotlin@") + source.resolve("logo.png").writeBytes(binary) + + TokenReplacer.copyAndReplace( + source, + target, + tokens = mapOf("kotlin" to "2.3.21", "name" to "demo"), + matchers = TokenReplacer.parseMatchers("*.gradle.kts") + ) + + // build scripts at any depth have their tokens replaced + expectThat(target.resolve("build.gradle.kts").readText()).isEqualTo("kotlin = \"2.3.21\"") + expectThat(target.resolve("nested/settings.gradle.kts").readText()).isEqualTo("name = \"demo\"") + + // files that match no glob are copied verbatim, tokens untouched + expectThat(target.resolve("gradle.properties").readText()).isEqualTo("kept = @kotlin@") + + // binary files round-trip byte-for-byte + expectThat(target.resolve("logo.png").readBytes().toList()).containsExactly(binary.toList()) + } + + @Test + fun `no matchers filters every file`() { + source.resolve("build.gradle.kts").writeText("kotlin = \"@kotlin@\"") + source.resolve("gradle.properties").writeText("kept = @kotlin@") + + TokenReplacer.copyAndReplace( + source, + target, + tokens = mapOf("kotlin" to "2.3.21"), + matchers = emptyList() + ) + + expectThat(target.resolve("build.gradle.kts").readText()).isEqualTo("kotlin = \"2.3.21\"") + expectThat(target.resolve("gradle.properties").readText()).isEqualTo("kept = 2.3.21") + } +} diff --git a/junit5/src/test/kotlin/com/toasttab/gradle/testkit/TokenReplacerTest.kt b/junit5/src/test/kotlin/com/toasttab/gradle/testkit/TokenReplacerTest.kt new file mode 100644 index 0000000..29555db --- /dev/null +++ b/junit5/src/test/kotlin/com/toasttab/gradle/testkit/TokenReplacerTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Toast Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.toasttab.gradle.testkit + +import org.junit.jupiter.api.Test +import strikt.api.expectThat +import strikt.assertions.isEmpty +import strikt.assertions.isFalse +import strikt.assertions.isTrue +import java.nio.file.Path + +class TokenReplacerTest { + @Test + fun `no includes configured filters every file`() { + val matchers = TokenReplacer.parseMatchers(null) + + expectThat(matchers).isEmpty() + expectThat(TokenReplacer.shouldFilter(Path.of("anything.bin"), matchers)).isTrue() + expectThat(TokenReplacer.shouldFilter(Path.of("a/b/c.gradle.kts"), matchers)).isTrue() + } + + @Test + fun `blank includes filters every file`() { + val matchers = TokenReplacer.parseMatchers(" , \n ") + + expectThat(matchers).isEmpty() + expectThat(TokenReplacer.shouldFilter(Path.of("anything.bin"), matchers)).isTrue() + } + + @Test + fun `bare name globs match files at any depth`() { + val matchers = TokenReplacer.parseMatchers("*.gradle.kts, *.gradle") + + expectThat(TokenReplacer.shouldFilter(Path.of("build.gradle.kts"), matchers)).isTrue() + expectThat(TokenReplacer.shouldFilter(Path.of("sub/build.gradle.kts"), matchers)).isTrue() + expectThat(TokenReplacer.shouldFilter(Path.of("settings.gradle"), matchers)).isTrue() + expectThat(TokenReplacer.shouldFilter(Path.of("gradle.properties"), matchers)).isFalse() + expectThat(TokenReplacer.shouldFilter(Path.of("src/main/Foo.kt"), matchers)).isFalse() + } + + @Test + fun `path globs match against the relative path`() { + val matchers = TokenReplacer.parseMatchers("**/*.gradle.kts") + + expectThat(TokenReplacer.shouldFilter(Path.of("sub/build.gradle.kts"), matchers)).isTrue() + expectThat(TokenReplacer.shouldFilter(Path.of("src/main/Foo.kt"), matchers)).isFalse() + } +} diff --git a/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitExtension.kt b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitExtension.kt index 3f21da2..4ff8e13 100644 --- a/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitExtension.kt +++ b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitExtension.kt @@ -15,6 +15,7 @@ package com.toasttab.gradle.testkit +import org.gradle.api.provider.ListProperty import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property @@ -22,10 +23,20 @@ abstract class TestkitExtension { abstract val testProjectsDir: Property abstract val replaceTokens: MapProperty + // Globs restricting which files have their tokens replaced. When empty, every file is filtered. + // Each glob is matched against both the path relative to the test project root and the bare + // file name, so filterIncludes("*.gradle.kts", "*.gradle") narrows filtering to the build + // scripts at any depth. + abstract val filterIncludes: ListProperty + fun replaceToken( name: String, value: String ) { replaceTokens.put(name, value) } + + fun filterIncludes(vararg globs: String) { + filterIncludes.addAll(*globs) + } } diff --git a/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt index 06ec147..fd66967 100644 --- a/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt +++ b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt @@ -52,11 +52,16 @@ class TestkitPlugin outputFile.set(tokensFile) } + val filterIncludes = extension.filterIncludes.map { it.joinToString(",") }.orElse("") + project.tasks.named("test") { dependsOn(writeTokens) doFirst(JacocoOutputCleanupTestTaskAction(fs, destfile)) + inputs + .property("testkit-filter-includes", filterIncludes) + inputs .dir(testProjectsDir) .withPropertyName("testkit-projects-input") @@ -74,6 +79,7 @@ class TestkitPlugin systemProperty("testkit-coverage-output", "${destfile.get()}") systemProperty("testkit-projects", testProjectsDir.get().asFile.path) systemProperty("testkit-tokens", tokensFile.get().asFile.path) + systemProperty("testkit-filter-includes", filterIncludes.get()) systemProperty("testkit-integration-repo", project.integrationDirectory().path) systemProperty("testkit-plugin-version", BuildConfig.VERSION) }