use crate::{
ir::{Coerce, ContainerIr, ContainerKind, FieldIr},
iri::looks_like_iri,
};
use proc_macro2::Span;
use syn::spanned::Spanned;
pub fn parse_container(attrs: &[syn::Attribute]) -> syn::Result<ContainerIr> {
let mut out = ContainerIr::default();
let mut span: Option<Span> = None;
for attr in attrs {
if !attr.path().is_ident("jsonld") {
continue;
}
span = Some(attr.span());
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("type") {
let lit: syn::LitStr = meta.value()?.parse()?;
let iri = lit.value();
if !looks_like_iri(&iri) && !is_curie(&iri) {
return Err(syn::Error::new(
lit.span(),
format!(
"invalid IRI \"{iri}\": expected a full IRI with a scheme \
(e.g. \"https://example.com/Type\") or a CURIE \
(e.g. \"ex:Type\") backed by a `prefix(...)` entry"
),
));
}
out.type_iri = Some(iri);
} else if meta.path.is_ident("fragment") {
out.fragment = true;
} else if meta.path.is_ident("debug") {
out.debug = true;
} else if meta.path.is_ident("prefix") {
let content;
syn::parenthesized!(content in meta.input);
while !content.is_empty() {
let name = if content.peek(syn::LitStr) {
content.parse::<syn::LitStr>()?.value()
} else {
let mut name = content.parse::<syn::Ident>()?.to_string();
while content.peek(syn::Token![-]) {
content.parse::<syn::Token![-]>()?;
name.push('-');
name.push_str(&content.parse::<syn::Ident>()?.to_string());
}
name
};
content.parse::<syn::Token![=]>()?;
let lit: syn::LitStr = content.parse()?;
out.prefixes.push((name, lit.value()));
if !content.is_empty() {
content.parse::<syn::Token![,]>()?;
}
}
} else {
return Err(meta.error(format!(
"unknown jsonld container attribute `{}`; expected one of: \
type, fragment, debug, prefix",
meta.path.get_ident().map_or("?".into(), std::string::ToString::to_string)
)));
}
Ok(())
})?;
}
if out.fragment && out.type_iri.is_some() {
return Err(syn::Error::new(
span.unwrap_or_else(Span::call_site),
"`fragment` and `type = \"...\"` are mutually exclusive",
));
}
Ok(out)
}
pub fn parse_field(attrs: &[syn::Attribute]) -> syn::Result<FieldIr> {
let mut out = FieldIr::default();
let mut span: Option<Span> = None;
for attr in attrs {
if !attr.path().is_ident("jsonld") {
continue;
}
span = Some(attr.span());
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("id") {
out.is_id = true;
} else if meta.path.is_ident("type_value") {
out.is_type_value = true;
} else if meta.path.is_ident("skip") {
out.skip = true;
} else if meta.path.is_ident("property") {
let lit: syn::LitStr = meta.value()?.parse()?;
let iri = lit.value();
if !looks_like_iri(&iri) && !is_curie(&iri) {
return Err(syn::Error::new(
lit.span(),
format!(
"invalid property IRI \"{iri}\": expected a full IRI \
with a scheme or a CURIE backed by `prefix(...)`"
),
));
}
out.property = Some(iri);
} else if meta.path.is_ident("coerce") {
let lit: syn::LitStr = meta.value()?.parse()?;
let v = lit.value();
out.coerce = Some(match v.as_str() {
"@id" => Coerce::Id,
"@vocab" => Coerce::Vocab,
"@json" => Coerce::Json,
other if other.starts_with('@') => {
return Err(syn::Error::new(
lit.span(),
format!("unknown keyword coercion `{other}`; expected `@id`, `@vocab`, or `@json`"),
));
}
other => {
if !looks_like_iri(other) && !is_curie(other) {
return Err(syn::Error::new(
lit.span(),
format!(
"invalid datatype IRI \"{other}\": expected a full IRI \
with a scheme or a CURIE backed by `prefix(...)`"
),
));
}
Coerce::Datatype(other.to_owned())
}
});
} else if meta.path.is_ident("container") {
let lit: syn::LitStr = meta.value()?.parse()?;
let v = lit.value();
out.container = Some(match v.as_str() {
"list" => ContainerKind::List,
"set" => ContainerKind::Set,
"language" => ContainerKind::Language,
"index" => ContainerKind::Index,
"id" => ContainerKind::Id,
"type" => ContainerKind::Type,
"graph" => ContainerKind::Graph,
other => {
return Err(syn::Error::new(lit.span(), format!("unknown container `{other}`")));
}
});
} else if meta.path.is_ident("flatten") {
out.flatten = true;
} else if meta.path.is_ident("flatten_map") {
out.flatten_map = true;
} else if meta.path.is_ident("nested") {
out.nested = true;
} else if meta.path.is_ident("vec") {
out.is_vec = true;
} else if meta.path.is_ident("passthrough") {
out.passthrough = true;
} else if meta.path.is_ident("nest") || meta.path.is_ident("reverse") {
return Err(meta.error("`nest` / `reverse` are reserved but not yet implemented"));
} else {
return Err(meta.error(format!(
"unknown jsonld field attribute `{}`",
meta.path.get_ident().map_or("?".into(), std::string::ToString::to_string)
)));
}
Ok(())
})?;
}
validate_field(&out, span)?;
Ok(out)
}
fn validate_field(f: &FieldIr, span_hint: Option<Span>) -> syn::Result<()> {
let span = span_hint.unwrap_or_else(Span::call_site);
if f.is_id && (f.property.is_some() || f.nested || f.coerce.is_some() || f.container.is_some() || f.flatten || f.flatten_map) {
return Err(syn::Error::new(
span,
"`id` cannot be combined with property, nested, coerce, container, or flatten",
));
}
if f.skip && (f.is_id || f.nested || f.coerce.is_some() || f.container.is_some() || f.flatten || f.flatten_map || f.property.is_some()) {
return Err(syn::Error::new(span, "`skip` cannot be combined with other attributes"));
}
if f.flatten && f.flatten_map {
return Err(syn::Error::new(span, "`flatten` and `flatten_map` are mutually exclusive"));
}
if (f.flatten || f.flatten_map) && f.property.is_some() {
return Err(syn::Error::new(span, "`flatten` / `flatten_map` cannot be combined with `property`"));
}
if (f.flatten || f.flatten_map) && (f.coerce.is_some() || f.container.is_some() || f.nested) {
return Err(syn::Error::new(
span,
"`flatten` / `flatten_map` cannot be combined with coerce, container, or nested",
));
}
if f.passthrough && (f.coerce.is_some() || f.container.is_some() || f.nested || f.flatten || f.flatten_map || f.is_id) {
return Err(syn::Error::new(
span,
"`passthrough` cannot be combined with coerce, container, nested, \
flatten, flatten_map, or id",
));
}
if f.is_vec && !f.nested && !matches!(f.coerce, Some(Coerce::Id | Coerce::Vocab)) {
return Err(syn::Error::new(span, "`vec` requires `nested` (or an `@id`/`@vocab` coercion that iterates)"));
}
if f.nested && f.coerce.is_some() {
return Err(syn::Error::new(span, "`nested` and `coerce` are mutually exclusive"));
}
if matches!(f.container, Some(ContainerKind::Language)) && f.coerce.is_some() {
return Err(syn::Error::new(span, "`container = \"language\"` and `coerce` are mutually exclusive"));
}
Ok(())
}
fn is_curie(s: &str) -> bool {
if let Some((prefix, _suffix)) = s.split_once(':') {
!prefix.is_empty()
&& prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
&& !s.starts_with("http://")
&& !s.starts_with("https://")
&& !s.starts_with("urn:")
} else {
false
}
}