use syn::{Attribute, LitStr};
#[derive(Default)]
pub(crate) struct DomainAttr {
pub(crate) category: Option<String>,
pub(crate) code: Option<String>,
pub(crate) prefix: Option<String>,
pub(crate) transparent: bool,
}
pub(crate) fn parse_domain_attr(attrs: &[Attribute]) -> syn::Result<DomainAttr> {
let mut out = DomainAttr::default();
for attr in attrs {
if !attr.path().is_ident("domain") {
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("transparent") {
out.transparent = true;
return Ok(());
}
let is_code = meta.path.is_ident("code");
let is_prefix = meta.path.is_ident("prefix");
let target = if meta.path.is_ident("category") {
&mut out.category
} else if is_code {
&mut out.code
} else if is_prefix {
&mut out.prefix
} else {
return Err(meta.error(
"unknown `domain` key (expected category, code, prefix, or transparent)",
));
};
let value: LitStr = meta.value()?.parse()?;
let s = value.value();
if is_code {
validate_code_segment(&s, value.span())?;
} else if is_prefix {
validate_prefix_segment(&s, value.span())?;
}
*target = Some(s);
Ok(())
})?;
}
Ok(out)
}
fn validate_code_segment(value: &str, span: proc_macro2::Span) -> syn::Result<()> {
if value.is_empty() {
return Err(syn::Error::new(span, "code must not be empty"));
}
if value.starts_with('.') || value.ends_with('.') || value.contains("..") {
return Err(syn::Error::new(
span,
format!(
"code '{value}' has invalid dot placement (no leading, trailing, or consecutive dots)"
),
));
}
for segment in value.split('.') {
validate_ident_segment(segment, value, span)?;
}
Ok(())
}
fn validate_prefix_segment(value: &str, span: proc_macro2::Span) -> syn::Result<()> {
if value.is_empty() {
return Err(syn::Error::new(span, "prefix must not be empty"));
}
if value.contains('.') {
return Err(syn::Error::new(
span,
format!("prefix '{value}' must not contain dots — it is a single namespace label"),
));
}
validate_ident_segment(value, value, span)
}
fn validate_ident_segment(segment: &str, full: &str, span: proc_macro2::Span) -> syn::Result<()> {
if segment.is_empty() {
return Err(syn::Error::new(
span,
format!("code/prefix '{full}' contains an empty segment"),
));
}
let mut chars = segment.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
Some(other) => {
return Err(syn::Error::new(
span,
format!(
"'{segment}' must start with a lowercase ASCII letter (a-z), \
not '{other}'"
),
));
}
None => unreachable!(),
}
for c in chars {
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' {
return Err(syn::Error::new(
span,
format!(
"'{segment}' contains invalid character '{c}' — \
only lowercase letters (a-z), digits (0-9), and underscores (_) are allowed"
),
));
}
}
Ok(())
}