| name | Lisp Macro Architecture |
| description | This skill should be used when the user asks to "write a macro", "defmacro", "design a DSL", "build a compiler-time code transformer", "hygienic macro", "macro hygiene", "code walker", "CPS transform", "anaphoric macro", "once-only", "g!-symbol", "duality of syntax", "pandoric macro", "On Lisp", "Let Over Lambda", or works with compile-time metaprogramming in Common Lisp or Emacs Lisp. Provides both the canonical macro-writing technique catalog (On Lisp, Let Over Lambda) and a rigorous engineering discipline โ phase separation, hygiene, evaluation-order preservation, compile-time diagnostics โ for turning those techniques into correct, production-quality macros and DSLs. For general CL/Elisp language basics, defer to common-lisp-ecosystem / emacs-ecosystem. |
| version | 2.1.0 |
Serve as a general-purpose Lisp macro-writing skill, dialect-agnostic across Common Lisp
and Emacs Lisp, combining two layers:
(1) the canonical technique catalog for "correct" macros as established in Paul Graham's
On Lisp and Doug Hoyte's Let Over Lambda โ
once-only, anaphora, auto-gensym (g!/o!-symbols), generalized variables, macros returning
functions, CPS macros, macro-defining-macros, duality of syntax, pandoric macros โ and
(2) an engineering discipline that turns those techniques into non-trivial, multi-clause
DSLs safely: phase separation, hygiene, evaluation-order preservation, compile-time
diagnostics, and a parser/analyzer/emitter pipeline behind a thin defmacro.
Reach for layer (1) when writing or reviewing any individual macro; reach for layer (2)
when the macro is a DSL with several clause forms or non-trivial static analysis.
The canonical macro technique catalog: once-only, anaphora, auto-gensym (g!/o!-symbols), generalized variables, macros returning functions, CPS macros, macro-defining-macros, duality of syntax, pandoric macros (see canonical_technique_library)
Macro/DSL architecture: parser โ analyzer/walker โ emitter pipelines behind a thin defmacro
Phase separation between compile-time helpers and runtime code (eval-when / eval-and-compile)
Hygiene: gensym discipline, intentional anaphoric capture, evaluation-order preservation
Compile-time diagnostics: turning malformed DSL input into macro-expansion-time errors
Editor/DX metadata: &body vs &rest, (declare (indent ...) (debug ...)) in Elisp
Self-verification of expansions (macroexpand-1 / macroexpand / pp-macroexpand-last-sexp)
CLOS, ASDF, condition system fundamentals โ see common-lisp-ecosystem
SBCL runtime operations, debugging, profiling โ see sbcl-usage
Emacs package system, use-package, LSP integration โ see emacs-ecosystem
Reader macros / set-macro-character โ covered only as a scoped, opt-in technique, not a default tool
Read - Inspect existing macro definitions and their call sites
Edit - Modify defmacro forms and their compile-time helper functions
Bash - Run sbcl/emacs --batch to verify macroexpansion and byte-compilation
mcp__plugin_claude-code-home-manager_context7__query-docs - Verify ASDF/SBCL/Elisp macro-system edge cases
<absolute_laws priority="critical">
Resolve everything resolvable at macro-expansion time: clause structure, state/register dependency graphs, lifetimes, continuation chains. Never reach for eval.
Every fact known from the S-expression shape alone is a fact the runtime should never have to recompute. A DSL that defers this to eval pays a performance and safety tax on every execution instead of once at compile time.
<how_to_apply>In the analyzer stage, build a static graph (plist/struct/alist) over the AST and answer questions ("is this register still live here?", "does this clause reference an undefined state?") by walking that graph, not by generating code that asks the question at runtime.</how_to_apply>
Compile-time helper functions (parser, analyzer, emitter) must be declared so they exist in the compile-time environment.
Without this, cross-compilation, minimal-compile (fasl-only), or a fresh REPL load order in CL will signal "undefined function" during macroexpansion; in Elisp, byte-compiling a file that uses a macro from another file will silently fall back to a runtime function call and lose expansion-time errors.
CL: wrap parser/analyzer/emitter defuns in
(eval-when (:compile-toplevel :load-toplevel :execute) ...).
Elisp: wrap them in
(eval-and-compile ...), and require lexical-binding: t at the top of the file.
Never evaluate a user-supplied argument form more than once, and never reorder the left-to-right evaluation of user-supplied forms.
A macro that evaluates
(incf counter) twice or evaluates argument B before argument A silently breaks any caller relying on ordinary function-call semantics โ the single most common macro-hygiene bug.
Bind every argument form exactly once via gensym'd let-bindings in the order they appear (the "once-only" idiom), then reference only the bound symbols in the expansion body.
Reject malformed DSL input during macro-expansion with an actionable error, not at runtime.
DSL users write S-expressions, not English; the parser is their only source of feedback. A runtime error three call frames deep costs far more debugging time than an expansion-time error that names the offending clause.
The parser validates shape as it builds the AST and calls (error "~S: expected (VAR FORM) in clause, got ~S" 'macro-name clause) [CL] / (error "macro-name: expected (VAR FORM) in clause, got %S" clause) [Elisp] before the emitter ever runs.
A DSL macro must indent, debug-step, and macroexpand as naturally as a built-in special form.
If the user must think about internal indentation or step-debugging quirks, the abstraction has failed โ cognitive load has leaked from "what the DSL means" to "how the DSL is implemented."
CL: use &body (not &rest) for the trailing body argument so SLY/SLIME's arglist-derived indentation works automatically.
Elisp: add
(declare (indent N) (debug FORM)) as the first form in the macro body.
A defmacro body containing three or more nested backquote levels is an architecture failure.
Nested backquote/comma is nearly unreadable and impossible to unit-test in isolation; every bug becomes a full-expansion debugging session.
The defmacro body should be a 1-3 line pipeline call:
(emit (analyze (parse forms))). All real logic โ including code generation โ lives in ordinary functions that return S-expressions and can be unit-tested directly without macroexpansion.
Every symbol introduced by the macro that the user did not write must be gensym'd; every symbol the macro intentionally exposes to user code (anaphora) must be documented as such.
Unhygienic macros cause variable capture bugs that are invisible in the macro source and only appear at obscure call sites โ the classic Lisp macro footgun.
CL: (gensym "PREFIX-") for internal temporaries.
Elisp: (cl-gensym "prefix-") or (make-symbol "prefix") โ never (gensym) alone without a distinguishing prefix in Elisp; require lexical-binding: t.
Anaphoric macros (e.g. aif binding `it`): use the literal symbol, and state the capture explicitly in the docstring.
Design the DSL's input S-expression and prove its expansion is efficient before writing any macro code
Declare the target dialect (Common Lisp or Emacs Lisp) explicitly โ hygiene primitives and phase-separation forms differ
Dialect declared
Draft the DSL's user-facing input S-expression, optimizing for the lowest possible cognitive load ("้ไธญๅใฎๆพๆฃ" โ the user should never think about registers, continuations, or environment plumbing)
Example input S-expression
Hand-write the ideal, fully-expanded, runtime-optimal S-expression that this input should produce โ this is the executable proof of efficiency the design is judged against
Example expanded S-expression, annotated with why each part is there
Implement parser, analyzer, and emitter as independently testable compile-time pure functions
Parser: read the raw DSL form, validate its shape, and build an intermediate representation (plist/alist/defstruct). Signal a compile-time error immediately on malformed input.
parse(form) -> AST, or a compile-time error
Analyzer/Walker: traverse the AST to perform static analysis โ dependency graphs, lifetime/liveness checks, non-deterministic-branch enumeration โ entirely without emitting code yet
analyze(ast) -> annotated AST
Emitter: transform the annotated AST into the final, flattened, optimized S-expression from Phase 1
emit(annotated-ast) -> S-expression
Expose the pipeline through the thinnest possible defmacro
Write defmacro as a direct pipeline call: (emit (analyze (parse forms))) โ no independent logic in the macro body itself
Thin defmacro, โค3 lines of logic
Attach editor/DX metadata: &body argument ordering in CL, or (declare (indent ...) (debug ...)) in Elisp
Macro indents and debug-steps like a built-in form
Verify the expansion matches the Phase-1 proof and preserves hygiene/evaluation-order guarantees
Run macroexpand-1 / macroexpand (CL) or macroexpand-1 / pp-macroexpand-last-sexp (Elisp) against the example input and diff it against the Phase-1 ideal expansion
Expansion matches design proof
Self-check: does any argument form appear more than once in the expansion? Are all internal symbols gensym'd? Does a malformed clause raise a compile-time error naming the clause?
Hygiene and diagnostics checklist passed
The defmacro itself is a 1-3 line hook; all logic lives in ordinary, independently testable functions (Law: ban_on_macro_monoliths)
;; Common Lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
(defun sm--parse (clauses) ...) ; -> AST
(defun sm--analyze (ast) ...) ; -> annotated AST (static checks)
(defun sm--emit (ast) ...)) ; -> single top-level S-expression
(defmacro state-machine (name &body clauses)
"Define a compile-time-verified finite state machine NAME."
(sm--emit (sm--analyze (sm--parse clauses))))
</example>
Compile-time helpers must exist in the compile-time environment (Law: strict_phase_separation)
;; Common Lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
(defun dsl--parse-clause (clause) ...))
;; Emacs Lisp (requires -*- lexical-binding: t; -*-)
(eval-and-compile
(defun dsl--parse-clause (clause) ...))
</example>
Bind every user-supplied form exactly once, in argument order, before referencing it in the expansion (Law: evaluation_order_and_single_evaluation)
;; Common Lisp (alexandria:once-only handles this idiom directly)
(defmacro my-max2 (a b)
(alexandria:once-only (a b)
`(if (> ,a ,b) ,a ,b)))
;; Emacs Lisp (manual once-only, since cl-lib has no equivalent)
(defmacro my-max2 (a b)
(let ((ga (make-symbol "a")) (gb (make-symbol "b")))
`(let ((,ga ,a) (,gb ,b))
(if (> ,ga ,gb) ,ga ,gb))))
</example>
Reject malformed input with a named, actionable error before the emitter runs (Law: compile_time_diagnostics)
;; Common Lisp
(defun dsl--parse-clause (clause)
(unless (and (consp clause) (symbolp (first clause)))
(error "state-machine: expected (STATE-NAME . TRANSITIONS), got ~S" clause))
...)
;; Emacs Lisp
(defun dsl--parse-clause (clause)
(unless (and (consp clause) (symbolp (car clause)))
(error "state-machine: expected (STATE-NAME . TRANSITIONS), got %S" clause))
...)
</example>
Make the DSL indent and step-debug like a built-in form (Law: editor_dx_parity)
;; Common Lisp: &body (not &rest) signals "indent as code" to SLY/SLIME
(defmacro with-resource ((var resource-form) &body body)
`(let ((,var ,resource-form))
(unwind-protect (progn ,@body)
(close-resource ,var))))
;; Emacs Lisp: declare indent + debug spec explicitly
(defmacro with-resource (var resource-form &rest body)
"Bind VAR to RESOURCE-FORM for the dynamic extent of BODY."
(declare (indent 1) (debug ((symbolp form) body)))
(let ((gvar (make-symbol "resource")))
`(let ((,gvar ,resource-form))
(let ((,var ,gvar))
(unwind-protect (progn ,@body)
(close-resource ,gvar))))))
</example>
Gensym internal temporaries; document anaphoric capture explicitly instead of hiding it (Law: total_hygiene)
;; Common Lisp: hygienic internal temp
(defmacro my-swap (a b)
(let ((tmp (gensym "TMP-")))
`(let ((,tmp ,a))
(setf ,a ,b ,b ,tmp))))
;; Common Lisp: intentional anaphoric capture, documented
(defmacro aif (test then &optional else)
"Anaphoric IF. Binds IT to the value of TEST, visible inside THEN/ELSE.
This capture is intentional; see anaphoric macro convention."
`(let ((it ,test))
(if it ,then ,else)))
;; Emacs Lisp: hygienic internal temp
(defmacro my-swap (a b)
(let ((tmp (cl-gensym "tmp-")))
`(let ((,tmp ,a))
(setf ,a ,b ,b ,tmp))))
</example>
Push static analysis (dependency/lifetime graphs, non-deterministic branch enumeration) entirely into the analyzer stage (Law: no_runtime_resolution)
;; Analyzer stage answers "is REGISTER still live after this instruction?"
;; by walking the AST once and recording last-use positions โ the emitted
;; code never asks this question again; it just reuses or frees the slot.
(defun vm--analyze-liveness (instructions)
(let ((last-use (make-hash-table)))
(loop for instr in instructions
for pos from 0
do (dolist (reg (vm--instr-reads instr))
(setf (gethash reg last-use) pos)))
last-use))
<canonical_technique_library>
The core repertoire for writing individually "correct" macros, drawn from Paul Graham's
On Lisp and Doug Hoyte's Let Over Lambda. Apply these techniques to any single macro,
independent of whether it is part of a larger DSL pipeline (see workflow/patterns above).
Code below is Common Lisp; Elisp equivalents are noted where they diverge.
The textbook, reusable once-only: binds each named form to a fresh gensym exactly once, preserving argument evaluation order, so the macro body can reference the names freely without re-evaluating or reordering user code.
(defmacro once-only (names &body body)
(let ((gensyms (mapcar (lambda (n) (gensym (string n))) names)))
`(let (,@(mapcar (lambda (g) `(,g (gensym))) gensyms))
`(let (,,@(mapcar (lambda (g n) ``(,,g ,,n)) gensyms names))
,(let (,@(mapcar (lambda (n g) `(,n ,g)) names gensyms))
,@body)))))
;; usage
(defmacro my-max2 (a b)
(once-only (a b)
`(if (> ,a ,b) ,a ,b)))
</example>
<note>The triple-backquote body is exactly the kind of nesting the ban_on_macro_monoliths law warns against โ it is a deliberately-hardened, well-tested exception. Prefer a battle-tested library implementation (alexandria:once-only) over hand-rolling this in application code.</note>
Intentionally capture a fixed variable name (conventionally `it`) so the body can refer to a just-computed value without re-stating it. Document the capture; it is the entire point of the macro.
(defmacro aif (test then &optional else)
"Anaphoric IF: binds IT to the value of TEST within THEN/ELSE."
`(let ((it ,test))
(if it ,then ,else)))
(defmacro awhen (test &body body)
`(aif ,test (progn ,@body)))
(defmacro aand (&rest args)
(cond ((null args) t)
((null (cdr args)) (car args))
(t `(aif ,(car args) (aand ,@(cdr args))))))
(defmacro alambda (parms &body body)
"Anonymous function that can recurse via SELF."
`(labels ((self ,parms ,@body))
#'self))
;; usage: (alambda (n) (if (<= n 1) 1 (* n (self (1- n)))))
</example>
defmacro! auto-gensyms every symbol whose name starts with "G!" appearing anywhere in the macro body, and auto-once-only's every "O!"-prefixed parameter in the argument list โ collapsing the once_only and gensym techniques above into a declarative naming convention instead of manual boilerplate.
;; prerequisite utilities (On Lisp, ch.4)
(defun mkstr (&rest args)
(with-output-to-string (s) (dolist (a args) (princ a s))))
(defun symb (&rest args)
(values (intern (apply #'mkstr args))))
(defun flatten (x)
(labels ((rec (x acc)
(cond ((null x) acc)
((atom x) (cons x acc))
(t (rec (car x) (rec (cdr x) acc))))))
(rec x nil)))
(defun g!-symbol-p (s)
(and (symbolp s) (> (length (symbol-name s)) 2)
(string= (symbol-name s) "G!" :end1 2)))
(defmacro defmacro/g! (name args &rest body)
(let ((syms (remove-duplicates
(remove-if-not #'g!-symbol-p (flatten body)))))
`(defmacro ,name ,args
(let ,(mapcar (lambda (s) `(,s (gensym ,(subseq (symbol-name s) 2)))) syms)
,@body))))
(defun o!-symbol-p (s)
(and (symbolp s) (> (length (symbol-name s)) 2)
(string= (symbol-name s) "O!" :end1 2)))
(defun o!-symbol-to-g!-symbol (s)
(symb "G!" (subseq (symbol-name s) 2)))
(defmacro defmacro! (name args &rest body)
(let* ((os (remove-if-not #'o!-symbol-p args))
(gs (mapcar #'o!-symbol-to-g!-symbol os)))
`(defmacro/g! ,name ,args
`(let ,(mapcar #'list (list ,@gs) (list ,@os))
,(progn ,@body)))))
;; usage: o! args are evaluated exactly once; g! symbols are auto-gensym'd
(defmacro! my-max2 (o!a o!b)
`(if (> ,g!a ,g!b) ,g!a ,g!b))
</example>
<note>This is the idiomatic replacement for hand-writing once_only + gensym boilerplate on every macro. Prefer it once available in a project; the manual once_only_evaluation_order pattern above shows what it replaces.</note>
Write a macro that abstracts over any "place" (any form valid as a setf target), not just plain variables, by expanding through get-setf-expansion.
(defmacro _f (op place &rest args)
(multiple-value-bind (vars forms var set access)
(get-setf-expansion place)
`(let* (,@(mapcar #'list vars forms)
(,(car var) (,op ,access ,@args)))
,set)))
(defmacro toggle (place)
`(_f not ,place))
;; usage: (toggle (gethash 'k table)) or (toggle (aref v i)) โ works on any place
</example>
<note>Standard CL also provides define-modify-macro for the common case of a fixed operator, e.g. (define-modify-macro appendf (&rest args) append).</note>
A macro-defining-macro: =defun defines both a macro NAME (the call-site sugar) and a function =NAME (the CPS-transformed implementation taking an explicit continuation), so callers write ordinary-looking code while the underlying control flow is continuation-passing.
(defvar *cont* #'identity)
(defmacro =lambda (parms &body body)
`#'(lambda (*cont* ,@parms) ,@body))
(defmacro =defun (name parms &body body)
(let ((f (intern (concatenate 'string "=" (symbol-name name)))))
`(progn
(defmacro ,name ,parms
`(,',f *cont* ,,@parms))
(defun ,f (*cont* ,@parms) ,@body))))
(defmacro =bind (parms expr &body body)
`(let ((*cont* #'(lambda ,parms ,@body))) ,expr))
(defmacro =values (&rest retvals)
`(funcall *cont* ,@retvals))
;; usage
(=defun add1 (n) (=values (1+ n)))
(=bind (result) (add1 41) (print result)) ; => prints 42
</example>
<note>This is the direct, textbook mechanism behind the "CPS transform" the Absolute Laws refer to: the continuation chain is built entirely by macro-defined plumbing (*cont*), and =defun/=bind read like ordinary function calls at every call site.</note>
Because Common Lisp is a Lisp-2 (separate function and variable namespaces), a single name can be simultaneously a macro (callable as `(name args)`) and a symbol-macro (usable bare as `name`), giving callers a choice of syntax for the same underlying computation.
(define-symbol-macro tau (* 2 pi)) ; bare TAU expands to (* 2 pi)
(defmacro tau (radius) ; (TAU r) expands to (* 2 pi r)
`(* 2 pi ,radius))
tau ; => 6.283185307179586
(tau 5) ; => 31.41592653589793
</example>
<note>Minimal illustration in the spirit of Let Over Lambda's duality-of-syntax technique, not a verbatim book example. Use sparingly โ it trades a small amount of surprise for call-site brevity, and should be documented at the definition site.</note>
Closures whose internal lexical variables are exposed for controlled external get/set access via a dispatching function, blurring the line between a closure and an object with named slots.
The full implementation (pandoriclet/plambda/pandoric-let/with-pandoric, built on dlambda and symbol-macrolet tricks) is intricate and easy to get subtly wrong from memory. Do not hand-roll it inline: pull in the tested let-over-lambda library implementation, or consult the book directly, before using this technique in production code.
<fasl_safe_expansion>
Constraints on what a macro may place into the code it returns, so the expansion survives
file-compilation (compile-file / FASL dump) and self-hosted or descriptor-backed evaluation,
not just interactive macroexpansion at the REPL. Interactive expansion hides these bugs
because nothing is serialized.
Do not let a compiled function object become a literal in macro output. A macro that captures #'fn (e.g. as a default callback or transformer) and splices it into the expansion embeds a function object that the file compiler cannot externalize, and compile-file fails at FASL-dump time.
Literal function objects are not dumpable to a FASL; only source-level function designators are. A REPL macroexpand never triggers the failure because it never dumps.
Splice a function-designator symbol (identity, not #'identity) and funcall it in the expansion, or resolve #'fn at the call site rather than inside the macro. This keeps the expansion source-serializable.
;; bad: the &optional default is evaluated at macroexpansion to a FUNCTION OBJECT,
;; which is then spliced into the expansion and cannot be dumped to a FASL.
(defmacro deftransform (name &optional (fn #'identity))
`(defun ,name (x) (funcall ,fn x))) ; ,fn splices a compiled function object
;; good: let a function-designator SYMBOL flow through; funcall accepts it.
(defmacro deftransform (name &optional (fn ''identity))
`(defun ,name (x) (funcall ,fn x))) ; ,fn splices the symbol IDENTITY
</example>
Leading (declare ...) forms in a macro body are declarations, not runtime calls. Any machinery that evaluates a macro body form-by-form โ a self-hosted or descriptor-backed evaluator โ must strip or specially handle leading declarations before evaluation; otherwise (declare (ignore x)) is evaluated as a call to a nonexistent function IGNORE.
General for any custom macro-body evaluator; standard CL compilers already handle this. Recorded as an observation from a self-hosting expander context.
<binding_form_scope_reference>
A macro that mechanically rewrites code โ rename a symbol, extract a function, inline a
binding, hoist a let โ is only correct if it respects the exact scope, shadowing, and
namespace rules of every binding form it walks. The most common macro/refactor bug is
treating all parenthesized (name value) shapes as one uniform "binding": the safety of a
mechanical edit depends on which namespace a name lives in and which sub-forms it shadows.
This catalogs the rules that make such rewrites safe.
<namespace_rule name="value_vs_callable_bindings">
Distinguish value-place bindings from callable bindings, because a name in one namespace must not shadow a rename target in the other.
<value_bindings>let, let*, symbol-macrolet, do/do*, prog/prog*, dolist, dotimes, with-slots, with-accessors bind value/place names. symbol-macrolet in particular is a value-place form: its names must not shadow function-call heads, and its expansion forms resolve in the outer environment while its body is shadowed by the symbol-macro names.</value_bindings>
<callable_bindings>flet, labels, macrolet, compiler-macrolet introduce local callable bindings that DO shadow an outer callable rename target within their body scope.</callable_bindings>
</namespace_rule>
<scope_rule name="init_vs_body_evaluation_environment">
For each binding form, know which sub-forms see the outer environment and which see the bound names โ this determines which occurrences a rename may rewrite.
let / prog: init forms are evaluated in the outer scope (parallel); the body sees all bound names.
let* / do* / prog*: sequential โ each init sees earlier bound names but not its own; the body sees all.
do: init forms are outer-scope; step forms, the end-test, and the body see all iteration variables.
dolist / dotimes: the list/count source is outer-scope; the iteration variable shadows the target in the optional result form and body.
symbol-macrolet: expansion forms resolve in the outer environment; only body references count as in-scope references to the symbol macro (relevant to unused-binding and inline analyses). In quasiquote, preserve comma/comma-at prefixes and rename only the unquoted symbol-macro references.
with-slots / with-accessors: the instance expression is outer-scope; slot/accessor names in specs are not value references; body references are the shadowed ones. Renaming a bare with-slots spec must expand it from slot to (new-name slot) to preserve the slot-name mapping.
handler-bind / restart-bind: the spec head (condition type / restart name) and restart-bind option keywords (:report, :test, :interactive) are designators, not value references. A lambda in a handler function or restart-option position introduces parameters that shadow only that lambda's own body.
</scope_rule>
<boundary_rule name="reader_prefix_and_quasiquote">
Treat quote and quasiquote reader prefixes as reference-literal boundaries, not merely (quote ...)/(list ...) heads. Under quote, symbols are data and must not be rewritten. Under quasiquote, data is protected but unquote (, / ,@) re-enters evaluation, so a rename must thread quasiquote depth: rewrite only unquoted references and preserve the , / ,@ prefixes on the replacement.
#'symbol and (function symbol) are callable designators: rename them with the target in executable context, but skip them inside quasiquoted data unless an unquote re-enters evaluation.
</boundary_rule>
<special_rule name="define_modify_macro_implicit_place">
define-modify-macro has an implicit leading place argument that precedes the user lambda-list parameters. Any call-site rewrite (add/remove/move/reorder an argument) must offset user arguments by one so the place argument is preserved.
</special_rule>
</binding_form_scope_reference>
<lambda_list_and_quasiquote_traps>
Implementation-detail traps encountered when a macro system parses and re-emits macro
lambda lists, and when tests construct expected expansions by hand.
&whole binds the entire original form. If a destructuring routine stores its result as an alist consumed via (cdr (assoc ...)), the &whole entry must be a dotted pair (cons whole arg) โ but that dotted shape is not itself a valid let binding, so normalize dotted/improper entries to (var value) at the code-generation boundary before emitting let/let*.
Handling &environment requires two coordinated steps: extract the environment symbol and wrap the expander body so it is bound, AND remove &environment from the lambda list used to generate the call-site argument bindings, or the environment symbol is overwritten by an argument binding. Because some expansion paths call a local expander with a nil environment, normalize a nil environment to a non-nil sentinel when the macro requests &environment.
When a test builds the expected expansion, do not construct it with '(,name ...) inside a backquote โ nested quote/backquote can preserve the comma as literal data instead of interpolating the gensym. Build the expected structure explicitly with list/cons.
defsetf long form and similar accessor-defining macros should reuse the same lambda-list binding generation as macro lambda lists, so optional/rest/key/aux structure is supported uniformly; bind the store variable separately and emit the body inside a let/let* wrapper.
When an expander forwards user-supplied structural options into a runtime helper (e.g. the symbol-type list of with-package-iterator: :internal / :external / :inherited), pass them as quoted data, not as a live form โ otherwise (:internal :external :inherited) is emitted as a runtime call.
A with-standard-io-syntax-style macro must rebind the full ANSI set, not a subset. Beyond the read/print numeric and escape variables it must bind *readtable* to a standard readtable (copy-readtable nil), *print-pprint-dispatch* to a standard dispatch (copy-pprint-dispatch nil), and *print-readably* to t. Omitting *readtable* or the pprint-dispatch/readably bindings is a common incomplete-reimplementation bug.
The 21-variable binding set is ANSI-specified (CLHS with-standard-io-syntax).
<dialect_notes>
Phase separation: eval-when (:compile-toplevel :load-toplevel :execute)
Hygiene primitive: gensym (or alexandria:once-only / alexandria:with-gensyms for batches)
Editor indentation: derived automatically from &body in the lambda list by SLY/SLIME; no explicit declare needed
Verification: macroexpand-1 (single step) vs macroexpand (fully expand); use sly-macroexpand-1 / sly-macroexpand-all interactively
Debug tooling: sly-macrostep (macrostep.el via SLY) for interactive step-through expansion
Phase separation: eval-and-compile; requires -- lexical-binding: t; -- at file top
Hygiene primitive: cl-gensym (from cl-lib) or make-symbol; gensym alone has no namespacing convention โ always pass a descriptive prefix
Editor indentation/debugging: explicit (declare (indent N) (debug SPEC)) as the first form in the macro body โ this is not automatic
Verification: macroexpand-1, macroexpand-all (from cl-macs); interactively via pp-macroexpand-last-sexp or macrostep.el
Byte-compilation: compile-time helpers not wrapped in eval-and-compile fail silently to a runtime call instead of erroring โ always verify with emacs --batch -f batch-byte-compile
</dialect_notes>
<decision_tree name="macro_vs_function">
Does this abstraction need to control evaluation (timing/order/whether an argument is evaluated at all), capture syntax, or generate code shaped by its arguments?
<if_yes>Use a macro โ but keep it a thin pipeline hook over pure parse/analyze/emit functions</if_yes>
<if_no>Use an ordinary function or higher-order function; a macro here only adds hygiene risk and debugging friction for no semantic benefit</if_no>
</decision_tree>
<best_practices>
Reach for the canonical_technique_library first (once-only, anaphora, defmacro!/g!-o!-symbols, generalized variables, CPS macros) instead of inventing ad hoc gensym/evaluation-order handling
Design the input S-expression and hand-write its ideal expansion before writing any macro code (Phase 1 proof-first)
Keep defmacro bodies to a 1-3 line pipeline call; all logic lives in testable functions
Gensym every internal temporary; document every intentional anaphoric capture
Validate DSL clause shape in the parser and signal errors before the emitter runs
Bind every user-supplied argument form exactly once, in left-to-right order, before referencing it in the expansion
Wrap compile-time helpers in eval-when (CL) or eval-and-compile (Elisp)
Use &body in CL lambda lists for trailing body arguments; use (declare (indent ...) (debug ...)) in Elisp macros
Verify every non-trivial macro's expansion with macroexpand-1/macroexpand against the Phase-1 proof before considering it complete
Push static analysis (liveness, dependency graphs, non-determinism enumeration) into the analyzer stage, never into emitted runtime code
Splice function designators (symbols), never #'fn function objects, into macro output so expansions remain FASL-dumpable
Before mechanically renaming/extracting across a binding form, confirm its namespace (value vs callable) and which sub-forms it shadows
Thread quasiquote depth in code-walking rewrites; preserve , / ,@ prefixes and leave quoted data untouched
Reimplement standard binding macros (e.g. with-standard-io-syntax) against the full ANSI variable set, not a convenient subset
</best_practices>
<anti_patterns>
Three or more nested backquote/comma levels directly inside defmacro
Extract a pure emit function that builds and returns the S-expression; keep defmacro to a pipeline call
Referencing a user-supplied argument form more than once in the expansion (e.g. `(if ,test (foo ,test) (bar ,test))`)
Bind it once via gensym (the once-only idiom) and reference the bound symbol thereafter
Generating code that calls eval, or deferring static-analyzable decisions (branch selection, lifetime checks) to runtime
Resolve them in the analyzer stage at macro-expansion time; emit only the already-decided, flattened result
Introducing a plain symbol like `result` or `tmp` in a macro expansion without gensym
gensym (CL) / cl-gensym or make-symbol (Elisp) for every macro-introduced temporary; reserve plain symbols for documented, intentional anaphora
Letting malformed DSL input pass through the parser unchecked and fail deep inside generated runtime code
Validate shape in the parser and error at macro-expansion time, naming the offending clause
Shipping a multi-clause DSL macro that mis-indents in the editor or can't be stepped in the debugger
&body in CL lambda lists; (declare (indent ...) (debug ...)) as the first form in Elisp macros
Reaching for set-macro-character / custom reader syntax to simplify a DSL
Prefer ordinary defmacro; reader macros are global, non-composable, and break tooling that doesn't know about them. Reserve for narrow, well-documented cases.
Splicing a captured #'fn function object into macro output (e.g. as a default callback), which the file compiler cannot externalize and fails to dump to a FASL
Splice a function-designator symbol and funcall it, or resolve #'fn at the call site; keep the expansion source-serializable
Treating every (name value) shape as one uniform "binding" during a mechanical rewrite, ignoring value-vs-callable namespace and per-form shadowing rules
Consult the binding_form_scope_reference: check the namespace of each name and which sub-forms it shadows before renaming, extracting, or inlining across it
Every non-trivial macro must have a hand-written Phase-1 "ideal expansion" it is verified against via macroexpand-1/macroexpand
defmacro bodies must be thin pipeline calls (parse โ analyze โ emit); no inline multi-level backquote logic
Every macro-introduced symbol not documented as intentional anaphora must be gensym'd
Every user-supplied argument form must be evaluated exactly once, in left-to-right order, in the expansion
Malformed DSL input must fail at macro-expansion time with a clause-naming error, never silently at runtime
Compile-time helper functions must be wrapped in eval-when (CL) or eval-and-compile (Elisp)
Use &body for CL body arguments; use (declare (indent ...) (debug ...)) for Elisp macros
Require lexical-binding: t in any Elisp file defining macros
Avoid reader macros unless no defmacro-based design is viable
<error_escalation inherits="core-patterns#error_escalation">
Missing &body/indent metadata causing cosmetic mis-indentation
Multiple evaluation of a side-effecting argument form
Variable capture bug from a missing gensym, silently breaking a caller
Compile-time helper not phase-separated, causing macroexpansion to fail only in fresh/cross-compiled environments
</error_escalation>
Prove the design with a hand-written ideal expansion before implementing (Phase 1)
Keep defmacro as a thin pipeline hook over pure parse/analyze/emit functions
Gensym all macro-introduced symbols except documented anaphora
Preserve left-to-right, single evaluation of user-supplied forms
Fail malformed DSL input at macro-expansion time with a named clause error
Nested backquote/comma beyond two levels inside defmacro
eval or other runtime resolution of statically-decidable DSL structure
Reader macros as a default tool
Fundamentals of defmacro, backquote, mkstr/symb/flatten utilities
get-setf-expansion-based place abstraction (_f, toggle)
The textbook once-only implementation
aif, awhen, aand, alambda and intentional capture
Compile-time selection among generated closures
Macro-defining-macros; code generation as ordinary programming
CPS macros: =defun, =lambda, =bind, =values
g!-symbols / o!-symbols, defmacro/g!, defmacro! as a declarative once-only + gensym convention
Reader-syntax extension; treat as an opt-in, narrowly-scoped technique
Pairing defmacro with define-symbol-macro for a name usable both bare and applied
Closures with externally accessible named slots via dlambda-based dispatch
Use these as the source of truth for "is this macro written correctly" โ when in doubt about a technique's exact semantics, prefer consulting the book or a tested library (e.g. alexandria, let-over-lambda) over reproducing intricate code (once-only, pandoric) from memory.
<related_skills>
CLOS, ASDF, condition-system, and general Common Lisp fundamentals underlying macro design
Emacs Lisp fundamentals, use-package, and editor integration this skill's DX laws build on
macroexpand/trace/inspect workflows for verifying and debugging expansions at runtime
Evidence-driven debugging methodology for tracking down capture/evaluation-order bugs
Symbol navigation across macro definitions and their call sites
</related_skills>
<related_agents>
Locate existing macro definitions and call sites in this skill domain
Review macro hygiene, evaluation-order, and diagnostics quality against this skill's laws
Flag backquote-monolith macros and suggest parser/analyzer/emitter decomposition
</related_agents>