How DynamicValue Lazy Evaluation Works for Body Arguments in Quarkdown
Quarkdown defers parsing of body arguments by wrapping them in a DynamicValue that stores the raw text and caller's evaluation context, only materialising the Markdown AST when a function explicitly consumes the content.
In the Quarkdown markup language (iamgio/quarkdown), body arguments—the indented blocks following function calls—use DynamicValue lazy evaluation to balance performance and lexical scoping. Rather than parsing these blocks immediately, the compiler stores the raw text alongside its original evaluation context. This ensures that variables defined outside a lambda remain accessible when the body finally executes, while avoiding the cost of parsing unused content.
Stage 1: Extraction in FunctionCallRefiner
The lazy evaluation process begins when the parser detects a trailing indented block. In quarkdown-core/src/main/kotlin/com/quarkdown/core/parser/FunctionCallRefiner.kt (lines 59-68), the extractArguments() method creates a plain-text DynamicValue without triggering the Markdown parser.
// Inside FunctionCallRefiner.extractArguments()
call.bodyArgument?.takeUnless { it.value.isBlank() }?.value?.let { body ->
// Body is plain, indented text – store it lazily
val value = DynamicValue(body) // <- no parsing yet
arguments += FunctionCallArgument(value, isBody = true)
}
At this stage, the DynamicValue holds only the raw string and carries no context. The argument is marked with isBody = true to distinguish it from standard arguments.
Stage 2: Propagation via Lambda.invokeDynamic
When a lambda receives a body argument, it must preserve the caller's variable scope. Inside quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/data/Lambda.kt (lines 74-78), the invokeDynamic method forks a new context that inherits the caller's libraries and attaches this context to the DynamicValue.
// Inside Lambda.invokeDynamic(...)
val context = parentContext.fork() // Fresh fork
context.libraries += createLambdaParametersLibrary(actualArguments) // Lambda params
if (callingContext is MutableContext) {
context.libraries += callingContext.libraries // Bring caller's symbols
}
return action(actualArguments, context) // Body will now see the right scope
Specifically, the createLambdaParametersLibrary step initializes the DynamicValue with:
DynamicValue(argument.unwrappedValue, evaluationContext = call.context)
This forked evaluation context ensures that when the body eventually parses, it can resolve references to variables defined outside the lambda.
Stage 3: Materialisation in NodeOutputValueVisitor
The final materialisation occurs when a function actually consumes the body content. In quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/output/node/NodeOutputValueVisitor.kt (lines 81-92), the visitor detects a DynamicValue and lazily parses the raw string using the stored evaluationContext.
override fun visit(value: DynamicValue): Node =
when (value.unwrappedValue) {
is OutputValue<*> -> value.unwrappedValue.accept(this)
is Iterable<*> -> GeneralCollectionValue(value.unwrappedValue as Iterable<OutputValue<*>>).accept(this)
is Node -> value.unwrappedValue
else -> this.visit(parseRaw(value.unwrappedValue.toString(),
value.evaluationContext)) // <- Lazy parse happens here
}
The parseRaw method delegates to ValueFactory.blockMarkdown, which tokenises and parses the content using the preserved context. If evaluationContext is null, the visitor falls back to its own context, but body arguments typically retain the caller's context, guaranteeing correct variable resolution.
Practical Examples
Example 1: Basic Body Argument Resolution
.doctype {plain}
.title {Demo}
.container
.function {arg}
.bold {arg}
Here, the .function call creates a Lambda with a body containing .bold {arg}. The body is stored as a DynamicValue during extraction. When invoked, the lambda's forked context contains the parameter arg, allowing the body to parse and resolve the reference correctly.
Example 2: Lexical Scoping with Outer Variables
.doctype {plain}
.title {Scope test}
.var x: 42 // Variable defined in outer scope
.myfunc
.text {x} // Body refers to `x`
The .myfunc lambda receives the indented block as a DynamicValue. During propagation, Lambda.invokeDynamic copies the caller's libraries (including variable x) into the forked context. When materialisation occurs, x resolves to 42 despite the body being written inside the lambda.
Example 3: Nested Function Calls and Lazy Expansion
.doctype {plain}
.title {Lazy}
.list
.item { .bold {Hello} } // Nested call inside body
The .list body contains a nested .bold call. Because the body is a DynamicValue, the inner call is not executed during initial extraction. Only when a consumer (such as .list itself) processes the body does NodeOutputValueVisitor trigger parsing, expand .bold, and produce the final AST.
Key Implementation Files
FunctionCallRefiner.kt– Detects body arguments and wraps them inDynamicValuewithout parsing (source).Lambda.kt– Forks execution contexts and attaches the caller's libraries to body arguments (source).NodeOutputValueVisitor.kt– ConsumesDynamicValueoutputs and triggers lazy parsing with stored contexts (source).DynamicValue.kt– Defines the generic wrapper that carries optionalevaluationContext(source).ValueFactory.kt– ProvidesblockMarkdownandinlineMarkdownhelpers for materialising raw strings into AST nodes.
Summary
- DynamicValue lazy evaluation defers parsing of body arguments until consumption, improving compiler performance by avoiding work on unused blocks.
- The extraction stage wraps raw body text in a
DynamicValuewithout context or parsing, occurring inFunctionCallRefiner. - The propagation stage forks the caller's context and attaches it to the
DynamicValueinsideLambda.invokeDynamic, ensuring lexical scoping. - The materialisation stage parses the raw content using the stored context when
NodeOutputValueVisitorprocesses the value. - This architecture allows body arguments to reference variables from their definition scope while maintaining efficient compilation.
Frequently Asked Questions
When does a body argument get parsed in Quarkdown?
A body argument is parsed only when a function or visitor explicitly consumes the DynamicValue as Markdown content. This typically happens in NodeOutputValueVisitor when it detects a raw string inside a DynamicValue and calls parseRaw with the stored evaluationContext. Until this materialisation step, the content remains a plain string.
Why does DynamicValue need to carry an evaluationContext?
The evaluationContext preserves the lexical scope from where the body argument was defined. Without it, variables or functions referenced inside the body would resolve against the wrong scope when parsing finally occurs. By forking the caller's context during Lambda.invokeDynamic, the DynamicValue ensures that outer variables remain accessible even when the body executes inside a different lambda.
Can DynamicValue store non-string content?
Yes. While body arguments typically store raw Markdown strings, DynamicValue is a generic wrapper that can hold any OutputValue. When NodeOutputValueVisitor encounters a DynamicValue, it checks the unwrapped type—if it is already an OutputValue, Iterable, or Node, it processes it directly. Only raw strings trigger the lazy Markdown parsing via ValueFactory.blockMarkdown.
What happens if evaluationContext is null during materialisation?
If the evaluationContext property is null when NodeOutputValueVisitor processes the DynamicValue, the visitor falls back to its own current context. However, in standard body argument flows, the context is populated during the propagation stage in Lambda.invokeDynamic, ensuring proper variable resolution. Null contexts typically occur only when DynamicValue is created manually outside the standard function call refinement pipeline.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →