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