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..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,75 +19,82 @@ private val logger = LoggerFactory.getLogger("WorkspaceExts") internal fun Workspace.collectKtModules( project: Project, - appEnv: CoreApplicationEnvironment -): List = buildList { - fun addModule(module: KtModule) = add(module) + appEnv: CoreApplicationEnvironment, +): List = + buildList { + fun addModule(module: KtModule) = add(module) - val moduleProjects = subProjects - .asSequence() - .filterIsInstance() - .filter { it.path != rootProject.path } - - val jarToModMap = mutableMapOf() - - fun addLibrary(path: Path): KtLibraryModule { - val module = buildKtLibraryModule(project, appEnv) { - id = path.pathString - addContentRoot(path) - } - jarToModMap[path] = module - return module - } - - val bootClassPaths = moduleProjects - .filterIsInstance() - .flatMap { project -> - project.bootClassPaths + 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 + 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/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..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 @@ -36,19 +36,20 @@ 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, ) { 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 @@ -61,31 +62,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 +104,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 +137,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..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 @@ -21,15 +21,19 @@ 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() + + // 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) @@ -42,29 +46,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 +92,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() 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..551667f7c3 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/CollectKtModulesTest.kt @@ -0,0 +1,115 @@ +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 = tempJar("android.jar") + val modulePaths = listOf(":a", ":b", ":c") + + val sourceModules = collect(modulePaths.map { AndroidModule(gradleProject(it, bootClassPath = androidJar)) }) + + 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: Path? = null, + compileJar: Path? = 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.pathString), + ) + } + if (compileJar != null) { + setJavaProject( + JavaModels.JavaProject.newBuilder().addDependency( + JavaModels.JavaDependency + .newBuilder() + .setJarFilePath(compileJar.pathString) + .setScope(JavaModule.SCOPE_COMPILE) + .setExternalLibrary(JavaModels.JavaExternalLibraryDependency.getDefaultInstance()), + ), + ) + } + }.build() + } +}