embed-changelog 0.1.2

A helper macro to embed changelogs in crate docs.
Documentation
use std::fmt::{Display, Write};

use convert_case::Casing;
use proc_macro2::{Span, TokenStream};
use quote::quote;

#[derive(Debug)]
struct ChangeLogEntry<'a> {
    module_name: syn::Ident,
    module_comment: String,
    lines: Vec<&'a str>,
}

static HEADING_REGEX: std::sync::LazyLock<regex::Regex> =
    std::sync::LazyLock::new(|| regex::Regex::new(r"^## \[([^]]+)\](?:\(([^)]+)\))?(?: - (\d{4}-\d{2}-\d{2}))?$").unwrap());

fn parse_changelog(changelog: &str) -> Result<Vec<ChangeLogEntry<'_>>, String> {
    let mut entries = Vec::new();
    let mut lines_iter = changelog.lines().peekable();
    while let Some(line) = lines_iter.next() {
        let Some(capture) = HEADING_REGEX.captures(line) else {
            continue;
        };

        let name = capture.get(1).unwrap().as_str();
        let url = capture.get(2).map(|m| m.as_str());
        let date = capture.get(3).map(|m| m.as_str());
        let semver = semver::Version::parse(name).ok();
        let module_name = if let Some(version) = &semver {
            let mut name = format!("v{}_{}_{}", version.major, version.minor, version.patch);
            if !version.pre.is_empty() {
                name.push('_');
                name.extend(version.pre.chars().map(|c| match c {
                    '-' | '.' => '_',
                    c => c,
                }));
            }
            name
        } else {
            name.to_case(convert_case::Case::Snake)
        };

        let mut lines = Vec::new();
        while let Some(line) = lines_iter.peek() {
            if HEADING_REGEX.is_match(line) {
                break;
            }

            lines.push(lines_iter.next().unwrap());
        }

        if lines.iter().any(|line| !line.trim().is_empty()) {
            entries.push(ChangeLogEntry {
                module_name: syn::Ident::new(&module_name, Span::call_site()),
                lines,
                module_comment: fmtools::fmt(|f| {
                    if let Some(semver) = &semver {
                        f.write_str("Release ")?;
                        if url.is_some() {
                            f.write_char('[')?;
                        }
                        semver.fmt(f)?;
                        if let Some(url) = url {
                            f.write_char(']')?;
                            f.write_char('(')?;
                            f.write_str(url)?;
                            f.write_char(')')?;
                        }
                    } else {
                        f.write_str(name)?;
                    }

                    if let Some(date) = &date {
                        f.write_str(" (")?;
                        f.write_str(date)?;
                        f.write_char(')')?;
                    }

                    Ok(())
                })
                .to_string(),
            });
        }
    }

    Ok(entries)
}

pub(crate) fn changelog(_: TokenStream, item: TokenStream) -> syn::Result<TokenStream> {
    let syn::ItemMod {
        attrs,
        content,
        ident,
        mod_token,
        vis,
        ..
    } = syn::parse2(item)?;

    let manifest_dir = std::env::var_os("CARGO_MANIFEST_DIR").unwrap();
    let path = std::path::PathBuf::from(manifest_dir);
    let changelog =
        std::fs::read_to_string(path.join("CHANGELOG.md")).map_err(|err| syn::Error::new(ident.span(), err.to_string()))?;
    let entries = parse_changelog(&changelog).map_err(|err| syn::Error::new(ident.span(), err.to_string()))?;

    let entries = entries.into_iter().map(
        |ChangeLogEntry {
             module_name,
             lines,
             module_comment,
         }| {
            quote! {
                #[doc = concat!(" ", #module_comment)]
                #( #[doc = concat!(" ", #lines)] )*
                #vis #mod_token #module_name {}
            }
        },
    );

    let content = content.unwrap_or_default().1;

    Ok(quote! {
        #(#attrs)*
        #vis #mod_token #ident {
            #(#entries)*
            #(#content)*
        }
    })
}