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/.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" + } + ] +} 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 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 }} 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 new file mode 100644 index 0000000..7a51664 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# 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), 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 + +Gradle wrapper (9.4.1): + +- `./gradlew build` — compile and assemble both modules. +- `./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 **not** runnable from this repo's root — only from a consumer project or an `integration-tests/fixtures/*` fixture. + +## Architecture + +Two modules separated by a Gradle worker boundary. + +### `korro-gradle-plugin/` — Gradle-facing layer (thin) + +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`). + +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. + +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. + +- **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. 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/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..e9b9408 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,158 @@ +# Migrating from Korro 0.1.x to 0.2.0 + +## TL;DR + +The plugin id (`io.github.devcrocod.korro`) and the `` 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` 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. + +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 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` + +```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` (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 `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 + +- **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 for `.md`, new MDX variant + +``, ``, `` 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 + +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/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/README.md b/README.md index b951418..73ad706 100644 --- a/README.md +++ b/README.md @@ -1,223 +1,286 @@ # 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 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. + * [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 | +|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| `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`. + +Typical workflow: + +```bash +# Local authoring: +./gradlew korro # regenerate and update source markdown in one step -### Parameters +# CI: +./gradlew korroCheck # fail if docs drift from samples +``` + +### DSL -```groovy +```kotlin korro { - docs = fileTree(project.rootDir) { - include '**/*.md' + docs { + from(fileTree("docs") { include("**/*.md", "**/*.mdx") }) + 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 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` + 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 -``` -` (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--*/} ``` ---> -``` -There are also two types of directives that require and don't require the `END` closing directive. -#### IMPORT -The `IMPORT` directive is used to import a class containing test functions. +### IMPORT + ``` - + ``` -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. + +Package wildcards (`samples.*`) are not supported. -_Import will not include the entire package, that is, such a path is not recognized - `org.example.*`._ +### 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`: -``` +````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(…) }`. +- `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) }`. +- `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). + +A ready-to-copy consumer project lives at [`integration-tests/fixtures/basic/`](integration-tests/fixtures/basic). diff --git a/build.gradle.kts b/build.gradle.kts index 39526e6..b06e1e9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,98 +1,51 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion 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" + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.mavenPublish) 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 kotlinLanguageVersion = libs.versions.kotlinLanguage.get() +subprojects { + group = rootProject.group + version = rootProject.version -tasks.jar { - enabled = false - dependsOn("shadowJar") - manifest { - attributes( - "Implementation-Title" to "$archiveBaseName", - "Implementation-Version" to "$archiveVersion" - ) + repositories { + mavenCentral() + gradlePluginPortal() } -} -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 - } -} - -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 { + compilerOptions { + freeCompilerArgs.addAll( + "-opt-in=kotlin.RequiresOptIn", + "-Xskip-metadata-version-check", + "-Xjsr305=strict", + ) + languageVersion.set(KotlinVersion.fromVersion(kotlinLanguageVersion)) + apiVersion.set(KotlinVersion.fromVersion(kotlinLanguageVersion)) + jvmTarget.set(JvmTarget.JVM_17) } } -} -tasks.withType { - kotlinOptions { - jvmTarget = "1.8" + tasks.withType().configureEach { + sourceCompatibility = JavaVersion.VERSION_17.toString() + targetCompatibility = JavaVersion.VERSION_17.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..12dfc5d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,9 +1,12 @@ -kotlin.code.style=official +# Project +version=0.2.0 -version=0.1.7 +# 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_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 +# 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..2daefec --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,34 @@ +[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" +mavenPublish = "0.36.0" + +[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" } +mavenPublish = { id = "com.vanniktech.maven.publish.base", version.ref = "mavenPublish" } diff --git a/integration-tests/README.md b/integration-tests/README.md new file mode 100644 index 0000000..3a79064 --- /dev/null +++ b/integration-tests/README.md @@ -0,0 +1,28 @@ +# Korro integration tests + +GradleTestKit-driven golden-file tests. One fixture per scenario under `fixtures/`, +executed in a temp directory to avoid polluting the source tree. + +## Running + +``` +./gradlew :integration-tests:test +``` + +## Adding a fixture + +1. Create `fixtures//` with `settings.gradle.kts`, `build.gradle.kts` + (applying `id("io.github.devcrocod.korro")` and `mavenCentral()`), source docs + under `docs/in/`, samples under `samples/`, and an empty + `docs/expected/foo.md` placeholder. +2. Add a `@Test` method to `KorroIntegrationTest` calling `loadFixture(, tempDir)`. +3. Regenerate the golden file: + + ``` + ./gradlew :integration-tests:test -Pkorro.regenerate.expected=true + ``` + + The test writes the produced markdown back into `fixtures//docs/expected/` + and short-circuits the assertion. +4. Inspect the diff, commit, and re-run without the flag to confirm the + assertion passes. diff --git a/integration-tests/build.gradle.kts b/integration-tests/build.gradle.kts new file mode 100644 index 0000000..55b339c --- /dev/null +++ b/integration-tests/build.gradle.kts @@ -0,0 +1,36 @@ +import org.gradle.plugin.devel.tasks.PluginUnderTestMetadata + +plugins { + alias(libs.plugins.kotlin.jvm) + `java-gradle-plugin` +} + +evaluationDependsOn(":korro-gradle-plugin") + +val pluginShadowJar = project(":korro-gradle-plugin").tasks.named("shadowJar") + +dependencies { + testImplementation(gradleTestKit()) + testImplementation(platform(libs.junit.bom)) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} + +gradlePlugin { + testSourceSets(sourceSets["test"]) +} + +tasks.named("pluginUnderTestMetadata") { + pluginClasspath.from(pluginShadowJar) +} + +val regenerateExpected = providers.gradleProperty("korro.regenerate.expected").orElse("false") + +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 new file mode 100644 index 0000000..8ba574e --- /dev/null +++ b/integration-tests/fixtures/basic/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/basic/docs/expected/foo.md b/integration-tests/fixtures/basic/docs/expected/foo.md new file mode 100644 index 0000000..1d60e62 --- /dev/null +++ b/integration-tests/fixtures/basic/docs/expected/foo.md @@ -0,0 +1,11 @@ +# Example + + + + + +```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/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/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/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/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/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 new file mode 100644 index 0000000..4860bc4 --- /dev/null +++ b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt @@ -0,0 +1,252 @@ +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 +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 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", + ) + } + + @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 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) + + 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(":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" + } + 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", + ) + } + + @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, + generatedRelativePath: String, + expectedRelativePath: String, + ) { + val fixture = loadFixture(name, 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.build() + + assertEquals(TaskOutcome.SUCCESS, result.task(":korro")?.outcome, "korro task outcome for $name") + + val actualFile = fixture.resolve(generatedRelativePath) + assertTrue(Files.exists(actualFile)) { "korro did not produce $actualFile" } + + val expectedFile = fixturesRoot().resolve(expectedRelativePath) + 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 for $name") + } + + 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 { + System.getProperty("korro.fixtures.dir")?.let { return File(it).toPath() } + + val cwd = File("").absoluteFile.toPath() + val candidates = listOfNotNull( + cwd.resolve("fixtures"), + cwd.resolve("integration-tests/fixtures"), + cwd.parent?.resolve("fixtures"), + ) + 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 = listOfNotNull( + cwd.resolve("../korro-gradle-plugin/build/libs"), + cwd.resolve("korro-gradle-plugin/build/libs"), + cwd.parentFile?.resolve("korro-gradle-plugin/build/libs"), + ).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 new file mode 100644 index 0000000..cc8e26a --- /dev/null +++ b/korro-analysis/build.gradle.kts @@ -0,0 +1,113 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.shadow) + alias(libs.plugins.mavenPublish) +} + +repositories { + mavenCentral() + maven("https://cache-redirector.jetbrains.com/intellij-dependencies") +} + +dependencies { + 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 { + 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") +} + +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-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..5f765c6 --- /dev/null +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt @@ -0,0 +1,127 @@ +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> + private val ordered: List> + + init { + 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? { + byFqn[candidateFqn]?.let { return it } + if ('.' !in candidateFqn) { + byShortName[candidateFqn]?.singleOrNull()?.let { return it } + } + 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 + .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( + file: KtFile, + fqn: MutableMap, + shortName: MutableMap>, + ordered: MutableList>, + ) { + fun visit(declarations: List) { + declarations.forEach { decl -> + when (decl) { + is KtNamedFunction -> { + 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) + else -> {} + } + } + } + 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/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-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt new file mode 100644 index 0000000..6a2bada --- /dev/null +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt @@ -0,0 +1,173 @@ +package io.github.devcrocod.korro.analysis + +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.kotlin.psi.* +import org.jetbrains.kotlin.psi.psiUtil.prevLeaf + +class SampleExtractor(private val rewriteAsserts: Boolean) { + + fun extract(function: KtNamedFunction): String { + val body = processBody(function) + return createSampleBody(body) + } + + 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).minOfOrNull { it.takeWhile(Char::isWhitespace).count() } ?: 0 + return lines.joinToString("\n") { it.drop(indent) } + } + + 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) + return sampleBuilder.text + } + + 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 + + private fun convertAssertPrints(expression: KtCallExpression) { + val (argument, commentArgument) = expression.valueArguments + builder.apply { + append("println(") + append(argument.text) + append(") // ") + append(commentArgument.extractStringArgumentValue()) + } + } + + private fun convertAssertTrueFalse(expression: KtCallExpression, expectedResult: Boolean) { + val (argument) = expression.valueArguments + builder.apply { + expression.valueArguments.getOrNull(1)?.let { value -> + append("// ${value.extractStringArgumentValue()}") + val ws = expression.prevLeaf { it is PsiWhiteSpace } + append(ws?.text ?: "\n") + } + append("println(\"") + append(argument.text) + append(" is \${") + append(argument.text) + append("}\") // $expectedResult") + } + } + + private fun convertAssertFails(expression: KtCallExpression) { + val valueArguments = expression.valueArguments + val funcArgument: KtValueArgument + val message: KtValueArgument? + + if (valueArguments.size == 1) { + message = null + funcArgument = valueArguments.first() + } else { + message = valueArguments.first() + funcArgument = valueArguments.last() + } + + builder.apply { + val argument = funcArgument.extractFunctionalArgumentText() + append(argument.lines().joinToString(separator = "\n") { "// $it" }) + append(" // ") + if (message != null) { + append(message.extractStringArgumentValue()) + } + append(" will fail") + } + } + + private fun convertAssertFailsWith(expression: KtCallExpression) { + val (funcArgument) = expression.valueArguments + val (exceptionType) = expression.typeArguments + builder.apply { + val argument = funcArgument.extractFunctionalArgumentText() + append(argument.lines().joinToString(separator = "\n") { "// $it" }) + append(" // will fail with ") + append(exceptionType.text) + } + } + + 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) { + "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) + } + + override fun visitElement(element: PsiElement) { + if (element is LeafPsiElement) { + val t = element.text + if (t.filterNot { it.isWhitespace() } == "//SampleEnd") start = false + if (start) builder.append(t) + if (t.filterNot { it.isWhitespace() } == "//SampleStart") start = true + } + + element.acceptChildren(object : PsiElementVisitor() { + override fun visitElement(element: PsiElement) { + try { + element.accept(this@SampleBuilder) + } catch (_: Exception) { + builder.append(element.text) + } + } + }) + } + } +} 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..5c54c12 --- /dev/null +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt @@ -0,0 +1,33 @@ +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, +) : 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) + } + + 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/build.gradle.kts b/korro-gradle-plugin/build.gradle.kts new file mode 100644 index 0000000..2e932ae --- /dev/null +++ b/korro-gradle-plugin/build.gradle.kts @@ -0,0 +1,107 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + `java-gradle-plugin` + alias(libs.plugins.pluginPublish) + alias(libs.plugins.mavenPublish) + alias(libs.plugins.shadow) +} + +configurations.named(JavaPlugin.API_CONFIGURATION_NAME) { + dependencies.remove(project.dependencies.gradleApi()) +} + +dependencies { + shadow(libs.kotlin.stdlib) + + 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("") +} + + +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")) + } + } +} + +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") + } + } +} 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 new file mode 100644 index 0000000..4d2309b --- /dev/null +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt @@ -0,0 +1,251 @@ +package io.github.devcrocod.korro + +import java.io.File + +const val IMPORT_DIRECTIVE = "IMPORT" +const val FUN_DIRECTIVE = "FUN" +const val FUNS_DIRECTIVE = "FUNS" +const val END_DIRECTIVE = "END" + +/** + * 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(""), + 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 + } + } +} + +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("") + + 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 { sampleText -> + group.beforeSample?.let { pattern.processSubstitutions(it) } + sampleText + + group.afterSample?.let { pattern.processSubstitutions(it) } + } + }.takeIf { it.isNotEmpty() }?.joinToString( + separator = "\n", + prefix = group.beforeGroup ?: "", + postfix = group.afterGroup ?: "" + ) + } + val output = outputsMap[name] + if (text != null && output != null) { + text += output.readText() + } + text?.split("\n")?.plus(endSample) + } + } + + fun processFun(funName: String, oldSampleLines: List, directiveLine: Int) { + val newSamplesLines = renderFunBody(funName) + if (newSamplesLines == null) { + 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 (oldSampleLines != newSamplesLines) { + logger.info("*** Add $funName sample") + lines.addAll(newSamplesLines) + } else { + lines.addAll(oldSampleLines) + } + } + + 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") + endSample + } + + 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 sampleLine = reader.readLine() ?: return BlockCollect(old, null, null, unclosed = true) to n + n++ + val nextDirective = parseDirective(sampleLine, syntax) + when (nextDirective?.name) { + END_DIRECTIVE -> { + 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) + } + } + } + + 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, syntax) + 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) + } + } + } + + else -> logger.warn( + "Unrecognized directive '${directive.name}' on a line starting with '${syntax.start}' in '$inputFile'" + ) + } + } + } + + outputFile.parentFile?.mkdirs() + outputFile.printWriter().use { out -> + lines.forEach { out.println(it) } + } + return diagnostics.none { it.severity == Severity.ERROR } +} + +data class Directive( + val name: String, + val value: String, +) + +fun parseDirective(line: String, syntax: DirectiveSyntax = DirectiveSyntax.HTML): Directive? { + val trimLine = line.trim() + 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 == syntax.end) { "Directive must end on the same line with '${syntax.end}'" } + return Directive(groups[1].value.trim(), groups.getOrNull(2)?.value?.trim() ?: "") +} 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 new file mode 100644 index 0000000..c7eb94c --- /dev/null +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt @@ -0,0 +1,53 @@ +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 +import java.io.File + +interface KorroParameters : WorkParameters { + var docsToOutputs: Map + var samples: Set + var sampleOutputs: Set + var groups: List + var rewriteAsserts: Boolean + var ignoreMissing: Boolean + var korroPluginVersion: String + var taskName: String +} + +abstract class KorroWorkAction : WorkAction { + override fun execute() { + val p = parameters + 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, + ) + 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/KorroCheckTask.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroCheckTask.kt new file mode 100644 index 0000000..fd536ba --- /dev/null +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroCheckTask.kt @@ -0,0 +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() { + 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/KorroContext.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt new file mode 100644 index 0000000..001d2d0 --- /dev/null +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt @@ -0,0 +1,26 @@ +package io.github.devcrocod.korro + +import io.github.devcrocod.korro.analysis.SamplesTransformer +import java.io.File + +class KorroContext( + val logger: KorroLog, + docsToOutputs: Map, + sampleOutputs: Collection, + val groups: List, + val ignoreMissing: Boolean, + val samplesTransformer: SamplesTransformer, +) { + val fileQueue: ArrayDeque> = ArrayDeque( + docsToOutputs.entries.map { (input, output) -> input to output } + ) + val outputsMap: Map = sampleOutputs.associateBy { it.name } + val diagnostics: MutableList = mutableListOf() +} + +fun KorroContext.process() { + while (!fileQueue.isEmpty()) { + val (input, output) = fileQueue.removeFirst() + korro(input, output) + } +} diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroExtension.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroExtension.kt new file mode 100644 index 0000000..4e0eb18 --- /dev/null +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroExtension.kt @@ -0,0 +1,109 @@ +package io.github.devcrocod.korro + +import org.gradle.api.Action +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Nested + +abstract class KorroExtension { + @get:Nested + abstract val docs: DocsSpec + + @get:Nested + abstract val samples: SamplesSpec + + @get:Nested + abstract val behavior: BehaviorSpec + + @get:Nested + abstract val groupSamples: GroupSamplesApi + + fun docs(action: Action) { + action.execute(docs) + } + + fun samples(action: Action) { + action.execute(samples) + } + + fun behavior(action: Action) { + action.execute(behavior) + } + + fun groupSamples(action: Action) { + action.execute(groupSamples) + } +} + +abstract class DocsSpec { + abstract val from: ConfigurableFileCollection + abstract val baseDir: DirectoryProperty + + fun from(vararg paths: Any) { + from.from(*paths) + } +} + +abstract class SamplesSpec { + abstract val from: ConfigurableFileCollection + abstract val outputs: ConfigurableFileCollection + + fun from(vararg paths: Any) { + from.from(*paths) + } +} + +abstract class BehaviorSpec { + @get:Input + abstract val rewriteAsserts: Property + + @get:Input + abstract val ignoreMissing: Property + + init { + rewriteAsserts.convention(false) + ignoreMissing.convention(false) + } +} + +abstract class GroupSamplesApi { + @get:Input + abstract val beforeGroup: Property + + @get:Input + abstract val afterGroup: Property + + @get:Input + abstract val beforeSample: Property + + @get:Input + abstract val afterSample: Property + + @get:Input + abstract val patterns: ListProperty + + init { + beforeGroup.convention("") + afterGroup.convention("") + beforeSample.convention("") + afterSample.convention("") + patterns.convention(emptyList()) + } + + fun funSuffix(suffix: String, action: Action) { + val api = FunSuffixApi() + action.execute(api) + patterns.add(FunctionPattern(suffix, api.substitutions.toMap())) + } +} + +class FunSuffixApi { + internal val substitutions = mutableMapOf() + + fun replaceText(placeholder: String, text: String) { + substitutions[placeholder] = text + } +} 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/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/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 new file mode 100644 index 0000000..2f64e22 --- /dev/null +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt @@ -0,0 +1,77 @@ +package io.github.devcrocod.korro + +import org.gradle.api.Plugin +import org.gradle.api.Project +import java.util.* + +class KorroPlugin : Plugin { + override fun apply(project: Project): Unit = with(project) { + val ext = extensions.create("korro", KorroExtension::class.java) + + val korroPluginVersion = readKorroPluginVersion() + + val runtime = configurations.create("korroAnalysisRuntime") { + it.isCanBeConsumed = false + it.isCanBeResolved = true + } + dependencies.add(runtime.name, "io.github.devcrocod:korro-analysis:$korroPluginVersion") + + afterEvaluate { + 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) + t.docsBaseDir.set(ext.docs.baseDir) + t.samples.from(ext.samples.from) + t.samplesOutputs.from(ext.samples.outputs) + t.rewriteAsserts.set(ext.behavior.rewriteAsserts) + t.ignoreMissing.set(ext.behavior.ignoreMissing) + t.groupSamples.beforeGroup.set(ext.groupSamples.beforeGroup) + t.groupSamples.afterGroup.set(ext.groupSamples.afterGroup) + t.groupSamples.beforeSample.set(ext.groupSamples.beforeSample) + t.groupSamples.afterSample.set(ext.groupSamples.afterSample) + t.groupSamples.patterns.set(ext.groupSamples.patterns) + t.korroRuntimeClasspath.from(runtime) + t.outputDirectory.set(layout.buildDirectory.dir("korro/docs")) + t.korroPluginVersion.set(korroPluginVersion) + } + + 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 }) + t.into(ext.docs.baseDir) + } + + tasks.register("korroCheck", KorroCheckTask::class.java) { t -> + 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) + t.samples.from(ext.samples.from) + t.samplesOutputs.from(ext.samples.outputs) + t.rewriteAsserts.set(ext.behavior.rewriteAsserts) + t.ignoreMissing.set(ext.behavior.ignoreMissing) + t.groupSamples.beforeGroup.set(ext.groupSamples.beforeGroup) + t.groupSamples.afterGroup.set(ext.groupSamples.afterGroup) + t.groupSamples.beforeSample.set(ext.groupSamples.beforeSample) + t.groupSamples.afterSample.set(ext.groupSamples.afterSample) + 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")) + } + } + } + + 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 new file mode 100644 index 0000000..1ca465e --- /dev/null +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt @@ -0,0 +1,7 @@ +package io.github.devcrocod.korro + +import org.gradle.api.tasks.Copy +import org.gradle.work.DisableCachingByDefault + +@DisableCachingByDefault(because = "Writes outside the build directory (mutates source tree).") +abstract class KorroTask : Copy() 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 63% 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 index d6d045f..e40ecff 100644 --- a/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) } @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts index 6a070ff..e924d5a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,8 +1,3 @@ -pluginManagement { - val kotlin_version: String by settings - plugins { - id("org.jetbrains.kotlin.jvm") version kotlin_version - } -} +rootProject.name = "korro" -rootProject.name = "korro" \ No newline at end of file +include("korro-gradle-plugin", "korro-analysis", "integration-tests") diff --git a/src/main/kotlin/io/github/devcrocod/korro/Korro.kt b/src/main/kotlin/io/github/devcrocod/korro/Korro.kt deleted file mode 100644 index 6c2b822..0000000 --- a/src/main/kotlin/io/github/devcrocod/korro/Korro.kt +++ /dev/null @@ -1,202 +0,0 @@ -package io.github.devcrocod.korro - -import org.jetbrains.kotlin.utils.addToStdlib.firstNotNullResult -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" - -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*") - -fun KorroContext.korro(inputFile: File): Boolean { - logger.info("*** Reading $inputFile") - val inputFileType = inputFile.type() - if (inputFileType != InputFileType.MARKDOWN) { - logger.warn("WARNING: $inputFile: Unknown input file type. Treating it as markdown.") - } - val samplesTransformer = SamplesTransformer(this) - val lines = ArrayList() - val imports = mutableListOf("") - var rewrite = false - - fun processFun(funName: String, oldSampleLines: List) { - val functionNames = imports.map { - it + funName - } - val newSamplesLines = functionNames.firstNotNullOfOrNull { name -> // TODO: can be improved - var text = samplesTransformer(name) ?: groups.firstNotNullOfOrNull { group -> - group.patterns.mapNotNull { pattern -> - samplesTransformer(name + pattern.nameSuffix)?.let { - group.beforeSample?.let { pattern.processSubstitutions(it) } + it + - group.afterSample?.let { pattern.processSubstitutions(it) } - } - }.takeIf { it.isNotEmpty() }?.joinToString( - separator = "\n", - prefix = group.beforeGroup ?: "", - postfix = group.afterGroup ?: "" - ) - } - - val output = outputsMap[name] - if (text != null && output != null) { - text += output.readText() - } - - text?.split("\n")?.plus(END_SAMPLE) //?: oldSampleLines - } - if (newSamplesLines == null) { - logger.warn("Cannot find PsiElement corresponding to '$funName'") - } - if (newSamplesLines != null && oldSampleLines != newSamplesLines) { - rewrite = true - logger.info("*** Add $funName sample") - lines.addAll(newSamplesLines) - } else { - lines.addAll(oldSampleLines) - } - } - - inputFile.bufferedReader().use { bufferedReader -> - while (true) { - val line = bufferedReader.readLine() ?: break - lines.add(line) - var directive = parseDirective(line) - when (directive?.name) { - null, END_DIRECTIVE -> { - } - IMPORT_DIRECTIVE -> { - imports.add(directive.value + ".") - } - 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) // eof - { - directive = null - break - } - directive = nextDirective - lines.add(sampleLine) - } - else -> { - oldSampleLines.add(sampleLine) - } - } - } - if(directive == null) // eof - break - processFun(directive.value, oldSampleLines) - } - FUNS_DIRECTIVE -> { - } - else -> logger.warn("Unrecognized directive '${directive.name}' on a line starting with '$DIRECTIVE_START' in '$inputFile'") - } - } - } - if (rewrite) { - inputFile.printWriter().use { out -> - lines.forEach { out.println(it) } - } - } - return true -} - -fun KorroContext.korroClean(inputFile: File): Boolean { - logger.info("*** Cleaning $inputFile") - val inputFileType = inputFile.type() - if (inputFileType != InputFileType.MARKDOWN) { - logger.warn("WARNING: $inputFile: Unknown input file type. Treating it as markdown.") - } - val lines = ArrayList() - var rewrite = false - inputFile.bufferedReader().use { bufferedReader -> - while (true) { - val line = bufferedReader.readLine() ?: break - lines.add(line) - var directive = parseDirective(line) - when (directive?.name) { - FUN_DIRECTIVE -> { - val oldSampleLines = ArrayList() - while (true) { - val sampleLine = bufferedReader.readLine() - val nextDir = if(sampleLine != null) parseDirective(sampleLine) else Directive(EOF, "") - when(nextDir?.name) { - END_DIRECTIVE -> { - oldSampleLines.add(sampleLine) - lines.add(sampleLine) - break - } - EOF, FUN_DIRECTIVE -> { - lines.addAll(oldSampleLines) - oldSampleLines.clear() - if(sampleLine == null) - break - lines.add(sampleLine) - directive = nextDir - } - else -> { - oldSampleLines.add(sampleLine) - } - } - } - if (oldSampleLines.isNotEmpty()) { - rewrite = true - logger.info("*** Clean ${directive?.value} sample") - } - } - FUNS_DIRECTIVE -> { - } - else -> { - } - } - } - } - if (rewrite) { - inputFile.printWriter().use { out -> - lines.forEach { out.println(it) } - } - } - return true -} - -data class Directive( - val name: String, - val value: String, -) - -fun parseDirective(line: String): Directive? { - val trimLine = line.trim() - if (!trimLine.startsWith(DIRECTIVE_START)) return null - val match = DIRECTIVE_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'" } - return Directive(groups[1].value.trim(), groups.getOrNull(2)?.value?.trim() ?: "") -} - -enum class InputFileType( - val extension: String -) { - MARKDOWN(".md"), - UNKNOWN("") // works just like MARKDOWN -} - -fun File.type(): InputFileType = InputFileType.values().first { name.endsWith(it.extension) } \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt b/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt deleted file mode 100644 index 2c5b83f..0000000 --- a/src/main/kotlin/io/github/devcrocod/korro/KorroAction.kt +++ /dev/null @@ -1,55 +0,0 @@ -package io.github.devcrocod.korro - -import org.gradle.api.GradleException -import org.gradle.api.tasks.Nested -import org.gradle.workers.WorkAction -import org.gradle.workers.WorkParameters -import java.io.File - -interface KorroParameters : WorkParameters { - var docs: Set - var samples: Set - var outputs: Set - var groups: List - var name: String -} - -abstract class KorroAction : WorkAction { - - @get:Nested - abstract val ext: KorroExtension - - override fun execute() { - ext.groups.addAll(parameters.groups) - val ctx = ext.createContext(parameters.docs, parameters.samples, parameters.outputs) - - //TODO - check missing files! - - //TODO - process!!! error - if (!ctx.process()) { - val extra = if (ctx.logger.nOutdated > 0) - "\nRun 'korro' task to write ${ctx.logger.nOutdated} missing/outdated files." - else - "" - throw GradleException("${parameters.name} task failed, see log for details (use '--info' for detailed log).$extra") - } - } -} - -abstract class KorroCleanAction : WorkAction { - - @get:Nested - abstract val ext: KorroExtension - - override fun execute() { - val ctx = ext.createContext(parameters.docs, parameters.samples, parameters.outputs) - - if (!ctx.processClean()) { - val extra = if (ctx.logger.nOutdated > 0) - "\nRun 'korro' task to write ${ctx.logger.nOutdated} missing/outdated files." - else - "" - throw GradleException("${parameters.name} task failed, see log for details (use '--info' for detailed log).$extra") - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt b/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt deleted file mode 100644 index b01364f..0000000 --- a/src/main/kotlin/io/github/devcrocod/korro/KorroContext.kt +++ /dev/null @@ -1,31 +0,0 @@ -package io.github.devcrocod.korro - -import java.io.File -import java.util.* - -class KorroContext( - val logger: KorroLog, - docs: Collection, - samples: Collection, - outputs: Collection, - val groups: List -) { - // state - val fileQueue = ArrayDeque(docs) - val sampleSet = HashSet(samples) - val outputsMap = outputs.associateBy { it.name } -} - -fun KorroContext.process(): Boolean { - while (!fileQueue.isEmpty()) { - if (!korro(fileQueue.removeFirst())) return false - } - return true -} - -fun KorroContext.processClean(): Boolean { - while (!fileQueue.isEmpty()) { - if (!korroClean(fileQueue.removeFirst())) return false - } - return true -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcrocod/korro/KorroExtension.kt b/src/main/kotlin/io/github/devcrocod/korro/KorroExtension.kt deleted file mode 100644 index cf5f939..0000000 --- a/src/main/kotlin/io/github/devcrocod/korro/KorroExtension.kt +++ /dev/null @@ -1,72 +0,0 @@ -package io.github.devcrocod.korro - -import org.gradle.api.Action -import org.gradle.api.file.FileCollection -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.Nested -import java.io.File - -class FunSuffixApi { - internal val substitutions = mutableMapOf() - - fun replaceText(placeholder: String, text: String) { - substitutions.put(placeholder, text) - } -} - -abstract class GroupSamplesApi { - - val patterns = mutableListOf() - - fun funSuffix(suffix: String, action: Action) { - val api = FunSuffixApi() - action.execute(api) - patterns.add(FunctionPattern(suffix, api.substitutions)) - } - - @get:Input - abstract val beforeGroup: Property - - @get:Input - abstract val afterGroup: Property - - @get:Input - abstract val beforeSample: Property - - @get:Input - abstract val afterSample: Property - - init { - afterGroup.set("") - beforeGroup.set("") - afterSample.set("") - beforeSample.set("") - } -} - -abstract class KorroExtension { - var docs: FileCollection? = null - var samples: FileCollection? = null - var outputs: FileCollection? = null - - internal val groups = mutableListOf() - - @Nested - abstract fun getGroupSamples(): GroupSamplesApi - - fun groupSamples(action: Action) { - val api = getGroupSamples() - action.execute(api) - val group = SamplesGroup(api.beforeGroup.get(), api.afterGroup.get(), api.beforeSample.get(), api.afterSample.get(), api.patterns) - groups.add(group) - } - - fun createContext(docs: Collection, samples: Collection, outputs: Collection) = KorroContext( - logger = LoggerLog(), - docs = docs, - samples = samples, - outputs = outputs, - groups = groups - ) -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt b/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt deleted file mode 100644 index 6d3e13b..0000000 --- a/src/main/kotlin/io/github/devcrocod/korro/KorroPlugin.kt +++ /dev/null @@ -1,21 +0,0 @@ -package io.github.devcrocod.korro - -import org.gradle.api.Plugin -import org.gradle.api.Project - -class KorroPlugin : Plugin { - override fun apply(project: Project): Unit = with(project) { - extensions.create("korro", KorroExtension::class.java) - project.afterEvaluate { - tasks.register("korro", KorroTask::class.java) { - it.description = "Runs Korro Tool" - it.group = "documentation" - } - - tasks.register("korroClean", KorroCleanTask::class.java) { - it.description = "Deletes inserted samples" - it.group = "documentation" - } - } - } -} diff --git a/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt b/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt deleted file mode 100644 index e861bf5..0000000 --- a/src/main/kotlin/io/github/devcrocod/korro/KorroTask.kt +++ /dev/null @@ -1,133 +0,0 @@ -package io.github.devcrocod.korro - -import org.gradle.api.DefaultTask -import org.gradle.api.NamedDomainObjectContainer -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.workers.WorkAction -import org.gradle.workers.WorkerExecutor -import javax.inject.Inject - -// TODO get from central version -const val dokkaVersion = "1.8.20" -private interface KorroTasksCommon { - - @get:Internal - val projectReference: Project - - @get:Internal - val nameReference: String - - @get:Classpath - val classpath: Configuration - get() = projectReference.configurations.maybeCreate("korroRuntime") { - isCanBeConsumed = true - listOf( - "org.jetbrains.dokka:dokka-analysis", - "org.jetbrains.dokka:dokka-base", - "org.jetbrains.dokka:dokka-core", - ).forEach { - dependencies += projectReference.dependencies.create("$it:$dokkaVersion") - } - } - - @get:Inject - val workerExecutor: WorkerExecutor - - @get:Internal - val ext: KorroExtension - - var docs: FileCollection - - var samples: FileCollection - - var outputs: FileCollection - - @get:Internal - val groups: List - - fun execute(clazz: Class>) { - val workQueue = workerExecutor.classLoaderIsolation { - it.classpath.setFrom(classpath.resolve()) - } - workQueue.submit(clazz) { - it.docs = docs.files - it.samples = samples.files - it.outputs = outputs.files - it.groups = groups - it.name = nameReference - } - } -} - -abstract class KorroTask : DefaultTask(), KorroTasksCommon { - - final override val ext: KorroExtension = project.extensions.getByType(KorroExtension::class.java) - - @InputFiles - override var docs: FileCollection = ext.docs ?: project.fileTree(project.rootDir) { - it.include("**/*.md") - } - - @InputFiles - override var samples: FileCollection = ext.samples ?: project.fileTree(project.rootDir) { - it.include("**/*.kt") - } - - @InputFiles - override var outputs: FileCollection = ext.outputs ?: project.files() - - @get:Internal - override val groups: List = ext.groups - - @get:Internal - override val projectReference: Project - get() = project - - @get:Internal - override val nameReference: String - get() = name - - @TaskAction - fun korro() { - execute(KorroAction::class.java) - } -} - -abstract class KorroCleanTask : Delete(), KorroTasksCommon { - final override val ext: KorroExtension = project.extensions.getByType(KorroExtension::class.java) - - @InputFiles - override var docs: FileCollection = ext.docs ?: project.fileTree(project.rootDir) { - it.include("**/*.md") - } - - @InputFiles - override var samples: FileCollection = ext.samples ?: project.fileTree(project.rootDir) { - it.include("**/*.kt") - } - - @InputFiles - override var outputs: FileCollection = ext.outputs ?: project.files() - - @get:Internal - override val groups: List = ext.groups - - @get:Internal - override val projectReference: Project - get() = project - - @get:Internal - override val nameReference: String - get() = name - - @TaskAction - fun korroClean() { - execute(KorroCleanAction::class.java) - } -} - -private fun NamedDomainObjectContainer.maybeCreate(name: String, configuration: T.() -> Unit): T = - findByName(name) ?: create(name, configuration) \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcrocod/korro/SamplesTransformer.kt b/src/main/kotlin/io/github/devcrocod/korro/SamplesTransformer.kt deleted file mode 100644 index 6579df8..0000000 --- a/src/main/kotlin/io/github/devcrocod/korro/SamplesTransformer.kt +++ /dev/null @@ -1,256 +0,0 @@ -package io.github.devcrocod.korro - -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.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 facade: DokkaResolutionFacade by lazy { setUpAnalysis() } - - private class SampleBuilder : KtTreeVisitorVoid() { - val builder = StringBuilder() - val text: String - get() = builder.toString() - - val errors = mutableListOf() - - var start: Boolean = false - - data class ConvertError(val e: Exception, val text: String, val loc: String) - - fun convertAssertPrints(expression: KtCallExpression) { - val (argument, commentArgument) = expression.valueArguments - builder.apply { - append("println(") - append(argument.text) - append(") // ") - append(commentArgument.extractStringArgumentValue()) - } - } - - fun convertAssertTrueFalse(expression: KtCallExpression, expectedResult: Boolean) { - val (argument) = expression.valueArguments - builder.apply { - expression.valueArguments.getOrNull(1)?.let { - append("// ${it.extractStringArgumentValue()}") - val ws = expression.prevLeaf { it is PsiWhiteSpace } - append(ws?.text ?: "\n") - } - append("println(\"") - append(argument.text) - append(" is \${") - append(argument.text) - append("}\") // $expectedResult") - } - } - - fun convertAssertFails(expression: KtCallExpression) { - val valueArguments = expression.valueArguments - - val funcArgument: KtValueArgument - val message: KtValueArgument? - - if (valueArguments.size == 1) { - message = null - funcArgument = valueArguments.first() - } else { - message = valueArguments.first() - funcArgument = valueArguments.last() - } - - builder.apply { - val argument = funcArgument.extractFunctionalArgumentText() - append(argument.lines().joinToString(separator = "\n") { "// $it" }) - append(" // ") - if (message != null) { - append(message.extractStringArgumentValue()) - } - append(" will fail") - } - } - - 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) { - val (funcArgument) = expression.valueArguments - val (exceptionType) = expression.typeArguments - builder.apply { - val argument = funcArgument.extractFunctionalArgumentText() - append(argument.lines().joinToString(separator = "\n") { "// $it" }) - append(" // will fail with ") - append(exceptionType.text) - } - } - - override fun visitCallExpression(expression: KtCallExpression) { - when (expression.calleeExpression?.text) { - "assertPrints" -> convertAssertPrints(expression) - "assertTrue" -> convertAssertTrueFalse(expression, expectedResult = true) - "assertFalse" -> convertAssertTrueFalse(expression, expectedResult = false) - "assertFails" -> convertAssertFails(expression) - "assertFailsWith" -> convertAssertFailsWith(expression) - else -> 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 - if (t.filterNot { it.isWhitespace() } == "//SampleEnd") start = false - if (start) builder.append(t) - if (t.filterNot { it.isWhitespace() } == "//SampleStart") start = true - } - - element.acceptChildren(object : PsiElementVisitor() { - override fun visitElement(element: PsiElement) { - try { - element.accept(this@SampleBuilder) - } catch (e: Exception) { - try { - reportProblemConvertingElement(element, e) - } finally { - builder.append(element.text) //recover - } - } - } - }) - } - - } - - 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() - 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 -}