From 5ede7ae817df26e42d8ec3b1a50f8a40ecd3a430 Mon Sep 17 00:00:00 2001 From: devcrocod Date: Fri, 17 Apr 2026 20:24:29 +0200 Subject: [PATCH 01/15] restructure project, add korro-gradle-plugin and korro-analysis subprojects --- build.gradle.kts | 104 +++++------------- gradle.properties | 4 +- korro-analysis/build.gradle.kts | 3 + korro-gradle-plugin/build.gradle.kts | 53 +++++++++ .../kotlin/io/github/devcrocod/korro/Korro.kt | 0 .../io/github/devcrocod/korro/KorroAction.kt | 0 .../io/github/devcrocod/korro/KorroContext.kt | 0 .../github/devcrocod/korro/KorroExtension.kt | 0 .../io/github/devcrocod/korro/KorroLog.kt | 0 .../io/github/devcrocod/korro/KorroPlugin.kt | 0 .../io/github/devcrocod/korro/KorroTask.kt | 9 ++ .../io/github/devcrocod/korro/SampleGroups.kt | 0 .../devcrocod/korro/SamplesTransformer.kt | 0 settings.gradle.kts | 4 +- 14 files changed, 97 insertions(+), 80 deletions(-) create mode 100644 korro-analysis/build.gradle.kts create mode 100644 korro-gradle-plugin/build.gradle.kts rename {src => korro-gradle-plugin/src}/main/kotlin/io/github/devcrocod/korro/Korro.kt (100%) rename {src => korro-gradle-plugin/src}/main/kotlin/io/github/devcrocod/korro/KorroAction.kt (100%) rename {src => korro-gradle-plugin/src}/main/kotlin/io/github/devcrocod/korro/KorroContext.kt (100%) rename {src => korro-gradle-plugin/src}/main/kotlin/io/github/devcrocod/korro/KorroExtension.kt (100%) rename {src => korro-gradle-plugin/src}/main/kotlin/io/github/devcrocod/korro/KorroLog.kt (100%) rename {src => korro-gradle-plugin/src}/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt (100%) rename {src => korro-gradle-plugin/src}/main/kotlin/io/github/devcrocod/korro/KorroTask.kt (87%) rename {src => korro-gradle-plugin/src}/main/kotlin/io/github/devcrocod/korro/SampleGroups.kt (100%) rename {src => korro-gradle-plugin/src}/main/kotlin/io/github/devcrocod/korro/SamplesTransformer.kt (100%) diff --git a/build.gradle.kts b/build.gradle.kts index 39526e6..0d85404 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,98 +1,48 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - kotlin("jvm") - `java-gradle-plugin` - id("com.gradle.plugin-publish") version "1.1.0" - `maven-publish` - id("com.github.johnrengelman.shadow") version "8.1.1" + kotlin("jvm") apply false } group = "io.github.devcrocod" version = detectVersion() fun detectVersion(): String { - val buildNumber = rootProject.findProperty("build.number") as String? - return if (hasProperty("release")) { - version as String - } else if (buildNumber != null) { - "$version-dev-$buildNumber" - } else { - "$version-dev" + val buildNumber = findProperty("build.number") as String? + val baseVersion = version as String + return when { + hasProperty("release") -> baseVersion + buildNumber != null -> "$baseVersion-dev-$buildNumber" + else -> "$baseVersion-dev" } } -configurations.named(JavaPlugin.API_CONFIGURATION_NAME) { - dependencies.remove(project.dependencies.gradleApi()) -} - -repositories { - mavenCentral() - gradlePluginPortal() -} - -val dokka_version: String by project -val kotlin_version: String by project -dependencies { - shadow(kotlin("stdlib-jdk8", version = kotlin_version)) - shadow("org.jetbrains.dokka:dokka-core:$dokka_version") - shadow("org.jetbrains.dokka:dokka-analysis:$dokka_version") - - shadow(gradleApi()) - shadow(gradleKotlinDsl()) -} - -tasks.shadowJar { - isZip64 = true - archiveClassifier.set("") -} - +val language_version: String by project -tasks.jar { - enabled = false - dependsOn("shadowJar") - manifest { - attributes( - "Implementation-Title" to "$archiveBaseName", - "Implementation-Version" to "$archiveVersion" - ) - } -} +subprojects { + group = rootProject.group + version = rootProject.version -val language_version: String by project -tasks.withType(KotlinCompile::class).all { - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + listOf( - "-opt-in=kotlin.RequiresOptIn", - "-Xskip-metadata-version-check", - "-Xjsr305=strict" - ) - languageVersion = language_version - apiVersion = language_version + repositories { + mavenCentral() + gradlePluginPortal() } -} -gradlePlugin { - website.set("https://github.com/devcrocod/korro") - vcsUrl.set("https://github.com/devcrocod/korro") - plugins { - create("korro") { - id = "io.github.devcrocod.korro" - implementationClass = "io.github.devcrocod.korro.KorroPlugin" - displayName = "Korro documentation plugin" - description = "Inserts snippets code of Kotlin into markdown documents from source example files and tests." - tags.set(listOf("kotlin", "documentation", "markdown")) + tasks.withType().configureEach { + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + listOf( + "-opt-in=kotlin.RequiresOptIn", + "-Xskip-metadata-version-check", + "-Xjsr305=strict", + ) + languageVersion = language_version + apiVersion = language_version + jvmTarget = "1.8" } } -} -tasks.withType { - kotlinOptions { - jvmTarget = "1.8" + tasks.withType().configureEach { + sourceCompatibility = JavaVersion.VERSION_1_8.toString() + targetCompatibility = JavaVersion.VERSION_1_8.toString() } } - -tasks.withType { - sourceCompatibility = JavaVersion.VERSION_1_8.toString() - targetCompatibility = JavaVersion.VERSION_1_8.toString() -} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 760cb81..3f4b0d9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,9 +1,9 @@ kotlin.code.style=official -version=0.1.7 +version=0.2.0 kotlin_version=1.9.22 language_version=1.8 dokka_version=1.8.20 pluginPublishVersion=0.15.0 -org.gradle.jvmargs=-Xmx2G \ No newline at end of file +org.gradle.jvmargs=-Xmx2G diff --git a/korro-analysis/build.gradle.kts b/korro-analysis/build.gradle.kts new file mode 100644 index 0000000..444baaa --- /dev/null +++ b/korro-analysis/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + kotlin("jvm") +} diff --git a/korro-gradle-plugin/build.gradle.kts b/korro-gradle-plugin/build.gradle.kts new file mode 100644 index 0000000..b74bb42 --- /dev/null +++ b/korro-gradle-plugin/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + kotlin("jvm") + `java-gradle-plugin` + id("com.gradle.plugin-publish") version "1.1.0" + `maven-publish` + id("com.gradleup.shadow") version "8.3.5" +} + +configurations.named(JavaPlugin.API_CONFIGURATION_NAME) { + dependencies.remove(project.dependencies.gradleApi()) +} + +val dokka_version: String by project +val kotlin_version: String by project +dependencies { + shadow(kotlin("stdlib-jdk8", version = kotlin_version)) + shadow("org.jetbrains.dokka:dokka-core:$dokka_version") + shadow("org.jetbrains.dokka:dokka-analysis:$dokka_version") + + shadow(gradleApi()) + shadow(gradleKotlinDsl()) +} + +tasks.shadowJar { + isZip64 = true + archiveClassifier.set("") +} + + +tasks.jar { + enabled = false + dependsOn("shadowJar") + manifest { + attributes( + "Implementation-Title" to "$archiveBaseName", + "Implementation-Version" to "$archiveVersion" + ) + } +} + +gradlePlugin { + website.set("https://github.com/devcrocod/korro") + vcsUrl.set("https://github.com/devcrocod/korro") + plugins { + create("korro") { + id = "io.github.devcrocod.korro" + implementationClass = "io.github.devcrocod.korro.KorroPlugin" + displayName = "Korro documentation plugin" + description = "Inserts snippets code of Kotlin into markdown documents from source example files and tests." + tags.set(listOf("kotlin", "documentation", "markdown")) + } + } +} diff --git a/src/main/kotlin/io/github/devcrocod/korro/Korro.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt similarity index 100% rename from src/main/kotlin/io/github/devcrocod/korro/Korro.kt rename to korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt diff --git a/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt similarity index 100% rename from src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt rename to korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt diff --git a/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt similarity index 100% rename from src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt rename to korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt diff --git a/src/main/kotlin/io/github/devcrocod/korro/KorroExtension.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroExtension.kt similarity index 100% rename from src/main/kotlin/io/github/devcrocod/korro/KorroExtension.kt rename to korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroExtension.kt diff --git a/src/main/kotlin/io/github/devcrocod/korro/KorroLog.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroLog.kt similarity index 100% rename from src/main/kotlin/io/github/devcrocod/korro/KorroLog.kt rename to korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroLog.kt diff --git a/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt similarity index 100% rename from src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt rename to korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt diff --git a/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt similarity index 87% rename from src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt rename to korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt index e861bf5..0a6e29b 100644 --- a/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt @@ -6,6 +6,7 @@ import org.gradle.api.Project import org.gradle.api.artifacts.Configuration import org.gradle.api.file.FileCollection import org.gradle.api.tasks.* +import org.gradle.work.DisableCachingByDefault import org.gradle.workers.WorkAction import org.gradle.workers.WorkerExecutor import javax.inject.Inject @@ -62,21 +63,25 @@ private interface KorroTasksCommon { } } +@DisableCachingByDefault(because = "Rewrites source markdown in place; Phase 2 introduces out-of-place writes and caching.") abstract class KorroTask : DefaultTask(), KorroTasksCommon { final override val ext: KorroExtension = project.extensions.getByType(KorroExtension::class.java) @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) override var docs: FileCollection = ext.docs ?: project.fileTree(project.rootDir) { it.include("**/*.md") } @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) override var samples: FileCollection = ext.samples ?: project.fileTree(project.rootDir) { it.include("**/*.kt") } @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) override var outputs: FileCollection = ext.outputs ?: project.files() @get:Internal @@ -96,20 +101,24 @@ abstract class KorroTask : DefaultTask(), KorroTasksCommon { } } +@DisableCachingByDefault(because = "Deletes FUN/END blocks from source markdown; not cacheable.") abstract class KorroCleanTask : Delete(), KorroTasksCommon { final override val ext: KorroExtension = project.extensions.getByType(KorroExtension::class.java) @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) override var docs: FileCollection = ext.docs ?: project.fileTree(project.rootDir) { it.include("**/*.md") } @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) override var samples: FileCollection = ext.samples ?: project.fileTree(project.rootDir) { it.include("**/*.kt") } @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) override var outputs: FileCollection = ext.outputs ?: project.files() @get:Internal diff --git a/src/main/kotlin/io/github/devcrocod/korro/SampleGroups.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SampleGroups.kt similarity index 100% rename from src/main/kotlin/io/github/devcrocod/korro/SampleGroups.kt rename to korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SampleGroups.kt diff --git a/src/main/kotlin/io/github/devcrocod/korro/SamplesTransformer.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SamplesTransformer.kt similarity index 100% rename from src/main/kotlin/io/github/devcrocod/korro/SamplesTransformer.kt rename to korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SamplesTransformer.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index 6a070ff..1d86e25 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,4 +5,6 @@ pluginManagement { } } -rootProject.name = "korro" \ No newline at end of file +rootProject.name = "korro" + +include("korro-gradle-plugin", "korro-analysis") \ No newline at end of file From 7db0256e5796078f8af5203b1f0f255d7657ca6b Mon Sep 17 00:00:00 2001 From: devcrocod Date: Fri, 17 Apr 2026 21:51:00 +0200 Subject: [PATCH 02/15] Refactor `korro-gradle-plugin`: restructure tasks, enhance configuration, and refine sample handling logic. --- .../kotlin/io/github/devcrocod/korro/Korro.kt | 96 +-------- .../io/github/devcrocod/korro/KorroAction.kt | 58 ++---- .../github/devcrocod/korro/KorroApplyTask.kt | 7 + .../github/devcrocod/korro/KorroCheckTask.kt | 22 ++ .../io/github/devcrocod/korro/KorroContext.kt | 28 ++- .../github/devcrocod/korro/KorroExtension.kt | 117 +++++++---- .../io/github/devcrocod/korro/KorroPlugin.kt | 63 +++++- .../io/github/devcrocod/korro/KorroTask.kt | 196 ++++++++---------- .../devcrocod/korro/SamplesTransformer.kt | 25 ++- 9 files changed, 302 insertions(+), 310 deletions(-) create mode 100644 korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroApplyTask.kt create mode 100644 korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroCheckTask.kt diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt index 6c2b822..64da1ea 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt @@ -1,6 +1,5 @@ package io.github.devcrocod.korro -import org.jetbrains.kotlin.utils.addToStdlib.firstNotNullResult import java.io.File const val DIRECTIVE_START = " + + + +```kotlin +println("hello") +``` + + diff --git a/integration-tests/fixtures/basic/docs/in/foo.md b/integration-tests/fixtures/basic/docs/in/foo.md new file mode 100644 index 0000000..13b2887 --- /dev/null +++ b/integration-tests/fixtures/basic/docs/in/foo.md @@ -0,0 +1,6 @@ +# Example + + + + + diff --git a/integration-tests/fixtures/basic/samples/Example.kt b/integration-tests/fixtures/basic/samples/Example.kt new file mode 100644 index 0000000..a862c78 --- /dev/null +++ b/integration-tests/fixtures/basic/samples/Example.kt @@ -0,0 +1,7 @@ +package samples + +fun example() { + //SampleStart + println("hello") + //SampleEnd +} diff --git a/integration-tests/fixtures/basic/settings.gradle.kts b/integration-tests/fixtures/basic/settings.gradle.kts new file mode 100644 index 0000000..0056e51 --- /dev/null +++ b/integration-tests/fixtures/basic/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "korro-basic-fixture" diff --git a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt new file mode 100644 index 0000000..b14df97 --- /dev/null +++ b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt @@ -0,0 +1,64 @@ +package io.github.devcrocod.korro.it + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.readText +import kotlin.io.path.writeText + +class KorroIntegrationTest { + + @Test + fun korroReplacesFunDirectiveWithSnippet(@TempDir tempDir: Path) { + val fixture = loadFixture("basic", tempDir) + + val runner = GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withArguments("korro", "--stacktrace") + .withPluginClasspath() + .forwardOutput() + + System.getProperty("korro.testkit.gradleVersion") + ?.takeIf { it.isNotBlank() } + ?.let(runner::withGradleVersion) + + val result = runner.build() + + assertEquals(TaskOutcome.SUCCESS, result.task(":korro")?.outcome, "korro task outcome") + + val actualFile = fixture.resolve("build/korro/docs/foo.md") + assertTrue(Files.exists(actualFile)) { "korro did not produce $actualFile" } + + val expectedFile = fixturesRoot().resolve("basic/docs/expected/foo.md") + val actual = normalize(actualFile.readText()) + + if (System.getProperty("korro.regenerate.expected") == "true") { + expectedFile.writeText(actual) + return + } + + val expected = normalize(expectedFile.readText()) + assertEquals(expected, actual, "Generated markdown does not match golden file") + } + + private fun loadFixture(name: String, tempDir: Path): Path { + val source = fixturesRoot().resolve(name).toFile() + val target = tempDir.resolve(name).toFile() + source.copyRecursively(target, overwrite = true) + return target.toPath() + } + + private fun fixturesRoot(): Path { + val dir = System.getProperty("korro.fixtures.dir") + ?: error("System property 'korro.fixtures.dir' is not set; check integration-tests/build.gradle.kts") + return File(dir).toPath() + } + + private fun normalize(s: String): String = s.replace("\r\n", "\n") +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1d86e25..980361f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,4 +7,4 @@ pluginManagement { rootProject.name = "korro" -include("korro-gradle-plugin", "korro-analysis") \ No newline at end of file +include("korro-gradle-plugin", "korro-analysis", "integration-tests") \ No newline at end of file From 68a42f52876420ac69fa38c9706e815101b20e8e Mon Sep 17 00:00:00 2001 From: devcrocod Date: Fri, 17 Apr 2026 23:05:33 +0200 Subject: [PATCH 04/15] Set up GitHub Actions CI workflow using Java 17 and Gradle --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5bb6b18 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@v4 + + - run: ./gradlew build From 0fd142fdfa1b5e9bc2d371a49db4d00d96a7d74d Mon Sep 17 00:00:00 2001 From: devcrocod Date: Sat, 18 Apr 2026 01:00:35 +0200 Subject: [PATCH 05/15] Refactor project structure, modularize functionality, and update dependencies. - Removed `SamplesTransformer.kt` in `korro-gradle-plugin` and introduced `korro-analysis` subproject for sample extraction and transformation logic. - Added new modular components: `SampleExtractor`, `SamplesTransformer`, and `FqnResolver` in `korro-analysis`. - Updated Kotlin to version 2.3.20 and aligned Gradle scripts with the latest changes. - Enhanced `integration-tests`: added `commonTestFixture`, updated test setup, and improved golden-file comparison logic. - Adjusted build configuration: updated Gradle plugin versions, improved shadow JAR handling, and introduced resource generation (`korro-gradle-plugin`). - Added `commonTest` fixture with example test and documentation expectations. --- build.gradle.kts | 16 +- gradle.properties | 6 +- integration-tests/build.gradle.kts | 3 +- .../fixtures/basic/build.gradle.kts | 2 + .../fixtures/commonTest/build.gradle.kts | 19 ++ .../commonTest/docs/expected/readme.md | 12 + .../fixtures/commonTest/docs/in/readme.md | 6 + .../fixtures/commonTest/settings.gradle.kts | 1 + .../src/commonTest/kotlin/Example.kt | 15 ++ .../korro/it/KorroIntegrationTest.kt | 88 ++++++- korro-analysis/build.gradle.kts | 61 +++++ .../devcrocod/korro/analysis/FqnResolver.kt | 46 ++++ .../korro/analysis/KorroAnalysisSession.kt | 60 +++++ .../korro/analysis/SampleExtractor.kt | 220 +++++------------- .../korro/analysis/SamplesTransformer.kt | 21 ++ korro-gradle-plugin/build.gradle.kts | 25 +- .../kotlin/io/github/devcrocod/korro/Korro.kt | 2 +- .../io/github/devcrocod/korro/KorroAction.kt | 26 ++- .../io/github/devcrocod/korro/KorroContext.kt | 5 +- .../io/github/devcrocod/korro/KorroPlugin.kt | 24 +- .../io/github/devcrocod/korro/KorroTask.kt | 3 +- 21 files changed, 450 insertions(+), 211 deletions(-) create mode 100644 integration-tests/fixtures/commonTest/build.gradle.kts create mode 100644 integration-tests/fixtures/commonTest/docs/expected/readme.md create mode 100644 integration-tests/fixtures/commonTest/docs/in/readme.md create mode 100644 integration-tests/fixtures/commonTest/settings.gradle.kts create mode 100644 integration-tests/fixtures/commonTest/src/commonTest/kotlin/Example.kt create mode 100644 korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt create mode 100644 korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/KorroAnalysisSession.kt rename korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SamplesTransformer.kt => korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt (53%) create mode 100644 korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt diff --git a/build.gradle.kts b/build.gradle.kts index 0d85404..80325aa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -29,20 +31,20 @@ subprojects { } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + listOf( + compilerOptions { + freeCompilerArgs.addAll( "-opt-in=kotlin.RequiresOptIn", "-Xskip-metadata-version-check", "-Xjsr305=strict", ) - languageVersion = language_version - apiVersion = language_version - jvmTarget = "1.8" + languageVersion.set(KotlinVersion.fromVersion(language_version)) + apiVersion.set(KotlinVersion.fromVersion(language_version)) + jvmTarget.set(JvmTarget.JVM_17) } } tasks.withType().configureEach { - sourceCompatibility = JavaVersion.VERSION_1_8.toString() - targetCompatibility = JavaVersion.VERSION_1_8.toString() + sourceCompatibility = JavaVersion.VERSION_17.toString() + targetCompatibility = JavaVersion.VERSION_17.toString() } } diff --git a/gradle.properties b/gradle.properties index 3f4b0d9..763d341 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,8 +2,6 @@ kotlin.code.style=official version=0.2.0 -kotlin_version=1.9.22 -language_version=1.8 -dokka_version=1.8.20 -pluginPublishVersion=0.15.0 +kotlin_version=2.3.20 +language_version=2.1 org.gradle.jvmargs=-Xmx2G diff --git a/integration-tests/build.gradle.kts b/integration-tests/build.gradle.kts index 8774d7c..e3fc7b1 100644 --- a/integration-tests/build.gradle.kts +++ b/integration-tests/build.gradle.kts @@ -11,7 +11,7 @@ val pluginShadowJar = project(":korro-gradle-plugin").tasks.named("shadowJar") dependencies { testImplementation(gradleTestKit()) - testImplementation(platform("org.junit:junit-bom:5.10.2")) + testImplementation(platform("org.junit:junit-bom:5.14.3")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } @@ -29,6 +29,7 @@ val regenerateExpected = providers.gradleProperty("korro.regenerate.expected").o tasks.test { useJUnitPlatform() dependsOn(":korro-gradle-plugin:shadowJar") + dependsOn(":korro-analysis:publishToMavenLocal") systemProperty("korro.testkit.gradleVersion", "8.5") systemProperty("korro.regenerate.expected", regenerateExpected.get()) systemProperty("korro.fixtures.dir", layout.projectDirectory.dir("fixtures").asFile.absolutePath) diff --git a/integration-tests/fixtures/basic/build.gradle.kts b/integration-tests/fixtures/basic/build.gradle.kts index 8b87f5f..8ba574e 100644 --- a/integration-tests/fixtures/basic/build.gradle.kts +++ b/integration-tests/fixtures/basic/build.gradle.kts @@ -3,7 +3,9 @@ plugins { } repositories { + mavenLocal() mavenCentral() + maven("https://cache-redirector.jetbrains.com/intellij-dependencies") } korro { diff --git a/integration-tests/fixtures/commonTest/build.gradle.kts b/integration-tests/fixtures/commonTest/build.gradle.kts new file mode 100644 index 0000000..0128daa --- /dev/null +++ b/integration-tests/fixtures/commonTest/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("io.github.devcrocod.korro") +} + +repositories { + mavenLocal() + mavenCentral() + maven("https://cache-redirector.jetbrains.com/intellij-dependencies") +} + +korro { + docs { + from(fileTree("docs/in")) + baseDir.set(layout.projectDirectory.dir("docs/in")) + } + samples { + from(fileTree("src/commonTest/kotlin")) + } +} diff --git a/integration-tests/fixtures/commonTest/docs/expected/readme.md b/integration-tests/fixtures/commonTest/docs/expected/readme.md new file mode 100644 index 0000000..5564109 --- /dev/null +++ b/integration-tests/fixtures/commonTest/docs/expected/readme.md @@ -0,0 +1,12 @@ +# Common test sample + + + + + +```kotlin +val greeting = "hello, world" +println(greeting) +``` + + diff --git a/integration-tests/fixtures/commonTest/docs/in/readme.md b/integration-tests/fixtures/commonTest/docs/in/readme.md new file mode 100644 index 0000000..e36e16b --- /dev/null +++ b/integration-tests/fixtures/commonTest/docs/in/readme.md @@ -0,0 +1,6 @@ +# Common test sample + + + + + diff --git a/integration-tests/fixtures/commonTest/settings.gradle.kts b/integration-tests/fixtures/commonTest/settings.gradle.kts new file mode 100644 index 0000000..ea26cd0 --- /dev/null +++ b/integration-tests/fixtures/commonTest/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "korro-commontest-fixture" diff --git a/integration-tests/fixtures/commonTest/src/commonTest/kotlin/Example.kt b/integration-tests/fixtures/commonTest/src/commonTest/kotlin/Example.kt new file mode 100644 index 0000000..90e39fb --- /dev/null +++ b/integration-tests/fixtures/commonTest/src/commonTest/kotlin/Example.kt @@ -0,0 +1,15 @@ +package samples + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ExampleTest { + @Test + fun greeting() { + //SampleStart + val greeting = "hello, world" + println(greeting) + //SampleEnd + assertEquals("hello, world", greeting) + } +} diff --git a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt index b14df97..440c2d3 100644 --- a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt +++ b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt @@ -1,6 +1,7 @@ package io.github.devcrocod.korro.it import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.InvalidPluginMetadataException import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue @@ -15,27 +16,52 @@ import kotlin.io.path.writeText class KorroIntegrationTest { @Test - fun korroReplacesFunDirectiveWithSnippet(@TempDir tempDir: Path) { - val fixture = loadFixture("basic", tempDir) + fun basicFixture(@TempDir tempDir: Path) { + runFixture( + name = "basic", + tempDir = tempDir, + generatedRelativePath = "build/korro/docs/foo.md", + expectedRelativePath = "basic/docs/expected/foo.md", + ) + } + + @Test + fun commonTestFixture(@TempDir tempDir: Path) { + runFixture( + name = "commonTest", + tempDir = tempDir, + generatedRelativePath = "build/korro/docs/readme.md", + expectedRelativePath = "commonTest/docs/expected/readme.md", + ) + } + + private fun runFixture( + name: String, + tempDir: Path, + generatedRelativePath: String, + expectedRelativePath: String, + ) { + val fixture = loadFixture(name, tempDir) val runner = GradleRunner.create() .withProjectDir(fixture.toFile()) .withArguments("korro", "--stacktrace") - .withPluginClasspath() .forwardOutput() + configurePluginClasspath(runner) + System.getProperty("korro.testkit.gradleVersion") ?.takeIf { it.isNotBlank() } ?.let(runner::withGradleVersion) val result = runner.build() - assertEquals(TaskOutcome.SUCCESS, result.task(":korro")?.outcome, "korro task outcome") + assertEquals(TaskOutcome.SUCCESS, result.task(":korro")?.outcome, "korro task outcome for $name") - val actualFile = fixture.resolve("build/korro/docs/foo.md") + val actualFile = fixture.resolve(generatedRelativePath) assertTrue(Files.exists(actualFile)) { "korro did not produce $actualFile" } - val expectedFile = fixturesRoot().resolve("basic/docs/expected/foo.md") + val expectedFile = fixturesRoot().resolve(expectedRelativePath) val actual = normalize(actualFile.readText()) if (System.getProperty("korro.regenerate.expected") == "true") { @@ -44,7 +70,7 @@ class KorroIntegrationTest { } val expected = normalize(expectedFile.readText()) - assertEquals(expected, actual, "Generated markdown does not match golden file") + assertEquals(expected, actual, "Generated markdown does not match golden file for $name") } private fun loadFixture(name: String, tempDir: Path): Path { @@ -55,10 +81,52 @@ class KorroIntegrationTest { } private fun fixturesRoot(): Path { - val dir = System.getProperty("korro.fixtures.dir") - ?: error("System property 'korro.fixtures.dir' is not set; check integration-tests/build.gradle.kts") - return File(dir).toPath() + System.getProperty("korro.fixtures.dir")?.let { return File(it).toPath() } + + val cwd = File("").absoluteFile.toPath() + val candidates = listOf( + cwd.resolve("fixtures"), + cwd.resolve("integration-tests/fixtures"), + cwd.parent?.resolve("fixtures"), + ).filterNotNull() + return candidates.firstOrNull { Files.isDirectory(it) } + ?: error( + "Cannot locate integration-tests/fixtures. " + + "Set system property 'korro.fixtures.dir' or run via `./gradlew integration-tests:test`. " + + "CWD=$cwd" + ) } private fun normalize(s: String): String = s.replace("\r\n", "\n") + + private fun configurePluginClasspath(runner: GradleRunner) { + try { + runner.withPluginClasspath() + } catch (_: InvalidPluginMetadataException) { + runner.withPluginClasspath(fallbackPluginClasspath()) + } + } + + private fun fallbackPluginClasspath(): List { + val jar = findPluginShadowJar() + ?: error( + "Cannot locate korro-gradle-plugin shadow jar. " + + "Run `./gradlew korro-gradle-plugin:shadowJar` first, " + + "or run tests via `./gradlew integration-tests:test`." + ) + return listOf(jar) + } + + private fun findPluginShadowJar(): File? { + val cwd = File("").absoluteFile + val candidates = listOf( + cwd.resolve("../korro-gradle-plugin/build/libs"), + cwd.resolve("korro-gradle-plugin/build/libs"), + cwd.parentFile?.resolve("korro-gradle-plugin/build/libs"), + ).filterNotNull().filter { it.isDirectory } + return candidates.asSequence() + .flatMap { (it.listFiles { _, name -> name.endsWith(".jar") } ?: emptyArray()).asSequence() } + .filterNot { it.name.contains("-sources") || it.name.contains("-javadoc") } + .firstOrNull() + } } diff --git a/korro-analysis/build.gradle.kts b/korro-analysis/build.gradle.kts index 444baaa..91c2ef7 100644 --- a/korro-analysis/build.gradle.kts +++ b/korro-analysis/build.gradle.kts @@ -1,3 +1,64 @@ plugins { kotlin("jvm") + id("com.gradleup.shadow") version "9.4.1" + `maven-publish` +} + +val kotlin_version: String by project + +repositories { + mavenCentral() + maven("https://cache-redirector.jetbrains.com/intellij-dependencies") +} + +dependencies { + compileOnly(kotlin("stdlib")) + + implementation("org.jetbrains.kotlin:analysis-api-for-ide:$kotlin_version") { isTransitive = false } + implementation("org.jetbrains.kotlin:analysis-api-impl-base-for-ide:$kotlin_version") { isTransitive = false } + implementation("org.jetbrains.kotlin:analysis-api-platform-interface-for-ide:$kotlin_version") { isTransitive = false } + implementation("org.jetbrains.kotlin:analysis-api-standalone-for-ide:$kotlin_version") { isTransitive = false } + implementation("org.jetbrains.kotlin:analysis-api-k2-for-ide:$kotlin_version") { isTransitive = false } + implementation("org.jetbrains.kotlin:low-level-api-fir-for-ide:$kotlin_version") { isTransitive = false } + implementation("org.jetbrains.kotlin:symbol-light-classes-for-ide:$kotlin_version") { isTransitive = false } + + implementation("org.jetbrains.kotlin:kotlin-compiler:$kotlin_version") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0") + implementation("com.github.ben-manes.caffeine:caffeine:3.2.3") +} + +tasks.shadowJar { + archiveClassifier.set("") + isZip64 = true + mergeServiceFiles() + + exclude("com/sun/jna/**") + exclude("org/jline/**") + exclude("io/vavr/**") + exclude("org/fusesource/**") + exclude("org/jetbrains/kotlin/js/**") + exclude("org/jetbrains/kotlin/ir/backend/js/**") + exclude("org/jetbrains/kotlin/incremental/**") + exclude("org/jetbrains/kotlin/backend/wasm/**") + exclude("org/jetbrains/kotlin/backend/konan/**") + exclude("org/jetbrains/kotlin/psi2ir/**") + exclude("org/jetbrains/kotlin/cli/js/**") + exclude("org/jetbrains/kotlin/cli/metadata/**") + exclude("org/jetbrains/kotlin/library/**") +} + +tasks.jar { + enabled = false + dependsOn("shadowJar") +} + +publishing { + publications { + create("maven") { + artifact(tasks.shadowJar) + groupId = project.group.toString() + artifactId = "korro-analysis" + version = project.version.toString() + } + } } diff --git a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt new file mode 100644 index 0000000..31cf7a8 --- /dev/null +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt @@ -0,0 +1,46 @@ +package io.github.devcrocod.korro.analysis + +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedFunction + +class FqnResolver(session: KorroAnalysisSession) { + private val byFqn: Map + private val byShortName: Map> + + init { + val fqn = mutableMapOf() + val shortName = mutableMapOf>() + session.files.forEach { file -> collectFunctions(file, fqn, shortName) } + byFqn = fqn + byShortName = shortName + } + + fun resolve(candidateFqn: String): KtNamedFunction? { + byFqn[candidateFqn]?.let { return it } + if ('.' !in candidateFqn) { + byShortName[candidateFqn]?.singleOrNull()?.let { return it } + } + return null + } + + private fun collectFunctions( + file: KtFile, + fqn: MutableMap, + shortName: MutableMap>, + ) { + fun visit(declarations: List) { + declarations.forEach { decl -> + when (decl) { + is KtNamedFunction -> { + decl.fqName?.asString()?.let { fqn[it] = decl } + decl.name?.let { shortName.getOrPut(it) { mutableListOf() }.add(decl) } + } + is KtClassOrObject -> visit(decl.declarations) + else -> {} + } + } + } + visit(file.declarations) + } +} diff --git a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/KorroAnalysisSession.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/KorroAnalysisSession.kt new file mode 100644 index 0000000..9abc87d --- /dev/null +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/KorroAnalysisSession.kt @@ -0,0 +1,60 @@ +package io.github.devcrocod.korro.analysis + +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import org.jetbrains.kotlin.analysis.api.projectStructure.KaSourceModule +import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISession +import org.jetbrains.kotlin.analysis.api.standalone.buildStandaloneAnalysisAPISession +import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtSdkModule +import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtSourceModule +import org.jetbrains.kotlin.config.ApiVersion +import org.jetbrains.kotlin.config.LanguageVersion +import org.jetbrains.kotlin.config.LanguageVersionSettingsImpl +import org.jetbrains.kotlin.platform.jvm.JvmPlatforms +import org.jetbrains.kotlin.psi.KtFile +import java.io.File +import java.nio.file.Paths + +class KorroAnalysisSession(samples: Set) : AutoCloseable { + private val disposable = Disposer.newDisposable("korro.analysis") + val session: StandaloneAnalysisAPISession + val contextModule: KaSourceModule + val project: Project + val files: List + + init { + lateinit var sourceModule: KaSourceModule + session = buildStandaloneAnalysisAPISession(projectDisposable = disposable) { + buildKtModuleProvider { + platform = JvmPlatforms.defaultJvmPlatform + val jdk = addModule( + buildKtSdkModule { + platform = JvmPlatforms.defaultJvmPlatform + addBinaryRootsFromJdkHome(Paths.get(System.getProperty("java.home")), isJre = true) + libraryName = "jdk" + } + ) + sourceModule = addModule( + buildKtSourceModule { + platform = JvmPlatforms.defaultJvmPlatform + languageVersionSettings = LanguageVersionSettingsImpl( + LanguageVersion.LATEST_STABLE, ApiVersion.LATEST_STABLE + ) + addSourceRoots(samples.map { it.toPath() }) + moduleName = "korro.samples" + addRegularDependency(jdk) + } + ) + } + } + contextModule = sourceModule + project = session.project + files = session.modulesWithFiles[contextModule] + .orEmpty() + .filterIsInstance() + } + + override fun close() { + Disposer.dispose(disposable) + } +} diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SamplesTransformer.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt similarity index 53% rename from korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SamplesTransformer.kt rename to korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt index e3f25d8..54958be 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SamplesTransformer.kt +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt @@ -1,48 +1,65 @@ -package io.github.devcrocod.korro +package io.github.devcrocod.korro.analysis -import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiElement import com.intellij.psi.PsiElementVisitor import com.intellij.psi.PsiWhiteSpace import com.intellij.psi.impl.source.tree.LeafPsiElement import com.intellij.psi.util.PsiTreeUtil -import org.jetbrains.dokka.Platform -import org.jetbrains.dokka.analysis.AnalysisEnvironment -import org.jetbrains.dokka.analysis.DokkaResolutionFacade -import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity -import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation -import org.jetbrains.kotlin.cli.common.messages.MessageCollector -import org.jetbrains.kotlin.cli.common.messages.MessageRenderer -import org.jetbrains.kotlin.idea.kdoc.resolveKDocLink -import org.jetbrains.kotlin.name.FqName -import org.jetbrains.kotlin.psi.* +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtDeclarationWithBody +import org.jetbrains.kotlin.psi.KtLambdaExpression +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtStringTemplateExpression +import org.jetbrains.kotlin.psi.KtTreeVisitorVoid +import org.jetbrains.kotlin.psi.KtValueArgument import org.jetbrains.kotlin.psi.psiUtil.prevLeaf -import org.jetbrains.kotlin.psi.psiUtil.startOffset -import org.jetbrains.kotlin.resolve.BindingContext -import org.jetbrains.kotlin.resolve.DescriptorToSourceUtils -import org.jetbrains.kotlin.utils.PathUtil -import java.io.PrintWriter -import java.io.StringWriter -class SamplesTransformer( - private val context: KorroContext, - private val rewriteAsserts: Boolean, -) { +class SampleExtractor(private val rewriteAsserts: Boolean) { - private val facade: DokkaResolutionFacade by lazy { setUpAnalysis() } + fun extract(function: KtNamedFunction): String { + val body = processBody(function) + return createSampleBody(body) + } - private class SampleBuilder(private val rewriteAsserts: Boolean) : KtTreeVisitorVoid() { - val builder = StringBuilder() - val text: String - get() = builder.toString() + private fun processBody(psiElement: PsiElement): String { + val text = processSampleBody(psiElement).trim { it == '\n' || it == '\r' }.trimEnd() + val lines = text.split("\n") + val indent = lines.filter(String::isNotBlank).map { it.takeWhile(Char::isWhitespace).count() }.minOrNull() ?: 0 + return lines.joinToString("\n") { it.drop(indent) } + } - val errors = mutableListOf() + private fun processSampleBody(psiElement: PsiElement) = when (psiElement) { + is KtDeclarationWithBody -> { + val bodyExpression = psiElement.bodyExpression + val bodyExpressionText = bodyExpression!!.buildSampleText() + when (bodyExpression) { + is KtBlockExpression -> bodyExpressionText.removeSurrounding("{", "}") + else -> bodyExpressionText + } + } + else -> psiElement.buildSampleText() + } - var start: Boolean = false + private fun PsiElement.buildSampleText(): String { + val sampleBuilder = SampleBuilder(rewriteAsserts) + this.accept(sampleBuilder) + return sampleBuilder.text + } - data class ConvertError(val e: Exception, val text: String, val loc: String) + private fun createSampleBody(body: String) = + """ | + |```kotlin + |$body + |``` + |""".trimMargin() + + private class SampleBuilder(private val rewriteAsserts: Boolean) : KtTreeVisitorVoid() { + val builder = StringBuilder() + val text: String get() = builder.toString() + var start: Boolean = false - fun convertAssertPrints(expression: KtCallExpression) { + private fun convertAssertPrints(expression: KtCallExpression) { val (argument, commentArgument) = expression.valueArguments builder.apply { append("println(") @@ -52,7 +69,7 @@ class SamplesTransformer( } } - fun convertAssertTrueFalse(expression: KtCallExpression, expectedResult: Boolean) { + private fun convertAssertTrueFalse(expression: KtCallExpression, expectedResult: Boolean) { val (argument) = expression.valueArguments builder.apply { expression.valueArguments.getOrNull(1)?.let { @@ -68,9 +85,8 @@ class SamplesTransformer( } } - fun convertAssertFails(expression: KtCallExpression) { + private fun convertAssertFails(expression: KtCallExpression) { val valueArguments = expression.valueArguments - val funcArgument: KtValueArgument val message: KtValueArgument? @@ -93,18 +109,7 @@ class SamplesTransformer( } } - private fun KtValueArgument.extractFunctionalArgumentText(): String { - return if (getArgumentExpression() is KtLambdaExpression) - PsiTreeUtil.findChildOfType(this, KtBlockExpression::class.java)?.text ?: "" - else - text - } - - private fun KtValueArgument.extractStringArgumentValue() = - (getArgumentExpression() as KtStringTemplateExpression) - .entries.joinToString("") { it.text } - - fun convertAssertFailsWith(expression: KtCallExpression) { + private fun convertAssertFailsWith(expression: KtCallExpression) { val (funcArgument) = expression.valueArguments val (exceptionType) = expression.typeArguments builder.apply { @@ -115,6 +120,16 @@ class SamplesTransformer( } } + private fun KtValueArgument.extractFunctionalArgumentText(): String = + if (getArgumentExpression() is KtLambdaExpression) + PsiTreeUtil.findChildOfType(this, KtBlockExpression::class.java)?.text ?: "" + else + text + + private fun KtValueArgument.extractStringArgumentValue() = + (getArgumentExpression() as KtStringTemplateExpression) + .entries.joinToString("") { it.text } + override fun visitCallExpression(expression: KtCallExpression) { if (rewriteAsserts) { when (expression.calleeExpression?.text) { @@ -128,19 +143,6 @@ class SamplesTransformer( super.visitCallExpression(expression) } - private fun reportProblemConvertingElement(element: PsiElement, e: Exception) { - val text = element.text - val document = PsiDocumentManager.getInstance(element.project).getDocument(element.containingFile) - - val lineInfo = if (document != null) { - val lineNumber = document.getLineNumber(element.startOffset) - "$lineNumber, ${element.startOffset - document.getLineStartOffset(lineNumber)}" - } else { - "offset: ${element.startOffset}" - } - errors += ConvertError(e, text, lineInfo) - } - override fun visitElement(element: PsiElement) { if (element is LeafPsiElement) { val t = element.text @@ -154,108 +156,10 @@ class SamplesTransformer( try { element.accept(this@SampleBuilder) } catch (e: Exception) { - try { - reportProblemConvertingElement(element, e) - } finally { - builder.append(element.text) //recover - } + builder.append(element.text) } } }) } - - } - - private fun processBody(psiElement: PsiElement): String { - val text = processSampleBody(psiElement).trim { it == '\n' || it == '\r' }.trimEnd() - val lines = text.split("\n") - val indent = lines.filter(String::isNotBlank).map { it.takeWhile(Char::isWhitespace).count() }.minOrNull() ?: 0 - return lines.joinToString("\n") { it.drop(indent) } - } - - operator fun invoke(functionName: String): String? { - val psiElement = fqNameToPsiElement(facade, functionName) - ?: return null//.also { context.logger.warn("Cannot find PsiElement corresponding to $functionName") } - val body = processBody(psiElement) - return createSampleBody(body) - } - - private fun setUpAnalysis(): DokkaResolutionFacade = - AnalysisEnvironment(KorroMessageCollector(context.logger), Platform.jvm).run { - addClasspath(PathUtil.getJdkClassesRootsFromCurrentJre()) - addSources(context.sampleSet.toList()) - loadLanguageVersionSettings(null, null) - - val environment = createCoreEnvironment() - val (facade, _) = createResolutionFacade(environment) - facade - } - - private fun createSampleBody(body: String) = - """ | - |```kotlin - |$body - |``` - |""".trimMargin() - - private fun fqNameToPsiElement(resolutionFacade: DokkaResolutionFacade?, functionName: String): PsiElement? { - val packageName = functionName.takeWhile { it != '.' } - val descriptor = resolutionFacade?.resolveSession?.getPackageFragment(FqName(packageName)) - ?: return null.also { context.logger.debug("Cannot find descriptor for package $functionName") } // todo - val symbol = resolveKDocLink( - BindingContext.EMPTY, - resolutionFacade, - descriptor, - null, - functionName.split(".") - ).firstOrNull() ?: return null.also { context.logger.debug("Unresolved function $functionName") } - return DescriptorToSourceUtils.descriptorToDeclaration(symbol) - } - - private fun processSampleBody(psiElement: PsiElement) = when (psiElement) { - is KtDeclarationWithBody -> { - val bodyExpression = psiElement.bodyExpression - val bodyExpressionText = bodyExpression!!.buildSampleText() - when (bodyExpression) { - is KtBlockExpression -> bodyExpressionText.removeSurrounding("{", "}") - else -> bodyExpressionText - } - } - - else -> psiElement.buildSampleText() - } - - private fun PsiElement.buildSampleText(): String { - val sampleBuilder = SampleBuilder(rewriteAsserts) - this.accept(sampleBuilder) - - sampleBuilder.errors.forEach { - val sw = StringWriter() - val pw = PrintWriter(sw) - it.e.printStackTrace(pw) - - this@SamplesTransformer.context.logger.error( - "${containingFile.name}: (${it.loc}): Exception thrown while converting \n```\n${it.text}\n```\n$sw", - it.e - ) - } - return sampleBuilder.text - } -} - -class KorroMessageCollector(private val logger: KorroLog) : MessageCollector { - override fun clear() { - seenErrors = false - } - - private var seenErrors = false - - override fun report(severity: CompilerMessageSeverity, message: String, location: CompilerMessageSourceLocation?) { - if (severity == CompilerMessageSeverity.ERROR) { - seenErrors = true - } - logger.info(MessageRenderer.PLAIN_FULL_PATHS.render(severity, message, location)) } - - override fun hasErrors() = seenErrors } diff --git a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt new file mode 100644 index 0000000..d8d75fb --- /dev/null +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt @@ -0,0 +1,21 @@ +package io.github.devcrocod.korro.analysis + +import java.io.File + +class SamplesTransformer( + samples: Set, + rewriteAsserts: Boolean, +) : AutoCloseable { + private val session = KorroAnalysisSession(samples) + private val resolver = FqnResolver(session) + private val extractor = SampleExtractor(rewriteAsserts) + + operator fun invoke(functionName: String): String? { + val fn = resolver.resolve(functionName) ?: return null + return extractor.extract(fn) + } + + override fun close() { + session.close() + } +} diff --git a/korro-gradle-plugin/build.gradle.kts b/korro-gradle-plugin/build.gradle.kts index b74bb42..ec3a610 100644 --- a/korro-gradle-plugin/build.gradle.kts +++ b/korro-gradle-plugin/build.gradle.kts @@ -1,26 +1,41 @@ plugins { kotlin("jvm") `java-gradle-plugin` - id("com.gradle.plugin-publish") version "1.1.0" + id("com.gradle.plugin-publish") version "2.1.1" `maven-publish` - id("com.gradleup.shadow") version "8.3.5" + id("com.gradleup.shadow") version "9.4.1" } configurations.named(JavaPlugin.API_CONFIGURATION_NAME) { dependencies.remove(project.dependencies.gradleApi()) } -val dokka_version: String by project val kotlin_version: String by project dependencies { shadow(kotlin("stdlib-jdk8", version = kotlin_version)) - shadow("org.jetbrains.dokka:dokka-core:$dokka_version") - shadow("org.jetbrains.dokka:dokka-analysis:$dokka_version") + + compileOnly(project(":korro-analysis")) shadow(gradleApi()) shadow(gradleKotlinDsl()) } +val generateKorroVersionResource by tasks.registering { + val outputDir = layout.buildDirectory.dir("generated/korroVersion") + val korroVersion = project.version.toString() + inputs.property("korroVersion", korroVersion) + outputs.dir(outputDir) + doLast { + val file = outputDir.get().file("META-INF/korro-gradle-plugin.properties").asFile + file.parentFile.mkdirs() + file.writeText("version=$korroVersion\n") + } +} + +tasks.processResources { + from(generateKorroVersionResource) +} + tasks.shadowJar { isZip64 = true archiveClassifier.set("") diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt index 64da1ea..f1723db 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt @@ -17,7 +17,7 @@ val DIRECTIVE_REGEX = fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { logger.info("*** Reading $inputFile") - val samplesTransformer = SamplesTransformer(this, rewriteAsserts) + val samplesTransformer = this.samplesTransformer val lines = ArrayList() val imports = mutableListOf("") diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt index 8b06af0..f025846 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt @@ -1,5 +1,6 @@ package io.github.devcrocod.korro +import io.github.devcrocod.korro.analysis.SamplesTransformer import org.gradle.api.GradleException import org.gradle.workers.WorkAction import org.gradle.workers.WorkParameters @@ -19,19 +20,20 @@ interface KorroParameters : WorkParameters { abstract class KorroWorkAction : WorkAction { override fun execute() { val p = parameters - val ctx = KorroContext( - logger = LoggerLog(), - docsToOutputs = p.docsToOutputs, - samples = p.samples, - sampleOutputs = p.sampleOutputs, - groups = p.groups, - rewriteAsserts = p.rewriteAsserts, - ignoreMissing = p.ignoreMissing, - ) - if (!ctx.process()) { - throw GradleException( - "${p.taskName} failed, see log for details (use --info for detailed log)." + SamplesTransformer(p.samples, p.rewriteAsserts).use { transformer -> + val ctx = KorroContext( + logger = LoggerLog(), + docsToOutputs = p.docsToOutputs, + sampleOutputs = p.sampleOutputs, + groups = p.groups, + ignoreMissing = p.ignoreMissing, + samplesTransformer = transformer, ) + if (!ctx.process()) { + throw GradleException( + "${p.taskName} failed, see log for details (use --info for detailed log)." + ) + } } } } diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt index 853593a..81e97a0 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt @@ -1,20 +1,19 @@ package io.github.devcrocod.korro +import io.github.devcrocod.korro.analysis.SamplesTransformer import java.io.File class KorroContext( val logger: KorroLog, docsToOutputs: Map, - samples: Collection, sampleOutputs: Collection, val groups: List, - val rewriteAsserts: Boolean, val ignoreMissing: Boolean, + val samplesTransformer: SamplesTransformer, ) { val fileQueue: ArrayDeque> = ArrayDeque( docsToOutputs.entries.map { (input, output) -> input to output } ) - val sampleSet: Set = samples.toHashSet() val outputsMap: Map = sampleOutputs.associateBy { it.name } } diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt index 59c8a5a..40a2fd0 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt @@ -2,22 +2,21 @@ package io.github.devcrocod.korro import org.gradle.api.Plugin import org.gradle.api.Project +import java.util.Properties class KorroPlugin : Plugin { override fun apply(project: Project): Unit = with(project) { val ext = extensions.create("korro", KorroExtension::class.java) - val runtime = configurations.create("korroRuntime") { + val korroPluginVersion = readKorroPluginVersion() + + val runtime = configurations.create("korroAnalysisRuntime") { it.isCanBeConsumed = false it.isCanBeResolved = true } - listOf("dokka-analysis", "dokka-base", "dokka-core").forEach { art -> - dependencies.add(runtime.name, "org.jetbrains.dokka:$art:$DOKKA_VERSION") - } + dependencies.add(runtime.name, "io.github.devcrocod:korro-analysis:$korroPluginVersion") afterEvaluate { - val pluginVersion = version.toString() - val korroTask = tasks.register("korro", KorroTask::class.java) { t -> t.description = "Generates markdown docs with sample snippets into build/korro/docs." t.group = "documentation" @@ -34,7 +33,7 @@ class KorroPlugin : Plugin { t.groupSamples.patterns.set(ext.groupSamples.patterns) t.korroRuntimeClasspath.from(runtime) t.outputDirectory.set(layout.buildDirectory.dir("korro/docs")) - t.korroPluginVersion.set(pluginVersion) + t.korroPluginVersion.set(korroPluginVersion) } tasks.register("korroApply", KorroApplyTask::class.java) { t -> @@ -60,9 +59,18 @@ class KorroPlugin : Plugin { t.groupSamples.afterSample.set(ext.groupSamples.afterSample) t.groupSamples.patterns.set(ext.groupSamples.patterns) t.korroRuntimeClasspath.from(runtime) - t.korroPluginVersion.set(pluginVersion) + t.korroPluginVersion.set(korroPluginVersion) t.reportFile.set(layout.buildDirectory.file("korro/check.report")) } } } + + private fun readKorroPluginVersion(): String { + val resource = KorroPlugin::class.java.classLoader + .getResourceAsStream("META-INF/korro-gradle-plugin.properties") + ?: error("Cannot locate META-INF/korro-gradle-plugin.properties on the plugin classpath.") + val props = resource.use { Properties().apply { load(it) } } + return props.getProperty("version") + ?: error("Property 'version' missing from META-INF/korro-gradle-plugin.properties.") + } } diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt index 4a931cf..4f73a6f 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt @@ -3,6 +3,7 @@ package io.github.devcrocod.korro import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.provider.Provider import org.gradle.api.tasks.CacheableTask @@ -20,8 +21,6 @@ import org.gradle.workers.WorkerExecutor import java.io.File import javax.inject.Inject -internal const val DOKKA_VERSION = "1.8.20" - @DisableCachingByDefault(because = "Abstract base; concrete subclasses opt in with @CacheableTask.") abstract class AbstractKorroTask : DefaultTask() { From d8b63b32c5acf313ce2e6ff3936219a9e432d0d1 Mon Sep 17 00:00:00 2001 From: devcrocod Date: Sat, 18 Apr 2026 01:12:17 +0200 Subject: [PATCH 06/15] Add integration tests for sample extraction and improve diagnostics system - Introduced `funs` and `strictErrors` integration test fixtures, verifying sample extraction and strict error handling scenarios. - Enhanced diagnostics with detailed reporting for unresolved directives (`FUN` and `FUNS`) and hints for potential matches. - Expanded `SamplesTransformer`: added glob matching (`matchGlob`) and Levenshtein-based suggestions (`suggestShortNames`). - Extended `KorroAction`, handling diagnostics and formatting detailed error tables. - Updated fixture Gradle scripts for `ignoreMissing` and added `korro` configurations. --- .../fixtures/funs/build.gradle.kts | 19 ++ .../fixtures/funs/docs/expected/readme.md | 15 ++ .../fixtures/funs/docs/in/readme.md | 6 + .../fixtures/funs/samples/Example.kt | 13 ++ .../fixtures/funs/settings.gradle.kts | 1 + .../fixtures/ignoreMissing/build.gradle.kts | 22 ++ .../ignoreMissing/docs/expected/broken.md | 6 + .../fixtures/ignoreMissing/docs/in/broken.md | 6 + .../fixtures/ignoreMissing/samples/Example.kt | 7 + .../ignoreMissing/settings.gradle.kts | 1 + .../fixtures/strictErrors/build.gradle.kts | 19 ++ .../fixtures/strictErrors/docs/in/broken.md | 6 + .../fixtures/strictErrors/samples/Example.kt | 7 + .../fixtures/strictErrors/settings.gradle.kts | 1 + .../korro/it/KorroIntegrationTest.kt | 46 +++++ .../devcrocod/korro/analysis/FqnResolver.kt | 86 +++++++- .../korro/analysis/SamplesTransformer.kt | 12 ++ .../kotlin/io/github/devcrocod/korro/Korro.kt | 195 +++++++++++++----- .../io/github/devcrocod/korro/KorroAction.kt | 22 +- .../io/github/devcrocod/korro/KorroContext.kt | 6 +- .../io/github/devcrocod/korro/SampleGroups.kt | 12 +- 21 files changed, 449 insertions(+), 59 deletions(-) create mode 100644 integration-tests/fixtures/funs/build.gradle.kts create mode 100644 integration-tests/fixtures/funs/docs/expected/readme.md create mode 100644 integration-tests/fixtures/funs/docs/in/readme.md create mode 100644 integration-tests/fixtures/funs/samples/Example.kt create mode 100644 integration-tests/fixtures/funs/settings.gradle.kts create mode 100644 integration-tests/fixtures/ignoreMissing/build.gradle.kts create mode 100644 integration-tests/fixtures/ignoreMissing/docs/expected/broken.md create mode 100644 integration-tests/fixtures/ignoreMissing/docs/in/broken.md create mode 100644 integration-tests/fixtures/ignoreMissing/samples/Example.kt create mode 100644 integration-tests/fixtures/ignoreMissing/settings.gradle.kts create mode 100644 integration-tests/fixtures/strictErrors/build.gradle.kts create mode 100644 integration-tests/fixtures/strictErrors/docs/in/broken.md create mode 100644 integration-tests/fixtures/strictErrors/samples/Example.kt create mode 100644 integration-tests/fixtures/strictErrors/settings.gradle.kts diff --git a/integration-tests/fixtures/funs/build.gradle.kts b/integration-tests/fixtures/funs/build.gradle.kts new file mode 100644 index 0000000..8ba574e --- /dev/null +++ b/integration-tests/fixtures/funs/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("io.github.devcrocod.korro") +} + +repositories { + mavenLocal() + mavenCentral() + maven("https://cache-redirector.jetbrains.com/intellij-dependencies") +} + +korro { + docs { + from(fileTree("docs/in")) + baseDir.set(layout.projectDirectory.dir("docs/in")) + } + samples { + from(fileTree("samples")) + } +} diff --git a/integration-tests/fixtures/funs/docs/expected/readme.md b/integration-tests/fixtures/funs/docs/expected/readme.md new file mode 100644 index 0000000..127ac5e --- /dev/null +++ b/integration-tests/fixtures/funs/docs/expected/readme.md @@ -0,0 +1,15 @@ +# FUNS example + + + + + +```kotlin +println("version one") +``` + +```kotlin +println("version two") +``` + + diff --git a/integration-tests/fixtures/funs/docs/in/readme.md b/integration-tests/fixtures/funs/docs/in/readme.md new file mode 100644 index 0000000..936af7a --- /dev/null +++ b/integration-tests/fixtures/funs/docs/in/readme.md @@ -0,0 +1,6 @@ +# FUNS example + + + + + diff --git a/integration-tests/fixtures/funs/samples/Example.kt b/integration-tests/fixtures/funs/samples/Example.kt new file mode 100644 index 0000000..f52651c --- /dev/null +++ b/integration-tests/fixtures/funs/samples/Example.kt @@ -0,0 +1,13 @@ +package samples + +fun sample_v1() { + //SampleStart + println("version one") + //SampleEnd +} + +fun sample_v2() { + //SampleStart + println("version two") + //SampleEnd +} diff --git a/integration-tests/fixtures/funs/settings.gradle.kts b/integration-tests/fixtures/funs/settings.gradle.kts new file mode 100644 index 0000000..1eab758 --- /dev/null +++ b/integration-tests/fixtures/funs/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "korro-funs-fixture" diff --git a/integration-tests/fixtures/ignoreMissing/build.gradle.kts b/integration-tests/fixtures/ignoreMissing/build.gradle.kts new file mode 100644 index 0000000..02b0354 --- /dev/null +++ b/integration-tests/fixtures/ignoreMissing/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("io.github.devcrocod.korro") +} + +repositories { + mavenLocal() + mavenCentral() + maven("https://cache-redirector.jetbrains.com/intellij-dependencies") +} + +korro { + docs { + from(fileTree("docs/in")) + baseDir.set(layout.projectDirectory.dir("docs/in")) + } + samples { + from(fileTree("samples")) + } + behavior { + ignoreMissing.set(true) + } +} diff --git a/integration-tests/fixtures/ignoreMissing/docs/expected/broken.md b/integration-tests/fixtures/ignoreMissing/docs/expected/broken.md new file mode 100644 index 0000000..01ef974 --- /dev/null +++ b/integration-tests/fixtures/ignoreMissing/docs/expected/broken.md @@ -0,0 +1,6 @@ +# Broken + + + + + diff --git a/integration-tests/fixtures/ignoreMissing/docs/in/broken.md b/integration-tests/fixtures/ignoreMissing/docs/in/broken.md new file mode 100644 index 0000000..01ef974 --- /dev/null +++ b/integration-tests/fixtures/ignoreMissing/docs/in/broken.md @@ -0,0 +1,6 @@ +# Broken + + + + + diff --git a/integration-tests/fixtures/ignoreMissing/samples/Example.kt b/integration-tests/fixtures/ignoreMissing/samples/Example.kt new file mode 100644 index 0000000..58c56e8 --- /dev/null +++ b/integration-tests/fixtures/ignoreMissing/samples/Example.kt @@ -0,0 +1,7 @@ +package samples + +fun present() { + //SampleStart + println("only real sample") + //SampleEnd +} diff --git a/integration-tests/fixtures/ignoreMissing/settings.gradle.kts b/integration-tests/fixtures/ignoreMissing/settings.gradle.kts new file mode 100644 index 0000000..704c7be --- /dev/null +++ b/integration-tests/fixtures/ignoreMissing/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "korro-ignore-missing-fixture" diff --git a/integration-tests/fixtures/strictErrors/build.gradle.kts b/integration-tests/fixtures/strictErrors/build.gradle.kts new file mode 100644 index 0000000..8ba574e --- /dev/null +++ b/integration-tests/fixtures/strictErrors/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("io.github.devcrocod.korro") +} + +repositories { + mavenLocal() + mavenCentral() + maven("https://cache-redirector.jetbrains.com/intellij-dependencies") +} + +korro { + docs { + from(fileTree("docs/in")) + baseDir.set(layout.projectDirectory.dir("docs/in")) + } + samples { + from(fileTree("samples")) + } +} diff --git a/integration-tests/fixtures/strictErrors/docs/in/broken.md b/integration-tests/fixtures/strictErrors/docs/in/broken.md new file mode 100644 index 0000000..01ef974 --- /dev/null +++ b/integration-tests/fixtures/strictErrors/docs/in/broken.md @@ -0,0 +1,6 @@ +# Broken + + + + + diff --git a/integration-tests/fixtures/strictErrors/samples/Example.kt b/integration-tests/fixtures/strictErrors/samples/Example.kt new file mode 100644 index 0000000..58c56e8 --- /dev/null +++ b/integration-tests/fixtures/strictErrors/samples/Example.kt @@ -0,0 +1,7 @@ +package samples + +fun present() { + //SampleStart + println("only real sample") + //SampleEnd +} diff --git a/integration-tests/fixtures/strictErrors/settings.gradle.kts b/integration-tests/fixtures/strictErrors/settings.gradle.kts new file mode 100644 index 0000000..1574b64 --- /dev/null +++ b/integration-tests/fixtures/strictErrors/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "korro-strict-errors-fixture" diff --git a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt index 440c2d3..b09986e 100644 --- a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt +++ b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt @@ -35,6 +35,52 @@ class KorroIntegrationTest { ) } + @Test + fun funsFixture(@TempDir tempDir: Path) { + runFixture( + name = "funs", + tempDir = tempDir, + generatedRelativePath = "build/korro/docs/readme.md", + expectedRelativePath = "funs/docs/expected/readme.md", + ) + } + + @Test + fun strictModeFailsOnMissing(@TempDir tempDir: Path) { + val fixture = loadFixture("strictErrors", tempDir) + + val runner = GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withArguments("korro", "--stacktrace") + .forwardOutput() + + configurePluginClasspath(runner) + System.getProperty("korro.testkit.gradleVersion") + ?.takeIf { it.isNotBlank() } + ?.let(runner::withGradleVersion) + + val result = runner.buildAndFail() + + assertEquals(TaskOutcome.FAILED, result.task(":korro")?.outcome, "korro task should fail in strict mode") + val output = result.output + assertTrue(output.contains("nonExistent")) { + "Expected failure output to name the unresolved directive 'nonExistent'; got:\n$output" + } + assertTrue(output.contains("error(s) found")) { + "Expected failure output to contain formatted diagnostic table header; got:\n$output" + } + } + + @Test + fun ignoreMissingPreservesSource(@TempDir tempDir: Path) { + runFixture( + name = "ignoreMissing", + tempDir = tempDir, + generatedRelativePath = "build/korro/docs/broken.md", + expectedRelativePath = "ignoreMissing/docs/expected/broken.md", + ) + } + private fun runFixture( name: String, tempDir: Path, diff --git a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt index 31cf7a8..cb51370 100644 --- a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt @@ -7,13 +7,17 @@ import org.jetbrains.kotlin.psi.KtNamedFunction class FqnResolver(session: KorroAnalysisSession) { private val byFqn: Map private val byShortName: Map> + private val ordered: List> init { - val fqn = mutableMapOf() - val shortName = mutableMapOf>() - session.files.forEach { file -> collectFunctions(file, fqn, shortName) } + val fqn = linkedMapOf() + val shortName = linkedMapOf>() + val orderedList = mutableListOf>() + val files = session.files.sortedBy { it.virtualFilePath } + files.forEach { file -> collectFunctions(file, fqn, shortName, orderedList) } byFqn = fqn byShortName = shortName + ordered = orderedList } fun resolve(candidateFqn: String): KtNamedFunction? { @@ -24,16 +28,53 @@ class FqnResolver(session: KorroAnalysisSession) { return null } + /** + * Return every function whose FQN matches `prefix + pattern` for some prefix in [prefixes]. + * Deduplicates across prefixes (a function reached via several prefixes appears once), + * preserving the first-encountered order: prefixes in the given order, and within each + * prefix the declaration order from the source set. + */ + fun matchGlob(pattern: String, prefixes: List): List { + val regexes = prefixes.map { compileGlob(it + pattern) } + val seen = mutableSetOf() + val result = mutableListOf() + for (regex in regexes) { + for ((fqn, fn) in ordered) { + if (regex.matches(fqn) && seen.add(fn)) { + result += fn + } + } + } + return result + } + + /** Top [limit] short names closest to [bareName] by Levenshtein distance, used for hints. */ + fun suggestShortNames(bareName: String, limit: Int = 3): List { + val target = bareName.substringAfterLast('.') + if (target.isEmpty()) return emptyList() + return byShortName.keys + .map { it to levenshtein(it, target) } + .filter { it.second <= (target.length / 2).coerceAtLeast(2) } + .sortedWith(compareBy({ it.second }, { it.first })) + .take(limit) + .map { it.first } + } + private fun collectFunctions( file: KtFile, fqn: MutableMap, shortName: MutableMap>, + ordered: MutableList>, ) { fun visit(declarations: List) { declarations.forEach { decl -> when (decl) { is KtNamedFunction -> { - decl.fqName?.asString()?.let { fqn[it] = decl } + val fqnString = decl.fqName?.asString() + if (fqnString != null) { + fqn[fqnString] = decl + ordered += fqnString to decl + } decl.name?.let { shortName.getOrPut(it) { mutableListOf() }.add(decl) } } is KtClassOrObject -> visit(decl.declarations) @@ -44,3 +85,40 @@ class FqnResolver(session: KorroAnalysisSession) { visit(file.declarations) } } + +private fun compileGlob(pattern: String): Regex { + val sb = StringBuilder("^") + for (c in pattern) { + when (c) { + '*' -> sb.append(".*") + '?' -> sb.append('.') + '.', '\\', '+', '(', ')', '[', ']', '{', '}', '|', '^', '$' -> sb.append('\\').append(c) + else -> sb.append(c) + } + } + sb.append('$') + return Regex(sb.toString()) +} + +private fun levenshtein(a: String, b: String): Int { + if (a == b) return 0 + if (a.isEmpty()) return b.length + if (b.isEmpty()) return a.length + var prev = IntArray(b.length + 1) { it } + var curr = IntArray(b.length + 1) + for (i in 1..a.length) { + curr[0] = i + for (j in 1..b.length) { + val cost = if (a[i - 1] == b[j - 1]) 0 else 1 + curr[j] = minOf( + curr[j - 1] + 1, + prev[j] + 1, + prev[j - 1] + cost, + ) + } + val tmp = prev + prev = curr + curr = tmp + } + return prev[b.length] +} diff --git a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt index d8d75fb..5c54c12 100644 --- a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt @@ -2,6 +2,8 @@ package io.github.devcrocod.korro.analysis import java.io.File +data class RenderedSample(val fqn: String, val snippet: String) + class SamplesTransformer( samples: Set, rewriteAsserts: Boolean, @@ -15,6 +17,16 @@ class SamplesTransformer( return extractor.extract(fn) } + fun matchGlob(globPattern: String, imports: List): List { + val matches = resolver.matchGlob(globPattern, imports) + return matches.map { fn -> + val fqn = fn.fqName?.asString() ?: fn.name ?: "" + RenderedSample(fqn, extractor.extract(fn)) + } + } + + fun suggestions(bareName: String): List = resolver.suggestShortNames(bareName) + override fun close() { session.close() } diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt index f1723db..e57d658 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt @@ -21,11 +21,17 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { val lines = ArrayList() val imports = mutableListOf("") - fun processFun(funName: String, oldSampleLines: List) { - val functionNames = imports.map { - it + funName - } - val newSamplesLines = functionNames.firstNotNullOfOrNull { name -> // TODO: can be improved + fun reportMissing(line: Int, message: String, hint: String? = null) { + val sev = if (ignoreMissing) Severity.WARN else Severity.ERROR + diagnostics += Diagnostic(sev, inputFile.path, line, message, hint) + val suffix = hint?.let { " ($it)" } ?: "" + if (sev == Severity.WARN) logger.warn("$inputFile:$line: $message$suffix") + else logger.info("$inputFile:$line: $message$suffix") + } + + fun renderFunBody(funName: String): List? { + val functionNames = imports.map { it + funName } + return functionNames.firstNotNullOfOrNull { name -> var text = samplesTransformer(name) ?: groups.firstNotNullOfOrNull { group -> group.patterns.mapNotNull { pattern -> samplesTransformer(name + pattern.nameSuffix)?.let { @@ -38,18 +44,24 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { postfix = group.afterGroup ?: "" ) } - val output = outputsMap[name] if (text != null && output != null) { text += output.readText() } - text?.split("\n")?.plus(END_SAMPLE) } + } + + fun processFun(funName: String, oldSampleLines: List, directiveLine: Int) { + val newSamplesLines = renderFunBody(funName) if (newSamplesLines == null) { - logger.warn("Cannot find PsiElement corresponding to '$funName'") + val hint = samplesTransformer.suggestions(funName).takeIf { it.isNotEmpty() } + ?.joinToString(prefix = "did you mean: ", separator = ", ") + reportMissing(directiveLine, "Cannot resolve FUN '$funName'", hint) + lines.addAll(oldSampleLines) + return } - if (newSamplesLines != null && oldSampleLines != newSamplesLines) { + if (oldSampleLines != newSamplesLines) { logger.info("*** Add $funName sample") lines.addAll(newSamplesLines) } else { @@ -57,49 +69,138 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { } } - inputFile.bufferedReader().use { bufferedReader -> + fun renderFunsBody(glob: String): List? { + val matches = samplesTransformer.matchGlob(glob, imports) + if (matches.isEmpty()) return null + + val trimmed = matches.map { it.copy(snippet = it.snippet.trim { ch -> ch == '\n' }) } + + val group = groups.firstOrNull() + val hasWrapping = group != null && ( + !group.beforeGroup.isNullOrEmpty() || !group.afterGroup.isNullOrEmpty() || + !group.beforeSample.isNullOrEmpty() || !group.afterSample.isNullOrEmpty() + ) + + val body = when { + hasWrapping && trimmed.size >= 2 -> trimmed.joinToString( + separator = "\n", + prefix = group!!.beforeGroup.orEmpty(), + postfix = group.afterGroup.orEmpty(), + ) { rs -> group.beforeSample.orEmpty() + rs.snippet + group.afterSample.orEmpty() } + + hasWrapping -> { + val rs = trimmed.single() + group!!.beforeSample.orEmpty() + rs.snippet + group.afterSample.orEmpty() + } + + else -> trimmed.joinToString(separator = "\n\n") { it.snippet } + } + return ("\n" + body + "\n").split("\n") + END_SAMPLE + } + + fun processFuns(glob: String, oldSampleLines: List, directiveLine: Int) { + val newSamplesLines = renderFunsBody(glob) + if (newSamplesLines == null) { + reportMissing(directiveLine, "FUNS '$glob' matched no functions") + lines.addAll(oldSampleLines) + return + } + if (oldSampleLines != newSamplesLines) { + logger.info("*** Expand FUNS $glob (${newSamplesLines.size} lines)") + lines.addAll(newSamplesLines) + } else { + lines.addAll(oldSampleLines) + } + } + + data class BlockCollect( + val old: List, + val terminator: Directive?, + val terminatorLine: String?, + val unclosed: Boolean, + ) + + fun collectBlock(reader: java.io.BufferedReader, startLineNo: Int): Pair { + val old = ArrayList() + var n = startLineNo while (true) { - val line = bufferedReader.readLine() ?: break - lines.add(line) - var directive = parseDirective(line) - when (directive?.name) { - null, END_DIRECTIVE -> { + val sampleLine = reader.readLine() + if (sampleLine == null) { + return BlockCollect(old, null, null, unclosed = true) to n + } + n++ + val nextDirective = parseDirective(sampleLine) + when (nextDirective?.name) { + END_DIRECTIVE -> { + old.add(sampleLine) + return BlockCollect(old, nextDirective, sampleLine, unclosed = false) to n } - IMPORT_DIRECTIVE -> { - imports.add(directive.value + ".") + FUN_DIRECTIVE, FUNS_DIRECTIVE -> { + return BlockCollect(old, nextDirective, sampleLine, unclosed = true) to n } - FUN_DIRECTIVE -> { - val oldSampleLines = ArrayList() - while (true) { - val sampleLine = bufferedReader.readLine() - val nextDirective = if (sampleLine != null) parseDirective(sampleLine) else Directive(EOF, "") - when (nextDirective?.name) { - END_DIRECTIVE -> { - oldSampleLines.add(sampleLine) - break - } - EOF, FUN_DIRECTIVE -> { - processFun(directive!!.value, emptyList()) - lines.addAll(oldSampleLines) - oldSampleLines.clear() - if (sampleLine == null) { - directive = null - break - } - directive = nextDirective - lines.add(sampleLine) - } - else -> { - oldSampleLines.add(sampleLine) - } + else -> old.add(sampleLine) + } + } + } + + inputFile.bufferedReader().use { reader -> + var lineNo = 0 + var pendingDirective: Directive? = null + var pendingDirectiveLine = 0 + var pendingLineText: String? = null + + while (true) { + val line: String + val directive: Directive? + val directiveLineNo: Int + + if (pendingDirective != null) { + directive = pendingDirective + directiveLineNo = pendingDirectiveLine + line = pendingLineText!! + pendingDirective = null + pendingLineText = null + } else { + val raw = reader.readLine() ?: break + lineNo++ + line = raw + directive = parseDirective(raw) + directiveLineNo = lineNo + } + lines.add(line) + + when (directive?.name) { + null, END_DIRECTIVE -> { /* no-op */ } + + IMPORT_DIRECTIVE -> imports.add(directive.value + ".") + + FUN_DIRECTIVE, FUNS_DIRECTIVE -> { + val (collected, newLineNo) = collectBlock(reader, lineNo) + lineNo = newLineNo + + if (collected.unclosed) { + val kind = directive.name + reportMissing( + directiveLineNo, + "Unclosed $kind '${directive.value}' (reached ${if (collected.terminator == null) "EOF" else "next " + collected.terminator.name})", + ) + lines.addAll(collected.old) + if (collected.terminator != null) { + pendingDirective = collected.terminator + pendingLineText = collected.terminatorLine + pendingDirectiveLine = lineNo + } + } else { + when (directive.name) { + FUN_DIRECTIVE -> processFun(directive.value, collected.old, directiveLineNo) + FUNS_DIRECTIVE -> processFuns(directive.value, collected.old, directiveLineNo) } } - if (directive == null) break - processFun(directive.value, oldSampleLines) } - FUNS_DIRECTIVE -> { - } - else -> logger.warn("Unrecognized directive '${directive.name}' on a line starting with '$DIRECTIVE_START' in '$inputFile'") + + else -> logger.warn( + "Unrecognized directive '${directive.name}' on a line starting with '$DIRECTIVE_START' in '$inputFile'" + ) } } } @@ -108,7 +209,7 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { outputFile.printWriter().use { out -> lines.forEach { out.println(it) } } - return true + return diagnostics.none { it.severity == Severity.ERROR } } data class Directive( diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt index f025846..c7eb94c 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt @@ -29,11 +29,25 @@ abstract class KorroWorkAction : WorkAction { ignoreMissing = p.ignoreMissing, samplesTransformer = transformer, ) - if (!ctx.process()) { - throw GradleException( - "${p.taskName} failed, see log for details (use --info for detailed log)." - ) + ctx.process() + + val errors = ctx.diagnostics.filter { it.severity == Severity.ERROR } + if (errors.isNotEmpty()) { + throw GradleException(formatDiagnosticTable(p.taskName, errors)) } } } } + +internal fun formatDiagnosticTable(taskName: String, errors: List): String { + val header = "$taskName: ${errors.size} error(s) found" + val sevWidth = errors.maxOf { it.severity.name.length } + val locWidth = errors.maxOf { "${it.file}:${it.line}".length } + val rows = errors.joinToString("\n") { d -> + val loc = "${d.file}:${d.line}".padEnd(locWidth) + val sev = d.severity.name.padEnd(sevWidth) + val hint = d.hint?.let { " ($it)" } ?: "" + " $sev $loc ${d.message}$hint" + } + return "$header\n$rows" +} diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt index 81e97a0..001d2d0 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt @@ -15,12 +15,12 @@ class KorroContext( docsToOutputs.entries.map { (input, output) -> input to output } ) val outputsMap: Map = sampleOutputs.associateBy { it.name } + val diagnostics: MutableList = mutableListOf() } -fun KorroContext.process(): Boolean { +fun KorroContext.process() { while (!fileQueue.isEmpty()) { val (input, output) = fileQueue.removeFirst() - if (!korro(input, output)) return false + korro(input, output) } - return true } diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SampleGroups.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SampleGroups.kt index d6d045f..658ff52 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SampleGroups.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SampleGroups.kt @@ -14,4 +14,14 @@ data class SamplesGroup( val beforeSample: String?, val afterSample: String?, val patterns: List -) : Serializable \ No newline at end of file +) : Serializable + +enum class Severity { ERROR, WARN } + +data class Diagnostic( + val severity: Severity, + val file: String, + val line: Int, + val message: String, + val hint: String? = null, +) : Serializable From 10892a17716158ccc6798226d148d3175f5dd311 Mon Sep 17 00:00:00 2001 From: devcrocod Date: Sat, 18 Apr 2026 01:28:45 +0200 Subject: [PATCH 07/15] Add CLAUDE.md with detailed contributor guidelines and project architecture overview - Introduced `CLAUDE.md` providing guidance for contributors, including repository structure, build commands, and version wiring. - Clarified Korro's `korro-gradle-plugin` and `korro-analysis` subproject responsibilities. - Detailed directive parsing, sample extraction, and markdown rewriter behavior. - Documented caching strategy, configuration properties, and consumer project usage. - Covered package dependencies, runtime classpath isolation, and design considerations for future refactoring. --- CLAUDE.md | 84 +++++ MIGRATION.md | 147 +++++++++ README.md | 312 ++++++++++-------- .../korro/it/KorroIntegrationTest.kt | 8 +- .../devcrocod/korro/analysis/FqnResolver.kt | 3 + .../korro/analysis/SampleExtractor.kt | 42 ++- .../kotlin/io/github/devcrocod/korro/Korro.kt | 18 +- .../io/github/devcrocod/korro/KorroPlugin.kt | 2 +- .../io/github/devcrocod/korro/KorroTask.kt | 12 +- .../io/github/devcrocod/korro/SampleGroups.kt | 2 +- 10 files changed, 456 insertions(+), 174 deletions(-) create mode 100644 CLAUDE.md create mode 100644 MIGRATION.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..57c790e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Korro is a Gradle plugin (Kotlin/JVM) that injects code snippets from Kotlin sample/test source files into Markdown docs. It is published on the Gradle Plugin Portal as `io.github.devcrocod.korro`. User-facing directive syntax and consumer configuration are documented in `README.md`; the 0.2.0 upgrade contract is in `SPEC.md` and the 0.1.x→0.2.0 migration in `MIGRATION.md` — read those before changing the directive parser or the extension DSL. + +The repository is a multi-module Gradle build: + +- `korro-gradle-plugin/` — thin plugin (published to the Gradle Plugin Portal). Only Gradle API + Kotlin stdlib at compile time. No Analysis API imports. +- `korro-analysis/` — shadowed fat jar with the Kotlin Analysis API (K2 standalone mode), IntelliJ platform, and Korro's PSI-based snippet extraction. Published to Maven Central and pulled in at task-execution time through the `korroAnalysisRuntime` configuration. +- `integration-tests/` — GradleTestKit + golden-file tests under `integration-tests/fixtures/`. + +## Commands + +Build uses the Gradle wrapper (currently 9.4.1): + +- `./gradlew build` — compile and assemble both modules. +- `./gradlew :korro-analysis:shadowJar` — build only the shadowed analysis jar. In 0.2.0 the **plugin** module is thin; the **analysis** module is the fat one. +- `./gradlew publishToMavenLocal` — install both artifacts to `~/.m2/repository` for local testing in a consumer project (the plugin's `korroAnalysisRuntime` looks up `korro-analysis` at its own version, so both must be installed together). +- `./gradlew publishPlugins` — publish the plugin to the Gradle Plugin Portal (requires credentials). +- `./gradlew -Prelease build` — produce a release-versioned artifact. Without `-Prelease`, `detectVersion()` in `build.gradle.kts` appends `-dev` (or `-dev-`) to the version in `gradle.properties`. +- `./gradlew :integration-tests:test` — run the GradleTestKit integration tests under `integration-tests/fixtures/*`. + +The `korro` / `korroApply` / `korroCheck` tasks the plugin registers are only runnable from a *consumer* project that applies this plugin (or from one of the integration-test fixtures). They are not runnable from this repo's root. + +## Version wiring + +Versions live in `gradle.properties`: + +- `version` — Korro's own version. Both modules inherit this through `subprojects { version = rootProject.version }` in the root `build.gradle.kts`. At runtime the plugin reads this from a generated `META-INF/korro-gradle-plugin.properties` resource on the plugin classpath (see `KorroPlugin.readKorroPluginVersion`). +- `kotlin_version` — the pinned Kotlin / Analysis API version used by `korro-analysis`. Single source of truth. +- `language_version` — sets both Kotlin `languageVersion` and `apiVersion` in the subproject Kotlin compilation. Unrelated to the pinned Analysis API version. JVM target is hard-coded to `17` in the root `build.gradle.kts`. + +A new cache key: every task has an `@Input korroPluginVersion` property, so cached outputs are invalidated on plugin bump (which is also a bundled Analysis API bump). + +## Architecture + +Two layers separated by a worker boundary. + +**Gradle-facing layer** — `korro-gradle-plugin/`, runs in the Gradle daemon's classloader, no Analysis API imports. + +- `KorroPlugin` creates the `korro` extension, creates the detached `korroAnalysisRuntime` configuration (with a dependency on `io.github.devcrocod:korro-analysis:`), and in `afterEvaluate` registers three tasks: `korro`, `korroApply`, `korroCheck`. No `korroClean`. +- `KorroExtension` (`KorroExtension.kt`) exposes the nested DSL: `docs { from(...); baseDir.set(...) }`, `samples { from(...); outputs.from(...) }`, `behavior { rewriteAsserts.set(...); ignoreMissing.set(...) }`, `groupSamples { ... }`. All properties use Gradle's `Property` / `ConfigurableFileCollection` / `DirectoryProperty` for config-cache safety. +- Tasks: + - `KorroTask` (`@CacheableTask`, extends `AbstractKorroTask`) — `@InputFiles docs`/`samples`/`samplesOutputs`, `@Input` flags, `@Classpath korroRuntimeClasspath`, `@OutputDirectory outputDirectory` (defaults to `build/korro/docs`). On `@TaskAction`, submits a `KorroWorkAction` via `WorkerExecutor.classLoaderIsolation { classpath.from(korroRuntimeClasspath) }`. + - `KorroApplyTask` (`@DisableCachingByDefault`, extends `Sync`) — wired to copy the `korro` task's output directory onto `docs.baseDir`. This is the only mutation point. + - `KorroCheckTask` (`@CacheableTask`, extends `AbstractKorroTask`) — **currently a stub.** The action logs "not implemented" and writes a placeholder `build/korro/check.report`. Full diff-against-source implementation is pending a follow-up phase; the task is already registered with the same inputs as `korro` so CI callers don't change later. +- `AbstractKorroTask.buildDocsToOutputs(outDir)` computes each input doc's output path relative to `docs.baseDir` — fails loudly if an input is outside `baseDir`. +- `Korro.kt` is the markdown rewriter (parser + state machine). It lives in the plugin module, not the analysis module — parsing `` doesn't need Analysis API, so the parser can run without spinning up a worker. + +**Worker layer** — `korro-analysis/`, runs in a fresh classloader with the Analysis API, IntelliJ platform, and stdlib on the classpath. + +- `KorroWorkAction` (`KorroAction.kt`) receives serialized `KorroWorkParameters` (docs→output map, sample files, sampleOutput files, `SamplesGroup` list, boolean flags, task name, plugin version), builds a `KorroContext`, and drives it. +- `KorroContext` wires the markdown rewriter to a single `SamplesTransformer` constructed once per `execute()`. +- `SamplesTransformer` / `FqnResolver` / `SampleExtractor` (in `korro-analysis/`) drive the K2 Analysis API. One `StandaloneAnalysisAPISession` per worker `execute()` (disposed in a `try/finally`). FQN resolution uses the two-tier strategy from SPEC §9.3: a fast-path short-name index over `KtNamedFunction`s, then a dummy-KDoc fallback for qualified / ambiguous names. +- `Korro.kt` markdown rewriter behavior: + - `IMPORT` pushes a dotted prefix onto an `imports` list; `FUN` / `FUNS` are tried against each prefix until one resolves. First-import-wins on ambiguity. + - `FUN` opens a block; the loop consumes lines into `oldSampleLines` until `END`, EOF, or the next `FUN` / `FUNS`. On close, `processFun` asks `SamplesTransformer` for replacement text. File is rewritten only if any block changed (`rewrite` flag preserved). + - `FUNS` is fully implemented as an Ant-style glob over FQNs. `renderFunsBody(glob)` asks the transformer for all matches, emits them in deterministic order (file path, then source offset), and wraps the group with `groupSamples.beforeGroup`/`afterGroup` when 2+ matches exist. + - Unresolved `FUN` / `FUNS`, unclosed `//SampleStart`, and non-function targets are collected into a `DiagnosticList`. Under `behavior.ignoreMissing=false` (default) the task fails with a single `GradleException` containing the formatted table; under `ignoreMissing=true` everything degrades to `WARN` and the task succeeds with the old snippet lines retained. + - If a `samples.outputs` file named `` exists, its contents are appended to the generated snippet. +- Sample extraction (from SPEC §9.4): + - Markers are detected by trimming comment text — `//SampleStart`, `// SampleStart`, and `/* SampleStart */` all match. Marker comments never appear in the output. + - Multiple `//SampleStart`/`//SampleEnd` pairs in one function are concatenated in source order, separated by a blank line. + - Zero markers → emit the whole body (minus the outer `{ }`). + - Unclosed `//SampleStart` → diagnostic. + - Assert-rewriting (`assertPrints`, `assertTrue`, `assertFalse`, `assertFails`, `assertFailsWith` → commented `println`) runs only when `behavior.rewriteAsserts=true`. + - Output is wrapped in a ```` ```kotlin ```` fence. + +## Packaging detail + +- `korro-gradle-plugin` has minimal runtime dependencies — `compileOnly(gradleApi())`, `implementation(kotlin("stdlib"))`, and an `implementation` edge on `korro-analysis` that is *not* on the plugin's runtime classpath (the consumer resolves `korro-analysis` at task-execution time via the `korroAnalysisRuntime` configuration). This keeps the plugin jar on the Gradle Portal small and avoids classpath conflicts with other Kotlin-compiler-based plugins the consumer might apply. +- `korro-analysis` is the fat/shaded jar, built via the Shadow plugin. It bundles the Analysis API, low-level FIR, and the IntelliJ platform bits needed by standalone mode. `com.intellij.*` and `org.jetbrains.kotlin.*` are left unrelocated intentionally (the Analysis API is already uniquely namespaced under those packages). + +## Consumer-project behavior to preserve when refactoring + +- Directive lines must start at column 0 after `trim()`; `parseDirective` returns `null` otherwise. +- When multiple `IMPORT`s resolve the same short name, the **first** one wins (`firstNotNullOfOrNull` over `imports`). +- The directive regex only allows `[_a-zA-Z.]+` for the directive name — changing it affects parsing of every consumer's docs. +- The open marker is **four dashes** `` directive grammar in your markdown are **unchanged**. +What changed: + +- The `korro { }` DSL is now nested and Property-based. Assignments (`docs = …`, `beforeGroup = …`) no longer compile. +- `korro` no longer mutates source files. It writes to `build/korro/docs/`, and a new `korroApply` task copies back onto + the source tree. Use `korroCheck` in CI. +- Unresolved `FUN` references now fail the build by default (was: silently kept the stale snippet). +- Minimum Gradle 8.5, JDK 17, Kotlin Analysis API 2.3.20 bundled. + +Existing markdown files do not need to be edited. + +## Baseline requirements + +| Surface | 0.1.6 | 0.2.0 | +|-------------------------------|-----------------------------|-----------------| +| Gradle | 7.0+ | **8.5+** | +| JDK (build + runtime) | 8 | **17** | +| Bundled Kotlin / Analysis API | 1.9.22 (Dokka K1) | **2.3.20 (K2)** | +| Plugin id | `io.github.devcrocod.korro` | unchanged | +| Directive syntax | `` | unchanged | + +The Kotlin version is pinned inside Korro. Your consumer project can use any Kotlin plugin version — Korro runs Analysis +API in an isolated worker classloader, so there is no version alignment required. + +## DSL migration + +### `docs` + +```diff + korro { +- docs = fileTree(project.rootDir) { +- include "**/*.md" +- } ++ docs { ++ from(fileTree(project.rootDir) { include("**/*.md") }) ++ baseDir.set(project.rootDir) // REQUIRED ++ } + } +``` + +`docs.baseDir` is mandatory. Korro 0.2.0 writes output out-of-place to `build/korro/docs/`, +and `korroApply` mirrors that tree back onto `baseDir`. Set it to whichever directory the paths in `docs.from` are +rooted under — usually `project.rootDir` or `layout.projectDirectory.dir("docs")`. + +### `samples` and `outputs` + +```diff + korro { +- samples = fileTree("src/test/samples") +- outputs = fileTree("build/sampleOutputs") ++ samples { ++ from(fileTree("src/test/samples")) ++ outputs.from(fileTree("build/sampleOutputs")) ++ } + } +``` + +The top-level `outputs` property moved inside the `samples` block. Semantics are unchanged: a file whose name exactly +equals a resolved `FUN` fully-qualified name is appended verbatim after the generated snippet. + +### `groupSamples` + +```diff + korro { + groupSamples { +- beforeGroup = "\n" +- afterGroup = "" +- beforeSample = "\n" +- afterSample = "\n" ++ beforeGroup.set("\n") ++ afterGroup.set("") ++ beforeSample.set("\n") ++ afterSample.set("\n") + funSuffix("_v1") { replaceText("NAME", "Version 1") } + funSuffix("_v2") { replaceText("NAME", "Version 2") } + } + } +``` + +All string and boolean properties are now `Property` — assign with `.set(...)`. The +`funSuffix(...) { replaceText(...) }` helper is unchanged. + +### `behavior` (new) + +Two flags moved into a dedicated `behavior { }` block. Both default to `false`: + +```kotlin +korro { + behavior { + ignoreMissing.set(false) + rewriteAsserts.set(false) + } +} +``` + +See "Behavior changes" below for when you'll need to flip these. + +## Task migration + +| 0.1.x | 0.2.0 | +|------------------------------------|-------------------------------------------------------------------------------------------------| +| `./gradlew korro` (mutates source) | `./gradlew korro` (writes `build/korro/docs/`), then `./gradlew korroApply` to copy onto source | +| `./gradlew korroClean` | `./gradlew clean` or `rm -rf build/korro/` | +| `korroCheck` (TODO) | `./gradlew korroCheck` — fails when committed docs don't match regeneration. Use in CI. | +| `korroTest` (TODO) | Not implemented; deferred. | + +The split between `korro` and `korroApply` is what makes `korro` cacheable and safe to run from CI without mutating the +repo. + +## Behavior changes + +- **Unresolved `FUN` now fails the build.** 0.1.x silently kept the existing snippet text in the output. To restore that + behavior: + ```kotlin + korro { behavior { ignoreMissing.set(true) } } + ``` +- **`assertPrints` / `assertTrue` / `assertFalse` / `assertFails` / `assertFailsWith` are no longer rewritten into + commented `println` by default.** Restore with: + ```kotlin + korro { behavior { rewriteAsserts.set(true) } } + ``` +- **Unclosed `//SampleStart`** (a start marker with no matching `//SampleEnd` in the same function) is now a diagnostic + error. 0.1.x silently included the tail of the function. +- **Functions with no `//SampleStart`/`//SampleEnd`** now emit the whole body (minus the outer `{ }`). 0.1.x returned an + empty snippet. +- **Non-function targets** (properties, classes, top-level declarations, `.kts` scripts) now produce a diagnostic. Only + `fun` declarations are valid `FUN` targets. + +All new diagnostics are collected across the whole run and reported as a single table at the end of the task. + +## Directive syntax — unchanged + +``, ``, `` and the four-dash open marker all work exactly as in 0.1.x. +Existing markdown files parse without modification. Two things worth knowing: + +- The previously-reserved `` directive is now live. See + the [FUNS section of the README](README.md#funs). +- First-import-wins on ambiguous short names is preserved. + +## Consumer-project template + +A working 0.2.0 fixture lives at [`integration-tests/fixtures/basic/`](integration-tests/fixtures/basic). Copy its +`build.gradle.kts`, `settings.gradle.kts`, and directory layout as a starting point. diff --git a/README.md b/README.md index b951418..df91cbe 100644 --- a/README.md +++ b/README.md @@ -1,223 +1,273 @@ # Korro + [![Apache license](https://img.shields.io/badge/license-Apache%20License%202.0-blue.svg?style=flat)](https://www.apache.org/licenses/LICENSE-2.0) -[![Gradle plugin](https://img.shields.io/maven-metadata/v/https/plugins.gradle.org/m2/io/github/devcrocod/korro/maven-metadata.xml.svg?label=Gradle+plugin)] +[![Gradle plugin](https://img.shields.io/maven-metadata/v/https/plugins.gradle.org/m2/io/github/devcrocod/korro/maven-metadata.xml.svg?label=Gradle+plugin)](https://plugins.gradle.org/plugin/io.github.devcrocod.korro) Kotlin source code documentation plugin. Inspired by [kotlinx-knit](https://github.com/Kotlin/kotlinx-knit). -This plugin produces code snippets into markdown documents from tests. +Korro injects Kotlin sample snippets into markdown documents. You point it at some `.md` files and some Kotlin source +files, mark regions with `` directives, and Korro fills those regions with the body of the referenced +function. + * [Setup](#setup) + * [Baseline](#baseline) * [Tasks](#tasks) - * [Parameters](#parameters) -* [Docs](#docs) - * [Directives](#directives) + * [DSL](#dsl) + * [Behavior flags](#behavior-flags) + * [Grouping samples](#grouping-samples) +* [Directives](#directives) * [IMPORT](#import) * [FUN](#fun) * [FUNS](#funs) * [END](#end) -* [Sample](#sample) +* [Example](#example) +* [What changed in 0.2](#what-changed-in-02) + ## Setup -```groovy + +```kotlin plugins { - id("io.github.devcrocod.korro") version "0.0.3" + id("io.github.devcrocod.korro") version "0.2.0" } ``` -or +The legacy `buildscript { classpath … }` installation form is no longer supported. 0.2.0 requires the `plugins { }` DSL. -```groovy -buildscript { - dependencies { - classpath "io.github.devcrocod:korro:0.0.3" - } -} - -apply plugin: 'io.github.devcrocod.korro' -``` +### Baseline + +| Requirement | Version | +|-------------------------------|---------| +| Gradle | 8.5+ | +| JDK (build + runtime) | 17+ | +| Kotlin Analysis API (bundled) | 2.3.20 | + +The bundled Kotlin version is pinned inside the plugin. Your consumer project's own `org.jetbrains.kotlin.*` plugin +version is irrelevant — Korro runs the Analysis API inside a worker with an isolated classloader. Your sample code can +be authored against any Kotlin version that the 2.3.20 Analysis API can parse. ### Tasks -* `korro` - create/update samples in documentation -* `korroClean` - remove inserted code snippets in documentation. -Removes everything between the `FUN`/`END` and `FUNS`/`END` directives. -* `korroCheck` - TODO -* `korroTest` - TODO +| Task | Purpose | +|--------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `korro` | Regenerates markdown into `build/korro/docs/`. Cacheable. Never touches source files. | +| `korroApply` | Copies generated output from `build/korro/docs/` onto `docs.baseDir`. Run locally after `korro` to propagate changes into the source tree. | +| `korroCheck` | Regenerates docs into a temp directory and fails the build if the committed source tree is out of date. Run this in CI. | + +There is no `korroClean` — use `./gradlew clean` or delete `build/korro/`. There is no `korroTest`. + +Typical workflow: -### Parameters +```bash +# Local authoring: +./gradlew korro korroApply # regenerate and update source markdown -```groovy +# CI: +./gradlew korroCheck # fail if docs drift from samples +``` + +### DSL + +```kotlin korro { - docs = fileTree(project.rootDir) { - include '**/*.md' + docs { + from(fileTree("docs") { include("**/*.md") }) + baseDir.set(layout.projectDirectory.dir("docs")) // REQUIRED } - - samples = fileTree(project.rootDir) { - include 'src/test/samples/*.kt' + samples { + from(fileTree("src/test/samples")) + outputs.from(fileTree("build/sampleOutputs")) // optional + } + behavior { + rewriteAsserts.set(false) + ignoreMissing.set(false) } } ``` -To insert several samples by single reference in markdown use `groupSamples`. For example, to wrap samples that have the same function name prefix followed by `_v1` or `_v2` within HTML tabs use the following configuration: -```groovy -korro { - groupSamples { +- `docs.from(...)` is the set of markdown files to process. +- `docs.baseDir` is **mandatory**. Output files land at `/korro/docs/`, and + `korroApply` mirrors that tree back onto `baseDir`. Set it to whichever directory the paths in `docs.from` are rooted + under — typically `layout.projectDirectory` or `layout.projectDirectory.dir("docs")`. +- `samples.from(...)` is the set of Kotlin source files scanned for `FUN`/`FUNS` targets. +- `samples.outputs.from(...)` is optional. A file in this collection whose name exactly equals a resolved `FUN` + fully-qualified name is appended verbatim after the generated snippet. - beforeSample = "\n" - afterSample = "\n" +### Behavior flags - funSuffix("_v1") { - replaceText("NAME", "Version 1") - } - funSuffix("_v2") { - replaceText("NAME", "Version 2") +- `rewriteAsserts` (default `false`) — when `true`, sample bodies have their `assertPrints` / `assertTrue` / + `assertFalse` / `assertFails` / `assertFailsWith` calls rewritten into a commented `println`. Enable this only if your + samples use `kotlin.test` idioms. +- `ignoreMissing` (default `false`) — strict by default. Unresolved `FUN`/`FUNS`, unclosed `//SampleStart`, and + non-function targets fail the task with a collected diagnostic list. Set `true` to degrade those errors to warnings + and keep the old snippet lines in the output. + +### Grouping samples + +Use `groupSamples` to wrap multiple related snippets (for example, HTML tabs). Semantics are unchanged from 0.1.x; only +the property API moved from `= ...` to `.set(...)`. + +```kotlin +korro { + groupSamples { + beforeGroup.set("\n") + afterGroup.set("") + beforeSample.set("\n") + afterSample.set("\n") + funSuffix("_v1") { replaceText("NAME", "Version 1") } + funSuffix("_v2") { replaceText("NAME", "Version 2") } } - beforeGroup = "\n" - afterGroup = "" - } } ``` -## Docs -### Directives +For new docs, prefer a single `FUNS myFun_v*` directive over two `FUN myFun_v1` / `FUN myFun_v2` directives. -Korro does not parse the document and only recognizes _directives_. -Directives must always start at the beginning of a line, start with -``` - -``` -There are also two types of directives that require and don't require the `END` closing directive. +Korro does not parse markdown; it recognizes _directives_ only. A directive: + +- starts at column 0 after `String.trim()`, +- opens with **four dashes** `` on the **same line** (multi-line directives are an error), +- has a name matching `[_a-zA-Z.]+`. + +### IMPORT -#### IMPORT -The `IMPORT` directive is used to import a class containing test functions. ``` - + ``` -Multiple imports can be specified in the documentation file. -_**Note**_: +Pushes `"samples.Test."` onto the prefix list used by subsequent `FUN`/`FUNS` lookups. Multiple `IMPORT`s are allowed; +when more than one prefix resolves a short name, the **first** import wins. -_Import will not include the entire package, that is, such a path is not recognized - `org.example.*`._ +Package wildcards (`samples.*`) are not supported. + +### FUN -_You can specify the same classes._ ``` - - + + ``` -_If two classes contain the same function names, then the function will be taken from the first imported class._ +Inserts the body of the referenced Kotlin function between the directives, wrapped in a ```` ```kotlin ```` fence. + +If the function contains `//SampleStart` / `//SampleEnd` comments, only the region between them is emitted; multiple +pairs are concatenated, separated by a blank line. If the function has no markers, the whole body is emitted (without +the outer `{ }`). -#### FUN +Only `fun` declarations (`KtNamedFunction`) are valid targets. Properties, classes, top-level expressions, and `.kts` +scripts are not. Don't wrap function names in backticks. + +### FUNS -FUN directive is used to insert code into documentation: ``` - + ``` -Code will be inserted between these two directives. -Only the part between the two comments `// SampleStart`, `// SampleEnd` will be taken from the test function: -```kotlin -fun test() { - ... - // SampleStart - sample code - // SampleEnd - ... -} -``` +Expands to every function matching the Ant-style glob (`*`, `?`) over the fully-qualified names reachable from the +current `IMPORT` prefixes. Matches are emitted in deterministic order: first by containing file path, then by source +offset. -_**Note**_: +When `groupSamples.beforeGroup` / `afterGroup` are set and there are two or more matches, the whole group is wrapped by +those strings; each individual match is wrapped by `beforeSample` / `afterSample`. -_Do not use function names with spaces enclosed in backticks_ +Zero matches: fails the task in strict mode, or warns under `ignoreMissing`. -#### FUNS +### END -#### END +Closes `FUN` or `FUNS`. -The `END` directive is the closing directive for `FUN` and `FUNS`. +## Example -## Sample +Minimal end-to-end setup (lifted from `integration-tests/fixtures/basic/`): -`build.gradle` -```groovy +`settings.gradle.kts`: + +```kotlin +rootProject.name = "korro-example" +``` + +`build.gradle.kts`: + +```kotlin plugins { - id("io.github.devcrocod.korro") version "0.0.3" + id("io.github.devcrocod.korro") version "0.2.0" } -... +repositories { + mavenCentral() + maven("https://cache-redirector.jetbrains.com/intellij-dependencies") +} korro { - docs = fileTree(project.rootDir) { - include 'docs/doc.md' + docs { + from(fileTree("docs")) + baseDir.set(layout.projectDirectory.dir("docs")) } - - samples = fileTree(project.rootDir) { - include 'src/test/samples/test.kt' + samples { + from(fileTree("samples")) } } ``` -`test.kt` +`samples/Example.kt`: + ```kotlin package samples -import org.junit.Test -import org.junit.Assert.assertEquals - -class Test { - - @Test - fun exampleTest() { - val a = 1 - val b = 2 - val c: Int - // SampleStart - c = a + b - // SampleEnd - assertEquals(3, c) - } +fun example() { + //SampleStart + println("hello") + //SampleEnd } ``` -`doc.md` -``` -# Docs - +`docs/foo.md` (before `korro`): -Some text. +```markdown +# Example -Example: - + + + +``` -Some text. +After `./gradlew korro korroApply`: -``` +````markdown +# Example -After you run `korro` you get the following file `doc.md`: -``` -# Docs - + -Some text. + -Example: - ```kotlin -c = a + b -``' +println("hello") +``` + +```` -Some text. +## What changed in 0.2 -``` +- The analysis backend moved from Dokka 1.x (K1) to the Kotlin Analysis API (K2, standalone mode). +- The DSL is now nested and Property-based (config-cache safe). `docs = …` / `samples = …` became + `docs { from(…); baseDir.set(…) }` / `samples { from(…); outputs.from(…) }`. +- `korro` is cacheable and writes out-of-place to `build/korro/docs/`. Use the new `korroApply` to propagate into the + source tree and `korroCheck` in CI. +- Strict-by-default: unresolved `FUN`/`FUNS` fails the build. Opt back in to the old warn-and-continue behavior with + `behavior { ignoreMissing.set(true) }`. +- Assert rewriting is off by default. Restore with `behavior { rewriteAsserts.set(true) }`. +- `FUNS` is now implemented as a glob-filter directive. +- `korroClean` is removed; `korroTest` is deferred. + +Full upgrade guide: [MIGRATION.md](MIGRATION.md). + +A ready-to-copy consumer project lives at [`integration-tests/fixtures/basic/`](integration-tests/fixtures/basic). diff --git a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt index b09986e..3bb66ec 100644 --- a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt +++ b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt @@ -130,11 +130,11 @@ class KorroIntegrationTest { System.getProperty("korro.fixtures.dir")?.let { return File(it).toPath() } val cwd = File("").absoluteFile.toPath() - val candidates = listOf( + val candidates = listOfNotNull( cwd.resolve("fixtures"), cwd.resolve("integration-tests/fixtures"), cwd.parent?.resolve("fixtures"), - ).filterNotNull() + ) return candidates.firstOrNull { Files.isDirectory(it) } ?: error( "Cannot locate integration-tests/fixtures. " + @@ -165,11 +165,11 @@ class KorroIntegrationTest { private fun findPluginShadowJar(): File? { val cwd = File("").absoluteFile - val candidates = listOf( + val candidates = listOfNotNull( cwd.resolve("../korro-gradle-plugin/build/libs"), cwd.resolve("korro-gradle-plugin/build/libs"), cwd.parentFile?.resolve("korro-gradle-plugin/build/libs"), - ).filterNotNull().filter { it.isDirectory } + ).filter { it.isDirectory } return candidates.asSequence() .flatMap { (it.listFiles { _, name -> name.endsWith(".jar") } ?: emptyArray()).asSequence() } .filterNot { it.name.contains("-sources") || it.name.contains("-javadoc") } diff --git a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt index cb51370..5f765c6 100644 --- a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt @@ -53,11 +53,13 @@ class FqnResolver(session: KorroAnalysisSession) { val target = bareName.substringAfterLast('.') if (target.isEmpty()) return emptyList() return byShortName.keys + .asSequence() .map { it to levenshtein(it, target) } .filter { it.second <= (target.length / 2).coerceAtLeast(2) } .sortedWith(compareBy({ it.second }, { it.first })) .take(limit) .map { it.first } + .toList() } private fun collectFunctions( @@ -77,6 +79,7 @@ class FqnResolver(session: KorroAnalysisSession) { } decl.name?.let { shortName.getOrPut(it) { mutableListOf() }.add(decl) } } + is KtClassOrObject -> visit(decl.declarations) else -> {} } diff --git a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt index 54958be..6a2bada 100644 --- a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt @@ -5,14 +5,7 @@ import com.intellij.psi.PsiElementVisitor import com.intellij.psi.PsiWhiteSpace import com.intellij.psi.impl.source.tree.LeafPsiElement import com.intellij.psi.util.PsiTreeUtil -import org.jetbrains.kotlin.psi.KtBlockExpression -import org.jetbrains.kotlin.psi.KtCallExpression -import org.jetbrains.kotlin.psi.KtDeclarationWithBody -import org.jetbrains.kotlin.psi.KtLambdaExpression -import org.jetbrains.kotlin.psi.KtNamedFunction -import org.jetbrains.kotlin.psi.KtStringTemplateExpression -import org.jetbrains.kotlin.psi.KtTreeVisitorVoid -import org.jetbrains.kotlin.psi.KtValueArgument +import org.jetbrains.kotlin.psi.* import org.jetbrains.kotlin.psi.psiUtil.prevLeaf class SampleExtractor(private val rewriteAsserts: Boolean) { @@ -25,7 +18,7 @@ class SampleExtractor(private val rewriteAsserts: Boolean) { private fun processBody(psiElement: PsiElement): String { val text = processSampleBody(psiElement).trim { it == '\n' || it == '\r' }.trimEnd() val lines = text.split("\n") - val indent = lines.filter(String::isNotBlank).map { it.takeWhile(Char::isWhitespace).count() }.minOrNull() ?: 0 + val indent = lines.filter(String::isNotBlank).minOfOrNull { it.takeWhile(Char::isWhitespace).count() } ?: 0 return lines.joinToString("\n") { it.drop(indent) } } @@ -38,6 +31,7 @@ class SampleExtractor(private val rewriteAsserts: Boolean) { else -> bodyExpressionText } } + else -> psiElement.buildSampleText() } @@ -72,8 +66,8 @@ class SampleExtractor(private val rewriteAsserts: Boolean) { private fun convertAssertTrueFalse(expression: KtCallExpression, expectedResult: Boolean) { val (argument) = expression.valueArguments builder.apply { - expression.valueArguments.getOrNull(1)?.let { - append("// ${it.extractStringArgumentValue()}") + expression.valueArguments.getOrNull(1)?.let { value -> + append("// ${value.extractStringArgumentValue()}") val ws = expression.prevLeaf { it is PsiWhiteSpace } append(ws?.text ?: "\n") } @@ -133,11 +127,25 @@ class SampleExtractor(private val rewriteAsserts: Boolean) { override fun visitCallExpression(expression: KtCallExpression) { if (rewriteAsserts) { when (expression.calleeExpression?.text) { - "assertPrints" -> { convertAssertPrints(expression); return } - "assertTrue" -> { convertAssertTrueFalse(expression, expectedResult = true); return } - "assertFalse" -> { convertAssertTrueFalse(expression, expectedResult = false); return } - "assertFails" -> { convertAssertFails(expression); return } - "assertFailsWith" -> { convertAssertFailsWith(expression); return } + "assertPrints" -> { + convertAssertPrints(expression); return + } + + "assertTrue" -> { + convertAssertTrueFalse(expression, expectedResult = true); return + } + + "assertFalse" -> { + convertAssertTrueFalse(expression, expectedResult = false); return + } + + "assertFails" -> { + convertAssertFails(expression); return + } + + "assertFailsWith" -> { + convertAssertFailsWith(expression); return + } } } super.visitCallExpression(expression) @@ -155,7 +163,7 @@ class SampleExtractor(private val rewriteAsserts: Boolean) { override fun visitElement(element: PsiElement) { try { element.accept(this@SampleBuilder) - } catch (e: Exception) { + } catch (_: Exception) { builder.append(element.text) } } diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt index e57d658..b273dbb 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt @@ -34,8 +34,8 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { return functionNames.firstNotNullOfOrNull { name -> var text = samplesTransformer(name) ?: groups.firstNotNullOfOrNull { group -> group.patterns.mapNotNull { pattern -> - samplesTransformer(name + pattern.nameSuffix)?.let { - group.beforeSample?.let { pattern.processSubstitutions(it) } + it + + samplesTransformer(name + pattern.nameSuffix)?.let { sampleText -> + group.beforeSample?.let { pattern.processSubstitutions(it) } + sampleText + group.afterSample?.let { pattern.processSubstitutions(it) } } }.takeIf { it.isNotEmpty() }?.joinToString( @@ -84,13 +84,13 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { val body = when { hasWrapping && trimmed.size >= 2 -> trimmed.joinToString( separator = "\n", - prefix = group!!.beforeGroup.orEmpty(), + prefix = group.beforeGroup.orEmpty(), postfix = group.afterGroup.orEmpty(), ) { rs -> group.beforeSample.orEmpty() + rs.snippet + group.afterSample.orEmpty() } hasWrapping -> { val rs = trimmed.single() - group!!.beforeSample.orEmpty() + rs.snippet + group.afterSample.orEmpty() + group.beforeSample.orEmpty() + rs.snippet + group.afterSample.orEmpty() } else -> trimmed.joinToString(separator = "\n\n") { it.snippet } @@ -124,10 +124,7 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { val old = ArrayList() var n = startLineNo while (true) { - val sampleLine = reader.readLine() - if (sampleLine == null) { - return BlockCollect(old, null, null, unclosed = true) to n - } + val sampleLine = reader.readLine() ?: return BlockCollect(old, null, null, unclosed = true) to n n++ val nextDirective = parseDirective(sampleLine) when (nextDirective?.name) { @@ -135,9 +132,11 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { old.add(sampleLine) return BlockCollect(old, nextDirective, sampleLine, unclosed = false) to n } + FUN_DIRECTIVE, FUNS_DIRECTIVE -> { return BlockCollect(old, nextDirective, sampleLine, unclosed = true) to n } + else -> old.add(sampleLine) } } @@ -170,7 +169,8 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { lines.add(line) when (directive?.name) { - null, END_DIRECTIVE -> { /* no-op */ } + null, END_DIRECTIVE -> { /* no-op */ + } IMPORT_DIRECTIVE -> imports.add(directive.value + ".") diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt index 40a2fd0..5a9a973 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt @@ -2,7 +2,7 @@ package io.github.devcrocod.korro import org.gradle.api.Plugin import org.gradle.api.Project -import java.util.Properties +import java.util.* class KorroPlugin : Plugin { override fun apply(project: Project): Unit = with(project) { diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt index 4f73a6f..1c3fb10 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt @@ -3,19 +3,9 @@ package io.github.devcrocod.korro import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.provider.Provider -import org.gradle.api.tasks.CacheableTask -import org.gradle.api.tasks.Classpath -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.Nested -import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.* import org.gradle.work.DisableCachingByDefault import org.gradle.workers.WorkerExecutor import java.io.File diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SampleGroups.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SampleGroups.kt index 658ff52..e40ecff 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SampleGroups.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/SampleGroups.kt @@ -2,7 +2,7 @@ package io.github.devcrocod.korro import java.io.Serializable -data class FunctionPattern(val nameSuffix: String, val substitutions: Map): Serializable { +data class FunctionPattern(val nameSuffix: String, val substitutions: Map) : Serializable { fun processSubstitutions(text: String) = substitutions.entries.fold(text) { acc, entry -> acc.replace(entry.key, entry.value) } From 9e7225b88c1a2ce3ab805344cd4732a9abc4485d Mon Sep 17 00:00:00 2001 From: devcrocod Date: Sat, 18 Apr 2026 01:44:20 +0200 Subject: [PATCH 08/15] Refactor build configuration: centralize dependency versions using Gradle version catalog, modularize scripts with aliases, and enhance consistency across subprojects --- .editorconfig | 18 ++++++++++++++++ CLAUDE.md | 11 ++++++---- LICENSE | 27 ++++++++++++++++++++++- NOTICE | 8 +++++++ build.gradle.kts | 8 +++---- gradle.properties | 15 ++++++++----- gradle/libs.versions.toml | 32 ++++++++++++++++++++++++++++ integration-tests/build.gradle.kts | 8 +++---- korro-analysis/build.gradle.kts | 32 +++++++++++++--------------- korro-gradle-plugin/build.gradle.kts | 9 ++++---- settings.gradle.kts | 9 +------- 11 files changed, 129 insertions(+), 48 deletions(-) create mode 100644 .editorconfig create mode 100644 NOTICE create mode 100644 gradle/libs.versions.toml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4f8aba9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.kt] +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL + +[*.{yml,yaml,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 57c790e..189daf2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,11 +27,14 @@ The `korro` / `korroApply` / `korroCheck` tasks the plugin registers are only ru ## Version wiring -Versions live in `gradle.properties`: +Korro's own version lives in `gradle.properties` as `version`. Both modules inherit it through `subprojects { version = rootProject.version }` in the root `build.gradle.kts`. At runtime the plugin reads this from a generated `META-INF/korro-gradle-plugin.properties` resource on the plugin classpath (see `KorroPlugin.readKorroPluginVersion`). -- `version` — Korro's own version. Both modules inherit this through `subprojects { version = rootProject.version }` in the root `build.gradle.kts`. At runtime the plugin reads this from a generated `META-INF/korro-gradle-plugin.properties` resource on the plugin classpath (see `KorroPlugin.readKorroPluginVersion`). -- `kotlin_version` — the pinned Kotlin / Analysis API version used by `korro-analysis`. Single source of truth. -- `language_version` — sets both Kotlin `languageVersion` and `apiVersion` in the subproject Kotlin compilation. Unrelated to the pinned Analysis API version. JVM target is hard-coded to `17` in the root `build.gradle.kts`. +All other versions live in the Gradle version catalog at `gradle/libs.versions.toml`. The catalog is the single source of truth — don't hard-code versions in subproject build scripts, add them to the catalog and reference as `libs.*` / `libs.plugins.*`. Key entries: + +- `kotlin` — the pinned Kotlin / Analysis API version used by `korro-analysis` and for the `kotlin("jvm")` plugin. +- `kotlinLanguage` — sets both Kotlin `languageVersion` and `apiVersion` in the subproject Kotlin compilation. Read in the root `build.gradle.kts` via `libs.versions.kotlinLanguage.get()`. Unrelated to the pinned Analysis API version. JVM target is hard-coded to `17` in the root `build.gradle.kts`. +- `shadow`, `pluginPublish` — Gradle plugin versions consumed via `alias(libs.plugins.*)`. +- `kotlinxSerialization`, `caffeine`, `junit` — runtime/test library versions. A new cache key: every task has an `@Input korroPluginVersion` property, so cached outputs are invalidated on plugin bump (which is also a bundled Analysis API bump). diff --git a/LICENSE b/LICENSE index 49cc83d..7a4a3ea 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ Apache License Version 2.0, January 2004 - https://www.apache.org/licenses/ + http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION @@ -175,3 +175,28 @@ of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..2b11695 --- /dev/null +++ b/NOTICE @@ -0,0 +1,8 @@ +========================================================================= +== NOTICE file corresponding to the section 4 d of == +== the Apache License, Version 2.0, == +== in this case for the korro. == +========================================================================= + +korro plugin. +Copyright 2021-2026 devcrocod \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 80325aa..299b579 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - kotlin("jvm") apply false + alias(libs.plugins.kotlin.jvm) apply false } group = "io.github.devcrocod" @@ -19,7 +19,7 @@ fun detectVersion(): String { } } -val language_version: String by project +val kotlinLanguageVersion = libs.versions.kotlinLanguage.get() subprojects { group = rootProject.group @@ -37,8 +37,8 @@ subprojects { "-Xskip-metadata-version-check", "-Xjsr305=strict", ) - languageVersion.set(KotlinVersion.fromVersion(language_version)) - apiVersion.set(KotlinVersion.fromVersion(language_version)) + languageVersion.set(KotlinVersion.fromVersion(kotlinLanguageVersion)) + apiVersion.set(KotlinVersion.fromVersion(kotlinLanguageVersion)) jvmTarget.set(JvmTarget.JVM_17) } } diff --git a/gradle.properties b/gradle.properties index 763d341..12dfc5d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,12 @@ -kotlin.code.style=official - +# Project version=0.2.0 -kotlin_version=2.3.20 -language_version=2.1 -org.gradle.jvmargs=-Xmx2G +# Gradle daemon & performance +org.gradle.jvmargs=-Xmx2G -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true + +# Kotlin +kotlin.code.style=official +kotlin.jvm.target.validation.mode=error diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..8f0b4a6 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,32 @@ +[versions] +kotlin = "2.3.20" +kotlinLanguage = "2.1" +shadow = "9.4.1" +pluginPublish = "2.1.1" +kotlinxSerialization = "1.11.0" +caffeine = "3.2.3" +junit = "5.14.3" + +[libraries] +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +kotlin-compiler = { module = "org.jetbrains.kotlin:kotlin-compiler", version.ref = "kotlin" } + +kotlin-analysisApi = { module = "org.jetbrains.kotlin:analysis-api-for-ide", version.ref = "kotlin" } +kotlin-analysisApi-implBase = { module = "org.jetbrains.kotlin:analysis-api-impl-base-for-ide", version.ref = "kotlin" } +kotlin-analysisApi-platformInterface = { module = "org.jetbrains.kotlin:analysis-api-platform-interface-for-ide", version.ref = "kotlin" } +kotlin-analysisApi-standalone = { module = "org.jetbrains.kotlin:analysis-api-standalone-for-ide", version.ref = "kotlin" } +kotlin-analysisApi-k2 = { module = "org.jetbrains.kotlin:analysis-api-k2-for-ide", version.ref = "kotlin" } +kotlin-lowLevelApiFir = { module = "org.jetbrains.kotlin:low-level-api-fir-for-ide", version.ref = "kotlin" } +kotlin-symbolLightClasses = { module = "org.jetbrains.kotlin:symbol-light-classes-for-ide", version.ref = "kotlin" } + +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" } +caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } + +junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } +pluginPublish = { id = "com.gradle.plugin-publish", version.ref = "pluginPublish" } diff --git a/integration-tests/build.gradle.kts b/integration-tests/build.gradle.kts index e3fc7b1..55b339c 100644 --- a/integration-tests/build.gradle.kts +++ b/integration-tests/build.gradle.kts @@ -1,7 +1,7 @@ import org.gradle.plugin.devel.tasks.PluginUnderTestMetadata plugins { - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) `java-gradle-plugin` } @@ -11,9 +11,9 @@ val pluginShadowJar = project(":korro-gradle-plugin").tasks.named("shadowJar") dependencies { testImplementation(gradleTestKit()) - testImplementation(platform("org.junit:junit-bom:5.14.3")) - testImplementation("org.junit.jupiter:junit-jupiter") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation(platform(libs.junit.bom)) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) } gradlePlugin { diff --git a/korro-analysis/build.gradle.kts b/korro-analysis/build.gradle.kts index 91c2ef7..5b973f0 100644 --- a/korro-analysis/build.gradle.kts +++ b/korro-analysis/build.gradle.kts @@ -1,30 +1,28 @@ plugins { - kotlin("jvm") - id("com.gradleup.shadow") version "9.4.1" + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.shadow) `maven-publish` } -val kotlin_version: String by project - repositories { mavenCentral() maven("https://cache-redirector.jetbrains.com/intellij-dependencies") } dependencies { - compileOnly(kotlin("stdlib")) - - implementation("org.jetbrains.kotlin:analysis-api-for-ide:$kotlin_version") { isTransitive = false } - implementation("org.jetbrains.kotlin:analysis-api-impl-base-for-ide:$kotlin_version") { isTransitive = false } - implementation("org.jetbrains.kotlin:analysis-api-platform-interface-for-ide:$kotlin_version") { isTransitive = false } - implementation("org.jetbrains.kotlin:analysis-api-standalone-for-ide:$kotlin_version") { isTransitive = false } - implementation("org.jetbrains.kotlin:analysis-api-k2-for-ide:$kotlin_version") { isTransitive = false } - implementation("org.jetbrains.kotlin:low-level-api-fir-for-ide:$kotlin_version") { isTransitive = false } - implementation("org.jetbrains.kotlin:symbol-light-classes-for-ide:$kotlin_version") { isTransitive = false } - - implementation("org.jetbrains.kotlin:kotlin-compiler:$kotlin_version") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0") - implementation("com.github.ben-manes.caffeine:caffeine:3.2.3") + compileOnly(libs.kotlin.stdlib) + + implementation(libs.kotlin.analysisApi) { isTransitive = false } + implementation(libs.kotlin.analysisApi.implBase) { isTransitive = false } + implementation(libs.kotlin.analysisApi.platformInterface) { isTransitive = false } + implementation(libs.kotlin.analysisApi.standalone) { isTransitive = false } + implementation(libs.kotlin.analysisApi.k2) { isTransitive = false } + implementation(libs.kotlin.lowLevelApiFir) { isTransitive = false } + implementation(libs.kotlin.symbolLightClasses) { isTransitive = false } + + implementation(libs.kotlin.compiler) + implementation(libs.kotlinx.serialization.core) + implementation(libs.caffeine) } tasks.shadowJar { diff --git a/korro-gradle-plugin/build.gradle.kts b/korro-gradle-plugin/build.gradle.kts index ec3a610..acb7a7f 100644 --- a/korro-gradle-plugin/build.gradle.kts +++ b/korro-gradle-plugin/build.gradle.kts @@ -1,18 +1,17 @@ plugins { - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) `java-gradle-plugin` - id("com.gradle.plugin-publish") version "2.1.1" + alias(libs.plugins.pluginPublish) `maven-publish` - id("com.gradleup.shadow") version "9.4.1" + alias(libs.plugins.shadow) } configurations.named(JavaPlugin.API_CONFIGURATION_NAME) { dependencies.remove(project.dependencies.gradleApi()) } -val kotlin_version: String by project dependencies { - shadow(kotlin("stdlib-jdk8", version = kotlin_version)) + shadow(libs.kotlin.stdlib) compileOnly(project(":korro-analysis")) diff --git a/settings.gradle.kts b/settings.gradle.kts index 980361f..e924d5a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,10 +1,3 @@ -pluginManagement { - val kotlin_version: String by settings - plugins { - id("org.jetbrains.kotlin.jvm") version kotlin_version - } -} - rootProject.name = "korro" -include("korro-gradle-plugin", "korro-analysis", "integration-tests") \ No newline at end of file +include("korro-gradle-plugin", "korro-analysis", "integration-tests") From 517e73192ab89e2c6d5acd45d73b68dea764a9aa Mon Sep 17 00:00:00 2001 From: devcrocod Date: Sat, 18 Apr 2026 02:32:30 +0200 Subject: [PATCH 09/15] Refactor Korro tasks: replace `korroApply` with `korroGenerate` and `korro` for improved caching and clarity - Deprecated `korroApply` in favor of a streamlined task structure with `korroGenerate` and `korro`. - Introduced `korroGenerate` (cacheable) for isolated markdown generation into `build/korro/docs/` without mutating sources. - Updated `korro` to depend on `korroGenerate` and handle applying generated docs onto the source tree. - Updated documentation, integration tests, and example commands to reflect task changes. - Enhanced task descriptions, clarified CI usage, and ensured safe regeneration workflows. --- CLAUDE.md | 10 +- MIGRATION.md | 27 +++-- README.md | 22 ++-- .../korro/it/KorroIntegrationTest.kt | 6 +- .../github/devcrocod/korro/KorroApplyTask.kt | 7 -- .../devcrocod/korro/KorroGenerateTask.kt | 103 ++++++++++++++++++ .../io/github/devcrocod/korro/KorroPlugin.kt | 6 +- .../io/github/devcrocod/korro/KorroTask.kt | 102 +---------------- 8 files changed, 145 insertions(+), 138 deletions(-) delete mode 100644 korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroApplyTask.kt create mode 100644 korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroGenerateTask.kt diff --git a/CLAUDE.md b/CLAUDE.md index 189daf2..0278c64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ Build uses the Gradle wrapper (currently 9.4.1): - `./gradlew -Prelease build` — produce a release-versioned artifact. Without `-Prelease`, `detectVersion()` in `build.gradle.kts` appends `-dev` (or `-dev-`) to the version in `gradle.properties`. - `./gradlew :integration-tests:test` — run the GradleTestKit integration tests under `integration-tests/fixtures/*`. -The `korro` / `korroApply` / `korroCheck` tasks the plugin registers are only runnable from a *consumer* project that applies this plugin (or from one of the integration-test fixtures). They are not runnable from this repo's root. +The `korroGenerate` / `korro` / `korroCheck` tasks the plugin registers are only runnable from a *consumer* project that applies this plugin (or from one of the integration-test fixtures). They are not runnable from this repo's root. ## Version wiring @@ -44,12 +44,12 @@ Two layers separated by a worker boundary. **Gradle-facing layer** — `korro-gradle-plugin/`, runs in the Gradle daemon's classloader, no Analysis API imports. -- `KorroPlugin` creates the `korro` extension, creates the detached `korroAnalysisRuntime` configuration (with a dependency on `io.github.devcrocod:korro-analysis:`), and in `afterEvaluate` registers three tasks: `korro`, `korroApply`, `korroCheck`. No `korroClean`. +- `KorroPlugin` creates the `korro` extension, creates the detached `korroAnalysisRuntime` configuration (with a dependency on `io.github.devcrocod:korro-analysis:`), and in `afterEvaluate` registers three tasks: `korroGenerate`, `korro`, `korroCheck`. No `korroClean`. - `KorroExtension` (`KorroExtension.kt`) exposes the nested DSL: `docs { from(...); baseDir.set(...) }`, `samples { from(...); outputs.from(...) }`, `behavior { rewriteAsserts.set(...); ignoreMissing.set(...) }`, `groupSamples { ... }`. All properties use Gradle's `Property` / `ConfigurableFileCollection` / `DirectoryProperty` for config-cache safety. - Tasks: - - `KorroTask` (`@CacheableTask`, extends `AbstractKorroTask`) — `@InputFiles docs`/`samples`/`samplesOutputs`, `@Input` flags, `@Classpath korroRuntimeClasspath`, `@OutputDirectory outputDirectory` (defaults to `build/korro/docs`). On `@TaskAction`, submits a `KorroWorkAction` via `WorkerExecutor.classLoaderIsolation { classpath.from(korroRuntimeClasspath) }`. - - `KorroApplyTask` (`@DisableCachingByDefault`, extends `Sync`) — wired to copy the `korro` task's output directory onto `docs.baseDir`. This is the only mutation point. - - `KorroCheckTask` (`@CacheableTask`, extends `AbstractKorroTask`) — **currently a stub.** The action logs "not implemented" and writes a placeholder `build/korro/check.report`. Full diff-against-source implementation is pending a follow-up phase; the task is already registered with the same inputs as `korro` so CI callers don't change later. + - `KorroGenerateTask` (registered as `korroGenerate`; `@CacheableTask`, extends `AbstractKorroTask`) — `@InputFiles docs`/`samples`/`samplesOutputs`, `@Input` flags, `@Classpath korroRuntimeClasspath`, `@OutputDirectory outputDirectory` (defaults to `build/korro/docs`). On `@TaskAction`, submits a `KorroWorkAction` via `WorkerExecutor.classLoaderIsolation { classpath.from(korroRuntimeClasspath) }`. + - `KorroTask` (registered as `korro`; `@DisableCachingByDefault`, extends `Copy`) — depends on `korroGenerate` and copies its output directory onto `docs.baseDir`. This is the only mutation point, and the default task users invoke. **Must be `Copy`, not `Sync`:** `docs.baseDir` is typically the project or repo root, which contains many files not managed by Korro (build scripts, sources, hidden dirs, untracked work). A `Sync` task deletes any file in the destination that isn't in the source — i.e. it would wipe the entire repo except the regenerated markdown tree. + - `KorroCheckTask` (`@CacheableTask`, extends `AbstractKorroTask`) — **currently a stub.** The action logs "not implemented" and writes a placeholder `build/korro/check.report`. Full diff-against-source implementation is pending a follow-up phase; the task is already registered with the same inputs as `korroGenerate` so CI callers don't change later. - `AbstractKorroTask.buildDocsToOutputs(outDir)` computes each input doc's output path relative to `docs.baseDir` — fails loudly if an input is outside `baseDir`. - `Korro.kt` is the markdown rewriter (parser + state machine). It lives in the plugin module, not the analysis module — parsing `` doesn't need Analysis API, so the parser can run without spinning up a worker. diff --git a/MIGRATION.md b/MIGRATION.md index 0660c2f..4f0ce82 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -6,8 +6,9 @@ The plugin id (`io.github.devcrocod.korro`) and the `` directive gram What changed: - The `korro { }` DSL is now nested and Property-based. Assignments (`docs = …`, `beforeGroup = …`) no longer compile. -- `korro` no longer mutates source files. It writes to `build/korro/docs/`, and a new `korroApply` task copies back onto - the source tree. Use `korroCheck` in CI. +- `korro` still mutates source files (end-to-end: regenerate + apply), but the heavy lifting moved to a new + `korroGenerate` task that writes to `build/korro/docs/` and is cacheable/safe to run from CI. `korro` now depends on + `korroGenerate` and copies its output onto the source tree. Use `korroCheck` in CI instead of `korro`. - Unresolved `FUN` references now fail the build by default (was: silently kept the stale snippet). - Minimum Gradle 8.5, JDK 17, Kotlin Analysis API 2.3.20 bundled. @@ -43,8 +44,9 @@ API in an isolated worker classloader, so there is no version alignment required ``` `docs.baseDir` is mandatory. Korro 0.2.0 writes output out-of-place to `build/korro/docs/`, -and `korroApply` mirrors that tree back onto `baseDir`. Set it to whichever directory the paths in `docs.from` are -rooted under — usually `project.rootDir` or `layout.projectDirectory.dir("docs")`. +and the `korro` task (a `Copy` wrapper around `korroGenerate`) mirrors that tree back onto `baseDir`. Set it to whichever +directory the paths in `docs.from` are rooted under — usually `project.rootDir` or +`layout.projectDirectory.dir("docs")`. ### `samples` and `outputs` @@ -101,15 +103,16 @@ See "Behavior changes" below for when you'll need to flip these. ## Task migration -| 0.1.x | 0.2.0 | -|------------------------------------|-------------------------------------------------------------------------------------------------| -| `./gradlew korro` (mutates source) | `./gradlew korro` (writes `build/korro/docs/`), then `./gradlew korroApply` to copy onto source | -| `./gradlew korroClean` | `./gradlew clean` or `rm -rf build/korro/` | -| `korroCheck` (TODO) | `./gradlew korroCheck` — fails when committed docs don't match regeneration. Use in CI. | -| `korroTest` (TODO) | Not implemented; deferred. | +| 0.1.x | 0.2.0 | +|------------------------------------|-----------------------------------------------------------------------------------------| +| `./gradlew korro` (mutates source) | `./gradlew korro` (regenerates into `build/korro/docs/` via `korroGenerate`, then applies onto source) | +| — | `./gradlew korroGenerate` — cacheable, out-of-place only; the task to wire into CI builds that don't want source mutation | +| `./gradlew korroClean` | `./gradlew clean` or `rm -rf build/korro/` | +| `korroCheck` (TODO) | `./gradlew korroCheck` — fails when committed docs don't match regeneration. Use in CI. | +| `korroTest` (TODO) | Not implemented; deferred. | -The split between `korro` and `korroApply` is what makes `korro` cacheable and safe to run from CI without mutating the -repo. +The split between `korroGenerate` (cacheable, out-of-place) and `korro` (copies onto source) is what keeps regeneration +safe to run from CI without mutating the repo. ## Behavior changes diff --git a/README.md b/README.md index df91cbe..b0f2323 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,11 @@ be authored against any Kotlin version that the 2.3.20 Analysis API can parse. ### Tasks -| Task | Purpose | -|--------------|--------------------------------------------------------------------------------------------------------------------------------------------| -| `korro` | Regenerates markdown into `build/korro/docs/`. Cacheable. Never touches source files. | -| `korroApply` | Copies generated output from `build/korro/docs/` onto `docs.baseDir`. Run locally after `korro` to propagate changes into the source tree. | -| `korroCheck` | Regenerates docs into a temp directory and fails the build if the committed source tree is out of date. Run this in CI. | +| Task | Purpose | +|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| `korroGenerate` | Regenerates markdown into `build/korro/docs/`. Cacheable. Never touches source files. | +| `korro` | Applies generated output from `build/korro/docs/` onto `docs.baseDir`. Depends on `korroGenerate`, so one command regenerates and copies. | +| `korroCheck` | Regenerates docs into a temp directory and fails the build if the committed source tree is out of date. Run this in CI. | There is no `korroClean` — use `./gradlew clean` or delete `build/korro/`. There is no `korroTest`. @@ -65,7 +65,7 @@ Typical workflow: ```bash # Local authoring: -./gradlew korro korroApply # regenerate and update source markdown +./gradlew korro # regenerate and update source markdown in one step # CI: ./gradlew korroCheck # fail if docs drift from samples @@ -91,8 +91,8 @@ korro { ``` - `docs.from(...)` is the set of markdown files to process. -- `docs.baseDir` is **mandatory**. Output files land at `/korro/docs/`, and - `korroApply` mirrors that tree back onto `baseDir`. Set it to whichever directory the paths in `docs.from` are rooted +- `docs.baseDir` is **mandatory**. Output files land at `/korro/docs/`, and the + `korro` task mirrors that tree back onto `baseDir`. Set it to whichever directory the paths in `docs.from` are rooted under — typically `layout.projectDirectory` or `layout.projectDirectory.dir("docs")`. - `samples.from(...)` is the set of Kotlin source files scanned for `FUN`/`FUNS` targets. - `samples.outputs.from(...)` is optional. A file in this collection whose name exactly equals a resolved `FUN` @@ -239,7 +239,7 @@ fun example() { ``` -After `./gradlew korro korroApply`: +After `./gradlew korro`: ````markdown # Example @@ -260,8 +260,8 @@ println("hello") - The analysis backend moved from Dokka 1.x (K1) to the Kotlin Analysis API (K2, standalone mode). - The DSL is now nested and Property-based (config-cache safe). `docs = …` / `samples = …` became `docs { from(…); baseDir.set(…) }` / `samples { from(…); outputs.from(…) }`. -- `korro` is cacheable and writes out-of-place to `build/korro/docs/`. Use the new `korroApply` to propagate into the - source tree and `korroCheck` in CI. +- `korroGenerate` is cacheable and writes out-of-place to `build/korro/docs/`. `korro` depends on it and applies the + output onto the source tree; use `korroCheck` in CI. - Strict-by-default: unresolved `FUN`/`FUNS` fails the build. Opt back in to the old warn-and-continue behavior with `behavior { ignoreMissing.set(true) }`. - Assert rewriting is off by default. Restore with `behavior { rewriteAsserts.set(true) }`. diff --git a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt index 3bb66ec..ef2dec7 100644 --- a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt +++ b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt @@ -61,7 +61,11 @@ class KorroIntegrationTest { val result = runner.buildAndFail() - assertEquals(TaskOutcome.FAILED, result.task(":korro")?.outcome, "korro task should fail in strict mode") + assertEquals( + TaskOutcome.FAILED, + result.task(":korroGenerate")?.outcome, + "korroGenerate task should fail in strict mode", + ) val output = result.output assertTrue(output.contains("nonExistent")) { "Expected failure output to name the unresolved directive 'nonExistent'; got:\n$output" diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroApplyTask.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroApplyTask.kt deleted file mode 100644 index eea6520..0000000 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroApplyTask.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.devcrocod.korro - -import org.gradle.api.tasks.Sync -import org.gradle.work.DisableCachingByDefault - -@DisableCachingByDefault(because = "Writes outside the build directory (mutates source tree).") -abstract class KorroApplyTask : Sync() diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroGenerateTask.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroGenerateTask.kt new file mode 100644 index 0000000..099d821 --- /dev/null +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroGenerateTask.kt @@ -0,0 +1,103 @@ +package io.github.devcrocod.korro + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.* +import org.gradle.work.DisableCachingByDefault +import org.gradle.workers.WorkerExecutor +import java.io.File +import javax.inject.Inject + +@DisableCachingByDefault(because = "Abstract base; concrete subclasses opt in with @CacheableTask.") +abstract class AbstractKorroTask : DefaultTask() { + + @get:Inject + abstract val workerExecutor: WorkerExecutor + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val docs: ConfigurableFileCollection + + @get:Internal + abstract val docsBaseDir: DirectoryProperty + + @get:Input + val docsRelativePaths: Provider> + get() = docs.elements.map { files -> + val base = docsBaseDir.get().asFile + files.map { it.asFile.toRelativeString(base) }.sorted() + } + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val samples: ConfigurableFileCollection + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val samplesOutputs: ConfigurableFileCollection + + @get:Input + abstract val rewriteAsserts: Property + + @get:Input + abstract val ignoreMissing: Property + + @get:Input + abstract val korroPluginVersion: Property + + @get:Nested + abstract val groupSamples: GroupSamplesApi + + @get:Classpath + abstract val korroRuntimeClasspath: ConfigurableFileCollection + + protected fun buildSamplesGroups(): List = listOf( + SamplesGroup( + beforeGroup = groupSamples.beforeGroup.get(), + afterGroup = groupSamples.afterGroup.get(), + beforeSample = groupSamples.beforeSample.get(), + afterSample = groupSamples.afterSample.get(), + patterns = groupSamples.patterns.get(), + ) + ) + + protected fun buildDocsToOutputs(outDir: File): Map { + val base = docsBaseDir.get().asFile + return docs.files.associateWith { input -> + val rel = input.toRelativeString(base) + check(!rel.startsWith("..")) { + "$input is outside docs.baseDir=$base. Set docs.baseDir to a directory that contains all docs." + } + File(outDir, rel) + } + } +} + +@CacheableTask +abstract class KorroGenerateTask : AbstractKorroTask() { + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun generate() { + val outDir = outputDirectory.get().asFile + val docsToOutputs = buildDocsToOutputs(outDir) + val queue = workerExecutor.classLoaderIsolation { + it.classpath.from(korroRuntimeClasspath) + } + queue.submit(KorroWorkAction::class.java) { p -> + p.docsToOutputs = docsToOutputs + p.samples = samples.files + p.sampleOutputs = samplesOutputs.files + p.groups = buildSamplesGroups() + p.rewriteAsserts = rewriteAsserts.get() + p.ignoreMissing = ignoreMissing.get() + p.korroPluginVersion = korroPluginVersion.get() + p.taskName = name + } + } +} diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt index 5a9a973..03b5443 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt @@ -17,7 +17,7 @@ class KorroPlugin : Plugin { dependencies.add(runtime.name, "io.github.devcrocod:korro-analysis:$korroPluginVersion") afterEvaluate { - val korroTask = tasks.register("korro", KorroTask::class.java) { t -> + val korroTask = tasks.register("korroGenerate", KorroGenerateTask::class.java) { t -> t.description = "Generates markdown docs with sample snippets into build/korro/docs." t.group = "documentation" t.docs.from(ext.docs.from) @@ -36,8 +36,8 @@ class KorroPlugin : Plugin { t.korroPluginVersion.set(korroPluginVersion) } - tasks.register("korroApply", KorroApplyTask::class.java) { t -> - t.description = "Copies generated docs onto the source tree (mutates source)." + tasks.register("korro", KorroTask::class.java) { t -> + t.description = "Applies generated docs onto the source tree (runs korroGenerate first)." t.group = "documentation" t.dependsOn(korroTask) t.from(korroTask.flatMap { it.outputDirectory }) diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt index 1c3fb10..1ca465e 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt @@ -1,103 +1,7 @@ package io.github.devcrocod.korro -import org.gradle.api.DefaultTask -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.provider.Property -import org.gradle.api.provider.Provider -import org.gradle.api.tasks.* +import org.gradle.api.tasks.Copy import org.gradle.work.DisableCachingByDefault -import org.gradle.workers.WorkerExecutor -import java.io.File -import javax.inject.Inject -@DisableCachingByDefault(because = "Abstract base; concrete subclasses opt in with @CacheableTask.") -abstract class AbstractKorroTask : DefaultTask() { - - @get:Inject - abstract val workerExecutor: WorkerExecutor - - @get:InputFiles - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val docs: ConfigurableFileCollection - - @get:Internal - abstract val docsBaseDir: DirectoryProperty - - @get:Input - val docsRelativePaths: Provider> - get() = docs.elements.map { files -> - val base = docsBaseDir.get().asFile - files.map { it.asFile.toRelativeString(base) }.sorted() - } - - @get:InputFiles - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val samples: ConfigurableFileCollection - - @get:InputFiles - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val samplesOutputs: ConfigurableFileCollection - - @get:Input - abstract val rewriteAsserts: Property - - @get:Input - abstract val ignoreMissing: Property - - @get:Input - abstract val korroPluginVersion: Property - - @get:Nested - abstract val groupSamples: GroupSamplesApi - - @get:Classpath - abstract val korroRuntimeClasspath: ConfigurableFileCollection - - protected fun buildSamplesGroups(): List = listOf( - SamplesGroup( - beforeGroup = groupSamples.beforeGroup.get(), - afterGroup = groupSamples.afterGroup.get(), - beforeSample = groupSamples.beforeSample.get(), - afterSample = groupSamples.afterSample.get(), - patterns = groupSamples.patterns.get(), - ) - ) - - protected fun buildDocsToOutputs(outDir: File): Map { - val base = docsBaseDir.get().asFile - return docs.files.associateWith { input -> - val rel = input.toRelativeString(base) - check(!rel.startsWith("..")) { - "$input is outside docs.baseDir=$base. Set docs.baseDir to a directory that contains all docs." - } - File(outDir, rel) - } - } -} - -@CacheableTask -abstract class KorroTask : AbstractKorroTask() { - - @get:OutputDirectory - abstract val outputDirectory: DirectoryProperty - - @TaskAction - fun korro() { - val outDir = outputDirectory.get().asFile - val docsToOutputs = buildDocsToOutputs(outDir) - val queue = workerExecutor.classLoaderIsolation { - it.classpath.from(korroRuntimeClasspath) - } - queue.submit(KorroWorkAction::class.java) { p -> - p.docsToOutputs = docsToOutputs - p.samples = samples.files - p.sampleOutputs = samplesOutputs.files - p.groups = buildSamplesGroups() - p.rewriteAsserts = rewriteAsserts.get() - p.ignoreMissing = ignoreMissing.get() - p.korroPluginVersion = korroPluginVersion.get() - p.taskName = name - } - } -} +@DisableCachingByDefault(because = "Writes outside the build directory (mutates source tree).") +abstract class KorroTask : Copy() From 6e62b8e41104931ebff8c0b341ce352d2bc9ee78 Mon Sep 17 00:00:00 2001 From: devcrocod Date: Sat, 18 Apr 2026 02:55:42 +0200 Subject: [PATCH 10/15] Add MDX directive support and extend integration tests - Introduced support for MDX (`.mdx`) files using JSX-expression-style directives (`{/*---...--*/}`) for compatibility with MDX parsers like Mintlify and Docusaurus. Updated README, CLAUDE.md, and MIGRATION.md with usage documentation. - Refactored directive parsing to support syntax selection by file extension via a new `DirectiveSyntax` enum. - Extended directive validation and handling in `korro` to ensure consistency across `.md` and `.mdx` files. - Added MDX-specific integration tests, fixtures, and example Kotlin samples. - Updated Gradle scripts and integration test setup to include `.mdx` support. --- CLAUDE.md | 4 +- MIGRATION.md | 14 ++++- README.md | 25 ++++++-- .../fixtures/mdx/build.gradle.kts | 19 ++++++ .../fixtures/mdx/docs/expected/overview.mdx | 11 ++++ .../fixtures/mdx/docs/in/overview.mdx | 6 ++ .../fixtures/mdx/samples/Example.kt | 7 +++ .../fixtures/mdx/settings.gradle.kts | 1 + .../korro/it/KorroIntegrationTest.kt | 10 ++++ .../kotlin/io/github/devcrocod/korro/Korro.kt | 58 ++++++++++++++----- 10 files changed, 131 insertions(+), 24 deletions(-) create mode 100644 integration-tests/fixtures/mdx/build.gradle.kts create mode 100644 integration-tests/fixtures/mdx/docs/expected/overview.mdx create mode 100644 integration-tests/fixtures/mdx/docs/in/overview.mdx create mode 100644 integration-tests/fixtures/mdx/samples/Example.kt create mode 100644 integration-tests/fixtures/mdx/settings.gradle.kts diff --git a/CLAUDE.md b/CLAUDE.md index 0278c64..b9711c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ Two layers separated by a worker boundary. - `KorroTask` (registered as `korro`; `@DisableCachingByDefault`, extends `Copy`) — depends on `korroGenerate` and copies its output directory onto `docs.baseDir`. This is the only mutation point, and the default task users invoke. **Must be `Copy`, not `Sync`:** `docs.baseDir` is typically the project or repo root, which contains many files not managed by Korro (build scripts, sources, hidden dirs, untracked work). A `Sync` task deletes any file in the destination that isn't in the source — i.e. it would wipe the entire repo except the regenerated markdown tree. - `KorroCheckTask` (`@CacheableTask`, extends `AbstractKorroTask`) — **currently a stub.** The action logs "not implemented" and writes a placeholder `build/korro/check.report`. Full diff-against-source implementation is pending a follow-up phase; the task is already registered with the same inputs as `korroGenerate` so CI callers don't change later. - `AbstractKorroTask.buildDocsToOutputs(outDir)` computes each input doc's output path relative to `docs.baseDir` — fails loudly if an input is outside `baseDir`. -- `Korro.kt` is the markdown rewriter (parser + state machine). It lives in the plugin module, not the analysis module — parsing `` doesn't need Analysis API, so the parser can run without spinning up a worker. +- `Korro.kt` is the markdown rewriter (parser + state machine). It lives in the plugin module, not the analysis module — parsing `` doesn't need Analysis API, so the parser can run without spinning up a worker. The directive marker form is selected per-file by extension through the `DirectiveSyntax` enum: `.mdx` files use `{/*---…--*/}` (JSX-expression comments, required because Mintlify/Docusaurus reject raw HTML comments); everything else uses ``. Both forms share the same parser, grammar, regex shape (only the literal `start`/`end` differ), and `END_SAMPLE` is chosen to match the opening file's syntax. **Worker layer** — `korro-analysis/`, runs in a fresh classloader with the Analysis API, IntelliJ platform, and stdlib on the classpath. @@ -82,6 +82,6 @@ Two layers separated by a worker boundary. - Directive lines must start at column 0 after `trim()`; `parseDirective` returns `null` otherwise. - When multiple `IMPORT`s resolve the same short name, the **first** one wins (`firstNotNullOfOrNull` over `imports`). - The directive regex only allows `[_a-zA-Z.]+` for the directive name — changing it affects parsing of every consumer's docs. -- The open marker is **four dashes** `` for `.md` (and anything not `.mdx`), `{/*---NAME VALUE--*/}` for `.mdx`. Both preserve the 3-dashes-to-open / 2-dashes-to-close asymmetry, so the directive has the same visual signature in either file type. Do **not** collapse to two open dashes (that's a standard HTML comment or a standard MDX comment — consumer docs rely on the distinction). - `FUN`/`FUNS` targets must be `KtNamedFunction`s. Properties, classes, top-level expressions, and `.kts` scripts produce a diagnostic, not a silent empty snippet. - `behavior.ignoreMissing=false` is the strict-by-default contract. Don't silently lower severity on unresolved references without an opt-in. diff --git a/MIGRATION.md b/MIGRATION.md index 4f0ce82..e9b9408 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -135,14 +135,22 @@ safe to run from CI without mutating the repo. All new diagnostics are collected across the whole run and reported as a single table at the end of the task. -## Directive syntax — unchanged +## Directive syntax — unchanged for `.md`, new MDX variant -``, ``, `` and the four-dash open marker all work exactly as in 0.1.x. -Existing markdown files parse without modification. Two things worth knowing: +``, ``, `` and the three-dash open marker all work exactly as in 0.1.x. +Existing markdown files parse without modification. Three things worth knowing: - The previously-reserved `` directive is now live. See the [FUNS section of the README](README.md#funs). - First-import-wins on ambiguous short names is preserved. +- `.mdx` files now have a dedicated directive form. MDX v2 parsers (Mintlify, Docusaurus) reject raw HTML comments, so + Korro recognizes a JSX-expression variant in files with the `.mdx` extension: + ```mdx + {/*---IMPORT samples--*/} + {/*---FUN exampleTest--*/} + {/*---END--*/} + ``` + Same three directives, same semantics; only the outer marker changes. Selection is automatic by file extension. ## Consumer-project template diff --git a/README.md b/README.md index b0f2323..73ad706 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ Kotlin source code documentation plugin. Inspired by [kotlinx-knit](https://github.com/Kotlin/kotlinx-knit). -Korro injects Kotlin sample snippets into markdown documents. You point it at some `.md` files and some Kotlin source -files, mark regions with `` directives, and Korro fills those regions with the body of the referenced -function. +Korro injects Kotlin sample snippets into Markdown and MDX documents. You point it at some `.md`/`.mdx` files and some +Kotlin source files, mark regions with `` directives (or `{/*---FUN ...--*/}` in MDX), and Korro fills +those regions with the body of the referenced function. @@ -76,7 +76,7 @@ Typical workflow: ```kotlin korro { docs { - from(fileTree("docs") { include("**/*.md") }) + from(fileTree("docs") { include("**/*.md", "**/*.mdx") }) baseDir.set(layout.projectDirectory.dir("docs")) // REQUIRED } samples { @@ -132,10 +132,22 @@ For new docs, prefer a single `FUNS myFun_v*` directive over two `FUN myFun_v1` Korro does not parse markdown; it recognizes _directives_ only. A directive: - starts at column 0 after `String.trim()`, -- opens with **four dashes** `` on the **same line** (multi-line directives are an error), +- opens with three dashes after an HTML- or MDX-comment prefix — `` (in `.md`) or `--*/}` (in `.mdx`); multi-line directives are an error, - has a name matching `[_a-zA-Z.]+`. +The syntax is selected per file by extension — `.mdx` uses the JSX-expression form, everything else uses the +HTML-comment form. Both encode the same four directives (`IMPORT`, `FUN`, `FUNS`, `END`) with identical semantics; +examples below show the `.md` form. + +MDX equivalents (for Mintlify, Docusaurus, etc.): + +```mdx +{/*---IMPORT samples.Test--*/} +{/*---FUN exampleTest--*/} +{/*---END--*/} +``` + ### IMPORT ``` @@ -266,6 +278,7 @@ println("hello") `behavior { ignoreMissing.set(true) }`. - Assert rewriting is off by default. Restore with `behavior { rewriteAsserts.set(true) }`. - `FUNS` is now implemented as a glob-filter directive. +- MDX files (`.mdx`) are supported natively via a JSX-expression directive form `{/*---FUN ...--*/}`. - `korroClean` is removed; `korroTest` is deferred. Full upgrade guide: [MIGRATION.md](MIGRATION.md). diff --git a/integration-tests/fixtures/mdx/build.gradle.kts b/integration-tests/fixtures/mdx/build.gradle.kts new file mode 100644 index 0000000..8ba574e --- /dev/null +++ b/integration-tests/fixtures/mdx/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("io.github.devcrocod.korro") +} + +repositories { + mavenLocal() + mavenCentral() + maven("https://cache-redirector.jetbrains.com/intellij-dependencies") +} + +korro { + docs { + from(fileTree("docs/in")) + baseDir.set(layout.projectDirectory.dir("docs/in")) + } + samples { + from(fileTree("samples")) + } +} diff --git a/integration-tests/fixtures/mdx/docs/expected/overview.mdx b/integration-tests/fixtures/mdx/docs/expected/overview.mdx new file mode 100644 index 0000000..ae69cf0 --- /dev/null +++ b/integration-tests/fixtures/mdx/docs/expected/overview.mdx @@ -0,0 +1,11 @@ +# Example + +{/*---IMPORT samples--*/} + +{/*---FUN example--*/} + +```kotlin +println("hello from mdx") +``` + +{/*---END--*/} diff --git a/integration-tests/fixtures/mdx/docs/in/overview.mdx b/integration-tests/fixtures/mdx/docs/in/overview.mdx new file mode 100644 index 0000000..aa6091d --- /dev/null +++ b/integration-tests/fixtures/mdx/docs/in/overview.mdx @@ -0,0 +1,6 @@ +# Example + +{/*---IMPORT samples--*/} + +{/*---FUN example--*/} +{/*---END--*/} diff --git a/integration-tests/fixtures/mdx/samples/Example.kt b/integration-tests/fixtures/mdx/samples/Example.kt new file mode 100644 index 0000000..b5229a0 --- /dev/null +++ b/integration-tests/fixtures/mdx/samples/Example.kt @@ -0,0 +1,7 @@ +package samples + +fun example() { + //SampleStart + println("hello from mdx") + //SampleEnd +} diff --git a/integration-tests/fixtures/mdx/settings.gradle.kts b/integration-tests/fixtures/mdx/settings.gradle.kts new file mode 100644 index 0000000..7e889a2 --- /dev/null +++ b/integration-tests/fixtures/mdx/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "korro-mdx-fixture" diff --git a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt index ef2dec7..8432a84 100644 --- a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt +++ b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt @@ -45,6 +45,16 @@ class KorroIntegrationTest { ) } + @Test + fun mdxFixture(@TempDir tempDir: Path) { + runFixture( + name = "mdx", + tempDir = tempDir, + generatedRelativePath = "build/korro/docs/overview.mdx", + expectedRelativePath = "mdx/docs/expected/overview.mdx", + ) + } + @Test fun strictModeFailsOnMissing(@TempDir tempDir: Path) { val fixture = loadFixture("strictErrors", tempDir) diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt index b273dbb..d67d4f4 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt @@ -2,21 +2,53 @@ package io.github.devcrocod.korro import java.io.File -const val DIRECTIVE_START = "" const val IMPORT_DIRECTIVE = "IMPORT" const val FUN_DIRECTIVE = "FUN" const val FUNS_DIRECTIVE = "FUNS" const val END_DIRECTIVE = "END" const val EOF = "\u001a" +// Legacy HTML-comment style (backwards compatible; kept as public constants). +const val DIRECTIVE_START = "" const val END_SAMPLE = DIRECTIVE_START + END_DIRECTIVE + DIRECTIVE_END -val DIRECTIVE_REGEX = - Regex("$DIRECTIVE_START\\s*([_a-zA-Z.]+)(?:\\s+(.+?(?=$DIRECTIVE_END|)))?(?:\\s*($DIRECTIVE_END))?\\s*") +/** + * Marker syntax used to wrap a Korro directive on a single line. + * + * [HTML] matches Markdown's HTML-comment form ``. + * [MDX] matches an MDX JSX-expression comment form `{/*---NAME VALUE--*/}`; + * plain `` is rejected by MDX v2 parsers (e.g. Mintlify), so MDX docs + * must use this variant. Both forms share the same 3-dashes-to-open, 2-dashes-to-close + * asymmetry so the directive signature is visually consistent across file types. + */ +enum class DirectiveSyntax(val start: String, val end: String) { + HTML(DIRECTIVE_START, DIRECTIVE_END), + MDX("{/*---", "--*/}"), + ; + + val endSample: String get() = "$start$END_DIRECTIVE$end" + + val regex: Regex = run { + val s = Regex.escape(start) + val e = Regex.escape(end) + Regex("$s\\s*([_a-zA-Z.]+)(?:\\s+(.+?(?=$e|)))?(?:\\s*($e))?\\s*") + } + + companion object { + fun forFile(file: File): DirectiveSyntax = when (file.extension.lowercase()) { + "mdx" -> MDX + else -> HTML + } + } +} + +val DIRECTIVE_REGEX: Regex = DirectiveSyntax.HTML.regex fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { logger.info("*** Reading $inputFile") + val syntax = DirectiveSyntax.forFile(inputFile) + val endSample = syntax.endSample val samplesTransformer = this.samplesTransformer val lines = ArrayList() val imports = mutableListOf("") @@ -48,7 +80,7 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { if (text != null && output != null) { text += output.readText() } - text?.split("\n")?.plus(END_SAMPLE) + text?.split("\n")?.plus(endSample) } } @@ -95,7 +127,7 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { else -> trimmed.joinToString(separator = "\n\n") { it.snippet } } - return ("\n" + body + "\n").split("\n") + END_SAMPLE + return ("\n" + body + "\n").split("\n") + endSample } fun processFuns(glob: String, oldSampleLines: List, directiveLine: Int) { @@ -126,7 +158,7 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { while (true) { val sampleLine = reader.readLine() ?: return BlockCollect(old, null, null, unclosed = true) to n n++ - val nextDirective = parseDirective(sampleLine) + val nextDirective = parseDirective(sampleLine, syntax) when (nextDirective?.name) { END_DIRECTIVE -> { old.add(sampleLine) @@ -163,7 +195,7 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { val raw = reader.readLine() ?: break lineNo++ line = raw - directive = parseDirective(raw) + directive = parseDirective(raw, syntax) directiveLineNo = lineNo } lines.add(line) @@ -199,7 +231,7 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { } else -> logger.warn( - "Unrecognized directive '${directive.name}' on a line starting with '$DIRECTIVE_START' in '$inputFile'" + "Unrecognized directive '${directive.name}' on a line starting with '${syntax.start}' in '$inputFile'" ) } } @@ -217,11 +249,11 @@ data class Directive( val value: String, ) -fun parseDirective(line: String): Directive? { +fun parseDirective(line: String, syntax: DirectiveSyntax = DirectiveSyntax.HTML): Directive? { val trimLine = line.trim() - if (!trimLine.startsWith(DIRECTIVE_START)) return null - val match = DIRECTIVE_REGEX.matchEntire(trimLine) ?: return null + if (!trimLine.startsWith(syntax.start)) return null + val match = syntax.regex.matchEntire(trimLine) ?: return null val groups = match.groups.filterNotNull().toMutableList() - require(groups.last().value == DIRECTIVE_END) { "Directive must end on the same line with '$DIRECTIVE_END'" } + require(groups.last().value == syntax.end) { "Directive must end on the same line with '${syntax.end}'" } return Directive(groups[1].value.trim(), groups.getOrNull(2)?.value?.trim() ?: "") } From 65ce83d6ddbce9fe2990bd7e61e42074b74b4aa3 Mon Sep 17 00:00:00 2001 From: devcrocod Date: Sat, 18 Apr 2026 03:01:48 +0200 Subject: [PATCH 11/15] Implement `korroCheckTask`: add diff-based validation for generated docs and update integration tests --- .gitignore | 68 +++++++++++++- CLAUDE.md | 2 +- .../fixtures/checkOk/build.gradle.kts | 19 ++++ .../fixtures/checkOk/docs/in/foo.md | 11 +++ .../fixtures/checkOk/samples/Example.kt | 7 ++ .../fixtures/checkOk/settings.gradle.kts | 1 + .../korro/it/KorroIntegrationTest.kt | 60 ++++++++++++ .../kotlin/io/github/devcrocod/korro/Korro.kt | 18 +--- .../github/devcrocod/korro/KorroCheckTask.kt | 91 ++++++++++++++++++- .../io/github/devcrocod/korro/KorroPlugin.kt | 3 +- 10 files changed, 257 insertions(+), 23 deletions(-) create mode 100644 integration-tests/fixtures/checkOk/build.gradle.kts create mode 100644 integration-tests/fixtures/checkOk/docs/in/foo.md create mode 100644 integration-tests/fixtures/checkOk/samples/Example.kt create mode 100644 integration-tests/fixtures/checkOk/settings.gradle.kts diff --git a/.gitignore b/.gitignore index 7429ead..f8358f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,66 @@ +# Gradle +.gradle/ +build/ +out/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/**/build/ +!**/src/**/out/ + +# Gradle local config (paths, SDK locations, credentials) +local.properties +gradle.properties.local + +# Kotlin +.kotlin/ +*.kotlin_metadata + +# IntelliJ IDEA +.idea/ +*.iml +*.ipr +*.iws + +# Eclipse +.classpath +.project +.settings/ +bin/ + +# VS Code +.vscode/ + +# JVM crash logs +hs_err_pid* +replay_pid* + +# macOS .DS_Store +.AppleDouble +.LSOverride + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# Linux +*~ +.directory +.Trash-* + +# Editor temp files +*.swp +*.swo +*.bak +*.orig +*.rej + +# Logs +*.log -.gradle -.idea -build -out \ No newline at end of file +# Env / secrets +.env +.env.local +*.pem +*.key diff --git a/CLAUDE.md b/CLAUDE.md index b9711c4..561e0fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ Two layers separated by a worker boundary. - Tasks: - `KorroGenerateTask` (registered as `korroGenerate`; `@CacheableTask`, extends `AbstractKorroTask`) — `@InputFiles docs`/`samples`/`samplesOutputs`, `@Input` flags, `@Classpath korroRuntimeClasspath`, `@OutputDirectory outputDirectory` (defaults to `build/korro/docs`). On `@TaskAction`, submits a `KorroWorkAction` via `WorkerExecutor.classLoaderIsolation { classpath.from(korroRuntimeClasspath) }`. - `KorroTask` (registered as `korro`; `@DisableCachingByDefault`, extends `Copy`) — depends on `korroGenerate` and copies its output directory onto `docs.baseDir`. This is the only mutation point, and the default task users invoke. **Must be `Copy`, not `Sync`:** `docs.baseDir` is typically the project or repo root, which contains many files not managed by Korro (build scripts, sources, hidden dirs, untracked work). A `Sync` task deletes any file in the destination that isn't in the source — i.e. it would wipe the entire repo except the regenerated markdown tree. - - `KorroCheckTask` (`@CacheableTask`, extends `AbstractKorroTask`) — **currently a stub.** The action logs "not implemented" and writes a placeholder `build/korro/check.report`. Full diff-against-source implementation is pending a follow-up phase; the task is already registered with the same inputs as `korroGenerate` so CI callers don't change later. + - `KorroCheckTask` (`@CacheableTask`, extends `AbstractKorroTask`) — regenerates via the same `KorroWorkAction` into an internal directory (`build/korro/check`, `@Internal`), diffs each generated file against the corresponding source under `docs.baseDir`, writes a summary to `@OutputFile build/korro/check.report`, and throws a `GradleException` listing the first differing line per file when any mismatch is found. Strict-mode worker failures (unresolved `FUN`, etc.) propagate through `queue.await()` and also fail the task — intended CI behavior. - `AbstractKorroTask.buildDocsToOutputs(outDir)` computes each input doc's output path relative to `docs.baseDir` — fails loudly if an input is outside `baseDir`. - `Korro.kt` is the markdown rewriter (parser + state machine). It lives in the plugin module, not the analysis module — parsing `` doesn't need Analysis API, so the parser can run without spinning up a worker. The directive marker form is selected per-file by extension through the `DirectiveSyntax` enum: `.mdx` files use `{/*---…--*/}` (JSX-expression comments, required because Mintlify/Docusaurus reject raw HTML comments); everything else uses ``. Both forms share the same parser, grammar, regex shape (only the literal `start`/`end` differ), and `END_SAMPLE` is chosen to match the opening file's syntax. diff --git a/integration-tests/fixtures/checkOk/build.gradle.kts b/integration-tests/fixtures/checkOk/build.gradle.kts new file mode 100644 index 0000000..8ba574e --- /dev/null +++ b/integration-tests/fixtures/checkOk/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("io.github.devcrocod.korro") +} + +repositories { + mavenLocal() + mavenCentral() + maven("https://cache-redirector.jetbrains.com/intellij-dependencies") +} + +korro { + docs { + from(fileTree("docs/in")) + baseDir.set(layout.projectDirectory.dir("docs/in")) + } + samples { + from(fileTree("samples")) + } +} diff --git a/integration-tests/fixtures/checkOk/docs/in/foo.md b/integration-tests/fixtures/checkOk/docs/in/foo.md new file mode 100644 index 0000000..1d60e62 --- /dev/null +++ b/integration-tests/fixtures/checkOk/docs/in/foo.md @@ -0,0 +1,11 @@ +# Example + + + + + +```kotlin +println("hello") +``` + + diff --git a/integration-tests/fixtures/checkOk/samples/Example.kt b/integration-tests/fixtures/checkOk/samples/Example.kt new file mode 100644 index 0000000..a862c78 --- /dev/null +++ b/integration-tests/fixtures/checkOk/samples/Example.kt @@ -0,0 +1,7 @@ +package samples + +fun example() { + //SampleStart + println("hello") + //SampleEnd +} diff --git a/integration-tests/fixtures/checkOk/settings.gradle.kts b/integration-tests/fixtures/checkOk/settings.gradle.kts new file mode 100644 index 0000000..b61270c --- /dev/null +++ b/integration-tests/fixtures/checkOk/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "korro-checkOk-fixture" diff --git a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt index 8432a84..4860bc4 100644 --- a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt +++ b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt @@ -95,6 +95,66 @@ class KorroIntegrationTest { ) } + @Test + fun korroCheckPassesWhenUpToDate(@TempDir tempDir: Path) { + val fixture = loadFixture("checkOk", tempDir) + + val runner = GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withArguments("korroCheck", "--stacktrace") + .forwardOutput() + + configurePluginClasspath(runner) + System.getProperty("korro.testkit.gradleVersion") + ?.takeIf { it.isNotBlank() } + ?.let(runner::withGradleVersion) + + val result = runner.build() + + assertEquals( + TaskOutcome.SUCCESS, + result.task(":korroCheck")?.outcome, + "korroCheck should succeed when docs are up to date", + ) + val report = fixture.resolve("build/korro/check.report") + assertTrue(Files.exists(report)) { "korroCheck did not produce $report" } + assertTrue(report.readText().contains("OK")) { + "Expected OK report, got:\n${report.readText()}" + } + } + + @Test + fun korroCheckFailsWhenOutOfDate(@TempDir tempDir: Path) { + val fixture = loadFixture("basic", tempDir) + + val runner = GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withArguments("korroCheck", "--stacktrace") + .forwardOutput() + + configurePluginClasspath(runner) + System.getProperty("korro.testkit.gradleVersion") + ?.takeIf { it.isNotBlank() } + ?.let(runner::withGradleVersion) + + val result = runner.buildAndFail() + + assertEquals( + TaskOutcome.FAILED, + result.task(":korroCheck")?.outcome, + "korroCheck should fail when docs differ from regeneration", + ) + val output = result.output + assertTrue(output.contains("foo.md")) { + "Expected failure output to name the out-of-date file; got:\n$output" + } + assertTrue(output.contains("out of date")) { + "Expected 'out of date' in the diff report; got:\n$output" + } + val report = fixture.resolve("build/korro/check.report") + assertTrue(Files.exists(report)) { "korroCheck did not produce $report" } + } + private fun runFixture( name: String, tempDir: Path, diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt index d67d4f4..4d2309b 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt @@ -6,12 +6,6 @@ const val IMPORT_DIRECTIVE = "IMPORT" const val FUN_DIRECTIVE = "FUN" const val FUNS_DIRECTIVE = "FUNS" const val END_DIRECTIVE = "END" -const val EOF = "\u001a" - -// Legacy HTML-comment style (backwards compatible; kept as public constants). -const val DIRECTIVE_START = "" -const val END_SAMPLE = DIRECTIVE_START + END_DIRECTIVE + DIRECTIVE_END /** * Marker syntax used to wrap a Korro directive on a single line. @@ -23,7 +17,7 @@ const val END_SAMPLE = DIRECTIVE_START + END_DIRECTIVE + DIRECTIVE_END * asymmetry so the directive signature is visually consistent across file types. */ enum class DirectiveSyntax(val start: String, val end: String) { - HTML(DIRECTIVE_START, DIRECTIVE_END), + HTML(""), MDX("{/*---", "--*/}"), ; @@ -43,8 +37,6 @@ enum class DirectiveSyntax(val start: String, val end: String) { } } -val DIRECTIVE_REGEX: Regex = DirectiveSyntax.HTML.regex - fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { logger.info("*** Reading $inputFile") val syntax = DirectiveSyntax.forFile(inputFile) @@ -68,7 +60,7 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { group.patterns.mapNotNull { pattern -> samplesTransformer(name + pattern.nameSuffix)?.let { sampleText -> group.beforeSample?.let { pattern.processSubstitutions(it) } + sampleText + - group.afterSample?.let { pattern.processSubstitutions(it) } + group.afterSample?.let { pattern.processSubstitutions(it) } } }.takeIf { it.isNotEmpty() }?.joinToString( separator = "\n", @@ -109,9 +101,9 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { val group = groups.firstOrNull() val hasWrapping = group != null && ( - !group.beforeGroup.isNullOrEmpty() || !group.afterGroup.isNullOrEmpty() || - !group.beforeSample.isNullOrEmpty() || !group.afterSample.isNullOrEmpty() - ) + !group.beforeGroup.isNullOrEmpty() || !group.afterGroup.isNullOrEmpty() || + !group.beforeSample.isNullOrEmpty() || !group.afterSample.isNullOrEmpty() + ) val body = when { hasWrapping && trimmed.size >= 2 -> trimmed.joinToString( diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroCheckTask.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroCheckTask.kt index 275ac63..fd536ba 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroCheckTask.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroCheckTask.kt @@ -1,22 +1,105 @@ package io.github.devcrocod.korro +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction +import java.io.File @CacheableTask abstract class KorroCheckTask : AbstractKorroTask() { + @get:Internal + abstract val generatedDirectory: DirectoryProperty + @get:OutputFile abstract val reportFile: RegularFileProperty @TaskAction fun check() { - logger.warn("korroCheck: not implemented (wired in a later phase). Task succeeded.") - reportFile.get().asFile.apply { - parentFile?.mkdirs() - writeText("pending: implementation deferred\n") + val outDir = generatedDirectory.get().asFile + outDir.deleteRecursively() + outDir.mkdirs() + + val docsToOutputs = buildDocsToOutputs(outDir) + val queue = workerExecutor.classLoaderIsolation { + it.classpath.from(korroRuntimeClasspath) + } + queue.submit(KorroWorkAction::class.java) { p -> + p.docsToOutputs = docsToOutputs + p.samples = samples.files + p.sampleOutputs = samplesOutputs.files + p.groups = buildSamplesGroups() + p.rewriteAsserts = rewriteAsserts.get() + p.ignoreMissing = ignoreMissing.get() + p.korroPluginVersion = korroPluginVersion.get() + p.taskName = name + } + queue.await() + + val base = docsBaseDir.get().asFile + val mismatches = mutableListOf() + for ((source, generated) in docsToOutputs) { + if (!generated.exists()) continue + val actual = if (source.exists()) source.readText() else "" + val expected = generated.readText() + if (actual != expected) { + val diff = firstDifferingLine(actual, expected) + mismatches += CheckMismatch( + relativePath = source.toRelativeString(base), + lineNumber = diff.line, + sourceLine = diff.sourceLine, + generatedLine = diff.generatedLine, + ) + } + } + + val report = formatCheckReport(name, mismatches) + val reportF = reportFile.get().asFile + reportF.parentFile?.mkdirs() + reportF.writeText(report) + + if (mismatches.isNotEmpty()) { + throw GradleException(report) + } + } +} + +internal data class CheckMismatch( + val relativePath: String, + val lineNumber: Int, + val sourceLine: String?, + val generatedLine: String?, +) + +internal data class FirstDiff(val line: Int, val sourceLine: String?, val generatedLine: String?) + +internal fun firstDifferingLine(actual: String, expected: String): FirstDiff { + val actualLines = actual.split("\n") + val expectedLines = expected.split("\n") + val maxLines = maxOf(actualLines.size, expectedLines.size) + for (i in 0 until maxLines) { + val a = actualLines.getOrNull(i) + val e = expectedLines.getOrNull(i) + if (a != e) return FirstDiff(i + 1, a, e) + } + return FirstDiff(maxLines, null, null) +} + +internal fun formatCheckReport(taskName: String, mismatches: List): String { + if (mismatches.isEmpty()) { + return "$taskName: OK (all docs up to date)\n" + } + return buildString { + append(taskName).append(": ").append(mismatches.size) + .append(" file(s) out of date — run `./gradlew korro` to regenerate.\n") + for (m in mismatches) { + append("\n ").append(m.relativePath).append(":").append(m.lineNumber).append('\n') + append(" - ").append(m.sourceLine ?: "").append('\n') + append(" + ").append(m.generatedLine ?: "").append('\n') } } } diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt index 03b5443..2f64e22 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt @@ -45,7 +45,7 @@ class KorroPlugin : Plugin { } tasks.register("korroCheck", KorroCheckTask::class.java) { t -> - t.description = "Verifies generated docs match source tree (stub; full implementation pending)." + t.description = "Verifies generated docs match the source tree (fails on diff)." t.group = "verification" t.docs.from(ext.docs.from) t.docsBaseDir.set(ext.docs.baseDir) @@ -60,6 +60,7 @@ class KorroPlugin : Plugin { t.groupSamples.patterns.set(ext.groupSamples.patterns) t.korroRuntimeClasspath.from(runtime) t.korroPluginVersion.set(korroPluginVersion) + t.generatedDirectory.set(layout.buildDirectory.dir("korro/check")) t.reportFile.set(layout.buildDirectory.file("korro/check.report")) } } From f39688e78a41519af1bfb6bfeb84b354fe50852d Mon Sep 17 00:00:00 2001 From: devcrocod Date: Sat, 18 Apr 2026 03:07:25 +0200 Subject: [PATCH 12/15] Simplify CLAUDE.md: streamline overview, consolidate build commands, and clarify module descriptions. --- CLAUDE.md | 116 ++++++++++++++++++++++-------------------------------- 1 file changed, 48 insertions(+), 68 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 561e0fc..7a51664 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,84 +4,64 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -Korro is a Gradle plugin (Kotlin/JVM) that injects code snippets from Kotlin sample/test source files into Markdown docs. It is published on the Gradle Plugin Portal as `io.github.devcrocod.korro`. User-facing directive syntax and consumer configuration are documented in `README.md`; the 0.2.0 upgrade contract is in `SPEC.md` and the 0.1.x→0.2.0 migration in `MIGRATION.md` — read those before changing the directive parser or the extension DSL. - -The repository is a multi-module Gradle build: - -- `korro-gradle-plugin/` — thin plugin (published to the Gradle Plugin Portal). Only Gradle API + Kotlin stdlib at compile time. No Analysis API imports. -- `korro-analysis/` — shadowed fat jar with the Kotlin Analysis API (K2 standalone mode), IntelliJ platform, and Korro's PSI-based snippet extraction. Published to Maven Central and pulled in at task-execution time through the `korroAnalysisRuntime` configuration. -- `integration-tests/` — GradleTestKit + golden-file tests under `integration-tests/fixtures/`. +Korro is a Gradle plugin (Kotlin/JVM), published as `io.github.devcrocod.korro`, that injects Kotlin function bodies into `.md`/`.mdx` docs via `` or `{/*---FUN ...--*/}` directives. Consumer-facing syntax and the DSL are in `README.md`; the 0.1.x→0.2.0 migration is in `MIGRATION.md`. **Read both before changing the directive parser or the extension DSL** — they are the downstream contract. ## Commands -Build uses the Gradle wrapper (currently 9.4.1): +Gradle wrapper (9.4.1): - `./gradlew build` — compile and assemble both modules. -- `./gradlew :korro-analysis:shadowJar` — build only the shadowed analysis jar. In 0.2.0 the **plugin** module is thin; the **analysis** module is the fat one. -- `./gradlew publishToMavenLocal` — install both artifacts to `~/.m2/repository` for local testing in a consumer project (the plugin's `korroAnalysisRuntime` looks up `korro-analysis` at its own version, so both must be installed together). -- `./gradlew publishPlugins` — publish the plugin to the Gradle Plugin Portal (requires credentials). -- `./gradlew -Prelease build` — produce a release-versioned artifact. Without `-Prelease`, `detectVersion()` in `build.gradle.kts` appends `-dev` (or `-dev-`) to the version in `gradle.properties`. -- `./gradlew :integration-tests:test` — run the GradleTestKit integration tests under `integration-tests/fixtures/*`. +- `./gradlew :integration-tests:test` — GradleTestKit + golden-file tests under `integration-tests/fixtures/`. This is the only meaningful test suite in this repo. +- `./gradlew publishToMavenLocal` — install **both** artifacts to `~/.m2/` for consumer testing. Both must be installed together: `korroAnalysisRuntime` resolves `korro-analysis` at the plugin's own version at task-execution time. +- `./gradlew -Prelease build` — release-versioned artifact. Without `-Prelease`, `detectVersion()` in the root `build.gradle.kts` appends `-dev` (or `-dev-`) to the version in `gradle.properties`. +- `./gradlew :korro-analysis:shadowJar` — build only the fat jar. +- `./gradlew publishPlugins` — publish to the Gradle Plugin Portal (requires credentials). -The `korroGenerate` / `korro` / `korroCheck` tasks the plugin registers are only runnable from a *consumer* project that applies this plugin (or from one of the integration-test fixtures). They are not runnable from this repo's root. +The `korroGenerate` / `korro` / `korroCheck` tasks the plugin registers are **not** runnable from this repo's root — only from a consumer project or an `integration-tests/fixtures/*` fixture. -## Version wiring +## Architecture -Korro's own version lives in `gradle.properties` as `version`. Both modules inherit it through `subprojects { version = rootProject.version }` in the root `build.gradle.kts`. At runtime the plugin reads this from a generated `META-INF/korro-gradle-plugin.properties` resource on the plugin classpath (see `KorroPlugin.readKorroPluginVersion`). +Two modules separated by a Gradle worker boundary. -All other versions live in the Gradle version catalog at `gradle/libs.versions.toml`. The catalog is the single source of truth — don't hard-code versions in subproject build scripts, add them to the catalog and reference as `libs.*` / `libs.plugins.*`. Key entries: +### `korro-gradle-plugin/` — Gradle-facing layer (thin) -- `kotlin` — the pinned Kotlin / Analysis API version used by `korro-analysis` and for the `kotlin("jvm")` plugin. -- `kotlinLanguage` — sets both Kotlin `languageVersion` and `apiVersion` in the subproject Kotlin compilation. Read in the root `build.gradle.kts` via `libs.versions.kotlinLanguage.get()`. Unrelated to the pinned Analysis API version. JVM target is hard-coded to `17` in the root `build.gradle.kts`. -- `shadow`, `pluginPublish` — Gradle plugin versions consumed via `alias(libs.plugins.*)`. -- `kotlinxSerialization`, `caffeine`, `junit` — runtime/test library versions. +Runs in the Gradle daemon classloader. No Analysis API imports at compile time — only `compileOnly(gradleApi())` + `implementation(kotlin("stdlib"))`. Contains `KorroPlugin`, `KorroExtension`, the three tasks, and the markdown directive parser (`Korro.kt`). -A new cache key: every task has an `@Input korroPluginVersion` property, so cached outputs are invalidated on plugin bump (which is also a bundled Analysis API bump). +The parser lives here, not in `korro-analysis`, because `` / `{/*---…--*/}` parsing doesn't need the Analysis API. Per-file marker form is selected by extension through `DirectiveSyntax`: `.mdx` uses JSX-expression comments (required — Mintlify/Docusaurus reject raw HTML comments); everything else uses the HTML-comment form. -## Architecture +Analysis code is pulled in at task-execution time: `KorroPlugin` creates a detached `korroAnalysisRuntime` configuration with a dependency on `io.github.devcrocod:korro-analysis:`, and tasks submit work via `WorkerExecutor.classLoaderIsolation { classpath.from(korroRuntimeClasspath) }`. + +**Task shape to preserve:** + +- `korroGenerate` (`@CacheableTask`) writes out-of-place to `build/korro/docs/`. +- `korro` extends `Copy` (never `Sync`), depends on `korroGenerate`, and copies its output onto `docs.baseDir`. This is the only source-mutation point. **Must stay `Copy`:** `docs.baseDir` is typically the repo or project root and contains many files Korro does not manage — `Sync`'s delete-unknown semantics would wipe the working tree. +- `korroCheck` (`@CacheableTask`) regenerates into `build/korro/check/`, diffs against the source tree, and fails the build with the first differing line per file. CI entry point. +- Every task has an `@Input korroPluginVersion` so cached outputs invalidate on plugin bump (which is also the Analysis API bump). + +### `korro-analysis/` — Analysis layer (shadowed fat jar) + +Runs inside the worker's isolated classloader. Bundles the Kotlin Analysis API (K2 standalone), low-level FIR, and the IntelliJ platform. `com.intellij.*` and `org.jetbrains.kotlin.*` are **intentionally unrelocated** — the Analysis API is already uniquely namespaced, and relocating it breaks reflection lookups inside the platform. + +- One `StandaloneAnalysisAPISession` per `KorroWorkAction.execute()` call, disposed in a `try/finally`. Do **not** call `disposeGlobalStandaloneApplicationServices()` — it's a one-shot that invalidates all future Analysis API use in the JVM. `classLoaderIsolation` gives a fresh classloader per task run, so singletons are reloaded naturally. +- FQN resolution is two-tier: a fast-path short-name index over `KtNamedFunction`s for unambiguous bare names, then a dummy-KDoc `/** [fqn] */` fallback for qualified/ambiguous names. First-import-wins on ambiguity. + +### Worker boundary + +`KorroWorkParameters` is serialized across the classloader boundary (even under `classLoaderIsolation`, Gradle serializes parameters). All fields must stay `Serializable` — `Set`, primitives, strings, and the `SamplesGroup` DTO only. No `Project` / `Task` / `Logger` references. + +## Version wiring + +- Korro's own version lives in `gradle.properties` (`version=...`). Both subprojects inherit it via `subprojects { version = rootProject.version }` in the root `build.gradle.kts`. At runtime the plugin reads it from a generated `META-INF/korro-gradle-plugin.properties` resource (`KorroPlugin.readKorroPluginVersion`). +- Every other version lives in `gradle/libs.versions.toml`. The catalog is the single source of truth — do not hard-code versions in subproject scripts; add to the catalog and reference as `libs.*` / `libs.plugins.*`. +- `libs.versions.kotlin` — pinned Kotlin / Analysis API version. `libs.versions.kotlinLanguage` — Kotlin `languageVersion`/`apiVersion` used to compile Korro itself; unrelated to the bundled Analysis API. JVM target is hard-coded to `17` in the root `build.gradle.kts`. + +## Invariants to preserve + +These are contracts for every consumer's docs; breaking any of them silently breaks downstream projects. -Two layers separated by a worker boundary. - -**Gradle-facing layer** — `korro-gradle-plugin/`, runs in the Gradle daemon's classloader, no Analysis API imports. - -- `KorroPlugin` creates the `korro` extension, creates the detached `korroAnalysisRuntime` configuration (with a dependency on `io.github.devcrocod:korro-analysis:`), and in `afterEvaluate` registers three tasks: `korroGenerate`, `korro`, `korroCheck`. No `korroClean`. -- `KorroExtension` (`KorroExtension.kt`) exposes the nested DSL: `docs { from(...); baseDir.set(...) }`, `samples { from(...); outputs.from(...) }`, `behavior { rewriteAsserts.set(...); ignoreMissing.set(...) }`, `groupSamples { ... }`. All properties use Gradle's `Property` / `ConfigurableFileCollection` / `DirectoryProperty` for config-cache safety. -- Tasks: - - `KorroGenerateTask` (registered as `korroGenerate`; `@CacheableTask`, extends `AbstractKorroTask`) — `@InputFiles docs`/`samples`/`samplesOutputs`, `@Input` flags, `@Classpath korroRuntimeClasspath`, `@OutputDirectory outputDirectory` (defaults to `build/korro/docs`). On `@TaskAction`, submits a `KorroWorkAction` via `WorkerExecutor.classLoaderIsolation { classpath.from(korroRuntimeClasspath) }`. - - `KorroTask` (registered as `korro`; `@DisableCachingByDefault`, extends `Copy`) — depends on `korroGenerate` and copies its output directory onto `docs.baseDir`. This is the only mutation point, and the default task users invoke. **Must be `Copy`, not `Sync`:** `docs.baseDir` is typically the project or repo root, which contains many files not managed by Korro (build scripts, sources, hidden dirs, untracked work). A `Sync` task deletes any file in the destination that isn't in the source — i.e. it would wipe the entire repo except the regenerated markdown tree. - - `KorroCheckTask` (`@CacheableTask`, extends `AbstractKorroTask`) — regenerates via the same `KorroWorkAction` into an internal directory (`build/korro/check`, `@Internal`), diffs each generated file against the corresponding source under `docs.baseDir`, writes a summary to `@OutputFile build/korro/check.report`, and throws a `GradleException` listing the first differing line per file when any mismatch is found. Strict-mode worker failures (unresolved `FUN`, etc.) propagate through `queue.await()` and also fail the task — intended CI behavior. -- `AbstractKorroTask.buildDocsToOutputs(outDir)` computes each input doc's output path relative to `docs.baseDir` — fails loudly if an input is outside `baseDir`. -- `Korro.kt` is the markdown rewriter (parser + state machine). It lives in the plugin module, not the analysis module — parsing `` doesn't need Analysis API, so the parser can run without spinning up a worker. The directive marker form is selected per-file by extension through the `DirectiveSyntax` enum: `.mdx` files use `{/*---…--*/}` (JSX-expression comments, required because Mintlify/Docusaurus reject raw HTML comments); everything else uses ``. Both forms share the same parser, grammar, regex shape (only the literal `start`/`end` differ), and `END_SAMPLE` is chosen to match the opening file's syntax. - -**Worker layer** — `korro-analysis/`, runs in a fresh classloader with the Analysis API, IntelliJ platform, and stdlib on the classpath. - -- `KorroWorkAction` (`KorroAction.kt`) receives serialized `KorroWorkParameters` (docs→output map, sample files, sampleOutput files, `SamplesGroup` list, boolean flags, task name, plugin version), builds a `KorroContext`, and drives it. -- `KorroContext` wires the markdown rewriter to a single `SamplesTransformer` constructed once per `execute()`. -- `SamplesTransformer` / `FqnResolver` / `SampleExtractor` (in `korro-analysis/`) drive the K2 Analysis API. One `StandaloneAnalysisAPISession` per worker `execute()` (disposed in a `try/finally`). FQN resolution uses the two-tier strategy from SPEC §9.3: a fast-path short-name index over `KtNamedFunction`s, then a dummy-KDoc fallback for qualified / ambiguous names. -- `Korro.kt` markdown rewriter behavior: - - `IMPORT` pushes a dotted prefix onto an `imports` list; `FUN` / `FUNS` are tried against each prefix until one resolves. First-import-wins on ambiguity. - - `FUN` opens a block; the loop consumes lines into `oldSampleLines` until `END`, EOF, or the next `FUN` / `FUNS`. On close, `processFun` asks `SamplesTransformer` for replacement text. File is rewritten only if any block changed (`rewrite` flag preserved). - - `FUNS` is fully implemented as an Ant-style glob over FQNs. `renderFunsBody(glob)` asks the transformer for all matches, emits them in deterministic order (file path, then source offset), and wraps the group with `groupSamples.beforeGroup`/`afterGroup` when 2+ matches exist. - - Unresolved `FUN` / `FUNS`, unclosed `//SampleStart`, and non-function targets are collected into a `DiagnosticList`. Under `behavior.ignoreMissing=false` (default) the task fails with a single `GradleException` containing the formatted table; under `ignoreMissing=true` everything degrades to `WARN` and the task succeeds with the old snippet lines retained. - - If a `samples.outputs` file named `` exists, its contents are appended to the generated snippet. -- Sample extraction (from SPEC §9.4): - - Markers are detected by trimming comment text — `//SampleStart`, `// SampleStart`, and `/* SampleStart */` all match. Marker comments never appear in the output. - - Multiple `//SampleStart`/`//SampleEnd` pairs in one function are concatenated in source order, separated by a blank line. - - Zero markers → emit the whole body (minus the outer `{ }`). - - Unclosed `//SampleStart` → diagnostic. - - Assert-rewriting (`assertPrints`, `assertTrue`, `assertFalse`, `assertFails`, `assertFailsWith` → commented `println`) runs only when `behavior.rewriteAsserts=true`. - - Output is wrapped in a ```` ```kotlin ```` fence. - -## Packaging detail - -- `korro-gradle-plugin` has minimal runtime dependencies — `compileOnly(gradleApi())`, `implementation(kotlin("stdlib"))`, and an `implementation` edge on `korro-analysis` that is *not* on the plugin's runtime classpath (the consumer resolves `korro-analysis` at task-execution time via the `korroAnalysisRuntime` configuration). This keeps the plugin jar on the Gradle Portal small and avoids classpath conflicts with other Kotlin-compiler-based plugins the consumer might apply. -- `korro-analysis` is the fat/shaded jar, built via the Shadow plugin. It bundles the Analysis API, low-level FIR, and the IntelliJ platform bits needed by standalone mode. `com.intellij.*` and `org.jetbrains.kotlin.*` are left unrelocated intentionally (the Analysis API is already uniquely namespaced under those packages). - -## Consumer-project behavior to preserve when refactoring - -- Directive lines must start at column 0 after `trim()`; `parseDirective` returns `null` otherwise. -- When multiple `IMPORT`s resolve the same short name, the **first** one wins (`firstNotNullOfOrNull` over `imports`). -- The directive regex only allows `[_a-zA-Z.]+` for the directive name — changing it affects parsing of every consumer's docs. -- Two directive forms are supported and selected per-file by extension: `` for `.md` (and anything not `.mdx`), `{/*---NAME VALUE--*/}` for `.mdx`. Both preserve the 3-dashes-to-open / 2-dashes-to-close asymmetry, so the directive has the same visual signature in either file type. Do **not** collapse to two open dashes (that's a standard HTML comment or a standard MDX comment — consumer docs rely on the distinction). -- `FUN`/`FUNS` targets must be `KtNamedFunction`s. Properties, classes, top-level expressions, and `.kts` scripts produce a diagnostic, not a silent empty snippet. -- `behavior.ignoreMissing=false` is the strict-by-default contract. Don't silently lower severity on unresolved references without an opt-in. +- **Directives start at column 0 after `String.trim()`.** `parseDirective` returns `null` otherwise. +- **Three dashes to open, two to close.** `` for `.md` (and anything non-`.mdx`); `{/*---NAME VALUE--*/}` for `.mdx`. Do not collapse the open marker to two dashes — that becomes a standard HTML/MDX comment, and consumer docs rely on the distinction. +- **Directive name regex is `[_a-zA-Z.]+`.** Broadening it changes parsing for every consumer. +- **First `IMPORT` wins** on ambiguous short names (`firstNotNullOfOrNull` over the `imports` list). +- **Only `KtNamedFunction`** is a valid `FUN`/`FUNS` target. Properties, classes, top-level expressions, and `.kts` scripts must produce a diagnostic, not a silent empty snippet. +- **`behavior.ignoreMissing=false` is the strict-by-default contract.** Don't silently lower severity on unresolved references without an explicit opt-in. From 9e3ce686d82bbe5881c6681cd5c80de315c72968 Mon Sep 17 00:00:00 2001 From: devcrocod Date: Sat, 18 Apr 2026 03:46:41 +0200 Subject: [PATCH 13/15] Migrate to `maven-publish` plugin alias and enhance publishing configuration for Maven Central deployment --- build.gradle.kts | 5 +-- gradle/libs.versions.toml | 2 ++ korro-analysis/build.gradle.kts | 53 +++++++++++++++++++++++++++- korro-gradle-plugin/build.gradle.kts | 42 +++++++++++++++++++++- 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 299b579..b06e1e9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.mavenPublish) apply false } group = "io.github.devcrocod" @@ -14,8 +15,8 @@ fun detectVersion(): String { val baseVersion = version as String return when { hasProperty("release") -> baseVersion - buildNumber != null -> "$baseVersion-dev-$buildNumber" - else -> "$baseVersion-dev" + buildNumber != null -> "$baseVersion-dev-$buildNumber" + else -> "$baseVersion-dev" } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8f0b4a6..2daefec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ pluginPublish = "2.1.1" kotlinxSerialization = "1.11.0" caffeine = "3.2.3" junit = "5.14.3" +mavenPublish = "0.36.0" [libraries] kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } @@ -30,3 +31,4 @@ junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } pluginPublish = { id = "com.gradle.plugin-publish", version.ref = "pluginPublish" } +mavenPublish = { id = "com.vanniktech.maven.publish.base", version.ref = "mavenPublish" } diff --git a/korro-analysis/build.gradle.kts b/korro-analysis/build.gradle.kts index 5b973f0..cc8e26a 100644 --- a/korro-analysis/build.gradle.kts +++ b/korro-analysis/build.gradle.kts @@ -1,7 +1,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.shadow) - `maven-publish` + alias(libs.plugins.mavenPublish) } repositories { @@ -50,13 +50,64 @@ tasks.jar { dependsOn("shadowJar") } +val sourcesJar by tasks.registering(Jar::class) { + archiveClassifier.set("sources") + from(sourceSets.main.get().allSource) +} + +val emptyJavadocJar by tasks.registering(Jar::class) { + archiveClassifier.set("javadoc") +} + publishing { publications { create("maven") { artifact(tasks.shadowJar) + artifact(sourcesJar) + artifact(emptyJavadocJar) groupId = project.group.toString() artifactId = "korro-analysis" version = project.version.toString() } } } + +val signingEnabled = providers.gradleProperty("signingInMemoryKey").isPresent || + providers.gradleProperty("signing.keyId").isPresent + +mavenPublishing { + publishToMavenCentral(automaticRelease = true) + if (signingEnabled) { + signAllPublications() + } + + coordinates(project.group.toString(), "korro-analysis", project.version.toString()) + + pom { + name.set("Korro Analysis") + description.set( + "Kotlin Analysis API (K2 standalone) backend for Korro" + ) + inceptionYear.set("2021") + url.set("https://github.com/devcrocod/korro") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("devcrocod") + name.set("Pavel Gorgulov") + url.set("https://github.com/devcrocod") + } + } + scm { + url.set("https://github.com/devcrocod/korro") + connection.set("scm:git:git://github.com/devcrocod/korro.git") + developerConnection.set("scm:git:ssh://git@github.com/devcrocod/korro.git") + } + } +} diff --git a/korro-gradle-plugin/build.gradle.kts b/korro-gradle-plugin/build.gradle.kts index acb7a7f..2e932ae 100644 --- a/korro-gradle-plugin/build.gradle.kts +++ b/korro-gradle-plugin/build.gradle.kts @@ -2,7 +2,7 @@ plugins { alias(libs.plugins.kotlin.jvm) `java-gradle-plugin` alias(libs.plugins.pluginPublish) - `maven-publish` + alias(libs.plugins.mavenPublish) alias(libs.plugins.shadow) } @@ -65,3 +65,43 @@ gradlePlugin { } } } + +val signingEnabled = providers.gradleProperty("signingInMemoryKey").isPresent || + providers.gradleProperty("signing.keyId").isPresent + +mavenPublishing { + publishToMavenCentral(automaticRelease = true) + if (signingEnabled) { + signAllPublications() + } + + coordinates(project.group.toString(), "korro-gradle-plugin", project.version.toString()) + + pom { + name.set("Korro Gradle Plugin") + description.set( + "Gradle plugin that injects Kotlin sample snippets into documentation" + ) + inceptionYear.set("2021") + url.set("https://github.com/devcrocod/korro") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("devcrocod") + name.set("Pavel Gorgulov") + url.set("https://github.com/devcrocod") + } + } + scm { + url.set("https://github.com/devcrocod/korro") + connection.set("scm:git:git://github.com/devcrocod/korro.git") + developerConnection.set("scm:git:ssh://git@github.com/devcrocod/korro.git") + } + } +} From 872bb8663c0d06db0f26d5865aef40a89aef70eb Mon Sep 17 00:00:00 2001 From: devcrocod Date: Sat, 18 Apr 2026 20:42:24 +0200 Subject: [PATCH 14/15] Set up release workflow with version validation, Maven Central, and Gradle Plugin Portal publishing --- .github/workflows/release.yml | 95 +++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4fd356a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,95 @@ +name: Release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Version to release (must match gradle.properties, e.g. 0.2.0)" + required: true + type: string + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.resolve.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || github.ref }} + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@v4 + + - name: Resolve & validate version + id: resolve + run: | + RAW="${{ github.event.release.tag_name || inputs.version }}" + VERSION="${RAW#v}" + PROP=$(grep '^version=' gradle.properties | cut -d= -f2 | tr -d '[:space:]') + if [ "$VERSION" != "$PROP" ]; then + echo "::error::Release version ($VERSION) does not match gradle.properties ($PROP)" + exit 1 + fi + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Version '$VERSION' is not X.Y.Z" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build + integration tests + run: ./gradlew -Prelease build :integration-tests:test --stacktrace + + publish-maven-central: + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || github.ref }} + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@v4 + + - name: Publish to Maven Central + run: ./gradlew -Prelease publishToMavenCentral --no-configuration-cache + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_IN_MEMORY_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }} + + publish-gradle-plugin-portal: + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || github.ref }} + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@v4 + + - name: Publish to Gradle Plugin Portal + run: ./gradlew -Prelease :korro-gradle-plugin:publishPlugins --no-configuration-cache + env: + GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} + GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} From cec0096024e7f4e8a00d3582773d5cf89fbe5ca8 Mon Sep 17 00:00:00 2001 From: devcrocod Date: Sat, 18 Apr 2026 20:48:35 +0200 Subject: [PATCH 15/15] Configure Renovate: add dependency grouping and monthly update schedule --- .github/renovate.json | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/renovate.json diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..f4a5110 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "schedule": ["on the first day of the month"], + "packageRules": [ + { + "matchManagers": ["gradle"], + "groupName": "other dependencies" + }, + { + "matchPackageNames": [ + "org.jetbrains.kotlin:kotlin-stdlib", + "org.jetbrains.kotlin:kotlin-compiler", + "org.jetbrains.kotlin:analysis-api-for-ide", + "org.jetbrains.kotlin:analysis-api-impl-base-for-ide", + "org.jetbrains.kotlin:analysis-api-platform-interface-for-ide", + "org.jetbrains.kotlin:analysis-api-standalone-for-ide", + "org.jetbrains.kotlin:analysis-api-k2-for-ide", + "org.jetbrains.kotlin:low-level-api-fir-for-ide", + "org.jetbrains.kotlin:symbol-light-classes-for-ide", + "org.jetbrains.kotlin.jvm" + ], + "groupName": "kotlin" + }, + { + "matchManagers": ["github-actions"], + "groupName": "github actions" + }, + { + "matchManagers": ["gradle-wrapper"], + "groupName": "gradle" + } + ] +}