scuffle_changelog/
macro_impl.rs

1use std::fmt::{Display, Write};
2
3use convert_case::Casing;
4use proc_macro2::{Span, TokenStream};
5use quote::quote;
6
7#[derive(Debug)]
8struct ChangeLogEntry<'a> {
9    module_name: syn::Ident,
10    module_comment: String,
11    lines: Vec<&'a str>,
12}
13
14static HEADING_REGEX: std::sync::LazyLock<regex::Regex> =
15    std::sync::LazyLock::new(|| regex::Regex::new(r"^## \[([^]]+)\](?:\(([^)]+)\))?(?: - (\d{4}-\d{2}-\d{2}))?$").unwrap());
16
17fn parse_changelog(changelog: &str) -> Result<Vec<ChangeLogEntry>, String> {
18    let mut entries = Vec::new();
19    let mut lines_iter = changelog.lines().peekable();
20    while let Some(line) = lines_iter.next() {
21        let Some(capture) = HEADING_REGEX.captures(line) else {
22            continue;
23        };
24
25        let name = capture.get(1).unwrap().as_str();
26        let url = capture.get(2).map(|m| m.as_str());
27        let date = capture.get(3).map(|m| m.as_str());
28        let semver = semver::Version::parse(name).ok();
29        let module_name = if let Some(version) = &semver {
30            let mut name = format!("v{}_{}_{}", version.major, version.minor, version.patch);
31            if !version.pre.is_empty() {
32                name.push('_');
33                name.extend(version.pre.chars().map(|c| match c {
34                    '-' | '.' => '_',
35                    c => c,
36                }));
37            }
38            name
39        } else {
40            name.to_case(convert_case::Case::Snake)
41        };
42
43        let mut lines = Vec::new();
44        while let Some(line) = lines_iter.peek() {
45            if HEADING_REGEX.is_match(line) {
46                break;
47            }
48
49            lines.push(lines_iter.next().unwrap());
50        }
51
52        if lines.iter().any(|line| !line.trim().is_empty()) {
53            entries.push(ChangeLogEntry {
54                module_name: syn::Ident::new(&module_name, Span::call_site()),
55                lines,
56                module_comment: fmtools::fmt(|f| {
57                    if let Some(semver) = &semver {
58                        f.write_str("Release ")?;
59                        if url.is_some() {
60                            f.write_char('[')?;
61                        }
62                        semver.fmt(f)?;
63                        if let Some(url) = url {
64                            f.write_char(']')?;
65                            f.write_char('(')?;
66                            f.write_str(url)?;
67                            f.write_char(')')?;
68                        }
69                    } else {
70                        f.write_str(name)?;
71                    }
72
73                    if let Some(date) = &date {
74                        f.write_str(" (")?;
75                        f.write_str(date)?;
76                        f.write_char(')')?;
77                    }
78
79                    Ok(())
80                })
81                .to_string(),
82            });
83        }
84    }
85
86    Ok(entries)
87}
88
89pub(crate) fn changelog(_: TokenStream, item: TokenStream) -> syn::Result<TokenStream> {
90    let syn::ItemMod {
91        attrs,
92        content,
93        ident,
94        mod_token,
95        vis,
96        ..
97    } = syn::parse2(item)?;
98
99    let manifest_dir = std::env::var_os("CARGO_MANIFEST_DIR").unwrap();
100    let path = std::path::PathBuf::from(manifest_dir);
101    let changelog =
102        std::fs::read_to_string(path.join("CHANGELOG.md")).map_err(|err| syn::Error::new(ident.span(), err.to_string()))?;
103    let entries = parse_changelog(&changelog).map_err(|err| syn::Error::new(ident.span(), err.to_string()))?;
104
105    let entries = entries.into_iter().map(
106        |ChangeLogEntry {
107             module_name,
108             lines,
109             module_comment,
110         }| {
111            quote! {
112                #[doc = concat!(" ", #module_comment)]
113                #( #[doc = concat!(" ", #lines)] )*
114                #vis #mod_token #module_name {}
115            }
116        },
117    );
118
119    let content = content.unwrap_or_default().1;
120
121    Ok(quote! {
122        #(#attrs)*
123        #vis #mod_token #ident {
124            #(#entries)*
125            #(#content)*
126        }
127    })
128}