How to Debug Function Call Resolution Failures in Quarkdown's Compiler
To debug function call resolution failures in Quarkdown, trace the four-stage pipeline from FunctionCallRefiner through BaseContext.resolve to UncheckedFunctionCall, verifying that the function name matches the @Name annotation and that the containing library is registered in the Context.
When the Quarkdown compiler throws an UnresolvedReferenceException, the failure occurs within a specific resolution pipeline that transforms parsed syntax into executable function calls. Understanding how to debug function call resolution failures in Quarkdown requires familiarity with the internal stages that resolve a FunctionCallNode into a concrete FunctionCall. This guide walks through the pipeline architecture and provides actionable debugging steps using the actual source code from the iamgio/quarkdown repository.
The Four Stages of Resolution
The compiler processes function calls through a strict pipeline. A failure at any stage produces the "unresolved reference" error. The stages are:
- Parsing → Refinement: Converts a syntactic
WalkedFunctionCallinto a context-awareFunctionCallNodeinFunctionCallRefiner.kt(lines 30–90). - Resolution: Looks up the function name in the current
Context’s libraries viaBaseContext.resolve(lines 89–100). - Unchecked Delegate: Wraps the optional result in an
UncheckedFunctionCallthat throwsUnresolvedReferenceExceptionif the lookup returnsnull, implemented inBaseContext.resolveUnchecked(lines 102–103) andUncheckedFunctionCall.kt(lines 16–34). - Execution & Mapping: Expands the call to an AST node in
FunctionCallNodeExpander.expand(lines 31–48).
If any step cannot locate the function, the compiler throws UnresolvedReferenceException as defined in UnresolvedReferenceException.kt.
Debugging Checklist
Verify the Function Name Against @Name Annotations
The name the parser sees is the literal identifier used for lookup. In FunctionCallRefiner.toNode(), the node is created with:
val node = FunctionCallNode(context, call.name, …)
Ensure your Quarkdown source uses the exact name defined in the function's annotation. The @Name annotation determines the Quarkdown-visible identifier; if omitted, the Kotlin function name is used.
@Name("myfunction")
fun myFunction(...): StringValue { … }
Confirm Library Registration in the Context
Libraries must be registered in the Context before parsing begins. The lookup sequence in BaseContext iterates through Context.libraries:
override fun getFunctionByName(name: String): Function<*>? =
libraries.asSequence()
.flatMap { it.functions }
.find { it.name == name }
Check your PipelineOptions configuration to ensure the library containing your function is included in the libraries set. Custom libraries must be added to the pipeline before execution starts.
Inspect the Resolution Call with Logging
To pinpoint where resolution fails, insert temporary logging or breakpoints in FunctionCallNodeExpander.expand:
val call = node.context.resolveUnchecked(node) // ← this line
println("Resolving '${node.name}' → ${call.javaClass.simpleName}")
If the UnresolvedReferenceException is thrown, this log will not appear, confirming the failure occurs during the resolveUnchecked call in BaseContext.kt.
Validate Chained Call Logic
Chained calls such as .a {x}::b {y} resolve the inner call first and pass it as an argument to the outer function. In FunctionCallRefiner.toNode():
val call: UncheckedFunctionCall<*> = context.resolveUnchecked(node) // A
val initialArguments = listOf(FunctionCallArgument(call)) // A as argument for B
If the inner call a fails resolution, the outer call b never executes. Test inner calls in isolation to identify which link in the chain is broken.
Handle Body Argument Reparsing
Functions accepting body arguments receive DynamicValue that is not automatically re-lexed. If nested calls inside body content remain unevaluated, explicitly re-parse the content:
@Name("wrap")
fun wrap(@LikelyBody content: DynamicValue, @Injected context: Context): OutputValue<*> {
val parsed = ValueFactory.blockMarkdown(content.value, context)
return Paragraph(parsed).wrappedAsValue()
}
Without this explicit re-lexing using ValueFactory.blockMarkdown, inner function calls like .bold {Important} inside the body will not expand.
Common Pitfalls
| Symptom | Likely Cause | Fix |
|---|---|---|
"Unresolved function foo" though foo is defined in a custom library |
Library not added to Context.libraries |
Register the library when building the pipeline: pipelineOptions.libraries += myLib |
| Function works in inline form but fails in body form | Body arguments are DynamicValue not automatically re-lexed |
Use ValueFactory.blockMarkdown(body, context) inside the function implementation |
Chained call .a::b fails with "Unresolved function a" |
The inner call a is missing or incorrectly named |
Test .a alone to verify its registration |
| No error message, but content renders as plain text | Function resolved but returned an empty DynamicValue |
Ensure the function returns a proper OutputValue (e.g., Paragraph(...).wrappedAsValue()) |
Practical Debugging Examples
Reproducing an Unresolved Reference
Create a file example.qd:
.doNotExist {42}
Running the compiler yields:
Error: Unresolved function: doNotExist
This occurs because doNotExist is not present in any loaded library, causing BaseContext.resolve to return null, which triggers the UnresolvedReferenceException in UncheckedFunctionCall.
Registering a Custom Function
Define a library in MyLib.kt:
package com.example
import com.quarkdown.core.function.Function
import com.quarkdown.core.function.output.StringValue
import com.quarkdown.core.function.annotation.Name
val MyLib = Library("my-lib") {
function(::hello)
}
@Name("hello")
fun hello(@Name("name") name: String): StringValue =
StringValue("Hello, $name!")
Register it in the pipeline:
val pipeline = Pipeline.builder()
.libraries(setOf(Stdlib, MyLib))
.build()
Now .hello {World} renders correctly. If MyLib is omitted from the libraries set, the compiler throws UnresolvedReferenceException for "hello".
Debugging Chained Calls
For the chain .foo {123}::bar {456}, add logging inside FunctionCallRefiner.toNode():
println("Refining '${call.name}' – inner call resolved? ${context.resolveUnchecked(node)}")
When the inner call cannot resolve, the log shows null, indicating that foo is not registered in Stdlib.kt or your custom library.
Handling Body Content
To ensure nested calls inside body arguments expand:
@Name("wrap")
fun wrap(@LikelyBody content: DynamicValue, @Injected context: Context): OutputValue<*> {
val parsed = ValueFactory.blockMarkdown(content.value, context)
return Paragraph(parsed).wrappedAsValue()
}
This allows .wrap followed by indented content containing .bold {Text} to properly evaluate the inner bold function.
Summary
- Function resolution in Quarkdown follows a four-stage pipeline: refinement, resolution, unchecked delegation, and expansion.
BaseContext.resolvesearches all registeredLibraryinstances for a function matching the@Nameannotation or Kotlin method name.UnresolvedReferenceExceptionis thrown byUncheckedFunctionCallwhenBaseContext.resolvereturnsnull.- Chained calls resolve inner-to-outer; failure in the inner call prevents the outer call from executing.
- Body arguments require explicit re-parsing via
ValueFactory.blockMarkdownto evaluate nested function calls.
Frequently Asked Questions
What causes an UnresolvedReferenceException in Quarkdown?
An UnresolvedReferenceException occurs when BaseContext.resolve cannot find a function matching the requested name in any loaded library. This happens if the function name is misspelled, the @Name annotation differs from the call site, or the library containing the function was not added to the pipeline's Context.libraries set before parsing began.
How do chained function calls resolve in Quarkdown?
Chained calls like .a {x}::b {y} resolve from left to right. The compiler first resolves a using FunctionCallRefiner.toNode(), then passes the result as the first argument to b via context.resolveUnchecked. If a is not found, the exception is thrown before b is attempted, making it necessary to verify the innermost call first when debugging chains.
Why is my custom library function not found despite being defined?
Custom library functions require explicit registration in the pipeline. Even if the Kotlin code compiles, the Library instance must be added to the libraries set in PipelineOptions before the pipeline executes. The lookup logic in BaseContext only searches libraries present in this set at pipeline initialization.
How do I debug body arguments that contain unevaluated function calls?
Body content arrives as DynamicValue and is not automatically re-parsed. To debug, ensure your function implementation uses ValueFactory.blockMarkdown(content.value, context) to re-lex the body content, allowing the FunctionCallNodeExpander to process nested calls. Without this step, function calls inside body arguments remain as literal text.
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 →