IJPL-189337 Moved ApiCheckTest to community

GitOrigin-RevId: e798bed7257f80b5f647b098b6673bc7c2c3cec8
This commit is contained in:
Jakub Senohrabek
2025-05-26 14:00:03 +02:00
committed by intellij-monorepo-bot
parent b308cc54ce
commit d16e748a66
16 changed files with 960 additions and 0 deletions

1
.idea/modules.xml generated
View File

@@ -943,6 +943,7 @@
<module fileurl="file://$PROJECT_DIR$/platform/testFramework/junit5/highlighting/intellij.platform.testFramework.junit5.highlighting.iml" filepath="$PROJECT_DIR$/platform/testFramework/junit5/highlighting/intellij.platform.testFramework.junit5.highlighting.iml" />
<module fileurl="file://$PROJECT_DIR$/platform/testFramework/junit5.jimfs/intellij.platform.testFramework.junit5.jimfs.iml" filepath="$PROJECT_DIR$/platform/testFramework/junit5.jimfs/intellij.platform.testFramework.junit5.jimfs.iml" />
<module fileurl="file://$PROJECT_DIR$/platform/testFramework/junit5/projectStructure/intellij.platform.testFramework.junit5.projectStructure.iml" filepath="$PROJECT_DIR$/platform/testFramework/junit5/projectStructure/intellij.platform.testFramework.junit5.projectStructure.iml" />
<module fileurl="file://$PROJECT_DIR$/platform/testFramework/monorepo/intellij.platform.testFramework.monorepo.iml" filepath="$PROJECT_DIR$/platform/testFramework/monorepo/intellij.platform.testFramework.monorepo.iml" />
<module fileurl="file://$PROJECT_DIR$/platform/testFramework/teamCity/intellij.platform.testFramework.teamCity.iml" filepath="$PROJECT_DIR$/platform/testFramework/teamCity/intellij.platform.testFramework.teamCity.iml" />
<module fileurl="file://$PROJECT_DIR$/platform/testFramework/ui/intellij.platform.testFramework.ui.iml" filepath="$PROJECT_DIR$/platform/testFramework/ui/intellij.platform.testFramework.ui.iml" />
<module fileurl="file://$PROJECT_DIR$/platform/testRunner/intellij.platform.testRunner.iml" filepath="$PROJECT_DIR$/platform/testRunner/intellij.platform.testRunner.iml" />

13
.idea/runConfigurations/ApiCheckTest.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="ApiCheckTest" type="JUnit" factoryName="JUnit" nameIsGenerated="true">
<module name="intellij.platform.testFramework.monorepo" />
<shortenClasspath name="ARGS_FILE" />
<option name="PACKAGE_NAME" value="com.intellij.platform.testFramework.monorepo.api" />
<option name="MAIN_CLASS_NAME" value="com.intellij.platform.testFramework.monorepo.api.ApiCheckTest" />
<option name="METHOD_NAME" value="" />
<option name="TEST_OBJECT" value="class" />
<method v="2">
<option name="MakeProject" enabled="true" />
</method>
</configuration>
</component>

View File

@@ -251,5 +251,6 @@
<orderEntry type="module" module-name="intellij.vcs.git.commit.modal" scope="RUNTIME" />
<orderEntry type="module" module-name="intellij.python.pyproject" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.indexing.tests" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.testFramework.monorepo" scope="TEST" />
</component>
</module>

View File

@@ -0,0 +1,41 @@
### auto-generated section `build intellij.platform.testFramework.monorepo` start
load("@rules_java//java:defs.bzl", "java_library")
load("@rules_jvm//:jvm.bzl", "jvm_library", "jvm_test")
java_library(
name = "monorepo",
visibility = ["//visibility:public"]
)
jvm_library(
name = "monorepo_test_lib",
visibility = ["//visibility:public"],
srcs = glob(["tests/**/*.kt", "tests/**/*.java"], allow_empty = True),
deps = [
"@lib//:junit5Jupiter",
"//platform/testFramework/junit5",
"//platform/testFramework/junit5:junit5_test_lib",
"@lib//:kotlin-stdlib",
"//platform/util/coroutines",
"@lib//:kotlinx-coroutines-core",
"@lib//:kotlinx-coroutines-test",
"@lib//:jetbrains-annotations",
"//jps/model-api:model",
"//tools/apiDump",
"//platform/util/diff",
"//platform/util-ex",
"@lib//:junit5",
"//platform/util",
"//platform/testFramework/core",
"//jps/jps-builders:build",
"//jps/model-serialization",
"//platform/testFramework",
"//platform/testFramework:testFramework_test_lib",
]
)
jvm_test(
name = "monorepo_test",
runtime_deps = [":monorepo_test_lib"]
)
### auto-generated section `build intellij.platform.testFramework.monorepo` end

View File

@@ -0,0 +1,71 @@
java/awt/*
java/awt/color/ColorSpace
java/awt/datatransfer/*
java/awt/dnd/DragSourceDropEvent
java/awt/dnd/DropTargetListener
java/awt/event/*
java/awt/font/*
java/awt/geom/*
java/awt/im/InputMethodRequests
java/awt/image/*
java/awt/image/renderable/RenderableImage
java/beans/PropertyChangeEvent
java/beans/PropertyChangeListener
java/beans/PropertyChangeSupport
java/io/*
java/lang/*
java/lang/annotation/*
java/lang/invoke/MethodHandle
java/lang/ref/*
java/lang/reflect/*
java/math/BigInteger
java/net/*
java/net/http/*
java/nio/**
java/rmi/Remote
java/rmi/server/Unreferenced
java/security/*
java/security/cert/*
java/text/*
java/time/*
java/util/*
java/util/concurrent/*
java/util/concurrent/atomic/*
java/util/concurrent/locks/*
java/util/function/*
java/util/jar/Attributes$Name
java/util/jar/Manifest
java/util/logging/*
java/util/regex/Pattern
java/util/stream/*
java/util/zip/*
javax/accessibility/*
javax/net/ssl/*
javax/swing/*
javax/swing/border/*
javax/swing/event/*
javax/swing/plaf/*
javax/swing/plaf/basic/*
javax/swing/plaf/metal/*
javax/swing/table/*
javax/swing/text/*
javax/swing/text/html/*
javax/swing/tree/*
javax/swing/undo/UndoManager
kotlin/Lazy
kotlin/Pair
kotlin/Unit
kotlin/coroutines/*
kotlin/enums/EnumEntries
kotlin/jvm/functions/*
kotlin/jvm/internal/markers/KMappedMarker
kotlin/properties/ObservableProperty
kotlin/properties/ReadOnlyProperty
kotlin/properties/ReadWriteProperty
kotlin/ranges/ClosedRange
kotlin/ranges/IntRange
kotlin/sequences/Sequence
kotlinx/coroutines/*
kotlinx/coroutines/channels/*
kotlinx/coroutines/flow/*
kotlinx/serialization/KSerializer

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="JUnit5Jupiter" level="project" />
<orderEntry type="module" module-name="intellij.platform.testFramework.junit5" scope="TEST" />
<orderEntry type="library" scope="TEST" name="kotlin-stdlib" level="project" />
<orderEntry type="module" module-name="intellij.platform.util.coroutines" scope="TEST" />
<orderEntry type="library" scope="TEST" name="kotlinx-coroutines-core" level="project" />
<orderEntry type="library" scope="TEST" name="kotlinx-coroutines-test" level="project" />
<orderEntry type="library" scope="TEST" name="jetbrains-annotations" level="project" />
<orderEntry type="module" module-name="intellij.platform.jps.model" scope="TEST" />
<orderEntry type="module" module-name="intellij.tools.apiDump" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.util.diff" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.util.ex" scope="TEST" />
<orderEntry type="library" scope="TEST" name="JUnit5" level="project" />
<orderEntry type="module" module-name="intellij.platform.util" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.testFramework.core" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.jps.build" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.jps.model.serialization" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.testFramework" scope="TEST" />
</component>
</module>

View File

@@ -0,0 +1,31 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.testFramework.monorepo
import com.intellij.openapi.util.io.FileUtil
import com.intellij.project.IntelliJProjectConfiguration
import com.intellij.testFramework.PlatformTestUtil
import org.jetbrains.jps.model.JpsProject
import org.jetbrains.jps.model.java.JavaSourceRootType
import org.jetbrains.jps.model.library.JpsLibrary
import org.jetbrains.jps.model.library.JpsOrderRootType
import org.jetbrains.jps.model.module.JpsModule
import org.jetbrains.jps.model.serialization.JpsModelSerializationDataService
import java.io.File
import java.nio.file.Path
import kotlin.io.path.Path
object MonorepoProjectStructure {
val communityHomePath: String = PlatformTestUtil.getCommunityPath()
val communityRoot: Path = Path(communityHomePath)
val communityProject: JpsProject by lazy { IntelliJProjectConfiguration.loadIntelliJProject(communityHomePath) }
val JpsModule.baseDirectory: File
get() = JpsModelSerializationDataService.getModuleExtension(this)!!.baseDirectory
fun JpsModule.isFromCommunity(): Boolean = FileUtil.isAncestor(File(communityHomePath), this.baseDirectory, false)
fun JpsLibrary.isFromCommunity(): Boolean = getFiles(JpsOrderRootType.COMPILED).all {
FileUtil.isAncestor(File(communityHomePath), it, false)
}
}
fun JpsModule.hasProductionSources(): Boolean = getSourceRoots(JavaSourceRootType.SOURCE).iterator().hasNext()

View File

@@ -0,0 +1,24 @@
package com.intellij.platform.testFramework.monorepo.api
import com.intellij.platform.testFramework.monorepo.MonorepoProjectStructure
import com.intellij.platform.util.coroutines.childScope
import kotlinx.coroutines.*
import org.junit.jupiter.api.*
class ApiCheckTest {
companion object {
@OptIn(DelicateCoroutinesApi::class)
private val cs: CoroutineScope = GlobalScope.childScope("ApiCheckTest")
@AfterAll
@JvmStatic
fun afterAll() {
cs.cancel()
}
}
@TestFactory
fun api(): List<DynamicTest> = performApiCheckTest(cs, MonorepoProjectStructure.communityProject.modules)
}

View File

@@ -0,0 +1,302 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.testFramework.monorepo.api
import com.intellij.openapi.util.io.FileUtil
import com.intellij.platform.testFramework.core.FileComparisonFailedError
import com.intellij.tools.apiDump.*
import com.intellij.util.diff.Diff
import kotlinx.coroutines.*
import org.jetbrains.jps.model.module.JpsModule
import org.jetbrains.jps.util.JpsPathUtil
import org.junit.jupiter.api.*
import java.io.File
import java.nio.file.Path
import kotlin.collections.forEach
import kotlin.collections.iterator
import kotlin.collections.toTypedArray
import kotlin.io.path.div
import kotlin.io.path.exists
import kotlin.io.path.readText
fun performApiCheckTest(cs: CoroutineScope, modules: List<JpsModule>): List<DynamicTest> = buildList {
val exposedThirdPartyApiFilter: FileApiClassFilter = globalExposedThirdPartyClasses(modules)
val moduleApi = ModuleApi(cs)
for (module in modules) {
val contentRootPath = module.firstContentRoot() ?: continue
val stableApiDumpPath = contentRootPath.stableApiDumpPath()
val unreviewedApiDumpPath = contentRootPath.unreviewedApiDumpPath()
if (!stableApiDumpPath.exists() && !unreviewedApiDumpPath.exists()) {
continue
}
val experimentalApiDumpPath = contentRootPath.experimentalApiDumpPath() // may not exist
moduleApi.discoverModule(module)
this += DynamicTest.dynamicTest(module.getTestName()) {
val moduleName = module.name
val api = runBlocking {
moduleApi.moduleApi(module)
}
val checks = ArrayList<() -> Unit>(7)
checks.addAll(checkModuleDump(moduleName, stableApiDumpPath, unreviewedApiDumpPath, api.stableApi))
checks.addAll(checkModuleDump(moduleName, experimentalApiDumpPath, null, api.experimentalApi))
val privateApiFilter = (contentRootPath / exposedPrivateApiFileName).apiClassFilter()
val thirdPartyApiFilter = (contentRootPath / exposedThirdPartyApiFileName).apiClassFilter()
val apiFilter: ApiClassFilter = exposedThirdPartyApiFilter
.andThen(privateApiFilter ?: ApiClassFilter.Empty)
.andThen(thirdPartyApiFilter ?: ApiClassFilter.Empty)
checks.add {
checkExposedApi(moduleName, api, apiFilter)
}
fun checkFilter(filter: FileApiClassFilter?) {
if (filter == null) {
return
}
checks.add {
filter.checkAllEntriesAreUsed()
}
checks.add {
filter.checkEntriesSortedAndUnique()
}
}
checkFilter(privateApiFilter)
checkFilter(thirdPartyApiFilter)
assertAll(checks)
}
}
this += DynamicTest.dynamicTest(exposedThirdPartyApiFileName) {
assertAll(
{
exposedThirdPartyApiFilter.checkAllEntriesAreUsed()
},
{
exposedThirdPartyApiFilter.checkEntriesSortedAndUnique()
},
)
}
}
private fun JpsModule.getTestName(): String =
// without this, TC treats the part after the last dot as a test name
name.replace(".", "-")
internal fun globalExposedThirdPartyClasses(modules: List<JpsModule>): FileApiClassFilter =
modules
.first { it.name == "intellij.platform.testFramework.monorepo" }
.contentRootsList.urls
.firstNotNullOf { Path.of(JpsPathUtil.urlToPath(it), exposedThirdPartyApiFileName) }
.apiClassFilter()!!
const val MODULE_API_DUMP_FILE_NAME = "api-dump.txt"
const val MODULE_API_DUMP_EXPERIMENTAL_FILE_NAME = "api-dump-experimental.txt"
const val MODULE_API_DUMP_UNREVIEWED_FILE_NAME = "api-dump-unreviewed.txt"
internal fun JpsModule.firstContentRoot(): Path? {
val contentRoot = contentRootsList.urls.firstOrNull()
?: return null
return Path.of(JpsPathUtil.urlToPath(contentRoot))
}
internal fun Path.stableApiDumpPath(): Path = this / MODULE_API_DUMP_FILE_NAME
internal fun Path.unreviewedApiDumpPath(): Path = this / MODULE_API_DUMP_UNREVIEWED_FILE_NAME
internal fun Path.experimentalApiDumpPath(): Path {
return this / MODULE_API_DUMP_EXPERIMENTAL_FILE_NAME
}
private data class ExpectedDump(
val path: Path,
val rawContent: String,
val pathExists: Boolean,
val categorizedDump: Map<ClassName, ClassMembers>,
val simplifiedBuilder: StringBuilder = StringBuilder(),
) {
companion object {
fun build(path: Path): ExpectedDump {
val exists = path.exists()
val content = if (exists) path.readText().replace("\r\n", "\n") else ""
val lines = Diff.splitLines(content)
return ExpectedDump(path, content, exists, lines.groupByClasses())
}
}
}
private fun checkModuleDump(moduleName: String, primaryExpectedDumpPath: Path, secondaryExpectedDumpPath: Path?, classSignatures: List<ApiClass>): List<() -> Unit> {
val primaryExpectedDump = ExpectedDump.build(primaryExpectedDumpPath)
val secondaryExpectedDump = secondaryExpectedDumpPath?.let { ExpectedDump.build(it) }
val actualCategorizedDump = dumpApiAndGroupByClasses(classSignatures)
// partition of the actual dump that corresponds to the primary dump
val primaryActualBuilder = StringBuilder()
// partition of the actual dump that corresponds to the secondary dump
val secondaryActualBuilder = StringBuilder()
val mutablePrimaryDump = primaryExpectedDump.categorizedDump.toMutableMap()
val mutableSecondaryDump = secondaryExpectedDump?.categorizedDump?.toMutableMap()
for ((className, actualMembers) in actualCategorizedDump) {
val primaryMembers = mutablePrimaryDump.remove(className)
val secondaryMembers = mutableSecondaryDump?.remove(className)
if (primaryMembers == null && secondaryMembers == null) {
// new classes go to the primary dump by default
primaryActualBuilder.appendLine(className)
primaryExpectedDump.simplifiedBuilder.appendLine("+ $className")
actualMembers.forEach {
primaryActualBuilder.appendLine(it)
primaryExpectedDump.simplifiedBuilder.appendLine("+ $it")
}
continue
}
// members that are physically written in `api-dump*.txt`
val serializedPrimaryMembers = primaryMembers?.toMutableSet()
val serializedSecondaryMembers = secondaryMembers?.toMutableSet()
// containers for actual API members
val actualPrimaryMembers = mutableListOf<String>()
val actualSecondaryMembers = mutableListOf<String>()
var headerAdded = false
for (actualMember in actualMembers) {
if (serializedPrimaryMembers?.contains(actualMember) == true) {
// member is mentioned in the primary dump; skipping it
serializedPrimaryMembers.remove(actualMember)
actualPrimaryMembers.add(actualMember)
}
else if (serializedSecondaryMembers?.contains(actualMember) == true) {
// member is mentioned in the secondary dump; skipping it
serializedSecondaryMembers.remove(actualMember)
actualSecondaryMembers.add(actualMember)
}
else {
// member is not mentioned anywhere; adding it to the primary dump by default
if (!headerAdded) {
if (primaryMembers == null) {
// the class was not previously mentioned in the primary dump, let's add class name
primaryExpectedDump.simplifiedBuilder.appendLine("+ $className")
}
headerAdded = true
}
primaryExpectedDump.simplifiedBuilder.appendLine("+ $actualMember")
actualPrimaryMembers.add(actualMember)
}
}
// building partition of actual API dump by primary and secondary dumps
appendMembersToActualDump(primaryActualBuilder, actualPrimaryMembers, className, primaryMembers != null)
appendMembersToActualDump(secondaryActualBuilder, actualSecondaryMembers, className, secondaryMembers != null)
// suggesting to remove leftover members from the primary
removeLeftoverMethods(serializedPrimaryMembers, primaryExpectedDump.simplifiedBuilder)
if (serializedSecondaryMembers?.isNotEmpty() == true && serializedSecondaryMembers.size == secondaryMembers.size) {
// we moved all members from secondary dump; let's remove the class name altogether
secondaryExpectedDump.simplifiedBuilder.appendLine("- $className")
}
removeLeftoverMethods(serializedSecondaryMembers, secondaryExpectedDump?.simplifiedBuilder)
}
cleanupClassesThatDoNotExist(mutablePrimaryDump, primaryExpectedDump)
if (mutableSecondaryDump != null) {
cleanupClassesThatDoNotExist(mutableSecondaryDump, secondaryExpectedDump)
}
fun runCheck(expectedDump: ExpectedDump, actualDump: String) {
if (expectedDump.rawContent == actualDump) {
return
}
val actualDumpPath: File? = try {
FileUtil.createTempFile("actual_${expectedDump.path.fileName}".removeSuffix(".txt"), ".txt").also { it.writeText(actualDump) }
}
catch (e: Throwable) {
e.printStackTrace()
null
}
throw FileComparisonFailedError(
message =
"'$moduleName' ${expectedDump.path.fileName} does not match the actual API. " +
"If the API change was intentional, please ${if (expectedDump.pathExists) "update" else "create"} '${expectedDump.path}', " +
"otherwise revert the change. " + "Simplified diff:\n" + expectedDump.simplifiedBuilder.toString(),
expected = expectedDump.rawContent,
expectedFilePath = if (expectedDump.pathExists) expectedDump.path.toString() else null,
actual = actualDump,
actualFilePath = actualDumpPath?.toString())
}
return listOf(
{
runCheck(primaryExpectedDump, primaryActualBuilder.toString())
}, {
if (secondaryExpectedDump != null) {
runCheck(secondaryExpectedDump, secondaryActualBuilder.toString())
}
})
}
private fun appendMembersToActualDump(actualBuilder: StringBuilder, actualMembers: List<String>, className: String, existsInDump: Boolean) {
if (actualMembers.isNotEmpty() || existsInDump) {
actualBuilder.appendLine(className)
actualMembers.forEach { actualBuilder.appendLine(it) }
}
}
private fun removeLeftoverMethods(serializedMembers: Set<String>?, simplifiedBuilder: StringBuilder?) {
if (serializedMembers?.isNotEmpty() == true) {
checkNotNull(simplifiedBuilder) { "simplified dump is expected to be not null" }
serializedMembers.forEach {
simplifiedBuilder.appendLine("- $it")
}
}
}
private fun cleanupClassesThatDoNotExist(classes: Map<ClassName, ClassMembers>, expectedDump: ExpectedDump) {
for ((redundantName, redundantMembers) in classes) {
expectedDump.simplifiedBuilder.appendLine("- $redundantName")
redundantMembers.forEach { expectedDump.simplifiedBuilder.appendLine("- $it") }
}
}
private fun Array<String>.groupByClasses(): Map<ClassName, ClassMembers> {
return fold(mutableMapOf<String, MutableList<String>>() to "") { (acc, className), s ->
if (s.isBlank()) {
return@fold acc to className
}
if (!s.startsWith("-")) {
acc[s] = mutableListOf()
acc to s
}
else {
acc[className]!!.add(s)
acc to className
}
}.first
}
private fun checkExposedApi(moduleName: String, api: API, apiFilter: ApiClassFilter) {
val (exposedThirdPartyApi, exposedPrivateApi) = exposedApi(api, apiFilter)
assertAll(
{ assertExposedApi(exposedThirdPartyApi, "'${moduleName}' Third party classes are exposed through API") },
{ assertExposedApi(exposedPrivateApi, "'${moduleName}' Private classes are exposed through API") },
)
}
private fun assertExposedApi(exposedThrough: Map<String, List<String>>, message: String) {
if (exposedThrough.isEmpty()) {
return
}
val exposedApiString = buildString {
for ((exposed, through) in exposedThrough) {
for (signature in through) {
appendLine("$exposed <- $signature")
}
}
}
fail("$message:\n$exposedApiString")
}
internal const val exposedThirdPartyApiFileName = "exposed-third-party-api.txt"
internal const val exposedPrivateApiFileName = "exposed-private-api.txt"

View File

@@ -0,0 +1,72 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.testFramework.monorepo.api
import com.intellij.tools.apiDump.API
import com.intellij.tools.apiDump.printFlags
import com.intellij.tools.apiDump.referencedFqns
import java.util.*
/**
* @return map with an FQN as a key, and a member (class, method, field) which exposes the FQN as a value
*/
internal fun exposedApi(api: API, filter: ApiClassFilter): ExposedApi {
val exposedThirdPartyClasses = TreeMap<String, MutableList<String>>()
val exposedPrivateClasses = TreeMap<String, MutableList<String>>()
fun checkExposure(fqn: String, through: () -> String) {
val exposures = when (api.index.isPublicOrUnknown(fqn)) {
null -> {
if (fqn in filter) {
return
}
exposedThirdPartyClasses
}
false -> {
if (fqn in filter) {
return
}
// A member function exposes a package-local or internal class via its return type or parameter types.
exposedPrivateClasses
}
true -> {
return
}
}
exposures.getOrPut(fqn) { ArrayList() }.add(through())
}
for (classData in api.publicApi) {
val className = classData.className
for (superClassFqn in classData.supers) {
checkExposure(fqn = superClassFqn) {
className
}
}
for (member in classData.members) {
for (referenced in referencedFqns(member.ref.descriptor)) {
checkExposure(fqn = referenced) {
buildString {
if (printFlags(member.flags, isClass = false)) {
append(" ")
}
append(className)
append("#")
append(member.ref.name)
append(" ")
append(member.ref.descriptor)
}
}
}
}
}
return ExposedApi(
exposedThirdPartyClasses,
exposedPrivateClasses,
)
}
internal data class ExposedApi(
val exposedThirdPartyClasses: Map<String, List<String>>,
val exposedPrivateClasses: Map<String, List<String>>,
)

View File

@@ -0,0 +1,144 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.testFramework.monorepo.api
import com.intellij.platform.testFramework.core.FileComparisonFailedError
import com.intellij.tools.apiDump.packageName
import java.io.InputStream
import java.nio.file.Path
import java.util.*
import kotlin.io.path.exists
import kotlin.io.path.inputStream
internal fun Path.apiClassFilter(): FileApiClassFilter? {
if (exists()) {
return FileApiClassFilter(this, inputStream().readApiEntries())
}
else {
return null
}
}
/**
* @return list of non-blank lines which don't start with `#`
*/
private fun InputStream.readApiEntries(): List<String> {
return bufferedReader().useLines {
it.filter { line ->
!line.isBlank() && !line.startsWith("#")
}.toList()
}
}
internal interface ApiClassFilter {
operator fun contains(className: String): Boolean
fun andThen(another: ApiClassFilter): ApiClassFilter {
if (another == Empty) {
return this
}
return object : ApiClassFilter {
override fun contains(className: String): Boolean = className in this@ApiClassFilter || className in another
override fun toString(): String = "${this@ApiClassFilter} | $another"
}
}
data object Empty : ApiClassFilter {
override fun contains(className: String): Boolean = false
override fun andThen(another: ApiClassFilter): ApiClassFilter = another
}
}
internal class FileApiClassFilter(
private val path: Path,
private val entries: List<String>,
) : ApiClassFilter {
private val packages: Set<String>
private val recursivePackages: Set<String> // TODO use prefix tree
private val classes: Set<String>
init {
val packages = HashSet<String>()
val recursivePackages = HashSet<String>()
val classes = HashSet<String>()
for (line in entries) {
if (line.endsWith("/*")) {
packages.add(line.removeSuffix("*"))
}
else if (line.endsWith("/**")) {
recursivePackages.add(line.removeSuffix("**"))
}
else {
classes.add(line)
}
}
this.packages = packages
this.recursivePackages = recursivePackages
this.classes = classes
}
private val usedEntries = HashSet<String>()
override fun toString(): String = path.toString()
override fun contains(className: String): Boolean {
if (className in classes) {
usedEntries.add(className)
return true
}
val packageName = className.packageName() + "/"
if (packageName in packages) {
usedEntries.add(packageName)
return true
}
for (recursivePackage in recursivePackages) {
if (packageName.startsWith(recursivePackage)) {
usedEntries.add(recursivePackage)
return true
}
}
return false
}
fun checkAllEntriesAreUsed() {
val unusedEntries = unusedEntries()
if (unusedEntries.isEmpty()) {
return
}
val message = buildString {
append("$path entries does not match any exposed API. ")
appendLine("The following entries are no longer exposed and should be removed from $path:")
for (it in unusedEntries) {
appendLine(it)
}
}
throw FileComparisonFailedError(
message,
expected = entries.joinToString("\n", postfix = "\n"),
actual = (entries - unusedEntries).joinToString("\n", postfix = "\n"),
expectedFilePath = path.toString(),
)
}
private fun unusedEntries(): Set<String> {
val result = TreeSet<String>()
(packages - usedEntries).mapTo(result) { "$it*" }
(recursivePackages - usedEntries).mapTo(result) { "$it**" }
result.addAll(classes - usedEntries)
return result
}
fun checkEntriesSortedAndUnique() {
val sorted = entries.toSortedSet().toList()
if (sorted == entries) {
return
}
throw FileComparisonFailedError(
message = "$path contents are not sorted",
expected = sorted.joinToString(separator = "\n", postfix = "\n"),
actual = entries.joinToString(separator = "\n", postfix = "\n"),
actualFilePath = path.toString(),
)
}
}

View File

@@ -0,0 +1,12 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.testFramework.monorepo.api
import com.intellij.platform.testFramework.monorepo.MonorepoProjectStructure
import org.junit.jupiter.api.Test
internal class ApiDumpFilePresenseTest {
@Test
fun `platform modules define API dump`() {
checkModulesDefineApiDump(MonorepoProjectStructure.communityProject.modules)
}
}

View File

@@ -0,0 +1,27 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.testFramework.monorepo.api
import com.intellij.platform.testFramework.monorepo.api.PlatformApi.isPlatformModule
import com.intellij.platform.testFramework.monorepo.hasProductionSources
import org.jetbrains.jps.model.module.JpsModule
import org.junit.jupiter.api.fail
import kotlin.io.path.exists
fun checkModulesDefineApiDump(modules: List<JpsModule>) {
val modulesWithoutApiDump = modules
.filter { module ->
module.isPlatformModule()
&& module.hasProductionSources() // skip modules without sources, for example, modules with tests
&& module.firstContentRoot().let { contentRoot ->
contentRoot != null // skip modules without content roots
&& !contentRoot.stableApiDumpPath().exists()
}
}
if (modulesWithoutApiDump.isNotEmpty()) {
fail {
"Platform modules should define 'api-dump.txt' in the first content root. " +
"Modules without API dump:\n" +
modulesWithoutApiDump.joinToString("\n") { it.name }
}
}
}

View File

@@ -0,0 +1,70 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.testFramework.monorepo.api
import com.intellij.openapi.application.PathManager
import com.intellij.tools.apiDump.API
import com.intellij.tools.apiDump.api
import com.intellij.tools.apiDump.emptyApiIndex
import com.intellij.util.SuspendingLazy
import com.intellij.util.suspendingLazy
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.launch
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.jps.ProjectPaths
import org.jetbrains.jps.model.java.JpsJavaExtensionService
import org.jetbrains.jps.model.module.JpsModule
import org.junit.jupiter.api.fail
import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
import kotlin.io.path.name
@ApiStatus.Internal
class ModuleApi(private val cs: CoroutineScope) {
private val knownModules = ConcurrentHashMap<String, SuspendingLazy<API>>()
suspend fun moduleApi(module: JpsModule): API {
return checkNotNull(knownModules[module.name]).getValue()
}
fun discoverModule(module: JpsModule) {
knownModules.computeIfAbsent(module.name) {
cs.suspendingLazy(CoroutineName(module.name)) {
computeModuleApi(module)
}
}
}
private suspend fun computeModuleApi(module: JpsModule): API {
val dependencies = JpsJavaExtensionService.dependencies(module)
.compileOnly()
.productionOnly()
.modules
.filter {
knownModules.containsKey(it.name)
}
val dependencyIndex = channelFlow {
for (dependency in dependencies) {
launch {
val api = moduleApi(dependency)
send(api.index)
}
}
}.fold(emptyApiIndex) { acc, item ->
acc + item
}
var outputDir = ProjectPaths.getModuleOutputDir(module, false)?.toPath()
?: fail("'${module.name}' has no out directory")
val mapping = PathManager.getArchivedCompiledClassesMapping()
if (mapping != null) {
// path is absolute, mapping contains only the last two path elements
outputDir = mapping[outputDir.parent.name + "/" + outputDir.name]?.let { Path.of(it) } ?: outputDir
}
return api(dependencyIndex, outputDir)
}
}

View File

@@ -0,0 +1,38 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.testFramework.monorepo.api
import com.intellij.platform.testFramework.monorepo.MonorepoProjectStructure.isFromCommunity
import org.jetbrains.jps.model.module.JpsModule
internal object PlatformApi {
private val excludeModuleNames = setOf(
"intellij.platform.buildScripts", // build scripts
"intellij.platform.testFramework",
"intellij.platform.testExtensions",
"intellij.platform.uast", // IDEA
"intellij.platform.uast.ide", // IDEA
"intellij.platform.images", // plugin
"intellij.platform.images.copyright", // plugin
"intellij.platform.images.build", // build scripts
)
private val excludeModuleNamePrefixes = setOf(
"intellij.platform.testFramework.",
"intellij.platform.buildScripts.",
)
private val excludeModuleNameSuffixes = setOf(
".testFramework",
".performanceTesting"
)
fun JpsModule.isPlatformModule(): Boolean {
val name = name
return name.startsWith("intellij.platform.") &&
name !in excludeModuleNames &&
excludeModuleNamePrefixes.none(name::startsWith) &&
excludeModuleNameSuffixes.none(name::endsWith) &&
isFromCommunity()
}
}

View File

@@ -0,0 +1,85 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.testFramework.monorepo.api
import com.intellij.platform.testFramework.monorepo.MonorepoProjectStructure
import com.intellij.platform.testFramework.monorepo.api.PlatformApi.isPlatformModule
import com.intellij.platform.testFramework.monorepo.hasProductionSources
import com.intellij.tools.apiDump.dumpApi
import com.intellij.util.io.delete
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.plus
import kotlinx.coroutines.runBlocking
import org.jetbrains.jps.model.module.JpsModule
import java.nio.file.Path
import kotlin.io.path.div
import kotlin.io.path.exists
import kotlin.io.path.writeText
/**
* Generates and overwrites [MODULE_API_DUMP_FILE_NAME] or [MODULE_API_DUMP_UNREVIEWED_FILE_NAME]
* in modules which match [isPlatformModule].
*/
fun main(): Unit = runBlocking {
generateApiDumps(this, MonorepoProjectStructure.communityProject.modules)
}
suspend fun generateApiDumps(coroutineScope: CoroutineScope, wantedModules: List<JpsModule>) {
val modules = wantedModules
.filter {
it.isPlatformModule()
|| it.firstContentRoot().let { it != null && (it / MODULE_API_DUMP_FILE_NAME).exists() }
}
.filter(JpsModule::hasProductionSources)
.toList()
val moduleApi = ModuleApi(coroutineScope + Dispatchers.Default)
for (module in modules) {
moduleApi.discoverModule(module)
}
val exposedThirdPartyApiFilter: FileApiClassFilter = globalExposedThirdPartyClasses(modules)
var reviewedModules = 0
var privateApiExposures = 0
var privateApiExposuresInUnreviewedModules = 0
for (module in modules) {
val contentRootPath = module.firstContentRoot() ?: continue
var reviewed = true
var stableApiDumpFilePath = contentRootPath / MODULE_API_DUMP_FILE_NAME
if (!stableApiDumpFilePath.exists()) {
stableApiDumpFilePath = contentRootPath / MODULE_API_DUMP_UNREVIEWED_FILE_NAME
reviewed = false
}
var experimentalApiDumpFilePath = contentRootPath / MODULE_API_DUMP_EXPERIMENTAL_FILE_NAME
println("- [${if (reviewed) "x" else " "}] ${module.name}")
val api = moduleApi.moduleApi(module)
stableApiDumpFilePath.writeText(dumpApi(api.stableApi))
if (api.experimentalApi.isEmpty()) {
experimentalApiDumpFilePath.delete()
}
else {
experimentalApiDumpFilePath.writeText(dumpApi(api.experimentalApi))
}
val (exposedThirdPartyClasses, exposedPrivateClasses) = exposedApi(api, exposedThirdPartyApiFilter)
listExposures(exposedThirdPartyClasses, contentRootPath / exposedThirdPartyApiFileName)
listExposures(exposedPrivateClasses, contentRootPath / exposedPrivateApiFileName)
if (reviewed) {
reviewedModules++
privateApiExposures += exposedPrivateClasses.values.sumOf { it.size }
}
else {
privateApiExposuresInUnreviewedModules += exposedPrivateClasses.values.sumOf { it.size }
}
}
println("Reviewed modules: $reviewedModules of ${modules.size}")
println("Exposed private API in reviewed/unreviewed modules: $privateApiExposures/$privateApiExposuresInUnreviewedModules")
}
private fun listExposures(exposures: Map<String, *>, out: Path) {
if (exposures.isEmpty()) {
out.delete()
}
else {
out.writeText(
exposures.keys.sorted().joinToString(separator = "\n", postfix = "\n")
)
}
}