Skip to content

KDocExtractor.kt

src/jvmMain/kotlin/afield/tools/docs/index/KDocExtractor.kt · 319 lines

package afield.tools.docs.index

import afield.ai.conversation.*
import afield.ai.models.*
import afield.graphics.render.*
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.exists
import kotlin.io.path.isRegularFile
import kotlin.io.path.readText

Reads the KDoc comments out of a Kotlin source file and attaches each one to the declaration below it.

KDocExtractor.extractKDocs returns one KDocComment per block, with its line span and the name and kind of the declaration it documents. The completion doc panel, KotlinSourceIndex and the generated reference pages all read summaries through it. A block attaches to the first following line that parseDeclaration recognises; KDocAttachmentGuard reports blocks that would attach to the wrong declaration.

data class KDocComment

Data class representing a KDoc comment found in source code

data class KDocComment(
    val text: String,
    val startLine: Int,
    val endLine: Int,
    val declarationLine: Int,
    val declarationName: String?,
    val declarationType: String? // "class", "fun", "val", "var"
)

object KDocExtractor

Utility object for extracting KDoc comments from Kotlin source files

object KDocExtractor {

    private val modifierTokens = setOf(
        "public", "private", "internal", "protected",
        "open", "final", "abstract", "sealed", "data", "enum", "annotation", "value",
        "inline", "suspend", "tailrec", "operator", "infix", "external", "override",
        "const", "lateinit", "companion", "inner",
        // `actual`/`expect` are ordinary modifiers to Kotlin but were missing here, so every
        // documented `actual fun` in a platform source set had its KDoc slid onto the next
        // declaration (caught by afield.tools.docs.KDocAttachmentGuard).
        "actual", "expect"
    )

fun extractKDocs(sourceText: String): List<KDocComment>

Extract all KDoc comments from a Kotlin source file

body — 117 lines, from line 54
    fun extractKDocs(sourceText: String): List<KDocComment> {
        val result = mutableListOf<KDocComment>()
        val lines = sourceText.split("\n")
        var i = 0

        while (i < lines.size) {
            val line = lines[i]
            val trimmed = line.trimStart()

            // Check for KDoc start
            if (trimmed.startsWith("/**")) {
                val kdocStartLine = i + 1 // 1-indexed
                val kdocLines = mutableListOf<String>()
                var kdocEndLine = i
                var foundEnd = false

                // Collect KDoc lines
                var j = i
                while (j < lines.size) {
                    var kdocLine = lines[j].trimStart()
                    if (j == i) {
                        if (kdocLine == "/**") {
                            // First line with only /**
                            j++
                            continue
                        }
                        // A one-liner (`/** text */`) or a block whose first line already carries
                        // text: strip the opening marker here, or it survives into the extracted
                        // comment and is rendered literally on completion cards and in the
                        // generated codebase reference.
                        kdocLine = kdocLine.removePrefix("/**").trimStart()
                    }

                    if (kdocLine.contains("*/")) {
                        // End of KDoc
                        val beforeEnd = kdocLine.substringBefore("*/").trimStart()
                        if (beforeEnd.startsWith("*")) {
                            val content = beforeEnd.substring(1).trimStart()
                            if (content.isNotEmpty()) {
                                kdocLines.add(content)
                            }
                        } else if (beforeEnd.isNotEmpty()) {
                            kdocLines.add(beforeEnd)
                        }
                        kdocEndLine = j + 1 // 1-indexed
                        foundEnd = true
                        j++
                        break
                    }

                    // Process KDoc content line
                    if (kdocLine.startsWith("*")) {
                        val content = kdocLine.substring(1).trimStart()
                        kdocLines.add(content)
                    } else {
                        kdocLines.add(kdocLine)
                    }
                    j++
                }

                if (foundEnd) {
                    // Look for the declaration after the KDoc
                    var declLine = j
                    var declaration: Pair<String, String>? = null

                    // Skip blank lines and annotations
                    while (declLine < lines.size) {
                        val declLineText = lines[declLine].trimStart()
                        if (declLineText.isEmpty()) {
                            declLine++
                            continue
                        }
                        if (declLineText.startsWith("@")) {
                            declLine++
                            continue
                        }
                        // A second KDoc block before any declaration means this block documents
                        // nothing in particular — a file-level overview, most often. Stop here, or
                        // this block would be attached to the *next* declaration and, worse, the
                        // outer loop would resume past that declaration's own KDoc and drop it.
                        if (declLineText.startsWith("/**")) {
                            break
                        }

                        // Try to parse declaration
                        declaration = parseDeclaration(declLineText)
                        if (declaration != null) {
                            break
                        }
                        declLine++
                    }

                    if (declaration != null) {
                        val kdocText = kdocLines.joinToString("\n")
                        result.add(
                            KDocComment(
                                text = kdocText,
                                startLine = kdocStartLine,
                                endLine = kdocEndLine,
                                declarationLine = declLine + 1, // 1-indexed
                                declarationName = declaration.second,
                                declarationType = declaration.first
                            )
                        )
                    }

                    i = declLine
                } else {
                    i = j
                }
            } else {
                i++
            }
        }

        return result
    }

internal fun parseDeclaration(line: String): Pair<String, String>?

The declaration a single source line declares, as kind to name, or null if this line is not a declaration this parser can see.

internal rather than private because it is the definition of which KDoc blocks attach: extractKDocs walks forward from a comment until this returns non-null, so a line it cannot read is silently skipped and the comment slides onto whatever parses next. afield.tools.docs.KDocAttachmentGuard calls it directly to find those sites before they publish a wrong caption, and must therefore ask exactly the same question this scan asks.

body — 70 lines, from line 182
    internal fun parseDeclaration(line: String): Pair<String, String>? {
        var s = line.trimStart()
        if (s.isEmpty()) return null
        if (s.startsWith("package ") || s.startsWith("import ")) return null
        if (s.startsWith("//")) return null

        // Skip annotations
        val annotationRegex = Regex("^@\\w[\\w\\.]*\\s*(\\([^)]*\\))?\\s*")
        while (true) {
            val match = annotationRegex.find(s)
            if (match != null && match.range.first == 0) {
                s = s.substring(match.range.last + 1).trimStart()
            } else {
                break
            }
        }

        // Skip modifiers
        val tokens = s.split(Regex("\\s+"))
        var idx = 0
        while (idx < tokens.size && modifierTokens.contains(tokens[idx])) {
            idx++
        }
        if (idx >= tokens.size) return null

        // `constructor(` carries its parameter list with no space, so the keyword has to be cut at the
        // first `(`; no declaration keyword contains one, and a line that is really a call still fails
        // the whitelist below.
        var keyword = tokens[idx].substringBefore('(')
        var remainder = s.substringAfter(keyword, "").trimStart()

        // `fun interface Foo` declares a SAM interface, not a function. Treating it as unparseable
        // dropped the declaration *and* slid its KDoc onto whatever parsed next.
        if (keyword == "fun" && FUN_INTERFACE.containsMatchIn(remainder)) {
            keyword = "interface"
            remainder = remainder.removePrefix("interface").trimStart()
        }

        val name = when (keyword) {
            "fun" -> {
                val head = remainder.substringBefore("(").trim()
                parseQualifiedName(head)
            }
            "class", "interface", "object" -> {
                Regex("^(`[^`]+`|[A-Za-z_][A-Za-z0-9_]*)")
                    .find(remainder)?.groupValues?.get(1)?.let { unwrapIdentifier(it) }
            }
            // A `typealias` is a declaration the reference emits (it is in the generator's TYPE_KINDS),
            // so failing to parse it here dropped its KDoc *and* slid the prose onto the next symbol —
            // which is what `linalg.kt`'s Vec2/Vec3/Vec4/Quat aliases were doing until 2026-07-28.
            "typealias" -> parseQualifiedName(remainder.substringBefore("=").trim())
            "val", "var" -> {
                val head = propertyNameHead(remainder)
                parseQualifiedName(head)
            }
            // A secondary constructor carries no name of its own on its line. It is recognised anyway,
            // as itself, so that a KDoc written above one *stops* here instead of sliding onto the next
            // real declaration; no consumer looks up a symbol called `constructor`.
            "constructor" -> "constructor"
            else -> null
        } ?: return null

        if (name.isBlank()) return null

        return when (keyword) {
            "class", "interface", "object", "fun", "val", "var", "typealias", "constructor" ->
                keyword to name
            else -> null
        }
    }

fun followed by the interface keyword — a SAM declaration, not a function.

    private val FUN_INTERFACE = Regex("""^interface\b""")

The name part of a property declaration's text, with any delegate or initialiser removed.

by is cut before : because a delegated property need have no type annotation at all (var FLine.color by Delegation<Vector4f> { vec(1, 1, 1, 1) }), in which case there is no : to stop at and the whole delegate expression would otherwise be taken for the name — which fails to parse, dropping the declaration and sliding its KDoc onto the next one.

get( is cut for the same reason as : and =: an accessor that follows the name directly, with no explicit type between them, otherwise stays in the head and takes the parentheses with it — val n get()=x reduces to nget(), which is not an identifier, so the declaration is unparseable and its KDoc slides onto whatever parses next. A type is what normally saves this (val n: T get()=x is already cut at the colon), so the gap is invisible until someone writes a documented accessor without one. DlssReconstruction.sdkAllocatedBytes was the first.

source — 49 lines, from line 270
    private fun propertyNameHead(remainder: String): String =
        remainder.substringBefore(" by ").substringBefore(":").substringBefore(" get(")
            .substringBefore("=").trim()

    private fun parseQualifiedName(raw: String): String? {
        if (raw.isBlank()) return null
        val noGenerics = stripGenericArgs(raw)
        val compact = removeWhitespaceOutsideBackticks(noGenerics).replace("?", "")
        if (compact.isBlank()) return null
        val regex = Regex("^(`[^`]+`|[A-Za-z_][A-Za-z0-9_]*)(\\.`[^`]+`|\\.[A-Za-z_][A-Za-z0-9_]*)*$")
        val match = regex.find(compact) ?: return null
        return match.value.split(".").joinToString(".") { unwrapIdentifier(it) }
    }

    private fun unwrapIdentifier(raw: String): String =
        if (raw.startsWith("`") && raw.endsWith("`") && raw.length > 1) {
            raw.removePrefix("`").removeSuffix("`")
        } else {
            raw
        }

    private fun stripGenericArgs(raw: String): String {
        val sb = StringBuilder(raw.length)
        var depth = 0
        for (ch in raw) {
            when (ch) {
                '<' -> depth++
                '>' -> if (depth > 0) depth--
                else -> if (depth == 0) sb.append(ch)
            }
        }
        return sb.toString()
    }

    private fun removeWhitespaceOutsideBackticks(raw: String): String {
        val sb = StringBuilder(raw.length)
        var inBackticks = false
        for (ch in raw) {
            if (ch == '`') {
                inBackticks = !inBackticks
                sb.append(ch)
                continue
            }
            if (!inBackticks && ch.isWhitespace()) continue
            sb.append(ch)
        }
        return sb.toString()
    }
}