diff --git a/README.md b/README.md index eadf7d06..5f64ca64 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ systems like Buck and Bazel. 3. [Dependency matrix](docs/DEPENDENCY-MATRIX.md) — what may depend on what 4. [External deps catalogs](docs/DEPS-CATALOG.md) · [Compose](docs/COMPOSE.md) · [Environment](docs/ENV.md) 5. [Plugin publish path](docs/PLUGIN-PUBLISH.md) — Portal metadata DSL + release notes (F-016) +6. [Configuration performance](docs/CONFIGURATION-PERFORMANCE.md) — measure + hot-path guidance (F-017 / GH #106) Configuration made easy: @@ -191,6 +192,8 @@ Android getting-started tutorial: **F-015** ([`docs/GETTING-STARTED.md`](docs/GETTING-STARTED.md)). Plugin Portal publish path: **F-016** ([`docs/PLUGIN-PUBLISH.md`](docs/PLUGIN-PUBLISH.md)). +Configuration-time performance: **F-017** +([`docs/CONFIGURATION-PERFORMANCE.md`](docs/CONFIGURATION-PERFORMANCE.md)). Icons made by Freepik from www.flaticon.com diff --git a/TICKETS.md b/TICKETS.md index 0b8538e6..1cef2dc3 100644 --- a/TICKETS.md +++ b/TICKETS.md @@ -24,7 +24,7 @@ Update this file when picking or finishing work. Cron workers must pick the **hi | F-014 | done | Sample app: gold-standard multi-feature structure | packageName + source-root alignment; `docs/SAMPLE-APP.md`; home/characters pattern | | F-015 | done | Android project tutorial (getting started) | GH #53; `docs/GETTING-STARTED.md` + README entry | | F-016 | done | Plugin publish path (Portal user + target publish config) | GH #132 done (`formaPluginConfiguration`/`formaPublishedPlugin`); GH #133 Portal org is human admin — see `docs/PLUGIN-PUBLISH.md` | -| F-017 | todo | Configuration-time performance pass | GH #106, #42 | +| F-017 | done | Configuration-time performance pass | GH #106, #42; validator/feature caches + lean deps; `docs/CONFIGURATION-PERFORMANCE.md` | ## P2 — forma-core extraction diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6292e251..24f2bd50 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -335,7 +335,7 @@ Suggested extraction order (tickets F-020…F-024): | ~~Missing Android getting-started tutorial~~ → [`docs/GETTING-STARTED.md`](GETTING-STARTED.md) | F-015 done | | Shared `library` suffix for JVM vs Android library | F-020 | | Plugin publish / Portal path | F-016 (`docs/PLUGIN-PUBLISH.md`; Portal org GH #133 is human) | -| Configuration-time cost (includer walk, stores) | F-017 | +| ~~Configuration-time cost (validators/deps/repos)~~ → [`docs/CONFIGURATION-PERFORMANCE.md`](CONFIGURATION-PERFORMANCE.md) | F-017 done | --- @@ -344,6 +344,7 @@ Suggested extraction order (tickets F-020…F-024): | Goal | Start here | |------|------------| | New user / first project | [`docs/GETTING-STARTED.md`](GETTING-STARTED.md) | +| Configuration-time performance | [`docs/CONFIGURATION-PERFORMANCE.md`](CONFIGURATION-PERFORMANCE.md) | | New target type | `AndroidTargets.kt` + new DSL file under `plugins/android/src/main/java/` + validator list | | Tighten dep rules | `validator(...)` in that DSL file; update this doc §2.2 | | Global SDK/AGP defaults | `androidProjectConfiguration` + sample `application/build.gradle.kts` | diff --git a/docs/CONFIGURATION-PERFORMANCE.md b/docs/CONFIGURATION-PERFORMANCE.md new file mode 100644 index 00000000..73a50886 --- /dev/null +++ b/docs/CONFIGURATION-PERFORMANCE.md @@ -0,0 +1,115 @@ +# Configuration-time performance (F-017) + +Forma should keep **Gradle configuration** cheap so large multi-module Android +graphs stay interactive. This note captures what we measure, what we changed +for GH #106 / #42, and what to avoid when extending Forma. + +## How to measure (sample `application/`) + +From a clean daemon + no configuration cache: + +```bash +source scripts/env-mac.sh +cd application +./gradlew --stop +rm -rf .gradle/configuration-cache +./gradlew help --no-configuration-cache --profile --offline +# open build/reports/profile/profile-*.html +# look at "Configuring Projects" +``` + +Warm / configuration-cache path: + +```bash +./gradlew help --profile --offline +# second run should reuse configuration cache when inputs are unchanged +``` + +Optional: `./gradlew help --scan` when a Build Scan account is available +(GH #42). Local `--profile` is enough for regression checks on this host. + +### Profile anchors on worker host (2026-07-12) + +Absolute numbers move with machine load, daemon state, and composite plugin +recompilation. Prefer same-machine before/after pairs. + +| Run | Configuring projects (before → after F-017) | Notes | +|-----|---------------------------------------------|-------| +| Daemon warm, `help --no-configuration-cache --offline` | ~4.8s → **~1.1s** (best pair) | first cold after heavy load can spike | +| `help` with configuration cache | ~0.7–1.3s | sample already enables CC | + +Treat F-017 as **allocation / hot-path** work (validators, deps, feature +definitions, skip empty repo blocks), not as a claim of a fixed wall-clock %. + +## What costs configuration time in Forma + +1. **Per-target work** — every `api` / `impl` / `androidLibrary` / … call: + - suffix validation for self + each project dependency + - AGP / Kotlin plugin apply + extension configuration + - dependency registration (`applyDependencies`) +2. **Dependency list plumbing** — `map` / `filter` / `flatMap` on dep specs + allocates intermediate `ArrayList`s (GH #106). +3. **Repeated repository configuration** — historically each target invoked + `Forma.settings.repositories` on `project.repositories`. Prefer settings / + `dependencyResolutionManagement` (sample already does); Forma no longer + re-applies empty or default repo lambdas per module. +4. **Composite builds** — sample includes `plugins/`, `includer/`, + `build-settings/`, `build-dependencies/` (settings + classpath). That cost + is paid once per cold daemon, not per target, but dominates small samples. +5. **Includer** — uses flat module names (`path` with `-` separators) to avoid + Gradle intermediate projects (see `IncluderPlugin`). + +## Changes shipped in F-017 + +| Area | Change | +|------|--------| +| `validator(...)` | Identity-cache single- and multi-suffix validators; hot path uses precomputed `endsWith` strings, no intermediate `map`/`contains` lists | +| `kotlinFeatureDefinition` / `kotlinAndroidFeatureDefinition` / kapt | Singleton `FeatureDefinition` instances; read live `Forma.settings` at apply time | +| `applyDependencies` | Skip `repositories {}` when config is the empty sentinel; skip entire `dependencies {}` block when all three dep args are `EmptyDependency`; skip plugin lookup when no plugin deps registered | +| `deps` / `FormaDependency.plus` | Prefer typed fields over `filterIsInstance`; pre-size / single-pass merges; indexed `forEach` over specs | +| Catalog generators | `filteredTokens` is a `Set` for O(1) membership | +| Android targets | Drop redundant `repositoriesConfiguration = Forma.settings.repositories` (default empty) | + +## Guidance for contributors + +**Do** + +- Reuse shared validators / feature definitions instead of allocating per call. +- Prefer `FormaDependency.forEach` over materializing `.names` / `.targets` / + `.files` when applying deps. +- Put repositories in **settings** (`dependencyResolutionManagement` / + `pluginManagement`), not in every subproject. +- Keep content validators (`onlyAllowResources`, …) cheap — they touch the + filesystem once per target that needs them. +- Profile cold configuration after non-trivial plugin changes. + +**Avoid** + +- Configuration-time `println` / logging in hot generators (removed in F-012). +- Building new `List` pipelines on every dependency edge. +- Calling `project.repositories { … }` from each target with the same block. +- Nested Gradle projects named with `:` path separators (Includer already + flattens this). + +## Configuration cache & build cache + +Sample `application/gradle.properties` already enables: + +- `org.gradle.caching=true` +- `org.gradle.parallel=true` +- `org.gradle.configureondemand=true` +- configuration-cache (unsafe flags for Gradle 8.4) + +Forma still uses a process-wide `FormaSettingsStore` singleton. That is +configuration-cache friendly only when settings are written during settings / +root buildscript evaluation and read during project configuration of the same +build — which is the supported layout. Do not mutate the store from task +actions. + +## Related + +- GH #106 Optimize configuration time +- GH #42 Build performance impact +- `docs/ARCHITECTURE.md` — module map +- `docs/DEPS-CATALOG.md` — pure generators +- Ticket **F-017** diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 1665b824..8be6fd30 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,6 +2,37 @@ Newest entries first. +## 2026-07-12 — F-017 Configuration-time performance pass (GH #106, #42) + +- **Ticket:** F-017 → `done` +- **Branch:** `forma/F-017-config-performance` (from `origin/v2`) +- **Code:** + - `validator(...)` identity-caches single/multi suffix validators; hot path uses + precomputed dash-suffixes (no per-call `map`/`contains` lists) + - `kotlinFeatureDefinition` / `kotlinAndroidFeatureDefinition` / kapt: singleton + `FeatureDefinition` instances; live `Forma.settings` read at apply time + - `applyDependencies`: empty-repo sentinel skips per-target `repositories {}`; + early-return when all dep bags are `EmptyDependency`; skip plugin lookup when + no plugin deps registered + - `deps` / `FormaDependency.plus` / `forEach`: typed merges + indexed loops + (less `filterIsInstance` / intermediate lists) + - Catalog `filteredTokens` → `Set`; Android targets drop redundant + `repositoriesConfiguration = Forma.settings.repositories` +- **Docs:** `docs/CONFIGURATION-PERFORMANCE.md` (measure + guidance); README / + ARCHITECTURE / SAMPLE-APP / TICKETS links +- **Verify (real tool output, OpenJDK 17 + SDK 34):** + - `plugins/`: `./gradlew build --offline` → **BUILD SUCCESSFUL** (63 tasks; + `:deps:test` green) + - `application/`: `./gradlew build --offline` → **BUILD SUCCESSFUL** + (2155 tasks) + - Profile `help --no-configuration-cache --offline` (daemon warm): Configuring + Projects ~4.8s → ~1.1s on best same-host pair; CC reuse `help` **880ms** + - Wall-clock is noisy under load (first cold after full app build spiked); + treat as allocation/hot-path win, not a fixed % +- **Commits/PRs:** this run — push + PR base `v2` +- **Blockers:** none for F-017; deeper remote Build Scans / GH #42 still optional +- **Next step:** F-020 Design forma-core public API (`docs/forma-core-api.md`) + ## 2026-07-12 — F-016 Plugin publish path (Portal + target publish config) - **Ticket:** F-016 → `done` (GH #132 code path; GH #133 Portal org remains human admin) diff --git a/docs/SAMPLE-APP.md b/docs/SAMPLE-APP.md index fc93b4f0..57813f27 100644 --- a/docs/SAMPLE-APP.md +++ b/docs/SAMPLE-APP.md @@ -119,5 +119,5 @@ JDK **17**. See [ENV.md](ENV.md), [COMPOSE.md](COMPOSE.md), [ARCHITECTURE.md](AR 5. **Modern targets** represented: View widgets + Compose widget side by side. 6. **Documented** enough to copy without reading every `build.gradle.kts`. -Not in scope for F-014: full navigation redesign (GH #46), publish path (F-016), -or configuration-time performance (F-017). +Not in scope for F-014: full navigation redesign (GH #46), publish path (F-016). +Configuration-time performance: [CONFIGURATION-PERFORMANCE.md](CONFIGURATION-PERFORMANCE.md) (F-017). diff --git a/plugins/android/src/main/java/androidApp.kt b/plugins/android/src/main/java/androidApp.kt index 71cb4dfe..32d5bb7a 100644 --- a/plugins/android/src/main/java/androidApp.kt +++ b/plugins/android/src/main/java/androidApp.kt @@ -81,7 +81,6 @@ fun Project.androidApp( UiLibraryTargetTemplate, ), dependencies = dependencies, - repositoriesConfiguration = Forma.settings.repositories, testDependencies = testDependencies, androidTestDependencies = androidTestDependencies, configurationFeatures = kaptConfigurationFeature() diff --git a/plugins/android/src/main/java/androidBinary.kt b/plugins/android/src/main/java/androidBinary.kt index a19b0c80..f2b94131 100644 --- a/plugins/android/src/main/java/androidBinary.kt +++ b/plugins/android/src/main/java/androidBinary.kt @@ -86,8 +86,7 @@ fun Project.androidBinary( ComposeWidgetTargetTemplate, UiLibraryTargetTemplate, ), - dependencies = dependencies, - repositoriesConfiguration = Forma.settings.repositories + dependencies = dependencies ) return TargetBuilder(this) diff --git a/plugins/android/src/main/java/androidLibrary.kt b/plugins/android/src/main/java/androidLibrary.kt index 8a64c316..240983e1 100644 --- a/plugins/android/src/main/java/androidLibrary.kt +++ b/plugins/android/src/main/java/androidLibrary.kt @@ -69,7 +69,6 @@ fun Project.androidLibrary( dependencies = dependencies, testDependencies = testDependencies, androidTestDependencies = androidTestDependencies, - repositoriesConfiguration = Forma.settings.repositories, configurationFeatures = kaptConfigurationFeature() ) diff --git a/plugins/android/src/main/java/androidRes.kt b/plugins/android/src/main/java/androidRes.kt index 6f25cbf9..f2ff63e1 100644 --- a/plugins/android/src/main/java/androidRes.kt +++ b/plugins/android/src/main/java/androidRes.kt @@ -43,7 +43,6 @@ fun Project.androidRes( WidgetTargetTemplate, ComposeWidgetTargetTemplate, ), - dependencies = dependencies, - repositoriesConfiguration = Forma.settings.repositories + dependencies = dependencies ) } diff --git a/plugins/android/src/main/java/androidTestUtil.kt b/plugins/android/src/main/java/androidTestUtil.kt index 78446034..902a8cfe 100644 --- a/plugins/android/src/main/java/androidTestUtil.kt +++ b/plugins/android/src/main/java/androidTestUtil.kt @@ -32,7 +32,6 @@ fun Project.androidTestUtil( applyDependencies( validator = validator(AndroidTestUtilTargetTemplate, TestUtilTargetTemplate), - dependencies = dependencies, - repositoriesConfiguration = Forma.settings.repositories + dependencies = dependencies ) } diff --git a/plugins/android/src/main/java/androidUtil.kt b/plugins/android/src/main/java/androidUtil.kt index 7671d58d..aee97ad2 100644 --- a/plugins/android/src/main/java/androidUtil.kt +++ b/plugins/android/src/main/java/androidUtil.kt @@ -66,7 +66,6 @@ fun Project.androidUtil( ), dependencies = dependencies, testDependencies = testDependencies, - repositoriesConfiguration = Forma.settings.repositories, configurationFeatures = kaptConfigurationFeature() ) } diff --git a/plugins/android/src/main/java/api.kt b/plugins/android/src/main/java/api.kt index f353c9b5..0a93e7d7 100644 --- a/plugins/android/src/main/java/api.kt +++ b/plugins/android/src/main/java/api.kt @@ -32,7 +32,6 @@ fun Project.api( ) applyDependencies( validator = validator(ApiTargetTemplate, LibraryTargetTemplate), - dependencies = dependencies, - repositoriesConfiguration = Forma.settings.repositories + dependencies = dependencies ) } diff --git a/plugins/android/src/main/java/composeWidget.kt b/plugins/android/src/main/java/composeWidget.kt index 787954ab..d9d976bf 100644 --- a/plugins/android/src/main/java/composeWidget.kt +++ b/plugins/android/src/main/java/composeWidget.kt @@ -64,7 +64,6 @@ fun Project.composeWidget( ), dependencies = dependencies, testDependencies = testDependencies, - androidTestDependencies = androidTestDependencies, - repositoriesConfiguration = Forma.settings.repositories + androidTestDependencies = androidTestDependencies ) } diff --git a/plugins/android/src/main/java/impl.kt b/plugins/android/src/main/java/impl.kt index 014bb20c..89940cee 100644 --- a/plugins/android/src/main/java/impl.kt +++ b/plugins/android/src/main/java/impl.kt @@ -79,7 +79,6 @@ fun Project.impl( dependencies = dependencies, testDependencies = testDependencies, androidTestDependencies = androidTestDependencies, - repositoriesConfiguration = Forma.settings.repositories, configurationFeatures = kaptConfigurationFeature() ) } diff --git a/plugins/android/src/main/java/library.kt b/plugins/android/src/main/java/library.kt index 1115a632..5d8a2887 100644 --- a/plugins/android/src/main/java/library.kt +++ b/plugins/android/src/main/java/library.kt @@ -35,7 +35,6 @@ fun Project.library( validator = validator(UtilTargetTemplate, TestUtilTargetTemplate), dependencies = dependencies, testDependencies = testDependencies, - repositoriesConfiguration = Forma.settings.repositories, configurationFeatures = kaptConfigurationFeature() ) } diff --git a/plugins/android/src/main/java/testUtil.kt b/plugins/android/src/main/java/testUtil.kt index 8b2e4176..7a51ee2a 100644 --- a/plugins/android/src/main/java/testUtil.kt +++ b/plugins/android/src/main/java/testUtil.kt @@ -30,7 +30,6 @@ fun Project.testUtil( applyDependencies( validator = validator(TestUtilTargetTemplate, UtilTargetTemplate), - dependencies = dependencies, - repositoriesConfiguration = Forma.settings.repositories + dependencies = dependencies ) } diff --git a/plugins/android/src/main/java/tools/forma/android/feature/FeatureDefinition.kt b/plugins/android/src/main/java/tools/forma/android/feature/FeatureDefinition.kt index ea7b2a91..e23d6284 100644 --- a/plugins/android/src/main/java/tools/forma/android/feature/FeatureDefinition.kt +++ b/plugins/android/src/main/java/tools/forma/android/feature/FeatureDefinition.kt @@ -15,14 +15,19 @@ data class FeatureDefinition( val pluginExtension: KClass, val featureConfiguration: FeatureConfiguration, val defaultDependencies: NamedDependency = emptyDependency(), - val androidProjectSettings: AndroidProjectSettings = Forma.settings, + /** + * Optional settings override. When null (default), [applyConfiguration] reads the live + * [Forma.settings] so cached [FeatureDefinition] instances stay correct after + * `androidProjectConfiguration { … }` stores settings (F-017). + */ + val androidProjectSettings: AndroidProjectSettings? = null, val configuration: (Extension, FeatureConfiguration, Project, AndroidProjectSettings) -> Unit ) { fun applyConfiguration(project: Project) = configuration( project.the(pluginExtension), featureConfiguration, project, - androidProjectSettings + androidProjectSettings ?: Forma.settings ) } @@ -31,7 +36,10 @@ fun Project.applyFeatures( ) = features.forEach { definition -> apply(plugin = definition.pluginName) definition.applyConfiguration(this) - definition.defaultDependencies.names.forEach { - dependencies.addDependencyTo(it.config.name, it.name) { isTransitive = it.transitive } + val defaults = definition.defaultDependencies.names + if (defaults.isNotEmpty()) { + defaults.forEach { + dependencies.addDependencyTo(it.config.name, it.name) { isTransitive = it.transitive } + } } } diff --git a/plugins/android/src/main/java/tools/forma/android/feature/Kotlin.kt b/plugins/android/src/main/java/tools/forma/android/feature/Kotlin.kt index fcd5a221..7ced6809 100644 --- a/plugins/android/src/main/java/tools/forma/android/feature/Kotlin.kt +++ b/plugins/android/src/main/java/tools/forma/android/feature/Kotlin.kt @@ -22,38 +22,50 @@ private fun defaultConfiguration(project: Project, androidProjectSettings: Andro } } -fun kotlinFeatureDefinition() = FeatureDefinition( - pluginName = "kotlin", - pluginExtension = KotlinJvmProjectExtension::class, - featureConfiguration = {}, - configuration = featureConfiguration() -) - -private fun featureConfiguration() = - { _: Extension, _: FeatureConfiguration, project: Project, configuration: AndroidProjectSettings -> - defaultConfiguration( - project, - configuration - ) - } +private val sharedFeatureConfiguration: + (Any, Any, Project, AndroidProjectSettings) -> Unit = + { _, _, project, configuration -> defaultConfiguration(project, configuration) } + +/** Cached — same definition for every pure-JVM target (F-017). */ +private val kotlinFeatureDefinitionInstance = + FeatureDefinition( + pluginName = "kotlin", + pluginExtension = KotlinJvmProjectExtension::class, + featureConfiguration = Unit, + configuration = sharedFeatureConfiguration + ) + +/** Cached — same definition for every Android library / widget target (F-017). */ +private val kotlinAndroidFeatureDefinitionInstance = + FeatureDefinition( + pluginName = "kotlin-android", + pluginExtension = KotlinAndroidProjectExtension::class, + featureConfiguration = Unit, + configuration = sharedFeatureConfiguration + ) + +private val kotlinKaptFeatureDefinitionInstance = + FeatureDefinition( + pluginName = "kotlin-kapt", + pluginExtension = KaptExtension::class, + featureConfiguration = Unit, + defaultDependencies = deps("org.jetbrains.kotlinx:kotlinx-metadata-jvm:0.2.0".kapt), + configuration = sharedFeatureConfiguration + ) + +fun kotlinFeatureDefinition() = kotlinFeatureDefinitionInstance + +fun kotlinAndroidFeatureDefinition() = kotlinAndroidFeatureDefinitionInstance + +fun kotlinKaptFeatureDefinition() = kotlinKaptFeatureDefinitionInstance -fun kotlinAndroidFeatureDefinition() = FeatureDefinition( - pluginName = "kotlin-android", - pluginExtension = KotlinAndroidProjectExtension::class, - featureConfiguration = {}, - configuration = featureConfiguration() -) - -fun kotlinKaptFeatureDefinition() = FeatureDefinition( - pluginName = "kotlin-kapt", - pluginExtension = KaptExtension::class, - featureConfiguration = {}, - defaultDependencies = deps("org.jetbrains.kotlinx:kotlinx-metadata-jvm:0.2.0".kapt), - configuration = featureConfiguration() -) - -fun Project.kaptConfigurationFeature(): Map Unit> = mapOf(Kapt to { - applyFeatures( - kotlinKaptFeatureDefinition() +/** + * Lazy kapt plugin application when a target declares kapt deps. + * Map is small and shared; the lambda closes over the [Project] receiver. + */ +fun Project.kaptConfigurationFeature(): Map Unit> = + mapOf( + Kapt to { + applyFeatures(kotlinKaptFeatureDefinition()) + } ) -}) diff --git a/plugins/android/src/main/java/uiLibrary.kt b/plugins/android/src/main/java/uiLibrary.kt index 51185a1b..23000aff 100644 --- a/plugins/android/src/main/java/uiLibrary.kt +++ b/plugins/android/src/main/java/uiLibrary.kt @@ -66,7 +66,6 @@ fun Project.uiLibrary( dependencies = dependencies, testDependencies = testDependencies, androidTestDependencies = androidTestDependencies, - repositoriesConfiguration = Forma.settings.repositories, configurationFeatures = kaptConfigurationFeature() ) diff --git a/plugins/android/src/main/java/util.kt b/plugins/android/src/main/java/util.kt index 72b0d33c..771f215a 100644 --- a/plugins/android/src/main/java/util.kt +++ b/plugins/android/src/main/java/util.kt @@ -48,7 +48,6 @@ fun Project.util( applyDependencies( validator = validator(UtilTargetTemplate, LibraryTargetTemplate), dependencies = dependencies, - testDependencies = testDependencies, - repositoriesConfiguration = Forma.settings.repositories + testDependencies = testDependencies ) } diff --git a/plugins/android/src/main/java/viewBinding.kt b/plugins/android/src/main/java/viewBinding.kt index d9855a57..f34d948e 100644 --- a/plugins/android/src/main/java/viewBinding.kt +++ b/plugins/android/src/main/java/viewBinding.kt @@ -59,7 +59,6 @@ fun Project.viewBinding( LibraryTargetTemplate, AndroidUtilTargetTemplate, ), - dependencies = dependencies, - repositoriesConfiguration = Forma.settings.repositories + dependencies = dependencies ) } diff --git a/plugins/android/src/main/java/widget.kt b/plugins/android/src/main/java/widget.kt index 4aeb0074..afc93f74 100644 --- a/plugins/android/src/main/java/widget.kt +++ b/plugins/android/src/main/java/widget.kt @@ -62,7 +62,6 @@ fun Project.widget( ), dependencies = dependencies, testDependencies = testDependencies, - androidTestDependencies = androidTestDependencies, - repositoriesConfiguration = Forma.settings.repositories + androidTestDependencies = androidTestDependencies ) } diff --git a/plugins/deps/src/main/java/dependencies.kt b/plugins/deps/src/main/java/dependencies.kt index 615bada0..09be22a9 100644 --- a/plugins/deps/src/main/java/dependencies.kt +++ b/plugins/deps/src/main/java/dependencies.kt @@ -23,6 +23,10 @@ import tools.forma.deps.core.TargetDependency import tools.forma.deps.core.TargetSpec import tools.forma.target.FormaTarget +/** + * Typed views over [DepType]. Prefer [FormaDependency.forEach] at apply-time so we do not + * allocate three filtered lists when only one kind is needed (F-017 / GH #106). + */ val DepType.names: List get(): List = filterIsInstance(NameSpec::class.java) @@ -34,14 +38,13 @@ val DepType.files: List val Provider.dep: NameSpec get() { - val depName = get().run { "$group:$name:$version" } + val resolved = get() + val depName = "${resolved.group}:${resolved.name}:${resolved.version}" val pluginConf = FormaSettingsStore.pluginFor(depName) - return with(get()) { - NameSpec( - "$group:$name:$version", - pluginConf?.configuration?.let(::CustomConfiguration) ?: Implementation - ) - } + return NameSpec( + depName, + pluginConf?.configuration?.let(::CustomConfiguration) ?: Implementation + ) } fun Provider.dep(configuration: CustomConfiguration): NameSpec { @@ -58,12 +61,80 @@ val Dependency.dep: NameSpec val Provider.dep: List get() = get().map { it.dep } -infix operator fun FormaDependency.plus(dep: FormaDependency): MixedDependency = - MixedDependency( - dependency.names + dep.dependency.names, - dependency.targets + dep.dependency.targets, - dependency.files + dep.dependency.files - ) +infix operator fun FormaDependency.plus(dep: FormaDependency): MixedDependency { + // Avoid three filterIsInstance passes (names/targets/files) when both sides are already typed. + val leftNames: List + val leftTargets: List + val leftFiles: List + when (this) { + is NamedDependency -> { + leftNames = names + leftTargets = emptyList() + leftFiles = emptyList() + } + is TargetDependency -> { + leftNames = emptyList() + leftTargets = targets + leftFiles = emptyList() + } + is FileDependency -> { + leftNames = emptyList() + leftTargets = emptyList() + leftFiles = files + } + is MixedDependency -> { + leftNames = names + leftTargets = targets + leftFiles = files + } + is PlatformDependency -> { + leftNames = emptyList() + leftTargets = emptyList() + leftFiles = emptyList() + } + EmptyDependency -> { + leftNames = emptyList() + leftTargets = emptyList() + leftFiles = emptyList() + } + } + val rightNames: List + val rightTargets: List + val rightFiles: List + when (dep) { + is NamedDependency -> { + rightNames = dep.names + rightTargets = emptyList() + rightFiles = emptyList() + } + is TargetDependency -> { + rightNames = emptyList() + rightTargets = dep.targets + rightFiles = emptyList() + } + is FileDependency -> { + rightNames = emptyList() + rightTargets = emptyList() + rightFiles = dep.files + } + is MixedDependency -> { + rightNames = dep.names + rightTargets = dep.targets + rightFiles = dep.files + } + is PlatformDependency -> { + rightNames = emptyList() + rightTargets = emptyList() + rightFiles = emptyList() + } + EmptyDependency -> { + rightNames = emptyList() + rightTargets = emptyList() + rightFiles = emptyList() + } + } + return MixedDependency(leftNames + rightNames, leftTargets + rightTargets, leftFiles + rightFiles) +} inline fun emptyDependency(): T = when (T::class) { @@ -84,8 +155,10 @@ fun FormaDependency.forEach( fileAction: (FileSpec) -> Unit = {}, platformAction: (PlatformSpec) -> Unit = {} ) { - dependency.forEach { spec -> - when (spec) { + val specs = dependency + if (specs.isEmpty()) return + for (i in specs.indices) { + when (val spec = specs[i]) { is TargetSpec -> targetAction(spec) is NameSpec -> nameAction(spec) is PlatformSpec -> platformAction(spec) @@ -97,10 +170,10 @@ fun FormaDependency.forEach( fun deps(vararg names: String): NamedDependency = transitiveDeps(names = names, transitive = false) fun transitivePlatform(vararg names: String, transitive: Boolean = true): PlatformDependency = - PlatformDependency(names.toList().map { PlatformSpec(it, Implementation, transitive) }) + PlatformDependency(names.map { PlatformSpec(it, Implementation, transitive) }) fun transitiveDeps(vararg names: String, transitive: Boolean = true): NamedDependency = - NamedDependency(names.toList().map { NameSpec(it, Implementation, transitive) }) + NamedDependency(names.map { NameSpec(it, Implementation, transitive) }) fun deps(vararg targets: FormaTarget): TargetDependency = TargetDependency(targets.map { TargetSpec(it, Implementation) }) @@ -108,32 +181,46 @@ fun deps(vararg targets: FormaTarget): TargetDependency = fun deps(vararg files: File): FileDependency = FileDependency(files.map { FileSpec(it, Implementation) }) -fun deps(vararg dependencies: NamedDependency): NamedDependency = - dependencies.flatMap { it.names }.let(::NamedDependency) +fun deps(vararg dependencies: NamedDependency): NamedDependency { + if (dependencies.isEmpty()) return NamedDependency() + if (dependencies.size == 1) return dependencies[0] + val out = ArrayList(dependencies.sumOf { it.names.size }) + for (dep in dependencies) out.addAll(dep.names) + return NamedDependency(out) +} fun deps(vararg projects: DelegatingProjectDependency) = projects.map { TargetSpec(target(it)) }.let(::TargetDependency) -fun deps(vararg dependencies: Provider<*>): NamedDependency = - dependencies - .flatMap { provider -> - val source = provider.get() - @Suppress("UNCHECKED_CAST") - when (source) { - // here we need to call .dep on provider to get the correct configuration - // since on configuration phase we don't have the actual dependency - is Dependency -> listOf((provider as Provider).dep) - is ExternalModuleDependencyBundle -> source.map { it.dep } - else -> - throw IllegalArgumentException( - "Unsupported dependency type ${source::class.simpleName}" - ) +fun deps(vararg dependencies: Provider<*>): NamedDependency { + if (dependencies.isEmpty()) return NamedDependency() + val out = ArrayList(dependencies.size) + for (provider in dependencies) { + val source = provider.get() + @Suppress("UNCHECKED_CAST") + when (source) { + // here we need to call .dep on provider to get the correct configuration + // since on configuration phase we don't have the actual dependency + is Dependency -> out.add((provider as Provider).dep) + is ExternalModuleDependencyBundle -> { + for (item in source) out.add(item.dep) } + else -> + throw IllegalArgumentException( + "Unsupported dependency type ${source::class.simpleName}" + ) } - .let(::NamedDependency) + } + return NamedDependency(out) +} -fun deps(vararg dependencies: TargetDependency): TargetDependency = - dependencies.flatMap { it.targets }.let(::TargetDependency) +fun deps(vararg dependencies: TargetDependency): TargetDependency { + if (dependencies.isEmpty()) return TargetDependency() + if (dependencies.size == 1) return dependencies[0] + val out = ArrayList(dependencies.sumOf { it.targets.size }) + for (dep in dependencies) out.addAll(dep.targets) + return TargetDependency(out) +} fun kapt(vararg names: String): NamedDependency = NamedDependency(names.map { NameSpec(it, Kapt, true) }) diff --git a/plugins/deps/src/main/java/tools.forma/deps/core/PluginWrapper.kt b/plugins/deps/src/main/java/tools.forma/deps/core/PluginWrapper.kt index 2e19cb42..b50af5c2 100644 --- a/plugins/deps/src/main/java/tools.forma/deps/core/PluginWrapper.kt +++ b/plugins/deps/src/main/java/tools.forma/deps/core/PluginWrapper.kt @@ -20,7 +20,7 @@ class PluginWrapper( project.applyDependencies( validator = EmptyValidator, dependencies = dependencies, - repositoriesConfiguration = {} + repositoriesConfiguration = EmptyRepositoriesConfiguration ) } } diff --git a/plugins/deps/src/main/java/tools.forma/deps/core/applyDependencies.kt b/plugins/deps/src/main/java/tools.forma/deps/core/applyDependencies.kt index 7f581d0e..700020a5 100644 --- a/plugins/deps/src/main/java/tools.forma/deps/core/applyDependencies.kt +++ b/plugins/deps/src/main/java/tools.forma/deps/core/applyDependencies.kt @@ -9,17 +9,35 @@ import org.gradle.kotlin.dsl.dependencies import tools.forma.config.FormaSettingsStore import tools.forma.validation.Validator +/** No-op repo config sentinel — skip [Project.repositories] when callers pass this. */ +val EmptyRepositoriesConfiguration: RepositoryHandler.() -> Unit = {} + fun Project.applyDependencies( validator: Validator, - repositoriesConfiguration: RepositoryHandler.() -> Unit, + repositoriesConfiguration: RepositoryHandler.() -> Unit = EmptyRepositoriesConfiguration, dependencies: FormaDependency = emptyDependency(), testDependencies: FormaDependency = emptyDependency(), androidTestDependencies: FormaDependency = emptyDependency(), configurationFeatures: Map Unit> = emptyMap() ) { - repositoriesConfiguration(repositories) + // Per-target repository blocks are expensive at scale. Prefer empty / settings-level + // dependencyResolutionManagement; only invoke when the caller passes a real config. + if (repositoriesConfiguration !== EmptyRepositoriesConfiguration) { + repositoriesConfiguration(repositories) + } + + // EmptyDependency is the common default — avoid opening a dependencies {} block at all. + if ( + dependencies === EmptyDependency && + testDependencies === EmptyDependency && + androidTestDependencies === EmptyDependency + ) { + return + } + // Prevent same plugin to be applied twice val appliedPlugins = mutableSetOf() + val hasPluginDeps = FormaSettingsStore.dependencyPlugins.isNotEmpty() dependencies { val projectAction: (TargetSpec) -> Unit = { @@ -28,12 +46,12 @@ fun Project.applyDependencies( } dependencies.forEach( { spec -> - val plugin = FormaSettingsStore.pluginFor(spec.name) + val plugin = + if (hasPluginDeps) FormaSettingsStore.pluginFor(spec.name) else null if (plugin != null) { val pluginName = plugin.plugin.get().pluginId.split(":")[0] - if (!appliedPlugins.contains(pluginName)) { + if (appliedPlugins.add(pluginName)) { apply(plugin = pluginName) - appliedPlugins.add(pluginName) } // For custom plugin specs we always apply transitive dependencies // since this is what most of the plugins expect @@ -47,19 +65,23 @@ fun Project.applyDependencies( { add(it.config.name, files(it.file)) }, { addDependencyTo(it.config.name, platform(it.name)) { isTransitive = it.transitive } } ) - testDependencies.forEach( - { addDependencyTo("testImplementation", it.name) { isTransitive = it.transitive } }, - { add("testImplementation", it.target.project) }, - { add("testImplementation", files(it.file)) } - ) - androidTestDependencies.forEach( - { - addDependencyTo("androidTestImplementation", it.name) { - isTransitive = it.transitive - } - }, - { add("androidTestImplementation", it.target.project) }, - { add("androidTestImplementation", files(it.file)) } - ) + if (testDependencies !== EmptyDependency) { + testDependencies.forEach( + { addDependencyTo("testImplementation", it.name) { isTransitive = it.transitive } }, + { add("testImplementation", it.target.project) }, + { add("testImplementation", files(it.file)) } + ) + } + if (androidTestDependencies !== EmptyDependency) { + androidTestDependencies.forEach( + { + addDependencyTo("androidTestImplementation", it.name) { + isTransitive = it.transitive + } + }, + { add("androidTestImplementation", it.target.project) }, + { add("androidTestImplementation", files(it.file)) } + ) + } } } diff --git a/plugins/deps/src/main/java/tools/forma/deps/catalog/Generators.kt b/plugins/deps/src/main/java/tools/forma/deps/catalog/Generators.kt index 8afc4212..5703278d 100644 --- a/plugins/deps/src/main/java/tools/forma/deps/catalog/Generators.kt +++ b/plugins/deps/src/main/java/tools/forma/deps/catalog/Generators.kt @@ -8,9 +8,10 @@ import org.gradle.configurationcache.extensions.capitalized * accessor names (e.g. `com.jakewharton.timber:timber:5.0.1` → `jakewhartonTimber`). * * Keep this list conservative: filtering too aggressively yields empty or colliding names. + * Stored as a [Set] for O(1) membership during name generation (F-017). */ -val filteredTokens = - listOf( +val filteredTokens: Set = + setOf( "com", "io", "net", @@ -67,6 +68,7 @@ internal fun generateName( ): String { val name = tokens + .asSequence() .filter { it.isNotBlank() && it !in filteredTokens } .distinct() .joinToString("") { it.capitalized() } diff --git a/plugins/validation/src/main/java/tools/forma/validation/Validator.kt b/plugins/validation/src/main/java/tools/forma/validation/Validator.kt index 9fc6db8f..eaa778d0 100644 --- a/plugins/validation/src/main/java/tools/forma/validation/Validator.kt +++ b/plugins/validation/src/main/java/tools/forma/validation/Validator.kt @@ -14,16 +14,59 @@ object EmptyValidator : Validator { } fun TargetTemplate.validate(name: String): Boolean { - return name == suffix || name.endsWith("-$suffix") + return name == suffix || name.endsWith("-$suffix", ignoreCase = false) } fun FormaTarget.validate(target: TargetTemplate) { validator(target).validate(this) } -fun validator(vararg targets: TargetTemplate): Validator = object : Validator { +/** + * Name-suffix validator for project dependency / self-type checks. + * + * Validators are **identity-cached** for a given set of [TargetTemplate] instances so + * multi-module configuration does not allocate a fresh anonymous [Validator] (and + * intermediate lists) on every `impl` / `api` / … call (F-017 / GH #106). + */ +fun validator(vararg targets: TargetTemplate): Validator { + if (targets.isEmpty()) return EmptyValidator + if (targets.size == 1) { + val only = targets[0] + return singleValidators.getOrPut(only) { SingleSuffixValidator(only) } + } + // Identity-based key: TargetTemplate objects are singletons in Forma. + val key = targets.toList() + return multiValidators.getOrPut(key) { MultiSuffixValidator(targets.copyOf()) } +} + +private val singleValidators = java.util.concurrent.ConcurrentHashMap() +private val multiValidators = java.util.concurrent.ConcurrentHashMap, Validator>() + +private class SingleSuffixValidator( + private val template: TargetTemplate +) : Validator { + private val suffix: String = template.suffix + private val dashSuffix: String = "-$suffix" + override fun validate(target: FormaTarget) { - validateName(target.name, *targets) + val name = target.name + if (name == suffix || name.endsWith(dashSuffix)) return + throwProjectValidationError(name, listOf(template)) + } +} + +private class MultiSuffixValidator( + private val templates: Array +) : Validator { + private val suffixes: Array = Array(templates.size) { templates[it].suffix } + private val dashSuffixes: Array = Array(suffixes.size) { "-${suffixes[it]}" } + + override fun validate(target: FormaTarget) { + val name = target.name + for (i in suffixes.indices) { + if (name == suffixes[i] || name.endsWith(dashSuffixes[i])) return + } + throwProjectValidationError(name, templates.asList()) } } @@ -31,10 +74,10 @@ fun validateName( name: String, vararg targets: TargetTemplate ) { - //Name should match with at least one targetName - if (targets.map { it.validate(name) }.contains(true).not()) { - throwProjectValidationError(name, targets.toList()) + for (template in targets) { + if (template.validate(name)) return } + throwProjectValidationError(name, targets.toList()) } fun throwProjectValidationError( @@ -60,4 +103,4 @@ fun throwProjectDepsValidationError( Allowed only ${allowedTargets.map { it.suffix }} types """.trimIndent() ) -} \ No newline at end of file +}