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.

DirectivePurpose
#defineDefine an object-like or function-like macro (single line)
##define / ##enddefineDefine a multi-line block macro
#undefRemove a macro definition
#if / #elifConditional compilation with expression evaluation
#ifdef / #ifndefConditional compilation based on macro existence
#else / #endifAlternative branch / end conditional block
#errorAbort 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.

basic #define
#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.

function-like macro
#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.

block macro
##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.

parameterized block macro
##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.

LF line endings only. Continuation is detected by inspecting the byte immediately before the newline. In a file saved with CRLF endings that byte is the carriage return, not the backslash, so the lines are not joined and the backslash is left in the source. Save files that rely on continuation with LF endings.
line continuation
#define LONG_MACRO(a, b, c) \
    a + b + c

// Equivalent to: #define LONG_MACRO(a, b, c) a + b + c
Tip: For anything beyond a simple one-liner, prefer block macros (##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).

#ifdef / #ifndef
#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.

#if / #elif / #else
#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:

OperatorMeaningExample
==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.

nested conditionals
#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.

#undef
#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.

#error
#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.

recursive expansion
#define A B + 1
#define B 10

var x = A
// A → B + 1 → 10 + 1
// x is 11
self-reference guard
#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.

feature flags
#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

platform branching
#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.

code generation
##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

configuration
#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.

guard patterns
// Ensure a required config is set
#ifndef API_VERSION
    #error
#endif

// Default values
#ifndef MAX_RETRIES
    #define MAX_RETRIES 3
#endif

Directive Reference

DirectiveSyntaxDescription
#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
...
##enddefine
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 GuideEmbedding GuideContinuations