Skip to content

WorkspaceSymbolSearchSupport.kt

src/jvmMain/kotlin/afield/ai/tools/WorkspaceSymbolSearchSupport.kt · 374 lines

package afield.ai.tools

import afield.ai.conversation.*
import afield.ai.models.*
import afield.ai.retrieval.RelatedContextDiscovery
import afield.ai.retrieval.RelatedContextHints
import afield.graphics.render.*
import afield.runtime.completion.KotlinDefinitionRecord
import afield.runtime.completion.KotlinSourceIndexDb
import afield.ai.tools.WorkspaceSymbolMatch
import afield.ai.tools.WorkspaceSymbolSearchResult
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Paths

private data class RankedWorkspaceSymbolMatch(
    val definition: KotlinDefinitionRecord,
    val score: Int,
    val definitionIndex: Int
)

private data class WorkspaceQueryAnalysis(
    val normalizedQueries: List<String>,
    val significantTokens: List<String>,
    val preferredKinds: Set<String>
)

private val workspaceSearchNoiseTokens = setOf(
    "class", "interface", "object", "fun", "val", "var",
    "public", "private", "protected", "internal",
    "open", "final", "abstract", "sealed", "data", "enum",
    "annotation", "value", "inline", "suspend", "tailrec",
    "operator", "infix", "external", "override", "const",
    "lateinit", "companion", "inner"
)

fun searchWorkspaceSymbols

body — 148 lines, from line 37
fun searchWorkspaceSymbols(
    index: KotlinSourceIndexDb?,
    rawQuery: String,
    limit: Int,
    includeRelatedContext: Boolean = false
): WorkspaceSymbolSearchResult {
    val query = rawQuery.trim()
    if (query.isEmpty()) return WorkspaceSymbolSearchResult(emptyList())

    val definitions = index?.definitions.orEmpty()
    if (definitions.isEmpty()) return WorkspaceSymbolSearchResult(emptyList())

    val analysis = analyzeWorkspaceSymbolQuery(query)
    if (analysis.normalizedQueries.isEmpty()) return WorkspaceSymbolSearchResult(emptyList())

    val fileCache = HashMap<String, List<String>>()
    fun fileLines(path: String): List<String>? {
        return fileCache.getOrPut(path) {
            val direct = Paths.get(path)
            val resolved = if (direct.isAbsolute) direct else Paths.get(index!!.root).resolve(path)
            if (!Files.exists(resolved)) return@getOrPut emptyList()
            runCatching { Files.readAllLines(resolved, StandardCharsets.UTF_8) }.getOrDefault(emptyList())
        }.takeIf { it.isNotEmpty() }
    }

    fun definitionLineText(def: KotlinDefinitionRecord): String? {
        val lines = fileLines(def.filePath) ?: return null
        val idx = def.line - 1
        return lines.getOrNull(idx)?.trimEnd()
    }

    fun functionBody(def: KotlinDefinitionRecord, maxChars: Int = 6000): String? {
        if (def.kind == "val" || def.kind == "var") {
            val lines = fileLines(def.filePath) ?: return null
            val start = def.line.coerceAtMost(lines.size)
            val end = definitions.asSequence().filter { it.filePath == def.filePath && it.line > def.line }
                .minOfOrNull { it.line - 1 } ?: lines.size
            // Include property initializers/accessors, but never the next declaration's KDoc.
            return lines.subList((start - 1).coerceAtLeast(0), end.coerceIn(start, lines.size))
                .joinToString("\n").substringBefore("/**").trimEnd().take(maxChars)
        }
        if (def.kind != "fun") return null
        val lines = fileLines(def.filePath) ?: return null
        val startIdx = (def.line - 1).coerceIn(0, lines.lastIndex)
        var depth = 0
        var found = false
        val sb = StringBuilder()
        var chars = 0
        for (i in startIdx..lines.lastIndex) {
            val line = lines[i]
            var j = 0
            while (j < line.length) {
                val ch = line[j]
                if (!found) {
                    if (ch == '{') {
                        found = true
                        depth = 1
                        sb.append(ch)
                    }
                } else {
                    sb.append(ch)
                    if (ch == '{') depth++
                    if (ch == '}') {
                        depth--
                        if (depth == 0) return sb.toString().trimEnd()
                    }
                }
                if (found) {
                    chars++
                    if (chars >= maxChars) return sb.toString().trimEnd()
                }
                j++
            }
            if (found) {
                sb.append('\n')
                chars++
                if (chars >= maxChars) return sb.toString().trimEnd()
            }
        }
        return if (found) sb.toString().trimEnd() else null
    }

    fun toMatch(def: KotlinDefinitionRecord): WorkspaceSymbolMatch {
        val location = if (!def.fileId.isNullOrBlank()) {
            "${def.filePath}:${def.line} (fileId=${def.fileId})"
        } else {
            "${def.filePath}:${def.line}"
        }
        val related = if (includeRelatedContext) RelatedContextDiscovery.forPath(def.filePath) else RelatedContextHints()
        return WorkspaceSymbolMatch(
            symbol = def.name,
            kind = def.kind,
            locationHint = location,
            filePath = def.filePath,
            fileId = def.fileId,
            line = def.line,
            definitionLine = definitionLineText(def),
            definitionBody = functionBody(def, maxChars = 3_000),
            relatedGuides = related.guides,
            relatedExamples = related.examples,
            kdoc = def.kdoc
        )
    }

    val unindexed = unindexedFilesMentioning(index!!, query.substringAfterLast('.'))
    fun result(matches: List<WorkspaceSymbolMatch>) = WorkspaceSymbolSearchResult(matches, unindexed,
        if (unindexed.isEmpty()) "" else "WARNING: ${unindexed.size} source file(s) saved since the symbol index was built " +
            "mention '$query' and their declarations may be missing from matches: ${unindexed.joinToString()}. " +
            "Read them with WorkspaceSearchRequest(scope=FIELD_KOTLIN_SOURCE, query=\"$query\").")

    // Avoid reading and scoring every source file for an exact indexed symbol.
    val exact = definitions.filter { it.name == query || it.name.endsWith(".$query") }
    if (exact.isNotEmpty()) return result(withBodyBudget(exact.take(limit.coerceIn(1, 100)).map(::toMatch)))

    val ranked = LinkedHashMap<String, RankedWorkspaceSymbolMatch>()
    definitions.forEachIndexed { definitionIndex, def ->
        val definitionLine = definitionLineText(def).orEmpty()
        val fullName = def.name
        val simpleName = fullName.substringAfterLast('.')
        val score = scoreWorkspaceSymbolCandidate(
            fullName = fullName,
            simpleName = simpleName,
            definitionLine = definitionLine,
            kind = def.kind,
            filePath = def.filePath,
            analysis = analysis
        )
        if (score <= 0) return@forEachIndexed

        val key = "${def.filePath}:${def.line}:${def.kind}:${def.name}"
        val rankedMatch = RankedWorkspaceSymbolMatch(
            definition = def,
            score = score,
            definitionIndex = definitionIndex
        )
        val existing = ranked[key]
        if (existing == null || rankedMatch.score > existing.score) {
            ranked[key] = rankedMatch
        }
    }

    val matches = ranked.values
        .sortedWith(compareByDescending<RankedWorkspaceSymbolMatch> { it.score }.thenBy { it.definitionIndex })
        .take(limit.coerceAtLeast(1))
        .map { toMatch(it.definition) }

    return result(withBodyBudget(matches))
}

internal fun unindexedFilesMentioning(index: KotlinSourceIndexDb, name: String, limit: Int = 10): List<String>

.kt files under the index root's src/ saved after the index was built (and after their own record) that contain name literally, at most ten. A file with no record is not enough: the index never covers some trees (src/personal/verification, tests), and listing those on every search was noise. On 2026-10-28 a local model asked for drawn an hour after Shape2.drawn was written, got eight unrelated hits, and could not tell the index was behind.

body — 161 lines, from line 192
internal fun unindexedFilesMentioning(index: KotlinSourceIndexDb, name: String, limit: Int = 10): List<String> {
    if (name.isBlank()) return emptyList()
    val root = Paths.get(index.root)
    val src = root.resolve("src")
    if (!Files.isDirectory(src)) return emptyList()
    val recorded = index.files.associate { root.resolve(it.path).normalize().toString() to it.modifiedEpochMs }
    return runCatching {
        Files.walk(src).use { paths ->
            paths.filter { it.toString().endsWith(".kt") && Files.isRegularFile(it) }
                .filter { path ->
                    val mtime = Files.getLastModifiedTime(path).toMillis()
                    mtime > index.updatedAtEpochMs && mtime > (recorded[path.normalize().toString()] ?: Long.MIN_VALUE)
                }
                .filter { runCatching { Files.readString(it).contains(name) }.getOrDefault(false) }
                .limit(limit.toLong())
                .map { root.relativize(it).toString() }
                .toList()
        }
    }.getOrDefault(emptyList())
}

private fun analyzeWorkspaceSymbolQuery(rawQuery: String): WorkspaceQueryAnalysis {
    val preferredKinds = linkedSetOf<String>()
    val loweredRaw = rawQuery.lowercase()
    if (Regex("\\bclass\\b").containsMatchIn(loweredRaw)) preferredKinds += "class"
    if (Regex("\\binterface\\b").containsMatchIn(loweredRaw)) preferredKinds += "interface"
    if (Regex("\\bobject\\b").containsMatchIn(loweredRaw)) preferredKinds += "object"
    if (Regex("\\bfun\\b").containsMatchIn(loweredRaw) || loweredRaw.contains("operator")) preferredKinds += "fun"
    if (Regex("\\bval\\b").containsMatchIn(loweredRaw)) preferredKinds += "val"
    if (Regex("\\bvar\\b").containsMatchIn(loweredRaw)) preferredKinds += "var"

    val variants = LinkedHashSet<String>()
    val significantTokens = LinkedHashSet<String>()

    fun add(candidate: String?, allowShort: Boolean = false) {
        val cleaned = candidate
            ?.trim()
            ?.trim('"', '\'', '`')
            ?.replace(Regex("\\s+"), " ")
            ?.trim()
            .orEmpty()
        if (cleaned.isNotEmpty() && (allowShort || cleaned.length >= 2)) {
            variants += cleaned
        }
    }

    add(rawQuery, allowShort = true)
    add(
        rawQuery.replace(
            Regex(
                "^(?:data\\s+class|sealed\\s+class|annotation\\s+class|enum\\s+class|class|interface|object|fun|val|var)\\s+",
                RegexOption.IGNORE_CASE
            ),
            ""
        )
    )

    extractOperatorAliases(rawQuery).forEach(::add)

    Regex("`[^`]+`|[A-Za-z_][A-Za-z0-9_]*").findAll(rawQuery).forEach { match ->
        val token = match.value.trim('`')
        if (token.length >= 2 && token.lowercase() !in workspaceSearchNoiseTokens) {
            add(token)
            significantTokens += token.lowercase()
        }
    }

    rawQuery.split(Regex("[\\./:]+"))
        .map { it.trim('`', '"', '\'', ' ') }
        .filter { it.length >= 2 && it.lowercase() !in workspaceSearchNoiseTokens }
        .forEach {
            add(it)
            significantTokens += it.lowercase()
        }

    return WorkspaceQueryAnalysis(
        normalizedQueries = variants.toList(),
        significantTokens = significantTokens.toList(),
        preferredKinds = preferredKinds
    )
}

private fun extractOperatorAliases(query: String): List<String> {
    val aliases = mutableListOf<String>()
    if ("+=" in query) aliases += "plusAssign"
    if ("-=" in query) aliases += "minusAssign"
    if ("*=" in query) aliases += "timesAssign"
    if ("/=" in query) aliases += "divAssign"
    if ("%=" in query) aliases += "remAssign"
    if ("==" in query) aliases += "equals"
    return aliases
}

private fun scoreWorkspaceSymbolCandidate(
    fullName: String,
    simpleName: String,
    definitionLine: String,
    kind: String,
    filePath: String,
    analysis: WorkspaceQueryAnalysis
): Int {
    val full = fullName.lowercase()
    val simple = simpleName.lowercase()
    val line = definitionLine.lowercase()
    val kindLower = kind.lowercase()
    val pathLower = filePath.lowercase()

    val bestQueryScore = analysis.normalizedQueries.maxOfOrNull { query ->
        val q = query.lowercase()
        if (q.isBlank()) {
            0
        } else {
            when {
                full == q -> 150
                simple == q -> 145
                full.endsWith(".$q") -> 140
                full.startsWith("$q.") -> 135
                full.startsWith(q) -> 125
                simple.startsWith(q) -> 120
                full.contains(".$q.") -> 115
                full.contains(".$q") -> 110
                simple.contains(q) -> 100
                full.contains(q) -> 90
                line.contains(q) -> 60
                else -> 0
            }
        }
    } ?: 0
    if (bestQueryScore <= 0) return 0

    val tokenScore = analysis.significantTokens.sumOf { token ->
        when {
            full == token -> 22
            simple == token -> 22
            full.endsWith(".$token") -> 20
            simple.startsWith(token) -> 16
            simple.contains(token) -> 12
            full.contains(token) -> 10
            line.contains(token) -> 6
            else -> 0
        }
    }.coerceAtMost(90)

    val kindScore = when {
        analysis.preferredKinds.isEmpty() -> 0
        kindLower in analysis.preferredKinds -> 35
        else -> -20
    }

    val pathScore = when {
        pathLower.startsWith("src/jvmmain/kotlin/afield/four/") -> 18
        pathLower.startsWith("src/jvmmain/kotlin/afield/stdlib/") -> 14
        pathLower.startsWith("src/jvmmain/kotlin/") -> 10
        pathLower.startsWith("src/commonmain/kotlin/") -> 8
        "/examples/" in pathLower -> -12
        pathLower.startsWith("src/personal/") -> -35
        else -> 0
    }

    return bestQueryScore + tokenScore + kindScore + pathScore
}

internal const val SYMBOL_SEARCH_BODY_BUDGET = 6_000

All the definition bodies one search returns, together; see withBodyBudget.

internal fun withBodyBudget(matches: List<WorkspaceSymbolMatch>): List<WorkspaceSymbolMatch>

Bodies in rank order until SYMBOL_SEARCH_BODY_BUDGET; later matches keep their signature and KDoc. One search for "FLines" returned 17k characters, most of it a single unrelated function's body, to a local model with a 64k-token window (2026-10-22). A body left out says how to get it.

The ranking itself is unchanged on purpose: plural folding, a usage prior learned from Field's tutorial box code, and type-aware bonuses were measured against 60 searches labelled by what agents then wrote (dev_docs/ai/AI4_FIRST_LOCAL_CONVERSATION.md), and none beat it by more than noise.

internal fun withBodyBudget(matches: List<WorkspaceSymbolMatch>): List<WorkspaceSymbolMatch> {
    var left = SYMBOL_SEARCH_BODY_BUDGET
    return matches.map { match ->
        val body = match.definitionBody ?: return@map match
        if (body.length <= left) { left -= body.length; match }
        else match.copy(definitionBody = "(body of ${body.length} chars omitted from this search; read it with AttachmentRequest(file=\"${match.filePath}\", find=\"${match.symbol.substringAfterLast('.')}\"))")
    }
}