Skip to content

allenj12/anchor

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

61 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Anchor

Anchor is a systems programming language with Lisp syntax that compiles to C.

Every value is an AnchorVal — a raw 64-bit quantity. Pointers and integers are stored directly with no boxing or tagging visible to user code. Memory is managed through arenas — bump-pointer regions that free all at once when the function returns. There is no GC.

The macro system is hygienic syntax-rules plus macro-case, which runs arbitrary Chez Scheme at expand time. This lets macros compute sizes, unroll loops, generate families of functions, and define other macros — all before a line of C is emitted.


Build

Requires Chez Scheme.

chez --script build.ss   # → ./anchorc  (standalone binary, no Chez needed to run)

Windows

The Chez Scheme Windows installer puts scheme.exe and csv1030.dll in C:\Program Files\Chez Scheme 10.3.0\bin\a6nt\. Both that directory and the directory where you install anchorc.exe must be on your PATH.

# add once (run as Administrator, then restart your terminal)
[System.Environment]::SetEnvironmentVariable(
    "PATH",
    "C:\Program Files\Chez Scheme 10.3.0\bin\a6nt;" +
        [System.Environment]::GetEnvironmentVariable("PATH","Machine"),
    "Machine"
)

scheme --script build.ss   # note: 'scheme', not 'chez'  → anchorc.exe

For programs that link against external C libraries, install MSYS2 and add C:\msys64\mingw64\bin to your PATH as well. Pass include/lib paths via --cflags:

anchorc main.anc -o main --cflags "-IC:/msys64/mingw64/include -LC:/msys64/mingw64/lib -lsomelib"

Stack size. Windows defaults to a 1 MB thread stack. Each with-arena scope allocates a 1 MB buffer on the stack, so programs with more than one nested arena scope will overflow immediately. Add -Wl,--stack,16777216 to --cflags to raise the limit to 16 MB:

anchorc main.anc -o main --cflags "-Wl,--stack,16777216"

Run a file:

./anchorc examples/hello.anc --run          # compile + run
./anchorc examples/hello.anc -o hello       # compile to binary
./anchorc examples/hello.anc -o hello.c     # emit C only
./anchorc examples/hello.anc --emit-exp     # print macro-expanded AST
./anchorc examples/hello.anc --multi-threaded -o hello  # thread-safe arena pointer (uses _Thread_local)

Hello World

(include <stdio.h>)
(ffi printf (const char* ...) -> int)

(fn main ()
  (printf "Hello, Anchor!\n"))

A more involved example

(include <stdio.h>)
(ffi printf (const char* ...) -> int)

; each: hygienic list iterator — _cur in the template never clashes with call-site vars
(define-syntax each
  (syntax-rules ()
    [(_ var lst body ...)
     (do (let _cur lst)
         (while (! (null? _cur))
           (let var (car _cur))
           body ...
           (set! _cur (cdr _cur))))]))

; unroll: macro-case duplicates body N times at expand time — no loop in generated C
(define-syntax unroll
  (macro-case ()
    [(_ n body ...)
     `(do ,@(apply append (map (lambda (_) body) (iota n))))]))

(fn fold (lst f init)
  (let acc init)
  (each x lst (set! acc (f x acc)))
  (return acc))

(with-arena
  (fn main ()
    (let nums (cons 2 (cons 3 (cons 4 (cons 5 nil)))))

    ; closure captures base from the enclosing scope
    (let base 10)
    (let add-base (lambda ((base) (x)) (return (+ x base))))

    ; _cur here is a different variable from the one inside each —
    ; hygiene ensures they never collide
    (let _cur 99)
    (each n nums
      (printf "%d " (cast int (add-base n))))
    (printf "\n")

    (let sum (fold nums (lambda (x acc) (return (+ x acc))) 0))
    (printf "sum: %d\n" (cast int sum))

    ; unroll inlines 4 increments — no branch, no loop counter in the generated C
    (let ticks 0)
    (unroll 4 (set! ticks (+ ticks 1)))
    (printf "ticks: %d\n" (cast int ticks))

    (printf "_cur: %d\n" (cast int _cur))))

with-arena attaches a heap arena to the function. All alloc calls inside use it; everything is freed on return. The default size is 1 MB.


Language Tour

Variables and control flow

(let x 10)          ; declare
(set! x (+ x 1))   ; mutate

(if (> x 5)
  (printf "big\n")
  (printf "small\n"))

(while (< x 100)
  (set! x (* x 2)))

(while #t
  (if (== x 0) (break))
  (if (== (% x 2) 0) (do (set! x (- x 1)) (continue)))
  (set! x (- x 1)))

(block              ; introduces a C scope — variables declared here are local to it
  (let tmp x)
  (set! x 0))

do sequences expressions and returns the last one; block does the same but wraps in { } so let bindings inside don't escape. break and continue work exactly as in C — they apply to the innermost enclosing while.

when and unless are one-armed conditionals:

(when (< x 10) (printf "small\n"))
(unless (> x 10) (printf "not large\n"))

cond chains tests with an optional else. Single-form clauses execute as statements (useful for inline bindings):

(cond
  [(< x 0)  (printf "negative\n")]
  [(let abs (- 0 x))]
  [(< abs 10) (printf "small\n")]
  [else (printf "big\n")])

Functions

(fn square (n)
  (return (* n n)))

(fn abs-val (n)
  (if (< n 0)
    (return (* n -1))
    (return n)))

All functions return AnchorVal. main is the exception — it returns int. Tail calls are not optimized; use while for loops.

Anchor identifiers are mapped to valid C names: -_, !_mut, ?_p, ._dot_, >gt_, <lt_, %_pct_. So string->symbol becomes string_gt_symbol in the generated C.

Arithmetic

(+ a b)    (- a b)    (* a b)    (/ a b)    (% a b)   ; signed integer
(f+ a b)   (f- a b)   (f* a b)   (f/ a b)             ; float64 (double)
(f32+ a b) (f32- a b) (f32* a b) (f32/ a b)           ; float32
(u+ a b)   (u- a b)   (u* a b)   (u/ a b)   (u% a b)  ; unsigned

(band x mask)   (bor x y)   (bxor x y)   (bnot x)
(lshift x n)    (rshift x n)

(== a b)  (!= a b)  (< a b)  (> a b)  (<= a b)  (>= a b)
(f== a b) (f!= a b) (f< a b) (f> a b) (f<= a b) (f>= a b)    ; float64
(f32== a b) (f32!= a b) (f32< a b) (f32> a b) (f32<= a b) (f32>= a b)  ; float32
(u< a b)  (u> a b)  (u<= a b) (u>= a b)                      ; unsigned

Floats

Bare decimal literals like 0.5 produce f64 (double) values — the bit pattern of the double is stored directly in the AnchorVal. Use f+, f-, f*, f/ and the f==/f</etc. comparisons.

f32 (float) values store 32-bit IEEE 754 bits in the low 32 bits of the AnchorVal. Create them with (f32 expr):

(let a (f32 3.14))          ; f32 literal
(let b (f32 2.0))
(let c (f32+ a b))          ; f32 arithmetic
(printf "%f\n" (cast double (f32->f64 c)))  ; widen to f64, cast for printf

Conversions use the -> family, which actually converts between representations:

Form Description
(f32 expr) Create f32 from literal or integer
(int->f64 expr) Convert integer → f64
(int->f32 expr) Convert integer → f32
(f64->f32 expr) Narrow f64 → f32
(f32->f64 expr) Widen f32 → f64
(f64->int expr) Truncate f64 → integer
(f32->int expr) Truncate f32 → integer

cast vs ->: cast does not convert — it tells C what type the AnchorVal already holds. The -> functions actually convert between representations:

; cast = "this is already a double, unwrap it for C"
(printf "%f\n" (cast double 3.14))

; -> = "convert this integer into f64 representation, then unwrap"
(printf "%f\n" (cast double (int->f64 42)))

FFI and floats: Both ffi and fn-c respect C types at the boundary. A float param expects f32 bits; a double param expects f64 bits. The compiler does not auto-convert — the AnchorVal must already hold the right representation:

(ffi sqrtf (float) -> float)
(ffi sqrt  (double) -> double)

(sqrtf (f32 2.0))     ; f32 in → f32 out
(sqrtf (f64->f32 x))  ; narrow f64 to f32 before calling
(sqrt 2.0)            ; f64 in → f64 out

Return values follow the same rule: -> float returns an f32 AnchorVal, -> double returns an f64 AnchorVal.

Character literals

Anchor uses Chez Scheme's #\ syntax. Character literals evaluate to their Unicode codepoint as an integer:

#\a        ; 97
#\newline  ; 10
#\space    ; 32
#\tab      ;  9
#\nul      ;  0
#\x41      ; 65  (hex codepoint)
#\[        ; 91  (punctuation)

Includes

Split code across multiple files using (include "path/to/file.anc"). The compiler resolves includes at parse time and inlines them into the AST before expansion — so macros, structs, and functions defined in an included file are visible everywhere after the include point.

(include "math.anc")
(include "utils/string.anc")

Paths are relative to the file containing the include. You only pass one file to the compiler; everything else comes in through includes:

./anchorc main.anc --run

Duplicate includes are silently ignored — each file is inlined at most once regardless of how many times it appears, so circular or redundant includes are safe.

C header includes pass through unchanged to the generated C file:

(include <stdio.h>)
(include "mylib.h")

FFI

Declare a C function once; call it directly. Fixed parameters are automatically cast to the declared types. Variadic arguments (after ...) require explicit (cast TYPE arg).

(include <unistd.h>)
(include <string.h>)

(ffi write  (int const void* size_t) -> int)
(ffi memcpy (void* (const void*) size_t) -> void*)
(ffi strlen ((const char*)) -> size_t)

; call — types applied automatically for fixed params
(write 1 buf (strlen buf))

; variadic — explicit cast required
(ffi printf (const char* ...) -> int)
(printf "%d items\n" (cast int count))

Use (c-const NAME) to pull in a C preprocessor constant at compile time:

(c-const STDOUT_FILENO)   ; → anchor_int((intptr_t)(STDOUT_FILENO))
(c-const CLOCKS_PER_SEC)

fn-c — C-native-signature functions

Use fn-c when you need a function with a specific C signature — for callbacks, qsort comparators, signal handlers, or any place the C ABI is fixed. Parameters are automatically wrapped as AnchorVal inside the body so Anchor expressions work normally; the return value is cast back to the declared C type.

(include <stdlib.h>)

(fn-c compare-ints ((const void* a) (const void* b)) -> int
  (let av (deref (cast intptr_t* a)))
  (let bv (deref (cast intptr_t* b)))
  (return (- av bv)))

; qsort needs a C comparator — fn-c is it
(ffi qsort (void* size_t size_t void*) -> void)
(qsort arr n 8 (fn-ptr compare-ints))

No separate (ffi ...) declaration is needed for the fn-c function itself — the compiler registers it in the extern table automatically.

Each parameter is a space-separated list ending with the parameter name: (const void* a) → C type const void*, name a.

Because parameters are immediately re-boxed as AnchorVal, the declared C type is only visible at the call boundary — inside the body every parameter is an AnchorVal regardless of its declared type. To use a parameter as its original C type you must cast it explicitly:

(fn-c greet ((const char* name)) -> void
  (printf "Hello, %s\n" (cast char* name)))   ; cast needed — name is AnchorVal

Lambda and closures

lambda creates an anonymous callable. Call it exactly like a named function — no special syntax at the call site.

(global-arena pool (mb 1))

(fn main ()
  (with-arena pool
    ; plain lambda — no captures
    (let sq (lambda (x) (return (* x x))))
    (sq 5)   ; → 25

    ; closure — first arg is ((captures...) (params...))
    (let base 10)
    (let add-base (lambda ((base) (x)) (return (+ x base))))
    (add-base 7)   ; → 17

    (sq 6)   ; → 36
    ))

Captures are packed into a flat struct [fn-ptr, cap0, cap1, ...] allocated in the current arena. Bit 63 is set to mark the pointer as a closure. Both live as long as the arena does — don't call a lambda after its arena is reset.

Multiple captures work the same way:

(let a 3) (let b 4)
(let f (lambda ((a b) (x)) (return (+ x (+ a b)))))
(f 10)   ; → 17

Function pointers

fn-ptr takes the address of any named function (fn, fn-c, or ffi) and boxes it as an AnchorVal. The result uses the same calling convention as lambdas — you can store it in a variable and call it directly:

(fn add (a b) (return (+ a b)))

(let fp (fn-ptr add))
(fp 3 4)   ; → 7

; also works for passing callbacks to C
(ffi qsort (void* size_t size_t void*) -> void)
(qsort arr n 8 (fn-ptr compare-ints))

For calling C-typed function pointers (fn-c or ffi), use call-ptr-c with an explicit signature:

(let fp (fn-ptr printf))
(call-ptr-c fp ((const char* ...) -> int) "x = %d\n" 42)

The signature ((param-types...) -> ret-type) matches the ffi declaration syntax.

Calling convention: fn-ptr and lambda values share a unified tagged-pointer convention. Bit 63 flags whether the value is a closure: 0 means direct call (no environment), 1 means the pointer addresses a flat struct [fn-ptr, caps...] and the callee receives the captures as a hidden first argument. This means fn-ptr values, plain lambdas, and closures are all interchangeable at call sites.

Memory and arenas

alloc bumps the current arena pointer — O(1), no malloc overhead.

kb, mb, and gb are built-in size macros that expand at compile time.

(with-arena (mb 4) ...)
(global-arena scratch (kb 64))
(alloc (kb 512))
(with-arena (mb 4)       ; 4 MB arena
  (fn process (n)
    (let buf (alloc (* n 8)))   ; slice of arena bytes
    ; buf freed automatically when process returns
    ))

global-arena declares a named arena whose backing buffer lives for the entire program. Use it when allocations need to outlive the function that creates them — linked lists, trees, or any per-request scratch buffer that gets rebuilt in a loop.

To attach a global arena to a function so all its allocations go there, wrap the fn definition with with-arena:

(global-arena scratch (kb 64))

(with-arena scratch
  (fn build-list (n) ...))     ; all allocs inside go into scratch
(global-arena scratch (kb 64))   ; 64 KiB, allocated once

(fn build-list (n)
  (with-arena scratch           ; directs allocations into scratch
    (let result nil)
    (while (> n 0)
      (set! result (cons n result))
      (set! n (- n 1)))
    (return result)))           ; safe — scratch is never freed

(fn main ()
  (let lst (build-list 5))
  ; ... use lst ...
  (arena-reset! scratch)        ; reclaim all allocations in O(1)
  (let lst2 (build-list 3))     ; reuse same backing memory
  )

Returning a list from a with-arena scratch block is safe because the backing memory is permanent. Contrast with anonymous with-arena scopes, where returning a linked structure would dangle — only flat values copy out correctly on return.

with-parent-arena temporarily redirects allocations into the arena one level up the stack. Use it to deep-copy a data structure out of a scope that is about to end:

(fn list-copy (lst)
  (if (null? lst)
    (return nil))
  (with-parent-arena
    (return (cons (car lst) (list-copy (cdr lst))))))

(with-arena
  (fn main ()
    (let original nil)
    (with-arena
      (set! original (cons 1 (cons 2 (cons 3 nil))))
      (set! original (list-copy original)))  ; copied into outer arena before inner dies
    ; original is safe to use here
    ))

Nesting with-parent-arena climbs one level each time. Global arenas behave the same as local ones — the parent of whatever arena is currently active is used.

arena-reset! reclaims arena allocations in O(1). With a name it resets that global arena to zero; with no arguments it resets the current arena to its checkpoint floor (or zero if no checkpoint is active):

(arena-reset! scratch)   ; reset named global arena to zero
(arena-reset!)           ; reset current arena to checkpoint floor (or 0)

with-arena-checkpoint saves the current allocation point and restores it on scope exit — everything allocated inside the checkpoint is reclaimed automatically. Use it for scratch work in a loop without resetting the entire arena:

(global-arena pool (mb 10))

(with-arena pool
  (let permanent (alloc 1024))   ; lives beyond checkpoints

  (while (< i n)
    (with-arena-checkpoint
      ;; all scratch allocations here are reclaimed at end of each iteration
      (let tmp (alloc (* size 8)))
      (process tmp))
    (set! i (+ i 1)))

  ;; permanent is still valid here
  (use permanent))

arena-reset! inside a checkpoint resets to the checkpoint's start, not to zero — preserving allocations made before the checkpoint:

(with-arena-checkpoint
  (let a (alloc 64))
  (let b (alloc 128))
  (arena-reset!)          ; reclaims a and b, resets to checkpoint start
  (let c (alloc 64)))     ; reuses the same memory

Checkpoints nest — each level saves and restores independently. arena-reset! always targets the innermost checkpoint. The checkpoint value is stored in the arena struct at runtime, so it works correctly across function boundaries:

(fn do-scratch ()
  (let tmp (alloc 512))
  (arena-reset!)            ; resets to caller's checkpoint floor, not zero
  )

(with-arena-checkpoint
  (do-scratch))             ; checkpoint protects pre-existing allocations

(arena-remaining) returns the number of bytes still available in the current arena as a signed integer. Useful for debugging or capacity checks:

(let free (arena-remaining))
(if (< free (mb 1))
  (printf "arena nearly full\n"))

(arena-in? ptr) returns 1 if ptr falls within the current arena's buffer, 0 otherwise. Useful for asserting that a pointer was allocated from the expected arena:

(let node (alloc (sizeof Node)))
(if (! (arena-in? node))
  (printf "unexpected allocation source\n"))

(ref expr) takes the address of a value; (deref ptr) reads through one. When the argument is a plain variable, ref takes its address directly (no copy). For complex expressions, a temporary is created.

(let n 0)
(some-c-fn (ref n))     ; passes &n directly
(let result (deref ptr))

; useful for treating a scalar as a C string (e.g. packed symbols)
(let s (sym hello))
(printf "%s\n" (cast char* (ref s)))   ; prints "hello"

Structs

Fields default to 8 bytes. Specify smaller sizes explicitly (e.g. 4 for int, 1 for char, 2 for short).

(struct Point (x 8) (y 8))

(let p (alloc (sizeof Point)))
(set! p Point x 100)
(set! p Point y 200)
(let px (get p Point x))   ; → anchor_int(100)

get, aref, and set! take the pointer first, then the struct type, then fields. Nest structs inline using (sizeof Name) as the field size — chain field names to navigate without an intermediate variable. Use -> to follow a stored pointer instead of navigating into inline bytes:

(struct AABB
  (min (sizeof Point))
  (max (sizeof Point)))

(let b (alloc (sizeof AABB)))

;; chained — embedded: navigate into inline Point bytes
(set! b AABB min Point x 0)
(set! b AABB min Point y 0)
(set! b AABB max Point x 800)
(set! b AABB max Point y 600)
(let x0 (get b AABB min Point x))

;; pointer field — node.next stores an address to another Node
;; (struct Node (val 8) (next 8))
(get n Node nxt -> Node val)   ; -> signals pointer dereference
(set! n Node nxt -> Node val 99)

aref is the address-returning mirror of get — same navigation syntax, but stops at the address rather than reading the value. Use it wherever you need a pointer to a field rather than its contents:

(aref b AABB min Point x)   ; address of x inside the embedded min Point
(aref b AABB min Point)     ; address of the embedded min Point itself
(aref b AABB min)           ; address of the min field

aref supports the full navigation chain including -> pointer follows and byte-offset steps, so it composes naturally with array indexing.

Arrays

A bare number or expression in get/set! is a byte offset into the buffer. For element i with element size sz, the offset is (* i sz):

(let arr (alloc (* n 8)))
(set! arr 0  42)              ; write to byte offset 0
(set! arr 8  99)              ; write to byte offset 8
(let v (get arr (* i 8)))     ; scalar read at offset i*8

For sub-8-byte elements, pass a byte size after the offset:

(let buf (alloc (* n 3)))         ; array of 3-byte integers
(set! buf 0 3 100)                ; write 3 bytes at offset 0
(set! buf 3 3 200)                ; write 3 bytes at offset 3
(let v (get buf (* i 3) 3))      ; read 3 bytes at offset i*3

Literal sizes are checked at compile time (must be ≤ 8). Runtime sizes emit a trap guard; use memcpy directly to avoid the check.

Array of structs — byte offset then named chain in one form:

(let pts (alloc (* n (sizeof Point))))
(let sz  (sizeof Point))
(set! pts 0        Point x 10)
(set! pts 0        Point y 20)
(let x0 (get pts 0        Point x))
(let x1 (get pts sz       Point x))
(let x2 (get pts (* 2 sz) Point x))

;; aref for a pointer to element i
(let p (aref pts (* i sz) Point))      ; address of element i
(let px (aref pts (* i sz) Point x))   ; address of field x in element i

Array of pointers — -> dereferences a stored pointer:

(let arr (alloc (* n 8)))
(set! arr 0 p0)
(let x (get arr 0 -> Point x))
(set! arr 0 -> Point x 99)

Pointer-to-array in a struct field — follow with -> then navigate by byte offset:

(struct Bag (len 8) (items 8))   ; items stores a raw pointer

;; get items base pointer, then index into it
(let base (get bag Bag items))             ; follow the items pointer
(let x (get base (* i sz) Point x))       ; element i, field x
(let addr (aref base (* i sz) Point x))   ; address of element i, field x

Unions

All fields share offset 0. Total size is the largest field.

(union Val
  (as-byte  1)
  (as-int   4)
  (as-ptr   8))   ; total size = 8 (largest field)

(let u (alloc (sizeof Val)))
(set! u Val as-ptr 0x0102030405060708)
(get u Val as-ptr)   ; 0x0102030405060708
(get u Val as-int)   ; 0x05060708  — lower 32 bits
(get u Val as-byte)  ; 0x08        — lowest byte

Enums

Emit #define constants. Access them with (c-const Name_Variant).

(enum Direction
  (North 0) (East 1) (South 2) (West 3))

(if (== dir (c-const Direction_North))
  (printf "heading north\n"))

Auto-incrementing (omit the value):

(enum Color Red Green Blue)   ; Red=0, Green=1, Blue=2

Tiny structs

tiny-struct packs named bit fields into a single AnchorVal scalar — no allocation, no pointer, just inline bit operations. Field sizes are in bits. Total must be ≤ 64.

(tiny-struct Color (r 8) (g 8) (b 8) (a 8))   ; 32 bits = 4 bytes

(let c (Color 255 128 64 200))     ; constructor — packed into one integer
(Color-get c r)                     ; → 255  (first field: mask only, no shift)
(Color-get c g)                     ; → 128  (shift + mask)
(Color-get c a)                     ; → 200  (last field: shift only, no mask)

(let c2 (Color-set c g 42))        ; returns NEW value — no mutation
(Color-get c2 g)                    ; → 42
(Color-get c g)                     ; → 128  (original unchanged)

Value semantics: Color-set returns a new packed integer. The original is unchanged. This is different from regular structs, which mutate through a pointer.

sizeof works — returns the byte count (bits rounded up):

(sizeof Color)                      ; → 4

Tiny structs compose with regular structs for storage:

(struct Pixel (color (sizeof Color)) (x 4) (y 4))

(let p (alloc (sizeof Pixel)))
(set! p Pixel color (Color 255 0 0 255))
(let c (get p Pixel color))
(Color-get c r)                     ; → 255

Fields can be any bit width — not just byte-aligned:

(tiny-struct Flags (read 1) (write 1) (exec 1) (mode 5))   ; 8 bits = 1 byte

(let f (Flags 1 1 0 3))
(Flags-get f mode)                  ; → 3
(let f2 (Flags-set f exec 1))      ; flip exec on

Globals and constants

(global count 0)              ; mutable global AnchorVal
(set! count (+ count 1))

(global buf (array-of (kb 64)))  ; static 64 KB buffer

(const max-size 4096)         ; immutable — compiler may fold it
(const keys (array "Am" "F" "C" "G"))  ; static array of strings

Linked lists

cons, car, cdr, set-car!, set-cdr!, nil, and null? are built into the language. cons allocates a two-slot cell from the current arena.

nil is the zero value — a null pointer. It is identical to integer 0 at the bit level; null? simply tests whether the value is zero.

(let lst (cons 1 (cons 2 (cons 3 nil))))

(let cur lst)
(while (! (null? cur))
  (printf "%d\n" (cast int (car cur)))
  (set! cur (cdr cur)))

set-car! and set-cdr! mutate a cons cell in place:

(set-car! lst 99)          ; replace head value
(set-cdr! lst (cons 5 nil)) ; replace tail

For loops

for is a unified loop macro with range and each forms. continue and break work correctly — the increment is auto-inserted before continue.

;; range
(for range (i 10) (printf "%lld " i))           ; 0 to 9
(for range (i 3 8) (printf "%lld " i))           ; 3 to 7

;; untyped array
(for each (val arr len) (printf "%lld " val))
(for each ((val i) arr len) (printf "%lld:%lld " i val))

;; typed struct array
(for each (Point p points count)
  (printf "(%lld,%lld) " (get p Point x) (get p Point y)))
(for each (Point (p i) points count)
  (set! p Point x (* i 10)))

;; cons list
(for each (v lst ->) (printf "%lld " v))
(for each ((v i) lst ->) (printf "%lld:%lld " i v))

Limits, lengths, and counts are evaluated once. Untyped array iteration uses an offset-bump pattern that keeps the base pointer loop-invariant, enabling SIMD auto-vectorization in the generated C.

Symbols

Symbols pack short strings (up to 7 bytes UTF-8) into a single AnchorVal integer. Comparison is a single integer == — no strcmp, no hashing. The 8th byte is always zero, so (ref s) yields a valid null-terminated C string for free.

(let status (sym ready))       ; compile-time constant — zero cost
(let other  (sym waiting))

(if (== status (sym ready))    ; single integer comparison
  (printf "go!\n"))

(printf "status: %s\n" (cast char* (ref status)))   ; prints "ready"

sym is a macro that runs at compile time. The identifier is converted to a UTF-8 byte sequence and packed into a 64-bit integer literal — no function call in the generated C.

For runtime conversion from a C string, use string->symbol:

(let s (string->symbol "hello"))   ; runtime — packs bytes via memcpy
(== s (sym hello))                 ; → 1 (same integer)

Symbols longer than 7 bytes are truncated by string->symbol (returns 0 from sym if the guard fails at compile time). The 7-byte limit covers most identifiers and keywords: default, private, include, message, request, display, etc.


Compiler flags

Flag Effect
--run Compile and execute immediately
-o FILE Output binary (or .c if FILE ends in .c)
--emit-exp Print macro-expanded AST
--multi-threaded Use _Thread_local for the arena pointer. Required for programs that use threads. Without this flag, the arena pointer is a plain global — faster (no TLS overhead) but not thread-safe.

Macros

syntax-rules — pattern-based

Hygienic: names introduced in a template (like _tmp) are automatically gensymmed so they never clash with variables at the call site.

; when / unless — one-armed conditionals
(define-syntax when
  (syntax-rules ()
    [(_ cond body ...)
     (if cond (do body ...))]))

(define-syntax unless
  (syntax-rules ()
    [(_ cond body ...)
     (if (! cond) (do body ...))]))

; for loop — block scopes the variable, so it doesn't leak after the loop
(define-syntax for
  (syntax-rules (to)
    [(_ var from to limit body ...)
     (block                      ; C scope: var not visible after the loop
       (let var from)
       (while (< var limit)
         body ...
         (set! var (+ var 1))))]))

; swap! — _tmp is gensymmed, so (let _tmp 99) in caller is safe
(define-syntax swap!
  (syntax-rules ()
    [(_ a b)
     (do (let _tmp a) (set! a b) (set! b _tmp))]))

Recursive patterns — my-and rewrites itself until base cases apply:

(define-syntax my-and
  (syntax-rules ()
    [(_)          1]
    [(_ e)        e]
    [(_ e rest ...)  (if e (my-and rest ...) 0)]))

Literal keyword in pattern — else is matched exactly, not as a pattern variable:

(define-syntax my-cond
  (syntax-rules (else)
    [(_ (else body ...))             (do body ...)]
    [(_ (test body ...) clause ...)  (if test (do body ...) (my-cond clause ...))]))

macro-case — with expansion-time computation

Templates are plain Chez Scheme code. Pattern variables bind to the matched Anchor AST values. This lets you run arbitrary computation — length, map, iota, string manipulation — before emitting a single line of C.

Three template styles are available inside macro-case clause bodies:

Style Ellipsis vars Ellipsis in template
` (Chez quasiquote) plain Chez lists ,@var to splice
#' (syntax template) plain Chez lists var ... via pattern engine
#` (quasisyntax) plain Chez lists var ... via pattern engine, #,expr for escapes

With backtick, body ... in the template is a literal symbol pair — use ,@body to splice. With #' or #`, the pattern engine handles var ... expansion directly.

arena-array — size computed at expand time, indices are literals:

(define-syntax arena-array
  (macro-case ()
    [(_ name val ...)
     (let* ([n    (length val)]
            [size (* n 8)])
       `(do
          (let ,name (alloc ,size))
          ,@(let loop ([i 0] [vs val])
              (if (null? vs) '()
                  (cons `(set! ,name ,(* i 8) ,(car vs))
                        (loop (+ i 1) (cdr vs)))))))]))

(arena-array primes 2 3 5 7 11 13)
; expands to: (let primes (alloc 48))
;             (set! primes 0 2)
;             (set! primes 8 3) ...

unroll — loop body inlined N times, enforced by guard:

(define-syntax unroll
  (macro-case ()
    [(_ n body ...)
     (number? n)
     `(do ,@(apply append (map (lambda (_) body) (iota n))))]))

(unroll 4 (set! ticks (+ ticks 1)))
; expands to four sequential set! calls — no loop, no branch

Macros that define macros

syntax-rules cannot write macros whose inner templates contain ... because the outer instantiator would try to expand them. macro-case with quasiquote treats the inner template as plain data — r, ..., x are just symbols being consed into a list:

(define-syntax define-fold-op
  (macro-case ()
    [(_ name op identity)
     `(define-syntax ,name
        (syntax-rules ()
          [(_)         ,identity]
          [(_ x)       x]
          [(_ x r ...) (,op x (,name r ...))]))]))

(define-fold-op my-add + 0)
(define-fold-op my-mul * 1)

(my-add 1 2 3 4)   ; → 10
(my-mul 2 3 4)     ; → 24

define-struct — generating multiple top-level definitions

A macro that returns (do ...) at the top level has its children spliced as separate top-level forms. This lets one call site emit a struct definition, a constructor, and accessor functions:

(define-syntax define-struct
  (macro-case ()
    [(_ name (field size) ...)
     (let* ([sname  (id-sym name)]
            [cname  (string->symbol (string-append "make-" (symbol->string sname)))]
            [pnames (map (lambda (f) (string->symbol (string-append "p_" (symbol->string (id-sym f))))) field)]
            [anames (map (lambda (f) (string->symbol (string-append (symbol->string sname) "-" (symbol->string (id-sym f))))) field)])
       `(do
          (struct ,name ,@(map list field size))
          (fn ,cname (,@pnames)
            (let _ptr (alloc (sizeof ,name)))
            ,@(map (lambda (f p) `(set! _ptr ,name ,f ,p)) field pnames)
            (return _ptr))
          ,@(map (lambda (aname f)
                   `(fn ,aname (s) (return (get s ,name ,f))))
                 anames field)))]))

(define-struct Vec2 (x 8) (y 8))

; Generated at compile time:
;   (struct Vec2 (x 8) (y 8))
;   (fn make-Vec2 (p_x p_y) ...)
;   (fn Vec2-x (s) ...)
;   (fn Vec2-y (s) ...)

Anaphoric macros — intentional capture with datum->syntax

By default macros are hygienic: names introduced in a template never clash with names at the call site. For deliberately anaphoric macros (e.g. aif, which binds it for the user to reference), use datum->syntax to place a name in the call-site scope. Name the keyword position in the pattern (rather than _) to get a handle carrying the call-site marks:

(define-syntax aif
  (macro-case ()
    [(self test then else-clause)
     `(block
        (let ,(datum->syntax self 'it) ,test)
        (if ,(datum->syntax self 'it) ,then ,else-clause))]))

(aif (find-item key table)
  (printf "found: %d\n" (cast int it))
  (printf "not found\n"))

it inside then refers to the macro-introduced binding, not any outer it. The block scope means any outer it is simply shadowed, not renamed.

Local helpers in macro-case

Place (define name body) forms after the literals list and before the first pattern clause. They become internal definitions scoped to that macro — shared across all branches, invisible outside.

(define-syntax for
  (macro-case (to)
    (define walk-continue
      (lambda (stmts incr)
        (map (lambda (s)
               (let walk ([form s])
                 (cond
                   [(not (pair? form)) form]
                   [(memq (id-sym (car form)) '(fn lambda while)) form]
                   [(eq? (id-sym (car form)) 'continue)
                    `(do ,incr (continue))]
                   [else (map walk form)])))
             stmts)))
    (define expand-body
      (lambda (body)
        (let ([expanded (local-expand `(do ,@body))])
          (if (and (pair? expanded) (eq? (id-sym (car expanded)) 'do))
              (cdr expanded)
              (list expanded)))))
    [(_ i to limit body ...)
     (let* ([incr `(set! ,i (+ ,i 1))]
            [stmts (expand-body body)]
            [walked (walk-continue stmts incr)])
       `(block (let ,i 0)
              (while (< ,i ,limit)
                ,@walked
                ,incr)))]))

Local helpers have access to the same functions as macro-case templates: id-sym, anchor-error, anchor-gensym, datum->syntax, is-struct?, local-expand, filter-map, and syntax record accessors (stx?, stx-sym, stx-marks, stx-src, make-stx).


Examples

File What it shows
examples/hello.anc Hello World, with-arena, basic FFI
examples/fizzbuzz.anc Functions, while, conditionals, %
examples/array.anc alloc, get/set!, bubble sort, for macro
examples/linked_list.anc cons/car/cdr/nil/null?, list operations
examples/global_arena.anc global-arena, arena-reset!, lists escaping function scope
examples/structs.anc Structs, nested structs, unions, enums, array-of-structs
examples/macros_showcase.anc Full macro spectrum: syntax-rulesmacro-case → macros defining macros
examples/fn_pointers.anc fn-ptr, call-ptr, fn-c, call-ptr-c, passing callbacks to qsort
examples/get_set_chains.anc get/aref/set! edge cases: array-of-structs, array-of-pointers, -> pointer chaining, embedded struct chains, inline array fields, byte-offset navigation

Design notes

Uniform value type. Every value is an AnchorVal — a raw 64-bit quantity that holds either a pointer or an integer/float unboxed. Everything flows through the same ABI — no overloaded calling conventions, no special-casing for primitives, no GC write barriers.

Arenas, not GC. alloc bumps a pointer. Anonymous with-arena scopes free all allocations when the block exits. global-arena declares a named arena with permanent backing memory — reset it explicitly with arena-reset! when you want to reclaim. with-arena-checkpoint marks a restore point for scratch work within a loop or function call — everything allocated inside is reclaimed at scope exit, while earlier allocations survive. Arenas nest and stack; cons and alloc always use the innermost active arena.

C as the backend. The compiler emits a single .c file with no dependencies beyond anchor.h (included from anchor/runtime/). You can inspect, modify, or link the C output directly. cc is invoked automatically with --run or when compiling to a binary.

Hygiene without a runtime. Macros use KFFD mark-based hygiene. Each macro application gets a fresh mark; user-provided identifiers cancel (XOR) while template-introduced names keep their mark and become global references after resolution. No syntax objects, no scope chains — Anchor has no module system or runtime environments, so the flat-namespace model is sufficient.

About

Dynamic C with s-expressions and hygenic macros

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors