caixa_core/dialeto.rs
1//! `defcaixa` is spoken by two unrelated declarations. This module makes that
2//! a **typed fact** instead of an anonymous parse failure.
3//!
4//! # The finding
5//!
6//! Measured 2026-07-31 over the pleme-io org checkout (270 `caixa.lisp` /
7//! `*.caixa.lisp` files found with `rg --no-ignore`; a bare `rg` from the org
8//! root returns 0, which is how this stayed invisible), the corpus splits into
9//! two schemas that share zero required slots:
10//!
11//! * [`CaixaDialeto::Pacote`] — this crate's [`crate::Caixa`]. `:nome
12//! :versao :kind :deps :bibliotecas :exe :servicos` + the supervisor/mesh
13//! slots. It declares a **tatara-lisp package**: the thing `feira` resolves,
14//! builds, links and publishes.
15//! * [`CaixaDialeto::Molde`] — `:name :kind :ecosystem :package {…} :workflows
16//! […] :ci-config {…} :files […]`. It declares a **repo's generated
17//! surface**: which foreign ecosystem (rust / go / python / …), that
18//! ecosystem's own package metadata, the CI shims to emit, and byte-captured
19//! file bodies. Read by `pleme-doc-gen`, never by `feira`.
20//!
21//! `:package`, `:ecosystem`, `:supports` and `:profile` have no counterpart in
22//! [`crate::Caixa`] at all — the theory doc's own D4 note records the same
23//! thing: those manifests "are authored against a schema that does not exist in
24//! Rust". They are not two spellings of one declaration. They are two domains
25//! that collided on one word, because *caixa* names a box and both are boxes.
26//!
27//! # Why this is not a bug report about broken files
28//!
29//! The Molde-dialect files are not malformed. They are correct inputs to their
30//! own consumer, and nothing in the shipped `feira` reads them, so nothing is
31//! failing today. The hazard is **latent and certain**: any new declarative
32//! surface written against "a `.caixa.lisp` is a [`crate::Caixa`]" meets a
33//! corpus where that is false for the large majority of files, and gets a flat
34//! unknown-keyword rejection that reads as "this manifest is broken" rather
35//! than "this manifest is not yours".
36//!
37//! # What this module does about it
38//!
39//! [`classify`] is total: every `(defcaixa …)` form lands in exactly one
40//! [`CaixaDialeto`], including [`CaixaDialeto::Desconhecido`] for one that
41//! matches neither. [`crate::Caixa::from_lisp`] runs it first, so a foreign
42//! dialect is [`crate::ManifestError::DialetoEstrangeiro`] — an error that
43//! names the dialect it found and the consumer that speaks it — rather than an
44//! unknown-kwarg error indistinguishable from a typo.
45//!
46//! Tier-honest: this is **parse-time rejection with a named cause**, not
47//! unrepresentability. A caller that ignores the `Err` still gets nothing
48//! useful; what it can no longer do is mistake "wrong dialect" for "bad file".
49
50use tatara_lisp::{Atom, Sexp};
51
52/// Which `(defcaixa …)` declaration a source speaks.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum CaixaDialeto {
55 /// This crate's [`crate::Caixa`] — a tatara-lisp package manifest.
56 /// Keyword-argument form headed by `:nome`.
57 Pacote,
58 /// `pleme-doc-gen`'s repo-surface declaration, keyword-argument form
59 /// headed by `:name` (plus `:ecosystem` / `:package`).
60 Molde,
61 /// The same declaration as [`Self::Molde`], written with the package name
62 /// as a bare positional symbol — `(defcaixa todoku-go :kind :Biblioteca
63 /// :ecosystem :go …)`. `pleme-doc-gen`'s parser reads the first token
64 /// after the head as the name, so this is one arity of one declaration,
65 /// not a third schema.
66 MoldePosicional,
67 /// A `(defcaixa …)` form matching neither. Kept as a variant rather than
68 /// an error so [`classify`] is total and a census can COUNT the residue —
69 /// a classifier that threw here would report "0 unknown" by construction.
70 Desconhecido,
71}
72
73impl CaixaDialeto {
74 /// Exhaustive iteration surface for every consumer that walks the
75 /// closed four-arm [`CaixaDialeto`] discriminator set — the
76 /// [`feira dialeto`](../../caixa_feira/cmd/dialeto/index.html)
77 /// census counter's per-arm accept-set, a future
78 /// `feira dialeto --list-dialects` CLI listing of the accepted
79 /// classifications, a future M4 `mesh.pleme.io/v1alpha1/Manifesto`
80 /// CR materializer's admission-webhook rejection body naming the
81 /// accepted-dialect set, any future census-report shape probe that
82 /// sweeps every arm to compute per-arm coverage. A future arm
83 /// addition (a fifth dialect the [`crate::dialeto`] module doc's
84 /// "third dialect" hazard actualises — the module explicitly frames
85 /// its purpose as "what stops a third dialect appearing", and this
86 /// slice is the substrate-side answer: the arm-set is one edit and
87 /// every consumer picks up the new entry by construction) extends
88 /// this slice as one edit and every downstream consumer picks up
89 /// the new entry through the shared iteration; the compiler-checked
90 /// exhaustiveness on the sibling method `match` arms
91 /// ([`Self::palavra_canonica`] / [`Self::consumidor`] /
92 /// [`Self::descricao`] / [`std::fmt::Display`]) is the build-time
93 /// guarantee that no arm forgets to grow.
94 ///
95 /// Peer of the sibling closed-set typed enums'
96 /// [`crate::CaixaKind::ALL`] (6b1f4fb) /
97 /// [`crate::aplicacao::PlacementStrategy::ALL`] (18c7342) /
98 /// [`crate::aplicacao::RateLimitUnit::ALL`] (6bce03d) /
99 /// [`crate::dep::DepList::ALL`] (45ee563) /
100 /// [`crate::supervisor::RestartStrategy::ALL`] (4eec29c) /
101 /// [`crate::supervisor::RestartPolicy::ALL`] (dd32ccf)
102 /// exhaustive-iteration surfaces — the seventh closed-set typed
103 /// enum on the caixa surface to converge onto the same
104 /// one-canonical-arm-list-per-enum discipline, and the first
105 /// dialect-classification axis (as distinct from an OTP-shape M2
106 /// slot or an M3 mesh slot) to reach it. Order matches variant
107 /// declaration order verbatim (`Pacote` → `Molde` →
108 /// `MoldePosicional` → `Desconhecido`) so the slice is the
109 /// canonical ordering every listing / rendering consumer defers to.
110 pub const ALL: &'static [Self] = &[
111 Self::Pacote,
112 Self::Molde,
113 Self::MoldePosicional,
114 Self::Desconhecido,
115 ];
116
117 /// The keyword an author should write for this dialect, once the
118 /// migration named in [`Self::consumidor`] completes.
119 #[must_use]
120 pub const fn palavra_canonica(self) -> &'static str {
121 match self {
122 Self::Pacote => "defcaixa",
123 Self::Molde | Self::MoldePosicional => "defmolde",
124 Self::Desconhecido => "?",
125 }
126 }
127
128 /// Who reads this dialect.
129 #[must_use]
130 pub const fn consumidor(self) -> &'static str {
131 match self {
132 Self::Pacote => "caixa-core / feira",
133 Self::Molde | Self::MoldePosicional => "pleme-doc-gen",
134 Self::Desconhecido => "nobody known",
135 }
136 }
137
138 /// A one-line description for a census row or an error message.
139 #[must_use]
140 pub const fn descricao(self) -> &'static str {
141 match self {
142 Self::Pacote => "tatara-lisp package manifest (:nome :versao :kind :deps …)",
143 Self::Molde => "repo-surface declaration (:name :ecosystem :package {…} …)",
144 Self::MoldePosicional => {
145 "repo-surface declaration, positional name (defcaixa <nome> :kind …)"
146 }
147 Self::Desconhecido => "unrecognised — matches no known defcaixa schema",
148 }
149 }
150}
151
152impl std::fmt::Display for CaixaDialeto {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 f.write_str(match self {
155 Self::Pacote => "Pacote",
156 Self::Molde => "Molde",
157 Self::MoldePosicional => "MoldePosicional",
158 Self::Desconhecido => "Desconhecido",
159 })
160 }
161}
162
163/// A source that is not a `(defcaixa …)` / `(defmolde …)` form at all.
164#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
165pub enum DialetoError {
166 #[error("source has no top-level form")]
167 Vazio,
168 #[error("top-level form is not a list — a manifest is `(defcaixa …)`")]
169 NaoEhLista,
170 #[error(
171 "top-level form is headed by `{encontrado}`, not `defcaixa` or `defmolde` \
172 (a manifest's first form must be the declaration itself)"
173 )]
174 CabecaErrada { encontrado: String },
175 #[error("manifest does not parse as tatara-lisp: {0}")]
176 Leitura(String),
177}
178
179/// Classify a manifest source without committing to either schema.
180///
181/// Deliberately reads only the head symbol and the set of top-level keywords —
182/// enough to route, never enough to half-parse. A classifier that started
183/// validating would grow into a third parser, which is the shape of the problem
184/// it exists to name.
185///
186/// # Errors
187/// [`DialetoError`] when the source is not a manifest declaration at all.
188pub fn classify(src: &str) -> Result<CaixaDialeto, DialetoError> {
189 let forms = tatara_lisp::read(src).map_err(|e| DialetoError::Leitura(e.to_string()))?;
190 let first = forms.first().ok_or(DialetoError::Vazio)?;
191 classify_form(first)
192}
193
194/// [`classify`] over an already-read form.
195///
196/// # Errors
197/// [`DialetoError`] when the form is not a manifest declaration.
198pub fn classify_form(form: &Sexp) -> Result<CaixaDialeto, DialetoError> {
199 let list = form.as_list().ok_or(DialetoError::NaoEhLista)?;
200 let head = list
201 .first()
202 .and_then(Sexp::as_symbol)
203 .ok_or(DialetoError::NaoEhLista)?;
204
205 match head {
206 // `defmolde` is unambiguous by construction — it exists precisely so a
207 // consumer never has to infer which declaration it holds. Both arities
208 // are the same declaration; the positional one keeps its own variant
209 // only so a census can report the split.
210 "defmolde" => {
211 return Ok(if starts_with_positional_name(&list[1..]) {
212 CaixaDialeto::MoldePosicional
213 } else {
214 CaixaDialeto::Molde
215 });
216 }
217 "defcaixa" => {}
218 other => {
219 return Err(DialetoError::CabecaErrada {
220 encontrado: other.to_string(),
221 });
222 }
223 }
224
225 let args = &list[1..];
226
227 // `(defcaixa <symbol> :kind … :ecosystem …)`. Only the Molde dialect has a
228 // positional arity; `Caixa` is keyword-only, so a leading bare symbol
229 // settles it without looking further.
230 if starts_with_positional_name(args) {
231 return Ok(CaixaDialeto::MoldePosicional);
232 }
233
234 let keys = top_level_keywords(args);
235 let has = |k: &str| keys.iter().any(|s| s == k);
236
237 // Order matters, and it is not arbitrary: `:nome` and `:name` are the two
238 // required head slots and no file in the measured corpus carries both.
239 // Checking them FIRST means the decision rests on the one slot each schema
240 // makes mandatory, rather than on optional evidence like `:ecosystem`.
241 if has("nome") {
242 return Ok(CaixaDialeto::Pacote);
243 }
244 if has("name") || has("ecosystem") || has("package") {
245 return Ok(CaixaDialeto::Molde);
246 }
247 Ok(CaixaDialeto::Desconhecido)
248}
249
250/// True when the first argument is a bare symbol rather than a keyword — the
251/// positional-name arity.
252fn starts_with_positional_name(args: &[Sexp]) -> bool {
253 matches!(args.first(), Some(Sexp::Atom(Atom::Symbol(_))))
254}
255
256/// The top-level keyword names (without the leading `:`) of a kwarg list.
257///
258/// Steps in pairs so a keyword appearing as a VALUE — `:kind :Biblioteca`, or a
259/// nested `(:nome "dep" :versao "^0.1")` inside `:deps` — is never counted as a
260/// top-level slot. A naive scan for `:nome` anywhere in the source classifies
261/// every Molde manifest with a `:deps` list as a Pacote.
262fn top_level_keywords(args: &[Sexp]) -> Vec<String> {
263 let mut out = Vec::new();
264 let mut i = 0;
265 while i < args.len() {
266 if let Sexp::Atom(Atom::Keyword(k)) = &args[i] {
267 out.push(k.clone());
268 i += 2;
269 } else {
270 i += 1;
271 }
272 }
273 out
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 const PACOTE: &str = r#"
281 (defcaixa
282 :nome "checkout"
283 :versao "0.1.0"
284 :kind Servico
285 :deps ((:nome "caixa-teia" :versao "^0.1")))
286 "#;
287
288 const MOLDE: &str = r#"
289 (defcaixa
290 :name "base64"
291 :kind :Biblioteca
292 :ecosystem :rust-single-crate
293 :package {:name "base64" :version "0.22.1"}
294 :workflows [:auto-release])
295 "#;
296
297 const MOLDE_POSICIONAL: &str = r#"
298 (defcaixa todoku-go
299 :kind :Biblioteca
300 :ecosystem :go
301 :package {:name "todoku-go" :version "0.3.0"})
302 "#;
303
304 #[test]
305 fn the_package_dialect_is_recognised() {
306 assert_eq!(classify(PACOTE), Ok(CaixaDialeto::Pacote));
307 }
308
309 #[test]
310 fn the_repo_surface_dialect_is_recognised() {
311 assert_eq!(classify(MOLDE), Ok(CaixaDialeto::Molde));
312 }
313
314 #[test]
315 fn the_positional_arity_is_recognised() {
316 assert_eq!(
317 classify(MOLDE_POSICIONAL),
318 Ok(CaixaDialeto::MoldePosicional)
319 );
320 }
321
322 #[test]
323 fn defmolde_classifies_without_inference() {
324 // The whole point of the new keyword: no schema sniffing required.
325 let src = r#"(defmolde :name "x" :kind :Biblioteca :ecosystem :go)"#;
326 assert_eq!(classify(src), Ok(CaixaDialeto::Molde));
327 let pos = r"(defmolde todoku-go :kind :Biblioteca :ecosystem :go)";
328 assert_eq!(classify(pos), Ok(CaixaDialeto::MoldePosicional));
329 }
330
331 #[test]
332 fn a_nested_nome_does_not_make_a_repo_surface_look_like_a_package() {
333 // The exact failure a substring scan produces: `:deps ((:nome …))`
334 // contains `:nome`, but not as a top-level slot.
335 let src = r#"
336 (defcaixa
337 :name "x"
338 :ecosystem :rust-single-crate
339 :deps ((:nome "inner" :versao "^0.1")))
340 "#;
341 assert_eq!(classify(src), Ok(CaixaDialeto::Molde));
342 }
343
344 #[test]
345 fn a_keyword_in_value_position_is_not_a_slot() {
346 // `:kind :Biblioteca` — the value is itself a keyword. Stepping one at
347 // a time would read `:Biblioteca` as a top-level slot.
348 let src = r#"(defcaixa :kind :Biblioteca :name "x")"#;
349 assert_eq!(classify(src), Ok(CaixaDialeto::Molde));
350 }
351
352 #[test]
353 fn an_unrecognised_defcaixa_is_reported_not_guessed() {
354 let src = r#"(defcaixa :licenca "MIT")"#;
355 assert_eq!(classify(src), Ok(CaixaDialeto::Desconhecido));
356 }
357
358 #[test]
359 fn a_form_that_is_not_a_manifest_is_an_error_not_a_dialect() {
360 assert_eq!(
361 classify("(defflake :nome \"x\")"),
362 Err(DialetoError::CabecaErrada {
363 encontrado: "defflake".into()
364 })
365 );
366 assert_eq!(classify(""), Err(DialetoError::Vazio));
367 }
368
369 #[test]
370 fn every_dialect_names_its_consumer_and_its_canonical_keyword() {
371 // Guards the routing table itself: a new variant added without an arm
372 // here is a compile error in the match, and a variant that claims
373 // `defcaixa` while being read by pleme-doc-gen would re-open the
374 // collision this module closes. Sweeps [`CaixaDialeto::ALL`] rather
375 // than the pre-lift open-coded four-arm literal list — a future arm
376 // addition extends the slice as one edit and this pin picks it up
377 // by construction.
378 for &d in CaixaDialeto::ALL {
379 assert!(!d.descricao().is_empty(), "{d}");
380 assert!(!d.consumidor().is_empty(), "{d}");
381 }
382 assert_eq!(CaixaDialeto::Pacote.palavra_canonica(), "defcaixa");
383 assert_eq!(CaixaDialeto::Molde.palavra_canonica(), "defmolde");
384 assert_ne!(
385 CaixaDialeto::Pacote.palavra_canonica(),
386 CaixaDialeto::Molde.palavra_canonica(),
387 "the two dialects must not share a canonical keyword — that IS the defect"
388 );
389 }
390
391 #[test]
392 fn caixa_dialeto_all_enumerates_every_variant_exactly_once() {
393 // Three-legged exhaustiveness pin, peer of the sibling
394 // `caixa_kind_all_enumerates_every_variant_exactly_once`
395 // (caixa-core/src/kind.rs) /
396 // `restart_strategy_all_enumerates_every_variant_exactly_once`
397 // (caixa-core/src/supervisor.rs) shape.
398 //
399 // 1. arm-count invariant: `ALL.len()` matches the declared arm
400 // count (four — a fifth arm added without extending `ALL`
401 // fails this pin at caixa-core test time);
402 // 2. pairwise-distinctness invariant: every variant appears at
403 // most once in the slice (a duplicate arm would silently
404 // double-count in the census consumer, so the pin rejects
405 // duplicates outright);
406 // 3. coverage invariant: every literal `CaixaDialeto::X` is in
407 // the slice (the compiler-checked exhaustiveness on the peer
408 // per-arm `match self` in the accessors keeps the enum arm
409 // set and the `ALL` slice mutually aligned).
410 assert_eq!(
411 CaixaDialeto::ALL.len(),
412 4,
413 "ALL must list every arm exactly once; a fifth arm added \
414 without extending ALL fails this pin — extend ALL alongside \
415 the new variant"
416 );
417
418 let mut seen: Vec<CaixaDialeto> = Vec::new();
419 for &d in CaixaDialeto::ALL {
420 assert!(
421 !seen.contains(&d),
422 "ALL contains a duplicate arm: {d}. Every variant appears \
423 exactly once — a duplicate would double-count in every \
424 iteration consumer"
425 );
426 seen.push(d);
427 }
428
429 // Coverage: exhaustively assert every literal variant is somewhere
430 // in the slice. Written as an exhaustive `match` so a future arm
431 // addition fails to compile here (missing match arm) until the
432 // corresponding `assert` is added — the compiler enforces the pin's
433 // completeness rather than a hand-maintained variant list.
434 for variant in [
435 CaixaDialeto::Pacote,
436 CaixaDialeto::Molde,
437 CaixaDialeto::MoldePosicional,
438 CaixaDialeto::Desconhecido,
439 ] {
440 let coverage_probe = match variant {
441 CaixaDialeto::Pacote
442 | CaixaDialeto::Molde
443 | CaixaDialeto::MoldePosicional
444 | CaixaDialeto::Desconhecido => variant,
445 };
446 assert!(
447 CaixaDialeto::ALL.contains(&coverage_probe),
448 "ALL is missing variant {coverage_probe} — extend the slice"
449 );
450 }
451 }
452
453 #[test]
454 fn caixa_dialeto_all_is_const_and_matches_iteration_count() {
455 // Pins the const-ness of the slice at const-fold time. A future
456 // change that promoted `ALL` to a non-const initializer (a lazy-
457 // static, a runtime-computed Vec) would fail to compile here —
458 // the pin locks in the compile-time-known iteration surface
459 // every consumer builds against. Peer of the sibling
460 // `caixa_kind_all_is_const_and_matches_iteration_count` (kind.rs)
461 // / `restart_strategy_all_is_const_and_matches_iteration_count`
462 // (supervisor.rs) shape.
463 const ALL: &[CaixaDialeto] = CaixaDialeto::ALL;
464 assert_eq!(ALL.len(), CaixaDialeto::ALL.len());
465 // Sweep the iterator without collapsing to `.len()` so a future
466 // change to `ALL`'s carrier that decouples `.len()` from the
467 // iteration count (a lazy-computed shape, an alias `impl Iterator`
468 // return, a wrapper newtype) still passes here iff the two agree
469 // arm-for-arm; the `#[allow]` opts this local pin out of the
470 // clippy `iter_count` collapse that would defeat the intent.
471 #[allow(clippy::iter_count)]
472 let iterated = ALL.iter().count();
473 assert_eq!(iterated, CaixaDialeto::ALL.len());
474 }
475
476 #[test]
477 fn caixa_dialeto_all_covers_every_variant_by_display_probe() {
478 // Fanning `Display` over the slice sweeps the paired accessors
479 // ([`CaixaDialeto::palavra_canonica`] / [`CaixaDialeto::consumidor`]
480 // / [`CaixaDialeto::descricao`]) at every arm — every returned
481 // byte-string is non-empty (the accessors' contract). A future
482 // arm added without extending its per-arm `match self` return
483 // would compile-fail at the accessor call inside the loop;
484 // together with the `ALL.len() == 4` pin above, this locks the
485 // accessor arm-set and the `ALL` slice mutually.
486 for &d in CaixaDialeto::ALL {
487 let display_form = d.to_string();
488 assert!(
489 !display_form.is_empty(),
490 "Display must render a non-empty byte-string for every \
491 arm; empty: {d:?}"
492 );
493 // Consumidor / descricao / palavra-canonica must each surface
494 // a non-empty scalar; every downstream diagnostic consumer
495 // reaches through these accessors.
496 assert!(!d.palavra_canonica().is_empty(), "{d}");
497 assert!(!d.consumidor().is_empty(), "{d}");
498 assert!(!d.descricao().is_empty(), "{d}");
499 }
500 }
501}