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,
34}
35
36#[derive(Debug, Clone)]
38pub struct DocCommentBlock {
39 pub kind: DocCommentKind,
41 pub start_line: usize,
43 pub end_line: usize,
45 pub byte_start: usize,
47 pub byte_end: usize,
49 pub markdown: String,
51 pub line_metadata: Vec<DocCommentLineInfo>,
53 pub prefix_byte_lengths: Vec<usize>,
56}
57
58fn classify_doc_comment_line(line: &str) -> Option<(DocCommentKind, String, String)> {
72 let trimmed = line.trim_start();
73 let leading_ws = &line[..line.len() - trimmed.len()];
74
75 if trimmed.starts_with("////") {
77 return None;
78 }
79
80 if let Some(after) = trimmed.strip_prefix("///") {
81 let prefix = if after.starts_with(' ') || after.starts_with('\t') {
83 format!("///{}", &after[..1])
84 } else {
85 "///".to_string()
86 };
87 Some((DocCommentKind::Outer, leading_ws.to_string(), prefix))
88 } else if let Some(after) = trimmed.strip_prefix("//!") {
89 let prefix = if after.starts_with(' ') || after.starts_with('\t') {
90 format!("//!{}", &after[..1])
91 } else {
92 "//!".to_string()
93 };
94 Some((DocCommentKind::Inner, leading_ws.to_string(), prefix))
95 } else {
96 None
97 }
98}
99
100fn extract_markdown_from_line(trimmed: &str, kind: DocCommentKind) -> &str {
102 let prefix = match kind {
103 DocCommentKind::Outer => "///",
104 DocCommentKind::Inner => "//!",
105 };
106
107 let after_prefix = &trimmed[prefix.len()..];
108 if let Some(stripped) = after_prefix.strip_prefix(' ') {
110 stripped
111 } else {
112 after_prefix
113 }
114}
115
116pub fn extract_doc_comment_blocks(content: &str) -> Vec<DocCommentBlock> {
130 let mut blocks = Vec::new();
131 let mut current_block: Option<DocCommentBlock> = None;
132 let mut byte_offset = 0;
133
134 let lines: Vec<&str> = content.split('\n').collect();
135 let num_lines = lines.len();
136
137 for (line_idx, line) in lines.iter().enumerate() {
138 let line_byte_start = byte_offset;
139 let has_newline = line_idx < num_lines - 1 || content.ends_with('\n');
141 let line_byte_end = byte_offset + line.len() + usize::from(has_newline);
142
143 if let Some((kind, leading_ws, prefix)) = classify_doc_comment_line(line) {
144 let trimmed = line.trim_start();
145 let md_content = extract_markdown_from_line(trimmed, kind);
146
147 let prefix_byte_len = leading_ws.len() + prefix.len();
149
150 let line_info = DocCommentLineInfo {
151 leading_whitespace: leading_ws,
152 prefix,
153 };
154
155 match current_block.as_mut() {
156 Some(block) if block.kind == kind => {
157 block.end_line = line_idx;
159 block.byte_end = line_byte_end;
160 block.markdown.push('\n');
161 block.markdown.push_str(md_content);
162 block.line_metadata.push(line_info);
163 block.prefix_byte_lengths.push(prefix_byte_len);
164 }
165 _ => {
166 if let Some(block) = current_block.take() {
168 blocks.push(block);
169 }
170 current_block = Some(DocCommentBlock {
172 kind,
173 start_line: line_idx,
174 end_line: line_idx,
175 byte_start: line_byte_start,
176 byte_end: line_byte_end,
177 markdown: md_content.to_string(),
178 line_metadata: vec![line_info],
179 prefix_byte_lengths: vec![prefix_byte_len],
180 });
181 }
182 }
183 } else {
184 if let Some(block) = current_block.take() {
186 blocks.push(block);
187 }
188 }
189
190 byte_offset = line_byte_end;
191 }
192
193 if let Some(block) = current_block.take() {
195 blocks.push(block);
196 }
197
198 blocks
199}
200
201pub const SKIPPED_RULES: &[&str] = &["MD025", "MD033", "MD040", "MD041", "MD047", "MD051", "MD052", "MD054"];
212
213pub fn is_rust_source(path: &Path) -> bool {
222 path.extension().is_some_and(|ext| ext == "rs")
223}
224
225pub fn check_doc_comment_blocks(
233 content: &str,
234 rules: &[Box<dyn Rule>],
235 config: &rumdl_config::Config,
236) -> Vec<LintWarning> {
237 let blocks = extract_doc_comment_blocks(content);
238 let mut all_warnings = Vec::new();
239
240 for block in &blocks {
241 if block.markdown.trim().is_empty() {
243 continue;
244 }
245
246 let ctx = LintContext::new(&block.markdown, config.markdown_flavor(), None);
247
248 for rule in rules {
249 if SKIPPED_RULES.contains(&rule.name()) {
250 continue;
251 }
252
253 let doc_rule: Box<dyn Rule>;
257 let effective_rule: &dyn Rule = if rule.name() == "MD013" {
258 if let Some(md013) = rule.as_any().downcast_ref::<MD013LineLength>() {
259 doc_rule = Box::new(md013.with_code_blocks_disabled());
260 doc_rule.as_ref()
261 } else {
262 rule.as_ref()
263 }
264 } else {
265 rule.as_ref()
266 };
267
268 if let Ok(rule_warnings) = effective_rule.check(&ctx) {
269 for warning in rule_warnings {
270 let file_line = warning.line + block.start_line;
275 let file_end_line = warning.end_line + block.start_line;
276
277 let block_line_idx = warning.line.saturating_sub(1);
279 let col_offset = block.prefix_byte_lengths.get(block_line_idx).copied().unwrap_or(0);
280 let file_column = warning.column + col_offset;
281
282 let block_end_line_idx = warning.end_line.saturating_sub(1);
283 let end_col_offset = block.prefix_byte_lengths.get(block_end_line_idx).copied().unwrap_or(0);
284 let file_end_column = warning.end_column + end_col_offset;
285
286 all_warnings.push(LintWarning {
287 line: file_line,
288 end_line: file_end_line,
289 column: file_column,
290 end_column: file_end_column,
291 fix: None,
292 ..warning
293 });
294 }
295 }
296 }
297 }
298
299 all_warnings
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn test_classify_outer_doc_comment() {
308 let (kind, ws, prefix) = classify_doc_comment_line("/// Hello").unwrap();
309 assert_eq!(kind, DocCommentKind::Outer);
310 assert_eq!(ws, "");
311 assert_eq!(prefix, "/// ");
312 }
313
314 #[test]
315 fn test_classify_inner_doc_comment() {
316 let (kind, ws, prefix) = classify_doc_comment_line("//! Module doc").unwrap();
317 assert_eq!(kind, DocCommentKind::Inner);
318 assert_eq!(ws, "");
319 assert_eq!(prefix, "//! ");
320 }
321
322 #[test]
323 fn test_classify_empty_outer() {
324 let (kind, ws, prefix) = classify_doc_comment_line("///").unwrap();
325 assert_eq!(kind, DocCommentKind::Outer);
326 assert_eq!(ws, "");
327 assert_eq!(prefix, "///");
328 }
329
330 #[test]
331 fn test_classify_empty_inner() {
332 let (kind, ws, prefix) = classify_doc_comment_line("//!").unwrap();
333 assert_eq!(kind, DocCommentKind::Inner);
334 assert_eq!(ws, "");
335 assert_eq!(prefix, "//!");
336 }
337
338 #[test]
339 fn test_classify_indented() {
340 let (kind, ws, prefix) = classify_doc_comment_line(" /// Indented").unwrap();
341 assert_eq!(kind, DocCommentKind::Outer);
342 assert_eq!(ws, " ");
343 assert_eq!(prefix, "/// ");
344 }
345
346 #[test]
347 fn test_classify_no_space_after_prefix() {
348 let (kind, ws, prefix) = classify_doc_comment_line("///content").unwrap();
350 assert_eq!(kind, DocCommentKind::Outer);
351 assert_eq!(ws, "");
352 assert_eq!(prefix, "///");
353 }
354
355 #[test]
356 fn test_classify_tab_after_prefix() {
357 let (kind, ws, prefix) = classify_doc_comment_line("///\tcontent").unwrap();
358 assert_eq!(kind, DocCommentKind::Outer);
359 assert_eq!(ws, "");
360 assert_eq!(prefix, "///\t");
361 }
362
363 #[test]
364 fn test_classify_inner_no_space() {
365 let (kind, _, prefix) = classify_doc_comment_line("//!content").unwrap();
366 assert_eq!(kind, DocCommentKind::Inner);
367 assert_eq!(prefix, "//!");
368 }
369
370 #[test]
371 fn test_classify_four_slashes_is_not_doc() {
372 assert!(classify_doc_comment_line("//// Not a doc comment").is_none());
373 }
374
375 #[test]
376 fn test_classify_regular_comment() {
377 assert!(classify_doc_comment_line("// Regular comment").is_none());
378 }
379
380 #[test]
381 fn test_classify_code_line() {
382 assert!(classify_doc_comment_line("let x = 3;").is_none());
383 }
384
385 #[test]
386 fn test_extract_no_space_content() {
387 let content = "///no space here\n";
388 let blocks = extract_doc_comment_blocks(content);
389 assert_eq!(blocks.len(), 1);
390 assert_eq!(blocks[0].markdown, "no space here");
391 }
392
393 #[test]
394 fn test_extract_basic_outer_block() {
395 let content = "/// First line\n/// Second line\nfn foo() {}\n";
396 let blocks = extract_doc_comment_blocks(content);
397 assert_eq!(blocks.len(), 1);
398 assert_eq!(blocks[0].kind, DocCommentKind::Outer);
399 assert_eq!(blocks[0].start_line, 0);
400 assert_eq!(blocks[0].end_line, 1);
401 assert_eq!(blocks[0].markdown, "First line\nSecond line");
402 assert_eq!(blocks[0].line_metadata.len(), 2);
403 }
404
405 #[test]
406 fn test_extract_basic_inner_block() {
407 let content = "//! Module doc\n//! More info\n\nuse std::io;\n";
408 let blocks = extract_doc_comment_blocks(content);
409 assert_eq!(blocks.len(), 1);
410 assert_eq!(blocks[0].kind, DocCommentKind::Inner);
411 assert_eq!(blocks[0].markdown, "Module doc\nMore info");
412 }
413
414 #[test]
415 fn test_extract_multiple_blocks() {
416 let content = "/// Block 1\nfn foo() {}\n/// Block 2\nfn bar() {}\n";
417 let blocks = extract_doc_comment_blocks(content);
418 assert_eq!(blocks.len(), 2);
419 assert_eq!(blocks[0].markdown, "Block 1");
420 assert_eq!(blocks[0].start_line, 0);
421 assert_eq!(blocks[1].markdown, "Block 2");
422 assert_eq!(blocks[1].start_line, 2);
423 }
424
425 #[test]
426 fn test_extract_mixed_kinds_separate_blocks() {
427 let content = "//! Inner\n/// Outer\n";
428 let blocks = extract_doc_comment_blocks(content);
429 assert_eq!(blocks.len(), 2);
430 assert_eq!(blocks[0].kind, DocCommentKind::Inner);
431 assert_eq!(blocks[1].kind, DocCommentKind::Outer);
432 }
433
434 #[test]
435 fn test_extract_empty_doc_line() {
436 let content = "/// First\n///\n/// Third\n";
437 let blocks = extract_doc_comment_blocks(content);
438 assert_eq!(blocks.len(), 1);
439 assert_eq!(blocks[0].markdown, "First\n\nThird");
440 }
441
442 #[test]
443 fn test_extract_preserves_extra_space() {
444 let content = "/// Two spaces\n";
445 let blocks = extract_doc_comment_blocks(content);
446 assert_eq!(blocks.len(), 1);
447 assert_eq!(blocks[0].markdown, " Two spaces");
448 }
449
450 #[test]
451 fn test_extract_indented_doc_comments() {
452 let content = " /// Indented\n /// More\n";
453 let blocks = extract_doc_comment_blocks(content);
454 assert_eq!(blocks.len(), 1);
455 assert_eq!(blocks[0].markdown, "Indented\nMore");
456 assert_eq!(blocks[0].line_metadata[0].leading_whitespace, " ");
457 }
458
459 #[test]
460 fn test_no_doc_comments() {
461 let content = "fn main() {\n let x = 3;\n}\n";
462 let blocks = extract_doc_comment_blocks(content);
463 assert!(blocks.is_empty());
464 }
465
466 #[test]
467 fn test_byte_offsets() {
468 let content = "/// Hello\nfn foo() {}\n/// World\n";
469 let blocks = extract_doc_comment_blocks(content);
470 assert_eq!(blocks.len(), 2);
471 assert_eq!(blocks[0].byte_start, 0);
473 assert_eq!(blocks[0].byte_end, 10);
474 assert_eq!(blocks[1].byte_start, 22);
476 assert_eq!(blocks[1].byte_end, 32);
477 }
478
479 #[test]
480 fn test_byte_offsets_no_trailing_newline() {
481 let content = "/// Hello";
482 let blocks = extract_doc_comment_blocks(content);
483 assert_eq!(blocks.len(), 1);
484 assert_eq!(blocks[0].byte_start, 0);
485 assert_eq!(blocks[0].byte_end, content.len());
487 }
488
489 #[test]
490 fn test_prefix_byte_lengths() {
491 let content = " /// Indented\n/// Top-level\n";
492 let blocks = extract_doc_comment_blocks(content);
493 assert_eq!(blocks.len(), 1);
494 assert_eq!(blocks[0].prefix_byte_lengths[0], 8);
496 assert_eq!(blocks[0].prefix_byte_lengths[1], 4);
498 }
499}