mirror of
https://github.com/bitsycore/bitsykonfig-gradle-plugin.git
synced 2026-10-07 04:27:27 +00:00
Compare commits
6
Commits
6dbdc93122
...
768479a9c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
768479a9c5 | ||
|
|
e8ab8f405b | ||
|
|
9d3ac485d4 | ||
|
|
3d553bcaf3 | ||
|
|
fe6f20121f | ||
|
|
b29b853db6 |
@@ -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
|
||||||
@@ -39,6 +39,12 @@ jobs:
|
|||||||
- name: Set up Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
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
|
- name: Resolve version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# AGENTS.md
|
# 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
|
## Commands
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ This file provides guidance to AI agents (Claude, Copilot, Codex, etc.) working
|
|||||||
# Publish to maven.bitsycore.com only (requires bitsycore.maven.user + bitsycore.maven.token)
|
# Publish to maven.bitsycore.com only (requires bitsycore.maven.user + bitsycore.maven.token)
|
||||||
./gradlew publishAllPublicationsToBitsycoreRepository
|
./gradlew publishAllPublicationsToBitsycoreRepository
|
||||||
|
|
||||||
# CI: pushing a version tag (e.g. 0.6.0) publishes to BOTH repos and creates the GitHub release
|
# 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)
|
# Publish only the plugin marker artifact (fixes resolution without re-uploading the jar)
|
||||||
./gradlew publishKonfigPluginMarkerMavenPublicationToGitHubPackagesRepository
|
./gradlew publishKonfigPluginMarkerMavenPublicationToGitHubPackagesRepository
|
||||||
@@ -47,12 +47,19 @@ automatically wired into the consuming project's source sets. Fields can be cons
|
|||||||
overridden per build type (debug/release), or scoped to a named **dimension** (e.g. environment,
|
overridden per build type (debug/release), or scoped to a named **dimension** (e.g. environment,
|
||||||
region) each with their own variants.
|
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
|
### Key design constraints
|
||||||
|
|
||||||
**Configuration cache compatibility** is a hard requirement throughout. This means:
|
**Configuration cache compatibility** is a hard requirement throughout. This means:
|
||||||
- Never capture `project` inside a `Provider.map {}` or `Provider.zip {}` lambda, and **never
|
- Never capture `project` inside a `Provider.map {}` or `Provider.zip {}` lambda, and **never
|
||||||
access `project` inside a `@TaskAction`** - both break caching. Use only declared
|
access `project` inside a `@TaskAction`** - both break caching. Use declared
|
||||||
`@Input`/`@OutputDirectory` properties inside task actions.
|
task properties inside task actions.
|
||||||
- Use `Class<T>` (`.javaObjectType`) instead of `KClass<T>` - Kotlin's `KClass` uses
|
- Use `Class<T>` (`.javaObjectType`) instead of `KClass<T>` - Kotlin's `KClass` uses
|
||||||
`SoftReference` internally which Gradle can't serialize.
|
`SoftReference` internally which Gradle can't serialize.
|
||||||
- Use `gradlePropertiesPrefixedBy()` to read groups of properties - **note:** in Gradle 9.x this
|
- Use `gradlePropertiesPrefixedBy()` to read groups of properties - **note:** in Gradle 9.x this
|
||||||
@@ -70,6 +77,26 @@ region) each with their own variants.
|
|||||||
in `MapProperty<String, String>` rather than a managed-type `ListProperty`. Values are
|
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
|
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.
|
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
|
### DSL design
|
||||||
|
|
||||||
@@ -89,10 +116,18 @@ per-type maps down to one per scope and avoids Gradle's `@Nested` managed-type r
|
|||||||
|
|
||||||
1. Explicit Gradle property: `-Pkonfig.dimension.<name>=<variant>`
|
1. Explicit Gradle property: `-Pkonfig.dimension.<name>=<variant>`
|
||||||
2. `konfig.properties` file in the project directory: `konfig.dimension.<name>=<variant>`
|
2. `konfig.properties` file in the project directory: `konfig.dimension.<name>=<variant>`
|
||||||
3. Task-name detection (variant name substring match, if not disabled by `konfig.android.flavordetection=false`)
|
3. 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
|
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
|
||||||
|
|
||||||
|
`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>"`
|
`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
|
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.
|
action can emit structured lifecycle/warning/error log messages without re-running resolution logic.
|
||||||
@@ -128,9 +163,9 @@ action can emit structured lifecycle/warning/error log messages without re-runni
|
|||||||
- **Plugin ID:** `com.bitsycore.konfig`
|
- **Plugin ID:** `com.bitsycore.konfig`
|
||||||
- **Group:** `com.bitsycore`
|
- **Group:** `com.bitsycore`
|
||||||
- **Artifact:** `konfig-gradle-plugin`
|
- **Artifact:** `konfig-gradle-plugin`
|
||||||
- **Version:** set via `konfig.version` in `gradle.properties` (currently `0.6.0`)
|
- **Version:** set via `konfig.version` in `gradle.properties` (currently `0.7.0`)
|
||||||
- **Repositories:** `https://maven.bitsycore.com/releases` (primary, no auth) and
|
- **Repositories:** `https://maven.bitsycore.com/releases` (primary, no auth) and
|
||||||
`https://maven.pkg.github.com/bitsycore/bitsykonfig-gradle-plugin` (fallback, needs PAT)
|
`https://maven.pkg.github.com/bitsycore/bitsykonfig` (fallback, needs PAT)
|
||||||
- **JVM target:** 17 (set via `sourceCompatibility` + `KotlinCompile.compilerOptions.jvmTarget`,
|
- **JVM target:** 17 (set via `sourceCompatibility` + `KotlinCompile.compilerOptions.jvmTarget`,
|
||||||
no toolchain - avoids requiring a specific JDK installation)
|
no toolchain - avoids requiring a specific JDK installation)
|
||||||
- **AGP dependency:** `compileOnly("com.android.tools.build:gradle:8.7.3")` - never leaked to consumers
|
- **AGP dependency:** `compileOnly("com.android.tools.build:gradle:8.7.3")` - never leaked to consumers
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -7,7 +7,7 @@ Fields can be constant, overridden per build type (debug/release), or scoped to
|
|||||||
**Plugin ID:** `com.bitsycore.konfig`
|
**Plugin ID:** `com.bitsycore.konfig`
|
||||||
**Group:** `com.bitsycore`
|
**Group:** `com.bitsycore`
|
||||||
**Artifact:** `konfig-gradle-plugin`
|
**Artifact:** `konfig-gradle-plugin`
|
||||||
**Version:** `0.6.0`
|
**Version:** `0.7.0`
|
||||||
**JVM target:** 17
|
**JVM target:** 17
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -45,7 +45,7 @@ pluginManagement {
|
|||||||
repositories {
|
repositories {
|
||||||
maven {
|
maven {
|
||||||
name = "GitHubPackages"
|
name = "GitHubPackages"
|
||||||
url = uri("https://maven.pkg.github.com/bitsycore/bitsykonfig-gradle-plugin")
|
url = uri("https://maven.pkg.github.com/bitsycore/bitsykonfig")
|
||||||
credentials {
|
credentials {
|
||||||
username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GPR_USER")
|
username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GPR_USER")
|
||||||
password = providers.gradleProperty("gpr.key").orNull ?: System.getenv("GPR_KEY")
|
password = providers.gradleProperty("gpr.key").orNull ?: System.getenv("GPR_KEY")
|
||||||
@@ -63,7 +63,7 @@ Using a version catalog (`libs.versions.toml`):
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[versions]
|
[versions]
|
||||||
konfig = "0.6.0"
|
konfig = "0.7.0"
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
konfig = { id = "com.bitsycore.konfig", version.ref = "konfig" }
|
konfig = { id = "com.bitsycore.konfig", version.ref = "konfig" }
|
||||||
@@ -81,7 +81,7 @@ Or inline:
|
|||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
plugins {
|
plugins {
|
||||||
id("com.bitsycore.konfig") version "0.6.0"
|
id("com.bitsycore.konfig") version "0.7.0"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,6 +139,91 @@ konfig {
|
|||||||
| `Long` | `field("MAX_SIZE", 1_000_000L)` |
|
| `Long` | `field("MAX_SIZE", 1_000_000L)` |
|
||||||
| `Float` | `field("RATIO", 1.5f)` |
|
| `Float` | `field("RATIO", 1.5f)` |
|
||||||
| `Double` | `field("PI", 3.14159)` |
|
| `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)` |
|
||||||
|
|
||||||
|
### Lists, sets, maps, and arrays
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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
|
### Build-type overrides
|
||||||
|
|
||||||
@@ -191,9 +276,9 @@ public object BuildKonfig {
|
|||||||
|
|
||||||
public object Env /*env*/ {
|
public object Env /*env*/ {
|
||||||
const val VARIANT: String = "dev"
|
const val VARIANT: String = "dev"
|
||||||
inline val TIMEOUT: Int get() = 5 // common {}, debug override
|
const val TIMEOUT: Int = 5 // common {}, debug override
|
||||||
const val BASE_URL: String = "https://dev.example.com"
|
const val BASE_URL: String = "https://dev.example.com"
|
||||||
const val ANALYTICS: Boolean = false
|
inline val ANALYTICS: Boolean get() = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -243,6 +328,11 @@ 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
|
built-in constant (`BUILD_TYPE`, `MODULE_NAME`, `IS_DEBUG`), a global field, or
|
||||||
a field from another flat dimension.
|
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
|
## Variant selection
|
||||||
@@ -253,7 +343,7 @@ Variants are resolved in priority order:
|
|||||||
|----------|--------------------------|------------------------------------------|
|
|----------|--------------------------|------------------------------------------|
|
||||||
| 1 | Gradle property | `-Pkonfig.dimension.env=dev` |
|
| 1 | Gradle property | `-Pkonfig.dimension.env=dev` |
|
||||||
| 2 | `konfig.properties` file | `konfig.dimension.env=dev` |
|
| 2 | `konfig.properties` file | `konfig.dimension.env=dev` |
|
||||||
| 3 | Task-name detection | Running `assembleDevDebug` matches `dev` |
|
| 3 | Android flavor metadata, or task names for JVM/KMP | Android flavor dimension `env=dev`, or `assembleDevDebug` |
|
||||||
| 4 | `defaultTo` in DSL | `dimension("env", defaultTo = "prod")` |
|
| 4 | `defaultTo` in DSL | `dimension("env", defaultTo = "prod")` |
|
||||||
| - | Omitted silently | No variant → no nested object generated |
|
| - | Omitted silently | No variant → no nested object generated |
|
||||||
|
|
||||||
@@ -276,9 +366,48 @@ matches a whole segment of the task name, never a plain substring:
|
|||||||
- `assembleProdRelease` matches variant `prod`, **not** `preprod`
|
- `assembleProdRelease` matches variant `prod`, **not** `preprod`
|
||||||
- `assembleDevelopRelease` does **not** match variant `dev`
|
- `assembleDevelopRelease` does **not** match variant `dev`
|
||||||
|
|
||||||
When several variants match and every match is a substring of the longest one
|
When several variants match within a single task and every match is a substring
|
||||||
(e.g. `prod` inside `preProd` for `assemblePreProdRelease`), the longest wins.
|
of the longest one (e.g. `prod` inside `preProd` for `assemblePreProdRelease`),
|
||||||
Genuinely ambiguous matches are skipped with a warning.
|
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
|
### Selection logging
|
||||||
|
|
||||||
@@ -300,9 +429,19 @@ Build type is resolved in priority order:
|
|||||||
| Priority | Source | Example |
|
| Priority | Source | Example |
|
||||||
|----------|---------------------|-----------------------------------|
|
|----------|---------------------|-----------------------------------|
|
||||||
| 1 | Explicit property | `-Pkonfig.buildtype=DEBUG` |
|
| 1 | Explicit property | `-Pkonfig.buildtype=DEBUG` |
|
||||||
| 2 | Task-name detection | Running `assembleDebug` → `DEBUG` |
|
| 2 | Android variant metadata, or task names for JVM/KMP | A debuggable Android variant → `DEBUG` |
|
||||||
| - | Default | `RELEASE` |
|
| - | 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Gradle properties
|
## Gradle properties
|
||||||
@@ -367,6 +506,12 @@ val activeEnv: String? = konfig.getCurrentDimension("env") // "prod", or null
|
|||||||
| `konfig.getCurrentDimension(name)` | `String?` | Active variant, or `null` when skipped/unknown |
|
| `konfig.getCurrentDimension(name)` | `String?` | Active variant, or `null` when skipped/unknown |
|
||||||
| `konfig.currentDimension(name)` | `Provider<String>` | Lazy variant (absent 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
|
## Using Gradle providers as field values
|
||||||
@@ -390,16 +535,29 @@ The generated directory (`build/generated/konfig/`) is automatically added as a
|
|||||||
|
|
||||||
- `org.jetbrains.kotlin.multiplatform` → `commonMain`
|
- `org.jetbrains.kotlin.multiplatform` → `commonMain`
|
||||||
- `org.jetbrains.kotlin.jvm` → `main`
|
- `org.jetbrains.kotlin.jvm` → `main`
|
||||||
- `org.jetbrains.kotlin.android` → `main`
|
- `com.android.application` / `com.android.library` → one generated directory per
|
||||||
- `com.android.application` / `com.android.library` → all variants via the
|
variant, including projects using `org.jetbrains.kotlin.android` or AGP built-in Kotlin
|
||||||
`androidComponents` Sources API (AGP 8.1+ required)
|
|
||||||
|
|
||||||
The Android wiring uses the modern variant Sources API instead of the
|
The Android wiring uses the modern variant Sources API instead of the
|
||||||
`AndroidSourceSet` DSL, so it keeps working on **AGP 9.2+** where
|
`AndroidSourceSet` DSL, so it keeps working on **AGP 9.2+** where
|
||||||
`android.sourceset.disallowProvider` defaults to `true` (passing providers to
|
`android.sourceset.disallowProvider` defaults to `true` (passing providers to
|
||||||
the source-set DSL is rejected). No legacy flag needed.
|
the source-set DSL is rejected). No legacy flag needed.
|
||||||
|
|
||||||
The `generateKonfig` task is automatically wired as a dependency of all `compileKotlin*` and `sourcesJar` tasks.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ val functionalTestTask = tasks.register<Test>("functionalTest") {
|
|||||||
group = "verification"
|
group = "verification"
|
||||||
testClassesDirs = functionalTest.output.classesDirs
|
testClassesDirs = functionalTest.output.classesDirs
|
||||||
classpath = functionalTest.runtimeClasspath
|
classpath = functionalTest.runtimeClasspath
|
||||||
|
listOf("kotlinVersion", "androidVersion").forEach { setting ->
|
||||||
|
providers.gradleProperty("konfig.test.$setting").orNull?.let {
|
||||||
|
systemProperty("konfig.test.$setting", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.check { dependsOn(functionalTestTask) }
|
tasks.check { dependsOn(functionalTestTask) }
|
||||||
|
|||||||
+6
-6
@@ -7,9 +7,9 @@ org.gradle.configuration-cache=true
|
|||||||
# MARK: Publishing
|
# MARK: Publishing
|
||||||
# =========================================================
|
# =========================================================
|
||||||
|
|
||||||
konfig.version=0.6.0
|
konfig.version=0.7.0
|
||||||
konfig.artifactId=konfig-gradle-plugin
|
konfig.artifactId=konfig-gradle-plugin
|
||||||
konfig.publish.url=https://maven.pkg.github.com/bitsycore/bitsykonfig-gradle-plugin
|
konfig.publish.url=https://maven.pkg.github.com/bitsycore/bitsykonfig
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# MARK: POM
|
# 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.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.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.id=bitsycore
|
||||||
konfig.pom.developer.name=bitsycore
|
konfig.pom.developer.name=bitsycore
|
||||||
konfig.pom.developer.url=https://github.com/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.connection=scm:git:git://github.com/bitsycore/bitsykonfig.git
|
||||||
konfig.pom.scm.developerConnection=scm:git:ssh://github.com/bitsycore/bitsykonfig-gradle-plugin.git
|
konfig.pom.scm.developerConnection=scm:git:ssh://github.com/bitsycore/bitsykonfig.git
|
||||||
konfig.pom.scm.url=https://github.com/bitsycore/bitsykonfig-gradle-plugin
|
konfig.pom.scm.url=https://github.com/bitsycore/bitsykonfig
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# MARK: Credentials
|
# MARK: Credentials
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ import kotlin.io.path.createTempDirectory
|
|||||||
*/
|
*/
|
||||||
abstract class FunctionalTestBase {
|
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.
|
* 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")
|
projectDir.resolve("settings.gradle.kts")
|
||||||
.writeText("""rootProject.name = "test-project"""")
|
.writeText("""rootProject.name = "test-project"""")
|
||||||
val run = { args: List<String> ->
|
val run = { args: List<String> ->
|
||||||
GradleRunner.create()
|
runner(projectDir, args).build()
|
||||||
.withProjectDir(projectDir)
|
|
||||||
.withPluginClasspath()
|
|
||||||
.withArguments(args)
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
block(projectDir, run)
|
block(projectDir, run)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -49,11 +48,7 @@ abstract class FunctionalTestBase {
|
|||||||
projectDir.resolve("settings.gradle.kts")
|
projectDir.resolve("settings.gradle.kts")
|
||||||
.writeText("""rootProject.name = "test-project"""")
|
.writeText("""rootProject.name = "test-project"""")
|
||||||
val run = { args: List<String> ->
|
val run = { args: List<String> ->
|
||||||
GradleRunner.create()
|
runner(projectDir, args).buildAndFail()
|
||||||
.withProjectDir(projectDir)
|
|
||||||
.withPluginClasspath()
|
|
||||||
.withArguments(args)
|
|
||||||
.buildAndFail()
|
|
||||||
}
|
}
|
||||||
block(projectDir, run)
|
block(projectDir, run)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -63,9 +58,35 @@ abstract class FunctionalTestBase {
|
|||||||
|
|
||||||
/** Finds the single generated `.kt` file inside the project's output tree. */
|
/** Finds the single generated `.kt` file inside the project's output tree. */
|
||||||
protected fun File.generatedFile(): File =
|
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. */
|
/** Writes a minimal `build.gradle.kts` applying the konfig plugin. */
|
||||||
protected fun File.writeBuildGradle(content: String) =
|
protected fun File.writeBuildGradle(content: String) =
|
||||||
resolve("build.gradle.kts").writeText(content.trimIndent())
|
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")
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\""))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,24 @@ package com.bitsycore.konfig
|
|||||||
|
|
||||||
import com.bitsycore.konfig.types.BuildType
|
import com.bitsycore.konfig.types.BuildType
|
||||||
import com.bitsycore.konfig.types.Visibility
|
import com.bitsycore.konfig.types.Visibility
|
||||||
|
import com.bitsycore.konfig.types.isValidKotlinIdentifier
|
||||||
|
import com.bitsycore.konfig.types.toVariantConstName
|
||||||
|
import com.bitsycore.konfig.types.toKotlinStringLiteral
|
||||||
import org.gradle.api.DefaultTask
|
import org.gradle.api.DefaultTask
|
||||||
import org.gradle.api.GradleException
|
import org.gradle.api.GradleException
|
||||||
import org.gradle.api.file.DirectoryProperty
|
import org.gradle.api.file.DirectoryProperty
|
||||||
|
import org.gradle.api.file.RegularFileProperty
|
||||||
import org.gradle.api.provider.ListProperty
|
import org.gradle.api.provider.ListProperty
|
||||||
import org.gradle.api.provider.MapProperty
|
import org.gradle.api.provider.MapProperty
|
||||||
import org.gradle.api.provider.Property
|
import org.gradle.api.provider.Property
|
||||||
import org.gradle.api.provider.SetProperty
|
import org.gradle.api.provider.SetProperty
|
||||||
import org.gradle.api.tasks.CacheableTask
|
import org.gradle.api.tasks.CacheableTask
|
||||||
import org.gradle.api.tasks.Input
|
import org.gradle.api.tasks.Input
|
||||||
import org.gradle.api.tasks.OutputDirectory
|
import org.gradle.api.tasks.Internal
|
||||||
|
import org.gradle.api.tasks.OutputFile
|
||||||
import org.gradle.api.tasks.TaskAction
|
import org.gradle.api.tasks.TaskAction
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the `BuildKonfig` (or custom named) Kotlin object.
|
* Generates the `BuildKonfig` (or custom named) Kotlin object.
|
||||||
@@ -20,6 +27,8 @@ import org.gradle.api.tasks.TaskAction
|
|||||||
* Fields are stored in flat [MapProperty]<String, String> maps where each value is
|
* Fields are stored in flat [MapProperty]<String, String> maps where each value is
|
||||||
* type-encoded as `"<TYPE>:<literal>"` (e.g. `"String:hello"`, `"Int:42"`).
|
* type-encoded as `"<TYPE>:<literal>"` (e.g. `"String:hello"`, `"Int:42"`).
|
||||||
* Dimension fields use keys of the form `"<dimName>|<fieldName>"`.
|
* 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.
|
* This collapses 6 separate per-type maps down to one per scope.
|
||||||
*/
|
*/
|
||||||
@CacheableTask
|
@CacheableTask
|
||||||
@@ -71,7 +80,25 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
/** Dimension fields: `"<dimName>|<fieldName>" -> "TYPE:value"`. */
|
/** Dimension fields: `"<dimName>|<fieldName>" -> "TYPE:value"`. */
|
||||||
@get:Input abstract val dimensionFields: MapProperty<String, String>
|
@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
|
@TaskAction
|
||||||
fun generate() {
|
fun generate() {
|
||||||
@@ -90,9 +117,7 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
validate(mod, objName, pkg)
|
validate(mod, objName, pkg)
|
||||||
|
|
||||||
val outDir = outputDirectory.get().asFile
|
val outDir = outputDirectory.get().asFile
|
||||||
if (outDir.exists()) outDir.deleteRecursively()
|
|
||||||
val pkgDir = outDir.resolve(pkg.replace('.', '/'))
|
val pkgDir = outDir.resolve(pkg.replace('.', '/'))
|
||||||
pkgDir.mkdirs()
|
|
||||||
|
|
||||||
val gFields = globalFields.get()
|
val gFields = globalFields.get()
|
||||||
val dFields = dimensionFields.get()
|
val dFields = dimensionFields.get()
|
||||||
@@ -101,7 +126,7 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
val dimVars = dimensionActiveVariants.get()
|
val dimVars = dimensionActiveVariants.get()
|
||||||
val flatDims = flatDimensionNames.get()
|
val flatDims = flatDimensionNames.get()
|
||||||
|
|
||||||
checkRootCollisions(mod, dimNames.filter { it in flatDims }, gFields, dFields, dimVars)
|
checkRootCollisions(mod, dimNames, gFields, dFields, dimVars)
|
||||||
|
|
||||||
val content = buildString {
|
val content = buildString {
|
||||||
appendLine("""@file:Suppress("RedundantVisibilityModifier")""")
|
appendLine("""@file:Suppress("RedundantVisibilityModifier")""")
|
||||||
@@ -115,7 +140,7 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
appendLine("${visPrefix}object $objName {")
|
appendLine("${visPrefix}object $objName {")
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine(""" const val BUILD_TYPE: String = "${btVal.name.lowercase()}"""")
|
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)
|
if (isDebug)
|
||||||
appendLine(" inline val IS_DEBUG: Boolean get() = true")
|
appendLine(" inline val IS_DEBUG: Boolean get() = true")
|
||||||
else
|
else
|
||||||
@@ -136,17 +161,17 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
if (dimName in flatDims) {
|
if (dimName in flatDims) {
|
||||||
// Flat dimension: fields live directly on the root object.
|
// Flat dimension: fields live directly on the root object.
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine(" // dimension: $dimName (flat), variant: $activeVariant")
|
appendLine(" // dimension: ${dimName.safeComment()} (flat), variant: ${activeVariant.safeComment()}")
|
||||||
appendLine(" const val ${dimName.toVariantConstName()}: String = \"$activeVariant\"")
|
appendLine(" const val ${dimName.toVariantConstName()}: String = ${activeVariant.toKotlinStringLiteral()}")
|
||||||
if (fields.isNotEmpty()) {
|
if (fields.isNotEmpty()) {
|
||||||
appendEncodedFields(" ", fields, btVal)
|
appendEncodedFields(" ", fields, btVal)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val dimObjName = dimObjN[dimName] ?: continue
|
val dimObjName = dimObjN[dimName] ?: continue
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine(" ${visPrefix}object $dimObjName /*$dimName*/ {")
|
appendLine(" ${visPrefix}object $dimObjName /*${dimName.safeComment()}*/ {")
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine(" const val VARIANT: String = \"$activeVariant\"")
|
appendLine(" const val VARIANT: String = ${activeVariant.toKotlinStringLiteral()}")
|
||||||
if (fields.isNotEmpty()) {
|
if (fields.isNotEmpty()) {
|
||||||
appendLine()
|
appendLine()
|
||||||
appendEncodedFields(" ", fields, btVal)
|
appendEncodedFields(" ", fields, btVal)
|
||||||
@@ -161,8 +186,30 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
appendLine("}")
|
appendLine("}")
|
||||||
}
|
}
|
||||||
|
|
||||||
val outFile = pkgDir.resolve("$objName.kt")
|
val outFile = generatedFile.get().asFile
|
||||||
outFile.writeText(content)
|
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"
|
val dimSummary = if (dimNames.isEmpty()) "no dimensions"
|
||||||
else "${dimNames.size} dimension(s): ${dimNames.joinToString { "'$it'" }}"
|
else "${dimNames.size} dimension(s): ${dimNames.joinToString { "'$it'" }}"
|
||||||
@@ -199,9 +246,14 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pkg.split(".").forEach { segment ->
|
pkg.split(".").forEach { segment ->
|
||||||
if (segment.isEmpty() || !segment.all { it.isLetterOrDigit() || it == '_' })
|
if (!segment.isValidKotlinIdentifier())
|
||||||
errors += "package segment '$segment' in '$pkg' is not valid"
|
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()) {
|
if (errors.isNotEmpty()) {
|
||||||
throw GradleException(buildString {
|
throw GradleException(buildString {
|
||||||
@@ -212,55 +264,61 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
// MARK: Flat dimension collision detection
|
// MARK: Generated scope collision detection
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fails the build when a flat dimension would generate a root-level name that
|
* Checks built-in constants, global/flat fields, nested object names and VARIANT.
|
||||||
* already exists (base constants, global fields, or another flat dimension).
|
|
||||||
*/
|
*/
|
||||||
private fun checkRootCollisions(
|
private fun checkRootCollisions(
|
||||||
mod: String,
|
mod: String,
|
||||||
activeFlatDims: List<String>,
|
activeDims: List<String>,
|
||||||
gFields: Map<String, String>,
|
gFields: Map<String, String>,
|
||||||
dFields: Map<String, String>,
|
dFields: Map<String, String>,
|
||||||
dimVars: Map<String, String>,
|
dimVars: Map<String, String>,
|
||||||
) {
|
) {
|
||||||
if (activeFlatDims.isEmpty()) return
|
|
||||||
|
|
||||||
val owners = mutableMapOf<String, String>()
|
val owners = mutableMapOf<String, String>()
|
||||||
listOf("BUILD_TYPE", "MODULE_NAME", "IS_DEBUG").forEach { owners[it] = "built-in constant" }
|
listOf("BUILD_TYPE", "MODULE_NAME", "IS_DEBUG").forEach { owners[it] = "built-in constant" }
|
||||||
gFields.keys.forEach { owners[it] = "global field" }
|
|
||||||
|
|
||||||
val errors = mutableListOf<String>()
|
val errors = mutableListOf<String>()
|
||||||
for (dimName in activeFlatDims) {
|
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
|
if (dimVars[dimName] == null) continue
|
||||||
val prefix = "$dimName|"
|
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 {
|
val rootNames = buildList {
|
||||||
add(dimName.toVariantConstName())
|
add(dimName.toVariantConstName())
|
||||||
addAll(dFields.keys.filter { it.startsWith(prefix) }.map { it.removePrefix(prefix) })
|
addAll(dFields.keys.filter { it.startsWith(prefix) }.map { it.removePrefix(prefix) })
|
||||||
}
|
}
|
||||||
for (name in rootNames) {
|
for (name in rootNames) {
|
||||||
val existing = owners[name]
|
claim(name, "flat dimension '$dimName'")
|
||||||
if (existing != null) {
|
|
||||||
errors += "flat dimension '$dimName' generates '$name' which collides with $existing"
|
|
||||||
} else {
|
|
||||||
owners[name] = "flat dimension '$dimName'"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.isNotEmpty()) {
|
if (errors.isNotEmpty()) {
|
||||||
throw GradleException(buildString {
|
throw GradleException(buildString {
|
||||||
appendLine("konfig [$mod]: flat dimension name collisions:")
|
appendLine("konfig [$mod]: name collisions:")
|
||||||
errors.forEach { appendLine(" - $it") }
|
errors.forEach { appendLine(" - $it") }
|
||||||
}.trimEnd())
|
}.trimEnd())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `"my-env"` → `"MY_ENV_VARIANT"` - root constant holding the active variant of a flat dimension. */
|
private fun String.safeComment(): String = toKotlinStringLiteral().removeSurrounding("\"")
|
||||||
private fun String.toVariantConstName(): String =
|
.replace("/*", "/ *").replace("*/", "* /")
|
||||||
uppercase().replace(Regex("[^A-Z0-9]"), "_") + "_VARIANT"
|
|
||||||
|
private companion object {
|
||||||
|
const val GENERATED_MARKER = "// Generated by buildkonfig-gradle-plugin"
|
||||||
|
}
|
||||||
|
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
// MARK: Helpers
|
// MARK: Helpers
|
||||||
@@ -282,32 +340,17 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
BuildType.RELEASE -> """${indent}const val $name: Boolean = $raw"""
|
BuildType.RELEASE -> """${indent}const val $name: Boolean = $raw"""
|
||||||
}
|
}
|
||||||
"Int" -> """${indent}const val $name: Int = $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()}"""
|
"Float" -> """${indent}const val $name: Float = ${raw.toFloat().toKotlinFloat()}"""
|
||||||
"Double" -> """${indent}const val $name: Double = ${raw.toDouble().toKotlinDouble()}"""
|
"Double" -> """${indent}const val $name: Double = ${raw.toDouble().toKotlinDouble()}"""
|
||||||
|
"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
|
else -> return@forEach // unknown type - skip
|
||||||
}
|
}
|
||||||
appendLine(line)
|
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 {
|
private fun Float.toKotlinFloat(): String = when {
|
||||||
isNaN() -> "Float.NaN"
|
isNaN() -> "Float.NaN"
|
||||||
this == Float.POSITIVE_INFINITY -> "Float.POSITIVE_INFINITY"
|
this == Float.POSITIVE_INFINITY -> "Float.POSITIVE_INFINITY"
|
||||||
@@ -326,8 +369,4 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.isValidKotlinIdentifier(): Boolean =
|
|
||||||
isNotEmpty()
|
|
||||||
&& first().let { it.isLetter() || it == '_' }
|
|
||||||
&& all { it.isLetterOrDigit() || it == '_' }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.bitsycore.konfig.configs.FieldConfig
|
|||||||
import com.bitsycore.konfig.configs.VariantConfig
|
import com.bitsycore.konfig.configs.VariantConfig
|
||||||
import com.bitsycore.konfig.types.BuildType
|
import com.bitsycore.konfig.types.BuildType
|
||||||
import com.bitsycore.konfig.types.KonfigDsl
|
import com.bitsycore.konfig.types.KonfigDsl
|
||||||
|
import com.bitsycore.konfig.types.isValidDimensionName
|
||||||
import com.bitsycore.konfig.types.Visibility
|
import com.bitsycore.konfig.types.Visibility
|
||||||
import org.gradle.api.file.DirectoryProperty
|
import org.gradle.api.file.DirectoryProperty
|
||||||
import org.gradle.api.model.ObjectFactory
|
import org.gradle.api.model.ObjectFactory
|
||||||
@@ -48,6 +49,18 @@ abstract class KonfigExtension @Inject constructor(
|
|||||||
/** Output directory - kept as [DirectoryProperty] for full Gradle lazy semantics. */
|
/** Output directory - kept as [DirectoryProperty] for full Gradle lazy semantics. */
|
||||||
val outputDir: DirectoryProperty = objects.directoryProperty()
|
val outputDir: DirectoryProperty = objects.directoryProperty()
|
||||||
|
|
||||||
|
/** 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
|
// MARK: DSL Internal
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
@@ -71,6 +84,7 @@ abstract class KonfigExtension @Inject constructor(
|
|||||||
defaultTo: String? = null,
|
defaultTo: String? = null,
|
||||||
config: DimensionConfig.() -> Unit
|
config: DimensionConfig.() -> Unit
|
||||||
) {
|
) {
|
||||||
|
require(name.isValidDimensionName()) { "konfig: invalid dimension name '$name' (blank, delimiter, or control character)" }
|
||||||
require(dimensions.none { it.dimensionName == name }) {
|
require(dimensions.none { it.dimensionName == name }) {
|
||||||
"konfig: dimension '$name' is already declared"
|
"konfig: dimension '$name' is already declared"
|
||||||
}
|
}
|
||||||
@@ -92,6 +106,7 @@ abstract class KonfigExtension @Inject constructor(
|
|||||||
defaultTo: String? = null,
|
defaultTo: String? = null,
|
||||||
config: DimensionConfig.() -> Unit
|
config: DimensionConfig.() -> Unit
|
||||||
) {
|
) {
|
||||||
|
require(name.isValidDimensionName()) { "konfig: invalid dimension name '$name' (blank, delimiter, or control character)" }
|
||||||
require(dimensions.none { it.dimensionName == name }) {
|
require(dimensions.none { it.dimensionName == name }) {
|
||||||
"konfig: dimension '$name' is already declared"
|
"konfig: dimension '$name' is already declared"
|
||||||
}
|
}
|
||||||
@@ -100,14 +115,14 @@ abstract class KonfigExtension @Inject constructor(
|
|||||||
dimensions.add(d)
|
dimensions.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
inline fun <reified T : Any> field(
|
inline fun <reified T> field(
|
||||||
name: String,
|
name: String,
|
||||||
default: T,
|
default: T,
|
||||||
) = globalScope.field(name, default)
|
) = globalScope.field(name, default)
|
||||||
|
|
||||||
inline fun <reified T : Any> field(
|
inline fun <reified T> field(
|
||||||
name: String,
|
name: String,
|
||||||
default: Provider<T>,
|
default: Provider<T & Any>,
|
||||||
) = globalScope.field(name, default)
|
) = globalScope.field(name, default)
|
||||||
|
|
||||||
fun debug(block: BuildTypedFieldDeclScope.() -> Unit) = globalScope.debug(block)
|
fun debug(block: BuildTypedFieldDeclScope.() -> Unit) = globalScope.debug(block)
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ package com.bitsycore.konfig
|
|||||||
|
|
||||||
import com.android.build.api.variant.AndroidComponentsExtension
|
import com.android.build.api.variant.AndroidComponentsExtension
|
||||||
import com.bitsycore.konfig.configs.DimensionConfig
|
import com.bitsycore.konfig.configs.DimensionConfig
|
||||||
|
import com.bitsycore.konfig.configs.NullFieldValue
|
||||||
import com.bitsycore.konfig.configs.FieldConfig
|
import com.bitsycore.konfig.configs.FieldConfig
|
||||||
import com.bitsycore.konfig.types.BuildType
|
import com.bitsycore.konfig.types.BuildType
|
||||||
import com.bitsycore.konfig.types.Visibility
|
import com.bitsycore.konfig.types.Visibility
|
||||||
|
import com.bitsycore.konfig.types.CollectionLiteral
|
||||||
import com.bitsycore.konfig.types.containsWordCamelCase
|
import com.bitsycore.konfig.types.containsWordCamelCase
|
||||||
|
import com.bitsycore.konfig.types.isValidKotlinIdentifier
|
||||||
import org.gradle.api.Plugin
|
import org.gradle.api.Plugin
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
import org.gradle.api.provider.Provider
|
import org.gradle.api.provider.Provider
|
||||||
|
import org.gradle.api.tasks.TaskProvider
|
||||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
|
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
|
||||||
import org.jetbrains.kotlin.gradle.dsl.KotlinSingleTargetExtension
|
import org.jetbrains.kotlin.gradle.dsl.KotlinSingleTargetExtension
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -21,6 +25,8 @@ private data class DimensionContext(
|
|||||||
val fileProps: Map<String, String>,
|
val fileProps: Map<String, String>,
|
||||||
val flavorDetect: Boolean,
|
val flavorDetect: Boolean,
|
||||||
val taskNames: List<String>,
|
val taskNames: List<String>,
|
||||||
|
val androidFlavors: Map<String, String> = emptyMap(),
|
||||||
|
val strict: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
class KonfigPlugin : Plugin<Project> {
|
class KonfigPlugin : Plugin<Project> {
|
||||||
@@ -78,11 +84,16 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
val rawBuildTypeProp = project.providers.gradleProperty("konfig.buildtype")
|
val rawBuildTypeProp = project.providers.gradleProperty("konfig.buildtype")
|
||||||
val buildTypeProvider: Provider<BuildType> = rawBuildTypeProp
|
val strictProvider = project.providers.provider { extension.strictResolution }
|
||||||
.map { BuildType.resolve(it) ?: BuildType.RELEASE }
|
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(
|
.orElse(
|
||||||
buildTypeDetectionEnabled.zip(taskNamesProvider) { enabled, names ->
|
buildTypeDetectionEnabled.zip(taskNamesProvider) { enabled, names ->
|
||||||
if (enabled) BuildType.resolve(names.joinToString(" ")) ?: BuildType.RELEASE
|
if (enabled) BuildType.resolveTasks(names) ?: BuildType.RELEASE
|
||||||
else BuildType.RELEASE
|
else BuildType.RELEASE
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -104,7 +115,7 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
!enabled -> "detection disabled by konfig.android.buildtypedetection=false, using RELEASE"
|
!enabled -> "detection disabled by konfig.android.buildtypedetection=false, using RELEASE"
|
||||||
names.isEmpty() -> "no tasks running, using RELEASE"
|
names.isEmpty() -> "no tasks running, using RELEASE"
|
||||||
else -> {
|
else -> {
|
||||||
val resolved = BuildType.resolve(names.joinToString(" "))
|
val resolved = BuildType.resolveTasks(names)
|
||||||
if (resolved != null)
|
if (resolved != null)
|
||||||
"task-name detection matched ${resolved.name.lowercase()} in [${names.joinToString()}]"
|
"task-name detection matched ${resolved.name.lowercase()} in [${names.joinToString()}]"
|
||||||
else
|
else
|
||||||
@@ -121,17 +132,7 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
.zip(flavorDetectionEnabled) { (gp, fp), fe -> Triple(gp, fp, fe) }
|
.zip(flavorDetectionEnabled) { (gp, fp), fe -> Triple(gp, fp, fe) }
|
||||||
.zip(taskNamesProvider) { (gp, fp, fe), names ->
|
.zip(taskNamesProvider) { (gp, fp, fe), names ->
|
||||||
DimensionContext(gradleProps = gp, fileProps = fp, flavorDetect = fe, taskNames = 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)
|
// MARK: Build-script query wiring (konfig.isDebug / konfig.getCurrentDimension)
|
||||||
@@ -141,7 +142,7 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
extension.dimensionResolverInternal = { dimName ->
|
extension.dimensionResolverInternal = { dimName ->
|
||||||
combinedProps.map { ctx ->
|
combinedProps.map { ctx ->
|
||||||
extension.dimensions.firstOrNull { it.dimensionName == dimName }?.let { dim ->
|
extension.dimensions.firstOrNull { it.dimensionName == dimName }?.let { dim ->
|
||||||
resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames)
|
resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames, ctx.androidFlavors, ctx.strict)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,9 +153,30 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
|
|
||||||
val forceRegen = project.providers.gradleProperty("konfig.force").isPresent
|
val forceRegen = project.providers.gradleProperty("konfig.force").isPresent
|
||||||
|
|
||||||
|
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
|
// Untracked logging task: runs on EVERY build (even fully cached ones) so the
|
||||||
// selected build type / dimension variants always appear in the logs.
|
// selected build type / dimension variants always appear in the logs.
|
||||||
val infoTask = project.tasks.register("konfigInfo", KonfigInfoTask::class.java) {
|
val infoTask = project.tasks.register(infoName, KonfigInfoTask::class.java) {
|
||||||
description = "Prints the resolved konfig build type and dimension variants."
|
description = "Prints the resolved konfig build type and dimension variants."
|
||||||
group = "konfig"
|
group = "konfig"
|
||||||
moduleName.set(project.name)
|
moduleName.set(project.name)
|
||||||
@@ -163,7 +185,7 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
dimensionResolutionLog.set(dimensionResolutionLogProvider)
|
dimensionResolutionLog.set(dimensionResolutionLogProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
val generateTask = project.tasks.register("generateKonfig", GenerateKonfigTask::class.java).apply {
|
return project.tasks.register(taskName, GenerateKonfigTask::class.java).apply {
|
||||||
configure {
|
configure {
|
||||||
if (forceRegen) outputs.upToDateWhen { false }
|
if (forceRegen) outputs.upToDateWhen { false }
|
||||||
dependsOn(infoTask)
|
dependsOn(infoTask)
|
||||||
@@ -183,18 +205,19 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
outputDirectory.set(extension.outputDir)
|
outputDirectory.set(extension.outputDir)
|
||||||
|
ownershipFile.set(project.layout.buildDirectory.file("konfig-state/$taskName.txt"))
|
||||||
objectPackage.set(extension.objectPackageProp)
|
objectPackage.set(extension.objectPackageProp)
|
||||||
objectName.set(extension.objectNameProp)
|
objectName.set(extension.objectNameProp)
|
||||||
objectVisibility.set(extension.objectVisibilityProp)
|
objectVisibility.set(extension.objectVisibilityProp)
|
||||||
|
|
||||||
// ── Global fields ─────────────────────────────────────────────
|
// ── Global fields ─────────────────────────────────────────────
|
||||||
globalFields.set(resolveFields(buildTypeProvider, extension.globalFields))
|
globalFields.set(resolveFields(buildTypeProvider, extension.globalFields, extension))
|
||||||
|
|
||||||
// ── Dimension metadata ────────────────────────────────────────
|
// ── Dimension metadata ────────────────────────────────────────
|
||||||
activeDimensionNames.set(
|
activeDimensionNames.set(
|
||||||
buildTypeProvider.zip(combinedProps) { _, ctx ->
|
buildTypeProvider.zip(combinedProps) { _, ctx ->
|
||||||
extension.dimensions.mapNotNull { dim ->
|
extension.dimensions.mapNotNull { dim ->
|
||||||
resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames)
|
resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames, ctx.androidFlavors, ctx.strict)
|
||||||
?: return@mapNotNull null
|
?: return@mapNotNull null
|
||||||
dim.dimensionName
|
dim.dimensionName
|
||||||
}
|
}
|
||||||
@@ -208,7 +231,7 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
dimensionActiveVariants.set(
|
dimensionActiveVariants.set(
|
||||||
buildTypeProvider.zip(combinedProps) { _, ctx ->
|
buildTypeProvider.zip(combinedProps) { _, ctx ->
|
||||||
extension.dimensions.mapNotNull { dim ->
|
extension.dimensions.mapNotNull { dim ->
|
||||||
val sv = resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames)
|
val sv = resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames, ctx.androidFlavors, ctx.strict)
|
||||||
?: return@mapNotNull null
|
?: return@mapNotNull null
|
||||||
dim.dimensionName to sv
|
dim.dimensionName to sv
|
||||||
}.toMap()
|
}.toMap()
|
||||||
@@ -219,6 +242,8 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
dimensionFields.set(resolveDimensionFields(buildTypeProvider, combinedProps, extension))
|
dimensionFields.set(resolveDimensionFields(buildTypeProvider, combinedProps, extension))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
val generateTask = registerGeneration("generateKonfig", "konfigInfo", buildTypeProvider, buildTypeSourceProvider, combinedProps)
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// MARK: Auto Sourceset
|
// MARK: Auto Sourceset
|
||||||
@@ -226,15 +251,11 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
|
|
||||||
project.plugins.withId("org.jetbrains.kotlin.multiplatform") {
|
project.plugins.withId("org.jetbrains.kotlin.multiplatform") {
|
||||||
project.extensions.findByType(KotlinMultiplatformExtension::class.java)
|
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.plugins.withId("org.jetbrains.kotlin.jvm") {
|
||||||
project.extensions.findByType(KotlinSingleTargetExtension::class.java)
|
project.extensions.findByType(KotlinSingleTargetExtension::class.java)
|
||||||
?.sourceSets?.findByName("main")?.kotlin?.srcDir(extension.outputDir)
|
?.sourceSets?.findByName("main")?.kotlin?.srcDir(generateTask.flatMap { it.sourceDirectory })
|
||||||
}
|
|
||||||
project.plugins.withId("org.jetbrains.kotlin.android") {
|
|
||||||
project.extensions.findByType(KotlinSingleTargetExtension::class.java)
|
|
||||||
?.sourceSets?.findByName("main")?.kotlin?.srcDir(extension.outputDir)
|
|
||||||
}
|
}
|
||||||
listOf("com.android.application", "com.android.library").forEach { androidPluginId ->
|
listOf("com.android.application", "com.android.library").forEach { androidPluginId ->
|
||||||
project.plugins.withId(androidPluginId) {
|
project.plugins.withId(androidPluginId) {
|
||||||
@@ -243,27 +264,42 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
// Register the generated directory through the variant Sources API instead.
|
// Register the generated directory through the variant Sources API instead.
|
||||||
project.extensions.findByType(AndroidComponentsExtension::class.java)
|
project.extensions.findByType(AndroidComponentsExtension::class.java)
|
||||||
?.onVariants { variant ->
|
?.onVariants { variant ->
|
||||||
val vOutDir = extension.outputDir.get().asFile
|
// KMP shares one object through the task-backed commonMain source directory.
|
||||||
vOutDir.mkdirs()
|
if (project.plugins.hasPlugin("org.jetbrains.kotlin.multiplatform")) return@onVariants
|
||||||
variant.sources.kotlin?.addStaticSourceDirectory(vOutDir.absolutePath)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==============================================
|
// ==============================================
|
||||||
@@ -279,12 +315,15 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
gradleProps: Map<String, String>,
|
gradleProps: Map<String, String>,
|
||||||
fileProps: Map<String, String>,
|
fileProps: Map<String, String>,
|
||||||
flavorDetect: Boolean,
|
flavorDetect: Boolean,
|
||||||
taskNames: List<String>
|
taskNames: List<String>,
|
||||||
|
androidFlavors: Map<String, String> = emptyMap(),
|
||||||
|
strict: Boolean = false,
|
||||||
): String? {
|
): 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)
|
val parts = encoded.split("\t", limit = 3)
|
||||||
return when (parts[0]) {
|
return when (parts[0]) {
|
||||||
"OK" -> parts.getOrNull(1)?.takeIf { it.isNotEmpty() }
|
"OK" -> parts.getOrNull(1)?.takeIf { it.isNotEmpty() }
|
||||||
|
"ERROR" -> throw org.gradle.api.GradleException("konfig: dimension '${dim.dimensionName}': ${parts.getOrElse(2) { "resolution failed" }}")
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,7 +343,20 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
gradleProps: Map<String, String>,
|
gradleProps: Map<String, String>,
|
||||||
fileProps: Map<String, String>,
|
fileProps: Map<String, String>,
|
||||||
flavorDetect: Boolean,
|
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 {
|
): String {
|
||||||
// Validate defaultTo at resolution time
|
// Validate defaultTo at resolution time
|
||||||
if (dim.defaultVariant != null && !dim.variants.containsKey(dim.defaultVariant)) {
|
if (dim.defaultVariant != null && !dim.variants.containsKey(dim.defaultVariant)) {
|
||||||
@@ -336,23 +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 && taskNames.isNotEmpty()) {
|
if (flavorDetect) {
|
||||||
val rawMatches = dim.variants.keys.filter { variant ->
|
val flavor = androidFlavors[dim.androidDimension]
|
||||||
taskNames.any { task -> task.containsWordCamelCase(variant) }
|
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}'"
|
||||||
}
|
}
|
||||||
// When every match is a substring of the longest one (e.g. 'prod' inside
|
|
||||||
// 'preProd' for task assemblePreProdRelease), the longest is the real one.
|
// Shared generation: task-name detection
|
||||||
val matches = if (rawMatches.size > 1) {
|
if (flavorDetect && taskNames.isNotEmpty()) {
|
||||||
val longest = rawMatches.maxBy { it.length }
|
// Resolve overlap within EACH task; separate prod and preProd tasks conflict.
|
||||||
if (rawMatches.all { it == longest || longest.contains(it, ignoreCase = true) }) listOf(longest)
|
val matches = taskNames.flatMap { qualifiedTask ->
|
||||||
else rawMatches
|
val task = qualifiedTask.substringAfterLast(':')
|
||||||
} else rawMatches
|
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) {
|
when (matches.size) {
|
||||||
1 -> return "OK\t${matches.first()}\ttask-name detection: '${matches.first()}' " +
|
1 -> return "OK\t${matches.first()}\ttask-name detection: '${matches.first()}' " +
|
||||||
"found in [${taskNames.joinToString()}]"
|
"found in [${taskNames.joinToString()}]"
|
||||||
in 2..Int.MAX_VALUE -> return "WARN_AMBIGUOUS\t\ttask names matched multiple variants " +
|
in 2..Int.MAX_VALUE -> return "ERROR\t\tconflicting variants ${matches.sorted()} " +
|
||||||
"${matches.sorted()} in [${taskNames.joinToString()}] -- dimension skipped"
|
"in tasks [${taskNames.joinToString()}]. Run separate builds or select -P$propKey explicitly."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,8 +432,9 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
private fun resolveFields(
|
private fun resolveFields(
|
||||||
buildType: Provider<BuildType>,
|
buildType: Provider<BuildType>,
|
||||||
fields: List<FieldConfig<*>>,
|
fields: List<FieldConfig<*>>,
|
||||||
|
extension: KonfigExtension,
|
||||||
): Provider<Map<String, String>> = buildType.map { bt ->
|
): 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(
|
private fun resolveDimensionFields(
|
||||||
@@ -388,19 +445,26 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
buildType.zip(combined) { bt, ctx ->
|
buildType.zip(combined) { bt, ctx ->
|
||||||
buildMap {
|
buildMap {
|
||||||
for (dim in extension.dimensions) {
|
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 vc = dim.variants[sv] ?: continue
|
||||||
val prefix = "${dim.dimensionName}|"
|
val prefix = "${dim.dimensionName}|"
|
||||||
for (field in mergeVariantFields(dim.commonConfig.fields, vc.fields)) {
|
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. */
|
/** 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 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) {
|
val encoded = when (value) {
|
||||||
is String -> "String:$value"
|
is String -> "String:$value"
|
||||||
is Boolean -> "Boolean:$value"
|
is Boolean -> "Boolean:$value"
|
||||||
@@ -408,11 +472,38 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
is Long -> "Long:$value"
|
is Long -> "Long:$value"
|
||||||
is Float -> "Float:$value"
|
is Float -> "Float:$value"
|
||||||
is Double -> "Double:$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
|
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
|
// MARK: Fields Merge
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
@@ -442,7 +533,7 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
val artifact = projectName
|
val artifact = projectName
|
||||||
.replace("-", ".")
|
.replace("-", ".")
|
||||||
.replace(Regex("[^A-Za-z0-9.]"), "")
|
.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(".")
|
private fun String.ensureValidPackage(): String = split(".")
|
||||||
@@ -450,9 +541,10 @@ class KonfigPlugin : Plugin<Project> {
|
|||||||
.joinToString(".") { segment ->
|
.joinToString(".") { segment ->
|
||||||
val cleaned = segment.replace(Regex("[^A-Za-z0-9_]"), "")
|
val cleaned = segment.replace(Regex("[^A-Za-z0-9_]"), "")
|
||||||
when {
|
when {
|
||||||
cleaned.isBlank() -> "_"
|
cleaned.isBlank() -> "generated"
|
||||||
cleaned.first().isDigit() -> "_$cleaned"
|
cleaned.first().isDigit() -> "_$cleaned"
|
||||||
|
!cleaned.lowercase().isValidKotlinIdentifier() -> "_${cleaned}pkg"
|
||||||
else -> cleaned
|
else -> cleaned
|
||||||
}
|
}
|
||||||
}.lowercase()
|
}.lowercase().ifEmpty { "generated" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ class DimensionConfig @PublishedApi internal constructor(
|
|||||||
/** When true, fields are generated at the root of the konfig object instead of a nested object. */
|
/** When true, fields are generated at the root of the konfig object instead of a nested object. */
|
||||||
val flat: Boolean = false,
|
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. */
|
/** All named variants. */
|
||||||
@PublishedApi
|
@PublishedApi
|
||||||
internal val variants: MutableMap<String, VariantConfig> = mutableMapOf()
|
internal val variants: MutableMap<String, VariantConfig> = mutableMapOf()
|
||||||
@@ -50,6 +59,7 @@ class DimensionConfig @PublishedApi internal constructor(
|
|||||||
* to merge additional fields.
|
* to merge additional fields.
|
||||||
*/
|
*/
|
||||||
fun variant(name: String, config: VariantConfig.() -> Unit) {
|
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) }
|
val v = variants.getOrPut(name) { VariantConfig(name) }
|
||||||
config(v)
|
config(v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.bitsycore.konfig.configs
|
package com.bitsycore.konfig.configs
|
||||||
|
|
||||||
import com.bitsycore.konfig.types.BuildType
|
import com.bitsycore.konfig.types.BuildType
|
||||||
|
import com.bitsycore.konfig.types.FieldValueType
|
||||||
import org.gradle.api.Transformer
|
import org.gradle.api.Transformer
|
||||||
import org.gradle.api.provider.Provider
|
import org.gradle.api.provider.Provider
|
||||||
import org.gradle.api.specs.Spec
|
import org.gradle.api.specs.Spec
|
||||||
@@ -14,24 +15,25 @@ import java.util.function.BiFunction
|
|||||||
* is intentionally NOT stored here - it is not configuration-cache serializable and
|
* is intentionally NOT stored here - it is not configuration-cache serializable and
|
||||||
* must never flow into the object graph captured by task input providers.
|
* must never flow into the object graph captured by task input providers.
|
||||||
*/
|
*/
|
||||||
class FieldConfig<T : Any> @PublishedApi internal constructor(
|
class FieldConfig<T> @PublishedApi internal constructor(
|
||||||
val fieldName: String,
|
val fieldName: String,
|
||||||
internal val type: Class<T>,
|
internal val type: Class<T>,
|
||||||
/**
|
/**
|
||||||
* The unconditional default value, or `null` if this field was declared only inside
|
* 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.
|
* 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
|
@PublishedApi
|
||||||
internal val buildTypeOverrides: MutableMap<BuildType, Provider<T>> = mutableMapOf()
|
internal val buildTypeOverrides: MutableMap<BuildType, Provider<out Any>> = mutableMapOf()
|
||||||
|
|
||||||
// ── BuildType overrides ───────────────────────────────────────────────────
|
// ── BuildType overrides ───────────────────────────────────────────────────
|
||||||
|
|
||||||
fun debug(value: T) { buildTypeOverrides[BuildType.DEBUG] = constantProvider(value) }
|
fun debug(value: T) { buildTypeOverrides[BuildType.DEBUG] = constantProvider(value ?: NullFieldValue) }
|
||||||
fun debug(value: Provider<T>) { buildTypeOverrides[BuildType.DEBUG] = value }
|
fun debug(value: Provider<T & Any>) { buildTypeOverrides[BuildType.DEBUG] = value }
|
||||||
fun release(value: T) { buildTypeOverrides[BuildType.RELEASE] = constantProvider(value) }
|
fun release(value: T) { buildTypeOverrides[BuildType.RELEASE] = constantProvider(value ?: NullFieldValue) }
|
||||||
fun release(value: Provider<T>) { buildTypeOverrides[BuildType.RELEASE] = value }
|
fun release(value: Provider<T & Any>) { buildTypeOverrides[BuildType.RELEASE] = value }
|
||||||
|
|
||||||
// ── Resolution ────────────────────────────────────────────────────────────
|
// ── Resolution ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ class FieldConfig<T : Any> @PublishedApi internal constructor(
|
|||||||
* Resolves the effective value for [buildType].
|
* Resolves the effective value for [buildType].
|
||||||
* Returns `null` if no applicable value exists (field is absent for this context).
|
* 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
|
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?>
|
combiner: BiFunction<in T, in B, out R?>
|
||||||
): Provider<R> = ConstantProvider(combiner.apply(value, right.get())!!)
|
): 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.BuildType
|
||||||
import com.bitsycore.konfig.types.KonfigDsl
|
import com.bitsycore.konfig.types.KonfigDsl
|
||||||
|
import com.bitsycore.konfig.types.FieldValueType
|
||||||
import org.gradle.api.provider.Provider
|
import org.gradle.api.provider.Provider
|
||||||
|
import kotlin.reflect.typeOf
|
||||||
|
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
// MARK: FieldHandle with modifier
|
// MARK: FieldHandle with modifier
|
||||||
@@ -14,17 +16,17 @@ import org.gradle.api.provider.Provider
|
|||||||
* Allows fluent `.debug(value)` / `.release(value)` overrides:
|
* Allows fluent `.debug(value)` / `.release(value)` overrides:
|
||||||
* ```kotlin
|
* ```kotlin
|
||||||
* field("TIMEOUT", 30).debug(5)
|
* field("TIMEOUT", 30).debug(5)
|
||||||
* field("URL", "https://prod.example.com").debug("https://dev.example.com").release("https://prod.example.com")
|
* field("URL", "https://prod.example.com").debug("https://dev.example.com")
|
||||||
* ```
|
* ```
|
||||||
* 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>
|
@PublishedApi internal val field: FieldConfig<T>
|
||||||
) {
|
) {
|
||||||
fun debug(value: T): Unit = field.debug(value)
|
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: T): Unit = field.release(value)
|
||||||
fun release(value: Provider<T>): Unit = field.release(value)
|
fun release(value: Provider<T & Any>): Unit = field.release(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
@@ -42,11 +44,11 @@ class BuildTypedFieldDeclScope @PublishedApi internal constructor(
|
|||||||
@PublishedApi internal val buildType: BuildType,
|
@PublishedApi internal val buildType: BuildType,
|
||||||
@PublishedApi internal val owner: VariantConfig
|
@PublishedApi internal val owner: VariantConfig
|
||||||
) {
|
) {
|
||||||
inline fun <reified T : Any> field(name: String, value: T) {
|
inline fun <reified T> field(name: String, value: T) {
|
||||||
owner.getOrCreateField<T>(name).buildTypeOverrides[buildType] = constantProvider(value)
|
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
|
owner.getOrCreateField<T>(name).buildTypeOverrides[buildType] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,13 +78,16 @@ class VariantConfig @PublishedApi internal constructor(val variantName: String)
|
|||||||
// ── Internal helpers ──────────────────────────────────────────────────────
|
// ── Internal helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@PublishedApi
|
@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 }
|
val existing = fields.firstOrNull { it.fieldName == name }
|
||||||
if (existing != null) {
|
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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
return existing as FieldConfig<T>
|
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)
|
fields.add(fc)
|
||||||
return fc
|
return fc
|
||||||
}
|
}
|
||||||
@@ -93,20 +98,20 @@ class VariantConfig @PublishedApi internal constructor(val variantName: String)
|
|||||||
* Declares a field with an unconditional default value.
|
* Declares a field with an unconditional default value.
|
||||||
* Returns a [FieldHandle] to optionally set `.debug(value)` / `.release(value)` build-type overrides.
|
* 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 }) {
|
require(fields.none { it.fieldName == name }) {
|
||||||
"konfig: field '$name' is already declared in variant '$variantName'"
|
"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)
|
fields.add(fc)
|
||||||
return FieldHandle(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 }) {
|
require(fields.none { it.fieldName == name }) {
|
||||||
"konfig: field '$name' is already declared in variant '$variantName'"
|
"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)
|
fields.add(fc)
|
||||||
return FieldHandle(fc)
|
return FieldHandle(fc)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.bitsycore.konfig.types
|
package com.bitsycore.konfig.types
|
||||||
|
|
||||||
import kotlin.enums.enumEntries
|
import kotlin.enums.enumEntries
|
||||||
|
import org.gradle.api.GradleException
|
||||||
|
|
||||||
enum class BuildType(val value: String) {
|
enum class BuildType(val value: String) {
|
||||||
DEBUG("debug"),
|
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 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])""")
|
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? {
|
fun resolve(value: String): BuildType? {
|
||||||
enumEntries<BuildType>().firstOrNull { it.name == value }?.let {
|
enumEntries<BuildType>().firstOrNull { it.name == value }?.let {
|
||||||
return it
|
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,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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user