use crate::config as rumdl_config;
use crate::rule::{LintWarning, Rule};
use crate::rules::md013_line_length::MD013LineLength;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocCommentKind {
Outer,
Inner,
}
#[derive(Debug, Clone)]
pub struct DocCommentLineInfo {
pub leading_whitespace: String,
pub prefix: String,
}
#[derive(Debug, Clone)]
pub struct DocCommentBlock {
pub kind: DocCommentKind,
pub start_line: usize,
pub end_line: usize,
pub byte_start: usize,
pub byte_end: usize,
pub markdown: String,
pub line_metadata: Vec<DocCommentLineInfo>,
pub prefix_byte_lengths: Vec<usize>,
}
fn classify_doc_comment_line(line: &str) -> Option<(DocCommentKind, String, String)> {
let trimmed = line.trim_start();
let leading_ws = &line[..line.len() - trimmed.len()];
if trimmed.starts_with("////") {
return None;
}
if let Some(after) = trimmed.strip_prefix("///") {
let prefix = if after.starts_with(' ') || after.starts_with('\t') {
format!("///{}", &after[..1])
} else {
"///".to_string()
};
Some((DocCommentKind::Outer, leading_ws.to_string(), prefix))
} else if let Some(after) = trimmed.strip_prefix("//!") {
let prefix = if after.starts_with(' ') || after.starts_with('\t') {
format!("//!{}", &after[..1])
} else {
"//!".to_string()
};
Some((DocCommentKind::Inner, leading_ws.to_string(), prefix))
} else {
None
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum LexState {
Code,
LineComment,
BlockComment(usize),
Str,
RawStr(usize),
}
fn lines_starting_in_code(content: &str) -> Vec<bool> {
let bytes = content.as_bytes();
let mut starts = vec![true];
let mut state = LexState::Code;
let mut i = 0;
while i < bytes.len() {
let byte = bytes[i];
if byte == b'\n' {
if state == LexState::LineComment {
state = LexState::Code;
}
starts.push(state == LexState::Code);
i += 1;
continue;
}
let next = bytes.get(i + 1).copied();
match state {
LexState::Code => match byte {
b'/' if next == Some(b'/') => {
state = LexState::LineComment;
i += 2;
}
b'/' if next == Some(b'*') => {
state = LexState::BlockComment(1);
i += 2;
}
b'"' => {
state = LexState::Str;
i += 1;
}
b'\'' => i = skip_char_literal(bytes, i),
_ if byte == b'_' || byte.is_ascii_alphanumeric() || !byte.is_ascii() => {
let start = i;
while i < bytes.len()
&& (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric() || !bytes[i].is_ascii())
{
i += 1;
}
if matches!(&bytes[start..i], b"r" | b"br" | b"cr") {
let hashes = bytes[i..].iter().take_while(|&&b| b == b'#').count();
if bytes.get(i + hashes) == Some(&b'"') {
state = LexState::RawStr(hashes);
i += hashes + 1;
}
}
}
_ => i += 1,
},
LexState::LineComment => i += 1,
LexState::BlockComment(depth) => {
if byte == b'/' && next == Some(b'*') {
state = LexState::BlockComment(depth + 1);
i += 2;
} else if byte == b'*' && next == Some(b'/') {
state = if depth == 1 {
LexState::Code
} else {
LexState::BlockComment(depth - 1)
};
i += 2;
} else {
i += 1;
}
}
LexState::Str => match byte {
b'\\' if next != Some(b'\n') => i += 2,
b'"' => {
state = LexState::Code;
i += 1;
}
_ => i += 1,
},
LexState::RawStr(hashes) => {
let closes =
byte == b'"' && bytes[i + 1..].iter().take(hashes).filter(|&&b| b == b'#').count() == hashes;
if closes {
state = LexState::Code;
i += hashes + 1;
} else {
i += 1;
}
}
}
}
starts
}
fn skip_char_literal(bytes: &[u8], quote: usize) -> usize {
let after = quote + 1;
if bytes.get(after) == Some(&b'\\') {
let mut i = after + 2;
while i < bytes.len() && bytes[i] != b'\'' && bytes[i] != b'\n' {
i += 1;
}
return if bytes.get(i) == Some(&b'\'') { i + 1 } else { after };
}
let char_len = match bytes.get(after) {
Some(&b) if b < 0x80 => 1,
Some(&b) if b >= 0xF0 => 4,
Some(&b) if b >= 0xE0 => 3,
Some(_) => 2,
None => return after,
};
if bytes.get(after + char_len) == Some(&b'\'') {
after + char_len + 1
} else {
after
}
}
pub fn extract_doc_comment_blocks(content: &str) -> Vec<DocCommentBlock> {
let mut blocks = Vec::new();
let mut current_block: Option<DocCommentBlock> = None;
let mut byte_offset = crate::utils::bom::bom_len(content);
let content = crate::utils::bom::strip_bom(content);
let lines: Vec<&str> = content.split('\n').collect();
let num_lines = lines.len();
let starts_in_code = lines_starting_in_code(content);
for (line_idx, line) in lines.iter().enumerate() {
let line_byte_start = byte_offset;
let has_newline = line_idx < num_lines - 1 || content.ends_with('\n');
let line_byte_end = byte_offset + line.len() + usize::from(has_newline);
let doc_comment = if starts_in_code[line_idx] {
classify_doc_comment_line(line)
} else {
None
};
if let Some((kind, leading_ws, prefix)) = doc_comment {
let prefix_byte_len = leading_ws.len() + prefix.len();
let md_content = &line[prefix_byte_len..];
let line_info = DocCommentLineInfo {
leading_whitespace: leading_ws,
prefix,
};
match current_block.as_mut() {
Some(block) if block.kind == kind => {
block.end_line = line_idx;
block.byte_end = line_byte_end;
block.markdown.push('\n');
block.markdown.push_str(md_content);
block.line_metadata.push(line_info);
block.prefix_byte_lengths.push(prefix_byte_len);
}
_ => {
if let Some(block) = current_block.take() {
blocks.push(block);
}
current_block = Some(DocCommentBlock {
kind,
start_line: line_idx,
end_line: line_idx,
byte_start: line_byte_start,
byte_end: line_byte_end,
markdown: md_content.to_string(),
line_metadata: vec![line_info],
prefix_byte_lengths: vec![prefix_byte_len],
});
}
}
} else {
if let Some(block) = current_block.take() {
blocks.push(block);
}
}
byte_offset = line_byte_end;
}
if let Some(block) = current_block.take() {
blocks.push(block);
}
blocks
}
pub const SKIPPED_RULES: &[&str] = &["MD025", "MD033", "MD040", "MD041", "MD047", "MD051", "MD052", "MD054"];
pub fn is_rust_source(path: &Path) -> bool {
path.extension().is_some_and(|ext| ext == "rs")
}
pub fn check_doc_comment_blocks(
content: &str,
rules: &[Box<dyn Rule>],
config: &rumdl_config::Config,
) -> Vec<LintWarning> {
let blocks = extract_doc_comment_blocks(content);
let mut all_warnings = Vec::new();
let mut doc_config = config.clone();
let md013 = doc_config.rules.entry("MD013".into()).or_default();
md013.values.remove("code_blocks");
md013.values.insert("code-blocks".into(), toml::Value::Boolean(false));
let block_rules: Vec<Box<dyn Rule>> = rules
.iter()
.filter(|rule| !SKIPPED_RULES.contains(&rule.name()))
.map(|rule| {
if let Some(md013) = rule.as_any().downcast_ref::<MD013LineLength>() {
Box::new(md013.with_code_blocks_disabled()) as Box<dyn Rule>
} else {
dyn_clone::clone_box(&**rule)
}
})
.collect();
for block in &blocks {
if block.markdown.trim().is_empty() {
continue;
}
if let Ok(warnings) = crate::lint(
&block.markdown,
&block_rules,
false,
config.markdown_flavor(),
None,
Some(&doc_config),
) {
for warning in warnings {
let file_line = warning.line + block.start_line;
let file_end_line = warning.end_line + block.start_line;
let block_line_idx = warning.line.saturating_sub(1);
let col_offset = block.prefix_byte_lengths.get(block_line_idx).copied().unwrap_or(0);
let file_column = warning.column + col_offset;
let block_end_line_idx = warning.end_line.saturating_sub(1);
let end_col_offset = block.prefix_byte_lengths.get(block_end_line_idx).copied().unwrap_or(0);
let file_end_column = warning.end_column + end_col_offset;
all_warnings.push(LintWarning {
line: file_line,
end_line: file_end_line,
column: file_column,
end_column: file_end_column,
fix: None,
..warning
});
}
}
}
all_warnings
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inline_line_length_keeps_rust_examples_excluded() {
let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD013LineLength::default())];
let config = rumdl_config::Config::default();
for key in ["line_length", "line-length"] {
let source = format!(
"/// <!-- 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"
);
let warnings = check_doc_comment_blocks(&source, &rules, &config);
assert_eq!(warnings.len(), 1, "{key}: {warnings:?}");
assert_eq!(warnings[0].line, 5);
}
}
#[test]
fn doc_comments_account_for_used_and_unused_directives_per_block() {
let rules: Vec<Box<dyn Rule>> = vec![
Box::new(MD013LineLength::default()),
Box::new(crate::rules::MD087UnusedDisableComment),
];
let config = rumdl_config::Config::default();
let source = format!(
"/// {}<!-- rumdl-disable-line MD013 -->\nfn first() {{}}\n\n/// Short <!-- rumdl-disable-line MD013 -->\nfn second() {{}}\n",
"word ".repeat(30)
);
let warnings = check_doc_comment_blocks(&source, &rules, &config);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(warnings[0].rule_name.as_deref(), Some("MD087"));
assert_eq!((warnings[0].line, warnings[0].column), (4, 11));
assert!(warnings[0].fix.is_none());
}
#[test]
fn test_classify_outer_doc_comment() {
let (kind, ws, prefix) = classify_doc_comment_line("/// Hello").unwrap();
assert_eq!(kind, DocCommentKind::Outer);
assert_eq!(ws, "");
assert_eq!(prefix, "/// ");
}
#[test]
fn test_classify_inner_doc_comment() {
let (kind, ws, prefix) = classify_doc_comment_line("//! Module doc").unwrap();
assert_eq!(kind, DocCommentKind::Inner);
assert_eq!(ws, "");
assert_eq!(prefix, "//! ");
}
#[test]
fn test_classify_empty_outer() {
let (kind, ws, prefix) = classify_doc_comment_line("///").unwrap();
assert_eq!(kind, DocCommentKind::Outer);
assert_eq!(ws, "");
assert_eq!(prefix, "///");
}
#[test]
fn test_classify_empty_inner() {
let (kind, ws, prefix) = classify_doc_comment_line("//!").unwrap();
assert_eq!(kind, DocCommentKind::Inner);
assert_eq!(ws, "");
assert_eq!(prefix, "//!");
}
#[test]
fn test_classify_indented() {
let (kind, ws, prefix) = classify_doc_comment_line(" /// Indented").unwrap();
assert_eq!(kind, DocCommentKind::Outer);
assert_eq!(ws, " ");
assert_eq!(prefix, "/// ");
}
#[test]
fn test_classify_no_space_after_prefix() {
let (kind, ws, prefix) = classify_doc_comment_line("///content").unwrap();
assert_eq!(kind, DocCommentKind::Outer);
assert_eq!(ws, "");
assert_eq!(prefix, "///");
}
#[test]
fn test_classify_tab_after_prefix() {
let (kind, ws, prefix) = classify_doc_comment_line("///\tcontent").unwrap();
assert_eq!(kind, DocCommentKind::Outer);
assert_eq!(ws, "");
assert_eq!(prefix, "///\t");
}
#[test]
fn test_classify_inner_no_space() {
let (kind, _, prefix) = classify_doc_comment_line("//!content").unwrap();
assert_eq!(kind, DocCommentKind::Inner);
assert_eq!(prefix, "//!");
}
#[test]
fn test_classify_four_slashes_is_not_doc() {
assert!(classify_doc_comment_line("//// Not a doc comment").is_none());
}
#[test]
fn test_classify_regular_comment() {
assert!(classify_doc_comment_line("// Regular comment").is_none());
}
#[test]
fn test_classify_code_line() {
assert!(classify_doc_comment_line("let x = 3;").is_none());
}
#[test]
fn test_extract_no_space_content() {
let content = "///no space here\n";
let blocks = extract_doc_comment_blocks(content);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].markdown, "no space here");
}
#[test]
fn test_extract_strips_tab_separator() {
let blocks = extract_doc_comment_blocks("///\t# Heading\n");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].markdown, "# Heading");
assert_eq!(blocks[0].line_metadata[0].prefix, "///\t");
assert_eq!(blocks[0].prefix_byte_lengths[0], 4);
}
#[test]
fn test_extract_keeps_whitespace_after_the_separator() {
let blocks = extract_doc_comment_blocks("/// \tcode\n");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].markdown, "\tcode");
assert_eq!(blocks[0].prefix_byte_lengths[0], 4);
}
#[test]
fn test_extract_basic_outer_block() {
let content = "/// First line\n/// Second line\nfn foo() {}\n";
let blocks = extract_doc_comment_blocks(content);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].kind, DocCommentKind::Outer);
assert_eq!(blocks[0].start_line, 0);
assert_eq!(blocks[0].end_line, 1);
assert_eq!(blocks[0].markdown, "First line\nSecond line");
assert_eq!(blocks[0].line_metadata.len(), 2);
}
#[test]
fn test_extract_basic_inner_block() {
let content = "//! Module doc\n//! More info\n\nuse std::io;\n";
let blocks = extract_doc_comment_blocks(content);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].kind, DocCommentKind::Inner);
assert_eq!(blocks[0].markdown, "Module doc\nMore info");
}
#[test]
fn test_extract_multiple_blocks() {
let content = "/// Block 1\nfn foo() {}\n/// Block 2\nfn bar() {}\n";
let blocks = extract_doc_comment_blocks(content);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].markdown, "Block 1");
assert_eq!(blocks[0].start_line, 0);
assert_eq!(blocks[1].markdown, "Block 2");
assert_eq!(blocks[1].start_line, 2);
}
#[test]
fn test_extract_mixed_kinds_separate_blocks() {
let content = "//! Inner\n/// Outer\n";
let blocks = extract_doc_comment_blocks(content);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].kind, DocCommentKind::Inner);
assert_eq!(blocks[1].kind, DocCommentKind::Outer);
}
#[test]
fn test_extract_empty_doc_line() {
let content = "/// First\n///\n/// Third\n";
let blocks = extract_doc_comment_blocks(content);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].markdown, "First\n\nThird");
}
#[test]
fn test_extract_preserves_extra_space() {
let content = "/// Two spaces\n";
let blocks = extract_doc_comment_blocks(content);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].markdown, " Two spaces");
}
#[test]
fn test_extract_indented_doc_comments() {
let content = " /// Indented\n /// More\n";
let blocks = extract_doc_comment_blocks(content);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].markdown, "Indented\nMore");
assert_eq!(blocks[0].line_metadata[0].leading_whitespace, " ");
}
#[test]
fn test_no_doc_comments() {
let content = "fn main() {\n let x = 3;\n}\n";
let blocks = extract_doc_comment_blocks(content);
assert!(blocks.is_empty());
}
#[test]
fn test_byte_offsets() {
let content = "/// Hello\nfn foo() {}\n/// World\n";
let blocks = extract_doc_comment_blocks(content);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].byte_start, 0);
assert_eq!(blocks[0].byte_end, 10);
assert_eq!(blocks[1].byte_start, 22);
assert_eq!(blocks[1].byte_end, 32);
}
#[test]
fn test_byte_offsets_no_trailing_newline() {
let content = "/// Hello";
let blocks = extract_doc_comment_blocks(content);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].byte_start, 0);
assert_eq!(blocks[0].byte_end, content.len());
}
fn markdown_of(content: &str) -> Vec<String> {
extract_doc_comment_blocks(content)
.into_iter()
.map(|block| block.markdown)
.collect()
}
#[test]
fn test_a_doc_comment_inside_a_raw_string_is_text() {
let content = "const S: &str = r#\"\n/// #Heading\n\"#;\n/// After\nfn f() {}\n";
assert_eq!(markdown_of(content), ["After"]);
}
#[test]
fn test_a_raw_string_ends_only_at_its_own_hash_count() {
let content = "const S: &str = r##\"\n\"#\n/// Inside\n\"##;\n/// After\n";
assert_eq!(markdown_of(content), ["After"]);
}
#[test]
fn test_byte_and_c_raw_strings_are_strings() {
for opener in ["br\"", "cr#\""] {
let closer = if opener.contains('#') { "\"#" } else { "\"" };
let content = format!("let s = {opener}\n/// Inside\n{closer};\n/// After\n");
assert_eq!(markdown_of(&content), ["After"], "{opener}");
}
}
#[test]
fn test_a_raw_identifier_opens_no_string() {
let content = "let r#type = 1;\n/// After\n";
assert_eq!(markdown_of(content), ["After"]);
}
#[test]
fn test_a_doc_comment_inside_a_string_is_text() {
let content = "let s = \"a \\\" quote\n/// Inside\n\";\n/// After\n";
assert_eq!(markdown_of(content), ["After"]);
}
#[test]
fn test_a_line_continuation_keeps_the_string_open() {
let content = "let s = \"a\\\n/// Inside\n\";\n/// After\n";
assert_eq!(markdown_of(content), ["After"]);
}
#[test]
fn test_a_doc_comment_inside_a_nested_block_comment_is_text() {
let content = "/* outer /* inner */\n/// Inside\n*/\n/// After\n";
assert_eq!(markdown_of(content), ["After"]);
}
#[test]
fn test_a_block_doc_comment_body_is_not_extracted() {
let content = "/**\n/// Inside\n */\n/// After\n";
assert_eq!(markdown_of(content), ["After"]);
}
#[test]
fn test_a_quote_in_a_char_literal_opens_no_string() {
for literal in ["'\"'", "'\\''", "b'\"'", "'\\u{22}'", "'é'"] {
let content = format!("let c = {literal};\n/// After\n");
assert_eq!(markdown_of(&content), ["After"], "{literal}");
}
}
#[test]
fn test_a_lifetime_is_not_a_char_literal() {
let content = "fn f<'a>(s: &'a str) -> &'a str { \"\n/// Inside\n\" }\nlet c = 'x';\n/// After\n";
assert_eq!(markdown_of(content), ["After"]);
}
#[test]
fn test_quotes_in_comments_open_no_string() {
let content = "// don't \"\n/// One\nfn f() {}\n/* \" */\n/// Two\n";
assert_eq!(markdown_of(content), ["One", "Two"]);
}
#[test]
fn test_prefix_byte_lengths() {
let content = " /// Indented\n/// Top-level\n";
let blocks = extract_doc_comment_blocks(content);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].prefix_byte_lengths[0], 8);
assert_eq!(blocks[0].prefix_byte_lengths[1], 4);
}
}