tier-derive 0.1.17

Derive macros for tier Rust configuration metadata
Documentation
use quote::quote;
use syn::{Expr, Lit, LitStr, punctuated::Punctuated, spanned::Spanned};

use super::attr::{NumericLiteral, NumericLiteralValue};

pub(crate) fn parse_expr_value(meta: syn::meta::ParseNestedMeta<'_>) -> syn::Result<Expr> {
    meta.value()?.parse()
}

pub(crate) fn parse_string_value(meta: syn::meta::ParseNestedMeta<'_>) -> syn::Result<String> {
    let literal: LitStr = meta.value()?.parse()?;
    Ok(literal.value())
}

pub(crate) fn parse_usize_value(meta: syn::meta::ParseNestedMeta<'_>) -> syn::Result<usize> {
    let literal: syn::LitInt = meta.value()?.parse()?;
    literal.base10_parse()
}

pub(crate) fn parse_string_list_call(
    meta: syn::meta::ParseNestedMeta<'_>,
) -> syn::Result<Vec<String>> {
    let content;
    syn::parenthesized!(content in meta.input);
    let values = Punctuated::<LitStr, syn::Token![,]>::parse_terminated(&content)?;
    if values.is_empty() {
        return Err(meta.error("expected at least one string literal"));
    }
    Ok(values.into_iter().map(|value| value.value()).collect())
}

pub(crate) fn parse_expr_list_call(meta: syn::meta::ParseNestedMeta<'_>) -> syn::Result<Vec<Expr>> {
    let content;
    syn::parenthesized!(content in meta.input);
    let values = Punctuated::<Expr, syn::Token![,]>::parse_terminated(&content)?;
    if values.is_empty() {
        return Err(meta.error("expected at least one expression"));
    }
    Ok(values.into_iter().collect())
}

pub(crate) fn parse_literal_expr_list(
    meta: syn::meta::ParseNestedMeta<'_>,
) -> syn::Result<Vec<Expr>> {
    let content;
    syn::parenthesized!(content in meta.input);
    let values = Punctuated::<Expr, syn::Token![,]>::parse_terminated(&content)?;
    if values.is_empty() {
        return Err(meta.error("expected at least one literal value"));
    }
    let values = values.into_iter().collect::<Vec<_>>();
    for value in &values {
        validate_value_expr(value, value.span())?;
    }
    Ok(values)
}

pub(crate) fn parse_rule_key_string_value(
    meta: syn::meta::ParseNestedMeta<'_>,
) -> syn::Result<(String, String)> {
    let mut rule = None::<String>;
    let mut value = None::<String>;
    meta.parse_nested_meta(|nested| {
        if nested.path.is_ident("rule") {
            rule = Some(parse_string_value(nested)?);
            return Ok(());
        }
        if nested.path.is_ident("value") {
            value = Some(parse_string_value(nested)?);
            return Ok(());
        }
        Err(nested.error("unsupported validation rule config option"))
    })?;

    let Some(rule) = rule else {
        return Err(meta.error("validation rule config requires `rule = \"...\"`"));
    };
    let Some(value) = value else {
        return Err(meta.error("validation rule config requires `value = \"...\"`"));
    };
    Ok((rule, value))
}

pub(crate) fn parse_rule_key_string_list(
    meta: syn::meta::ParseNestedMeta<'_>,
) -> syn::Result<(String, Vec<String>)> {
    let mut rule = None::<String>;
    let mut values = None::<Vec<String>>;
    meta.parse_nested_meta(|nested| {
        if nested.path.is_ident("rule") {
            rule = Some(parse_string_value(nested)?);
            return Ok(());
        }
        if nested.path.is_ident("values") {
            values = Some(parse_string_list_call(nested)?);
            return Ok(());
        }
        Err(nested.error("unsupported validation tag config option"))
    })?;

    let Some(rule) = rule else {
        return Err(meta.error("validation tag config requires `rule = \"...\"`"));
    };
    let Some(values) = values else {
        return Err(meta.error("validation tag config requires `values(\"...\")`"));
    };
    Ok((rule, values))
}

pub(crate) fn parse_numeric_literal(
    meta: syn::meta::ParseNestedMeta<'_>,
) -> syn::Result<NumericLiteral> {
    let expr: Expr = meta.value()?.parse()?;
    parse_numeric_expr(expr, meta.path.span())
}

fn parse_numeric_expr(expr: Expr, span: proc_macro2::Span) -> syn::Result<NumericLiteral> {
    match expr {
        Expr::Lit(expr_lit) => match expr_lit.lit {
            Lit::Int(literal) => Ok(NumericLiteral {
                tokens: quote! { #literal },
                value: parse_decimal_numeric_literal(literal.base10_digits(), false, span)?,
            }),
            Lit::Float(literal) => Ok(NumericLiteral {
                tokens: quote! { #literal },
                value: parse_decimal_numeric_literal(literal.base10_digits(), false, span)?,
            }),
            _ => Err(syn::Error::new(
                span,
                "expected an integer or float literal",
            )),
        },
        Expr::Unary(expr_unary) if matches!(expr_unary.op, syn::UnOp::Neg(_)) => {
            match *expr_unary.expr {
                Expr::Lit(expr_lit) => match expr_lit.lit {
                    Lit::Int(literal) => Ok(NumericLiteral {
                        tokens: quote! { -#literal },
                        value: parse_decimal_numeric_literal(literal.base10_digits(), true, span)?,
                    }),
                    Lit::Float(literal) => Ok(NumericLiteral {
                        tokens: quote! { -#literal },
                        value: parse_decimal_numeric_literal(literal.base10_digits(), true, span)?,
                    }),
                    _ => Err(syn::Error::new(
                        span,
                        "expected an integer or float literal",
                    )),
                },
                _ => Err(syn::Error::new(
                    span,
                    "expected an integer or float literal",
                )),
            }
        }
        _ => Err(syn::Error::new(
            span,
            "expected an integer or float literal",
        )),
    }
}

fn parse_decimal_numeric_literal(
    text: &str,
    negative: bool,
    span: proc_macro2::Span,
) -> syn::Result<NumericLiteralValue> {
    let text = text.replace('_', "");
    let (mantissa, exponent) = match text.split_once(['e', 'E']) {
        Some((mantissa, exponent)) => (
            mantissa,
            exponent
                .parse::<i32>()
                .map_err(|_| numeric_literal_error(span))?,
        ),
        None => (text.as_str(), 0),
    };
    let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
    let digits = format!("{whole}{fraction}");
    let numerator = digits
        .parse::<u128>()
        .map_err(|_| numeric_literal_error(span))?;
    let fraction_digits = i32::try_from(fraction.len()).map_err(|_| numeric_literal_error(span))?;
    let scale = fraction_digits
        .checked_sub(exponent)
        .ok_or_else(|| numeric_literal_error(span))?;

    let (numerator, denominator) = if scale <= 0 {
        let multiplier =
            pow10_u128(scale.unsigned_abs()).ok_or_else(|| numeric_literal_error(span))?;
        (
            numerator
                .checked_mul(multiplier)
                .ok_or_else(|| numeric_literal_error(span))?,
            1,
        )
    } else {
        (
            numerator,
            pow10_u128(u32::try_from(scale).map_err(|_| numeric_literal_error(span))?)
                .ok_or_else(|| numeric_literal_error(span))?,
        )
    };

    Ok(NumericLiteralValue::new(negative, numerator, denominator))
}

fn pow10_u128(exponent: u32) -> Option<u128> {
    let mut value = 1u128;
    for _ in 0..exponent {
        value = value.checked_mul(10)?;
    }
    Some(value)
}

fn numeric_literal_error(span: proc_macro2::Span) -> syn::Error {
    syn::Error::new(
        span,
        "numeric literal is too large for exact tier validation",
    )
}

pub(crate) fn parse_value_expr(meta: syn::meta::ParseNestedMeta<'_>) -> syn::Result<Expr> {
    let expr: Expr = meta.value()?.parse()?;
    validate_value_expr(&expr, meta.path.span())?;
    Ok(expr)
}

fn validate_value_expr(expr: &Expr, span: proc_macro2::Span) -> syn::Result<()> {
    match expr {
        Expr::Lit(expr_lit) => match &expr_lit.lit {
            Lit::Str(_) | Lit::Bool(_) | Lit::Int(_) | Lit::Float(_) => Ok(()),
            _ => Err(syn::Error::new(
                span,
                "expected a string, bool, integer, or float literal",
            )),
        },
        Expr::Unary(expr_unary) if matches!(expr_unary.op, syn::UnOp::Neg(_)) => match &*expr_unary
            .expr
        {
            Expr::Lit(expr_lit) if matches!(expr_lit.lit, Lit::Int(_) | Lit::Float(_)) => Ok(()),
            _ => Err(syn::Error::new(
                span,
                "expected a string, bool, integer, or float literal",
            )),
        },
        _ => Err(syn::Error::new(
            span,
            "expected a string, bool, integer, or float literal",
        )),
    }
}

pub(crate) fn unraw(ident: &syn::Ident) -> String {
    ident.to_string().trim_start_matches("r#").to_owned()
}

pub(crate) fn consume_unused_meta(meta: syn::meta::ParseNestedMeta<'_>) -> syn::Result<()> {
    if meta.input.peek(syn::Token![=]) {
        let _: Expr = meta.value()?.parse()?;
        return Ok(());
    }

    if meta.input.peek(syn::token::Paren) {
        meta.parse_nested_meta(|nested| {
            consume_unused_meta(nested)?;
            Ok(())
        })?;
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use std::cmp::Ordering;

    use syn::Expr;

    use super::parse_numeric_expr;

    fn parse(value: &str) -> super::NumericLiteralValue {
        let expr = syn::parse_str::<Expr>(value).expect("valid numeric expression");
        parse_numeric_expr(expr, proc_macro2::Span::call_site())
            .expect("numeric literal parses")
            .value
    }

    #[test]
    fn numeric_literal_comparison_preserves_large_integer_precision() {
        assert_eq!(
            parse("9007199254740993").cmp_exact(&parse("9007199254740992")),
            Some(Ordering::Greater)
        );
    }

    #[test]
    fn numeric_literal_comparison_preserves_decimal_precision() {
        assert_eq!(
            parse("0.10").cmp_exact(&parse("0.1")),
            Some(Ordering::Equal)
        );
        assert_eq!(
            parse("-1.5").cmp_exact(&parse("-1.4")),
            Some(Ordering::Less)
        );
    }

    #[test]
    fn numeric_literal_positivity_is_exact() {
        assert!(parse("0.0001").is_positive());
        assert!(!parse("0").is_positive());
        assert!(!parse("-0.0001").is_positive());
    }
}