1use crate::config as rumdl_config;
15use crate::rule::{LintWarning, Rule};
16use crate::rules::md013_line_length::MD013LineLength;
17use std::path::Path;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum DocCommentKind {
22 Outer,
24 Inner,
26}
27
28#[derive(Debug, Clone)]
30pub struct DocCommentLineInfo {
31 pub leading_whitespace: String,
33 pub prefix: String,
36}
37
38#[derive(Debug, Clone)]
40pub struct DocCommentBlock {
41 pub kind: DocCommentKind,
43 pub start_line: usize,
45 pub end_line: usize,
47 pub byte_start: usize,
49 pub byte_end: usize,
51 pub markdown: String,
53 pub line_metadata: Vec<DocCommentLineInfo>,
55 pub prefix_byte_lengths: Vec<usize>,
58}
59
60fn 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 if trimmed.starts_with("////") {
80 return None;
81 }
82
83 if let Some(after) = trimmed.strip_prefix("///") {
84 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#[derive(Clone, Copy, PartialEq, Eq)]
105enum LexState {
106 Code,
107 LineComment,
108 BlockComment(usize),
109 Str,
110 RawStr(usize),
111}
112
113fn 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 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 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
213fn skip_char_literal(bytes: &[u8], quote: usize) -> usize {
217 let after = quote + 1;
218 if bytes.get(after) == Some(&b'\\') {
219 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
240pub fn extract_doc_comment_blocks(content: &str) -> Vec<DocCommentBlock> {
255 let mut blocks = Vec::new();
256 let mut current_block: Option<DocCommentBlock> = None;
257 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 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 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 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 if let Some(block) = current_block.take() {
301 blocks.push(block);
302 }
303 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 if let Some(block) = current_block.take() {
319 blocks.push(block);
320 }
321 }
322
323 byte_offset = line_byte_end;
324 }
325
326 if let Some(block) = current_block.take() {
328 blocks.push(block);
329 }
330
331 blocks
332}
333
334pub const SKIPPED_RULES: &[&str] = &["MD025", "MD033", "MD040", "MD041", "MD047", "MD051", "MD052", "MD054"];
345
346pub fn is_rust_source(path: &Path) -> bool {
355 path.extension().is_some_and(|ext| ext == "rs")
356}
357
358pub 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 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 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 let file_line = warning.line + block.start_line;
412 let file_end_line = warning.end_line + block.start_line;
413
414 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 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 assert_eq!(blocks[0].byte_start, 0);
658 assert_eq!(blocks[0].byte_end, 10);
659 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 assert_eq!(blocks[0].byte_end, content.len());
672 }
673
674 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 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 assert_eq!(blocks[0].prefix_byte_lengths[0], 8);
763 assert_eq!(blocks[0].prefix_byte_lengths[1], 4);
765 }
766}