11 Commits
38 changed files with 2611 additions and 314 deletions
+43
View File
@@ -0,0 +1,43 @@
name: Check
on:
pull_request:
push:
branches: [master, main]
permissions:
contents: read
concurrency:
group: check-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- java: '17'
kotlin: '2.3.0'
agp: '8.13.2'
- java: '25'
kotlin: '2.4.0'
agp: '9.3.2'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ matrix.java }}
- uses: gradle/actions/setup-gradle@v4
- uses: android-actions/setup-android@v3
- name: Install Android test SDK
run: sdkmanager 'platforms;android-35' 'build-tools;35.0.0' 'build-tools;36.0.0'
- name: Check and compile consumer projects
run: >-
./gradlew check
-Pkonfig.test.kotlinVersion=${{ matrix.kotlin }}
-Pkonfig.test.androidVersion=${{ matrix.agp }}
--stacktrace
+97
View File
@@ -0,0 +1,97 @@
# 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: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android test SDK
run: sdkmanager 'platforms;android-35' 'build-tools;35.0.0' 'build-tools;36.0.0'
- 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
.DS_Store
# Local credential overrides — never commit tokens
# Local credential overrides - never commit tokens
gradle.properties.local
secrets.properties
+169 -42
View File
@@ -1,6 +1,6 @@
# AGENTS.md
This file provides guidance to AI agents (Claude, Copilot, Codex, etc.) working in this repository.
This file provides guidance to AI agents working in this repository.
## Commands
@@ -11,7 +11,7 @@ This file provides guidance to AI agents (Claude, Copilot, Codex, etc.) working
# Run unit tests only
./gradlew test
# Run functional tests (Gradle TestKit — starts real Gradle builds)
# Run functional tests (Gradle TestKit - starts real Gradle builds)
./gradlew functionalTest
# Run a single functional test
@@ -22,81 +22,173 @@ This file provides guidance to AI agents (Claude, Copilot, Codex, etc.) working
# Force re-run (skip UP-TO-DATE / cache)
./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.7.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
### 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.
Android application/library projects register a generator per variant, such as
`generateProdDebugKonfig`, with output under `build/generated/konfig/prodDebug/`.
AGP's `debuggable` and exact flavor-dimension metadata drive these tasks.
JVM/KMP retain shared generation; conflicting task-name selections fail unless
explicitly overridden. `generateKonfig` and `konfigInfo` aggregate variant tasks
on standalone Android projects. KMP Android targets retain the commonMain object.
### Key design constraints
**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.
- Use `Class<T>` (`.javaObjectType`) instead of `KClass<T>` — Kotlin's `KClass` uses `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.
- Never capture `project` inside a `Provider.map {}` or `Provider.zip {}` lambda, and **never
access `project` inside a `@TaskAction`** - both break caching. Use declared
task properties inside task actions.
- Use `Class<T>` (`.javaObjectType`) instead of `KClass<T>` - Kotlin's `KClass` uses
`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.
Collections use `"Value:<Kotlin type>\n<initializer>"`. `FieldValueType` snapshots
generic types into strings/lists; never retain KType/KClass in the DSL graph.
Fresh array-containing values use `"Getter:<Kotlin type>\n<initializer>"` when
`copyArraysOnAccess` is enabled. `specializeArrays` defaults to true; explicit
primitive arrays always retain their type. Sets, enum references, and unsigned
scalar numbers are also supported.
Nullable DSL fields use `NullFieldValue` inside a non-null constant provider to
distinguish explicit null from an absent Gradle provider. Check this marker by
type, not singleton identity: configuration-cache restoration may recreate it.
`fieldClass<T>()` immediately converts nullable type reflection to a Java Class.
**Output ownership:** `generatedFile` and `ownershipFile` are managed
`@OutputFile` properties. `outputDirectory` is `@Internal`; never annotate the
shared directory as an output or delete it recursively. `sourceDirectory` is
an internal directory view zipped from `generatedFile` and `outputDirectory` so
source consumers inherit the file's producer dependency. Android registers that
view with `addGeneratedSourceDirectory`. Keep the file producer in this chain.
The ownership record stores a relative path; validate all inputs before replacing
files and preserve unrelated files on both execution and build-cache restoration.
### DSL design
- **`@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.
- **`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.
- **`debug {}` / `release {}` scope blocks** use `BuildTypedFieldDeclScope` as receiver — `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.
- **`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.
- **`debug {}` / `release {}` scope blocks** use `BuildTypedFieldDeclScope` as receiver -
`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
1. Explicit Gradle property: `-Pkonfig.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. Exact Android flavor-dimension metadata, or camelCase task-name matching for shared JVM/KMP generation (unless `konfig.android.flavordetection=false`)
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.
`strictResolution = true` rejects unknown explicit build types, unknown dimension
property names, and missing/unknown selections. Per-dimension `required = true`
enforces just that dimension's selection. Both default to false. The per-dimension
`androidDimension` alias defaults to its Konfig name and only affects Android
metadata lookup. `validateVariantSchema = true` checks effective field names and
declared types across all variants/build types, including common fallbacks and
provider presence; its default is false.
`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
| Property | Effect |
|---------------------------------------|----------------------------------------------------------------------------------|
| `-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.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.flavordetection=false` | Disables task-name dimension variant detection |
| Property | Effect |
|---------------------------------------------|---------------------------------------------------------------------------------|
| `-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.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.flavordetection=false` | Disables task-name dimension variant detection |
### File map
| File | Role |
|-------------------------|-----------------------------------------------------------------------------------------------------------------|
| `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 |
| `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 |
| `FieldHandle.kt` | Fluent handle returned by top-level `field()` — `.debug(value)` / `.release(value)` return `Unit` |
| `BuildTypedFieldDeclScope.kt` | Receiver for `debug {}`/`release {}` blocks — `field()` returns `Unit`, no chaining possible |
| `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 |
| `Visibility.kt` | `PUBLIC` / `INTERNAL` enum |
| `KonfigDsl.kt` | `@DslMarker` annotation applied to all DSL scope classes |
| File | Role |
|-------------------------------|----------------------------------------------------------------------------------------------------------------|
| `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 |
| `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 |
| `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 |
| `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 ID:** `com.bitsycore.konfig`
- **Group:** `com.bitsycore`
- **Version:** set via `konfig.version` in `gradle.properties` (currently `0.2.0`)
- **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.0.0")` — never leaked to consumers
- **Artifact:** `konfig-gradle-plugin`
- **Version:** set via `konfig.version` in `gradle.properties` (currently `0.7.0`)
- **Repositories:** `https://maven.bitsycore.com/releases` (primary, no auth) and
`https://maven.pkg.github.com/bitsycore/bitsykonfig` (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
@@ -109,3 +201,38 @@ Generates a Kotlin `object BuildKonfig { ... }` at build time, placed in `build/
| Unknown/ambiguous variant | `warn` | Bad `-P` value or multiple task matches |
| Field/dim details | `info` | With `--info` |
| 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 |
+22
View File
@@ -0,0 +1,22 @@
# Changelog
## 0.7.0 — 2026-09-06
- Added opt-in `strictResolution`, per-dimension `required`, and cross-variant `validateVariantSchema` checks.
- Added `androidDimension` aliases for differently named Android flavor dimensions.
- Added explicit nullable fields, sets through `setOf`, enum references, and unsigned scalar numbers.
- Added `specializeArrays` and `copyArraysOnAccess` settings; existing array behavior remains the default.
- Added list, map, and array fields using `listOf`, `mapOf`, and `arrayOf`, including nested, nullable, empty, and provider-backed values.
- Optimized arrays of non-null primitive elements into primitive arrays, such as `Array<Int>` becoming `IntArray`.
- Added `Byte`, `Short`, and `Char` fields and fixed `Long.MIN_VALUE` generation.
- Fixed standalone Android projects to generate separate configuration for each variant, including simultaneous debug/release and flavor builds. KMP keeps one shared configuration in `commonMain`.
- Changed Android detection to use variant metadata: build types follow `debuggable`, and automatic flavor selection uses the Konfig dimension name or its `androidDimension` alias. Explicit properties still take precedence.
- Changed Android output to `build/generated/konfig/<variant>/` with tasks such as `generateProdDebugKonfig`. `generateKonfig` and `konfigInfo` now run all variant tasks; custom paths and task integrations may need updating.
- Fixed conflicting JVM/KMP task selections to fail clearly instead of silently choosing the wrong configuration. Use separate builds or explicit selection properties to resolve conflicts.
- Excluded project-path segments from task-name detection, so `:prod:compileKotlin` no longer selects the `prod` variant by itself.
- Added validation for invalid Kotlin names, package names, reserved fields, name collisions, unsupported field types, and incompatible field override types. Previously accepted invalid configurations may now fail earlier.
- Fixed default package derivation for unusual project/group names and escaping of module names, variant names, and generated comments.
- Made regeneration and build-cache restoration preserve neighboring files. Renames remove only the previously owned generated file, and existing user files are protected from overwriting.
- Fixed generated-source dependencies for compilation, Android lint, and source publication.
- Kept the existing DSL syntax and project-wide query APIs. On Android, `konfig.isDebug` and dimension queries describe one project-wide selection; use `androidComponents.onVariants` for decisions that vary by Android variant.
- Added regression tests, JVM/KMP/Android consumer integration tests, and a CI check workflow. Configuration-cache reuse and Android integration are checked on AGP 8.13.2 and 9.3.2.
+88
View File
@@ -0,0 +1,88 @@
Project checkup — updated 6 September 2026
All six findings from the initial review have been addressed. The collection
support was added in commit b29b853; this report describes the follow-up fixes.
**Resolved findings**
| Original finding | Fix |
| --- | --- |
| P1: Shared configuration selected the wrong Android variant | Standalone Android application/library projects now register separate generation/logging tasks and directories per variant. Build type follows AGP's debuggable flag; dimensions use exact Android flavor-dimension metadata. JVM/KMP shared generation rejects conflicting automatic selections; explicit properties remain available. |
| P1: Output-directory cleanup could delete unrelated files | Gradle owns two output files: generated Kotlin and a relative ownership record. The directory is not an output. Renames remove only the previous owned file, and cache restoration preserves neighbors. Existing user files and paths escaping through symbolic links are rejected. Validation completes before replacement. |
| P2: Incomplete identifier validation | Object names, active dimension object names, flat variant constants, fields, and package segments are validated for Kotlin identifier rules, hard keywords, and underscore-only names. Default package derivation also sanitizes invalid segments. Internal dimension delimiters/control characters and ambiguous variant-name tabs are rejected. |
| P2: Reserved-name and object collisions | Global fields cannot reuse BUILD_TYPE, MODULE_NAME, or IS_DEBUG. Nested dimensions reserve VARIANT. Nested object names, global fields, and flat fields share one collision check. |
| P2: Unescaped metadata | Module and selected-variant strings use the shared Kotlin literal encoder. Dimension comments escape controls and neutralize block-comment delimiters. A compiler/runtime test verifies the original values survive. |
| P2: Missing source-generation dependencies | JVM/KMP use a source-directory provider linked to the managed generated-file output. Android registers that view with addGeneratedSourceDirectory: Java sources for external KGP, Kotlin sources for AGP built-in Kotlin. Compile, lint, and source publication consume the producer dependency. |
The Android registration follows the official
[AGP generated-source API](https://developer.android.com/reference/tools/gradle-api/8.7/com/android/build/api/variant/SourceDirectories).
**Verification and maintenance**
The checkup-fix validation passed all 327 tests (179 unit and 148 functional), with
zero failures or skips, using Kotlin 2.4.0 and AGP 9.3.2 for consumer integration.
The Kotlin 2.3.0 / AGP 8.13.2 consumer integration run also passed. Local runs used
JDK 25; the additional JDK 17 leg is configured in CI. `git diff --check` passed.
- Regression tests cover invalid names, every generated scope's reserved names,
incompatible shared selections, metadata compilation/runtime values, failed
generation preserving the previous file, custom-directory neighbors, output
renaming, and build-cache restoration.
- Consumer integration tests package a temporary Maven artifact, so the optional
Kotlin/Android APIs are resolved as they are for a published plugin. These test
real JVM and KMP compilation and source publication with configuration caching.
- Android integration tests build simultaneous prod/debug and preProd/release
AARs and inspect the compiled constants. They also run lint and source
publication from a clean build, reuse configuration caching, and invoke the
aggregate generation task alongside variant builds.
- A pull-request/push check workflow covers Kotlin 2.3.0 with AGP 8.13.2 and Kotlin
2.4.0 with AGP 9.3.2, on JDK 17/25 respectively. AGP 9.3.2 consumer tests use
Gradle 9.5.0 to satisfy its minimum version; the project wrapper remains 9.4.1.
- Android integration requires SDK platform 35; CI installs it. The Android test
explicitly skips on developer machines without that SDK.
- The README's incorrect debug output example is corrected. The compiler-test
helper is shared by collection and metadata regression tests.
**Behavior to account for when upgrading**
Android generation writes under build/generated/konfig/<variant>/, or the same
variant child beneath a custom outputDir. Tasks are named generateProdDebugKonfig
and konfigProdDebugInfo, for example. generateKonfig and konfigInfo aggregate the
variant tasks. Explicit build-type/dimension properties retain higher priority.
KMP retains one object in commonMain, including with Android targets. Conflicting
automatic selections fail; aggregate/custom tasks without selection in their
names require explicit properties when defaults are unsuitable. Project-wide
isDebug/currentDimension queries represent one selection and cannot describe
several different Android variants simultaneously.
Invalid identifiers and conflicting automatic selections that previously
produced broken or incorrect source now fail with an actionable error.
**Additional features implemented after the checkup**
- Opt-in strict resolution, required dimensions, and cross-variant field-schema validation.
- Android flavor-dimension aliases.
- Explicit nullable fields, sets, enum references, and unsigned scalar numbers.
- Boxed-array selection and fresh array-containing values on access.
Per-platform configuration was deferred at the user's request. KMP retains its
shared object and existing const/inline generation.
Feature validation passed a full check of 331 tests with Kotlin 2.4.0 / AGP 9.3.2.
A follow-up run passed all eight feature/consumer tests with Kotlin 2.3.0 /
AGP 8.13.2, including one additional array-policy cache-invalidation test and
expanded provider-schema, constant-preservation, and Android alias-priority checks.
Both runs had zero failures or skips.
**Optional feature proposals remaining**
These were roadmap suggestions, not defects, and were not added in this fix pass:
| Proposal | Purpose |
| --- | --- |
| Configurable propertiesFile | Share an explicitly selected, tracked configuration file across modules |
| Target/source-set selection and automatic-wiring toggle | Support custom KMP layouts and integrations |
| Structured konfigInfo output and configurable verbosity | Export resolution decisions to CI and control normal log output |
The initial diagnostic projects remain under ignored build/checkup.
+361 -45
View File
@@ -1,39 +1,87 @@
# 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.
**Plugin ID:** `com.bitsycore.konfig`
**Version:** `0.2.0`
**Group:** `com.bitsycore`
**Artifact:** `konfig-gradle-plugin`
**Version:** `0.7.0`
**JVM target:** 17
---
## Setup
### 1. Publish to local Maven (until published to a registry)
### 1. Configure plugin resolution
```bash
./gradlew publishToMavenLocal
```
### 2. Add to your project
The plugin is published to **maven.bitsycore.com** (no authentication) and to
**GitHub Packages** as a fallback (requires a GitHub PAT with `read:packages`).
`settings.gradle.kts`:
```kotlin
pluginManagement {
repositories {
mavenLocal()
maven("https://maven.bitsycore.com/releases")
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")
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.7.0"
[plugins]
konfig = { id = "com.bitsycore.konfig", version.ref = "konfig" }
```
`build.gradle.kts`:
```kotlin
plugins {
id("com.bitsycore.konfig") version "0.2.0"
alias(libs.plugins.konfig)
}
```
Or inline:
```kotlin
plugins {
id("com.bitsycore.konfig") version "0.7.0"
}
```
@@ -75,9 +123,9 @@ In debug builds (`-Pkonfig.buildtype=DEBUG`), `ENABLE_LOGGING` becomes `inline v
```kotlin
konfig {
objectPackage = "com.example.app" // default: derived from group + name or projectName + moduleName
objectName = "BuildKonfig" // default: "BuildKonfig"
objectVisibility = Visibility.INTERNAL // default: Visibility.PUBLIC
objectPackage = "com.example.app" // default: derived from group + project name
objectName = "BuildKonfig" // default: "BuildKonfig"
objectVisibility = Visibility.INTERNAL // default: Visibility.PUBLIC
}
```
@@ -91,30 +139,114 @@ konfig {
| `Long` | `field("MAX_SIZE", 1_000_000L)` |
| `Float` | `field("RATIO", 1.5f)` |
| `Double` | `field("PI", 3.14159)` |
| `Byte`, `Short`, `Char` | `field("SEPARATOR", ':')` |
| `List<T>` | `field("HOSTS", listOf("api.example.com"))` |
| `Set<T>` | `field("FEATURES", setOf("search", "export"))` |
| `Map<K, V>` | `field("PORTS", mapOf("https" to 443))` |
| `Array<T>` | `field("REGIONS", arrayOf("eu", "us"))` |
| Primitive arrays | `field("RETRIES", intArrayOf(1, 3, 5))` |
| Nullable types | `field<String?>("OPTIONAL_URL", null)` |
| Enum values | `field("DAY", java.time.DayOfWeek.MONDAY)` (JVM) |
| `UByte`, `UShort`, `UInt`, `ULong` | `field("MAX_ID", ULong.MAX_VALUE)` |
### Build-type overrides
### Lists, sets, maps, and arrays
Three equivalent forms for overriding per build type:
Collections generate ordinary `val` properties initialized with `listOf`,
`setOf`, `mapOf`, or an array factory. They work with providers, build-type overrides,
dimension variants, `common {}`, and flat dimensions:
```kotlin
konfig {
// Fluent handle (default + one or both overrides)
field("HOSTS", listOf("api.example.com")).debug(listOf("localhost"))
field("PORTS", mapOf("http" to 80, "https" to 443))
field("REGIONS", arrayOf("eu", "us"))
field("RETRIES", arrayOf(1, 3, 5))
field("EMPTY", emptyList<String>())
field("ROUTES", mapOf("primary" to listOf("/health", "/status")))
}
```
```kotlin
val HOSTS: List<String> = listOf<String>("api.example.com")
val PORTS: Map<String, Int> = mapOf<String, Int>("http" to 80, "https" to 443)
val REGIONS: Array<String> = arrayOf<String>("eu", "us")
val RETRIES: IntArray = intArrayOf(1, 3, 5)
val EMPTY: List<String> = listOf<String>()
val ROUTES: Map<String, List<String>> = mapOf<String, List<String>>("primary" to listOf<String>("/health", "/status"))
```
Arrays of non-null primitive elements are specialized: `Array<Int>` generates
`IntArray` with `intArrayOf`. The same applies to Boolean, Byte, Short, Char,
Long, Float, and Double. Existing primitive arrays retain their primitive type.
Arrays with nullable elements such as `Array<Int?>` stay generic. Specialization also applies
inside nested collections, so `List<Array<Int>>` generates `List<IntArray>`.
Empty collections retain the type supplied in the DSL. Nullable elements,
nested lists/sets/maps/arrays, and mixed values explicitly typed as `Any` are
supported; values must ultimately be supported scalars or collections.
Custom objects other than enums are rejected. Lists, sets, and maps expose read-only Kotlin
interfaces; generated arrays are mutable. Collection properties are not `const`.
Build-type scope overrides must keep the declared field type.
### Array policies
```kotlin
konfig {
specializeArrays = false // Keep Array<Int> as Array<Int>; default is true
copyArraysOnAccess = true // Return fresh array-containing values; default is false
field("RETRIES", arrayOf(1, 3, 5))
}
```
Explicit primitive arrays such as `intArrayOf(1, 2)` stay primitive under either
policy. Both policies apply recursively inside collections. With copying enabled,
an array-containing field uses a getter that recreates the whole value on every
access. This prevents a caller's array mutations from affecting later reads, at
the cost of allocations. Fields without arrays retain their usual declarations.
### Nullable fields, enums, and unsigned numbers
```kotlin
konfig {
field<String?>("OPTIONAL_URL", null).debug("http://localhost")
field<String?>("TOKEN", providers.gradleProperty("token"))
field("MAX_ID", ULong.MAX_VALUE)
}
```
An explicit `null` generates a present nullable `val`. An absent Gradle provider
still omits the field; Gradle providers cannot carry a present null. Specify the
nullable type explicitly, including in `debug {}` / `release {}` declarations
that override a nullable field. Nullable properties are ordinary `val` properties.
Enums generate a qualified reference such as `java.time.DayOfWeek.MONDAY` and
support nullable values and nesting in collections. The enum must be accessible
both from the build script and from the consuming source set. A build-script-only
enum is not automatically copied into application code. Unsigned scalar numbers
and collections of those numbers are supported; unsigned array classes are not.
### Build-type overrides
Three equivalent forms:
```kotlin
konfig {
// Fluent handle - default + one or both overrides
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) }
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 let you select a named variant at build time (e.g. `env=prod`, `env=dev`).
Each active dimension generates a nested object inside `BuildKonfig`.
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`.
```kotlin
konfig {
@@ -144,9 +276,9 @@ public object BuildKonfig {
public object Env /*env*/ {
const val VARIANT: String = "dev"
inline val TIMEOUT: Boolean get() = 5 // from common {}, debug override
const val TIMEOUT: Int = 5 // common {}, debug override
const val BASE_URL: String = "https://dev.example.com"
const val ANALYTICS: Boolean = false
inline val ANALYTICS: Boolean get() = false
}
}
```
@@ -159,11 +291,48 @@ Fields declared in `common {}` act as fallbacks for all variants. A variant fiel
```kotlin
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`).
### 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.
Global fields also cannot reuse built-in names. Nested dimensions reserve
`VARIANT`, and their object names must be unique within the root object.
Object names, field names, and package segments must be valid Kotlin identifiers;
keywords and underscore-only names are rejected before generation.
---
## Variant selection
@@ -174,9 +343,9 @@ Variants are resolved in priority order:
|----------|--------------------------|------------------------------------------|
| 1 | Gradle property | `-Pkonfig.dimension.env=dev` |
| 2 | `konfig.properties` file | `konfig.dimension.env=dev` |
| 3 | Task-name detection | Running `assembleDevDebug` matches `dev` |
| 3 | Android flavor metadata, or task names for JVM/KMP | Android flavor dimension `env=dev`, or `assembleDevDebug` |
| 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
@@ -186,7 +355,70 @@ Place a `konfig.properties` file in your project directory:
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 within a single task and every match is a substring
of the longest one (e.g. `prod` inside `preProd` for `assemblePreProdRelease`),
the longest wins. Conflicting selections across tasks fail with a clear error;
`assembleProdRelease assemblePreProdRelease` never silently selects preProd.
Project-path segments such as `:prod:` are excluded from task-name detection.
Android application/library projects select flavors by the exact Android
dimension name, so `dimension("env")` maps to the Android `env` flavor dimension.
Explicit Gradle properties and `konfig.properties` still take precedence.
### Strict resolution and schema validation
All new validation settings are opt-in:
```kotlin
konfig {
strictResolution = true
validateVariantSchema = true
dimension("env", defaultTo = "prod") {
required = true
androidDimension = "environment"
common { field("TIMEOUT", 30) }
variant("prod") { field("URL", "https://example.com") }
variant("dev") { field("URL", "http://localhost") }
}
}
```
- `strictResolution` rejects unknown explicit build types, unknown dimension
property names, and missing or unknown dimension selections. A valid `defaultTo`
satisfies the selection requirement. Existing build-type defaults still apply
when no build type is explicitly supplied.
- `required` requires a selection for just that dimension, even when global
strict resolution is disabled. Its default is `false`.
- `validateVariantSchema` checks effective field names and declared types across
every variant and both DEBUG/RELEASE contexts, including inactive variants and
`common {}` fallback fields. It also checks global fields across build types.
Missing provider values count as absent fields. Field values may differ.
- `androidDimension` maps a Konfig dimension to an Android flavor dimension with
a different name. It defaults to the Konfig dimension name. Explicit selection
properties still use the Konfig name, such as `-Pkonfig.dimension.env=dev`, and
override Android metadata. The alias does not change JVM/KMP task-name matching.
### 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])
```
---
@@ -197,8 +429,18 @@ Build type is resolved in priority order:
| Priority | Source | Example |
|----------|---------------------|-----------------------------------|
| 1 | Explicit property | `-Pkonfig.buildtype=DEBUG` |
| 2 | Task-name detection | Running `assembleDebug` → `DEBUG` |
| — | Default | `RELEASE` |
| 2 | Android variant metadata, or task names for JVM/KMP | A debuggable Android variant → `DEBUG` |
| - | Default | `RELEASE` |
Android application/library variants generate separate objects, so aggregate
tasks and simultaneous debug/release builds are supported. Custom Android build
types use their `debuggable` setting.
JVM/KMP projects share one object. Conflicting debug/release task requests fail;
run separate builds or set `-Pkonfig.buildtype` explicitly. For aggregate/custom
tasks whose names contain no selection, use explicit build-type/dimension
properties when a result other than the defaults is required. KMP keeps this
shared behavior in `commonMain`, including when it has an Android target.
---
@@ -208,20 +450,20 @@ Build type is resolved in priority order:
|---------------------------------------------|--------------------------------------------------|
| `-Pkonfig.buildtype=DEBUG\|RELEASE` | Forces build type |
| `-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.flavordetection=false` | Disables task-name dimension-variant detection |
### `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
./gradlew generateKonfig -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.
---
@@ -230,17 +472,51 @@ The flag is presence-based — any value (or no value) enables it.
```kotlin
import com.example.app.BuildKonfig
println(BuildKonfig.BUILD_TYPE) // "debug" or "release"
println(BuildKonfig.IS_DEBUG) // true (debug) or false (release)
println(BuildKonfig.Env.BASE_URL) // dimension field
println(BuildKonfig.Env.VARIANT) // "dev" or "prod"
println(BuildKonfig.BUILD_TYPE) // "debug" or "release"
println(BuildKonfig.IS_DEBUG) // true (debug) or false (release)
println(BuildKonfig.Env.BASE_URL) // dimension field
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) |
These queries describe one project-wide selection. Android generation uses
each variant's metadata, so use `androidComponents.onVariants` for decisions
that must vary within a simultaneous Android build. A single `isDebug` query
cannot represent both debug and release; conflicting task-name selections fail
unless an explicit project-wide build type is supplied.
---
## 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
konfig {
@@ -249,7 +525,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.
---
@@ -259,25 +535,65 @@ The generated directory (`build/generated/konfig/`) is automatically added as a
- `org.jetbrains.kotlin.multiplatform` → `commonMain`
- `org.jetbrains.kotlin.jvm` → `main`
- `org.jetbrains.kotlin.android` → `main`
- `com.android.application` / `com.android.library` → `main`
- `com.android.application` / `com.android.library` → one generated directory per
variant, including projects using `org.jetbrains.kotlin.android` or AGP built-in Kotlin
The `generateKonfig` task is automatically wired as a dependency of all `compileKotlin*` and `sourcesJar` tasks.
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.
Source directories carry the generating task dependency. Android uses
`addGeneratedSourceDirectory`; JVM/KMP source sets use task-backed directory
providers. Compilation, lint, and source publication therefore receive generated
sources without task-name dependency heuristics.
On Android, tasks such as `generateProdDebugKonfig` write beneath
`build/generated/konfig/prodDebug/`. `generateKonfig` runs all variant generators,
and `konfigInfo` runs all variant logging tasks. On JVM/KMP, these retain their
single-object behavior.
`outputDir` remains configurable. The generator and build cache own only the
generated file and an ownership record under `build/konfig-state`; other files in
the source directory are preserved. Renaming the object/package removes its
previous generated file. Validation completes before files are replaced, and an
existing file without the generator header is never overwritten.
---
## Build
## Publishing (plugin development)
```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
# Run all tests
./gradlew check
# Run unit tests only
./gradlew test
# Run only functional tests
# Run functional tests (Gradle TestKit - starts real Gradle builds)
./gradlew functionalTest
# Run a specific functional test
./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
```
+27 -5
View File
@@ -46,7 +46,9 @@ configurations[functionalTest.implementationConfigurationName]
dependencies {
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"))
add("functionalTestImplementation", gradleTestKit())
}
@@ -56,6 +58,11 @@ val functionalTestTask = tasks.register<Test>("functionalTest") {
group = "verification"
testClassesDirs = functionalTest.output.classesDirs
classpath = functionalTest.runtimeClasspath
listOf("kotlinVersion", "androidVersion").forEach { setting ->
providers.gradleProperty("konfig.test.$setting").orNull?.let {
systemProperty("konfig.test.$setting", it)
}
}
}
tasks.check { dependsOn(functionalTestTask) }
@@ -69,11 +76,16 @@ fun prop(name: String): String? =
?: System.getenv(name.replace('.', '_').uppercase())
publishing {
publications {
create<MavenPublication>("pluginMaven") {
groupId = project.group.toString()
// The `kotlin-dsl` + `gradlePlugin {}` combo automatically creates two publications:
// - "pluginMaven" → the real implementation jar (groupId:artifactId:version)
// - "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()
version = project.version.toString()
pom {
name = providers.gradleProperty("konfig.pom.name").get()
@@ -115,5 +127,15 @@ publishing {
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")
}
}
}
}
+6 -6
View File
@@ -7,9 +7,9 @@ org.gradle.configuration-cache=true
# MARK: Publishing
# =========================================================
konfig.version=0.5.0
konfig.version=0.7.0
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
# =========================================================
# MARK: POM
@@ -17,15 +17,15 @@ konfig.publish.url=https://maven.pkg.github.com/bitsycore/bitsykonfig-gradle-plu
konfig.pom.name=BuildKonfig Gradle Plugin
konfig.pom.description=Generates a BuildKonfig Kotlin object at build time - like Android BuildConfig, but for any Kotlin project (JVM, Multiplatform, Android).
konfig.pom.url=https://github.com/bitsycore/bitsykonfig-gradle-plugin
konfig.pom.url=https://github.com/bitsycore/bitsykonfig
konfig.pom.developer.id=bitsycore
konfig.pom.developer.name=bitsycore
konfig.pom.developer.url=https://github.com/bitsycore
konfig.pom.scm.connection=scm:git:git://github.com/bitsycore/bitsykonfig-gradle-plugin.git
konfig.pom.scm.developerConnection=scm:git:ssh://github.com/bitsycore/bitsykonfig-gradle-plugin.git
konfig.pom.scm.url=https://github.com/bitsycore/bitsykonfig-gradle-plugin
konfig.pom.scm.connection=scm:git:git://github.com/bitsycore/bitsykonfig.git
konfig.pom.scm.developerConnection=scm:git:ssh://github.com/bitsycore/bitsykonfig.git
konfig.pom.scm.url=https://github.com/bitsycore/bitsykonfig
# =========================================================
# MARK: Credentials
Vendored Regular → Executable
View File
@@ -0,0 +1,160 @@
package com.bitsycore.konfig
import org.gradle.testkit.runner.TaskOutcome
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class CollectionsFunctionalTest : FunctionalTestBase() {
@Test fun `collections compile and preserve values including empty nullable and nested types`() = withProject { dir, run ->
dir.writeCompilingProject("""
konfig {
field("WORDS", listOf("quote\"", "line\n", "\\path"))
field("EMPTY_LIST", emptyList<String>())
field("EMPTY_MAP", emptyMap<String, Int>())
field("NESTED", mapOf("items" to listOf(1, 2)))
field("NULLS", listOf<String?>(null, "present"))
field("MIXED", listOf<Any>("text", 42, true, listOf(1), (-128).toByte(), 7.toShort()))
field("KEYS", mapOf(1 to "one", 2 to "two"))
field("STRINGS", arrayOf("a", "b"))
field("EMPTY_ARRAY", emptyArray<String>())
field("BOXED", arrayOf(1, 2))
field("NULL_ARRAY", arrayOf<Int?>(1, null))
field("ARRAYS", listOf(arrayOf(1, 2)))
field("MIN", Long.MIN_VALUE)
}
""", """
import com.example.BuildKonfig as K
fun main() {
check(K.WORDS == listOf("quote\"", "line\n", "\\path"))
val emptyList: List<String> = K.EMPTY_LIST
val emptyMap: Map<String, Int> = K.EMPTY_MAP
check(emptyList.isEmpty() && emptyMap.isEmpty())
check(K.NESTED == mapOf("items" to listOf(1, 2)))
check(K.NULLS == listOf(null, "present"))
check(K.MIXED == listOf("text", 42, true, listOf(1), (-128).toByte(), 7.toShort()))
check(K.KEYS == mapOf(1 to "one", 2 to "two"))
check(K.STRINGS.contentEquals(arrayOf("a", "b")))
val emptyArray: Array<String> = K.EMPTY_ARRAY
check(emptyArray.isEmpty())
val optimized: IntArray = K.BOXED
check(optimized.contentEquals(intArrayOf(1, 2)))
check(K.NULL_ARRAY.contentEquals(arrayOf<Int?>(1, null)))
val nested: List<IntArray> = K.ARRAYS
check(nested.single().contentEquals(intArrayOf(1, 2)))
check(K.MIN == Long.MIN_VALUE)
}
""")
assertEquals(TaskOutcome.SUCCESS, run(listOf("verifyGenerated")).task(":verifyGenerated")?.outcome)
}
@Test fun `all primitive arrays compile with primitive factories and retain boundary values`() = withProject { dir, run ->
dir.writeCompilingProject("""
konfig {
field("BOOLS", booleanArrayOf(true, false))
field("BYTES", byteArrayOf(-128, 127))
field("SHORTS", shortArrayOf(-32768, 32767))
field("CHARS", charArrayOf('\'', '\\', '\n', '\u0000', '\uD800'))
field("INTS", intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE))
field("LONGS", longArrayOf(Long.MIN_VALUE, Long.MAX_VALUE))
field("FLOATS", floatArrayOf(Float.NaN, Float.POSITIVE_INFINITY, -0.0f))
field("DOUBLES", doubleArrayOf(Double.NaN, Double.NEGATIVE_INFINITY, -0.0))
field("EMPTY", intArrayOf())
}
""", """
import com.example.BuildKonfig as K
fun main() {
check(K.BOOLS.contentEquals(booleanArrayOf(true, false)))
check(K.BYTES.contentEquals(byteArrayOf(-128, 127)))
check(K.SHORTS.contentEquals(shortArrayOf(-32768, 32767)))
check(K.CHARS.contentEquals(charArrayOf('\'', '\\', '\n', '\u0000', '\uD800')))
check(K.INTS.contentEquals(intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE)))
check(K.LONGS.contentEquals(longArrayOf(Long.MIN_VALUE, Long.MAX_VALUE)))
check(K.FLOATS.contentEquals(floatArrayOf(Float.NaN, Float.POSITIVE_INFINITY, -0.0f)))
check(K.DOUBLES.contentEquals(doubleArrayOf(Double.NaN, Double.NEGATIVE_INFINITY, -0.0)))
check(K.EMPTY.isEmpty())
}
""")
run(listOf("verifyGenerated"))
val text = dir.resolve("build/generated/konfig/com/example/BuildKonfig.kt").readText()
listOf("boolean", "byte", "short", "char", "int", "long", "float", "double").forEach {
assertTrue(text.contains("${it}ArrayOf("), text)
}
}
@Test fun `collections support handles scopes common fields and flat dimensions`() = withProject { dir, run ->
dir.writeCompilingProject("""
konfig {
field("GLOBAL", listOf("release")).debug(listOf("debug"))
release { field("SCOPED", mapOf("mode" to "release")) }
debug { field("SCOPED", mapOf("mode" to "debug")) }
dimension("env", defaultTo = "prod") {
common { field("FALLBACK", listOf(1, 2)) }
variant("prod") { field("HOSTS", arrayOf("prod")) }
}
flatDimension("region", defaultTo = "eu") {
common { field("REGIONS", mapOf("id" to 1)) }
variant("eu") { field("PORTS", intArrayOf(443)) }
}
}
""", """
import com.example.BuildKonfig as K
fun main() {
val mode = if (K.IS_DEBUG) "debug" else "release"
check(K.GLOBAL == listOf(mode))
check(K.SCOPED == mapOf("mode" to mode))
check(K.Env.FALLBACK == listOf(1, 2))
check(K.Env.HOSTS.contentEquals(arrayOf("prod")))
check(K.REGIONS == mapOf("id" to 1))
check(K.PORTS.contentEquals(intArrayOf(443)))
}
""")
run(listOf("verifyGenerated", "-Pkonfig.buildtype=DEBUG"))
run(listOf("verifyGenerated", "-Pkonfig.buildtype=RELEASE"))
}
@Test fun `provider collections reuse configuration cache and react to changed inputs`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
konfig {
objectPackage = "com.example"
field("VALUES", providers.gradleProperty("items").map { it.split(",") })
field("MISSING", providers.gradleProperty("missing").map { listOf(it) })
field("PORTS", providers.gradleProperty("port").map { intArrayOf(it.toInt()) })
field("MAPPING", providers.gradleProperty("items").map { mapOf(it to listOf(1, 2)) })
field("LITERAL", mapOf("nested" to listOf(arrayOf(1, 2))))
}
""")
val args = listOf("generateKonfig", "--configuration-cache", "-Pitems=a,b", "-Pport=443")
run(args)
val second = run(args)
assertTrue(second.output.contains("Configuration cache entry reused"), second.output)
assertEquals(TaskOutcome.UP_TO_DATE, second.task(":generateKonfig")?.outcome)
val changed = run(args.filterNot { it.startsWith("-Pitems=") } + "-Pitems=c")
assertEquals(TaskOutcome.SUCCESS, changed.task(":generateKonfig")?.outcome)
val text = dir.resolve("build/generated/konfig/com/example/BuildKonfig.kt").readText()
assertTrue(text.contains("listOf<String>(\"c\")"))
assertFalse(text.contains("MISSING"))
}
@Test fun `unsupported collection element type fails with useful error`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
konfig { field("FILES", listOf(java.io.File("example"))) }
""")
assertTrue(run(listOf("generateKonfig")).output.contains("unsupported field type"))
}
@Test fun `scope override cannot change a collection element type`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
konfig {
field("VALUES", listOf(1))
debug { field("VALUES", listOf("one")) }
}
""")
assertTrue(run(listOf("generateKonfig")).output.contains("must use the same type"))
}
}
@@ -7,7 +7,7 @@ import kotlin.test.assertTrue
/**
* 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.
*/
class CommonBlockFunctionalTest : FunctionalTestBase() {
@@ -0,0 +1,198 @@
package com.bitsycore.konfig
import java.io.File
import java.net.URLClassLoader
import java.util.zip.ZipFile
import java.util.Properties
import java.util.jar.JarEntry
import java.util.jar.JarOutputStream
import org.gradle.testkit.runner.TaskOutcome
import org.gradle.testkit.runner.GradleRunner
import org.junit.Assume.assumeTrue
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class ConsumerIntegrationFunctionalTest : FunctionalTestBase() {
private val kotlinVersion = System.getProperty("konfig.test.kotlinVersion", "2.3.0")
private val androidVersion = System.getProperty("konfig.test.androidVersion", "9.3.2")
@Test fun `JVM compilation and source publication carry generation dependencies`() = withProject { dir, _ ->
val run = consumerRunner(dir)
dir.consumerSettings()
dir.resolve("src/main/kotlin/Use.kt").apply { parentFile.mkdirs(); writeText("val value = com.example.BuildKonfig.VALUE") }
dir.writeConsumerBuildGradle("""
plugins {
kotlin("jvm") version "$kotlinVersion"
id("com.bitsycore.konfig")
}
repositories { mavenCentral() }
java { withSourcesJar() }
konfig { objectPackage = "com.example"; field("VALUE", listOf("ok")) }
""")
val args = listOf("compileKotlin", "sourcesJar", "--configuration-cache")
assertEquals(TaskOutcome.SUCCESS, run(args).task(":generateKonfig")?.outcome)
val second = run(args)
assertTrue(second.output.contains("Configuration cache entry reused"), second.output)
assertEquals(TaskOutcome.UP_TO_DATE, second.task(":generateKonfig")?.outcome)
ZipFile(dir.resolve("build/libs/test-project-sources.jar")).use { jar ->
assertNotNull(jar.getEntry("com/example/BuildKonfig.kt"))
}
run(listOf("clean"))
assertNotNull(run(listOf("sourcesJar")).task(":generateKonfig"))
}
@Test fun `KMP commonMain compilation and source publication carry generation dependencies`() = withProject { dir, _ ->
val run = consumerRunner(dir)
dir.consumerSettings()
dir.resolve("src/commonMain/kotlin/Use.kt").apply { parentFile.mkdirs(); writeText("val value = com.example.BuildKonfig.VALUE") }
dir.writeConsumerBuildGradle("""
plugins {
kotlin("multiplatform") version "$kotlinVersion"
id("com.bitsycore.konfig")
}
repositories { mavenCentral() }
kotlin { jvm() }
konfig { objectPackage = "com.example"; field("VALUE", 42) }
""")
val args = listOf("compileKotlinJvm", "allMetadataJar", "jvmSourcesJar", "--configuration-cache")
assertNotNull(run(args).task(":generateKonfig"))
assertTrue(run(args).output.contains("Configuration cache entry reused"))
}
@Test fun `Android variants compile independently and wire lint and sources from a clean build`() = withProject { dir, _ ->
val run = consumerRunner(dir, android = true)
val sdk = sequenceOf(System.getenv("ANDROID_HOME"), System.getenv("ANDROID_SDK_ROOT"),
"${System.getProperty("user.home")}/AppData/Local/Android/Sdk").filterNotNull().map(::File)
.firstOrNull { it.resolve("platforms/android-35/android.jar").isFile }
assumeTrue("Android SDK platform 35 is required for Android integration tests", sdk != null)
dir.consumerSettings()
dir.resolve("local.properties").writeText("sdk.dir=${sdk!!.invariantSeparatorsPath}")
dir.resolve("src/main/AndroidManifest.xml").apply { parentFile.mkdirs(); writeText("<manifest />") }
dir.resolve("src/main/kotlin/Use.kt").apply { parentFile.mkdirs(); writeText("val value = com.example.BuildKonfig.Env.URL") }
val externalKotlin = if (androidVersion.startsWith("8.")) "kotlin(\"android\") version \"$kotlinVersion\"" else ""
dir.writeConsumerBuildGradle("""
plugins {
id("com.android.library") version "$androidVersion"
$externalKotlin
id("com.bitsycore.konfig")
}
repositories { google(); mavenCentral() }
android {
namespace = "com.example"
compileSdk = 35
defaultConfig { minSdk = 21 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
flavorDimensions += "environment"
productFlavors {
create("prod") { dimension = "environment" }
create("preProd") { dimension = "environment" }
}
publishing { singleVariant("prodRelease") { withSourcesJar() } }
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile>().configureEach {
compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
konfig {
objectPackage = "com.example"
dimension("env") {
androidDimension = "environment"
required = true
variant("prod") { field("URL", "production") }
variant("preProd") { field("URL", "preproduction") }
}
}
""")
val args = listOf("assembleProdDebug", "assemblePreProdRelease", "--configuration-cache")
val first = run(args)
assertNotNull(first.task(":generateProdDebugKonfig"))
assertNotNull(first.task(":generatePreProdReleaseKonfig"))
assertEquals(null, first.task(":generateKonfig"))
dir.assertAndroidConstants("prod-debug", "debug", "prod", "production")
dir.assertAndroidConstants("preProd-release", "release", "preProd", "preproduction")
assertTrue(run(args).output.contains("Configuration cache entry reused"))
run(listOf("clean"))
val analysis = run(listOf("lintProdDebug", "sourceProdReleaseJar"))
assertNotNull(analysis.task(":generateProdDebugKonfig"))
assertNotNull(analysis.task(":generateProdReleaseKonfig"))
val sourceJars = dir.resolve("build").walkTopDown().filter { it.isFile && it.name.endsWith("sources.jar") }.toList()
assertTrue(sourceJars.any { file -> ZipFile(file).use { it.getEntry("com/example/BuildKonfig.kt") != null } })
val aggregate = run(listOf("generateKonfig", "assembleProdDebug", "assemblePreProdRelease", "--configuration-cache"))
listOf("ProdDebug", "ProdRelease", "PreProdDebug", "PreProdRelease").forEach {
assertNotNull(aggregate.task(":generate${it}Konfig"))
}
val explicitArgs = listOf("generateProdDebugKonfig", "-Pkonfig.dimension.env=preProd", "--configuration-cache")
run(explicitArgs)
assertTrue(run(explicitArgs).output.contains("Configuration cache entry reused"))
assertTrue(dir.resolve("build/generated/konfig/prodDebug/com/example/BuildKonfig.kt").readText()
.contains("const val VARIANT: String = \"preProd\""))
dir.resolve("konfig.properties").writeText("konfig.dimension.env=prod")
run(listOf("generatePreProdReleaseKonfig"))
assertTrue(dir.resolve("build/generated/konfig/preProdRelease/com/example/BuildKonfig.kt").readText()
.contains("const val VARIANT: String = \"prod\""))
}
private fun File.consumerSettings() {
writePluginRepository()
resolve("settings.gradle.kts").writeText("""
pluginManagement { repositories { maven { url = uri("repo") }; google(); mavenCentral(); gradlePluginPortal() } }
rootProject.name = "test-project"
""".trimIndent())
}
// Resolve a real plugin artifact instead of TestKit's isolated injected classloader.
private fun consumerRunner(dir: File, android: Boolean = false): (List<String>) -> org.gradle.testkit.runner.BuildResult = { args ->
val runner = GradleRunner.create().withProjectDir(dir).withArguments(args + "--stacktrace")
if (android && !androidVersion.startsWith("8.")) runner.withGradleVersion("9.5.0")
runner.build()
}
private fun File.writeConsumerBuildGradle(script: String) {
writeBuildGradle(script.replace("id(\"com.bitsycore.konfig\")", "id(\"com.bitsycore.konfig\") version \"0.0-test\""))
}
private fun File.writePluginRepository() {
val metadata = Properties().apply {
this@ConsumerIntegrationFunctionalTest.javaClass.classLoader
.getResourceAsStream("plugin-under-test-metadata.properties")!!.use { load(it) }
}
val artifact = resolve("repo/com/bitsycore/test-plugin/0.0-test").apply { mkdirs() }
JarOutputStream(artifact.resolve("test-plugin-0.0-test.jar").outputStream()).use { jar ->
metadata.getProperty("implementation-classpath").split(File.pathSeparator).map(::File).filter { it.isDirectory }.forEach { root ->
root.walkTopDown().filter { it.isFile }.forEach { file ->
jar.putNextEntry(JarEntry(file.relativeTo(root).invariantSeparatorsPath))
file.inputStream().use { it.copyTo(jar) }
jar.closeEntry()
}
}
}
val pomPrefix = "<project><modelVersion>4.0.0</modelVersion>"
artifact.resolve("test-plugin-0.0-test.pom").writeText(
"$pomPrefix<groupId>com.bitsycore</groupId><artifactId>test-plugin</artifactId><version>0.0-test</version></project>")
val marker = resolve("repo/com/bitsycore/konfig/com.bitsycore.konfig.gradle.plugin/0.0-test").apply { mkdirs() }
marker.resolve("com.bitsycore.konfig.gradle.plugin-0.0-test.pom").writeText("""
$pomPrefix<groupId>com.bitsycore.konfig</groupId><artifactId>com.bitsycore.konfig.gradle.plugin</artifactId>
<version>0.0-test</version><packaging>pom</packaging><dependencies><dependency>
<groupId>com.bitsycore</groupId><artifactId>test-plugin</artifactId><version>0.0-test</version>
</dependency></dependencies></project>
""".trimIndent())
}
private fun File.assertAndroidConstants(variant: String, buildType: String, flavor: String, url: String) {
val aar = resolve("build/outputs/aar/test-project-$variant.aar")
assertTrue(aar.isFile, aar.path)
val classes = resolve("$variant-classes.jar")
ZipFile(aar).use { archive -> classes.writeBytes(archive.getInputStream(archive.getEntry("classes.jar")).readBytes()) }
URLClassLoader(arrayOf(classes.toURI().toURL()), javaClass.classLoader).use { loader ->
val root = loader.loadClass("com.example.BuildKonfig")
val env = loader.loadClass("com.example.BuildKonfig\$Env")
assertEquals(buildType, root.getField("BUILD_TYPE").get(null))
assertEquals(flavor, env.getField("VARIANT").get(null))
assertEquals(url, env.getField("URL").get(null))
}
}
}
@@ -73,7 +73,7 @@ class DuplicateDetectionFunctionalTest : FunctionalTestBase() {
}
@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("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
@@ -106,7 +106,7 @@ class DuplicateDetectionFunctionalTest : FunctionalTestBase() {
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 ->
dir.writeBuildGradle("""
@@ -121,7 +121,7 @@ class DuplicateDetectionFunctionalTest : FunctionalTestBase() {
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 ->
dir.writeBuildGradle("""
@@ -0,0 +1,164 @@
package com.bitsycore.konfig
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import org.gradle.testkit.runner.TaskOutcome
class FeatureOptionsFunctionalTest : FunctionalTestBase() {
@Test fun `strict and required selections reject missing and unknown values`() = withProject { dir, run ->
fun script(setting: String) = """
plugins { id("com.bitsycore.konfig") }
konfig {
$setting
dimension("env") { variant("prod") { field("URL", "ok") } }
}
"""
dir.writeBuildGradle(script("strictResolution = true"))
assertTrue(runner(dir, listOf("generateKonfig")).buildAndFail().output.contains("selection is required"))
assertTrue(runner(dir, listOf("generateKonfig", "-Pkonfig.dimension.env=typo")).buildAndFail().output.contains("not a known variant"))
assertTrue(runner(dir, listOf("generateKonfig", "-Pkonfig.dimension.env=prod", "-Pkonfig.dimension.typo=prod")).buildAndFail().output.contains("unknown dimension"))
assertTrue(runner(dir, listOf("generateKonfig", "-Pkonfig.buildtype=typo")).buildAndFail().output.contains("unknown build type"))
val args = listOf("generateKonfig", "-Pkonfig.dimension.env=prod", "--configuration-cache")
run(args)
assertTrue(run(args).output.contains("Configuration cache entry reused"))
dir.resolve("konfig.properties").writeText("konfig.dimension.env=typo")
assertTrue(runner(dir, listOf("generateKonfig")).buildAndFail().output.contains("not a known variant"))
run(args) // explicit selection overrides the invalid file value
dir.resolve("konfig.properties").writeText("")
dir.writeBuildGradle(script("").replace("dimension(\"env\") {", "dimension(\"env\") { required = true;"))
assertTrue(runner(dir, listOf("generateKonfig")).buildAndFail().output.contains("selection is required"))
dir.writeBuildGradle(script(""))
run(listOf("generateKonfig")) // optional remains the default
}
@Test fun `schema validation includes inactive variants common fields and build types`() = withProject { dir, run ->
val base = """
plugins { id("com.bitsycore.konfig") }
konfig {
validateVariantSchema = true
dimension("env", defaultTo = "prod") {
common { field("SHARED", 1) }
variant("prod") { field("URL", "ok") }
variant("dev") { REPLACEMENT }
}
}
"""
for (replacement in listOf("", "field(\"URL\", 42)", "debug { field(\"URL\", \"dev\") }")) {
dir.writeBuildGradle(base.replace("REPLACEMENT", replacement))
val failed = runner(dir, listOf("generateKonfig")).buildAndFail()
assertTrue(failed.output.contains("schema"), failed.output)
}
dir.writeBuildGradle(base.replace("REPLACEMENT", "field(\"URL\", \"dev\")"))
run(listOf("generateKonfig", "--configuration-cache"))
assertTrue(run(listOf("generateKonfig", "--configuration-cache")).output.contains("Configuration cache entry reused"))
dir.writeBuildGradle(base.replace("REPLACEMENT", "field(\"URL\", providers.gradleProperty(\"devUrl\"))"))
assertTrue(runner(dir, listOf("generateKonfig")).buildAndFail().output.contains("missing=[URL]"))
run(listOf("generateKonfig", "-PdevUrl=dev", "--configuration-cache"))
dir.writeBuildGradle(base.replace("REPLACEMENT", "").replace("validateVariantSchema = true", "validateVariantSchema = false"))
run(listOf("generateKonfig"))
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
konfig { validateVariantSchema = true; debug { field("ONLY_DEBUG", 1) } }
""")
assertTrue(runner(dir, listOf("generateKonfig")).buildAndFail().output.contains("global fields differ"))
}
@Test fun `nullable set enum and unsigned values compile and survive configuration caching`() = withProject { dir, run ->
dir.writeCompilingProject("""
konfig {
field<String?>("NULL_TEXT", null)
field<Int?>("NUMBER", 7).debug(null)
field<String?>("PROVIDED", providers.provider { "provided" })
field<String?>("ABSENT", providers.gradleProperty("missing"))
field("CONST_TEXT", providers.provider { "constant" })
field("INLINE_FLAG", true)
field<Set<String?>>("NAMES", setOf("a", null, "a"))
field("EMPTY", emptySet<Int>())
field("DAY", java.time.DayOfWeek.MONDAY)
field("DAYS", setOf(java.time.DayOfWeek.MONDAY, java.time.DayOfWeek.FRIDAY))
field("UB", UByte.MAX_VALUE)
field("US", UShort.MAX_VALUE)
field("UI", UInt.MAX_VALUE)
field("UL", ULong.MAX_VALUE)
field("UNSIGNED", listOf(0uL, ULong.MAX_VALUE))
dimension("env", defaultTo = "prod") {
common { field<String?>("OPTIONAL", null) }
variant("prod") { debug { field<List<Int>?>("ITEMS", null) } }
}
}
""", """
import com.example.BuildKonfig
const val copiedConstant: String = BuildKonfig.CONST_TEXT
fun main() {
check(copiedConstant == "constant" && BuildKonfig.INLINE_FLAG)
check(BuildKonfig.NULL_TEXT == null)
check(BuildKonfig.NUMBER == null)
check(BuildKonfig.PROVIDED == "provided")
check(BuildKonfig.NAMES == setOf("a", null))
check(BuildKonfig.EMPTY.isEmpty())
check(BuildKonfig.DAY == java.time.DayOfWeek.MONDAY)
check(BuildKonfig.DAYS.contains(java.time.DayOfWeek.FRIDAY))
check(BuildKonfig.UB == UByte.MAX_VALUE && BuildKonfig.US == UShort.MAX_VALUE)
check(BuildKonfig.UI == UInt.MAX_VALUE && BuildKonfig.UL == ULong.MAX_VALUE)
check(BuildKonfig.UNSIGNED.last() == ULong.MAX_VALUE)
check(BuildKonfig.Env.OPTIONAL == null && BuildKonfig.Env.ITEMS == null)
}
""")
val args = listOf("verifyGenerated", "-Pkonfig.buildtype=DEBUG", "--configuration-cache")
run(args)
assertTrue(!dir.generatedFile().readText().contains("ABSENT"))
assertTrue(dir.generatedFile().readText().contains("inline val INLINE_FLAG: Boolean get() = true"))
val reused = run(args)
assertTrue(reused.output.contains("Configuration cache entry reused"), reused.output)
assertEquals(TaskOutcome.UP_TO_DATE, reused.task(":generateKonfig")?.outcome)
}
@Test fun `boxed arrays and fresh nested arrays preserve declared runtime types`() = withProject { dir, run ->
dir.writeCompilingProject("""
konfig {
specializeArrays = false
copyArraysOnAccess = true
field("BOXED", arrayOf(1, 2))
field("PRIMITIVE", intArrayOf(3, 4))
field("NESTED", mapOf("items" to listOf(arrayOf(5))))
field("DYNAMIC", listOf<Any>(intArrayOf(6)))
field<Array<Int>?>("NULL_ARRAY", null)
}
""", """
import com.example.BuildKonfig
fun main() {
val boxed: Array<Int> = BuildKonfig.BOXED
boxed[0] = 99
check(BuildKonfig.BOXED[0] == 1)
val primitive: IntArray = BuildKonfig.PRIMITIVE
primitive[0] = 99
check(BuildKonfig.PRIMITIVE[0] == 3)
BuildKonfig.NESTED.getValue("items")[0][0] = 99
check(BuildKonfig.NESTED.getValue("items")[0][0] == 5)
check(BuildKonfig.DYNAMIC[0] is IntArray)
check(BuildKonfig.NULL_ARRAY == null)
}
""")
run(listOf("verifyGenerated", "--configuration-cache"))
assertTrue(run(listOf("verifyGenerated", "--configuration-cache")).output.contains("Configuration cache entry reused"))
}
@Test fun `array policy changes invalidate generation while defaults preserve shared arrays`() = withProject { dir, run ->
fun script(settings: String) = """
plugins { id("com.bitsycore.konfig") }
konfig {
$settings
field("VALUES", arrayOf(1, 2))
}
"""
val args = listOf("generateKonfig", "--configuration-cache")
dir.writeBuildGradle(script(""))
run(args)
assertTrue(dir.generatedFile().readText().contains("val VALUES: IntArray = intArrayOf(1, 2)"))
dir.writeBuildGradle(script("specializeArrays = false; copyArraysOnAccess = true"))
assertEquals(TaskOutcome.SUCCESS, run(args).task(":generateKonfig")?.outcome)
assertTrue(dir.generatedFile().readText().contains("val VALUES: Array<Int> get() = arrayOf<Int>(1, 2)"))
assertEquals(TaskOutcome.UP_TO_DATE, run(args).task(":generateKonfig")?.outcome)
}
}
@@ -6,7 +6,7 @@ import kotlin.test.assertTrue
/**
* 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).
*/
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,12 +9,15 @@ import kotlin.io.path.createTempDirectory
* Base class for all functional tests.
*
* Provides:
* - [withProject] — creates a temp Gradle project, runs it, then cleans up.
* - [File.generatedFile] — walks the output dir to find the generated `.kt` file.
* - [File.writeBuildGradle] — shorthand for writing a `build.gradle.kts`.
* - [withProject] - creates a temp Gradle project, runs it, then cleans up.
* - [File.generatedFile] - walks the output dir to find the generated `.kt` file.
* - [File.writeBuildGradle] - shorthand for writing a `build.gradle.kts`.
*/
abstract class FunctionalTestBase {
protected fun runner(projectDir: File, args: List<String>): GradleRunner = GradleRunner.create()
.withProjectDir(projectDir).withPluginClasspath().withArguments(args + "--stacktrace")
/**
* Creates a fresh temp project directory, invokes [block] with it, then deletes it.
*
@@ -27,11 +30,7 @@ abstract class FunctionalTestBase {
projectDir.resolve("settings.gradle.kts")
.writeText("""rootProject.name = "test-project"""")
val run = { args: List<String> ->
GradleRunner.create()
.withProjectDir(projectDir)
.withPluginClasspath()
.withArguments(args)
.build()
runner(projectDir, args).build()
}
block(projectDir, run)
} finally {
@@ -41,7 +40,7 @@ abstract class FunctionalTestBase {
/**
* 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) {
val projectDir = createTempDirectory("konfig-ft-fail").toFile()
@@ -49,11 +48,7 @@ abstract class FunctionalTestBase {
projectDir.resolve("settings.gradle.kts")
.writeText("""rootProject.name = "test-project"""")
val run = { args: List<String> ->
GradleRunner.create()
.withProjectDir(projectDir)
.withPluginClasspath()
.withArguments(args)
.buildAndFail()
runner(projectDir, args).buildAndFail()
}
block(projectDir, run)
} finally {
@@ -63,9 +58,35 @@ abstract class FunctionalTestBase {
/** Finds the single generated `.kt` file inside the project's output tree. */
protected fun File.generatedFile(): File =
walkTopDown().first { it.isFile && it.extension == "kt" }
resolve("build/generated/konfig").walkTopDown().first { it.isFile && it.extension == "kt" }
/** Writes a minimal `build.gradle.kts` applying the konfig plugin. */
protected fun File.writeBuildGradle(content: String) =
resolve("build.gradle.kts").writeText(content.trimIndent())
/** Compile and execute the generated code using the compiler bundled with TestKit's Gradle. */
protected fun File.writeCompilingProject(dsl: String, assertions: String) {
resolve("Check.kt").writeText(assertions.trimIndent())
writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
konfig { objectPackage = "com.example" }
${dsl.trimIndent()}
val compilerLib = gradle.gradleHomeDir!!.resolve("lib")
val stdlib = compilerLib.listFiles()!!.first { it.name.startsWith("kotlin-stdlib-") }
val generated = layout.buildDirectory.dir("generated/konfig")
val classes = layout.buildDirectory.dir("verified-classes")
val compileGenerated = tasks.register<JavaExec>("compileGenerated") {
dependsOn("generateKonfig")
classpath = files(fileTree(compilerLib) { include("*.jar") })
mainClass.set("org.jetbrains.kotlin.cli.jvm.K2JVMCompiler")
args("-no-stdlib", "-no-reflect", "-classpath", stdlib.absolutePath,
"-d", classes.get().asFile.absolutePath,
generated.get().asFile.absolutePath, file("Check.kt").absolutePath)
}
tasks.register<JavaExec>("verifyGenerated") {
dependsOn(compileGenerated)
classpath = files(classes, stdlib)
mainClass.set("CheckKt")
}
""")
}
}
@@ -6,7 +6,7 @@ import kotlin.test.assertTrue
/**
* 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.
*/
class GlobalFieldsFunctionalTest : FunctionalTestBase() {
@@ -0,0 +1,147 @@
package com.bitsycore.konfig
import org.gradle.testkit.runner.TaskOutcome
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SafetyFunctionalTest : FunctionalTestBase() {
@Test fun `metadata escaping produces compilable Kotlin and preserves values`() = withProject { dir, run ->
dir.resolve("settings.gradle.kts").writeText("rootProject.name = \"module\\\$value\"")
dir.writeCompilingProject("""
val selected = "qa\"\\path\n${'$'}{'$'}value"
konfig {
dimension("env*/comment", objectNameOverride = "Env", defaultTo = selected) {
variant(selected) { field("VALUE", "ok") }
}
flatDimension("region", defaultTo = selected) { variant(selected) {} }
}
""", """
import com.example.BuildKonfig as K
fun main() {
check(K.MODULE_NAME == "module${'$'}{'$'}value")
check(K.Env.VARIANT == "qa\"\\path\n${'$'}{'$'}value")
check(K.REGION_VARIANT == K.Env.VARIANT)
}
""")
run(listOf("verifyGenerated"))
}
@Test fun `invalid Kotlin names fail before writing sources`() = withFailingProject { dir, run ->
val cases = listOf(
"objectName = \"when\"", "objectName = \"___\"",
"objectPackage = \"com.123example\"", "objectPackage = \"com.class\"",
"field(\"when\", 1)", "field(\"_\", 1)",
"dimension(\"env\", objectNameOverride = \"Invalid-Name\", defaultTo = \"prod\") { variant(\"prod\") {} }",
"flatDimension(\"123env\", defaultTo = \"prod\") { variant(\"prod\") {} }",
"dimension(\"env|other\", defaultTo = \"prod\") { variant(\"prod\") {} }",
)
for (dsl in cases) {
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
konfig { $dsl }
""")
val result = run(listOf("generateKonfig"))
assertTrue(result.output.contains("not valid") || result.output.contains("invalid") || result.output.contains("not a valid"), result.output)
assertFalse(dir.resolve("build/generated/konfig").walkTopDown().any { it.extension == "kt" })
}
}
@Test fun `all generated scopes reject collisions`() = withFailingProject { dir, run ->
val cases = listOf(
"field(\"BUILD_TYPE\", \"custom\")",
"field(\"MODULE_NAME\", \"custom\")",
"field(\"IS_DEBUG\", false)",
"dimension(\"env\", defaultTo = \"prod\") { variant(\"prod\") { field(\"VARIANT\", \"custom\") } }",
"dimension(\"one\", \"Same\", \"prod\") { variant(\"prod\") {} }; dimension(\"two\", \"Same\", \"prod\") { variant(\"prod\") {} }",
"dimension(\"env\", defaultTo = \"prod\") { variant(\"prod\") {} }; field(\"Env\", 1)",
"dimension(\"env\", defaultTo = \"prod\") { variant(\"prod\") {} }; flatDimension(\"flat\", \"prod\") { variant(\"prod\") { field(\"Env\", 1) } }",
)
for (dsl in cases) {
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
konfig { $dsl }
""")
assertTrue(run(listOf("generateKonfig")).output.contains("collides"))
}
}
@Test fun `shared directory neighbors survive regeneration renaming and cache restoration`() = withProject { dir, run ->
val neighbor = dir.resolve("shared/Keep.kt").apply { parentFile.mkdirs(); writeText("// user source") }
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
konfig {
objectPackage = "com.example"
objectName = providers.gradleProperty("name").get()
outputDir.set(layout.projectDirectory.dir("shared"))
field("VALUE", 12)
}
""")
val args = listOf("generateKonfig", "--build-cache", "--configuration-cache")
run(args + "-Pname=Alpha")
val alpha = dir.resolve("shared/com/example/Alpha.kt")
val beta = dir.resolve("shared/com/example/Beta.kt")
assertTrue(alpha.delete())
assertEquals(TaskOutcome.FROM_CACHE, run(args + "-Pname=Alpha").task(":generateKonfig")?.outcome)
assertEquals("// user source", neighbor.readText())
run(args + "-Pname=Beta")
assertTrue(beta.isFile)
assertFalse(alpha.exists())
run(args + "-Pname=Alpha")
assertTrue(alpha.isFile)
assertFalse(beta.exists())
assertEquals("// user source", neighbor.readText())
}
@Test fun `validation failure preserves the previous generated source`() = withProject { dir, run ->
val script = """
plugins { id("com.bitsycore.konfig") }
konfig { field("VALUE", 1) }
"""
dir.writeBuildGradle(script)
run(listOf("generateKonfig"))
val generated = dir.generatedFile()
val before = generated.readText()
dir.writeBuildGradle(script + """
konfig { flatDimension("env", "prod") { variant("prod") { field("VALUE", 2) } } }
""")
assertTrue(runner(dir, listOf("generateKonfig")).buildAndFail().output.contains("collides"))
assertEquals(before, generated.readText())
}
@Test fun `generation never overwrites an existing user source`() = withFailingProject { dir, run ->
val source = dir.resolve("shared/com/example/BuildKonfig.kt").apply { parentFile.mkdirs(); writeText("// user source") }
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
konfig { objectPackage = "com.example"; outputDir.set(layout.projectDirectory.dir("shared")) }
""")
assertTrue(run(listOf("generateKonfig", "--no-build-cache")).output.contains("refusing to overwrite"))
assertEquals("// user source", source.readText())
}
@Test fun `conflicting shared build types fail but an explicit selection works`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
tasks.register("assembleDebug") { dependsOn("generateKonfig") }
tasks.register("assembleRelease") { dependsOn("generateKonfig") }
""")
val args = listOf("assembleDebug", "assembleRelease")
assertTrue(runner(dir, args).buildAndFail().output.contains("conflicting debug and release"))
run(args + "-Pkonfig.buildtype=DEBUG")
assertTrue(dir.generatedFile().readText().contains("BUILD_TYPE: String = \"debug\""))
}
@Test fun `separate prod and preProd requests conflict instead of longest match winning`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
konfig { dimension("env") { variant("prod") {}; variant("preProd") {} } }
tasks.register("assembleProdRelease") { dependsOn("generateKonfig") }
tasks.register("assemblePreProdRelease") { dependsOn("generateKonfig") }
""")
val args = listOf("assembleProdRelease", "assemblePreProdRelease")
assertTrue(runner(dir, args).buildAndFail().output.contains("conflicting variants"))
run(args + "-Pkonfig.dimension.env=prod")
assertTrue(dir.generatedFile().readText().contains("VARIANT: String = \"prod\""))
}
}
@@ -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)
}
}
@@ -2,16 +2,24 @@ package com.bitsycore.konfig
import com.bitsycore.konfig.types.BuildType
import com.bitsycore.konfig.types.Visibility
import com.bitsycore.konfig.types.isValidKotlinIdentifier
import com.bitsycore.konfig.types.toVariantConstName
import com.bitsycore.konfig.types.toKotlinStringLiteral
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import java.nio.file.Files
import java.nio.file.StandardCopyOption
/**
* Generates the `BuildKonfig` (or custom named) Kotlin object.
@@ -19,6 +27,8 @@ import org.gradle.api.tasks.TaskAction
* Fields are stored in flat [MapProperty]<String, String> maps where each value is
* type-encoded as `"<TYPE>:<literal>"` (e.g. `"String:hello"`, `"Int:42"`).
* Dimension fields use keys of the form `"<dimName>|<fieldName>"`.
* Collections use `"Value:<Kotlin type>\n<initializer>"`, with literals escaped by
* the encoder. Only strings, not reflection types or collection instances, reach task inputs.
* This collapses 6 separate per-type maps down to one per scope.
*/
@CacheableTask
@@ -39,11 +49,10 @@ abstract class GenerateKonfigTask : DefaultTask() {
@get:Input abstract val objectName: Property<String>
@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>"`.
*/
@get:Input abstract val dimensionResolutionLog: MapProperty<String, String>
@@ -61,6 +70,8 @@ abstract class GenerateKonfigTask : DefaultTask() {
/** Ordered list of active dimension names. */
@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`. */
@get:Input abstract val dimensionObjectNames: MapProperty<String, String>
/** `dimName -> selected variant`. */
@@ -69,7 +80,25 @@ abstract class GenerateKonfigTask : DefaultTask() {
/** Dimension fields: `"<dimName>|<fieldName>" -> "TYPE:value"`. */
@get:Input abstract val dimensionFields: MapProperty<String, String>
@get:OutputDirectory abstract val outputDirectory: DirectoryProperty
// Register only the generated file as output: Gradle must never own neighboring files.
@get:Internal abstract val outputDirectory: DirectoryProperty
/** A directory view that carries generatedFile's producer without owning the directory. */
@get:Internal abstract val sourceDirectory: DirectoryProperty
@get:OutputFile abstract val ownershipFile: RegularFileProperty
@get:OutputFile abstract val generatedFile: RegularFileProperty
init {
generatedFile.convention(outputDirectory.zip(objectPackage) { dir, pkg -> dir.dir(pkg.replace('.', '/')) }
.zip(objectName) { dir, name -> dir.file("$name.kt") })
sourceDirectory.convention(generatedFile.zip(outputDirectory) { _, directory -> directory })
outputs.doNotCacheIf("Output ownership must be checked or a renamed output cleaned up") {
val record = ownershipFile.get().asFile
val target = generatedFile.get().asFile
!target.canonicalFile.toPath().startsWith(outputDirectory.get().asFile.canonicalFile.toPath()) ||
(target.exists() && !target.readText().contains(GENERATED_MARKER)) ||
(record.exists() && record.readText() != "${objectPackage.get().replace('.', '/')}/${objectName.get()}.kt")
}
}
@TaskAction
fun generate() {
@@ -86,18 +115,18 @@ abstract class GenerateKonfigTask : DefaultTask() {
val isDebug = btVal == BuildType.DEBUG
validate(mod, objName, pkg)
logResolution(mod, btVal, objName, pkg)
val outDir = outputDirectory.get().asFile
if (outDir.exists()) outDir.deleteRecursively()
val pkgDir = outDir.resolve(pkg.replace('.', '/'))
pkgDir.mkdirs()
val gFields = globalFields.get()
val dFields = dimensionFields.get()
val dimNames = activeDimensionNames.get()
val dimObjN = dimensionObjectNames.get()
val dimVars = dimensionActiveVariants.get()
val flatDims = flatDimensionNames.get()
checkRootCollisions(mod, dimNames, gFields, dFields, dimVars)
val content = buildString {
appendLine("""@file:Suppress("RedundantVisibilityModifier")""")
@@ -111,7 +140,7 @@ abstract class GenerateKonfigTask : DefaultTask() {
appendLine("${visPrefix}object $objName {")
appendLine()
appendLine(""" const val BUILD_TYPE: String = "${btVal.name.lowercase()}"""")
appendLine(""" const val MODULE_NAME: String = "$mod"""")
appendLine(" const val MODULE_NAME: String = ${mod.toKotlinStringLiteral()}")
if (isDebug)
appendLine(" inline val IS_DEBUG: Boolean get() = true")
else
@@ -123,22 +152,32 @@ abstract class GenerateKonfigTask : DefaultTask() {
}
for (dimName in dimNames) {
val dimObjName = dimObjN[dimName] ?: continue
val activeVariant = dimVars[dimName] ?: continue
val prefix = "$dimName|"
val fields = dFields
.filterKeys { it.startsWith(prefix) }
.mapKeys { (k, _) -> k.removePrefix(prefix) }
appendLine()
appendLine(" ${visPrefix}object $dimObjName /*$dimName*/ {")
appendLine()
appendLine(" const val VARIANT: String = \"$activeVariant\"")
if (fields.isNotEmpty()) {
if (dimName in flatDims) {
// Flat dimension: fields live directly on the root object.
appendLine()
appendEncodedFields(" ", fields, btVal)
appendLine(" // dimension: ${dimName.safeComment()} (flat), variant: ${activeVariant.safeComment()}")
appendLine(" const val ${dimName.toVariantConstName()}: String = ${activeVariant.toKotlinStringLiteral()}")
if (fields.isNotEmpty()) {
appendEncodedFields(" ", fields, btVal)
}
} else {
val dimObjName = dimObjN[dimName] ?: continue
appendLine()
appendLine(" ${visPrefix}object $dimObjName /*${dimName.safeComment()}*/ {")
appendLine()
appendLine(" const val VARIANT: String = ${activeVariant.toKotlinStringLiteral()}")
if (fields.isNotEmpty()) {
appendLine()
appendEncodedFields(" ", fields, btVal)
}
appendLine(" }")
}
appendLine(" }")
logger.info("konfig [$mod]: dim '$dimName' fields: ${fields.keys.sorted().joinToString()}")
}
@@ -147,8 +186,30 @@ abstract class GenerateKonfigTask : DefaultTask() {
appendLine("}")
}
val outFile = pkgDir.resolve("$objName.kt")
outFile.writeText(content)
val outFile = generatedFile.get().asFile
val rootPath = outDir.canonicalFile.toPath()
if (!outFile.canonicalFile.toPath().startsWith(rootPath))
throw GradleException("konfig: generated file escapes outputDirectory through a symbolic link")
if (outFile.exists() && !outFile.readText().contains(GENERATED_MARKER))
throw GradleException("konfig: refusing to overwrite non-generated file '$outFile'")
pkgDir.mkdirs()
val temporary = Files.createTempFile(pkgDir.toPath(), ".konfig-", ".tmp")
try {
Files.writeString(temporary, content)
Files.move(temporary, outFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
} finally {
Files.deleteIfExists(temporary)
}
val record = ownershipFile.get().asFile
if (record.exists()) {
val previous = outDir.resolve(record.readText())
if (previous.canonicalFile != outFile.canonicalFile && previous.isFile &&
previous.canonicalFile.toPath().startsWith(rootPath) && previous.readText().contains(GENERATED_MARKER)) {
Files.delete(previous.toPath())
}
}
record.parentFile.mkdirs()
record.writeText("${pkg.replace('.', '/')}/$objName.kt")
val dimSummary = if (dimNames.isEmpty()) "no dimensions"
else "${dimNames.size} dimension(s): ${dimNames.joinToString { "'$it'" }}"
@@ -185,9 +246,14 @@ abstract class GenerateKonfigTask : DefaultTask() {
}
pkg.split(".").forEach { segment ->
if (segment.isEmpty() || !segment.all { it.isLetterOrDigit() || it == '_' })
if (!segment.isValidKotlinIdentifier())
errors += "package segment '$segment' in '$pkg' is not valid"
}
val flat = flatDimensionNames.get()
activeDimensionNames.get().forEach { dimension ->
val name = if (dimension in flat) dimension.toVariantConstName() else dimensionObjectNames.get()[dimension].orEmpty()
if (!name.isValidKotlinIdentifier()) errors += "dimension '$dimension' generates invalid Kotlin identifier '$name'"
}
if (errors.isNotEmpty()) {
throw GradleException(buildString {
@@ -198,35 +264,60 @@ abstract class GenerateKonfigTask : DefaultTask() {
}
// ==============================================================================
// MARK: Loggings
// MARK: Generated scope collision detection
// ==============================================================================
private fun logResolution(mod: String, btVal: BuildType, objName: String, pkg: String) {
logger.lifecycle("konfig [$mod]: BUILD_TYPE = ${btVal.name.lowercase()} (${buildTypeSource.get()})")
val resolutionLog = dimensionResolutionLog.get()
if (resolutionLog.isEmpty()) {
logger.info("konfig [$mod]: no dimensions declared")
} else {
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")
}
}
/**
* Checks built-in constants, global/flat fields, nested object names and VARIANT.
*/
private fun checkRootCollisions(
mod: String,
activeDims: List<String>,
gFields: Map<String, String>,
dFields: Map<String, String>,
dimVars: Map<String, String>,
) {
val owners = mutableMapOf<String, String>()
listOf("BUILD_TYPE", "MODULE_NAME", "IS_DEBUG").forEach { owners[it] = "built-in constant" }
val errors = mutableListOf<String>()
fun claim(name: String, owner: String) {
val existing = owners.putIfAbsent(name, owner)
if (existing != null) errors += "$owner generates '$name' which collides with $existing"
}
gFields.keys.forEach { claim(it, "global field") }
val flatDims = flatDimensionNames.get()
for (dimName in activeDims) {
if (dimVars[dimName] == null) continue
val prefix = "$dimName|"
if (dimName !in flatDims) {
val dimObject = dimensionObjectNames.get().getValue(dimName)
claim(dimObject, "dimension '$dimName'")
if (dimObject == objectName.get()) errors += "dimension '$dimName' reuses enclosing object name '$dimObject'"
if (dFields.containsKey("${prefix}VARIANT")) errors += "dimension '$dimName' field 'VARIANT' collides with built-in constant"
continue
}
val rootNames = buildList {
add(dimName.toVariantConstName())
addAll(dFields.keys.filter { it.startsWith(prefix) }.map { it.removePrefix(prefix) })
}
for (name in rootNames) {
claim(name, "flat dimension '$dimName'")
}
}
logger.info("konfig [$mod]: object = $pkg.$objName")
if (errors.isNotEmpty()) {
throw GradleException(buildString {
appendLine("konfig [$mod]: name collisions:")
errors.forEach { appendLine(" - $it") }
}.trimEnd())
}
}
private fun String.safeComment(): String = toKotlinStringLiteral().removeSurrounding("\"")
.replace("/*", "/ *").replace("*/", "* /")
private companion object {
const val GENERATED_MARKER = "// Generated by buildkonfig-gradle-plugin"
}
// ==============================================================================
@@ -249,32 +340,17 @@ abstract class GenerateKonfigTask : DefaultTask() {
BuildType.RELEASE -> """${indent}const val $name: Boolean = $raw"""
}
"Int" -> """${indent}const val $name: Int = $raw"""
"Long" -> """${indent}const val $name: Long = ${raw}L"""
"Long" -> """${indent}const val $name: Long = ${if (raw == Long.MIN_VALUE.toString()) "Long.MIN_VALUE" else "${raw}L"}"""
"Float" -> """${indent}const val $name: Float = ${raw.toFloat().toKotlinFloat()}"""
"Double" -> """${indent}const val $name: Double = ${raw.toDouble().toKotlinDouble()}"""
else -> return@forEach // unknown type — skip
"Value" -> "${indent}val $name: ${raw.substringBefore('\n')} = ${raw.substringAfter('\n')}"
"Getter" -> "${indent}val $name: ${raw.substringBefore('\n')} get() = ${raw.substringAfter('\n')}"
else -> return@forEach // unknown type - skip
}
appendLine(line)
}
}
private fun String.toKotlinStringLiteral(): String {
val escaped = buildString {
this@toKotlinStringLiteral.forEach { c ->
when (c) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
'$' -> append("\\\$")
else -> append(c)
}
}
}
return "\"$escaped\""
}
private fun Float.toKotlinFloat(): String = when {
isNaN() -> "Float.NaN"
this == Float.POSITIVE_INFINITY -> "Float.POSITIVE_INFINITY"
@@ -293,8 +369,4 @@ abstract class GenerateKonfigTask : DefaultTask() {
}
}
private fun String.isValidKotlinIdentifier(): Boolean =
isNotEmpty()
&& first().let { it.isLetter() || it == '_' }
&& all { it.isLetterOrDigit() || it == '_' }
}
@@ -4,7 +4,9 @@ import com.bitsycore.konfig.configs.BuildTypedFieldDeclScope
import com.bitsycore.konfig.configs.DimensionConfig
import com.bitsycore.konfig.configs.FieldConfig
import com.bitsycore.konfig.configs.VariantConfig
import com.bitsycore.konfig.types.BuildType
import com.bitsycore.konfig.types.KonfigDsl
import com.bitsycore.konfig.types.isValidDimensionName
import com.bitsycore.konfig.types.Visibility
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.model.ObjectFactory
@@ -44,9 +46,21 @@ abstract class KonfigExtension @Inject constructor(
get() = objectVisibilityProp.get()
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()
/** Reject missing/unknown dimension selections and invalid explicit build types. */
var strictResolution: Boolean = false
/** Check effective field names/types across all variants and both build types. */
var validateVariantSchema: Boolean = false
/** Specialize Array<Int> and other non-null primitive arrays. Explicit IntArray stays primitive. */
var specializeArrays: Boolean = true
/** Recreate array-containing values on each access, including arrays nested in collections. */
var copyArraysOnAccess: Boolean = false
// ==============================================================================
// MARK: DSL Internal
// ==============================================================================
@@ -54,7 +68,7 @@ abstract class KonfigExtension @Inject constructor(
@PublishedApi
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
internal val globalScope: VariantConfig = VariantConfig("\$global")
@@ -70,6 +84,7 @@ abstract class KonfigExtension @Inject constructor(
defaultTo: String? = null,
config: DimensionConfig.() -> Unit
) {
require(name.isValidDimensionName()) { "konfig: invalid dimension name '$name' (blank, delimiter, or control character)" }
require(dimensions.none { it.dimensionName == name }) {
"konfig: dimension '$name' is already declared"
}
@@ -78,16 +93,80 @@ abstract class KonfigExtension @Inject constructor(
dimensions.add(d)
}
inline fun <reified T : Any> field(
/**
* 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(name.isValidDimensionName()) { "konfig: invalid dimension name '$name' (blank, delimiter, or control character)" }
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> field(
name: String,
default: T,
) = globalScope.field(name, default)
inline fun <reified T : Any> field(
inline fun <reified T> field(
name: String,
default: Provider<T>,
default: Provider<T & Any>,
) = globalScope.field(name, default)
fun debug(block: BuildTypedFieldDeclScope.() -> Unit) = globalScope.debug(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,14 +2,19 @@
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.NullFieldValue
import com.bitsycore.konfig.configs.FieldConfig
import com.bitsycore.konfig.types.BuildType
import com.bitsycore.konfig.types.Visibility
import com.bitsycore.konfig.types.CollectionLiteral
import com.bitsycore.konfig.types.containsWordCamelCase
import com.bitsycore.konfig.types.isValidKotlinIdentifier
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.TaskProvider
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinSingleTargetExtension
import java.util.*
@@ -20,6 +25,8 @@ private data class DimensionContext(
val fileProps: Map<String, String>,
val flavorDetect: Boolean,
val taskNames: List<String>,
val androidFlavors: Map<String, String> = emptyMap(),
val strict: Boolean = false,
)
class KonfigPlugin : Plugin<Project> {
@@ -77,11 +84,16 @@ class KonfigPlugin : Plugin<Project> {
// =========================================================================
val rawBuildTypeProp = project.providers.gradleProperty("konfig.buildtype")
val buildTypeProvider: Provider<BuildType> = rawBuildTypeProp
.map { BuildType.resolve(it) ?: BuildType.RELEASE }
val strictProvider = project.providers.provider { extension.strictResolution }
val explicitBuildType = rawBuildTypeProp.zip(strictProvider) { raw, strict ->
val resolved = BuildType.resolve(raw)
require(!strict || resolved != null) { "konfig: unknown build type '$raw'; use DEBUG or RELEASE" }
resolved ?: BuildType.RELEASE
}
val buildTypeProvider: Provider<BuildType> = explicitBuildType
.orElse(
buildTypeDetectionEnabled.zip(taskNamesProvider) { enabled, names ->
if (enabled) BuildType.resolve(names.joinToString(" ")) ?: BuildType.RELEASE
if (enabled) BuildType.resolveTasks(names) ?: BuildType.RELEASE
else BuildType.RELEASE
}
)
@@ -103,7 +115,7 @@ class KonfigPlugin : Plugin<Project> {
!enabled -> "detection disabled by konfig.android.buildtypedetection=false, using RELEASE"
names.isEmpty() -> "no tasks running, using RELEASE"
else -> {
val resolved = BuildType.resolve(names.joinToString(" "))
val resolved = BuildType.resolveTasks(names)
if (resolved != null)
"task-name detection matched ${resolved.name.lowercase()} in [${names.joinToString()}]"
else
@@ -120,17 +132,20 @@ class KonfigPlugin : Plugin<Project> {
.zip(flavorDetectionEnabled) { (gp, fp), fe -> Triple(gp, fp, fe) }
.zip(taskNamesProvider) { (gp, fp, fe), names ->
DimensionContext(gradleProps = gp, fileProps = fp, flavorDetect = fe, taskNames = names)
}
}.zip(strictProvider) { ctx, strict -> ctx.copy(strict = strict) }
// Encodes resolution status for EVERY declared dimension (including skipped ones).
// Format per entry: "<TAG>\t<variant>\t<reason>"
// TAG = OK | WARN_UNKNOWN | WARN_AMBIGUOUS | SKIP | ERROR
val dimensionResolutionLogProvider: Provider<Map<String, String>> =
buildTypeProvider.zip(combinedProps) { _, ctx ->
extension.dimensions.associate { dim ->
dim.dimensionName to resolveWithSource(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames)
// =========================================================================
// 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, ctx.androidFlavors, ctx.strict)
}
}
}
// =========================================================================
// MARK: Task Registration
@@ -138,51 +153,97 @@ class KonfigPlugin : Plugin<Project> {
val forceRegen = project.providers.gradleProperty("konfig.force").isPresent
val generateTask = project.tasks.register("generateKonfig", GenerateKonfigTask::class.java).apply {
configure {
if (forceRegen) outputs.upToDateWhen { false }
fun registerGeneration(
taskName: String,
infoName: String,
buildTypeProvider: Provider<BuildType>,
buildTypeSourceProvider: Provider<String>,
combinedProps: Provider<DimensionContext>,
): TaskProvider<GenerateKonfigTask> {
val dimensionResolutionLogProvider = buildTypeProvider.zip(combinedProps) { _, ctx ->
extension.dimensions.associate { dim ->
dim.dimensionName to resolveWithSource(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames, ctx.androidFlavors, ctx.strict)
} + buildMap {
if (extension.validateVariantSchema) validateSchemas(extension).forEachIndexed { index, error ->
put("schema[$index]", "ERROR\t\t$error")
}
if (ctx.strict) (ctx.gradleProps.keys + ctx.fileProps.keys.filter { it.startsWith("konfig.dimension.") })
.map { it.removePrefix("konfig.dimension.") }.distinct()
.filter { name -> extension.dimensions.none { it.dimensionName == name } }
.forEach { put(it, "ERROR\t\tunknown dimension '$it'") }
}
}
// 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(infoName, 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)
outputDirectory.set(extension.outputDir)
objectPackage.set(extension.objectPackageProp)
objectName.set(extension.objectNameProp)
objectVisibility.set(extension.objectVisibilityProp)
}
// ── Global fields ─────────────────────────────────────────────
globalFields.set(resolveFields(buildTypeProvider, extension.globalFields))
return project.tasks.register(taskName, GenerateKonfigTask::class.java).apply {
configure {
if (forceRegen) outputs.upToDateWhen { false }
dependsOn(infoTask)
// ── Dimension metadata ────────────────────────────────────────
activeDimensionNames.set(
buildTypeProvider.zip(combinedProps) { _, ctx ->
extension.dimensions.mapNotNull { dim ->
resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames)
?: return@mapNotNull null
dim.dimensionName
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") }
}
}
)
dimensionObjectNames.set(
buildTypeProvider.map {
extension.dimensions.associate { dim -> dim.dimensionName to dim.objectName() }
}
)
dimensionActiveVariants.set(
buildTypeProvider.zip(combinedProps) { _, ctx ->
extension.dimensions.mapNotNull { dim ->
val sv = resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames)
?: return@mapNotNull null
dim.dimensionName to sv
}.toMap()
}
)
)
flatDimensionNames.set(
project.providers.provider {
extension.dimensions.filter { it.flat }.map { it.dimensionName }.toSet()
}
)
outputDirectory.set(extension.outputDir)
ownershipFile.set(project.layout.buildDirectory.file("konfig-state/$taskName.txt"))
objectPackage.set(extension.objectPackageProp)
objectName.set(extension.objectNameProp)
objectVisibility.set(extension.objectVisibilityProp)
// ── Dimension fields ──────────────────────────────────────────
dimensionFields.set(resolveDimensionFields(buildTypeProvider, combinedProps, extension))
// ── Global fields ─────────────────────────────────────────────
globalFields.set(resolveFields(buildTypeProvider, extension.globalFields, extension))
// ── Dimension metadata ────────────────────────────────────────
activeDimensionNames.set(
buildTypeProvider.zip(combinedProps) { _, ctx ->
extension.dimensions.mapNotNull { dim ->
resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames, ctx.androidFlavors, ctx.strict)
?: return@mapNotNull null
dim.dimensionName
}
}
)
dimensionObjectNames.set(
buildTypeProvider.map {
extension.dimensions.associate { dim -> dim.dimensionName to dim.objectName() }
}
)
dimensionActiveVariants.set(
buildTypeProvider.zip(combinedProps) { _, ctx ->
extension.dimensions.mapNotNull { dim ->
val sv = resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames, ctx.androidFlavors, ctx.strict)
?: return@mapNotNull null
dim.dimensionName to sv
}.toMap()
}
)
// ── Dimension fields ──────────────────────────────────────────
dimensionFields.set(resolveDimensionFields(buildTypeProvider, combinedProps, extension))
}
}
}
val generateTask = registerGeneration("generateKonfig", "konfigInfo", buildTypeProvider, buildTypeSourceProvider, combinedProps)
// =========================================================================
// MARK: Auto Sourceset
@@ -190,38 +251,55 @@ class KonfigPlugin : Plugin<Project> {
project.plugins.withId("org.jetbrains.kotlin.multiplatform") {
project.extensions.findByType(KotlinMultiplatformExtension::class.java)
?.sourceSets?.findByName("commonMain")?.kotlin?.srcDir(extension.outputDir)
?.sourceSets?.findByName("commonMain")?.kotlin?.srcDir(generateTask.flatMap { it.sourceDirectory })
}
project.plugins.withId("org.jetbrains.kotlin.jvm") {
project.extensions.findByType(KotlinSingleTargetExtension::class.java)
?.sourceSets?.findByName("main")?.kotlin?.srcDir(extension.outputDir)
}
project.plugins.withId("org.jetbrains.kotlin.android") {
project.extensions.findByType(KotlinSingleTargetExtension::class.java)
?.sourceSets?.findByName("main")?.kotlin?.srcDir(extension.outputDir)
?.sourceSets?.findByName("main")?.kotlin?.srcDir(generateTask.flatMap { it.sourceDirectory })
}
listOf("com.android.application", "com.android.library").forEach { androidPluginId ->
project.plugins.withId(androidPluginId) {
@Suppress("UnstableApiUsage")
(project.extensions.findByName("android") as? CommonExtension<*, *, *, *>)
?.sourceSets?.findByName("main")?.kotlin?.srcDir(extension.outputDir)
// AGP 9.2 flips android.sourceset.disallowProvider to true: passing a
// provider (like a DirectoryProperty) to the AndroidSourceSet DSL fails.
// Register the generated directory through the variant Sources API instead.
project.extensions.findByType(AndroidComponentsExtension::class.java)
?.onVariants { variant ->
// KMP shares one object through the task-backed commonMain source directory.
if (project.plugins.hasPlugin("org.jetbrains.kotlin.multiplatform")) return@onVariants
val variantName = variant.name
val suffix = variantName.replaceFirstChar { it.uppercase() }
val androidBuildType = if (variant.debuggable) BuildType.DEBUG else BuildType.RELEASE
val flavors = variant.productFlavors.toMap()
val selectedType = explicitBuildType
.orElse(buildTypeDetectionEnabled.map { if (it) androidBuildType else BuildType.RELEASE })
val source = rawBuildTypeProp.map { "explicit property -Pkonfig.buildtype=$it" }
.orElse(buildTypeDetectionEnabled.map { enabled ->
if (enabled) "Android variant '$variantName' (debuggable=${androidBuildType == BuildType.DEBUG})"
else "detection disabled, using RELEASE"
})
val context = combinedProps.map { ctx -> ctx.copy(taskNames = emptyList(), androidFlavors = flavors) }
val variantTask = registerGeneration("generate${suffix}Konfig", "konfig${suffix}Info", selectedType, source, context)
// External KGP (AGP 8) consumes generated Kotlin through the Java source API;
// AGP's built-in Kotlin consumes the dedicated Kotlin source API.
val sources = if (project.plugins.hasPlugin("org.jetbrains.kotlin.android")) variant.sources.java
else variant.sources.kotlin ?: variant.sources.java
sources?.addGeneratedSourceDirectory(variantTask, GenerateKonfigTask::sourceDirectory)
variantTask.configure {
outputDirectory.set(extension.outputDir.dir(variantName))
// Replace AGP's default directory with the configurable directory, retaining
// the @OutputFile producer. Gradle never owns/deletes neighboring sources.
sourceDirectory.set(generatedFile.zip(outputDirectory) { _, directory -> directory })
}
generateTask.configure { enabled = false; dependsOn(variantTask) }
project.tasks.named("konfigInfo").configure {
enabled = false
dependsOn("konfig${suffix}Info")
}
}
}
}
// ==============================================
// MARK: Task Dependency
// ==============================================
project.tasks.configureEach {
if (
name.contains("sourcesJar") || name.contains("SourcesJar")
|| name.contains("compileKotlin")
|| (name.startsWith("compile") && name.contains("Kotlin"))
|| (name.startsWith("compile") && name.contains("Main"))
) {
dependsOn(generateTask)
}
}
}
// ==============================================
@@ -237,12 +315,15 @@ class KonfigPlugin : Plugin<Project> {
gradleProps: Map<String, String>,
fileProps: Map<String, String>,
flavorDetect: Boolean,
taskNames: List<String>
taskNames: List<String>,
androidFlavors: Map<String, String> = emptyMap(),
strict: Boolean = false,
): String? {
val encoded = resolveWithSource(dim, gradleProps, fileProps, flavorDetect, taskNames)
val encoded = resolveWithSource(dim, gradleProps, fileProps, flavorDetect, taskNames, androidFlavors, strict)
val parts = encoded.split("\t", limit = 3)
return when (parts[0]) {
"OK" -> parts.getOrNull(1)?.takeIf { it.isNotEmpty() }
"ERROR" -> throw org.gradle.api.GradleException("konfig: dimension '${dim.dimensionName}': ${parts.getOrElse(2) { "resolution failed" }}")
else -> null
}
}
@@ -251,18 +332,31 @@ class KonfigPlugin : Plugin<Project> {
* Resolves a dimension and encodes the result as a tab-separated string for logging.
*
* Format: `"<TAG>\t<variant>\t<reason>"`
* - TAG = `OK` — active, variant resolved successfully
* - TAG = `WARN_UNKNOWN` — property set but value is not a known variant
* - TAG = `WARN_AMBIGUOUS` — multiple variants matched task names
* - TAG = `SKIP` — no active variant could be determined
* - TAG = `ERROR` — configuration error (e.g. invalid defaultTo)
* - TAG = `OK` - active, variant resolved successfully
* - TAG = `WARN_UNKNOWN` - property set but value is not a known variant
* - TAG = `WARN_AMBIGUOUS` - multiple variants matched task names
* - TAG = `SKIP` - no active variant could be determined
* - TAG = `ERROR` - configuration error (e.g. invalid defaultTo)
*/
private fun resolveWithSource(
dim: DimensionConfig,
gradleProps: Map<String, String>,
fileProps: Map<String, String>,
flavorDetect: Boolean,
taskNames: List<String>
taskNames: List<String>,
androidFlavors: Map<String, String> = emptyMap(),
strict: Boolean = false,
): String {
val result = resolveUnchecked(dim, gradleProps, fileProps, flavorDetect, taskNames, androidFlavors)
if ((strict || dim.required) && (result.startsWith("SKIP") || result.startsWith("WARN"))) {
return "ERROR\t\tdimension selection is required: " + result.substringAfter('\t').substringAfter('\t')
}
return result
}
private fun resolveUnchecked(
dim: DimensionConfig, gradleProps: Map<String, String>, fileProps: Map<String, String>,
flavorDetect: Boolean, taskNames: List<String>, androidFlavors: Map<String, String>,
): String {
// Validate defaultTo at resolution time
if (dim.defaultVariant != null && !dim.variants.containsKey(dim.defaultVariant)) {
@@ -294,16 +388,27 @@ class KonfigPlugin : Plugin<Project> {
}
}
// Priority 3: task-name detection
// Priority 3: exact Android flavor mapping, or task names for shared generation.
if (flavorDetect) {
val flavor = androidFlavors[dim.androidDimension]
if (flavor != null) return if (flavor in dim.variants) "OK\t$flavor\tAndroid flavor '${dim.androidDimension}=$flavor'"
else "ERROR\t\tAndroid flavor '$flavor' is not a known variant for dimension '${dim.dimensionName}'"
}
// Shared generation: task-name detection
if (flavorDetect && taskNames.isNotEmpty()) {
val matches = dim.variants.keys.filter { variant ->
taskNames.any { task -> task.contains(variant, ignoreCase = true) }
}
// Resolve overlap within EACH task; separate prod and preProd tasks conflict.
val matches = taskNames.flatMap { qualifiedTask ->
val task = qualifiedTask.substringAfterLast(':')
val raw = dim.variants.keys.filter { task.containsWordCamelCase(it) }
val longest = raw.maxByOrNull { it.length }
if (longest != null && raw.all { longest.contains(it, ignoreCase = true) }) listOf(longest) else raw
}.distinct()
when (matches.size) {
1 -> return "OK\t${matches.first()}\ttask-name detection: '${matches.first()}' " +
"found in [${taskNames.joinToString()}]"
in 2..Int.MAX_VALUE -> return "WARN_AMBIGUOUS\t\ttask names matched multiple variants " +
"${matches.sorted()} in [${taskNames.joinToString()}] -- dimension skipped"
in 2..Int.MAX_VALUE -> return "ERROR\t\tconflicting variants ${matches.sorted()} " +
"in tasks [${taskNames.joinToString()}]. Run separate builds or select -P$propKey explicitly."
}
}
@@ -327,8 +432,9 @@ class KonfigPlugin : Plugin<Project> {
private fun resolveFields(
buildType: Provider<BuildType>,
fields: List<FieldConfig<*>>,
extension: KonfigExtension,
): Provider<Map<String, String>> = buildType.map { bt ->
fields.mapNotNull { field -> encodeField(field, bt) }.toMap()
fields.mapNotNull { field -> encodeField(field, bt, extension) }.toMap()
}
private fun resolveDimensionFields(
@@ -339,19 +445,26 @@ class KonfigPlugin : Plugin<Project> {
buildType.zip(combined) { bt, ctx ->
buildMap {
for (dim in extension.dimensions) {
val sv = resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames) ?: continue
val sv = resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames, ctx.androidFlavors, ctx.strict) ?: continue
val vc = dim.variants[sv] ?: continue
val prefix = "${dim.dimensionName}|"
for (field in mergeVariantFields(dim.commonConfig.fields, vc.fields)) {
encodeField(field, bt)?.let { (name, value) -> put("$prefix$name", value) }
encodeField(field, bt, extension)?.let { (name, value) -> put("$prefix$name", value) }
}
}
}
}
/** Encodes a [FieldConfig] value for [buildType] as `"TYPE:rawValue"`, or `null` if absent. */
private fun encodeField(field: FieldConfig<*>, buildType: BuildType): Pair<String, String>? {
private fun encodeField(field: FieldConfig<*>, buildType: BuildType, extension: KonfigExtension): Pair<String, String>? {
val value = field.resolve(buildType)?.orNull ?: return null
val type = requireNotNull(field.valueType) { "konfig: missing type for field '${field.fieldName}'" }
if (type.nullable || type.name !in setOf("String", "Boolean", "Int", "Long", "Float", "Double")) {
val literal = CollectionLiteral(extension.specializeArrays)
val tag = if (extension.copyArraysOnAccess && containsArray(value)) "Getter" else "Value"
val encoded = "$tag:${literal.type(type)}\n${literal.value(if (value is NullFieldValue) null else value, type)}"
return field.fieldName to encoded
}
val encoded = when (value) {
is String -> "String:$value"
is Boolean -> "Boolean:$value"
@@ -359,11 +472,38 @@ class KonfigPlugin : Plugin<Project> {
is Long -> "Long:$value"
is Float -> "Float:$value"
is Double -> "Double:$value"
else -> return null
else -> error("konfig: unsupported value type '${value.javaClass.name}' for field '${field.fieldName}'")
}
return field.fieldName to encoded
}
private fun containsArray(value: Any?): Boolean = when (value) {
null -> false
is Map<*, *> -> value.any { containsArray(it.key) || containsArray(it.value) }
is Collection<*> -> value.any { containsArray(it) }
else -> value.javaClass.isArray
}
private fun validateSchemas(extension: KonfigExtension): List<String> = buildList {
fun schema(fields: List<FieldConfig<*>>, bt: BuildType) = fields
.filter { it.resolve(bt)?.isPresent == true }.associate { it.fieldName to it.valueType }
val globalDebug = schema(extension.globalFields, BuildType.DEBUG)
if (globalDebug != schema(extension.globalFields, BuildType.RELEASE))
add("global fields differ between DEBUG and RELEASE")
extension.dimensions.forEach { dim ->
val schemas = dim.variants.flatMap { (name, config) -> BuildType.entries.map { bt ->
"$name/${bt.name}" to schema(mergeVariantFields(dim.commonConfig.fields, config.fields), bt)
} }
val baseline = schemas.firstOrNull() ?: return@forEach
schemas.drop(1).filter { it.second != baseline.second }.forEach { (name, fields) ->
val missing = baseline.second.keys - fields.keys
val extra = fields.keys - baseline.second.keys
val changed = fields.keys.intersect(baseline.second.keys).filter { fields[it] != baseline.second[it] }
add("dimension '${dim.dimensionName}' schema '$name' differs from '${baseline.first}': missing=$missing, extra=$extra, different types=$changed")
}
}
}
// ==============================================================================
// MARK: Fields Merge
// ==============================================================================
@@ -393,7 +533,7 @@ class KonfigPlugin : Plugin<Project> {
val artifact = projectName
.replace("-", ".")
.replace(Regex("[^A-Za-z0-9.]"), "")
return if (group != null) "$group.$artifact" else artifact.ensureValidPackage()
return (if (group != null) "$group.$artifact" else artifact).ensureValidPackage()
}
private fun String.ensureValidPackage(): String = split(".")
@@ -401,9 +541,10 @@ class KonfigPlugin : Plugin<Project> {
.joinToString(".") { segment ->
val cleaned = segment.replace(Regex("[^A-Za-z0-9_]"), "")
when {
cleaned.isBlank() -> "_"
cleaned.isBlank() -> "generated"
cleaned.first().isDigit() -> "_$cleaned"
!cleaned.lowercase().isValidKotlinIdentifier() -> "_${cleaned}pkg"
else -> cleaned
}
}.lowercase()
}.lowercase().ifEmpty { "generated" }
}
@@ -28,7 +28,18 @@ class DimensionConfig @PublishedApi internal constructor(
val dimensionName: String,
val objectNameOverride: 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,
) {
/** Require a selected variant even when global strict resolution is disabled. */
var required: Boolean = false
/** Android flavor dimension to read; defaults to this Konfig dimension's name. */
var androidDimension: String = dimensionName
set(value) {
require(value.isNotBlank() && value.none { it.isISOControl() }) { "konfig: Android dimension alias must not be blank or contain control characters" }
field = value
}
/** All named variants. */
@PublishedApi
internal val variants: MutableMap<String, VariantConfig> = mutableMapOf()
@@ -48,6 +59,7 @@ class DimensionConfig @PublishedApi internal constructor(
* to merge additional fields.
*/
fun variant(name: String, config: VariantConfig.() -> Unit) {
require(name.isNotEmpty() && '\t' !in name) { "konfig: variant name must be nonempty and cannot contain tabs" }
val v = variants.getOrPut(name) { VariantConfig(name) }
config(v)
}
@@ -1,6 +1,7 @@
package com.bitsycore.konfig.configs
import com.bitsycore.konfig.types.BuildType
import com.bitsycore.konfig.types.FieldValueType
import org.gradle.api.Transformer
import org.gradle.api.provider.Provider
import org.gradle.api.specs.Spec
@@ -11,27 +12,28 @@ import java.util.function.BiFunction
*
* Values are stored as [Provider]<T> so lazy sources (e.g. Gradle property providers)
* 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.
*/
class FieldConfig<T : Any> @PublishedApi internal constructor(
class FieldConfig<T> @PublishedApi internal constructor(
val fieldName: String,
internal val type: Class<T>,
/**
* The unconditional default value, or `null` if this field was declared only inside
* a scope block (debug/release) and has no fallback outside that scope.
*/
internal val default: Provider<T>?
internal val default: Provider<out Any>?,
@PublishedApi internal val valueType: FieldValueType? = null,
) {
@PublishedApi
internal val buildTypeOverrides: MutableMap<BuildType, Provider<T>> = mutableMapOf()
internal val buildTypeOverrides: MutableMap<BuildType, Provider<out Any>> = mutableMapOf()
// ── BuildType overrides ───────────────────────────────────────────────────
fun debug(value: T) { buildTypeOverrides[BuildType.DEBUG] = constantProvider(value) }
fun debug(value: Provider<T>) { buildTypeOverrides[BuildType.DEBUG] = value }
fun release(value: T) { buildTypeOverrides[BuildType.RELEASE] = constantProvider(value) }
fun release(value: Provider<T>) { buildTypeOverrides[BuildType.RELEASE] = value }
fun debug(value: T) { buildTypeOverrides[BuildType.DEBUG] = constantProvider(value ?: NullFieldValue) }
fun debug(value: Provider<T & Any>) { buildTypeOverrides[BuildType.DEBUG] = value }
fun release(value: T) { buildTypeOverrides[BuildType.RELEASE] = constantProvider(value ?: NullFieldValue) }
fun release(value: Provider<T & Any>) { buildTypeOverrides[BuildType.RELEASE] = value }
// ── Resolution ────────────────────────────────────────────────────────────
@@ -39,7 +41,7 @@ class FieldConfig<T : Any> @PublishedApi internal constructor(
* Resolves the effective value for [buildType].
* Returns `null` if no applicable value exists (field is absent for this context).
*/
internal fun resolve(buildType: BuildType): Provider<T>? =
internal fun resolve(buildType: BuildType): Provider<out Any>? =
buildTypeOverrides[buildType] ?: default
}
@@ -78,3 +80,12 @@ private class ConstantProvider<T : Any>(private val value: T) : Provider<T> {
combiner: BiFunction<in T, in B, out R?>
): Provider<R> = ConstantProvider(combiner.apply(value, right.get())!!)
}
/** Explicit null is present; absent Gradle providers still omit a field. */
@PublishedApi
internal object NullFieldValue
@PublishedApi
@Suppress("UNCHECKED_CAST")
internal inline fun <reified T> fieldClass(): Class<T> =
(kotlin.reflect.typeOf<T>().classifier as kotlin.reflect.KClass<*>).javaObjectType as Class<T>
@@ -2,7 +2,9 @@ package com.bitsycore.konfig.configs
import com.bitsycore.konfig.types.BuildType
import com.bitsycore.konfig.types.KonfigDsl
import com.bitsycore.konfig.types.FieldValueType
import org.gradle.api.provider.Provider
import kotlin.reflect.typeOf
// ==============================================================================
// MARK: FieldHandle with modifier
@@ -14,17 +16,17 @@ import org.gradle.api.provider.Provider
* Allows fluent `.debug(value)` / `.release(value)` overrides:
* ```kotlin
* 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")
* ```
* 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> @PublishedApi internal constructor(
@PublishedApi internal val field: FieldConfig<T>
) {
fun debug(value: T): Unit = field.debug(value)
fun debug(value: Provider<T>): Unit = field.debug(value)
fun debug(value: Provider<T & Any>): Unit = field.debug(value)
fun release(value: T): Unit = field.release(value)
fun release(value: Provider<T>): Unit = field.release(value)
fun release(value: Provider<T & Any>): Unit = field.release(value)
}
// ==============================================================================
@@ -34,7 +36,7 @@ class FieldHandle<T : Any> @PublishedApi internal constructor(
/**
* 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.
*/
@KonfigDsl
@@ -42,11 +44,11 @@ class BuildTypedFieldDeclScope @PublishedApi internal constructor(
@PublishedApi internal val buildType: BuildType,
@PublishedApi internal val owner: VariantConfig
) {
inline fun <reified T : Any> field(name: String, value: T) {
owner.getOrCreateField<T>(name).buildTypeOverrides[buildType] = constantProvider(value)
inline fun <reified T> field(name: String, value: T) {
owner.getOrCreateField<T>(name).buildTypeOverrides[buildType] = constantProvider(value ?: NullFieldValue)
}
inline fun <reified T : Any> field(name: String, value: Provider<T>) {
inline fun <reified T> field(name: String, value: Provider<T & Any>) {
owner.getOrCreateField<T>(name).buildTypeOverrides[buildType] = value
}
}
@@ -76,13 +78,16 @@ class VariantConfig @PublishedApi internal constructor(val variantName: String)
// ── Internal helpers ──────────────────────────────────────────────────────
@PublishedApi
internal inline fun <reified T : Any> getOrCreateField(name: String): FieldConfig<T> {
internal inline fun <reified T> getOrCreateField(name: String): FieldConfig<T> {
val existing = fields.firstOrNull { it.fieldName == name }
if (existing != null) {
require(existing.valueType == FieldValueType.from(typeOf<T>())) {
"konfig: field '$name' must use the same type in every build-type scope"
}
@Suppress("UNCHECKED_CAST")
return existing as FieldConfig<T>
}
val fc = FieldConfig(name, T::class.javaObjectType, null)
val fc = FieldConfig(name, fieldClass<T>(), null, FieldValueType.from(typeOf<T>()))
fields.add(fc)
return fc
}
@@ -93,20 +98,20 @@ class VariantConfig @PublishedApi internal constructor(val variantName: String)
* Declares a field with an unconditional default value.
* Returns a [FieldHandle] to optionally set `.debug(value)` / `.release(value)` build-type overrides.
*/
inline fun <reified T : Any> field(name: String, default: T): FieldHandle<T> {
inline fun <reified T> field(name: String, default: T): FieldHandle<T> {
require(fields.none { it.fieldName == name }) {
"konfig: field '$name' is already declared in variant '$variantName'"
}
val fc = FieldConfig(name, T::class.javaObjectType, constantProvider(default))
val fc = FieldConfig(name, fieldClass<T>(), constantProvider(default ?: NullFieldValue), FieldValueType.from(typeOf<T>()))
fields.add(fc)
return FieldHandle(fc)
}
inline fun <reified T : Any> field(name: String, default: Provider<T>): FieldHandle<T> {
inline fun <reified T> field(name: String, default: Provider<T & Any>): FieldHandle<T> {
require(fields.none { it.fieldName == name }) {
"konfig: field '$name' is already declared in variant '$variantName'"
}
val fc = FieldConfig(name, T::class.javaObjectType, default)
val fc = FieldConfig(name, fieldClass<T>(), default, FieldValueType.from(typeOf<T>()))
fields.add(fc)
return FieldHandle(fc)
}
@@ -1,6 +1,7 @@
package com.bitsycore.konfig.types
import kotlin.enums.enumEntries
import org.gradle.api.GradleException
enum class BuildType(val value: String) {
DEBUG("debug"),
@@ -10,6 +11,17 @@ enum class BuildType(val value: String) {
private val DEBUG_REGEX = Regex("""(?<![a-z])debug(?![a-z])|(?<![A-Z])Debug(?![a-z])""")
private val RELEASE_REGEX = Regex("""(?<![a-z])release(?![a-z])|(?<![A-Z])Release(?![a-z])""")
internal fun resolveTasks(names: List<String>): BuildType? {
val tasks = names.map { it.substringAfterLast(':') }
val debug = tasks.any { it == "DEBUG" || DEBUG_REGEX.containsMatchIn(it) }
val release = tasks.any { it == "RELEASE" || RELEASE_REGEX.containsMatchIn(it) }
if (debug && release) throw GradleException(
"konfig: conflicting debug and release tasks $names cannot share one generated object. " +
"Run separate builds or select -Pkonfig.buildtype explicitly."
)
return when { debug -> DEBUG; release -> RELEASE; else -> null }
}
fun resolve(value: String): BuildType? {
enumEntries<BuildType>().firstOrNull { it.name == value }?.let {
return it
@@ -0,0 +1,129 @@
package com.bitsycore.konfig.types
import java.lang.reflect.Array as JavaArray
/** Produces code only from supported values and validated type descriptors. */
internal class CollectionLiteral(private val specializeArrays: Boolean = true) {
private val primitives = setOf("Boolean", "Byte", "Short", "Char", "Int", "Long", "Float", "Double")
fun type(type: FieldValueType): String {
val element = type.arguments.singleOrNull()
val base = if (specializeArrays && type.name == "Array" && element != null && !element.nullable && element.name in primitives) {
"${element.name}Array"
} else {
type.name + if (type.arguments.isEmpty()) "" else type.arguments.joinToString(", ", "<", ">", transform = ::type)
}
return base + if (type.nullable) "?" else ""
}
fun value(value: Any?, declared: FieldValueType): String {
if (value == null) {
require(declared.nullable) { "konfig: null is not valid for ${type(declared)}" }
return "null"
}
if (declared.name == "Any") return dynamicValue(value)
if (declared.enumType) {
require(value is Enum<*> && value.declaringJavaClass.canonicalName == declared.name) {
"konfig: expected enum ${type(declared)}"
}
return enumLiteral(value)
}
return when (declared.name) {
"List", "Set" -> {
require(if (declared.name == "Set") value is Set<*> else value is List<*>) { "konfig: expected ${type(declared)}" }
val element = declared.arguments.single()
(value as Collection<*>).joinToString(", ", "${declared.name.lowercase()}Of<${type(element)}>(", ")") { value(it, element) }
}
"Map" -> {
require(value is Map<*, *>) { "konfig: expected ${type(declared)}" }
val (key, item) = declared.arguments
value.entries.joinToString(", ", "mapOf<${type(key)}, ${type(item)}>(", ")") {
"${value(it.key, key)} to ${value(it.value, item)}"
}
}
"Array", "BooleanArray", "ByteArray", "ShortArray", "CharArray", "IntArray", "LongArray", "FloatArray", "DoubleArray" -> {
require(value.javaClass.isArray) { "konfig: expected ${type(declared)}" }
val element = if (declared.name == "Array") declared.arguments.single()
else FieldValueType(declared.name.removeSuffix("Array"))
val primitive = (specializeArrays || declared.name != "Array") && !element.nullable && element.name in primitives
val factory = if (primitive) "${element.name.replaceFirstChar { it.lowercase() }}ArrayOf"
else "arrayOf<${type(element)}>"
(0 until JavaArray.getLength(value)).joinToString(", ", "$factory(", ")") {
value(JavaArray.get(value, it), element)
}
}
else -> {
require(value.javaClass.simpleName == declared.name ||
(declared.name == "Int" && value is Int) ||
(declared.name == "Char" && value is Char)) {
"konfig: expected ${type(declared)}, got ${value.javaClass.simpleName}"
}
scalar(value)
}
}
}
private fun dynamicValue(value: Any): String = when (value) {
is Set<*> -> value(value, FieldValueType("Set", listOf(FieldValueType("Any", nullable = true))))
is List<*> -> value(value, FieldValueType("List", listOf(FieldValueType("Any", nullable = true))))
is Map<*, *> -> value(value, FieldValueType("Map", List(2) { FieldValueType("Any", nullable = true) }))
else -> if (value.javaClass.isArray) {
val component = value.javaClass.componentType
val element = if (component.isPrimitive) FieldValueType(when (component.name) {
"int" -> "Int"
"char" -> "Char"
else -> component.name.replaceFirstChar { it.uppercase() }
}) else FieldValueType("Any", nullable = true)
value(value, if (component.isPrimitive) FieldValueType("${element.name}Array") else FieldValueType("Array", listOf(element)))
} else scalar(value)
}
private fun scalar(value: Any): String = when (value) {
is Enum<*> -> enumLiteral(value)
is UByte -> "${value}u.toUByte()"
is UShort -> "${value}u.toUShort()"
is UInt -> "${value}u"
is ULong -> "${value}uL"
is String -> value.toKotlinStringLiteral()
is Char -> "'${escape(value, charLiteral = true)}'"
is Boolean, is Int -> value.toString()
is Byte -> "($value).toByte()"
is Short -> "($value).toShort()"
is Long -> if (value == Long.MIN_VALUE) "Long.MIN_VALUE" else "${value}L"
is Float -> when {
value.isNaN() -> "Float.NaN"
value == Float.POSITIVE_INFINITY -> "Float.POSITIVE_INFINITY"
value == Float.NEGATIVE_INFINITY -> "Float.NEGATIVE_INFINITY"
else -> "${value}f"
}
is Double -> when {
value.isNaN() -> "Double.NaN"
value == Double.POSITIVE_INFINITY -> "Double.POSITIVE_INFINITY"
value == Double.NEGATIVE_INFINITY -> "Double.NEGATIVE_INFINITY"
else -> value.toString()
}
else -> error("konfig: unsupported collection value type '${value.javaClass.name}'")
}
private fun enumLiteral(value: Enum<*>): String {
val name = requireNotNull(value.declaringJavaClass.canonicalName) { "konfig: enum must have a qualified name" }
require(name.split('.').all { it.isValidKotlinIdentifier() } && value.name.isValidKotlinIdentifier()) {
"konfig: enum '$name.${value.name}' must have Kotlin-compatible names"
}
return "$name.${value.name}"
}
}
internal fun String.toKotlinStringLiteral(): String =
map { escape(it, charLiteral = false) }.joinToString("", "\"", "\"")
private fun escape(c: Char, charLiteral: Boolean): String = when (c) {
'\\' -> "\\\\"
'\"' -> if (charLiteral) "\"" else "\\\""
'\'' -> if (charLiteral) "\\'" else "'"
'\n' -> "\\n"
'\r' -> "\\r"
'\t' -> "\\t"
'$' -> if (charLiteral) "$" else "\\$"
else -> if (c.code < 32 || c.code == 127 || c.isSurrogate()) "\\u${c.code.toString(16).padStart(4, '0')}" else c.toString()
}
@@ -0,0 +1,43 @@
package com.bitsycore.konfig.types
import kotlin.reflect.KClass
import kotlin.reflect.KType
/** A serializable snapshot of a DSL type. Never retain KType/KClass in the DSL graph. */
@PublishedApi
internal data class FieldValueType(
val name: String,
val arguments: List<FieldValueType> = emptyList(),
val nullable: Boolean = false,
val enumType: Boolean = false,
) {
companion object {
fun from(type: KType): FieldValueType {
val klass = type.classifier as? KClass<*>
?: error("konfig: unsupported field type '$type'")
val javaType = klass.java
val name = when {
javaType.isArray && !javaType.componentType.isPrimitive -> "Array"
List::class.java.isAssignableFrom(javaType) -> "List"
Map::class.java.isAssignableFrom(javaType) -> "Map"
Set::class.java.isAssignableFrom(javaType) -> "Set"
javaType.isEnum -> requireNotNull(klass.qualifiedName) { "konfig: enum types must have a qualified name" }
else -> klass.simpleName
}
require(javaType.isEnum || name in supportedNames) { "konfig: unsupported field type '$type'" }
if (javaType.isEnum) require(name!!.split('.').all { it.isValidKotlinIdentifier() }) {
"konfig: enum type '$name' must have a Kotlin-compatible qualified name"
}
return FieldValueType(name!!, type.arguments.map {
it.type?.let(::from) ?: FieldValueType("Any", nullable = true)
}, type.isMarkedNullable, javaType.isEnum)
}
private val supportedNames = setOf(
"String", "Boolean", "Byte", "Short", "Char", "Int", "Long", "Float", "Double", "Any",
"Set", "UByte", "UShort", "UInt", "ULong",
"List", "Map", "Array", "BooleanArray", "ByteArray", "ShortArray", "CharArray",
"IntArray", "LongArray", "FloatArray", "DoubleArray",
)
}
}
@@ -0,0 +1,18 @@
package com.bitsycore.konfig.types
internal fun String.isValidKotlinIdentifier(): Boolean =
isNotEmpty() && any { it != '_' } && this !in kotlinKeywords &&
first().let { it.isLetter() || it == '_' } && all { it.isLetterOrDigit() || it == '_' }
private val kotlinKeywords = setOf(
"as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", "interface",
"is", "null", "object", "package", "return", "super", "this", "throw", "true", "try", "typealias",
"typeof", "val", "var", "when", "while",
)
/** Dimensions are also used as property keys and delimiters in task input maps. */
internal fun String.isValidDimensionName(): Boolean =
isNotBlank() && none { it == '|' || it.isISOControl() }
internal fun String.toVariantConstName(): String =
uppercase().replace(Regex("[^A-Z0-9]"), "_") + "_VARIANT"
@@ -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`() {
// "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:
// lookbehind (?<![a-z]) fails because char before 'r' of "release" is 'e'
assertNull(BuildType.resolve("prereleased"))
}
@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.
assertNull(BuildType.resolve("released"))
}
@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"))
}
@@ -119,7 +119,7 @@ class DimensionConfigTest {
d.variant("prod") { field("URL", "https://prod.example.com") }
d.variant("prod") { field("KEY", "secret") }
val v = d.variants["prod"]!!
// Same VariantConfig instance reused — two fields total
// Same VariantConfig instance reused - two fields total
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`() {
val fc = FieldConfig<String>("F", String::class.java, null)
@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.RELEASE))
}
@@ -0,0 +1,21 @@
package com.bitsycore.konfig
import com.bitsycore.konfig.types.isValidKotlinIdentifier
import com.bitsycore.konfig.types.BuildType
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class KotlinNamesTest {
@Test fun `hard keywords numeric starts and underscore-only identifiers are invalid`() {
listOf("class", "when", "true", "typeof", "_", "___", "1bad", "", "a-b").forEach {
assertFalse(it.isValidKotlinIdentifier(), it)
}
listOf("_valid", "Env", "VALUE", "value2", "café").forEach { assertTrue(it.isValidKotlinIdentifier(), it) }
}
@Test fun `project paths do not select build types`() {
assertNull(BuildType.resolveTasks(listOf(":debug:compileKotlin", ":release:compileKotlin")))
}
}
@@ -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(""))
}
}