diff --git a/application/settings.gradle.kts b/application/settings.gradle.kts index 4d50a0c..a95b313 100644 --- a/application/settings.gradle.kts +++ b/application/settings.gradle.kts @@ -1,5 +1,3 @@ -import org.gradle.api.internal.artifacts.dependencies.DefaultDependencyConstraint.strictly - pluginManagement { apply( from = @@ -19,6 +17,8 @@ plugins { id("tools.forma.depgen") } +includer { arbitraryBuildScriptNames = true } + rootProject.name = "application" val filteredTokens = @@ -58,7 +58,7 @@ dependencyResolutionManagement { addPlugin("tools.forma.demo:dependencies", "0.0.1") addPlugin( "com.google.devtools.ksp", - "1.8.10-1.0.9", + "$embeddedKotlinVersion-1.0.9", "androidx.room:room-compiler:$roomVersion" ) } diff --git a/includer/Changelog.md b/includer/Changelog.md index ae58ee9..8e6a476 100644 --- a/includer/Changelog.md +++ b/includer/Changelog.md @@ -1,3 +1,7 @@ +# 0.2.0 Minor patch release + +- Includer configuration extension added + # 0.1.3 Minor patch release - Prevent traversing nested projects diff --git a/includer/Readme.md b/includer/Readme.md index 0e2350e..a2a46b5 100644 --- a/includer/Readme.md +++ b/includer/Readme.md @@ -1,6 +1,7 @@ # Includer -Simple and powerful plugin which helps you to save time and avoid writing tons of boilerplate code. Uses kotlin coroutines to efficiently traverse even deeply nested project trees. +Simple and powerful plugin which helps you to save time and avoid writing tons of boilerplate code. +Uses kotlin coroutines to efficiently traverse even deeply nested project trees. ## Installation @@ -11,3 +12,55 @@ plugins { id("tools.forma.includer") version "0.1.3" } ``` + +# How does it work? + +Includer traverses the project's file tree and includes as subprojects all directories that have +files with `build.gradle(.kts)` files. + +Includer skips directories that have `settings.gradle(.kts)` files, treating them as nested projects. + +Example: + +``` +rootProject +|--app <- will be included as :app +|----build.gradle.kts +| +|--build-logic <- will be ignored +|----settings.gradle.kts +| +|--feature1 +|----api <- will be included as :feature1-api +|------build.gradle.kts +``` + +# Plugin configuration + +The plugin is configurable by specifying properties in the `includer` extension. + +## Ignored folders + +Includer always skips directories with following names: `build`, `src`, `buildSrc`. But you can +specify additional ignored folder names: + +```kotlin +// in settings.gradle.kts file after `plugins` block +includer { + excludeFolders(".cmake_cache", "scripts") +} +``` + +## Arbitrary build file names + +If you want the plugin to look for any `*.gradle(.kts)` files, not just `build.gradle(.kts)`: + +```kotlin +// in settings.gradle.kts file after `plugins` block +includer { + arbitraryBuildScriptNames = true +} +``` + +> NOTE: If you use this property, there can only be one `*.gradle(.kts)` file in the root +> of any module. diff --git a/includer/plugin/build.gradle.kts b/includer/plugin/build.gradle.kts index 096e578..9875183 100644 --- a/includer/plugin/build.gradle.kts +++ b/includer/plugin/build.gradle.kts @@ -1,11 +1,11 @@ @file:Suppress("UnstableApiUsage") plugins { - id("com.gradle.plugin-publish") version "1.1.0" + id("com.gradle.plugin-publish") version "1.2.0" id("org.jetbrains.kotlin.jvm") version embeddedKotlinVersion } -version = "0.1.3" +version = "0.2.0" group = "tools.forma" repositories { @@ -13,8 +13,7 @@ repositories { } dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.2") } val javaLanguageVersion = JavaLanguageVersion.of(11) @@ -34,19 +33,18 @@ testing { suites { // Configure the built-in test suite val test by getting(JvmTestSuite::class) { - // Use Kotlin Test test framework + // Use Kotlin Test framework useKotlinTest(embeddedKotlinVersion) } // Create a new test suite val functionalTest by registering(JvmTestSuite::class) { - // Use Kotlin Test test framework + // Use Kotlin Test framework useKotlinTest(embeddedKotlinVersion) dependencies { // functionalTest test suite depends on the production code in tests implementation(project()) - // implementation("org.gradle:gradle-test-kit:${gradle.gradleVersion}") } targets { diff --git a/includer/plugin/src/functionalTest/kotlin/tools/forma/includer/IncluderPluginFunctionalTest.kt b/includer/plugin/src/functionalTest/kotlin/tools/forma/includer/IncluderPluginFunctionalTest.kt index 23d12d4..06168c7 100644 --- a/includer/plugin/src/functionalTest/kotlin/tools/forma/includer/IncluderPluginFunctionalTest.kt +++ b/includer/plugin/src/functionalTest/kotlin/tools/forma/includer/IncluderPluginFunctionalTest.kt @@ -3,11 +3,14 @@ */ package tools.forma.includer -import java.io.File -import kotlin.test.assertTrue -import kotlin.test.Test import org.gradle.testkit.runner.GradleRunner +import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * A simple functional test includer plugin. @@ -17,26 +20,44 @@ class IncluderPluginFunctionalTest { @field:TempDir lateinit var projectDir: File - private val rootBuildFile by lazy { projectDir.resolve("build.gradle.kts") } - private val settingsFile by lazy { projectDir.resolve("settings.gradle.kts") } - private val projectFile by lazy { - File(projectDir, "app").mkdir() - projectDir.resolve("app/build.gradle.kts") - } - - @Test - fun `include extra projects`() { - // Set up the test build - rootBuildFile.writeText("") - projectFile.writeText("") - settingsFile.writeText( + @BeforeTest + fun `prepare filesystem`() { + projectDir.resolve("build.gradle.kts").createNewFile() + projectDir.resolve("settings.gradle.kts").writeText( """ plugins { id("tools.forma.includer") } + """.trimIndent() ) + // :app + File(projectDir, "app").mkdir() + projectDir.resolve("app/build.gradle.kts").createNewFile() + + // :feature1-api + File(projectDir, "feature1/api").mkdirs() + projectDir.resolve("feature1/api/api.gradle.kts").createNewFile() + // :feature1-impl + File(projectDir, "feature1/impl").mkdirs() + projectDir.resolve("feature1/impl/impl.gradle.kts").createNewFile() + + // :util-android + File(projectDir, "util/android").mkdirs() + projectDir.resolve("util/android/build.gradle.kts").createNewFile() + + // composite build :build-logic + File(projectDir, "build-logic").mkdir() + projectDir.resolve("build-logic/settings.gradle.kts").createNewFile() + + // composite build :build-logic:conventions + File(projectDir, "build-logic/conventions").mkdir() + projectDir.resolve("build-logic/conventions/build.gradle.kts").createNewFile() + } + + @Test + fun `include projects with default options`() { // Run the build val runner = GradleRunner.create() runner.forwardOutput() @@ -46,6 +67,163 @@ class IncluderPluginFunctionalTest { val result = runner.build() // Verify the result - assertTrue(result.output.contains("Project ':app'")) + assertTrue("Should include ':app' project") { + result.output.contains("Project ':app'") + } + assertFalse( + "Shouldn't include ':feature1-api' project " + + "because this project has a non-standard build file name" + ) { + result.output.contains("Project ':feature1-api'") + } + assertFalse( + "Shouldn't include ':feature1-impl' project " + + "because this project has a non-standard build file name" + ) { + result.output.contains("Project ':feature1-impl'") + } + assertTrue("Should include ':util-android' project") { + result.output.contains("Project ':util-android'") + } + assertFalse( + "Shouldn't include ':build-logic' project " + + "because it's a nested project" + ) { + result.output.contains("Project ':build-logic'") + } + assertFalse( + "Shouldn't include ':build-logic:conventions' project " + + "because it's a subproject of a nested project" + ) { + result.output.contains("Project ':build-logic-conventions'") + } + } + + @Test + fun `include projects with 'arbitraryBuildScriptNames=true' option`() { + // Enable option `arbitraryBuildScriptNames` + projectDir.resolve("settings.gradle.kts").appendText(""" + includer { + arbitraryBuildScriptNames = true + } + """.trimIndent()) + + // Run the build + val runner = GradleRunner.create() + runner.forwardOutput() + runner.withPluginClasspath() + runner.withArguments("projects") + runner.withProjectDir(projectDir) + val result = runner.build() + + // Verify the result + assertTrue("Should include ':app' project") { + result.output.contains("Project ':app'") + } + assertTrue("Should include ':feature1-api' project" + + "because 'arbitraryBuildScriptNames = true'") { + result.output.contains("Project ':feature1-api'") + } + assertTrue("Should include ':feature1-impl' project" + + "because 'arbitraryBuildScriptNames = true'") { + result.output.contains("Project ':feature1-impl'") + } + assertTrue("Should include ':util-android' project") { + result.output.contains("Project ':util-android'") + } + assertFalse( + "Shouldn't include ':build-logic' project " + + "because it's a nested project" + ) { + result.output.contains("Project ':build-logic'") + } + assertFalse( + "Shouldn't include ':build-logic:conventions' project " + + "because it's a subproject of a nested project" + ) { + result.output.contains("Project ':build-logic-conventions'") + } + } + + + @Test + fun `include projects with 'excludeFolders' option`() { + // Exclude additional folders with `excludeFolders(...)` + projectDir.resolve("settings.gradle.kts").appendText(""" + includer { + excludeFolders("android", "impl") + } + """.trimIndent()) + + // Run the build + val runner = GradleRunner.create() + runner.forwardOutput() + runner.withPluginClasspath() + runner.withArguments("projects") + runner.withProjectDir(projectDir) + val result = runner.build() + + // Verify the result + assertTrue("Should include ':app' project") { + result.output.contains("Project ':app'") + } + assertFalse( + "Shouldn't include ':feature1-api' project " + + "because this project has a non-standard build file name" + ) { + result.output.contains("Project ':feature1-api'") + } + assertFalse( + "Shouldn't include ':feature1-impl' project " + + "because this project has a non-standard build file name" + ) { + result.output.contains("Project ':feature1-impl'") + } + assertFalse( + "Shouldn't include ':util-android' project " + + "because its folder name is in ignored folder names" + ) { + result.output.contains("Project ':util-android'") + } + assertFalse( + "Shouldn't include ':build-logic' project " + + "because it's a nested project" + ) { + result.output.contains("Project ':build-logic'") + } + assertFalse( + "Shouldn't include ':build-logic:conventions' project " + + "because it's a subproject of a nested project" + ) { + result.output.contains("Project ':build-logic-conventions'") + } + } + + @Test + fun `include projects with multiple build files`() { + // Enable option `arbitraryBuildScriptNames` + projectDir.resolve("settings.gradle.kts").appendText(""" + includer { + arbitraryBuildScriptNames = true + } + """.trimIndent()) + + // Create a second build file in addition to the existing one + projectDir.resolve("app/something.gradle").createNewFile() + + // Run the build + val runner = GradleRunner.create() + runner.forwardOutput() + runner.withPluginClasspath() + runner.withArguments("projects") + runner.withProjectDir(projectDir) + + // Verify the result + val exception = assertThrows("The build should fail") { + runner.build() + } + assertTrue("Should detect more than one build file in :app module") { + exception.message?.contains("app has more than one gradle build file") == true + } } } diff --git a/includer/plugin/src/main/kotlin/Dsl.kt b/includer/plugin/src/main/kotlin/Dsl.kt new file mode 100644 index 0000000..5c5caf5 --- /dev/null +++ b/includer/plugin/src/main/kotlin/Dsl.kt @@ -0,0 +1,5 @@ +import org.gradle.api.initialization.Settings +import tools.forma.includer.IncluderExtension + +fun Settings.includer(action: IncluderExtension.() -> Unit) = + extensions.getByType(IncluderExtension::class.java).action() diff --git a/includer/plugin/src/main/kotlin/tools/forma/includer/IncluderExtension.kt b/includer/plugin/src/main/kotlin/tools/forma/includer/IncluderExtension.kt new file mode 100644 index 0000000..689b76e --- /dev/null +++ b/includer/plugin/src/main/kotlin/tools/forma/includer/IncluderExtension.kt @@ -0,0 +1,27 @@ +package tools.forma.includer + +abstract class IncluderExtension { + + /** + * Folder names to be excluded when searching for submodules. + * + * Default set it: `build`, `src`, `buildSrc` + */ + var excludeFolders: Set = DEFAULT_EXCLUDED_FOLDERS + + /** + * If true, include directories with any `.gradle(.kts)` files as modules. + * + * If false, only include directories with `build.gradle(.kts)` files as modules. + */ + var arbitraryBuildScriptNames: Boolean = false + + /** Add folder names to be excluded when searching for submodules */ + fun excludeFolders(vararg names: String) { + excludeFolders = excludeFolders + names + } + + private companion object { + private val DEFAULT_EXCLUDED_FOLDERS = setOf("build", "src", "buildSrc") + } +} diff --git a/includer/plugin/src/main/kotlin/tools/forma/includer/IncluderPlugin.kt b/includer/plugin/src/main/kotlin/tools/forma/includer/IncluderPlugin.kt index f696346..cd522ec 100644 --- a/includer/plugin/src/main/kotlin/tools/forma/includer/IncluderPlugin.kt +++ b/includer/plugin/src/main/kotlin/tools/forma/includer/IncluderPlugin.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking import org.gradle.api.Plugin import org.gradle.api.initialization.Settings -import org.gradle.api.logging.LogLevel import org.gradle.api.logging.Logger import org.gradle.api.logging.Logging @@ -19,85 +18,94 @@ val logger: Logger = Logging.getLogger(IncluderPlugin::class.java) * Once applied, Includer will search for nested projects and automatically includes them in the * build. */ -class IncluderPlugin : Plugin { - override fun apply(settings: Settings) { - includeSubprojects(settings) - } -} - -private fun includeSubprojects(settings: Settings) { - val measuredTime = measureTimeMillis { - runBlocking { findGradleKtsFiles(settings.rootDir, settings.rootDir) } - .forEach { buildFile -> - val moduleDir = buildFile.parentFile - val relativePath = - settings.rootDir.toPath().relativize(moduleDir.toPath()).toString() - // Avoid using `:` as separator for module names as it is used by gradle to - // mark - // intermittent nested projects, which created automatically. This behavior - // leads to increased configuration time - val moduleName = ":" + relativePath.replace(File.separator, "-") +abstract class IncluderPlugin : Plugin { - settings.include(moduleName) + override fun apply(settings: Settings) { + with(settings) { + val extension = extensions.create("includer", IncluderExtension::class.java) - val project = settings.findProject(moduleName)!! - project.projectDir = moduleDir - project.buildFileName = buildFile.name - } + gradle.settingsEvaluated { it.includeSubprojects(extension) } + } } - logger.log(LogLevel.INFO, "Loaded in $measuredTime ms") -} - -/** - * Recursively finds all gradle files in the given directory, excluding the hidden files, given - * filenames and folders. - * - * Ignores nested projects if the current directory contains a file from the `projectMarkerFiles` - * list. - * - * @param rootDir root directory of the project - * @param currentDir current directory to search - * @param ignoredFilenames list of filenames to ignore - * @param ignoredFolders list of folder names to ignore - * @param projectMarkerFiles filenames that indicate that the directory as a gradle project - */ -private suspend fun findGradleKtsFiles( - rootDir: File, - currentDir: File, - ignoredFilenames: List = emptyList(), - ignoredFolders: List = listOf("build", "src", "buildSrc"), - projectMarkerFiles: List = listOf("settings.gradle.kts", "settings.gradle"), -): List = coroutineScope { - val children = currentDir.listFiles() ?: emptyArray() - val (dirs, files) = children.partition { it.isDirectory } + private fun Settings.includeSubprojects(extension: IncluderExtension) { + val measuredTime = measureTimeMillis { + runBlocking { rootDir.findBuildFiles(extension) } + .forEach { buildFile -> + val moduleDir = buildFile.parentFile + val relativePath = moduleDir.relativeTo(rootDir).path + // Avoid using `:` as separator for module names as it is used by gradle to mark + // intermittent nested projects, which created automatically. This behavior + // leads to increased configuration time + val moduleName = ":$relativePath".replace(File.separator, "-") - val gradleKtsFiles = - if (currentDir == rootDir) { - // Root project's build.gradle(.kts) always included implicitly - emptyList() - } else { - files - // gradle files may have name that is different from `build.gradle(.kts)` so we - // include files which follows `*.gradle(.kts)` pattern - .filter { it.name.endsWith(".gradle.kts") || it.name.endsWith(".gradle") } - .filterNot { it.isHidden || it.name in ignoredFilenames } + include(moduleName) + with(project(moduleName)) { + projectDir = moduleDir + buildFileName = buildFile.name + } + } } + logger.info("Loaded in $measuredTime ms") + } + + private suspend fun File.findBuildFiles( + extension: IncluderExtension, + root: Boolean = true, + ): List = coroutineScope { + val (dirs, files) = + listFiles()?.partition { it.isDirectory } ?: Pair(emptyList(), emptyList()) - val skipNestedProject = currentDir != rootDir && files.any { it.name in projectMarkerFiles } + // Completely ignore the project with the settings file and all its child directories + if (!root && files.any { it.name in PROJECT_MARKER_FILES }) + return@coroutineScope emptyList() - if (skipNestedProject) { - gradleKtsFiles - } else { - gradleKtsFiles + + files.filterBuildFiles(extension, root) + dirs - .filterNot { it.isHidden || it.name in ignoredFolders } + .filterNot { it.isHidden || it.name in extension.excludeFolders } .map { dir -> - async(Dispatchers.IO) { - findGradleKtsFiles(rootDir, dir, ignoredFilenames, ignoredFolders) - } + async(Dispatchers.IO) { dir.findBuildFiles(extension, root = false) } } .awaitAll() .flatten() } + + private fun List.filterBuildFiles( + extension: IncluderExtension, + root: Boolean, + ): List { + if (root) return emptyList() + + val buildFiles = + if (extension.arbitraryBuildScriptNames) { + filter { it.name.endsWith(".gradle.kts") || it.name.endsWith(".gradle") } + } else { + filter { it.name in BUILD_GRADLE_FILES } + } + + // Make sure that we found the only build file in the directory or did not find it at all + // Thus, we prevent the addition of the same module by several build files + if (buildFiles.isEmpty() || buildFiles.size == 1) { + return buildFiles + } else { + // If more than one build file is found, we inform the developer about the conflict + val parentDir = buildFiles.first().parentFile + error( + buildString { + appendLine("Directory $parentDir has more than one gradle build file:") + buildFiles.forEach { appendLine("- ${it.name}") } + appendLine( + "Leave only one .gradle(.kts) file, or use the " + + "`arbitraryBuildScriptNames = false` setting " + + "to ignore any build files other than build.gradle(.kts)." + ) + } + ) + } + } + + companion object { + private val BUILD_GRADLE_FILES = setOf("build.gradle.kts", "build.gradle") + private val PROJECT_MARKER_FILES = setOf("settings.gradle.kts", "settings.gradle") + } }