diff --git a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/FoundryProperties.kt b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/FoundryProperties.kt index 6938e5f34..33dfa852d 100644 --- a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/FoundryProperties.kt +++ b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/FoundryProperties.kt @@ -16,7 +16,6 @@ package foundry.gradle import foundry.common.FoundryKeys -import foundry.gradle.FoundryProperties.Companion.CACHED_PROVIDER_EXT_NAME import foundry.gradle.anvil.AnvilMode import foundry.gradle.artifacts.FoundryArtifact import foundry.gradle.properties.PropertyResolver @@ -39,15 +38,22 @@ public class FoundryProperties internal constructor( private val projectName: String, private val resolver: PropertyResolver, - private val fileProvider: (String) -> RegularFile, + private val regularFileProvider: (String) -> RegularFile, private val rootDirFileProvider: (String) -> RegularFile, internal val versions: FoundryVersions, ) { private fun presenceProperty(key: String): Boolean = optionalStringProperty(key) != null - private fun fileProperty(key: String): File? = - optionalStringProperty(key)?.let(fileProvider)?.asFile + private fun fileProperty(key: String, useRoot: Boolean = false): File? = + optionalStringProperty(key) + ?.let(if (useRoot) rootDirFileProvider else regularFileProvider) + ?.asFile + + private fun fileProvider(key: String, useRoot: Boolean = false): Provider = + resolver + .optionalStringProvider(key) + .map(if (useRoot) rootDirFileProvider else regularFileProvider) private fun intProperty(key: String, defaultValue: Int = -1): Int = resolver.intValue(key, defaultValue = defaultValue) @@ -149,7 +155,7 @@ internal constructor( * dependencies shadow jobs. */ public val versionsJson: File? - get() = fileProperty("foundry.versionsJson") + get() = fileProperty("foundry.versionsJson", useRoot = true) /** * An alias name to a libs.versions.toml bundle for common Android Compose dependencies that @@ -481,7 +487,7 @@ internal constructor( * affected in this build. */ public val affectedProjects: File? - get() = fileProperty("foundry.avoidance.affectedProjectsFile") + get() = fileProperty("foundry.avoidance.affectedProjectsFile", useRoot = true) /* Controls for Java/JVM/JDK versions uses in compilations and execution of tests. */ @@ -741,6 +747,13 @@ internal constructor( public val topographyAutoFix: Provider get() = resolver.booleanProvider("foundry.topography.validation.autoFix", defaultValue = false) + /** + * Property pointing at a features config JSON file for + * [foundry.gradle.topography.ModuleFeaturesConfig]. + */ + public val topographyFeaturesConfig: Provider + get() = fileProvider("foundry.topography.features.config", useRoot = true) + internal fun requireAndroidSdkProperties(): AndroidSdkProperties { val compileSdk = compileSdkVersion ?: error("foundry.android.compileSdkVersion not set") val minSdk = minSdkVersion?.toInt() ?: error("foundry.android.minSdkVersion not set") @@ -826,7 +839,7 @@ internal constructor( return FoundryProperties( projectName = project.name, resolver = resolver, - fileProvider = project.layout.projectDirectory::file, + regularFileProvider = project.layout.projectDirectory::file, rootDirFileProvider = project.rootProject.layout.projectDirectory::file, versions = versions, ) diff --git a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/stats/ModuleStats.kt b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/stats/ModuleStats.kt index f0d299144..215835c06 100644 --- a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/stats/ModuleStats.kt +++ b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/stats/ModuleStats.kt @@ -35,14 +35,13 @@ import foundry.gradle.properties.mapToBoolean import foundry.gradle.properties.setDisallowChanges import foundry.gradle.register import foundry.gradle.tasks.mustRunAfterSourceGeneratingTasks -import foundry.gradle.topography.KnownFeatures +import foundry.gradle.topography.DefaultFeatures import foundry.gradle.topography.ModuleTopography import foundry.gradle.topography.ModuleTopographyTask import foundry.gradle.util.toJson import java.io.File import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject -import okio.source import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.Task @@ -425,7 +424,7 @@ internal abstract class ModuleStatsCollectorTask @Inject constructor(objects: Ob for (feature in topography.features) { when (feature) { - KnownFeatures.DaggerCompiler.name -> finalTags.add(TAG_DAGGER_COMPILER) + DefaultFeatures.DaggerCompiler.name -> finalTags.add(TAG_DAGGER_COMPILER) } } diff --git a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/models.kt b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/DefaultFeatures.kt similarity index 80% rename from platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/models.kt rename to platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/DefaultFeatures.kt index d616a673f..5baefabe4 100644 --- a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/models.kt +++ b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/DefaultFeatures.kt @@ -15,69 +15,21 @@ */ package foundry.gradle.topography -import com.squareup.moshi.JsonClass -import foundry.common.json.JsonTools -import java.nio.file.Path import kotlin.reflect.full.declaredMemberProperties -import org.gradle.api.file.FileSystemLocation -import org.gradle.api.provider.Provider -@JsonClass(generateAdapter = true) -public data class ModuleTopography( - val name: String, - val gradlePath: String, - val features: Set, - val plugins: Set, -) { - public fun writeJsonTo(property: Provider, prettyPrint: Boolean = false) { - writeJsonTo(property.get().asFile.toPath(), prettyPrint) - } - - public fun writeJsonTo(path: Path, prettyPrint: Boolean = false) { - JsonTools.toJson(path, this, prettyPrint) - } - - public companion object { - public fun from(provider: Provider): ModuleTopography = - from(provider.get().asFile.toPath()) - - public fun from(path: Path): ModuleTopography = JsonTools.fromJson(path) - } -} - -@JsonClass(generateAdapter = true) -public data class ModuleFeature( - val name: String, - val explanation: String, - val advice: String, - val removalPatterns: Set?, - /** - * Generated sources root dir relative to the project dir, if any. Files are checked recursively. - */ - val generatedSourcesDir: String? = null, - val generatedSourcesExtensions: Set = emptySet(), - val matchingText: Set = emptySet(), - val matchingTextFileExtensions: Set = emptySet(), - /** - * If specified, looks for any sources in this dir relative to the project dir. Files are checked - * recursively. - */ - val matchingSourcesDir: String? = null, - val matchingPlugin: String? = null, -) - -// TODO eventually move these to JSON configs? -internal object KnownFeatures { - fun load(): Map { - return KnownFeatures::class +internal object DefaultFeatures { + private val cachedValue by lazy { + DefaultFeatures::class .declaredMemberProperties .filter { it.returnType.classifier == ModuleFeature::class } .associate { - val feature = it.get(KnownFeatures) as ModuleFeature + val feature = it.get(DefaultFeatures) as ModuleFeature feature.name to feature } } + fun load(): Map = cachedValue + internal val AndroidTest = ModuleFeature( name = "androidTest", diff --git a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ModuleFeature.kt b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ModuleFeature.kt new file mode 100644 index 000000000..d11e4eb51 --- /dev/null +++ b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ModuleFeature.kt @@ -0,0 +1,39 @@ +/* + * 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.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +public data class ModuleFeature( + val name: String, + val explanation: String, + val advice: String, + val removalPatterns: Set?, + /** + * Generated sources root dir relative to the project dir, if any. Files are checked recursively. + */ + val generatedSourcesDir: String? = null, + val generatedSourcesExtensions: Set = emptySet(), + val matchingText: Set = emptySet(), + val matchingTextFileExtensions: Set = emptySet(), + /** + * If specified, looks for any sources in this dir relative to the project dir. Files are checked + * recursively. + */ + val matchingSourcesDir: String? = null, + val matchingPlugin: String? = null, +) diff --git a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ModuleFeaturesConfig.kt b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ModuleFeaturesConfig.kt new file mode 100644 index 000000000..f42423b77 --- /dev/null +++ b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ModuleFeaturesConfig.kt @@ -0,0 +1,75 @@ +/* + * 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.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import foundry.common.json.JsonTools +import foundry.common.json.JsonTools.readJsonValueMap +import java.nio.file.Path + +/** + * Represents a configuration for module features that can be JSON-encoded. + * + * @property _features the set of user-defined [features][ModuleFeature]. + * @property _buildUponDefaults indicates whether these should build upon the [DefaultFeatures] set. + * @property _defaultFeatureOverrides adhoc overrides of default feature values. These should be a + * subset of [ModuleFeature] properties and will be overlaid onto them + */ +@Suppress("PropertyName") +@JsonClass(generateAdapter = true) +internal data class ModuleFeaturesConfig( + @Json(name = "features") val _features: Set = emptySet(), + @Json(name = "buildUponDefaults") val _buildUponDefaults: Boolean = true, + @Json(name = "defaultFeatureOverrides") + val _defaultFeatureOverrides: List> = emptyList(), +) { + + fun loadFeatures(): Map { + val inputFeatures = _features.associateBy { it.name } + val defaultFeatures: Map = + if (_buildUponDefaults) { + val defaults = DefaultFeatures.load() + buildMap { + putAll(defaults) + for (override in _defaultFeatureOverrides) { + val overrideName = + override["name"] as? String? + ?: error("No feature name defined in override '$override'") + val defaultToOverride = + defaults[overrideName] ?: error("No default feature found for '$overrideName'") + // To simply do this, we just finagle the default to a JSON map and then overlay the new + // one onto it + val defaultJsonValueMap = JsonTools.toJsonBuffer(defaultToOverride).readJsonValueMap() + val newJsonValueMap = defaultJsonValueMap + override + val newFeature = JsonTools.fromJsonValue(newJsonValueMap) + put(overrideName, newFeature) + } + } + } else { + emptyMap() + } + return defaultFeatures + inputFeatures + } + + companion object { + val DEFAULT = ModuleFeaturesConfig() + + fun load(path: Path): ModuleFeaturesConfig { + return JsonTools.fromJson(path) + } + } +} diff --git a/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ModuleTopography.kt b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ModuleTopography.kt new file mode 100644 index 000000000..a1b4be5c2 --- /dev/null +++ b/platforms/gradle/foundry-gradle-plugin/src/main/kotlin/foundry/gradle/topography/ModuleTopography.kt @@ -0,0 +1,45 @@ +/* + * 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.squareup.moshi.JsonClass +import foundry.common.json.JsonTools +import java.nio.file.Path +import org.gradle.api.file.FileSystemLocation +import org.gradle.api.provider.Provider + +@JsonClass(generateAdapter = true) +public data class ModuleTopography( + val name: String, + val gradlePath: String, + val features: Set, + val plugins: Set, +) { + public fun writeJsonTo(property: Provider, prettyPrint: Boolean = false) { + writeJsonTo(property.get().asFile.toPath(), prettyPrint) + } + + public fun writeJsonTo(path: Path, prettyPrint: Boolean = false) { + JsonTools.toJson(path, this, prettyPrint) + } + + public companion object { + public fun from(provider: Provider): ModuleTopography = + from(provider.get().asFile.toPath()) + + public fun from(path: Path): ModuleTopography = JsonTools.fromJson(path) + } +} 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 8034a77ce..b8a3c3618 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 @@ -92,32 +92,32 @@ internal object ModuleTopographyTasks { projectName.set(project.name) projectPath.set(project.path) features.put( - KnownFeatures.MoshiCodeGen, + DefaultFeatures.MoshiCodeGen, foundryExtension.featuresHandler.moshiHandler.moshiCodegen, ) features.put( - KnownFeatures.CircuitInject, + DefaultFeatures.CircuitInject, foundryExtension.featuresHandler.circuitHandler.codegen, ) - features.put(KnownFeatures.Dagger, foundryExtension.featuresHandler.daggerHandler.enabled) + features.put(DefaultFeatures.Dagger, foundryExtension.featuresHandler.daggerHandler.enabled) features.put( - KnownFeatures.DaggerCompiler, + DefaultFeatures.DaggerCompiler, foundryExtension.featuresHandler.daggerHandler.useDaggerCompiler, ) features.put( - KnownFeatures.Compose, + DefaultFeatures.Compose, foundryExtension.featuresHandler.composeHandler.enableCompiler, ) features.put( - KnownFeatures.AndroidTest, + DefaultFeatures.AndroidTest, foundryExtension.androidHandler.featuresHandler.androidTest, ) features.put( - KnownFeatures.Robolectric, + DefaultFeatures.Robolectric, foundryExtension.androidHandler.featuresHandler.robolectric, ) features.put( - KnownFeatures.ViewBinding, + DefaultFeatures.ViewBinding, project.provider { foundryExtension.androidHandler.featuresHandler.viewBindingEnabled() }, ) topographyOutputFile.setDisallowChanges( @@ -183,6 +183,11 @@ public abstract class ModuleTopographyTask : DefaultTask() { @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 @@ -208,11 +213,15 @@ public abstract class ValidateModuleTopographyTask : DefaultTask() { @TaskAction public fun validate() { val topography = ModuleTopography.from(topographyJson) - val knownFeatures = KnownFeatures.load() + val loadedFeatures = + featuresConfigFile.asFile + .map { ModuleFeaturesConfig.load(it.toPath()) } + .getOrElse(ModuleFeaturesConfig.DEFAULT) + .loadFeatures() val features = buildSet { - addAll(topography.features.map { featureKey -> knownFeatures.getValue(featureKey) }) + addAll(topography.features.map { featureKey -> loadedFeatures.getValue(featureKey) }) // Include plugin-specific features to the check here - addAll(knownFeatures.filterValues { it.matchingPlugin in topography.plugins }.values) + addAll(loadedFeatures.filterValues { it.matchingPlugin in topography.plugins }.values) } val featuresToRemove = mutableSetOf() @@ -358,6 +367,7 @@ public abstract class ValidateModuleTopographyTask : DefaultTask() { 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( diff --git a/platforms/gradle/foundry-gradle-plugin/src/test/kotlin/foundry/gradle/topography/ModuleFeatureTest.kt b/platforms/gradle/foundry-gradle-plugin/src/test/kotlin/foundry/gradle/topography/ModuleFeatureTest.kt new file mode 100644 index 000000000..a2fb07616 --- /dev/null +++ b/platforms/gradle/foundry-gradle-plugin/src/test/kotlin/foundry/gradle/topography/ModuleFeatureTest.kt @@ -0,0 +1,35 @@ +/* + * 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.google.common.truth.Truth.assertThat +import org.junit.Test + +class ModuleFeatureTest { + @Test + fun overridesTest() { + val customExplanation = "This is a custom explanation" + val config = + ModuleFeaturesConfig( + _defaultFeatureOverrides = + listOf(mapOf("name" to DefaultFeatures.Dagger.name, "explanation" to customExplanation)) + ) + + val overriddenExplanation = + config.loadFeatures().getValue(DefaultFeatures.Dagger.name).explanation + assertThat(overriddenExplanation).isEqualTo(customExplanation) + } +} diff --git a/tools/foundry-common/src/main/kotlin/foundry/common/json/JsonTools.kt b/tools/foundry-common/src/main/kotlin/foundry/common/json/JsonTools.kt index 5ab478ce1..e6fb67640 100644 --- a/tools/foundry-common/src/main/kotlin/foundry/common/json/JsonTools.kt +++ b/tools/foundry-common/src/main/kotlin/foundry/common/json/JsonTools.kt @@ -52,6 +52,15 @@ public object JsonTools { return source.use { MOSHI.adapter().fromJson(it)!! } } + public inline fun fromJsonValue(value: Any): T { + return MOSHI.adapter().fromJsonValue(value)!! + } + + public fun Buffer.readJsonValueMap(): Map { + @Suppress("UNCHECKED_CAST") + return JsonReader.of(this).use { it.readJsonValue() } as Map + } + public inline fun toJson(value: T?, prettyPrint: Boolean = false): String { val buffer = Buffer() toJson(buffer, value, prettyPrint) @@ -66,6 +75,15 @@ public object JsonTools { return toJson(file.sink().buffer(), value, prettyPrint) } + public inline fun toJsonBuffer( + value: T?, + prettyPrint: Boolean = false, + ): Buffer { + val buffer = Buffer() + toJson(buffer, value, prettyPrint) + return buffer + } + public inline fun toJson( sink: BufferedSink, value: T?,