pmcp_macros_support/
lib.rs1#![deny(missing_docs)]
16#![warn(clippy::pedantic)]
17
18pub mod rustdoc {
20 #[must_use]
42 pub fn extract_doc_description(attrs: &[syn::Attribute]) -> Option<String> {
43 let mut lines: Vec<String> = Vec::new();
44 for attr in attrs {
45 if !attr.path().is_ident("doc") {
46 continue;
47 }
48 let syn::Meta::NameValue(nv) = &attr.meta else {
49 continue;
50 };
51 let syn::Expr::Lit(syn::ExprLit {
52 lit: syn::Lit::Str(lit_str),
53 ..
54 }) = &nv.value
55 else {
56 continue;
57 };
58 let raw = lit_str.value();
59 let trimmed = raw.trim();
60 if trimmed.is_empty() {
61 continue;
62 }
63 lines.push(trimmed.to_string());
64 }
65 if lines.is_empty() {
66 None
67 } else {
68 Some(lines.join("\n"))
69 }
70 }
71
72 #[doc(hidden)]
77 #[must_use]
78 pub fn reference_normalize(lines: &[String]) -> Option<String> {
79 let joined = lines
80 .iter()
81 .map(|l| l.trim())
82 .filter(|l| !l.is_empty())
83 .collect::<Vec<&str>>()
84 .join("\n");
85 if joined.is_empty() {
86 None
87 } else {
88 Some(joined)
89 }
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::rustdoc::{extract_doc_description, reference_normalize};
96
97 fn doc_attrs(lines: &[&str]) -> Vec<syn::Attribute> {
98 lines
99 .iter()
100 .map(|line| {
101 let lit = syn::LitStr::new(line, proc_macro2::Span::call_site());
102 syn::parse_quote! { #[doc = #lit] }
103 })
104 .collect()
105 }
106
107 #[test]
110 fn vec1_single_line() {
111 assert_eq!(
112 extract_doc_description(&doc_attrs(&[" Add two numbers."])),
113 Some("Add two numbers.".to_string())
114 );
115 }
116
117 #[test]
118 fn vec2_two_lines_join_newline() {
119 assert_eq!(
120 extract_doc_description(&doc_attrs(&[" Add two numbers.", " Returns their sum."])),
121 Some("Add two numbers.\nReturns their sum.".to_string())
122 );
123 }
124
125 #[test]
126 fn vec3_blank_middle_line_dropped() {
127 assert_eq!(
128 extract_doc_description(&doc_attrs(&[" Line 1.", "", " Line 2."])),
129 Some("Line 1.\nLine 2.".to_string())
130 );
131 }
132
133 #[test]
134 fn vec4_leading_whitespace_trimmed() {
135 assert_eq!(
136 extract_doc_description(&doc_attrs(&[" Indented body."])),
137 Some("Indented body.".to_string())
138 );
139 }
140
141 #[test]
142 fn vec5_trailing_whitespace_trimmed() {
143 assert_eq!(
144 extract_doc_description(&doc_attrs(&[" Line 1. "])),
145 Some("Line 1.".to_string())
146 );
147 }
148
149 #[test]
150 fn vec6_no_doc_attrs_returns_none() {
151 assert_eq!(extract_doc_description(&[]), None);
152 }
153
154 #[test]
155 fn vec7_only_empty_lines_returns_none() {
156 assert_eq!(extract_doc_description(&doc_attrs(&["", " ", ""])), None);
157 }
158
159 #[test]
160 fn vec8_doc_hidden_skipped() {
161 let mut attrs = doc_attrs(&[" Line 1."]);
162 attrs.push(syn::parse_quote! { #[doc(hidden)] });
163 attrs.extend(doc_attrs(&[" Line 2."]));
164 assert_eq!(
165 extract_doc_description(&attrs),
166 Some("Line 1.\nLine 2.".to_string())
167 );
168 }
169
170 #[test]
171 fn vec9_embedded_quotes_preserved() {
172 assert_eq!(
173 extract_doc_description(&doc_attrs(&[" Line with \"quotes\""])),
174 Some("Line with \"quotes\"".to_string())
175 );
176 }
177
178 #[test]
179 fn vec10_whitespace_only_lines_dropped() {
180 assert_eq!(
181 extract_doc_description(&doc_attrs(&[" ", " Real content.", " "])),
182 Some("Real content.".to_string())
183 );
184 }
185
186 #[test]
189 fn unsupported_include_str_skipped() {
190 let attr: syn::Attribute = syn::parse_quote! { #[doc = include_str!("nonexistent.md")] };
193 assert_eq!(extract_doc_description(&[attr]), None);
194 }
195
196 #[test]
197 fn unsupported_cfg_attr_doc_skipped() {
198 let attr: syn::Attribute = syn::parse_quote! { #[cfg_attr(docsrs, doc = "conditional")] };
201 assert_eq!(extract_doc_description(&[attr]), None);
202 }
203
204 #[test]
205 fn unsupported_forms_mixed_with_real_docs() {
206 let mut attrs = doc_attrs(&[" Real line."]);
208 attrs.push(syn::parse_quote! { #[doc = include_str!("nonexistent.md")] });
209 assert_eq!(
210 extract_doc_description(&attrs),
211 Some("Real line.".to_string())
212 );
213 }
214
215 #[test]
218 fn ref_empty_input_returns_none() {
219 assert_eq!(reference_normalize(&[]), None);
220 }
221
222 #[test]
223 fn ref_matches_extract_for_simple_case() {
224 let lines = vec!["Line 1.".to_string(), " Line 2.".to_string()];
225 let via_attrs = extract_doc_description(&doc_attrs(&["Line 1.", " Line 2."]));
226 let via_ref = reference_normalize(&lines);
227 assert_eq!(via_attrs, via_ref);
228 }
229
230 #[test]
231 fn ref_idempotent_on_normalized_output() {
232 let once = reference_normalize(&[" A ".to_string(), String::new(), "B".to_string()]);
233 let s = once.as_ref().unwrap();
234 let twice = reference_normalize(&s.split('\n').map(String::from).collect::<Vec<_>>());
235 assert_eq!(twice.as_deref(), Some(s.as_str()));
236 }
237}