scuffle_changelog/
macro_impl.rs1use 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}