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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,5 @@ These are contracts for every consumer's docs; breaking any of them silently bre
- **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).
- **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.
- **`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.
7 changes: 5 additions & 2 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,11 @@ safe to run from CI without mutating the repo.
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.
- **Non-function targets.** 0.2.0 narrowed `FUN`/`FUNS` to `fun` declarations only, which broke 0.1.x-style docs
pointing at an `object` / `class` / `interface` / `val`. 0.2.x restores support: `KtNamedFunction`, `KtClassOrObject`,
and `KtProperty` are all valid targets. Enum entries, type aliases, and `.kts` scripts are not; unresolved references
still produce a diagnostic. Class / object / property targets render the region between `//SampleStart` /
`//SampleEnd` markers inside their body (same marker semantics as function bodies).

All new diagnostics are collected across the whole run and reported as a single table at the end of the task.

Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,11 @@ If the function contains `//SampleStart` / `//SampleEnd` comments, only the regi
pairs are concatenated, separated by a blank line. If the function has no markers, the whole body is emitted (without
the outer `{ }`).

Only `fun` declarations (`KtNamedFunction`) are valid targets. Properties, classes, top-level expressions, and `.kts`
scripts are not. Don't wrap function names in backticks.
Valid targets are `fun` declarations, classes / objects / interfaces, and top-level or member properties
(`KtNamedFunction`, `KtClassOrObject`, `KtProperty`). For class, object, or property targets, the snippet contents come
from `//SampleStart` / `//SampleEnd` markers inside the declaration body — the wrapper name is not emitted, only the
marker-bracketed region. Enum entries, type aliases, local declarations, and `.kts` scripts are not valid targets.
Don't wrap names in backticks.

### FUNS

Expand Down
19 changes: 19 additions & 0 deletions integration-tests/fixtures/declarations/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"))
}
}
68 changes: 68 additions & 0 deletions integration-tests/fixtures/declarations/docs/expected/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Declarations example

<!---IMPORT samples-->

## Top-level object

<!---FUN Greeter-->

```kotlin
const val GREETING = "hello"

fun say() {
println(GREETING)
}
```

<!---END-->

## Top-level class

<!---FUN Counter-->

```kotlin
var value: Int = start
private set

fun increment() {
value++
}
```

<!---END-->

## Nested declaration (DataFrame pattern)

<!---FUN DbDemo-->

```kotlin
object HSQLDB {
val driver: String = "org.hsqldb.jdbcDriver"
}
```

<!---END-->

## Top-level property

<!---FUN slogan-->

```kotlin
return "declarations are first-class"
```

<!---END-->

## Glob over class-kind declarations

<!---FUNS Version?-->

```kotlin
const val NAME = "one"
```

```kotlin
const val NAME = "two"
```

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

<!---IMPORT samples-->

## Top-level object

<!---FUN Greeter-->
<!---END-->

## Top-level class

<!---FUN Counter-->
<!---END-->

## Nested declaration (DataFrame pattern)

<!---FUN DbDemo-->
<!---END-->

## Top-level property

<!---FUN slogan-->
<!---END-->

## Glob over class-kind declarations

<!---FUNS Version?-->
<!---END-->
49 changes: 49 additions & 0 deletions integration-tests/fixtures/declarations/samples/Example.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package samples

object Greeter {
//SampleStart
const val GREETING = "hello"

fun say() {
println(GREETING)
}
//SampleEnd
}

class Counter(val start: Int) {
//SampleStart
var value: Int = start
private set

fun increment() {
value++
}
//SampleEnd
}

interface DbDemo {
//SampleStart
object HSQLDB {
val driver: String = "org.hsqldb.jdbcDriver"
}
//SampleEnd
}

val slogan: String
get() {
//SampleStart
return "declarations are first-class"
//SampleEnd
}

object Version1 {
//SampleStart
const val NAME = "one"
//SampleEnd
}

object Version2 {
//SampleStart
const val NAME = "two"
//SampleEnd
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = "korro-declarations-fixture"
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ class KorroIntegrationTest {
)
}

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

@Test
fun commonTestFixture(@TempDir tempDir: Path) {
runFixture(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
package io.github.devcrocod.korro.analysis

import org.jetbrains.kotlin.psi.KtClassOrObject
import org.jetbrains.kotlin.psi.KtDeclaration
import org.jetbrains.kotlin.psi.KtEnumEntry
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtNamedDeclaration
import org.jetbrains.kotlin.psi.KtNamedFunction
import org.jetbrains.kotlin.psi.KtProperty

class FqnResolver(session: KorroAnalysisSession) {
private val byFqn: Map<String, KtNamedFunction>
private val byShortName: Map<String, List<KtNamedFunction>>
private val ordered: List<Pair<String, KtNamedFunction>>
private val byFqn: Map<String, KtNamedDeclaration>
private val byShortName: Map<String, List<KtNamedDeclaration>>
private val ordered: List<Pair<String, KtNamedDeclaration>>

init {
val fqn = linkedMapOf<String, KtNamedFunction>()
val shortName = linkedMapOf<String, MutableList<KtNamedFunction>>()
val orderedList = mutableListOf<Pair<String, KtNamedFunction>>()
val fqn = linkedMapOf<String, KtNamedDeclaration>()
val shortName = linkedMapOf<String, MutableList<KtNamedDeclaration>>()
val orderedList = mutableListOf<Pair<String, KtNamedDeclaration>>()
val files = session.files.sortedBy { it.virtualFilePath }
files.forEach { file -> collectFunctions(file, fqn, shortName, orderedList) }
files.forEach { file -> collectDeclarations(file, fqn, shortName, orderedList) }
byFqn = fqn
byShortName = shortName
ordered = orderedList
}

fun resolve(candidateFqn: String): KtNamedFunction? {
fun resolve(candidateFqn: String): KtNamedDeclaration? {
byFqn[candidateFqn]?.let { return it }
if ('.' !in candidateFqn) {
byShortName[candidateFqn]?.singleOrNull()?.let { return it }
Expand All @@ -29,19 +33,31 @@ class FqnResolver(session: KorroAnalysisSession) {
}

/**
* Return every function whose FQN matches `prefix + pattern` for some prefix in [prefixes].
* Deduplicates across prefixes (a function reached via several prefixes appears once),
* 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.
*/
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() }
}

/**
* Return every declaration whose FQN matches `prefix + pattern` for some prefix in [prefixes].
* Deduplicates across prefixes (a declaration 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<String>): List<KtNamedFunction> {
fun matchGlob(pattern: String, prefixes: List<String>): List<KtNamedDeclaration> {
val regexes = prefixes.map { compileGlob(it + pattern) }
val seen = mutableSetOf<KtNamedFunction>()
val result = mutableListOf<KtNamedFunction>()
val seen = mutableSetOf<KtNamedDeclaration>()
val result = mutableListOf<KtNamedDeclaration>()
for (regex in regexes) {
for ((fqn, fn) in ordered) {
if (regex.matches(fqn) && seen.add(fn)) {
result += fn
for ((fqn, decl) in ordered) {
if (regex.matches(fqn) && seen.add(decl)) {
result += decl
}
}
}
Expand All @@ -62,25 +78,32 @@ class FqnResolver(session: KorroAnalysisSession) {
.toList()
}

private fun collectFunctions(
private fun collectDeclarations(
file: KtFile,
fqn: MutableMap<String, KtNamedFunction>,
shortName: MutableMap<String, MutableList<KtNamedFunction>>,
ordered: MutableList<Pair<String, KtNamedFunction>>,
fqn: MutableMap<String, KtNamedDeclaration>,
shortName: MutableMap<String, MutableList<KtNamedDeclaration>>,
ordered: MutableList<Pair<String, KtNamedDeclaration>>,
) {
fun visit(declarations: List<org.jetbrains.kotlin.psi.KtDeclaration>) {
fun index(decl: KtNamedDeclaration) {
val fqnString = decl.fqName?.asString()
if (fqnString != null) {
fqn[fqnString] = decl
ordered += fqnString to decl
}
decl.name?.let { shortName.getOrPut(it) { mutableListOf() }.add(decl) }
}

fun visit(declarations: List<KtDeclaration>) {
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 KtEnumEntry -> {}
is KtNamedFunction -> index(decl)
is KtProperty -> index(decl)
is KtClassOrObject -> {
index(decl)
visit(decl.declarations)
}

is KtClassOrObject -> visit(decl.declarations)
else -> {}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import org.jetbrains.kotlin.psi.psiUtil.prevLeaf

class SampleExtractor(private val rewriteAsserts: Boolean) {

fun extract(function: KtNamedFunction): String {
val body = processBody(function)
fun extract(decl: KtNamedDeclaration): String {
val body = processBody(decl)
return createSampleBody(body)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,23 @@ class SamplesTransformer(
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)
operator fun invoke(name: String): String? {
val decl = resolver.resolve(name) ?: return null
return extractor.extract(decl)
}

fun matchGlob(globPattern: String, imports: List<String>): List<RenderedSample> {
val matches = resolver.matchGlob(globPattern, imports)
return matches.map { fn ->
val fqn = fn.fqName?.asString() ?: fn.name ?: "<anonymous>"
RenderedSample(fqn, extractor.extract(fn))
return matches.map { decl ->
val fqn = decl.fqName?.asString() ?: decl.name ?: "<anonymous>"
RenderedSample(fqn, extractor.extract(decl))
}
}

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

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

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