pub enum LispError {
Show 45 variants
UnexpectedChar(char, usize),
UnterminatedString(Span),
UnmatchedParen {
span: Span,
},
UnmatchedOpenParen {
span: Span,
},
Eof {
span: Span,
},
InvalidNumber(String),
UnknownSymbol(String),
Type {
expected: &'static str,
got: String,
},
Compile {
form: String,
message: String,
},
TypeMismatch {
form: KwargPath,
expected: ExpectedKwargShape,
got: SexpShape,
},
HeadMismatch {
keyword: &'static str,
got: String,
},
Unknown {
category: &'static str,
value: String,
},
Missing(&'static str),
OddKwargs {
dangling: String,
},
UnboundTemplateVar {
prefix: UnquoteForm,
name: String,
hint: Option<String>,
},
DuplicateKwarg {
key: String,
},
MissingKwarg {
key: String,
},
UnknownKwarg {
key: String,
hint: Option<String>,
allowed: Vec<String>,
},
UnknownDomainKeyword {
keyword: String,
hint: Option<String>,
registered: Vec<String>,
},
NonSymbolUnquoteTarget {
prefix: UnquoteForm,
got: SexpWitness,
},
SpliceOutsideList {
got: SexpWitness,
},
MissingMacroArg {
macro_name: String,
param: String,
},
TooManyMacroArgs {
macro_name: String,
expected: usize,
got: usize,
},
NonSymbolParam {
position: usize,
got: SexpWitness,
},
RestParamMissingName {
rest_position: usize,
got: Option<SexpWitness>,
},
RestParamTrailingTokens {
rest_position: usize,
extra: usize,
first: SexpWitness,
},
OptionalMarkerRepeated {
first_position: usize,
second_position: usize,
},
OptionalParamMalformed {
position: usize,
got: SexpWitness,
reason: OptionalParamMalformedReason,
},
DefmacroArity {
head: MacroDefHead,
arity: usize,
},
DefmacroNonSymbolName {
head: MacroDefHead,
got: SexpWitness,
},
DefmacroNonListParams {
head: MacroDefHead,
got: SexpWitness,
},
NotAListForm {
keyword: &'static str,
},
MissingHeadSymbol {
keyword: &'static str,
got: Option<SexpWitness>,
},
NamedFormMissingName {
keyword: &'static str,
},
NamedFormNonSymbolName {
keyword: &'static str,
got: SexpShape,
},
RewriterNonList {
keyword: &'static str,
got: SexpWitness,
},
DomainSerialize {
keyword: &'static str,
message: String,
},
KwargDeserialize {
path: KwargPath,
message: String,
},
CompilerSpecIo {
stage: CompilerSpecIoStage,
message: String,
},
TemplateInvariant {
macro_name: String,
kind: TemplateInvariantKind,
},
ExpansionDepthExceeded {
macro_name: String,
limit: usize,
},
ExpansionSizeExceeded {
macro_name: String,
size: usize,
limit: usize,
},
MacroBodySizeExceeded {
macro_name: String,
size: usize,
limit: usize,
},
RegisteredMacrosExceeded {
macro_name: String,
count: usize,
limit: usize,
},
MacroArityExceeded {
macro_name: String,
arity: usize,
limit: usize,
},
}Variants§
UnexpectedChar(char, usize)
UnterminatedString(Span)
Unterminated string literal. The span runs from the opening "
to end-of-input — the whole run the tokenizer consumed looking
for a closer — so the rendered diagnostic underlines the
unterminated literal rather than pointing at its opening quote.
UnmatchedParen
A ) with no matching (. The span is the offending token — one
byte — so this renders as a single caret, same as pre-lift.
UnmatchedOpenParen
A ( the reader never found a closer for. The span runs from the
unclosed ( to the END OF THE LAST TOKEN read, i.e. the extent of
the partial form — NOT to src.len(), which would drag trailing
whitespace and comments under the underline. This is the variant
the Span lift buys the most: pre-lift it named the ( byte and
rendered one caret, post-lift it underlines the form that was left
open.
Eof
Input ran out where a datum was required. A zero-width span AT the
end offset — there is no text to underline, so caret_run’s
clamp-up-to-one renders the single caret one column past the last
visible char, exactly as pre-lift.
InvalidNumber(String)
UnknownSymbol(String)
Type
Compile
TypeMismatch
Structured type mismatch — both sides are first-class fields, not
embedded substrings of message. Rendered as "compile error in {form}: expected {expected}, got {got}" so the user-facing string
matches the legacy Compile-shaped diagnostic byte-for-byte; the
gain is structural — authoring tools (REPL, LSP, tatara-check)
pattern-match on the variant and bind directly to expected /
got instead of substring-parsing the rendered message.
form is the typed closed-set KwargPath enum so consumers
pattern-match on path-shape identity (KwargPath::Item { .. },
KwargPath::Slot(_), KwargPath::Named(_)) directly rather than
substring-matching the rendered prefix. The Display projection
flows through KwargPath::Display, so the user-facing
"compile error in {form}: …" rendering matches the legacy
String-shaped diagnostic byte-for-byte.
expected is the typed closed-set ExpectedKwargShape enum —
the seven reachable expected-shape labels the typed-entry kwarg
gate emits (Keyword ⊎ String ⊎ Int ⊎ Number ⊎ Bool ⊎
List ⊎ ListOfStrings) encoded as a TYPE so a typo in any
label literal can never drift into the diagnostic at runtime;
consumers (REPL, LSP, tatara-check) pattern-match on
ExpectedKwargShape::Number etc. directly instead of
substring-matching expected == "number". Same posture as
LispError::Defmacro*.head: MacroDefHead,
LispError::UnboundTemplateVar.prefix: UnquoteForm,
LispError::CompilerSpecIo.stage: CompilerSpecIoStage,
LispError::TemplateInvariant.kind: TemplateInvariantKind, and
LispError::TypeMismatch.form: KwargPath: the closed set
becomes a TYPE rather than a &'static str projection at the
helper boundary. The Display projection flows through
ExpectedKwargShape::Display, so the user-facing
"... expected {expected}, ..." rendering matches the legacy
&'static str-shaped diagnostic byte-for-byte.
got is the typed closed-set SexpShape enum — the twelve
reachable Sexp outermost shapes (Nil ⊎ Symbol ⊎ Keyword ⊎
String ⊎ Int ⊎ Float ⊎ Bool ⊎ List ⊎ Quote ⊎
Quasiquote ⊎ Unquote ⊎ UnquoteSplice) encoded as variant
identities so the SexpShape that the typed-entry gate observed
is load-bearing data in the type system. Consumers (REPL, LSP,
tatara-check) pattern-match on SexpShape::Int etc. directly
instead of substring-matching got == "int". Same posture as
form: KwargPath and expected: ExpectedKwargShape: after this
lift the TypeMismatch variant’s identity is fully closed-set
typed in ALL THREE of its slots — no &'static str projection
at any helper boundary, every reachable identity encoded as a
variant of a typed enum. The Display projection flows through
SexpShape::Display (which delegates to SexpShape::label()),
so the user-facing "... got {got}" rendering matches the
legacy &'static str-shaped diagnostic byte-for-byte. When a
future run gives Sexp source spans, pos: Option<usize>
lands here in ONE place and every type-mismatch site picks up
positional rendering via crate::diagnostic::format_diagnostic
mechanically.
HeadMismatch
Structural head-mismatch — the (head ...) of a top-level form
didn’t match T::KEYWORD. Both sides are first-class fields, not
embedded substrings of message. Rendered as "compile error in {keyword}: expected ({keyword} ...), got ({got} ...)" so the
user-facing string matches the legacy Compile-shaped diagnostic
byte-for-byte; the gain is structural — authoring tools (REPL,
LSP, tatara-check) pattern-match on the variant and bind
directly to keyword / got instead of substring-parsing the
rendered message.
keyword is &'static str because it always comes from
T::KEYWORD, a compile-time literal; got is String because
it is an arbitrary symbol from the source. When a future run
gives Sexp source spans, pos: Option<usize> lands here in
ONE place and every head-mismatch site picks up positional
rendering via crate::diagnostic::format_diagnostic
mechanically.
Unknown
Missing(&'static str)
OddKwargs
A kwargs list of odd length: the last element has no partner. The
dangling field holds the offending element’s Sexp::Display
projection — :query for a keyword whose value got lost, or the
literal form of a stray non-keyword. Naming both halves of the
failure (the failure mode AND the offending element) is the
typed-entry gate’s structural-completeness floor (THEORY.md §V.1):
without it the operator must re-read the source to find what
actually misfired.
UnboundTemplateVar
An unquote (,name) or unquote-splice (,@name) in a macro template
body referenced a name that wasn’t bound by the macro’s params (during
compile_template) or wasn’t available in the substitution scope
(during substitute). prefix is the syntactic marker — "," for
unquote, ",@" for splice — so the rendered diagnostic preserves the
form exactly as the author wrote it. hint is Some(name) when the
substrate found a near-miss against the available bound names within
the bounded edit distance (see crate::domain::suggest); None
otherwise — a wrong hint is worse than no hint, so the slot stays
empty unless the substrate is confident.
prefix is UnquoteForm — the closed-set typed enum whose two
variants are EXACTLY the two reachable syntactic markers
(Unquote ⊎ Splice). Encoding the closed set as a TYPE makes the
constraint that ONLY 2 marker identities are reachable load-bearing
in the type system — a third pseudo-marker can never drift into the
diagnostic at runtime; consumers (REPL, LSP, tatara-check)
pattern-match on UnquoteForm::Splice etc. directly instead of
substring-matching prefix == ",@". Same posture as
LispError::Defmacro*.head: MacroDefHead,
LispError::CompilerSpecIo.stage: CompilerSpecIoStage, and
LispError::TemplateInvariant.kind: TemplateInvariantKind: the
closed set becomes a TYPE rather than a &'static str projection
at the helper boundary. name and hint are String because
they come from arbitrary source / the live bindings set. When a
future run gives Sexp source spans, pos: Option<usize> lands
here in ONE place and every unbound-template-var site picks up
positional rendering via crate::diagnostic::format_diagnostic
mechanically.
Display matches the legacy Compile-shaped diagnostic byte-for-byte
when hint is None — "compile error in {prefix}{name}: unbound"
— so existing consumer assertions that pattern-match on the message
substring keep passing. With a hint, the suffix "; did you mean {prefix}{hint}?" is appended; the prefix is preserved in the hint so
the operator can copy-paste the suggestion verbatim.
DuplicateKwarg
A kwargs slice contained the same :key twice. The offending key is
carried as a structural field — not embedded in a free-form message —
so authoring surfaces (REPL, LSP, tatara-check) pattern-match on
the variant and bind to key directly instead of substring-parsing
the rendered diagnostic. Same posture as the OddKwargs { dangling }
sibling: every distinct parse_kwargs failure mode (odd length,
not-a-keyword-at-position, duplicate key) is now a structural variant
of LispError, not a Compile-shaped substring.
key is String because it comes from arbitrary source. Display
renders "compile error in :{key}: duplicate keyword" byte-for-byte
equivalent to the legacy Compile { form: kwarg_form(key), message: "duplicate keyword" } shape, so existing consumer assertions
(msg.contains(":name"), msg.contains("duplicate keyword")) pass
unchanged. When a future run gives Sexp source spans, pos: Option<usize> lands here in ONE place and every duplicate-kwarg
site picks up positional rendering via
crate::diagnostic::format_diagnostic mechanically.
MissingKwarg
A required kwarg was absent from the kwargs slice. The offending key
is carried as a structural field — not embedded in a free-form
message — so authoring surfaces (REPL, LSP, tatara-check)
pattern-match on the variant and bind to key directly instead of
substring-parsing the rendered diagnostic. Same posture as
DuplicateKwarg { key } and OddKwargs { dangling }: every
distinct typed-entry kwarg failure mode is now a structural variant
of LispError, not a Compile-shaped substring. Sibling of the
pre-existing Missing(&'static str) variant — MissingKwarg
covers the runtime-key path the kwargs extractors share, while
Missing stays for compile-time-known names.
key is String because it comes from the runtime kwargs lookup
(each derive-generated extractor and every hand-written
TataraDomain impl can pass an arbitrary key). Display renders
"compile error in :{key}: required but not provided"
byte-for-byte equivalent to the legacy Compile { form: kwarg_form(key), message: "required but not provided" } shape, so
existing consumer assertions (msg.contains(":threshold"),
msg.contains("required")) pass unchanged. When a future run gives
Sexp source spans, pos: Option<usize> lands here in ONE place
and every missing-kwarg site picks up positional rendering via
crate::diagnostic::format_diagnostic mechanically.
UnknownKwarg
A kwargs slice contained a :key that isn’t in the allowed-kwarg
set for the surrounding TataraDomain. The offending key, the
near-miss hint (if any), and the full allowed set are all carried
as first-class fields — not embedded in a free-form message — so
authoring surfaces (REPL, LSP, tatara-check) pattern-match on
the variant and bind to key / hint / allowed directly
instead of substring-parsing the rendered message. Same posture
as the OddKwargs { dangling }, DuplicateKwarg { key }, and
MissingKwarg { key } siblings: every distinct typed-entry
kwarg-gate failure mode is now a structural variant of
LispError, not a Compile-shaped substring.
key is String because it comes from arbitrary source. hint
is Some(allowed_keyword) when crate::domain::suggest ranks an
allowed kwarg within the bounded edit distance; None
otherwise — a wrong hint is worse than no hint, so the slot
stays empty unless the substrate is confident. allowed is
Vec<String> (sorted lexicographically by unknown_kwarg)
because the variant owns its data — the derive-emitted
&'static [&'static str] allowed-set crosses the structural
boundary as owned Strings so the diagnostic crosses thread
boundaries cleanly and lives independent of the call frame.
Display matches the legacy Compile { form: kwarg_form(key), message: "unknown keyword (...)" } rendering byte-for-byte
("compile error in :{key}: unknown keyword (did you mean :{hint}?; allowed: :a, :b, :c)" with a hint, "compile error in :{key}: unknown keyword (allowed: :a, :b, :c)" without),
so existing consumer assertions
(msg.contains("unknown keyword"),
msg.contains("did you mean :threshold?"),
msg.contains("allowed: ")) pass unchanged. When a future
run gives Sexp source spans, pos: Option<usize> lands
here in ONE place and every unknown-kwarg site picks up
positional rendering via
crate::diagnostic::format_diagnostic mechanically.
UnknownDomainKeyword
A registry-dispatched form (<keyword> ...) whose head symbol isn’t in
the global TataraDomain registry. The offending keyword, the
near-miss hint (if any), and the full registered keyword set are all
carried as first-class fields — not embedded in a free-form message —
so authoring surfaces (REPL, LSP, tatara-check) pattern-match on the
variant and bind to keyword / hint / registered directly instead
of substring-parsing the rendered message. Same posture as the
UnknownKwarg { key, hint, allowed } sibling: the kwarg-gate’s
unknown-allowed-set rejection and the registry-gate’s
unknown-registered-set rejection share ONE structural shape.
keyword is String because it comes from arbitrary source (a
top-level form’s head symbol). hint is
Some(registered_keyword) when crate::domain::suggest_keyword
ranks a registered keyword within the bounded edit distance;
None otherwise — a wrong hint is worse than no hint, so the slot
stays empty unless the substrate is confident. registered is
Vec<String> (sorted lexicographically by
unknown_domain_keyword) because the variant owns its data — the
registry’s &'static [&'static str] keyword-set crosses the
structural boundary as owned Strings so the diagnostic crosses
thread boundaries cleanly and lives independent of the call frame.
Empty registered (no domains seeded) renders (no domains registered) so the operator sees the structural reason — the
registry has no candidates at all — instead of a misleading empty
“registered: “ suffix. When a future run gives Sexp source
spans, pos: Option<usize> lands here in ONE place and every
unknown-domain-keyword site picks up positional rendering via
crate::diagnostic::format_diagnostic mechanically.
NonSymbolUnquoteTarget
The slot inside a Sexp::Unquote(_) (,X) or
Sexp::UnquoteSplice(_) (,@X) was not a symbol. The prefix
field is the syntactic marker — "," for unquote, ",@" for
splice — so the rendered diagnostic preserves the form exactly
as the author wrote it. The got field is the offending inner’s
Sexp::Display projection so the operator sees both what was
expected (a symbol — the only form a no-evaluator template can
substitute) and what was actually written (the literal value —
(list 1 2), 5, :foo, etc.). Naming both halves of the
failure is the typed-entry gate’s structural-completeness floor
(THEORY.md §V.1).
Sibling of UnboundTemplateVar { prefix, name, hint } for the
same template-side typed-entry surface — that variant fires when
the slot IS a symbol but the symbol isn’t bound; this variant
fires when the slot isn’t a symbol at all. After this lift every
distinct typed-entry template-gate failure mode binds to ONE
structural variant of LispError, not a Compile-shaped
substring.
prefix is UnquoteForm — the closed-set typed enum whose two
variants are EXACTLY the two reachable syntactic markers
(Unquote ⊎ Splice). Encoding the closed set as a TYPE makes
the constraint load-bearing in the type system — a third
pseudo-marker can never drift into the diagnostic at runtime;
consumers (REPL, LSP, tatara-check) pattern-match on
UnquoteForm::Splice etc. directly instead of substring-matching
prefix == ",@". Same typed-slot posture as UnboundTemplateVar’s
prefix slot, parallel to LispError::Defmacro*.head: MacroDefHead. got is SexpWitness — the closed-set typed
joint identity pairing the offending inner’s SexpShape (the
twelve reachable outermost shapes the reader can produce) with
its Sexp::Display projection (the literal value the author
wrote — (list 1 2), 5, :foo, etc.). Same typed-witness
posture as SpliceOutsideList.got: SexpWitness: authoring
tools (REPL, LSP, tatara-check) bind to BOTH got.shape
(structurally pattern-matchable on SexpShape::List etc.) AND
got.display (the literal value, renderable verbatim) without
losing either side. The two template-gate ,X/,@X rejection
variants (NonSymbolUnquoteTarget AND SpliceOutsideList)
now share ONE typed witness identity at their got slot —
every Sexp-display-source got slot on the template-gate’s
distinct rejection variants carries the SAME typed primitive.
When a future run gives Sexp source spans, pos: Option<usize> lands here in ONE place and every
non-symbol-unquote-target site picks up positional rendering
via crate::diagnostic::format_diagnostic mechanically.
SpliceOutsideList
A ,@X (unquote-splice) appeared at a syntactic position where there
is no containing list to splice into — i.e. the splice is the entire
macro-template body, not nested inside a (... ,@xs ...) list. Splice
is always list-flattening: ,@(a b c) inside (outer ,@xs) becomes
(outer a b c). At a non-list position there is no list to flatten
into; the form is ill-positioned regardless of whether the inner slot
is a symbol, a literal, or a bound list.
Sibling of NonSymbolUnquoteTarget { prefix, got } and
UnboundTemplateVar { prefix, name, hint } for the template-gate’s
other distinct failure modes — together the three close every
distinct typed-entry template-gate failure mode for the no-evaluator
template language: each is a structural variant of LispError, not
a Compile-shaped substring. prefix is implicit (always ,@) and
elided from the variant: this failure mode names ONE syntactic
marker, parallel to how OddKwargs names ONE failure mode (odd-length
kwargs slice) without a syntactic-marker slot.
got is SexpWitness — the closed-set typed joint identity
pairing the offending inner’s SexpShape (the twelve reachable
outermost shapes the reader can produce) with its
Sexp::Display projection (the literal value the author wrote
— xs, (list 1 2), 5, :foo, etc.). Promotes the legacy
got: String shape to a typed witness so authoring tools (REPL,
LSP, tatara-check) bind to BOTH got.shape (structurally
pattern-matchable on SexpShape::List etc.) AND got.display
(the literal value, renderable verbatim) without losing either
side. Naming both the failure mode AND the offending element
is the typed-entry gate’s structural-completeness floor
(THEORY.md §V.1) — without it the operator must re-read the
source to find what actually misfired. After this lift the
structural identity is part of the variant’s typed data shape;
a regression that re-collapses got to a free-form String
loses the rustc-enforced closed-set guarantee on shape
identity.
First consumer of the SexpWitness primitive. Sibling lifts
landed for NonSymbolUnquoteTarget.got, NonSymbolParam.got,
DefmacroNonSymbolName.got, and DefmacroNonListParams.got;
the remaining trajectory —
RestParamMissingName.got: Option<String> and
MissingHeadSymbol.got: Option<String> — is the next set of
moves: every got: String (or Option<String>) slot whose
source is Sexp::Display picks up the typed witness
mechanically once the variant’s data shape is bumped. The
remaining two are both Option<String> — the typed witness
lands on the Some arm directly, the None arm encodes the
“missing entirely” sub-mode that’s structurally distinct from
“present but malformed”.
When a future run gives Sexp source spans, pos: Option<usize>
lands on SexpWitness ONCE and every splice-outside-list site
picks up positional rendering via
crate::diagnostic::format_diagnostic mechanically.
Display renders "compile error in ,@: \,@` may only appear inside
a list (got ,@{got})“— the legacy substring”`,@` may only
appear inside a list“is preserved verbatim so authoring tools that substring-match on the rendered diagnostic see no drift; the parenthetical(got ,@{got})names the offending form so an LSP quick-fix that surfaces "the splice has no containing list; you wrote,@xs" gains the literal value as data, no message re-parsing required. The {got}slot flows throughSexpWitness::Display, which writes only the displayfield, so the rendering is byte-for-byte identical to the legacygot: String` shape.
Fields
got: SexpWitnessMissingMacroArg
A macro was called with fewer arguments than its required-param arity:
(defmacro f (a b) (,a ,b)) (f 1)—bhas no arg. Both the failing macro's name AND the un-bound param are first-class structural fields, not embedded substrings ofmessage, so authoring surfaces (REPL, LSP, tatara-check) pattern-match on the variant and bind to macro_name/paramdirectly instead of substring-parsing the rendered message. Sibling ofMissingKwarg { key }for the macro-call-gate's positional-arity surface — that variant fires when a(
kwargs form omits a required keyword; this variant fires when a(Same single emission shape across both expansion strategies — the
substitute path’s bind_args and the bytecode path’s
apply_compiled share ONE structural variant, parallel to how the
template-gate’s SpliceOutsideList is shared across both paths
(THEORY.md §II.1 invariant 2 — free middle: which strategy you
picked must not change which inputs you reject). Before this lift
the same failure mode emitted ONE LispError::Compile { form: format!("call to {macro_name}"), message: format!("missing required arg: {param}") } triple at TWO call sites — the
three-times rule had two sites with byte-identical shape and one
failure mode.
macro_name and param are String because they come from
arbitrary source (the call-site head symbol AND the
macro-definition’s param symbol). Display matches the legacy
Compile-shaped diagnostic byte-for-byte — "compile error in call to {macro_name}: missing required arg: {param}" — so existing
consumer assertions (msg.contains("missing required arg")) pass
unchanged. When a future run gives Sexp source spans, pos: Option<usize> lands here in ONE place and every missing-macro-arg
site picks up positional rendering via
crate::diagnostic::format_diagnostic mechanically.
TooManyMacroArgs
A macro was called with MORE arguments than its declared
required+optional arity, on a param list with NO &rest slot to
collect the surplus: (defmacro f (a b) (,a ,b)) (f 1 2 3)—3has nowhere to bind. The mirror at the call-site ofRestParamTrailingTokens(the definition-site rejection that surfaces tokens trailing a&rest clause, lifted in the prior-run typed-promotion lineage): that variant rejects malformed DEFINITIONS that the typedMacroParamsshape cannot hold (a&restclause is structurally LAST); this variant rejects malformed CALLS that the typedbindcannot honor (a rest-less param list has a FIXED maximum arity equal torequired.len() + optional.len()). Together with MissingMacroArg, the macro-call-gate's positional-arity surface is now structurally complete in both directions — too-few AND too-many — closing the asymmetry where the typed-entry gate rejected too-few-args loudly but silently truncated too-many to the slice bind` could consume.
A rest-PRESENT param list has no maximum arity (the &rest slot
collects every trailing arg into a Sexp::List), so this
rejection fires ONLY when MacroParams.rest is None. The
expected slot is required.len() + optional.len() — the maximum
number of args the rest-less binder can consume; got is
args.len() — the actual number supplied. Surfacing both lets
authoring tools (REPL, LSP, tatara-check) name the
“you supplied {got} args but the macro takes at most {expected}”
quick-fix without re-deriving either count from the source.
The leftmost-priority discipline is preserved: MissingMacroArg
for a missing REQUIRED arg fires BEFORE this too-many gate
(bind iterates the required walk first and bails on the first
missing slot), so (defmacro f (a b c) …) (f 1) is
MissingMacroArg { param: "b" }, NOT TooManyMacroArgs. The two
failure modes are structurally disjoint: too-few-required vs.
too-many-with-no-rest.
macro_name is String because it comes from arbitrary source
(the call-site head symbol); expected and got are usize
arities. Display matches the legacy Compile-shaped diagnostic
style — "compile error in call to {macro_name}: too many args: expected at most {expected}, got {got}" — so the same
"compile error in call to {macro_name}:" substring authoring
tools’ assertions key on stays unchanged across the new
rejection mode. When a future run gives Sexp source spans,
pos: Option<usize> lands here in ONE place and every
too-many-macro-args site picks up positional rendering via
crate::diagnostic::format_diagnostic mechanically — same
posture as MissingMacroArg.
NonSymbolParam
A non-symbol element appeared in a defmacro / defpoint-template
/ defcheck param list at the named position. The legacy
LispError::Compile { form: "defmacro params", message: "expected symbol" } shape named only the failure mode — it didn’t say WHICH
element of the param list misfired NOR what was found in its slot.
The structural variant names both: position is the 0-based index
of the offending element within the param list, got is its
Sexp::Display projection so the operator sees the literal value
they wrote (5, "x", :foo, (nested)) instead of the bare
“expected symbol” verdict. Naming both the position AND the
offending element is the typed-entry gate’s
structural-completeness floor (THEORY.md §V.1) — without both an
LSP that wants to surface “the third element of your param list
isn’t a symbol; you wrote 5” must re-parse the source.
Sibling of MissingMacroArg { macro_name, param } for the
macro-call-gate’s positional-arity surface — that variant fires
when a CALL (<macroname> a b …) omits a required positional
param; this variant fires when the DEFMACRO (defmacro <name> (a b …) …) declaration’s param list contains a non-symbol where a
param name was expected. The two are the macro-call-gate and the
defmacro-syntax-gate’s first-named structural failure modes
respectively — call-site malformed vs. definition-site malformed.
position is usize because it is always the loop index inside
parse_params; got is SexpWitness — the closed-set typed
joint identity (structural SexpShape + renderable
Sexp::Display projection) the offending-value side of the
typed-entry rejection owes the operator. Third consumer of the
SexpWitness primitive (after SpliceOutsideList.got and
NonSymbolUnquoteTarget.got); same posture — authoring tools
(REPL, LSP, tatara-check) bind to BOTH got.shape
(SexpShape::Int, SexpShape::Keyword, SexpShape::List, etc.)
AND got.display (the literal value, renderable verbatim)
jointly across the variant slot rather than substring-grepping
a free-form String. Display projects the witness’s display
field verbatim into the #[error(... got {got})] annotation’s
{got} slot, so the rendered "compile error in defmacro params: expected symbol at position {position}, got <display>" shape is
byte-for-byte identical to the pre-lift got: String rendering;
authoring tools that substring-grep on the rendered diagnostic
see no drift. When a future run gives Sexp source spans, pos: Option<usize> lands inside SexpWitness in ONE place and every
non-symbol-param site picks up positional rendering via
crate::diagnostic::format_diagnostic mechanically.
RestParamMissingName
A &rest marker in a defmacro / defpoint-template / defcheck
param list was followed by no element at all ((&rest),
(a &rest)) OR by a non-symbol element ((&rest 5),
(&rest :foo)). The legacy LispError::Compile { form: "defmacro params", message: "&rest needs a name" } shape named only the
failure mode — it didn’t say WHICH &rest (i.e. its position
within the param list) misfired NOR what was found in the slot
where the rest-name should have been. The structural variant
names both: rest_position is the 0-based index of the &rest
marker within the param list, got is the offending follower’s
typed witness (Some(SexpWitness::new(SexpShape::Int, "5")),
Some(SexpWitness::new(SexpShape::Keyword, ":foo")),
Some(SexpWitness::new(SexpShape::List, "(nested)"))) or
None when the marker was the last element in the list and
nothing followed at all. Naming both the marker position AND
the offending follower (or its absence) is the typed-entry
gate’s structural-completeness floor (THEORY.md §V.1) —
without both, an LSP that wants to surface “your &rest at
param-list position 1 has no name; you wrote 5 instead of
a symbol” must re-parse the source.
Sibling of NonSymbolParam { position, got } for the
defmacro-syntax-gate’s other definition-site failure mode —
that variant fires when a NON-&rest element at a param
position isn’t a symbol; this variant fires specifically on the
post-&rest follower slot, where the failure mode bifurcates
into “missing entirely” vs. “present but not a symbol”. Both
modes share ONE structural variant via got: Option<SexpWitness>
(parallel to how UnboundTemplateVar and UnknownKwarg carry
hint: Option<String> for a present-or-absent secondary slot)
rather than splitting into two near-identical variants — the
failure mode IS one (“rest name missing”); the bifurcation is
in the renderable detail, not in what the gate rejects.
Together, NonSymbolParam and RestParamMissingName close the
parse_params pair — every distinct failure mode the
parse_params walker can emit is now a structural variant of
LispError, not a Compile-shaped substring.
rest_position is usize because it is always the loop index
inside parse_params at which the &rest marker was matched;
got is Option<SexpWitness> — the SIXTH consumer of the typed
SexpWitness primitive (after SpliceOutsideList.got,
NonSymbolUnquoteTarget.got, NonSymbolParam.got,
DefmacroNonSymbolName.got, and DefmacroNonListParams.got).
The Option-shape bifurcates structurally into “missing
entirely” (None, when the marker was the param list’s last
element) and “present but malformed” (Some(SexpWitness), when
a non-symbol follower came from arbitrary source via
Sexp::Display); the typed witness lands on the Some arm
only. Display preserves the legacy "compile error in defmacro params: &rest needs a name" prefix byte-for-byte so authoring
tools that substring-grep on the rendered diagnostic see no
drift; the structural detail ((rest marker at position {rest_position}, got {got}) when present, (rest marker at position {rest_position}, none provided) when absent) is
appended. When a future run gives Sexp source spans, pos: Option<usize> lands inside SexpWitness in ONE place and
every rest-param-missing-name site picks up positional
rendering via crate::diagnostic::format_diagnostic
mechanically.
RestParamTrailingTokens
A &rest <name> param was followed by one or more further tokens —
(defmacro f (a &rest xs extra) …). The &rest name binds every
remaining call arg into a list, so it is structurally the LAST thing
a param list can name: nothing can follow it. Before this variant
parse_params returned the moment it bound the rest name, SILENTLY
DROPPING any trailing tokens — extra above vanished with no error,
so an author who fat-fingered a stray param (or wrote &rest xs &optional y expecting a feature that doesn’t exist yet) got no
signal that their text was ignored. This variant turns that silent
drop into a loud rejection at the typed-entry gate.
Sibling of NonSymbolParam and RestParamMissingName — the third
and final parse_params definition-site failure mode. The earlier
two fire on a param SLOT (a non-symbol where a name was expected) and
on the post-&rest follower (missing-or-malformed name); this one
fires once the rest name is bound and the walker discovers the param
list does not end there. Together the three now genuinely close the
parse_params walker — every shape it accepts is a well-formed
MacroParams, and every shape it rejects is a structural variant of
LispError, not a silently-truncated Vec nor a Compile-shaped
substring.
rest_position is the loop index at which the &rest marker was
matched (parallel to RestParamMissingName.rest_position), so an LSP
quick-fix can point at the &rest form whose name must be last.
extra is the count of trailing tokens (always ≥ 1) and first is
the typed witness of the first of them — the SEVENTH consumer of the
SexpWitness primitive. first is a non-Option witness (unlike
RestParamMissingName.got) because the trailing run is non-empty by
construction: this variant is only built when list[rest_position + 2..] has a first element. Display appends (rest marker at position {rest_position}, {extra} trailing after name, first: {first}) via
rest_param_trailing_tokens_suffix, which delegates the bare
parenthetical to the shared paren_suffix. When a future run gives
Sexp source spans, pos: Option<usize> lands inside SexpWitness
in ONE place and this site picks up positional rendering
mechanically — exactly as its two siblings do.
OptionalMarkerRepeated
A defmacro / defpoint-template / defcheck param list carried a
SECOND &optional marker — (defmacro f (a &optional b &optional c) …). The canonical Lisp lambda-list ((req* &optional opt* &rest r),
the shape MacroParams makes a
type) has exactly ONE optional section, between the required run and
the rest. A second &optional is unrepresentable: MacroParams.optional
is one flat Vec, not a sequence of sections. Without this gate the
walker would treat the second &optional as an optional param literally
NAMED &optional, binding call args to a marker symbol — the precise
silent misalignment the typed param-list shape exists to forbid (a
sibling of the index-misalignment MacroParams ruled out when it
replaced Vec<Param>).
Sibling of RestParamTrailingTokens — both fire INSIDE parse_params
once a marker is matched and the walker finds the surrounding param
list’s marker structure is one the canonical lambda-list ordering
cannot represent (tokens after &rest <name>; a repeated &optional).
first_position is the loop index of the first &optional marker,
second_position the index of the offending second one — naming both
lets an LSP quick-fix point at the redundant marker to delete. Neither
is a SexpWitness: both elements ARE the &optional symbol by
construction (the variant is only built when s == "&optional" twice),
so there is nothing to witness — only the two positions carry
information. When a future run gives Sexp source spans, the marker
positions gain editor-ready rendering by threading spans here.
OptionalParamMalformed
A defmacro / defpoint-template / defcheck &optional section
carried a list-form entry that did not match the only admissible
shape (NAME DEFAULT) — exactly two elements with a symbol head.
Per-param default forms are the typed promotion of optional: Vec<String> to optional: Vec<OptionalParam> that the prior
&optional run signposted, and this variant is the gate that
admits only the canonical (NAME DEFAULT) shape into the typed
OptionalParam.default slot. Four distinct list shapes are
rejected, named via the closed-set typed reason
(OptionalParamMalformedReason):
()— empty list spec.(name)— one element, no default form supplied.(name d e …)— three or more elements (CL’s(name default supplied-p)shape is not yet supported — nosupplied-pvariable binding without an evaluator).(5 default)— first element isn’t a symbol.
Sibling of OptionalMarkerRepeated (the &optional-section
marker gate) and NonSymbolParam (the bare-symbol gate): together
the three close every distinct typed-entry rejection the optional
section can emit. The bare-symbol form &optional x is still
admitted through the bare-symbol path; the list form &optional (x default) is admitted iff this gate accepts the spec.
position is the loop index of the offending list inside
parse_params, parallel to OptionalMarkerRepeated.first_position
/ RestParamTrailingTokens.rest_position — naming the position
lets an LSP quick-fix point at the spec to repair. got is
SexpWitness — the closed-set typed joint identity pairing the
offending list’s SexpShape::List with its Sexp::Display
projection, so consumers (REPL, LSP, tatara-check) bind to
BOTH the structural shape AND the renderable literal jointly,
same posture as NonSymbolParam.got / OptionalMarkerRepeated’s
SexpWitness siblings. reason is OptionalParamMalformedReason
— the closed-set typed enum whose four variants are EXACTLY the
four reachable list-spec rejection modes, encoded as a TYPE so a
future fifth reason (e.g. supplied-p once an evaluator lands)
becomes a type-level extension rather than a substring drift.
Mirror at the parse_params optional-section boundary of the
prior-run MacroDefHead / UnquoteForm / TemplateInvariantKind
/ CompilerSpecIoStage closed-set lifts.
Theory anchor: THEORY.md §V.1 — knowable platform / “make invalid
states unrepresentable”; the four malformed list-spec shapes are
nonsense MacroParams cannot hold, so the gate must REJECT
rather than bind args to a marker symbol or drop the extras
silently. THEORY.md §II.1 invariant 1 — typed entry; a malformed
default-form spec is exactly the failure mode the typed-entry
gate exists to reject — and the gate must reject DEFINITIONS as
readily as it rejects CALLS. THEORY.md §II.1 invariant 2 — free
middle; the gate fires inside parse_params BEFORE either
expansion strategy runs, so both Expander::new() (bytecode) and
Expander::new_substitute_only() (substitute) reject the SAME
malformed spec at the SAME gate.
DefmacroArity
A defmacro / defpoint-template / defcheck form had fewer
than 4 list elements: the head keyword must be followed by a
name symbol, a param list, and a body — three required slots
after the head, total length 4. The legacy LispError::Compile { form: head.to_string(), message: "(defmacro name (params) body) required" } shape named only the failure mode — it
didn’t say HOW MANY elements the operator actually wrote, so
an authoring surface that wants to surface “you wrote 2
elements; need 4” had to re-parse the source. The structural
variant carries both: head is the head keyword (one of
"defmacro" / "defpoint-template" / "defcheck"); arity
is the actual length of the form, including the head element.
Naming the actual arity is the typed-entry gate’s structural-
completeness floor (THEORY.md §V.1).
Sibling of NonSymbolParam and RestParamMissingName for
the defmacro-syntax-gate’s other definition-site failure
modes — those variants fire INSIDE parse_params, AFTER the
arity gate has passed; this variant fires AT the arity gate
itself, BEFORE name / params / body validation can run.
Together, the three close macro_def_from’s outermost
rejection chain — every distinct failure mode the gate can
emit at the top level becomes a structural variant of
LispError, not a Compile-shaped substring.
head is MacroDefHead — the closed-set typed enum whose
three variants are EXACTLY the three reachable head keywords
(Defmacro ⊎ DefpointTemplate ⊎ Defcheck). Encoding the
closed set as a TYPE makes the constraint that ONLY 3 head
identities are reachable load-bearing in the type system — a
fourth pseudo-head can never drift into the diagnostic at
runtime; consumers (REPL, LSP, tatara-check) pattern-match
on MacroDefHead::Defcheck etc. directly instead of
substring-matching head == "defcheck". Same posture as
LispError::CompilerSpecIo.stage: CompilerSpecIoStage and
LispError::TemplateInvariant.kind: TemplateInvariantKind:
the closed set becomes a TYPE rather than a &'static str
projection at the helper boundary. arity is usize because
it is always list.len() at the call site (the length of
the form including the head element). Display renders the
head via MacroDefHead’s Display impl (which projects
through MacroDefHead::keyword() to the canonical &'static str literal), so the legacy head: &'static str-shaped
diagnostic rides through byte-for-byte.
Display preserves the legacy "(defmacro name (params) body) required" substring byte-for-byte: the head is parameterized
in the prefix compile error in {head}:, but the example
template literal stays (defmacro name (params) body) —
matching the legacy form’s small infidelity for non-defmacro
heads (the legacy shape rendered compile error in defpoint-template: (defmacro name (params) body) required)
so authoring tools that substring-grep on the legacy
rendering see no drift; the structural detail (got {arity} elements, need 4) is appended. When a future run gives
Sexp source spans, pos: Option<usize> lands here in ONE
place and every defmacro-arity site picks up positional
rendering via crate::diagnostic::format_diagnostic
mechanically.
DefmacroNonSymbolName
A defmacro / defpoint-template / defcheck form passed the
arity gate (≥4 elements) but its name slot — list[1], the
element directly after the head — wasn’t a symbol. The legacy
LispError::Compile { form: head.to_string(), message: "expected name symbol" } shape named only the failure mode — it didn’t
say WHAT was found in the name slot, so an authoring surface
that wants to surface “you wrote 5 where a name symbol was
expected” had to re-parse the source. The structural variant
carries both: head is the head keyword (one of "defmacro" /
"defpoint-template" / "defcheck"); got is the offending
Sexp::Display projection of the non-symbol element. Naming
both the head AND the offending element is the typed-entry
gate’s structural-completeness floor (THEORY.md §V.1).
Sibling of DefmacroArity and the parse_params pair
(NonSymbolParam, RestParamMissingName) for the
defmacro-syntax-gate’s other definition-site failure modes.
Walking a malformed (defmacro …) from the outside in, the
gate fires:
DefmacroArity { head, arity }if the form has fewer than 4 elements ((defmacro),(defmacro f)).DefmacroNonSymbolName { head, got }if list[1] isn’t a symbol ((defmacro 5 () body),(defmacro :foo () body)).- Inside
parse_params:NonSymbolParam { position, got }andRestParamMissingName { rest_position, got }.
This run lifts step 2; the only remaining Compile-shaped
site in macro_def_from is the expected param list gate
(list[2] is not a list), which is the next move in the same
rejection chain.
head is MacroDefHead — same typed closed-set posture as
DefmacroArity.head: the three reachable head identities
(Defmacro ⊎ DefpointTemplate ⊎ Defcheck) are encoded as
a TYPE so consumers pattern-match on variant identity rather
than substring-comparing the rendered head literal.
got is SexpWitness — the closed-set typed joint identity
pairing the offending name-slot element’s SexpShape (the
twelve reachable outermost shapes the reader can produce) with
its Sexp::Display projection (5, :foo, "name",
(nested), etc.). Fourth consumer of the SexpWitness
primitive (after SpliceOutsideList.got,
NonSymbolUnquoteTarget.got, and NonSymbolParam.got):
authoring tools (REPL, LSP, tatara-check) bind to BOTH
got.shape (structurally pattern-matchable on SexpShape::Int,
SexpShape::Keyword, SexpShape::List, etc.) AND got.display
(the literal value, renderable verbatim) jointly across the
variant slot.
Display preserves the legacy "expected name symbol" substring
byte-for-byte: the prefix compile error in {head}: matches
the legacy Compile { form: head.to_string(), message: "expected name symbol" } shape; the structural detail (, got {got}) is appended. {got} flows through
SexpWitness::Display, which writes only the display field,
so the rendering is byte-for-byte identical to the legacy
got: String shape. When a future run gives Sexp source
spans, pos: Option<usize> lands inside SexpWitness in ONE
place and every non-symbol-name site picks up positional
rendering via crate::diagnostic::format_diagnostic
mechanically.
DefmacroNonListParams
A defmacro / defpoint-template / defcheck form passed both
the arity gate (≥4 elements) AND the name-symbol gate (list[1]
is a symbol) but its param-list slot — list[2], the third
element after the head — wasn’t a list. The legacy
LispError::Compile { form: head.to_string(), message: "expected param list" } shape named only the failure mode — it didn’t
say WHAT was found in the param-list slot, so an authoring
surface that wants to surface “you wrote x where a param list
was expected” had to re-parse the source. The structural variant
carries both: head is the head keyword (one of "defmacro" /
"defpoint-template" / "defcheck"); got is the offending
Sexp::Display projection of the non-list element. Naming both
the head AND the offending element is the typed-entry gate’s
structural-completeness floor (THEORY.md §V.1).
Sibling of DefmacroArity, DefmacroNonSymbolName, and the
parse_params pair (NonSymbolParam, RestParamMissingName)
for the defmacro-syntax-gate’s other definition-site failure
modes. Walking a malformed (defmacro …) from the outside in,
the gate fires:
DefmacroArity { head, arity }if the form has fewer than 4 elements ((defmacro),(defmacro f)).DefmacroNonSymbolName { head, got }if list[1] isn’t a symbol ((defmacro 5 () body)).DefmacroNonListParams { head, got }if list[2] isn’t a list ((defmacro f x body)).- Inside
parse_params:NonSymbolParam { position, got }andRestParamMissingName { rest_position, got }.
This run lifts step 3; after it, every inline LispError::Compile { … } triple in macro_def_from has been lifted to a structural
variant — the entire macro_def_from rejection chain (arity →
name-symbol → param-list → parse_params) is structurally typed
for failure modes, with each variant naming WHICH failure mode
AND WHAT was offending.
head is MacroDefHead — same typed closed-set posture as
DefmacroArity.head and DefmacroNonSymbolName.head. After
this lift all three Defmacro* variants share ONE typed
head identity, parallel to how LispError::CompilerSpecIo
carries stage: CompilerSpecIoStage for the four
disk-persistence (operation, stage) pairs.
got is SexpWitness — the closed-set typed joint identity
pairing the offending param-list-slot element’s SexpShape
(the twelve reachable outermost shapes the reader can produce)
with its Sexp::Display projection (x, 5, :foo,
"params", etc.). Fifth consumer of the SexpWitness
primitive (after SpliceOutsideList.got,
NonSymbolUnquoteTarget.got, NonSymbolParam.got, and
DefmacroNonSymbolName.got): authoring tools (REPL, LSP,
tatara-check) bind to BOTH got.shape (structurally
pattern-matchable on SexpShape::Symbol, SexpShape::Int,
SexpShape::Keyword, SexpShape::String, etc.) AND
got.display (the literal value, renderable verbatim) jointly
across the variant slot. After this lift the entire
macro_def_from rejection chain — arity → name-symbol →
param-list — shares ONE typed witness identity at every
Sexp::Display-source slot; the only remaining unlifted
rejection points in macro_def_from’s typed-entry chain are
RestParamMissingName.got: Option<String> (inside
parse_params) and MissingHeadSymbol.got: Option<String>
(at the outer typed-entry gate).
Display preserves the legacy "expected param list" substring
byte-for-byte: the prefix compile error in {head}: matches
the legacy Compile { form: head.to_string(), message: "expected param list" } shape; the structural detail (, got {got}) is appended. {got} flows through
SexpWitness::Display, which writes only the display field,
so the rendering is byte-for-byte identical to the legacy
got: String shape. When a future run gives Sexp source
spans, pos: Option<usize> lands inside SexpWitness in ONE
place and every non-list-params site picks up positional
rendering via crate::diagnostic::format_diagnostic
mechanically.
NotAListForm
T::compile_from_sexp (the TataraDomain trait default) was
passed something that isn’t a list — a bare atom (5, :foo,
"x", name) where a top-level (KEYWORD …) form was
expected. The legacy LispError::Compile { form: keyword.to_string(), message: "expected list form" } shape
named only the failure mode and the keyword, and required
authoring tools (REPL, LSP, tatara-check) to substring-grep
the rendered message to recognize this specific gate. The
structural variant carries keyword as a first-class field so
consumers pattern-match on the variant and bind directly to
the keyword instead of substring-parsing.
Sibling of HeadMismatch — both are typed-entry rejection
gates inside the trait default compile_from_sexp walking a
malformed form from the outside in:
NotAListForm { keyword }if the form isn’t a list at all (5,:foo,"x",name— bare atoms).LispError::Compile { form, message: "missing head symbol" }(NOT YET LIFTED) if the list is empty or list[0] isn’t a symbol ((),(5 …),(:foo …)).HeadMismatch { keyword, got }if list[0] is a symbol but doesn’t matchT::KEYWORD((other-name …)).
After this lift step 1 is structural; the missing head symbol gate is the next move in the same rejection chain
(its own structural-variant lift, parallel to how the
defmacro_* family was lifted gate-by-gate).
keyword is &'static str because every call site passes
Self::KEYWORD from the trait default — a compile-time
literal sourced from the #[tatara(keyword = "...")] derive
attribute (or hand-written const). Using a static slot makes
that compile-time guarantee load-bearing in the type system
(a typo in the keyword can never drift into the diagnostic at
runtime — the type system is the floor, same posture as
HeadMismatch.keyword, TypeMismatch.expected, and the
Defmacro*.head family).
Display preserves the legacy "expected list form" substring
AND the "compile error in {keyword}:" prefix byte-for-byte
— "compile error in {keyword}: expected list form" — so
existing consumer assertions (e.g., the
compile_from_sexp_emits_compile_for_non_list_form test
against MonitorSpec, tatara-check’s diagnostic capture)
pass unchanged. The variant carries no got slot because the
offending value’s type is itself the diagnostic — 5 /
:foo / "x" / name all reduce to the same failure mode
(not a list); naming the type would be redundant with what
the source already shows. When a future run gives Sexp
source spans, pos: Option<usize> lands here in ONE place
and every not-a-list-form site picks up positional rendering
via crate::diagnostic::format_diagnostic mechanically.
MissingHeadSymbol
T::compile_from_sexp was passed a list whose head can’t be
projected to a symbol — either the list is empty (() — there
is no first element) or its first element exists but isn’t a
symbol ((5 …), (:foo …), ("x" …), ((nested) …)). The
legacy LispError::Compile { form: keyword.to_string(), message: "missing head symbol" } shape collapsed both
sub-modes into one diagnostic — a () form and a (5 …) form
produced byte-identical messages, so an authoring surface that
wants to surface “your form is empty” vs. “your form’s head is
5, not a symbol” had to re-parse the source. The structural
variant carries got: Option<String> so the two sub-modes are
distinguishable structurally — None for the empty-list case,
Some(g) for the present-but-not-symbol case where g is
the offending head’s Sexp::Display projection. Naming both
the failure mode AND the structural detail (empty vs. offending
head) is the typed-entry gate’s structural-completeness floor
(THEORY.md §V.1).
Sibling of NotAListForm { keyword } and HeadMismatch { keyword, got } — together the three close every distinct
failure mode the trait-default compile_from_sexp rejection
chain can emit. Walking a malformed (KEYWORD …) form from
the outside in:
NotAListForm { keyword }— the form isn’t a list at all (5,:foo,"x",name— bare atoms).MissingHeadSymbol { keyword, got }— the form is a list but list[0] doesn’t exist (()) or isn’t a symbol ((5 …),(:foo …)).HeadMismatch { keyword, got }— list[0] is a symbol but doesn’t matchT::KEYWORD((other-name …)).
After this lift the entire compile_from_sexp rejection chain
is structurally typed for failure modes — every distinct
rejection binds to ONE structural variant of LispError, not
a Compile-shaped substring. The got: Option<String>
posture parallels RestParamMissingName.got: Option<String>:
the failure mode IS one (“head can’t be projected to a
symbol”); the bifurcation is in the renderable detail (empty
vs. present-but-wrong-type), not in what the gate rejects, so
the two sub-modes share ONE variant rather than splitting into
near-identical siblings.
keyword is &'static str because every call site passes
Self::KEYWORD from the trait default — a compile-time literal
sourced from the #[tatara(keyword = "...")] derive attribute
(or hand-written const). Using a static slot makes that
compile-time guarantee load-bearing in the type system (a typo
in the keyword can never drift into the diagnostic at runtime —
the type system is the floor, same posture as
NotAListForm.keyword, HeadMismatch.keyword, and the
Defmacro*.head family). got is Option<SexpWitness> — the
SEVENTH consumer of the typed SexpWitness primitive (after
SpliceOutsideList.got, NonSymbolUnquoteTarget.got,
NonSymbolParam.got, DefmacroNonSymbolName.got,
DefmacroNonListParams.got, and RestParamMissingName.got).
The Option-wrap bifurcates structurally between “missing
entirely” (None, when the list is empty) and “present but
malformed” (Some(SexpWitness), when the head exists but
isn’t a symbol); the typed witness lands on the Some arm
only — same posture as RestParamMissingName.got: Option<SexpWitness>. With this lift EVERY Sexp-display-source
got slot in the substrate carries ONE typed identity:
the typed-entry / template-gate / defmacro-syntax-gate
rejection surface is structurally unified end-to-end across
ALL got: <T> slots where <T> projects from Sexp::Display.
Display preserves the legacy "missing head symbol" substring
AND the "compile error in {keyword}:" prefix byte-for-byte —
"compile error in {keyword}: missing head symbol" is the
stable prefix; the structural detail ((empty list) for
None, (got {g}) for Some(g)) is appended in a
parenthetical, parallel to how RestParamMissingName appends
(rest marker at position {n}, got {g}) /
(rest marker at position {n}, none provided) and how
SpliceOutsideList appends (got ,@{got}). The {g} slot
flows through SexpWitness::Display, which writes only the
display field, so the rendering is byte-for-byte identical
to the pre-lift Option<String> shape. When a future run
gives Sexp source spans, pos: Option<usize> lands inside
SexpWitness in ONE place and every missing-head-symbol site
picks up positional rendering via
crate::diagnostic::format_diagnostic mechanically.
NamedFormMissingName
compile_named_from_forms::<T> — driving every (KEYWORD NAME …)
positional-name surface ((defpoint NAME …), (defalertpolicy NAME …)) — was passed a list whose head matched T::KEYWORD but
whose tail had no NAME slot at all. (defpoint) — list.len() == 1
(just the keyword); the gate fires before NAME extraction. The
legacy LispError::Compile { form: T::KEYWORD.to_string(), message: format!("expected ({} NAME …)", T::KEYWORD) } shape
named the failure mode AND the keyword by embedding both into a
formatted message — required authoring tools (REPL, LSP,
tatara-check) to substring-grep the rendered diagnostic to
recognize this specific gate. The structural variant carries
keyword as a first-class field so consumers pattern-match on
the variant and bind to the keyword directly instead of
substring-parsing.
Sibling of NotAListForm { keyword }, MissingHeadSymbol { keyword, got }, and HeadMismatch { keyword, got } — those
close the trait-default compile_from_sexp rejection chain
(the keyword-only entry point, (KEYWORD :k v …)); this
variant opens the parallel compile_named_from_forms
rejection chain (the positional-name entry point, (KEYWORD NAME :k v …)). Walking a malformed (KEYWORD NAME …) form
from the outside in:
NamedFormMissingName { keyword }— the form passes the keyword-head match but has no NAME slot ((defpoint)).LispError::Compile { form, message: "positional NAME must be a symbol or string" }(NOT YET LIFTED) — the form has a NAME slot but it’s not a symbol or string ((defpoint 5 …),(defpoint :foo …),(defpoint (nested) …)).- Inside
T::compile_from_args(&list[2..])— derive- generated kwargs handling with its own structural variants (UnknownKwarg,MissingKwarg, etc.).
This run lifts step 1; step 2 is the next move in the same
rejection chain (its own structural-variant lift, parallel to
how the compile_from_sexp chain was lifted gate-by-gate
across 092a2b2 (NotAListForm) and b3e941e
(MissingHeadSymbol)).
keyword is &'static str because every call site passes
T::KEYWORD from compile_named_from_forms — a compile-time
literal sourced from the #[tatara(keyword = "...")] derive
attribute (or hand-written const). Using a static slot makes
that compile-time guarantee load-bearing in the type system —
a typo in the keyword can never drift into the diagnostic at
runtime, the type system is the floor, same posture as
NotAListForm.keyword, MissingHeadSymbol.keyword,
HeadMismatch.keyword, TypeMismatch.expected, and the
Defmacro*.head family. The variant carries no arity slot
because the offending form’s structure is invariant — every
trigger has list.len() == 1 exactly (list[0] is the keyword,
no list[1] for NAME); naming a fixed value would be
misleading data, parallel to how NotAListForm carries no
got slot (the form’s not-a-list type is itself the
diagnostic).
Display matches the legacy Compile-shaped diagnostic
byte-for-byte — "compile error in {keyword}: expected ({keyword} NAME …)" (note: … is the Unicode horizontal
ellipsis U+2026, preserved verbatim from the legacy
format!("expected ({} NAME …)", T::KEYWORD) shape) — so
existing consumer assertions (tatara-check’s diagnostic
capture, REPL substring-greps) pass unchanged. When a future
run gives Sexp source spans, pos: Option<usize> lands
here in ONE place and every named-form-missing-name site
picks up positional rendering via
crate::diagnostic::format_diagnostic mechanically.
NamedFormNonSymbolName
compile_named_from_forms::<T> was passed a (KEYWORD NAME …) form
whose NAME slot exists but isn’t projectable to a symbol or string —
(defpoint 5 …), (defpoint :foo …), (defpoint (nested) …). Gate
2 of the same rejection chain NamedFormMissingName opens: that
variant fires when there is no NAME slot at all ((defpoint) —
list.len() == 1); this variant fires when the NAME slot exists but
is wrong-typed. Together the two close compile_named_from_forms’s
outer rejection chain — every typed-entry rejection mode in the
positional-name authoring surface is now a structural variant of
LispError, not a Compile-shaped substring.
keyword is &'static str because every call site passes
T::KEYWORD — a compile-time literal sourced from the
#[tatara(keyword = "...")] derive attribute (or hand-written
const); a typo in the keyword can never drift into the diagnostic
at runtime. got is the typed closed-set SexpShape enum —
the twelve reachable Sexp outermost shapes encoded as variant
identities so the SexpShape that the typed-entry gate observed
is load-bearing data in the type system. Same posture as
TypeMismatch.got: SexpShape: consumers pattern-match on
SexpShape::Int etc. directly instead of substring-matching
got == "int". Encoding the closed set as a TYPE makes the
compile-time guarantee load-bearing, parallel to
NotAListForm.keyword, MissingHeadSymbol.keyword,
HeadMismatch.keyword, and the Defmacro*.head family.
Display preserves the legacy "positional NAME must be a symbol or string" substring AND the "compile error in {keyword}:"
prefix byte-for-byte; the structural detail ((got {got})) is
appended in a parenthetical, parallel to how MissingHeadSymbol
appends (got {g}) / (empty list) and how RestParamMissingName
appends (rest marker at position {n}, got {g}). When a future
run gives Sexp source spans, pos: Option<usize> lands here in
ONE place and every named-form-non-symbol-name site picks up
positional rendering via crate::diagnostic::format_diagnostic
mechanically.
RewriterNonList
rewrite_typed::<T> — the typed-exit gate of the self-optimization
primitive (THEORY.md §II.1 invariant 3) — was handed a rewriter
closure whose output, after typed round-trip through canonical JSON,
did not project to Sexp::List. The round-trip contract is:
serialize T → Sexp::List (alternating kwargs), hand the list
to the rewriter F, re-enter T::compile_from_args over the
returned list’s items. A non-list result violates that contract —
the gate fires before compile_from_args runs, so a wrong-shaped
rewriter output is rejected at the typed-exit boundary rather than
confusingly later inside the kwargs decoder.
Mirror at the typed-exit boundary of the typed-entry-side
NamedFormNonSymbolName lift: the latter rejects a wrong-typed
NAME slot at compile_named_from_forms::<T>’s entry; this variant
rejects a wrong-typed rewriter output at rewrite_typed::<T>’s
exit. Both round-trip the same compile-time T::KEYWORD projection
into the variant’s keyword slot, so authoring tools (REPL, LSP,
tatara-check) bind on variant identity at both boundaries of the
self-optimization primitive rather than substring-grepping the
rendered diagnostic.
keyword is &'static str because every call site passes
T::KEYWORD from rewrite_typed::<T> — a compile-time literal
sourced from the #[tatara(keyword = "...")] derive attribute (or
hand-written const). Using a static slot makes that compile-time
guarantee load-bearing in the type system — a typo can never drift
into the diagnostic at runtime, the type system is the floor, same
posture as NotAListForm.keyword, MissingHeadSymbol.keyword,
HeadMismatch.keyword, NamedFormMissingName.keyword, and
NamedFormNonSymbolName.keyword.
got is SexpWitness — the closed-set typed joint identity
pairing the offending rewriter output’s SexpShape (the twelve
reachable Sexp outermost shapes the rewriter closure can produce)
with its Sexp::Display projection (the literal value the rewriter
actually returned — 42, :foo, "bad", notify-ref, (),
etc.). EIGHTH consumer of the typed SexpWitness primitive
introduced in error.rs’s SpliceOutsideList.got lift, and the
FIRST consumer on the typed-EXIT boundary — sibling lifts of
SpliceOutsideList.got: SexpWitness, NonSymbolUnquoteTarget.got: SexpWitness, NonSymbolParam.got: SexpWitness,
DefmacroNonSymbolName.got: SexpWitness,
DefmacroNonListParams.got: SexpWitness,
RestParamMissingName.got: Option<SexpWitness>, and
MissingHeadSymbol.got: Option<SexpWitness> close the typed-ENTRY
rejection surface across the substrate’s seven entry-side gates.
This eighth lift extends the typed-identity unification contract
across BOTH boundaries of the typed-IR algebra
(THEORY.md §II.1 invariant 1 + invariant 3) — every
Sexp::Display-source got slot in the substrate, regardless of
whether the rejection fires at typed-ENTRY (compile_from_sexp
chain, template-gate, defmacro-syntax-gate, parse_params walker)
or typed-EXIT (rewrite_typed’s Sexp::List-contract gate), now
shares ONE typed witness identity at the variant slot. Authoring
tools (REPL, LSP, tatara-check) bind to BOTH got.shape
(structurally pattern-matchable on SexpShape::Int etc.) AND
got.display (the literal value, renderable verbatim) jointly
for a typed-exit-side rejection too — no projection-to-String
at the helper boundary loses the structural identity. Promotes
the legacy got: String shape parallel to how the seven entry-
side lifts promoted theirs.
Display matches the legacy Compile-shaped diagnostic byte-for-
byte — "compile error in {keyword}: rewriter must return a list; got {got}" — so existing consumer assertions (tatara-check’s
diagnostic capture, REPL substring-greps that match on "rewriter must return a list; got ") pass unchanged across the lift. The
{got} slot flows through SexpWitness::Display, which writes
only the display field, so the rendering is byte-for-byte
identical to the pre-lift got: String shape. When a future run
gives Sexp source spans, pos: Option<usize> lands inside
SexpWitness in ONE place and every rewriter-non-list site
picks up positional rendering via
crate::diagnostic::format_diagnostic mechanically.
DomainSerialize
serde_json::to_value of a typed T value (any registered
TataraDomain) errored. Two sites share this failure mode:
register::<T>’s registry-dispatch closure (the registered
handler serializes the just-typed value to JSON for the
dispatcher) and rewrite_typed::<T>’s round-trip prelude (the
self-optimization primitive serializes its input to JSON before
projecting it to a Sexp::List for the rewriter closure). Both
funnel through serialize_to_json_err::<T> so the type-level
T::KEYWORD projection is mechanically threaded into the
keyword slot, parallel to how rewriter_non_list_err::<T>
threads T::KEYWORD into RewriterNonList.keyword.
Mirror at the typed-exit boundary of the typed-entry-side
from_value failure path: extract_via_serde /
extract_optional_via_serde / extract_vec_via_serde route
through deserialize_err / deserialize_item_err, which now
produce the structural LispError::KwargDeserialize { key, idx, message } variant — the typed-entry-side sibling of this lift.
After both lifts BOTH directions of the JSON-projection round-
trip — to_value (typed-exit, keyword-keyed) AND from_value
(typed-entry, key-keyed) — are structurally typed; there are
zero LispError::Compile { ... } construction sites left in
tatara-lisp/src/domain.rs.
Sibling of RewriterNonList { keyword, got } for the
rewrite_typed::<T> rejection chain — that variant fires when
the rewriter’s OUTPUT is not a list; this variant fires when
the round-trip’s INPUT (the typed value) fails to project to
JSON at all. Together with RewriterNonList, every distinct
to_value-side rejection mode in the self-optimization
primitive and the registry-dispatch closure binds to ONE
structural variant of LispError, not a Compile-shaped
substring.
keyword is &'static str because every call site projects
T::KEYWORD via serialize_to_json_err::<T> — a compile-time
literal sourced from the #[tatara(keyword = "...")] derive
attribute (or hand-written const). Using a static slot makes
that compile-time guarantee load-bearing in the type system —
a typo can never drift into the diagnostic at runtime, the
type system is the floor, same posture as
RewriterNonList.keyword, NamedFormMissingName.keyword,
NamedFormNonSymbolName.keyword, NotAListForm.keyword,
MissingHeadSymbol.keyword, HeadMismatch.keyword, and the
Defmacro*.head family. message is String because it
carries the serde_json::Error::Display projection (errors
render expected … at line L column C shapes — arbitrary text
from the underlying serde_json::Error). Carrying the rendered
message rather than a #[source] serde_json::Error keeps the
variant’s structural shape parallel to every other String-
carrying variant in this enum — every consumer renders via
Display, none consumes the underlying error chain.
Display matches the legacy Compile-shaped diagnostic byte-
for-byte — "compile error in {keyword}: serialize: {message}"
— so existing consumer assertions (tatara-check’s diagnostic
capture, REPL substring-greps that match on "serialize: ")
pass unchanged across the lift. When a future run gives Sexp
source spans, pos: Option<usize> lands here in ONE place and
every domain-serialize site picks up positional rendering via
crate::diagnostic::format_diagnostic mechanically.
KwargDeserialize
serde_json::from_value::<T> of a kwarg’s canonical-JSON projection
errored. Two distinct sites share this failure mode through ONE
structural variant whose data carries the typed closed-set
KwargPath enum directly — KwargPath::Named(key) for the scalar
/ Option<T> path, KwargPath::Item { key, idx } for the per-item
path inside a Vec<T> kwarg. The bifurcation lives inside the
typed enum’s variant identity, not in a sibling idx: Option<usize>
slot:
extract_via_serde(required) andextract_optional_via_serde(optional) — kwarg-keyedfrom_valuefailures at the scalar /Option<T>path.path: KwargPath::Named(key); the failure binds to the kwarg slot identity ONLY (:{key}).extract_vec_via_serde(per-item) — kwarg-AND-index-keyedfrom_valuefailures inside aVec<T>kwarg’s items.path: KwargPath::Item { key, idx }; the failure binds to the kwarg slot AND the failing item index (:{key}[{i}]).
Mirror at the typed-entry JSON boundary of the typed-exit-side
DomainSerialize { keyword, message } lift: the latter rejects a
to_value::<T> failure (typed-exit, keyword-keyed, sourced from
T::KEYWORD and so &'static str); this variant rejects a
from_value::<T> failure (typed-entry, kwargs-path-keyed, sourced
from the runtime kwarg lookup and carried as a typed KwargPath).
Together the two close the JSON-projection boundary of the
typed-domain surface — every distinct serde_json failure mode at
the typed-domain boundary binds to ONE structural variant of
LispError, not a Compile-shaped substring.
Sibling of TypeMismatch.form: KwargPath: both kwargs-path-keyed
typed-entry rejection modes now carry the SAME typed kwargs-path
identity inside their variant’s data shape. The (key, idx: Option<usize>) bifurcation collapses into KwargPath’s variant
identity — Named vs. Item — so the invalid combination
(key: "", idx: Some(0)) for a scalar path (or any combination that
invented a fourth sub-mode) becomes structurally unrepresentable
rather than re-asserted at the helper boundary via runtime
Option::is_some comparison. Same closed-set posture as
LispError::TypeMismatch.form: KwargPath,
LispError::Defmacro*.head: MacroDefHead,
LispError::UnboundTemplateVar.prefix: UnquoteForm,
LispError::CompilerSpecIo.stage: CompilerSpecIoStage, and
LispError::TemplateInvariant.kind: TemplateInvariantKind.
path is KwargPath — the closed-set typed enum whose variants
are EXACTLY the reachable kwargs-path shapes (Named(String) /
Item { key: String, idx: usize } / Slot(usize)). The runtime
kwarg lookup source-of-key is carried inside the typed enum’s
String payload; the per-item-index bifurcation is the enum’s
Named vs. Item variant identity, not a sibling Option slot.
message is String because it carries the
serde_json::Error::Display projection (errors render expected … at line L column C shapes — arbitrary text from the underlying
serde_json::Error); carrying the rendered message rather than a
#[source] serde_json::Error keeps the variant’s structural shape
parallel to every other String-carrying variant in this enum
(DomainSerialize.message, Compile.message).
message carries the raw serde_json::Error::Display projection
— NO "deserialize: " prefix in the field, the prefix is in the
Display rendering — so consumers that pattern-match on
message get the underlying diagnostic unchanged, parallel to how
DomainSerialize.message carries the raw serde_json projection
(the "serialize: " prefix lives in Display, not in the slot).
Display matches the legacy Compile-shaped diagnostic byte-for-
byte across both sub-modes via KwargPath’s Display projection:
"compile error in :{key}: deserialize: {message}" for
KwargPath::Named, "compile error in :{key}[{idx}]: deserialize: {message}" for KwargPath::Item — so existing substring-grep
consumers (tatara-check’s diagnostic capture, REPL substring-greps
that match on "deserialize: ", ":steps[1]", ":level") pass
unchanged across the lift. When a future run gives Sexp source
spans, pos: Option<usize> lands here in ONE place and every
kwarg-deserialize site picks up positional rendering via
crate::diagnostic::format_diagnostic mechanically.
CompilerSpecIo
compiler_spec.rs’s disk-persistence surface emitted an
I/O or serde failure. Four call sites in compiler_spec.rs
share this failure mode through ONE structural variant keyed
on the closed-set CompilerSpecIoStage enum (realize_to_disk
× {serialize, write} ⊎ load_from_disk × {read, deserialize}).
Encoding the (operation, stage) pair as ONE typed enum (rather
than two &'static str slots operation × stage) makes the
constraint that ONLY 4 of the 2×4 = 8 hypothetical pairs are
reachable load-bearing in the type system — a typo like
(operation: "load_from_disk", stage: "write") becomes
structurally unrepresentable rather than re-asserted at the
helper boundary via runtime string comparison. Same posture as
MacroDefHead in macro_expand.rs: the closed set becomes a
TYPE, and rustc’s exhaustiveness check is the future invariant-
keeper. Adding a new disk-persistence operation (e.g.,
load_from_str) requires extending CompilerSpecIoStage,
which rustc-enforces matching at every projection site
(operation() / label()).
Mirror at the disk boundary of the typed-domain JSON-projection
round-trip’s DomainSerialize / KwargDeserialize sibling pair
at the in-memory kwarg boundary: those variants reject
to_value::<T> / from_value::<T> failures at the typed-domain
boundary; this variant rejects file I/O + top-level JSON
failures at the disk boundary. After this lift, every distinct
failure mode in tatara-lisp/src/compiler_spec.rs’s persistence
surface is structurally typed; there are zero
LispError::Compile { ... } construction sites left in
tatara-lisp/src/compiler_spec.rs.
stage is CompilerSpecIoStage — a closed-set typed enum
whose operation() and label() projections feed the Display
rendering — so the compile-time guarantee on BOTH slots is
load-bearing in the type system. message is String because
it carries the underlying error’s Display projection
(serde_json::Error for serialize / deserialize, std::io::Error
for read / write — arbitrary text); carrying the rendered
message rather than a #[source] chain keeps the variant’s
structural shape parallel to every other String-carrying variant
in this enum (DomainSerialize.message, KwargDeserialize.message,
Compile.message).
message carries the raw underlying-error Display projection
— NO "{stage}: " prefix in the field, the prefix is in the
Display rendering — so consumers that pattern-match on
message get the underlying diagnostic unchanged, parallel to
how DomainSerialize.message carries the raw serde_json
projection (the "serialize: " prefix lives in Display, not in
the slot) and KwargDeserialize.message carries the raw
serde_json projection (the "deserialize: " prefix lives in
Display, not in the slot).
Display matches the legacy Compile-shaped diagnostic byte-for-
byte across all four stages — "compile error in {operation}: {stage}: {message}" where {operation} is stage.operation()
and {stage} is stage.label() — so existing consumer
assertions (tatara-check’s diagnostic capture, REPL substring-
greps that match on "realize_to_disk", "load_from_disk",
"serialize: ", "write: ", "read: ", "deserialize: ")
pass unchanged across the lift. When a future run gives Sexp
source spans, pos: Option<usize> lands here in ONE place
(though the disk surface is non-positional — failures originate
from file I/O / serde, not from a Sexp slot — so the field
would stay None at every call site, the variant joining the
position_is_none_for_non_positional_variants cohort).
TemplateInvariant
apply_compiled’s bytecode-runtime invariant violation. Four call
sites in macro_expand.rs::apply_compiled share this failure mode
through ONE structural variant keyed on the closed-set
TemplateInvariantKind enum. Every violation here is a
COMPILER-INTERNAL bug — the bytecode that drives apply_compiled
is produced by compile_template / compile_node in this same
module, and a well-formed bytecode never references an
out-of-bounds param index (Subst / Splice gates) nor leaves the
runtime stack unbalanced at the final pop (EndList / no-value
gates).
Encoding the four failure modes as ONE typed enum (rather than a
free-form message: String slot) makes the constraint that ONLY
4 distinct violations are reachable load-bearing in the type
system — a regression that drifts the failure mode (e.g. a fifth
“wrong opcode” gate added without a TemplateInvariantKind
extension) becomes a match compile error at the projection site,
not a substring-grep regression that ships. Same posture as
CompilerSpecIoStage for CompilerSpecIo: the closed set becomes
a TYPE, not a matches! literal in the helper. The index slot of
the Subst / Splice gates lives INSIDE the variant
(SubstBadIndex(usize) / SpliceBadIndex(usize)) rather than on
the outer variant as op_index: Option<usize>, so the invalid
combination EndListEmptyStack { op_index: Some(_) } is
structurally unrepresentable — the type system encodes “this gate
has an index, that gate does not.”
Display matches the legacy Compile-shaped diagnostic
byte-for-byte across all four kinds — "compile error in {macro_name}: {kind.message()}" — so existing consumer assertions
(tatara-check’s diagnostic capture, REPL substring-greps that
match on "compiled template referenced bad param index",
"compiled template referenced bad splice index", "compiled template: EndList with empty stack", "compiled template produced no value") pass unchanged across the lift.
macro_name is String because it comes from arbitrary source
(the call-site head symbol). kind is TemplateInvariantKind —
a closed-set typed enum whose message() projection feeds the
Display rendering.
Theory anchor: THEORY.md §V.1 — knowable platform; the closed set
of bytecode-invariant failure modes becomes a TYPE rather than a
runtime string-comparison-and-format dance. THEORY.md §VI.1 —
generation over composition; the typed enum lands the structural-
completeness floor for the bytecode-runtime surface, parallel to
how CompilerSpecIoStage lands the structural-completeness floor
for the disk-persistence surface and MacroDefHead lands it for
the macro-definition-head closed set. THEORY.md §II.1 invariant 5
(composition preserves proofs): a well-formed bytecode invariant
is the proof that drives the interpreter; the structural variant
makes the proof’s REJECTION shape first-class.
ExpansionDepthExceeded
Expander::expand recursed past its configured ceiling
(Expander::max_expansion_depth, default
crate::macro_expand::DEFAULT_MAX_EXPANSION_DEPTH = 256). Fires
on the runaway (defmacro loop (x) (loop ,x))— a macro whose expansion contains another call to itself — which pre- lift stack-overflowed the process (a below-floor failure with noResultwitness). The typed-entry gate turns "we abort" into "we reject at the expansion boundary" so authoring surfaces (REPL, LSP,tatara-check) see a Result::Errat the same call boundary as every other macro-expansion failure and can pattern-match on the variant identity to route diagnostics through the same channel asTooManyMacroArgs/MissingMacroArg/UnboundTemplateVar`.
macro_name is String because it comes from arbitrary
source — the head symbol of the offending call at the moment
the ceiling is hit; limit is usize because it is the
configured ceiling the expander enforced at that call. The
pair names BOTH the offending macro AND the ceiling it
breached, so an LSP that wants to surface “your macro loop
re-expanded past 256 levels; is it recursive without a base
case?” has both halves at the variant boundary without
substring-parsing the rendered diagnostic.
Theory anchor: THEORY.md §V.1 — knowable platform; the
runaway-macro-expansion failure mode becomes a first-class
typed rejection at the expander boundary instead of an
unhandled stack overflow that aborts the process without
producing a Result at all. THEORY.md §II.1 invariant 1 —
typed entry; every macro-expansion failure mode the expander
admits is a variant of LispError, so the below-floor abort
is the last un-typed rejection in the expander’s dispatch
chain — closing it moves the expansion surface fully into the
Result algebra.
ExpansionSizeExceeded
Expander::expand’s freshly-applied macro output crossed the
configured node-count ceiling (Expander::max_expansion_size,
default crate::macro_expand::DEFAULT_MAX_EXPANSION_SIZE).
Peer to Self::ExpansionDepthExceeded one RESOURCE axis over:
where the depth ceiling bounds RECURSION LENGTH (how many times
a macro re-expands into itself before the process aborts) and
crate::macro_expand::DEFAULT_MAX_CACHE_ENTRIES bounds
MEMOIZATION WIDTH (how many distinct (name, args) pairs the
cache retains), this variant bounds OUTPUT SIZE (how many
crate::ast::Sexp nodes a single macro apply may produce
before the expander rejects rather than descending into a
runaway tree). Fires on the canonical “expansion bomb”
(defmacro bomb (x) (list ,x ,x ,x ,x ,x ,x))— a well-defined but exponentially-productive macro whose depth stays small while the produced tree grows past any reasonable working-set budget. Pre-lift the runaway ran until the process exhausted its heap; post-lift the expander returns this typed rejection at the firstapplyoutput whosenode_countexceeds the ceiling, withmacro_namepopulated from the offending call,sizepopulated from the observed node count, andlimitpopulated from the enforced ceiling — so authoring surfaces (REPL, LSP,tatara-check`) see BOTH halves at the variant
boundary rather than substring-parsing free-form text.
The three-field shape names BOTH the offending macro AND the
observed size AND the configured ceiling. An LSP that wants to
surface “your macro bomb produced a 512-node result past the
256-node ceiling; is it exponentially productive?” reads all
three from the typed variant without substring parsing. Joins
the Self::ExpansionDepthExceeded cohort so the
Self::position projection routes both resource-ceiling
rejections through the SAME None arm — the offending macro’s
byte offset is not what a runaway diagnostic anchors to; the
(macro name, observed resource, ceiling) triple is.
Theory anchor: THEORY.md §V.1 — knowable platform; the
expansion-bomb failure mode becomes a first-class typed
rejection at the expander boundary rather than a heap-exhausted
abort that never reaches a Result. THEORY.md §II.1 invariant
1 — typed entry; every resource-ceiling failure mode the
expander admits (recursion, memory, output size) is now a
variant of LispError, closing the “runaway macro” surface at
three RESOURCE-DIMENSION axes.
MacroBodySizeExceeded
Expander::register_macro_def rejected a macro definition whose
body’s crate::ast::Sexp::node_count exceeded the configured
ceiling (Expander::max_macro_body_size, default
crate::macro_expand::DEFAULT_MAX_MACRO_BODY_SIZE). Peer to
Self::ExpansionSizeExceeded one PIPELINE-STAGE axis over:
where ExpansionSizeExceeded bounds a macro apply’s OUTPUT
size at EXPAND time (the runtime bomb — (defmacro bomb (x) (list ,x ,x ,x ,x))producing a large tree from a small input), this variant bounds a macro's BODY size at REGISTER time (the authoring bomb —(defmacro huge (x) <template-of-N-million-nodes>) whose
storage in the Expander::macros table alone would exhaust
memory before a single apply ran). Fires at
crate::macro_expand::Expander::register_macro_def BEFORE
compile_template walks the body or the entry lands in the
macros table — a failed registration leaves both tables exactly
as they were, matching the ordering TemplateInvariant /
UnboundTemplateVar already enjoy on the compile-time reject
path.
The three-field shape names BOTH the offending macro AND the
observed body size AND the configured ceiling — same shape as
ExpansionSizeExceeded (its EXPAND-time peer) so authoring
surfaces that route resource-ceiling rejections through one
diagnostic channel bind to ONE structural pattern. macro_name
is String because it comes from arbitrary source — the
(defmacro NAME …) head at the moment the ceiling is hit;
size is the observed body.node_count(); limit is the
configured ceiling.
Theory anchor: THEORY.md §V.1 — knowable platform; the
authoring-bomb failure mode (a macro body large enough to hurt
storage or template-compile time) becomes a first-class typed
rejection at the REGISTRATION boundary. THEORY.md §II.1
invariant 1 — typed entry; every resource-ceiling failure mode
the expander admits (expand-time recursion, expand-time
memoization width, expand-time output size, register-time body
size) is now a variant of LispError, closing the “runaway
macro” surface at FOUR RESOURCE-DIMENSION axes across TWO
pipeline stages (register + expand).
RegisteredMacrosExceeded
Expander::register_macro_def rejected a fresh (non-overwrite)
registration because the [Expander::macros] table already held
Expander::max_registered_macros entries (default
crate::macro_expand::DEFAULT_MAX_REGISTERED_MACROS). Peer to
Self::MacroBodySizeExceeded one RESOURCE-DIMENSION axis over
on the REGISTER-time surface — where MacroBodySizeExceeded
bounds a single macro’s BODY SIZE (the per-registration
authoring bomb — one huge template landing in
Expander::macros), this variant bounds the TABLE ENTRY COUNT
(the cumulative registration bomb — a code-generator emitting
unbounded (defmacro fresh-N (x) (list ,x))heads at REGISTER time whose bodies each sit comfortably underExpander::max_macro_body_size yet collectively saturate process memory). Also peer to [crate::macro_expand::DEFAULT_MAX_CACHE_ENTRIES`] one
PIPELINE-STAGE axis over: where the cache-entries ceiling
bounds EXPAND-time memoization width, this ceiling bounds
REGISTER-time macro-table width — the two entry-count guards
close the two pipeline stages symmetrically.
A re-registration of an already-registered key (self.macros
contains macro_name before the call) is an OVERWRITE, which
does not grow the table and therefore never fires this variant
— operators can redefine a macro at capacity without hitting
the ceiling, and only FRESH keys are gated. The gate fires
BEFORE [compile_template] walks the body or the entry lands
in the crate::macro_expand::Expander::macros /
templates tables — a failed registration leaves both tables
exactly as they were, matching the pristine-tables invariant
Self::MacroBodySizeExceeded holds on the body-size axis.
The three-field shape names BOTH the offending macro AND the
observed table count AND the configured ceiling — same shape
as MacroBodySizeExceeded / ExpansionSizeExceeded (its
pipeline-stage peers) so authoring surfaces that route
resource-ceiling rejections through one diagnostic channel
bind to ONE structural pattern. macro_name is String
because it comes from arbitrary source — the (defmacro NAME …) head at the moment the ceiling is hit; count is the
observed self.macros.len() (which equals limit at
rejection in the common case, or exceeds it if limit was
lowered mid-run past the current table size); limit is the
configured ceiling.
Joins the Self::ExpansionDepthExceeded cohort in
Self::position so all four resource-ceiling rejections
route through the SAME None arm — the offending macro’s
byte offset is not what a registration-bomb diagnostic
anchors to; the (macro name, observed table count, ceiling)
triple is.
Theory anchor: THEORY.md §V.1 — knowable platform; the
registration-bomb failure mode (unbounded macro-table growth)
becomes a first-class typed rejection at the REGISTRATION
boundary rather than an unhandled resource-drift that no
Result witnesses. THEORY.md §II.1 invariant 1 — typed entry;
every resource-ceiling failure mode the expander admits
(expand-time recursion, expand-time memoization width,
expand-time output size, register-time body size, register-
time table width) is now a variant of LispError, closing the
“runaway macro” surface at FIVE RESOURCE-DIMENSION axes across
TWO pipeline stages — the register-time surface is now a
two-dimensional (size × count) closure symmetric with the
expand-time (depth + count + size) triple.
MacroArityExceeded
Expander::register_macro_def rejected a macro definition whose
lambda-list arity — the crate::macro_expand::MacroParams::total_arity
projection: required.len() + optional.len() + rest.is_some() as usize — exceeded the configured ceiling
(Expander::max_macro_arity, default
crate::macro_expand::DEFAULT_MAX_MACRO_ARITY). Peer to
Self::MacroBodySizeExceeded one RESOURCE-DIMENSION axis over
on the REGISTER-time surface — where MacroBodySizeExceeded
bounds a per-registration BODY node count (the AST-nodes of the
template a macro rewrites INTO), this variant bounds a
per-registration PARAM slot count (the fresh symbols
compile_template’s ,name-index resolution AND
MacroParams::bind’s per-index binder walk have to thread
through on every call). Also peer to
Self::RegisteredMacrosExceeded one RESOURCE-DIMENSION axis
over on the REGISTER-time surface — where RegisteredMacrosExceeded
bounds cumulative registration COUNT (the table-scoped
resource), this variant bounds a per-registration PARAM-LIST
WIDTH (the per-entry resource). Together the three
REGISTER-time variants (this one +
Self::MacroBodySizeExceeded + Self::RegisteredMacrosExceeded)
close the (per-body SIZE, per-body ARITY, per-table COUNT)
three-corner surface. Fires at
crate::macro_expand::Expander::register_macro_def BETWEEN the
table-count gate (cheapest — O(1) HashMap::len) and the
body-size gate (O(N) on def.body.node_count()) — the arity
projection is a three-arm addition on def.params fields, no
AST walk, so it sits BEFORE the body-size gate on the
O(1)-first ordering of the REGISTER-time chain. A failed
registration leaves BOTH self.macros AND self.templates
tables exactly as they were, matching the pristine-tables
invariant Self::MacroBodySizeExceeded and
Self::RegisteredMacrosExceeded hold on their axes.
Fires on the canonical arity-bomb (defmacro huge (a-1 a-2 … a-N-million) ,a-1) — a code-generator whose macro body sits at ONE node (Sexp::Quasiquotewrapping a single,a-1unquote is 3 nodes total, well underExpander::max_macro_body_size`) yet whose param list carries
millions of fresh names each subsequent call would have to
walk in the per-index binder — a below-floor resource-drift
the two prior REGISTER-time guards admit through because
neither of them fires against the PER-REGISTRATION PARAM-LIST
WIDTH.
The three-field shape names BOTH the offending macro AND the
observed arity AND the configured ceiling — same shape as
MacroBodySizeExceeded / ExpansionSizeExceeded (its
register-time peer and expand-time peer). macro_name is
String because it comes from arbitrary source — the
(defmacro NAME …) head at the moment the ceiling is hit;
arity is the observed def.params.total_arity(); limit is
the configured ceiling. An LSP that wants to surface “your
macro huge declares 512 params past the 128-param ceiling;
is it a Vec<TypedEntity>-unroller?” reads all three from the
typed variant without substring parsing.
Joins the Self::ExpansionDepthExceeded cohort in
Self::position so all six resource-ceiling rejections
route through the SAME None arm — the offending macro’s byte
offset is not what an arity-bomb diagnostic anchors to; the
(macro name, observed arity, ceiling) triple is.
Theory anchor: THEORY.md §V.1 — knowable platform; the
arity-bomb failure mode (an unbounded per-registration param
list) becomes a first-class typed rejection at the REGISTRATION
boundary rather than an unhandled per-call binder-walk cost
that no Result witnesses. THEORY.md §II.1 invariant 1 —
typed entry; every resource-ceiling failure mode the expander
admits (expand-time recursion, expand-time memoization width,
expand-time output size, register-time body size, register-
time table width, register-time param-list width) is now a
variant of LispError, closing the “runaway macro” surface at
SIX RESOURCE-DIMENSION axes across TWO pipeline stages — the
register-time surface is now a three-dimensional (size × arity
× count) closure symmetric with the expand-time (depth + count
- size) triple.
Frontier inspiration: Common Lisp’s LAMBDA-PARAMETERS-LIMIT
standard variable (CLHS §3.4.1) — the runtime-reflectable
“upper exclusive bound on the number of parameters that may
appear in a lambda list” every conforming implementation
carries; this variant is the substrate’s typed-Rust peer at
compile time, translated through pleme-io primitives: a typed
LispError variant rather than an implementation-defined
condition, wired into the substrate’s Result algebra at
crate::macro_expand::Expander::register_macro_def rather
than raised at eval time.
Implementations§
Source§impl LispError
impl LispError
Sourcepub fn position(&self) -> Option<usize>
pub fn position(&self) -> Option<usize>
Byte offset of the failure into the source, when locatable.
A PROJECTION of LispError::span — span().map(|s| s.start) and
nothing else. Pre-lift this carried its own copy of the exhaustive
positional/non-positional classification, so a new variant had to
be classified TWICE and the two matches were free to disagree about
whether an error is locatable. Post-lift span is the single
classification site; this is the point-shaped view of it, kept
because format_diagnostic’s --> label:line:col header names one
byte, not a range.
Sourcepub fn span(&self) -> Option<Span>
pub fn span(&self) -> Option<Span>
Source Span of the failure, when locatable.
THE classification site: a variant is locatable iff it appears in
the Some(..) arms below. Variants without a location (Type,
Compile, …) return None, so callers render a snippet only when
the substrate has the information to do so; adding a variant
without classifying it here is a non-exhaustive-match build
failure, not a silently-positionless error.
The range — not just the start — is what lets
crate::diagnostic::format_diagnostic underline the offending
FORM rather than its first byte. A zero-width span is legal and
means “point here” (see Eof); caret_run clamps it up to one
caret.
Trait Implementations§
Source§impl Error for LispError
impl Error for LispError
1.30.0 · Source§fn source(&self) -> Option<&(dyn Error + 'static)>
fn source(&self) -> Option<&(dyn Error + 'static)>
1.0.0 · Source§fn description(&self) -> &str
fn description(&self) -> &str
use the Display impl or to_string()