breaking-attr 0.2.0

An attribute macro that enforces per-version invariants on items.
Documentation
//! [![Crates.io Version](https://img.shields.io/crates/v/breaking-attr)](https://crates.io/crates/breaking-attr)
//! [![docs.rs](https://img.shields.io/docsrs/breaking-attr)](https://docs.rs/breaking-attr)
//! [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Waridley/breaking-attr/.github%2Fworkflows%2Ftests.yml)](https://github.com/Waridley/breaking-attr/actions)
//!
//! An attribute macro that enforces per-version invariants on items.
//!
//! ```
//! use breaking_attr::breaking;
//! # use std::hash::Hasher;
//!
//! #[cfg_attr(test, test)]
//! fn hash_impl_did_not_change() {
//!     #[breaking(sha2 = "zEk8F98i1LX-Rr070lCztGerzO1-qlLbosetY1UvSww=")]
//!     const SEED: &str = "This value must not change between minor versions.";
//!
//!     #[breaking("cD1v24qkrBpNJl2awl4hTkYqrOHy3L3_IKMQSjN_LXo=")] // defaults to `blake3`
//!     const HASH: u64 = 5201689092505688044;
//!
//!     // Just for example:
//!     let mut hasher = std::hash::DefaultHasher::new();
//!     hasher.write(SEED.as_bytes());
//!     debug_assert_eq!(hasher.finish(), HASH);
//! }
//!
//! # hash_impl_did_not_change()
//! ```
//!
//! See the documentation on [`breaking`](`macro@breaking`)

use base64::{engine::general_purpose::URL_SAFE as BASE_64, Engine};
use proc_macro::TokenStream;
use proc_macro2::{Span, TokenTree};
use syn::__private::ToTokens;
use syn::{punctuated::Punctuated, Ident, LitStr, Token};

#[cfg(not(any(feature = "blake3", feature = "sha2", feature = "md5",)))]
compile_error!("At least one hasher feature must be enabled");

/// Marks an item that will break the public API if it is changed.
///
/// This works by comparing the hashed [`TokenStream`] of the item against a provided
/// [url-safe](BASE_64)  base64-encoded hash.
///
/// Changes to items marked with this attribute require updating the hash argument (which can be
/// retrieved from the compile error generated by running it with a wrong hash) and most likely
/// bumping the major version of the crate containing the item. At the very least, it should be
/// explained in the commit message and any accompanying PR why the hash was updated without bumping
/// the major version.
///
/// ### Hashers
/// Multiple hash functions are supported via feature flags and one of the following function names
/// provided as an argument to this macro, separated from the hash literal by an equal sign. Feel
/// free to make a [PR](https://github.com/Waridley/breaking-attr) to add your preferred hash
/// function.
///
/// - [`blake3`] (default)
/// - With the `sha2` dependency
///   - [`sha256`](`sha2::Sha256`) (also aliased to `sha2`)
///   - [`sha224`](`sha2::Sha224`)
///   - [`sha384`](`sha2::Sha384`)
///   - [`sha512`](`sha2::Sha512`)
///   - [`sha512_224`](`sha2::Sha512_224`)
///   - [`sha512_256`](`sha2::Sha512_256`)
/// - [`md5`]
///
/// ### Example
/// ```
/// use breaking_attr::breaking;
///
/// # #[cfg(feature = "sha2")]
/// # {
/// #[breaking(sha384 = "6N2eQw_UG4fnbtLYRDtZ_3aXlLfl88OmUBgqerMfwNYUTHmcC8FxCZsYtqVoZqPA")]
/// const SHA_384: &str = "This string must not change without updating the hash.";
/// # }
///
/// # #[cfg(feature = "blake3")]
/// # {
/// #[breaking("XMhrOOD6liFkErtqTnoZnLgSKZ7DHRHKGyH4jWoHu8s=")]
/// const DEFAULT: &str = "The default hasher is `blake3`";
/// # }
/// ```
#[proc_macro_attribute]
pub fn breaking(args: TokenStream, input: TokenStream) -> TokenStream {
    let args =
        syn::parse_macro_input!(args with Punctuated::<TokenTree, Token![=]>::parse_terminated);

    let Some(expected) = args.iter().find_map(|arg| {
        let TokenTree::Literal(lit) = arg else {
            return None;
        };
        syn::parse::<LitStr>(lit.to_token_stream().into()).ok()
    }) else {
        let msg = "couldn't find string literal of hash in arguments";
        let err = syn::Error::new(Span::call_site(), msg);
        return err.into_compile_error().into();
    };
    let expected = expected.value();

    let hasher = args.iter().find_map(|arg| {
        let TokenTree::Ident(ident) = arg else {
            return None;
        };
        Some(ident)
    });

    let input_str = sanitize(input.to_string());

    #[cfg(feature = "print_token_stream")]
    {
        eprintln!(
            "\
TokenStream string: {:?}
sanitized:          {:?}\
",
            input.to_string(),
            crate::sanitize(input.to_string())
        );
    }

    #[cfg(not(all(feature = "blake3", feature = "sha2", feature = "md5",)))]
    let feature_error = |feature: &str| {
        let msg = format!(
            "either the `{feature}` feature must be enabled or a different hasher must be specified"
        );
        let err = syn::Error::new_spanned(hasher.unwrap(), msg);
        TokenStream::from(err.into_compile_error())
    };
    let hash = match hasher.map(Ident::to_string).as_deref() {
        None | Some("blake3") => {
            #[cfg(feature = "blake3")]
            {
                blake3::hash(input_str.as_bytes()).as_bytes().to_vec()
            }
            #[cfg(not(feature = "blake3"))]
            return feature_error("blake3");
        }
        Some(s) if s.starts_with("sha") => {
            #[cfg(feature = "sha2")]
            {
                use sha2::Digest;
                fn sha2(input: &str, mut hasher: impl Digest) -> Vec<u8> {
                    hasher.update(input.as_bytes());
                    hasher.finalize().to_vec()
                }
                match s {
                    "sha2" | "sha256" => sha2(&input_str, sha2::Sha256::new()),
                    "sha224" => sha2(&input_str, sha2::Sha224::new()),
                    "sha384" => sha2(&input_str, sha2::Sha384::new()),
                    "sha512" => sha2(&input_str, sha2::Sha512::new()),
                    "sha512_224" => sha2(&input_str, sha2::Sha512_224::new()),
                    "sha512_256" => sha2(&input_str, sha2::Sha512_256::new()),
                    _ => {
                        return syn::Error::new_spanned(
                            hasher.unwrap(),
                            "unrecognized sha version",
                        )
                        .into_compile_error()
                        .into();
                    }
                }
            }
            #[cfg(not(feature = "sha2"))]
            return feature_error("sha2");
        }
        Some("md5") => {
            #[cfg(feature = "md5")]
            {
                md5::compute(input_str.as_bytes()).0.to_vec()
            }
            #[cfg(not(feature = "md5"))]
            return feature_error("md5");
        }
        Some(other) => {
            let msg = format!("unrecognized hasher `{other}`");
            return syn::Error::new_spanned(hasher.unwrap(), msg)
                .into_compile_error()
                .into();
        }
    };
    let hash = BASE_64.encode(hash);
    if hash != expected {
        let msg = format!(
            "\
hash {hash:?} doesn't match.
TokenStream string: {input_str:?}
unsanitized:        {:?}\
",
            input.to_string()
        );
        return syn::Error::new(proc_macro::Span::call_site().into(), msg)
            .into_compile_error()
            .into();
    }
    input
}

fn sanitize(s: String) -> String {
    let mut in_quote = false;
    let mut raw_quote_surround_stack = Vec::new();
    let mut escaped = false;
    s.chars()
        .map(|c| {
            if !in_quote {
                if raw_quote_surround_stack.is_empty() {
                    if c == 'r' {
                        raw_quote_surround_stack.push(c);
                        escaped = false;
                        return c;
                    }
                } else if c == '#' {
                    raw_quote_surround_stack.push(c);
                    escaped = false;
                    return c;
                } else if c == '"' {
                    in_quote = true;
                    raw_quote_surround_stack.push(c);
                    escaped = false;
                    return c;
                }
            } else if c != 'r' && raw_quote_surround_stack.last() == Some(&'r') {
                raw_quote_surround_stack.pop();
                escaped = false;
                return c;
            }
            if c == '"' {
                if raw_quote_surround_stack.last() == Some(&'"') {
                    raw_quote_surround_stack.pop();
                } else if !escaped {
                    in_quote = !in_quote;
                }
            }
            if c == '#' && raw_quote_surround_stack.last() == Some(&'#') {
                raw_quote_surround_stack.pop();
            }
            if c == '\\' && in_quote && raw_quote_surround_stack.is_empty() {
                escaped = !escaped;
            } else {
                escaped = false;
            }
            let is_line_breaker = c == '\n' || c == '\r';
            if in_quote || !is_line_breaker {
                c
            } else {
                ' '
            }
        })
        .collect()
}