Skip to main content

rumdl_lib/
doc_comment_lint.rs

1//! Linting of markdown embedded in Rust doc comments (`///` and `//!`).
2//!
3//! This module provides extraction and check-only logic for line doc comments.
4//! It is used by both the CLI and LSP to lint Rust doc comments.
5//!
6//! **Precondition:** Input content must be LF-normalized (no `\r\n`).
7//! The CLI path handles this via `normalize_line_ending`, but callers using
8//! these functions directly must normalize first.
9//!
10//! **Not supported:** Block doc comments (`/** ... */`) are not extracted.
11
12use crate::config as rumdl_config;
13use crate::lint_context::LintContext;
14use crate::rule::{LintWarning, Rule};
15use crate::rules::md013_line_length::MD013LineLength;
16use std::path::Path;
17
18/// The kind of doc comment: outer (`///`) or inner (`//!`).
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DocCommentKind {
21    /// Outer doc comment (`///`)
22    Outer,
23    /// Inner doc comment (`//!`)
24    Inner,
25}
26
27/// Metadata for a single line in a doc comment block.
28#[derive(Debug, Clone)]
29pub struct DocCommentLineInfo {
30    /// Leading whitespace before the doc comment prefix (e.g. `"    "` for indented code)
31    pub leading_whitespace: String,
32    /// The doc comment prefix as it appeared in source (e.g. `"/// "`, `"///"`, `"///\t"`)
33    pub prefix: String,
34}
35
36/// A contiguous block of same-kind doc comments extracted from a Rust source file.
37#[derive(Debug, Clone)]
38pub struct DocCommentBlock {
39    /// Whether this is an outer (`///`) or inner (`//!`) doc comment.
40    pub kind: DocCommentKind,
41    /// 0-indexed line number of the first line in the original file.
42    pub start_line: usize,
43    /// 0-indexed line number of the last line in the original file (inclusive).
44    pub end_line: usize,
45    /// Byte offset of the first character of the first line in the block.
46    pub byte_start: usize,
47    /// Byte offset past the last character (including `\n`) of the last line in the block.
48    pub byte_end: usize,
49    /// Extracted markdown content with prefixes stripped.
50    pub markdown: String,
51    /// Per-line metadata for prefix restoration during fix mode.
52    pub line_metadata: Vec<DocCommentLineInfo>,
53    /// Length of leading whitespace + prefix (in bytes) for column offset remapping.
54    /// Each entry corresponds to a line in `line_metadata`.
55    pub prefix_byte_lengths: Vec<usize>,
56}
57
58/// Classify a line as a doc comment, returning the kind, leading whitespace,
59/// and the full prefix (including the conventional single space if present).
60///
61/// Returns `None` if the line is not a doc comment. A doc comment must start
62/// with optional whitespace followed by `///` or `//!`. Lines starting with
63/// `////` are regular comments (not doc comments).
64///
65/// Handles all valid rustdoc forms:
66///
67/// - `/// content` (space after prefix)
68/// - `///content` (no space — valid rustdoc, content is `content`)
69/// - `///` (bare prefix, empty content)
70/// - `///\tcontent` (tab after prefix)
71fn classify_doc_comment_line(line: &str) -> Option<(DocCommentKind, String, String)> {
72    let trimmed = line.trim_start();
73    let leading_ws = &line[..line.len() - trimmed.len()];
74
75    // `////` is NOT a doc comment (regular comment)
76    if trimmed.starts_with("////") {
77        return None;
78    }
79
80    if let Some(after) = trimmed.strip_prefix("///") {
81        // Determine the prefix: include the conventional space/tab if present
82        let prefix = if after.starts_with(' ') || after.starts_with('\t') {
83            format!("///{}", &after[..1])
84        } else {
85            "///".to_string()
86        };
87        Some((DocCommentKind::Outer, leading_ws.to_string(), prefix))
88    } else if let Some(after) = trimmed.strip_prefix("//!") {
89        let prefix = if after.starts_with(' ') || after.starts_with('\t') {
90            format!("//!{}", &after[..1])
91        } else {
92            "//!".to_string()
93        };
94        Some((DocCommentKind::Inner, leading_ws.to_string(), prefix))
95    } else {
96        None
97    }
98}
99
100/// Extract the markdown content from a doc comment line after stripping the prefix.
101fn extract_markdown_from_line(trimmed: &str, kind: DocCommentKind) -> &str {
102    let prefix = match kind {
103        DocCommentKind::Outer => "///",
104        DocCommentKind::Inner => "//!",
105    };
106
107    let after_prefix = &trimmed[prefix.len()..];
108    // Strip exactly one leading space if present (conventional rustdoc formatting)
109    if let Some(stripped) = after_prefix.strip_prefix(' ') {
110        stripped
111    } else {
112        after_prefix
113    }
114}
115
116/// Extract all doc comment blocks from Rust source code.
117///
118/// Groups contiguous same-kind doc comment lines into blocks. A block boundary
119/// occurs when:
120///
121/// - A line is not a doc comment
122/// - The doc comment kind changes (from `///` to `//!` or vice versa)
123///
124/// Each block's `markdown` field contains the extracted markdown with prefixes
125/// stripped. The `line_metadata` field preserves the original indentation and
126/// prefix for each line, enabling faithful restoration during fix mode.
127///
128/// **Precondition:** `content` must be LF-normalized (no `\r\n`).
129pub fn extract_doc_comment_blocks(content: &str) -> Vec<DocCommentBlock> {
130    let mut blocks = Vec::new();
131    let mut current_block: Option<DocCommentBlock> = None;
132    let mut byte_offset = 0;
133
134    let lines: Vec<&str> = content.split('\n').collect();
135    let num_lines = lines.len();
136
137    for (line_idx, line) in lines.iter().enumerate() {
138        let line_byte_start = byte_offset;
139        // Only add 1 for the newline if this is not the last segment
140        let has_newline = line_idx < num_lines - 1 || content.ends_with('\n');
141        let line_byte_end = byte_offset + line.len() + usize::from(has_newline);
142
143        if let Some((kind, leading_ws, prefix)) = classify_doc_comment_line(line) {
144            let trimmed = line.trim_start();
145            let md_content = extract_markdown_from_line(trimmed, kind);
146
147            // Compute column offset: leading whitespace bytes + prefix bytes
148            let prefix_byte_len = leading_ws.len() + prefix.len();
149
150            let line_info = DocCommentLineInfo {
151                leading_whitespace: leading_ws,
152                prefix,
153            };
154
155            match current_block.as_mut() {
156                Some(block) if block.kind == kind => {
157                    // Continue the current block
158                    block.end_line = line_idx;
159                    block.byte_end = line_byte_end;
160                    block.markdown.push('\n');
161                    block.markdown.push_str(md_content);
162                    block.line_metadata.push(line_info);
163                    block.prefix_byte_lengths.push(prefix_byte_len);
164                }
165                _ => {
166                    // Flush any existing block
167                    if let Some(block) = current_block.take() {
168                        blocks.push(block);
169                    }
170                    // Start a new block
171                    current_block = Some(DocCommentBlock {
172                        kind,
173                        start_line: line_idx,
174                        end_line: line_idx,
175                        byte_start: line_byte_start,
176                        byte_end: line_byte_end,
177                        markdown: md_content.to_string(),
178                        line_metadata: vec![line_info],
179                        prefix_byte_lengths: vec![prefix_byte_len],
180                    });
181                }
182            }
183        } else {
184            // Not a doc comment line — flush current block
185            if let Some(block) = current_block.take() {
186                blocks.push(block);
187            }
188        }
189
190        byte_offset = line_byte_end;
191    }
192
193    // Flush final block
194    if let Some(block) = current_block.take() {
195        blocks.push(block);
196    }
197
198    blocks
199}
200
201/// Rules that should be skipped when linting doc comment blocks.
202///
203/// - MD025: Multiple H1 headings are standard in rustdoc (`# Errors`, `# Examples`, `# Safety`).
204/// - MD033: HTML tags like `<div class="warning">` are required syntax for rustdoc warning blocks.
205/// - MD040: Rustdoc assumes unlabeled code blocks are Rust, so requiring language labels is noise.
206/// - MD041: "First line should be a heading" doesn't apply — doc blocks aren't standalone documents.
207/// - MD047: "File should end with a newline" doesn't apply for the same reason.
208/// - MD051: Rustdoc anchors like `#method.bar` and `#structfield.name` aren't document headings.
209/// - MD052: Intra-doc links like `[crate::io]` are rustdoc syntax, not markdown reference links.
210/// - MD054: Shortcut reference style `[crate::module]` is the canonical intra-doc link syntax.
211pub const SKIPPED_RULES: &[&str] = &["MD025", "MD033", "MD040", "MD041", "MD047", "MD051", "MD052", "MD054"];
212
213/// Whether a path names a file rumdl reads through its Rust doc comments.
214///
215/// The single definition of it, because every pass that touches such a file has
216/// to agree: the lint pass, the fix pass, and the re-lint a fix run reconciles
217/// against. A pass that reads the source as markdown reports on the Rust code
218/// itself, and a fix pass that does it rewrites the Rust code, producing bytes
219/// no `check` ever reported (`#[derive(Debug)]` is an MD018 heading to a
220/// markdown fixer).
221pub fn is_rust_source(path: &Path) -> bool {
222    path.extension().is_some_and(|ext| ext == "rs")
223}
224
225/// Check all doc comment blocks in a Rust source file and return lint warnings.
226///
227/// Warnings have their line numbers and column numbers remapped to point to the
228/// correct location in the original Rust file. Fix suggestions are stripped
229/// (fixes are only applied through the fix mode path in the binary crate).
230///
231/// Empty doc comment blocks (only whitespace content) are skipped.
232pub fn check_doc_comment_blocks(
233    content: &str,
234    rules: &[Box<dyn Rule>],
235    config: &rumdl_config::Config,
236) -> Vec<LintWarning> {
237    let blocks = extract_doc_comment_blocks(content);
238    let mut all_warnings = Vec::new();
239
240    for block in &blocks {
241        // Skip empty blocks to avoid spurious warnings
242        if block.markdown.trim().is_empty() {
243            continue;
244        }
245
246        let ctx = LintContext::new(&block.markdown, config.markdown_flavor(), None);
247
248        for rule in rules {
249            if SKIPPED_RULES.contains(&rule.name()) {
250                continue;
251            }
252
253            // For MD013 in doc comments, disable code block checking.
254            // Code blocks contain Rust code formatted by rustfmt (max_width = 100),
255            // not prose governed by markdown line length limits.
256            let doc_rule: Box<dyn Rule>;
257            let effective_rule: &dyn Rule = if rule.name() == "MD013" {
258                if let Some(md013) = rule.as_any().downcast_ref::<MD013LineLength>() {
259                    doc_rule = Box::new(md013.with_code_blocks_disabled());
260                    doc_rule.as_ref()
261                } else {
262                    rule.as_ref()
263                }
264            } else {
265                rule.as_ref()
266            };
267
268            if let Ok(rule_warnings) = effective_rule.check(&ctx) {
269                for warning in rule_warnings {
270                    // Remap line numbers:
271                    // warning.line is 1-indexed within the block markdown
272                    // block.start_line is 0-indexed in the file
273                    // (1-indexed block) + (0-indexed file start) = 1-indexed file line
274                    let file_line = warning.line + block.start_line;
275                    let file_end_line = warning.end_line + block.start_line;
276
277                    // Remap column: add the prefix byte length for the corresponding line
278                    let block_line_idx = warning.line.saturating_sub(1);
279                    let col_offset = block.prefix_byte_lengths.get(block_line_idx).copied().unwrap_or(0);
280                    let file_column = warning.column + col_offset;
281
282                    let block_end_line_idx = warning.end_line.saturating_sub(1);
283                    let end_col_offset = block.prefix_byte_lengths.get(block_end_line_idx).copied().unwrap_or(0);
284                    let file_end_column = warning.end_column + end_col_offset;
285
286                    all_warnings.push(LintWarning {
287                        line: file_line,
288                        end_line: file_end_line,
289                        column: file_column,
290                        end_column: file_end_column,
291                        fix: None,
292                        ..warning
293                    });
294                }
295            }
296        }
297    }
298
299    all_warnings
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_classify_outer_doc_comment() {
308        let (kind, ws, prefix) = classify_doc_comment_line("/// Hello").unwrap();
309        assert_eq!(kind, DocCommentKind::Outer);
310        assert_eq!(ws, "");
311        assert_eq!(prefix, "/// ");
312    }
313
314    #[test]
315    fn test_classify_inner_doc_comment() {
316        let (kind, ws, prefix) = classify_doc_comment_line("//! Module doc").unwrap();
317        assert_eq!(kind, DocCommentKind::Inner);
318        assert_eq!(ws, "");
319        assert_eq!(prefix, "//! ");
320    }
321
322    #[test]
323    fn test_classify_empty_outer() {
324        let (kind, ws, prefix) = classify_doc_comment_line("///").unwrap();
325        assert_eq!(kind, DocCommentKind::Outer);
326        assert_eq!(ws, "");
327        assert_eq!(prefix, "///");
328    }
329
330    #[test]
331    fn test_classify_empty_inner() {
332        let (kind, ws, prefix) = classify_doc_comment_line("//!").unwrap();
333        assert_eq!(kind, DocCommentKind::Inner);
334        assert_eq!(ws, "");
335        assert_eq!(prefix, "//!");
336    }
337
338    #[test]
339    fn test_classify_indented() {
340        let (kind, ws, prefix) = classify_doc_comment_line("    /// Indented").unwrap();
341        assert_eq!(kind, DocCommentKind::Outer);
342        assert_eq!(ws, "    ");
343        assert_eq!(prefix, "/// ");
344    }
345
346    #[test]
347    fn test_classify_no_space_after_prefix() {
348        // `///content` is valid rustdoc — content is "content"
349        let (kind, ws, prefix) = classify_doc_comment_line("///content").unwrap();
350        assert_eq!(kind, DocCommentKind::Outer);
351        assert_eq!(ws, "");
352        assert_eq!(prefix, "///");
353    }
354
355    #[test]
356    fn test_classify_tab_after_prefix() {
357        let (kind, ws, prefix) = classify_doc_comment_line("///\tcontent").unwrap();
358        assert_eq!(kind, DocCommentKind::Outer);
359        assert_eq!(ws, "");
360        assert_eq!(prefix, "///\t");
361    }
362
363    #[test]
364    fn test_classify_inner_no_space() {
365        let (kind, _, prefix) = classify_doc_comment_line("//!content").unwrap();
366        assert_eq!(kind, DocCommentKind::Inner);
367        assert_eq!(prefix, "//!");
368    }
369
370    #[test]
371    fn test_classify_four_slashes_is_not_doc() {
372        assert!(classify_doc_comment_line("//// Not a doc comment").is_none());
373    }
374
375    #[test]
376    fn test_classify_regular_comment() {
377        assert!(classify_doc_comment_line("// Regular comment").is_none());
378    }
379
380    #[test]
381    fn test_classify_code_line() {
382        assert!(classify_doc_comment_line("let x = 3;").is_none());
383    }
384
385    #[test]
386    fn test_extract_no_space_content() {
387        let content = "///no space here\n";
388        let blocks = extract_doc_comment_blocks(content);
389        assert_eq!(blocks.len(), 1);
390        assert_eq!(blocks[0].markdown, "no space here");
391    }
392
393    #[test]
394    fn test_extract_basic_outer_block() {
395        let content = "/// First line\n/// Second line\nfn foo() {}\n";
396        let blocks = extract_doc_comment_blocks(content);
397        assert_eq!(blocks.len(), 1);
398        assert_eq!(blocks[0].kind, DocCommentKind::Outer);
399        assert_eq!(blocks[0].start_line, 0);
400        assert_eq!(blocks[0].end_line, 1);
401        assert_eq!(blocks[0].markdown, "First line\nSecond line");
402        assert_eq!(blocks[0].line_metadata.len(), 2);
403    }
404
405    #[test]
406    fn test_extract_basic_inner_block() {
407        let content = "//! Module doc\n//! More info\n\nuse std::io;\n";
408        let blocks = extract_doc_comment_blocks(content);
409        assert_eq!(blocks.len(), 1);
410        assert_eq!(blocks[0].kind, DocCommentKind::Inner);
411        assert_eq!(blocks[0].markdown, "Module doc\nMore info");
412    }
413
414    #[test]
415    fn test_extract_multiple_blocks() {
416        let content = "/// Block 1\nfn foo() {}\n/// Block 2\nfn bar() {}\n";
417        let blocks = extract_doc_comment_blocks(content);
418        assert_eq!(blocks.len(), 2);
419        assert_eq!(blocks[0].markdown, "Block 1");
420        assert_eq!(blocks[0].start_line, 0);
421        assert_eq!(blocks[1].markdown, "Block 2");
422        assert_eq!(blocks[1].start_line, 2);
423    }
424
425    #[test]
426    fn test_extract_mixed_kinds_separate_blocks() {
427        let content = "//! Inner\n/// Outer\n";
428        let blocks = extract_doc_comment_blocks(content);
429        assert_eq!(blocks.len(), 2);
430        assert_eq!(blocks[0].kind, DocCommentKind::Inner);
431        assert_eq!(blocks[1].kind, DocCommentKind::Outer);
432    }
433
434    #[test]
435    fn test_extract_empty_doc_line() {
436        let content = "/// First\n///\n/// Third\n";
437        let blocks = extract_doc_comment_blocks(content);
438        assert_eq!(blocks.len(), 1);
439        assert_eq!(blocks[0].markdown, "First\n\nThird");
440    }
441
442    #[test]
443    fn test_extract_preserves_extra_space() {
444        let content = "///  Two spaces\n";
445        let blocks = extract_doc_comment_blocks(content);
446        assert_eq!(blocks.len(), 1);
447        assert_eq!(blocks[0].markdown, " Two spaces");
448    }
449
450    #[test]
451    fn test_extract_indented_doc_comments() {
452        let content = "    /// Indented\n    /// More\n";
453        let blocks = extract_doc_comment_blocks(content);
454        assert_eq!(blocks.len(), 1);
455        assert_eq!(blocks[0].markdown, "Indented\nMore");
456        assert_eq!(blocks[0].line_metadata[0].leading_whitespace, "    ");
457    }
458
459    #[test]
460    fn test_no_doc_comments() {
461        let content = "fn main() {\n    let x = 3;\n}\n";
462        let blocks = extract_doc_comment_blocks(content);
463        assert!(blocks.is_empty());
464    }
465
466    #[test]
467    fn test_byte_offsets() {
468        let content = "/// Hello\nfn foo() {}\n/// World\n";
469        let blocks = extract_doc_comment_blocks(content);
470        assert_eq!(blocks.len(), 2);
471        // First block: "/// Hello\n" = 10 bytes
472        assert_eq!(blocks[0].byte_start, 0);
473        assert_eq!(blocks[0].byte_end, 10);
474        // Second block starts after "fn foo() {}\n" (12 bytes), at offset 22
475        assert_eq!(blocks[1].byte_start, 22);
476        assert_eq!(blocks[1].byte_end, 32);
477    }
478
479    #[test]
480    fn test_byte_offsets_no_trailing_newline() {
481        let content = "/// Hello";
482        let blocks = extract_doc_comment_blocks(content);
483        assert_eq!(blocks.len(), 1);
484        assert_eq!(blocks[0].byte_start, 0);
485        // No trailing newline, so byte_end == content.len()
486        assert_eq!(blocks[0].byte_end, content.len());
487    }
488
489    #[test]
490    fn test_prefix_byte_lengths() {
491        let content = "    /// Indented\n/// Top-level\n";
492        let blocks = extract_doc_comment_blocks(content);
493        assert_eq!(blocks.len(), 1);
494        // "    " (4) + "/// " (4) = 8 bytes for first line
495        assert_eq!(blocks[0].prefix_byte_lengths[0], 8);
496        // "" (0) + "/// " (4) = 4 bytes for second line
497        assert_eq!(blocks[0].prefix_byte_lengths[1], 4);
498    }
499}