Skip to content

Commit fb70d2a

Browse files
BlaBlaHumanSpace Team
authored andcommitted
[Analysis API] Implement a PSI-based test checking for missing context parameter bridges
^KT-78093
1 parent 149d8a3 commit fb70d2a

File tree

3 files changed

+148
-0
lines changed

3 files changed

+148
-0
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
org.jetbrains.kotlin.analysis.api.components.KaOriginalPsiProvider.getOriginalDeclaration()
2+
org.jetbrains.kotlin.analysis.api.components.KaOriginalPsiProvider.getOriginalKtFile()
3+
org.jetbrains.kotlin.analysis.api.components.KaOriginalPsiProvider.recordOriginalDeclaration(KtDeclaration)
4+
org.jetbrains.kotlin.analysis.api.components.KaOriginalPsiProvider.recordOriginalKtFile(KtFile)
5+
org.jetbrains.kotlin.analysis.api.components.KaTypeInformationProvider.canBeNull
6+
org.jetbrains.kotlin.analysis.api.components.KaTypeProvider.defaultType
7+
org.jetbrains.kotlin.analysis.api.components.KaTypeProvider.withNullability(org.jetbrains.kotlin.analysis.api.types.KaTypeNullability)
8+
org.jetbrains.kotlin.analysis.api.components.KaVisibilityChecker.isVisible(KaDeclarationSymbol, KaFileSymbol, KtExpression?, PsiElement)

analysis/analysis-api/src/org/jetbrains/kotlin/analysis/api/components/KaSessionComponent.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ import org.jetbrains.kotlin.analysis.api.lifetime.KaLifetimeOwner
3131
* While [symbol][org.jetbrains.kotlin.analysis.api.symbols.KaSymbolProvider.symbol] is actually a property from the [KaSymbolProvider][org.jetbrains.kotlin.analysis.api.symbols.KaSymbolProvider]
3232
* session component, it is usable directly in the [KaSession][org.jetbrains.kotlin.analysis.api.KaSession] context because the property has
3333
* been mixed into the session.
34+
*
35+
* All public API components inherited from [KaSessionComponent] are expected to be direct children of [KaSessionComponent].
36+
* That's required for the correctness of the Analysis API context parameter bridge checker, which ensures that each API endpoint from
37+
* session components has a corresponding context parameter bridge in the same file.
3438
*/
3539
@SubclassOptInRequired(KaImplementationDetail::class)
3640
public interface KaSessionComponent : KaLifetimeOwner
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* Copyright 2010-2025 JetBrains s.r.o. and Kotlin Programming Language contributors.
3+
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
4+
*/
5+
6+
package org.jetbrains.kotlin.analysis.api.test
7+
8+
import com.intellij.psi.PsiFile
9+
import org.jetbrains.kotlin.AbstractAnalysisApiCodebaseDumpFileComparisonTest
10+
import org.jetbrains.kotlin.psi.*
11+
import org.jetbrains.kotlin.psi.psiUtil.isPublic
12+
import org.jetbrains.kotlin.utils.addToStdlib.ifNotEmpty
13+
import org.junit.jupiter.api.Test
14+
15+
/**
16+
* This test was introduced to automatically check that every public API
17+
* from some [org.jetbrains.kotlin.analysis.api.components.KaSessionComponent] has a corresponding context parameter bridge in the same file.
18+
*
19+
* See KT-78093 Add bridges for context parameters
20+
*
21+
* The test iterates through all the source directories [sourceDirectories] and
22+
* for each directory [SourceDirectory.sourcePaths] builds a separate resulting file
23+
* containing all public API endpoints with no context parameter bridge along with fully qualified names and parameter types.
24+
*
25+
* Then the test compares the contents of the resulting file
26+
* and the master file [SourceDirectory.ForDumpFileComparison.outputFilePath]
27+
*
28+
* The test is intended to prevent developers from not implementing context parameter bridges,
29+
* as it's a vital feature for the users' experience.
30+
* If the lack of a context parameters bridge for some declaration is intentional,
31+
* the developer has to manually add this declaration to the master file.
32+
*
33+
* The test works as follows:
34+
* 1. For each file, the test finds all classes that are subtypes of [org.jetbrains.kotlin.analysis.api.components.KaSessionComponent] and collects all public members from them.
35+
* 2. In the exact same file it collects all top-level callable declarations that have [org.jetbrains.kotlin.analysis.api.KaContextParameterApi] annotation,
36+
* which marks them as context parameter bridges.
37+
* 3. For each member declaration from step 1, it checks if there's a corresponding context parameter bridge in the same file.
38+
* The test checks that the context parameter of the bridge matches the type of [org.jetbrains.kotlin.analysis.api.components.KaSessionComponent] the member belongs to
39+
* and then checks that their signatures are equivalent.
40+
* 4. If there's no corresponding context parameter bridge found, the test adds the member to the resulting file.
41+
*
42+
* The test also checks that there are no unused context parameter bridges, i.e., a bridge that doesn't have a pairing member declaration
43+
* and thus points to itself. If such a bridge is found, the test throws an exception.
44+
*
45+
* The test relies on two fundamental assumptions:
46+
* 1. All public children of [org.jetbrains.kotlin.analysis.api.components.KaSessionComponent] have it as their direct supertype.
47+
* 2. All context parameter bridges are annotated with [org.jetbrains.kotlin.analysis.api.KaContextParameterApi] and are located in the same file as the corresponding component.
48+
*/
49+
class AnalysisApiContextParametersBridgesTest : AbstractAnalysisApiCodebaseDumpFileComparisonTest() {
50+
@Test
51+
fun testContextParameterBridges() = doTest()
52+
53+
override val sourceDirectories = listOf(
54+
SourceDirectory.ForDumpFileComparison(
55+
listOf("analysis/analysis-api/src/org/jetbrains/kotlin/analysis/api"),
56+
"analysis/analysis-api/api/analysis-api.missing_bridges",
57+
)
58+
)
59+
60+
override fun PsiFile.processFile(): List<String> = buildList {
61+
if (this@processFile !is KtFile) return emptyList()
62+
63+
val membersBySessionComponents = this@processFile.getMembersByComponent()
64+
val bridgesByContextParameter = this@processFile.getBridgesByComponent()
65+
66+
for ((sessionComponent, members) in membersBySessionComponents) {
67+
val relatedBridges = bridgesByContextParameter[sessionComponent] ?: emptyList()
68+
69+
relatedBridges.filter { bridge ->
70+
members.none { member ->
71+
bridge.hasTheSameSignatureWith(member)
72+
}
73+
}.ifNotEmpty {
74+
error(
75+
"The following context parameters bridges are unused. Please, remove them:\n" +
76+
this.joinToString("\n") { it.renderDeclaration() + " from " + it.containingKtFile.virtualFilePath }
77+
)
78+
}
79+
80+
members.filter { member ->
81+
relatedBridges.none { bridge ->
82+
bridge.hasTheSameSignatureWith(member)
83+
}
84+
}.forEach { memberWithNoBridge ->
85+
add(memberWithNoBridge.renderDeclaration())
86+
}
87+
}
88+
}
89+
90+
override fun SourceDirectory.ForDumpFileComparison.getErrorMessage(): String =
91+
"""
92+
The list of context parameter bridges `${getRoots()}` does not match the expected list in `$outputFilePath`.
93+
If you added new declarations to some `KaSessionComponent`, please implement a proper context parameter bridge for them.
94+
Otherwise, update the exclusion list accordingly.
95+
""".trimIndent()
96+
97+
private fun KtFile.getMembersByComponent(): Map<String, List<KtCallableDeclaration>> =
98+
this.getSessionComponents()
99+
.associate { sessionComponent -> sessionComponent.name as String to sessionComponent.collectPublicMembers() }
100+
101+
private fun KtFile.getBridgesByComponent(): Map<String, List<KtCallableDeclaration>> =
102+
this.collectPublicMembers().filter { callableDeclaration ->
103+
callableDeclaration.annotationEntries.any { annotation ->
104+
annotation.shortName.toString() == BRIDGE_ANNOTATION_MARKER
105+
}
106+
}.groupBy {
107+
it.modifierList?.contextReceiverList?.contextParameters()?.singleOrNull()?.typeReference?.typeElement?.text ?: "NO_COMPONENT"
108+
}
109+
110+
private fun KtFile.getSessionComponents(): List<KtClass> {
111+
return this.declarations.filterIsInstance<KtClass>()
112+
.filter { ktClass ->
113+
ktClass.superTypeListEntries.any { superTypeListEntry ->
114+
superTypeListEntry.text == KA_SESSION_COMPONENT
115+
} || ktClass.name == KA_SESSION_CLASS
116+
}
117+
}
118+
119+
private fun KtElement.collectPublicMembers(): List<KtCallableDeclaration> =
120+
(this as KtDeclarationContainer).declarations.filterIsInstance<KtCallableDeclaration>().filter { it.isPublic }
121+
122+
private fun KtCallableDeclaration.hasTheSameSignatureWith(other: KtCallableDeclaration): Boolean {
123+
if (this.name != other.name) return false
124+
if (this.typeReference?.text != other.typeReference?.text) return false
125+
if (this.receiverTypeReference?.text != other.receiverTypeReference?.text) return false
126+
if (this.valueParameters.map { it.name to it.typeReference?.text } !=
127+
other.valueParameters.map { it.name to it.typeReference?.text }) return false
128+
return true
129+
}
130+
131+
companion object {
132+
private const val BRIDGE_ANNOTATION_MARKER = "KaContextParameterApi"
133+
private const val KA_SESSION_COMPONENT = "KaSessionComponent"
134+
private const val KA_SESSION_CLASS = "KaSession"
135+
}
136+
}

0 commit comments

Comments
 (0)