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