mirror of
https://github.com/bitsycore/bitsykonfig-gradle-plugin.git
synced 2026-10-06 20:17:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fbbe1a92a | ||
|
|
3c8f57581f | ||
|
|
534f085367 | ||
|
|
40a4f86972 |
@@ -0,0 +1,91 @@
|
|||||||
|
# Publishes the konfig Gradle plugin to GitHub Packages and maven.bitsycore.com
|
||||||
|
# whenever a version tag (e.g. 0.6.0) is pushed. The tag is the single source
|
||||||
|
# of truth for the published version. A GitHub release is created afterwards.
|
||||||
|
name: Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '[0-9]+.[0-9]+.[0-9]+'
|
||||||
|
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to publish (e.g. 0.6.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: publish-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write # create the GitHub release
|
||||||
|
packages: write # push to GitHub Packages with GITHUB_TOKEN
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Set up Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- name: Resolve version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
else
|
||||||
|
VERSION="${{ inputs.version }}"
|
||||||
|
fi
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "::error::No version could be resolved." && exit 1
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Publishing version: $VERSION"
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: ./gradlew check -Pkonfig.version=${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
|
- name: Publish to GitHub Packages
|
||||||
|
env:
|
||||||
|
GPR_USER: ${{ github.actor }}
|
||||||
|
GPR_KEY: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: >
|
||||||
|
./gradlew publishAllPublicationsToGitHubPackagesRepository
|
||||||
|
-Pkonfig.version=${{ steps.version.outputs.version }}
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
- name: Publish to maven.bitsycore.com
|
||||||
|
env:
|
||||||
|
BITSYCORE_MAVEN_USER: ${{ secrets.BITSYCORE_MAVEN_USER }}
|
||||||
|
BITSYCORE_MAVEN_TOKEN: ${{ secrets.BITSYCORE_MAVEN_TOKEN }}
|
||||||
|
run: >
|
||||||
|
./gradlew publishAllPublicationsToBitsycoreRepository
|
||||||
|
-Pkonfig.version=${{ steps.version.outputs.version }}
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
# Create the GitHub release once both publishes succeeded (skipped if it
|
||||||
|
# already exists, e.g. created by hand beforehand).
|
||||||
|
- name: Create GitHub release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
if gh release view "$VERSION" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then
|
||||||
|
echo "Release $VERSION already exists — skipping."
|
||||||
|
else
|
||||||
|
gh release create "$VERSION" \
|
||||||
|
--repo "$GITHUB_REPOSITORY" \
|
||||||
|
--title "$VERSION" \
|
||||||
|
--generate-notes
|
||||||
|
fi
|
||||||
@@ -22,35 +22,68 @@ This file provides guidance to AI agents (Claude, Copilot, Codex, etc.) working
|
|||||||
|
|
||||||
# Force re-run (skip UP-TO-DATE / cache)
|
# Force re-run (skip UP-TO-DATE / cache)
|
||||||
./gradlew functionalTest --rerun-tasks
|
./gradlew functionalTest --rerun-tasks
|
||||||
|
|
||||||
|
# Publish to GitHub Packages only (requires gpr.user + gpr.key in ~/.gradle/gradle.properties)
|
||||||
|
./gradlew publishAllPublicationsToGitHubPackagesRepository
|
||||||
|
|
||||||
|
# Publish to maven.bitsycore.com only (requires bitsycore.maven.user + bitsycore.maven.token)
|
||||||
|
./gradlew publishAllPublicationsToBitsycoreRepository
|
||||||
|
|
||||||
|
# CI: pushing a version tag (e.g. 0.6.0) publishes to BOTH repos and creates the GitHub release
|
||||||
|
|
||||||
|
# Publish only the plugin marker artifact (fixes resolution without re-uploading the jar)
|
||||||
|
./gradlew publishKonfigPluginMarkerMavenPublicationToGitHubPackagesRepository
|
||||||
```
|
```
|
||||||
|
|
||||||
> Functional tests are the primary test suite. The unit test file (`KonfigTypeTest.kt`) only covers `BuildType` resolution regex.
|
> Functional tests are the primary test suite. Unit tests (`src/test/`) cover the DSL
|
||||||
|
> model classes and `BuildType` resolution in isolation; they do not start real Gradle builds.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### What this plugin does
|
### What this plugin does
|
||||||
|
|
||||||
Generates a Kotlin `object BuildKonfig { ... }` at build time, placed in `build/generated/konfig/`, automatically wired into the consuming project's source sets. Fields can be constant across builds, overridden per build type (debug/release), or scoped to a named **dimension** (e.g. environment, region) each with their own variants.
|
Generates a Kotlin `object BuildKonfig { ... }` at build time, placed in `build/generated/konfig/`,
|
||||||
|
automatically wired into the consuming project's source sets. Fields can be constant across builds,
|
||||||
|
overridden per build type (debug/release), or scoped to a named **dimension** (e.g. environment,
|
||||||
|
region) each with their own variants.
|
||||||
|
|
||||||
### Key design constraints
|
### Key design constraints
|
||||||
|
|
||||||
**Configuration cache compatibility** is a hard requirement throughout. This means:
|
**Configuration cache compatibility** is a hard requirement throughout. This means:
|
||||||
- Never capture `project` inside a `Provider.map {}` or `Provider.zip {}` lambda, and **never access `project` inside a `@TaskAction`** — both break caching. Use only declared `@Input`/`@OutputDirectory` properties inside task actions.
|
- Never capture `project` inside a `Provider.map {}` or `Provider.zip {}` lambda, and **never
|
||||||
- Use `Class<T>` (`.javaObjectType`) instead of `KClass<T>` — Kotlin's `KClass` uses `SoftReference` internally which Gradle can't serialize.
|
access `project` inside a `@TaskAction`** — both break caching. Use only declared
|
||||||
- Use `gradlePropertiesPrefixedBy()` to read groups of properties — **note:** in Gradle 9.x this returns full property names as keys (prefix is NOT stripped), so always check both `dimProps["env"]` and `dimProps["konfig.dimension.env"]`.
|
`@Input`/`@OutputDirectory` properties inside task actions.
|
||||||
- All DSL field values are wrapped in `Provider<T>` from the start — literals via `constantProvider(value)` (a hand-written `ConstantProvider<T>`), external values via `providers.gradleProperty()` / `providers.environmentVariable()` etc. **Never store `ProviderFactory` anywhere in the DSL object graph** — it is not config-cache serializable.
|
- Use `Class<T>` (`.javaObjectType`) instead of `KClass<T>` — Kotlin's `KClass` uses
|
||||||
- `forceRegen` (`konfig.force` property) is evaluated eagerly at configuration time as a plain `Boolean` via `providers.gradleProperty("konfig.force").isPresent` — not inside a provider lambda — so the value is captured by value and is config-cache safe.
|
`SoftReference` internally which Gradle can't serialize.
|
||||||
|
- Use `gradlePropertiesPrefixedBy()` to read groups of properties — **note:** in Gradle 9.x this
|
||||||
|
returns full property names as keys (prefix is NOT stripped), so always check both
|
||||||
|
`dimProps["env"]` and `dimProps["konfig.dimension.env"]`.
|
||||||
|
- All DSL field values are wrapped in `Provider<T>` from the start — literals via
|
||||||
|
`constantProvider(value)` (a hand-written `ConstantProvider<T>`), external values via
|
||||||
|
`providers.gradleProperty()` / `providers.environmentVariable()` etc. **Never store
|
||||||
|
`ProviderFactory` anywhere in the DSL object graph** — it is not config-cache serializable.
|
||||||
|
- `forceRegen` (`konfig.force` property) is evaluated eagerly at configuration time as a plain
|
||||||
|
`Boolean` via `providers.gradleProperty("konfig.force").isPresent` — not inside a provider
|
||||||
|
lambda — so the value is captured by value and is config-cache safe.
|
||||||
|
|
||||||
**Dimension data in task inputs** uses flat-map encoding (`"<dimName>|<fieldName>"` as map keys) in `MapProperty<String, String>` rather than a managed-type `ListProperty`. Values are type-encoded as `"TYPE:rawValue"` (e.g. `"String:hello"`, `"Int:42"`). This collapses 6 separate per-type maps down to one per scope and avoids Gradle's `@Nested` managed-type restrictions.
|
**Dimension data in task inputs** uses flat-map encoding (`"<dimName>|<fieldName>"` as map keys)
|
||||||
|
in `MapProperty<String, String>` rather than a managed-type `ListProperty`. Values are
|
||||||
|
type-encoded as `"TYPE:rawValue"` (e.g. `"String:hello"`, `"Int:42"`). This collapses 6 separate
|
||||||
|
per-type maps down to one per scope and avoids Gradle's `@Nested` managed-type restrictions.
|
||||||
|
|
||||||
### DSL design
|
### DSL design
|
||||||
|
|
||||||
- **`@KonfigDsl` / `@DslMarker`** is applied to all DSL scope classes to prevent accidental scope leakage.
|
- **`@KonfigDsl` / `@DslMarker`** is applied to all DSL scope classes to prevent accidental scope leakage.
|
||||||
- **`ConstantProvider<T>`** wraps literal values — no `ProviderFactory` anywhere in the DSL object graph.
|
- **`ConstantProvider<T>`** wraps literal values — no `ProviderFactory` anywhere in the DSL object graph.
|
||||||
- **`field()` at the top level** returns `FieldHandle<T>` which exposes `.debug(value)` and `.release(value)`, both returning `Unit` — chaining beyond the first call is intentionally impossible.
|
- **`field()` at the top level** returns `FieldHandle<T>` which exposes `.debug(value)` and
|
||||||
- **`debug {}` / `release {}` scope blocks** use `BuildTypedFieldDeclScope` as receiver — `field()` inside these returns `Unit`, since the build type is already fixed by the enclosing scope.
|
`.release(value)`, both returning `Unit` — chaining beyond the first call is intentionally impossible.
|
||||||
- **`common {}` block in `DimensionConfig`** — shared fallback fields for all variants; merged in plugin with variant fields taking precedence.
|
- **`debug {}` / `release {}` scope blocks** use `BuildTypedFieldDeclScope` as receiver —
|
||||||
- **Plain `var` properties on `KonfigExtension`** — `objectPackage`, `objectName`, `objectVisibility` are user-facing `var` properties backed by internal `Property<T>` (`objectPackageProp`, `objectNameProp`, `objectVisibilityProp`) used for lazy task wiring.
|
`field()` inside these returns `Unit`, since the build type is already fixed by the enclosing scope.
|
||||||
|
- **`common {}` block in `DimensionConfig`** — shared fallback fields for all variants; merged in
|
||||||
|
plugin with variant fields taking precedence.
|
||||||
|
- **Plain `var` properties on `KonfigExtension`** — `objectPackage`, `objectName`,
|
||||||
|
`objectVisibility` are user-facing `var` properties backed by internal `Property<T>`
|
||||||
|
(`objectPackageProp`, `objectNameProp`, `objectVisibilityProp`) used for lazy task wiring.
|
||||||
|
|
||||||
### Resolution priority for dimensions
|
### Resolution priority for dimensions
|
||||||
|
|
||||||
@@ -60,43 +93,67 @@ Generates a Kotlin `object BuildKonfig { ... }` at build time, placed in `build/
|
|||||||
4. `defaultTo` fallback declared in DSL
|
4. `defaultTo` fallback declared in DSL
|
||||||
5. **Omitted silently** if none of the above — no crash, dimension object not generated
|
5. **Omitted silently** if none of the above — no crash, dimension object not generated
|
||||||
|
|
||||||
`resolveWithSource()` in `KonfigPlugin` returns a tab-separated `"<TAG>\t<variant>\t<reason>"` string for every dimension. This is stored as a task input (`dimensionResolutionLog`) so the task action can emit structured lifecycle/warning/error log messages without re-running resolution logic.
|
`resolveWithSource()` in `KonfigPlugin` returns a tab-separated `"<TAG>\t<variant>\t<reason>"`
|
||||||
|
string for every dimension. This is stored as a task input (`dimensionResolutionLog`) so the task
|
||||||
|
action can emit structured lifecycle/warning/error log messages without re-running resolution logic.
|
||||||
|
|
||||||
### Gradle properties understood by the plugin
|
### Gradle properties understood by the plugin
|
||||||
|
|
||||||
| Property | Effect |
|
| Property | Effect |
|
||||||
|---------------------------------------|----------------------------------------------------------------------------------|
|
|---------------------------------------------|---------------------------------------------------------------------------------|
|
||||||
| `-Pkonfig.buildtype=DEBUG\|RELEASE` | Forces build type; falls back to task-name detection then RELEASE |
|
| `-Pkonfig.buildtype=DEBUG\|RELEASE` | Forces build type; falls back to task-name detection then RELEASE |
|
||||||
| `-Pkonfig.dimension.<name>=<variant>` | Selects a dimension variant explicitly |
|
| `-Pkonfig.dimension.<name>=<variant>` | Selects a dimension variant explicitly |
|
||||||
| `-Pkonfig.force` | Disables UP-TO-DATE checks — task always re-runs (any value or bare flag works) |
|
| `-Pkonfig.force` | Disables UP-TO-DATE checks — task always re-runs (any value or bare flag works) |
|
||||||
| `-Pkonfig.android.buildtypedetection=false` | Disables task-name build-type detection |
|
| `-Pkonfig.android.buildtypedetection=false` | Disables task-name build-type detection |
|
||||||
| `-Pkonfig.android.flavordetection=false` | Disables task-name dimension variant detection |
|
| `-Pkonfig.android.flavordetection=false` | Disables task-name dimension variant detection |
|
||||||
|
|
||||||
### File map
|
### File map
|
||||||
|
|
||||||
| File | Role |
|
| File | Role |
|
||||||
|-------------------------|-----------------------------------------------------------------------------------------------------------------|
|
|-------------------------------|----------------------------------------------------------------------------------------------------------------|
|
||||||
| `KonfigPlugin.kt` | Entry point — wires providers, registers task, auto-wires source sets, hooks compile tasks |
|
| `KonfigPlugin.kt` | Entry point — wires providers, registers task, auto-wires source sets, hooks compile tasks |
|
||||||
| `KonfigExtension.kt` | DSL (`konfig { }`) — top-level `field()`, `debug {}`, `release {}`, and `dimension()` functions |
|
| `KonfigExtension.kt` | DSL (`konfig { }`) — top-level `field()`, `debug {}`, `release {}`, and `dimension()` functions |
|
||||||
| `DimensionConfig.kt` | DSL node for a dimension — holds variants, `common {}` block, `objectNameOverride`, `defaultVariant` |
|
| `DimensionConfig.kt` | DSL node for a dimension — holds variants, `common {}` block, `objectNameOverride`, `defaultVariant` |
|
||||||
| `VariantConfig.kt` | DSL node for a variant or common block — `field()` returns `FieldHandle<T>`; `debug {}`/`release {}` supported |
|
| `VariantConfig.kt` | DSL node for a variant or common block — `field()` returns `FieldHandle<T>`; `debug {}`/`release {}` supported |
|
||||||
| `FieldHandle.kt` | Fluent handle returned by top-level `field()` — `.debug(value)` / `.release(value)` return `Unit` |
|
| `FieldConfig.kt` | Single typed field — holds default `Provider<T>?` and per-`BuildType` overrides; `resolve()` returns `Provider<T>?` |
|
||||||
| `BuildTypedFieldDeclScope.kt` | Receiver for `debug {}`/`release {}` blocks — `field()` returns `Unit`, no chaining possible |
|
| `GenerateKonfigTask.kt` | `@CacheableTask` — validates inputs, logs detection results, writes the `.kt` file |
|
||||||
| `FieldConfig.kt` | Single typed field — holds default `Provider<T>?` and per-`BuildType` overrides; `resolve()` returns `Provider<T>?` |
|
| `BuildType.kt` | `enum` with regex-based task-name detection |
|
||||||
| `GenerateKonfigTask.kt` | `@CacheableTask` — validates inputs, logs detection results, writes the `.kt` file |
|
| `Visibility.kt` | `PUBLIC` / `INTERNAL` enum |
|
||||||
| `BuildType.kt` | `enum` with regex-based task-name detection |
|
| `KonfigDsl.kt` | `@DslMarker` annotation applied to all DSL scope classes |
|
||||||
| `Visibility.kt` | `PUBLIC` / `INTERNAL` enum |
|
|
||||||
| `KonfigDsl.kt` | `@DslMarker` annotation applied to all DSL scope classes |
|
|
||||||
|
|
||||||
> Note: `FieldHandle` and `BuildTypedFieldDeclScope` are defined inside `VariantConfig.kt`, not separate files.
|
> `FieldHandle<T>` and `BuildTypedFieldDeclScope` are defined inside `VariantConfig.kt`, not in separate files.
|
||||||
|
|
||||||
### Plugin metadata
|
### Plugin metadata
|
||||||
|
|
||||||
- **Plugin ID:** `com.bitsycore.konfig`
|
- **Plugin ID:** `com.bitsycore.konfig`
|
||||||
- **Group:** `com.bitsycore`
|
- **Group:** `com.bitsycore`
|
||||||
- **Version:** set via `konfig.version` in `gradle.properties` (currently `0.2.0`)
|
- **Artifact:** `konfig-gradle-plugin`
|
||||||
- **JVM target:** 17 (set via `sourceCompatibility` + `KotlinCompile.compilerOptions.jvmTarget`, no toolchain — avoids requiring a specific JDK installation)
|
- **Version:** set via `konfig.version` in `gradle.properties` (currently `0.6.0`)
|
||||||
- **AGP dependency:** `compileOnly("com.android.tools.build:gradle:8.0.0")` — never leaked to consumers
|
- **Repositories:** `https://maven.bitsycore.com/releases` (primary, no auth) and
|
||||||
|
`https://maven.pkg.github.com/bitsycore/bitsykonfig-gradle-plugin` (fallback, needs PAT)
|
||||||
|
- **JVM target:** 17 (set via `sourceCompatibility` + `KotlinCompile.compilerOptions.jvmTarget`,
|
||||||
|
no toolchain — avoids requiring a specific JDK installation)
|
||||||
|
- **AGP dependency:** `compileOnly("com.android.tools.build:gradle:8.7.3")` — never leaked to consumers
|
||||||
|
|
||||||
|
### Publishing
|
||||||
|
|
||||||
|
Two Maven publications are created automatically by the `kotlin-dsl` + `gradlePlugin {}` combo:
|
||||||
|
|
||||||
|
| Publication name | Artifact ID | Purpose |
|
||||||
|
|-------------------------------------|------------------------------------------|--------------------------------------|
|
||||||
|
| `pluginMaven` | `konfig-gradle-plugin` | Implementation jar + sources + POM |
|
||||||
|
| `konfigPluginMarkerMaven` | `com.bitsycore.konfig.gradle.plugin` | Marker POM that points to the impl |
|
||||||
|
|
||||||
|
**Never** use `publications { create<MavenPublication>("pluginMaven") { ... } }` — this replaces
|
||||||
|
the auto-wired publication and breaks the marker. Always configure existing publications via
|
||||||
|
`publications.withType<MavenPublication>().configureEach { ... }`.
|
||||||
|
|
||||||
|
Credentials are read from Gradle properties `gpr.user` / `gpr.key` (GitHub Packages) and
|
||||||
|
`bitsycore.maven.user` / `bitsycore.maven.token` (maven.bitsycore.com), falling back to
|
||||||
|
environment variables (`GPR_USER` / `GPR_KEY` / `BITSYCORE_MAVEN_USER` / `BITSYCORE_MAVEN_TOKEN`).
|
||||||
|
Store them in `~/.gradle/gradle.properties`, never in the project `gradle.properties`.
|
||||||
|
CI uses the repo secrets `BITSYCORE_MAVEN_USER` / `BITSYCORE_MAVEN_TOKEN` and the built-in
|
||||||
|
`GITHUB_TOKEN`.
|
||||||
|
|
||||||
### Logging levels used in GenerateKonfigTask
|
### Logging levels used in GenerateKonfigTask
|
||||||
|
|
||||||
@@ -109,3 +166,38 @@ Generates a Kotlin `object BuildKonfig { ... }` at build time, placed in `build/
|
|||||||
| Unknown/ambiguous variant | `warn` | Bad `-P` value or multiple task matches |
|
| Unknown/ambiguous variant | `warn` | Bad `-P` value or multiple task matches |
|
||||||
| Field/dim details | `info` | With `--info` |
|
| Field/dim details | `info` | With `--info` |
|
||||||
| Config errors (bad `defaultTo`, invalid identifier) | `GradleException` | Fail fast |
|
| Config errors (bad `defaultTo`, invalid identifier) | `GradleException` | Fail fast |
|
||||||
|
|
||||||
|
## Test structure
|
||||||
|
|
||||||
|
### Unit tests (`src/test/`)
|
||||||
|
|
||||||
|
Fast, no Gradle processes. Cover DSL model classes and type resolution in isolation.
|
||||||
|
|
||||||
|
| File | What it covers |
|
||||||
|
|-------------------------------|------------------------------------------------------------------------|
|
||||||
|
| `BuildTypeExtendedTest.kt` | `BuildType.resolve()` — all regex edge cases, task names, enum values |
|
||||||
|
| `VisibilityTest.kt` | `Visibility` enum entries, ordinals, valueOf |
|
||||||
|
| `ConstantProviderTest.kt` | `constantProvider()` / `ConstantProvider` — all Provider API methods |
|
||||||
|
| `FieldConfigTest.kt` | `FieldConfig` construction, default resolution, build-type overrides |
|
||||||
|
| `VariantConfigTest.kt` | `VariantConfig` field declarations, duplicate guard, scope blocks |
|
||||||
|
| `DimensionConfigTest.kt` | `DimensionConfig` objectName derivation, variants, common block |
|
||||||
|
| `FieldHandleTest.kt` | `FieldHandle` debug/release delegation (literal and provider) |
|
||||||
|
| `BuildTypedFieldDeclScopeTest.kt` | Scope block field declarations, getOrCreateField reuse |
|
||||||
|
|
||||||
|
### Functional tests (`src/functionalTest/`)
|
||||||
|
|
||||||
|
Full Gradle TestKit builds — each test spins up a real Gradle project in a temp directory.
|
||||||
|
`FunctionalTestBase` provides shared helpers (`withProject`, `withFailingProject`, `generatedFile()`, `writeBuildGradle()`).
|
||||||
|
|
||||||
|
| File | What it covers |
|
||||||
|
|---------------------------------------|----------------------------------------------------------------------------------|
|
||||||
|
| `BasicGenerationFunctionalTest.kt` | Task success, package derivation, BUILD_TYPE, IS_DEBUG, MODULE_NAME, file header |
|
||||||
|
| `GlobalFieldsFunctionalTest.kt` | All field types, string escaping, debug/release overrides, scope blocks |
|
||||||
|
| `VisibilityAndNamingFunctionalTest.kt`| PUBLIC/INTERNAL visibility, custom objectName/objectPackage |
|
||||||
|
| `DimensionsFunctionalTest.kt` | Variant selection, objectNameOverride, defaultTo, silent omission, multi-dim |
|
||||||
|
| `CommonBlockFunctionalTest.kt` | Common fallback fields, variant override precedence, build-type scopes in common |
|
||||||
|
| `CachingFunctionalTest.kt` | UP-TO-DATE behaviour, re-execution on input changes, `-Pkonfig.force` |
|
||||||
|
| `LoggingAndValidationFunctionalTest.kt` | Lifecycle logs, validation errors, generation summary |
|
||||||
|
| `KonfigPropertiesFunctionalTest.kt` | `konfig.properties` file reading, priority over file, cache invalidation |
|
||||||
|
| `DuplicateDetectionFunctionalTest.kt` | Duplicate dimension/field name errors, same-name across variants allowed |
|
||||||
|
| `FieldTypesFunctionalTest.kt` | Long/Float/Double literal syntax, NaN/Infinity, Boolean inline vs const |
|
||||||
|
|||||||
@@ -5,35 +5,83 @@ A Gradle plugin that generates a `BuildKonfig` Kotlin object at build time — l
|
|||||||
Fields can be constant, overridden per build type (debug/release), or scoped to named **dimensions** (e.g. environment, region) with their own variants.
|
Fields can be constant, overridden per build type (debug/release), or scoped to named **dimensions** (e.g. environment, region) with their own variants.
|
||||||
|
|
||||||
**Plugin ID:** `com.bitsycore.konfig`
|
**Plugin ID:** `com.bitsycore.konfig`
|
||||||
**Version:** `0.2.0`
|
**Group:** `com.bitsycore`
|
||||||
|
**Artifact:** `konfig-gradle-plugin`
|
||||||
|
**Version:** `0.6.0`
|
||||||
**JVM target:** 17
|
**JVM target:** 17
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### 1. Publish to local Maven (until published to a registry)
|
### 1. Configure plugin resolution
|
||||||
|
|
||||||
```bash
|
The plugin is published to **maven.bitsycore.com** (no authentication) and to
|
||||||
./gradlew publishToMavenLocal
|
**GitHub Packages** as a fallback (requires a GitHub PAT with `read:packages`).
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add to your project
|
|
||||||
|
|
||||||
`settings.gradle.kts`:
|
`settings.gradle.kts`:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
pluginManagement {
|
pluginManagement {
|
||||||
repositories {
|
repositories {
|
||||||
mavenLocal()
|
maven("https://maven.bitsycore.com/releases")
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>GitHub Packages fallback (authenticated)</summary>
|
||||||
|
|
||||||
|
Store credentials in `~/.gradle/gradle.properties` — never commit them:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
gpr.user=YOUR_GITHUB_USERNAME
|
||||||
|
gpr.key=YOUR_GITHUB_PERSONAL_ACCESS_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
name = "GitHubPackages"
|
||||||
|
url = uri("https://maven.pkg.github.com/bitsycore/bitsykonfig-gradle-plugin")
|
||||||
|
credentials {
|
||||||
|
username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GPR_USER")
|
||||||
|
password = providers.gradleProperty("gpr.key").orNull ?: System.getenv("GPR_KEY")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 2. Declare the plugin
|
||||||
|
|
||||||
|
Using a version catalog (`libs.versions.toml`):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[versions]
|
||||||
|
konfig = "0.6.0"
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
konfig = { id = "com.bitsycore.konfig", version.ref = "konfig" }
|
||||||
|
```
|
||||||
|
|
||||||
`build.gradle.kts`:
|
`build.gradle.kts`:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
plugins {
|
plugins {
|
||||||
id("com.bitsycore.konfig") version "0.2.0"
|
alias(libs.plugins.konfig)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or inline:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
plugins {
|
||||||
|
id("com.bitsycore.konfig") version "0.6.0"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -75,9 +123,9 @@ In debug builds (`-Pkonfig.buildtype=DEBUG`), `ENABLE_LOGGING` becomes `inline v
|
|||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
konfig {
|
konfig {
|
||||||
objectPackage = "com.example.app" // default: derived from group + name or projectName + moduleName
|
objectPackage = "com.example.app" // default: derived from group + project name
|
||||||
objectName = "BuildKonfig" // default: "BuildKonfig"
|
objectName = "BuildKonfig" // default: "BuildKonfig"
|
||||||
objectVisibility = Visibility.INTERNAL // default: Visibility.PUBLIC
|
objectVisibility = Visibility.INTERNAL // default: Visibility.PUBLIC
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -94,14 +142,14 @@ konfig {
|
|||||||
|
|
||||||
### Build-type overrides
|
### Build-type overrides
|
||||||
|
|
||||||
Three equivalent forms for overriding per build type:
|
Three equivalent forms:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
konfig {
|
konfig {
|
||||||
// Fluent handle (default + one or both overrides)
|
// Fluent handle — default + one or both overrides
|
||||||
field("BASE_URL", "https://prod.example.com").debug("https://dev.example.com")
|
field("BASE_URL", "https://prod.example.com").debug("https://dev.example.com")
|
||||||
|
|
||||||
// Scope blocks (build type is fixed — field() returns Unit, no chaining)
|
// Scope blocks — build type is fixed, field() returns Unit, no chaining
|
||||||
debug { field("MOCK_API", true) }
|
debug { field("MOCK_API", true) }
|
||||||
release { field("MOCK_API", false) }
|
release { field("MOCK_API", false) }
|
||||||
}
|
}
|
||||||
@@ -113,8 +161,7 @@ konfig {
|
|||||||
|
|
||||||
## Dimensions
|
## Dimensions
|
||||||
|
|
||||||
Dimensions let you select a named variant at build time (e.g. `env=prod`, `env=dev`).
|
Dimensions let you select a named variant at build time (e.g. `env=prod`, `env=dev`). Each active dimension generates a nested object inside `BuildKonfig`.
|
||||||
Each active dimension generates a nested object inside `BuildKonfig`.
|
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
konfig {
|
konfig {
|
||||||
@@ -144,7 +191,7 @@ public object BuildKonfig {
|
|||||||
|
|
||||||
public object Env /*env*/ {
|
public object Env /*env*/ {
|
||||||
const val VARIANT: String = "dev"
|
const val VARIANT: String = "dev"
|
||||||
inline val TIMEOUT: Boolean get() = 5 // from common {}, debug override
|
inline val TIMEOUT: Int get() = 5 // common {}, debug override
|
||||||
const val BASE_URL: String = "https://dev.example.com"
|
const val BASE_URL: String = "https://dev.example.com"
|
||||||
const val ANALYTICS: Boolean = false
|
const val ANALYTICS: Boolean = false
|
||||||
}
|
}
|
||||||
@@ -159,11 +206,43 @@ Fields declared in `common {}` act as fallbacks for all variants. A variant fiel
|
|||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
dimension("env", objectNameOverride = "Environment", defaultTo = "prod") { ... }
|
dimension("env", objectNameOverride = "Environment", defaultTo = "prod") { ... }
|
||||||
// generates: object Environment { ... }
|
// generates: object Environment /*env*/ { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
If no override is given, the object name is derived from the dimension name via CamelCase conversion (`my-env` → `MyEnv`).
|
If no override is given, the object name is derived from the dimension name via CamelCase conversion (`my-env` → `MyEnv`).
|
||||||
|
|
||||||
|
### Flat dimensions
|
||||||
|
|
||||||
|
`flatDimension` works exactly like `dimension`, but its fields are generated
|
||||||
|
directly at the root of the konfig object instead of a nested object. The
|
||||||
|
active variant is exposed as `<NAME>_VARIANT`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
konfig {
|
||||||
|
flatDimension("env", defaultTo = "prod") {
|
||||||
|
variant("prod") { field("BASE_URL", "https://prod.example.com") }
|
||||||
|
variant("dev") { field("BASE_URL", "https://dev.example.com") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated output (with `env=prod`):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
public object BuildKonfig {
|
||||||
|
const val BUILD_TYPE: String = "release"
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// dimension: env (flat), variant: prod
|
||||||
|
const val ENV_VARIANT: String = "prod"
|
||||||
|
const val BASE_URL: String = "https://prod.example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Root-level name collisions **fail the build** — a flat field may not shadow a
|
||||||
|
built-in constant (`BUILD_TYPE`, `MODULE_NAME`, `IS_DEBUG`), a global field, or
|
||||||
|
a field from another flat dimension.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Variant selection
|
## Variant selection
|
||||||
@@ -188,6 +267,30 @@ konfig.dimension.env=dev
|
|||||||
|
|
||||||
This file is tracked as a task input — changing it invalidates the build cache.
|
This file is tracked as a task input — changing it invalidates the build cache.
|
||||||
|
|
||||||
|
### Task-name matching rules
|
||||||
|
|
||||||
|
Variant detection respects **camelCase word boundaries** — a variant only
|
||||||
|
matches a whole segment of the task name, never a plain substring:
|
||||||
|
|
||||||
|
- `assemblePreprodRelease` matches variant `preprod`, **not** `prod`
|
||||||
|
- `assembleProdRelease` matches variant `prod`, **not** `preprod`
|
||||||
|
- `assembleDevelopRelease` does **not** match variant `dev`
|
||||||
|
|
||||||
|
When several variants match and every match is a substring of the longest one
|
||||||
|
(e.g. `prod` inside `preProd` for `assemblePreProdRelease`), the longest wins.
|
||||||
|
Genuinely ambiguous matches are skipped with a warning.
|
||||||
|
|
||||||
|
### Selection logging
|
||||||
|
|
||||||
|
The resolved build type and every dimension decision are printed by the
|
||||||
|
`konfigInfo` task on **every** build — including fully cached / UP-TO-DATE
|
||||||
|
builds with the configuration cache enabled:
|
||||||
|
|
||||||
|
```
|
||||||
|
konfig [app]: BUILD_TYPE = release (task-name detection matched release in [assembleProdRelease])
|
||||||
|
konfig [app]: dim 'env' -> 'prod' (task-name detection: 'prod' found in [assembleProdRelease])
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build-type detection
|
## Build-type detection
|
||||||
@@ -214,7 +317,7 @@ Build type is resolved in priority order:
|
|||||||
|
|
||||||
### `konfig.force`
|
### `konfig.force`
|
||||||
|
|
||||||
Forces the `generateKonfig` task to re-run on every build, bypassing Gradle's UP-TO-DATE and build-cache checks. Useful when generating a release build for a client or diagnosing cache issues.
|
Forces `generateKonfig` to re-run on every build, bypassing Gradle's UP-TO-DATE and build-cache checks.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew generateKonfig -Pkonfig.force
|
./gradlew generateKonfig -Pkonfig.force
|
||||||
@@ -230,14 +333,42 @@ The flag is presence-based — any value (or no value) enables it.
|
|||||||
```kotlin
|
```kotlin
|
||||||
import com.example.app.BuildKonfig
|
import com.example.app.BuildKonfig
|
||||||
|
|
||||||
println(BuildKonfig.BUILD_TYPE) // "debug" or "release"
|
println(BuildKonfig.BUILD_TYPE) // "debug" or "release"
|
||||||
println(BuildKonfig.IS_DEBUG) // true (debug) or false (release)
|
println(BuildKonfig.IS_DEBUG) // true (debug) or false (release)
|
||||||
println(BuildKonfig.Env.BASE_URL) // dimension field
|
println(BuildKonfig.Env.BASE_URL) // dimension field
|
||||||
println(BuildKonfig.Env.VARIANT) // "dev" or "prod"
|
println(BuildKonfig.Env.VARIANT) // "dev" or "prod"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Build-script queries (`konfig.isDebug`, `konfig.getCurrentDimension`)
|
||||||
|
|
||||||
|
The same recognition logic that drives generation is queryable from build
|
||||||
|
scripts — useful for wiring per-build-type dependencies in KMP projects:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
konfig {
|
||||||
|
dimension("env", defaultTo = "prod") { /* ... */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
if (konfig.isDebug) implementation(project(":debugImpl"))
|
||||||
|
else implementation(project(":releaseImpl"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val activeEnv: String? = konfig.getCurrentDimension("env") // "prod", or null if skipped
|
||||||
|
```
|
||||||
|
|
||||||
|
| API | Type | Description |
|
||||||
|
|--------------------------------------|---------------------|------------------------------------------------|
|
||||||
|
| `konfig.isDebug` | `Boolean` | True when the resolved build type is debug |
|
||||||
|
| `konfig.isDebugProvider` | `Provider<Boolean>` | Lazy variant for provider-based wiring |
|
||||||
|
| `konfig.currentBuildType` | `Provider<String>` | `"debug"` / `"release"` |
|
||||||
|
| `konfig.getCurrentDimension(name)` | `String?` | Active variant, or `null` when skipped/unknown |
|
||||||
|
| `konfig.currentDimension(name)` | `Provider<String>` | Lazy variant (absent when skipped/unknown) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Using Gradle providers as field values
|
## Using Gradle providers as field values
|
||||||
|
|
||||||
Lazy `Provider<T>` values are supported — useful for reading Gradle properties or environment variables:
|
Lazy `Provider<T>` values are supported — useful for reading Gradle properties or environment variables:
|
||||||
@@ -260,24 +391,51 @@ The generated directory (`build/generated/konfig/`) is automatically added as a
|
|||||||
- `org.jetbrains.kotlin.multiplatform` → `commonMain`
|
- `org.jetbrains.kotlin.multiplatform` → `commonMain`
|
||||||
- `org.jetbrains.kotlin.jvm` → `main`
|
- `org.jetbrains.kotlin.jvm` → `main`
|
||||||
- `org.jetbrains.kotlin.android` → `main`
|
- `org.jetbrains.kotlin.android` → `main`
|
||||||
- `com.android.application` / `com.android.library` → `main`
|
- `com.android.application` / `com.android.library` → all variants via the
|
||||||
|
`androidComponents` Sources API (AGP 8.1+ required)
|
||||||
|
|
||||||
|
The Android wiring uses the modern variant Sources API instead of the
|
||||||
|
`AndroidSourceSet` DSL, so it keeps working on **AGP 9.2+** where
|
||||||
|
`android.sourceset.disallowProvider` defaults to `true` (passing providers to
|
||||||
|
the source-set DSL is rejected). No legacy flag needed.
|
||||||
|
|
||||||
The `generateKonfig` task is automatically wired as a dependency of all `compileKotlin*` and `sourcesJar` tasks.
|
The `generateKonfig` task is automatically wired as a dependency of all `compileKotlin*` and `sourcesJar` tasks.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build
|
## Publishing (plugin development)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build and publish to local Maven
|
# Publish to GitHub Packages (requires gpr.user + gpr.key)
|
||||||
|
./gradlew publish
|
||||||
|
|
||||||
|
# Publish only the plugin marker (fixes resolution without re-uploading the jar)
|
||||||
|
./gradlew publishKonfigPluginMarkerMavenPublicationToGitHubPackagesRepository
|
||||||
|
|
||||||
|
# Publish to local Maven for local testing
|
||||||
|
./gradlew publishToMavenLocal
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and publish to local Maven (primary development loop)
|
||||||
./gradlew publishToMavenLocal
|
./gradlew publishToMavenLocal
|
||||||
|
|
||||||
# Run all tests
|
# Run unit tests only
|
||||||
./gradlew check
|
./gradlew test
|
||||||
|
|
||||||
# Run only functional tests
|
# Run functional tests (Gradle TestKit — starts real Gradle builds)
|
||||||
./gradlew functionalTest
|
./gradlew functionalTest
|
||||||
|
|
||||||
# Run a specific functional test
|
# Run a specific functional test
|
||||||
./gradlew functionalTest --tests "*dimension with defaultTo*"
|
./gradlew functionalTest --tests "*dimension with defaultTo*"
|
||||||
|
|
||||||
|
# Run all checks (test + functionalTest)
|
||||||
|
./gradlew check
|
||||||
|
|
||||||
|
# Force re-run (skip UP-TO-DATE / cache)
|
||||||
|
./gradlew functionalTest --rerun-tasks
|
||||||
```
|
```
|
||||||
|
|||||||
+22
-5
@@ -46,7 +46,9 @@ configurations[functionalTest.implementationConfigurationName]
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly("org.jetbrains.kotlin:kotlin-gradle-plugin:$embeddedKotlinVersion")
|
compileOnly("org.jetbrains.kotlin:kotlin-gradle-plugin:$embeddedKotlinVersion")
|
||||||
compileOnly("com.android.tools.build:gradle:8.0.0")
|
// 8.7+ needed for the variant Sources API (variant.sources.kotlin) used instead of
|
||||||
|
// the AndroidSourceSet DSL (whose provider support is removed by AGP 9.2).
|
||||||
|
compileOnly("com.android.tools.build:gradle:8.7.3")
|
||||||
testImplementation(kotlin("test"))
|
testImplementation(kotlin("test"))
|
||||||
add("functionalTestImplementation", gradleTestKit())
|
add("functionalTestImplementation", gradleTestKit())
|
||||||
}
|
}
|
||||||
@@ -69,11 +71,16 @@ fun prop(name: String): String? =
|
|||||||
?: System.getenv(name.replace('.', '_').uppercase())
|
?: System.getenv(name.replace('.', '_').uppercase())
|
||||||
|
|
||||||
publishing {
|
publishing {
|
||||||
publications {
|
// The `kotlin-dsl` + `gradlePlugin {}` combo automatically creates two publications:
|
||||||
create<MavenPublication>("pluginMaven") {
|
// - "pluginMaven" → the real implementation jar (groupId:artifactId:version)
|
||||||
groupId = project.group.toString()
|
// - "konfigPluginMarkerMaven" → the plugin marker (pluginId:pluginId.gradle.plugin:version)
|
||||||
|
//
|
||||||
|
// We must NOT create a third "pluginMaven" manually — that breaks the marker.
|
||||||
|
// Instead we configure the existing ones via withType.
|
||||||
|
publications.withType<MavenPublication>().configureEach {
|
||||||
|
// Only decorate the implementation publication, not the marker
|
||||||
|
if (artifactId != "com.bitsycore.konfig.gradle.plugin") {
|
||||||
artifactId = providers.gradleProperty("konfig.artifactId").get()
|
artifactId = providers.gradleProperty("konfig.artifactId").get()
|
||||||
version = project.version.toString()
|
|
||||||
|
|
||||||
pom {
|
pom {
|
||||||
name = providers.gradleProperty("konfig.pom.name").get()
|
name = providers.gradleProperty("konfig.pom.name").get()
|
||||||
@@ -115,5 +122,15 @@ publishing {
|
|||||||
password = prop("gpr.key")
|
password = prop("gpr.key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maven {
|
||||||
|
name = "Bitsycore"
|
||||||
|
url = uri("https://maven.bitsycore.com/releases")
|
||||||
|
credentials {
|
||||||
|
// props resolve from gradle.properties or env BITSYCORE_MAVEN_USER / BITSYCORE_MAVEN_TOKEN
|
||||||
|
username = prop("bitsycore.maven.user")
|
||||||
|
password = prop("bitsycore.maven.token")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ org.gradle.configuration-cache=true
|
|||||||
# MARK: Publishing
|
# MARK: Publishing
|
||||||
# =========================================================
|
# =========================================================
|
||||||
|
|
||||||
konfig.version=0.5.0
|
konfig.version=0.6.0
|
||||||
konfig.artifactId=konfig-gradle-plugin
|
konfig.artifactId=konfig-gradle-plugin
|
||||||
konfig.publish.url=https://maven.pkg.github.com/bitsycore/bitsykonfig-gradle-plugin
|
konfig.publish.url=https://maven.pkg.github.com/bitsycore/bitsykonfig-gradle-plugin
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.bitsycore.konfig
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functional tests for `flatDimension` — fields generated at the root of the
|
||||||
|
* konfig object, with hard failure on root-level name collisions.
|
||||||
|
*/
|
||||||
|
class FlatDimensionFunctionalTest : FunctionalTestBase() {
|
||||||
|
|
||||||
|
@Test fun `flat dimension fields are generated at root without nested object`() = withProject { dir, run ->
|
||||||
|
dir.writeBuildGradle("""
|
||||||
|
plugins { id("com.bitsycore.konfig") }
|
||||||
|
group = "com.example"
|
||||||
|
konfig {
|
||||||
|
flatDimension("env", defaultTo = "prod") {
|
||||||
|
variant("prod") { field("API_URL", "https://prod.example.com") }
|
||||||
|
variant("dev") { field("API_URL", "https://dev.example.com") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
|
||||||
|
val content = dir.generatedFile().readText()
|
||||||
|
|
||||||
|
assertTrue(content.contains("""const val ENV_VARIANT: String = "prod""""), "root variant const missing:\n$content")
|
||||||
|
assertTrue(content.contains("""const val API_URL: String = "https://prod.example.com""""), "root field missing:\n$content")
|
||||||
|
assertFalse(content.contains("object Env"), "flat dimension must not generate a nested object:\n$content")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `flat dimension colliding with global field fails the build`() = withFailingProject { dir, run ->
|
||||||
|
dir.writeBuildGradle("""
|
||||||
|
plugins { id("com.bitsycore.konfig") }
|
||||||
|
group = "com.example"
|
||||||
|
konfig {
|
||||||
|
field("API_URL", "global")
|
||||||
|
flatDimension("env", defaultTo = "prod") {
|
||||||
|
variant("prod") { field("API_URL", "https://prod.example.com") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
|
||||||
|
assertTrue(result.output.contains("collides"), "expected collision error, got:\n${result.output}")
|
||||||
|
assertTrue(result.output.contains("API_URL"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `flat dimension colliding with built-in constant fails the build`() = withFailingProject { dir, run ->
|
||||||
|
dir.writeBuildGradle("""
|
||||||
|
plugins { id("com.bitsycore.konfig") }
|
||||||
|
group = "com.example"
|
||||||
|
konfig {
|
||||||
|
flatDimension("env", defaultTo = "prod") {
|
||||||
|
variant("prod") { field("BUILD_TYPE", "oops") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
|
||||||
|
assertTrue(result.output.contains("collides"), "expected collision error, got:\n${result.output}")
|
||||||
|
assertTrue(result.output.contains("BUILD_TYPE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `two flat dimensions with colliding fields fail the build`() = withFailingProject { dir, run ->
|
||||||
|
dir.writeBuildGradle("""
|
||||||
|
plugins { id("com.bitsycore.konfig") }
|
||||||
|
group = "com.example"
|
||||||
|
konfig {
|
||||||
|
flatDimension("env", defaultTo = "prod") {
|
||||||
|
variant("prod") { field("URL", "a") }
|
||||||
|
}
|
||||||
|
flatDimension("region", defaultTo = "eu") {
|
||||||
|
variant("eu") { field("URL", "b") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
|
||||||
|
assertTrue(result.output.contains("collides"), "expected collision error, got:\n${result.output}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `flat and nested dimensions can coexist`() = withProject { dir, run ->
|
||||||
|
dir.writeBuildGradle("""
|
||||||
|
plugins { id("com.bitsycore.konfig") }
|
||||||
|
group = "com.example"
|
||||||
|
konfig {
|
||||||
|
flatDimension("env", defaultTo = "prod") {
|
||||||
|
variant("prod") { field("API_URL", "https://prod.example.com") }
|
||||||
|
}
|
||||||
|
dimension("region", defaultTo = "eu") {
|
||||||
|
variant("eu") { field("DC", "fra") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
|
||||||
|
val content = dir.generatedFile().readText()
|
||||||
|
|
||||||
|
assertTrue(content.contains("const val ENV_VARIANT"))
|
||||||
|
assertTrue(content.contains("object Region"))
|
||||||
|
assertTrue(content.contains("""const val DC: String = "fra""""))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.bitsycore.konfig
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functional tests for task-name variant detection edge cases:
|
||||||
|
* prod vs preprod differentiation, camelCase flavors, and the konfigInfo
|
||||||
|
* task always logging the selection (even on cached builds).
|
||||||
|
*/
|
||||||
|
class VariantDetectionFunctionalTest : FunctionalTestBase() {
|
||||||
|
|
||||||
|
private val buildScript = """
|
||||||
|
plugins { id("com.bitsycore.konfig") }
|
||||||
|
group = "com.example"
|
||||||
|
konfig {
|
||||||
|
dimension("env") {
|
||||||
|
variant("prod") { field("URL", "https://prod.example.com") }
|
||||||
|
variant("preprod") { field("URL", "https://preprod.example.com") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tasks.register("assemblePreprodRelease") { dependsOn("generateKonfig") }
|
||||||
|
tasks.register("assembleProdRelease") { dependsOn("generateKonfig") }
|
||||||
|
"""
|
||||||
|
|
||||||
|
@Test fun `preprod task selects preprod not prod`() = withProject { dir, run ->
|
||||||
|
dir.writeBuildGradle(buildScript)
|
||||||
|
val result = run(listOf("assemblePreprodRelease"))
|
||||||
|
assertTrue(result.output.contains("dim 'env' -> 'preprod'"), "expected preprod selection:\n${result.output}")
|
||||||
|
val content = dir.generatedFile().readText()
|
||||||
|
assertTrue(content.contains("https://preprod.example.com"), "wrong URL generated:\n$content")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `prod task selects prod not ambiguous`() = withProject { dir, run ->
|
||||||
|
dir.writeBuildGradle(buildScript)
|
||||||
|
val result = run(listOf("assembleProdRelease"))
|
||||||
|
assertTrue(result.output.contains("dim 'env' -> 'prod'"), "expected prod selection:\n${result.output}")
|
||||||
|
val content = dir.generatedFile().readText()
|
||||||
|
assertTrue(content.contains("https://prod.example.com"), "wrong URL generated:\n$content")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `camelCase flavor preProd wins longest match over prod`() = withProject { dir, run ->
|
||||||
|
dir.writeBuildGradle("""
|
||||||
|
plugins { id("com.bitsycore.konfig") }
|
||||||
|
group = "com.example"
|
||||||
|
konfig {
|
||||||
|
dimension("env") {
|
||||||
|
variant("prod") { field("URL", "https://prod.example.com") }
|
||||||
|
variant("preProd") { field("URL", "https://preprod.example.com") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tasks.register("assemblePreProdRelease") { dependsOn("generateKonfig") }
|
||||||
|
""")
|
||||||
|
val result = run(listOf("assemblePreProdRelease"))
|
||||||
|
assertTrue(result.output.contains("dim 'env' -> 'preProd'"), "expected preProd selection:\n${result.output}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `selection is logged even when generateKonfig is up-to-date`() = withProject { dir, run ->
|
||||||
|
dir.writeBuildGradle("""
|
||||||
|
plugins { id("com.bitsycore.konfig") }
|
||||||
|
group = "com.example"
|
||||||
|
konfig {
|
||||||
|
field("X", "y")
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
|
||||||
|
// Second run: generateKonfig is UP-TO-DATE, but konfigInfo must still log.
|
||||||
|
val second = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
|
||||||
|
assertTrue(second.output.contains("UP-TO-DATE"), "expected cached generate task:\n${second.output}")
|
||||||
|
assertTrue(second.output.contains("BUILD_TYPE = release"), "selection must be logged on cached builds:\n${second.output}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `isDebug and getCurrentDimension are usable in build scripts`() = withProject { dir, run ->
|
||||||
|
dir.writeBuildGradle("""
|
||||||
|
plugins { id("com.bitsycore.konfig") }
|
||||||
|
group = "com.example"
|
||||||
|
konfig {
|
||||||
|
dimension("env", defaultTo = "prod") {
|
||||||
|
variant("prod") { field("X", "y") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tasks.register("konfigQuery") {
|
||||||
|
dependsOn("generateKonfig")
|
||||||
|
val debug = konfig.isDebug
|
||||||
|
val env = konfig.getCurrentDimension("env")
|
||||||
|
val missing = konfig.getCurrentDimension("nope")
|
||||||
|
doLast {
|
||||||
|
println("QUERY isDebug=" + debug)
|
||||||
|
println("QUERY env=" + env)
|
||||||
|
println("QUERY missing=" + missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
val result = run(listOf("konfigQuery", "-Pkonfig.buildtype=DEBUG"))
|
||||||
|
assertTrue(result.output.contains("QUERY isDebug=true"), result.output)
|
||||||
|
assertTrue(result.output.contains("QUERY env=prod"), result.output)
|
||||||
|
assertTrue(result.output.contains("QUERY missing=null"), result.output)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import org.gradle.api.file.DirectoryProperty
|
|||||||
import org.gradle.api.provider.ListProperty
|
import org.gradle.api.provider.ListProperty
|
||||||
import org.gradle.api.provider.MapProperty
|
import org.gradle.api.provider.MapProperty
|
||||||
import org.gradle.api.provider.Property
|
import org.gradle.api.provider.Property
|
||||||
|
import org.gradle.api.provider.SetProperty
|
||||||
import org.gradle.api.tasks.CacheableTask
|
import org.gradle.api.tasks.CacheableTask
|
||||||
import org.gradle.api.tasks.Input
|
import org.gradle.api.tasks.Input
|
||||||
import org.gradle.api.tasks.OutputDirectory
|
import org.gradle.api.tasks.OutputDirectory
|
||||||
@@ -39,11 +40,10 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
@get:Input abstract val objectName: Property<String>
|
@get:Input abstract val objectName: Property<String>
|
||||||
@get:Input abstract val objectVisibility: Property<Visibility>
|
@get:Input abstract val objectVisibility: Property<Visibility>
|
||||||
|
|
||||||
/** Human-readable explanation of why the current build type was chosen. */
|
|
||||||
@get:Input abstract val buildTypeSource: Property<String>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolution log for every declared dimension, keyed by dimension name.
|
* Resolution log entries with the ERROR tag only (configuration errors), keyed by
|
||||||
|
* dimension name. Full logs (with task-name-dependent reasons) live on [KonfigInfoTask]
|
||||||
|
* so they don't bust this task's up-to-date check.
|
||||||
* Each value is tab-separated: `"<TAG>\t<variant>\t<reason>"`.
|
* Each value is tab-separated: `"<TAG>\t<variant>\t<reason>"`.
|
||||||
*/
|
*/
|
||||||
@get:Input abstract val dimensionResolutionLog: MapProperty<String, String>
|
@get:Input abstract val dimensionResolutionLog: MapProperty<String, String>
|
||||||
@@ -61,6 +61,8 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
|
|
||||||
/** Ordered list of active dimension names. */
|
/** Ordered list of active dimension names. */
|
||||||
@get:Input abstract val activeDimensionNames: ListProperty<String>
|
@get:Input abstract val activeDimensionNames: ListProperty<String>
|
||||||
|
/** Names of dimensions declared with `flatDimension` (fields emitted at the root). */
|
||||||
|
@get:Input abstract val flatDimensionNames: SetProperty<String>
|
||||||
/** `dimName -> Kotlin object name`. */
|
/** `dimName -> Kotlin object name`. */
|
||||||
@get:Input abstract val dimensionObjectNames: MapProperty<String, String>
|
@get:Input abstract val dimensionObjectNames: MapProperty<String, String>
|
||||||
/** `dimName -> selected variant`. */
|
/** `dimName -> selected variant`. */
|
||||||
@@ -86,7 +88,6 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
val isDebug = btVal == BuildType.DEBUG
|
val isDebug = btVal == BuildType.DEBUG
|
||||||
|
|
||||||
validate(mod, objName, pkg)
|
validate(mod, objName, pkg)
|
||||||
logResolution(mod, btVal, objName, pkg)
|
|
||||||
|
|
||||||
val outDir = outputDirectory.get().asFile
|
val outDir = outputDirectory.get().asFile
|
||||||
if (outDir.exists()) outDir.deleteRecursively()
|
if (outDir.exists()) outDir.deleteRecursively()
|
||||||
@@ -98,6 +99,9 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
val dimNames = activeDimensionNames.get()
|
val dimNames = activeDimensionNames.get()
|
||||||
val dimObjN = dimensionObjectNames.get()
|
val dimObjN = dimensionObjectNames.get()
|
||||||
val dimVars = dimensionActiveVariants.get()
|
val dimVars = dimensionActiveVariants.get()
|
||||||
|
val flatDims = flatDimensionNames.get()
|
||||||
|
|
||||||
|
checkRootCollisions(mod, dimNames.filter { it in flatDims }, gFields, dFields, dimVars)
|
||||||
|
|
||||||
val content = buildString {
|
val content = buildString {
|
||||||
appendLine("""@file:Suppress("RedundantVisibilityModifier")""")
|
appendLine("""@file:Suppress("RedundantVisibilityModifier")""")
|
||||||
@@ -123,22 +127,32 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (dimName in dimNames) {
|
for (dimName in dimNames) {
|
||||||
val dimObjName = dimObjN[dimName] ?: continue
|
|
||||||
val activeVariant = dimVars[dimName] ?: continue
|
val activeVariant = dimVars[dimName] ?: continue
|
||||||
val prefix = "$dimName|"
|
val prefix = "$dimName|"
|
||||||
val fields = dFields
|
val fields = dFields
|
||||||
.filterKeys { it.startsWith(prefix) }
|
.filterKeys { it.startsWith(prefix) }
|
||||||
.mapKeys { (k, _) -> k.removePrefix(prefix) }
|
.mapKeys { (k, _) -> k.removePrefix(prefix) }
|
||||||
|
|
||||||
appendLine()
|
if (dimName in flatDims) {
|
||||||
appendLine(" ${visPrefix}object $dimObjName /*$dimName*/ {")
|
// Flat dimension: fields live directly on the root object.
|
||||||
appendLine()
|
|
||||||
appendLine(" const val VARIANT: String = \"$activeVariant\"")
|
|
||||||
if (fields.isNotEmpty()) {
|
|
||||||
appendLine()
|
appendLine()
|
||||||
appendEncodedFields(" ", fields, btVal)
|
appendLine(" // dimension: $dimName (flat), variant: $activeVariant")
|
||||||
|
appendLine(" const val ${dimName.toVariantConstName()}: String = \"$activeVariant\"")
|
||||||
|
if (fields.isNotEmpty()) {
|
||||||
|
appendEncodedFields(" ", fields, btVal)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val dimObjName = dimObjN[dimName] ?: continue
|
||||||
|
appendLine()
|
||||||
|
appendLine(" ${visPrefix}object $dimObjName /*$dimName*/ {")
|
||||||
|
appendLine()
|
||||||
|
appendLine(" const val VARIANT: String = \"$activeVariant\"")
|
||||||
|
if (fields.isNotEmpty()) {
|
||||||
|
appendLine()
|
||||||
|
appendEncodedFields(" ", fields, btVal)
|
||||||
|
}
|
||||||
|
appendLine(" }")
|
||||||
}
|
}
|
||||||
appendLine(" }")
|
|
||||||
|
|
||||||
logger.info("konfig [$mod]: dim '$dimName' fields: ${fields.keys.sorted().joinToString()}")
|
logger.info("konfig [$mod]: dim '$dimName' fields: ${fields.keys.sorted().joinToString()}")
|
||||||
}
|
}
|
||||||
@@ -198,37 +212,56 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
// MARK: Loggings
|
// MARK: Flat dimension collision detection
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
|
|
||||||
private fun logResolution(mod: String, btVal: BuildType, objName: String, pkg: String) {
|
/**
|
||||||
logger.lifecycle("konfig [$mod]: BUILD_TYPE = ${btVal.name.lowercase()} (${buildTypeSource.get()})")
|
* Fails the build when a flat dimension would generate a root-level name that
|
||||||
|
* already exists (base constants, global fields, or another flat dimension).
|
||||||
|
*/
|
||||||
|
private fun checkRootCollisions(
|
||||||
|
mod: String,
|
||||||
|
activeFlatDims: List<String>,
|
||||||
|
gFields: Map<String, String>,
|
||||||
|
dFields: Map<String, String>,
|
||||||
|
dimVars: Map<String, String>,
|
||||||
|
) {
|
||||||
|
if (activeFlatDims.isEmpty()) return
|
||||||
|
|
||||||
val resolutionLog = dimensionResolutionLog.get()
|
val owners = mutableMapOf<String, String>()
|
||||||
if (resolutionLog.isEmpty()) {
|
listOf("BUILD_TYPE", "MODULE_NAME", "IS_DEBUG").forEach { owners[it] = "built-in constant" }
|
||||||
logger.info("konfig [$mod]: no dimensions declared")
|
gFields.keys.forEach { owners[it] = "global field" }
|
||||||
} else {
|
|
||||||
val maxDimLen = resolutionLog.keys.maxOf { it.length }
|
val errors = mutableListOf<String>()
|
||||||
resolutionLog.entries
|
for (dimName in activeFlatDims) {
|
||||||
.sortedBy { (n, enc) -> "${if (enc.startsWith("OK")) "0" else "1"}_$n" }
|
if (dimVars[dimName] == null) continue
|
||||||
.forEach { (dimName, encoded) ->
|
val prefix = "$dimName|"
|
||||||
val parts = encoded.split("\t", limit = 3)
|
val rootNames = buildList {
|
||||||
val tag = parts[0]
|
add(dimName.toVariantConstName())
|
||||||
val variant = parts.getOrElse(1) { "" }
|
addAll(dFields.keys.filter { it.startsWith(prefix) }.map { it.removePrefix(prefix) })
|
||||||
val reason = parts.getOrElse(2) { "" }
|
}
|
||||||
val padded = dimName.padEnd(maxDimLen)
|
for (name in rootNames) {
|
||||||
when (tag) {
|
val existing = owners[name]
|
||||||
"OK" -> logger.lifecycle("konfig [$mod]: dim '$padded' -> '$variant' ($reason)")
|
if (existing != null) {
|
||||||
"SKIP" -> logger.lifecycle("konfig [$mod]: dim '$padded' -> skipped ($reason)")
|
errors += "flat dimension '$dimName' generates '$name' which collides with $existing"
|
||||||
"WARN_UNKNOWN", "WARN_AMBIGUOUS" -> logger.warn("konfig [$mod]: dim '$dimName' -- $reason")
|
} else {
|
||||||
"ERROR" -> logger.error("konfig [$mod]: dim '$dimName' -- ERROR: $reason")
|
owners[name] = "flat dimension '$dimName'"
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("konfig [$mod]: object = $pkg.$objName")
|
if (errors.isNotEmpty()) {
|
||||||
|
throw GradleException(buildString {
|
||||||
|
appendLine("konfig [$mod]: flat dimension name collisions:")
|
||||||
|
errors.forEach { appendLine(" - $it") }
|
||||||
|
}.trimEnd())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `"my-env"` → `"MY_ENV_VARIANT"` — root constant holding the active variant of a flat dimension. */
|
||||||
|
private fun String.toVariantConstName(): String =
|
||||||
|
uppercase().replace(Regex("[^A-Z0-9]"), "_") + "_VARIANT"
|
||||||
|
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
// MARK: Helpers
|
// MARK: Helpers
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.bitsycore.konfig.configs.BuildTypedFieldDeclScope
|
|||||||
import com.bitsycore.konfig.configs.DimensionConfig
|
import com.bitsycore.konfig.configs.DimensionConfig
|
||||||
import com.bitsycore.konfig.configs.FieldConfig
|
import com.bitsycore.konfig.configs.FieldConfig
|
||||||
import com.bitsycore.konfig.configs.VariantConfig
|
import com.bitsycore.konfig.configs.VariantConfig
|
||||||
|
import com.bitsycore.konfig.types.BuildType
|
||||||
import com.bitsycore.konfig.types.KonfigDsl
|
import com.bitsycore.konfig.types.KonfigDsl
|
||||||
import com.bitsycore.konfig.types.Visibility
|
import com.bitsycore.konfig.types.Visibility
|
||||||
import org.gradle.api.file.DirectoryProperty
|
import org.gradle.api.file.DirectoryProperty
|
||||||
@@ -78,6 +79,27 @@ abstract class KonfigExtension @Inject constructor(
|
|||||||
dimensions.add(d)
|
dimensions.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares a dimension whose fields are generated directly at the root of the
|
||||||
|
* konfig object instead of inside a nested `object`.
|
||||||
|
*
|
||||||
|
* The selected variant is exposed as `<NAME>_VARIANT` (dimension name uppercased).
|
||||||
|
* Name collisions with global fields, base constants or other flat dimensions
|
||||||
|
* fail the build.
|
||||||
|
*/
|
||||||
|
fun flatDimension(
|
||||||
|
name: String,
|
||||||
|
defaultTo: String? = null,
|
||||||
|
config: DimensionConfig.() -> Unit
|
||||||
|
) {
|
||||||
|
require(dimensions.none { it.dimensionName == name }) {
|
||||||
|
"konfig: dimension '$name' is already declared"
|
||||||
|
}
|
||||||
|
val d = DimensionConfig(name, objectNameOverride = null, defaultVariant = defaultTo, flat = true)
|
||||||
|
config(d)
|
||||||
|
dimensions.add(d)
|
||||||
|
}
|
||||||
|
|
||||||
inline fun <reified T : Any> field(
|
inline fun <reified T : Any> field(
|
||||||
name: String,
|
name: String,
|
||||||
default: T,
|
default: T,
|
||||||
@@ -90,4 +112,46 @@ abstract class KonfigExtension @Inject constructor(
|
|||||||
|
|
||||||
fun debug(block: BuildTypedFieldDeclScope.() -> Unit) = globalScope.debug(block)
|
fun debug(block: BuildTypedFieldDeclScope.() -> Unit) = globalScope.debug(block)
|
||||||
fun release(block: BuildTypedFieldDeclScope.() -> Unit) = globalScope.release(block)
|
fun release(block: BuildTypedFieldDeclScope.() -> Unit) = globalScope.release(block)
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// MARK: Build-script queries
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
/** Wired by the plugin at apply time — same resolution chain as the generated object. */
|
||||||
|
internal lateinit var buildTypeProviderInternal: Provider<BuildType>
|
||||||
|
|
||||||
|
/** Wired by the plugin at apply time — resolves a dimension with the same recognition logic. */
|
||||||
|
internal lateinit var dimensionResolverInternal: (String) -> Provider<String>
|
||||||
|
|
||||||
|
/** Resolved build type as a lazy provider (`"debug"` / `"release"`). */
|
||||||
|
val currentBuildType: Provider<String>
|
||||||
|
get() = buildTypeProviderInternal.map { it.value }
|
||||||
|
|
||||||
|
/** Lazy provider variant of [isDebug], for provider-based wiring. */
|
||||||
|
val isDebugProvider: Provider<Boolean>
|
||||||
|
get() = buildTypeProviderInternal.map { it == BuildType.DEBUG }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the resolved build type is debug — uses the exact same recognition
|
||||||
|
* as the generated object, so it can drive build-script decisions such as
|
||||||
|
* KMP `debugImplementation`-style wiring:
|
||||||
|
*
|
||||||
|
* ```kotlin
|
||||||
|
* dependencies {
|
||||||
|
* if (konfig.isDebug) implementation(project(":debugImpl"))
|
||||||
|
* else implementation(project(":releaseImpl"))
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
val isDebug: Boolean
|
||||||
|
get() = isDebugProvider.get()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy provider for the active variant of dimension [name].
|
||||||
|
* The provider has no value when the dimension is skipped or unknown.
|
||||||
|
*/
|
||||||
|
fun currentDimension(name: String): Provider<String> = dimensionResolverInternal(name)
|
||||||
|
|
||||||
|
/** Active variant of dimension [name], or `null` when skipped/unknown. */
|
||||||
|
fun getCurrentDimension(name: String): String? = currentDimension(name).orNull
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.bitsycore.konfig
|
||||||
|
|
||||||
|
import com.bitsycore.konfig.types.BuildType
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.provider.MapProperty
|
||||||
|
import org.gradle.api.provider.Property
|
||||||
|
import org.gradle.api.tasks.Internal
|
||||||
|
import org.gradle.api.tasks.TaskAction
|
||||||
|
import org.gradle.work.DisableCachingByDefault
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the resolved build type and dimension variants on EVERY build.
|
||||||
|
*
|
||||||
|
* [GenerateKonfigTask] only logs when it actually executes, so a cached /
|
||||||
|
* up-to-date build would silently hide which variant is active. This task is
|
||||||
|
* untracked (never up-to-date, never cached) and `generateKonfig` depends on
|
||||||
|
* it, guaranteeing the selection is always visible in the logs — even on a
|
||||||
|
* fully cached build with the configuration cache enabled.
|
||||||
|
*/
|
||||||
|
@DisableCachingByDefault(because = "pure logging task, must run on every build")
|
||||||
|
abstract class KonfigInfoTask : DefaultTask() {
|
||||||
|
|
||||||
|
@get:Internal abstract val moduleName: Property<String>
|
||||||
|
@get:Internal abstract val buildType: Property<BuildType>
|
||||||
|
@get:Internal abstract val buildTypeSource: Property<String>
|
||||||
|
|
||||||
|
/** Resolution log per dimension: `"<TAG>\t<variant>\t<reason>"`. */
|
||||||
|
@get:Internal abstract val dimensionResolutionLog: MapProperty<String, String>
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Never skipped: no declared outputs + explicit upToDateWhen false.
|
||||||
|
outputs.upToDateWhen { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun report() {
|
||||||
|
val mod = moduleName.get()
|
||||||
|
logger.lifecycle("konfig [$mod]: BUILD_TYPE = ${buildType.get().name.lowercase()} (${buildTypeSource.get()})")
|
||||||
|
|
||||||
|
val resolutionLog = dimensionResolutionLog.get()
|
||||||
|
if (resolutionLog.isEmpty()) {
|
||||||
|
logger.info("konfig [$mod]: no dimensions declared")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val maxDimLen = resolutionLog.keys.maxOf { it.length }
|
||||||
|
resolutionLog.entries
|
||||||
|
.sortedBy { (n, enc) -> "${if (enc.startsWith("OK")) "0" else "1"}_$n" }
|
||||||
|
.forEach { (dimName, encoded) ->
|
||||||
|
val parts = encoded.split("\t", limit = 3)
|
||||||
|
val tag = parts[0]
|
||||||
|
val variant = parts.getOrElse(1) { "" }
|
||||||
|
val reason = parts.getOrElse(2) { "" }
|
||||||
|
val padded = dimName.padEnd(maxDimLen)
|
||||||
|
when (tag) {
|
||||||
|
"OK" -> logger.lifecycle("konfig [$mod]: dim '$padded' -> '$variant' ($reason)")
|
||||||
|
"SKIP" -> logger.lifecycle("konfig [$mod]: dim '$padded' -> skipped ($reason)")
|
||||||
|
"WARN_UNKNOWN", "WARN_AMBIGUOUS" -> logger.warn("konfig [$mod]: dim '$dimName' -- $reason")
|
||||||
|
"ERROR" -> logger.error("konfig [$mod]: dim '$dimName' -- ERROR: $reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
package com.bitsycore.konfig
|
package com.bitsycore.konfig
|
||||||
|
|
||||||
import com.android.build.api.dsl.CommonExtension
|
import com.android.build.api.variant.AndroidComponentsExtension
|
||||||
import com.bitsycore.konfig.configs.DimensionConfig
|
import com.bitsycore.konfig.configs.DimensionConfig
|
||||||
import com.bitsycore.konfig.configs.FieldConfig
|
import com.bitsycore.konfig.configs.FieldConfig
|
||||||
import com.bitsycore.konfig.types.BuildType
|
import com.bitsycore.konfig.types.BuildType
|
||||||
import com.bitsycore.konfig.types.Visibility
|
import com.bitsycore.konfig.types.Visibility
|
||||||
|
import com.bitsycore.konfig.types.containsWordCamelCase
|
||||||
import org.gradle.api.Plugin
|
import org.gradle.api.Plugin
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
import org.gradle.api.provider.Provider
|
import org.gradle.api.provider.Provider
|
||||||
@@ -132,20 +133,55 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// MARK: Build-script query wiring (konfig.isDebug / konfig.getCurrentDimension)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
extension.buildTypeProviderInternal = buildTypeProvider
|
||||||
|
extension.dimensionResolverInternal = { dimName ->
|
||||||
|
combinedProps.map { ctx ->
|
||||||
|
extension.dimensions.firstOrNull { it.dimensionName == dimName }?.let { dim ->
|
||||||
|
resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// MARK: Task Registration
|
// MARK: Task Registration
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
val forceRegen = project.providers.gradleProperty("konfig.force").isPresent
|
val forceRegen = project.providers.gradleProperty("konfig.force").isPresent
|
||||||
|
|
||||||
|
// Untracked logging task: runs on EVERY build (even fully cached ones) so the
|
||||||
|
// selected build type / dimension variants always appear in the logs.
|
||||||
|
val infoTask = project.tasks.register("konfigInfo", KonfigInfoTask::class.java) {
|
||||||
|
description = "Prints the resolved konfig build type and dimension variants."
|
||||||
|
group = "konfig"
|
||||||
|
moduleName.set(project.name)
|
||||||
|
buildType.set(buildTypeProvider)
|
||||||
|
buildTypeSource.set(buildTypeSourceProvider)
|
||||||
|
dimensionResolutionLog.set(dimensionResolutionLogProvider)
|
||||||
|
}
|
||||||
|
|
||||||
val generateTask = project.tasks.register("generateKonfig", GenerateKonfigTask::class.java).apply {
|
val generateTask = project.tasks.register("generateKonfig", GenerateKonfigTask::class.java).apply {
|
||||||
configure {
|
configure {
|
||||||
if (forceRegen) outputs.upToDateWhen { false }
|
if (forceRegen) outputs.upToDateWhen { false }
|
||||||
|
dependsOn(infoTask)
|
||||||
|
|
||||||
moduleName.set(project.name)
|
moduleName.set(project.name)
|
||||||
buildType.set(buildTypeProvider)
|
buildType.set(buildTypeProvider)
|
||||||
buildTypeSource.set(buildTypeSourceProvider)
|
// Only ERROR entries: full logs (task-name dependent) are on konfigInfo,
|
||||||
dimensionResolutionLog.set(dimensionResolutionLogProvider)
|
// keeping this task's inputs stable across invocations.
|
||||||
|
dimensionResolutionLog.set(
|
||||||
|
dimensionResolutionLogProvider.map { log ->
|
||||||
|
log.filterValues { it.startsWith("ERROR") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
flatDimensionNames.set(
|
||||||
|
project.providers.provider {
|
||||||
|
extension.dimensions.filter { it.flat }.map { it.dimensionName }.toSet()
|
||||||
|
}
|
||||||
|
)
|
||||||
outputDirectory.set(extension.outputDir)
|
outputDirectory.set(extension.outputDir)
|
||||||
objectPackage.set(extension.objectPackageProp)
|
objectPackage.set(extension.objectPackageProp)
|
||||||
objectName.set(extension.objectNameProp)
|
objectName.set(extension.objectNameProp)
|
||||||
@@ -202,9 +238,15 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
}
|
}
|
||||||
listOf("com.android.application", "com.android.library").forEach { androidPluginId ->
|
listOf("com.android.application", "com.android.library").forEach { androidPluginId ->
|
||||||
project.plugins.withId(androidPluginId) {
|
project.plugins.withId(androidPluginId) {
|
||||||
@Suppress("UnstableApiUsage")
|
// AGP 9.2 flips android.sourceset.disallowProvider to true: passing a
|
||||||
(project.extensions.findByName("android") as? CommonExtension<*, *, *, *>)
|
// provider (like a DirectoryProperty) to the AndroidSourceSet DSL fails.
|
||||||
?.sourceSets?.findByName("main")?.kotlin?.srcDir(extension.outputDir)
|
// Register the generated directory through the variant Sources API instead.
|
||||||
|
project.extensions.findByType(AndroidComponentsExtension::class.java)
|
||||||
|
?.onVariants { variant ->
|
||||||
|
val vOutDir = extension.outputDir.get().asFile
|
||||||
|
vOutDir.mkdirs()
|
||||||
|
variant.sources.kotlin?.addStaticSourceDirectory(vOutDir.absolutePath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,9 +338,16 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
|
|
||||||
// Priority 3: task-name detection
|
// Priority 3: task-name detection
|
||||||
if (flavorDetect && taskNames.isNotEmpty()) {
|
if (flavorDetect && taskNames.isNotEmpty()) {
|
||||||
val matches = dim.variants.keys.filter { variant ->
|
val rawMatches = dim.variants.keys.filter { variant ->
|
||||||
taskNames.any { task -> task.contains(variant, ignoreCase = true) }
|
taskNames.any { task -> task.containsWordCamelCase(variant) }
|
||||||
}
|
}
|
||||||
|
// When every match is a substring of the longest one (e.g. 'prod' inside
|
||||||
|
// 'preProd' for task assemblePreProdRelease), the longest is the real one.
|
||||||
|
val matches = if (rawMatches.size > 1) {
|
||||||
|
val longest = rawMatches.maxBy { it.length }
|
||||||
|
if (rawMatches.all { it == longest || longest.contains(it, ignoreCase = true) }) listOf(longest)
|
||||||
|
else rawMatches
|
||||||
|
} else rawMatches
|
||||||
when (matches.size) {
|
when (matches.size) {
|
||||||
1 -> return "OK\t${matches.first()}\ttask-name detection: '${matches.first()}' " +
|
1 -> return "OK\t${matches.first()}\ttask-name detection: '${matches.first()}' " +
|
||||||
"found in [${taskNames.joinToString()}]"
|
"found in [${taskNames.joinToString()}]"
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class DimensionConfig @PublishedApi internal constructor(
|
|||||||
val dimensionName: String,
|
val dimensionName: String,
|
||||||
val objectNameOverride: String?,
|
val objectNameOverride: String?,
|
||||||
val defaultVariant: String?,
|
val defaultVariant: String?,
|
||||||
|
/** When true, fields are generated at the root of the konfig object instead of a nested object. */
|
||||||
|
val flat: Boolean = false,
|
||||||
) {
|
) {
|
||||||
/** All named variants. */
|
/** All named variants. */
|
||||||
@PublishedApi
|
@PublishedApi
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.bitsycore.konfig.types
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Case-insensitive word match respecting camelCase segment boundaries.
|
||||||
|
*
|
||||||
|
* A candidate occurrence only counts when it starts AND ends on a word segment
|
||||||
|
* boundary, so `"assemblePreprodRelease"` matches variant `"preprod"` but NOT
|
||||||
|
* variant `"prod"` (which is a plain substring of `Preprod`), and
|
||||||
|
* `"assembleDevelopRelease"` does not match variant `"dev"`.
|
||||||
|
*
|
||||||
|
* Boundary rules for an occurrence at index `i` of length `n` in [this]:
|
||||||
|
* - start: `i == 0`, or the previous char is not a letter/digit, or `this[i]` is uppercase
|
||||||
|
* - end: the match reaches the end, or the next char is not a letter/digit, or is uppercase
|
||||||
|
*/
|
||||||
|
internal fun String.containsWordCamelCase(word: String): Boolean {
|
||||||
|
if (word.isEmpty()) return false
|
||||||
|
var vIndex = indexOf(word, 0, ignoreCase = true)
|
||||||
|
while (vIndex >= 0) {
|
||||||
|
val vStartOk = vIndex == 0 || !this[vIndex - 1].isLetterOrDigit() || this[vIndex].isUpperCase()
|
||||||
|
val vEnd = vIndex + word.length
|
||||||
|
val vEndOk = vEnd >= length || !this[vEnd].isLetterOrDigit() || this[vEnd].isUpperCase()
|
||||||
|
if (vStartOk && vEndOk) return true
|
||||||
|
vIndex = indexOf(word, vIndex + 1, ignoreCase = true)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.bitsycore.konfig
|
||||||
|
|
||||||
|
import com.bitsycore.konfig.types.containsWordCamelCase
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for camelCase-boundary word matching used by dimension variant detection.
|
||||||
|
* The critical property: variants that are substrings of other variants
|
||||||
|
* (prod / preprod) must never cross-match.
|
||||||
|
*/
|
||||||
|
class TaskNameMatchingTest {
|
||||||
|
|
||||||
|
// ── Prod vs Preprod differentiation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun `preprod task does not match prod variant`() {
|
||||||
|
assertFalse("assemblePreprodRelease".containsWordCamelCase("prod"))
|
||||||
|
assertFalse(":app:assemblePreprodDebug".containsWordCamelCase("prod"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `preprod task matches preprod variant`() {
|
||||||
|
assertTrue("assemblePreprodRelease".containsWordCamelCase("preprod"))
|
||||||
|
assertTrue(":app:assemblePreprodDebug".containsWordCamelCase("preprod"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `prod task matches prod variant only`() {
|
||||||
|
assertTrue("assembleProdRelease".containsWordCamelCase("prod"))
|
||||||
|
assertFalse("assembleProdRelease".containsWordCamelCase("preprod"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CamelCase variant names ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun `camelCase variant matches its camelCase segment`() {
|
||||||
|
assertTrue("assemblePreProdRelease".containsWordCamelCase("preProd"))
|
||||||
|
// "Prod" is a legitimate camelCase segment inside PreProd — the resolver's
|
||||||
|
// longest-match rule (tested functionally) disambiguates this case.
|
||||||
|
assertTrue("assemblePreProdRelease".containsWordCamelCase("prod"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `prefix variant does not match longer word`() {
|
||||||
|
assertFalse("assembleDevelopRelease".containsWordCamelCase("dev"))
|
||||||
|
assertTrue("assembleDevRelease".containsWordCamelCase("dev"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boundaries ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun `matches at string start and end`() {
|
||||||
|
assertTrue("prodRelease".containsWordCamelCase("prod"))
|
||||||
|
assertTrue("assembleProd".containsWordCamelCase("prod"))
|
||||||
|
assertTrue("prod".containsWordCamelCase("prod"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `matches with non-letter separators`() {
|
||||||
|
assertTrue("app:prod:assemble".containsWordCamelCase("prod"))
|
||||||
|
assertTrue("assemble-prod-release".containsWordCamelCase("prod"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `empty word never matches`() {
|
||||||
|
assertFalse("assembleProdRelease".containsWordCamelCase(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user