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}