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    /// The keyword an author should write for this dialect, once the
75    /// migration named in [`Self::consumidor`] completes.
76    #[must_use]
77    pub const fn palavra_canonica(self) -> &'static str {
78        match self {
79            Self::Pacote => "defcaixa",
80            Self::Molde | Self::MoldePosicional => "defmolde",
81            Self::Desconhecido => "?",
82        }
83    }
84
85    /// Who reads this dialect.
86    #[must_use]
87    pub const fn consumidor(self) -> &'static str {
88        match self {
89            Self::Pacote => "caixa-core / feira",
90            Self::Molde | Self::MoldePosicional => "pleme-doc-gen",
91            Self::Desconhecido => "nobody known",
92        }
93    }
94
95    /// A one-line description for a census row or an error message.
96    #[must_use]
97    pub const fn descricao(self) -> &'static str {
98        match self {
99            Self::Pacote => "tatara-lisp package manifest (:nome :versao :kind :deps …)",
100            Self::Molde => "repo-surface declaration (:name :ecosystem :package {…} …)",
101            Self::MoldePosicional => {
102                "repo-surface declaration, positional name (defcaixa <nome> :kind …)"
103            }
104            Self::Desconhecido => "unrecognised — matches no known defcaixa schema",
105        }
106    }
107}
108
109impl std::fmt::Display for CaixaDialeto {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.write_str(match self {
112            Self::Pacote => "Pacote",
113            Self::Molde => "Molde",
114            Self::MoldePosicional => "MoldePosicional",
115            Self::Desconhecido => "Desconhecido",
116        })
117    }
118}
119
120/// A source that is not a `(defcaixa …)` / `(defmolde …)` form at all.
121#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
122pub enum DialetoError {
123    #[error("source has no top-level form")]
124    Vazio,
125    #[error("top-level form is not a list — a manifest is `(defcaixa …)`")]
126    NaoEhLista,
127    #[error(
128        "top-level form is headed by `{encontrado}`, not `defcaixa` or `defmolde` \
129         (a manifest's first form must be the declaration itself)"
130    )]
131    CabecaErrada { encontrado: String },
132    #[error("manifest does not parse as tatara-lisp: {0}")]
133    Leitura(String),
134}
135
136/// Classify a manifest source without committing to either schema.
137///
138/// Deliberately reads only the head symbol and the set of top-level keywords —
139/// enough to route, never enough to half-parse. A classifier that started
140/// validating would grow into a third parser, which is the shape of the problem
141/// it exists to name.
142///
143/// # Errors
144/// [`DialetoError`] when the source is not a manifest declaration at all.
145pub fn classify(src: &str) -> Result<CaixaDialeto, DialetoError> {
146    let forms = tatara_lisp::read(src).map_err(|e| DialetoError::Leitura(e.to_string()))?;
147    let first = forms.first().ok_or(DialetoError::Vazio)?;
148    classify_form(first)
149}
150
151/// [`classify`] over an already-read form.
152///
153/// # Errors
154/// [`DialetoError`] when the form is not a manifest declaration.
155pub fn classify_form(form: &Sexp) -> Result<CaixaDialeto, DialetoError> {
156    let list = form.as_list().ok_or(DialetoError::NaoEhLista)?;
157    let head = list
158        .first()
159        .and_then(Sexp::as_symbol)
160        .ok_or(DialetoError::NaoEhLista)?;
161
162    match head {
163        // `defmolde` is unambiguous by construction — it exists precisely so a
164        // consumer never has to infer which declaration it holds. Both arities
165        // are the same declaration; the positional one keeps its own variant
166        // only so a census can report the split.
167        "defmolde" => {
168            return Ok(if starts_with_positional_name(&list[1..]) {
169                CaixaDialeto::MoldePosicional
170            } else {
171                CaixaDialeto::Molde
172            });
173        }
174        "defcaixa" => {}
175        other => {
176            return Err(DialetoError::CabecaErrada {
177                encontrado: other.to_string(),
178            });
179        }
180    }
181
182    let args = &list[1..];
183
184    // `(defcaixa <symbol> :kind … :ecosystem …)`. Only the Molde dialect has a
185    // positional arity; `Caixa` is keyword-only, so a leading bare symbol
186    // settles it without looking further.
187    if starts_with_positional_name(args) {
188        return Ok(CaixaDialeto::MoldePosicional);
189    }
190
191    let keys = top_level_keywords(args);
192    let has = |k: &str| keys.iter().any(|s| s == k);
193
194    // Order matters, and it is not arbitrary: `:nome` and `:name` are the two
195    // required head slots and no file in the measured corpus carries both.
196    // Checking them FIRST means the decision rests on the one slot each schema
197    // makes mandatory, rather than on optional evidence like `:ecosystem`.
198    if has("nome") {
199        return Ok(CaixaDialeto::Pacote);
200    }
201    if has("name") || has("ecosystem") || has("package") {
202        return Ok(CaixaDialeto::Molde);
203    }
204    Ok(CaixaDialeto::Desconhecido)
205}
206
207/// True when the first argument is a bare symbol rather than a keyword — the
208/// positional-name arity.
209fn starts_with_positional_name(args: &[Sexp]) -> bool {
210    matches!(args.first(), Some(Sexp::Atom(Atom::Symbol(_))))
211}
212
213/// The top-level keyword names (without the leading `:`) of a kwarg list.
214///
215/// Steps in pairs so a keyword appearing as a VALUE — `:kind :Biblioteca`, or a
216/// nested `(:nome "dep" :versao "^0.1")` inside `:deps` — is never counted as a
217/// top-level slot. A naive scan for `:nome` anywhere in the source classifies
218/// every Molde manifest with a `:deps` list as a Pacote.
219fn top_level_keywords(args: &[Sexp]) -> Vec<String> {
220    let mut out = Vec::new();
221    let mut i = 0;
222    while i < args.len() {
223        if let Sexp::Atom(Atom::Keyword(k)) = &args[i] {
224            out.push(k.clone());
225            i += 2;
226        } else {
227            i += 1;
228        }
229    }
230    out
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    const PACOTE: &str = r#"
238      (defcaixa
239        :nome   "checkout"
240        :versao "0.1.0"
241        :kind   Servico
242        :deps   ((:nome "caixa-teia" :versao "^0.1")))
243    "#;
244
245    const MOLDE: &str = r#"
246      (defcaixa
247        :name "base64"
248        :kind :Biblioteca
249        :ecosystem :rust-single-crate
250        :package {:name "base64" :version "0.22.1"}
251        :workflows [:auto-release])
252    "#;
253
254    const MOLDE_POSICIONAL: &str = r#"
255      (defcaixa todoku-go
256        :kind :Biblioteca
257        :ecosystem :go
258        :package {:name "todoku-go" :version "0.3.0"})
259    "#;
260
261    #[test]
262    fn the_package_dialect_is_recognised() {
263        assert_eq!(classify(PACOTE), Ok(CaixaDialeto::Pacote));
264    }
265
266    #[test]
267    fn the_repo_surface_dialect_is_recognised() {
268        assert_eq!(classify(MOLDE), Ok(CaixaDialeto::Molde));
269    }
270
271    #[test]
272    fn the_positional_arity_is_recognised() {
273        assert_eq!(
274            classify(MOLDE_POSICIONAL),
275            Ok(CaixaDialeto::MoldePosicional)
276        );
277    }
278
279    #[test]
280    fn defmolde_classifies_without_inference() {
281        // The whole point of the new keyword: no schema sniffing required.
282        let src = r#"(defmolde :name "x" :kind :Biblioteca :ecosystem :go)"#;
283        assert_eq!(classify(src), Ok(CaixaDialeto::Molde));
284        let pos = r#"(defmolde todoku-go :kind :Biblioteca :ecosystem :go)"#;
285        assert_eq!(classify(pos), Ok(CaixaDialeto::MoldePosicional));
286    }
287
288    #[test]
289    fn a_nested_nome_does_not_make_a_repo_surface_look_like_a_package() {
290        // The exact failure a substring scan produces: `:deps ((:nome …))`
291        // contains `:nome`, but not as a top-level slot.
292        let src = r#"
293          (defcaixa
294            :name "x"
295            :ecosystem :rust-single-crate
296            :deps ((:nome "inner" :versao "^0.1")))
297        "#;
298        assert_eq!(classify(src), Ok(CaixaDialeto::Molde));
299    }
300
301    #[test]
302    fn a_keyword_in_value_position_is_not_a_slot() {
303        // `:kind :Biblioteca` — the value is itself a keyword. Stepping one at
304        // a time would read `:Biblioteca` as a top-level slot.
305        let src = r#"(defcaixa :kind :Biblioteca :name "x")"#;
306        assert_eq!(classify(src), Ok(CaixaDialeto::Molde));
307    }
308
309    #[test]
310    fn an_unrecognised_defcaixa_is_reported_not_guessed() {
311        let src = r#"(defcaixa :licenca "MIT")"#;
312        assert_eq!(classify(src), Ok(CaixaDialeto::Desconhecido));
313    }
314
315    #[test]
316    fn a_form_that_is_not_a_manifest_is_an_error_not_a_dialect() {
317        assert_eq!(
318            classify("(defflake :nome \"x\")"),
319            Err(DialetoError::CabecaErrada {
320                encontrado: "defflake".into()
321            })
322        );
323        assert_eq!(classify(""), Err(DialetoError::Vazio));
324    }
325
326    #[test]
327    fn every_dialect_names_its_consumer_and_its_canonical_keyword() {
328        // Guards the routing table itself: a new variant added without an arm
329        // here is a compile error in the match, and a variant that claims
330        // `defcaixa` while being read by pleme-doc-gen would re-open the
331        // collision this module closes.
332        for d in [
333            CaixaDialeto::Pacote,
334            CaixaDialeto::Molde,
335            CaixaDialeto::MoldePosicional,
336            CaixaDialeto::Desconhecido,
337        ] {
338            assert!(!d.descricao().is_empty(), "{d}");
339            assert!(!d.consumidor().is_empty(), "{d}");
340        }
341        assert_eq!(CaixaDialeto::Pacote.palavra_canonica(), "defcaixa");
342        assert_eq!(CaixaDialeto::Molde.palavra_canonica(), "defmolde");
343        assert_ne!(
344            CaixaDialeto::Pacote.palavra_canonica(),
345            CaixaDialeto::Molde.palavra_canonica(),
346            "the two dialects must not share a canonical keyword — that IS the defect"
347        );
348    }
349}