Skip to main content

read_doc/
lib.rs

1//! # Read module documentation from Rust source files.
2//!
3//! See [`read_doc::module!`](module) for usage.
4//!
5//! If you want to read other doc comments, consider one of the follow crates:
6//!
7//!   * [documented](https://docs.rs/documented/latest/documented/)
8//!   * [doc_for](https://docs.rs/doc_for/latest/doc_for/)
9//!   * [user_doc](https://docs.rs/user_doc/latest/user_doc/)
10//!
11//! # Minimum supported Rust version
12//!
13//! Currently the minimum supported Rust version (MSRV) is **1.88**. Future
14//! increases in the MSRV will require a major version bump.
15
16// Lint configuration in Cargo.toml isn't supported by cargo-geiger.
17#![forbid(unsafe_code)]
18// Enable doc_cfg on docsrs so that we get feature markers.
19#![cfg_attr(docsrs, feature(doc_cfg))]
20
21use proc_macro::TokenStream;
22use proc_macro2::Span;
23use quote::quote;
24use std::fs;
25use std::path::{Path, PathBuf};
26use syn::{
27    LitStr, Meta, Token, parse::Parse, parse::ParseStream, parse_macro_input,
28};
29
30/// Input for `module!` macro.
31struct ModuleInput {
32    /// Paths to the files, relative to the directory of the calling file.
33    paths: Vec<LitStr>,
34}
35
36impl Parse for ModuleInput {
37    fn parse(input: ParseStream) -> syn::Result<Self> {
38        let mut paths = Vec::new();
39        while !input.is_empty() {
40            paths.push(input.parse()?);
41            if !input.is_empty() {
42                input.parse::<Token![,]>()?;
43            }
44        }
45        Ok(Self { paths })
46    }
47}
48
49/// # Read module documentation from Rust source files.
50///
51/// ```ignore
52/// //! # Overall module documentation
53/// #![doc = read_doc::module!("submodule1.rs", "submodule2.rs")]
54///
55/// mod submodule1;
56/// mod submodule2;
57/// ```
58///
59/// This macro extracts inner doc comments from the passed Rust source files and
60/// combines them into a string that can be used with `#[doc = ...]`.
61///
62/// Each file’s module documentation will be separated by a blank line.
63///
64/// Paths are relative to the directory containing the calling file.
65///
66/// # Example
67///
68/// Given the source files below, `cargo doc` will produce the following
69/// documentation:
70///
71/// ```Markdown
72/// # Fruit functionality
73///
74/// This has a lot of interesting functionality.
75///
76/// ### Apple processing
77///
78/// Green or red, we don't care.
79///
80/// ### Orange processing
81///
82/// Various orange-related code.
83/// ```
84///
85/// ### `/src/fruit/mod.rs`
86///
87/// ```rust,ignore
88/// //! # Fruit functionality
89/// //!
90/// //! This has a lot of interesting functionality.
91/// #![doc = read_doc::module!("apple.rs", "orange.rs")]
92///
93/// mod apple;
94/// pub use apple::*;
95///
96/// mod orange;
97/// pub use orange::*;
98/// ```
99///
100/// ### `/src/fruit/apple.rs`
101///
102/// ```rust
103/// //! ### Apple processing
104/// //!
105/// //! Green or red, we don't care.
106///
107/// /// Sweet or tart.
108/// pub struct Apple;
109/// ```
110///
111/// ### `/src/fruit/orange.rs`
112///
113/// ```rust
114/// //! ### Orange processing
115/// //!
116/// //! Various orange-related code.
117///
118/// /// A round fruit.
119/// pub struct Orange;
120/// ```
121#[proc_macro]
122pub fn module(input: TokenStream) -> TokenStream {
123    fn inner(input: &ModuleInput) -> syn::Result<TokenStream> {
124        let base_dir = get_source_dir()?;
125
126        let docs = input
127            .paths
128            .iter()
129            .filter_map(|path_lit| {
130                let path = Path::new(&base_dir).join(path_lit.value());
131                fs::read_to_string(&path)
132                    .map_err(|error| error.to_string())
133                    .and_then(|content| extract_inner_docs(&content))
134                    .map(|docs| if docs.is_empty() { None } else { Some(docs) })
135                    .map_err(|error| {
136                        syn::Error::new(
137                            path_lit.span(),
138                            format!("Failed to read {path:?}: {error}"),
139                        )
140                    })
141                    .transpose()
142            })
143            .collect::<syn::Result<Vec<String>>>()?
144            .join("\n\n"); // FIXME all errors
145
146        let lit = LitStr::new(&docs, Span::call_site());
147        Ok(quote! { #lit }.into())
148    }
149
150    match inner(&parse_macro_input!(input as ModuleInput)) {
151        Ok(stream) => stream,
152        Err(error) => error.to_compile_error().into(),
153    }
154}
155
156/// Extract inner doc comments from Rust source.
157///
158/// # Errors
159///
160/// Returns an error if there was a problem parsing the file.
161fn extract_inner_docs(content: &str) -> Result<String, String> {
162    Ok(syn::parse_file(content)
163        .map_err(|error| error.to_string())?
164        .attrs
165        .into_iter()
166        .filter_map(|attr| {
167            if attr.path().is_ident("doc")
168                && let Meta::NameValue(meta) = &attr.meta
169                && let syn::Expr::Lit(expr_lit) = &meta.value
170                && let syn::Lit::Str(lit_str) = &expr_lit.lit
171            {
172                Some(lit_str.value())
173            } else {
174                // Skip attributes other than a doc attributes with a value.
175                None
176            }
177        })
178        .collect::<Vec<_>>()
179        .join("\n"))
180}
181
182/// Get the directory containing the source file that called the macro.
183///
184/// # Errors
185///
186/// Returns and error if source didn’t have a path, or if we couldn’t get the
187/// parent of that path.
188fn get_source_dir() -> Result<PathBuf, syn::Error> {
189    match Span::call_site()
190        .local_file()
191        .and_then(|path| path.parent().map(Path::to_path_buf))
192    {
193        Some(path) => Ok(path),
194        None => Err(syn::Error::new(
195            Span::call_site(),
196            "Could not get path to source file",
197        )),
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use assert2::assert;
205
206    #[test]
207    fn line_doc_comments() {
208        assert!(
209            extract_inner_docs(
210                r"
211//! First line
212//! Second line
213
214fn foo() {}
215"
216            )
217            .unwrap()
218                == " First line\n Second line"
219        );
220    }
221
222    #[test]
223    fn mixed_attrs() {
224        assert!(
225            extract_inner_docs(
226                r"
227//! First line
228#![forbid(unsafe_code)]
229//! Second line
230
231fn foo() {}
232"
233            )
234            .unwrap()
235                == " First line\n Second line"
236        );
237    }
238
239    #[test]
240    fn block_doc_comments() {
241        assert!(
242            extract_inner_docs(
243                r"
244/*! Block doc comment
245with multiple lines
246*/
247
248fn foo() {}
249"
250            )
251            .unwrap()
252                == " Block doc comment\nwith multiple lines\n"
253        );
254    }
255
256    #[test]
257    fn doc_attributes() {
258        assert!(
259            extract_inner_docs(
260                r#"
261#![doc = "First line"]
262#![doc = "Second line"]
263
264fn foo() {}
265"#
266            )
267            .unwrap()
268                == "First line\nSecond line"
269        );
270    }
271
272    #[test]
273    fn mixed_doc_styles() {
274        assert!(
275            extract_inner_docs(
276                r#"
277//! Line comment
278#![doc = "Attribute doc"]
279
280fn foo() {}
281"#
282            )
283            .unwrap()
284                == " Line comment\nAttribute doc"
285        );
286    }
287
288    #[test]
289    fn no_docs() {
290        assert!(extract_inner_docs("fn foo() {}\n").unwrap() == "");
291    }
292
293    #[test]
294    fn only_outer_docs_ignored() {
295        assert!(
296            extract_inner_docs(
297                r"
298/// This is an outer doc comment
299fn foo() {}
300"
301            )
302            .unwrap()
303                == ""
304        );
305    }
306
307    #[test]
308    fn realistic_module() {
309        assert!(
310            extract_inner_docs(
311                r"
312//! # Module Title
313//!
314//! This module does things.
315
316use std::io;
317
318/// Function doc
319pub fn do_thing() {}
320"
321            )
322            .unwrap()
323                == " # Module Title\n\n This module does things."
324        );
325    }
326
327    #[test]
328    fn empty_doc_lines() {
329        assert!(
330            extract_inner_docs(
331                r"
332//! First
333//!
334//! Third
335
336fn foo() {}
337"
338            )
339            .unwrap()
340                == " First\n\n Third"
341        );
342    }
343}