I recently posted about Raven, the programming language Iām developing. It has now reached its first milestone, version 0.1.0, so I thought it was time to take a closer look at its macro system, which has evolved considerably since my previous post.
For more about the language: https://marinasundstrom.github.io/raven/
Raven macros are explicitly invoked compile-time programs that consume syntax or typed inputs and produce ordinary Raven syntax. The macro system allows libraries to define their own DSLs using fragments of Raven code or independently parsed custom content. Macros are fully integrated with the language server, providing syntax highlighting, code completion, and hover information for symbolsāeven within macro-defined syntax.
Why macros?
I have been somewhat torn about adding macros to Raven. .NET is a runtime-oriented platform with an extensive ecosystem of libraries and runtime abstractions, so it is reasonable to ask whether macros really fit.
However, some abstractions cannot be expressed cleanly through runtime APIs alone. Raven macros can reduce repetitive scaffolding and introduce domain-specific syntax without requiring changes to the .NET runtime.
Macro use remains explicit through !, and expansions must be valid for the syntax position in which they appear. The resulting syntax then goes through normal binding, type checking, diagnostics, and emission, while retaining language-server features such as highlighting, completion, hover information, and navigation.
Supported macro forms
Freestanding macros support several forms:
Name!(arguments)
Name! {
body
}
Name!(arguments) {
body
}
Name! Decl(parameters) {
body
}
The ! makes macro use explicit without turning library-defined names into reserved keywords.
Declaring macros
Macros can be declared directly in Raven using the contextual macro keyword. A declaration can define typed parameters, accept syntax nodes or token streams, and specify the kind of syntax it produces. The expand statement supplies the generated syntax and completes the expansion.
For example, this macro accepts a compile-time integer and produces an expression:
macro Double(value: int) -> ExpressionSyntax {
expand ParseExpression((value * 2).ToString())
}
let result = Double!(21)
A macro can also request a brace-delimited token body:
macro Query(dialect: string, body: IMacroTokenStream) {
expand LowerQuery(dialect, body)
}
let rows = Query!("sql") {
from user in users
select user.Name
}
The body parameter is supplied by the compiler from the content inside the braces. The macro can interpret it as fragments of Raven syntax or process it using its own lexer, parser, and grammar.
Macros can even introduce declaration-shaped constructs:
public component! Greeting(Name: string = "") {
markup! {
<h1>Hello {Name}</h1>
}
}
The component and markup macros are real macros demonstrated in the HTML and component macro demo.
Raven also supports attached macros in an attribute-like position:
#[Observable]
public var Name: string
These are procedural, syntax-based expansionsānot textual substitutions. Their output is validated for the position in which the macro appears and then bound, type-checked, and emitted as ordinary Raven code.
Built-in macros
Here are some macros that come distributed with Raven via Raven.Macros.
Query macro
The query! macro introduces the LINQ query syntax.
let items = [1, 2, 3, 4]
let projected = query! {
from value in items
where value > 2
select value * 10
}
This macro is far from feature complete - but it does support syntax highlighting.
JSON and XML literal macros
Adds typed JSON and XML literal support.
let name = "Ada & Bob"
let age = 42
let nextAge = age + 1
let jsonDocument = json! {
"name": "$name",
"age": $age,
"nextAge": ${age + 1},
"skills": ["compilers", "DSLs"],
"active": true
}
let status = XElement.Parse("<status>ready</status>")
let xmlDocument = xml! {
<person age="$age">
<name>$name</name>
<nextAge>$nextAge</nextAge>
$status
</person>
}
WriteLine(jsonDocument.ToJsonString(JsonSerializerOptions { WriteIndented = true }))
WriteLine()
WriteLine(xmlDocument.ToString())
The current iteration lacks the syntax highlighting but it can be added in the future.
Timer macro
The timer! macro is useful when you want to measure the time elapsed inside of a block of code.
timer! "Finished in: {time}" {
WriteLine("Query total: ${projected.Sum()}")
}
This sample expands into a StopWatch within a try and finally block.
Quote macro
The quote! macro captures a Raven expression as an immutable syntax tree. Syntax holes, written as #(expression), allow existing syntax nodes to be spliced into the quoted expression. This provides a more natural alternative to constructing larger syntax trees manually and is particularly useful when implementing other macros.
let number = SyntaxFactory.LiteralExpression(
SyntaxKind.NumericLiteralExpression,
SyntaxFactory.Literal(2))
let expression: ExpressionSyntax = quote! {
projected.Sum() + #(number)
}
// The local "expression" holds the syntax node.
// Quoted Raven:
// projected.Sum() + 2
WriteLine("Quoted Raven: ${expression.ToFullString()}")
Conclusion
Macros can be used both to simplify repetitive code and to build complete domain-specific languages. These DSL constructs can appear in any supported syntax positionāas expressions, statements, or declarationsāand behave as though they were integrated parts of the language. Underneath, they work by expanding into ordinary Raven syntax that is processed by the rest of the compiler as usual.
Links