5 Commits
25 changed files with 1011 additions and 156 deletions
+91
View File
@@ -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
+1 -1
View File
@@ -5,6 +5,6 @@
local.properties local.properties
.DS_Store .DS_Store
# Local credential overrides — never commit tokens # Local credential overrides - never commit tokens
gradle.properties.local gradle.properties.local
secrets.properties secrets.properties
+123 -31
View File
@@ -11,7 +11,7 @@ This file provides guidance to AI agents (Claude, Copilot, Codex, etc.) working
# Run unit tests only # Run unit tests only
./gradlew test ./gradlew test
# Run functional tests (Gradle TestKit — starts real Gradle builds) # Run functional tests (Gradle TestKit - starts real Gradle builds)
./gradlew functionalTest ./gradlew functionalTest
# Run a single functional test # Run a single functional test
@@ -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
@@ -58,45 +91,69 @@ Generates a Kotlin `object BuildKonfig { ... }` at build time, placed in `build/
2. `konfig.properties` file in the project directory: `konfig.dimension.<name>=<variant>` 2. `konfig.properties` file in the project directory: `konfig.dimension.<name>=<variant>`
3. Task-name detection (variant name substring match, if not disabled by `konfig.android.flavordetection=false`) 3. Task-name detection (variant name substring match, if not disabled by `konfig.android.flavordetection=false`)
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>?` |
| `GenerateKonfigTask.kt` | `@CacheableTask` — validates inputs, logs detection results, writes the `.kt` file |
| `BuildType.kt` | `enum` with regex-based task-name detection | | `BuildType.kt` | `enum` with regex-based task-name detection |
| `Visibility.kt` | `PUBLIC` / `INTERNAL` enum | | `Visibility.kt` | `PUBLIC` / `INTERNAL` enum |
| `KonfigDsl.kt` | `@DslMarker` annotation applied to all DSL scope classes | | `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 |
+191 -33
View File
@@ -1,39 +1,87 @@
# buildkonfig-gradle-plugin # buildkonfig-gradle-plugin
A Gradle plugin that generates a `BuildKonfig` Kotlin object at build time — like Android's `BuildConfig`, but for any Kotlin project (JVM, Multiplatform, Android). A Gradle plugin that generates a `BuildKonfig` Kotlin object at build time - like Android's `BuildConfig`, but for any Kotlin project (JVM, Multiplatform, Android).
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,7 +123,7 @@ 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,27 +142,26 @@ 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) }
} }
``` ```
> `field()` inside `debug {}` / `release {}` blocks intentionally returns `Unit` — the build type is already fixed by the enclosing scope, so `.debug()` / `.release()` chaining is impossible by design. > `field()` inside `debug {}` / `release {}` blocks intentionally returns `Unit` - the build type is already fixed by the enclosing scope, so `.debug()` / `.release()` chaining is impossible by design.
--- ---
## 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
@@ -176,7 +255,7 @@ Variants are resolved in priority order:
| 2 | `konfig.properties` file | `konfig.dimension.env=dev` | | 2 | `konfig.properties` file | `konfig.dimension.env=dev` |
| 3 | Task-name detection | Running `assembleDevDebug` matches `dev` | | 3 | Task-name detection | Running `assembleDevDebug` matches `dev` |
| 4 | `defaultTo` in DSL | `dimension("env", defaultTo = "prod")` | | 4 | `defaultTo` in DSL | `dimension("env", defaultTo = "prod")` |
| — | Omitted silently | No variant → no nested object generated | | - | Omitted silently | No variant → no nested object generated |
### `konfig.properties` file ### `konfig.properties` file
@@ -186,7 +265,31 @@ Place a `konfig.properties` file in your project directory:
konfig.dimension.env=dev 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])
```
--- ---
@@ -198,7 +301,7 @@ Build type is resolved in priority order:
|----------|---------------------|-----------------------------------| |----------|---------------------|-----------------------------------|
| 1 | Explicit property | `-Pkonfig.buildtype=DEBUG` | | 1 | Explicit property | `-Pkonfig.buildtype=DEBUG` |
| 2 | Task-name detection | Running `assembleDebug` → `DEBUG` | | 2 | Task-name detection | Running `assembleDebug` → `DEBUG` |
| — | Default | `RELEASE` | | - | Default | `RELEASE` |
--- ---
@@ -208,20 +311,20 @@ Build type is resolved in priority order:
|---------------------------------------------|--------------------------------------------------| |---------------------------------------------|--------------------------------------------------|
| `-Pkonfig.buildtype=DEBUG\|RELEASE` | Forces build type | | `-Pkonfig.buildtype=DEBUG\|RELEASE` | Forces build type |
| `-Pkonfig.dimension.<name>=<variant>` | Selects a dimension variant | | `-Pkonfig.dimension.<name>=<variant>` | Selects a dimension variant |
| `-Pkonfig.force` | Disables UP-TO-DATE checks — task always re-runs | | `-Pkonfig.force` | Disables UP-TO-DATE checks - task always re-runs |
| `-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 |
### `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
./gradlew assembleRelease -Pkonfig.force ./gradlew assembleRelease -Pkonfig.force
``` ```
The flag is presence-based — any value (or no value) enables it. The flag is presence-based - any value (or no value) enables it.
--- ---
@@ -238,9 +341,37 @@ 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:
```kotlin ```kotlin
konfig { konfig {
@@ -249,7 +380,7 @@ konfig {
} }
``` ```
> Do not call `System.getenv()` or `project.findProperty()` directly inside `field()` — these bypass the Provider API and break configuration cache. > Do not call `System.getenv()` or `project.findProperty()` directly inside `field()` - these bypass the Provider API and break configuration cache.
--- ---
@@ -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
View File
@@ -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
View File
@@ -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
@@ -7,7 +7,7 @@ import kotlin.test.assertTrue
/** /**
* Functional tests for the `common {}` block inside a dimension. * Functional tests for the `common {}` block inside a dimension.
* *
* The common block provides fallback fields for all variants — a variant field * The common block provides fallback fields for all variants - a variant field
* with the same name must take precedence over the common field. * with the same name must take precedence over the common field.
*/ */
class CommonBlockFunctionalTest : FunctionalTestBase() { class CommonBlockFunctionalTest : FunctionalTestBase() {
@@ -73,7 +73,7 @@ class DuplicateDetectionFunctionalTest : FunctionalTestBase() {
} }
@Test fun `same field name in different variants is allowed`() = withProject { dir, run -> @Test fun `same field name in different variants is allowed`() = withProject { dir, run ->
// Different variants may each define the same field name — that is the whole point // Different variants may each define the same field name - that is the whole point
dir.writeBuildGradle(""" dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") } plugins { id("com.bitsycore.konfig") }
group = "com.example" group = "com.example"
@@ -106,7 +106,7 @@ class DuplicateDetectionFunctionalTest : FunctionalTestBase() {
assertTrue(result.output.contains("generateKonfig")) assertTrue(result.output.contains("generateKonfig"))
} }
// ── Duplicate dimension name — error message ────────────────────────────── // ── Duplicate dimension name - error message ──────────────────────────────
@Test fun `duplicate dimension error message contains dimension name`() = withFailingProject { dir, run -> @Test fun `duplicate dimension error message contains dimension name`() = withFailingProject { dir, run ->
dir.writeBuildGradle(""" dir.writeBuildGradle("""
@@ -121,7 +121,7 @@ class DuplicateDetectionFunctionalTest : FunctionalTestBase() {
assertTrue(result.output.contains("my-dim")) assertTrue(result.output.contains("my-dim"))
} }
// ── Duplicate global field — error message ──────────────────────────────── // ── Duplicate global field - error message ────────────────────────────────
@Test fun `duplicate global field error message contains field name`() = withFailingProject { dir, run -> @Test fun `duplicate global field error message contains field name`() = withFailingProject { dir, run ->
dir.writeBuildGradle(""" dir.writeBuildGradle("""
@@ -6,7 +6,7 @@ import kotlin.test.assertTrue
/** /**
* Functional tests for every supported field type emitted by the plugin: * Functional tests for every supported field type emitted by the plugin:
* String, Boolean, Int, Long, Float, Double — including correct Kotlin * String, Boolean, Int, Long, Float, Double - including correct Kotlin
* literal syntax (suffixes, special Float/Double values, escaping). * literal syntax (suffixes, special Float/Double values, escaping).
*/ */
class FieldTypesFunctionalTest : FunctionalTestBase() { class FieldTypesFunctionalTest : FunctionalTestBase() {
@@ -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""""))
}
}
@@ -9,9 +9,9 @@ import kotlin.io.path.createTempDirectory
* Base class for all functional tests. * Base class for all functional tests.
* *
* Provides: * Provides:
* - [withProject] — creates a temp Gradle project, runs it, then cleans up. * - [withProject] - creates a temp Gradle project, runs it, then cleans up.
* - [File.generatedFile] — walks the output dir to find the generated `.kt` file. * - [File.generatedFile] - walks the output dir to find the generated `.kt` file.
* - [File.writeBuildGradle] — shorthand for writing a `build.gradle.kts`. * - [File.writeBuildGradle] - shorthand for writing a `build.gradle.kts`.
*/ */
abstract class FunctionalTestBase { abstract class FunctionalTestBase {
@@ -41,7 +41,7 @@ abstract class FunctionalTestBase {
/** /**
* Same as [withProject] but Gradle is invoked with `buildAndFail()` so a build * Same as [withProject] but Gradle is invoked with `buildAndFail()` so a build
* failure does NOT throw — the returned [BuildResult] carries the failed output. * failure does NOT throw - the returned [BuildResult] carries the failed output.
*/ */
protected fun withFailingProject(block: (projectDir: File, run: (List<String>) -> BuildResult) -> Unit) { protected fun withFailingProject(block: (projectDir: File, run: (List<String>) -> BuildResult) -> Unit) {
val projectDir = createTempDirectory("konfig-ft-fail").toFile() val projectDir = createTempDirectory("konfig-ft-fail").toFile()
@@ -6,7 +6,7 @@ import kotlin.test.assertTrue
/** /**
* Functional tests for top-level (global) field declarations: * Functional tests for top-level (global) field declarations:
* String, Boolean, Int — defaults, debug overrides, release overrides, * String, Boolean, Int - defaults, debug overrides, release overrides,
* and the debug/release scope block syntax. * and the debug/release scope block syntax.
*/ */
class GlobalFieldsFunctionalTest : FunctionalTestBase() { class GlobalFieldsFunctionalTest : FunctionalTestBase() {
@@ -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,13 +127,22 @@ 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) }
if (dimName in flatDims) {
// Flat dimension: fields live directly on the root object.
appendLine()
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()
appendLine(" ${visPrefix}object $dimObjName /*$dimName*/ {") appendLine(" ${visPrefix}object $dimObjName /*$dimName*/ {")
appendLine() appendLine()
@@ -139,6 +152,7 @@ abstract class GenerateKonfigTask : DefaultTask() {
appendEncodedFields(" ", fields, btVal) 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,36 +212,55 @@ 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" }
val errors = mutableListOf<String>()
for (dimName in activeFlatDims) {
if (dimVars[dimName] == null) continue
val prefix = "$dimName|"
val rootNames = buildList {
add(dimName.toVariantConstName())
addAll(dFields.keys.filter { it.startsWith(prefix) }.map { it.removePrefix(prefix) })
}
for (name in rootNames) {
val existing = owners[name]
if (existing != null) {
errors += "flat dimension '$dimName' generates '$name' which collides with $existing"
} else { } else {
val maxDimLen = resolutionLog.keys.maxOf { it.length } owners[name] = "flat dimension '$dimName'"
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")
} }
} }
} }
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
@@ -252,7 +285,7 @@ abstract class GenerateKonfigTask : DefaultTask() {
"Long" -> """${indent}const val $name: Long = ${raw}L""" "Long" -> """${indent}const val $name: Long = ${raw}L"""
"Float" -> """${indent}const val $name: Float = ${raw.toFloat().toKotlinFloat()}""" "Float" -> """${indent}const val $name: Float = ${raw.toFloat().toKotlinFloat()}"""
"Double" -> """${indent}const val $name: Double = ${raw.toDouble().toKotlinDouble()}""" "Double" -> """${indent}const val $name: Double = ${raw.toDouble().toKotlinDouble()}"""
else -> return@forEach // unknown type — skip else -> return@forEach // unknown type - skip
} }
appendLine(line) appendLine(line)
} }
@@ -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
@@ -44,7 +45,7 @@ abstract class KonfigExtension @Inject constructor(
get() = objectVisibilityProp.get() get() = objectVisibilityProp.get()
set(value) = objectVisibilityProp.set(value) set(value) = objectVisibilityProp.set(value)
/** Output directory — kept as [DirectoryProperty] for full Gradle lazy semantics. */ /** Output directory - kept as [DirectoryProperty] for full Gradle lazy semantics. */
val outputDir: DirectoryProperty = objects.directoryProperty() val outputDir: DirectoryProperty = objects.directoryProperty()
// ============================================================================== // ==============================================================================
@@ -54,7 +55,7 @@ abstract class KonfigExtension @Inject constructor(
@PublishedApi @PublishedApi
internal val dimensions: MutableList<DimensionConfig> = mutableListOf() internal val dimensions: MutableList<DimensionConfig> = mutableListOf()
/** Backing store for global fields — reuses [com.bitsycore.konfig.configs.VariantConfig] for its field/debug/release logic. */ /** Backing store for global fields - reuses [com.bitsycore.konfig.configs.VariantConfig] for its field/debug/release logic. */
@PublishedApi @PublishedApi
internal val globalScope: VariantConfig = VariantConfig("\$global") internal val globalScope: VariantConfig = VariantConfig("\$global")
@@ -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
val generateTask = project.tasks.register("generateKonfig", GenerateKonfigTask::class.java).apply { // Untracked logging task: runs on EVERY build (even fully cached ones) so the
configure { // selected build type / dimension variants always appear in the logs.
if (forceRegen) outputs.upToDateWhen { false } 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) moduleName.set(project.name)
buildType.set(buildTypeProvider) buildType.set(buildTypeProvider)
buildTypeSource.set(buildTypeSourceProvider) buildTypeSource.set(buildTypeSourceProvider)
dimensionResolutionLog.set(dimensionResolutionLogProvider) dimensionResolutionLog.set(dimensionResolutionLogProvider)
}
val generateTask = project.tasks.register("generateKonfig", GenerateKonfigTask::class.java).apply {
configure {
if (forceRegen) outputs.upToDateWhen { false }
dependsOn(infoTask)
moduleName.set(project.name)
buildType.set(buildTypeProvider)
// Only ERROR entries: full logs (task-name dependent) are on konfigInfo,
// 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)
}
} }
} }
@@ -251,11 +293,11 @@ class KonfigPlugin : Plugin<Project> {
* Resolves a dimension and encodes the result as a tab-separated string for logging. * Resolves a dimension and encodes the result as a tab-separated string for logging.
* *
* Format: `"<TAG>\t<variant>\t<reason>"` * Format: `"<TAG>\t<variant>\t<reason>"`
* - TAG = `OK` — active, variant resolved successfully * - TAG = `OK` - active, variant resolved successfully
* - TAG = `WARN_UNKNOWN` — property set but value is not a known variant * - TAG = `WARN_UNKNOWN` - property set but value is not a known variant
* - TAG = `WARN_AMBIGUOUS` — multiple variants matched task names * - TAG = `WARN_AMBIGUOUS` - multiple variants matched task names
* - TAG = `SKIP` — no active variant could be determined * - TAG = `SKIP` - no active variant could be determined
* - TAG = `ERROR` — configuration error (e.g. invalid defaultTo) * - TAG = `ERROR` - configuration error (e.g. invalid defaultTo)
*/ */
private fun resolveWithSource( private fun resolveWithSource(
dim: DimensionConfig, dim: DimensionConfig,
@@ -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
@@ -11,7 +11,7 @@ import java.util.function.BiFunction
* *
* Values are stored as [Provider]<T> so lazy sources (e.g. Gradle property providers) * Values are stored as [Provider]<T> so lazy sources (e.g. Gradle property providers)
* are supported alongside plain constants. [org.gradle.api.provider.ProviderFactory] * are supported alongside plain constants. [org.gradle.api.provider.ProviderFactory]
* is intentionally NOT stored here — it is not configuration-cache serializable and * is intentionally NOT stored here - it is not configuration-cache serializable and
* must never flow into the object graph captured by task input providers. * must never flow into the object graph captured by task input providers.
*/ */
class FieldConfig<T : Any> @PublishedApi internal constructor( class FieldConfig<T : Any> @PublishedApi internal constructor(
@@ -16,7 +16,7 @@ import org.gradle.api.provider.Provider
* field("TIMEOUT", 30).debug(5) * field("TIMEOUT", 30).debug(5)
* field("URL", "https://prod.example.com").debug("https://dev.example.com").release("https://prod.example.com") * field("URL", "https://prod.example.com").debug("https://dev.example.com").release("https://prod.example.com")
* ``` * ```
* Not available inside `debug {}` / `release {}` blocks — those return [Unit]. * Not available inside `debug {}` / `release {}` blocks - those return [Unit].
*/ */
class FieldHandle<T : Any> @PublishedApi internal constructor( class FieldHandle<T : Any> @PublishedApi internal constructor(
@PublishedApi internal val field: FieldConfig<T> @PublishedApi internal val field: FieldConfig<T>
@@ -34,7 +34,7 @@ class FieldHandle<T : Any> @PublishedApi internal constructor(
/** /**
* Receiver of `debug { ... }` and `release { ... }` blocks inside [VariantConfig]. * Receiver of `debug { ... }` and `release { ... }` blocks inside [VariantConfig].
* *
* `field()` here returns [Unit] — no `.debug()`/`.release()` chaining is possible * `field()` here returns [Unit] - no `.debug()`/`.release()` chaining is possible
* because the build type is already fixed by the enclosing scope. * because the build type is already fixed by the enclosing scope.
*/ */
@KonfigDsl @KonfigDsl
@@ -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
}
@@ -128,20 +128,20 @@ class BuildTypeExtendedTest {
} }
@Test fun `prereleased does not resolve RELEASE`() { @Test fun `prereleased does not resolve RELEASE`() {
// "release" is preceded by lowercase 'e' in "prere[lease]" — but "release" // "release" is preceded by lowercase 'e' in "prere[lease]" - but "release"
// starts after "prere", let's verify actual behaviour via the regex: // starts after "prere", let's verify actual behaviour via the regex:
// lookbehind (?<![a-z]) fails because char before 'r' of "release" is 'e' // lookbehind (?<![a-z]) fails because char before 'r' of "release" is 'e'
assertNull(BuildType.resolve("prereleased")) assertNull(BuildType.resolve("prereleased"))
} }
@Test fun `released does not resolve RELEASE`() { @Test fun `released does not resolve RELEASE`() {
// "released" — 'd' after "release" is NOT a lowercase letter... wait, // "released" - 'd' after "release" is NOT a lowercase letter... wait,
// actually the regex checks (?![a-z]) so 'd' fails the lookahead. // actually the regex checks (?![a-z]) so 'd' fails the lookahead.
assertNull(BuildType.resolve("released")) assertNull(BuildType.resolve("released"))
} }
@Test fun `debugMode resolves DEBUG`() { @Test fun `debugMode resolves DEBUG`() {
// 'M' after "debug" — uppercase, not [a-z], so lookahead passes // 'M' after "debug" - uppercase, not [a-z], so lookahead passes
assertEquals(BuildType.DEBUG, BuildType.resolve("debugMode")) assertEquals(BuildType.DEBUG, BuildType.resolve("debugMode"))
} }
@@ -119,7 +119,7 @@ class DimensionConfigTest {
d.variant("prod") { field("URL", "https://prod.example.com") } d.variant("prod") { field("URL", "https://prod.example.com") }
d.variant("prod") { field("KEY", "secret") } d.variant("prod") { field("KEY", "secret") }
val v = d.variants["prod"]!! val v = d.variants["prod"]!!
// Same VariantConfig instance reused — two fields total // Same VariantConfig instance reused - two fields total
assertEquals(2, v.fields.size) assertEquals(2, v.fields.size)
} }
@@ -115,7 +115,7 @@ class FieldHandleTest {
@Test fun `handle with no debug or release set returns null for scope-only field`() { @Test fun `handle with no debug or release set returns null for scope-only field`() {
val fc = FieldConfig<String>("F", String::class.java, null) val fc = FieldConfig<String>("F", String::class.java, null)
@Suppress("UNUSED_VARIABLE") val handle = FieldHandle(fc) @Suppress("UNUSED_VARIABLE") val handle = FieldHandle(fc)
// No overrides set — resolve returns null // No overrides set - resolve returns null
assertNull(fc.resolve(BuildType.DEBUG)) assertNull(fc.resolve(BuildType.DEBUG))
assertNull(fc.resolve(BuildType.RELEASE)) assertNull(fc.resolve(BuildType.RELEASE))
} }
@@ -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(""))
}
}