Macros & Preprocessor
Zym includes a built-in preprocessor that runs before compilation. It supports #define macros, function-like macros, multi-line block macros, conditional compilation, and expression evaluation — giving you compile-time code generation and configuration without runtime overhead.
Overview
The preprocessor processes your source code before the compiler sees it. Directives start with # at the beginning of a line (leading whitespace is allowed). The preprocessor strips comments, evaluates directives, expands macros, and passes the result to the compiler.
It works on text. The only structures it recognises are string literals, character literals, comments, and identifiers — an identifier being a letter or underscore followed by letters, digits, and underscores. Every other character is copied through one at a time.
| Directive | Purpose |
|---|---|
#define | Define an object-like or function-like macro (single line) |
##define / ##enddefine | Define a multi-line block macro |
#undef | Remove a macro definition |
#if / #elif | Conditional compilation with expression evaluation |
#ifdef / #ifndef | Conditional compilation based on macro existence |
#else / #endif | Alternative branch / end conditional block |
#error | Abort compilation |
Object-like Macros
The simplest form of macro: a name that expands to a value. Anywhere the macro name appears in code, it is replaced with the defined value before compilation.
#define MAX_SIZE 100 #define GREETING "Hello, World" #define PI 3.14159 var arr = [null] * MAX_SIZE // expands to: [null] * 100 print(GREETING) // expands to: print("Hello, World") var circumference = 2 * PI * r
Flag macros
A macro can be defined without a value — it simply “exists” and can be tested with #ifdef or defined().
#define DEBUG
#define VERBOSE
#ifdef DEBUG
print("Debug mode is on")
#endif
A flag macro has an empty body, so wherever the name appears in code it expands to nothing. It cannot be used as an operand in #if: #if DEBUG expands to an empty expression, which fails to parse and is treated as false. Test flag macros with #ifdef, #ifndef, or defined().
Redefining macros
Defining a macro with the same name replaces the previous definition. The replacement is silent, and it may change a macro from object-like to function-like or back. Use #undef to remove a macro entirely.
#define MODE 1 // MODE expands to 1 #define MODE 2 // MODE now expands to 2 #undef MODE // MODE is no longer defined
Function-like Macros
Macros can take parameters. A ( after the macro name opens a parameter list; whitespace between the name and the parenthesis is allowed and does not change that.
#define SQUARE(x) x * x #define ADD(a, b) a + b var result = SQUARE(5) // expands to: 5 * 5 var sum = ADD(3, 4) // expands to: 3 + 4
Because any ( after the name opens a parameter list, an object-like macro body cannot start with a parenthesis. #define ORIGIN (0, 0) is read as a parameter list, 0 is not a valid parameter name, and the definition is dropped without a diagnostic.
Multiple parameters
Function-like macros can have any number of parameters, separated by commas. Arguments are substituted as raw text, so parenthesise the parameters in the body when the macro is meant to be used inside a larger expression.
#define LERP(a, b, t) (a) + ((b) - (a)) * (t) #define INDEX(row, col, width) (row) * (width) + (col) var mid = LERP(0, 10, 0.5) var i = INDEX(2, 3, 8)
What gets substituted
Substitution replaces whole identifiers in the macro body. An identifier that matches a parameter name is replaced by that argument’s text; every other identifier, and every other character, is copied through unchanged. There is no token-pasting operator and no stringifying operator, so a parameter cannot supply part of an identifier — every name that varies has to be its own parameter.
String and character literals in the body are copied verbatim, so a parameter name inside one is not replaced.
#define LABEL(text) "text" LABEL(hello) // expands to: "text" — the literal is untouched
Argument expansion
Arguments are expanded before substitution into the macro body. This means you can pass macro names or expressions as arguments and they will be resolved.
#define VALUE 10 #define DOUBLE(x) x + x DOUBLE(VALUE) // VALUE expands to 10, then: 10 + 10 DOUBLE(3 + 2) // expands to: 3 + 2 + 3 + 2
Argument splitting and arity
Arguments are split on commas at the top level of the argument list. Commas inside parentheses, string literals, and character literals do not split. Brackets and braces are not tracked, so a comma inside [ ] or { } does split the list.
#define FIRST(a, b) a FIRST(f(1, 2), 3) // two arguments: f(1, 2) and 3 FIRST("a, b", 3) // two arguments: "a, b" and 3 FIRST([1, 2], 3) // three arguments — the list is split at its comma
Each argument is trimmed of leading and trailing spaces. Arity is not checked: a missing argument substitutes as empty text, and extra arguments are discarded. Neither case is reported.
#define ADD(a, b) a + b ADD(1) // expands to: 1 + ADD(1, 2, 3) // expands to: 1 + 2, the 3 is dropped
An invocation has to be complete on one logical line. If the closing parenthesis is missing, the invocation is not expanded and the macro name is emitted as written — but scanning resumes just after it, so macros inside the unterminated argument text are still expanded. A function-like macro name that is not followed by ( is likewise left alone.
Block Macros (Multi-line)
For macros that span multiple lines, use the ##define / ##enddefine syntax (double hash). Everything between the opening directive and ##enddefine becomes the macro body, preserving line breaks.
The body is captured verbatim, line by line. ##enddefine is the only line the preprocessor looks for while capturing, so directives written inside a block body are stored as text instead of being executed. Names in the body are expanded when the block macro is used, not when it is defined.
##define SETUP_PLAYER var health = 100 var mana = 50 var level = 1 func heal(amount) { health = health + amount if (health > 100) health = 100 } ##enddefine // Use it — expands to the full block SETUP_PLAYER heal(30)
Block macros with parameters
Block macros take parameters the same way single-line function macros do. Since substitution replaces whole identifiers, every name the expansion declares has to be passed in.
##define MAKE_COUNTER(state, bump, read, start) var state = start func bump() { state = state + 1 } func read() { return state } ##enddefine MAKE_COUNTER(score, bumpScore, readScore, 0) MAKE_COUNTER(lives, bumpLives, readLives, 3)
The first invocation expands to:
var score = 0 func bumpScore() { score = score + 1 } func readScore() { return score }
Line Continuation
A backslash (\) as the last character of a line joins that line with the next one. The backslash and the newline are both removed and nothing is inserted in their place, so leave a space before the backslash when the two halves must stay separate tokens. Continuation applies to every line, not only to directives.
#define LONG_MACRO(a, b, c) \
a + b + c
// Equivalent to: #define LONG_MACRO(a, b, c) a + b + c
##define / ##enddefine) over line continuations. They are easier to read and maintain.Conditional Compilation
Conditional directives let you include or exclude code based on whether macros are defined and what values they hold. The preprocessor evaluates conditions and only passes the active branch to the compiler — excluded code is completely removed.
#ifdef / #ifndef
Test whether a macro is defined (or not defined).
#define DEBUG
#ifdef DEBUG
print("Debug: initializing...")
#endif
#ifndef RELEASE
print("Not a release build")
#endif
#if / #elif / #else
Evaluate an expression to decide which code to include. The grammar covers equality tests, logical operators, grouping parentheses, defined, and unsigned decimal integer literals.
#define VERSION 3
#if VERSION == 1
print("Version 1")
#elif VERSION == 2
print("Version 2")
#elif VERSION == 3
print("Version 3")
#else
print("Unknown version")
#endif
Expression operators
The following operators are available inside #if and #elif expressions:
| Operator | Meaning | Example |
|---|---|---|
== | Equal | #if VERSION == 2 |
!= | Not equal | #if MODE != 0 |
&& | Logical AND | #if defined(DEBUG) && defined(VERBOSE) |
|| | Logical OR | #if defined(A) || defined(B) |
! | Logical NOT | #if !defined(RELEASE) |
defined() | Check if macro exists | #if defined(FEATURE_X) |
( ) | Grouping | #if (defined(A) || defined(B)) && defined(C) |
That is the entire grammar. There are no relational operators (<, >, <=, >=) and no arithmetic. The operands are unsigned decimal integer literals, defined tests, and macros that expand to those.
Parsing stops at the first thing it cannot read and ignores the rest of the line; if nothing parses at all, the condition is 0 and the branch is skipped. So #if VERSION >= 3 compares nothing — it evaluates VERSION and discards >= 3. An identifier that is not a defined macro also fails to parse, which makes the whole condition false: #if UNSET == 0 is false, not true.
The defined operator
The defined() operator returns 1 if a macro is defined, 0 otherwise. It can be used with or without parentheses.
#define FEATURE_A
#if defined(FEATURE_A) && !defined(FEATURE_B)
// Only FEATURE_A is enabled
print("Feature A only")
#endif
// Without parentheses
#if defined FEATURE_A
print("Feature A is defined")
#endif
Nesting conditionals
Conditional blocks can be nested. A block is active only if every block enclosing it is also active, so an inner condition cannot re-enable code inside a skipped outer block.
#define PLATFORM 1
#define DEBUG
#if PLATFORM == 1
print("Platform 1")
#ifdef DEBUG
print("Platform 1, debug mode")
#endif
#elif PLATFORM == 2
print("Platform 2")
#endif
Undefining Macros
#undef removes a previously defined macro. After undefining, the name is no longer expanded and #ifdef will be false. Undefining a name that was never defined does nothing.
#define TEMP 42 var x = TEMP // expands to: var x = 42 #undef TEMP var y = TEMP // TEMP is no longer a macro — treated as an identifier #ifdef TEMP // This block is skipped — TEMP is no longer defined #endif
#error
The #error directive immediately aborts compilation. It takes no message — any text after the directive is ignored — and it only fires when the conditional blocks around it are active. Use it inside conditional blocks to enforce configuration requirements.
#ifndef PLATFORM
#error
#endif
// Compilation only reaches here if PLATFORM is defined
Macro Expansion
Macros are expanded recursively — if a macro body contains another macro name, that name is expanded too. The preprocessor includes an infinite-recursion guard: if a macro references itself (directly or through a chain), the self-reference is left unexpanded.
#define A B + 1 #define B 10 var x = A // A → B + 1 → 10 + 1 // x is 11
#define FOO FOO + 1 var x = FOO // FOO expands once, but the inner FOO is NOT expanded again // Result: FOO + 1 (FOO treated as identifier in expansion)
The guard is the set of macros currently being expanded, so it also stops mutual recursion: if A expands to B and B refers back to A, the inner A is left as written.
Expansion runs over one logical line at a time and skips string and character literals — a macro name inside "..." is never replaced.
Expansion in conditionals
Macros inside #if and #elif expressions are expanded before the expression is evaluated, so macros can be used in condition expressions. The operand of defined is the exception: it is taken as a literal name and is not expanded first.
#define MAJOR 2
#define MINOR 5
#define VERSION MAJOR
#if VERSION == 2
print("Major version 2") // this branch is taken
#endif
Comment Handling
The preprocessor strips comments before processing directives and expanding macros. Both line comments (//) and block comments (/* */) are removed. Each one is replaced by a single space, so a/* */b becomes a b and never ab, and newlines inside a block comment are kept so that line numbers do not shift.
#define VALUE 42 // this comment is stripped before the macro is stored /* Block comments are also stripped before preprocessing */ var x = VALUE // expands to 42
Comments inside strings are not stripped — string literals are preserved exactly as written.
Common Patterns
Feature flags
Use macros as compile-time feature toggles to include or exclude functionality.
#define ENABLE_LOGGING #define ENABLE_METRICS func processRequest(req) { #ifdef ENABLE_LOGGING log("Processing: " + str(req)) #endif var result = handle(req) #ifdef ENABLE_METRICS recordMetric("request_processed") #endif return result }
Platform-specific code
#define PLATFORM 1 // 1 = desktop, 2 = mobile, 3 = web #if PLATFORM == 1 func getInput() { return readKeyboard() } #elif PLATFORM == 2 func getInput() { return readTouch() } #else func getInput() { return readEvent() } #endif
Code generation with block macros
A parameterized block macro emits the same group of declarations once per invocation. Every identifier that differs between invocations is a parameter; the accessor names cannot be derived from the field name.
##define ACCESSORS(getter, setter, field) func getter(obj) { return obj.field } func setter(obj, value) { obj.field = value } ##enddefine struct Player { name; health; score } ACCESSORS(getName, setName, name) ACCESSORS(getHealth, setHealth, health) ACCESSORS(getScore, setScore, score)
The first invocation expands to:
func getName(obj) { return obj.name } func setName(obj, value) { obj.name = value }
Configuration constants
#define MAX_PLAYERS 16 #define TICK_RATE 60 #define MAP_WIDTH 1024 #define MAP_HEIGHT 768 var players = [] for (var i = 0; i < MAX_PLAYERS; i = i + 1) { push(players, null) }
Guard patterns
Use #ifndef to prevent double-definition, or #error to enforce requirements.
// Ensure a required config is set #ifndef API_VERSION #error #endif // Default values #ifndef MAX_RETRIES #define MAX_RETRIES 3 #endif
Directive Reference
| Directive | Syntax | Description |
|---|---|---|
#define |
#define NAME value |
Define an object-like macro that expands to value |
#define |
#define NAME |
Define a macro with an empty body; expands to nothing, tested with #ifdef |
#define |
#define NAME(a, b) body |
Define a function-like macro with parameters |
##define |
##define NAME |
Define a multi-line block macro (with optional parameters) |
#undef |
#undef NAME |
Remove a macro definition |
#if |
#if expression |
Include following code if expression is non-zero |
#ifdef |
#ifdef NAME |
Include following code if NAME is defined |
#ifndef |
#ifndef NAME |
Include following code if NAME is not defined |
#elif |
#elif expression |
Alternative branch with expression check |
#else |
#else |
Alternative branch (no condition) |
#endif |
#endif |
End a conditional block |
#error |
#error |
Abort compilation immediately; takes no message text |
See also: Language Guide — Embedding Guide — Continuations