use tatara_lisp::{Atom, Sexp};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CaixaDialeto {
Pacote,
Molde,
MoldePosicional,
Desconhecido,
}
impl CaixaDialeto {
#[must_use]
pub const fn palavra_canonica(self) -> &'static str {
match self {
Self::Pacote => "defcaixa",
Self::Molde | Self::MoldePosicional => "defmolde",
Self::Desconhecido => "?",
}
}
#[must_use]
pub const fn consumidor(self) -> &'static str {
match self {
Self::Pacote => "caixa-core / feira",
Self::Molde | Self::MoldePosicional => "pleme-doc-gen",
Self::Desconhecido => "nobody known",
}
}
#[must_use]
pub const fn descricao(self) -> &'static str {
match self {
Self::Pacote => "tatara-lisp package manifest (:nome :versao :kind :deps …)",
Self::Molde => "repo-surface declaration (:name :ecosystem :package {…} …)",
Self::MoldePosicional => {
"repo-surface declaration, positional name (defcaixa <nome> :kind …)"
}
Self::Desconhecido => "unrecognised — matches no known defcaixa schema",
}
}
}
impl std::fmt::Display for CaixaDialeto {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Pacote => "Pacote",
Self::Molde => "Molde",
Self::MoldePosicional => "MoldePosicional",
Self::Desconhecido => "Desconhecido",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DialetoError {
#[error("source has no top-level form")]
Vazio,
#[error("top-level form is not a list — a manifest is `(defcaixa …)`")]
NaoEhLista,
#[error(
"top-level form is headed by `{encontrado}`, not `defcaixa` or `defmolde` \
(a manifest's first form must be the declaration itself)"
)]
CabecaErrada { encontrado: String },
#[error("manifest does not parse as tatara-lisp: {0}")]
Leitura(String),
}
pub fn classify(src: &str) -> Result<CaixaDialeto, DialetoError> {
let forms = tatara_lisp::read(src).map_err(|e| DialetoError::Leitura(e.to_string()))?;
let first = forms.first().ok_or(DialetoError::Vazio)?;
classify_form(first)
}
pub fn classify_form(form: &Sexp) -> Result<CaixaDialeto, DialetoError> {
let list = form.as_list().ok_or(DialetoError::NaoEhLista)?;
let head = list
.first()
.and_then(Sexp::as_symbol)
.ok_or(DialetoError::NaoEhLista)?;
match head {
"defmolde" => {
return Ok(if starts_with_positional_name(&list[1..]) {
CaixaDialeto::MoldePosicional
} else {
CaixaDialeto::Molde
});
}
"defcaixa" => {}
other => {
return Err(DialetoError::CabecaErrada {
encontrado: other.to_string(),
});
}
}
let args = &list[1..];
if starts_with_positional_name(args) {
return Ok(CaixaDialeto::MoldePosicional);
}
let keys = top_level_keywords(args);
let has = |k: &str| keys.iter().any(|s| s == k);
if has("nome") {
return Ok(CaixaDialeto::Pacote);
}
if has("name") || has("ecosystem") || has("package") {
return Ok(CaixaDialeto::Molde);
}
Ok(CaixaDialeto::Desconhecido)
}
fn starts_with_positional_name(args: &[Sexp]) -> bool {
matches!(args.first(), Some(Sexp::Atom(Atom::Symbol(_))))
}
fn top_level_keywords(args: &[Sexp]) -> Vec<String> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
if let Sexp::Atom(Atom::Keyword(k)) = &args[i] {
out.push(k.clone());
i += 2;
} else {
i += 1;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const PACOTE: &str = r#"
(defcaixa
:nome "checkout"
:versao "0.1.0"
:kind Servico
:deps ((:nome "caixa-teia" :versao "^0.1")))
"#;
const MOLDE: &str = r#"
(defcaixa
:name "base64"
:kind :Biblioteca
:ecosystem :rust-single-crate
:package {:name "base64" :version "0.22.1"}
:workflows [:auto-release])
"#;
const MOLDE_POSICIONAL: &str = r#"
(defcaixa todoku-go
:kind :Biblioteca
:ecosystem :go
:package {:name "todoku-go" :version "0.3.0"})
"#;
#[test]
fn the_package_dialect_is_recognised() {
assert_eq!(classify(PACOTE), Ok(CaixaDialeto::Pacote));
}
#[test]
fn the_repo_surface_dialect_is_recognised() {
assert_eq!(classify(MOLDE), Ok(CaixaDialeto::Molde));
}
#[test]
fn the_positional_arity_is_recognised() {
assert_eq!(
classify(MOLDE_POSICIONAL),
Ok(CaixaDialeto::MoldePosicional)
);
}
#[test]
fn defmolde_classifies_without_inference() {
let src = r#"(defmolde :name "x" :kind :Biblioteca :ecosystem :go)"#;
assert_eq!(classify(src), Ok(CaixaDialeto::Molde));
let pos = r#"(defmolde todoku-go :kind :Biblioteca :ecosystem :go)"#;
assert_eq!(classify(pos), Ok(CaixaDialeto::MoldePosicional));
}
#[test]
fn a_nested_nome_does_not_make_a_repo_surface_look_like_a_package() {
let src = r#"
(defcaixa
:name "x"
:ecosystem :rust-single-crate
:deps ((:nome "inner" :versao "^0.1")))
"#;
assert_eq!(classify(src), Ok(CaixaDialeto::Molde));
}
#[test]
fn a_keyword_in_value_position_is_not_a_slot() {
let src = r#"(defcaixa :kind :Biblioteca :name "x")"#;
assert_eq!(classify(src), Ok(CaixaDialeto::Molde));
}
#[test]
fn an_unrecognised_defcaixa_is_reported_not_guessed() {
let src = r#"(defcaixa :licenca "MIT")"#;
assert_eq!(classify(src), Ok(CaixaDialeto::Desconhecido));
}
#[test]
fn a_form_that_is_not_a_manifest_is_an_error_not_a_dialect() {
assert_eq!(
classify("(defflake :nome \"x\")"),
Err(DialetoError::CabecaErrada {
encontrado: "defflake".into()
})
);
assert_eq!(classify(""), Err(DialetoError::Vazio));
}
#[test]
fn every_dialect_names_its_consumer_and_its_canonical_keyword() {
for d in [
CaixaDialeto::Pacote,
CaixaDialeto::Molde,
CaixaDialeto::MoldePosicional,
CaixaDialeto::Desconhecido,
] {
assert!(!d.descricao().is_empty(), "{d}");
assert!(!d.consumidor().is_empty(), "{d}");
}
assert_eq!(CaixaDialeto::Pacote.palavra_canonica(), "defcaixa");
assert_eq!(CaixaDialeto::Molde.palavra_canonica(), "defmolde");
assert_ne!(
CaixaDialeto::Pacote.palavra_canonica(),
CaixaDialeto::Molde.palavra_canonica(),
"the two dialects must not share a canonical keyword — that IS the defect"
);
}
}