Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
plexus-interpolation = { module = "org.codehaus.plexus:plexus-interpolation", version = "1.29" }

# test
junit = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
Expand Down
1 change: 1 addition & 0 deletions junit5/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies {
implementation(libs.junit)
implementation(gradleTestKit())
implementation(libs.jacoco.core)
implementation(libs.plexus.interpolation)

testImplementation(libs.strikt.core)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@ import org.junit.jupiter.params.provider.ArgumentsProvider
import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
import java.util.stream.Stream
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.absolutePathString
import kotlin.io.path.appendText
import kotlin.io.path.copyToRecursively
import kotlin.io.path.createFile
import kotlin.io.path.createTempDirectory
import kotlin.io.path.createTempFile
Expand Down Expand Up @@ -138,7 +136,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()

Expand All @@ -161,7 +158,7 @@ class TestProjectExtension :
error { "expected a test project in $location" }
}

location.copyToRecursively(target = tempProjectDir, followLinks = false, overwrite = false)
TokenReplacer.copyAndReplace(location, tempProjectDir, gradleVersion.properties)

createProject(
tempProjectDir,
Expand Down
122 changes: 122 additions & 0 deletions junit5/src/main/kotlin/com/toasttab/gradle/testkit/TokenReplacer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* 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.codehaus.plexus.interpolation.MapBasedValueSource
import org.codehaus.plexus.interpolation.StringSearchInterpolator
import org.codehaus.plexus.interpolation.multi.MultiDelimiterInterpolatorFilterReader
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

object TokenReplacer {
private val fileTokens: Map<String, String> 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) }
}

// 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<PathMatcher> by lazy {
parseMatchers(System.getProperty("testkit-filter-includes"))
}

internal fun parseMatchers(spec: String?): List<PathMatcher> {
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<PathMatcher>
) = matchers.isEmpty() || matchers.any { it.matches(relative) || it.matches(relative.fileName) }

fun copyAndReplace(
source: Path,
target: Path,
extraTokens: Map<String, String>
) {
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<String, String>,
matchers: List<PathMatcher>
) {
if (tokens.isEmpty()) {
source.copyToRecursively(target = target, followLinks = false, overwrite = false)
return
}

val valueSource = MapBasedValueSource(tokens)

source.copyToRecursively(target = target, followLinks = false) { src, tgt ->
when {
!src.isRegularFile() -> Files.createDirectories(tgt)
shouldFilter(source.relativize(src), matchers) -> filterCopy(src, tgt, valueSource)
else -> src.copyTo(tgt, overwrite = false)
}
CopyActionResult.CONTINUE
}
}

private fun filterCopy(
source: Path,
target: Path,
valueSource: MapBasedValueSource
) {
// 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(source, StandardCharsets.ISO_8859_1).use { reader ->
val interpolator = StringSearchInterpolator("@", "@")
interpolator.addValueSource(valueSource)

val filtered =
MultiDelimiterInterpolatorFilterReader(reader, interpolator).apply {
addDelimiterSpec("@*@")
}

Files.newBufferedWriter(target, StandardCharsets.ISO_8859_1).use { writer ->
filtered.copyTo(writer)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package com.toasttab.gradle.testkit

import strikt.api.expectThat
import strikt.assertions.contains
import strikt.assertions.isEqualTo

@TestKit(
Expand All @@ -42,4 +43,22 @@ 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")
}
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
plugins {
java
}

// kotlin token: @kotlin@
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = "per-version-tokens"
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,28 @@

package com.toasttab.gradle.testkit

import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property

abstract class TestkitExtension {
abstract val testProjectsDir: Property<String>
abstract val replaceTokens: MapProperty<String, String>

// 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<String>

fun replaceToken(
name: String,
value: String
) {
replaceTokens.put(name, value)
}

fun filterIncludes(vararg globs: String) {
filterIncludes.addAll(*globs)
}
}
Loading
Loading