nutype_test_util 0.1.3

Ergonomically create newtypes in tests
Documentation
#![doc = include_str!("../README.md")]

use proc_macro::TokenStream;
use quote::{ToTokens as _, format_ident, quote};
use syn::{
    AngleBracketedGenericArguments, Data, DataStruct, Error, Fields, FieldsUnnamed, PathArguments,
    Type, TypePath, spanned::Spanned as _,
};

#[cfg(test)]
mod tests;

/// Generates a `From` impl from the inner type for the newtype itself,
/// panicking if the constraints are violated.
///
/// These impls are active only under `#[cfg(test)]`, as such they don't exist
/// at runtime. Their sole purpose is to make it more ergonomic to write tests
/// that use newtypes.
///
/// If you have newtypes like this:
///
/// ```ignore
/// extern crate nutype;
/// use nutype::nutype;
///
/// #[nutype(validate(non_empty))]
/// struct Repo(String);
///
/// #[nutype]
/// struct PrNumber(NonZeroU32);
///
/// #[nutype(validate(non_empty, predicate = is_valid_commit_hash))]
/// struct Commit(String)
///
/// struct Remote {
///     repo: Repo,
///     pr_number: PrNumber,
///     commit: Commit
/// }
/// ```
///
/// Using them in test look like this:
///
/// ```ignore
/// // in `#[cfg(test)]`
/// Remote {
///     repo: Repo::try_new("helix-editor/helix").unwrap(),
///     pr_number: PrNumber::new(NonZeroU32::new(15).unwrap()),
///     commit: Some(Commit::try_new("1a2b3c4d").unwrap()),
/// }
/// ```
///
/// That is very verbose. By using `#[nutype_test_util::derive(From)]`:
///
/// ```ignore
/// #[nutype_test_util::derive(From)]
/// #[nutype(validate(non_empty))]
/// struct Repo(String);
///
/// #[nutype_test_util::derive(From)]
/// #[nutype]
/// struct PrNumber(NonZeroU32);
///
/// #[nutype_test_util::derive(From)]
/// #[nutype(validate(non_empty, predicate = is_valid_commit_hash))]
/// struct Commit(String)
/// ```
///
/// For tests, the following implementations will be generated:
///
/// ```ignore
/// #[cfg(test)]
/// impl<T: Into<String>> ::core::convert::From<T> for Repo {
///     fn from(value: T) -> Self {
///         let value: String = value.into();
///         Self::try_new(value).unwrap()
///     }
/// }
///
/// #[cfg(test)]
/// impl<T: Into<u32>> ::core::convert::From<T> for PrNumber {
///     fn from(value: T) -> Self {
///         let value: u32 = value.into();
///         Self::new(::core::num::NonZeroU32::new(value).unwrap())
///     }
/// }
///
/// #[cfg(test)]
/// impl<T: Into<String>> ::core::convert::From<T> for Commit {
///     fn from(value: T) -> Self {
///         let value: String = value.into();
///         Self::try_new(value).unwrap()
///     }
/// }
/// ```
///
/// Allowing you to ergonomically create these values in tests:
///
/// ```ignore
/// // in `#[cfg(test)]`
/// Remote {
///     repo: "helix-editor/helix".into(),
///     pr_number: 15.into(),
///     commit: Some("1a2b3c4d".into()),
/// }
/// ```
#[proc_macro_attribute]
pub fn derive(attr: TokenStream, item: TokenStream) -> TokenStream {
    do_output(do_derive(attr.into(), item.into()))
}

/// Wrapper around logic for the proc macro for easy
/// error recovery with rust-analyzer
fn do_output(res: Result<(proc_macro2::TokenStream, Vec<Error>), Error>) -> TokenStream {
    match res {
        Err(err) => err.to_compile_error().into(),
        Ok((out, errors)) => {
            let compiler_errors = errors.iter().map(Error::to_compile_error);

            // Put all the output we received first,
            // then emit compiler errors about whatever the user got wrong.
            //
            // That way rust-analyzer still has some stuff to work with.
            let output = quote! {
                #out
                #( #compiler_errors )*
            };

            output.into()
        }
    }
}

/// Entry point
///
/// If the macro encountered a fatal error, return Err.
/// If the macro encountered some errors, but was also able to construct some source code,
/// returns Ok with a non-empty vec of errors.
fn do_derive(
    attr: proc_macro2::TokenStream,
    item: proc_macro2::TokenStream,
) -> Result<(proc_macro2::TokenStream, Vec<Error>), Error> {
    // // try to place all non-fatal errors in the `Vec`, that way rust-analyzer will still
    // // receive some code to work with
    let mut errors = Vec::new();

    let attr = syn::parse2::<syn::Ident>(attr)?;

    if attr != "From" {
        errors.push(Error::new(attr.span(), "expected `From`"));
    }

    let item = syn::parse2::<syn::DeriveInput>(item)?;

    let Data::Struct(DataStruct { fields, .. }) = &item.data else {
        return Err(Error::new(item.span(), "expected a `struct`"));
    };

    let Fields::Unnamed(FieldsUnnamed { unnamed, .. }) = fields else {
        return Err(Error::new(
            fields.span(),
            "expected a tuple struct `struct Foo(_)`",
        ));
    };

    let unnamed_error = Error::new(
        unnamed.span(),
        "expected newtype to contain a single type, like: `struct Foo(Bar)`",
    );

    let Some(field) = unnamed.first() else {
        return Err(unnamed_error);
    };

    if unnamed.len() != 1 {
        errors.push(unnamed_error);
    }

    // // if an argument to `#[nutype]` has a `#[nutype(validate(...))]`, then
    // // we will use `try_new` then `.unwrap()`. If not, then we use `new`.
    let is_try_new = item
        .attrs
        .iter()
        .flat_map(|attr| attr.meta.require_list())
        .find(|list| {
            list.path
                .segments
                .first()
                .is_some_and(|first_segment| first_segment.ident == "nutype")
        })
        .is_some_and(|list| {
            // CLONE: `list.tokens` does not have a `.iter()` method
            list.tokens.clone().into_iter().any(|token| {
                if let proc_macro2::TokenTree::Ident(ident) = token {
                    ident == proc_macro2::Ident::new("validate", ident.span())
                } else {
                    false
                }
            })
        });

    // if the inner type is a `NonZero<u8>` (and the like), then we generate `impl From<u8> for Newtype`
    let (passed_to_constructor, from_ty) = if let Type::Path(TypePath { path, .. }) = &field.ty
        && let Some(last_segment) = path.segments.last()
        && last_segment.ident == "NonZero"
        && let PathArguments::AngleBracketed(AngleBracketedGenericArguments { args, .. }) =
            &last_segment.arguments
        && args.len() == 1
        && let Some(syn::GenericArgument::Type(Type::Path(TypePath { path, .. }))) = args.first()
        && path.segments.len() == 1
        && let Some(path) = path.segments.first()
        // NOTE: `.to_lowercase()` means we parse `u8`, `usize` into
        && let Some(non_zero) = parse_numeric_primitive(&path.ident.to_string(), true)
    {
        let non_zero = format_ident!("{non_zero}");
        (
            quote! { ::core::num::NonZero::<#non_zero>::new(value).unwrap() },
            quote! { #non_zero },
        )
    // if the inner type is a `NonZeroU8` (and the like), then we
    // generate an `impl From<u8> for Newtype`
    } else if let Type::Path(TypePath { path, .. }) = &field.ty
        && let Some(last_segment) = path.segments.last()
        && let Some(non_zero_ty_upper) = last_segment.ident.to_string().strip_prefix("NonZero")
        && let Some(non_zero_ty_lower) = parse_numeric_primitive(non_zero_ty_upper, false)
    {
        let non_zero_ty = format_ident!("NonZero{non_zero_ty_upper}");
        let non_zero_ty_lower = format_ident!("{}", non_zero_ty_lower);
        (
            quote! { ::core::num::#non_zero_ty::new(value).unwrap() },
            quote! { #non_zero_ty_lower },
        )
    // otherwise, use a `From<T>` impl where the `T` contains the inner type of the newtype
    // e.g. in `struct FooBar(BazQuux)` we generate a `From<BazQuux> for FooBar`
    } else {
        (quote!(value), field.ty.to_token_stream())
    };

    let into_ty = &item.ident;

    // creates `Self`
    let constructor = if is_try_new {
        quote! { Self::try_new(#passed_to_constructor).unwrap() }
    } else {
        quote! { Self::new(#passed_to_constructor) }
    };

    // `impl From for Newtype`
    let from_impl = quote! {
        #[cfg(test)]
        impl<T: Into<#from_ty>> ::core::convert::From<T> for #into_ty {
            fn from(value: T) -> Self {
                let value: #from_ty = value.into();
                #constructor
            }
        }
    };

    Ok((
        quote! {
            #item
            #from_impl
        },
        errors,
    ))
}

/// Ensure the `ty` is a numeric primitive like `i8` or `usize`.
/// The casing of the first (input) letter is controlled with `is_lower`,
/// it always outputs `Some<_>` containing the lowercase numeric type.
///
/// Outputs `None` when the `ty` is not a valid numeric literal
fn parse_numeric_primitive(ty: &str, is_lower: bool) -> Option<String> {
    fn convert(ty: &str, (signed_in, unsigned_in): (char, char)) -> Option<String> {
        if let Some(suffix) = ty.strip_prefix(signed_in) {
            if is_valid_suffix(suffix) {
                return Some(format!("i{suffix}"));
            }
        } else if let Some(suffix) = ty.strip_prefix(unsigned_in) {
            if is_valid_suffix(suffix) {
                return Some(format!("u{suffix}"));
            }
        }

        None
    }

    fn is_valid_suffix(s: &str) -> bool {
        matches!(s, "8" | "16" | "32" | "64" | "128" | "size")
    }

    let in_prefixes = if is_lower { ('i', 'u') } else { ('I', 'U') };

    convert(ty, in_prefixes)
}