Skip to content

formatter.kt

src/jvmMain/kotlin/afield/runtime/kotlin/formatter.kt · 552 lines

package afield.runtime.kotlin

import afield.app.protocol.put
import afield.data.table.div
import afield.data.table.get
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.document.graph.Box
import afield.document.graph.box
import afield.runtime.execution.plus
import afield.runtime.lifecycle.early
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.runtime.lifecycle.then
import afield.ui.notation.double
import afield.ui.notation.string
import afield.ui.notation.with
import afield.ui.output.column

import afield.audio.synth.*
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.lifecycle.end
import afield.runtime.lifecycle.map
import afield.runtime.macros.isFragmentBreak
import afield.tools.docs.format
import afield.ui.output.templates.filter
import org.jetbrains.ktfmt.format.FileType
import org.jetbrains.ktfmt.format.Formatter
import org.jetbrains.ktfmt.format.KotlinCode
import org.jetbrains.kotlin.com.intellij.psi.PsiComment
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType
import org.jetbrains.kotlin.psi.psiUtil.endOffset
import org.jetbrains.kotlin.psi.psiUtil.startOffset

Reformats a box's Kotlin or GLSL code.

formatCodeProperty picks the formatter by property name: formatKotlinCode for code, which runs ktfmt and then restores Field-specific layout such as fragment breaks and comment blocks, and formatGlslCode for shader properties.

fun formatKotlinCode(code: String, blockifyComments: Boolean = true): String

fun formatKotlinCode(code: String, blockifyComments: Boolean = true): String {
    // Box code is a script: top-level statements, not only declarations.
    // ktfmt attaches comments that follow the last statement of a script to that statement's last
    // token, so a closing `}` swallows them (`}// note`). A sentinel statement after them gives them
    // something to precede; it is removed again below.
    val endsInComment = code.lines().lastOrNull { it.isNotBlank() }?.trim()?.startsWith("//") == true
    val input = if (endsInComment) code.trimEnd() + "\n" + END_SENTINEL + "\n" else code
    var formatted = restoreFragmentBreaks(
        Formatter.format(FORMAT_OPTIONS, KotlinCode(input, FileType.SCRIPT))
    )
    if (endsInComment) formatted = formatted.trimEnd().removeSuffix(END_SENTINEL).trimEnd() + "\n"
    formatted = restoreStatementSpacing(code, formatted)
    formatted = joinAssignmentBreaks(formatted)
    formatted = collapseOneLineLambdaAssignments(formatted)
    return if (blockifyComments) blockifyLineCommentRuns(formatted) else formatted
}

fun joinAssignmentBreaks(code: String): String

Undo ktfmt's break after = when the right-hand side is one multi-line call or block:

val cloth =                 val cloth = sim.addCloth(
    sim.addCloth(               width = 22,
        width = 22,     ->  )
    )

The kotlinlang style breaks there whenever a dotted call's arguments go vertical, which pushes the name a reader is scanning for onto a line of its own and every argument one level deeper. The join only applies when the opening line ends the bracket it opens on its own closing line at the same indentation, and never across a raw string, whose indentation is its content. ktfmt re-breaks it on the next format and this re-joins it, so the pair is still a fixed point.

fun joinAssignmentBreaks(code: String): String {
    val lines = code.split("\n").toMutableList()
    var i = 0
    while (i + 1 < lines.size) {
        val head = lines[i]
        val next = lines[i + 1]
        val indent = head.length - head.trimStart().length
        val opener = next.trimEnd().lastOrNull()
        val closer = when (opener) { '(' -> ")"; '{' -> "}"; '[' -> "]"; else -> null }
        if (!head.trimEnd().endsWith(" =") || head.trimStart().startsWith("//") || closer == null ||
            next.length - next.trimStart().length != indent + 4
        ) { i++; continue }
        val end = (i + 2 until lines.size).firstOrNull { j ->
            val l = lines[j]
            l.isNotBlank() && l.length - l.trimStart().length <= indent + 4
        }
        val body = if (end == null) emptyList() else lines.subList(i + 2, end)
        val closes = end != null && lines[end].length - lines[end].trimStart().length == indent + 4 &&
            lines[end].trimStart().startsWith(closer)
        if (!closes || body.any { "\"\"\"" in it } || "\"\"\"" in next) { i++; continue }
        val joined = head.trimEnd() + " " + next.trim()
        val dedented = body.map { if (it.isBlank()) it else it.removePrefix("    ") }
        val closing = lines[end].removePrefix("    ")
        val replacement = listOf(joined) + dedented + closing
        for (k in i..end) lines.removeAt(i)
        lines.addAll(i, replacement)
        i += 1
    }
    return lines.joinToString("\n")
}

Box code is read in an editor beside a canvas and in a docs column, and it was mostly written to about 100. At 200, ktfmt joined deliberately wrapped string concatenations into one line.

private const val MAX_WIDTH = 120

ktfmt's kotlinlang style on its experimental engine, the one that keeps a block-like call or collection literal on the line it starts (val xs = [ ... ], foo( ... )) instead of breaking after = and indenting the whole call. The stock engine indents [/listOf( chains twice.

private val FORMAT_OPTIONS =
    Formatter.KOTLINLANG_FORMAT.copy(maxWidth = MAX_WIDTH, experimentalEngine = true)

private val BLANK_LINE = Regex("""\n[ \t]*\n""")

fun restoreStatementSpacing(input: String, formatted: String): String

Take out the blank lines ktfmt adds between top-level statements of a script.

In script mode ktfmt separates every pair of top-level statements with a blank line, so a box's consecutive buf.add(...) lines come back double-spaced. Blank lines the author wrote are kept: for each adjacent pair of statements, a gap that had no blank line in input has none in formatted. ktfmt never reorders, adds or drops statements, so the two lists pair up by position; if they do not (a parse problem on either side), formatted is returned unchanged.

fun restoreStatementSpacing(input: String, formatted: String): String {
    fun statements(code: String) = try {
        FieldKtParser.parse(code).script?.blockExpression?.statements
    } catch (t: Throwable) {
        null
    }
    val before = statements(input) ?: return formatted
    val after = statements(formatted) ?: return formatted
    if (before.size != after.size) return formatted
    val out = StringBuilder(formatted)
    for (k in after.indices.reversed()) {
        if (k == 0) continue
        val gapBefore = input.substring(before[k - 1].textRange.endOffset, before[k].textRange.startOffset)
        if (BLANK_LINE.containsMatchIn(gapBefore)) continue
        val start = after[k - 1].textRange.endOffset
        val end = after[k].textRange.startOffset
        out.replace(start, end, BLANK_LINE.replace(formatted.substring(start, end), "\n"))
    }
    return out.toString()
}

fun collapseOneLineLambdaAssignments(code: String): String

Put x = {\n e\n} back on one line as x = { e }.

At the top level of a script (and only there) ktfmt spreads every lambda on the right of an assignment over three lines, so mesh.tint = { tint } takes three and a block of them reads as a column of braces. Collapsed only when the body is one line, carries no comment, and the result fits MAX_WIDTH.

fun collapseOneLineLambdaAssignments(code: String): String {
    val lines = code.split("\n")
    val out = ArrayList<String>(lines.size)
    var i = 0
    while (i < lines.size) {
        val head = lines[i]
        if (i + 2 < lines.size && head.endsWith(" = {") && !head.trimStart().startsWith("//")) {
            val indent = head.length - head.trimStart().length
            val body = lines[i + 1]
            val close = lines[i + 2]
            val joined = "$head ${body.trim()} }"
            if (body.length - body.trimStart().length == indent + 4 && close == " ".repeat(indent) + "}" &&
                "//" !in body && "/*" !in body && "\"\"\"" !in body && joined.length <= MAX_WIDTH
            ) {
                out += joined
                i += 3
                continue
            }
        }
        out += head
        i++
    }
    return out.joinToString("\n")
}

private const val END_SENTINEL = "__afieldFormatterEnd__"

ktfmt puts a space after every //, which turns a //%- fragment break into // %-. The splitter accepts both (isFragmentBreak), but the documented spelling is the one boxes should carry, and older Field read the spaced form as a plain comment.

private val SPACED_FRAGMENT_BREAK = Regex("""(?m)^(\s*)//\s+%-""")

fun restoreFragmentBreaks(code: String): String = code.replace(SPACED_FRAGMENT_BREAK, "$1//%-")

Two // lines read as a pair of remarks as often as they read as one paragraph, so the run has to be longer than that to be worth a pair of delimiters.

private const val MINIMUM_RUN = 3

private class LineCommentLine(val end: Int, val lineStart: Int, val indent: String, val body: String)

Collapse a run of three or more consecutive own-line // comments into one block comment. Runs at the same indentation with no blank line between them are one comment in intent, so give them one pair of delimiters.

Two rules earn their keep here:

  • The delimiters get their own lines and the body sits at the block's own indentation. ktfmt re-indents a block comment's continuation lines to exactly that column, so this shape is a fixed point, and whatever indentation the body carries inside itself survives. Aligning the body past the opening delimiter would not survive: ktfmt pulls it back on the next pass.

  • A run whose text contains either block-comment delimiter is left alone. Kotlin block comments nest, so an opening delimiter inside the body would need a matching closer and a closing one would end the block early -- either way the rest of the file is swallowed.

Run this on ktfmt's output, not its input: the indentation is already canonical, and ktfmt never gets a second look at what we emit.

private val HEAD_MARKER = Regex("""^//\s*\{\{.*\}\}\s*$""")

fun blockifyLineCommentRuns(code: String): String

body — 71 lines, from line 221
fun blockifyLineCommentRuns(code: String): String {
    // If ktfmt handed us something we can't re-parse, the transform simply doesn't apply.
    val file = try {
        FieldKtParser.parse(code)
    } catch (t: Throwable) {
        return code
    }

    val ownLine = file.collectDescendantsOfType<PsiComment>()
        .filter { it.tokenType == KtTokens.EOL_COMMENT }
        .sortedBy { it.startOffset }
        .mapNotNull { comment ->
            // Fragment markers are execution boundaries. Leave them as line comments,
            // separating prose runs on either side so the splitter still sees them.
            if (isFragmentBreak(comment.text)) return@mapNotNull null
            // A `// {{ name }}` head marker is read as a line comment (KotlinJsHeadProfiles.resolve).
            if (HEAD_MARKER.matches(comment.text)) return@mapNotNull null
            val start = comment.startOffset
            val lineStart = code.lastIndexOf('\n', start - 1) + 1
            val indent = code.substring(lineStart, start)
            // Anything other than leading whitespace makes this a trailing comment on a line of
            // code, which stays as it is.
            if (indent.isNotBlank()) return@mapNotNull null
            LineCommentLine(comment.endOffset, lineStart, indent, comment.text.removePrefix("//"))
        }

    val runs = mutableListOf<MutableList<LineCommentLine>>()
    ownLine.forEach { line ->
        val open = runs.lastOrNull()
        val previous = open?.lastOrNull()
        // Exactly one newline between them: same indentation, no blank line, nothing else.
        val adjacent = previous != null &&
            previous.indent == line.indent &&
            code.substring(previous.end, line.lineStart) == "\n"
        if (open != null && adjacent) open.add(line) else runs.add(mutableListOf(line))
    }

    // Back to front, so the offsets of the runs we haven't rewritten yet stay valid.
    val out = StringBuilder(code)
    runs.asReversed()
        .filter { it.size >= MINIMUM_RUN }
        .filter { run -> run.none { it.body.contains("/*") || it.body.contains("*/") } }
        .forEach { run ->
            val indent = run.first().indent
            val block = buildString {
                append(indent).append("/*\n")
                run.forEach { line ->
                    val text = line.body.removePrefix(" ").trimEnd()
                    // An empty line stays empty rather than becoming an indent's worth of spaces.
                    if (text.isEmpty()) append('\n') else append(indent).append(text).append('\n')
                }
                append(indent).append("*/")
            }
            out.replace(run.first().lineStart, run.last().end, block)
        }
    return out.toString()
}

private val glslProperties = setOf(
    "fragment",
    "vertex",
    "compute",
    "task",
    "mesh",
    "raygen",
    "miss",
    "closesthit",
    "anyhit",
    "intersection",
    "callable"
)

fun formatCodeProperty(property: String, code: String): String?

fun formatCodeProperty(property: String, code: String): String? = when (property) {
    "code" -> formatKotlinCode(code)
    in glslProperties -> formatGlslCode(code)
    else -> null
}

fun formatGlslCode(code: String): String

body — 253 lines, from line 299
fun formatGlslCode(code: String): String {
    // Keep literal text and comment layout out of spacing/statement rewriting. In particular,
    // shader headers often contain aligned tables, and string contents are not GLSL whitespace.
    var marker = "FIELD_GLSL_PROTECTED_"
    while (code.contains(marker)) marker = "_" + marker
    val protected = mutableListOf<Pair<String, String>>()
    val masked = GLSL_PROTECTED.replace(code) { match ->
        val text = match.value
        val id = marker + protected.size + "_END"
        val token = when {
            text.startsWith("//") -> "//$id"
            text.startsWith("/*") -> "/*$id*/"
            else -> "\"$id\""
        }
        protected += token to text
        token
    }
    val normalized = splitGlslStatements(masked)
    val out = StringBuilder()
    var indent = 0
    var previousBlank = false
    var previousLine = ""

    normalized.lineSequence().forEach { raw ->
        val line = raw.trim()
        if (line.isEmpty()) {
            if (!previousBlank && out.isNotEmpty()) out.appendLine()
            previousBlank = true
            return@forEach
        }
        previousBlank = false
        val structure = line.replace(Regex("""/\*.*?\*/|//.*"""), "").trim()
        if (structure.startsWith("}")) indent = (indent - 1).coerceAtLeast(0)
        val effectiveIndent = if (structure.startsWith("case ") || structure.startsWith("default:")) {
            (indent - 1).coerceAtLeast(0)
        } else {
            indent
        }
        if (effectiveIndent == 0 && previousLine == "};" && looksLikeGlslFunctionDeclaration(line)) {
            out.appendLine()
        }
        repeat(effectiveIndent) { out.append("    ") }
        val cleaned = cleanGlslSpacing(line)
        out.appendLine(cleaned)
        previousLine = cleaned
        if (structure.endsWith("{")) indent += 1
    }

    var formatted = out.toString().trimEnd() + "\n"
    protected.forEach { (token, original) -> formatted = formatted.replace(token, original) }
    return formatted
}

private val GLSL_PROTECTED = Regex("""//[^\r\n]*|/\*[\s\S]*?\*/|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'""")

private fun splitGlslStatements(code: String): String {
    val out = StringBuilder()
    var i = 0
    var parenDepth = 0
    var atLineStart = true
    var inLineComment = false
    var inBlockComment = false
    var inString: Char? = null
    var escaped = false

    // Whether the newline `out` ends with was put there by us, after a `;` or a brace, rather than
    // copied from the source. The source's own newline after it is then the same line break; a
    // further one is a blank line the author wrote, and is kept.
    var inserted = false

    fun appendNewlineIfNeeded() {
        while (out.endsWith(' ') || out.endsWith('\t')) out.setLength(out.length - 1)
        if (!out.endsWith('\n')) {
            out.append('\n')
            inserted = true
        }
        atLineStart = true
    }

    while (i < code.length) {
        val c = code[i]
        val next = code.getOrNull(i + 1)

        if (inLineComment) {
            out.append(c)
            if (c == '\n') {
                inLineComment = false
                atLineStart = true
            }
            i++
            continue
        }

        if (inBlockComment) {
            out.append(c)
            if (c == '*' && next == '/') {
                out.append(next)
                i += 2
                inBlockComment = false
            } else {
                if (c == '\n') atLineStart = true
                i++
            }
            continue
        }

        if (inString != null) {
            out.append(c)
            if (escaped) {
                escaped = false
            } else if (c == '\\') {
                escaped = true
            } else if (c == inString) {
                inString = null
            }
            i++
            continue
        }

        if (atLineStart && c == '#') {
            val end = code.indexOf('\n', i).let { if (it < 0) code.length else it }
            out.append(code.substring(i, end).trimEnd())
            appendNewlineIfNeeded()
            i = if (end < code.length) end + 1 else end
            // The directive's source newline was consumed above. A following newline is
            // an authored blank line, rather than the duplicate of an inserted break.
            inserted = false
            continue
        }

        // A template span ({% property %}, {{ value }}, {# note #}) is Field's, not GLSL's: copy it as
        // it stands rather than reading its braces as a block.
        if (c == '{' && (next == '%' || next == '{' || next == '#')) {
            val closer = when (next) { '%' -> "%}"; '#' -> "#}"; else -> "}}" }
            val end = code.indexOf(closer, i + 2).let { if (it < 0) code.length else it + 2 }
            out.append(code, i, end)
            atLineStart = false
            i = end
            continue
        }

        when {
            c == '/' && next == '/' -> {
                // A semicolon/brace may just have inserted a newline. A comment authored on
                // that same source line still belongs to the preceding statement.
                val sourceLineStart = code.lastIndexOf('\n', i - 1) + 1
                if (inserted && out.endsWith('\n') && code.substring(sourceLineStart, i).isNotBlank()) {
                    out.setLength(out.length - 1)
                    out.append(' ')
                    inserted = false
                    atLineStart = false
                }
                out.append(c).append(next)
                inLineComment = true
                i += 2
            }
            c == '/' && next == '*' -> {
                out.append(c).append(next)
                inBlockComment = true
                i += 2
            }
            c == '"' || c == '\'' -> {
                out.append(c)
                inString = c
                i++
            }
            c == '(' -> {
                parenDepth += 1
                out.append(c)
                atLineStart = false
                i++
            }
            c == ')' -> {
                parenDepth = (parenDepth - 1).coerceAtLeast(0)
                out.append(c)
                atLineStart = false
                i++
            }
            c == '{' -> {
                if (out.isNotEmpty() && !out.endsWith(' ') && !out.endsWith('\n')) out.append(' ')
                out.append(c)
                appendNewlineIfNeeded()
                i++
            }
            c == '}' -> {
                appendNewlineIfNeeded()
                // `} pc;` closes a block that declares an instance; the name stays on the brace's line.
                val instance = BLOCK_INSTANCE.find(code, i + 1)?.takeIf { it.range.first == i + 1 }
                if (next == ';') {
                    out.append("};")
                    i += 2
                } else if (instance != null) {
                    out.append("} ").append(instance.groupValues[1].replace(Regex("""\s+"""), "")).append(';')
                    i = instance.range.last + 1
                } else {
                    out.append(c)
                    i++
                }
                appendNewlineIfNeeded()
            }
            c == ';' && parenDepth == 0 -> {
                out.append(c)
                appendNewlineIfNeeded()
                i++
            }
            c == '\n' -> {
                while (out.endsWith(' ') || out.endsWith('\t')) out.setLength(out.length - 1)
                when {
                    !out.endsWith('\n') -> out.append('\n')
                    inserted -> inserted = false
                    // A blank line; formatGlslCode collapses a run of them to one.
                    else -> out.append('\n')
                }
                atLineStart = true
                i++
            }
            c.isWhitespace() -> {
                if (!out.endsWith(' ') && !out.endsWith('\n')) out.append(' ')
                i++
            }
            else -> {
                out.append(c)
                atLineStart = false
                inserted = false
                i++
            }
        }
    }

    return out.toString()
}

private val BLOCK_INSTANCE = Regex("""[ \t]*([A-Za-z_]\w*(?:\s*\[[^\]\n]*\])?)[ \t]*;""")

private fun cleanGlslSpacing(line: String): String {
    return line
        .replace(Regex("""\s+([,;)\]])"""), "$1")
        .replace(Regex("""([(\[])\s+"""), "$1")
        .replace(Regex("""\s+\{"""), " {")
        .replace(Regex("""\s{2,}"""), " ")
}

private fun looksLikeGlslFunctionDeclaration(line: String): Boolean {
    if (!line.endsWith("{")) return false
    if (!line.contains('(') || !line.contains(')')) return false
    val head = line.substringBefore('(').trim()
    if (head.isEmpty()) return false
    val name = head.substringAfterLast(' ')
    if (name in setOf("if", "for", "while", "switch")) return false
    return head.any { it.isWhitespace() }
}

private fun StringBuilder.endsWith(c: Char): Boolean = isNotEmpty() && this[length - 1] == c