use proc_macro2::Span;
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{Expr, Ident, Lit, LitStr, Path, Token};
#[allow(dead_code)]
#[derive(Debug)]
pub struct AntigenArgs {
pub name: String,
pub fingerprint: String,
pub family: Option<String>,
pub summary: Option<String>,
pub references: Vec<String>,
pub name_span: Option<Span>,
pub fingerprint_span: Option<Span>,
pub args_span: Span,
}
pub struct PresentsArgs {
#[allow(dead_code)]
pub antigen: Path,
}
pub struct ImmuneArgs {
pub antigen: Path,
pub witness: Option<Expr>,
#[allow(dead_code)]
pub rationale: Option<String>,
}
pub struct DescendedFromArgs {
#[allow(dead_code)]
pub parent: Path,
}
pub struct ToleranceArgs {
#[allow(dead_code)]
pub antigen: Path,
pub rationale: Option<String>,
pub rationale_span: Option<Span>,
pub until: Option<String>,
pub until_span: Option<Span>,
#[allow(dead_code)]
pub see: Vec<String>,
pub args_span: Span,
}
impl Parse for AntigenArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let args_span = input.span();
let mut name: Option<String> = None;
let mut name_span: Option<Span> = None;
let mut fingerprint: Option<String> = None;
let mut fingerprint_span: Option<Span> = None;
let mut family: Option<String> = None;
let mut summary: Option<String> = None;
let mut references: Vec<String> = Vec::new();
let pairs: Punctuated<MetaPair, Token![,]> =
input.parse_terminated(MetaPair::parse, Token![,])?;
for pair in pairs {
match pair.key.to_string().as_str() {
"name" => {
let (s, span) = pair.expect_string_spanned()?;
name = Some(s);
name_span = Some(span);
}
"fingerprint" => {
let (s, span) = pair.expect_string_spanned()?;
fingerprint = Some(s);
fingerprint_span = Some(span);
}
"family" => family = Some(pair.expect_string()?),
"summary" => summary = Some(pair.expect_string()?),
"references" => references = pair.expect_string_array()?,
other => {
return Err(syn::Error::new(
pair.key.span(),
format!(
"unknown #[antigen] field `{other}`; expected one of: \
name, fingerprint, family, summary, references"
),
))
}
}
}
let name =
name.ok_or_else(|| syn::Error::new(args_span, "#[antigen] requires `name = \"...\"`"))?;
let fingerprint = fingerprint.ok_or_else(|| {
syn::Error::new(args_span, "#[antigen] requires `fingerprint = \"...\"`")
})?;
Ok(Self {
name,
fingerprint,
family,
summary,
references,
name_span,
fingerprint_span,
args_span,
})
}
}
impl AntigenArgs {
pub fn validate(&self) -> syn::Result<()> {
if self.name.is_empty() {
return Err(syn::Error::new(
self.name_span.unwrap_or(self.args_span),
"#[antigen] `name` cannot be empty",
));
}
if !is_kebab_case(&self.name) {
return Err(syn::Error::new(
self.name_span.unwrap_or(self.args_span),
format!(
"#[antigen] `name = \"{}\"` must be kebab-case (lowercase with hyphens)",
self.name
),
));
}
if self.fingerprint.is_empty() {
return Err(syn::Error::new(
self.fingerprint_span.unwrap_or(self.args_span),
"#[antigen] `fingerprint` cannot be empty",
));
}
if let Err(parse_err) = antigen_fingerprint::Fingerprint::parse(&self.fingerprint) {
let anchor = self.fingerprint_span.unwrap_or(self.args_span);
return Err(syn::Error::new(
anchor,
format!(
"#[antigen] `fingerprint` does not parse: {parse_err}\n\
(per ADR-010 Amendment 1 Path C — DSL syntax, not raw Rust expressions)"
),
));
}
Ok(())
}
}
impl Parse for PresentsArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let antigen: Path = input.parse()?;
Ok(Self { antigen })
}
}
impl Parse for ImmuneArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let antigen: Path = input.parse()?;
let mut witness: Option<Expr> = None;
let mut rationale: Option<String> = None;
while !input.is_empty() {
input.parse::<Token![,]>()?;
if input.is_empty() {
break;
}
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
match key.to_string().as_str() {
"witness" => {
witness = Some(input.parse()?);
}
"rationale" => {
let lit: LitStr = input.parse()?;
rationale = Some(lit.value());
}
other => {
return Err(syn::Error::new(
key.span(),
format!(
"unknown #[immune] field `{other}`; expected one of: witness, rationale"
),
));
}
}
}
Ok(Self {
antigen,
witness,
rationale,
})
}
}
impl ImmuneArgs {
pub fn validate(&self) -> syn::Result<()> {
if self.witness.is_none() {
return Err(syn::Error::new_spanned(
&self.antigen,
"#[immune] requires `witness = ...` (a test, proptest, lint reference, \
formal-verification proof, or phantom-type construction). \
A marker without proof is not a claim.",
));
}
Ok(())
}
}
impl Parse for DescendedFromArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let parent: Path = input.parse()?;
Ok(Self { parent })
}
}
impl Parse for ToleranceArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let args_span = input.span();
let antigen: Path = input.parse()?;
let mut rationale: Option<String> = None;
let mut rationale_span: Option<Span> = None;
let mut until: Option<String> = None;
let mut until_span: Option<Span> = None;
let mut see: Vec<String> = Vec::new();
while !input.is_empty() {
input.parse::<Token![,]>()?;
if input.is_empty() {
break;
}
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
match key.to_string().as_str() {
"rationale" => {
let lit: LitStr = input.parse()?;
rationale_span = Some(lit.span());
rationale = Some(lit.value());
}
"until" => {
let lit: LitStr = input.parse()?;
until_span = Some(lit.span());
until = Some(lit.value());
}
"see" => {
let arr: syn::ExprArray = input.parse()?;
for elem in &arr.elems {
if let Expr::Lit(syn::ExprLit {
lit: Lit::Str(s), ..
}) = elem
{
see.push(s.value());
} else {
return Err(syn::Error::new_spanned(
elem,
"expected a string literal in `see` array",
));
}
}
}
other => {
return Err(syn::Error::new(
key.span(),
format!(
"unknown #[antigen_tolerance] field `{other}`; expected one of: \
rationale, until, see",
),
));
}
}
}
Ok(Self {
antigen,
rationale,
rationale_span,
until,
until_span,
see,
args_span,
})
}
}
impl ToleranceArgs {
pub fn validate(&self) -> syn::Result<()> {
let Some(rationale) = self.rationale.as_deref() else {
return Err(syn::Error::new_spanned(
&self.antigen,
"#[antigen_tolerance] requires `rationale = \"...\"`. \
A tolerance without rationale is not a claim — it's a silent suppression.",
));
};
if rationale.is_empty() {
return Err(syn::Error::new(
self.rationale_span.unwrap_or(self.args_span),
"#[antigen_tolerance] `rationale` must not be empty",
));
}
if let Some(until) = self.until.as_deref() {
if until.is_empty() {
return Err(syn::Error::new(
self.until_span.unwrap_or(self.args_span),
"#[antigen_tolerance] `until = \"\"` rejected — \
an empty expiry indicates user error. Use `until = \"v1.0\"` \
(or similar) or omit the field entirely for forever-tolerance.",
));
}
}
Ok(())
}
}
struct MetaPair {
key: Ident,
value: Expr,
}
impl Parse for MetaPair {
fn parse(input: ParseStream) -> syn::Result<Self> {
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
let value: Expr = input.parse()?;
Ok(Self { key, value })
}
}
impl MetaPair {
fn expect_string(&self) -> syn::Result<String> {
if let Expr::Lit(syn::ExprLit {
lit: Lit::Str(s), ..
}) = &self.value
{
Ok(s.value())
} else {
Err(syn::Error::new_spanned(
&self.value,
format!("expected a string literal for `{}`", self.key),
))
}
}
fn expect_string_spanned(&self) -> syn::Result<(String, Span)> {
if let Expr::Lit(syn::ExprLit {
lit: Lit::Str(s), ..
}) = &self.value
{
Ok((s.value(), s.span()))
} else {
Err(syn::Error::new_spanned(
&self.value,
format!("expected a string literal for `{}`", self.key),
))
}
}
fn expect_string_array(&self) -> syn::Result<Vec<String>> {
if let Expr::Array(arr) = &self.value {
let mut out = Vec::new();
for elem in &arr.elems {
if let Expr::Lit(syn::ExprLit {
lit: Lit::Str(s), ..
}) = elem
{
out.push(s.value());
} else {
return Err(syn::Error::new_spanned(
elem,
"expected a string literal in references array",
));
}
}
Ok(out)
} else {
Err(syn::Error::new_spanned(
&self.value,
format!("expected a string array for `{}`", self.key),
))
}
}
}
fn is_kebab_case(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !s.starts_with('-')
&& !s.ends_with('-')
&& !s.contains("--")
}
#[cfg(test)]
type AntigenFixture = (
&'static str,
&'static str,
&'static str,
Option<&'static str>,
Option<&'static str>,
);
#[cfg(test)]
const ANTIGEN_PARSER_FIXTURES: &[AntigenFixture] = &[
(
r#"name = "panicking-in-drop", fingerprint = "impl Drop with panic""#,
"panicking-in-drop",
"impl Drop with panic",
None,
None,
),
(
r#"name = "frame-translation", fingerprint = "class enum + meet", family = "semantic-drift", summary = "Polarity inverts at the frame boundary""#,
"frame-translation",
"class enum + meet",
Some("semantic-drift"),
Some("Polarity inverts at the frame boundary"),
),
(
r#"name = "x", fingerprint = "item: enum, has_method(\"meet\", \"(Self, Self) -> Self\")""#,
"x",
r#"item: enum, has_method("meet", "(Self, Self) -> Self")"#,
None,
None,
),
(
r#"summary = "S", family = "F", fingerprint = "FP", name = "n""#,
"n",
"FP",
Some("F"),
Some("S"),
),
(
r#"name = "x", fingerprint = "y", references = ["GAP-1", "DEC-2"]"#,
"x",
"y",
None,
None,
),
(
"name = \"multi-line\",\n\tfingerprint = \"shape\",\n\tfamily = \"family\"",
"multi-line",
"shape",
Some("family"),
None,
),
];
#[cfg(test)]
mod tests {
use super::*;
use proc_macro2::TokenStream;
#[test]
fn antigen_parser_accepts_all_fixtures() {
for (input, exp_name, exp_fp, exp_family, exp_summary) in ANTIGEN_PARSER_FIXTURES {
let tokens: TokenStream = input
.parse()
.unwrap_or_else(|e| panic!("fixture failed to tokenize: {input:?}: {e}"));
let args = syn::parse2::<AntigenArgs>(tokens)
.unwrap_or_else(|e| panic!("macro parser rejected fixture {input:?}: {e}"));
assert_eq!(&args.name, exp_name, "name mismatch for fixture: {input:?}");
assert_eq!(
&args.fingerprint, exp_fp,
"fingerprint mismatch for fixture: {input:?}"
);
assert_eq!(
args.family.as_deref(),
*exp_family,
"family mismatch for fixture: {input:?}"
);
assert_eq!(
args.summary.as_deref(),
*exp_summary,
"summary mismatch for fixture: {input:?}"
);
}
}
#[test]
fn antigen_parser_rejects_missing_name() {
let tokens: TokenStream = r#"fingerprint = "x""#.parse().unwrap();
assert!(syn::parse2::<AntigenArgs>(tokens).is_err());
}
#[test]
fn antigen_parser_rejects_missing_fingerprint() {
let tokens: TokenStream = r#"name = "x""#.parse().unwrap();
assert!(syn::parse2::<AntigenArgs>(tokens).is_err());
}
#[test]
fn antigen_parser_rejects_unknown_field() {
let tokens: TokenStream = r#"name = "x", fingerprint = "y", bogus = "z""#.parse().unwrap();
match syn::parse2::<AntigenArgs>(tokens) {
Ok(_) => panic!("expected parse to reject unknown field `bogus`"),
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("unknown") && msg.contains("bogus"),
"expected unknown-field error mentioning `bogus`, got: {msg}"
);
}
}
}
fn args_with(name: &str, fingerprint: &str) -> AntigenArgs {
AntigenArgs {
name: name.to_string(),
fingerprint: fingerprint.to_string(),
family: None,
summary: None,
references: Vec::new(),
name_span: Some(proc_macro2::Span::call_site()),
fingerprint_span: Some(proc_macro2::Span::call_site()),
args_span: proc_macro2::Span::call_site(),
}
}
const VALID_DSL: &str = r#"name = matches("*")"#;
#[test]
fn validate_rejects_empty_name() {
assert!(args_with("", VALID_DSL).validate().is_err());
}
#[test]
fn validate_rejects_non_kebab_name() {
assert!(args_with("FooBar", VALID_DSL).validate().is_err());
}
#[test]
fn validate_accepts_kebab_name_with_digits() {
assert!(args_with("frame-2-translation", VALID_DSL)
.validate()
.is_ok());
}
#[test]
fn validate_rejects_name_with_double_hyphen() {
assert!(args_with("frame--translation", VALID_DSL)
.validate()
.is_err());
}
#[test]
fn validate_rejects_name_starting_with_hyphen() {
assert!(args_with("-frame", VALID_DSL).validate().is_err());
}
#[test]
fn validate_rejects_malformed_dsl_fingerprint() {
let args = args_with("ok-name", "this is not the dsl");
let err = args.validate().unwrap_err().to_string();
assert!(err.contains("fingerprint"), "got: {err}");
}
#[test]
fn immune_parser_requires_witness() {
let tokens: TokenStream = r"PanickingInDrop".parse().unwrap();
let args = syn::parse2::<ImmuneArgs>(tokens).unwrap();
assert!(args.validate().is_err());
}
#[test]
fn immune_parser_accepts_witness_path() {
let tokens: TokenStream = r"PanickingInDrop, witness = my::module::test_fn"
.parse()
.unwrap();
let args = syn::parse2::<ImmuneArgs>(tokens).unwrap();
assert!(args.witness.is_some());
assert!(args.validate().is_ok());
}
#[test]
fn immune_parser_accepts_rationale() {
let tokens: TokenStream = r#"X, witness = my_test, rationale = "checked manually""#
.parse()
.unwrap();
let args = syn::parse2::<ImmuneArgs>(tokens).unwrap();
assert_eq!(args.rationale.as_deref(), Some("checked manually"));
}
}
#[cfg(test)]
mod parser_props {
use super::*;
use proc_macro2::TokenStream;
use proptest::prelude::*;
const RUST_KEYWORDS: &[&str] = &[
"as", "async", "await", "box", "break", "const", "continue", "crate", "do", "dyn", "else",
"enum", "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "macro",
"match", "mod", "move", "mut", "pub", "ref", "return", "self", "static", "struct", "super",
"trait", "true", "type", "union", "unsafe", "use", "where", "while", "yield", "abstract",
"become", "final", "override", "priv", "try",
];
fn valid_kebab() -> impl Strategy<Value = String> {
proptest::collection::vec(
(
proptest::char::range('a', 'z'),
proptest::collection::vec(
prop_oneof![
proptest::char::range('a', 'z'),
proptest::char::range('0', '9')
],
0..8usize,
),
)
.prop_map(|(first, rest)| {
let mut s = String::with_capacity(rest.len() + 1);
s.push(first);
for c in rest {
s.push(c);
}
s
}),
1..5usize,
)
.prop_map(|segments| segments.join("-"))
}
fn valid_text(max_len: usize) -> impl Strategy<Value = String> {
proptest::collection::vec(
prop_oneof![
proptest::char::range(' ', '~').prop_filter("excluded chars", |c| {
*c != '\\' && *c != '"' && *c != '\0'
}),
],
1..=max_len,
)
.prop_map(|chars| chars.into_iter().collect())
}
fn lit(s: &str) -> String {
format!("{s:?}")
}
fn render_antigen_body(
name: &str,
fingerprint: &str,
family: Option<&str>,
summary: Option<&str>,
) -> String {
let mut parts = vec![
format!("name = {}", lit(name)),
format!("fingerprint = {}", lit(fingerprint)),
];
if let Some(f) = family {
parts.push(format!("family = {}", lit(f)));
}
if let Some(s) = summary {
parts.push(format!("summary = {}", lit(s)));
}
parts.join(", ")
}
proptest! {
#[test]
fn antigen_parser_round_trip(
name in valid_kebab(),
fingerprint in valid_text(64),
family in proptest::option::of(valid_text(32)),
summary in proptest::option::of(valid_text(64)),
) {
let body = render_antigen_body(&name, &fingerprint, family.as_deref(), summary.as_deref());
let tokens: TokenStream = body.parse().expect("body must tokenize");
let args = syn::parse2::<AntigenArgs>(tokens).expect("body must parse");
prop_assert_eq!(&args.name, &name);
prop_assert_eq!(&args.fingerprint, &fingerprint);
prop_assert_eq!(args.family.as_deref(), family.as_deref());
prop_assert_eq!(args.summary.as_deref(), summary.as_deref());
let re_rendered = render_antigen_body(&args.name, &args.fingerprint, args.family.as_deref(), args.summary.as_deref());
let re_tokens: TokenStream = re_rendered.parse().expect("re-rendered body must tokenize");
let args2 = syn::parse2::<AntigenArgs>(re_tokens).expect("re-rendered body must parse");
prop_assert_eq!(&args.name, &args2.name);
prop_assert_eq!(&args.fingerprint, &args2.fingerprint);
prop_assert_eq!(args.family, args2.family);
prop_assert_eq!(args.summary, args2.summary);
}
#[test]
fn antigen_parser_order_invariant(
name in valid_kebab(),
fingerprint in valid_text(48),
family in valid_text(24),
summary in valid_text(48),
) {
let canonical = format!(
"name = {}, fingerprint = {}, family = {}, summary = {}",
lit(&name), lit(&fingerprint), lit(&family), lit(&summary),
);
let reversed = format!(
"summary = {}, family = {}, fingerprint = {}, name = {}",
lit(&summary), lit(&family), lit(&fingerprint), lit(&name),
);
let a: AntigenArgs = syn::parse2(canonical.parse::<TokenStream>().unwrap()).unwrap();
let b: AntigenArgs = syn::parse2(reversed.parse::<TokenStream>().unwrap()).unwrap();
prop_assert_eq!(&a.name, &b.name);
prop_assert_eq!(&a.fingerprint, &b.fingerprint);
prop_assert_eq!(&a.family, &b.family);
prop_assert_eq!(&a.summary, &b.summary);
}
#[test]
fn is_kebab_case_accepts_generator(name in valid_kebab()) {
prop_assert!(is_kebab_case(&name), "is_kebab_case rejected generator output: {name:?}");
}
#[test]
fn antigen_parser_requires_name(
fingerprint in valid_text(32),
family in proptest::option::of(valid_text(16)),
) {
let mut parts = vec![format!("fingerprint = {}", lit(&fingerprint))];
if let Some(f) = &family {
parts.push(format!("family = {}", lit(f)));
}
let body = parts.join(", ");
let tokens: TokenStream = body.parse().expect("body tokenizes");
let result = syn::parse2::<AntigenArgs>(tokens);
prop_assert!(result.is_err(), "expected parser to reject input missing `name`: {body:?}");
let err = result.unwrap_err().to_string();
prop_assert!(err.contains("name"), "error must mention `name`, got: {err:?}");
}
#[test]
fn antigen_parser_requires_fingerprint(
name in valid_kebab(),
family in proptest::option::of(valid_text(16)),
) {
let mut parts = vec![format!("name = {}", lit(&name))];
if let Some(f) = &family {
parts.push(format!("family = {}", lit(f)));
}
let body = parts.join(", ");
let tokens: TokenStream = body.parse().expect("body tokenizes");
let result = syn::parse2::<AntigenArgs>(tokens);
prop_assert!(result.is_err(), "expected parser to reject input missing `fingerprint`: {body:?}");
let err = result.unwrap_err().to_string();
prop_assert!(err.contains("fingerprint"), "error must mention `fingerprint`, got: {err:?}");
}
#[test]
fn antigen_parser_rejects_unknown_field(
name in valid_kebab(),
fingerprint in valid_text(32),
unknown in "[a-z][a-z_]{2,12}".prop_filter(
"must not collide with known fields or Rust keywords",
|s| {
!matches!(s.as_str(), "name" | "fingerprint" | "family" | "summary" | "references")
&& !RUST_KEYWORDS.contains(&s.as_str())
},
),
) {
let body = format!(
"name = {}, fingerprint = {}, {} = \"x\"",
lit(&name), lit(&fingerprint), unknown,
);
let tokens: TokenStream = body.parse().expect("body tokenizes");
let result = syn::parse2::<AntigenArgs>(tokens);
prop_assert!(result.is_err(), "expected unknown field rejection for: {body:?}");
let err = result.unwrap_err().to_string();
prop_assert!(
err.contains("unknown") && err.contains(&unknown),
"error must name the unknown field. got: {err:?}",
);
}
#[test]
fn antigen_parser_accepts_references_array(
name in valid_kebab(),
fingerprint in valid_text(32),
refs in proptest::collection::vec(valid_text(24), 0..6usize),
) {
let refs_lit: Vec<String> = refs.iter().map(|s| lit(s)).collect();
let body = format!(
"name = {}, fingerprint = {}, references = [{}]",
lit(&name), lit(&fingerprint), refs_lit.join(", "),
);
let tokens: TokenStream = body.parse().expect("body tokenizes");
let args = syn::parse2::<AntigenArgs>(tokens).expect("body parses");
prop_assert_eq!(&args.references, &refs);
}
#[test]
fn immune_parser_accepts_witness(
antigen in "[A-Z][A-Za-z0-9]{0,16}",
witness_segments in proptest::collection::vec(
"[a-z][a-z_0-9]{0,8}".prop_filter("must not be a Rust keyword", |s| {
!RUST_KEYWORDS.contains(&s.as_str())
}),
1..4usize,
),
) {
let witness = witness_segments.join("::");
let body = format!("{antigen}, witness = {witness}");
let tokens: TokenStream = body.parse().expect("body tokenizes");
let args = syn::parse2::<ImmuneArgs>(tokens).expect("body parses");
prop_assert!(args.witness.is_some());
prop_assert!(args.validate().is_ok());
}
#[test]
fn immune_parser_validate_rejects_missing_witness(
antigen in "[A-Z][A-Za-z0-9]{0,16}",
) {
let tokens: TokenStream = antigen.parse().expect("antigen tokenizes");
let args = syn::parse2::<ImmuneArgs>(tokens).expect("bare path parses");
prop_assert!(args.witness.is_none());
let err = args.validate().unwrap_err().to_string();
prop_assert!(err.contains("witness"), "validate must mention `witness`, got: {err:?}");
}
}
}