Skip to content

templating.kt

src/jvmMain/kotlin/afield/graphics/vulkan/util/templating.kt · 820 lines

package afield.graphics.vulkan.util

import afield.document.graph.Box
import afield.document.graph.gather
import afield.runtime.lifecycle.gather
import afield.app.diagnostics.logging.lprintln
import java.util.concurrent.ConcurrentHashMap

The template engine behind {{ slot }} in a Field shader: a Jinja-shaped expander written against the Kotlin standard library alone, which a shader stage's source runs through before shaderc sees it. Box.shaderTemplate is where a box puts the values it resolves against.

A template may contain:

  • {{ expr }}, replaced by whatever expr resolves to.
  • {% if expr %} … {% else %} … {% endif %}, nested freely. not expr and !expr negate. An empty string, a zero, an empty map and an empty list are false; a resolver is true.
  • {% property "name" %}, which expands another named shader property in place and needs an include resolver to find it. {% include "name" %} is the same tag under its older spelling, kept so that saved documents still compile.

Everything a template reads comes from a Resolver, one function from a key to a value, and what comes back is read by value rather than by type. null means not resolved, so the next resolver in the chain is asked. A String is the answer, and so is anything else through its toString. A Resolver, a Map or a list of Resolvers is structural instead: the rest of the path is resolved against it, to a depth of fifty.

{{ a.b.c }} is matched longest prefix first, so a.b.c is offered to every resolver, then a.b, then a, then the empty key. A resolver may therefore own a whole dotted namespace or just its root, and where a answers with something structural, b.c is resolved against that. A path that resolves nowhere stops the shader rather than silently emitting nothing, and says where: the line and column, the line itself under a caret, and every key that was tried against every resolver with what each returned.

runShaderTemplate is runTemplate plus #line directives around any expansion that spans lines, so a compiler error after one still names the shader line that was written rather than the line it grew into.

//////////////////////////// Public API ////////////////////////////

fun interface Resolver

One source of named values for a template: given a key, either a value or null for "ask the next one". A key arrives dotted and whole (a.b.c before a.b before a), so a resolver may answer for a namespace as readily as for a name, and a resolver returned as the answer resolves the rest of the path.

fun interface Resolver {

fun resolve(key: String): Any?

Answers for key, or returns null to pass the question to the next resolver in the chain.

A String is the answer as it stands and any other non-null value answers through its toString, so returning a number or a vector is fine. Returning a Resolver, a Map or a list of resolvers is structural instead: the rest of a dotted path is resolved against what came back. The key arrives whole, longest prefix first.

typealias ResolverFn = (String) -> Any?

A resolver written as a plain lambda, (String) -> Any?, which the template entrypoints accept anywhere a Resolver is wanted.

The same contract: a value, or null for "ask the next one".

fun interface IncludeResolver

Finds the source text for a {% property "name" %} tag, given the name and where the tag was written.

A template that uses the tag without one of these stops with an error rather than expanding to nothing. Field supplies a resolver that reads the named shader property off the box being compiled; a test or a tool supplies its own.

fun interface IncludeResolver {

fun include(path: String, line: Int, col: Int): String

Returns the text to splice in for the property path, which was named by a tag at line and col of the template.

The position is there for error messages, not for lookup. Throwing is how a missing property is reported: anything other than a TemplateException is wrapped in one naming the path and the position.

private val globalShaderTemplates = ConcurrentHashMap<String, Any>()

private val globalShaderTemplateResolver = Resolver { key -> globalShaderTemplates[key] }

fun registerGlobalShaderTemplate(name: String, template: Any)

Publishes template under name for every box in the process, as the last fallback behind each box's own shaderTemplate map.

This is how a library ships GLSL that any shader can pull in by name: fmchirp registers its curve evaluator this way. template takes the same shapes a slot value does — a String, a nested Map addressed as {{ a.b.c }}, or a Resolver that computes the text when asked. Registering a name twice replaces it, and a box that defines the same slot shadows it.

fun registerGlobalShaderTemplate(name: String, template: Any) {
    globalShaderTemplates[name] = template
}

fun unregisterGlobalShaderTemplate(name: String): Any?

Removes the global template registered under name and returns what was there, or null if nothing was.

fun unregisterGlobalShaderTemplate(name: String): Any? =
    globalShaderTemplates.remove(name)

fun listGlobalShaderTemplateNames(): Set<String>

The names currently in the global template registry, sorted.

GLSL completion reads this to offer registered slots, so a library that registers a template at load time has its name appear in the editor without anything else being told about it.

fun listGlobalShaderTemplateNames(): Set<String> =
    globalShaderTemplates.keys.toSortedSet()

fun getShaderTemplateResolverForBox(b : Box): List<Resolver>

The resolver chain a box's shader templates are expanded against: that box's own slots first, then the process-global registry.

The first resolver is a snapshot of shaderTemplate gathered up the box graph, with a child's entries overriding its parents', so a slot can be defined on an ancestor and used below it. Called once per shader compile, and the snapshot does not track later edits to the map.

fun getShaderTemplateResolverForBox(b : Box): List<Resolver> =
    listOf(
        MapResolver(b.gather<MutableMap<String, Any>>("shaderTemplate")),
        globalShaderTemplateResolver
    )

fun runTemplate(template: String, resolver: Resolver, includeResolver: IncludeResolver? = null): String

Expands template against a single Resolver, and returns the result.

No #line directives, so prefer runShaderTemplate for anything a shader compiler will see. A tag or expression that cannot be resolved throws a TemplateException rather than expanding to nothing.

fun runTemplate(template: String, resolver: Resolver, includeResolver: IncludeResolver? = null): String =
    TemplateEngine().run(template, listOf(resolver), includeResolver)

fun runTemplate(template: String, resolver: ResolverFn, includeResolver: IncludeResolver? = null): String

Expands template against a single lambda resolver, which is the same as the Resolver overload with the lambda wrapped.

fun runTemplate(template: String, resolver: ResolverFn, includeResolver: IncludeResolver? = null): String =
    TemplateEngine().run(template, listOf(FnResolver(resolver)), includeResolver)

fun runTemplate(template: String, resolver: Any?, includeResolver: IncludeResolver? = null): String

Expands template, taking the resolver in whatever shape the caller has it.

resolver may be a Resolver, a ResolverFn lambda, a Map (wrapped as a MapResolver), or an iterable of any of those, which becomes a chain asked in order. null resolves nothing, so every expression in the template is an error. Anything else throws a TemplateException naming the type.

fun runTemplate(template: String, resolver: Any?, includeResolver: IncludeResolver? = null): String =
    TemplateEngine().run(template, TemplateEngine.asResolverList(resolver), includeResolver)

fun runShaderTemplate(template: String, resolver: Any?, includeResolver: IncludeResolver? = null): String

Expands template the way a shader stage is expanded: as runTemplate, plus #line directives around any expansion that spans more than one line.

Those directives are what keeps a compiler error after a multi-line slot pointing at the line the author wrote rather than the line it grew into. Use this for anything shaderc will see, and runTemplate for text where line numbers do not matter. resolver accepts a Resolver, a ResolverFn, a Map or an iterable of resolvers; includeResolver is needed only if the template uses {% property %}.

fun runShaderTemplate(template: String, resolver: Any?, includeResolver: IncludeResolver? = null): String =
    TemplateEngine(insertLineDirectiveAfterMultilineExpr = true)
        .run(template, TemplateEngine.asResolverList(resolver), includeResolver)

//////////////////////////// Engine ////////////////////////////

source — 41 lines, from line 181
private class TemplateEngine(
    private val insertLineDirectiveAfterMultilineExpr: Boolean = false
) {

    fun run(template: String, rootResolvers: List<Resolver>, includeResolver: IncludeResolver? = null): String {
        val lineIndex = LineIndex(template)
        val parser = Parser(template, lineIndex)
        val nodes = parser.parseTopLevel()
        val evaluator = Evaluator(template, lineIndex, rootResolvers, includeResolver, insertLineDirectiveAfterMultilineExpr)
        return evaluator.render(nodes, rootResolvers)
    }

    companion object {
        fun asResolverList(any: Any?): List<Resolver> = when (any) {
            null -> emptyList()
            is Resolver -> listOf(any)
            is Map<*, *> -> listOf(MapResolver(any.entries.associate { it.key.toString() to it.value }))
            is Iterable<*> -> any.mapNotNull { asSingleResolverOrNull(it) }
            else -> {
                // Try to treat it as a lambda-like resolver.
                @Suppress("UNCHECKED_CAST")
                val fn = any as? ResolverFn
                if (fn != null) listOf(FnResolver(fn))
                else throw TemplateException(
                    "Unsupported resolver root type: ${any::class.qualifiedName ?: any::class.simpleName}"
                )
            }
        }

        private fun asSingleResolverOrNull(any: Any?): Resolver? = when (any) {
            null -> null
            is Resolver -> any
            is Map<*, *> -> MapResolver(any.entries.associate { it.key.toString() to it.value })
            else -> {
                @Suppress("UNCHECKED_CAST")
                val fn = any as? ResolverFn
                if (fn != null) FnResolver(fn) else null
            }
        }
    }
}

//////////////////////////// Parsing ////////////////////////////

source — 143 lines, from line 227
private sealed interface Node
private data class TextNode(val text: String) : Node
private data class ExprNode(val expr: String, val loc: Location, val resumeLine: Int) : Node
private data class IncludeNode(val includeExpr: String, val loc: Location) : Node
private data class IfNode(
    val condition: String,
    val thenBranch: List<Node>,
    val elseBranch: List<Node>?,
    val loc: Location
) : Node

private class Parser(
    private val template: String,
    private val index: LineIndex
) {
    private var pos: Int = 0

    fun parseTopLevel(): List<Node> {
        val res = parseBlock(stopTags = emptySet())
        if (res.stopTag != null) {
            val loc = index.location(res.stopTagStartIndex ?: 0)
            throw TemplateException("Unexpected tag '${res.stopTag}' at ${loc.format()}.")
        }
        return res.nodes
    }

    private data class BlockResult(
        val nodes: List<Node>,
        val stopTag: String?,
        val stopTagStartIndex: Int?
    )

    private fun parseBlock(stopTags: Set<String>): BlockResult {
        val nodes = mutableListOf<Node>()

        while (pos < template.length) {
            val nextExpr = template.indexOf("{{", startIndex = pos)
            val nextTag = template.indexOf("{%", startIndex = pos)
            val next = minPositive(nextExpr, nextTag)

            if (next == -1) {
                if (pos < template.length) {
                    nodes += TextNode(template.substring(pos))
                }
                pos = template.length
                break
            }

            if (next > pos) {
                nodes += TextNode(template.substring(pos, next))
            }

            if (next == nextExpr) {
                val start = next
                val end = template.indexOf("}}", startIndex = start + 2)
                if (end == -1) {
                    val loc = index.location(start)
                    throw TemplateException("Unclosed '{{' at ${loc.format()}.")
                }
                val expr = template.substring(start + 2, end).trim()
                val loc = index.location(start)
                nodes += ExprNode(expr, loc, index.location(end + 2).line)
                pos = end + 2
                continue
            }

            // Tag
            val start = next
            val end = template.indexOf("%}", startIndex = start + 2)
            if (end == -1) {
                val loc = index.location(start)
                throw TemplateException("Unclosed '{%' at ${loc.format()}.")
            }

            val raw = template.substring(start + 2, end).trim()
            val loc = index.location(start)

            // Stop tags for nested parsing
            if (raw in stopTags) {
                pos = end + 2
                return BlockResult(nodes, raw, start)
            }

            when {
                raw.startsWith("if ") -> {
                    val condition = raw.removePrefix("if ").trim()
                    pos = end + 2

                    val thenRes = parseBlock(stopTags = setOf("else", "endif"))
                    val thenNodes = thenRes.nodes

                    when (thenRes.stopTag) {
                        "else" -> {
                            val elseRes = parseBlock(stopTags = setOf("endif"))
                            if (elseRes.stopTag != "endif") {
                                throw TemplateException("Missing '{% endif %}' for if at ${loc.format()}.")
                            }
                            nodes += IfNode(condition, thenNodes, elseRes.nodes, loc)
                        }
                        "endif" -> {
                            nodes += IfNode(condition, thenNodes, null, loc)
                        }
                        null -> {
                            throw TemplateException("Missing '{% endif %}' for if at ${loc.format()}.")
                        }
                        else -> {
                            throw TemplateException("Unexpected tag '${thenRes.stopTag}' near ${loc.format()}.")
                        }
                    }
                }
                // `property` is the spelling; `include` is its deprecated alias, kept so saved documents compile.
                raw.startsWith("property ") || raw.startsWith("include ") -> {
                    val includeExpr = raw.substringAfter(' ').trim()
                    if (includeExpr.isBlank()) {
                        throw TemplateException("Missing property name at ${loc.format()}.")
                    }
                    nodes += IncludeNode(includeExpr, loc)
                }
                raw == "else" || raw == "endif" -> {
                    // If we see these here, they were not expected by any enclosing block.
                    throw TemplateException("Unexpected '{% $raw %}' at ${loc.format()}.")
                }
                raw.isBlank() -> {
                    throw TemplateException("Empty tag at ${loc.format()}.")
                }
                else -> {
                    throw TemplateException("Unknown tag '{% $raw %}' at ${loc.format()}.")
                }
            }

            pos = maxOf(pos, end + 2)
        }

        return BlockResult(nodes, stopTag = null, stopTagStartIndex = null)
    }

    private fun minPositive(a: Int, b: Int): Int = when {
        a == -1 && b == -1 -> -1
        a == -1 -> b
        b == -1 -> a
        else -> minOf(a, b)
    }
}

//////////////////////////// Evaluation ////////////////////////////

source — 259 lines, from line 375
private class Evaluator(
    private val template: String,
    private val index: LineIndex,
    private val rootResolvers: List<Resolver>,
    private val includeResolver: IncludeResolver?,
    private val insertLineDirectiveAfterMultilineExpr: Boolean
) {

    fun render(nodes: List<Node>, currentResolvers: List<Resolver>): String {
        val sb = StringBuilder()
        for (n in nodes) {
            when (n) {
                is TextNode -> sb.append(n.text)
                is ExprNode -> sb.append(evalExprToString(n.expr, n.loc, n.resumeLine, currentResolvers))
                is IncludeNode -> sb.append(evalIncludeToString(n.includeExpr, n.loc, currentResolvers))
                is IfNode -> {
                    val truthy = evalCondition(n.condition, n.loc, currentResolvers)
                    val branch = if (truthy) n.thenBranch else (n.elseBranch ?: emptyList())
                    sb.append(render(branch, currentResolvers))
                }
            }
        }
        return sb.toString()
    }

    private fun evalExprToString(expr: String, loc: Location, resumeLine: Int, resolvers: List<Resolver>): String {
        val trace = mutableListOf<String>()
        val value = resolveExpression(expr, resolvers, trace, depth = 0)
        if (value == null) {
            throw resolutionError(expr, loc, trace)
        }

        val raw = when (value) {
            is String -> value
            else -> value.toString()
        }

        val withLineMagic = applyLineMagic(raw, loc.line)
        return if (insertLineDirectiveAfterMultilineExpr && withLineMagic.contains('\n')) {
            appendLineDirectiveReset(withLineMagic, resumeLine)
        } else {
            withLineMagic
        }
    }

    private fun evalIncludeToString(expr: String, loc: Location, resolvers: List<Resolver>): String {
        val includePath = evaluateIncludePath(expr, loc, resolvers)
        val resolver = includeResolver
            ?: throw TemplateException("Include tag used without include resolver at ${loc.format()}.")
        return try {
            resolver.include(includePath, loc.line, loc.col)
        } catch (e: TemplateException) {
            throw e
        } catch (e: Exception) {
            val msg = e.message?.takeIf { it.isNotBlank() } ?: e::class.simpleName ?: "Unknown include error"
            throw TemplateException("Failed to include '$includePath' at ${loc.format()}: $msg")
        }
    }

    private fun evaluateIncludePath(expr: String, loc: Location, resolvers: List<Resolver>): String {
        val trimmed = expr.trim()
        if (trimmed.length >= 2) {
            if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
                (trimmed.startsWith("'") && trimmed.endsWith("'"))
            ) {
                return trimmed.substring(1, trimmed.length - 1)
            }
        }

        val trace = mutableListOf<String>()
        val value = resolveExpression(trimmed, resolvers, trace, depth = 0)
            ?: throw resolutionError(trimmed, loc, trace)
        return if (value is String) value else value.toString()
    }


    private fun evalCondition(expr: String, loc: Location, resolvers: List<Resolver>): Boolean {
        val trimmed = expr.trim()
        val negated = trimmed.startsWith("not ") || trimmed.startsWith("!")
        val inner = when {
            trimmed.startsWith("not ") -> trimmed.removePrefix("not ").trim()
            trimmed.startsWith("!") -> trimmed.removePrefix("!").trim()
            else -> trimmed
        }

        val trace = mutableListOf<String>()
        val value = resolveExpression(inner, resolvers, trace, depth = 0)

        val truth = when (value) {
            null -> false
            is Boolean -> value
            is Number -> value.toDouble() != 0.0
            is String -> value.isNotEmpty()
            is Map<*, *> -> value.isNotEmpty()
            is Resolver -> true
            is Iterable<*> -> value.any()
            else -> true
        }

        return if (negated) !truth else truth
    }

    private fun applyLineMagic(s: String, line: Int): String {
        val rep = "#line $line"
        // handle both LF and CRLF just in case
        return s
            .replace("//#<line>\r\n", "$rep\r\n")
            .replace("//#<line>\n", "$rep\n")
    }

    private fun appendLineDirectiveReset(s: String, line: Int): String {
        val newline = if (s.contains("\r\n")) "\r\n" else "\n"
        return buildString {
            append(s)
            if (!s.endsWith('\n')) append(newline)
            append("#line $line")
            append(newline)
        }
    }

    private fun resolveExpression(
        expr: String,
        resolvers: List<Resolver>,
        trace: MutableList<String>,
        depth: Int
    ): Any? {
        if (depth > 50) {
            trace += "Resolver chain exceeded max depth (50)."
            return null
        }

        val raw = expr.trim()
        val segments = if (raw.isEmpty()) emptyList() else raw.split(".").map { it.trim() }

        return resolveSegments(segments, resolvers, trace, depth)
    }

    private fun resolveSegments(
        segments: List<String>,
        resolvers: List<Resolver>,
        trace: MutableList<String>,
        depth: Int
    ): Any? {
        val n = segments.size

        for (i in n downTo 0) {
            val prefix = if (i == 0) "" else segments.subList(0, i).joinToString(".")
            val suffix = segments.subList(i, n)

            var scalarCandidate: Any? = null

            for (resolver in resolvers) {
                val result = safeResolve(resolver, prefix, trace)
                if (result == null) continue

                when (result) {
                    is Resolver -> {
                        val childList = listOf(result)
                        val sub = resolveSegments(suffix, childList, trace, depth + 1)
                        if (sub != null) return sub
                        if (suffix.isEmpty()) {
                            val subBlank = resolveSegments(emptyList(), childList, trace, depth + 1)
                            if (subBlank != null) return subBlank
                        }
                    }

                    // NEW:
                    is Map<*, *> -> {
                        val child = MapResolver(result.entries.associate { it.key.toString() to it.value })
                        val childList = listOf(child)
                        val sub = resolveSegments(suffix, childList, trace, depth + 1)
                        if (sub != null) return sub
                        if (suffix.isEmpty()) {
                            val subBlank = resolveSegments(emptyList(), childList, trace, depth + 1)
                            if (subBlank != null) return subBlank
                        }
                        // If you want scalar-prefix fallback to consider the map itself as a value,
                        // don't set scalarCandidate here; typically you'd treat it as structural only.
                    }

                    is Iterable<*> -> {
                        val childResolvers = result.mapNotNull { it as? Resolver }
                        if (childResolvers.isNotEmpty()) {
                            val sub = resolveSegments(suffix, childResolvers, trace, depth + 1)
                            if (sub != null) return sub
                            if (suffix.isEmpty()) {
                                val subBlank = resolveSegments(emptyList(), childResolvers, trace, depth + 1)
                                if (subBlank != null) return subBlank
                            }
                        } else {
                            if (scalarCandidate == null) scalarCandidate = result
                        }
                    }

                    else -> {
                        if (scalarCandidate == null) scalarCandidate = result
                    }
                }

            }

            // NEW: after trying all resolvers at this prefix,
            // if we found a scalar candidate, use it.
            if (scalarCandidate != null) return scalarCandidate
        }

        return null
    }


    private fun safeResolve(resolver: Resolver, key: String, trace: MutableList<String>): Any? {
        return try {
            val out = resolver.resolve(key)
            trace += "${resolverName(resolver)}.resolve('$key') -> ${describeResult(out)}"
            out
        } catch (e: Exception) {
            trace += "${resolverName(resolver)}.resolve('$key') -> threw ${e::class.simpleName}: ${e.message}"
            throw e
        }
    }

    private fun describeResult(out: Any?): String = when (out) {
        null -> "null"
        is String -> "String(${short(out)})"
        is Resolver -> "Resolver(${resolverName(out)})"
        is Map<*, *> -> "Map(${out.size})"
        is Iterable<*> -> {
            val count = out.count { it is Resolver }
            if (count > 0) "List<Resolver>($count)" else "Iterable"
        }
        else -> "${out::class.simpleName}(${short(out.toString())})"
    }

    private fun short(s: String, max: Int = 60): String =
        if (s.length <= max) "\"$s\"" else "\"${s.take(max)}…\""

    private fun resolverName(r: Resolver): String =
        r::class.simpleName ?: r::class.qualifiedName ?: "AnonymousResolver"

    private fun resolutionError(expr: String, loc: Location, trace: List<String>): TemplateException {
        val lineText = index.lineText(loc.line)
        val caret = buildString {
            val safeCol = maxOf(1, loc.col)
            repeat(safeCol - 1) { append(' ') }
            append('^')
        }

        val attempts = trace.joinToString(separator = "\n") { "  - $it" }

        val msg = buildString {
            append("Unable to resolve expression '$expr' at ${loc.format()}.\n")
            append("Line ${loc.line}: $lineText\n")
            append("          $caret\n")
            append("Tried:\n")
            append(attempts)
        }
        return TemplateException(msg)
    }
}

//////////////////////////// Line/Location helpers ////////////////////////////

source — 51 lines, from line 639
private data class Location(val line: Int, val col: Int) {
    fun format(): String = "line $line, col $col"
}

private class LineIndex(private val text: String) {
    private val lineStarts: IntArray = buildLineStarts(text)

    fun location(index: Int): Location {
        val line = findLine(index)
        val lineStart = lineStarts[line - 1]
        val col = (index - lineStart) + 1
        return Location(line, col)
    }

    fun lineText(line: Int): String {
        if (line < 1) return ""
        val start = lineStarts.getOrNull(line - 1) ?: return ""
        val endExclusive = if (line < lineStarts.size) lineStarts[line] - 1 else text.length
        val raw = text.substring(start, endExclusive.coerceAtLeast(start))
        return raw.trimEnd('\r', '\n')
    }

    private fun findLine(index: Int): Int {
        var lo = 0
        var hi = lineStarts.size - 1
        var ans = 0
        while (lo <= hi) {
            val mid = (lo + hi) ushr 1
            val v = lineStarts[mid]
            if (v <= index) {
                ans = mid
                lo = mid + 1
            } else {
                hi = mid - 1
            }
        }
        return ans + 1 // 1-based
    }

    private fun buildLineStarts(s: String): IntArray {
        val starts = ArrayList<Int>()
        starts.add(0)
        for (i in s.indices) {
            if (s[i] == '\n') {
                val next = i + 1
                if (next < s.length) starts.add(next)
            }
        }
        return starts.toIntArray()
    }
}

//////////////////////////// Helpful resolver implementations ////////////////////////////

private class FnResolver(private val fn: ResolverFn) : Resolver {
    override fun resolve(key: String): Any? = fn(key)
}

class MapResolver(private val map: Map<String, Any?>) : Resolver

Map-backed resolver. - Looks up exact keys. - If the stored value is a Map, it returns a MapResolver to enable hierarchical resolution. - If the stored value is already a Resolver or Iterable of resolvers, returns it as-is.

class MapResolver(private val map: Map<String, Any?>) : Resolver {

override fun resolve(key: String): Any?

Looks key up in the map, wrapping a nested Map as another MapResolver so the rest of a dotted path resolves against it. An absent key is null, which hands the question on.

    override fun resolve(key: String): Any? {
        val v = map[key]
        return when (v) {
            is Map<*, *> -> MapResolver(v.entries.associate { it.key.toString() to it.value })
            else -> v
        }
    }
}

class CompositeResolver(private val resolvers: List<Resolver>) : Resolver

A resolver that asks each of its children in turn and takes the first non-null answer.

The engine already chains resolvers — a resolver may answer with a list of them, and the entrypoints accept an iterable — so this is for presenting a fixed chain as one Resolver, where something wants a single resolver and the order is known in advance.

class CompositeResolver(private val resolvers: List<Resolver>) : Resolver {

override fun resolve(key: String): Any?

Asks each child in turn and returns the first non-null answer, or null if none of them answers.

    override fun resolve(key: String): Any? {
        for (r in resolvers) {
            val v = r.resolve(key)
            if (v != null) return v
        }
        return null
    }
}

//////////////////////////// Errors ////////////////////////////

class TemplateException(message: String) : RuntimeException(message)

The one exception this engine throws, for every fault from an unclosed {{ to a path that no resolver answered.

Its message is written to be read on its own: a resolution failure names the expression, the line and column, prints the offending line with a caret under it, and lists every key tried against every resolver with what each returned. A shader whose slot is wrong therefore fails before shaderc sees it, with an explanation, rather than compiling to the wrong thing.

//////////////////////////// Representative main() ////////////////////////////

fun main()

Expands a small template against two maps and a facade resolver, and prints the result to the info,templating log channel.

Nothing in Field calls this. It is a runnable example of the three resolver shapes in one place: nested maps addressed as {{ foo.bar.gux }}, a fallback map consulted when the first has no answer, and the facade trick — a resolver that answers the empty key with a list of resolvers, which is how a chain is presented as a single one.

body — 56 lines, from line 764
fun main() {
    // A nested map example. MapResolver will auto-wrap nested maps into child resolvers.
    val data = mapOf(
        "name" to "Marc",
        "isAdmin" to true,
        "foo" to mapOf(
            "bar" to mapOf(
                "gux" to "Hello from gux!"
            ),
            "bar2" to 123
        )
    )

    val defaults = mapOf(
        "name" to "Stranger",
        "global" to "This is a fallback value"
    )

    val base = MapResolver(data)
    val fallback = MapResolver(defaults)

    // A "facade" resolver that exposes a list of resolvers when asked for "".
    // This demonstrates your "blank-string" handoff trick.
    val facade = object : Resolver {
        override fun resolve(key: String): Any? {
            return if (key.isEmpty()) listOf(base, fallback) else null
        }
    }

    val template = """
Hello {{ name }}!

Direct leaf: {{ foo.bar.gux }}
Number leaf: {{ foo.bar2 }}


{% if isAdmin %}
Welcome, admin {{ name }}.
{% else %}
Welcome, user {{ name }}.
{% endif %}

{% if not foo.bar.missingThing %}
This shows because the condition is not truthy.
{% endif %}

Global fallback via facade: {{ global }}
""".trimIndent()

    try {
        val out = runTemplate(template, facade)
        lprintln("info,templating", out)
    } catch (e: TemplateException) {
        lprintln("error,templating", "Template error:\n${e.message}")
    }
}