Skip to content

Preprocessor

The preprocessor runs before the lexer and produces preprocessed source the rest of the compiler operates on. It exists alongside the language proper rather than as part of it — its job is meta (file inclusion, conditional compilation, simple macro substitution) rather than semantic.

#include <file.xc> // search the system / -I paths only
#include "file.xc" // search next to the current file first
#import <Stdio.xc> // include-once form, same search rules
#import "Sprite.xc"

The two quote forms select different search orders, matching C/C++ convention:

  • "file.xc" — look next to the file doing the include first, then fall through to the system directories and any -I paths. Use this for files that live alongside your source.
  • <file.xc> — skip the current source’s directory and go straight to system / -I paths. Use this for library headers so a same-named file in your project can’t silently shadow the real library.

Filename matching is case-sensitive even on case-insensitive filesystems. #import <Sort.xc> will never match a sibling sort.xc, even on macOS’s HFS+/APFS — this prevents a classic collision where a user names their program after a library they import.

#import is identical to #include except that the named file is included only once across the entire compilation unit. Library headers should always use #import so a user can #import them freely without two copies of the contents ending up in scope.

#use is sugar for the common case of “import a library class and let me call its static methods bare.” It expands to #import "ClassName.xc" followed by use ClassName; (the language-level directive) — one line replaces two.

#use Stdio // == #import "Stdio.xc" + use Stdio;
#use Math
#use <Time> // angle-bracket and quote forms also accepted
#use "Sprite"
void main(void) {
printf("answer = %u\n", 42); // resolves to Stdio.printf
u8 r = rand((u8)100); // resolves to Math.rand
}

The class name may be written bare (#use Stdio), in angle brackets (#use <Stdio>), or in quotes (#use "Stdio"). The trailing .xc extension is stripped automatically if you include it. The same < > vs " " search-order rule as #include / #import applies to the underlying file lookup.

After #use Stdio, calling printf("hi") resolves the same way as Stdio.printf("hi") would — the receiver class is implied. This is bare-call sugar only; explicit Klass.method(...) calls, free functions, and local variables are unaffected. The full resolution rules — overload scoring, ambiguity diagnostics when multiple use’d classes both expose a method with the same name, and the file-local scope of the promotion — live with the language-level use directive.

#define DBL(x) (double(x))
#define ZP_BASE $80
#define ENABLE_DOUBLE 1

#define introduces a macro, optionally taking comma-separated arguments. At each later occurrence, the comma-separated actuals are substituted for the placeholders. Macros can be removed with #undef.

A macro whose last parameter is ... is variadic; substitute the variadic tail with __VA_ARGS__ in the body:

#define LOG(level, ...) Stdio.printf("[" level "] " __VA_ARGS__)
LOG("warn", "value=%d\n", x);
// expands to: Stdio.printf("[" "warn" "] " "value=%d\n", x);

This follows the standard C model. GNU’s , ##__VA_ARGS__ comma-swallow is supported too — an empty variadic tail removes the comma before it, so LOG("hi") expands cleanly:

#define LOG(fmt, ...) Stdio.printf(fmt, ##__VA_ARGS__)
LOG("done\n"); // → Stdio.printf("done\n") — no dangling comma
LOG("x=%d\n", x); // → Stdio.printf("x=%d\n", x)

#param replaces the parameter with a string literal of the argument as written. a ## b pastes two tokens into one.

Both operate on the argument unexpanded, which is why the idiomatic form uses two levels: the outer macro expands its arguments normally, and only the inner one applies the operator.

#define CAT2(a,b) a##b
#define CAT(a,b) CAT2(a,b)
#define STR2(x) #x
#define STR(x) STR2(x)
#define VER 7
CAT(x, VER) // → x7 — VER expanded first, then pasted
CAT2(x, VER) // → xVER — pasted raw
STR(VER) // → "7"
STR2(VER) // → "VER"

The usual application is building a name from its parts — an ABI symbol from a version number, say — so that a mismatch fails at link time, by name, rather than surfacing later as a wild jump through a stale vtable.

A parameter is substituted only where it appears as a whole token. It is not replaced inside a longer identifier, and not inside a string literal:

#define ABS_OK(a) a + abs_val // `a` does NOT rewrite `abs_val`
#define INSTR(a) "a is here" // `a` does NOT rewrite the string

Likewise, a comma inside a string argument is part of that argument, not a separator: P("a,b") passes one argument.

#ifndef ENABLE_DOUBLE
#define ENABLE_DOUBLE 1
#endif
#if ENABLE_DOUBLE
// …double-precision code…
#elif ENABLE_FLOAT
// …single-precision fallback…
#else
#error neither double nor float enabled
#endif
#ifdef DEBUG
Stdio.print("debug build\n");
#endif

#ifdef / #ifndef test for the presence (or absence) of a macro definition. #if evaluates a constant integer expression. The chain may include any number of #elif clauses and an optional #else, terminated by #endif.

Macros may be defined on the command line with -D:

Terminal window
xcc app.xc -DENABLE_DOUBLE=0 -DDEBUG -o app.xex

The driver predefines an ARCH_<arch> sentinel for the target being built, so a single source file can serve every backend. This is how the standard library keeps one copy of each class:

TargetDefined
-A arm64ARCH_arm64
-A x86_64ARCH_x86_64
-A win64ARCH_x86_64 and ARCH_win64
-A arm9ARCH_arm9
-A m68kARCH_m68k
-A 6502ARCH_6502

Windows defines both because it is the x86-64 instruction set: ISA-guarded code (inline assembly, register names) keys off ARCH_x86_64, while OS-specific code (calling convention, system calls) keys off ARCH_win64.

#if ARCH_6502
// a byte-oriented path, and no i64 arithmetic
#elif ARCH_win64
// kernel32, and the Microsoft x64 calling convention
#else
// the 64-bit hosts
#endif

Threading headers use exactly this to fail loudly rather than silently: on xt6502 and m68k, Thread.xc is an #error, not a stub.

Also predefined: BANK_DATA, BANK_CODE, BANK_C (selector constants for the bank(…) builtin), XTC_POINTER_WIDTH, and — on 6502 targets — a set of layout-derived addresses (XT_PRINTF_BUF, XTC_HP_LO …) that inline assembly in the runtime needs, since the preprocessor cannot test a memory-model property directly. Those come from the active .lnk file, so a custom layout changes them.

#warning need to implement doFrobble()
#error no supported target selected

#warning produces a compile-time warning containing the text and lets the build continue. #error produces a fatal error and stops compilation. Both honour conditional compilation — use them inside #if chains to enforce build-configuration invariants.