Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,24 @@ androidLibrary(

Worker / contributor host setup (JDK 17+, Android SDK platform 33): see [`docs/ENV.md`](docs/ENV.md) and `source scripts/env-mac.sh`.

## External dependency catalogs

Declare third-party libraries and plugins once in `settings.gradle.kts` with
`projectDependencies` (version catalog), or use typed Kotlin catalogs
(`build-dependencies/` pattern). Full guide: [`docs/DEPS-CATALOG.md`](docs/DEPS-CATALOG.md).

```gradle
// settings.gradle.kts
projectDependencies(
"libs",
"com.jakewharton.timber:timber:5.0.1",
library("io.coil-kt:coil:2.1.0", name = "coil"),
bundle(name = "room", "androidx.room:room-runtime:2.5.1", /* … */),
plugin("androidx.navigation:navigation-safe-args-gradle-plugin", "2.7.4"),
)
// modules: deps(libs.jakewhartonTimber, libs.coil, libs.bundles.room)
```

## Progress

DSL names match code entrypoints (`widget`, `util`, …). **Validation** =
Expand Down
2 changes: 1 addition & 1 deletion TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Update this file when picking or finishing work. Cron workers must pick the **hi
|----|--------|-------|-------|
| F-010 | done | Document strict dependency matrix from live code (not only README) | `docs/DEPENDENCY-MATRIX.md` + README summary aligned to validators |
| F-011 | done | Tighten validation for `api` / `impl` (Dagger2-friendly boundaries) | app/binary/androidLibrary no longer EmptyValidator; impl still no→impl; docs matrix updated |
| F-012 | todo | External deps catalog UX + tooling polish | `plugins/deps` catalog generators |
| F-012 | done | External deps catalog UX + tooling polish | `library()` + pure generators + GAV validation + unit tests + `docs/DEPS-CATALOG.md` |
| F-013 | todo | Compose support for Android library/ui targets | GH #96 |
| F-014 | todo | Sample app: gold-standard multi-feature structure | Home/characters already present; modernize |
| F-015 | todo | Android project tutorial (getting started) | GH #53 |
Expand Down
9 changes: 8 additions & 1 deletion application/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import tools.forma.deps.catalog.bundle
import tools.forma.deps.catalog.library
import tools.forma.deps.catalog.plugin
import tools.forma.deps.catalog.projectDependencies
import tools.forma.deps.core.CustomConfiguration
Expand Down Expand Up @@ -66,10 +67,16 @@ val roomVersion = "2.5.1"

val ksp = CustomConfiguration("ksp")

// Version catalog UX: bare GAV, library(name=…), bundle, plugin — see docs/DEPS-CATALOG.md
projectDependencies(
"libs",
"com.jakewharton.timber:timber:5.0.1",
bundle(name = "coil", "io.coil-kt:coil:$coilVersion", "io.coil-kt:coil-base:$coilVersion"),
// Explicit short names (library()) keep accessors stable: libs.coil, libs.coilBase
bundle(
name = "coil",
library("io.coil-kt:coil:$coilVersion", name = "coil"),
library("io.coil-kt:coil-base:$coilVersion", name = "coilBase"),
),
bundle(
name = "room",
"androidx.sqlite:sqlite:$sqliteVersion",
Expand Down
5 changes: 3 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,9 @@ compose flag, owners mandatory flag, Java compatibility).
`FileDependency`, `PlatformDependency`, `MixedDependency`, `EmptyDependency`)
- Application: `applyDependencies` — validates each project dep, applies plugin
side-effects from catalog registrations, sets transitive flags
- Catalog: `projectDependencies` / `bundle` / `plugin` in settings
(`tools.forma.deps.catalog`) + name generators
- Catalog: `projectDependencies` / `library` / `bundle` / `plugin` in settings
(`tools.forma.deps.catalog`) + pure name generators + GAV validation
(user guide: [`DEPS-CATALOG.md`](DEPS-CATALOG.md))
- Sample also uses hand-written catalogs in `build-dependencies/dependencies`
(`Androidx`, `Google`, `Test`, …)

Expand Down
148 changes: 148 additions & 0 deletions docs/DEPS-CATALOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Forma external dependency catalogs

Forma has **two** complementary ways to declare third-party libraries. This doc is the
user-facing guide for **F-012** catalog UX (settings-level version catalogs) and how it
relates to the typed demo catalogs under `build-dependencies/`.

## 1. Settings version catalog — `projectDependencies`

Declare libraries, bundles, and plugins once in `settings.gradle.kts`. Forma fills a
Gradle [version catalog](https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog)
so modules can depend on type-safe accessors (`libs.*`).

```kotlin
import tools.forma.deps.catalog.bundle
import tools.forma.deps.catalog.library
import tools.forma.deps.catalog.plugin
import tools.forma.deps.catalog.projectDependencies
import tools.forma.deps.core.CustomConfiguration

val ksp = CustomConfiguration("ksp")

projectDependencies(
"libs", // catalog name → accessors under `libs`
// Bare GAV → auto name (see generators below)
"com.jakewharton.timber:timber:5.0.1",
// Explicit short name when auto name is awkward
library("io.coil-kt:coil:2.1.0", name = "coil"),
library("io.coil-kt:coil-base:2.1.0", name = "coilBase"),
bundle(
name = "room",
"androidx.sqlite:sqlite:2.2.0",
"androidx.room:room-runtime:2.5.1",
// …
),
plugin(
id = "com.google.devtools.ksp:symbol-processing-gradle-plugin",
version = "1.9.0-1.0.13",
configuration = ksp,
"androidx.room:room-compiler:2.5.1"
),
plugin("androidx.navigation:navigation-safe-args-gradle-plugin", "2.7.4"),
)
```

### Entry kinds

| Entry | Factory | Catalog result | Notes |
|-------|---------|----------------|-------|
| GAV string | `"group:artifact:version"` | `libs.<generatedName>` | Must be exactly three `:` segments |
| Named library | `library(gav, name = "…")` | `libs.<name>` | Prefer when auto name is long/unclear |
| Bundle | `bundle(name, …gavs)` | `libs.bundles.<name>` | Members also registered as libraries |
| Plugin | `plugin(id, version, …)` | `libs.plugins.<generatedName>` | Optional companion libs + `CustomConfiguration` |

### Auto name generators

`defaultNameGenerator` (libraries) and `pluginNameGenerator` (plugins) camelCase the
coordinate after dropping common tokens (`com`, `org`, `androidx`, `google`, `plugin`, …)
and the version segment (libraries only).

| Coordinate | Generated accessor |
|------------|--------------------|
| `com.jakewharton.timber:timber:5.0.1` | `jakewhartonTimber` |
| `io.coil-kt:coil-base:2.1.0` | `coilKtBase` |
| `androidx.room:room-runtime:2.5.1` | `roomRuntime` |
| `androidx.navigation:navigation-safe-args-gradle-plugin` | `navigationSafeArgs` |

If every token is filtered, generators throw with a message asking for an explicit
`library(…, name = …)` instead of producing an empty name.

Generators are **pure** (no configuration-time `println`). Use Gradle debug logging if
you need to inspect catalog contents.

### Using catalog deps in targets

```kotlin
androidLibrary(
packageName = "…",
dependencies = deps(
libs.jakewhartonTimber,
libs.bundles.room,
) + deps(target(":some:api")),
)
```

`deps(Provider…)` / `Provider.dep` bridge catalog entries into Forma's
`NamedDependency` model. When a library was registered with a plugin's
`CustomConfiguration` (e.g. KSP), `applyDependencies` applies that plugin and uses the
custom configuration automatically.

### Plugin + processor wiring

```kotlin
plugin(
id = "com.google.devtools.ksp:symbol-processing-gradle-plugin",
version = "…",
configuration = CustomConfiguration("ksp"),
"androidx.room:room-compiler:2.5.1" // registered on the `ksp` configuration
)
```

At dependency application time, consuming the room-compiler coordinate applies the KSP
plugin once and adds the processor with `isTransitive = true`.

## 2. Typed catalogs — `build-dependencies/` (sample pattern)

The sample app also ships **hand-written** Kotlin objects
(`androidx`, `google`, `test`, …) under `build-dependencies/dependencies`. Those use
`String.dep` / `deps(...)` and nest transitive graphs explicitly (e.g. `androidx.appcompat`
pulls fragment, core, …).

| Approach | Best for | Trade-offs |
|----------|----------|------------|
| `projectDependencies` version catalog | App-level third-party pins, plugins, short `libs.*` accessors | Names generated or explicit; less control over nested transitive graphs |
| Typed `build-dependencies` objects | Large shared graphs, team-owned versions, non-transitive-by-default trees | More boilerplate; included via `includeBuild` + demo plugin |

Both can coexist: the sample uses catalogs for timber/coil/room/plugins and typed objects
for the androidx/google surface used across features.

## 3. API surface (plugins `:deps`)

| Symbol | Package | Role |
|--------|---------|------|
| `projectDependencies` | `tools.forma.deps.catalog` | Settings DSL entry |
| `library` / `LibraryDep` | same | Explicit library registration |
| `bundle` / `BundleDep` | same | Named library groups |
| `plugin` / `PluginDep` | same | Plugin + optional config/libs |
| `defaultNameGenerator` / `pluginNameGenerator` | same | Pure name helpers (overridable) |
| `parseGroupArtifactVersion` | same | GAV validation |
| `deps` / `String.dep` / `Provider.dep` | root (`dependencies.kt`) | Bridge into `FormaDependency` |
| `applyDependencies` | `tools.forma.deps.core` | Wire deps + plugin side effects |

## 4. Practical tips

1. **Pin versions in one place** — either catalog constants in `settings.gradle.kts` or
`versions` object in a typed catalog module; avoid scattering GAVs across targets.
2. **Prefer `library(…, name = …)`** for public-facing short names you will type often.
3. **Use `bundle`** when several artifacts always travel together (Room, Coil).
4. **Keep transitive control intentional** — bare catalog `deps(libs.foo)` follows Forma's
default non-transitive named-deps path unless a plugin registration forces transitive.
5. **Invalid GAV fails fast** — `group:artifact:version` only; two or four segments throw
a clear `IllegalArgumentException` at configuration time.

## Related

- Live project-dep matrix: [`DEPENDENCY-MATRIX.md`](DEPENDENCY-MATRIX.md)
- Architecture map: [`ARCHITECTURE.md`](ARCHITECTURE.md) §2.4
- Sample settings: `application/settings.gradle.kts`
- Sample typed catalogs: `build-dependencies/dependencies/src/main/kotlin/`
22 changes: 22 additions & 0 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@

Newest entries first.

## 2026-07-11 — F-012 External deps catalog UX

- **Ticket:** F-012 → `done`
- **Branch:** `forma/F-012-deps-catalog-ux` (from `origin/v2`)
- **Code (`plugins/deps` catalog):**
- Pure name generators (removed configuration-time `println`)
- Clear GAV validation via `parseGroupArtifactVersion` (exactly `group:artifact:version`)
- Fail-fast when auto-name would be empty (all tokens filtered)
- New `library(gav, name = …)` factory for stable short accessors
- `projectDependencies` accepts `library()` / `bundle()` / `plugin()` / bare GAV; better error for unknown types
- Unit tests: `GeneratorsTest` (`:deps:test`)
- **Sample:** `application/settings.gradle.kts` uses `library(..., name = "coil"|"coilBase")` inside the coil bundle
- **Docs:** `docs/DEPS-CATALOG.md` (user guide); README section; ARCHITECTURE §2.4 pointer
- **Verify (real tool output, OpenJDK 17 + SDK 33):**
- `plugins/`: `./gradlew build` → **BUILD SUCCESSFUL**
- `plugins/`: `./gradlew :deps:test` → **BUILD SUCCESSFUL** (GeneratorsTest)
- `application/`: `./gradlew build` → **BUILD SUCCESSFUL** (2080 tasks)
- Configuration log no longer spam-prints `Generated name …`
- **Commits/PRs:** this run — push + PR base `v2`
- **Blockers:** none
- **Next step:** F-013 Compose support (GH #96)

## 2026-07-11 — F-011 api/impl + composition-root validation

- **Ticket:** F-011 → `done`
Expand Down
6 changes: 6 additions & 0 deletions plugins/deps/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,10 @@ dependencies {
implementation(project(":target"))
implementation(project(":config"))
implementation(gradleKotlinDsl())

testImplementation(kotlin("test"))
}

tasks.test {
useJUnitPlatform()
}
91 changes: 72 additions & 19 deletions plugins/deps/src/main/java/tools/forma/deps/catalog/Generators.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ package tools.forma.deps.catalog
import java.util.Locale
import org.gradle.configurationcache.extensions.capitalized

/**
* Tokens dropped when turning Maven coordinates / plugin ids into Gradle version-catalog
* accessor names (e.g. `com.jakewharton.timber:timber:5.0.1` → `jakewhartonTimber`).
*
* Keep this list conservative: filtering too aggressively yields empty or colliding names.
*/
val filteredTokens =
listOf(
"com",
Expand All @@ -18,23 +24,70 @@ val filteredTokens =
"plugin"
)

fun pluginNameGenerator(groupArtifactVersion: String) =
groupArtifactVersion
.split(":")
.fold(emptyList<String>()) { acc, s -> acc + s.split(".", "-") }
.filter { it !in filteredTokens }
.distinct()
.joinToString("") { it.capitalized() }
.let { name -> name.replaceFirstChar { it.lowercase(Locale.getDefault()) } }
.also { println("Generated name $it for $groupArtifactVersion") }
/**
* Generates a camelCase catalog name for a plugin id (version optional).
*
* Examples:
* - `androidx.navigation:navigation-safe-args-gradle-plugin` → `navigationSafeArgs`
* - `com.google.devtools.ksp:symbol-processing-gradle-plugin` → `devtoolsKspSymbolProcessing`
*/
fun pluginNameGenerator(groupArtifactVersion: String): String =
generateName(
tokens =
groupArtifactVersion
.split(":")
.fold(emptyList()) { acc, s -> acc + s.split(".", "-") },
source = groupArtifactVersion,
kind = "plugin"
)

/**
* Generates a camelCase catalog name for a library coordinate (`group:artifact:version`).
* The version segment is ignored for naming.
*
* Examples:
* - `com.jakewharton.timber:timber:5.0.1` → `jakewhartonTimber`
* - `io.coil-kt:coil-base:2.1.0` → `coilKtBase`
*/
fun defaultNameGenerator(groupArtifactVersion: String): String =
generateName(
tokens =
groupArtifactVersion
.split(":")
.dropLast(1)
.fold(emptyList()) { acc, s -> acc + s.split(".", "-") },
source = groupArtifactVersion,
kind = "library"
)

internal fun generateName(
tokens: List<String>,
source: String,
kind: String
): String {
val name =
tokens
.filter { it.isNotBlank() && it !in filteredTokens }
.distinct()
.joinToString("") { it.capitalized() }
.let { raw -> raw.replaceFirstChar { it.lowercase(Locale.getDefault()) } }

require(name.isNotBlank()) {
"Could not generate version-catalog $kind name for '$source' " +
"(all path tokens were filtered). Pass an explicit name= instead."
}
return name
}

fun defaultNameGenerator(groupArtifactVersion: String) =
groupArtifactVersion
.split(":")
.dropLast(1)
.fold(emptyList<String>()) { acc, s -> acc + s.split(".", "-") }
.filter { it !in filteredTokens }
.distinct()
.joinToString("") { it.capitalized() }
.let { name -> name.replaceFirstChar { it.lowercase(Locale.getDefault()) } }
.also { println("Generated name $it for $groupArtifactVersion") }
/**
* Parse `group:artifact:version` into parts. Used by catalog builders so callers get a clear
* error instead of a cryptic `IndexOutOfBoundsException`.
*/
fun parseGroupArtifactVersion(notation: String): Triple<String, String, String> {
val parts = notation.split(":")
require(parts.size == 3 && parts.all { it.isNotBlank() }) {
"Invalid Maven coordinate '$notation'. Expected exactly group:artifact:version " +
"(three non-blank segments separated by ':')."
}
return Triple(parts[0], parts[1], parts[2])
}
Loading
Loading