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 whateverexprresolves to.{% if expr %}…{% else %}…{% endif %}, nested freely.not exprand!exprnegate. 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}")
}
}