1use crate::config as rumdl_config;
13use crate::lint_context::LintContext;
14use crate::rule::{LintWarning, Rule};
15use crate::rules::md013_line_length::MD013LineLength;
16use std::path::Path;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DocCommentKind {
21 Outer,
23 Inner,
25}
26
27#[derive(Debug, Clone)]
29pub struct DocCommentLineInfo {
30 pub leading_whitespace: String,
32 pub prefix: String,
35}
36
37#[derive(Debug, Clone)]
39pub struct DocCommentBlock {
40 pub kind: DocCommentKind,
42 pub start_line: usize,
44 pub end_line: usize,
46 pub byte_start: usize,
48 pub byte_end: usize,
50 pub markdown: String,
52 pub line_metadata: Vec<DocCommentLineInfo>,
54 pub prefix_byte_lengths: Vec<usize>,
57}
58
59fn classify_doc_comment_line(line: &str) -> Option<(DocCommentKind, String, String)> {
74 let trimmed = line.trim_start();
75 let leading_ws = &line[..line.len() - trimmed.len()];
76
77 if trimmed.starts_with("////") {
79 return None;
80 }
81
82 if let Some(after) = trimmed.strip_prefix("///") {
83 let prefix = if after.starts_with(' ') || after.starts_with('\t') {
85 format!("///{}", &after[..1])
86 } else {
87 "///".to_string()
88 };
89 Some((DocCommentKind::Outer, leading_ws.to_string(), prefix))
90 } else if let Some(after) = trimmed.strip_prefix("//!") {
91 let prefix = if after.starts_with(' ') || after.starts_with('\t') {
92 format!("//!{}", &after[..1])
93 } else {
94 "//!".to_string()
95 };
96 Some((DocCommentKind::Inner, leading_ws.to_string(), prefix))
97 } else {
98 None
99 }
100}
101
102pub fn extract_doc_comment_blocks(content: &str) -> Vec<DocCommentBlock> {
116 let mut blocks = Vec::new();
117 let mut current_block: Option<DocCommentBlock> = None;
118 let mut byte_offset = 0;
119
120 let lines: Vec<&str> = content.split('\n').collect();
121 let num_lines = lines.len();
122
123 for (line_idx, line) in lines.iter().enumerate() {
124 let line_byte_start = byte_offset;
125 let has_newline = line_idx < num_lines - 1 || content.ends_with('\n');
127 let line_byte_end = byte_offset + line.len() + usize::from(has_newline);
128
129 if let Some((kind, leading_ws, prefix)) = classify_doc_comment_line(line) {
130 let prefix_byte_len = leading_ws.len() + prefix.len();
133 let md_content = &line[prefix_byte_len..];
134
135 let line_info = DocCommentLineInfo {
136 leading_whitespace: leading_ws,
137 prefix,
138 };
139
140 match current_block.as_mut() {
141 Some(block) if block.kind == kind => {
142 block.end_line = line_idx;
144 block.byte_end = line_byte_end;
145 block.markdown.push('\n');
146 block.markdown.push_str(md_content);
147 block.line_metadata.push(line_info);
148 block.prefix_byte_lengths.push(prefix_byte_len);
149 }
150 _ => {
151 if let Some(block) = current_block.take() {
153 blocks.push(block);
154 }
155 current_block = Some(DocCommentBlock {
157 kind,
158 start_line: line_idx,
159 end_line: line_idx,
160 byte_start: line_byte_start,
161 byte_end: line_byte_end,
162 markdown: md_content.to_string(),
163 line_metadata: vec![line_info],
164 prefix_byte_lengths: vec![prefix_byte_len],
165 });
166 }
167 }
168 } else {
169 if let Some(block) = current_block.take() {
171 blocks.push(block);
172 }
173 }
174
175 byte_offset = line_byte_end;
176 }
177
178 if let Some(block) = current_block.take() {
180 blocks.push(block);
181 }
182
183 blocks
184}
185
186pub const SKIPPED_RULES: &[&str] = &["MD025", "MD033", "MD040", "MD041", "MD047", "MD051", "MD052", "MD054"];
197
198pub fn is_rust_source(path: &Path) -> bool {
207 path.extension().is_some_and(|ext| ext == "rs")
208}
209
210pub fn check_doc_comment_blocks(
218 content: &str,
219 rules: &[Box<dyn Rule>],
220 config: &rumdl_config::Config,
221) -> Vec<LintWarning> {
222 let blocks = extract_doc_comment_blocks(content);
223 let mut all_warnings = Vec::new();
224
225 for block in &blocks {
226 if block.markdown.trim().is_empty() {
228 continue;
229 }
230
231 let ctx = LintContext::new(&block.markdown, config.markdown_flavor(), None);
232
233 for rule in rules {
234 if SKIPPED_RULES.contains(&rule.name()) {
235 continue;
236 }
237
238 let doc_rule: Box<dyn Rule>;
242 let effective_rule: &dyn Rule = if rule.name() == "MD013" {
243 if let Some(md013) = rule.as_any().downcast_ref::<MD013LineLength>() {
244 doc_rule = Box::new(md013.with_code_blocks_disabled());
245 doc_rule.as_ref()
246 } else {
247 rule.as_ref()
248 }
249 } else {
250 rule.as_ref()
251 };
252
253 if let Ok(rule_warnings) = effective_rule.check(&ctx) {
254 for warning in rule_warnings {
255 let file_line = warning.line + block.start_line;
260 let file_end_line = warning.end_line + block.start_line;
261
262 let block_line_idx = warning.line.saturating_sub(1);
264 let col_offset = block.prefix_byte_lengths.get(block_line_idx).copied().unwrap_or(0);
265 let file_column = warning.column + col_offset;
266
267 let block_end_line_idx = warning.end_line.saturating_sub(1);
268 let end_col_offset = block.prefix_byte_lengths.get(block_end_line_idx).copied().unwrap_or(0);
269 let file_end_column = warning.end_column + end_col_offset;
270
271 all_warnings.push(LintWarning {
272 line: file_line,
273 end_line: file_end_line,
274 column: file_column,
275 end_column: file_end_column,
276 fix: None,
277 ..warning
278 });
279 }
280 }
281 }
282 }
283
284 all_warnings
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn test_classify_outer_doc_comment() {
293 let (kind, ws, prefix) = classify_doc_comment_line("/// Hello").unwrap();
294 assert_eq!(kind, DocCommentKind::Outer);
295 assert_eq!(ws, "");
296 assert_eq!(prefix, "/// ");
297 }
298
299 #[test]
300 fn test_classify_inner_doc_comment() {
301 let (kind, ws, prefix) = classify_doc_comment_line("//! Module doc").unwrap();
302 assert_eq!(kind, DocCommentKind::Inner);
303 assert_eq!(ws, "");
304 assert_eq!(prefix, "//! ");
305 }
306
307 #[test]
308 fn test_classify_empty_outer() {
309 let (kind, ws, prefix) = classify_doc_comment_line("///").unwrap();
310 assert_eq!(kind, DocCommentKind::Outer);
311 assert_eq!(ws, "");
312 assert_eq!(prefix, "///");
313 }
314
315 #[test]
316 fn test_classify_empty_inner() {
317 let (kind, ws, prefix) = classify_doc_comment_line("//!").unwrap();
318 assert_eq!(kind, DocCommentKind::Inner);
319 assert_eq!(ws, "");
320 assert_eq!(prefix, "//!");
321 }
322
323 #[test]
324 fn test_classify_indented() {
325 let (kind, ws, prefix) = classify_doc_comment_line(" /// Indented").unwrap();
326 assert_eq!(kind, DocCommentKind::Outer);
327 assert_eq!(ws, " ");
328 assert_eq!(prefix, "/// ");
329 }
330
331 #[test]
332 fn test_classify_no_space_after_prefix() {
333 let (kind, ws, prefix) = classify_doc_comment_line("///content").unwrap();
335 assert_eq!(kind, DocCommentKind::Outer);
336 assert_eq!(ws, "");
337 assert_eq!(prefix, "///");
338 }
339
340 #[test]
341 fn test_classify_tab_after_prefix() {
342 let (kind, ws, prefix) = classify_doc_comment_line("///\tcontent").unwrap();
343 assert_eq!(kind, DocCommentKind::Outer);
344 assert_eq!(ws, "");
345 assert_eq!(prefix, "///\t");
346 }
347
348 #[test]
349 fn test_classify_inner_no_space() {
350 let (kind, _, prefix) = classify_doc_comment_line("//!content").unwrap();
351 assert_eq!(kind, DocCommentKind::Inner);
352 assert_eq!(prefix, "//!");
353 }
354
355 #[test]
356 fn test_classify_four_slashes_is_not_doc() {
357 assert!(classify_doc_comment_line("//// Not a doc comment").is_none());
358 }
359
360 #[test]
361 fn test_classify_regular_comment() {
362 assert!(classify_doc_comment_line("// Regular comment").is_none());
363 }
364
365 #[test]
366 fn test_classify_code_line() {
367 assert!(classify_doc_comment_line("let x = 3;").is_none());
368 }
369
370 #[test]
371 fn test_extract_no_space_content() {
372 let content = "///no space here\n";
373 let blocks = extract_doc_comment_blocks(content);
374 assert_eq!(blocks.len(), 1);
375 assert_eq!(blocks[0].markdown, "no space here");
376 }
377
378 #[test]
379 fn test_extract_strips_tab_separator() {
380 let blocks = extract_doc_comment_blocks("///\t# Heading\n");
381 assert_eq!(blocks.len(), 1);
382 assert_eq!(blocks[0].markdown, "# Heading");
383 assert_eq!(blocks[0].line_metadata[0].prefix, "///\t");
384 assert_eq!(blocks[0].prefix_byte_lengths[0], 4);
385 }
386
387 #[test]
388 fn test_extract_keeps_whitespace_after_the_separator() {
389 let blocks = extract_doc_comment_blocks("/// \tcode\n");
390 assert_eq!(blocks.len(), 1);
391 assert_eq!(blocks[0].markdown, "\tcode");
392 assert_eq!(blocks[0].prefix_byte_lengths[0], 4);
393 }
394
395 #[test]
396 fn test_extract_basic_outer_block() {
397 let content = "/// First line\n/// Second line\nfn foo() {}\n";
398 let blocks = extract_doc_comment_blocks(content);
399 assert_eq!(blocks.len(), 1);
400 assert_eq!(blocks[0].kind, DocCommentKind::Outer);
401 assert_eq!(blocks[0].start_line, 0);
402 assert_eq!(blocks[0].end_line, 1);
403 assert_eq!(blocks[0].markdown, "First line\nSecond line");
404 assert_eq!(blocks[0].line_metadata.len(), 2);
405 }
406
407 #[test]
408 fn test_extract_basic_inner_block() {
409 let content = "//! Module doc\n//! More info\n\nuse std::io;\n";
410 let blocks = extract_doc_comment_blocks(content);
411 assert_eq!(blocks.len(), 1);
412 assert_eq!(blocks[0].kind, DocCommentKind::Inner);
413 assert_eq!(blocks[0].markdown, "Module doc\nMore info");
414 }
415
416 #[test]
417 fn test_extract_multiple_blocks() {
418 let content = "/// Block 1\nfn foo() {}\n/// Block 2\nfn bar() {}\n";
419 let blocks = extract_doc_comment_blocks(content);
420 assert_eq!(blocks.len(), 2);
421 assert_eq!(blocks[0].markdown, "Block 1");
422 assert_eq!(blocks[0].start_line, 0);
423 assert_eq!(blocks[1].markdown, "Block 2");
424 assert_eq!(blocks[1].start_line, 2);
425 }
426
427 #[test]
428 fn test_extract_mixed_kinds_separate_blocks() {
429 let content = "//! Inner\n/// Outer\n";
430 let blocks = extract_doc_comment_blocks(content);
431 assert_eq!(blocks.len(), 2);
432 assert_eq!(blocks[0].kind, DocCommentKind::Inner);
433 assert_eq!(blocks[1].kind, DocCommentKind::Outer);
434 }
435
436 #[test]
437 fn test_extract_empty_doc_line() {
438 let content = "/// First\n///\n/// Third\n";
439 let blocks = extract_doc_comment_blocks(content);
440 assert_eq!(blocks.len(), 1);
441 assert_eq!(blocks[0].markdown, "First\n\nThird");
442 }
443
444 #[test]
445 fn test_extract_preserves_extra_space() {
446 let content = "/// Two spaces\n";
447 let blocks = extract_doc_comment_blocks(content);
448 assert_eq!(blocks.len(), 1);
449 assert_eq!(blocks[0].markdown, " Two spaces");
450 }
451
452 #[test]
453 fn test_extract_indented_doc_comments() {
454 let content = " /// Indented\n /// More\n";
455 let blocks = extract_doc_comment_blocks(content);
456 assert_eq!(blocks.len(), 1);
457 assert_eq!(blocks[0].markdown, "Indented\nMore");
458 assert_eq!(blocks[0].line_metadata[0].leading_whitespace, " ");
459 }
460
461 #[test]
462 fn test_no_doc_comments() {
463 let content = "fn main() {\n let x = 3;\n}\n";
464 let blocks = extract_doc_comment_blocks(content);
465 assert!(blocks.is_empty());
466 }
467
468 #[test]
469 fn test_byte_offsets() {
470 let content = "/// Hello\nfn foo() {}\n/// World\n";
471 let blocks = extract_doc_comment_blocks(content);
472 assert_eq!(blocks.len(), 2);
473 assert_eq!(blocks[0].byte_start, 0);
475 assert_eq!(blocks[0].byte_end, 10);
476 assert_eq!(blocks[1].byte_start, 22);
478 assert_eq!(blocks[1].byte_end, 32);
479 }
480
481 #[test]
482 fn test_byte_offsets_no_trailing_newline() {
483 let content = "/// Hello";
484 let blocks = extract_doc_comment_blocks(content);
485 assert_eq!(blocks.len(), 1);
486 assert_eq!(blocks[0].byte_start, 0);
487 assert_eq!(blocks[0].byte_end, content.len());
489 }
490
491 #[test]
492 fn test_prefix_byte_lengths() {
493 let content = " /// Indented\n/// Top-level\n";
494 let blocks = extract_doc_comment_blocks(content);
495 assert_eq!(blocks.len(), 1);
496 assert_eq!(blocks[0].prefix_byte_lengths[0], 8);
498 assert_eq!(blocks[0].prefix_byte_lengths[1], 4);
500 }
501}