Skip to main content

mdbook_lint/
rustdoc.rs

1//! Rustdoc comment extraction for linting
2//!
3//! This module extracts module-level documentation comments (`//!`) from Rust source files
4//! and converts them to markdown for linting.
5
6use std::path::Path;
7
8/// Represents extracted documentation from a Rust source file
9#[derive(Debug, Clone)]
10pub struct ExtractedDoc {
11    /// The markdown content extracted from doc comments
12    pub content: String,
13    /// The starting line number in the original file (1-based)
14    pub start_line: usize,
15}
16
17/// Extract module-level documentation (`//!` comments) from Rust source content
18///
19/// Returns `None` if no module-level documentation is found.
20///
21/// # Arguments
22/// * `content` - The Rust source file content
23///
24/// # Example
25/// ```
26/// use mdbook_lint::rustdoc::extract_module_docs;
27///
28/// let content = r#"//! # My Crate
29/// //!
30/// //! This is the module documentation.
31///
32/// fn main() {}
33/// "#;
34///
35/// let doc = extract_module_docs(content).unwrap();
36/// assert!(doc.content.contains("# My Crate"));
37/// assert_eq!(doc.start_line, 1);
38/// ```
39pub fn extract_module_docs(content: &str) -> Option<ExtractedDoc> {
40    let mut doc_lines = Vec::new();
41    let mut start_line = None;
42    let mut in_doc_block = false;
43
44    for (i, line) in content.lines().enumerate() {
45        let trimmed = line.trim_start();
46
47        if trimmed.starts_with("//!") {
48            if start_line.is_none() {
49                start_line = Some(i + 1); // 1-based line numbers
50                in_doc_block = true;
51            }
52
53            // Extract the content after "//!"
54            let doc_content = trimmed.strip_prefix("//!").unwrap_or("");
55            // Remove at most one leading space (standard rustdoc formatting)
56            let doc_content = doc_content.strip_prefix(' ').unwrap_or(doc_content);
57            doc_lines.push(doc_content.to_string());
58        } else if in_doc_block {
59            // We've hit non-doc content after starting a doc block
60            // Empty lines between doc comments are okay, but code ends the block
61            if trimmed.is_empty() {
62                // Could be a blank line within docs, but we're conservative
63                // and end on first non-doc, non-empty line
64                continue;
65            }
66            // End of module doc block
67            break;
68        }
69    }
70
71    if doc_lines.is_empty() {
72        return None;
73    }
74
75    Some(ExtractedDoc {
76        content: doc_lines.join("\n"),
77        start_line: start_line.unwrap_or(1),
78    })
79}
80
81/// Recursively find all Rust source files in a directory
82pub fn find_rust_files(path: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
83    let mut files = Vec::new();
84
85    if path.is_file() {
86        if path.extension().and_then(|e| e.to_str()) == Some("rs") {
87            files.push(path.to_path_buf());
88        }
89        return Ok(files);
90    }
91
92    if path.is_dir() {
93        for entry in std::fs::read_dir(path)? {
94            let entry = entry?;
95            let entry_path = entry.path();
96
97            // Skip hidden directories and target
98            if let Some(name) = entry_path.file_name().and_then(|n| n.to_str())
99                && (name.starts_with('.') || name == "target")
100            {
101                continue;
102            }
103
104            if entry_path.is_dir() {
105                files.extend(find_rust_files(&entry_path)?);
106            } else if entry_path.extension().and_then(|e| e.to_str()) == Some("rs") {
107                files.push(entry_path);
108            }
109        }
110    }
111
112    Ok(files)
113}
114
115/// Map a line number from extracted markdown back to the original Rust file
116///
117/// # Arguments
118/// * `markdown_line` - Line number in the extracted markdown (1-based)
119/// * `doc_start_line` - Starting line of the doc block in the Rust file (1-based)
120///
121/// # Returns
122/// The corresponding line number in the original Rust file
123pub fn map_line_to_source(markdown_line: usize, doc_start_line: usize) -> usize {
124    // markdown_line is 1-based within the extracted content
125    // doc_start_line is 1-based in the source file
126    // Line 1 of markdown corresponds to doc_start_line in source
127    doc_start_line + markdown_line - 1
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn test_extract_simple_module_docs() {
136        let content = r#"//! # My Crate
137//!
138//! This is the module documentation.
139
140fn main() {}
141"#;
142
143        let doc = extract_module_docs(content).unwrap();
144        assert_eq!(doc.start_line, 1);
145        assert!(doc.content.contains("# My Crate"));
146        assert!(doc.content.contains("This is the module documentation."));
147    }
148
149    #[test]
150    fn test_extract_docs_with_code_blocks() {
151        let content = r#"//! # Example
152//!
153//! ```rust
154//! let x = 1;
155//! ```
156
157use std::io;
158"#;
159
160        let doc = extract_module_docs(content).unwrap();
161        assert!(doc.content.contains("```rust"));
162        assert!(doc.content.contains("let x = 1;"));
163    }
164
165    #[test]
166    fn test_no_module_docs() {
167        let content = r#"/// This is an item doc, not module doc
168fn foo() {}
169"#;
170
171        assert!(extract_module_docs(content).is_none());
172    }
173
174    #[test]
175    fn test_docs_not_at_start() {
176        let content = r#"// Regular comment
177//! Module doc starts here
178//! More docs
179
180fn main() {}
181"#;
182
183        let doc = extract_module_docs(content).unwrap();
184        assert_eq!(doc.start_line, 2);
185        assert!(doc.content.contains("Module doc starts here"));
186    }
187
188    #[test]
189    fn test_line_mapping() {
190        // If doc starts at line 5 in source, and we have a violation on line 3 of markdown,
191        // that corresponds to line 7 in the source (5 + 3 - 1 = 7)
192        assert_eq!(map_line_to_source(1, 5), 5);
193        assert_eq!(map_line_to_source(3, 5), 7);
194        assert_eq!(map_line_to_source(1, 1), 1);
195    }
196
197    #[test]
198    fn test_preserves_indentation() {
199        let content = r#"//! # Heading
200//!
201//! - Item 1
202//!   - Nested item
203//!     - Deeply nested
204"#;
205
206        let doc = extract_module_docs(content).unwrap();
207        assert!(doc.content.contains("  - Nested item"));
208        assert!(doc.content.contains("    - Deeply nested"));
209    }
210
211    #[test]
212    fn test_empty_doc_lines() {
213        let content = r#"//! # Title
214//!
215//! Paragraph after blank line.
216"#;
217
218        let doc = extract_module_docs(content).unwrap();
219        let lines: Vec<_> = doc.content.lines().collect();
220        assert_eq!(lines.len(), 3);
221        assert_eq!(lines[0], "# Title");
222        assert_eq!(lines[1], "");
223        assert_eq!(lines[2], "Paragraph after blank line.");
224    }
225}