diff --git a/CLAUDE.md b/CLAUDE.md index 7a51664..e7b92c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.** `` 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. diff --git a/MIGRATION.md b/MIGRATION.md index c1a55ea..11d7c43 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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. diff --git a/README.md b/README.md index 325b4e8..35aa673 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/integration-tests/fixtures/declarations/build.gradle.kts b/integration-tests/fixtures/declarations/build.gradle.kts new file mode 100644 index 0000000..8c7e1d6 --- /dev/null +++ b/integration-tests/fixtures/declarations/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 = layout.projectDirectory.dir("docs/in") + } + samples { + from(fileTree("samples")) + } +} diff --git a/integration-tests/fixtures/declarations/docs/expected/readme.md b/integration-tests/fixtures/declarations/docs/expected/readme.md new file mode 100644 index 0000000..3823ae0 --- /dev/null +++ b/integration-tests/fixtures/declarations/docs/expected/readme.md @@ -0,0 +1,68 @@ +# Declarations example + + + +## Top-level object + + + +```kotlin +const val GREETING = "hello" + +fun say() { + println(GREETING) +} +``` + + + +## Top-level class + + + +```kotlin +var value: Int = start + private set + +fun increment() { + value++ +} +``` + + + +## Nested declaration (DataFrame pattern) + + + +```kotlin +object HSQLDB { + val driver: String = "org.hsqldb.jdbcDriver" +} +``` + + + +## Top-level property + + + +```kotlin +return "declarations are first-class" +``` + + + +## Glob over class-kind declarations + + + +```kotlin +const val NAME = "one" +``` + +```kotlin +const val NAME = "two" +``` + + diff --git a/integration-tests/fixtures/declarations/docs/in/readme.md b/integration-tests/fixtures/declarations/docs/in/readme.md new file mode 100644 index 0000000..f12a5c6 --- /dev/null +++ b/integration-tests/fixtures/declarations/docs/in/readme.md @@ -0,0 +1,28 @@ +# Declarations example + + + +## Top-level object + + + + +## Top-level class + + + + +## Nested declaration (DataFrame pattern) + + + + +## Top-level property + + + + +## Glob over class-kind declarations + + + diff --git a/integration-tests/fixtures/declarations/samples/Example.kt b/integration-tests/fixtures/declarations/samples/Example.kt new file mode 100644 index 0000000..db726aa --- /dev/null +++ b/integration-tests/fixtures/declarations/samples/Example.kt @@ -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 +} diff --git a/integration-tests/fixtures/declarations/settings.gradle.kts b/integration-tests/fixtures/declarations/settings.gradle.kts new file mode 100644 index 0000000..321cb5f --- /dev/null +++ b/integration-tests/fixtures/declarations/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "korro-declarations-fixture" diff --git a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt index 4860bc4..ea9da0a 100644 --- a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt +++ b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt @@ -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( diff --git a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt index 5f765c6..5d63c89 100644 --- a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt @@ -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 - private val byShortName: Map> - private val ordered: List> + private val byFqn: Map + private val byShortName: Map> + private val ordered: List> init { - val fqn = linkedMapOf() - val shortName = linkedMapOf>() - val orderedList = mutableListOf>() + val fqn = linkedMapOf() + val shortName = linkedMapOf>() + val orderedList = mutableListOf>() 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 } @@ -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? { + 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): List { + fun matchGlob(pattern: String, prefixes: List): List { val regexes = prefixes.map { compileGlob(it + pattern) } - val seen = mutableSetOf() - val result = mutableListOf() + val seen = mutableSetOf() + val result = mutableListOf() 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 } } } @@ -62,25 +78,32 @@ class FqnResolver(session: KorroAnalysisSession) { .toList() } - private fun collectFunctions( + private fun collectDeclarations( file: KtFile, - fqn: MutableMap, - shortName: MutableMap>, - ordered: MutableList>, + fqn: MutableMap, + shortName: MutableMap>, + ordered: MutableList>, ) { - fun visit(declarations: List) { + 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) { 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 -> {} } } diff --git a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt index 6a2bada..af26dd8 100644 --- a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SampleExtractor.kt @@ -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) } diff --git a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt index 5c54c12..343fa20 100644 --- a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt @@ -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): List { val matches = resolver.matchGlob(globPattern, imports) - return matches.map { fn -> - val fqn = fn.fqName?.asString() ?: fn.name ?: "" - RenderedSample(fqn, extractor.extract(fn)) + return matches.map { decl -> + val fqn = decl.fqName?.asString() ?: decl.name ?: "" + RenderedSample(fqn, extractor.extract(decl)) } } fun suggestions(bareName: String): List = resolver.suggestShortNames(bareName) + fun ambiguous(bareName: String): List? = resolver.ambiguous(bareName) + override fun close() { session.close() } diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt index 4d2309b..15fdbe0 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt @@ -79,9 +79,16 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { 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) + val ambiguous = samplesTransformer.ambiguous(funName) + val (message, hint) = if (ambiguous != null) { + "Ambiguous FUN '$funName'" to + "candidates: ${ambiguous.joinToString(", ")}; qualify with IMPORT" + } else { + val suggestions = samplesTransformer.suggestions(funName).takeIf { it.isNotEmpty() } + ?.joinToString(prefix = "did you mean: ", separator = ", ") + "Cannot resolve FUN '$funName'" to suggestions + } + reportMissing(directiveLine, message, hint) lines.addAll(oldSampleLines) return } @@ -125,7 +132,7 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { fun processFuns(glob: String, oldSampleLines: List, directiveLine: Int) { val newSamplesLines = renderFunsBody(glob) if (newSamplesLines == null) { - reportMissing(directiveLine, "FUNS '$glob' matched no functions") + reportMissing(directiveLine, "FUNS '$glob' matched no declarations") lines.addAll(oldSampleLines) return }