Source: src/hql/s-exp/macro.ts, src/hql/s-exp/macro-reader.ts, src/hql/s-exp/macro-registry.ts, src/hql/macroexpand.ts, src/hql/gensym.ts, src/hql/transpiler/syntax/quote.ts
Macro libraries: src/hql/lib/macro/core.hql, src/hql/lib/macro/utils.hql, src/hql/lib/macro/loop.hql
HQL provides a Lisp-style macro system for compile-time code transformation. Macros are defined with the macro keyword and expand before runtime. The system includes:
quote / syntax-quote / quasiquote / unquote / unquote-splicing for code-as-data`, ~, ~@)macrosyntax-quote plus explicit raw quasiquotegensym and auto-gensym (foo#)%first, %rest, %nth, %length, %empty?, %eval, %macroexpand-1, %macroexpand-all) for S-expression manipulationlist?, symbol?, name) available at macro-time&form / &env pseudo-params and destructuring in macro parameter listsmacroexpand / macroexpand1 / macroexpandAll / macroexpandTrace for debugging macro expansion(macro name [params] body)
(macro name [params & rest-param] body)
(macro name [&form &env params] body)
The keyword is macro (not defmacro). Parameters use vector syntax [...]. Rest parameters use &. Macro parameter lists also support vector/map destructuring. &form and &env may appear at the front of the parameter list to access the original invocation form and the current macro environment snapshot.
(macro when [test & body]
`(if ~test
(do ~@body)
nil))
(when (> x 5) (print "big"))
;; expands to: (if (> x 5) (do (print "big")) nil)
quote prevents evaluation and converts code to data:
(quote x) ;; => "x" (symbol becomes string)
(quote 42) ;; => 42
(quote "hello") ;; => "hello"
(quote true) ;; => true
(quote ()) ;; => [] (empty list becomes empty array)
(quote (a b c)) ;; => ["a", "b", "c"]
(quote (a (b c) d)) ;; => ["a", ["b", "c"], "d"]
Symbols become strings. Lists become arrays (recursive). Primitives pass through.
syntax-quote is the hygienic template form. It resolves non-local symbols, preserves local binding identity metadata, and still supports unquote / unquote-splicing:
(syntax-quote (a b c)) ;; => ["a", "b", "c"]
(var x 10)
(syntax-quote (a (unquote x) c)) ;; => ["a", 10, "c"]
(var nums [1, 2, 3])
(syntax-quote (a (unquote-splicing nums) b)) ;; => ["a", 1, 2, 3, "b"]
The parser transforms backtick syntax into the long forms:
| Shorthand | Long form |
|---|---|
`(...) | (syntax-quote (...)) |
~expr | (unquote expr) |
~@expr | (unquote-splicing expr) |
(var x 42)
`(result is ~x) ;; => ["result", "is", 42]
(var items [1 2 3])
`(a ~@items b) ;; => ["a", 1, 2, 3, "b"]
Outside of a quasiquote context, ~ is treated as the bitwise NOT operator. ~@ outside quasiquote is a parse error.
quasiquote remains available as the raw, non-resolving template form. Use it when you explicitly want a template without hygienic symbol resolution.
Template quotes support nesting with depth tracking. Each nested syntax-quote or quasiquote increments depth; each unquote decrements it. Only at depth 0 does unquote evaluate the expression.
HQL uses hygienic syntax-quote for symbol resolution, plus explicit gensym-based control when you need to construct fresh locals yourself. Two mechanisms are available:
foo#)Inside a syntax-quote or quasiquote template, symbols ending with # are automatically replaced with unique generated symbols. All occurrences of the same foo# within the same template map to the same symbol.
(macro match [value & clauses]
`(let (val# ~value)
(__match_impl__ val# ~@clauses)))
;; val# is replaced with a unique symbol like val_42
The gensym function generates unique symbol names at macro expansion time:
(macro xor [a b]
(let (ga (gensym "xor_a")
gb (gensym "xor_b"))
`(let (~ga ~a
~gb ~b)
(if ~ga (not ~gb) ~gb))))
gensym returns a GensymSymbol object. When unquoted in a macro, it becomes a symbol (not a string literal).
with-gensyms MacroA utility macro (from utils.hql) that binds multiple gensyms at once:
(macro my-swap [a b]
(with-gensyms [tmp]
`(let (~tmp ~a)
(= ~a ~b)
(= ~b ~tmp))))
These functions are available during macro expansion for S-expression manipulation. They use the % prefix convention and are defined in environment.ts:
| Primitive | Description |
|---|---|
%first | First element of a list/vector |
%rest | All elements after the first |
%nth | Element at index |
%length | Number of elements |
%empty? | Whether collection is empty |
These operate on S-expression structures (not runtime arrays). They handle vector syntax ([a b] parsed as (vector a b)) by skipping the vector prefix.
Available at macro-time for inspecting S-expression types:
| Function | Description |
|---|---|
list? | True if value is an S-expression list |
symbol? | True if value is an S-expression symbol |
name | Returns the string name of a symbol |
(macro cond [& clauses]
(if (%empty? clauses)
nil
(let (first-clause (%first clauses))
(if (list? first-clause)
;; grouped syntax
...))))
Macro bodies can use the following at expansion time:
if, cond, let, var -- control flow and bindingsquote, syntax-quote, quasiquote -- code construction%first, %rest, etc.)+, -, ===, >=, etc.) via interpreter bridgefn definitions are registered in a persistent interpreter environment and can be called from later macrosMacros receive raw forms by default, including nested macro calls. If you want to force macro-time execution of a raw form, use %eval. %macroexpand-1 and %macroexpand-all expose explicit expansion from inside macro bodies.
The macro system uses a lazy singleton interpreter with a persistent environment for evaluating macro-time expressions. User-defined functions survive across macro expansions.
macroexpandFully expand all macros in a source string (returns array of S-expression strings):
import { macroexpand } from "hql";
const result = await macroexpand(`(when true (print "hello"))`);
macroexpand1Single-step expansion (one iteration, no recursive descent):
import { macroexpand1 } from "hql";
const result = await macroexpand1(`(when true (print "hello"))`);
macroexpandAllFull fixed-point expansion alias:
import { macroexpandAll } from "hql";
const result = await macroexpandAll(`(when true (print "hello"))`);
macroexpandTraceMachine-readable expansion trace for tooling and debugging:
import { macroexpandTrace } from "hql";
const result = await macroexpandTrace(`(when true (print "hello"))`);
// result.trace => [{ stage, before, after, macroName?, iteration?, ... }]
When the macro log namespace is enabled, macro expansions are printed with ASCII visualization showing original and expanded forms.
Logic:
not -- (not x) => (if x false true)and -- short-circuit, recursive. (and) => true, (and x) => x, (and x y z) => (&& x (&& y z))or -- short-circuit, recursive. (or) => false, (or x) => x, (or x y z) => (|| x (|| y z))Conditionals:
when -- (when test & body) => (if test (do ...body) nil)unless -- (unless test & body) => (if test nil (do ...body))if-let -- (if-let [name expr] then else) -- binds name, executes then if truthywhen-let -- (when-let [name expr] & body) -- binds name, executes body if truthycond -- multi-branch conditional. Supports grouped syntax ((test result) ...) and flat syntax (test result ...)ifLet / whenLet -- camelCase aliases for if-let / when-letType predicates (compile to inline JS):
isNull, isUndefined, isNil, isDefined, notNilisString, isNumber, isBoolean, isFunction, isSymbolisArray, isObjectUtility:
inc / dec -- (inc x) => (+ x 1)print -- (print & args) => (console.log ...args) (with format-print dispatch when 2 args)str -- string concatenation. (str) => "", (str x) => (+ "" x), (str a b) => (+ a b)length -- (length coll) => null-safe .length accesslist -- (list & items) => [...items]contains -- (contains coll key) => coll.has(key)set -- (set target value) => (= target value)method-call -- (method-call obj method & args) => (js-call obj method ...args)hasElements -- (hasElements coll) => (> (length coll) 0)isEmptyList -- (isEmptyList coll) => (=== (length coll) 0)Collections:
hash-map -- (hash-map & items) => (__hql_hash_map ...items)empty-map -- (empty-map) => (hash-map) => {}empty-set -- (empty-set) => (hash-set) => new Set()empty-array -- (empty-array) => (vector) => []Threading:
-> (thread-first) -- (-> x (f a) (g b)) => (g (f x a) b)->> (thread-last) -- (->> x (f a) (g b)) => (g b (f a x))as-> (thread-as) -- (as-> 2 x (+ x 1) (* x 3)) -- binds value to named symbol for arbitrary placementPattern matching:
match -- (match value (case pattern result) ... (default result))_, symbol binding, arrays [a b], rest [h & t], objects {name age}, or-patterns (| p1 p2 p3), guards (if condition)doto -- execute forms with object as first arg, return objectif-not -- swaps then/else brancheswhen-not -- execute body when condition is falsyxor -- logical exclusive or (uses gensym for hygiene)min / max -- (min & args) => (Math.min ...args)with-gensyms -- bind multiple gensyms for hygienic macro writingwhile -- (while condition & body) -- built on loop/recurrepeat -- (repeat count & body) -- execute body N timesfor -- enhanced iteration with multiple syntaxes: (for (x coll) body), (for (i start end) body), (for (i from: 0 to: 10 by: 2) body)HQL Source
|
v
Parser (backtick => syntax-quote, ~ => unquote, ~@ => unquote-splicing)
|
v
Macro Expansion (compile-time, iterative fixed-point)
|
v
S-expression AST (macro definitions filtered out)
|
v
IR Nodes (quote.ts handles quote/quasiquote => IR)
|
v
ESTree AST
|
v
JavaScript
Environment and expanded iteratively to a fixed point (max iterations controlled by MAX_EXPANSION_ITERATIONS)maxExpandDepth)updateMetaRecursively, so error messages point to user code, not macro definitionsMacroRegistry class manages system-level macros (from .hql library files)Interpreter with a persistent environment (bridgeToInterpreterEnv copies compiler scope bindings)