RectSLSource.kt
src/jvmMain/kotlin/afield/lang/rectsl/RectSLSource.kt · 350 lines
package afield.lang.rectsl
import afield.graphics.color.Color4
import afield.runtime.kotlin.KotlinScriptEngine
import afield.lang.stacksl.StackSLSeverity
import afield.math.functions.Functions
import java.util.Locale
RectSL read as source: what the editor and the study tools need that a plain compile does not give — where things are.
compileRectSL answers "does it compile, and to what". A person editing a scene, or a model
talking one into existence, also needs to know which line a problem is on, which line made a
given rectangle, and which numbers in the text are worth turning. The tokenizer records where each
token began (RectLangTokenizer.offsets), the evaluator records what it was evaluating when it
failed (RectLangVm.failedAt), and the scene reports each node as it is made
(SceneBuilder.onNode); this file turns those into lines.
Nothing here changes what a program means: compileRectSLWithSource runs the same tokenizer and evaluator as compileRectSL, with the same curves, engine and pre-compiled escapes.
data class RectSLProblem(val message: String, val line: Int?, val column: Int?, val error: Boolean = true)
A problem with a RectSL program, as the editor shows it. line and column are 1-based.
data class RectSLProblem(val message: String, val line: Int?, val column: Int?, val error: Boolean = true) {
val summary: String get() = (line?.let { "line $it: " } ?: "") + message
The problem in one line, with its place, as tool results quote it.
data class RectSLOrigin(val line: Int, val column: Int, val statementLine: Int)
Where a node came from: the line and column of the innermost word that made it (a rect, the
array that cloned it, a word inside a definition), and the line of the root-level statement that
was running — the same for a rectangle made directly, the call site for one made inside a definition.
class RectSLCompiled
compileRectSLWithSource's answer.
class RectSLCompiled(
val source: String,
/** The compiled scene, or null when it did not compile. */
val program: SceneProgram?,
/** Every problem, errors first; material (StackSL) problems included. */
val problems: List<RectSLProblem>,
/** Which line made each node, by node id. */
val origins: Map<String, RectSLOrigin>,
/** The materials the scene's StackSL compiled to, when it compiled. */
val materials: List<StackSLRectMaterialCompileResult>,
/**
* What took each literal off the stack, by the literal's offset in [source]: the word, and how many
* values that word had taken before it — the evaluator's own record ([RectLangVm.onLiteralTaken]).
*/
val takenBy: Map<Int, Pair<String, Int>> = emptyMap()
) {
val ok: Boolean get() = program != null && problems.none { it.error }
fun rectSLLineAndColumn(source: String, offset: Int): Pair<Int, Int>
The 1-based line and column of offset in source.
fun rectSLLineAndColumn(source: String, offset: Int): Pair<Int, Int> {
var line = 1
var lineStart = 0
for (n in 0 until offset.coerceIn(0, source.length)) if (source[n] == '\n') { line++; lineStart = n + 1 }
return line to (offset - lineStart + 1)
}
fun compileRectSLWithSource
Compile source as compileRectSL does, and say where: each problem on its line, and which line
made each rectangle and group. A material whose StackSL does not compile is a problem on the
material line that defines it, because the GPU would draw it magenta.
body — 46 lines, from line 70
fun compileRectSLWithSource(
source: String,
curves: Map<String, Functions.Fn> = emptyMap(),
engine: KotlinScriptEngine? = null,
precompiledEscapes: Map<String, (RectEscapeContext) -> Any?> = emptyMap()
): RectSLCompiled {
val tokenizer = RectLangTokenizer(source)
val tokens = try {
tokenizer.tokenize()
} catch (e: RectLangException) {
val at = tokenizer.failureOffset?.let { rectSLLineAndColumn(source, it) }
return RectSLCompiled(source, null, listOf(RectSLProblem(e.message ?: "$e", at?.first, at?.second)), emptyMap(), emptyList())
}
val offsets = tokenizer.offsets
val origins = linkedMapOf<String, RectSLOrigin>()
val scene = SceneBuilder()
lateinit var vm: RectLangVm
scene.onNode = { id, renamedFrom ->
val inherited = renamedFrom?.let { origins.remove(it) }
val chain = vm.evaluatingTokens()
val innermost = chain.lastOrNull { it in offsets }
val outermost = chain.firstOrNull { it in offsets }
val origin = inherited ?: innermost?.let { token ->
val (line, column) = rectSLLineAndColumn(source, offsets.getValue(token))
RectSLOrigin(line, column, rectSLLineAndColumn(source, offsets.getValue(outermost ?: token)).first)
}
if (origin != null) origins[id] = origin
}
vm = RectLangVm(scene = scene, namedCurves = curves, escapeEngine = engine, precompiledEscapes = precompiledEscapes)
val takenBy = HashMap<Int, Pair<String, Int>>()
vm.onLiteralTaken = { token, word, depth -> offsets[token]?.let { takenBy.putIfAbsent(it, word to depth) } }
val program = try {
vm.eval(tokens)
} catch (t: Throwable) {
val at = vm.failedAt?.let { offsets[it] }?.let { rectSLLineAndColumn(source, it) }
return RectSLCompiled(source, null, listOf(RectSLProblem(t.message ?: t.toString(), at?.first, at?.second)), origins, emptyList())
}
val materials = runCatching { compileStackSLMaterials(program) }.getOrElse { emptyList() }
val problems = materials.flatMap { compiled ->
val line = materialLine(source, compiled.module.name)
compiled.stackSL.diagnostics.filter { it.severity == StackSLSeverity.ERROR || it.severity == StackSLSeverity.WARNING }.map {
RectSLProblem("material ${compiled.module.name}: ${it.message}", line, null, it.severity == StackSLSeverity.ERROR)
}
}.sortedBy { !it.error }
return RectSLCompiled(source, program, problems, origins, materials, takenBy)
}
The line of material <name>, or null.
private fun materialLine(source: String, name: String): Int? {
val at = Regex("""(^|\s)material\s+"?${Regex.escape(name)}"?(\s|$)""").find(source) ?: return null
return rectSLLineAndColumn(source, at.range.first + at.groupValues[1].length).first
}
data class RectSLKnob
A number (or a colour) typed in a RectSL program, which a person or a model can turn without rewriting the line around it.
@property knob its index in the program's knob list, in source order
@property text the number exactly as typed
@property role what reads it: ->xy x, rgb green, ->wall width, keyframes of ->x: value, …
@property colour for a colour literal (0xffaa33), its hex; the three knobs of an rgb share one
data class RectSLKnob(
val knob: Int,
val value: Double,
val text: String,
val line: Int,
val column: Int,
val start: Int,
val end: Int,
val role: String,
val context: String,
val colour: String? = null
)
The number of literal tokens each of these words reads straight from the source after it, and what each one is.
private val LITERAL_ARGUMENTS = mapOf(
"rgb" to listOf("red", "green", "blue"),
"rgba" to listOf("red", "green", "blue", "alpha"),
"->wall" to listOf("width", "height"),
"->duration" to listOf("seconds"),
"->fps" to listOf("frames per second"),
// Only a hex colour straight after it; otherwise ->background takes a colour from the stack.
"->background" to listOf("colour"),
)
fun rectSLKnobs(source: String, takenBy: Map<Int, Pair<String, Int>> = emptyMap()): List<RectSLKnob>
Every knob in source: each number and hex colour, in source order, with what reads it. Numbers
inside {{ … }} Kotlin escapes are not knobs; numbers inside a material quote are (they are
StackSL numbers, and turning one re-shades the material). A source that does not tokenize has none.
body — 120 lines, from line 161
fun rectSLKnobs(source: String, takenBy: Map<Int, Pair<String, Int>> = emptyMap()): List<RectSLKnob> {
val tokenizer = RectLangTokenizer(source)
val tokens = runCatching { tokenizer.tokenize() }.getOrElse { return emptyList() }
val offsets = tokenizer.offsets
val out = mutableListOf<RectSLKnob>()
val lines = source.lines()
fun endOf(start: Int): Int {
var i = start
while (i < source.length) {
val c = source[i]
if (c.isWhitespace() || c == '#' || c == '"' || c == ';' || c == ':' || c == '=' || c == ')') break
if (source.startsWith("'(", i)) break
i++
}
return i
}
// A material's quote is StackSL: its numbers are described as StackSL's compiler describes them, by offset.
val materialPurposes = HashMap<Int, String>()
fun describeMaterial(quote: Token.Quote) {
val at = offsets[quote]?.let { source.indexOf(quote.source, it) }?.takeIf { it >= 0 } ?: return
val quoteLines = quote.source.lines()
val lineStarts = quoteLines.runningFold(0) { acc, l -> acc + l.length + 1 }
runCatching { afield.lang.stacksl.study.StackSLStudyShader.knobs(quote.source) }.getOrDefault(emptyList()).forEach { k ->
val purpose = afield.lang.stacksl.study.StackSLStudyViewer.purpose(k, quoteLines.getOrElse(k.line - 1) { "" })
k.spans.forEach { span -> materialPurposes[at + lineStarts[k.line - 1] + span.column - 1] = purpose }
}
}
fun nextWord(block: List<Token>, from: Int): String? =
block.drop(from + 1).firstOrNull { it is Token.Word }?.let { (it as Token.Word).name }
fun walk(block: List<Token>, quoteRole: String?) {
var consumer: Pair<String, Int>? = null // literal-reading word, and how many of its literals have been seen
val readForward = HashSet<Int>() // the literals a word before them read out of the source, not off the stack
block.forEachIndexed { index, token ->
val start = offsets[token]
when (token) {
is Token.Word -> consumer = LITERAL_ARGUMENTS[token.name]?.let { token.name to 0 }
is Token.Quote -> {
consumer = null
val before = block.getOrNull(index - 1)
val beforeThat = block.getOrNull(index - 2)
val role = when {
beforeThat is Token.Word && beforeThat.name == "material" -> "material ${(before as? Token.Word)?.name ?: (before as? Token.StringLit)?.value}"
beforeThat is Token.Word && beforeThat.name.endsWith("track") -> "keyframes of track ${(before as? Token.StringLit)?.value ?: (before as? Token.Word)?.name}"
else -> nextWord(block, index)?.let { word ->
// `'( 0=0 6=6.28 ) linear` is keyframes whatever reads them; only a mutator says which channel.
if (!keyframes(token.tokens)) "in a quote before $word"
else if (word.startsWith("->")) "keyframes of $word" else "keyframes before $word"
} ?: "in a quote"
}
if (role.startsWith("material ")) describeMaterial(token)
walk(token.tokens, role)
}
is Token.Number, is Token.ColorLit -> {
if (start == null) return@forEachIndexed
val end = endOf(start)
val raw = source.substring(start, end).trimEnd(',')
val (line, column) = rectSLLineAndColumn(source, start)
val literal = consumer?.takeIf { it.first != "->background" || token is Token.ColorLit }?.let { (word, seen) ->
LITERAL_ARGUMENTS.getValue(word).getOrNull(seen)?.let { part -> consumer = word to seen + 1; readForward += index; "$word $part" }
}
if (consumer?.first == "->background") consumer = null
val guessed = when {
literal != null -> literal
quoteRole != null && quoteRole.startsWith("keyframes") -> "$quoteRole: " + keyPart(block, index)
quoteRole != null && !quoteRole.startsWith("in a quote") -> quoteRole
else -> {
// What reads it: the word right after it, or — when that is arithmetic —
// the mutator the arithmetic feeds (`fc 6.5 * ->x` is "into ->x via *").
val body = quoteRole?.removePrefix("in a quote")?.removePrefix(" before ")?.takeIf { it.isNotBlank() }?.let { " in the $it body" }.orEmpty()
// The numbers pushed before it, back to the last word — not counting `->fps 30`'s 30 on the line above.
val run = (0..index).reversed().takeWhile { block[it] !is Token.Word && block[it] !is Token.Quote && it !in readForward }.size
val arity = block.drop(index + 1).takeWhile { it !is Token.Word && it !is Token.Quote }.size + run
val direct = (block.drop(index + 1).firstOrNull { it is Token.Word || it is Token.Quote } as? Token.Word)?.name
val mutator = block.drop(index + 1).firstOrNull { it is Token.Word && it.name.startsWith("->") }?.let { (it as Token.Word).name }
when {
// A number before a quote is the quote's word's argument: `12 '( … ) array` is array's count.
direct == null -> nextWord(block, index) ?: "left on the stack"
direct.startsWith("->") || mutator == null -> if (arity > 1) "$direct argument $run of $arity" else direct
else -> "into $mutator via $direct"
} + body
}
}
// What the evaluator saw take it, where the guess above does not already name that word.
val evaluated = takenBy[start]?.takeIf { (word, _) -> literal == null && word !in guessed }?.let { (word, depth) ->
"into $word" + (if (depth > 0) " (${depth + 1} down the stack)" else "")
}
val role = materialPurposes[start]?.let { "${quoteRole}: $it" } ?: evaluated ?: guessed
val colour = (token as? Token.ColorLit)?.color?.let(::hexOf)
val value = when (token) {
is Token.Number -> token.value
is Token.ColorLit -> raw.drop(2).toLong(16).toDouble()
}
out += RectSLKnob(out.size, value, raw, line, column, start, start + raw.length, role,
lines.getOrElse(line - 1) { "" }.trim(), colour)
}
else -> consumer = null
}
}
}
walk(tokens, null)
// The three (or four) knobs of an rgb share its colour, so a card can offer one swatch for them.
val grouped = out.toMutableList()
var i = 0
while (i < grouped.size) {
val k = grouped[i]
if (k.role == "rgb red" || k.role == "rgba red") {
val parts = grouped.subList(i, minOf(grouped.size, i + 3))
if (parts.size == 3 && parts[1].role.endsWith("green") && parts[2].role.endsWith("blue")) {
val hex = hexOf(Color4(parts[0].value, parts[1].value, parts[2].value))
for (j in 0 until 3) grouped[i + j] = grouped[i + j].copy(colour = hex)
}
}
i++
}
return grouped
}
Whether a quote reads as keyframes: numbers only, as t v t v … or t=v t=v ….
private fun keyframes(tokens: List<Token>): Boolean {
val numbers = tokens.count { it is Token.Number }
return numbers > 0 && numbers % 2 == 0 && tokens.all { it is Token.Number || (it is Token.Word && it.name == "=") }
}
Whether the number at index of a keyframe quote is a key's time or its value: t=v says so, t v goes by position.
private fun keyPart(block: List<Token>, index: Int): String = when {
(block.getOrNull(index + 1) as? Token.Word)?.name == "=" -> "time"
(block.getOrNull(index - 1) as? Token.Word)?.name == "=" -> "value"
else -> if (block.subList(0, index).count { it is Token.Number } % 2 == 0) "time" else "value"
}
private fun hexOf(c: Color4): String {
fun byte(v: Double) = (v.coerceIn(0.0, 1.0) * 255.0 + 0.5).toInt()
return "#%02x%02x%02x".format(byte(c.r), byte(c.g), byte(c.b))
}
fun formatRectSLKnob(value: Double, original: String): String
value written the way original was: as many decimals as it had (at least enough to show the change), no exponent.
fun formatRectSLKnob(value: Double, original: String): String {
if (original.startsWith("0x", ignoreCase = true)) {
val digits = original.length - 2
return "0x" + value.toLong().coerceIn(0, if (digits > 6) 0xffffffffL else 0xffffffL).toString(16).padStart(digits, '0')
}
val decimals = original.substringAfter('.', "").takeWhile { it.isDigit() }.length
for (d in decimals..6) {
val text = String.format(Locale.US, "%.${d}f", value)
if (text.toDouble() == value || d == 6) return if (d == 0) text.removeSuffix(".0") else text
}
return value.toString()
}
fun setRectSLKnobs(source: String, values: Map<Int, Double>): String
source with knobs rewritten in place (knob index to new value). A colour knob takes its value as
the 24-bit number of its hex (0xff8800 is 16746496). Refuses an index that is not a knob, and a
value the number's reader cannot hold — a wall, duration or frame rate that is not positive, an rgb
channel outside 0..1, a colour outside 24 bits — saying why, rather than writing a scene that fails
or silently clamps. The same knobs must exist afterwards.
fun setRectSLKnobs(source: String, values: Map<Int, Double>): String {
val knobs = rectSLKnobs(source)
values.keys.forEach { require(it in knobs.indices) { "No knob $it; the program has ${knobs.size}" } }
values.forEach { (index, value) ->
require(value.isFinite()) { "Knob values must be finite numbers" }
val k = knobs[index]
val role = k.role
when {
k.text.startsWith("0x", ignoreCase = true) ->
require(value >= 0 && value <= 0xffffff && value == Math.rint(value)) { "k$index is a colour ($role); give its 24-bit value, 0..16777215 (0xffffff)" }
role.startsWith("->wall ") || role == "->duration seconds" ->
require(value > 0) { "k$index is $role, which must be more than 0" }
role == "->fps frames per second" ->
require(value > 0 && value <= 240) { "k$index is $role, which must be more than 0 and at most 240" }
Regex("^rgba? (red|green|blue|alpha)$").matches(role) ->
require(value in 0.0..1.0) { "k$index is $role, an sRGB channel as typed, 0..1" }
}
}
val out = StringBuilder(source)
values.entries.sortedByDescending { knobs[it.key].start }.forEach { (index, value) ->
val k = knobs[index]
out.replace(k.start, k.end, formatRectSLKnob(value, k.text))
}
val rewritten = out.toString()
require(rectSLKnobs(rewritten).size == knobs.size) {
"Writing those values changed which numbers the scene has (${knobs.size} before, ${rectSLKnobs(rewritten).size} after)"
}
return rewritten
}