From 2b99429f20f9ce7ab6cede55be3ce4768221dcaf Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Mon, 9 Dec 2024 23:51:24 -0500 Subject: [PATCH 1/2] Split out ValidateModuleTopographyTask --- .../gradle/topography/ModuleTopographyTask.kt | 254 ---------------- .../ValidateModuleTopographyTask.kt | 272 ++++++++++++++++++ 2 files changed, 272 insertions(+), 254 deletions(-) create mode 100644 platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ValidateModuleTopographyTask.kt diff --git a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ModuleTopographyTask.kt b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ModuleTopographyTask.kt index 84614be5a..4304f0410 100644 --- a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ModuleTopographyTask.kt +++ b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ModuleTopographyTask.kt @@ -15,36 +15,18 @@ */ package foundry.gradle.topography -import com.github.ajalt.mordant.markdown.Markdown -import com.github.ajalt.mordant.rendering.AnsiLevel -import com.github.ajalt.mordant.terminal.Terminal -import foundry.cli.walkEachFile -import foundry.common.json.JsonTools import foundry.gradle.FoundryExtension import foundry.gradle.FoundryProperties import foundry.gradle.artifacts.FoundryArtifact -import foundry.gradle.artifacts.Publisher import foundry.gradle.artifacts.Resolver -import foundry.gradle.avoidance.SkippyArtifacts -import foundry.gradle.capitalizeUS import foundry.gradle.properties.setDisallowChanges import foundry.gradle.register import foundry.gradle.serviceOf -import foundry.gradle.tasks.SimpleFileProducerTask import foundry.gradle.tasks.SimpleFilesConsumerTask import foundry.gradle.tasks.mustRunAfterSourceGeneratingTasks -import foundry.gradle.tasks.publish -import foundry.gradle.util.toJson -import java.nio.file.Path -import kotlin.io.path.ExperimentalPathApi -import kotlin.io.path.extension -import kotlin.io.path.readText -import kotlin.io.path.useLines -import kotlin.io.path.writeText import kotlin.jvm.optionals.getOrNull import org.gradle.api.DefaultTask import org.gradle.api.Project -import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.internal.plugins.PluginRegistry import org.gradle.api.provider.MapProperty @@ -53,17 +35,10 @@ import org.gradle.api.provider.Provider import org.gradle.api.provider.SetProperty import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider -import org.gradle.api.tasks.options.Option -import org.gradle.language.base.plugins.LifecycleBasePlugin -import org.gradle.work.DisableCachingByDefault private fun MapProperty.put(feature: ModuleFeature, provider: Provider) { put(feature.name, provider.orElse(false)) @@ -182,232 +157,3 @@ public abstract class ModuleTopographyTask : DefaultTask() { topography.writeJsonTo(topographyOutputFile, prettyPrint = true) } } - -@DisableCachingByDefault -public abstract class ValidateModuleTopographyTask : DefaultTask() { - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - @get:Optional - public abstract val featuresConfigFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - public abstract val topographyJson: RegularFileProperty - - @get:Optional - @get:Option(option = "auto-fix", description = "Enables auto-fixing build files") - @get:Input - public abstract val autoFix: Property - - @get:Internal public abstract val projectDirProperty: DirectoryProperty - - @get:OutputFile public abstract val modifiedBuildFile: RegularFileProperty - @get:OutputFile public abstract val featuresToRemoveOutputFile: RegularFileProperty - - init { - group = "foundry" - @Suppress("LeakingThis") - notCompatibleWithConfigurationCache("This task modified build files in place") - @Suppress("LeakingThis") doNotTrackState("This task modified build files in place") - } - - @OptIn(ExperimentalPathApi::class) - @TaskAction - public fun validate() { - val topography = ModuleTopography.from(topographyJson) - val loadedFeatures = - featuresConfigFile.asFile - .map { ModuleFeaturesConfig.load(it.toPath()) } - .getOrElse(ModuleFeaturesConfig.DEFAULT) - .loadFeatures() - val features = buildSet { - addAll(topography.features.map { featureKey -> loadedFeatures.getValue(featureKey) }) - // Include plugin-specific features to the check here - addAll(loadedFeatures.filterValues { it.matchingPlugin in topography.plugins }.values) - } - val featuresToRemove = mutableSetOf() - - val projectDir = projectDirProperty.asFile.get().toPath() - val srcsDir = projectDir.resolve("src") - - val buildFile = projectDir.resolve("build.gradle.kts") - var buildFileText = buildFile.readText() - val initialBuildFileHash = buildFileText.hashCode() - - for (feature in features) { - val initialRemoveSize = featuresToRemove.size - feature.matchingSourcesDir?.let { matchingSrcsDir -> - if (projectDir.resolve(matchingSrcsDir).walkEachFile().none()) { - featuresToRemove += feature - } - } - - feature.generatedSourcesDir?.let { generatedSrcsDir -> - if (projectDir.resolve(generatedSrcsDir).walkEachFile().none()) { - featuresToRemove += feature - } - } - - if (feature.matchingText.isNotEmpty()) { - if (!feature.hasMatchingTextIn(srcsDir)) { - featuresToRemove += feature - } - } - - val isRemoving = featuresToRemove.size != initialRemoveSize - if (isRemoving) { - feature.removalPatterns?.let { removalPatterns -> - for (removalRegex in removalPatterns) { - buildFileText = buildFileText.replace(removalRegex, "").removeEmptyBraces() - } - } - } - } - - JsonTools.toJson>( - featuresToRemoveOutputFile, - featuresToRemove.toSortedSet(compareBy { it.name }), - ) - - val hasBuildFileChanges = initialBuildFileHash != buildFileText.hashCode() - val shouldAutoFix = autoFix.getOrElse(false) - if (hasBuildFileChanges) { - if (shouldAutoFix) { - buildFile.writeText(buildFileText) - } else { - modifiedBuildFile.asFile.get().writeText(buildFileText) - } - } - - val allAutoFixed = featuresToRemove.all { !it.removalPatterns.isNullOrEmpty() } - if (featuresToRemove.isNotEmpty()) { - val message = buildString { - appendLine( - "**Validation failed! The following features appear to be unused and can be removed.**" - ) - appendLine() - var first = true - featuresToRemove.forEach { - if (first) { - first = false - } else { - appendLine() - appendLine() - } - appendLine("- **${it.name}:** ${it.explanation}") - appendLine() - appendLine(" - **Advice:** ${it.advice}") - } - appendLine() - appendLine("Full list written to ${featuresToRemoveOutputFile.asFile.get().absolutePath}") - } - val t = Terminal(AnsiLevel.TRUECOLOR, interactive = true) - val md = Markdown(message) - t.println(md, stderr = true) - if (shouldAutoFix) { - if (allAutoFixed) { - logger.lifecycle("All issues auto-fixed") - } else { - throw AssertionError("Not all issues could be fixed automatically") - } - } else { - throw AssertionError() - } - } - } - - @OptIn(ExperimentalPathApi::class) - private fun ModuleFeature.hasMatchingTextIn(srcsDir: Path): Boolean { - logger.debug("Checking for $name annotation usages in sources") - return srcsDir - .walkEachFile() - .run { - if (matchingTextFileExtensions.isNotEmpty()) { - filter { it.extension in matchingTextFileExtensions } - } else { - this - } - } - .any { file -> - file.useLines { lines -> - for (line in lines) { - if (matchingText.any { it in line }) { - return@any true - } - } - } - false - } - } - - internal companion object { - private const val LOG = "[ValidateModuleTopography]" - private const val NAME = "validateModuleTopography" - private val CI_NAME = "ci${NAME.capitalizeUS()}" - internal val GLOBAL_CI_NAME = "global${CI_NAME.capitalizeUS()}" - - fun register( - project: Project, - topographyTask: TaskProvider, - foundryProperties: FoundryProperties, - affectedProjects: Set?, - ) { - val publisher = - if (affectedProjects == null || project.path in affectedProjects) { - Publisher.interProjectPublisher(project, FoundryArtifact.SKIPPY_VALIDATE_TOPOGRAPHY) - } else { - val log = "$LOG Skipping ${project.path}:$CI_NAME because it is not affected." - if (foundryProperties.debug) { - project.logger.lifecycle(log) - } else { - project.logger.debug(log) - } - SkippyArtifacts.publishSkippedTask(project, NAME) - null - } - - val validateModuleTopographyTask = - project.tasks.register(NAME) { - topographyJson.set(topographyTask.flatMap { it.topographyOutputFile }) - featuresConfigFile.convention(foundryProperties.topographyFeaturesConfig) - projectDirProperty.set(project.layout.projectDirectory) - autoFix.convention(foundryProperties.topographyAutoFix) - featuresToRemoveOutputFile.setDisallowChanges( - project.layout.buildDirectory.file("foundry/topography/validate/featuresToRemove.json") - ) - modifiedBuildFile.setDisallowChanges( - project.layout.buildDirectory.file( - "foundry/topography/validate/modified-build.gradle.kts" - ) - ) - } - val ciValidateModuleTopographyTask = - SimpleFileProducerTask.registerOrConfigure( - project, - CI_NAME, - description = "Lifecycle task to run $NAME for ${project.path}.", - group = LifecycleBasePlugin.VERIFICATION_GROUP, - ) { - dependsOn(validateModuleTopographyTask) - } - publisher?.publish(ciValidateModuleTopographyTask) - } - } -} - -//// Usage -// var code = "foundry { features { compose() } }" -// code = code.replace(Regex("\\bcompose\\(\\)"), "") // remove compose() -// code = removeEmptyBraces(code) // recursively remove empty braces -// -// println(code) // Should print "" -// TODO write tests for this -private val EMPTY_DSL_BLOCK = "(\\w*)\\s*\\{\\s*\\}".toRegex() - -internal fun String.removeEmptyBraces(): String { - var result = this - while (EMPTY_DSL_BLOCK.containsMatchIn(result)) { - result = EMPTY_DSL_BLOCK.replace(result, "") - } - return result -} diff --git a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ValidateModuleTopographyTask.kt b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ValidateModuleTopographyTask.kt new file mode 100644 index 000000000..7a88f6a20 --- /dev/null +++ b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ValidateModuleTopographyTask.kt @@ -0,0 +1,272 @@ +package foundry.gradle.topography + +import com.github.ajalt.mordant.markdown.Markdown +import com.github.ajalt.mordant.rendering.AnsiLevel +import com.github.ajalt.mordant.terminal.Terminal +import foundry.cli.walkEachFile +import foundry.common.json.JsonTools +import foundry.gradle.FoundryProperties +import foundry.gradle.artifacts.FoundryArtifact +import foundry.gradle.artifacts.Publisher +import foundry.gradle.avoidance.SkippyArtifacts +import foundry.gradle.capitalizeUS +import foundry.gradle.properties.setDisallowChanges +import foundry.gradle.register +import foundry.gradle.tasks.SimpleFileProducerTask +import foundry.gradle.tasks.publish +import foundry.gradle.util.toJson +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.TaskProvider +import org.gradle.api.tasks.options.Option +import org.gradle.language.base.plugins.LifecycleBasePlugin +import org.gradle.work.DisableCachingByDefault +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.extension +import kotlin.io.path.readText +import kotlin.io.path.useLines +import kotlin.io.path.writeText + +@DisableCachingByDefault +public abstract class ValidateModuleTopographyTask : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + @get:Optional + public abstract val featuresConfigFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + public abstract val topographyJson: RegularFileProperty + + @get:Optional + @get:Option(option = "auto-fix", description = "Enables auto-fixing build files") + @get:Input + public abstract val autoFix: Property + + @get:Internal + public abstract val projectDirProperty: DirectoryProperty + + @get:OutputFile + public abstract val modifiedBuildFile: RegularFileProperty + @get:OutputFile + public abstract val featuresToRemoveOutputFile: RegularFileProperty + + init { + group = "foundry" + @Suppress("LeakingThis") + notCompatibleWithConfigurationCache("This task modified build files in place") + @Suppress("LeakingThis") doNotTrackState("This task modified build files in place") + } + + @OptIn(ExperimentalPathApi::class) + @TaskAction + public fun validate() { + val topography = ModuleTopography.from(topographyJson) + val loadedFeatures = + featuresConfigFile.asFile + .map { ModuleFeaturesConfig.load(it.toPath()) } + .getOrElse(ModuleFeaturesConfig.DEFAULT) + .loadFeatures() + val features = buildSet { + addAll(topography.features.map { featureKey -> loadedFeatures.getValue(featureKey) }) + // Include plugin-specific features to the check here + addAll(loadedFeatures.filterValues { it.matchingPlugin in topography.plugins }.values) + } + val featuresToRemove = mutableSetOf() + + val projectDir = projectDirProperty.asFile.get().toPath() + val srcsDir = projectDir.resolve("src") + + val buildFile = projectDir.resolve("build.gradle.kts") + var buildFileText = buildFile.readText() + val initialBuildFileHash = buildFileText.hashCode() + + for (feature in features) { + val initialRemoveSize = featuresToRemove.size + feature.matchingSourcesDir?.let { matchingSrcsDir -> + if (projectDir.resolve(matchingSrcsDir).walkEachFile().none()) { + featuresToRemove += feature + } + } + + feature.generatedSourcesDir?.let { generatedSrcsDir -> + if (projectDir.resolve(generatedSrcsDir).walkEachFile().none()) { + featuresToRemove += feature + } + } + + if (feature.matchingText.isNotEmpty()) { + if (!feature.hasMatchingTextIn(srcsDir)) { + featuresToRemove += feature + } + } + + val isRemoving = featuresToRemove.size != initialRemoveSize + if (isRemoving) { + feature.removalPatterns?.let { removalPatterns -> + for (removalRegex in removalPatterns) { + buildFileText = buildFileText.replace(removalRegex, "").removeEmptyBraces() + } + } + } + } + + JsonTools.toJson>( + featuresToRemoveOutputFile, + featuresToRemove.toSortedSet(compareBy { it.name }), + ) + + val hasBuildFileChanges = initialBuildFileHash != buildFileText.hashCode() + val shouldAutoFix = autoFix.getOrElse(false) + if (hasBuildFileChanges) { + if (shouldAutoFix) { + buildFile.writeText(buildFileText) + } else { + modifiedBuildFile.asFile.get().writeText(buildFileText) + } + } + + val allAutoFixed = featuresToRemove.all { !it.removalPatterns.isNullOrEmpty() } + if (featuresToRemove.isNotEmpty()) { + val message = buildString { + appendLine( + "**Validation failed! The following features appear to be unused and can be removed.**" + ) + appendLine() + var first = true + featuresToRemove.forEach { + if (first) { + first = false + } else { + appendLine() + appendLine() + } + appendLine("- **${it.name}:** ${it.explanation}") + appendLine() + appendLine(" - **Advice:** ${it.advice}") + } + appendLine() + appendLine("Full list written to ${featuresToRemoveOutputFile.asFile.get().absolutePath}") + } + val t = Terminal(AnsiLevel.TRUECOLOR, interactive = true) + val md = Markdown(message) + t.println(md, stderr = true) + if (shouldAutoFix) { + if (allAutoFixed) { + logger.lifecycle("All issues auto-fixed") + } else { + throw AssertionError("Not all issues could be fixed automatically") + } + } else { + throw AssertionError() + } + } + } + + @OptIn(ExperimentalPathApi::class) + private fun ModuleFeature.hasMatchingTextIn(srcsDir: Path): Boolean { + logger.debug("Checking for $name annotation usages in sources") + return srcsDir + .walkEachFile() + .run { + if (matchingTextFileExtensions.isNotEmpty()) { + filter { it.extension in matchingTextFileExtensions } + } else { + this + } + } + .any { file -> + file.useLines { lines -> + for (line in lines) { + if (matchingText.any { it in line }) { + return@any true + } + } + } + false + } + } + + internal companion object { + private const val LOG = "[ValidateModuleTopography]" + private const val NAME = "validateModuleTopography" + private val CI_NAME = "ci${NAME.capitalizeUS()}" + internal val GLOBAL_CI_NAME = "global${CI_NAME.capitalizeUS()}" + + fun register( + project: Project, + topographyTask: TaskProvider, + foundryProperties: FoundryProperties, + affectedProjects: Set?, + ) { + val publisher = + if (affectedProjects == null || project.path in affectedProjects) { + Publisher.Companion.interProjectPublisher(project, FoundryArtifact.SKIPPY_VALIDATE_TOPOGRAPHY) + } else { + val log = "$LOG Skipping ${project.path}:$CI_NAME because it is not affected." + if (foundryProperties.debug) { + project.logger.lifecycle(log) + } else { + project.logger.debug(log) + } + SkippyArtifacts.publishSkippedTask(project, NAME) + null + } + + val validateModuleTopographyTask = + project.tasks.register(NAME) { + topographyJson.set(topographyTask.flatMap { it.topographyOutputFile }) + featuresConfigFile.convention(foundryProperties.topographyFeaturesConfig) + projectDirProperty.set(project.layout.projectDirectory) + autoFix.convention(foundryProperties.topographyAutoFix) + featuresToRemoveOutputFile.setDisallowChanges( + project.layout.buildDirectory.file("foundry/topography/validate/featuresToRemove.json") + ) + modifiedBuildFile.setDisallowChanges( + project.layout.buildDirectory.file( + "foundry/topography/validate/modified-build.gradle.kts" + ) + ) + } + val ciValidateModuleTopographyTask = + SimpleFileProducerTask.Companion.registerOrConfigure( + project, + CI_NAME, + description = "Lifecycle task to run $NAME for ${project.path}.", + group = LifecycleBasePlugin.VERIFICATION_GROUP, + ) { + dependsOn(validateModuleTopographyTask) + } + publisher?.publish(ciValidateModuleTopographyTask) + } + } +} + +//// Usage +// var code = "foundry { features { compose() } }" +// code = code.replace(Regex("\\bcompose\\(\\)"), "") // remove compose() +// code = removeEmptyBraces(code) // recursively remove empty braces +// +// println(code) // Should print "" +// TODO write tests for this +private val EMPTY_DSL_BLOCK = "(\\w*)\\s*\\{\\s*\\}".toRegex() + +internal fun String.removeEmptyBraces(): String { + var result = this + while (EMPTY_DSL_BLOCK.containsMatchIn(result)) { + result = EMPTY_DSL_BLOCK.replace(result, "") + } + return result +} \ No newline at end of file From b4d59be0bef7ddeb43dfb281c82044d3b8f18907 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Tue, 10 Dec 2024 00:18:45 -0500 Subject: [PATCH 2/2] Spotless --- .../ValidateModuleTopographyTask.kt | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ValidateModuleTopographyTask.kt b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ValidateModuleTopographyTask.kt index 7a88f6a20..7b2161439 100644 --- a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ValidateModuleTopographyTask.kt +++ b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ValidateModuleTopographyTask.kt @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2024 Slack Technologies, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package foundry.gradle.topography import com.github.ajalt.mordant.markdown.Markdown @@ -15,6 +30,12 @@ import foundry.gradle.register import foundry.gradle.tasks.SimpleFileProducerTask import foundry.gradle.tasks.publish import foundry.gradle.util.toJson +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.extension +import kotlin.io.path.readText +import kotlin.io.path.useLines +import kotlin.io.path.writeText import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.file.DirectoryProperty @@ -32,12 +53,6 @@ import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.options.Option import org.gradle.language.base.plugins.LifecycleBasePlugin import org.gradle.work.DisableCachingByDefault -import java.nio.file.Path -import kotlin.io.path.ExperimentalPathApi -import kotlin.io.path.extension -import kotlin.io.path.readText -import kotlin.io.path.useLines -import kotlin.io.path.writeText @DisableCachingByDefault public abstract class ValidateModuleTopographyTask : DefaultTask() { @@ -55,13 +70,10 @@ public abstract class ValidateModuleTopographyTask : DefaultTask() { @get:Input public abstract val autoFix: Property - @get:Internal - public abstract val projectDirProperty: DirectoryProperty + @get:Internal public abstract val projectDirProperty: DirectoryProperty - @get:OutputFile - public abstract val modifiedBuildFile: RegularFileProperty - @get:OutputFile - public abstract val featuresToRemoveOutputFile: RegularFileProperty + @get:OutputFile public abstract val modifiedBuildFile: RegularFileProperty + @get:OutputFile public abstract val featuresToRemoveOutputFile: RegularFileProperty init { group = "foundry" @@ -213,7 +225,10 @@ public abstract class ValidateModuleTopographyTask : DefaultTask() { ) { val publisher = if (affectedProjects == null || project.path in affectedProjects) { - Publisher.Companion.interProjectPublisher(project, FoundryArtifact.SKIPPY_VALIDATE_TOPOGRAPHY) + Publisher.Companion.interProjectPublisher( + project, + FoundryArtifact.SKIPPY_VALIDATE_TOPOGRAPHY, + ) } else { val log = "$LOG Skipping ${project.path}:$CI_NAME because it is not affected." if (foundryProperties.debug) { @@ -269,4 +284,4 @@ internal fun String.removeEmptyBraces(): String { result = EMPTY_DSL_BLOCK.replace(result, "") } return result -} \ No newline at end of file +}