Macros
Note
Status: work in progress. This page documents the macro behavior currently implemented by the compiler, not a stable compatibility contract. Macro syntax, the authoring and activation model, compiler APIs, and tooling integration may change as the design develops.
Overview
Raven supports attached declaration macros and freestanding expression macros. A macro is a compiler-driven expansion that produces ordinary Raven syntax before normal semantic analysis continues.
Use a macro when code needs to generate or transform Raven declarations or expressions—something an ordinary function cannot do. A macro expands to normal Raven syntax before the compiler analyzes the program.
Macros are distinct from .NET attributes:
[Serializable]is an attribute.#[Observable]is a macro.
Macros are compiler plugins. Macro resolution and expansion are owned by
Compilation and occur during binding; creating a Workspace is not required.
Analyzers and generators are instead workspace plugins whose discovery and
orchestration belong to a workspace or build host. A project system may resolve
a macro asset, but it passes that asset to the compiler and does not own the
macro's semantic execution.
An analyzer may optionally query compiler-provided retained structure for a
macro that explicitly supplies it. An ExpressionSyntax embedded in that
structure can trigger ordinary Raven expression analysis when an analyzer host
is present. If a macro does not retain structure, the query returns no
structure and analyzers must not infer one from raw tokens or expansion output.
Compiling and expanding a macro must not require a workspace or any analyzer to
be loaded.
Macros are resolved from compiler-plugin assemblies. Their meaning is defined
by the referenced macro implementation, not by the parser. A reusable Raven
macro project marks its assembly with RavenCompilerPlugin, preferably naming
each exported macro definition explicitly:
[assembly: RavenCompilerPlugin(typeof(QueryMacro))]
The same attribute and contracts can be used from a C# compiler-plugin project.
Apply the marker once for each macro definition intentionally exported from the
assembly. The declared type must be a non-abstract macro implementation in the
marked assembly with a public parameterless constructor. A bare
[assembly: RavenCompilerPlugin] marker authorizes fallback discovery of
concrete IMacroDefinition implementations.
Explicit and bare markers cannot be mixed. The marker is an inter-assembly
export boundary, not part of declaring a same-project macro. Consumers use a
normal project reference.
MacroKind is compiler-owned classification metadata. Concrete macro classes
do not implement a Kind property. MacroFacts.GetKind derives it from the
single category-specific interface implemented by the definition:
IAttachedDeclarationMacroimpliesAttachedDeclarationIFreestandingExpressionMacroimpliesFreestandingExpressionITokenTreeExpressionMacroimpliesFreestandingExpression
Target applicability belongs to IAttachedDeclarationMacro. Attached
definitions expose Targets; freestanding and token-tree definitions do not
declare a redundant MacroTarget.None property. Code that handles the common
IMacroDefinition surface can use MacroFacts.GetTargets, which returns
MacroTarget.None for non-attached definitions.
Attached macro syntax
An attached macro uses a # directly followed by an attribute list:
#[Observable]
var Title: string
The # token is part of the macro syntax. It is not optional.
Disambiguation with directives
# starts a macro attribute only when it is immediately followed by [.
Examples:
#[Observable]
var Title: string
#pragma warning disable RAV0103
#pragma and other directive forms remain directives. They do not parse as macros.
Freestanding macro syntax
A freestanding expression macro uses #name(...) in expression position:
func Main() -> int => #answer()
The expression expands to an ordinary Raven expression before normal expression binding continues.
A token-tree expression macro uses a raw brace-delimited body:
func Main() -> string => query! {
from user in users
where user.IsActive
select user.Name
}
The compiler recognizes and balances the invocation envelope, but does not run the body through ordinary Raven tokenization. The body is preserved as authored so a macro can implement a custom DSL lexer/parser without producing unrelated Raven lexer diagnostics.
TokenTreeMacroContext exposes the raw body text, its authored
BodySpan, body-relative diagnostic helpers, and Raven expression or statement
parsing for the complete body or a selected body-relative span. This supports
both complete custom parsing and hybrid DSLs with embedded Raven fragments.
ParseExpression() and ParseExpression(span) return recovered Raven syntax
directly. The corresponding ParseExpressionResult overloads return a
MacroSyntaxParseResult<ExpressionSyntax> containing that syntax, immutable
native parser diagnostics, and HasErrors. These diagnostics retain locations
in the authored invocation tree and may be forwarded through
FreestandingMacroExpansionResult.Diagnostics.
ParseStatement() and ParseStatement(span) provide the equivalent
syntax-only API for one complete Raven statement. Their
ParseStatementResult counterparts return
MacroSyntaxParseResult<StatementSyntax> with native diagnostics mapped to the
authored body. Both expression and statement helpers reject trailing input.
FreestandingMacroExpansionResult.FromExpression(...) creates an expression
result, optionally forwarding native parser diagnostics.
FromDiagnostic(...) and FromDiagnostics(...) create macro-authored,
native-parser, or combined diagnostic results without requiring property
initializers. Empty represents an explicit no-change result. Mutable
properties remain available for compatibility.
Attached declaration results use the corresponding
MacroExpansionResult.FromReplacement(...), FromIntroducedMembers(...),
FromPeerDeclarations(...), FromDiagnostic(...), and
FromDiagnostics(...) factories. Replacement overloads may include introduced
members and peer declarations. MacroExpansionResult.Empty represents no
declaration change.
Token-tree expression macros implement ITokenTreeExpressionMacro. They may
accept a typed argument list before the body by implementing
ITokenTreeExpressionMacro<TParameters>:
let result = query!(Dialect: "sql") {
from user in users
select user.Name
}
Token-tree macros retain a compatible hash spelling:
let result = #query(Dialect: "sql") {
from user in users
select user.Name
}
The two spellings have the same binding and expansion semantics. The syntax
tree represents them as BangMacroExpressionSyntax and
HashMacroExpressionSyntax, respectively, under the shared abstract
FreestandingMacroExpressionSyntax base. The bang spelling requires a
brace-delimited token-tree body. There must be no line break between the macro
name and !, or between ! (or its argument list) and the opening brace; this
lookahead keeps ordinary postfix ! expressions unambiguous.
The bang spelling is the preferred source form. A freestanding macro is a
parsed expression invocation that expands an owned region of syntax; it is not
a preprocessor directive. Directive-looking syntax remains appropriate for
lexical compilation controls such as #if, while name! { ... } preserves the
ordinary flow of expression code.
The compiler binds the parenthesized arguments into context.Parameters while
leaving the brace-delimited body unrestricted and available through the same
raw text and token-stream APIs. A non-generic token-tree macro rejects supplied
arguments. A token-tree macro must be invoked with braces; an argument-based
macro must be invoked with parentheses.
Expression quotes
quote! { expression }, equivalently #quote { expression }, is a
compiler-owned token-tree macro. It captures one
complete Raven expression as syntax data and expands to ordinary, fully
qualified SyntaxFactory construction code. Tokens and trivia are preserved.
Parser diagnostics, trailing input, and incomplete recovery are rejected at
locations within the authored body.
Within an expression quote, #(expression) inserts the resulting
ExpressionSyntax into the quoted structure. The # and ( are adjacent,
the hole contains exactly one complete ordinary Raven expression, and multiple
holes are permitted. Hole expressions are validated by Raven's parser at their
authored locations and type-checked through the ordinary generated expansion.
No splice-specific token kind is introduced.
The result is a runtime ExpressionSyntax value from
Raven.CodeAnalysis. The Raven compiler and SDK project integration add the
compiler-matched runtime assembly only when quote! or compile! is present.
An explicit compatible project reference is respected instead. Hosts that use
the Compilation API directly must provide the reference themselves.
Statement, member, declaration, token, identifier, list, and repetition
quote/splice forms are not part of the current language.
quote!/#quote is syntax quotation, analogous in shape to the
compiler-integrated
operation quotation that converts a target-typed lambda to
Expression<TDelegate>. The representations are different: expression trees
contain standardized .NET operations and no source syntax, while #quote
produces Raven syntax with its tokens and trivia. The resulting syntax object
can be traversed and rewritten into a new immutable tree using
Raven.CodeAnalysis; it is not semantically bound until inserted into a
compilation.
Runtime expression compilation
compile<TDelegate>! { expression } constructs an ExpressionSyntax using
the same quotation and #(expression) hole rules as quote!, compiles that
syntax at runtime, and returns TDelegate:
let increment = compile<System.Func<int, int>>! {
value => #(SyntaxFactory.IdentifierName("value")) + 1
}
Exactly one explicit type argument is required, and it must resolve to a delegate type. The quoted expression must evaluate to that delegate type. The explicit type is part of the ordinary generated generic call, so normal Raven binding validates it; the current macro model does not separately model strongly typed generic macro parameters.
The quote phase parses the authored body during macro expansion and rejects a structurally invalid or incomplete Raven expression. At runtime, after hole values have produced the final syntax tree, the compile phase parses the generated wrapper, binds names and types, and emits it. No delegate is returned unless both the structural and semantic checks succeed.
Runtime compilation references the platform assemblies, assemblies already
loaded by the process, and any additional metadata references passed to
RavenCompiler.Compile. Compilation errors throw
RavenCompilationException, whose Diagnostics property contains the Raven
diagnostics. Each successful compilation loads a generated assembly into the
default assembly load context. Callers should therefore cache delegates for
repeated use and must treat syntax derived from untrusted input as executable
code.
The intrinsic expands into an ordinary call to
Raven.CodeAnalysis.RavenCompiler.Compile<TDelegate>. SDK builds inject and
copy the compiler-matched Raven.CodeAnalysis assembly and its runtime
compiler dependencies only when needed. If the project already references
Raven.CodeAnalysis, its reference and copy-local policy remain authoritative.
Direct Compilation API hosts must provide the assembly reference and arrange
runtime deployment themselves.
The raw body is the source of truth. Any standard Raven token stream,
macro-local keyword overlay, custom lexer token stream, or custom DSL syntax
tree is derived from that body and remains scoped to the macro invocation.
Macro-local token kinds do not alter ordinary Raven lexing or SyntaxKind.
Token streams
TokenTreeMacroContext.CreateTokenStream() returns the stream selected for the
resolved macro. Streams implement IMacroTokenStream and emit SyntaxToken
values with body-relative positions.
By default, Raven uses its normal lexer over the macro body. A macro can
implement IMacroKeywordProvider to reclassify selected identifier text with a
provider-owned RawKind and keyword or reserved-word metadata. The token keeps
its ordinary Raven IdentifierToken kind, so the overlay does not change
normal Raven grammar or lexing.
A macro with a genuinely different lexical grammar can implement
IMacroTokenStreamProvider. The compiler discovers that capability with the
macro definition and uses the returned custom stream instead of the default
Raven-backed stream. Fully custom tokens may use SyntaxKind.None plus their
provider-owned raw kind.
Equal raw-kind integers from different macro providers do not imply equal token kinds. The provider owns their meaning.
The minimal direct-lowering pattern does not require a custom syntax tree. For
example, a macro can mark unless as a body-scoped keyword, consume it from the
standard stream, parse the remaining body-relative span as a Raven expression,
and return an ordinary logical-negation expression:
let shouldRetry = guard! {
unless retryCount < 3
}
This pattern is the starting point for DSLs with multiple clauses and embedded Raven fragments. Retained DSL structure is optional and can be added later when editor tooling or more involved lowering requires it.
A macro may identify several fragment spans from the same stream. For example,
the sample choose! macro treats test, then, and otherwise as
macro-reserved clause words, parses the text between them as three Raven
expressions, and lowers them directly to an ordinary if expression. Clause
words are not added to Raven's global keyword set.
The initial LINQ-like sample supports:
let result = query! {
from item in source
where item.IsActive
select item.Name
}
It lowers directly to source.Where(item => item.IsActive).Select(item => item.Name). The where clause is optional. The authored range variable is
used as the generated lambda parameter; the macro does not introduce hidden
temporary names. This sample shape is not part of Raven's ordinary grammar and
does not add from or select to the global keyword set.
Placement rules
Macro attributes follow the same placement rules as declaration attributes:
- A macro attribute may appear only directly before a declaration.
- No blank line may separate the macro attribute from the declaration it applies to.
- Multiple attribute lists may appear before the same declaration.
- Normal attributes and macro attributes may be mixed in the declaration prelude.
- Union case declarations are type declarations for attached macro target validation, so macros that target
MacroTarget.Typemay be applied tocasedeclarations.
Example:
[Obsolete]
#[Observable]
public var Title: string
Arguments
Attached macros may take arguments.
Both positional and named arguments are supported:
#[Observable]
#[Observable("TitleChanged")]
#[Observable(Name: "TitleChanged", Notify: true)]
The compiler parses and preserves these arguments generically. Their interpretation is defined by the macro implementation.
For attached declaration macros, plugins currently receive the raw parsed arguments through AttachedMacroContext.ArgumentList and a convenience parsed view through AttachedMacroContext.Arguments.
For freestanding expression macros, the equivalent APIs are FreestandingMacroContext.ArgumentList and FreestandingMacroContext.Arguments.
Each parsed MacroArgument exposes a richer constant representation through Constant, plus the evaluated CLR value directly through Value as a convenience.
For argument and usage validation inside the macro itself, plugins may also report macro-owned expansion diagnostics through MacroExpansionResult.MacroDiagnostics / FreestandingMacroExpansionResult.MacroDiagnostics. The helper methods CreateDiagnostic(...) and CreateArgumentDiagnostic(...) on both macro contexts create these diagnostics at either the macro site or a specific argument site.
This raw-argument model remains available for unrestricted macro implementations. Typed macro parameter objects allow macro signatures to be validated and later presented like normal attributes in completion and signature help. The public contract includes IMacroDefinition<TParameters>, IAttachedDeclarationMacro<TParameters>, IFreestandingExpressionMacro<TParameters>, and ITokenTreeExpressionMacro<TParameters> for that bound-parameter model.
Example direction:
public sealed class ObservableMacroParameters
{
public bool Notify { get; init; } = true;
public string? Name { get; init; }
}
public sealed class ObservableMacro : IAttachedDeclarationMacro<ObservableMacroParameters>
{
...
}
The current typed-parameter binding slice supports:
- one public constructor for positional arguments
- public writable properties for named arguments
- constant conversion into common CLR primitive/reference types
- typed arguments combined with an unrestricted token-tree body
MacroFacts.GetParametersType(...) and MacroFacts.GetParameters(...) expose
the compiler-normalized parameter schema without requiring tooling to inspect
the macro implementation itself. Each MacroParameterDescriptor identifies
the CLR type, positional or named role, ordinal, required state, and optional
constructor default. SemanticModel.GetMacroSignatureHelp(...) resolves that
schema at an attached, argument-style, or token-tree invocation and identifies
the active parameter for compiler hosts and editor tooling.
Completion uses that schema inside typed attached, argument-style, and
token-tree macro argument lists. It offers unused writable properties with
their Raven-facing type and inserts the named-argument form, such as
Optimize: . Constructor parameters remain positional and participate in the
same signature help.
String-valued parameters may later preserve
System.Diagnostics.CodeAnalysis.StringSyntaxAttribute as optional tooling
metadata, matching ordinary Raven parameters. That attribute is not the
representation for a token-tree body. A body has tokens and authored source
spans rather than a string value, and future highlighting/completion support
will use a general compiler-owned syntax-content descriptor. For example,
quote! { let x = "test" } can identify its body as Raven syntax, while a DSL
macro can identify a standard or custom syntax without changing Raven's normal
lexer.
The target experience is that macro arguments bind like attribute arguments:
- completion for named arguments
- signature help for supported shapes
- diagnostics for unknown names, missing required arguments, and invalid constant conversions
- typed parameter access in the macro implementation
Example macro-side validation:
return new MacroExpansionResult
{
MacroDiagnostics =
[
context.CreateArgumentDiagnostic(
context.Arguments[0],
"name cannot be empty",
code: "VAL001")
]
};
Expansion model
Macro expansion is not a preprocessor step. The source file is parsed normally first. After parsing, the compiler resolves macros from referenced macro assemblies and requests expansions using structured Raven syntax.
Ordering and composition
When multiple attached macros apply to the same declaration, Raven runs them as a source-ordered pipeline over one declaration.
This has two consequences:
- Macros on the same declaration are visited in source order.
AttachedMacroContext.TargetDeclarationalways refers to the original authored declaration.AttachedMacroContext.CurrentDeclarationrefers to the declaration shape immediately before the current macro runs.
When Raven integrates the results for one declaration, it uses this order:
- introduced members from all attached macros, preserving macro source order
- the effective declaration itself, where the last macro that returns
ReplacementDeclarationwins - peer declarations from all attached macros, preserving macro source order
If a macro returns ReplacementDeclaration, that replacement becomes the CurrentDeclaration seen by later attached macros on the same declaration. If a macro only introduces members or peer declarations, CurrentDeclaration does not change.
For an attached nominal-type macro, the effective replacement declaration also supplies the base class and interface list used when Raven binds the type shape. This allows a macro to introduce a required member and add its corresponding interface contract in the same expansion.
For parent/child relationships, parent-declaration macros still see the original parsed shape of the parent declaration. A macro attached to a type should not assume that attached macros on its members have already rewritten the type syntax visible through AttachedMacroContext.TargetDeclaration or AttachedMacroContext.CurrentDeclaration.
The current attached-macro system supports these generic result shapes:
- compiler-owned macro expansion diagnostics with custom messages and precise locations
- raw compiler diagnostics for advanced scenarios
- introduced members
- replacement of the annotated declaration
Expansion must remain generic. The compiler does not hardcode macro-specific behaviors such as property notification or equality semantics.
Freestanding expression macros return a generic expression-expansion result shape:
- compiler-owned macro expansion diagnostics with custom messages and precise locations
- raw compiler diagnostics for advanced scenarios
- replacement expression
Author guidelines
When designing attached macros:
- Prefer one replacement-owning macro per declaration. If multiple macros replace the same declaration, the last replacement wins.
- Use
TargetDeclarationwhen you need the original authored syntax, and useCurrentDeclarationonly when you intentionally want same-target pipeline behavior. - Use introduced members for additive behavior and keep cross-macro coordination explicit rather than inferred from transformed syntax.
- When a parent declaration and its members both use macros, keep the parent macro resilient to the original member syntax shape.
- If two macros need to cooperate, define that cooperation through explicit arguments, naming conventions, or generated marker members instead of depending on expansion order side effects.
Project references
A reusable Raven macro project marks its output as a compiler plugin:
import Raven.CodeAnalysis.Macros.*
[assembly: RavenCompilerPlugin(typeof(QueryMacro))]
class QueryMacro: ITokenTreeExpressionMacro {
// ...
}
The consumer uses an ordinary project reference:
<ItemGroup>
<ProjectReference Include="../macros/ObservableMacros.rvnproj" />
</ItemGroup>
The workspace recognizes the assembly-targeted marker in referenced Raven and
C# projects, builds the provider through the compiler-plugin path, and passes
the resulting macro reference to Compilation. The marked provider is not
added as a runtime project reference. A bare marker authorizes fallback
discovery of direct macro definitions.
Raven does not scan unmarked runtime references for plugins, and consumer
source needs no macro import directive. The former consumer-authored
RavenMacro project item is not supported; reusable providers use ordinary
marked project, assembly, or package references. During compilation setup,
portable references—including direct DLL and resolved package references—are
inspected for the assembly marker through metadata only. Marked references are
activated and join the same macro registry as explicitly supplied and
same-project macros. Unmarked assemblies are not loaded for macro execution or
searched for implementation types.
The active macro set belongs to an immutable compilation snapshot. A new
snapshot recomputes activation when local macro source or the reference set
changes. Existing portable-reference fingerprints prevent a changed assembly
at the same path from reusing stale metadata state. For a package with separate
assets, the ref/<tfm> assembly remains a consumer metadata reference while a
marked lib/<tfm> implementation is activated as a macro reference. The macro
load context resolves helper assemblies placed beside that implementation and
uses runtime assets from the selected NuGet target graph to resolve
dependencies supplied by transitive packages. These private dependency probes
are not consumer metadata references.
The selected Raven compiler and SDK may also register a version-matched default
macro set automatically. Default macros require no source import or explicit
dependency and must be available in the Playground. #quote is the first such
macro; future defaults such as #embedFile may be compiler intrinsics or
SDK-bundled plugins without exposing that distinction at the invocation site.
The compiler API supports an explicit same-project macro source partition.
Trees supplied through Compilation.AddMacroSyntaxTrees are compiled as an
in-memory library and activated before consumer binding. Their diagnostics are
reported by the consumer compilation, their macros participate in completion,
and their implementation declarations are excluded from runtime emit.
The dedicated-file MVP classifies this partition automatically from direct macro declarations:
import Raven.CodeAnalysis.Macros.*
class QueryMacro: ITokenTreeExpressionMacro {
// ...
}
Compilation.AddSyntaxTreesWithLocalMacros, Workspace compilation, and the SDK
recognize the macro interface in the declaration's base list and move the
declaration into the compile-time partition. A file containing only direct
macro declarations is therefore classified as a dedicated macro file. The SDK
form needs neither a RavenMacro item nor an explicit project reference to the
compiler contracts.
The object-oriented macro contracts are the authoritative MVP authoring and
execution model. Raven may later add dedicated declaration syntax that removes
macro class boilerplate and hides compile-time partition markers such
as [LocalMacro], but that syntax is intentionally deferred until the
infrastructure and common macro cases are stable. Any such syntax must lower to
or interoperate with the same IMacroDefinition, context, token-stream,
diagnostic, and expansion-result
contracts; it must not create a parallel macro execution model.
After activation, local and referenced implementations are equivalent entries in the macro registry. A future macro-specific symbol may expose this common semantic identity, but macro execution does not currently require a dedicated symbol kind.
Direct macro definitions are the only activation unit. They are first-class manifest exports and are discovered automatically inside the local compile-time partition. The category-specific macro interfaces and expansion contracts are the authoritative implementation surface for the MVP.
The automatic rule is intentionally syntax-only and declaration-granular.
Supporting types can be moved with [LocalMacro]. The partition remains
acyclic: macro source can reference metadata
and other macro plugins but cannot bind against consumer source declarations.
If a reference in local macro code resolves only to a declaration in the
consumer partition, the compiler reports RAVM003 at that reference. The
dependency must move into the local macro partition or a referenced assembly.
A mixed source file uses [LocalMacro] instead:
import Raven.CodeAnalysis.Macros.*
[LocalMacro]
class AnswerMacro: ITokenTreeExpressionMacro {
// ...
}
let answer = answer! { }
[LocalMacro] classifies only the marked top-level type and everything nested
within it as compile-time-only. Every separate top-level macro definition or
support type needed by the local macro must be marked.
The compiler creates same-length macro and consumer projections, retaining line
breaks and replacing declarations from the opposite partition with whitespace.
This preserves authored offsets for diagnostics while keeping macro
implementation types out of runtime emit.
Compiler hosts may use Compilation.GetSemanticModel(tree, position), and
Workspace callers may use Document.GetSemanticModelAsync(position), to select
the semantic projection that owns an authored position. A position inside a
declaration marked [LocalMacro] returns the macro-partition semantic model;
other positions return the consumer model. Nodes for semantic queries must come
from the returned model's SyntaxTree. The projections preserve authored
positions, so the same position can be used to find the corresponding node.
Language-server hover, completion, definition, references, and rename use this
position-aware view inside local macro declarations. Their semantic answers
come from the macro partition rather than the masked consumer projection.
Reference locations and rename edits remain expressed against the authored
document because both projections preserve its positions.
When a Workspace analyzer host is present, document analysis traverses both
compiler-owned projections of a mixed local-macro document. Analyzer callbacks
receive the semantic model for the projection being traversed, so ordinary
Raven code in [LocalMacro] declarations participates in syntax, symbol, and
operation analysis alongside consumer code. This does not make analyzers part
of macro compilation or activation.
The browser Playground supports this form in its single user buffer. Analyzer participation inside retained structured DSL regions is not yet complete; the current implementation does not infer Raven fragments from raw macro tokens or expansion output.
Across incremental compilations, an unchanged local macro partition may reuse its emitted in-memory plugin artifact. Changes limited to consumer source do not recompile that artifact. Changes to macro source, compilation or parse options, metadata references, macro references, or assembly identity invalidate it and therefore invalidate expansions that depend on the local registry. Every snapshot still owns a fresh macro semantic compilation, and reused partition diagnostics are associated with the current projected syntax trees.
Macro-reported validation failures currently surface through the shared compiler diagnostic RAVM021, with the macro name and custom message embedded in the diagnostic text. The diagnostic location may point either at the macro site or at a specific argument.
Example
class MyViewModel: ObservableBase {
#[Observable]
var Title: string
}
In this example, #[Observable] is an attached property macro. The macro may replace the property declaration with ordinary Raven members such as backing storage and accessor bodies.