Skip to main content

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}