Skip to main content

LispError

Enum LispError 

Source
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.

Fields

§span: Span
§

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.

Fields

§span: Span
§

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.

Fields

§span: Span
§

InvalidNumber(String)

§

UnknownSymbol(String)

§

Type

Fields

§expected: &'static str
§

Compile

Fields

§form: String
§message: String
§

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 (KeywordStringIntNumberBoolListListOfStrings) 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 (NilSymbolKeywordStringIntFloatBoolListQuoteQuasiquoteUnquoteUnquoteSplice) 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.

Fields

§keyword: &'static str
§

Unknown

Fields

§category: &'static str
§value: String
§

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.

Fields

§dangling: String
§

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 (UnquoteSplice). 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.

Fields

§name: String
§

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.

Fields

§

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.

Fields

§

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.

Fields

§allowed: Vec<String>
§

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.

Fields

§keyword: String
§registered: Vec<String>
§

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 (UnquoteSplice). 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.

Fields

§

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

§

MissingMacroArg

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( :key value …)kwargs form omits a required keyword; this variant fires when a( a b …)` call omits a required positional param. The two close every distinct typed-entry missing-required surface in the substrate.

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.

Fields

§macro_name: String
§param: String
§

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.

Fields

§macro_name: String
§expected: usize
§got: usize
§

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.

Fields

§position: usize
§

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.

Fields

§rest_position: usize
§

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.

Fields

§rest_position: usize
§extra: usize
§

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.

Fields

§first_position: usize
§second_position: usize
§

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 — no supplied-p variable 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 (DefmacroDefpointTemplateDefcheck). 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.

Fields

§arity: usize
§

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:

  1. DefmacroArity { head, arity } if the form has fewer than 4 elements ((defmacro), (defmacro f)).
  2. DefmacroNonSymbolName { head, got } if list[1] isn’t a symbol ((defmacro 5 () body), (defmacro :foo () body)).
  3. Inside parse_params: NonSymbolParam { position, got } and RestParamMissingName { 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 (DefmacroDefpointTemplateDefcheck) 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:

  1. DefmacroArity { head, arity } if the form has fewer than 4 elements ((defmacro), (defmacro f)).
  2. DefmacroNonSymbolName { head, got } if list[1] isn’t a symbol ((defmacro 5 () body)).
  3. DefmacroNonListParams { head, got } if list[2] isn’t a list ((defmacro f x body)).
  4. Inside parse_params: NonSymbolParam { position, got } and RestParamMissingName { 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:

  1. NotAListForm { keyword } if the form isn’t a list at all (5, :foo, "x", name — bare atoms).
  2. LispError::Compile { form, message: "missing head symbol" } (NOT YET LIFTED) if the list is empty or list[0] isn’t a symbol ((), (5 …), (:foo …)).
  3. HeadMismatch { keyword, got } if list[0] is a symbol but doesn’t match T::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.

Fields

§keyword: &'static str
§

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:

  1. NotAListForm { keyword } — the form isn’t a list at all (5, :foo, "x", name — bare atoms).
  2. MissingHeadSymbol { keyword, got } — the form is a list but list[0] doesn’t exist (()) or isn’t a symbol ((5 …), (:foo …)).
  3. HeadMismatch { keyword, got } — list[0] is a symbol but doesn’t match T::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.

Fields

§keyword: &'static str
§

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:

  1. NamedFormMissingName { keyword } — the form passes the keyword-head match but has no NAME slot ((defpoint)).
  2. 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) …)).
  3. 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.

Fields

§keyword: &'static str
§

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.

Fields

§keyword: &'static str
§

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 TSexp::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.

Fields

§keyword: &'static str
§

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.

Fields

§keyword: &'static str
§message: String
§

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:

  1. extract_via_serde (required) and extract_optional_via_serde (optional) — kwarg-keyed from_value failures at the scalar / Option<T> path. path: KwargPath::Named(key); the failure binds to the kwarg slot identity ONLY (:{key}).
  2. extract_vec_via_serde (per-item) — kwarg-AND-index-keyed from_value failures inside a Vec<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.

Fields

§message: String
§

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).

Fields

§message: String
§

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.

Fields

§macro_name: String
§

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.

Fields

§macro_name: String
§limit: usize
§

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.

Fields

§macro_name: String
§size: usize
§limit: usize
§

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).

Fields

§macro_name: String
§size: usize
§limit: usize
§

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.

Fields

§macro_name: String
§count: usize
§limit: usize
§

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.

Fields

§macro_name: String
§arity: usize
§limit: usize

Implementations§

Source§

impl LispError

Source

pub fn position(&self) -> Option<usize>

Byte offset of the failure into the source, when locatable.

A PROJECTION of LispError::spanspan().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.

Source

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 Debug for LispError

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Display for LispError

Source§

fn fmt(&self, __formatter: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Error for LispError

1.30.0 · Source§

fn source(&self) -> Option<&(dyn Error + 'static)>

Returns the lower-level source of this error, if any. Read more
1.0.0 · Source§

fn description(&self) -> &str

👎Deprecated since 1.42.0:

use the Display impl or to_string()

1.0.0 · Source§

fn cause(&self) -> Option<&dyn Error>

👎Deprecated since 1.33.0:

replaced by Error::source, which can support downcasting

Source§

fn provide<'a>(&'a self, request: &mut Request<'a>)

🔬This is a nightly-only experimental API. (error_generic_member_access)
Provides type-based access to context intended for error reports. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> ToString for T
where T: Display + ?Sized,

Source§

fn to_string(&self) -> String

Converts the given value to a String. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.