jsonld-expandable-core 0.24.0

Runtime traits, attribute parser, and codegen for the JSON-LD `Expandable` derive
Documentation
//! `#[jsonld(...)]` attribute parser.
//!
//! Lowers the container and field attributes of a `#[derive(Expandable)]`
//! input into the [`crate::ir`] form that codegen consumes, rejecting
//! combinations that cannot expand to valid JSON-LD.

use crate::{
    ir::{Coerce, ContainerIr, ContainerKind, FieldIr},
    iri::looks_like_iri,
};
use proc_macro2::Span;
use syn::spanned::Spanned;

/// Parses the `#[jsonld(...)]` attributes carried by a type.
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(attribute_anchor(attr));

        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") {
                // Hand-rolled so prefix names may contain hyphens
                // (`prefix(ngsi-ld = "...")`), which `Path`-based keys reject;
                // string-literal keys are accepted too.
                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)
}

/// Parses the `#[jsonld(...)]` attributes carried by a field.
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(attribute_anchor(attr));

        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") {
                // Field's own Expandable impl produces the final shape;
                // codegen calls expand() and inserts the value verbatim.
                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)
}

/// The span a diagnostic about a whole `#[jsonld(...)]` attribute points at:
/// the attribute's `jsonld` path.
///
/// [`Spanned::span`] of the attribute joins the spans from `#` to `]`, which
/// only a nightly compiler can do; a stable one falls back to the `#` token, so
/// the same diagnostic would render differently per toolchain. The path is a
/// single token, so it renders the same everywhere.
fn attribute_anchor(attr: &syn::Attribute) -> Span {
    attr.path().span()
}

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(':') {
        // a single-token prefix like "ngsi" without `://` qualifies
        !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
    }
}