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 let rule_warnings =
257 crate::retain_reportable_warnings(&ctx, Some(config), rule.name(), rule_warnings, None);
258 for warning in rule_warnings {
259 let file_line = warning.line + block.start_line;
264 let file_end_line = warning.end_line + block.start_line;
265
266 let block_line_idx = warning.line.saturating_sub(1);
268 let col_offset = block.prefix_byte_lengths.get(block_line_idx).copied().unwrap_or(0);
269 let file_column = warning.column + col_offset;
270
271 let block_end_line_idx = warning.end_line.saturating_sub(1);
272 let end_col_offset = block.prefix_byte_lengths.get(block_end_line_idx).copied().unwrap_or(0);
273 let file_end_column = warning.end_column + end_col_offset;
274
275 all_warnings.push(LintWarning {
276 line: file_line,
277 end_line: file_end_line,
278 column: file_column,
279 end_column: file_end_column,
280 fix: None,
281 ..warning
282 });
283 }
284 }
285 }
286 }
287
288 all_warnings
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn test_classify_outer_doc_comment() {
297 let (kind, ws, prefix) = classify_doc_comment_line("/// Hello").unwrap();
298 assert_eq!(kind, DocCommentKind::Outer);
299 assert_eq!(ws, "");
300 assert_eq!(prefix, "/// ");
301 }
302
303 #[test]
304 fn test_classify_inner_doc_comment() {
305 let (kind, ws, prefix) = classify_doc_comment_line("//! Module doc").unwrap();
306 assert_eq!(kind, DocCommentKind::Inner);
307 assert_eq!(ws, "");
308 assert_eq!(prefix, "//! ");
309 }
310
311 #[test]
312 fn test_classify_empty_outer() {
313 let (kind, ws, prefix) = classify_doc_comment_line("///").unwrap();
314 assert_eq!(kind, DocCommentKind::Outer);
315 assert_eq!(ws, "");
316 assert_eq!(prefix, "///");
317 }
318
319 #[test]
320 fn test_classify_empty_inner() {
321 let (kind, ws, prefix) = classify_doc_comment_line("//!").unwrap();
322 assert_eq!(kind, DocCommentKind::Inner);
323 assert_eq!(ws, "");
324 assert_eq!(prefix, "//!");
325 }
326
327 #[test]
328 fn test_classify_indented() {
329 let (kind, ws, prefix) = classify_doc_comment_line(" /// Indented").unwrap();
330 assert_eq!(kind, DocCommentKind::Outer);
331 assert_eq!(ws, " ");
332 assert_eq!(prefix, "/// ");
333 }
334
335 #[test]
336 fn test_classify_no_space_after_prefix() {
337 let (kind, ws, prefix) = classify_doc_comment_line("///content").unwrap();
339 assert_eq!(kind, DocCommentKind::Outer);
340 assert_eq!(ws, "");
341 assert_eq!(prefix, "///");
342 }
343
344 #[test]
345 fn test_classify_tab_after_prefix() {
346 let (kind, ws, prefix) = classify_doc_comment_line("///\tcontent").unwrap();
347 assert_eq!(kind, DocCommentKind::Outer);
348 assert_eq!(ws, "");
349 assert_eq!(prefix, "///\t");
350 }
351
352 #[test]
353 fn test_classify_inner_no_space() {
354 let (kind, _, prefix) = classify_doc_comment_line("//!content").unwrap();
355 assert_eq!(kind, DocCommentKind::Inner);
356 assert_eq!(prefix, "//!");
357 }
358
359 #[test]
360 fn test_classify_four_slashes_is_not_doc() {
361 assert!(classify_doc_comment_line("//// Not a doc comment").is_none());
362 }
363
364 #[test]
365 fn test_classify_regular_comment() {
366 assert!(classify_doc_comment_line("// Regular comment").is_none());
367 }
368
369 #[test]
370 fn test_classify_code_line() {
371 assert!(classify_doc_comment_line("let x = 3;").is_none());
372 }
373
374 #[test]
375 fn test_extract_no_space_content() {
376 let content = "///no space here\n";
377 let blocks = extract_doc_comment_blocks(content);
378 assert_eq!(blocks.len(), 1);
379 assert_eq!(blocks[0].markdown, "no space here");
380 }
381
382 #[test]
383 fn test_extract_strips_tab_separator() {
384 let blocks = extract_doc_comment_blocks("///\t# Heading\n");
385 assert_eq!(blocks.len(), 1);
386 assert_eq!(blocks[0].markdown, "# Heading");
387 assert_eq!(blocks[0].line_metadata[0].prefix, "///\t");
388 assert_eq!(blocks[0].prefix_byte_lengths[0], 4);
389 }
390
391 #[test]
392 fn test_extract_keeps_whitespace_after_the_separator() {
393 let blocks = extract_doc_comment_blocks("/// \tcode\n");
394 assert_eq!(blocks.len(), 1);
395 assert_eq!(blocks[0].markdown, "\tcode");
396 assert_eq!(blocks[0].prefix_byte_lengths[0], 4);
397 }
398
399 #[test]
400 fn test_extract_basic_outer_block() {
401 let content = "/// First line\n/// Second line\nfn foo() {}\n";
402 let blocks = extract_doc_comment_blocks(content);
403 assert_eq!(blocks.len(), 1);
404 assert_eq!(blocks[0].kind, DocCommentKind::Outer);
405 assert_eq!(blocks[0].start_line, 0);
406 assert_eq!(blocks[0].end_line, 1);
407 assert_eq!(blocks[0].markdown, "First line\nSecond line");
408 assert_eq!(blocks[0].line_metadata.len(), 2);
409 }
410
411 #[test]
412 fn test_extract_basic_inner_block() {
413 let content = "//! Module doc\n//! More info\n\nuse std::io;\n";
414 let blocks = extract_doc_comment_blocks(content);
415 assert_eq!(blocks.len(), 1);
416 assert_eq!(blocks[0].kind, DocCommentKind::Inner);
417 assert_eq!(blocks[0].markdown, "Module doc\nMore info");
418 }
419
420 #[test]
421 fn test_extract_multiple_blocks() {
422 let content = "/// Block 1\nfn foo() {}\n/// Block 2\nfn bar() {}\n";
423 let blocks = extract_doc_comment_blocks(content);
424 assert_eq!(blocks.len(), 2);
425 assert_eq!(blocks[0].markdown, "Block 1");
426 assert_eq!(blocks[0].start_line, 0);
427 assert_eq!(blocks[1].markdown, "Block 2");
428 assert_eq!(blocks[1].start_line, 2);
429 }
430
431 #[test]
432 fn test_extract_mixed_kinds_separate_blocks() {
433 let content = "//! Inner\n/// Outer\n";
434 let blocks = extract_doc_comment_blocks(content);
435 assert_eq!(blocks.len(), 2);
436 assert_eq!(blocks[0].kind, DocCommentKind::Inner);
437 assert_eq!(blocks[1].kind, DocCommentKind::Outer);
438 }
439
440 #[test]
441 fn test_extract_empty_doc_line() {
442 let content = "/// First\n///\n/// Third\n";
443 let blocks = extract_doc_comment_blocks(content);
444 assert_eq!(blocks.len(), 1);
445 assert_eq!(blocks[0].markdown, "First\n\nThird");
446 }
447
448 #[test]
449 fn test_extract_preserves_extra_space() {
450 let content = "/// Two spaces\n";
451 let blocks = extract_doc_comment_blocks(content);
452 assert_eq!(blocks.len(), 1);
453 assert_eq!(blocks[0].markdown, " Two spaces");
454 }
455
456 #[test]
457 fn test_extract_indented_doc_comments() {
458 let content = " /// Indented\n /// More\n";
459 let blocks = extract_doc_comment_blocks(content);
460 assert_eq!(blocks.len(), 1);
461 assert_eq!(blocks[0].markdown, "Indented\nMore");
462 assert_eq!(blocks[0].line_metadata[0].leading_whitespace, " ");
463 }
464
465 #[test]
466 fn test_no_doc_comments() {
467 let content = "fn main() {\n let x = 3;\n}\n";
468 let blocks = extract_doc_comment_blocks(content);
469 assert!(blocks.is_empty());
470 }
471
472 #[test]
473 fn test_byte_offsets() {
474 let content = "/// Hello\nfn foo() {}\n/// World\n";
475 let blocks = extract_doc_comment_blocks(content);
476 assert_eq!(blocks.len(), 2);
477 assert_eq!(blocks[0].byte_start, 0);
479 assert_eq!(blocks[0].byte_end, 10);
480 assert_eq!(blocks[1].byte_start, 22);
482 assert_eq!(blocks[1].byte_end, 32);
483 }
484
485 #[test]
486 fn test_byte_offsets_no_trailing_newline() {
487 let content = "/// Hello";
488 let blocks = extract_doc_comment_blocks(content);
489 assert_eq!(blocks.len(), 1);
490 assert_eq!(blocks[0].byte_start, 0);
491 assert_eq!(blocks[0].byte_end, content.len());
493 }
494
495 #[test]
496 fn test_prefix_byte_lengths() {
497 let content = " /// Indented\n/// Top-level\n";
498 let blocks = extract_doc_comment_blocks(content);
499 assert_eq!(blocks.len(), 1);
500 assert_eq!(blocks[0].prefix_byte_lengths[0], 8);
502 assert_eq!(blocks[0].prefix_byte_lengths[1], 4);
504 }
505}