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
Expand Up @@ -141,8 +141,18 @@ open class AndroidModule(
addAll(getSelectedVariant()?.mainArtifact?.classJars ?: emptyList())
}

override fun getCompileClasspaths(excludeSourceGeneratedClassPath: Boolean): Set<File> {
override fun getCompileClasspaths(
excludeSourceGeneratedClassPath: Boolean,
visited: MutableSet<String>,
): Set<File> {
val project = IProjectManager.getInstance().workspace ?: return emptySet()

// Guard against cyclic project-dependency graphs: contribute each module's classpaths at most
// once so a cycle (:a -> :b -> :a) terminates instead of recursing until the stack overflows.
if (!visited.add(path)) {
return emptySet()
}

val result = mutableSetOf<File>()
if (excludeSourceGeneratedClassPath) {
// TODO: The mainArtifact.classJars are technically generated from source files
Expand All @@ -159,6 +169,8 @@ open class AndroidModule(
libraries = variantDependencies.mainArtifact?.compileDependencyList ?: emptyList(),
result = result,
excludeSourceGeneratedClassPath = excludeSourceGeneratedClassPath,
visited = HashSet(),
moduleVisited = visited,
)
return result
}
Expand Down Expand Up @@ -234,54 +246,106 @@ open class AndroidModule(
return result
}

/**
* Recursively collect the compile classpath entries contributed by the given dependency-graph
* [libraries] into [result], guarding against cycles.
*
* @param root The workspace used to resolve project dependencies by path.
* @param libraries The dependency-graph nodes to expand at this level.
* @param result The accumulating set of classpath files.
* @param excludeSourceGeneratedClassPath Whether to exclude source-generated classpath entries.
* @param visited Keys of dependency-graph nodes already expanded within this module; each node is
* expanded at most once so a cyclic graph terminates.
* @param moduleVisited Module paths already expanded across modules; threaded into cross-module
* recursion so a cyclic PROJECT graph terminates.
*/
private fun collectLibraries(
root: Workspace,
libraries: List<AndroidModels.GraphItem>,
result: MutableSet<File>,
excludeSourceGeneratedClassPath: Boolean = false,
excludeSourceGeneratedClassPath: Boolean,
visited: MutableSet<String>,
moduleVisited: MutableSet<String>,
) {
val libraryMap = variantDependencies.librariesMap
for (library in libraries) {
// Guard against cyclic dependency graphs within this module: expand each graph node once.
if (!visited.add(library.key)) {
continue
}

val lib = libraryMap[library.key] ?: continue
if (lib.type == AndroidModels.LibraryType.Project) {
val module = root.findByPath(lib.projectInfo!!.projectPath) ?: continue
if (module !is ModuleProject) {
continue
}

result.addAll(module.getCompileClasspaths(excludeSourceGeneratedClassPath))
// Cross-module recursion threads moduleVisited so a cyclic PROJECT graph terminates.
result.addAll(module.getCompileClasspaths(excludeSourceGeneratedClassPath, moduleVisited))
} else if (lib.type == AndroidModels.LibraryType.ExternalAndroidLibrary && lib.hasAndroidLibraryData()) {
result.addAll(lib.androidLibraryData.compileJarFiles)
} else if (lib.type == AndroidModels.LibraryType.ExternalJavaLibrary && lib.hasArtifactPath()) {
result.add(lib.artifact)
}

collectLibraries(root, library.dependencyList, result)
// Note: the recursive call intentionally keeps the original behavior of not propagating
// `excludeSourceGeneratedClassPath` (it defaults to false here); only the visited-set
// cycle guard is added.
collectLibraries(
root = root,
libraries = library.dependencyList,
result = result,
excludeSourceGeneratedClassPath = false,
visited = visited,
moduleVisited = moduleVisited,
)
Comment on lines +292 to +302

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous behavior was an oversight, so the excludeSourceGeneratedClassPath should be propagated here. Could you please update this so the flag is propagated?

}
}

override fun getCompileModuleProjects(): List<ModuleProject> {
override fun getCompileModuleProjects(
visited: MutableSet<String>,
recursionPath: ArrayDeque<String>,
): List<ModuleProject> {
val root = IProjectManager.getInstance().workspace ?: return emptyList()
val result = mutableListOf<ModuleProject>()

val libraries = variantDependencies.mainArtifact.compileDependencyList
val libraryMap = variantDependencies.librariesMap
for (library in libraries) {
val lib = libraryMap[library.key] ?: continue
if (lib.type != AndroidModels.LibraryType.Project) {
continue
}
// True cycle: this module is already an ancestor on the current recursion path
// (e.g. :a -> :b -> :a). Report it and break the cycle.
if (recursionPath.contains(path)) {
reportDependencyCycle(recursionPath, path)
return emptyList()
}

val module = root.findByPath(lib.projectInfo!!.projectPath) ?: continue
if (module !is ModuleProject) {
continue
// Already fully expanded elsewhere (a diamond / shared dependency, not a cycle): dedup.
if (!visited.add(path)) {
return emptyList()
}

recursionPath.addLast(path)
try {
val result = mutableListOf<ModuleProject>()

val libraries = variantDependencies.mainArtifact.compileDependencyList
val libraryMap = variantDependencies.librariesMap
for (library in libraries) {
val lib = libraryMap[library.key] ?: continue
if (lib.type != AndroidModels.LibraryType.Project) {
continue
}

val module = root.findByPath(lib.projectInfo!!.projectPath) ?: continue
if (module !is ModuleProject) {
continue
}

result.add(module)
result.addAll(module.getCompileModuleProjects(visited, recursionPath))
}

result.add(module)
result.addAll(module.getCompileModuleProjects())
return result
} finally {
recursionPath.removeLast()
}

return result
}

override fun hasExternalDependency(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,21 @@ class JavaModule(

override fun getModuleClasspaths(): Set<File> = mutableSetOf(classesJar)

override fun getCompileClasspaths(excludeSourceGeneratedClassPath: Boolean): Set<File> {
override fun getCompileClasspaths(
excludeSourceGeneratedClassPath: Boolean,
visited: MutableSet<String>,
): Set<File> {
// Guard against cyclic project-dependency graphs: contribute each module's classpaths once.
if (!visited.add(path)) {
return emptySet()
}

val classpaths =
if (excludeSourceGeneratedClassPath) mutableSetOf() else getModuleClasspaths().toMutableSet()

getCompileModuleProjects().forEach {
classpaths.addAll(
it.getCompileClasspaths(
excludeSourceGeneratedClassPath
)
it.getCompileClasspaths(excludeSourceGeneratedClassPath, visited)
)
}

Expand All @@ -121,8 +127,25 @@ class JavaModule(

override fun getRuntimeDexFiles(): Set<File> = emptySet()

override fun getCompileModuleProjects(): List<ModuleProject> {
override fun getCompileModuleProjects(
visited: MutableSet<String>,
recursionPath: ArrayDeque<String>,
): List<ModuleProject> {
val root = IProjectManager.getInstance().workspace ?: return emptyList()

// True cycle guard (defensive: this override is non-recursive — it returns only direct
// compile-scope module dependencies — so it cannot itself extend the recursion path, but the
// check keeps behavior consistent with AndroidModule if that ever changes).
if (recursionPath.contains(path)) {
reportDependencyCycle(recursionPath, path)
return emptyList()
}

// Already fully expanded elsewhere (a diamond / shared dependency, not a cycle): dedup.
if (!visited.add(path)) {
return emptyList()
}

return this.dependencyList
.filter { it.hasModule() && it.scope == SCOPE_COMPILE }
.mapNotNull { root.findByPath(it.module.projectPath) }
Expand Down

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The newly added functions accept visited and recursionPath parameters, which seem to be something that the callers don't need to know about. They also seem to be only called internally. Can we make the newly added overloads protected?

Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ abstract class ModuleProject(
companion object {
private val log = LoggerFactory.getLogger(ModuleProject::class.java)

/**
* Format a dependency cycle as a human-readable chain, e.g. `:a -> :b -> :a`. [recursionPath]
* is the current DFS path of module paths; [repeated] is the module that closes the cycle. The
* chain starts at the first occurrence of [repeated] on the path and ends with [repeated]
* again, so it shows exactly the loop the user needs to break.
*/
internal fun formatDependencyCycle(
recursionPath: Collection<String>,
repeated: String,
): String = (recursionPath.dropWhile { it != repeated } + repeated).joinToString(" -> ")

@JvmStatic
val COMPLETION_MODULE_KEY = Lookup.Key<ModuleProject>()
}
Expand Down Expand Up @@ -102,9 +113,24 @@ abstract class ModuleProject(
* source files of this module or its dependencies. Defaults to `false`.
* @return The source directories.
*/
abstract fun getCompileClasspaths(excludeSourceGeneratedClassPath: Boolean): Set<File>
fun getCompileClasspaths(excludeSourceGeneratedClassPath: Boolean): Set<File> =
getCompileClasspaths(excludeSourceGeneratedClassPath, HashSet())
fun getCompileClasspaths() = getCompileClasspaths(false)

/**
* Variant of [getCompileClasspaths] that guards against cyclic project-dependency graphs.
*
* @param visited module paths already expanded in this traversal. Each module contributes its
* classpaths at most once; a module already in [visited] returns an empty set, so a cyclic graph
* (e.g. `:a -> :b -> :a`) terminates instead of recursing until the stack overflows. The
* user-facing cycle report is emitted once by [getCompileModuleProjects]; this method only breaks
* the cycle.
*/
abstract fun getCompileClasspaths(
excludeSourceGeneratedClassPath: Boolean,
visited: MutableSet<String>,
): Set<File>

/**
* Get the intermediate build output classpaths for this module.
* This includes compiled .class files from the build directory that aren't packaged into JARs yet.
Expand All @@ -127,7 +153,46 @@ abstract class ModuleProject(
* Get the list of module projects with compile scope. This includes transitive module projects as
* well.
*/
abstract fun getCompileModuleProjects(): List<ModuleProject>
fun getCompileModuleProjects(): List<ModuleProject> =
getCompileModuleProjects(HashSet(), ArrayDeque())

/**
* Get the list of module projects with compile scope (including transitive ones), guarding against
* cyclic project-dependency graphs.
*
* @param visited The set of already-expanded module paths. Each module is expanded at most once,
* keyed by its [path], so a diamond/shared dependency is not re-expanded and a cyclic graph
* terminates instead of recursing until the stack overflows. Implementations must add their own
* [path] before recursing into dependencies and must thread the same set through every recursive
* call.
* @param recursionPath The ordered stack of module paths currently being expanded (the DFS path
* from the root call down to this module). Implementations must push their own [path] before
* recursing and pop it afterwards. It distinguishes a *true cycle* (a module that is already an
* ancestor on this path) from a harmless diamond/shared dependency (a module merely seen before
* but not an ancestor), so only real cycles are reported via [reportDependencyCycle].
*/
abstract fun getCompileModuleProjects(
visited: MutableSet<String>,
recursionPath: ArrayDeque<String>,
): List<ModuleProject>

/**
* Report a detected project-dependency cycle as an actionable error. Called when a module is found
* to be its own ancestor in the compile-dependency graph (e.g. `:a -> :b -> :a`). The traversal
* breaks the cycle (returns without recursing again) to keep the IDE responsive; reporting it
* surfaces the misconfiguration to the user instead of silently swallowing it.
*
* @param recursionPath The current DFS path of module paths.
* @param repeated The module path that was found to already be on [recursionPath].
*/
protected fun reportDependencyCycle(recursionPath: ArrayDeque<String>, repeated: String) {
log.error(
"Module dependency cycle detected: {}. Breaking the cycle to keep the IDE responsive; " +
"the project's module/classpath graph may be incomplete until you remove one of these " +
"dependencies (check the build.gradle of the modules in the cycle).",
formatDependencyCycle(recursionPath, repeated),
)
}

/**
* Check if the given module is a dependency of this module.
Expand Down
Loading
Loading