Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Analysis code is pulled in at task-execution time: `KorroPlugin` creates a detac
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.
- FQN resolution is two-tier: `byFqn` (exact full-FQN map) is the primary path, used for every `IMPORT`-qualified candidate and for any `FUN`/`FUNS` value that already contains a `.`. `byShortName.singleOrNull()` is the bare-name fallback and fires only when no `IMPORT` directives are in effect — IMPORT is authoritative when present, so a bare `FUN` under `IMPORT` will *not* slide over to a same-named declaration in an unrelated package.

### Worker boundary

Expand All @@ -62,6 +62,6 @@ These are contracts for every consumer's docs; breaking any of them silently bre
- **Directives start at column 0 after `String.trim()`.** `parseDirective` returns `null` otherwise.
- **Three dashes to open, two to close.** `<!---NAME VALUE-->` 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).
- **First `IMPORT` wins** when several IMPORT prefixes resolve the same short name (`firstNotNullOfOrNull` over the `imports` list). The `imports` list holds *only* explicit IMPORT prefixes — do not re-introduce an implicit empty seed, since that lets the resolver's bare-name uniqueness fallback hijack IMPORT-scoped lookups.
- **`KtNamedFunction`, `KtClassOrObject`, and `KtProperty`** are valid `FUN`/`FUNS` targets. Enum entries, type aliases, local declarations, and `.kts` scripts are not; resolving to a non-target produces a diagnostic, not a silent empty snippet. Class/object/property targets rely on `//SampleStart` / `//SampleEnd` markers inside their body for non-empty output.
- **`behavior.ignoreMissing=false` is the strict-by-default contract.** Don't silently lower severity on unresolved references without an explicit opt-in.
19 changes: 19 additions & 0 deletions integration-tests/fixtures/importScoping/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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 = layout.projectDirectory.dir("docs/in")
}
samples {
from(fileTree("samples"))
}
}
11 changes: 11 additions & 0 deletions integration-tests/fixtures/importScoping/docs/in/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Mismatched IMPORT

The IMPORT below points at `samples.concepts.StringApi`, but `sharedName` is defined in
`samples.api.Access`. Korro must NOT silently pick up `Access.sharedName` just because its short
name is unique in the sample tree — that would make IMPORT a no-op whenever the bare name happens
to be unambiguous. Expected: strict-mode failure with a diagnostic pointing at the real location.

<!---IMPORT samples.concepts.StringApi-->

<!---FUN sharedName-->
<!---END-->
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package samples.api

class Access {
fun sharedName() {
//SampleStart
println("from Access.sharedName")
//SampleEnd
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package samples.concepts

class StringApi {
fun describe() {
//SampleStart
println("StringApi has its own samples but no 'sharedName'")
//SampleEnd
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = "korro-import-scoping-fixture"
20 changes: 20 additions & 0 deletions integration-tests/fixtures/samplesOutputs/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
plugins {
id("io.github.devcrocod.korro")
}

repositories {
mavenLocal()
mavenCentral()
maven("https://cache-redirector.jetbrains.com/intellij-dependencies")
}

korro {
docs {
from(fileTree("docs/in"))
baseDir = layout.projectDirectory.dir("docs/in")
}
samples {
from(fileTree("samples"))
outputs.from(fileTree("outputs"))
}
}
27 changes: 27 additions & 0 deletions integration-tests/fixtures/samplesOutputs/docs/expected/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Outputs

Short name + IMPORT resolves the output file by FQN.

<!---IMPORT samples-->

<!---FUN shortNameFun-->

```kotlin
println("from short name")
```

<!-- appended-output for samples.shortNameFun -->

<!---END-->

Explicit FQN also resolves the output file by FQN.

<!---FUN samples.fqnTargetFun-->

```kotlin
println("from fqn")
```

<!-- appended-output for samples.fqnTargetFun -->

<!---END-->
13 changes: 13 additions & 0 deletions integration-tests/fixtures/samplesOutputs/docs/in/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Outputs

Short name + IMPORT resolves the output file by FQN.

<!---IMPORT samples-->

<!---FUN shortNameFun-->
<!---END-->

Explicit FQN also resolves the output file by FQN.

<!---FUN samples.fqnTargetFun-->
<!---END-->
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

<!-- appended-output for samples.fqnTargetFun -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

<!-- appended-output for samples.shortNameFun -->
13 changes: 13 additions & 0 deletions integration-tests/fixtures/samplesOutputs/samples/Example.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package samples

fun shortNameFun() {
//SampleStart
println("from short name")
//SampleEnd
}

fun fqnTargetFun() {
//SampleStart
println("from fqn")
//SampleEnd
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = "korro-samples-outputs-fixture"
19 changes: 19 additions & 0 deletions integration-tests/fixtures/unknownDirectives/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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 = layout.projectDirectory.dir("docs/in")
}
samples {
from(fileTree("samples"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Unknown directive-like lines

<!---IMPORT samples-->

The following directive-shaped lines are NOT Korro directives and must pass through:

<!---TODO-->
<!---TOC -->
<!---FIXME refactor later-->
<!--- this is a generic comment -->
<!---NOTE not a korro directive-->

A three-dash close also passes through because the inner content is not a valid name:

<!---TODO--->

An unclosed opener — even one that names a real directive — must NOT throw; it stays as plain text:

<!---FUN missing close marker

A real directive still expands normally:

<!---FUN example-->

```kotlin
println("hello")
```

<!---END-->
24 changes: 24 additions & 0 deletions integration-tests/fixtures/unknownDirectives/docs/in/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Unknown directive-like lines

<!---IMPORT samples-->

The following directive-shaped lines are NOT Korro directives and must pass through:

<!---TODO-->
<!---TOC -->
<!---FIXME refactor later-->
<!--- this is a generic comment -->
<!---NOTE not a korro directive-->

A three-dash close also passes through because the inner content is not a valid name:

<!---TODO--->

An unclosed opener — even one that names a real directive — must NOT throw; it stays as plain text:

<!---FUN missing close marker

A real directive still expands normally:

<!---FUN example-->
<!---END-->
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package samples

fun example() {
//SampleStart
println("hello")
//SampleEnd
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = "korro-unknownDirectives-fixture"
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,26 @@ class KorroIntegrationTest {
)
}

@Test
fun samplesOutputsFixture(@TempDir tempDir: Path) {
runFixture(
name = "samplesOutputs",
tempDir = tempDir,
generatedRelativePath = "build/korro/docs/readme.md",
expectedRelativePath = "samplesOutputs/docs/expected/readme.md",
)
}

@Test
fun unknownDirectivesPassThrough(@TempDir tempDir: Path) {
runFixture(
name = "unknownDirectives",
tempDir = tempDir,
generatedRelativePath = "build/korro/docs/readme.md",
expectedRelativePath = "unknownDirectives/docs/expected/readme.md",
)
}

@Test
fun strictModeFailsOnMissing(@TempDir tempDir: Path) {
val fixture = loadFixture("strictErrors", tempDir)
Expand Down Expand Up @@ -95,6 +115,39 @@ class KorroIntegrationTest {
}
}

@Test
fun strictModeFailsWhenFunIsOutOfImportScope(@TempDir tempDir: Path) {
val fixture = loadFixture("importScoping", 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 when the FUN target lives outside every IMPORT prefix",
)
val output = result.output
assertTrue(output.contains("sharedName")) {
"Expected failure output to name the unresolved directive 'sharedName'; got:\n$output"
}
assertTrue(output.contains("samples.api.Access.sharedName")) {
"Expected diagnostic to point at the real FQN samples.api.Access.sharedName; got:\n$output"
}
assertTrue(output.contains("under current IMPORT")) {
"Expected diagnostic to flag the IMPORT scope mismatch; got:\n$output"
}
}

@Test
fun ignoreMissingPreservesSource(@TempDir tempDir: Path) {
runFixture(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,14 @@ class FqnResolver(session: KorroAnalysisSession) {
}

/**
* FQNs of every declaration sharing [bareName] when the short name is ambiguous,
* or `null` when the name is unambiguous, qualified (contains a dot), or unknown.
* Used by callers to distinguish "not found" from "multiple matches" in diagnostics.
* Every FQN whose declaration shares [bareName] as its short name, in encounter order.
* Empty for qualified names (containing `.`) or when the short name is unknown.
* Callers use this to distinguish "not found", "ambiguous", and "found-but-out-of-import-scope"
* in diagnostics.
*/
fun ambiguous(bareName: String): List<String>? {
if ('.' in bareName) return null
val candidates = byShortName[bareName] ?: return null
if (candidates.size < 2) return null
return candidates.mapNotNull { it.fqName?.asString() }
fun matchesByShortName(bareName: String): List<String> {
if ('.' in bareName) return emptyList()
return byShortName[bareName].orEmpty().mapNotNull { it.fqName?.asString() }
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ class SamplesTransformer(
private val resolver = FqnResolver(session)
private val extractor = SampleExtractor(rewriteAsserts)

operator fun invoke(name: String): String? {
operator fun invoke(name: String): RenderedSample? {
val decl = resolver.resolve(name) ?: return null
return extractor.extract(decl)
val fqn = decl.fqName?.asString() ?: decl.name ?: return null
return RenderedSample(fqn, extractor.extract(decl))
}

fun matchGlob(globPattern: String, imports: List<String>): List<RenderedSample> {
Expand All @@ -27,7 +28,7 @@ class SamplesTransformer(

fun suggestions(bareName: String): List<String> = resolver.suggestShortNames(bareName)

fun ambiguous(bareName: String): List<String>? = resolver.ambiguous(bareName)
fun matchesByShortName(bareName: String): List<String> = resolver.matchesByShortName(bareName)

override fun close() {
session.close()
Expand Down
Loading