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
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright 2026 Google LLC
* Copyright 2010-2026 JetBrains s.r.o. and Kotlin Programming Language contributors.
*
* 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.google.devtools.ksp.test.secondary

import com.google.devtools.ksp.test.fixtures.TemporaryTestProject
import org.gradle.testkit.runner.GradleRunner
import org.gradle.testkit.runner.TaskOutcome
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import java.io.File

@RunWith(Parameterized::class)
class IncrementalAnnotationArgumentClassReferencesJava(experimentalPsiResolution: Boolean) {

@Rule
@JvmField
val project: TemporaryTestProject = TemporaryTestProject(
"incremental-annotation-argument-class-references-java",
experimentalPsiResolution = experimentalPsiResolution
)

companion object {

@JvmStatic
@Parameterized.Parameters
fun data(): Collection<Boolean> = listOf(true, false)

private const val TARGET: String = "downstream"
private const val KSP_KOTLIN: String = ":$TARGET:kspKotlin"
private const val ASSEMBLE: String = "assemble"
private const val CLEAN: String = "clean"
private const val PROCESSOR_LABEL: String = "[TestProcessor]"
private const val GENERATED_FILE: String =
"downstream/build/generated/ksp/main/kotlin/DownstreamClassGenerated.kt"
}

@Test
fun testUpToDate() {
val gradleRunner = GradleRunner.create().withProjectDir(project.root)

gradleRunner.withArguments(CLEAN, ASSEMBLE).build().let { result ->
Assert.assertEquals(TaskOutcome.SUCCESS, result.task(":$TARGET:assemble")?.outcome)
}

gradleRunner.withArguments(ASSEMBLE).build().let { result ->
Assert.assertEquals(TaskOutcome.UP_TO_DATE, result.task(KSP_KOTLIN)?.outcome)
}
}

@Test
fun testTransitiveJavaClasspathChange() {
val gradleRunner = GradleRunner.create().withProjectDir(project.root)
// 1. Clean build
gradleRunner.withArguments(CLEAN, ASSEMBLE).build().let { result ->
Assert.assertEquals(TaskOutcome.SUCCESS, result.task(KSP_KOTLIN)?.outcome)
assertProcessed(result.output, "the clean build")
Assert.assertTrue(
"Expected the generated file to record the declarations visited by the processor",
File(project.root, GENERATED_FILE).readText().contains("UpstreamChanges")
)
}

// 2. Apply an ABI change to the upstream Java class that only the classpath refers to
val fileToChange = File(project.root, "upstream/src/main/java/com/example/upstream/UpstreamChanges.java")
val addedMember = " public void addedMember() {\n }\n}\n"
fileToChange.writeText(fileToChange.readText().trimEnd().removeSuffix("}") + addedMember)

// 3. Rebuild
gradleRunner.withArguments(ASSEMBLE).build().let { result ->
Assert.assertEquals(TaskOutcome.SUCCESS, result.task(KSP_KOTLIN)?.outcome)
// Bug: the downstream sources are wrongly considered up to date and nothing is
// reprocessed, because invalidation stops at the class file path of the changed class.
Assert.assertEquals(
emptyList<String>(),
result.output.lines().filter { it.startsWith(PROCESSOR_LABEL) }
)
Assert.assertFalse(
"Expected the generated file to remain stale",
File(project.root, GENERATED_FILE).readText().contains("addedMember")
)
}
}

private fun assertProcessed(output: String, buildDescription: String) {
val processed = output.lines().filter { it.startsWith(PROCESSOR_LABEL) }
listOf("DownstreamClass", "UpstreamEntryPoint", "UpstreamChanges").forEach { simpleName ->
Assert.assertTrue(
"Expected $simpleName to be processed by $buildDescription, but the processor reported:\n" +
processed.joinToString("\n"),
processed.contains("$PROCESSOR_LABEL Processing $simpleName")
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright 2026 Google LLC
* Copyright 2010-2026 JetBrains s.r.o. and Kotlin Programming Language contributors.
*
* 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.
*/

plugins {
kotlin("jvm")
}

repositories {
maven("https://redirector.kotlinlang.org/maven/bootstrap/")
mavenCentral()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright 2026 Google LLC
* Copyright 2010-2026 JetBrains s.r.o. and Kotlin Programming Language contributors.
*
* 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.
*/

val testRepo: String by project

plugins {
id("com.google.devtools.ksp")
kotlin("jvm")
}

version = "1.0-SNAPSHOT"

repositories {
maven(testRepo)
maven("https://redirector.kotlinlang.org/maven/bootstrap/")
mavenCentral()
}

dependencies {
implementation(project(":upstream"))
ksp(project(":processor"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright 2026 Google LLC
* Copyright 2010-2026 JetBrains s.r.o. and Kotlin Programming Language contributors.
*
* 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.example.upstream

@ExampleReference(UpstreamEntryPoint::class)
class DownstreamClass
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright 2026 Google LLC
* Copyright 2010-2026 JetBrains s.r.o. and Kotlin Programming Language contributors.
*
* 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.
*/

val kspVersion: String by project
val testRepo: String by project

plugins {
kotlin("jvm")
}

group = "com.example"
version = "1.0-SNAPSHOT"

repositories {
maven(testRepo)
maven("https://redirector.kotlinlang.org/maven/bootstrap/")
mavenCentral()
}

dependencies {
implementation("com.google.devtools.ksp:symbol-processing-api:$kspVersion")
}

sourceSets.main {
java.srcDirs("src/main/kotlin")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* Copyright 2026 Google LLC
* Copyright 2010-2026 JetBrains s.r.o. and Kotlin Programming Language contributors.
*
* 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.
*/

import com.google.devtools.ksp.processing.Dependencies
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 com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSDeclaration
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
import com.google.devtools.ksp.symbol.KSPropertyDeclaration
import com.google.devtools.ksp.symbol.KSNode
import com.google.devtools.ksp.symbol.KSType

class TestProcessor(val environment: SymbolProcessorEnvironment) : SymbolProcessor {
private val createdFiles = mutableSetOf<String>()
override fun process(resolver: Resolver): List<KSAnnotated> {
resolver.getSymbolsWithAnnotation("com.example.upstream.ExampleReference")
.filterIsInstance<KSClassDeclaration>().forEach { sym ->
// Follow dependencies to let KSP capture dependency graph
val visited = followSym(sym)

val fileName = "${sym.simpleName.asString()}Generated"

if (!createdFiles.contains(fileName)) {
createdFiles.add(fileName)
val file = environment.codeGenerator.createNewFile(
Dependencies(false, sym.containingFile!!),
"",
fileName
)
file.write(
("// visited: ${visited.joinToString()}\n" +
"class ${sym.simpleName.asString()}Generated").toByteArray()
)
}
}
// Do not keep KSNodes across rounds
seen.clear()
return emptyList()
}

private val seen = mutableListOf<KSNode>()
private val queue = mutableListOf<KSNode>()

private fun followSym(sym: KSAnnotated): List<String> {
val visited = mutableListOf<String>()
if (!seen.contains(sym)) {
queue.add(sym)
}
while (queue.isNotEmpty()) {
val node = queue.removeFirst()
log("Processing $node")
visited.add(node.toString())
seen.add(node)
when (node) {
is KSDeclaration -> {
followAnnotations(node)
followDeclaration(node)
}

is KSAnnotated -> {
followAnnotations(node)
}

else -> Unit
}
}
return visited
}

private fun followAnnotations(sym: KSAnnotated) {
sym.annotations.forEach { anno ->
anno.arguments.forEach { annoArg ->
annoArg.value?.let { value ->
when (value) {
is List<*> -> {
value.filterIsInstance<KSType>()
.map { ksType -> ksType.declaration }
.filterNot(seen::contains)
.forEach(queue::add)
}

is KSType -> {
val decl = value.declaration
if (!seen.contains(decl)) {
queue.add(decl)
}
}

else -> {
return@forEach
}
}
}
}
}
}

private fun followDeclaration(ksDecl: KSDeclaration): Unit = when (ksDecl) {
is KSClassDeclaration -> {
ksDecl.declarations
.filterNot(seen::contains)
.filter { it !is KSPropertyDeclaration }
.forEach(queue::add)
}

is KSFunctionDeclaration -> {
ksDecl.returnType?.resolve()?.declaration?.let {
if (!seen.contains(it)) {
queue.add(it)
}
}
ksDecl.parameters
.map { it.type.resolve().declaration }
.filterNot(seen::contains)
.forEach(queue::add)
}

else -> Unit
}

private fun log(msg: Any) {
println("[TestProcessor] $msg")
}
}
Loading