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, and
11//! a `///` on a line that starts inside one, a block comment or a string
12//! literal is part of that text, not a doc comment.
13
14use crate::config as rumdl_config;
15use crate::lint_context::LintContext;
16use crate::rule::{LintWarning, Rule};
17use crate::rules::md013_line_length::MD013LineLength;
18use std::path::Path;
19
20/// The kind of doc comment: outer (`///`) or inner (`//!`).
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DocCommentKind {
23    /// Outer doc comment (`///`)
24    Outer,
25    /// Inner doc comment (`//!`)
26    Inner,
27}
28
29/// Metadata for a single line in a doc comment block.
30#[derive(Debug, Clone)]
31pub struct DocCommentLineInfo {
32    /// Leading whitespace before the doc comment prefix (e.g. `"    "` for indented code)
33    pub leading_whitespace: String,
34    /// The doc comment prefix as it appeared in source, including the single space
35    /// or tab separating it from the markdown (e.g. `"/// "`, `"///"`, `"///\t"`)
36    pub prefix: String,
37}
38
39/// A contiguous block of same-kind doc comments extracted from a Rust source file.
40#[derive(Debug, Clone)]
41pub struct DocCommentBlock {
42    /// Whether this is an outer (`///`) or inner (`//!`) doc comment.
43    pub kind: DocCommentKind,
44    /// 0-indexed line number of the first line in the original file.
45    pub start_line: usize,
46    /// 0-indexed line number of the last line in the original file (inclusive).
47    pub end_line: usize,
48    /// Byte offset of the first character of the first line in the block.
49    pub byte_start: usize,
50    /// Byte offset past the last character (including `\n`) of the last line in the block.
51    pub byte_end: usize,
52    /// Extracted markdown content with prefixes stripped.
53    pub markdown: String,
54    /// Per-line metadata for prefix restoration during fix mode.
55    pub line_metadata: Vec<DocCommentLineInfo>,
56    /// Length of leading whitespace + prefix (in bytes) for column offset remapping.
57    /// Each entry corresponds to a line in `line_metadata`.
58    pub prefix_byte_lengths: Vec<usize>,
59}
60
61/// Classify a line as a doc comment, returning the kind, leading whitespace,
62/// and the full prefix (including a single separating space or tab if present).
63///
64/// Returns `None` if the line is not a doc comment. A doc comment must start
65/// with optional whitespace followed by `///` or `//!`. Lines starting with
66/// `////` are regular comments (not doc comments).
67///
68/// Handles all valid rustdoc forms:
69///
70/// - `/// content` (space after prefix)
71/// - `///content` (no space, valid rustdoc, content is `content`)
72/// - `///` (bare prefix, empty content)
73/// - `///\tcontent` (tab after prefix, content is `content`: rustdoc renders
74///   `///\t# Heading` as a heading, not as an indented code block)
75fn classify_doc_comment_line(line: &str) -> Option<(DocCommentKind, String, String)> {
76    let trimmed = line.trim_start();
77    let leading_ws = &line[..line.len() - trimmed.len()];
78
79    // `////` is NOT a doc comment (regular comment)
80    if trimmed.starts_with("////") {
81        return None;
82    }
83
84    if let Some(after) = trimmed.strip_prefix("///") {
85        // Determine the prefix: include the conventional space/tab if present
86        let prefix = if after.starts_with(' ') || after.starts_with('\t') {
87            format!("///{}", &after[..1])
88        } else {
89            "///".to_string()
90        };
91        Some((DocCommentKind::Outer, leading_ws.to_string(), prefix))
92    } else if let Some(after) = trimmed.strip_prefix("//!") {
93        let prefix = if after.starts_with(' ') || after.starts_with('\t') {
94            format!("//!{}", &after[..1])
95        } else {
96            "//!".to_string()
97        };
98        Some((DocCommentKind::Inner, leading_ws.to_string(), prefix))
99    } else {
100        None
101    }
102}
103
104/// Where a lexer over Rust source is between tokens.
105#[derive(Clone, Copy, PartialEq, Eq)]
106enum LexState {
107    Code,
108    LineComment,
109    BlockComment(usize),
110    Str,
111    RawStr(usize),
112}
113
114/// For each line of `content`, whether it starts in code: outside every string
115/// literal and block comment.
116///
117/// A line that starts inside one is part of a value or of a comment, so a `///`
118/// at its start is no doc comment, and rewriting it changes the program
119/// (`r#"\n/// #Heading\n"#` is a string constant). The lexer knows the tokens
120/// that can span lines: string literals with their escapes (`"\""`, a `\` that
121/// continues onto the next line), raw strings (`r#"…"#`, `br"…"`, `cr"…"`),
122/// nested block comments, and the `'"'` char literal that would otherwise open a
123/// string, told apart from a `'a` lifetime.
124fn lines_starting_in_code(content: &str) -> Vec<bool> {
125    let bytes = content.as_bytes();
126    let mut starts = vec![true];
127    let mut state = LexState::Code;
128    let mut i = 0;
129    while i < bytes.len() {
130        let byte = bytes[i];
131        if byte == b'\n' {
132            if state == LexState::LineComment {
133                state = LexState::Code;
134            }
135            starts.push(state == LexState::Code);
136            i += 1;
137            continue;
138        }
139        let next = bytes.get(i + 1).copied();
140        match state {
141            LexState::Code => match byte {
142                b'/' if next == Some(b'/') => {
143                    state = LexState::LineComment;
144                    i += 2;
145                }
146                b'/' if next == Some(b'*') => {
147                    state = LexState::BlockComment(1);
148                    i += 2;
149                }
150                b'"' => {
151                    state = LexState::Str;
152                    i += 1;
153                }
154                b'\'' => i = skip_char_literal(bytes, i),
155                _ if byte == b'_' || byte.is_ascii_alphanumeric() || !byte.is_ascii() => {
156                    let start = i;
157                    while i < bytes.len()
158                        && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric() || !bytes[i].is_ascii())
159                    {
160                        i += 1;
161                    }
162                    if matches!(&bytes[start..i], b"r" | b"br" | b"cr") {
163                        let hashes = bytes[i..].iter().take_while(|&&b| b == b'#').count();
164                        // `r#ident` is a raw identifier, not a string.
165                        if bytes.get(i + hashes) == Some(&b'"') {
166                            state = LexState::RawStr(hashes);
167                            i += hashes + 1;
168                        }
169                    }
170                }
171                _ => i += 1,
172            },
173            LexState::LineComment => i += 1,
174            LexState::BlockComment(depth) => {
175                if byte == b'/' && next == Some(b'*') {
176                    state = LexState::BlockComment(depth + 1);
177                    i += 2;
178                } else if byte == b'*' && next == Some(b'/') {
179                    state = if depth == 1 {
180                        LexState::Code
181                    } else {
182                        LexState::BlockComment(depth - 1)
183                    };
184                    i += 2;
185                } else {
186                    i += 1;
187                }
188            }
189            LexState::Str => match byte {
190                // The escaped byte is skipped, unless it is the newline of a line
191                // continuation, which the loop has to see to record the line.
192                b'\\' if next != Some(b'\n') => i += 2,
193                b'"' => {
194                    state = LexState::Code;
195                    i += 1;
196                }
197                _ => i += 1,
198            },
199            LexState::RawStr(hashes) => {
200                let closes =
201                    byte == b'"' && bytes[i + 1..].iter().take(hashes).filter(|&&b| b == b'#').count() == hashes;
202                if closes {
203                    state = LexState::Code;
204                    i += hashes + 1;
205                } else {
206                    i += 1;
207                }
208            }
209        }
210    }
211    starts
212}
213
214/// The index past a `'` at `quote`: past the whole literal when it opens a char
215/// literal (`'a'`, `'"'`, `'\''`, `'\u{1F600}'`), or past the `'` alone when it
216/// opens a lifetime or label (`'a`, `'static`).
217fn skip_char_literal(bytes: &[u8], quote: usize) -> usize {
218    let after = quote + 1;
219    if bytes.get(after) == Some(&b'\\') {
220        // An escape: the escaped byte, then up to the closing quote.
221        let mut i = after + 2;
222        while i < bytes.len() && bytes[i] != b'\'' && bytes[i] != b'\n' {
223            i += 1;
224        }
225        return if bytes.get(i) == Some(&b'\'') { i + 1 } else { after };
226    }
227    let char_len = match bytes.get(after) {
228        Some(&b) if b < 0x80 => 1,
229        Some(&b) if b >= 0xF0 => 4,
230        Some(&b) if b >= 0xE0 => 3,
231        Some(_) => 2,
232        None => return after,
233    };
234    if bytes.get(after + char_len) == Some(&b'\'') {
235        after + char_len + 1
236    } else {
237        after
238    }
239}
240
241/// Extract all doc comment blocks from Rust source code.
242///
243/// Groups contiguous same-kind doc comment lines into blocks. A block boundary
244/// occurs when:
245///
246/// - A line is not a doc comment, or starts inside a string literal or a block
247///   comment, where a `///` is part of the text
248/// - The doc comment kind changes (from `///` to `//!` or vice versa)
249///
250/// Each block's `markdown` field contains the extracted markdown with prefixes
251/// stripped. The `line_metadata` field preserves the original indentation and
252/// prefix for each line, enabling faithful restoration during fix mode.
253///
254/// **Precondition:** `content` must be LF-normalized (no `\r\n`).
255pub fn extract_doc_comment_blocks(content: &str) -> Vec<DocCommentBlock> {
256    let mut blocks = Vec::new();
257    let mut current_block: Option<DocCommentBlock> = None;
258    // Rust source files may also start with an encoding marker. Keep source
259    // byte ranges past it so rewriting the first doc block preserves the BOM.
260    let mut byte_offset = crate::utils::bom::bom_len(content);
261    let content = crate::utils::bom::strip_bom(content);
262
263    let lines: Vec<&str> = content.split('\n').collect();
264    let num_lines = lines.len();
265    let starts_in_code = lines_starting_in_code(content);
266
267    for (line_idx, line) in lines.iter().enumerate() {
268        let line_byte_start = byte_offset;
269        // Only add 1 for the newline if this is not the last segment
270        let has_newline = line_idx < num_lines - 1 || content.ends_with('\n');
271        let line_byte_end = byte_offset + line.len() + usize::from(has_newline);
272
273        let doc_comment = if starts_in_code[line_idx] {
274            classify_doc_comment_line(line)
275        } else {
276            None
277        };
278        if let Some((kind, leading_ws, prefix)) = doc_comment {
279            // The markdown starts right after the prefix, so the bytes stripped
280            // and the column offset remapping a warning back are one number.
281            let prefix_byte_len = leading_ws.len() + prefix.len();
282            let md_content = &line[prefix_byte_len..];
283
284            let line_info = DocCommentLineInfo {
285                leading_whitespace: leading_ws,
286                prefix,
287            };
288
289            match current_block.as_mut() {
290                Some(block) if block.kind == kind => {
291                    // Continue the current block
292                    block.end_line = line_idx;
293                    block.byte_end = line_byte_end;
294                    block.markdown.push('\n');
295                    block.markdown.push_str(md_content);
296                    block.line_metadata.push(line_info);
297                    block.prefix_byte_lengths.push(prefix_byte_len);
298                }
299                _ => {
300                    // Flush any existing block
301                    if let Some(block) = current_block.take() {
302                        blocks.push(block);
303                    }
304                    // Start a new block
305                    current_block = Some(DocCommentBlock {
306                        kind,
307                        start_line: line_idx,
308                        end_line: line_idx,
309                        byte_start: line_byte_start,
310                        byte_end: line_byte_end,
311                        markdown: md_content.to_string(),
312                        line_metadata: vec![line_info],
313                        prefix_byte_lengths: vec![prefix_byte_len],
314                    });
315                }
316            }
317        } else {
318            // Not a doc comment line — flush current block
319            if let Some(block) = current_block.take() {
320                blocks.push(block);
321            }
322        }
323
324        byte_offset = line_byte_end;
325    }
326
327    // Flush final block
328    if let Some(block) = current_block.take() {
329        blocks.push(block);
330    }
331
332    blocks
333}
334
335/// Rules that should be skipped when linting doc comment blocks.
336///
337/// - MD025: Multiple H1 headings are standard in rustdoc (`# Errors`, `# Examples`, `# Safety`).
338/// - MD033: HTML tags like `<div class="warning">` are required syntax for rustdoc warning blocks.
339/// - MD040: Rustdoc assumes unlabeled code blocks are Rust, so requiring language labels is noise.
340/// - MD041: "First line should be a heading" doesn't apply — doc blocks aren't standalone documents.
341/// - MD047: "File should end with a newline" doesn't apply for the same reason.
342/// - MD051: Rustdoc anchors like `#method.bar` and `#structfield.name` aren't document headings.
343/// - MD052: Intra-doc links like `[crate::io]` are rustdoc syntax, not markdown reference links.
344/// - MD054: Shortcut reference style `[crate::module]` is the canonical intra-doc link syntax.
345pub const SKIPPED_RULES: &[&str] = &["MD025", "MD033", "MD040", "MD041", "MD047", "MD051", "MD052", "MD054"];
346
347/// Whether a path names a file rumdl reads through its Rust doc comments.
348///
349/// The single definition of it, because every pass that touches such a file has
350/// to agree: the lint pass, the fix pass, and the re-lint a fix run reconciles
351/// against. A pass that reads the source as markdown reports on the Rust code
352/// itself, and a fix pass that does it rewrites the Rust code, producing bytes
353/// no `check` ever reported (`#[derive(Debug)]` is an MD018 heading to a
354/// markdown fixer).
355pub fn is_rust_source(path: &Path) -> bool {
356    path.extension().is_some_and(|ext| ext == "rs")
357}
358
359/// Check all doc comment blocks in a Rust source file and return lint warnings.
360///
361/// Warnings have their line numbers and column numbers remapped to point to the
362/// correct location in the original Rust file. Fix suggestions are stripped
363/// (fixes are only applied through the fix mode path in the binary crate).
364///
365/// Empty doc comment blocks (only whitespace content) are skipped.
366pub fn check_doc_comment_blocks(
367    content: &str,
368    rules: &[Box<dyn Rule>],
369    config: &rumdl_config::Config,
370) -> Vec<LintWarning> {
371    let blocks = extract_doc_comment_blocks(content);
372    let mut all_warnings = Vec::new();
373
374    for block in &blocks {
375        // Skip empty blocks to avoid spurious warnings
376        if block.markdown.trim().is_empty() {
377            continue;
378        }
379
380        let ctx = LintContext::new(&block.markdown, config.markdown_flavor(), None);
381
382        for rule in rules {
383            if SKIPPED_RULES.contains(&rule.name()) {
384                continue;
385            }
386
387            // For MD013 in doc comments, disable code block checking.
388            // Code blocks contain Rust code formatted by rustfmt (max_width = 100),
389            // not prose governed by markdown line length limits.
390            let doc_rule: Box<dyn Rule>;
391            let effective_rule: &dyn Rule = if rule.name() == "MD013" {
392                if let Some(md013) = rule.as_any().downcast_ref::<MD013LineLength>() {
393                    doc_rule = Box::new(md013.with_code_blocks_disabled());
394                    doc_rule.as_ref()
395                } else {
396                    rule.as_ref()
397                }
398            } else {
399                rule.as_ref()
400            };
401
402            if let Ok(rule_warnings) = effective_rule.check(&ctx) {
403                // Inline config comments are parsed per block, so filter before
404                // remapping while warning lines are still block-relative.
405                let rule_warnings =
406                    crate::retain_reportable_warnings(&ctx, Some(config), rule.name(), rule_warnings, None);
407                for warning in rule_warnings {
408                    // Remap line numbers:
409                    // warning.line is 1-indexed within the block markdown
410                    // block.start_line is 0-indexed in the file
411                    // (1-indexed block) + (0-indexed file start) = 1-indexed file line
412                    let file_line = warning.line + block.start_line;
413                    let file_end_line = warning.end_line + block.start_line;
414
415                    // Remap column: add the prefix byte length for the corresponding line
416                    let block_line_idx = warning.line.saturating_sub(1);
417                    let col_offset = block.prefix_byte_lengths.get(block_line_idx).copied().unwrap_or(0);
418                    let file_column = warning.column + col_offset;
419
420                    let block_end_line_idx = warning.end_line.saturating_sub(1);
421                    let end_col_offset = block.prefix_byte_lengths.get(block_end_line_idx).copied().unwrap_or(0);
422                    let file_end_column = warning.end_column + end_col_offset;
423
424                    all_warnings.push(LintWarning {
425                        line: file_line,
426                        end_line: file_end_line,
427                        column: file_column,
428                        end_column: file_end_column,
429                        fix: None,
430                        ..warning
431                    });
432                }
433            }
434        }
435    }
436
437    all_warnings
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_classify_outer_doc_comment() {
446        let (kind, ws, prefix) = classify_doc_comment_line("/// Hello").unwrap();
447        assert_eq!(kind, DocCommentKind::Outer);
448        assert_eq!(ws, "");
449        assert_eq!(prefix, "/// ");
450    }
451
452    #[test]
453    fn test_classify_inner_doc_comment() {
454        let (kind, ws, prefix) = classify_doc_comment_line("//! Module doc").unwrap();
455        assert_eq!(kind, DocCommentKind::Inner);
456        assert_eq!(ws, "");
457        assert_eq!(prefix, "//! ");
458    }
459
460    #[test]
461    fn test_classify_empty_outer() {
462        let (kind, ws, prefix) = classify_doc_comment_line("///").unwrap();
463        assert_eq!(kind, DocCommentKind::Outer);
464        assert_eq!(ws, "");
465        assert_eq!(prefix, "///");
466    }
467
468    #[test]
469    fn test_classify_empty_inner() {
470        let (kind, ws, prefix) = classify_doc_comment_line("//!").unwrap();
471        assert_eq!(kind, DocCommentKind::Inner);
472        assert_eq!(ws, "");
473        assert_eq!(prefix, "//!");
474    }
475
476    #[test]
477    fn test_classify_indented() {
478        let (kind, ws, prefix) = classify_doc_comment_line("    /// Indented").unwrap();
479        assert_eq!(kind, DocCommentKind::Outer);
480        assert_eq!(ws, "    ");
481        assert_eq!(prefix, "/// ");
482    }
483
484    #[test]
485    fn test_classify_no_space_after_prefix() {
486        // `///content` is valid rustdoc — content is "content"
487        let (kind, ws, prefix) = classify_doc_comment_line("///content").unwrap();
488        assert_eq!(kind, DocCommentKind::Outer);
489        assert_eq!(ws, "");
490        assert_eq!(prefix, "///");
491    }
492
493    #[test]
494    fn test_classify_tab_after_prefix() {
495        let (kind, ws, prefix) = classify_doc_comment_line("///\tcontent").unwrap();
496        assert_eq!(kind, DocCommentKind::Outer);
497        assert_eq!(ws, "");
498        assert_eq!(prefix, "///\t");
499    }
500
501    #[test]
502    fn test_classify_inner_no_space() {
503        let (kind, _, prefix) = classify_doc_comment_line("//!content").unwrap();
504        assert_eq!(kind, DocCommentKind::Inner);
505        assert_eq!(prefix, "//!");
506    }
507
508    #[test]
509    fn test_classify_four_slashes_is_not_doc() {
510        assert!(classify_doc_comment_line("//// Not a doc comment").is_none());
511    }
512
513    #[test]
514    fn test_classify_regular_comment() {
515        assert!(classify_doc_comment_line("// Regular comment").is_none());
516    }
517
518    #[test]
519    fn test_classify_code_line() {
520        assert!(classify_doc_comment_line("let x = 3;").is_none());
521    }
522
523    #[test]
524    fn test_extract_no_space_content() {
525        let content = "///no space here\n";
526        let blocks = extract_doc_comment_blocks(content);
527        assert_eq!(blocks.len(), 1);
528        assert_eq!(blocks[0].markdown, "no space here");
529    }
530
531    #[test]
532    fn test_extract_strips_tab_separator() {
533        let blocks = extract_doc_comment_blocks("///\t# Heading\n");
534        assert_eq!(blocks.len(), 1);
535        assert_eq!(blocks[0].markdown, "# Heading");
536        assert_eq!(blocks[0].line_metadata[0].prefix, "///\t");
537        assert_eq!(blocks[0].prefix_byte_lengths[0], 4);
538    }
539
540    #[test]
541    fn test_extract_keeps_whitespace_after_the_separator() {
542        let blocks = extract_doc_comment_blocks("/// \tcode\n");
543        assert_eq!(blocks.len(), 1);
544        assert_eq!(blocks[0].markdown, "\tcode");
545        assert_eq!(blocks[0].prefix_byte_lengths[0], 4);
546    }
547
548    #[test]
549    fn test_extract_basic_outer_block() {
550        let content = "/// First line\n/// Second line\nfn foo() {}\n";
551        let blocks = extract_doc_comment_blocks(content);
552        assert_eq!(blocks.len(), 1);
553        assert_eq!(blocks[0].kind, DocCommentKind::Outer);
554        assert_eq!(blocks[0].start_line, 0);
555        assert_eq!(blocks[0].end_line, 1);
556        assert_eq!(blocks[0].markdown, "First line\nSecond line");
557        assert_eq!(blocks[0].line_metadata.len(), 2);
558    }
559
560    #[test]
561    fn test_extract_basic_inner_block() {
562        let content = "//! Module doc\n//! More info\n\nuse std::io;\n";
563        let blocks = extract_doc_comment_blocks(content);
564        assert_eq!(blocks.len(), 1);
565        assert_eq!(blocks[0].kind, DocCommentKind::Inner);
566        assert_eq!(blocks[0].markdown, "Module doc\nMore info");
567    }
568
569    #[test]
570    fn test_extract_multiple_blocks() {
571        let content = "/// Block 1\nfn foo() {}\n/// Block 2\nfn bar() {}\n";
572        let blocks = extract_doc_comment_blocks(content);
573        assert_eq!(blocks.len(), 2);
574        assert_eq!(blocks[0].markdown, "Block 1");
575        assert_eq!(blocks[0].start_line, 0);
576        assert_eq!(blocks[1].markdown, "Block 2");
577        assert_eq!(blocks[1].start_line, 2);
578    }
579
580    #[test]
581    fn test_extract_mixed_kinds_separate_blocks() {
582        let content = "//! Inner\n/// Outer\n";
583        let blocks = extract_doc_comment_blocks(content);
584        assert_eq!(blocks.len(), 2);
585        assert_eq!(blocks[0].kind, DocCommentKind::Inner);
586        assert_eq!(blocks[1].kind, DocCommentKind::Outer);
587    }
588
589    #[test]
590    fn test_extract_empty_doc_line() {
591        let content = "/// First\n///\n/// Third\n";
592        let blocks = extract_doc_comment_blocks(content);
593        assert_eq!(blocks.len(), 1);
594        assert_eq!(blocks[0].markdown, "First\n\nThird");
595    }
596
597    #[test]
598    fn test_extract_preserves_extra_space() {
599        let content = "///  Two spaces\n";
600        let blocks = extract_doc_comment_blocks(content);
601        assert_eq!(blocks.len(), 1);
602        assert_eq!(blocks[0].markdown, " Two spaces");
603    }
604
605    #[test]
606    fn test_extract_indented_doc_comments() {
607        let content = "    /// Indented\n    /// More\n";
608        let blocks = extract_doc_comment_blocks(content);
609        assert_eq!(blocks.len(), 1);
610        assert_eq!(blocks[0].markdown, "Indented\nMore");
611        assert_eq!(blocks[0].line_metadata[0].leading_whitespace, "    ");
612    }
613
614    #[test]
615    fn test_no_doc_comments() {
616        let content = "fn main() {\n    let x = 3;\n}\n";
617        let blocks = extract_doc_comment_blocks(content);
618        assert!(blocks.is_empty());
619    }
620
621    #[test]
622    fn test_byte_offsets() {
623        let content = "/// Hello\nfn foo() {}\n/// World\n";
624        let blocks = extract_doc_comment_blocks(content);
625        assert_eq!(blocks.len(), 2);
626        // First block: "/// Hello\n" = 10 bytes
627        assert_eq!(blocks[0].byte_start, 0);
628        assert_eq!(blocks[0].byte_end, 10);
629        // Second block starts after "fn foo() {}\n" (12 bytes), at offset 22
630        assert_eq!(blocks[1].byte_start, 22);
631        assert_eq!(blocks[1].byte_end, 32);
632    }
633
634    #[test]
635    fn test_byte_offsets_no_trailing_newline() {
636        let content = "/// Hello";
637        let blocks = extract_doc_comment_blocks(content);
638        assert_eq!(blocks.len(), 1);
639        assert_eq!(blocks[0].byte_start, 0);
640        // No trailing newline, so byte_end == content.len()
641        assert_eq!(blocks[0].byte_end, content.len());
642    }
643
644    /// The markdown of every block extracted from `content`.
645    fn markdown_of(content: &str) -> Vec<String> {
646        extract_doc_comment_blocks(content)
647            .into_iter()
648            .map(|block| block.markdown)
649            .collect()
650    }
651
652    #[test]
653    fn test_a_doc_comment_inside_a_raw_string_is_text() {
654        let content = "const S: &str = r#\"\n/// #Heading\n\"#;\n/// After\nfn f() {}\n";
655        assert_eq!(markdown_of(content), ["After"]);
656    }
657
658    #[test]
659    fn test_a_raw_string_ends_only_at_its_own_hash_count() {
660        let content = "const S: &str = r##\"\n\"#\n/// Inside\n\"##;\n/// After\n";
661        assert_eq!(markdown_of(content), ["After"]);
662    }
663
664    #[test]
665    fn test_byte_and_c_raw_strings_are_strings() {
666        for opener in ["br\"", "cr#\""] {
667            let closer = if opener.contains('#') { "\"#" } else { "\"" };
668            let content = format!("let s = {opener}\n/// Inside\n{closer};\n/// After\n");
669            assert_eq!(markdown_of(&content), ["After"], "{opener}");
670        }
671    }
672
673    #[test]
674    fn test_a_raw_identifier_opens_no_string() {
675        let content = "let r#type = 1;\n/// After\n";
676        assert_eq!(markdown_of(content), ["After"]);
677    }
678
679    #[test]
680    fn test_a_doc_comment_inside_a_string_is_text() {
681        let content = "let s = \"a \\\" quote\n/// Inside\n\";\n/// After\n";
682        assert_eq!(markdown_of(content), ["After"]);
683    }
684
685    #[test]
686    fn test_a_line_continuation_keeps_the_string_open() {
687        let content = "let s = \"a\\\n/// Inside\n\";\n/// After\n";
688        assert_eq!(markdown_of(content), ["After"]);
689    }
690
691    #[test]
692    fn test_a_doc_comment_inside_a_nested_block_comment_is_text() {
693        let content = "/* outer /* inner */\n/// Inside\n*/\n/// After\n";
694        assert_eq!(markdown_of(content), ["After"]);
695    }
696
697    #[test]
698    fn test_a_block_doc_comment_body_is_not_extracted() {
699        let content = "/**\n/// Inside\n */\n/// After\n";
700        assert_eq!(markdown_of(content), ["After"]);
701    }
702
703    #[test]
704    fn test_a_quote_in_a_char_literal_opens_no_string() {
705        for literal in ["'\"'", "'\\''", "b'\"'", "'\\u{22}'", "'é'"] {
706            let content = format!("let c = {literal};\n/// After\n");
707            assert_eq!(markdown_of(&content), ["After"], "{literal}");
708        }
709    }
710
711    #[test]
712    fn test_a_lifetime_is_not_a_char_literal() {
713        // Read as quotes that pair up, the third `'a` pairs with the `'x'` two
714        // lines down and hides the string between them, whose text then reads
715        // as a doc comment.
716        let content = "fn f<'a>(s: &'a str) -> &'a str { \"\n/// Inside\n\" }\nlet c = 'x';\n/// After\n";
717        assert_eq!(markdown_of(content), ["After"]);
718    }
719
720    #[test]
721    fn test_quotes_in_comments_open_no_string() {
722        let content = "// don't \"\n/// One\nfn f() {}\n/* \" */\n/// Two\n";
723        assert_eq!(markdown_of(content), ["One", "Two"]);
724    }
725
726    #[test]
727    fn test_prefix_byte_lengths() {
728        let content = "    /// Indented\n/// Top-level\n";
729        let blocks = extract_doc_comment_blocks(content);
730        assert_eq!(blocks.len(), 1);
731        // "    " (4) + "/// " (4) = 8 bytes for first line
732        assert_eq!(blocks[0].prefix_byte_lengths[0], 8);
733        // "" (0) + "/// " (4) = 4 bytes for second line
734        assert_eq!(blocks[0].prefix_byte_lengths[1], 4);
735    }
736}