From d91dfbe8e6caf50019ee26e8ff40ece4ecdf0b57 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 6 Oct 2026 09:24:02 -0700 Subject: [PATCH 1/4] ADFA-6381: Build one Kotlin library module per jar collectKtModules held the boot classpath as a lazy Sequence that called addLibrary, so every source module re-ran it and got a fresh KtLibraryModule for android.jar from each Android module: modules x Android-modules copies. Each copy materializes the jar's full file list for its search scope. GlitchTip breadcrumbs show the same android.jar scope built 55-71 times on large multi-module projects, behind 219 of 336 OOM events. addLibrary now reuses the module for a path, and the boot classpath is a distinct List, so each jar maps to exactly one module. --- .../lsp/kotlin/compiler/WorkspaceExts.kt | 29 ++++----- .../kotlin/compiler/CollectKtModulesTest.kt | 62 +++++++++++++++++++ 2 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/CollectKtModulesTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/WorkspaceExts.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/WorkspaceExts.kt index 7d07076842..b105d70900 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/WorkspaceExts.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/WorkspaceExts.kt @@ -30,24 +30,25 @@ internal fun Workspace.collectKtModules( val jarToModMap = mutableMapOf() - fun addLibrary(path: Path): KtLibraryModule { - val module = buildKtLibraryModule(project, appEnv) { - id = path.pathString - addContentRoot(path) + // One module per jar: every module shares android.jar, and each KtLibraryModule + // materializes the jar's full file list for its search scope (ADFA-6381). + fun addLibrary(path: Path): KtLibraryModule = + jarToModMap.getOrPut(path) { + buildKtLibraryModule(project, appEnv) { + id = path.pathString + addContentRoot(path) + } } - jarToModMap[path] = module - return module - } + // A List, not a Sequence: a Sequence would re-run addLibrary for every source module below. val bootClassPaths = moduleProjects .filterIsInstance() - .flatMap { project -> - project.bootClassPaths - .asSequence() - .filter { it.exists() } - .map { it.toPath() } - .map(::addLibrary) - } + .flatMap { it.bootClassPaths.asSequence() } + .filter { it.exists() } + .map { it.toPath() } + .distinct() + .map(::addLibrary) + .toList() val libraryDependencies = moduleProjects .flatMap { it.getCompileClasspaths() } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/CollectKtModulesTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/CollectKtModulesTest.kt new file mode 100644 index 0000000000..cf7ffd8807 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/CollectKtModulesTest.kt @@ -0,0 +1,62 @@ +package com.itsaky.androidide.lsp.kotlin.compiler + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtLibraryModule +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import com.itsaky.androidide.project.AndroidModels +import com.itsaky.androidide.project.GradleModels +import com.itsaky.androidide.projects.api.AndroidModule +import com.itsaky.androidide.projects.api.GradleProject +import com.itsaky.androidide.projects.api.Workspace +import org.junit.Test +import kotlin.io.path.createFile +import kotlin.io.path.pathString + +class CollectKtModulesTest : KtLspTest() { + @Test + fun `modules sharing android jar share one library module`() { + val androidJar = lspTestRule.tempDir.root.toPath().resolve("android.jar").createFile() + val modulePaths = listOf(":a", ":b", ":c") + val workspace = + Workspace( + rootProject = GradleProject(gradleProject(":")), + subProjects = modulePaths.map { AndroidModule(gradleProject(it, androidJar.pathString)) }, + syncIssues = emptyList(), + ) + + val sourceModules = workspace.collectKtModules(env.project, env.applicationEnv) + + val androidJarDeps = + sourceModules.map { module -> + module.directRegularDependencies.filterIsInstance().filter { it.id == androidJar.pathString } + } + assertThat(sourceModules).hasSize(modulePaths.size) + // Before ADFA-6381 each source module depended on one fresh copy per Android module (3 x 3 here). + androidJarDeps.forEach { assertThat(it).hasSize(1) } + androidJarDeps.flatten().forEach { assertThat(it).isSameInstanceAs(androidJarDeps.first().first()) } + } + + private fun gradleProject( + path: String, + bootClassPath: String? = null, + ): GradleModels.GradleProject { + val dir = "/tmp/collect-kt-modules${path.replace(':', '/')}" + return GradleModels.GradleProject + .newBuilder() + .setName(path.trimStart(':')) + .setPath(path) + .setProjectDirPath(dir) + .setBuildDirPath("$dir/build") + .setBuildScriptPath("$dir/build.gradle") + .apply { + if (bootClassPath != null) { + setAndroidProject( + AndroidModels.AndroidProject + .newBuilder() + .setProjectType(AndroidModels.ProjectType.LibraryProject) + .addBootClassPaths(bootClassPath), + ) + } + }.build() + } +} From a1fbbac7acf135e9f7943e984ce8b865a364f743 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 6 Oct 2026 09:25:36 -0700 Subject: [PATCH 2/4] style: spotless reformat, no functional change --- .../lsp/kotlin/compiler/WorkspaceExts.kt | 136 +++++++++--------- .../kotlin/compiler/CollectKtModulesTest.kt | 6 +- 2 files changed, 76 insertions(+), 66 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/WorkspaceExts.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/WorkspaceExts.kt index b105d70900..db5c9bfeab 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/WorkspaceExts.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/WorkspaceExts.kt @@ -19,76 +19,82 @@ private val logger = LoggerFactory.getLogger("WorkspaceExts") internal fun Workspace.collectKtModules( project: Project, - appEnv: CoreApplicationEnvironment -): List = buildList { - fun addModule(module: KtModule) = add(module) - - val moduleProjects = subProjects - .asSequence() - .filterIsInstance() - .filter { it.path != rootProject.path } - - val jarToModMap = mutableMapOf() - - // One module per jar: every module shares android.jar, and each KtLibraryModule - // materializes the jar's full file list for its search scope (ADFA-6381). - fun addLibrary(path: Path): KtLibraryModule = - jarToModMap.getOrPut(path) { - buildKtLibraryModule(project, appEnv) { - id = path.pathString - addContentRoot(path) + appEnv: CoreApplicationEnvironment, +): List = + buildList { + fun addModule(module: KtModule) = add(module) + + val moduleProjects = + subProjects + .asSequence() + .filterIsInstance() + .filter { it.path != rootProject.path } + + val jarToModMap = mutableMapOf() + + // One module per jar: every module shares android.jar, and each KtLibraryModule + // materializes the jar's full file list for its search scope (ADFA-6381). + fun addLibrary(path: Path): KtLibraryModule = + jarToModMap.getOrPut(path) { + buildKtLibraryModule(project, appEnv) { + id = path.pathString + addContentRoot(path) + } } - } - // A List, not a Sequence: a Sequence would re-run addLibrary for every source module below. - val bootClassPaths = moduleProjects - .filterIsInstance() - .flatMap { it.bootClassPaths.asSequence() } - .filter { it.exists() } - .map { it.toPath() } - .distinct() - .map(::addLibrary) - .toList() - - val libraryDependencies = moduleProjects - .flatMap { it.getCompileClasspaths() } - .filter { it.exists() } - .map { it.toPath() } - .associateWith(::addLibrary) - - val subprojectsAsModules = mutableMapOf() - val sourceRootToModuleMap = mutableMapOf() - - fun getOrCreateModule(moduleProject: ModuleProject): KtSourceModule { - subprojectsAsModules[moduleProject]?.let { return it } - - val module = buildKtSourceModule(project) { - this.module = moduleProject - - bootClassPaths.forEach { addDependency(it) } - - moduleProject.getCompileClasspaths(excludeSourceGeneratedClassPath = true) - .forEach { classpath -> - val libDep = libraryDependencies[classpath.toPath()] - if (libDep == null) { - logger.error( - "Skipping non-existent classpath classpath: {}", - classpath - ) - return@forEach + // A List, not a Sequence: a Sequence would re-run addLibrary for every source module below. + val bootClassPaths = + moduleProjects + .filterIsInstance() + .flatMap { it.bootClassPaths.asSequence() } + .filter { it.exists() } + .map { it.toPath() } + .distinct() + .map(::addLibrary) + .toList() + + val libraryDependencies = + moduleProjects + .flatMap { it.getCompileClasspaths() } + .filter { it.exists() } + .map { it.toPath() } + .associateWith(::addLibrary) + + val subprojectsAsModules = mutableMapOf() + val sourceRootToModuleMap = mutableMapOf() + + fun getOrCreateModule(moduleProject: ModuleProject): KtSourceModule { + subprojectsAsModules[moduleProject]?.let { return it } + + val module = + buildKtSourceModule(project) { + this.module = moduleProject + + bootClassPaths.forEach { addDependency(it) } + + moduleProject + .getCompileClasspaths(excludeSourceGeneratedClassPath = true) + .forEach { classpath -> + val libDep = libraryDependencies[classpath.toPath()] + if (libDep == null) { + logger.error( + "Skipping non-existent classpath classpath: {}", + classpath, + ) + return@forEach + } + addDependency(libDep) + } + + moduleProject.getCompileModuleProjects().forEach { dep -> + addDependency(getOrCreateModule(dep)) } - addDependency(libDep) } - moduleProject.getCompileModuleProjects().forEach { dep -> - addDependency(getOrCreateModule(dep)) - } + subprojectsAsModules[moduleProject] = module + module.contentRoots.forEach { root -> sourceRootToModuleMap[root] = module } + return module } - subprojectsAsModules[moduleProject] = module - module.contentRoots.forEach { root -> sourceRootToModuleMap[root] = module } - return module + moduleProjects.forEach { addModule(getOrCreateModule(it)) } } - - moduleProjects.forEach { addModule(getOrCreateModule(it)) } -} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/CollectKtModulesTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/CollectKtModulesTest.kt index cf7ffd8807..d1a85c669a 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/CollectKtModulesTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/CollectKtModulesTest.kt @@ -15,7 +15,11 @@ import kotlin.io.path.pathString class CollectKtModulesTest : KtLspTest() { @Test fun `modules sharing android jar share one library module`() { - val androidJar = lspTestRule.tempDir.root.toPath().resolve("android.jar").createFile() + val androidJar = + lspTestRule.tempDir.root + .toPath() + .resolve("android.jar") + .createFile() val modulePaths = listOf(":a", ":b", ":c") val workspace = Workspace( From c63f304c90824ddbfd949aae4f42949bfb5e9f97 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 6 Oct 2026 15:12:00 -0700 Subject: [PATCH 3/4] style: spotless reformat, no functional change --- .../compiler/modules/KtLibraryModule.kt | 68 +++++++++---------- .../kotlin/compiler/modules/KtSourceModule.kt | 54 +++++++++------ 2 files changed, 66 insertions(+), 56 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt index aaca94149e..1d9456e375 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt @@ -36,12 +36,11 @@ internal class KtLibraryModule( override val isSdk: Boolean = false, private val jvmTarget: JvmTarget = DEFAULT_JVM_TARGET, override val librarySources: KaLibrarySourceModule? = null, -) : KaLibraryModule, - AbstractKtModule( +) : AbstractKtModule( project, - dependencies - ) { - + dependencies, + ), + KaLibraryModule { class Builder( private val project: Project, private val applicationEnvironment: CoreApplicationEnvironment, @@ -61,31 +60,37 @@ internal class KtLibraryModule( dependencies.add(dep) } - fun build(): KtLibraryModule = KtLibraryModule( - project = project, - id = id, - contentRoots = contentRoots.toSet(), - dependencies = dependencies.toList(), - applicationEnvironment = applicationEnvironment, - isSdk = isSdk, - jvmTarget = jvmTarget, - librarySources = librarySources, - ) + fun build(): KtLibraryModule = + KtLibraryModule( + project = project, + id = id, + contentRoots = contentRoots.toSet(), + dependencies = dependencies.toList(), + applicationEnvironment = applicationEnvironment, + isSdk = isSdk, + jvmTarget = jvmTarget, + librarySources = librarySources, + ) } @OptIn(KaImplementationDetail::class) override fun computeFiles(extended: Boolean): Sequence { - val roots = if (isSdk) project.read { - LibraryUtils.findClassesFromJdkHome( - contentRoots.first(), - isJre = false - ) - } - else contentRoots + val roots = + if (isSdk) { + project.read { + LibraryUtils.findClassesFromJdkHome( + contentRoots.first(), + isJre = false, + ) + } + } else { + contentRoots + } - val notExtendedFiles = roots - .asSequence() - .mapNotNull { getVirtualFileForLibraryRoot(it, applicationEnvironment, project) } + val notExtendedFiles = + roots + .asSequence() + .mapNotNull { getVirtualFileForLibraryRoot(it, applicationEnvironment, project) } if (!extended) return notExtendedFiles @@ -97,17 +102,11 @@ internal class KtLibraryModule( override val baseContentScope: GlobalSearchScope by lazy { val virtualFileUrls = computeFiles(extended = true).map { it.url }.toSet() object : GlobalSearchScope(project) { - override fun contains(vf: VirtualFile): Boolean { - return vf.url in virtualFileUrls - } + override fun contains(vf: VirtualFile): Boolean = vf.url in virtualFileUrls - override fun isSearchInModuleContent(module: Module): Boolean { - return false - } + override fun isSearchInModuleContent(module: Module): Boolean = false - override fun isSearchInLibraries(): Boolean { - return true - } + override fun isSearchInLibraries(): Boolean = true } } @@ -136,6 +135,7 @@ internal fun buildKtLibraryModule( ): KtLibraryModule = KtLibraryModule.Builder(project, applicationEnvironment).apply(init).build() private const val JAR_SEPARATOR = "!/" + private fun getVirtualFileForLibraryRoot( root: Path, environment: CoreApplicationEnvironment, diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtSourceModule.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtSourceModule.kt index cb62f1cf90..0cb3869bf2 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtSourceModule.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtSourceModule.kt @@ -21,13 +21,15 @@ internal class KtSourceModule( project: Project, val module: ModuleProject, directRegularDependencies: List, -) : KaSourceModule, AbstractSourceModule(project, directRegularDependencies) { - +) : AbstractSourceModule(project, directRegularDependencies), + KaSourceModule { companion object { private val logger = LoggerFactory.getLogger(KtSourceModule::class.java) } - class Builder(private val project: Project) { + class Builder( + private val project: Project, + ) { lateinit var module: ModuleProject private val dependencies = mutableListOf() @@ -42,29 +44,38 @@ internal class KtSourceModule( get() = module.path override val contentRoots by lazy { - module.getSourceDirectories() + module + .getSourceDirectories() .asSequence() .map { it.toPath() } .toSet() } private val versions by lazy { - val kotlinCompilerSettings = when { - module.hasJavaProject() -> module.javaProject - .kotlinCompilerSettings - - module.hasAndroidProject() -> module.androidProject - .kotlinCompilerSettings - - else -> null - } + val kotlinCompilerSettings = + when { + module.hasJavaProject() -> { + module.javaProject + .kotlinCompilerSettings + } + + module.hasAndroidProject() -> { + module.androidProject + .kotlinCompilerSettings + } + + else -> { + null + } + } if (kotlinCompilerSettings == null) { return@lazy DEFAULT_LANGUAGE_VERSION to DEFAULT_JVM_TARGET } - val apiVersion = LanguageVersion.fromVersionString(kotlinCompilerSettings.apiVersion) - ?: LanguageVersion.fromFullVersionString(kotlinCompilerSettings.apiVersion) + val apiVersion = + LanguageVersion.fromVersionString(kotlinCompilerSettings.apiVersion) + ?: LanguageVersion.fromFullVersionString(kotlinCompilerSettings.apiVersion) val jvmTarget = JvmTarget.fromString(kotlinCompilerSettings.jvmTarget) @@ -79,18 +90,17 @@ internal class KtSourceModule( get() = super.moduleDescription override val languageVersionSettings: LanguageVersionSettings - get() = LanguageVersionSettingsImpl( - languageVersion = versions.first, - apiVersion = ApiVersion.createByLanguageVersion(versions.first), - ) + get() = + LanguageVersionSettingsImpl( + languageVersion = versions.first, + apiVersion = ApiVersion.createByLanguageVersion(versions.first), + ) override val targetPlatform: TargetPlatform get() = JvmPlatforms.jvmPlatformByTargetVersion(versions.second) - - } internal fun buildKtSourceModule( project: Project, init: KtSourceModule.Builder.() -> Unit, -): KtSourceModule = KtSourceModule.Builder(project).apply(init).build() \ No newline at end of file +): KtSourceModule = KtSourceModule.Builder(project).apply(init).build() From bbb46789c33a0ff5096f76d6a8a847eb8569a788 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 6 Oct 2026 15:17:30 -0700 Subject: [PATCH 4/4] ADFA-6381: Add each module dependency once Now that addLibrary returns one shared KtLibraryModule per jar, a jar on both the boot classpath and a module's compile classpath was added to that module's dependencies twice as the same instance. Both module builders now keep dependencies in a LinkedHashSet: O(1) dedupe, order preserved. Tests: compile-classpath jars shared across modules resolve to one instance; a jar on both classpaths is one dependency (fails without the builder change: size 2). --- .../compiler/modules/KtLibraryModule.kt | 4 +- .../kotlin/compiler/modules/KtSourceModule.kt | 4 +- .../kotlin/compiler/CollectKtModulesTest.kt | 85 +++++++++++++++---- 3 files changed, 73 insertions(+), 20 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt index 1d9456e375..de39ae506a 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt @@ -47,7 +47,9 @@ internal class KtLibraryModule( ) { lateinit var id: String private val contentRoots = mutableSetOf() - private val dependencies = mutableListOf() + + // A set, like KtSourceModule.Builder: no module is a dependency twice. + private val dependencies = linkedSetOf() var isSdk: Boolean = false var jvmTarget: JvmTarget = DEFAULT_JVM_TARGET var librarySources: KaLibrarySourceModule? = null diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtSourceModule.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtSourceModule.kt index 0cb3869bf2..9db1dea152 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtSourceModule.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtSourceModule.kt @@ -31,7 +31,9 @@ internal class KtSourceModule( private val project: Project, ) { lateinit var module: ModuleProject - private val dependencies = mutableListOf() + + // A set: a jar on both the boot and compile classpath is one shared module (ADFA-6381). + private val dependencies = linkedSetOf() fun addDependency(dep: KtModule) { dependencies.add(dep) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/CollectKtModulesTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/CollectKtModulesTest.kt index d1a85c669a..551667f7c3 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/CollectKtModulesTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/CollectKtModulesTest.kt @@ -2,47 +2,85 @@ package com.itsaky.androidide.lsp.kotlin.compiler import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtLibraryModule +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtSourceModule import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest import com.itsaky.androidide.project.AndroidModels import com.itsaky.androidide.project.GradleModels +import com.itsaky.androidide.project.JavaModels import com.itsaky.androidide.projects.api.AndroidModule import com.itsaky.androidide.projects.api.GradleProject +import com.itsaky.androidide.projects.api.JavaModule +import com.itsaky.androidide.projects.api.ModuleProject import com.itsaky.androidide.projects.api.Workspace import org.junit.Test +import java.nio.file.Path import kotlin.io.path.createFile import kotlin.io.path.pathString class CollectKtModulesTest : KtLspTest() { @Test fun `modules sharing android jar share one library module`() { - val androidJar = - lspTestRule.tempDir.root - .toPath() - .resolve("android.jar") - .createFile() + val androidJar = tempJar("android.jar") val modulePaths = listOf(":a", ":b", ":c") - val workspace = - Workspace( - rootProject = GradleProject(gradleProject(":")), - subProjects = modulePaths.map { AndroidModule(gradleProject(it, androidJar.pathString)) }, - syncIssues = emptyList(), - ) - val sourceModules = workspace.collectKtModules(env.project, env.applicationEnv) + val sourceModules = collect(modulePaths.map { AndroidModule(gradleProject(it, bootClassPath = androidJar)) }) - val androidJarDeps = - sourceModules.map { module -> - module.directRegularDependencies.filterIsInstance().filter { it.id == androidJar.pathString } - } + val androidJarDeps = sourceModules.map { it.libraryDeps(androidJar) } assertThat(sourceModules).hasSize(modulePaths.size) // Before ADFA-6381 each source module depended on one fresh copy per Android module (3 x 3 here). androidJarDeps.forEach { assertThat(it).hasSize(1) } androidJarDeps.flatten().forEach { assertThat(it).isSameInstanceAs(androidJarDeps.first().first()) } } + @Test + fun `modules sharing a compile classpath jar share one library module`() { + val libJar = tempJar("lib.jar") + + val sourceModules = collect(listOf(":a", ":b").map { JavaModule(gradleProject(it, compileJar = libJar)) }) + + val libJarDeps = sourceModules.map { it.libraryDeps(libJar) } + libJarDeps.forEach { assertThat(it).hasSize(1) } + assertThat(libJarDeps[1].single()).isSameInstanceAs(libJarDeps[0].single()) + } + + @Test + fun `a jar on both the boot and compile classpath is one dependency`() { + val androidJar = tempJar("android.jar") + + // Boot classpaths from every Android module go to every source module, so :j sees + // android.jar twice: once from :a's boot classpath, once from its own compile classpath. + val sourceModules = + collect( + listOf( + AndroidModule(gradleProject(":a", bootClassPath = androidJar)), + JavaModule(gradleProject(":j", compileJar = androidJar)), + ), + ) + + assertThat(sourceModules.single { it.id == ":j" }.libraryDeps(androidJar)).hasSize(1) + } + + private fun tempJar(name: String): Path = + lspTestRule.tempDir.root + .toPath() + .resolve(name) + .createFile() + + private fun collect(subProjects: List): List = + Workspace( + rootProject = GradleProject(gradleProject(":")), + subProjects = subProjects, + syncIssues = emptyList(), + ).collectKtModules(env.project, env.applicationEnv) + .filterIsInstance() + + private fun KtSourceModule.libraryDeps(jar: Path) = + directRegularDependencies.filterIsInstance().filter { it.id == jar.pathString } + private fun gradleProject( path: String, - bootClassPath: String? = null, + bootClassPath: Path? = null, + compileJar: Path? = null, ): GradleModels.GradleProject { val dir = "/tmp/collect-kt-modules${path.replace(':', '/')}" return GradleModels.GradleProject @@ -58,7 +96,18 @@ class CollectKtModulesTest : KtLspTest() { AndroidModels.AndroidProject .newBuilder() .setProjectType(AndroidModels.ProjectType.LibraryProject) - .addBootClassPaths(bootClassPath), + .addBootClassPaths(bootClassPath.pathString), + ) + } + if (compileJar != null) { + setJavaProject( + JavaModels.JavaProject.newBuilder().addDependency( + JavaModels.JavaDependency + .newBuilder() + .setJarFilePath(compileJar.pathString) + .setScope(JavaModule.SCOPE_COMPILE) + .setExternalLibrary(JavaModels.JavaExternalLibraryDependency.getDefaultInstance()), + ), ) } }.build()