Skip to main content

pmcp_macros_support/
lib.rs

1//! Pure helpers for `pmcp-macros`.
2//!
3//! This crate exists because `pmcp-macros` has `proc-macro = true`, which
4//! per the Rust Reference restricts its public API to only the
5//! procedural macros defined via `#[proc_macro]`. Property tests and fuzz
6//! targets cannot import internal helpers from a proc-macro crate. This
7//! crate holds the pure normalization logic so it is importable by any
8//! downstream consumer: `pmcp-macros` itself (for the macro expansion
9//! path), property tests, and fuzz harnesses.
10//!
11//! This crate has no stability guarantees — it is a workspace-internal
12//! implementation detail published alongside `pmcp-macros`. External
13//! users should never depend on it directly.
14
15#![deny(missing_docs)]
16#![warn(clippy::pedantic)]
17
18/// Rustdoc-harvest helpers (`extract_doc_description`, `reference_normalize`).
19pub mod rustdoc {
20    /// Harvest `#[doc = "..."]` attributes into a normalized description
21    /// string.
22    ///
23    /// Applies the rmcp-parity normalization:
24    /// - trim each doc literal (leading/trailing whitespace stripped);
25    /// - drop empty post-trim lines;
26    /// - join remaining lines with `"\n"`.
27    ///
28    /// Skips non-`NameValue` doc attrs (e.g. `#[doc(hidden)]`, `#[doc(alias = "...")]`)
29    /// and skips `NameValue` forms whose value is not a string literal — including
30    /// `#[doc = include_str!("...")]` and `#[cfg_attr(..., doc = "...")]`.
31    ///
32    /// Returns `None` if no non-empty rustdoc is present.
33    ///
34    /// # Unsupported forms
35    ///
36    /// - `#[doc = include_str!("...")]` — silently skipped (macro expansion not evaluated).
37    /// - `#[cfg_attr(..., doc = "...")]` — silently skipped (attr shape does not match).
38    /// - Indented code fences inside doc blocks — indentation stripped along with all
39    ///   other whitespace per the trim-each-line rule. Tool descriptions render as plain
40    ///   text in MCP clients, not as rendered rustdoc HTML, so this is acceptable.
41    #[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    /// Reference implementation of the normalization algorithm over raw
73    /// line strings — the plain-Rust oracle used by property tests and
74    /// fuzz targets. Not public API; kept `pub` only so integration tests
75    /// and fuzz targets in sibling crates can import it.
76    #[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    // ==== 10 normalization vectors ====
108
109    #[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    // ==== Unsupported rustdoc forms ====
187
188    #[test]
189    fn unsupported_include_str_skipped() {
190        // `#[doc = include_str!("readme.md")]` has Meta::NameValue with
191        // Expr::Macro, not Expr::Lit. The helper skips it silently.
192        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        // `#[cfg_attr(docsrs, doc = "...")]` — outer path is `cfg_attr`,
199        // not `doc` — skipped by the `is_ident("doc")` guard.
200        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        // A real doc line + an unsupported form → real doc wins.
207        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    // ==== Reference oracle sanity checks ====
216
217    #[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}