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!(classify(MOLDE_POSICIONAL), Ok(CaixaDialeto::MoldePosicional));
274    }
275
276    #[test]
277    fn defmolde_classifies_without_inference() {
278        // The whole point of the new keyword: no schema sniffing required.
279        let src = r#"(defmolde :name "x" :kind :Biblioteca :ecosystem :go)"#;
280        assert_eq!(classify(src), Ok(CaixaDialeto::Molde));
281        let pos = r#"(defmolde todoku-go :kind :Biblioteca :ecosystem :go)"#;
282        assert_eq!(classify(pos), Ok(CaixaDialeto::MoldePosicional));
283    }
284
285    #[test]
286    fn a_nested_nome_does_not_make_a_repo_surface_look_like_a_package() {
287        // The exact failure a substring scan produces: `:deps ((:nome …))`
288        // contains `:nome`, but not as a top-level slot.
289        let src = r#"
290          (defcaixa
291            :name "x"
292            :ecosystem :rust-single-crate
293            :deps ((:nome "inner" :versao "^0.1")))
294        "#;
295        assert_eq!(classify(src), Ok(CaixaDialeto::Molde));
296    }
297
298    #[test]
299    fn a_keyword_in_value_position_is_not_a_slot() {
300        // `:kind :Biblioteca` — the value is itself a keyword. Stepping one at
301        // a time would read `:Biblioteca` as a top-level slot.
302        let src = r#"(defcaixa :kind :Biblioteca :name "x")"#;
303        assert_eq!(classify(src), Ok(CaixaDialeto::Molde));
304    }
305
306    #[test]
307    fn an_unrecognised_defcaixa_is_reported_not_guessed() {
308        let src = r#"(defcaixa :licenca "MIT")"#;
309        assert_eq!(classify(src), Ok(CaixaDialeto::Desconhecido));
310    }
311
312    #[test]
313    fn a_form_that_is_not_a_manifest_is_an_error_not_a_dialect() {
314        assert_eq!(
315            classify("(defflake :nome \"x\")"),
316            Err(DialetoError::CabecaErrada {
317                encontrado: "defflake".into()
318            })
319        );
320        assert_eq!(classify(""), Err(DialetoError::Vazio));
321    }
322
323    #[test]
324    fn every_dialect_names_its_consumer_and_its_canonical_keyword() {
325        // Guards the routing table itself: a new variant added without an arm
326        // here is a compile error in the match, and a variant that claims
327        // `defcaixa` while being read by pleme-doc-gen would re-open the
328        // collision this module closes.
329        for d in [
330            CaixaDialeto::Pacote,
331            CaixaDialeto::Molde,
332            CaixaDialeto::MoldePosicional,
333            CaixaDialeto::Desconhecido,
334        ] {
335            assert!(!d.descricao().is_empty(), "{d}");
336            assert!(!d.consumidor().is_empty(), "{d}");
337        }
338        assert_eq!(CaixaDialeto::Pacote.palavra_canonica(), "defcaixa");
339        assert_eq!(CaixaDialeto::Molde.palavra_canonica(), "defmolde");
340        assert_ne!(
341            CaixaDialeto::Pacote.palavra_canonica(),
342            CaixaDialeto::Molde.palavra_canonica(),
343            "the two dialects must not share a canonical keyword — that IS the defect"
344        );
345    }
346}