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