tatara_lisp/domain.rs
1//! `TataraDomain` — a Rust type authorable as a Lisp `(<keyword> :k v …)` form.
2//!
3//! Apply `#[derive(TataraDomain)]` (from `tatara-lisp-derive`) and a plain
4//! struct gains a full Lisp compiler: keyword dispatch, kwarg parsing, typed
5//! field extraction.
6//!
7//! Also exposes a `DomainRegistry` + `linkme`-free `register_domain!` macro
8//! so any crate that derives `TataraDomain` can auto-register itself; the
9//! dispatcher then looks up unknown top-level forms by keyword at runtime.
10
11use std::collections::HashMap;
12use std::sync::{Mutex, OnceLock};
13
14use serde::de::DeserializeOwned;
15
16use crate::ast::{Atom, Sexp};
17use crate::error::{
18 ExpectedKwargShape, KwargPath, LispError, NumericLiteral, NumericWidth, Result, SexpShape,
19 SexpWitness,
20};
21
22/// Phase F: a Rust type (typically a unit-only enum) whose variants map to
23/// a single Lisp keyword atom — e.g., `Role::Master` ↔ `:master`. Used by
24/// `#[derive(TataraDomain)]` fields with `#[tatara(keyword_enum)]`. Derive
25/// via `#[derive(KeywordSexp)]` from `tatara-lisp-derive`.
26pub trait KeywordSexp: Sized {
27 /// Parse `s` (the keyword name without the leading `:`) into Self.
28 fn from_keyword(s: &str) -> Result<Self>;
29 /// The keyword name (without leading `:`) for this variant.
30 fn to_keyword(self) -> &'static str;
31}
32
33/// A Rust type compilable from a Lisp form.
34pub trait TataraDomain: Sized {
35 /// The Lisp keyword (e.g., `"defmonitor"`).
36 const KEYWORD: &'static str;
37
38 /// Parse the argument list (everything after the keyword) into Self.
39 fn compile_from_args(args: &[Sexp]) -> Result<Self>;
40
41 /// Parse a complete form; validates the head symbol matches `KEYWORD`.
42 fn compile_from_sexp(form: &Sexp) -> Result<Self> {
43 let list = form
44 .as_list()
45 .ok_or_else(|| not_a_list_form_err(Self::KEYWORD))?;
46 // The two sub-modes of "head can't be projected to a symbol" — empty
47 // list (`first()` is `None`) vs. present-but-not-a-symbol
48 // (`as_symbol()` is `None`) — share ONE structural variant
49 // (`MissingHeadSymbol { keyword, got }`) but bind to distinct
50 // `got` payloads (`None` vs. `Some(<sexp display>)`). This lets
51 // an authoring tool render "your form is empty" vs. "your
52 // form's head is `5`, not a symbol" without re-parsing the
53 // source — the legacy `Compile`-shaped diagnostic collapsed
54 // both into one message.
55 let head_sexp = list
56 .first()
57 .ok_or_else(|| missing_head_err(Self::KEYWORD, None))?;
58 let head = head_sexp
59 .as_symbol()
60 .ok_or_else(|| missing_head_err(Self::KEYWORD, Some(head_sexp.witness())))?;
61 if head != Self::KEYWORD {
62 return Err(head_mismatch(Self::KEYWORD, head.to_string()));
63 }
64 Self::compile_from_args(&list[1..])
65 }
66}
67
68// ── compile_from_sexp diagnostics — the form-shape gate primitives ─
69//
70// `compile_from_sexp` (the trait default) gates every `TataraDomain`
71// invocation that takes a complete `(KEYWORD …)` form: ProcessSpec,
72// MonitorSpec, AlertPolicySpec, every hand-written impl. Three failure
73// modes — not a list, missing head symbol, wrong head — used to be
74// inline `LispError::Compile { form: KEYWORD.to_string(), message: …}`
75// triples in the trait default. The three-times-rule signal
76// (THEORY.md §VI.1) calls for one named primitive per shape; these
77// are them.
78//
79// All three are now structural: `not_a_list_form_err` returns
80// `LispError::NotAListForm`, `missing_head_err` returns
81// `LispError::MissingHeadSymbol { keyword, got }` (`got: None` for
82// empty list, `got: Some(<sexp display>)` for present-but-not-symbol),
83// and `head_mismatch` returns `LispError::HeadMismatch`. Each carries
84// its distinguishing data (the offending head's display projection,
85// the keyword) as first-class variant fields so authoring tools
86// pattern-match structurally instead of substring-grepping the
87// rendered message. The entire `compile_from_sexp` rejection chain
88// — bare-atom → empty/not-symbol head → wrong-keyword head — is
89// closed: every distinct typed-entry rejection at the form-shape
90// gate binds to ONE structural variant of `LispError`.
91
92/// `T::compile_from_sexp` was passed something that isn't a list.
93/// One named primitive every TataraDomain impl shares — returns the
94/// dedicated `LispError::NotAListForm { keyword }` variant so
95/// authoring surfaces (REPL, LSP, `tatara-check`) bind to the
96/// first-class `keyword` field instead of substring-parsing the
97/// rendered message. Display matches the legacy `Compile`-shaped
98/// diagnostic byte-for-byte (`"compile error in {keyword}: expected
99/// list form"`), so existing `format!("{err}").contains("expected
100/// list form")` assertions pass unchanged.
101///
102/// Theory anchor: THEORY.md §V.1 — knowable platform. The legacy
103/// `Compile { form, message }` shape required consumers to
104/// pattern-match on `message == "expected list form"` to recognize
105/// this specific gate (versus the sibling `missing head symbol`
106/// gate, which produces the same `Compile` shape with a different
107/// message). After this lift the discriminator is the variant
108/// itself — a regression that drifts the message string can no
109/// longer drift the gate's identity. THEORY.md §II.1 invariant 1 —
110/// typed entry; a non-list form is exactly the failure mode the
111/// typed-entry gate exists to reject, and the gate's identity is
112/// now load-bearing in the type system.
113#[must_use]
114pub fn not_a_list_form_err(keyword: &'static str) -> LispError {
115 LispError::NotAListForm { keyword }
116}
117
118/// `T::compile_from_sexp` was passed `()` or a list whose first
119/// element isn't a symbol — there's nothing to dispatch on. One named
120/// primitive every `TataraDomain` impl shares; returns the dedicated
121/// `LispError::MissingHeadSymbol { keyword, got }` variant so authoring
122/// surfaces (REPL, LSP, `tatara-check`) bind to the first-class
123/// `keyword` and `got` fields instead of substring-parsing the
124/// rendered message. `got: None` for the empty-list case (`()`),
125/// `got: Some(SexpWitness)` for the present-but-not-symbol case
126/// (`(5 …)`, `(:foo …)`, `("x" …)`, `((nested) …)`) — the legacy
127/// `Compile`-shaped diagnostic collapsed both into one message; this
128/// builder bifurcates them structurally so the renderable detail
129/// names which sub-mode fired. The `Some` arm carries the typed
130/// joint identity (`SexpShape` + `Sexp::Display`) routed through
131/// `sexp_witness(_)` so authoring tools that want to surface a
132/// structural autofix — "you wrote `:foo` at the head slot where a
133/// symbol was expected (did you mean `foo`?)" — bind on
134/// `got.shape == SexpShape::Keyword` directly, no substring-grep on
135/// the rendered display required.
136///
137/// Display matches the legacy `Compile`-shaped diagnostic byte-for-
138/// byte for the prefix (`"compile error in {keyword}: missing head
139/// symbol"`); the structural detail is appended in a parenthetical
140/// (`(empty list)` for `None`, `(got {g})` for `Some(g)`), parallel
141/// to how `RestParamMissingName` appends `(rest marker at position
142/// {n}, {got|none provided})` and how `SpliceOutsideList` appends
143/// `(got ,@{got})`. The `{g}` slot flows through `SexpWitness::Display`,
144/// which writes only the `display` field, so existing
145/// `format!("{err}").contains("missing head symbol")` assertions pass
146/// unchanged.
147///
148/// Theory anchor: THEORY.md §V.1 — knowable platform. The legacy
149/// `Compile { form, message }` shape required consumers to
150/// pattern-match on `message == "missing head symbol"` to recognize
151/// this specific gate (versus the sibling `expected list form` and
152/// head-mismatch gates, which produced different `message` strings
153/// in the same `Compile` shape). After this lift the discriminator
154/// is the variant itself — a regression that drifts the message
155/// string can no longer drift the gate's identity, AND the two
156/// distinct sub-modes (empty vs. present-but-not-symbol) are
157/// structurally addressable. THEORY.md §II.1 invariant 1 — typed
158/// entry; an empty form / non-symbol-head form is exactly the
159/// failure mode the typed-entry gate exists to reject, and the
160/// gate's identity is now load-bearing in the type system.
161#[must_use]
162pub fn missing_head_err(keyword: &'static str, got: Option<SexpWitness>) -> LispError {
163 LispError::MissingHeadSymbol { keyword, got }
164}
165
166/// Structural head-mismatch builder. Returns the dedicated
167/// `LispError::HeadMismatch` variant so authoring surfaces (REPL, LSP,
168/// `tatara-check`) bind to first-class `keyword`/`got` fields instead
169/// of substring-parsing the rendered message. Display matches the
170/// legacy `Compile`-shaped diagnostic byte-for-byte, so existing
171/// `format!("{err}").contains("expected ({KEYWORD}")` assertions pass
172/// unchanged.
173///
174/// Theory anchor: THEORY.md §V.1 — knowable platform. A diagnostic
175/// whose `got` is embedded in a free-form message is structurally
176/// incomplete; an authoring surface that wants to render
177/// "did-you-mean" suggestions on the offending head must re-parse
178/// the message. After this lift the slot exists in the variant's
179/// data shape itself.
180#[must_use]
181pub fn head_mismatch(keyword: &'static str, got: String) -> LispError {
182 LispError::HeadMismatch { keyword, got }
183}
184
185/// The substrate-wide [`TataraDomain`] well-formedness testkit — closes
186/// the four typed-entry rejection gates on the trait's default
187/// [`TataraDomain::compile_from_sexp`], three [`TataraDomain::KEYWORD`]
188/// grammar invariants, AND the reader round-trip theorem at ONE call
189/// every implementor's test module reaches for.
190///
191/// Peer of [`crate::closed_set::assert_closed_set_well_formed`] on the
192/// sibling [`crate::ClosedSet`] contract — after this lift both
193/// homoiconic-authoring contracts (the closed-set enum idiom AND the
194/// derived-domain idiom) carry ONE substrate-wide structural checker
195/// each, and every downstream implementor's test module reduces to a
196/// single-line invocation instead of re-deriving the invariants
197/// per-implementor.
198///
199/// ## The four `compile_from_sexp` rejection gates
200///
201/// 1. `NotAListForm { keyword }` on a bare atom — the typed-entry
202/// gate rejects the form-shape mismatch before descending into
203/// the list.
204/// 2. `MissingHeadSymbol { keyword, got: None }` on the empty list
205/// `()` — `list.first()` returns `None`, there's no head to
206/// project.
207/// 3. `MissingHeadSymbol { keyword, got: Some(_) }` on a list whose
208/// first element is not a symbol — `list.first().as_symbol()`
209/// returns `None`, and the offending element's typed identity
210/// threads into the `got` slot.
211/// 4. `HeadMismatch { keyword, got }` on a list headed by a symbol
212/// other than `T::KEYWORD` — the substring-free structural
213/// discriminator.
214///
215/// ## The three `KEYWORD` grammar invariants
216///
217/// 5. `KEYWORD` is non-empty — a keyword-less form cannot be
218/// dispatched.
219/// 6. `KEYWORD` classifies as [`Atom::Symbol`] through the substrate's
220/// typed-entry classifier [`Atom::from_lexeme`] — the ONE
221/// projection every bare-atom lexeme routes through inside the
222/// reader's parse arm. Subsumes the pre-lift "no leading ASCII
223/// digit" heuristic (a KEYWORD `"42"` decodes as [`Atom::Int`],
224/// `"1.5"` as [`Atom::Float`]) AND catches the two shapes the
225/// pre-lift check silently accepted: a leading `:` (KEYWORD
226/// `":foo"` decodes as [`Atom::Keyword`]) and the two boolean
227/// literals (`"#t"` / `"#f"` decode as [`Atom::Bool`]) — none of
228/// which the trait's `as_symbol()` head-match would fire on. Binds
229/// the invariant to the substrate's typed reader-classifier
230/// algebra so a future seventh [`Atom`] variant (e.g. `Char` for
231/// `#\x` reader syntax, `Bigint` for arbitrary-precision integers)
232/// strengthens the check ONCE at [`Atom::from_lexeme`] rather than
233/// re-heuristicing per implementor's test module.
234/// 7. `KEYWORD` contains no [`Sexp::is_bare_atom_boundary`] char —
235/// the ONE typed projection on the outer [`Sexp`] algebra that
236/// names "this char breaks the reader's bare-atom accumulator."
237/// Subsumes the pre-lift "no ASCII whitespace" heuristic (via
238/// `char::is_whitespace()` covering the Unicode whitespace surface
239/// the reader also splits on — NBSP `\u{00A0}`, ideographic space
240/// `\u{3000}`, and every other codepoint the pre-lift ASCII-only
241/// check silently accepted) AND catches the seven non-whitespace
242/// terminators the pre-lift check ignored:
243/// [`Sexp::LIST_OPEN`] `(`, [`Sexp::LIST_CLOSE`] `)`,
244/// [`crate::ast::QuoteForm::QUOTE_LEAD`] `'`,
245/// [`crate::ast::QuoteForm::QUASIQUOTE_LEAD`] `` ` ``,
246/// [`crate::ast::QuoteForm::UNQUOTE_LEAD`] `,`,
247/// [`Atom::STR_DELIMITER`] `"`, [`Sexp::COMMENT_LEAD`] `;` — every
248/// char that would tokenize a KEYWORD like `"def(x"` / `"def;x"` /
249/// `"def\"x"` into TWO tokens, breaking the trait's head-match
250/// structurally. Binds the invariant to the substrate's typed
251/// reader-boundary algebra so a future eighth outer-dispatch
252/// category (e.g. `#|…|#` block-comment lead byte) strengthens the
253/// check ONCE at [`Sexp::is_bare_atom_boundary`] rather than
254/// per-implementor re-derivation.
255///
256/// ## The round-trip theorem
257///
258/// 8. `read(KEYWORD)` produces exactly one form, and that form's
259/// [`Sexp::as_symbol`] projection returns `Some(KEYWORD)`. This is
260/// the STRUCTURAL condition the trait's default
261/// [`TataraDomain::compile_from_sexp`] head-match depends on: the
262/// reader tokenizes the head slot, projects through
263/// [`Atom::from_lexeme`], and the head-match calls `as_symbol()`
264/// on the resulting [`Sexp`]. If the round-trip holds, the
265/// head-match fires on the intended keyword; if it fails, no
266/// other invariant matters. Invariants (6) and (7) together
267/// *entail* this theorem — (6) closes the classifier axis,
268/// (7) closes the tokenizer axis — so a KEYWORD that passes both
269/// structural checks always passes the round-trip; pinning the
270/// theorem explicitly closes the LOOP at the verification site
271/// and catches drift outside the closed-set structural surface
272/// (e.g. a future reader-level input transformation that couldn't
273/// be reduced to either axis).
274///
275/// A hand-written implementor that overrides
276/// [`TataraDomain::compile_from_sexp`] and drifts any of the four gates
277/// from the substrate-wide structural variants surfaces here rather
278/// than as a mystery integration failure downstream — the same posture
279/// [`crate::closed_set::assert_closed_set_well_formed`] takes on the
280/// override-prone `parse_label` / `parse_label_with_hint` /
281/// `labels_joined` axes.
282///
283/// ## Usage
284///
285/// ```ignore
286/// #[test]
287/// fn my_spec_is_well_formed_tatara_domain() {
288/// tatara_lisp::assert_tatara_domain_well_formed::<MySpec>();
289/// }
290/// ```
291///
292/// ## Theory grounding
293///
294/// THEORY.md §II.1 invariant 1 — typed entry; the four structural
295/// gates ARE the typed-entry boundary of the derived-domain idiom, and
296/// the testkit makes their identity load-bearing at the per-implementor
297/// test surface.
298///
299/// THEORY.md §V.1 — knowable platform; the four structural rejections
300/// were previously re-derived per implementor with
301/// `matches!(err, LispError::NotAListForm { ... })` scaffolds. The
302/// testkit collapses the scaffolds onto ONE substrate entry so future
303/// implementors inherit the contract by calling one line — mirrors the
304/// `assert_closed_set_well_formed` posture that closed the closed-set
305/// enum idiom's 36+ per-implementor test modules onto ONE checker.
306///
307/// THEORY.md §VI.1 — generation over composition; the four gate
308/// primitives ([`not_a_list_form_err`], [`missing_head_err`],
309/// [`head_mismatch`]) already compose the structural rejections at the
310/// GENERATION site. This testkit closes the LOOP at the VERIFICATION
311/// site so the two ends of the substrate meet at ONE structural
312/// witness — every implementor's test module inherits both halves
313/// through ONE call rather than restating the four `matches!` arms
314/// per-implementor.
315#[track_caller]
316pub fn assert_tatara_domain_well_formed<T>()
317where
318 T: TataraDomain,
319{
320 let type_name = core::any::type_name::<T>();
321 let keyword = T::KEYWORD;
322
323 // (1) — KEYWORD is non-empty. A keyword-less form has no head
324 // symbol for the dispatch to key on; the trait's contract is
325 // structurally degenerate without a discriminating lexeme.
326 assert!(
327 !keyword.is_empty(),
328 "{type_name}: TataraDomain::KEYWORD is empty — the head symbol has no lexeme to dispatch on",
329 );
330
331 // (2) — KEYWORD classifies as `Atom::Symbol` through the substrate's
332 // typed-entry classifier `Atom::from_lexeme`. The reader routes every
333 // bare-atom lexeme through this ONE projection; anything that decodes
334 // as `Bool` / `Keyword` / `Int` / `Float` / `Str` never reaches the
335 // trait's head-match as a symbol. Subsumes the pre-lift "no leading
336 // ASCII digit" heuristic (`"42"` → `Int`, `"1.5"` → `Float`) AND
337 // catches the two shapes the pre-lift check silently accepted:
338 // `":foo"` → `Keyword` and `"#t"` / `"#f"` → `Bool`. Binding to the
339 // substrate's classifier means a future seventh `Atom` variant
340 // (`Char`, `Bigint`) strengthens the check ONCE.
341 match Atom::from_lexeme(keyword) {
342 Atom::Symbol(s) if s == keyword => {}
343 classified => panic!(
344 "{type_name}: KEYWORD {keyword:?} classifies as {classified:?} via Atom::from_lexeme — the Lisp reader would not project the head as a symbol at the trait's head-match",
345 ),
346 }
347
348 // (3) — KEYWORD contains no `Sexp::is_bare_atom_boundary` char. The
349 // substrate's typed reader-boundary projection covers BOTH the
350 // Unicode-whitespace surface (via `char::is_whitespace`) AND the
351 // seven non-whitespace terminators (`(` `)` `'` `` ` `` `,` `"` `;`)
352 // that would tokenize the KEYWORD into two tokens, breaking the
353 // trait's head-match structurally. Subsumes the pre-lift
354 // "no ASCII whitespace" heuristic; binding to the substrate's typed
355 // reader-boundary algebra means a future eighth outer-dispatch
356 // category (`#|` block-comment lead) strengthens the check ONCE.
357 if let Some(ch) = keyword.chars().find(|&c| Sexp::is_bare_atom_boundary(c)) {
358 panic!(
359 "{type_name}: KEYWORD {keyword:?} contains reader-boundary char {ch:?} (Sexp::is_bare_atom_boundary → true) — the Lisp reader would split it into multiple tokens, breaking the head-match structurally",
360 );
361 }
362
363 // (4) — a bare-atom form rejects with `NotAListForm { keyword }`.
364 // The typed-entry gate rejects the form-shape mismatch before
365 // descending into the list's head; the variant carries the keyword
366 // as structural data so authoring surfaces bind on
367 // `LispError::NotAListForm { keyword }` rather than substring-
368 // parsing the rendered message.
369 let bare_atom = Sexp::int(0);
370 match T::compile_from_sexp(&bare_atom) {
371 Err(LispError::NotAListForm { keyword: k }) => assert_eq!(
372 k, keyword,
373 "{type_name}: NotAListForm.keyword {k:?} drifted from T::KEYWORD {keyword:?}",
374 ),
375 Ok(_) => panic!(
376 "{type_name}: compile_from_sexp accepted a bare-atom form — the typed-entry gate would let a non-list form silently reach the kwargs decoder",
377 ),
378 Err(other) => panic!(
379 "{type_name}: compile_from_sexp on a bare-atom form emitted {other:?}, expected LispError::NotAListForm {{ keyword: {keyword:?} }}",
380 ),
381 }
382
383 // (5) — the empty list `()` rejects with
384 // `MissingHeadSymbol { keyword, got: None }`. `list.first()`
385 // returns `None`, so no head-witness is threaded through the
386 // rejection — the `got: None` arm names the empty-list sub-mode
387 // structurally.
388 let empty_list = Sexp::List(Vec::new());
389 match T::compile_from_sexp(&empty_list) {
390 Err(LispError::MissingHeadSymbol {
391 keyword: k,
392 got: None,
393 }) => assert_eq!(
394 k, keyword,
395 "{type_name}: MissingHeadSymbol.keyword {k:?} drifted from T::KEYWORD {keyword:?} on the empty-list arm",
396 ),
397 Ok(_) => panic!(
398 "{type_name}: compile_from_sexp accepted the empty list `()` — the typed-entry gate would let a headless form silently reach the head-match",
399 ),
400 Err(other) => panic!(
401 "{type_name}: compile_from_sexp on the empty list `()` emitted {other:?}, expected LispError::MissingHeadSymbol {{ keyword: {keyword:?}, got: None }}",
402 ),
403 }
404
405 // (6) — a list whose head is a non-symbol atom rejects with
406 // `MissingHeadSymbol { keyword, got: Some(_) }`. The offending
407 // element's typed identity threads through `SexpWitness` into the
408 // `got` slot so authoring surfaces can render "your form's head
409 // is `0`, an int, not a symbol" without re-parsing the source.
410 let non_symbol_head = Sexp::List(vec![Sexp::int(0)]);
411 match T::compile_from_sexp(&non_symbol_head) {
412 Err(LispError::MissingHeadSymbol {
413 keyword: k,
414 got: Some(_),
415 }) => assert_eq!(
416 k, keyword,
417 "{type_name}: MissingHeadSymbol.keyword {k:?} drifted from T::KEYWORD {keyword:?} on the non-symbol-head arm",
418 ),
419 Ok(_) => panic!(
420 "{type_name}: compile_from_sexp accepted a form with a non-symbol head — the typed-entry gate would let a numeric-head form silently reach the head-match",
421 ),
422 Err(other) => panic!(
423 "{type_name}: compile_from_sexp on a non-symbol-head form emitted {other:?}, expected LispError::MissingHeadSymbol {{ keyword: {keyword:?}, got: Some(_) }}",
424 ),
425 }
426
427 // (7) — a symbol-headed list whose head is NOT `T::KEYWORD`
428 // rejects with `HeadMismatch { keyword, got }`. The probe symbol
429 // is chosen to be lexically distinct from every conceivable
430 // canonical keyword across the substrate so no real implementor
431 // can accidentally match it. A hard equality assertion rules out
432 // the degenerate case where an implementor's KEYWORD collides
433 // with the reserved probe.
434 let probe = "__assert_tatara_domain_well_formed_probe__";
435 assert_ne!(
436 keyword, probe,
437 "{type_name}: T::KEYWORD collides with the reserved probe {probe:?} — the wrong-head arm cannot rule out an implementor whose KEYWORD equals the probe; rename either side",
438 );
439 let wrong_head = Sexp::List(vec![Sexp::symbol(probe)]);
440 match T::compile_from_sexp(&wrong_head) {
441 Err(LispError::HeadMismatch { keyword: k, got }) => {
442 assert_eq!(
443 k, keyword,
444 "{type_name}: HeadMismatch.keyword {k:?} drifted from T::KEYWORD {keyword:?}",
445 );
446 assert_eq!(
447 got, probe,
448 "{type_name}: HeadMismatch.got {got:?} drifted from the offending head {probe:?}",
449 );
450 }
451 Ok(_) => panic!(
452 "{type_name}: compile_from_sexp accepted a form headed by the reserved probe {probe:?} — the typed-entry gate would let a wrong-head form silently reach the kwargs decoder",
453 ),
454 Err(other) => panic!(
455 "{type_name}: compile_from_sexp on the wrong-head form emitted {other:?}, expected LispError::HeadMismatch {{ keyword: {keyword:?}, got: {probe:?} }}",
456 ),
457 }
458
459 // (8) — reader round-trip theorem. `read(KEYWORD)` produces exactly
460 // one form, and that form's `as_symbol()` projection returns
461 // `Some(KEYWORD)`. This is the SUFFICIENT condition invariants
462 // (6) + (7) together entail: (6) closes the classifier axis (the
463 // token, once assembled, classifies as `Atom::Symbol`), (7) closes
464 // the tokenizer axis (the KEYWORD arrives at the classifier as ONE
465 // token). Pinning the composition explicitly closes the LOOP at
466 // the verification site — a substrate-owned theorem the two
467 // structural checks compose into — and catches drift outside the
468 // closed-set structural surface (e.g. a future reader-level input
469 // transformation that couldn't be reduced to either axis).
470 match crate::reader::read(keyword) {
471 Ok(forms) if forms.len() == 1 && forms[0].as_symbol() == Some(keyword) => {}
472 Ok(forms) => panic!(
473 "{type_name}: KEYWORD {keyword:?} did not round-trip through read → as_symbol — read produced {forms:?} (expected one form projecting to Some({keyword:?}))",
474 ),
475 Err(err) => panic!(
476 "{type_name}: KEYWORD {keyword:?} failed to tokenize at all — read returned {err:?}",
477 ),
478 }
479}
480
481// ── kwarg parsing + typed extractors used by the derive macro ──────
482
483pub type Kwargs<'a> = HashMap<String, &'a Sexp>;
484
485/// Parse `:k v :k v …` into a kwargs map. Rejects duplicate keywords so the
486/// typed-entry gate fires on `(defX :name "a" :name "b")` instead of silently
487/// keeping the last value — same posture `reject_unknown_kwargs` takes for
488/// typo'd kwargs. A duplicate is ill-typed input: the author either meant
489/// distinct keys (typo) or a list (`:tags ("a" "b")`).
490///
491/// Odd-length kwargs lists fail with `LispError::OddKwargs { dangling }`,
492/// where `dangling` is the offending element's `Sexp::Display` projection
493/// — `:query` for a keyword whose value got lost, or the literal form of a
494/// stray non-keyword. Naming the dangling element keeps the diagnostic
495/// structurally complete instead of merely flagging "odd number"; authoring
496/// surfaces (REPL, LSP, `tatara-check`) render the mismatch without
497/// re-reading the source.
498///
499/// Theory anchor: THEORY.md §II.1 invariant 1 — "Typed entry. Ill-typed input
500/// errors before the value exists." THEORY.md §V.1 — "knowable platform"
501/// requires the diagnostic to name what was passed, not only what was
502/// expected.
503pub fn parse_kwargs(args: &[Sexp]) -> Result<Kwargs<'_>> {
504 let mut kw = HashMap::new();
505 let mut i = 0;
506 while i + 1 < args.len() {
507 let key = args[i].as_keyword().ok_or_else(|| {
508 type_mismatch(kwargs_pos_form(i), ExpectedKwargShape::Keyword, &args[i])
509 })?;
510 if kw.insert(key.to_string(), &args[i + 1]).is_some() {
511 return Err(duplicate_kwarg(key));
512 }
513 i += 2;
514 }
515 if i < args.len() {
516 return Err(LispError::OddKwargs {
517 dangling: args[i].to_string(),
518 });
519 }
520 Ok(kw)
521}
522
523/// Reject any keyword in `kw` that isn't in `allowed`. Closes the typed-entry
524/// hole where typos like `:tthreshold 0.99` would otherwise parse silently
525/// with the field unset. Emitted by `#[derive(TataraDomain)]` after
526/// `parse_kwargs` so every derived domain rejects unknown kwargs by default.
527///
528/// When the offending keyword is a near-miss of an allowed kwarg (bounded
529/// edit distance via `suggest`), the diagnostic prepends a `did you mean
530/// :X?` hint so the operator goes straight to the fix without scanning the
531/// allowed-list. The hint is purely additive — `unknown keyword` and the
532/// full allowed list still appear — so existing assertions
533/// (`msg.contains("unknown keyword")`, `msg.contains(":threshold")`) pass
534/// unchanged.
535///
536/// Returns the structural `LispError::UnknownKwarg { key, hint, allowed }`
537/// variant — same posture as the `OddKwargs` / `DuplicateKwarg` /
538/// `MissingKwarg` siblings. After this lift every distinct typed-entry
539/// kwarg-gate failure mode binds to ONE structural variant of `LispError`,
540/// not a `Compile`-shaped substring.
541///
542/// Theory anchor: THEORY.md §II.1 invariant 1 (typed entry — "Ill-typed input
543/// errors before the value exists"); §V.1 ("knowable platform … Render
544/// Anywhere" — naming the likely intended keyword is the floor of a
545/// constructive diagnostic).
546pub fn reject_unknown_kwargs(kw: &Kwargs<'_>, allowed: &[&str]) -> Result<()> {
547 for key in kw.keys() {
548 if !allowed.contains(&key.as_str()) {
549 return Err(unknown_kwarg(key, allowed));
550 }
551 }
552 Ok(())
553}
554
555/// Parse `:k v :k v …` AND gate the result against a closed allowed-key set —
556/// the fused typed-entry kwargs gate. ONE named primitive every
557/// `TataraDomain` impl shares for "compile-from-args header": every
558/// `#[derive(TataraDomain)]`-generated `compile_from_args` body emitted by
559/// `tatara-lisp-derive` begins with this single call, and every hand-
560/// written impl in the forge / lattice / tameshi crates that wants the
561/// substrate's closed-set kwargs posture binds to ONE function instead of
562/// remembering to call [`parse_kwargs`] AND [`reject_unknown_kwargs`] in
563/// that order.
564///
565/// Before this lift the derive emitted the two-call sequence
566/// `let kw = parse_kwargs(args)?; reject_unknown_kwargs(&kw, ALLOWED)?;`
567/// verbatim at every consumer's `compile_from_args` body — well past the
568/// ≥2 PRIME-DIRECTIVE trigger once the fleet's seven-plus
569/// `#[derive(TataraDomain)]` consumers (ProcessSpec, EphemeralSpec,
570/// MonitorSpec, NotifySpec, AlertPolicySpec, EscalationStep, CompilerSpec,
571/// and every future derived domain) inline the same two lines through the
572/// proc-macro emitter. The two-call sequence is structurally one
573/// operation — "parse the keyword/value run, then assert every key sits
574/// in the static allowed-set" — and a regression that drifts ONE
575/// consumer's gate from the others (e.g. the derive emits one call but a
576/// hand-written impl emits only the other, or a future emitter swaps the
577/// order so `reject_unknown_kwargs` runs against an unparsed slice) is
578/// the silent typed-entry hole this primitive closes by construction.
579///
580/// The two stages are composed in the canonical order:
581/// 1. [`parse_kwargs`] runs first — odd-length input, non-keyword at a
582/// key position, and duplicate keys surface as their structural
583/// variants ([`LispError::OddKwargs`] / [`LispError::TypeMismatch`]
584/// with `form = kwargs_pos_form(i)` / [`LispError::DuplicateKwarg`]).
585/// 2. Only on `Ok(kw)` does [`reject_unknown_kwargs`] run — keys
586/// outside `allowed` surface as [`LispError::UnknownKwarg`] with the
587/// typed `hint` / `allowed` slots populated.
588///
589/// This ordering is structural: `reject_unknown_kwargs` cannot inspect
590/// an unparsed `&[Sexp]`, so parse-stage rejection MUST precede
591/// reject-stage rejection. A call with BOTH an odd-length tail AND an
592/// unknown kwarg surfaces as `OddKwargs` (parse-stage), never as
593/// `UnknownKwarg` (reject-stage) — the gate is single-pass and the
594/// stages compose in exactly one order. Naming the composition makes
595/// that order load-bearing data on the substrate, not a discipline the
596/// derive's emit template happens to encode correctly.
597///
598/// Theory anchor: THEORY.md §II.1 invariant 1 — "Typed entry. Ill-typed
599/// input errors before the value exists." The kwargs gate is the
600/// typed-entry boundary for every derived domain; closing the gate
601/// behind ONE primitive lifts the closed-set posture from the derive's
602/// emit template to the substrate's typed surface. THEORY.md §VI.1 —
603/// generation over composition; the two-call sequence in the derive's
604/// emit template, multiplied across every consumer in the fleet, is
605/// well past the three-times rule once the structural shape is named.
606/// THEORY.md §V.1 — knowable platform; authoring tools (REPL, LSP,
607/// `tatara-check`) that want to surface "this form's kwargs gate
608/// rejected because …" bind to the unified primitive's call site
609/// instead of guessing which of the two component functions the
610/// rejection came from. THEORY.md §II.1 invariant 2 (free middle) —
611/// every consumer routes through the SAME composition, so a regression
612/// that drifts the order or skips a stage on one path can never reach
613/// the substrate's runtime: the type system binds every consumer to
614/// the fused primitive's single emission shape.
615///
616/// Lifetime: the returned [`Kwargs<'a>`] borrows from `args` (the typed
617/// alias is `HashMap<String, &'a Sexp>`), so the call site keeps the
618/// `&[Sexp]` slice alive for the lifetime of the parsed map — same
619/// posture as [`parse_kwargs`]. The fused primitive does not allocate
620/// beyond [`parse_kwargs`]'s map: [`reject_unknown_kwargs`] is a pure
621/// `O(allowed.len() · kw.len())` scan that returns `Ok(())` on success.
622pub fn parse_kwargs_strict<'a>(args: &'a [Sexp], allowed: &[&str]) -> Result<Kwargs<'a>> {
623 let kw = parse_kwargs(args)?;
624 reject_unknown_kwargs(&kw, allowed)?;
625 Ok(kw)
626}
627
628/// Structural unknown-kwarg builder. Returns the dedicated
629/// `LispError::UnknownKwarg` variant so authoring surfaces (REPL, LSP,
630/// `tatara-check`) bind to first-class `key` / `hint` / `allowed`
631/// fields instead of substring-parsing the rendered message. Display
632/// matches the legacy `Compile { form: kwarg_form(key), message:
633/// "unknown keyword (...)" }` rendering byte-for-byte
634/// (`"compile error in :{key}: unknown keyword (did you mean :{hint}?;
635/// allowed: :a, :b, :c)"` with a hint, `"compile error in :{key}:
636/// unknown keyword (allowed: :a, :b, :c)"` without), so existing
637/// `msg.contains("unknown keyword")` / `msg.contains(":threshold")` /
638/// `msg.contains("did you mean :threshold?")` assertions keep
639/// passing.
640///
641/// Encapsulates the three otherwise-inline steps every unknown-kwarg
642/// site shares: (1) ranking the near-miss via `suggest`, (2) sorting
643/// the allowed-set lexicographically so two operators on two machines
644/// see the same message for the same input — diagnostics are
645/// deterministic, (3) materializing the allowed-set as owned
646/// `Vec<String>` so the variant lives independent of the call frame
647/// and crosses thread boundaries cleanly. A future "registry-aware
648/// near-miss for unknown registry-dispatched forms" path
649/// (`tatara-check`'s unknown-keyword fallthrough) binds to this
650/// helper rather than re-formatting the shape per call site.
651///
652/// `reject_unknown_kwargs` is the first consumer; hand-written
653/// `TataraDomain` impls in the forge / lattice / tameshi crates that
654/// don't fit the derive's closed-field-type set bind to the
655/// substrate's primitive instead of inline `LispError::Compile { … }`
656/// assembly. After this lift `reject_unknown_kwargs` is no longer the
657/// last `LispError::Compile { ... }` site in the kwarg-gate's
658/// diagnostic surface — every distinct kwarg-gate failure mode is now
659/// a structural variant of `LispError`.
660///
661/// Theory anchor: THEORY.md §V.1 — "Knowable platform … Render
662/// Anywhere." A diagnostic whose offending `key` / hint / allowed-set
663/// are embedded in a free-form message is structurally incomplete; an
664/// authoring surface that wants to render a squiggly under the typo
665/// or surface the allowed-set as completions must re-parse the
666/// message. After this lift the slots exist in the variant's data
667/// shape itself. THEORY.md §II.1 invariant 1 (typed entry) — an
668/// unknown kwarg is exactly the failure mode the typed-entry gate
669/// exists to reject; naming it structurally is the typed posture for
670/// that gate's diagnostic. THEORY.md §VI.1 (generation over
671/// composition — one named primitive per structural shape).
672#[must_use]
673pub fn unknown_kwarg(key: &str, allowed: &[&str]) -> LispError {
674 let hint = suggest(key, allowed).map(String::from);
675 let mut sorted: Vec<String> = allowed.iter().map(|s| (*s).to_string()).collect();
676 sorted.sort();
677 LispError::UnknownKwarg {
678 key: key.to_string(),
679 hint,
680 allowed: sorted,
681 }
682}
683
684/// The typed-entry kwargs-gate's OPTIONAL lookup primitive — `Some(&Sexp)`
685/// when `key` is present in `kw`, `None` when absent. ONE named projection
686/// on the substrate's `Kwargs<'a>` algebra every optional-kwarg consumer
687/// (`extract_optional_atom`, `extract_list`, `extract_optional_via_serde`)
688/// routes through, and the sibling [`required`](self::required) composes
689/// directly atop it as `optional(kw, key).ok_or_else(|| missing_kwarg(key))`.
690/// Before this lift the same `kw.get(key).copied()` projection — turning
691/// `Option<&&'a Sexp>` (the raw `HashMap::get` return) into the consumer-
692/// shaped `Option<&'a Sexp>` — was inlined verbatim at FOUR sites: once
693/// inside `required`'s composition, and once inside each of the three
694/// optional consumers' absence-handling preludes. After this lift the
695/// projection lives in ONE place; `required` becomes the closed-form
696/// composition `optional + ok_or_else(missing_kwarg)`, and the three
697/// optional consumers read through `optional(kw, key)` without re-stating
698/// the `Option<&&Sexp>` → `Option<&Sexp>` projection at each call site.
699///
700/// Sibling pair with [`required`](self::required): together the two close
701/// the substrate's typed-entry kwargs-LOOKUP surface — `required` is the
702/// mandatory-presence path returning `Result<&Sexp>` (absence → typed
703/// `LispError::MissingKwarg`); `optional` is the may-be-absent path
704/// returning `Option<&Sexp>` (absence → `None`, the consumer decides
705/// what default behavior absence triggers — `None` for atoms, empty `Vec`
706/// for lists, `Sexp::Nil` for params). The TWO primitives between them
707/// cover every consumer's kwargs-lookup posture; a third would be a
708/// structural extension the type system would surface at every call site.
709/// The composition `required = optional + ok_or_else(missing_kwarg)` is
710/// the structural identity binding the two — `required(kw, key)` and
711/// `optional(kw, key).ok_or_else(|| missing_kwarg(key))` are
712/// observationally identical, and naming the composition makes the
713/// identity a substrate-owned theorem rather than a hand-inlined
714/// duplication discipline four sites had to keep in lockstep.
715///
716/// The returned `&'a Sexp` carries the SAME lifetime contract as
717/// [`required`](self::required)'s `Ok(&'a Sexp)` — the projection borrows
718/// from the kwargs map's value slot via `.copied()`, so the optional
719/// consumers can hold the reference through their absence-arm match
720/// without an intermediate clone. `'a` is the outer borrow lifetime
721/// (mirroring `required`); the inner `'_` is free so call sites with
722/// `Kwargs<'a>` (the typical `parse_kwargs` output binding) and
723/// `Kwargs<'static>` (a future static-bound shape) both type-check
724/// uniformly.
725///
726/// Theory anchor: THEORY.md §VI.1 — generation over composition; four
727/// inline copies of one structural projection past the three-times rule
728/// once the structural shape is named. THEORY.md §V.1 — knowable
729/// platform; the substrate's typed-entry kwargs-lookup surface is now
730/// the named PAIR `{required, optional}` — authoring tools (REPL, LSP,
731/// `tatara-check`) that want to surface "this domain reads kwarg X as
732/// optional" bind to the `optional` primitive's signature, not the
733/// HashMap-level `get` chain. THEORY.md §II.1 invariant 1 — typed entry;
734/// the kwargs-lookup gate's two postures (required vs. optional) are
735/// now structurally named, so a future fourth posture (e.g. "required
736/// with non-empty constraint") extends the pair as a peer rather than
737/// silently piggybacking on the inlined `get(key).copied()` chain.
738/// THEORY.md §II.1 invariant 2 — free middle; the typed-entry kwargs
739/// gate's lookup shape is uniform across every derived domain (and
740/// every hand-written `TataraDomain` impl), so a future emitter that
741/// wants to instrument the lookup (a span-aware lookup, a debug-mode
742/// lookup logger) wraps ONE function rather than four inline sites.
743#[must_use]
744pub fn optional<'a>(kw: &'a Kwargs<'_>, key: &str) -> Option<&'a Sexp> {
745 kw.get(key).copied()
746}
747
748/// The typed-entry kwargs-gate's REQUIRED lookup primitive — `Ok(&Sexp)`
749/// when `key` is present in `kw`, `Err(LispError::MissingKwarg)` when
750/// absent. Composes [`optional`](self::optional) (the may-be-absent
751/// lookup) with [`missing_kwarg`](self::missing_kwarg) (the canonical
752/// rejection on absence) so the substrate's typed-entry kwargs-lookup
753/// surface is named as the PAIR `{required, optional}` with `required`
754/// expressed as the closed-form composition of its two sibling
755/// primitives. Sibling pair documented in [`optional`](self::optional).
756pub fn required<'a>(kw: &'a Kwargs<'_>, key: &str) -> Result<&'a Sexp> {
757 optional(kw, key).ok_or_else(|| missing_kwarg(key))
758}
759
760/// Canonical typed `form:` value for a kwarg-level `LispError::TypeMismatch`.
761/// Every typed-entry diagnostic that names a kwarg (`required`, `type_err`,
762/// `deserialize_err`, the duplicate-keyword paths in `parse_kwargs` and
763/// `sexp_to_json`, the unknown-keyword path in `reject_unknown_kwargs`,
764/// the non-list path in `extract_vec_via_serde`) routes through this one
765/// helper, so authoring surfaces (REPL, LSP, `tatara-check`) bind to a
766/// single named primitive rather than seven inline `format!(":{key}")`
767/// copies.
768///
769/// Returns the typed `crate::error::KwargPath::Named(key.to_string())` value
770/// directly — consumers feed it into `LispError::TypeMismatch.form: KwargPath`
771/// where it is structurally bound via pattern-match (`KwargPath::Named(_)`),
772/// not substring-matched. The canonical `:<key>` literal lives in ONE place
773/// (`KwargPath`'s Display match arm) alongside its sibling shapes
774/// `kwarg_item_form` / `kwargs_pos_form`, so a typo in any of the three
775/// can never drift independent of the others.
776///
777/// Theory anchor: THEORY.md §VI.1 — "Generation over composition.
778/// Three-times rule: when a pattern repeats three times, extract an
779/// archetype/backend/synthesizer and generate from it." Seven inline
780/// copies in one module is the textbook signal. THEORY.md §V.1 —
781/// knowable platform; the typed `KwargPath` enum encodes the closed set
782/// of three reachable path shapes at the type level so authoring tools
783/// bind to path-shape identity rather than substring-matching the
784/// rendered prefix. THEORY.md §II.1 invariant 1 (typed entry) — the
785/// kwargs-path identity is now load-bearing data on the variant rather
786/// than a projection-to-String.
787#[must_use]
788pub fn kwarg_form(key: &str) -> crate::error::KwargPath {
789 crate::error::KwargPath::named(key)
790}
791
792/// Canonical `form:` label for a failure inside the Nth item of a
793/// list-typed kwarg — `:steps[1]` when the second item of `:steps` fails
794/// to deserialize, `:tags[2]` when the third tag isn't a string. The
795/// substrate names the item-path so the operator sees both *which kwarg*
796/// and *which element* misfired without re-counting from the source.
797///
798/// Frontier inspiration: JSON Pointer (`/steps/1`) and jq path
799/// expressions — lossless paths through value projections so downstream
800/// tooling (LSP underlines, structural rewrites) bind to the path
801/// instead of parsing the diagnostic message. Translation through
802/// pleme-io primitives: the surface syntax authors already write
803/// (`:<key>` + `[idx]`), no new error variant, no new IR layer. When a
804/// future run gives `Sexp` source spans, the indexed form gains a
805/// position the same way `kwarg_form` will — one helper, every consumer
806/// inherits.
807///
808/// Theory anchor: THEORY.md §V.1 — "Knowable platform … Render
809/// Anywhere." A diagnostic that names the kwarg but loses the item index
810/// is structurally incomplete; the path completes it.
811///
812/// Returns the typed `crate::error::KwargPath::Item { key, idx }` value
813/// directly — consumers feed it into `LispError::TypeMismatch.form: KwargPath`
814/// where it is structurally bound via pattern-match (`KwargPath::Item { .. }`),
815/// not substring-matched. The canonical `:<key>[<idx>]` literal lives in ONE
816/// place alongside `kwarg_form` / `kwargs_pos_form`. See `kwarg_form` for the
817/// typed-enum's role.
818#[must_use]
819pub fn kwarg_item_form(key: &str, idx: usize) -> crate::error::KwargPath {
820 crate::error::KwargPath::item(key, idx)
821}
822
823/// Canonical `form:` label for a kwargs-list slot whose key position is
824/// not yet known — the slot itself failed the
825/// "this-position-must-be-a-keyword" gate, so there is no `:<key>` to
826/// hang the path off. Renders `kwargs[<idx>]` — parallel to
827/// `kwarg_item_form`'s `:<key>[<idx>]` shape, rooted at the kwargs
828/// slice rather than at a named kwarg.
829///
830/// Used by `parse_kwargs` to label the structural type-mismatch when
831/// the element at an even position isn't a `Sexp::Atom(Keyword(_))`.
832/// Pairing this label with the existing `LispError::TypeMismatch`
833/// variant (`expected: "keyword"`, `got: sexp_type_name(_)`) means
834/// authoring surfaces (REPL, LSP, `tatara-check`) bind to ONE variant
835/// identity for every typed-entry mismatch — `:<key>` for kwarg-level
836/// failures, `:<key>[<idx>]` for per-item failures, and now
837/// `kwargs[<idx>]` for not-a-keyword-yet failures. When a future run
838/// gives `Sexp` source spans, the slot-form gains a position the same
839/// way `kwarg_form` / `kwarg_item_form` will — one helper, every
840/// consumer inherits.
841///
842/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
843/// fourth `form:`-label primitive after `kwarg_form`,
844/// `kwarg_item_form`, and the registry-keyword path; one helper per
845/// distinct path shape so the substrate's diagnostic surface stays
846/// structurally complete).
847///
848/// Returns the typed `crate::error::KwargPath::Slot(idx)` value directly —
849/// consumers feed it into `LispError::TypeMismatch.form: KwargPath` where it
850/// is structurally bound via pattern-match (`KwargPath::Slot(_)`), not
851/// substring-matched. The canonical `kwargs[<idx>]` literal lives in ONE
852/// place alongside `kwarg_form` / `kwarg_item_form`. See `kwarg_form` for
853/// the typed-enum's role.
854#[must_use]
855pub fn kwargs_pos_form(idx: usize) -> crate::error::KwargPath {
856 crate::error::KwargPath::Slot(idx)
857}
858
859/// Typed projection of a `Sexp`'s outermost shape into the closed-set
860/// `SexpShape` enum — the twelve reachable shapes the reader can produce.
861/// Used by the typed extractors to thread the observed shape into
862/// `LispError::TypeMismatch.got: SexpShape` /
863/// `LispError::NamedFormNonSymbolName.got: SexpShape` so a typed-entry
864/// gate's rejection-shape identity is load-bearing data in the type
865/// system, not a `&'static str` projection at the helper boundary.
866/// Consumers (REPL, LSP, `tatara-check`) pattern-match on
867/// `SexpShape::Int` etc. directly rather than substring-matching the
868/// rendered `got` literal.
869///
870/// Theory anchor: THEORY.md §V.1 — knowable platform. An error that names
871/// only the expected side leaves the operator to guess what was passed;
872/// naming both is the floor of constructive diagnostics. The typed
873/// projection extends that posture: not just naming both sides, but
874/// encoding the observed shape's identity as a TYPE so a regression that
875/// drifts the label becomes a compile error, not a runtime substring
876/// drift. When a future run gives `Sexp` source spans, this helper is
877/// the single site that learns to thread `got Y at <pos>`; today's call
878/// sites pick up the span automatically.
879/// Free-function delegate to the [`Sexp::shape`] inherent method on the
880/// `Sexp` algebra. Retained for backwards compatibility with consumers
881/// that import this helper by name (no callers reach in through the
882/// module path post-lift); the inherent method is the canonical site
883/// for the (Sexp variant, SexpShape variant) projection family —
884/// `Atom::kind().sexp_shape()` (atomic axis), `as_quote_form().map(|(qf,
885/// _)| qf.sexp_shape())` (quote-family axis), with `Nil` / `List`
886/// arms projecting to their own `SexpShape` variants directly. See
887/// [`Sexp::shape`]'s docstring for the closed-set composition law and
888/// the THEORY anchors.
889#[must_use]
890pub fn sexp_shape(s: &Sexp) -> SexpShape {
891 s.shape()
892}
893
894/// Thin delegate to [`Sexp::type_name`] retained for callers that
895/// want the free-function reach — the canonical site is now the
896/// inherent method on the [`Sexp`] algebra. Stable, human-readable
897/// name of a `Sexp`'s outermost shape — the `&'static str`
898/// projection of `s.shape().label()`. Retained for callers that
899/// want the canonical literal directly (e.g. test assertions on the
900/// rendered `expected X, got Y` substring); new code constructing
901/// `LispError::TypeMismatch` / `NamedFormNonSymbolName` passes
902/// through `sexp_shape` directly so the typed identity rides the
903/// variant slot rather than collapsing through the literal at the
904/// helper boundary.
905///
906/// Composition law: `sexp_type_name(s) == s.type_name() ==
907/// s.shape().label()` for every `s: &Sexp`. Pre-lift the dispatcher
908/// lived here as the canonical site; post-lift the inherent method
909/// [`Sexp::type_name`] is the canonical site and this free function
910/// delegates so existing callers continue to compile. Same lift
911/// posture as [`super::domain::sexp_shape`] → [`Sexp::shape`]
912/// (commit 121bb60), [`super::domain::sexp_witness`] →
913/// [`Sexp::witness`] (commit a427e3b), [`super::domain::sexp_to_json`]
914/// → [`Sexp::to_json`] (commit 875ee3b), and
915/// [`super::domain::json_to_sexp`] → [`Sexp::from_json`] (commit
916/// 4a467eb): the algebra-level projection sits on the value, the
917/// free function is a one-line thin delegate. The
918/// `LispError::TypeMismatch.got` projection at
919/// `compile::compile_typed`'s typed-entry rejection site and every
920/// legacy substring-grep rejection-message test routes through
921/// `s.type_name()` after this lift.
922///
923/// Sibling of [`sexp_shape`] (the typed-shape projection feeding
924/// `TypeMismatch.expected` typed slot) and [`sexp_witness`] (the
925/// joint typed-shape + renderable-literal projection feeding
926/// `NamedFormNonSymbolName.got` / `NonSymbolUnquoteTarget.got` /
927/// etc.). [`Sexp::type_name`] is the canonical-label-only
928/// projection — the `&'static str` literal flattened from the
929/// typed identity for substring-grep callers and the
930/// `TypeMismatch.got` slot.
931///
932/// Theory anchor: THEORY.md §V.1 — knowable platform / constructive
933/// diagnostics. The canonical-label projection becomes a NAMED
934/// primitive on the substrate's `Sexp` algebra rather than a free
935/// function consumers reach across module boundaries to call.
936/// THEORY.md §VI.1 — generation over composition; the projection now
937/// lives on the typed `Sexp` algebra alongside `Sexp::shape` /
938/// `Sexp::witness` / `Sexp::to_json` / `Sexp::from_json`, so a
939/// future `Sexp` variant lands at the algebra's match site (via
940/// `Sexp::shape`'s exhaustive arm) without a module-path
941/// indirection. THEORY.md §II.1 invariant 1 — typed entry; the
942/// offending Sexp's canonical-label identity is part of the proof
943/// of WHAT the typed-entry gate rejected.
944#[must_use]
945pub fn sexp_type_name(s: &Sexp) -> &'static str {
946 s.type_name()
947}
948
949/// Thin delegate to [`Sexp::witness`] retained for callers that want
950/// the free-function reach — the canonical site is now the inherent
951/// method on the [`Sexp`] algebra. Pairs the typed [`SexpShape`]
952/// (structural identity) with the renderable [`Sexp::Display`]
953/// projection in ONE owned [`SexpWitness`] value so the variant lives
954/// independent of the call frame and crosses thread boundaries
955/// cleanly.
956///
957/// Composition law: `sexp_witness(s) == s.witness()` for every
958/// `s: &Sexp`. Pre-lift the dispatcher lived here as the canonical
959/// site; post-lift the inherent method [`Sexp::witness`] is the
960/// canonical site and this free function delegates so existing
961/// callers continue to compile. Same lift posture as
962/// [`super::domain::sexp_shape`] → [`Sexp::shape`] (commit 121bb60):
963/// the algebra-level projection sits on the value, the free
964/// function is a one-line thin delegate. The 8 typed-entry
965/// rejection-builder callers in `macro_expand.rs`
966/// (`non_symbol_unquote_target`, `splice_outside_list`,
967/// `non_symbol_param`, `rest_param_missing_name`,
968/// `rest_param_trailing_tokens`, `optional_param_malformed`,
969/// `defmacro_non_symbol_name`, `defmacro_non_list_params`), the
970/// `missing_head_err` invocation in the `TataraDomain` blanket impl
971/// at line 46, and the typed-exit `rewriter_non_list_err` builder
972/// all route through `s.witness()` after this lift.
973///
974/// Sibling of [`sexp_shape`] (the shape-only projection feeding
975/// `TypeMismatch.got` / `NamedFormNonSymbolName.got`) and
976/// [`sexp_type_name`] (the `&'static str`-only projection feeding
977/// legacy substring-grep consumers). [`Sexp::witness`] is the
978/// typed JOINT projection — both halves of the identity bundled
979/// into ONE owned `SexpWitness` value.
980///
981/// Theory anchor: THEORY.md §V.1 — knowable platform / constructive
982/// diagnostics. An error that names only the shape leaves the operator
983/// to guess what they wrote; an error that names only the literal
984/// withholds the structural identity tools want to pattern-match on.
985/// The witness names both. THEORY.md §VI.1 — generation over
986/// composition; the projection now lives on the typed `Sexp` algebra
987/// alongside `Sexp::shape`, so a future `Sexp` variant lands at the
988/// algebra's match site (via `Sexp::shape`'s exhaustive arm) without
989/// a module-path indirection. THEORY.md §II.1 invariant 1 — typed
990/// entry; the offending Sexp's identity is part of the proof of WHAT
991/// the typed-entry gate rejected.
992#[must_use]
993pub fn sexp_witness(s: &Sexp) -> SexpWitness {
994 s.witness()
995}
996// ── Near-match suggestion ──────────────────────────────────────────
997//
998// The metric itself lives in `tatara-closed-set`, its only consumer
999// (`ClosedSet::suggest_closest`). It was briefly homed here by phase 2
1000// step 1, which created a `tatara-closed-set → tatara-lisp` edge and so
1001// made the reverse edge — the one step 2 needs, to carry LispError
1002// variants whose payloads are ClosedSet implementors — a cargo cycle.
1003// INVERTed: `suggest` went back to its only call site and this crate
1004// depends on that one instead.
1005//
1006// Re-exported rather than moved-and-forgotten so `tatara_lisp::domain::suggest`
1007// still resolves — A-side parity, and step 3's `unknown_kwarg` /
1008// `suggest_keyword` hints reach the metric through this path.
1009// One primitive, one implementation of edit distance, either way.
1010
1011/// The substrate's bounded edit-distance near-match metric.
1012///
1013/// Defined in [`tatara_closed_set`] (its only consumer) and re-exported
1014/// here so `tatara_lisp::domain::suggest` stays the canonical path.
1015pub use tatara_closed_set::suggest;
1016
1017/// Structural duplicate-kwarg builder. Returns the dedicated
1018/// `LispError::DuplicateKwarg` variant so authoring surfaces (REPL, LSP,
1019/// `tatara-check`) bind to a first-class `key` field instead of
1020/// substring-parsing the rendered message. Display matches the legacy
1021/// `Compile { form: kwarg_form(key), message: "duplicate keyword" }`
1022/// rendering byte-for-byte (`"compile error in :{key}: duplicate
1023/// keyword"`), so existing `msg.contains("duplicate keyword")` /
1024/// `msg.contains(":name")` assertions keep passing.
1025///
1026/// Two inline copies of the same triple — `parse_kwargs`'s top-level
1027/// duplicate-keyword path and `sexp_to_json`'s nested-kwargs duplicate-
1028/// keyword path — used to assemble this shape by hand. One named
1029/// primitive lifts both into the substrate's structural-variant surface,
1030/// so every `parse_kwargs` failure mode (`OddKwargs` for odd length,
1031/// `TypeMismatch` for not-a-keyword-at-position, `DuplicateKwarg` for
1032/// duplicate key) is now a structural variant of `LispError`, not a
1033/// `Compile`-shaped substring.
1034///
1035/// Theory anchor: THEORY.md §V.1 — "Knowable platform … Render
1036/// Anywhere." A diagnostic whose offending `key` is embedded in a
1037/// free-form message is structurally incomplete; an authoring surface
1038/// that wants to render a squiggly under the duplicate or hint a fix
1039/// must re-parse the message. After this lift the slot exists in the
1040/// variant's data shape itself. THEORY.md §II.1 invariant 1 (typed
1041/// entry — "Ill-typed input errors before the value exists") — a
1042/// duplicate kwarg is exactly the failure mode the typed-entry gate
1043/// exists to reject; naming it structurally is the typed posture for
1044/// that gate's diagnostic.
1045#[must_use]
1046pub fn duplicate_kwarg(key: &str) -> LispError {
1047 LispError::DuplicateKwarg {
1048 key: key.to_string(),
1049 }
1050}
1051
1052/// Structural missing-kwarg builder. Returns the dedicated
1053/// `LispError::MissingKwarg` variant so authoring surfaces (REPL, LSP,
1054/// `tatara-check`) bind to a first-class `key` field instead of
1055/// substring-parsing the rendered message. Display matches the legacy
1056/// `Compile { form: kwarg_form(key), message: "required but not
1057/// provided" }` rendering byte-for-byte (`"compile error in :{key}:
1058/// required but not provided"`), so existing
1059/// `msg.contains("required")` / `msg.contains(":threshold")` assertions
1060/// keep passing.
1061///
1062/// `required` (the kwarg lookup helper that fronts every typed
1063/// extractor — `extract_string`, `extract_int`, `extract_float`,
1064/// `extract_bool`, `extract_via_serde`, plus every hand-written
1065/// `TataraDomain` impl in the forge / lattice / tameshi crates) used
1066/// to assemble this shape inline. One named primitive lifts that into
1067/// the substrate's structural-variant surface, so every kwarg-level
1068/// "required-but-absent" failure routes through ONE function instead
1069/// of re-formatting the shape per call site. After this lift every
1070/// distinct `parse_kwargs` + `required` typed-entry kwarg failure mode
1071/// (odd length, not-a-keyword-at-position, duplicate key, missing
1072/// required key) is now a structural variant of `LispError`, not a
1073/// `Compile`-shaped substring.
1074///
1075/// Sibling of the pre-existing `Missing(&'static str)` variant —
1076/// `MissingKwarg` covers the runtime-key path the kwargs extractors
1077/// share (every derive-generated extractor and every hand-written
1078/// `TataraDomain` impl); `Missing` stays for compile-time-known names.
1079///
1080/// Theory anchor: THEORY.md §V.1 — "Knowable platform … Render
1081/// Anywhere." A diagnostic whose offending `key` is embedded in a
1082/// free-form message is structurally incomplete; an authoring surface
1083/// that wants to render a squiggly under the missing kwarg slot or
1084/// render a "did you mean :X?" hint must re-parse the message. After
1085/// this lift the slot exists in the variant's data shape itself.
1086/// THEORY.md §II.1 invariant 1 (typed entry — "Ill-typed input errors
1087/// before the value exists") — a missing required kwarg is exactly the
1088/// failure mode the typed-entry gate exists to reject; naming it
1089/// structurally is the typed posture for that gate's diagnostic.
1090#[must_use]
1091pub fn missing_kwarg(key: &str) -> LispError {
1092 LispError::MissingKwarg {
1093 key: key.to_string(),
1094 }
1095}
1096
1097/// Structural type-mismatch builder. Pairs a typed `form: KwargPath`
1098/// (typically `kwarg_form(_)` / `kwarg_item_form(_, _)` /
1099/// `kwargs_pos_form(_)`) with the static `expected` label and the `got`
1100/// projection of the offending `Sexp` through `sexp_type_name`. Returns
1101/// the dedicated `LispError::TypeMismatch` variant so authoring surfaces
1102/// (REPL, LSP, `tatara-check`) bind to first-class `form`/`expected`/`got`
1103/// fields — pattern-matching on `KwargPath::Item { .. }` etc. directly —
1104/// instead of substring-parsing the rendered message.
1105///
1106/// Three inline `format!("expected {X}, got {}", sexp_type_name(_))`
1107/// copies in this module (`type_err`, `extract_string_list` per-item,
1108/// `extract_vec_via_serde` non-list) used to assemble the same shape by
1109/// hand; the three-times rule (THEORY.md §VI.1) calls for one named
1110/// primitive. This is it. Future runs that thread `pos: Option<usize>`
1111/// from `Sexp` spans add ONE field to the variant; every type-mismatch
1112/// site inherits positional rendering with no consumer changes.
1113#[must_use]
1114pub fn type_mismatch(
1115 form: crate::error::KwargPath,
1116 expected: ExpectedKwargShape,
1117 got: &Sexp,
1118) -> LispError {
1119 LispError::TypeMismatch {
1120 form,
1121 expected,
1122 got: got.shape(),
1123 }
1124}
1125
1126fn type_err(key: &str, expected: ExpectedKwargShape, got: &Sexp) -> LispError {
1127 type_mismatch(kwarg_form(key), expected, got)
1128}
1129
1130/// Item-indexed sibling of `type_err` — pairs `kwarg_item_form` with
1131/// `type_mismatch` so a per-item failure inside a list-typed kwarg names
1132/// `KwargPath::Item { key, idx }` plus the structural `expected`/`got` shape.
1133/// Used by `extract_string_list`'s per-item path; future per-item type-mismatch
1134/// sites (e.g. typed enums-of-strings, typed numeric vecs) bind here
1135/// rather than re-inlining the shape.
1136fn type_err_at(key: &str, idx: usize, expected: ExpectedKwargShape, got: &Sexp) -> LispError {
1137 type_mismatch(kwarg_item_form(key, idx), expected, got)
1138}
1139
1140/// Range-axis sibling of [`type_err`] — the kwarg's `Sexp` shape was
1141/// RIGHT but the value does not fit the field's Rust width. Pairs the
1142/// same `kwarg_form(_)` typed path with the typed `NumericWidth` target
1143/// and the author's literal, returning [`LispError::KwargOutOfRange`].
1144///
1145/// Named beside `type_err` / `type_err_at` deliberately: the three are
1146/// the typed-entry kwarg gate's whole rejection vocabulary — shape,
1147/// per-item shape, and range — so a reader who finds one finds all
1148/// three, and a future span lift touches one neighbourhood.
1149fn range_err(key: &str, target: NumericWidth, value: NumericLiteral) -> LispError {
1150 LispError::KwargOutOfRange {
1151 form: kwarg_form(key),
1152 target,
1153 value,
1154 }
1155}
1156
1157/// Required atomic-kwarg extractor — fronts every typed-atom public
1158/// `extract_X` helper (`extract_string`, `extract_int`, `extract_float`,
1159/// `extract_bool`). The four byte-identical inline shapes —
1160///
1161/// ```ignore
1162/// let v = required(kw, key)?;
1163/// v.as_X().ok_or_else(|| type_err(key, "<X-name>", v))
1164/// ```
1165///
1166/// — collapse to ONE generic primitive parameterized by the projection
1167/// function `project: FnOnce(&'a Sexp) -> Option<T>` and the typed-name
1168/// label `expected: &'static str`. The four-times rule (THEORY.md §VI.1)
1169/// is decisively crossed; lifting it into ONE primitive means the next
1170/// change to the typed-atom failure-projection shape (e.g. threading
1171/// `pos: Option<usize>` once `Sexp` carries spans, attaching a structural
1172/// `source: SexpTypeMismatch` chain) lands as ONE signature change inside
1173/// `extract_atom`, and all four public extractors pick up the upgrade
1174/// mechanically — no per-extractor edit, no per-extractor test drift.
1175///
1176/// `T` is generic so the helper handles both owned (`i64`, `f64`, `bool`)
1177/// and borrowed (`&'a str`) projections uniformly — the lifetime
1178/// threading `&'a Sexp → Option<&'a str>` works because every
1179/// `Sexp::as_*` method is `for<'b> fn(&'b Self) -> Option<…&'b str…>`;
1180/// the helper inherits that lifetime quantification through
1181/// `FnOnce(&'a Sexp) -> Option<T>`. Calling `extract_atom(kw, key,
1182/// "string", Sexp::as_string)` infers `T = &'a str`; calling
1183/// `extract_atom(kw, key, "int", Sexp::as_int)` infers `T = i64`.
1184///
1185/// Sibling of `extract_optional_atom` for the optional kwarg path —
1186/// together the two close every distinct typed-atom kwarg extractor's
1187/// shape: required vs. optional, returning `Result<T>` vs.
1188/// `Result<Option<T>>` from the same underlying projection. Future
1189/// extension to additional atomic types (e.g. `Atom::Bytes` if/when
1190/// added) is ONE one-line public delegate plus ONE call site — no
1191/// new error-path duplication.
1192///
1193/// Theory anchor: THEORY.md §VI.1 — generation over composition;
1194/// three-times rule decisively crossed (four byte-identical
1195/// extract+project+type-err shapes across `extract_string`,
1196/// `extract_int`, `extract_float`, `extract_bool`). THEORY.md §V.1 —
1197/// knowable platform / constructive diagnostics: the typed-atom
1198/// kwarg-failure projection lives in ONE primitive so authoring
1199/// surfaces (`tatara-check`, REPL, LSP) pick up the diagnostic-shape
1200/// promotion mechanically once the variant is structurally extended.
1201/// THEORY.md §II.1 invariant 1 — typed entry; the typed-atom
1202/// extractor IS the rust-level typed-entry gate for primitive kwargs,
1203/// and naming its single shape lifts the gate from four-site
1204/// duplication to one rust function the substrate's diagnostic
1205/// promotions hang off of.
1206fn extract_atom<'a, T, F>(
1207 kw: &'a Kwargs<'a>,
1208 key: &str,
1209 expected: ExpectedKwargShape,
1210 project: F,
1211) -> Result<T>
1212where
1213 F: FnOnce(&'a Sexp) -> Option<T>,
1214{
1215 let v = required(kw, key)?;
1216 project(v).ok_or_else(|| type_err(key, expected, v))
1217}
1218
1219/// Optional sibling of `extract_atom` — collapses the four byte-identical
1220/// inline shapes of `extract_optional_string`, `extract_optional_int`,
1221/// `extract_optional_float`, `extract_optional_bool`:
1222///
1223/// ```ignore
1224/// match kw.get(key) {
1225/// None => Ok(None),
1226/// Some(v) => v.as_X().map(Some).ok_or_else(|| type_err(key, "<X-name>", v)),
1227/// }
1228/// ```
1229///
1230/// into ONE generic primitive. Same `T`/`project`/`expected` shape as
1231/// `extract_atom`; the difference is the `kw.get(key)` short-circuit at
1232/// the `None` arm — an absent kwarg is not an error for optional
1233/// extractors, only a malformed-present one is. The `.copied()` on
1234/// `kw.get(key)` projects `Option<&&'a Sexp>` to `Option<&'a Sexp>` so
1235/// the `project` call gets the same `&'a Sexp` shape as the required
1236/// path — type-checks against the same projection functions
1237/// (`Sexp::as_string`, `Sexp::as_int`, etc.) without per-call casts.
1238///
1239/// Future structural promotion of the type-mismatch diagnostic lands at
1240/// ONE call site inside this helper — same property as `extract_atom`.
1241fn extract_optional_atom<'a, T, F>(
1242 kw: &'a Kwargs<'a>,
1243 key: &str,
1244 expected: ExpectedKwargShape,
1245 project: F,
1246) -> Result<Option<T>>
1247where
1248 F: FnOnce(&'a Sexp) -> Option<T>,
1249{
1250 match optional(kw, key) {
1251 None => Ok(None),
1252 Some(v) => project(v)
1253 .map(Some)
1254 .ok_or_else(|| type_err(key, expected, v)),
1255 }
1256}
1257
1258/// List-typed kwarg extractor — fronts every public `extract_*` helper
1259/// that reads a kwarg as a `Sexp::List` and projects each element to an
1260/// owned `T`. The two byte-identical inline skeletons —
1261///
1262/// ```ignore
1263/// let Some(v) = kw.get(key).copied() else { return Ok(Vec::new()) };
1264/// let list = v.as_list().ok_or_else(|| type_err(key, <list-shape>, v))?;
1265/// list.iter().enumerate().map(<per-item>).collect()
1266/// ```
1267///
1268/// — `extract_string_list` (each item projected via `as_string`, per-item
1269/// failure via `type_err_at`) and `extract_vec_via_serde` (each item via
1270/// `from_value_with_path`, per-item failure carrying `KwargPath::item`) —
1271/// collapse to ONE generic primitive parameterized by the outer-shape
1272/// label `list_shape: ExpectedKwargShape` and the per-element projection
1273/// `item: FnMut(usize, &Sexp) -> Result<T>`. The skeleton owns the three
1274/// fixed decisions both extractors share: absent kwarg → `Ok(Vec::new())`
1275/// (an absent list kwarg is the empty list, never an error — same posture
1276/// `extract_optional_atom` takes for absent atoms); present-but-not-a-list
1277/// → `type_err(key, list_shape, v)` (the outer-shape gate, labeled by the
1278/// caller-supplied `list_shape` so `ListOfStrings` vs. `List` stays a
1279/// per-caller decision, not baked into the skeleton); and the
1280/// `iter().enumerate().map(item).collect()` per-element walk that threads
1281/// the element index into the projection so per-item diagnostics can name
1282/// `:<key>[<idx>]` without re-counting from the source.
1283///
1284/// This is the list-family sibling of `extract_atom` / `extract_optional_atom`
1285/// (the atom-family generic projection primitives). Together the three close
1286/// every distinct typed-kwarg extractor's outer skeleton: required atom,
1287/// optional atom, and list. The per-element projection is `FnMut(usize,
1288/// &Sexp) -> Result<T>` — generic over `T` so it handles both the owned-
1289/// `String` (`extract_string_list`) and `DeserializeOwned`-`T`
1290/// (`extract_vec_via_serde`) element shapes uniformly, and threading the
1291/// `usize` index lets the projection construct the item-keyed
1292/// `KwargPath::Item { key, idx }` / `type_err_at` path the per-item gate
1293/// reports through.
1294///
1295/// Future structural promotion of the outer not-a-list diagnostic, or a
1296/// move to a fallible-streaming collect that short-circuits on the first
1297/// bad element with its position, lands at ONE site inside this helper —
1298/// both public list extractors pick up the upgrade mechanically, same
1299/// property `extract_atom` gives the four atom extractors.
1300///
1301/// Theory anchor: THEORY.md §VI.1 — generation over composition; the
1302/// list-typed extractor skeleton recurs at two sites (the PRIME-DIRECTIVE
1303/// ≥2 trigger) and is lifted to one owner, exactly as the atom skeleton was.
1304/// THEORY.md §V.1 — knowable platform; the list-kwarg outer gate + per-item
1305/// path live in ONE primitive so authoring surfaces (`tatara-check`, REPL,
1306/// LSP) pick up diagnostic-shape promotions once, not per-extractor.
1307/// THEORY.md §II.1 invariant 1 — typed entry; the list extractor IS the
1308/// rust-level typed-entry gate for list-shaped kwargs, and naming its single
1309/// skeleton lifts the gate from two-site duplication to one function the
1310/// substrate's diagnostic promotions hang off of.
1311fn extract_list<T, F>(
1312 kw: &Kwargs<'_>,
1313 key: &str,
1314 list_shape: ExpectedKwargShape,
1315 mut item: F,
1316) -> Result<Vec<T>>
1317where
1318 F: FnMut(usize, &Sexp) -> Result<T>,
1319{
1320 let Some(v) = optional(kw, key) else {
1321 return Ok(Vec::new());
1322 };
1323 let list = v.as_list().ok_or_else(|| type_err(key, list_shape, v))?;
1324 list.iter()
1325 .enumerate()
1326 .map(|(idx, e)| item(idx, e))
1327 .collect()
1328}
1329
1330pub fn extract_string<'a>(kw: &'a Kwargs<'a>, key: &str) -> Result<&'a str> {
1331 extract_atom(kw, key, ExpectedKwargShape::String, Sexp::as_string)
1332}
1333
1334pub fn extract_optional_string<'a>(kw: &'a Kwargs<'a>, key: &str) -> Result<Option<&'a str>> {
1335 extract_optional_atom(kw, key, ExpectedKwargShape::String, Sexp::as_string)
1336}
1337
1338pub fn extract_string_list(kw: &Kwargs<'_>, key: &str) -> Result<Vec<String>> {
1339 extract_list(kw, key, ExpectedKwargShape::ListOfStrings, |idx, s| {
1340 s.as_string()
1341 .map(String::from)
1342 .ok_or_else(|| type_err_at(key, idx, ExpectedKwargShape::String, s))
1343 })
1344}
1345
1346pub fn extract_int(kw: &Kwargs<'_>, key: &str) -> Result<i64> {
1347 extract_atom(kw, key, ExpectedKwargShape::Int, Sexp::as_int)
1348}
1349
1350pub fn extract_optional_int(kw: &Kwargs<'_>, key: &str) -> Result<Option<i64>> {
1351 extract_optional_atom(kw, key, ExpectedKwargShape::Int, Sexp::as_int)
1352}
1353
1354pub fn extract_float(kw: &Kwargs<'_>, key: &str) -> Result<f64> {
1355 extract_atom(kw, key, ExpectedKwargShape::Number, Sexp::as_float)
1356}
1357
1358pub fn extract_optional_float(kw: &Kwargs<'_>, key: &str) -> Result<Option<f64>> {
1359 extract_optional_atom(kw, key, ExpectedKwargShape::Number, Sexp::as_float)
1360}
1361
1362// ── The narrowing axis: a wide reader value → the field's Rust width ──
1363//
1364// `extract_int` returns `i64` and `extract_float` returns `f64` — the
1365// widest thing the reader can hand back on each axis. A field declared
1366// `u32` / `i32` / `usize` / `f32` is NARROWER, and the projection from
1367// the reader's width to the field's is a partial function: `70000` has
1368// no `u16`, `-1` has no `u32`, `1e300` has no `f32`.
1369//
1370// `#[derive(TataraDomain)]` used to close that gap with a raw Rust `as`
1371// cast appended to the extractor call — `extract_int(&kw, "port")? as
1372// u32`. `as` is TOTAL by truncating: it wraps, sign-flips, or saturates
1373// to `inf` and reports nothing. So an author who wrote a number the
1374// field could not hold got a DIFFERENT number in the struct, silently,
1375// with a green build and a green parse. That is precisely the class the
1376// typed-entry gate exists to reject, leaking through the one hole the
1377// gate did not cover.
1378//
1379// The fix is one typed primitive, here, rather than a check at each of
1380// the derive's four numeric arms — same lift as the `extract_via_serde`
1381// family below, and for the same reason: a hand-written `TataraDomain`
1382// impl and a derived one must take the identical error path, and the
1383// next upgrade (a span, a suggested-value hint) has to land in ONE
1384// place.
1385//
1386// The target width rides the TYPE, not an argument. `NarrowNumeric`'s
1387// associated `WIDTH` const means the derive emits
1388// `extract_int_narrowed::<u32>(&kw, key)?` and cannot mislabel the
1389// diagnostic, because it never names the width at all — the impl does.
1390// A future `classify` arm for a width with no impl is a compile error
1391// at the consumer, not a mislabeled runtime message.
1392
1393/// A narrower numeric type reachable from the reader's wide `Wide`
1394/// value — `i64` on the int axis, `f64` on the float axis.
1395///
1396/// Implemented for exactly the seven widths `#[derive(TataraDomain)]`
1397/// recognises, which is what makes [`NumericWidth`] a genuinely closed
1398/// set: the enum's variants and this trait's impls are the same list,
1399/// generated from the same macro invocation below.
1400pub trait NarrowNumeric<Wide>: Sized + Copy {
1401 /// This type's identity in the typed diagnostic — the value that
1402 /// rides [`LispError::KwargOutOfRange`]'s `target` slot.
1403 const WIDTH: NumericWidth;
1404
1405 /// The partial projection. `None` means "this wide value has no
1406 /// representation at this width" and becomes a typed rejection;
1407 /// it never means "here is a nearby value instead".
1408 fn narrow(wide: Wide) -> Option<Self>;
1409}
1410
1411/// Emit the `NarrowNumeric<i64>` impls for the integer widths. Each is
1412/// `TryFrom<i64>` verbatim — the std conversion is already the exact
1413/// partial function we want (rejects too-large AND negative-into-
1414/// unsigned), so the impl delegates rather than re-deriving bounds
1415/// arithmetic that could disagree with std.
1416macro_rules! impl_narrow_int {
1417 ($($ty:ty => $width:ident),+ $(,)?) => {$(
1418 impl NarrowNumeric<i64> for $ty {
1419 const WIDTH: NumericWidth = NumericWidth::$width;
1420 fn narrow(wide: i64) -> Option<Self> {
1421 <$ty as ::core::convert::TryFrom<i64>>::try_from(wide).ok()
1422 }
1423 }
1424 )+};
1425}
1426
1427impl_narrow_int! {
1428 i32 => I32,
1429 i64 => I64,
1430 u32 => U32,
1431 u64 => U64,
1432 usize => Usize,
1433}
1434
1435impl NarrowNumeric<f64> for f64 {
1436 const WIDTH: NumericWidth = NumericWidth::F64;
1437 fn narrow(wide: f64) -> Option<Self> {
1438 Some(wide)
1439 }
1440}
1441
1442impl NarrowNumeric<f64> for f32 {
1443 const WIDTH: NumericWidth = NumericWidth::F32;
1444 /// Rejects exactly one thing: a FINITE `f64` whose magnitude
1445 /// overflows to `inf` at `f32`. Precision loss inside the range is
1446 /// accepted — an `f32` field asked for `f32` precision, and
1447 /// rejecting `0.1` would make the type unusable. An input that was
1448 /// already `inf` / `NaN` passes through unchanged, because `as`
1449 /// preserved it faithfully; the corruption case is only the finite
1450 /// value that becomes infinite.
1451 fn narrow(wide: f64) -> Option<Self> {
1452 #[allow(clippy::cast_possible_truncation)]
1453 let narrowed = wide as f32;
1454 if narrowed.is_finite() || !wide.is_finite() {
1455 Some(narrowed)
1456 } else {
1457 None
1458 }
1459 }
1460}
1461
1462// ── Runtime-dispatched narrowing (the `(cast T x)` axis) ───────────
1463//
1464// `NarrowNumeric` carries the target width in the TYPE, which is right
1465// for `#[derive(TataraDomain)]` — the derive knows the field's Rust
1466// type at expansion time and emits `extract_int_narrowed::<u32>`. A
1467// tatara-lisp `(cast :u32 x)` does NOT: it learns the width from a
1468// runtime keyword. The two must nevertheless reject the same values,
1469// so the runtime path is a DISPATCH onto the same trait impls, never a
1470// second bounds table. Every arm below is `<T as NarrowNumeric<_>>::
1471// narrow` verbatim; the only thing written twice is the
1472// variant-to-Rust-type mapping, which `narrow_literal_agrees_with_the_
1473// typed_projection` pins across `NumericWidth::ALL`.
1474//
1475// It lives HERE rather than in `error.rs` beside the enum because it
1476// delegates to trait impls that live here — an eighth width added to
1477// `NumericWidth` fails rustc's exhaustiveness check on both matches
1478// below, which is the same compile-error-at-the-consumer posture the
1479// enum's own doc claims for the derive.
1480
1481/// The reader-side wide axis a [`NumericWidth`] narrows FROM — the
1482/// typed answer to "which extractor produced the value this width has
1483/// to hold", `extract_int` (`-> i64`) or `extract_float` (`-> f64`).
1484///
1485/// A runtime caster needs this BEFORE it narrows: an `(cast :u32 3.5)`
1486/// is a SHAPE rejection (the derive's `extract_int` would refuse a
1487/// float `Sexp` outright with [`LispError::TypeMismatch`]), not a range
1488/// rejection, and conflating the two would report `3.5 is out of range
1489/// for u32` — true-sounding and wrong about which gate fired.
1490#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1491pub enum NumericAxis {
1492 /// Widths reached from the reader's `i64`: `i32` `i64` `u32` `u64`
1493 /// `usize`.
1494 Int,
1495 /// Widths reached from the reader's `f64`: `f32` `f64`.
1496 Float,
1497}
1498
1499impl NumericWidth {
1500 /// Which wide axis this width narrows from.
1501 #[must_use]
1502 pub const fn axis(self) -> NumericAxis {
1503 match self {
1504 Self::I32 | Self::I64 | Self::U32 | Self::U64 | Self::Usize => NumericAxis::Int,
1505 Self::F32 | Self::F64 => NumericAxis::Float,
1506 }
1507 }
1508
1509 /// Checked narrowing with the target width supplied at RUNTIME.
1510 ///
1511 /// `None` means exactly what [`NarrowNumeric::narrow`]'s `None`
1512 /// means — "this wide value has no representation at this width" —
1513 /// and is the same rejection [`extract_int_narrowed`] turns into
1514 /// [`LispError::KwargOutOfRange`]. It never means "here is a nearby
1515 /// value instead".
1516 ///
1517 /// The returned literal is re-projected back onto the reader's wide
1518 /// axis, because that is the only width a tatara-lisp `Value` has.
1519 /// On the int axis that round-trip is EXACT (a successful
1520 /// `TryFrom<i64>` is injective, so the i64 that comes back is the
1521 /// i64 that went in). On the float axis it is not, and deliberately
1522 /// so: `f32`'s narrowing accepts precision loss, so
1523 /// `narrow_literal(F32, 0.1)` answers with `0.1_f32 as f64` — the
1524 /// value the field would actually hold, not the value the author
1525 /// typed. A caster that echoed the input back would be reporting a
1526 /// coercion it did not perform.
1527 ///
1528 /// A literal on the WRONG axis is rejected rather than converted;
1529 /// routing by [`Self::axis`] first is what keeps that unreachable
1530 /// on the good path.
1531 #[must_use]
1532 pub fn narrow_literal(self, value: NumericLiteral) -> Option<NumericLiteral> {
1533 match (self, value) {
1534 (Self::I32, NumericLiteral::Int(w)) => {
1535 <i32 as NarrowNumeric<i64>>::narrow(w).map(|_| NumericLiteral::Int(w))
1536 }
1537 (Self::I64, NumericLiteral::Int(w)) => {
1538 <i64 as NarrowNumeric<i64>>::narrow(w).map(NumericLiteral::Int)
1539 }
1540 (Self::U32, NumericLiteral::Int(w)) => {
1541 <u32 as NarrowNumeric<i64>>::narrow(w).map(|_| NumericLiteral::Int(w))
1542 }
1543 (Self::U64, NumericLiteral::Int(w)) => {
1544 <u64 as NarrowNumeric<i64>>::narrow(w).map(|_| NumericLiteral::Int(w))
1545 }
1546 (Self::Usize, NumericLiteral::Int(w)) => {
1547 <usize as NarrowNumeric<i64>>::narrow(w).map(|_| NumericLiteral::Int(w))
1548 }
1549 (Self::F32, NumericLiteral::Float(w)) => {
1550 <f32 as NarrowNumeric<f64>>::narrow(w).map(|n| NumericLiteral::Float(f64::from(n)))
1551 }
1552 (Self::F64, NumericLiteral::Float(w)) => {
1553 <f64 as NarrowNumeric<f64>>::narrow(w).map(NumericLiteral::Float)
1554 }
1555 // Cross-axis: the caller failed to route by `axis()` first.
1556 // Rejecting is the only answer that cannot invent a number.
1557 (Self::I32 | Self::I64 | Self::U32 | Self::U64 | Self::Usize, _)
1558 | (Self::F32 | Self::F64, _) => None,
1559 }
1560 }
1561}
1562
1563/// Required integer kwarg projected into the field's own width —
1564/// [`extract_int`] followed by the typed [`NarrowNumeric`] projection,
1565/// with [`LispError::KwargOutOfRange`] on a value the width cannot
1566/// hold. The narrowing replacement for `extract_int(&kw, key)? as T`.
1567pub fn extract_int_narrowed<T: NarrowNumeric<i64>>(kw: &Kwargs<'_>, key: &str) -> Result<T> {
1568 let wide = extract_int(kw, key)?;
1569 T::narrow(wide).ok_or_else(|| range_err(key, T::WIDTH, NumericLiteral::Int(wide)))
1570}
1571
1572/// `Option` sibling of [`extract_int_narrowed`]. An ABSENT kwarg stays
1573/// `None`; a PRESENT but out-of-range one is a rejection, never a
1574/// `None` — silently dropping a value the author wrote would be the
1575/// same corruption in a different costume.
1576pub fn extract_optional_int_narrowed<T: NarrowNumeric<i64>>(
1577 kw: &Kwargs<'_>,
1578 key: &str,
1579) -> Result<Option<T>> {
1580 let Some(wide) = extract_optional_int(kw, key)? else {
1581 return Ok(None);
1582 };
1583 T::narrow(wide)
1584 .map(Some)
1585 .ok_or_else(|| range_err(key, T::WIDTH, NumericLiteral::Int(wide)))
1586}
1587
1588/// Float-axis sibling of [`extract_int_narrowed`] — [`extract_float`]
1589/// followed by the typed [`NarrowNumeric`] projection. The narrowing
1590/// replacement for `extract_float(&kw, key)? as T`.
1591pub fn extract_float_narrowed<T: NarrowNumeric<f64>>(kw: &Kwargs<'_>, key: &str) -> Result<T> {
1592 let wide = extract_float(kw, key)?;
1593 T::narrow(wide).ok_or_else(|| range_err(key, T::WIDTH, NumericLiteral::Float(wide)))
1594}
1595
1596/// `Option` sibling of [`extract_float_narrowed`]; same absent-vs-
1597/// out-of-range split as [`extract_optional_int_narrowed`].
1598pub fn extract_optional_float_narrowed<T: NarrowNumeric<f64>>(
1599 kw: &Kwargs<'_>,
1600 key: &str,
1601) -> Result<Option<T>> {
1602 let Some(wide) = extract_optional_float(kw, key)? else {
1603 return Ok(None);
1604 };
1605 T::narrow(wide)
1606 .map(Some)
1607 .ok_or_else(|| range_err(key, T::WIDTH, NumericLiteral::Float(wide)))
1608}
1609
1610pub fn extract_bool(kw: &Kwargs<'_>, key: &str) -> Result<bool> {
1611 extract_atom(kw, key, ExpectedKwargShape::Bool, Sexp::as_bool)
1612}
1613
1614pub fn extract_optional_bool(kw: &Kwargs<'_>, key: &str) -> Result<Option<bool>> {
1615 extract_optional_atom(kw, key, ExpectedKwargShape::Bool, Sexp::as_bool)
1616}
1617
1618// ── Universal serde-Deserialize fallthrough (enums, nested structs, …) ──
1619//
1620// `#[derive(TataraDomain)]` covers `String` / numeric / `bool` / their
1621// `Option` and `Vec<String>` shapes with the typed extractors above. Any
1622// field type outside that closed set falls through to these helpers, which
1623// project the kwarg `Sexp` to canonical JSON via `sexp_to_json` and feed
1624// it to `serde_json::from_value` — works for any `serde::Deserialize`.
1625//
1626// The shape used to live inline in three `quote!` blocks in the derive
1627// macro (`Kind::Deserialize`, `Kind::OptionalDeserialize`,
1628// `Kind::VecDeserialize`). Lifting them here means:
1629// - Hand-written `TataraDomain` impls share the same error path.
1630// - Future diagnostic upgrades (attaching a source position once `Sexp`
1631// carries spans, richer field-path traces) happen in ONE function,
1632// not three macro-emitted copies.
1633// - The `:<key> deserialize: …` message is a single named primitive in
1634// the substrate — `tatara-check` / LSP / REPL render it uniformly.
1635//
1636// Both helpers below funnel through the structural
1637// `LispError::KwargDeserialize { path: KwargPath, message }` variant —
1638// the typed-entry-side `from_value` mirror of the typed-exit-side
1639// `to_value` `LispError::DomainSerialize { keyword, message }` lift. The
1640// two sites bifurcate via the typed `KwargPath` enum's variant identity:
1641// `KwargPath::Named(key)` for kwarg-keyed failures (the
1642// `extract_via_serde` / `extract_optional_via_serde` path),
1643// `KwargPath::Item { key, idx }` for kwarg-AND-index-keyed failures (the
1644// `extract_vec_via_serde` per-item path). After this lift the
1645// `from_value` boundary's two distinct rejection modes BOTH bind to ONE
1646// structural variant of `LispError`, not a `Compile`-shaped substring;
1647// the `(key, idx: Option<usize>)` bifurcation collapses into
1648// `KwargPath`'s `Named` vs. `Item` variant identity, so the invalid
1649// sibling-slot combination `(key: "", idx: Some(_))` for a scalar path
1650// is structurally unrepresentable rather than re-asserted at the helper
1651// boundary via runtime `Option::is_some` comparison. Together with
1652// `DomainSerialize`, every distinct `serde_json` failure mode at the
1653// typed-domain JSON boundary — both directions of the round-trip — is
1654// now structurally typed. This is the LAST `LispError::Compile { ... }`
1655// construction site in this file.
1656//
1657// Theory anchor: THEORY.md §VI.1 (generation over composition — the
1658// generator must lean on the library, not duplicate the library inline).
1659// THEORY.md §II.1 invariant 1 (typed entry) — `from_value` failures are
1660// exactly the failure mode the typed-entry JSON gate exists to reject;
1661// naming them structurally is the typed posture for that gate's
1662// diagnostic.
1663
1664/// Project a single `&Sexp` through the typed-entry JSON boundary —
1665/// `sexp_to_json` canonical-JSON projection + `serde_json::from_value::<T>`
1666/// + structural `LispError::KwargDeserialize { path, message }` on failure.
1667///
1668/// THREE call sites in this module used to assemble this shape inline:
1669/// `extract_via_serde` (required scalar kwarg path), `extract_optional_via_serde`
1670/// (optional scalar kwarg path), and `extract_vec_via_serde`'s per-item
1671/// closure (each item in a `Vec<T>` kwarg). The three byte-identical
1672/// `let json = sexp_to_json(sexp)?; serde_json::from_value(json).map_err(|e|
1673/// deserialize_*_err(<path-args>, &e))` shapes — modulo the typed
1674/// `KwargPath` constructor (`KwargPath::Named` vs. `KwargPath::Item`) —
1675/// collapse to ONE primitive parameterized by `path: KwargPath`. The
1676/// path's variant identity bifurcates scalar-vs-item rendering inside
1677/// `KwargPath`'s Display impl (`:<key>` vs. `:<key>[<idx>]`) so the helper
1678/// is shape-of-typed-entry-JSON-boundary, not shape-of-call-site.
1679///
1680/// After this lift the three-times-rule on the `from_value` projection
1681/// shape is decisively crossed; the two prior-run thin `deserialize_err`
1682/// / `deserialize_item_err` shims — which encapsulated only the
1683/// `KwargPath::named(_)` / `KwargPath::item(_,_)` constructor projection
1684/// over an already-extant `serde_json::Error` reference — are subsumed
1685/// by this primitive's `map_err` closure. The three extractor entry
1686/// points now bind on `from_value_with_path::<T>` directly with their
1687/// `KwargPath` constructed at the call boundary; the JSON-boundary's
1688/// rejection shape (`LispError::KwargDeserialize { path, message }`)
1689/// lives in ONE place — the `map_err` arm here — instead of being
1690/// re-asserted at three site-specific shims.
1691///
1692/// `<T: DeserializeOwned>` is generic so the helper handles every serde-
1693/// projectable typed-domain field uniformly — scalar `i64` / `String` /
1694/// nested struct / `Vec<Nested>` / enum-by-symbol — same posture as the
1695/// `extract_atom` / `extract_optional_atom` generic-projection primitives
1696/// for the atom-typed kwarg path. `path: KwargPath` flows into the
1697/// variant's typed slot directly (owned), parallel to how `type_mismatch`
1698/// threads `KwargPath` into `LispError::TypeMismatch.form`. A future
1699/// fourth path shape (e.g. `:<key>.<field>` for nested-struct kwarg
1700/// failures) extends `KwargPath` ONCE and rustc-enforces matching at
1701/// every projection site; this helper picks up the new shape mechanically
1702/// with no signature change.
1703///
1704/// Theory anchor: THEORY.md §VI.1 — generation over composition; the
1705/// three-times rule's load-bearing trigger. THEORY.md §V.1 — knowable
1706/// platform; the typed-entry JSON-projection boundary's rejection shape
1707/// lives in ONE primitive so authoring surfaces (`tatara-check`, REPL,
1708/// LSP) pick up the diagnostic-shape promotion mechanically once the
1709/// variant is structurally extended. THEORY.md §II.1 invariant 1 (typed
1710/// entry) — a `from_value` failure is exactly the failure mode the
1711/// typed-entry JSON gate exists to reject; naming its single shape lifts
1712/// the gate from three-site duplication to one rust function the
1713/// substrate's diagnostic promotions hang off of.
1714fn from_value_with_path<T: DeserializeOwned>(sexp: &Sexp, path: KwargPath) -> Result<T> {
1715 let json = sexp.to_json()?;
1716 serde_json::from_value(json).map_err(|e| LispError::KwargDeserialize {
1717 path,
1718 message: e.to_string(),
1719 })
1720}
1721
1722/// Required field — feeds the kwarg's canonical-JSON projection to
1723/// `serde_json::from_value::<T>` via `from_value_with_path` with a
1724/// `KwargPath::Named(key)` path slot. Errors carry `:key` so authoring
1725/// tools can point at the offending kwarg.
1726pub fn extract_via_serde<T: DeserializeOwned>(kw: &Kwargs<'_>, key: &str) -> Result<T> {
1727 from_value_with_path(required(kw, key)?, KwargPath::named(key))
1728}
1729
1730/// Optional field — `None` if the kwarg is absent; `Some(T)` after a
1731/// successful `from_value_with_path` round-trip with a `KwargPath::Named(key)`
1732/// path slot.
1733pub fn extract_optional_via_serde<T: DeserializeOwned>(
1734 kw: &Kwargs<'_>,
1735 key: &str,
1736) -> Result<Option<T>> {
1737 let Some(sexp) = optional(kw, key) else {
1738 return Ok(None);
1739 };
1740 from_value_with_path(sexp, KwargPath::named(key)).map(Some)
1741}
1742
1743/// `Vec<T>` field — empty vec if the kwarg is absent; otherwise the kwarg
1744/// must be a `Sexp::List` and each item flows through `from_value_with_path`
1745/// with a `KwargPath::Item { key, idx }` path slot, naming both the outer
1746/// kwarg AND the failing item index in any per-item rejection.
1747pub fn extract_vec_via_serde<T: DeserializeOwned>(kw: &Kwargs<'_>, key: &str) -> Result<Vec<T>> {
1748 extract_list(kw, key, ExpectedKwargShape::List, |idx, item| {
1749 from_value_with_path(item, KwargPath::item(key, idx))
1750 })
1751}
1752
1753// ── Domain registry (runtime-registered, callable by keyword) ───────
1754
1755/// Erased handler that knows how to compile a form and hand back a typed
1756/// serde-JSON representation. JSON is the least-common-denominator typed
1757/// surface — every `TataraDomain` derives `serde::Serialize` by convention.
1758pub struct DomainHandler {
1759 pub keyword: &'static str,
1760 /// `type_name` of the Rust type that holds this keyword. Carried so a
1761 /// collision can NAME the incumbent, and so [`registrations`] can render a
1762 /// census an operator can read. Without it, "who already owns
1763 /// `defplugin`?" is answerable only by reading every crate that links in.
1764 pub owner: &'static str,
1765 pub compile: fn(args: &[Sexp]) -> Result<serde_json::Value>,
1766}
1767
1768/// A second, DIFFERENT type tried to claim a keyword that is already held.
1769///
1770/// This is the typed form of a defect that used to be a discarded `insert`
1771/// return value. `register` writes into a process-global map; before this type
1772/// existed, a colliding registration silently displaced the incumbent and every
1773/// subsequent `lookup` compiled the wrong struct — no error, no log, no
1774/// diagnostic, and the winner decided by link and call order rather than by
1775/// anything an author wrote.
1776///
1777/// Not a [`LispError`] variant on purpose: nothing here is a source-level
1778/// mistake in a `.tlisp` file. It is a fact about how one *process* was
1779/// assembled, and a caller that hits it must fix its crate graph, not its Lisp.
1780#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1781pub struct KeywordCollision {
1782 /// The contested keyword, e.g. `"defplugin"`.
1783 pub keyword: &'static str,
1784 /// `type_name` of the type that got there first and KEPT the keyword.
1785 pub incumbent: &'static str,
1786 /// `type_name` of the type that was turned away.
1787 pub challenger: &'static str,
1788}
1789
1790impl std::fmt::Display for KeywordCollision {
1791 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1792 write!(
1793 f,
1794 "keyword `{}` is already registered to `{}`; `{}` was refused \
1795 (one keyword, one type, per process)",
1796 self.keyword, self.incumbent, self.challenger
1797 )
1798 }
1799}
1800
1801impl std::error::Error for KeywordCollision {}
1802
1803static REGISTRY: OnceLock<Mutex<HashMap<&'static str, DomainHandler>>> = OnceLock::new();
1804
1805fn registry() -> &'static Mutex<HashMap<&'static str, DomainHandler>> {
1806 REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
1807}
1808
1809/// Register a `TataraDomain` type with the global dispatcher.
1810///
1811/// **First writer wins, and the loser is told.** Three outcomes, exhaustively:
1812///
1813/// | registry state | result |
1814/// |---|---|
1815/// | keyword free | inserted, `Ok(())` |
1816/// | keyword held by `T` itself | no-op, `Ok(())` — the documented idempotency |
1817/// | keyword held by a different type | registry UNCHANGED, `Err(KeywordCollision)` |
1818///
1819/// The middle row is why this is not simply "reject a second insert": several
1820/// crates call `Foo::register()` from more than one entry point (a `register_all`
1821/// seed plus a lazy path), and that has always been legitimate. Only the third
1822/// row is the defect, and it used to be spelled `insert` — displacing the
1823/// incumbent and discarding it.
1824///
1825/// `#[must_use]`: a discarded result is now a compiler warning at every call
1826/// site, which is the point. It is deliberately a warning and not a hard break
1827/// — every one of the call sites measured across the pleme-io tree on
1828/// 2026-07-31 (165 `domain::register::<…>` sites, 0 of them in tail position)
1829/// is a statement ending in `;`, so widening the return type from `()` to
1830/// `Result` compiles unchanged everywhere and merely gets loud.
1831///
1832/// **Tier-honest.** This makes a collision *impossible to be silent within one
1833/// process*: the value exists, it is typed, and it is `#[must_use]`. It does
1834/// NOT make a collision unrepresentable — a caller may still write `let _ =
1835/// register::<T>();`, and two crates in two repos can still declare the same
1836/// `#[tatara(keyword = "…")]` and never be linked together, so nothing here
1837/// observes them. Catching *that* needs a source-level census across the tree,
1838/// which is what `tatara-keywords` does; this function covers only what one
1839/// running process can see.
1840#[must_use = "a refused registration means this keyword is already held by another type; \
1841 ignoring the result restores the silent-overwrite defect"]
1842pub fn register<T>() -> std::result::Result<(), KeywordCollision>
1843where
1844 T: TataraDomain + serde::Serialize,
1845{
1846 let owner = std::any::type_name::<T>();
1847 let mut reg = registry().lock().unwrap();
1848
1849 if let Some(existing) = reg.get(T::KEYWORD) {
1850 // Same type re-registering: the documented idempotency, kept.
1851 if existing.owner == owner {
1852 return Ok(());
1853 }
1854 // Different type: refuse, and leave the incumbent exactly where it is.
1855 // Reporting a rejection while still mutating would be strictly worse
1856 // than the overwrite it replaces.
1857 return Err(KeywordCollision {
1858 keyword: T::KEYWORD,
1859 incumbent: existing.owner,
1860 challenger: owner,
1861 });
1862 }
1863
1864 reg.insert(
1865 T::KEYWORD,
1866 DomainHandler {
1867 keyword: T::KEYWORD,
1868 owner,
1869 compile: |args| {
1870 let v = T::compile_from_args(args)?;
1871 serde_json::to_value(&v).map_err(|e| LispError::Compile {
1872 form: T::KEYWORD.to_string(),
1873 message: format!("serialize: {e}"),
1874 })
1875 },
1876 },
1877 );
1878 Ok(())
1879}
1880
1881/// Look up a handler by keyword.
1882pub fn lookup(keyword: &str) -> Option<DomainHandler> {
1883 let reg = registry().lock().unwrap();
1884 reg.get(keyword).map(|h| DomainHandler {
1885 keyword: h.keyword,
1886 owner: h.owner,
1887 compile: h.compile,
1888 })
1889}
1890
1891/// List currently registered keywords.
1892pub fn registered_keywords() -> Vec<&'static str> {
1893 registry().lock().unwrap().keys().copied().collect()
1894}
1895
1896/// The live census: every registered keyword paired with the `type_name` of the
1897/// type holding it, sorted by keyword.
1898///
1899/// The queryable form of "who owns what" inside a running process. A binary that
1900/// links two crates claiming one keyword can print this and see exactly one row
1901/// for the contested keyword, naming the winner — where before, the losing
1902/// handler had been dropped with nothing recording that it ever existed.
1903#[must_use]
1904pub fn registrations() -> Vec<(&'static str, &'static str)> {
1905 let reg = registry().lock().unwrap();
1906 let mut rows: Vec<(&'static str, &'static str)> =
1907 reg.values().map(|h| (h.keyword, h.owner)).collect();
1908 rows.sort_unstable();
1909 rows
1910}
1911
1912// ── Capability registries — compounding metadata layer ────────────
1913//
1914// Each registered domain can ALSO carry capability metadata —
1915// orthogonal concerns the rest of the platform needs to ask about
1916// the type without importing it. Today: `RenderMetadata` (used by
1917// tatara-render to emit Kubernetes CR YAML without a hard-coded
1918// match). Future: `ComplianceMetadata`, `DocumentationMetadata`,
1919// `AttestationMetadata` — same shape, additional concerns.
1920//
1921// Each metadata kind has its own static registry parallel to
1922// `REGISTRY` (the handler registry). Domain crates call
1923// `register_render::<T>()` alongside `register::<T>()` during
1924// boot; consumers like `tatara-render` look up by keyword.
1925
1926/// Type that knows its Kubernetes-CR rendering metadata. Tiny —
1927/// just constants. Implementing crates derive nothing; they
1928/// `impl RenderableDomain for FooSpec { … }` with three lines.
1929pub trait RenderableDomain {
1930 /// Kubernetes apiVersion the resource lives under
1931 /// (`gateway.networking.k8s.io/v1`, `cilium.io/v2`, etc.).
1932 const API_VERSION: &'static str;
1933 /// Kubernetes kind (`Gateway`, `CiliumNetworkPolicy`).
1934 const KIND: &'static str;
1935 /// Field name (in the typed JSON) that supplies the CR's
1936 /// `metadata.name`. Most domains use `name`; gateway-api
1937 /// uses `gateway_class_name`. Defaults via `Default` impl.
1938 const NAME_FIELD: &'static str = "name";
1939}
1940
1941/// Erased render metadata — what `tatara-render` consumes.
1942#[derive(Clone, Copy, Debug)]
1943pub struct RenderHandler {
1944 pub keyword: &'static str,
1945 pub api_version: &'static str,
1946 pub kind: &'static str,
1947 pub name_field: &'static str,
1948}
1949
1950static RENDER_REGISTRY: OnceLock<Mutex<HashMap<&'static str, RenderHandler>>> = OnceLock::new();
1951
1952fn render_registry() -> &'static Mutex<HashMap<&'static str, RenderHandler>> {
1953 RENDER_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
1954}
1955
1956/// Register a `RenderableDomain`'s metadata. Idempotent.
1957/// Domain crates call this once at boot, alongside `register::<T>()`.
1958pub fn register_render<T>()
1959where
1960 T: TataraDomain + RenderableDomain,
1961{
1962 let handler = RenderHandler {
1963 keyword: T::KEYWORD,
1964 api_version: T::API_VERSION,
1965 kind: T::KIND,
1966 name_field: T::NAME_FIELD,
1967 };
1968 render_registry()
1969 .lock()
1970 .unwrap()
1971 .insert(T::KEYWORD, handler);
1972}
1973
1974/// Look up render metadata by keyword.
1975#[must_use]
1976pub fn lookup_render(keyword: &str) -> Option<RenderHandler> {
1977 render_registry().lock().unwrap().get(keyword).copied()
1978}
1979
1980/// List every keyword that has render metadata registered.
1981#[must_use]
1982pub fn registered_render_keywords() -> Vec<&'static str> {
1983 render_registry().lock().unwrap().keys().copied().collect()
1984}
1985
1986// ── Documented capability ─────────────────────────────────────────
1987//
1988// Third capability layer (compile / render / doc). Each domain
1989// can carry its struct-level + field-level documentation strings
1990// for catalog browsers, IDE hover-help, and the `tatara doc`
1991// CLI to consult uniformly.
1992
1993/// Type that knows its human-readable documentation. Tiny: one
1994/// `&'static str` for the type-level summary, plus an array of
1995/// (field, doc) pairs.
1996pub trait DocumentedDomain {
1997 /// Top-level docstring for the type — what an embedder sees
1998 /// when hovering the keyword in a catalog browser.
1999 const DOCSTRING: &'static str;
2000 /// Per-field docstrings, in declaration order. Empty when no
2001 /// docs were captured upstream (typical for hand-written
2002 /// domains until they fill them in). Forge-generated domains
2003 /// populate this from CRD `description` fields.
2004 const FIELD_DOCS: &'static [(&'static str, &'static str)];
2005}
2006
2007/// Erased doc handle.
2008#[derive(Clone, Copy, Debug)]
2009pub struct DocHandler {
2010 pub keyword: &'static str,
2011 pub docstring: &'static str,
2012 pub field_docs: &'static [(&'static str, &'static str)],
2013}
2014
2015static DOC_REGISTRY: OnceLock<Mutex<HashMap<&'static str, DocHandler>>> = OnceLock::new();
2016
2017fn doc_registry() -> &'static Mutex<HashMap<&'static str, DocHandler>> {
2018 DOC_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
2019}
2020
2021/// Register a `DocumentedDomain`'s metadata. Idempotent.
2022pub fn register_doc<T>()
2023where
2024 T: TataraDomain + DocumentedDomain,
2025{
2026 let handler = DocHandler {
2027 keyword: T::KEYWORD,
2028 docstring: T::DOCSTRING,
2029 field_docs: T::FIELD_DOCS,
2030 };
2031 doc_registry().lock().unwrap().insert(T::KEYWORD, handler);
2032}
2033
2034/// Look up doc metadata by keyword.
2035#[must_use]
2036pub fn lookup_doc(keyword: &str) -> Option<DocHandler> {
2037 doc_registry().lock().unwrap().get(keyword).copied()
2038}
2039
2040/// List every keyword that has doc metadata registered.
2041#[must_use]
2042pub fn registered_doc_keywords() -> Vec<&'static str> {
2043 doc_registry().lock().unwrap().keys().copied().collect()
2044}
2045
2046// ── Dependent capability ──────────────────────────────────────────
2047//
2048// Fourth capability layer (compile / render / doc / deps). Each
2049// domain can declare which OTHER keywords its instances logically
2050// depend on. The rollout pipeline consumes this to topo-sort the
2051// `Plan` so deploys land in the right order — apply
2052// `defservice` before `defpodmonitor` before `defciliumnetworkpolicy`,
2053// drain in reverse.
2054
2055/// Type-level dependency declarations. The strings are keywords
2056/// of OTHER domains this one expects to be present (e.g. a
2057/// `defciliumnetworkpolicy` depends on a `defservice` whose pods
2058/// it selects). The dependency relation is type-to-type, not
2059/// instance-to-instance — finer-grained refs live on the typed
2060/// resource value itself.
2061pub trait DependentDomain {
2062 /// Keywords this domain logically depends on. Empty by
2063 /// default for forge-generated domains since CRDs don't
2064 /// generally declare deps; hand-written domains override
2065 /// to capture real ordering constraints.
2066 const DEPENDS_ON: &'static [&'static str];
2067}
2068
2069/// Erased dep handle — what the topo-sort consumer reads.
2070#[derive(Clone, Copy, Debug)]
2071pub struct DepsHandler {
2072 pub keyword: &'static str,
2073 pub depends_on: &'static [&'static str],
2074}
2075
2076static DEPS_REGISTRY: OnceLock<Mutex<HashMap<&'static str, DepsHandler>>> = OnceLock::new();
2077
2078fn deps_registry() -> &'static Mutex<HashMap<&'static str, DepsHandler>> {
2079 DEPS_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
2080}
2081
2082/// Register a `DependentDomain`'s deps. Idempotent.
2083pub fn register_deps<T>()
2084where
2085 T: TataraDomain + DependentDomain,
2086{
2087 let handler = DepsHandler {
2088 keyword: T::KEYWORD,
2089 depends_on: T::DEPENDS_ON,
2090 };
2091 deps_registry().lock().unwrap().insert(T::KEYWORD, handler);
2092}
2093
2094/// Look up dep metadata by keyword.
2095#[must_use]
2096pub fn lookup_deps(keyword: &str) -> Option<DepsHandler> {
2097 deps_registry().lock().unwrap().get(keyword).copied()
2098}
2099
2100/// List every keyword that has dep metadata registered.
2101#[must_use]
2102pub fn registered_deps_keywords() -> Vec<&'static str> {
2103 deps_registry().lock().unwrap().keys().copied().collect()
2104}
2105
2106// ── Schematic capability ──────────────────────────────────────────
2107//
2108// Fifth capability layer: per-domain JSON Schema export. Forge-
2109// generated domains preserve the source CRD's openAPIV3Schema
2110// verbatim; hand-written domains can either skip the layer or
2111// hand-curate a schema. Consumers: IDE hover-help, web
2112// validators, openapi exporters, admin-UI form generators —
2113// everyone who wants the typed shape without depending on the
2114// Rust struct directly.
2115
2116pub trait SchematicDomain {
2117 /// JSON Schema source for this type. Preserved verbatim from
2118 /// the CRD's openAPIV3Schema for forge-generated domains;
2119 /// hand-curated for non-CRD domains. Consumers parse this on
2120 /// demand — keeping it as a static string avoids paying
2121 /// serde_json::Value at startup for every domain.
2122 const SCHEMA_JSON: &'static str;
2123}
2124
2125#[derive(Clone, Copy, Debug)]
2126pub struct SchemaHandler {
2127 pub keyword: &'static str,
2128 pub schema_json: &'static str,
2129}
2130
2131static SCHEMA_REGISTRY: OnceLock<Mutex<HashMap<&'static str, SchemaHandler>>> = OnceLock::new();
2132
2133fn schema_registry() -> &'static Mutex<HashMap<&'static str, SchemaHandler>> {
2134 SCHEMA_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
2135}
2136
2137pub fn register_schema<T>()
2138where
2139 T: TataraDomain + SchematicDomain,
2140{
2141 let handler = SchemaHandler {
2142 keyword: T::KEYWORD,
2143 schema_json: T::SCHEMA_JSON,
2144 };
2145 schema_registry()
2146 .lock()
2147 .unwrap()
2148 .insert(T::KEYWORD, handler);
2149}
2150
2151#[must_use]
2152pub fn lookup_schema(keyword: &str) -> Option<SchemaHandler> {
2153 schema_registry().lock().unwrap().get(keyword).copied()
2154}
2155
2156#[must_use]
2157pub fn registered_schema_keywords() -> Vec<&'static str> {
2158 schema_registry().lock().unwrap().keys().copied().collect()
2159}
2160
2161// ── Attestable capability ─────────────────────────────────────────
2162//
2163// Sixth capability layer: each domain declares its **attestation
2164// namespace** — the bucket the tameshi BLAKE3 chain groups its
2165// resources under. The canonical hash itself is namespace-aware
2166// (`blake3(namespace || canonical_json(value))`) so two resources
2167// with identical content but different domains never collide in
2168// the attestation tree. Closes the trust loop in the rollout
2169// pipeline.
2170
2171pub trait AttestableDomain {
2172 /// Bucket name for the tameshi attestation chain. Forge-
2173 /// generated CRD domains use the CRD's group (e.g.
2174 /// `gateway.networking.k8s.io`); hand-written domains pick
2175 /// a stable namespace (e.g. `pleme.io/ebpf`). The namespace
2176 /// is hashed into the resource's BLAKE3 so cross-domain
2177 /// collisions are impossible.
2178 const ATTESTATION_NAMESPACE: &'static str;
2179}
2180
2181#[derive(Clone, Copy, Debug)]
2182pub struct AttestHandler {
2183 pub keyword: &'static str,
2184 pub namespace: &'static str,
2185}
2186
2187static ATTEST_REGISTRY: OnceLock<Mutex<HashMap<&'static str, AttestHandler>>> = OnceLock::new();
2188
2189fn attest_registry() -> &'static Mutex<HashMap<&'static str, AttestHandler>> {
2190 ATTEST_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
2191}
2192
2193pub fn register_attest<T>()
2194where
2195 T: TataraDomain + AttestableDomain,
2196{
2197 let handler = AttestHandler {
2198 keyword: T::KEYWORD,
2199 namespace: T::ATTESTATION_NAMESPACE,
2200 };
2201 attest_registry()
2202 .lock()
2203 .unwrap()
2204 .insert(T::KEYWORD, handler);
2205}
2206
2207#[must_use]
2208pub fn lookup_attest(keyword: &str) -> Option<AttestHandler> {
2209 attest_registry().lock().unwrap().get(keyword).copied()
2210}
2211
2212#[must_use]
2213pub fn registered_attest_keywords() -> Vec<&'static str> {
2214 attest_registry().lock().unwrap().keys().copied().collect()
2215}
2216
2217/// Compute a namespaced BLAKE3 attestation for a typed value.
2218///
2219/// `BLAKE3(ATTESTATION_NAMESPACE || ":" || canonical_json(value))`
2220///
2221/// The namespace prefix prevents cross-domain hash collisions in
2222/// the tameshi attestation tree — two resources with identical
2223/// JSON but different domain semantics produce different hashes.
2224/// The canonical-JSON serialization is what `serde_json::to_string`
2225/// produces; consumers can rely on the hash being stable across
2226/// processes given the same input value.
2227#[must_use]
2228pub fn attest_value(namespace: &str, value: &serde_json::Value) -> String {
2229 let canonical = serde_json::to_string(value).unwrap_or_default();
2230 let mut hasher = blake3::Hasher::new();
2231 hasher.update(namespace.as_bytes());
2232 hasher.update(b":");
2233 hasher.update(canonical.as_bytes());
2234 hasher.finalize().to_hex().to_string()
2235}
2236
2237// ── Validated capability ──────────────────────────────────────────
2238//
2239// Seventh capability layer: per-domain semantic validators. The
2240// first capability with **executable behavior** (not just static
2241// metadata) — the registry stores function pointers, not
2242// constants. Each domain plugs in its own logic; the env-level
2243// validator dispatches.
2244
2245/// Type that carries a semantic validator for its typed values.
2246/// Default impl returns `Ok(())` — so domains opt in, never
2247/// out. The validator runs AFTER `compile_from_args` succeeds —
2248/// it's a chance to enforce cross-field invariants the type
2249/// system alone can't catch (e.g. "if `kind = :xdp`, `attach`
2250/// must include an interface").
2251pub trait ValidatedDomain {
2252 /// Validate the typed JSON form of a domain instance. The
2253 /// default returns Ok — domains override to add real checks.
2254 /// Errors carry a human-readable message naming the
2255 /// offending field + constraint.
2256 fn validate_value(_value: &serde_json::Value) -> std::result::Result<(), String> {
2257 Ok(())
2258 }
2259}
2260
2261/// Erased validator handle — function pointer, no captured state.
2262#[derive(Clone, Copy)]
2263pub struct ValidateHandler {
2264 pub keyword: &'static str,
2265 pub validate: fn(&serde_json::Value) -> std::result::Result<(), String>,
2266}
2267
2268impl std::fmt::Debug for ValidateHandler {
2269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2270 f.debug_struct("ValidateHandler")
2271 .field("keyword", &self.keyword)
2272 .field("validate", &"<fn>")
2273 .finish()
2274 }
2275}
2276
2277static VALIDATE_REGISTRY: OnceLock<Mutex<HashMap<&'static str, ValidateHandler>>> = OnceLock::new();
2278
2279fn validate_registry() -> &'static Mutex<HashMap<&'static str, ValidateHandler>> {
2280 VALIDATE_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
2281}
2282
2283pub fn register_validate<T>()
2284where
2285 T: TataraDomain + ValidatedDomain,
2286{
2287 let handler = ValidateHandler {
2288 keyword: T::KEYWORD,
2289 validate: <T as ValidatedDomain>::validate_value,
2290 };
2291 validate_registry()
2292 .lock()
2293 .unwrap()
2294 .insert(T::KEYWORD, handler);
2295}
2296
2297#[must_use]
2298pub fn lookup_validate(keyword: &str) -> Option<ValidateHandler> {
2299 validate_registry().lock().unwrap().get(keyword).copied()
2300}
2301
2302#[must_use]
2303pub fn registered_validate_keywords() -> Vec<&'static str> {
2304 validate_registry()
2305 .lock()
2306 .unwrap()
2307 .keys()
2308 .copied()
2309 .collect()
2310}
2311
2312// ── Lifecycle capability ──────────────────────────────────────────
2313//
2314// Eighth capability layer: per-domain rollout strategy. Where
2315// Layer 4 (DependentDomain) declares **apply X before Y**, Layer
2316// 8 declares **when X changes, here's how to swap it**.
2317//
2318// Different shapes need different protocols:
2319// - service-shaped CRs (Gateway, Service): RollingUpdate
2320// - stateful resources (ConfigMaps owned by stateful sets):
2321// Recreate
2322// - kernel-attached programs (eBPF): BlueGreen — load new
2323// before unloading old, atomic-swap (the verifier rejects
2324// half-loaded state, so blue/green is the only safe shape)
2325// - config CRs (most CRD-shaped resources): Immediate
2326//
2327// `tatara-rollout` (and future `tatara-deploy`) consult this
2328// per Change to pick the right swap protocol for each resource.
2329
2330#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2331pub enum RolloutStrategy {
2332 /// Apply once, no transition. Most config-shaped CRDs.
2333 Immediate,
2334 /// Tear down, then create. Stateful resources where in-place
2335 /// updates aren't safe.
2336 Recreate,
2337 /// Standard rolling update — replace pod-by-pod with health
2338 /// probes between. Service-shaped CRs.
2339 RollingUpdate,
2340 /// Install new alongside old, switch traffic, drain old.
2341 /// Kernel-attached programs (eBPF) — the verifier won't
2342 /// accept half-loaded state, so blue/green is the only
2343 /// safe shape.
2344 BlueGreen,
2345 /// Percentage traffic shift over time. Service mesh primary
2346 /// pattern.
2347 Canary,
2348}
2349
2350pub trait LifecycleProtocol {
2351 /// How changes to this domain's resources roll out.
2352 const STRATEGY: RolloutStrategy;
2353 /// Seconds to wait for graceful termination before force-kill.
2354 /// 30s default matches K8s pod terminationGracePeriodSeconds.
2355 const DRAIN_SECONDS: u32 = 30;
2356}
2357
2358#[derive(Clone, Copy, Debug)]
2359pub struct LifecycleHandler {
2360 pub keyword: &'static str,
2361 pub strategy: RolloutStrategy,
2362 pub drain_seconds: u32,
2363}
2364
2365static LIFECYCLE_REGISTRY: OnceLock<Mutex<HashMap<&'static str, LifecycleHandler>>> =
2366 OnceLock::new();
2367
2368fn lifecycle_registry() -> &'static Mutex<HashMap<&'static str, LifecycleHandler>> {
2369 LIFECYCLE_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
2370}
2371
2372pub fn register_lifecycle<T>()
2373where
2374 T: TataraDomain + LifecycleProtocol,
2375{
2376 let handler = LifecycleHandler {
2377 keyword: T::KEYWORD,
2378 strategy: T::STRATEGY,
2379 drain_seconds: T::DRAIN_SECONDS,
2380 };
2381 lifecycle_registry()
2382 .lock()
2383 .unwrap()
2384 .insert(T::KEYWORD, handler);
2385}
2386
2387#[must_use]
2388pub fn lookup_lifecycle(keyword: &str) -> Option<LifecycleHandler> {
2389 lifecycle_registry().lock().unwrap().get(keyword).copied()
2390}
2391
2392#[must_use]
2393pub fn registered_lifecycle_keywords() -> Vec<&'static str> {
2394 lifecycle_registry()
2395 .lock()
2396 .unwrap()
2397 .keys()
2398 .copied()
2399 .collect()
2400}
2401
2402// ── Meta-compounder: capability_layer! macro ──────────────────────
2403//
2404// Layers 1–8 above each take ~50 lines of boilerplate (trait +
2405// handler struct + registry + 3 fns). The macro below collapses
2406// every static-data capability layer to ~10 lines of declaration.
2407// First-class compounding the compounding: each new layer is now
2408// shorter to author than its predecessors.
2409//
2410// Use the macro for layers whose trait holds only `const` items
2411// (and whose handler is a flat struct of those values). Layers
2412// with executable behavior (Validated, layer 7) keep the
2413// hand-written form because the trait carries a method, not
2414// constants — `fn validate_value(&Value) -> Result<…>` doesn't
2415// fit a `const` slot.
2416//
2417// Shape:
2418//
2419// capability_layer! {
2420// trait $Trait, // pub trait + name
2421// handler $Handler, // erased Handler struct
2422// static $REGISTRY, // backing OnceLock
2423// registry_fn $internal_fn, // private accessor
2424// register $register_fn, // pub register::<T>()
2425// lookup $lookup_fn, // pub lookup(kw) -> Option<Handler>
2426// list $list_fn, // pub list registered keywords
2427// consts {
2428// const NAME: ty => field name, // trait const → handler field
2429// ...
2430// }
2431// }
2432
2433#[macro_export]
2434macro_rules! capability_layer {
2435 (
2436 trait $Trait:ident,
2437 handler $Handler:ident,
2438 static $REGISTRY:ident,
2439 registry_fn $registry_fn:ident,
2440 register $register:ident,
2441 lookup $lookup:ident,
2442 list $list:ident,
2443 consts {
2444 $(const $CONST:ident: $ty:ty => field $field:ident),* $(,)?
2445 } $(,)?
2446 ) => {
2447 pub trait $Trait {
2448 $(const $CONST: $ty;)*
2449 }
2450
2451 #[derive(Clone, Copy, Debug)]
2452 pub struct $Handler {
2453 pub keyword: &'static str,
2454 $(pub $field: $ty,)*
2455 }
2456
2457 static $REGISTRY: ::std::sync::OnceLock<
2458 ::std::sync::Mutex<::std::collections::HashMap<&'static str, $Handler>>
2459 > = ::std::sync::OnceLock::new();
2460
2461 fn $registry_fn() -> &'static ::std::sync::Mutex<
2462 ::std::collections::HashMap<&'static str, $Handler>
2463 > {
2464 $REGISTRY.get_or_init(|| {
2465 ::std::sync::Mutex::new(::std::collections::HashMap::new())
2466 })
2467 }
2468
2469 pub fn $register<T>()
2470 where
2471 T: $crate::domain::TataraDomain + $Trait,
2472 {
2473 let handler = $Handler {
2474 keyword: T::KEYWORD,
2475 $($field: T::$CONST,)*
2476 };
2477 $registry_fn().lock().unwrap().insert(T::KEYWORD, handler);
2478 }
2479
2480 #[must_use]
2481 pub fn $lookup(keyword: &str) -> Option<$Handler> {
2482 $registry_fn().lock().unwrap().get(keyword).copied()
2483 }
2484
2485 #[must_use]
2486 pub fn $list() -> Vec<&'static str> {
2487 $registry_fn().lock().unwrap().keys().copied().collect()
2488 }
2489 };
2490}
2491
2492// ── Layer 9: Compliant capability (via the macro) ─────────────────
2493//
2494// First layer authored with the meta-compounder. Compounding the
2495// compounding made operational. Per-domain compliance posture —
2496// which baselines the resource satisfies (NIST 800-53, CIS,
2497// FedRAMP, PCI DSS, SOC 2). Consumers: kensa (compliance engine),
2498// sekiban (admission webhook), tameshi (heartbeat chain).
2499
2500capability_layer! {
2501 trait CompliantDomain,
2502 handler ComplianceHandler,
2503 static COMPLIANCE_REGISTRY,
2504 registry_fn compliance_registry,
2505 register register_compliance,
2506 lookup lookup_compliance,
2507 list registered_compliance_keywords,
2508 consts {
2509 const FRAMEWORKS: &'static [&'static str] => field frameworks,
2510 const CONTROLS: &'static [&'static str] => field controls,
2511 }
2512}
2513
2514// ── Layer 10: Observable capability (via the macro) ───────────────
2515//
2516// Per-domain Prometheus metric prefix + log label names.
2517// Consumers: arch-synthesizer (auto-generates ServiceMonitor +
2518// PodMonitor specs that scrape the right prefixes) and the
2519// Loki query layer (knows which labels each domain emits).
2520
2521capability_layer! {
2522 trait ObservableDomain,
2523 handler ObservabilityHandler,
2524 static OBSERVABILITY_REGISTRY,
2525 registry_fn observability_registry,
2526 register register_observability,
2527 lookup lookup_observability,
2528 list registered_observability_keywords,
2529 consts {
2530 const METRIC_PREFIX: &'static str => field metric_prefix,
2531 const LOG_LABELS: &'static [&'static str] => field log_labels,
2532 }
2533}
2534
2535// ── Layer 11: Authoring help capability (via the macro) ───────────
2536//
2537// Per-domain authoring examples + a one-liner mnemonic for the
2538// catalog browser. Consumers: tatara-doc (renders examples in
2539// the catalog), IDE hover-help, the future `tatara init` CLI
2540// that scaffolds new programs from examples.
2541
2542capability_layer! {
2543 trait HelpDomain,
2544 handler HelpHandler,
2545 static HELP_REGISTRY,
2546 registry_fn help_registry,
2547 register register_help,
2548 lookup lookup_help,
2549 list registered_help_keywords,
2550 consts {
2551 const MNEMONIC: &'static str => field mnemonic,
2552 const EXAMPLES: &'static [&'static str] => field examples,
2553 }
2554}
2555
2556// ── Layer 12: Stable capability (via the macro) ───────────────────
2557//
2558// Per-domain stability signal. Consumers: caixa-lint (warns on
2559// unstable usages), tatara-doc (decorates the catalog), CI
2560// gates (blocks promotion to prod when an unstable resource
2561// crosses a `:tier "prod"` env boundary).
2562
2563capability_layer! {
2564 trait StableDomain,
2565 handler StabilityHandler,
2566 static STABILITY_REGISTRY,
2567 registry_fn stability_registry,
2568 register register_stability,
2569 lookup lookup_stability,
2570 list registered_stability_keywords,
2571 consts {
2572 const STABILITY: &'static str => field stability,
2573 const SINCE_VERSION: &'static str => field since_version,
2574 }
2575}
2576
2577// ── Meta-meta-compounder: impl_default_capabilities! ──────────────
2578//
2579// Forge-generated domains plug into the platform with a single
2580// macro call:
2581//
2582// impl_default_capabilities!(MyDomainSpec);
2583//
2584// Expands to default `impl` blocks for every static-data
2585// capability layer that *has* a meaningful default. Layers
2586// without a sensible default (Render, Validated — Render needs
2587// real api_version+kind, Validated has its trait-default
2588// `validate_value`) are skipped here; the forge emits those
2589// separately when CRD metadata is available.
2590//
2591// **Why this matters**: previously, adding a new capability
2592// layer required editing both `tatara-lisp::domain` (define the
2593// layer) AND `tatara-domain-forge::emit` (emit per-layer impl
2594// blocks). Now the forge's emit is a single line; new layers
2595// land in this macro alone. Compounding the compounding the
2596// compounding — three orders deep.
2597
2598#[macro_export]
2599macro_rules! impl_default_capabilities {
2600 ($Spec:ty) => {
2601 // NOTE: Layer 3 (Documented) is intentionally NOT here.
2602 // Forge-generated domains emit it explicitly with real
2603 // docs from CRD descriptions; hand-written domains
2604 // override directly. The macro covering it would create
2605 // a double-impl conflict in both cases.
2606 //
2607 // Layer 4 — Dependent (forge default empty).
2608 impl $crate::domain::DependentDomain for $Spec {
2609 const DEPENDS_ON: &'static [&'static str] = &[];
2610 }
2611 // Layer 7 — Validated (uses the trait's default fn).
2612 impl $crate::domain::ValidatedDomain for $Spec {}
2613 // Layer 8 — Lifecycle (Immediate is the safe CRD default).
2614 impl $crate::domain::LifecycleProtocol for $Spec {
2615 const STRATEGY: $crate::domain::RolloutStrategy =
2616 $crate::domain::RolloutStrategy::Immediate;
2617 }
2618 // Layer 9 — Compliance (claims none by default).
2619 impl $crate::domain::CompliantDomain for $Spec {
2620 const FRAMEWORKS: &'static [&'static str] = &[];
2621 const CONTROLS: &'static [&'static str] = &[];
2622 }
2623 // Layer 10 — Observable (no metrics by default).
2624 impl $crate::domain::ObservableDomain for $Spec {
2625 const METRIC_PREFIX: &'static str = "";
2626 const LOG_LABELS: &'static [&'static str] = &[];
2627 }
2628 // Layer 11 — Authoring help.
2629 impl $crate::domain::HelpDomain for $Spec {
2630 const MNEMONIC: &'static str = "";
2631 const EXAMPLES: &'static [&'static str] = &[];
2632 }
2633 // Layer 12 — Stability (assume stable + 0.1.0 unless
2634 // overridden; loud-failure beats silent missing field).
2635 impl $crate::domain::StableDomain for $Spec {
2636 const STABILITY: &'static str = "stable";
2637 const SINCE_VERSION: &'static str = "0.1.0";
2638 }
2639 };
2640}
2641
2642/// Companion to `impl_default_capabilities!` — registers every
2643/// layer's handler in one call. Domains that have explicit
2644/// Render + Schema + Attest metadata also call those register
2645/// fns separately (they're not part of this macro because not
2646/// every domain has them — hand-written ebpf doesn't have render
2647/// metadata). Adding a new always-present layer means updating
2648/// this macro and `impl_default_capabilities!` once.
2649///
2650/// Expands to an **expression** of type `Result<(), KeywordCollision>`, not a
2651/// statement block: the handler registry is the only one of the nine layers
2652/// that can refuse, and swallowing that refusal inside the macro would put the
2653/// silent overwrite back one level down where it is harder to see. The eight
2654/// capability layers still overwrite — they carry metadata about a keyword
2655/// whose ownership the handler registry has already adjudicated, so a second
2656/// writer there cannot change which struct a form compiles to.
2657#[macro_export]
2658macro_rules! register_all_capabilities {
2659 ($Spec:ty) => {{
2660 $crate::domain::register_doc::<$Spec>();
2661 $crate::domain::register_deps::<$Spec>();
2662 $crate::domain::register_validate::<$Spec>();
2663 $crate::domain::register_lifecycle::<$Spec>();
2664 $crate::domain::register_compliance::<$Spec>();
2665 $crate::domain::register_observability::<$Spec>();
2666 $crate::domain::register_help::<$Spec>();
2667 $crate::domain::register_stability::<$Spec>();
2668 $crate::domain::register::<$Spec>()
2669 }};
2670}
2671
2672// ── Sexp ↔ serde_json bridge (universal type support) ──────────────
2673//
2674// Lets the derive macro fall through to `serde_json::from_value` for any
2675// field type implementing `Deserialize`. Handles enums (via symbol→string),
2676// nested structs (via kwargs→object), and `Vec<T>` of either.
2677
2678use serde_json::Value as JValue;
2679
2680/// Thin delegate to [`Sexp::to_json`] retained for callers that want
2681/// the free-function reach — the canonical site is now the inherent
2682/// method on the [`Sexp`] algebra (sibling-lift posture to
2683/// [`super::domain::sexp_shape`] → [`Sexp::shape`] (commit 121bb60)
2684/// and [`super::domain::sexp_witness`] → [`Sexp::witness`] (commit
2685/// a427e3b)). Rules + round-trip semantics live at
2686/// [`Sexp::to_json`]'s docstring.
2687///
2688/// Composition law: `sexp_to_json(s) == s.to_json()` for every `s:
2689/// &Sexp`. Pre-lift the dispatcher lived here as the canonical site;
2690/// post-lift the inherent method [`Sexp::to_json`] is the canonical
2691/// site and this free function delegates so existing callers
2692/// continue to compile.
2693pub fn sexp_to_json(s: &Sexp) -> Result<JValue> {
2694 s.to_json()
2695}
2696
2697/// Thin delegate to [`Sexp::from_json`] retained for callers that want
2698/// the free-function reach — the canonical site is now the inherent
2699/// associated function on the [`Sexp`] algebra (sibling-lift posture to
2700/// [`super::domain::sexp_to_json`] → [`Sexp::to_json`] (commit 875ee3b),
2701/// [`super::domain::sexp_witness`] → [`Sexp::witness`] (commit a427e3b),
2702/// and [`super::domain::sexp_shape`] → [`Sexp::shape`] (commit
2703/// 121bb60)). Rules + round-trip semantics live at
2704/// [`Sexp::from_json`]'s docstring.
2705///
2706/// Composition law: `json_to_sexp(v) == Sexp::from_json(v)` for every
2707/// `v: &JValue`. Pre-lift the dispatcher lived here as the canonical
2708/// site; post-lift the inherent associated function
2709/// [`Sexp::from_json`] is the canonical site and this free function
2710/// delegates so existing callers continue to compile. With this lift the
2711/// substrate's `Sexp` ↔ `serde_json::Value` round-trip closure
2712/// ([`Sexp::to_json`] + [`Sexp::from_json`]) lives entirely on the
2713/// [`Sexp`] algebra; the four free functions that pre-dated the lift
2714/// chain (`sexp_to_json`, `json_to_sexp`, `sexp_shape`, `sexp_witness`)
2715/// are all delegates now — the canonical-form / structural-projection
2716/// surface is structurally on the algebra.
2717pub fn json_to_sexp(v: &JValue) -> Sexp {
2718 Sexp::from_json(v)
2719}
2720
2721/// `must-reach` → `mustReach`, `point-type` → `pointType`.
2722pub(crate) fn kebab_to_camel(s: &str) -> String {
2723 let mut out = String::with_capacity(s.len());
2724 let mut upper = false;
2725 for c in s.chars() {
2726 if c == '-' {
2727 upper = true;
2728 } else if upper {
2729 out.extend(c.to_uppercase());
2730 upper = false;
2731 } else {
2732 out.push(c);
2733 }
2734 }
2735 out
2736}
2737
2738/// `mustReach` → `must-reach` (inverse of `kebab_to_camel`).
2739pub(crate) fn camel_to_kebab(s: &str) -> String {
2740 let mut out = String::with_capacity(s.len() + 2);
2741 for (i, c) in s.chars().enumerate() {
2742 if c.is_uppercase() && i > 0 {
2743 out.push('-');
2744 out.extend(c.to_lowercase());
2745 } else {
2746 out.push(c);
2747 }
2748 }
2749 out
2750}
2751
2752// ── TypedRewriter — the self-optimization primitive ────────────────
2753//
2754// Takes a typed value, converts to Sexp, applies a Lisp rewrite, then
2755// re-enters the typed boundary via `compile_from_args`. Any rewrite that
2756// passes the typed re-validation is safe by construction — the Rust type
2757// system is the floor.
2758
2759/// Rewrite a typed `T` through Lisp form and re-validate on the way back.
2760///
2761/// The rewriter receives the value's kwargs representation (a `Sexp::List`
2762/// of alternating keywords + values) and returns a modified kwargs list.
2763/// `T::compile_from_args` validates the result — any ill-formed rewrite
2764/// produces a typed error; any well-formed rewrite produces a valid `T`.
2765pub fn rewrite_typed<T, F>(input: T, rewrite: F) -> Result<T>
2766where
2767 T: TataraDomain + serde::Serialize,
2768 F: FnOnce(Sexp) -> Result<Sexp>,
2769{
2770 let json = serde_json::to_value(&input).map_err(|e| LispError::Compile {
2771 form: T::KEYWORD.to_string(),
2772 message: format!("serialize {}: {e}", T::KEYWORD),
2773 })?;
2774 let sexp = json_to_sexp(&json);
2775 let rewritten = rewrite(sexp)?;
2776 let args = match rewritten {
2777 Sexp::List(items) => items,
2778 other => {
2779 return Err(LispError::Compile {
2780 form: T::KEYWORD.to_string(),
2781 message: format!("rewriter must return a list; got {other}"),
2782 })
2783 }
2784 };
2785 T::compile_from_args(&args)
2786}
2787
2788#[cfg(test)]
2789mod tests {
2790 use super::*;
2791 use crate::reader::read;
2792 use serde::Serialize;
2793 use tatara_lisp_derive::TataraDomain as DeriveTataraDomain;
2794
2795 /// Example domain authorable as Lisp — proves derive macro, trait, and
2796 /// registry all agree end-to-end.
2797 #[derive(DeriveTataraDomain, Serialize, Debug, PartialEq)]
2798 #[tatara(keyword = "defmonitor")]
2799 struct MonitorSpec {
2800 name: String,
2801 query: String,
2802 threshold: f64,
2803 window_seconds: Option<i64>,
2804 tags: Vec<String>,
2805 enabled: Option<bool>,
2806 }
2807
2808 #[test]
2809 fn derive_emits_correct_keyword() {
2810 assert_eq!(MonitorSpec::KEYWORD, "defmonitor");
2811 }
2812
2813 // ── The two kwarg gates, each proved to REJECT ──────────────────
2814 //
2815 // Both of these forms used to compile successfully, which is the
2816 // whole reason the helper layer moved: a `HashMap::insert` whose
2817 // return value was discarded made a repeated `:key` a silent
2818 // last-one-wins, and the total absence of an allowed-set check made
2819 // a typo'd `:key` a silent fall-through to the field's default.
2820 // Both are now typed rejections at the parse boundary, and both
2821 // tests fail (compile succeeds, `unwrap_err` panics) if either gate
2822 // is ever removed.
2823
2824 /// G1 — a repeated `:key` is REJECTED, and the diagnostic names it.
2825 #[test]
2826 fn duplicate_kwarg_is_rejected_not_silently_last_wins() {
2827 let forms = read(
2828 r#"(defmonitor
2829 :name "first"
2830 :query "up"
2831 :threshold 0.5
2832 :name "second")"#,
2833 )
2834 .expect("reads");
2835 let err = MonitorSpec::compile_from_sexp(&forms[0])
2836 .expect_err("a repeated :name must not parse — pre-fix it silently took \"second\"");
2837 assert!(
2838 matches!(&err, LispError::DuplicateKwarg { key } if key == "name"),
2839 "expected DuplicateKwarg {{ key: \"name\" }}, got {err:?}"
2840 );
2841 }
2842
2843 /// G1, the other half — the FIRST binding is not quietly kept either.
2844 /// Rejection, not a silent choice of winner, is the contract.
2845 #[test]
2846 fn duplicate_kwarg_rejects_rather_than_picking_a_winner() {
2847 let forms =
2848 read(r#"(defmonitor :name "a" :name "a" :query "q" :threshold 1.0)"#).expect("reads");
2849 assert!(MonitorSpec::compile_from_sexp(&forms[0]).is_err());
2850 }
2851
2852 /// G2 — an unknown `:key` is REJECTED rather than ignored, and a
2853 /// near-miss carries an edit-distance hint.
2854 #[test]
2855 fn unknown_kwarg_is_rejected_with_a_suggestion() {
2856 let forms = read(
2857 r#"(defmonitor
2858 :name "prom-up"
2859 :query "up"
2860 :threshold 0.5
2861 :thrshold 0.9)"#,
2862 )
2863 .expect("reads");
2864 let err = MonitorSpec::compile_from_sexp(&forms[0]).expect_err(
2865 "a typo'd :thrshold must not parse — pre-fix it was dropped and `threshold` \
2866 kept whatever the correctly-spelled slot held",
2867 );
2868 let LispError::UnknownKwarg { key, hint, .. } = &err else {
2869 panic!("expected UnknownKwarg, got {err:?}");
2870 };
2871 assert_eq!(key, "thrshold");
2872 assert_eq!(
2873 hint.as_deref(),
2874 Some("threshold"),
2875 "a one-character transposition is inside the suggestion bound"
2876 );
2877 }
2878
2879 /// G2, the far-from-anything case — still rejected, just without a
2880 /// hint. A wrong hint is worse than no hint.
2881 #[test]
2882 fn unknown_kwarg_with_no_near_match_is_still_rejected() {
2883 let forms =
2884 read(r#"(defmonitor :name "n" :query "q" :threshold 1.0 :zzzzzzzz 1)"#).expect("reads");
2885 let err = MonitorSpec::compile_from_sexp(&forms[0]).expect_err("unknown key must reject");
2886 let LispError::UnknownKwarg { key, hint, .. } = &err else {
2887 panic!("expected UnknownKwarg, got {err:?}");
2888 };
2889 assert_eq!(key, "zzzzzzzz");
2890 assert_eq!(hint.as_deref(), None);
2891 }
2892
2893 // ── G3 — the RANGE gate on the four numeric arms ────────────────
2894 //
2895 // Third gate, same posture as G1/G2 above: every form in this block
2896 // used to COMPILE SUCCESSFULLY and put a number in the struct that
2897 // the author never wrote. The derive emitted `extract_int(&kw,
2898 // key)? as u32`, and Rust's `as` is total by truncating — it wraps,
2899 // sign-flips, and saturates to `inf` without a word. So the failure
2900 // was not a bad diagnostic; it was silent data corruption behind a
2901 // green build.
2902 //
2903 // Each test below names the exact wrong value the pre-fix code
2904 // produced, so the test doubles as the record of what regressed if
2905 // the narrowing is ever backed out: restore the `as` casts and
2906 // every one of these fails at `expect_err`.
2907
2908 /// A domain whose numeric fields are all NARROWER than the reader's
2909 /// wide `i64` / `f64` — the shape the `as` cast corrupted. Covers
2910 /// both axes and both required/optional arms, i.e. all four numeric
2911 /// branches of `extractor_for`.
2912 #[derive(DeriveTataraDomain, Serialize, Debug, PartialEq)]
2913 #[tatara(keyword = "defnarrow")]
2914 struct NarrowSpec {
2915 port: u32,
2916 offset: i32,
2917 scale: f32,
2918 retries: Option<u32>,
2919 ratio: Option<f32>,
2920 }
2921
2922 fn narrow_form(body: &str) -> LispError {
2923 let forms = read(body).expect("reads");
2924 NarrowSpec::compile_from_sexp(&forms[0])
2925 .expect_err("an out-of-range numeric literal must not parse")
2926 }
2927
2928 /// The in-range case must still parse — a range gate that rejects
2929 /// valid input is worse than the truncation it replaced. Includes a
2930 /// value that LOSES PRECISION at `f32` (`0.1`) to pin that lossy-
2931 /// but-representable is accepted: an `f32` field asked for `f32`.
2932 #[test]
2933 fn narrowing_accepts_every_in_range_value_including_lossy_f32() {
2934 let forms = read(r"(defnarrow :port 8080 :offset -42 :scale 0.1 :retries 3 :ratio 2.5)")
2935 .expect("reads");
2936 let spec = NarrowSpec::compile_from_sexp(&forms[0]).expect("in-range values must parse");
2937 assert_eq!(
2938 spec,
2939 NarrowSpec {
2940 port: 8080,
2941 offset: -42,
2942 #[allow(clippy::cast_possible_truncation)]
2943 scale: 0.1_f64 as f32,
2944 retries: Some(3),
2945 ratio: Some(2.5),
2946 }
2947 );
2948 }
2949
2950 /// `u32` overflow. Pre-fix: `4294967296 as u32` == `0`, and the
2951 /// struct came back holding a port of zero.
2952 #[test]
2953 fn int_above_the_target_width_is_rejected_not_truncated() {
2954 let err = narrow_form(r"(defnarrow :port 4294967296 :offset 0 :scale 1.0)");
2955 let LispError::KwargOutOfRange {
2956 form,
2957 target,
2958 value,
2959 } = &err
2960 else {
2961 panic!("expected KwargOutOfRange, got {err:?}");
2962 };
2963 assert_eq!(form, &KwargPath::named("port"));
2964 assert_eq!(*target, NumericWidth::U32);
2965 assert_eq!(*value, NumericLiteral::Int(4_294_967_296));
2966 }
2967
2968 /// A NEGATIVE literal into an unsigned width. Pre-fix: `-1 as u32`
2969 /// == `4294967295` — not a truncation but a sign flip, and the
2970 /// worst of the family because the resulting number looks plausible.
2971 #[test]
2972 fn negative_int_into_an_unsigned_width_is_rejected_not_sign_flipped() {
2973 let err = narrow_form(r"(defnarrow :port -1 :offset 0 :scale 1.0)");
2974 assert!(
2975 matches!(
2976 &err,
2977 LispError::KwargOutOfRange {
2978 target: NumericWidth::U32,
2979 value: NumericLiteral::Int(-1),
2980 ..
2981 }
2982 ),
2983 "expected a u32 range rejection of -1, got {err:?}"
2984 );
2985 }
2986
2987 /// `i32` overflow — the signed sibling. Pre-fix: `2147483648 as i32`
2988 /// == `-2147483648`, a positive literal landing as a negative field.
2989 #[test]
2990 fn int_above_the_signed_target_width_is_rejected_not_wrapped() {
2991 let err = narrow_form(r"(defnarrow :port 0 :offset 2147483648 :scale 1.0)");
2992 assert!(
2993 matches!(
2994 &err,
2995 LispError::KwargOutOfRange {
2996 target: NumericWidth::I32,
2997 value: NumericLiteral::Int(2_147_483_648),
2998 ..
2999 }
3000 ),
3001 "expected an i32 range rejection, got {err:?}"
3002 );
3003 }
3004
3005 /// `f32` overflow. Pre-fix: `1e300 as f32` == `inf`, so a finite
3006 /// input became a non-finite field and every downstream arithmetic
3007 /// on it produced `inf`/`NaN`.
3008 #[test]
3009 fn float_above_the_target_width_is_rejected_not_saturated_to_infinity() {
3010 let err = narrow_form(r"(defnarrow :port 0 :offset 0 :scale 1.0e300)");
3011 let LispError::KwargOutOfRange { target, value, .. } = &err else {
3012 panic!("expected KwargOutOfRange, got {err:?}");
3013 };
3014 assert_eq!(*target, NumericWidth::F32);
3015 assert!(
3016 matches!(value, NumericLiteral::Float(x) if (*x - 1.0e300).abs() < f64::EPSILON),
3017 "the diagnostic must echo the author's own literal, got {value:?}"
3018 );
3019 }
3020
3021 /// The OPTIONAL arms carry the same gate. A present-but-out-of-range
3022 /// optional is a rejection, never a quiet `None` — dropping the
3023 /// value would be the same corruption in a different costume.
3024 #[test]
3025 fn optional_numeric_arms_reject_out_of_range_rather_than_yielding_none() {
3026 let int_err = narrow_form(r"(defnarrow :port 0 :offset 0 :scale 1.0 :retries 4294967296)");
3027 assert!(
3028 matches!(
3029 &int_err,
3030 LispError::KwargOutOfRange {
3031 target: NumericWidth::U32,
3032 ..
3033 }
3034 ),
3035 "expected an Option<u32> range rejection, got {int_err:?}"
3036 );
3037
3038 let float_err = narrow_form(r"(defnarrow :port 0 :offset 0 :scale 1.0 :ratio 1.0e300)");
3039 assert!(
3040 matches!(
3041 &float_err,
3042 LispError::KwargOutOfRange {
3043 target: NumericWidth::F32,
3044 ..
3045 }
3046 ),
3047 "expected an Option<f32> range rejection, got {float_err:?}"
3048 );
3049 }
3050
3051 /// An ABSENT optional is still `None` — the gate fires on presence,
3052 /// not on the field's existence.
3053 #[test]
3054 fn absent_optional_numeric_kwargs_stay_none_under_the_range_gate() {
3055 let forms = read(r"(defnarrow :port 1 :offset 1 :scale 1.0)").expect("reads");
3056 let spec = NarrowSpec::compile_from_sexp(&forms[0]).expect("absent optionals are legal");
3057 assert_eq!(spec.retries, None);
3058 assert_eq!(spec.ratio, None);
3059 }
3060
3061 /// The rendered diagnostic names the kwarg, the value, and the
3062 /// width — the three facts an author needs to fix the source. Pinned
3063 /// because this is the operator-facing surface, not just the variant.
3064 #[test]
3065 fn the_range_diagnostic_names_kwarg_value_and_target_width() {
3066 let err = narrow_form(r"(defnarrow :port 4294967296 :offset 0 :scale 1.0)");
3067 assert_eq!(
3068 err.to_string(),
3069 "compile error in :port: 4294967296 is out of range for u32"
3070 );
3071 }
3072
3073 /// The IDENTITY widths must not have become rejections: `i64` and
3074 /// `f64` fields route through the same `NarrowNumeric` projection
3075 /// with a total impl, so `i64::MIN` still parses. `MonitorSpec`'s
3076 /// `threshold: f64` / `window_seconds: Option<i64>` are the fields
3077 /// under test — the same two the pre-fix code cast with a no-op
3078 /// `as i64` / `as f64`.
3079 #[test]
3080 fn identity_widths_stay_total_under_the_range_gate() {
3081 let forms = read(
3082 r#"(defmonitor :name "n" :query "q" :threshold 1.0 :window-seconds -9223372036854775808)"#,
3083 )
3084 .expect("reads");
3085 let spec = MonitorSpec::compile_from_sexp(&forms[0])
3086 .expect("i64::MIN is representable at the identity width");
3087 assert_eq!(spec.window_seconds, Some(i64::MIN));
3088 }
3089
3090 /// The typed width identity is sourced from the TYPE, not from a
3091 /// literal the derive interpolated — `NarrowNumeric::WIDTH` is the
3092 /// single producer, so a mislabeled diagnostic is unconstructible
3093 /// rather than merely untested.
3094 #[test]
3095 fn every_supported_width_reports_its_own_typed_identity() {
3096 assert_eq!(<i32 as NarrowNumeric<i64>>::WIDTH, NumericWidth::I32);
3097 assert_eq!(<i64 as NarrowNumeric<i64>>::WIDTH, NumericWidth::I64);
3098 assert_eq!(<u32 as NarrowNumeric<i64>>::WIDTH, NumericWidth::U32);
3099 assert_eq!(<u64 as NarrowNumeric<i64>>::WIDTH, NumericWidth::U64);
3100 assert_eq!(<usize as NarrowNumeric<i64>>::WIDTH, NumericWidth::Usize);
3101 assert_eq!(<f32 as NarrowNumeric<f64>>::WIDTH, NumericWidth::F32);
3102 assert_eq!(<f64 as NarrowNumeric<f64>>::WIDTH, NumericWidth::F64);
3103 }
3104
3105 /// The float axis WIDENS an int literal — `Sexp::as_float` is
3106 /// `a.as_float().or_else(|| a.as_int().map(|n| n as f64))`, so an
3107 /// `f32` field accepts `:scale 7`. The int axis does NOT narrow a
3108 /// float (`Sexp::as_int` is strict), so `:port 3.5` is a shape
3109 /// rejection.
3110 ///
3111 /// Neither half is new here; both are pinned because the
3112 /// tatara-lisp `(cast T x)` caster COPIES this asymmetry rather than
3113 /// tidying it, and a change to `Sexp::as_float` that moved one
3114 /// surface without the other would put the two back into
3115 /// disagreement — the exact defect the caster was built to avoid.
3116 #[test]
3117 fn the_float_axis_widens_an_int_literal_and_the_int_axis_does_not_narrow_a_float() {
3118 let forms = read(r"(defnarrow :port 8080 :offset 0 :scale 7)").expect("reads");
3119 let spec = NarrowSpec::compile_from_sexp(&forms[0])
3120 .expect("an int literal widens into an f32 field");
3121 assert!((spec.scale - 7.0).abs() < f32::EPSILON);
3122
3123 let err = narrow_form(r"(defnarrow :port 3.5 :offset 0 :scale 1.0)");
3124 assert!(
3125 matches!(&err, LispError::TypeMismatch { .. }),
3126 "a float into an int field is a SHAPE rejection, got {err:?}"
3127 );
3128 }
3129
3130 // ── Runtime-dispatched narrowing (`NumericWidth::narrow_literal`) ──
3131 //
3132 // The `(cast :u32 x)` surface in tatara-lisp-eval learns its target
3133 // width from a keyword, so it cannot call the type-parameterized
3134 // `NarrowNumeric::narrow`. These pin the dispatch as EQUIVALENT to
3135 // the typed path rather than merely similar — the whole reason the
3136 // caster and the derive can be claimed to agree.
3137
3138 /// Axis assignment is the closed set's own partition: the five int
3139 /// widths come from `extract_int`'s `i64`, the two float widths from
3140 /// `extract_float`'s `f64`. Swept over `ALL` so an eighth width
3141 /// cannot be added without landing here.
3142 #[test]
3143 fn every_width_declares_the_axis_its_extractor_produces() {
3144 for w in NumericWidth::ALL {
3145 let expected = match w {
3146 NumericWidth::I32
3147 | NumericWidth::I64
3148 | NumericWidth::U32
3149 | NumericWidth::U64
3150 | NumericWidth::Usize => NumericAxis::Int,
3151 NumericWidth::F32 | NumericWidth::F64 => NumericAxis::Float,
3152 };
3153 assert_eq!(w.axis(), expected, "{w} declared the wrong wide axis");
3154 }
3155 }
3156
3157 /// THE AGREEMENT PIN. For every width, at every boundary value that
3158 /// matters, `narrow_literal`'s accept/reject verdict is the verdict
3159 /// `NarrowNumeric::narrow` gives. The two are written in different
3160 /// styles — one generic, one a runtime match — and this is what
3161 /// stops the runtime one from drifting into its own bounds table.
3162 #[test]
3163 fn narrow_literal_agrees_with_the_typed_projection() {
3164 macro_rules! agree_int {
3165 ($ty:ty => $width:ident, $($v:expr),+ $(,)?) => {$(
3166 let v: i64 = $v;
3167 let typed = <$ty as NarrowNumeric<i64>>::narrow(v).is_some();
3168 let dynamic = NumericWidth::$width
3169 .narrow_literal(NumericLiteral::Int(v))
3170 .is_some();
3171 assert_eq!(
3172 typed, dynamic,
3173 "NumericWidth::{} disagreed with <{} as NarrowNumeric<i64>> on {}",
3174 stringify!($width), stringify!($ty), v
3175 );
3176 )+};
3177 }
3178 agree_int!(i32 => I32, 0, -1, i64::from(i32::MAX), 2_147_483_648, i64::MIN);
3179 agree_int!(i64 => I64, 0, -1, i64::MAX, i64::MIN);
3180 agree_int!(u32 => U32, 0, -1, 4_294_967_295, 4_294_967_296, i64::MIN);
3181 agree_int!(u64 => U64, 0, -1, i64::MAX, i64::MIN);
3182 agree_int!(usize => Usize, 0, -1, i64::MAX);
3183
3184 for v in [0.0_f64, 0.1, 2.5, 1.0e300, -1.0e300, f64::INFINITY] {
3185 for (w, typed) in [
3186 (
3187 NumericWidth::F32,
3188 <f32 as NarrowNumeric<f64>>::narrow(v).is_some(),
3189 ),
3190 (
3191 NumericWidth::F64,
3192 <f64 as NarrowNumeric<f64>>::narrow(v).is_some(),
3193 ),
3194 ] {
3195 assert_eq!(
3196 typed,
3197 w.narrow_literal(NumericLiteral::Float(v)).is_some(),
3198 "{w} disagreed with its NarrowNumeric impl on {v}"
3199 );
3200 }
3201 }
3202 }
3203
3204 /// The int axis round-trips EXACTLY — a successful `TryFrom<i64>` is
3205 /// injective, so the literal handed back is the literal handed in.
3206 /// A dispatch that re-widened through the narrow type (`u32 as i64`)
3207 /// would pass this too; a dispatch that reconstructed the number
3208 /// some other way would not.
3209 #[test]
3210 fn accepted_int_literals_come_back_unchanged() {
3211 for (w, v) in [
3212 (NumericWidth::I32, -2_147_483_648_i64),
3213 (NumericWidth::I64, i64::MIN),
3214 (NumericWidth::U32, 4_294_967_295),
3215 (NumericWidth::U64, i64::MAX),
3216 (NumericWidth::Usize, 8080),
3217 ] {
3218 assert_eq!(
3219 w.narrow_literal(NumericLiteral::Int(v)),
3220 Some(NumericLiteral::Int(v)),
3221 "{w} must return the author's own literal, not a re-derived one"
3222 );
3223 }
3224 }
3225
3226 /// The float axis does NOT round-trip, on purpose: `f32` accepts
3227 /// precision loss, so the honest answer is the value the field would
3228 /// actually hold. `0.1` is the canonical witness — the same literal
3229 /// `narrowing_accepts_every_in_range_value_including_lossy_f32`
3230 /// pins on the derive side.
3231 #[test]
3232 fn accepted_f32_literals_come_back_at_f32_precision() {
3233 #[allow(clippy::cast_possible_truncation)]
3234 let expected = f64::from(0.1_f64 as f32);
3235 assert_eq!(
3236 NumericWidth::F32.narrow_literal(NumericLiteral::Float(0.1)),
3237 Some(NumericLiteral::Float(expected))
3238 );
3239 assert_ne!(expected, 0.1_f64, "the f32 round-trip must actually differ");
3240 // f64 is the identity width — it must NOT perturb anything.
3241 assert_eq!(
3242 NumericWidth::F64.narrow_literal(NumericLiteral::Float(0.1)),
3243 Some(NumericLiteral::Float(0.1))
3244 );
3245 }
3246
3247 /// A literal on the wrong axis is REJECTED, never converted. This is
3248 /// the off-the-good-path arm — a caster routes by `axis()` first —
3249 /// and it must never invent a number, because a silent float→int
3250 /// conversion here would be the exact class of corruption S2 removed.
3251 #[test]
3252 fn a_cross_axis_literal_is_rejected_rather_than_converted() {
3253 for w in NumericWidth::ALL {
3254 let wrong = match w.axis() {
3255 NumericAxis::Int => NumericLiteral::Float(3.0),
3256 NumericAxis::Float => NumericLiteral::Int(3),
3257 };
3258 assert_eq!(
3259 w.narrow_literal(wrong),
3260 None,
3261 "{w} converted a cross-axis literal instead of rejecting it"
3262 );
3263 }
3264 }
3265
3266 /// `f32`'s narrowing rejects exactly one thing — a FINITE input that
3267 /// overflows — and passes an already-non-finite input through. A
3268 /// naive `is_finite()` guard would have rejected an authored `inf`
3269 /// that `as` had preserved correctly, which would be a regression
3270 /// dressed as a fix.
3271 #[test]
3272 fn f32_narrowing_rejects_only_finite_overflow() {
3273 assert_eq!(<f32 as NarrowNumeric<f64>>::narrow(1.0), Some(1.0_f32));
3274 assert_eq!(<f32 as NarrowNumeric<f64>>::narrow(1.0e300), None);
3275 assert_eq!(<f32 as NarrowNumeric<f64>>::narrow(-1.0e300), None);
3276 assert_eq!(
3277 <f32 as NarrowNumeric<f64>>::narrow(f64::INFINITY),
3278 Some(f32::INFINITY)
3279 );
3280 assert!(<f32 as NarrowNumeric<f64>>::narrow(f64::NAN)
3281 .expect("NaN passes through")
3282 .is_nan());
3283 }
3284
3285 /// The gate must not over-reject: every declared field's kebab key —
3286 /// including the ones reached through the four extractor branches —
3287 /// stays accepted. This is what would fail if the allowed-set were
3288 /// collected inside a branch that `continue`s.
3289 #[test]
3290 fn every_declared_field_key_stays_accepted() {
3291 let forms = read(
3292 r#"(defmonitor
3293 :name "n"
3294 :query "q"
3295 :threshold 1.0
3296 :window-seconds 30
3297 :tags ("a")
3298 :enabled #f)"#,
3299 )
3300 .expect("reads");
3301 MonitorSpec::compile_from_sexp(&forms[0]).expect("all six declared keys must remain valid");
3302 }
3303
3304 #[test]
3305 fn derive_compiles_full_form() {
3306 let forms = read(
3307 r#"(defmonitor
3308 :name "prom-up"
3309 :query "up{job='prometheus'}"
3310 :threshold 0.99
3311 :window-seconds 300
3312 :tags ("prod" "observability")
3313 :enabled #t)"#,
3314 )
3315 .unwrap();
3316 let spec = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
3317 assert_eq!(
3318 spec,
3319 MonitorSpec {
3320 name: "prom-up".into(),
3321 query: "up{job='prometheus'}".into(),
3322 threshold: 0.99,
3323 window_seconds: Some(300),
3324 tags: vec!["prod".into(), "observability".into()],
3325 enabled: Some(true),
3326 }
3327 );
3328 }
3329
3330 #[test]
3331 fn derive_accepts_missing_optionals() {
3332 let forms = read(r#"(defmonitor :name "x" :query "q" :threshold 0.5)"#).unwrap();
3333 let spec = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
3334 assert_eq!(spec.name, "x");
3335 assert!(spec.window_seconds.is_none());
3336 assert!(spec.enabled.is_none());
3337 assert!(spec.tags.is_empty());
3338 }
3339
3340 #[test]
3341 fn derive_errors_on_missing_required() {
3342 let forms = read(r#"(defmonitor :name "x" :query "q")"#).unwrap();
3343 assert!(MonitorSpec::compile_from_sexp(&forms[0]).is_err());
3344 }
3345
3346 #[test]
3347 fn derive_errors_on_wrong_head() {
3348 let forms = read(r#"(not-a-monitor :name "x")"#).unwrap();
3349 let err = MonitorSpec::compile_from_sexp(&forms[0]).unwrap_err();
3350 assert!(format!("{err}").contains("expected (defmonitor"));
3351 }
3352
3353 #[test]
3354 fn registry_dispatches_by_keyword() {
3355 register::<MonitorSpec>().expect("keyword namespace must be free in this test binary");
3356 assert!(registered_keywords().contains(&"defmonitor"));
3357 let handler = lookup("defmonitor").expect("registered");
3358 assert_eq!(handler.keyword, "defmonitor");
3359 let forms = read(r#"(ignored :name "prom" :query "q" :threshold 0.5)"#).unwrap();
3360 let args = forms[0].as_list().unwrap();
3361 let json = (handler.compile)(&args[1..]).unwrap();
3362 assert_eq!(json["name"], "prom");
3363 assert_eq!(json["query"], "q");
3364 assert_eq!(json["threshold"], 0.5);
3365 }
3366
3367 // ── assert_tatara_domain_well_formed — the substrate-wide testkit ──
3368
3369 #[test]
3370 fn assert_tatara_domain_well_formed_passes_on_the_derive_reference_impl() {
3371 // The reference implementor `MonitorSpec` inherits the trait
3372 // default `compile_from_sexp` through the derive; every one of
3373 // the four rejection gates (bare atom, empty list, non-symbol
3374 // head, wrong-head symbol) MUST fire with the substrate-wide
3375 // structural `LispError` variant, AND its KEYWORD `"defmonitor"`
3376 // MUST pass the three grammar invariants (non-empty; classifies
3377 // as `Atom::Symbol` via `Atom::from_lexeme`; contains no
3378 // `Sexp::is_bare_atom_boundary` char) AND the round-trip
3379 // theorem (`read("defmonitor")` projects to
3380 // `Some("defmonitor")`). The single line below pins all EIGHT
3381 // at once — every future `#[derive(TataraDomain)]` implementor
3382 // reduces to the same one-line check in its test module,
3383 // mirroring the `assert_closed_set_well_formed` deployment
3384 // across 44+ closed-set implementor test sites.
3385 assert_tatara_domain_well_formed::<MonitorSpec>();
3386 }
3387
3388 #[test]
3389 fn assert_tatara_domain_well_formed_panics_on_empty_keyword() {
3390 // Negative arm on invariant (1) — a hand-written impl whose
3391 // KEYWORD is the empty string tries to be a keyword-less
3392 // dispatch target. The trait can't discriminate `(some-form
3393 // …)` from `(other-form …)` without a lexeme, so the testkit
3394 // MUST fire on this degenerate shape. Uses `catch_unwind` to
3395 // observe the panic without terminating the test process —
3396 // same posture the closed-set testkit's negative-arm tests
3397 // take (see `closed_set.rs`).
3398 struct EmptyKeyword;
3399 impl TataraDomain for EmptyKeyword {
3400 const KEYWORD: &'static str = "";
3401 fn compile_from_args(_: &[Sexp]) -> Result<Self> {
3402 unreachable!("compile_from_args unreachable — invariant (1) trips first")
3403 }
3404 }
3405 let result = std::panic::catch_unwind(|| {
3406 assert_tatara_domain_well_formed::<EmptyKeyword>();
3407 });
3408 let payload = result.expect_err("expected empty-KEYWORD invariant to panic");
3409 let msg = payload
3410 .downcast_ref::<String>()
3411 .map(String::as_str)
3412 .or_else(|| payload.downcast_ref::<&'static str>().copied())
3413 .unwrap_or("");
3414 assert!(
3415 msg.contains("KEYWORD is empty"),
3416 "expected empty-KEYWORD panic message to name the invariant, got {msg:?}",
3417 );
3418 }
3419
3420 /// Extract the panic message of the closure so the test module's
3421 /// negative-arm sweep binds to ONE substrate-owned decode instead of
3422 /// re-inlining the `catch_unwind` + `downcast_ref::<String>` + fallback
3423 /// to `&'static str` cascade at every arm.
3424 fn assert_panic_msg_contains(needle: &str, f: impl FnOnce() + std::panic::UnwindSafe) {
3425 let result = std::panic::catch_unwind(f);
3426 let payload = result.expect_err("expected invariant to panic");
3427 let msg = payload
3428 .downcast_ref::<String>()
3429 .map(String::as_str)
3430 .or_else(|| payload.downcast_ref::<&'static str>().copied())
3431 .unwrap_or("");
3432 assert!(
3433 msg.contains(needle),
3434 "expected panic message to contain {needle:?}, got {msg:?}",
3435 );
3436 }
3437
3438 #[test]
3439 fn assert_tatara_domain_well_formed_panics_on_ascii_whitespace_keyword() {
3440 // Negative arm on invariant (7) — a KEYWORD like `"def foo"`
3441 // would arrive at the trait's head-match as two tokens because
3442 // `Sexp::is_bare_atom_boundary(' ') == true` via
3443 // `char::is_whitespace`. The testkit MUST catch this before an
3444 // integration surface silently drops the trailing word. The
3445 // pre-lift ASCII-only heuristic caught the same case; the
3446 // sharpened invariant catches it via the substrate's typed
3447 // reader-boundary projection.
3448 struct WhitespaceKeyword;
3449 impl TataraDomain for WhitespaceKeyword {
3450 const KEYWORD: &'static str = "def foo";
3451 fn compile_from_args(_: &[Sexp]) -> Result<Self> {
3452 unreachable!("compile_from_args unreachable — invariant (7) trips first")
3453 }
3454 }
3455 assert_panic_msg_contains("reader-boundary char", || {
3456 assert_tatara_domain_well_formed::<WhitespaceKeyword>();
3457 });
3458 }
3459
3460 #[test]
3461 fn assert_tatara_domain_well_formed_panics_on_unicode_whitespace_keyword() {
3462 // Negative arm on invariant (7) — a KEYWORD carrying the
3463 // no-break-space codepoint `\u{00A0}` (a Unicode-whitespace
3464 // char the pre-lift `is_ascii_whitespace()` check silently
3465 // accepted). The reader's outer-dispatch calls
3466 // `char::is_whitespace()` (Unicode-aware) via
3467 // `Sexp::is_bare_atom_boundary`, so a KEYWORD `"def\u{00A0}foo"`
3468 // would split into two tokens. Binding the invariant to the
3469 // substrate's typed reader-boundary projection closes this hole
3470 // that the pre-lift ASCII-only heuristic left open.
3471 struct NbspKeyword;
3472 impl TataraDomain for NbspKeyword {
3473 const KEYWORD: &'static str = "def\u{00A0}foo";
3474 fn compile_from_args(_: &[Sexp]) -> Result<Self> {
3475 unreachable!("compile_from_args unreachable — invariant (7) trips first")
3476 }
3477 }
3478 assert_panic_msg_contains("reader-boundary char", || {
3479 assert_tatara_domain_well_formed::<NbspKeyword>();
3480 });
3481 }
3482
3483 #[test]
3484 fn assert_tatara_domain_well_formed_panics_on_list_open_char_keyword() {
3485 // Negative arm on invariant (7) — a KEYWORD like `"def(x"`
3486 // embeds `Sexp::LIST_OPEN` mid-lexeme; the reader's bare-atom
3487 // terminator disjunct fires on `(`, splitting the token so the
3488 // trait's head-match would see `"def"` followed by an opening
3489 // paren — the head-match would fire on `"def"`, silently
3490 // matching a DIFFERENT keyword. This is the reader-boundary
3491 // hole the pre-lift ASCII-whitespace heuristic silently
3492 // accepted; binding to `Sexp::is_bare_atom_boundary` catches
3493 // it structurally.
3494 struct ListOpenKeyword;
3495 impl TataraDomain for ListOpenKeyword {
3496 const KEYWORD: &'static str = "def(x";
3497 fn compile_from_args(_: &[Sexp]) -> Result<Self> {
3498 unreachable!("compile_from_args unreachable — invariant (7) trips first")
3499 }
3500 }
3501 assert_panic_msg_contains("reader-boundary char", || {
3502 assert_tatara_domain_well_formed::<ListOpenKeyword>();
3503 });
3504 }
3505
3506 #[test]
3507 fn assert_tatara_domain_well_formed_panics_on_comment_lead_char_keyword() {
3508 // Negative arm on invariant (7) — a KEYWORD like `"def;bad"`
3509 // embeds `Sexp::COMMENT_LEAD` mid-lexeme; the reader's outer
3510 // dispatch would treat `;` as the start of a line comment,
3511 // discarding everything after it up to newline. The trait's
3512 // head-match would fire on `"def"` (the token before `;`),
3513 // silently matching a DIFFERENT keyword. Sibling coverage to
3514 // the list-open arm above on the seven-terminator disjunction
3515 // `Sexp::NON_WHITESPACE_BARE_ATOM_TERMINATORS`.
3516 struct CommentLeadKeyword;
3517 impl TataraDomain for CommentLeadKeyword {
3518 const KEYWORD: &'static str = "def;bad";
3519 fn compile_from_args(_: &[Sexp]) -> Result<Self> {
3520 unreachable!("compile_from_args unreachable — invariant (7) trips first")
3521 }
3522 }
3523 assert_panic_msg_contains("reader-boundary char", || {
3524 assert_tatara_domain_well_formed::<CommentLeadKeyword>();
3525 });
3526 }
3527
3528 #[test]
3529 fn assert_tatara_domain_well_formed_panics_on_keyword_marker_prefix() {
3530 // Negative arm on invariant (6) — a KEYWORD `":foo"` classifies
3531 // as `Atom::Keyword` via the reader's `Atom::from_lexeme`
3532 // classifier (the `:` prefix is stripped and the remainder
3533 // becomes the keyword payload). The pre-lift "no leading ASCII
3534 // digit" heuristic silently accepted this shape; the sharpened
3535 // invariant binds to the substrate's typed classifier so the
3536 // shape rejects structurally.
3537 struct KeywordMarkerKeyword;
3538 impl TataraDomain for KeywordMarkerKeyword {
3539 const KEYWORD: &'static str = ":foo";
3540 fn compile_from_args(_: &[Sexp]) -> Result<Self> {
3541 unreachable!("compile_from_args unreachable — invariant (6) trips first")
3542 }
3543 }
3544 assert_panic_msg_contains("Atom::from_lexeme", || {
3545 assert_tatara_domain_well_formed::<KeywordMarkerKeyword>();
3546 });
3547 }
3548
3549 #[test]
3550 fn assert_tatara_domain_well_formed_panics_on_bool_literal_keyword() {
3551 // Negative arm on invariant (6) — a KEYWORD `"#t"` classifies
3552 // as `Atom::Bool(true)` via `Atom::from_lexeme`'s bool-literal
3553 // arm. The pre-lift heuristic silently accepted this shape
3554 // (starts with `#`, not a digit); the sharpened invariant binds
3555 // to the substrate's typed classifier so the shape rejects
3556 // structurally. Peer coverage to the `:foo` arm above on the
3557 // classifier's non-`Symbol` decode paths.
3558 struct BoolLiteralKeyword;
3559 impl TataraDomain for BoolLiteralKeyword {
3560 const KEYWORD: &'static str = "#t";
3561 fn compile_from_args(_: &[Sexp]) -> Result<Self> {
3562 unreachable!("compile_from_args unreachable — invariant (6) trips first")
3563 }
3564 }
3565 assert_panic_msg_contains("Atom::from_lexeme", || {
3566 assert_tatara_domain_well_formed::<BoolLiteralKeyword>();
3567 });
3568 }
3569
3570 #[test]
3571 fn assert_tatara_domain_well_formed_panics_on_numeric_keyword() {
3572 // Negative arm on invariant (6) — a KEYWORD `"42"` classifies
3573 // as `Atom::Int(42)` via `Atom::from_lexeme`'s `parse::<i64>`
3574 // arm. The pre-lift heuristic caught this via the leading-
3575 // digit check; the sharpened invariant catches it via the
3576 // classifier's typed decode — a stricter check with a
3577 // structurally-named diagnostic.
3578 struct NumericKeyword;
3579 impl TataraDomain for NumericKeyword {
3580 const KEYWORD: &'static str = "42";
3581 fn compile_from_args(_: &[Sexp]) -> Result<Self> {
3582 unreachable!("compile_from_args unreachable — invariant (6) trips first")
3583 }
3584 }
3585 assert_panic_msg_contains("Atom::from_lexeme", || {
3586 assert_tatara_domain_well_formed::<NumericKeyword>();
3587 });
3588 }
3589
3590 #[test]
3591 fn assert_tatara_domain_well_formed_panics_on_drifted_compile_from_sexp() {
3592 // Negative arm on invariant (4) — an override that swallows
3593 // the bare-atom form and returns `Ok(_)` drifts the trait's
3594 // typed-entry gate; the testkit MUST fire on this drift so
3595 // the substrate-wide `NotAListForm` contract stays enforced
3596 // across every implementor rather than only across those that
3597 // keep the trait default.
3598 #[derive(Debug, PartialEq)]
3599 struct SwallowsBareAtom;
3600 impl TataraDomain for SwallowsBareAtom {
3601 const KEYWORD: &'static str = "defbogus";
3602 fn compile_from_args(_: &[Sexp]) -> Result<Self> {
3603 Ok(SwallowsBareAtom)
3604 }
3605 fn compile_from_sexp(_form: &Sexp) -> Result<Self> {
3606 // Intentionally-broken: this override accepts EVERY
3607 // form, including a bare atom — the drift the testkit
3608 // catches.
3609 Ok(SwallowsBareAtom)
3610 }
3611 }
3612 let result = std::panic::catch_unwind(|| {
3613 assert_tatara_domain_well_formed::<SwallowsBareAtom>();
3614 });
3615 let payload = result.expect_err("expected drifted-override invariant to panic");
3616 let msg = payload
3617 .downcast_ref::<String>()
3618 .map(String::as_str)
3619 .or_else(|| payload.downcast_ref::<&'static str>().copied())
3620 .unwrap_or("");
3621 assert!(
3622 msg.contains("accepted a bare-atom form"),
3623 "expected drifted-override panic message to name the invariant, got {msg:?}",
3624 );
3625 }
3626}