1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DocCommentKind {
23 Outer,
25 Inner,
27}
28
29#[derive(Debug, Clone)]
31pub struct DocCommentLineInfo {
32 pub leading_whitespace: String,
34 pub prefix: String,
37}
38
39#[derive(Debug, Clone)]
41pub struct DocCommentBlock {
42 pub kind: DocCommentKind,
44 pub start_line: usize,
46 pub end_line: usize,
48 pub byte_start: usize,
50 pub byte_end: usize,
52 pub markdown: String,
54 pub line_metadata: Vec<DocCommentLineInfo>,
56 pub prefix_byte_lengths: Vec<usize>,
59}
60
61fn 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 if trimmed.starts_with("////") {
81 return None;
82 }
83
84 if let Some(after) = trimmed.strip_prefix("///") {
85 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#[derive(Clone, Copy, PartialEq, Eq)]
106enum LexState {
107 Code,
108 LineComment,
109 BlockComment(usize),
110 Str,
111 RawStr(usize),
112}
113
114fn 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 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 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
214fn skip_char_literal(bytes: &[u8], quote: usize) -> usize {
218 let after = quote + 1;
219 if bytes.get(after) == Some(&b'\\') {
220 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
241pub fn extract_doc_comment_blocks(content: &str) -> Vec<DocCommentBlock> {
256 let mut blocks = Vec::new();
257 let mut current_block: Option<DocCommentBlock> = None;
258 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 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 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 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 if let Some(block) = current_block.take() {
302 blocks.push(block);
303 }
304 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 if let Some(block) = current_block.take() {
320 blocks.push(block);
321 }
322 }
323
324 byte_offset = line_byte_end;
325 }
326
327 if let Some(block) = current_block.take() {
329 blocks.push(block);
330 }
331
332 blocks
333}
334
335pub const SKIPPED_RULES: &[&str] = &["MD025", "MD033", "MD040", "MD041", "MD047", "MD051", "MD052", "MD054"];
346
347pub fn is_rust_source(path: &Path) -> bool {
356 path.extension().is_some_and(|ext| ext == "rs")
357}
358
359pub 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 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 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 let rule_warnings =
406 crate::retain_reportable_warnings(&ctx, Some(config), rule.name(), rule_warnings, None);
407 for warning in rule_warnings {
408 let file_line = warning.line + block.start_line;
413 let file_end_line = warning.end_line + block.start_line;
414
415 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 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 assert_eq!(blocks[0].byte_start, 0);
628 assert_eq!(blocks[0].byte_end, 10);
629 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 assert_eq!(blocks[0].byte_end, content.len());
642 }
643
644 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 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 assert_eq!(blocks[0].prefix_byte_lengths[0], 8);
733 assert_eq!(blocks[0].prefix_byte_lengths[1], 4);
735 }
736}