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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,30 @@ You can run the plugin's task right away without any extra configuration. The pl

This will generate a module graph in your README file. If you need further customization, keep reading for more detailed examples of how to configure it.

## Isolated Projects & Configuration Cache

> [!NOTE]
> The information within this section applies purely to projects leveraging the experimental Gradle [Isolated Projects](https://docs.gradle.org/current/userguide/isolated_projects.html) feature.

The plugin is fully compatible with the [Configuration Cache](https://docs.gradle.org/current/userguide/configuration_cache.html) and [Isolated Projects](https://docs.gradle.org/current/userguide/isolated_projects.html). It never reads another project's state: each project contributes its own dependency snapshot from within its own context, and the graph is assembled at task-execution time.

For multi-project builds, apply the **settings plugin** once in `settings.gradle.kts`. It applies the plugin to the root project (giving you `createModuleGraph` and the `moduleGraphConfig` extension) and makes every other project contribute automatically:

```kotlin
// settings.gradle.kts
plugins {
id("dev.iurysouza.modulegraph.settings") version "0.13.0"
}
```

Then configure as usual in the root `build.gradle.kts` and run with Isolated Projects enabled:

```sh
./gradlew createModuleGraph -Dorg.gradle.unsafe.isolated-projects=true
```

Applying the regular `dev.iurysouza.modulegraph` plugin still works and graphs the project it is applied to, but only the settings plugin can aggregate sibling projects without breaking isolation.


## Configuration Docs

Expand Down
44 changes: 44 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
import dev.iurysouza.modulegraph.LinkText
import dev.iurysouza.modulegraph.ModuleType
import dev.iurysouza.modulegraph.Orientation
import dev.iurysouza.modulegraph.Theme
import io.gitlab.arturbosch.detekt.Detekt

plugins {
Expand Down Expand Up @@ -71,3 +75,43 @@ tasks.register("preMerge") {
tasks.wrapper {
distributionType = Wrapper.DistributionType.ALL
}

moduleGraphConfig {
heading.set("# Primary Graph")
readmePath.set("./sample/README.md")
showFullPath.set(false)
orientation.set(Orientation.TOP_TO_BOTTOM)
linkText.set(LinkText.NONE)
setStyleByModuleType.set(true)
theme.set(
Theme.BASE(
themeVariables = mapOf(
"primaryTextColor" to "#F6F8FAff",
"primaryColor" to "#5a4f7c",
"primaryBorderColor" to "#5a4f7c",
"tertiaryColor" to "#40375c",
"lineColor" to "#f5a623",
"fontSize" to "12px",
),
focusColor = "#F5A622",
moduleTypes = listOf(
ModuleType.Kotlin("#2C4162"),
),
),
)
excludedConfigurationsRegex.set(""".*test.*""")
graph(
readmePath = "./sample/README.md",
heading = "# Graph with root: gama",
) {
nestingEnabled = true
rootModulesRegex = ".*gama.*"
}
graph(
readmePath = "./sample/SomeOtherReadme.md",
heading = "# Graph",
) {
nestingEnabled = false
rootModulesRegex = ".*zeta.*"
}
}
1 change: 1 addition & 0 deletions plugin-build/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#Tue Oct 14 16:31:55 CEST 2025
GROUP=dev.iurysouza
IMPLEMENTATION_CLASS=dev.iurysouza.modulegraph.gradle.ModuleGraphPlugin
IMPLEMENTATION_CLASS_SETTINGS=dev.iurysouza.modulegraph.gradle.ModuleGraphSettingsPlugin
DESCRIPTION=Generates and embbed a mermaid graph showing the project's modules relationships in your README.md file.
VERSION=0.13.0
ID=dev.iurysouza.modulegraph
Expand Down
2 changes: 1 addition & 1 deletion plugin-build/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
8 changes: 8 additions & 0 deletions plugin-build/modulegraph/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ gradlePlugin {
displayName = property("DISPLAY_NAME").toString()
tags.set(listOf("mermaid", "diagram"))
}
create("${property("ID")}.settings") {
id = "${property("ID")}.settings"
implementationClass = property("IMPLEMENTATION_CLASS_SETTINGS").toString()
version = property("VERSION").toString()
description = property("DESCRIPTION").toString()
displayName = "${property("DISPLAY_NAME")} (Settings)"
tags.set(listOf("mermaid", "diagram"))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,35 +69,45 @@ private val defaultPlugins = listOf(
)

/**
* Determines the primary plugin type applied to the project based on a precedence order.
* Determines the primary [ModuleType] from a precedence-ordered list of candidates.
*
* The first plugin from this ordered list that matches a plugin applied to the project
* is returned as the primary plugin type. If no such plugin is found, `PluginType.Unknown()`
* is returned.
* The first candidate whose id matches an applied plugin id or an external dependency coordinate is
* returned, or [ModuleType.Unknown] if none match.
*
* @param customPlugins A list of additional `PluginType` instances that should be considered
* alongside the predefined list of plugins.
* @return The primary `PluginType` applied to the project or `PluginType.Unknown()` if
* no match is found.
* @param pluginIds the ids of plugins applied to the project.
* @param externalDependencies the "group:name" coordinates of the project's external dependencies.
* @param customPlugins additional [ModuleType] candidates considered alongside the defaults.
*/
internal fun Project.getModuleType(
internal fun resolveModuleType(
pluginIds: List<String>,
externalDependencies: List<String>,
customPlugins: List<ModuleType>,
): ModuleType = (customPlugins + defaultPlugins)
.distinctBy { it.id }
.sortedWith(pluginTypeComparator)
.firstOrNull {
plugins.hasPlugin(it.id) || hasLibraryDependency(it.id)
.firstOrNull { type ->
pluginIds.contains(type.id) || externalDependencies.any {
type.id.toRegex().matches(it)
}
} ?: ModuleType.Unknown()

internal fun Project.hasLibraryDependency(dependencyGroupAndName: String): Boolean = runCatching {
/** @return the ids of all known module-type plugins applied to this project. */
internal fun Project.appliedModuleTypePluginIds(
customPlugins: List<ModuleType>,
): List<String> = (customPlugins + defaultPlugins)
.map { it.id }
.distinct()
.filter { plugins.hasPlugin(it) }

/** @return the "group:name" coordinates of every external dependency declared in this project. */
internal fun Project.externalDependencyCoordinates(): List<String> = runCatching {
configurations.flatMap { configuration ->
configuration.dependencies.filterIsInstance<ExternalModuleDependency>()
}.any { dependency ->
dependencyGroupAndName.toRegex().matches("${dependency.group}:${dependency.name}")
}
}.map { dependency -> "${dependency.group}:${dependency.name}" }
.distinct()
}.getOrElse { e ->
println("Error resolving dependencies: ${e.message}")
false
emptyList()
}

internal val pluginPrecedenceOrder = listOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package dev.iurysouza.modulegraph.gradle

import dev.iurysouza.modulegraph.*
import dev.iurysouza.modulegraph.gradle.graphparser.ProjectParser
import dev.iurysouza.modulegraph.gradle.graphparser.projectquerier.SnapshotProjectQuerier
import dev.iurysouza.modulegraph.model.GraphConfig
import dev.iurysouza.modulegraph.model.GraphParseResult
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.logging.LogLevel
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.services.ServiceReference
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputDirectory
Expand Down Expand Up @@ -120,9 +123,17 @@ abstract class CreateModuleGraphTask : DefaultTask() {
@get:Optional
abstract val includeIsolatedModules: Property<Boolean>

/** All graph configs (primary + additional) resolved at configuration time. */
@get:Input
@get:Option(option = "graphModels", description = "The produced graph models")
internal abstract val graphModels: ListProperty<GraphParseResult>
internal abstract val graphConfigsResolved: ListProperty<GraphConfig>

/**
* Registry of every project's contributed
* [dev.iurysouza.modulegraph.gradle.graphparser.model.ProjectInfo], read at execution time.
*/
@Suppress("UnstableApiUsage")
@get:ServiceReference(ModuleGraphRegistry.NAME)
internal abstract val registry: Property<ModuleGraphRegistry>

@get:OutputDirectory
@get:Option(option = "projectDirectory", description = "The root project directory")
Expand All @@ -144,8 +155,19 @@ abstract class CreateModuleGraphTask : DefaultTask() {
@TaskAction
fun execute() {
runCatching {
val results = graphModels.orNull
?: error("Graph models have not been computed. This is a bug in the plugin - please report it!")
val configs = graphConfigsResolved.get()
val snapshot = registry.get().snapshot()
val allProjectPaths = snapshot.keys.toList()
val projectQuerier = SnapshotProjectQuerier(snapshot)

val results = configs.map { config ->
val projectGraph = ProjectParser.parseProjectGraph(
allProjectPaths = allProjectPaths,
config = config,
projectQuerier = projectQuerier,
)
GraphParseResult(projectGraph, config)
}

results.forEach { result ->
val config = result.config
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package dev.iurysouza.modulegraph.gradle

import dev.iurysouza.modulegraph.gradle.graphparser.model.ProjectInfo
import org.gradle.api.DefaultTask
import org.gradle.api.provider.Property
import org.gradle.api.services.ServiceReference
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction

/**
* Registers a single project's [ProjectInfo] into the [ModuleGraphRegistry] at execution time.
*
* The snapshot is captured as a serializable input during configuration and registered when the
* task runs.
*/
internal abstract class ModuleGraphContributeTask : DefaultTask() {
@get:Input
abstract val projectInfo: Property<ProjectInfo>

@Suppress("UnstableApiUsage")
@get:ServiceReference(ModuleGraphRegistry.NAME)
abstract val registry: Property<ModuleGraphRegistry>

@TaskAction
fun contribute() {
registry.get().register(projectInfo.get())
}

companion object {
const val NAME: String = "moduleGraphContribute"
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
package dev.iurysouza.modulegraph.gradle

import dev.iurysouza.modulegraph.gradle.graphparser.ProjectParser
import dev.iurysouza.modulegraph.gradle.graphparser.projectquerier.GradleProjectQuerier
import dev.iurysouza.modulegraph.appliedModuleTypePluginIds
import dev.iurysouza.modulegraph.externalDependencyCoordinates
import dev.iurysouza.modulegraph.gradle.graphparser.model.GradleProjectConfiguration
import dev.iurysouza.modulegraph.gradle.graphparser.model.ProjectInfo
import dev.iurysouza.modulegraph.model.GraphConfig
import dev.iurysouza.modulegraph.model.GraphParseResult
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.ProjectDependency

private const val EXTENSION_NAME = "moduleGraphConfig"
private const val TASK_NAME = "createModuleGraph"

/**
* Applies the module graph to a single project: creates the `moduleGraphConfig` extension and the
* `createModuleGraph` task, and contributes the project's own [ProjectInfo] snapshot.
*/
open class ModuleGraphPlugin : Plugin<Project> {
override fun apply(project: Project) {
project.gradle.sharedServices.registerIfAbsent(
ModuleGraphRegistry.NAME,
ModuleGraphRegistry::class.java,
) {}
val contribution = project.tasks.register(
ModuleGraphContributeTask.NAME,
ModuleGraphContributeTask::class.java,
) { task ->
task.projectInfo.set(project.provider { project.collectProjectInfo() })
}

val extension = project.extensions.create(
EXTENSION_NAME,
ModuleGraphExtension::class.java,
Expand Down Expand Up @@ -44,6 +61,7 @@ open class ModuleGraphPlugin : Plugin<Project> {
task.strictMode.set(extension.strictMode)
task.nestingEnabled.set(extension.nestingEnabled)
task.includeIsolatedModules.set(extension.includeIsolatedModules)
task.dependsOn(contribution)

val primaryGraphConfig = getPrimaryGraphConfig(task)
val additionalGraphConfigs = task.graphConfigs.getOrElse(emptyList())
Expand All @@ -56,21 +74,7 @@ open class ModuleGraphPlugin : Plugin<Project> {
""".trimIndent(),
)
}

val allProjects = project.allprojects
val allProjectPaths = allProjects.map { it.path }
val projectQuerier = GradleProjectQuerier(allProjects)

val results = allGraphConfigs.map { config ->
val projectGraph = ProjectParser.parseProjectGraph(
allProjectPaths = allProjectPaths,
config = config,
projectQuerier = projectQuerier,
)
GraphParseResult(projectGraph, config)
}

task.graphModels.set(results)
task.graphConfigsResolved.set(allGraphConfigs)
}
}

Expand Down Expand Up @@ -139,3 +143,19 @@ open class ModuleGraphPlugin : Plugin<Project> {
}.build()
}
}

/** @return a [ProjectInfo] snapshot of the given project's own configurations and plugins. */
internal fun Project.collectProjectInfo(): ProjectInfo {
val collectedConfigurations = configurations.map { configuration ->
val projectPaths = configuration.dependencies
.withType(ProjectDependency::class.java)
.map { dependency -> dependency.path }
GradleProjectConfiguration(configuration.name, projectPaths)
}
return ProjectInfo(
path = path,
pluginIds = appliedModuleTypePluginIds(emptyList()),
externalDependencies = externalDependencyCoordinates(),
configurations = collectedConfigurations,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package dev.iurysouza.modulegraph.gradle

import dev.iurysouza.modulegraph.gradle.graphparser.model.ProjectInfo
import dev.iurysouza.modulegraph.gradle.graphparser.model.ProjectPath
import java.util.concurrent.ConcurrentHashMap
import org.gradle.api.services.BuildService
import org.gradle.api.services.BuildServiceParameters

/**
* Build-scoped service that aggregates each project's [ProjectInfo].
*
* Projects register their snapshot during configuration; [CreateModuleGraphTask] reads the
* accumulated map at execution time.
*/
internal abstract class ModuleGraphRegistry : BuildService<BuildServiceParameters.None> {
private val infos = ConcurrentHashMap<ProjectPath, ProjectInfo>()

fun register(info: ProjectInfo) {
infos[info.path] = info
}

fun snapshot(): Map<ProjectPath, ProjectInfo> = infos.toMap()

companion object {
const val NAME: String = "moduleGraphRegistry"
}
}
Loading
Loading