1use crate::rule::{
5 AstExtensions, Fix, LintError, LintResult, LintWarning, MarkdownAst, MaybeAst, Rule, RuleCategory, Severity,
6};
7use crate::utils::range_utils::calculate_url_range;
8use crate::utils::regex_cache::EMAIL_PATTERN;
9
10use crate::lint_context::LintContext;
11use fancy_regex::Regex as FancyRegex;
12use lazy_static::lazy_static;
13use markdown::mdast::Node;
14use regex::Regex;
15
16lazy_static! {
17 static ref URL_QUICK_CHECK: Regex = Regex::new(r#"(?:https?|ftps?)://|@"#).unwrap();
19
20 static ref URL_REGEX: FancyRegex = FancyRegex::new(r#"(?<![\w\[\(\<])((?:https?|ftps?)://(?:\[[0-9a-fA-F:%]+\]|[^\s<>\[\]()\\'\"]+)(?::\d+)?(?:/[^\s<>\[\]()\\'\"]*)?(?:\?[^\s<>\[\]()\\'\"]*)?(?:#[^\s<>\[\]()\\'\"]*)?)"#).unwrap();
23 static ref URL_FIX_REGEX: FancyRegex = FancyRegex::new(r#"(?<![\w\[\(\<])((?:https?|ftps?)://(?:\[[0-9a-fA-F:%]+\]|[^\s<>\[\]()\\'\"]+)(?::\d+)?(?:/[^\s<>\[\]()\\'\"]*)?(?:\?[^\s<>\[\]()\\'\"]*)?(?:#[^\s<>\[\]()\\'\"]*)?)"#).unwrap();
24
25 static ref CUSTOM_PROTOCOL_PATTERN: Regex = Regex::new(r#"(?:grpc|ws|wss|ssh|git|svn|file|data|javascript|vscode|chrome|about|slack|discord|matrix|irc|redis|mongodb|postgresql|mysql|kafka|nats|amqp|mqtt|custom|app|api|service)://"#).unwrap();
28
29 static ref MARKDOWN_LINK_PATTERN: Regex = Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\(([^)\s]+)(?:\s+(?:\"[^\"]*\"|\'[^\']*\'))?\)"#).unwrap();
32
33 static ref ANGLE_LINK_PATTERN: Regex = Regex::new(r#"<((?:https?|ftps?)://(?:\[[0-9a-fA-F:]+(?:%[a-zA-Z0-9]+)?\]|[^>]+)|[^@\s]+@[^@\s]+\.[^@\s>]+)>"#).unwrap();
36
37 static ref BADGE_LINK_LINE: Regex = Regex::new(r#"^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*$"#).unwrap();
39
40 static ref IMAGE_ONLY_LINK_TEXT_PATTERN: Regex = Regex::new(r#"^!\s*\[[^\]]*\]\s*\([^)]*\)$"#).unwrap();
42
43 static ref MARKDOWN_IMAGE_PATTERN: Regex = Regex::new(r#"!\s*\[([^\]]*)\]\s*\(([^)\s]+)(?:\s+(?:\"[^\"]*\"|\'[^\']*\'))?\)"#).unwrap();
45
46 static ref SIMPLE_URL_REGEX: Regex = Regex::new(r#"(https?|ftps?)://(?:\[[0-9a-fA-F:%.]+\](?::\d+)?|[^\s<>\[\]()\\'\"`:\]]+(?::\d+)?)(?:/[^\s<>\[\]()\\'\"`]*)?(?:\?[^\s<>\[\]()\\'\"`]*)?(?:#[^\s<>\[\]()\\'\"`]*)?"#).unwrap();
54
55 static ref IPV6_URL_REGEX: Regex = Regex::new(r#"(https?|ftps?)://\[[0-9a-fA-F:%.\-a-zA-Z]+\](?::\d+)?(?:/[^\s<>\[\]()\\'\"`]*)?(?:\?[^\s<>\[\]()\\'\"`]*)?(?:#[^\s<>\[\]()\\'\"`]*)?"#).unwrap();
58
59 static ref REFERENCE_DEF_RE: Regex = Regex::new(r"^\s*\[[^\]]+\]:\s*(?:https?|ftps?)://\S+$").unwrap();
62
63 static ref HTML_COMMENT_PATTERN: Regex = Regex::new(r#"<!--[\s\S]*?-->"#).unwrap();
65}
66
67#[derive(Default, Clone)]
68pub struct MD034NoBareUrls;
69
70impl MD034NoBareUrls {
71 #[inline]
72 pub fn should_skip(&self, content: &str) -> bool {
73 let bytes = content.as_bytes();
76 !bytes.contains(&b':') && !bytes.contains(&b'@')
77 }
78
79 fn trim_trailing_punctuation<'a>(&self, url: &'a str) -> &'a str {
81 let trailing_punct = ['.', ',', ';', ':', '!', '?'];
82 let mut end = url.len();
83
84 while end > 0 {
86 let current_url = &url[..end];
88 if let Some((last_char_pos, last_char)) = current_url.char_indices().next_back() {
89 if trailing_punct.contains(&last_char) {
90 end = last_char_pos;
91 } else {
92 break;
93 }
94 } else {
95 break;
96 }
97 }
98
99 &url[..end]
100 }
101
102 pub fn check_with_structure(
104 &self,
105 ctx: &crate::lint_context::LintContext,
106 _structure: &crate::utils::document_structure::DocumentStructure,
107 ) -> LintResult {
108 let content = ctx.content;
109
110 if self.should_skip(content) {
112 return Ok(vec![]);
113 }
114
115 let mut warnings = Vec::new();
117
118 let mut excluded_ranges: Vec<(usize, usize)> = Vec::new();
120
121 for cap in MARKDOWN_LINK_PATTERN.captures_iter(content) {
123 if let Some(dest) = cap.get(1) {
124 excluded_ranges.push((dest.start(), dest.end()));
125 }
126 if let Some(full_match) = cap.get(0) {
128 excluded_ranges.push((full_match.start(), full_match.end()));
129 }
130 }
131
132 for cap in MARKDOWN_IMAGE_PATTERN.captures_iter(content) {
134 if let Some(dest) = cap.get(2) {
135 excluded_ranges.push((dest.start(), dest.end()));
136 }
137 }
138
139 for cap in ANGLE_LINK_PATTERN.captures_iter(content) {
141 if let Some(m) = cap.get(1) {
142 excluded_ranges.push((m.start(), m.end()));
143 }
144 }
145
146 for html_tag in ctx.html_tags().iter() {
148 excluded_ranges.push((html_tag.byte_offset, html_tag.byte_end));
149 }
150
151 for cap in HTML_COMMENT_PATTERN.captures_iter(content) {
153 if let Some(comment) = cap.get(0) {
154 excluded_ranges.push((comment.start(), comment.end()));
155 }
156 }
157
158 excluded_ranges.sort_by_key(|r| r.0);
160 let mut merged: Vec<(usize, usize)> = Vec::new();
161 for (start, end) in excluded_ranges {
162 if let Some((_, last_end)) = merged.last_mut()
163 && *last_end >= start
164 {
165 *last_end = (*last_end).max(end);
166 continue;
167 }
168 merged.push((start, end));
169 }
170
171 let mut all_matches: Vec<(usize, usize, bool)> = Vec::new(); if !content.contains("://") && !content.contains('@') {
177 return Ok(warnings);
178 }
179
180 let mut candidate_lines = Vec::new();
182 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
183 if line_info.in_code_block {
185 continue;
186 }
187
188 let line_content = &line_info.content;
189 let bytes = line_content.as_bytes();
190
191 let has_url = bytes.contains(&b':') && line_content.contains("://");
193 let has_email = bytes.contains(&b'@');
194
195 if has_url || has_email {
196 candidate_lines.push(line_idx);
197 }
198 }
199
200 for &line_idx in &candidate_lines {
202 let line_info = &ctx.lines[line_idx];
203 let line_content = &line_info.content;
204
205 for url_match in SIMPLE_URL_REGEX.find_iter(line_content) {
207 let start_in_line = url_match.start();
208 let end_in_line = url_match.end();
209 let matched_str = &line_content[start_in_line..end_in_line];
210
211 if matched_str.contains("::") && !matched_str.contains('[') && matched_str.contains(']') {
213 continue;
214 }
215
216 if start_in_line > 0 {
219 let prefix_start = start_in_line.saturating_sub(20); let prefix_start = if prefix_start == 0 {
224 0
225 } else {
226 let mut adjusted_start = prefix_start;
228 while adjusted_start < start_in_line && !line_content.is_char_boundary(adjusted_start) {
229 adjusted_start += 1;
230 }
231 adjusted_start
232 };
233
234 let prefix = &line_content[prefix_start..start_in_line];
235 if CUSTOM_PROTOCOL_PATTERN.is_match(prefix) {
236 continue;
237 }
238 }
239
240 let global_start = line_info.byte_offset + start_in_line;
241 let global_end = line_info.byte_offset + end_in_line;
242 all_matches.push((global_start, global_end, false));
243 }
244
245 for url_match in IPV6_URL_REGEX.find_iter(line_content) {
247 let global_start = line_info.byte_offset + url_match.start();
248 let global_end = line_info.byte_offset + url_match.end();
249
250 all_matches.retain(|(start, end, _)| !(*start < global_end && *end > global_start));
252
253 all_matches.push((global_start, global_end, false));
254 }
255
256 for email_match in EMAIL_PATTERN.find_iter(line_content) {
258 let global_start = line_info.byte_offset + email_match.start();
259 let global_end = line_info.byte_offset + email_match.end();
260 all_matches.push((global_start, global_end, true));
261 }
262 }
263
264 for (match_start, match_end_orig, is_email) in all_matches {
266 let mut match_end = match_end_orig;
267
268 if !is_email {
270 let raw_url = &content[match_start..match_end];
271 let trimmed_url = self.trim_trailing_punctuation(raw_url);
272 match_end = match_start + trimmed_url.len();
273 }
274
275 if match_end <= match_start {
277 continue;
278 }
279
280 let bytes = content.as_bytes();
283 let before_byte = if match_start == 0 {
284 None
285 } else {
286 bytes.get(match_start - 1).copied()
287 };
288 let after_byte = bytes.get(match_end).copied();
289
290 let is_valid_boundary = if is_email {
291 before_byte.is_none_or(|b| !b.is_ascii_alphanumeric() && b != b'_' && b != b'.')
292 && after_byte.is_none_or(|b| !b.is_ascii_alphanumeric() && b != b'_' && b != b'.')
293 } else {
294 before_byte.is_none_or(|b| !b.is_ascii_alphanumeric() && b != b'_')
295 && after_byte.is_none_or(|b| !b.is_ascii_alphanumeric() && b != b'_')
296 };
297
298 if !is_valid_boundary {
299 continue;
300 }
301
302 if crate::utils::skip_context::is_in_skip_context(ctx, match_start) {
304 continue;
305 }
306
307 let in_any_range = merged.iter().any(|(start, end)| {
309 (match_start >= *start && match_start < *end)
311 || (match_end > *start && match_end <= *end)
312 || (match_start < *start && match_end > *end)
313 });
314 if in_any_range {
315 continue;
316 }
317
318 let (line_num, col_num) = ctx.offset_to_line_col(match_start);
320
321 if !is_email
323 && let Some(line_info) = ctx.line_info(line_num)
324 && REFERENCE_DEF_RE.is_match(&line_info.content)
325 {
326 continue;
327 }
328
329 let matched_text = &content[match_start..match_end];
330 let line_info = ctx.line_info(line_num).unwrap();
331 let (start_line, start_col, end_line, end_col) =
332 calculate_url_range(line_num, &line_info.content, col_num - 1, matched_text.len());
333
334 let message = if is_email {
335 "Email address without angle brackets or link formatting".to_string()
336 } else {
337 "URL without angle brackets or link formatting".to_string()
338 };
339
340 warnings.push(LintWarning {
341 rule_name: Some(self.name()),
342 line: start_line,
343 column: start_col,
344 end_line,
345 end_column: end_col,
346 message,
347 severity: Severity::Warning,
348 fix: Some(Fix {
349 range: match_start..match_end,
350 replacement: format!("<{matched_text}>"),
351 }),
352 });
353 }
354
355 Ok(warnings)
356 }
357
358 fn find_bare_urls_in_ast(
360 &self,
361 node: &Node,
362 parent_is_link_or_image: bool,
363 _content: &str,
364 warnings: &mut Vec<LintWarning>,
365 ctx: &LintContext,
366 ) {
367 use markdown::mdast::Node::*;
368 match node {
369 Text(text) if !parent_is_link_or_image => {
370 let text_str = &text.value;
371
372 for url_match in SIMPLE_URL_REGEX.find_iter(text_str) {
374 let url_start = url_match.start();
375 let mut url_end = url_match.end();
376
377 let raw_url = &text_str[url_start..url_end];
379 let trimmed_url = self.trim_trailing_punctuation(raw_url);
380 url_end = url_start + trimmed_url.len();
381
382 if url_end <= url_start {
384 continue;
385 }
386
387 let before = if url_start == 0 {
388 None
389 } else {
390 text_str.get(url_start - 1..url_start)
391 };
392 let after = text_str.get(url_end..url_end + 1);
393 let is_valid_boundary = before
394 .is_none_or(|c| !c.chars().next().unwrap().is_alphanumeric() && c != "_")
395 && after.is_none_or(|c| !c.chars().next().unwrap().is_alphanumeric() && c != "_");
396 if !is_valid_boundary {
397 continue;
398 }
399 if let Some(pos) = &text.position {
400 let offset = pos.start.offset + url_start;
401 let (line, column) = ctx.offset_to_line_col(offset);
402 let url_text = &text_str[url_start..url_end];
403 let (start_line, start_col, end_line, end_col) =
404 (line, column, line, column + url_text.chars().count());
405 warnings.push(LintWarning {
406 rule_name: Some(self.name()),
407 line: start_line,
408 column: start_col,
409 end_line,
410 end_column: end_col,
411 message: "URL without angle brackets or link formatting".to_string(),
412 severity: Severity::Warning,
413 fix: Some(Fix {
414 range: offset..(offset + url_text.len()),
415 replacement: format!("<{url_text}>"),
416 }),
417 });
418 }
419 }
420
421 for email_match in EMAIL_PATTERN.find_iter(text_str) {
423 let email_start = email_match.start();
424 let email_end = email_match.end();
425 let before = if email_start == 0 {
426 None
427 } else {
428 text_str.get(email_start - 1..email_start)
429 };
430 let after = text_str.get(email_end..email_end + 1);
431 let is_valid_boundary = before
432 .is_none_or(|c| !c.chars().next().unwrap().is_alphanumeric() && c != "_" && c != ".")
433 && after.is_none_or(|c| !c.chars().next().unwrap().is_alphanumeric() && c != "_" && c != ".");
434 if !is_valid_boundary {
435 continue;
436 }
437 if let Some(pos) = &text.position {
438 let offset = pos.start.offset + email_start;
439 let (line, column) = ctx.offset_to_line_col(offset);
440 let email_text = &text_str[email_start..email_end];
441 let (start_line, start_col, end_line, end_col) =
442 (line, column, line, column + email_text.chars().count());
443 warnings.push(LintWarning {
444 rule_name: Some(self.name()),
445 line: start_line,
446 column: start_col,
447 end_line,
448 end_column: end_col,
449 message: "Email address without angle brackets or link formatting (wrap like: <email>)"
450 .to_string(),
451 severity: Severity::Warning,
452 fix: Some(Fix {
453 range: offset..(offset + email_text.len()),
454 replacement: format!("<{email_text}>"),
455 }),
456 });
457 }
458 }
459 }
460 Link(link) => {
461 for child in &link.children {
462 self.find_bare_urls_in_ast(child, true, _content, warnings, ctx);
463 }
464 }
465 Image(image) => {
466 let alt_str = &image.alt;
468 for url_match in SIMPLE_URL_REGEX.find_iter(alt_str) {
469 let url_start = url_match.start();
470 let mut url_end = url_match.end();
471
472 let raw_url = &alt_str[url_start..url_end];
474 let trimmed_url = self.trim_trailing_punctuation(raw_url);
475 url_end = url_start + trimmed_url.len();
476
477 if url_end <= url_start {
479 continue;
480 }
481
482 let before = if url_start == 0 {
483 None
484 } else {
485 alt_str.get(url_start - 1..url_start)
486 };
487 let after = alt_str.get(url_end..url_end + 1);
488 let is_valid_boundary = before
489 .is_none_or(|c| !c.chars().next().unwrap().is_alphanumeric() && c != "_")
490 && after.is_none_or(|c| !c.chars().next().unwrap().is_alphanumeric() && c != "_");
491 if !is_valid_boundary {
492 continue;
493 }
494 if let Some(pos) = &image.position {
495 let offset = pos.start.offset + url_start;
496 let (line, column) = ctx.offset_to_line_col(offset);
497 let url_text = &alt_str[url_start..url_end];
498 let (start_line, start_col, end_line, end_col) =
499 (line, column, line, column + url_text.chars().count());
500 warnings.push(LintWarning {
501 rule_name: Some(self.name()),
502 line: start_line,
503 column: start_col,
504 end_line,
505 end_column: end_col,
506 message: "URL without angle brackets or link formatting".to_string(),
507 severity: Severity::Warning,
508 fix: Some(Fix {
509 range: offset..(offset + url_text.len()),
510 replacement: format!("<{url_text}>"),
511 }),
512 });
513 }
514 }
515 }
516 Code(_) | InlineCode(_) | Html(_) => {
517 }
519 _ => {
520 if let Some(children) = node.children() {
521 for child in children {
522 self.find_bare_urls_in_ast(child, false, _content, warnings, ctx);
523 }
524 }
525 }
526 }
527 }
528
529 pub fn check_ast(&self, ctx: &LintContext, ast: &Node) -> LintResult {
531 let mut warnings = Vec::new();
532 self.find_bare_urls_in_ast(ast, false, ctx.content, &mut warnings, ctx);
533 Ok(warnings)
534 }
535}
536
537impl Rule for MD034NoBareUrls {
538 fn name(&self) -> &'static str {
539 "MD034"
540 }
541
542 fn description(&self) -> &'static str {
543 "URL without angle brackets or link formatting"
544 }
545
546 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
547 let content = ctx.content;
550
551 if content.is_empty() || self.should_skip(content) {
553 return Ok(Vec::new());
554 }
555
556 let structure = crate::utils::document_structure::DocumentStructure::new(content);
558 self.check_with_structure(ctx, &structure)
559 }
560
561 fn check_with_ast(&self, ctx: &LintContext, ast: &MarkdownAst) -> LintResult {
562 let mut warnings = Vec::new();
564 self.find_bare_urls_in_ast(ast, false, ctx.content, &mut warnings, ctx);
565 Ok(warnings)
566 }
567
568 fn uses_ast(&self) -> bool {
569 false
572 }
573
574 fn uses_document_structure(&self) -> bool {
575 true
576 }
577
578 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
579 let content = ctx.content;
580 if self.should_skip(content) {
581 return Ok(content.to_string());
582 }
583
584 let structure = crate::utils::document_structure::DocumentStructure::new(content);
587 let warnings = self.check_with_structure(ctx, &structure)?;
588 if warnings.is_empty() {
589 return Ok(content.to_string());
590 }
591
592 let mut sorted_warnings = warnings.clone();
594 sorted_warnings.sort_by_key(|w| std::cmp::Reverse(w.fix.as_ref().map(|f| f.range.start).unwrap_or(0)));
595
596 let mut result = content.to_string();
597 for warning in sorted_warnings {
598 if let Some(fix) = &warning.fix {
599 let start = fix.range.start;
600 let end = fix.range.end;
601
602 if start <= result.len() && end <= result.len() && start < end {
603 result.replace_range(start..end, &fix.replacement);
604 }
605 }
606 }
607
608 Ok(result)
609 }
610
611 fn category(&self) -> RuleCategory {
613 RuleCategory::Link
614 }
615
616 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
618 self.should_skip(ctx.content)
619 }
620
621 fn as_any(&self) -> &dyn std::any::Any {
622 self
623 }
624
625 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
626 Some(self)
627 }
628
629 fn as_maybe_ast(&self) -> Option<&dyn MaybeAst> {
630 Some(self)
631 }
632
633 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
634 where
635 Self: Sized,
636 {
637 Box::new(MD034NoBareUrls)
638 }
639}
640
641impl crate::utils::document_structure::DocumentStructureExtensions for MD034NoBareUrls {
642 fn has_relevant_elements(
643 &self,
644 ctx: &crate::lint_context::LintContext,
645 _doc_structure: &crate::utils::document_structure::DocumentStructure,
646 ) -> bool {
647 !self.should_skip(ctx.content)
649 }
650}
651
652impl AstExtensions for MD034NoBareUrls {
653 fn has_relevant_ast_elements(&self, ctx: &LintContext, ast: &MarkdownAst) -> bool {
654 use crate::utils::ast_utils::ast_contains_node_type;
656 !self.should_skip(ctx.content) && ast_contains_node_type(ast, "text")
657 }
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663 use crate::lint_context::LintContext;
664
665 #[test]
666 fn test_url_quick_check() {
667 assert!(URL_QUICK_CHECK.is_match("This is a URL: https://example.com"));
668 assert!(!URL_QUICK_CHECK.is_match("This has no URL"));
669 }
670
671 #[test]
672 fn test_multiple_badges_and_links_on_one_line() {
673 let rule = MD034NoBareUrls;
674 let content = "# [React](https://react.dev/) \
675· [](https://github.com/facebook/react/blob/main/LICENSE) \
676[](https://www.npmjs.com/package/react) \
677[](https://github.com/facebook/react/actions/workflows/runtime_build_and_test.yml) \
678[](https://github.com/facebook/react/actions/workflows/compiler_typescript.yml) \
679[](https://legacy.reactjs.org/docs/how-to-contribute.html#your-first-pull-request)";
680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
681 let result = rule.check(&ctx).unwrap();
682 if !result.is_empty() {
683 log::debug!("MD034 warnings: {result:#?}");
684 }
685 assert!(
686 result.is_empty(),
687 "Multiple badges and links on one line should not be flagged as bare URLs"
688 );
689 }
690
691 #[test]
692 fn test_bare_urls() {
693 let rule = MD034NoBareUrls;
694 let content = "This is a bare URL: https://example.com/foobar";
695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
696 let result = rule.check(&ctx).unwrap();
697 assert_eq!(result.len(), 1, "Bare URLs should be flagged");
698 assert_eq!(result[0].line, 1);
699 assert_eq!(result[0].column, 21);
700 }
701
702 #[test]
703 fn test_md034_performance_baseline() {
704 use std::time::Instant;
705
706 let mut content = String::with_capacity(50_000);
708
709 for i in 0..250 {
711 content.push_str(&format!("Line {i} with bare URL https://example{i}.com/path\n"));
712 }
713
714 for i in 0..250 {
716 content.push_str(&format!(
717 "Line {} with [proper link](https://example{}.com/path)\n",
718 i + 250,
719 i
720 ));
721 }
722
723 for i in 0..500 {
725 content.push_str(&format!("Line {} with no URLs, just regular text content\n", i + 500));
726 }
727
728 for i in 0..100 {
730 content.push_str(&format!("Contact user{i}@example{i}.com for more info\n"));
731 }
732
733 println!(
734 "MD034 Performance Test - Content: {} bytes, {} lines",
735 content.len(),
736 content.lines().count()
737 );
738
739 let rule = MD034NoBareUrls;
740 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
741
742 let _ = rule.check(&ctx).unwrap();
744
745 let mut total_duration = std::time::Duration::ZERO;
747 let runs = 10;
748 let mut warnings_count = 0;
749
750 for _ in 0..runs {
751 let start = Instant::now();
752 let warnings = rule.check(&ctx).unwrap();
753 total_duration += start.elapsed();
754 warnings_count = warnings.len();
755 }
756
757 let avg_check_duration = total_duration / runs;
758
759 println!("MD034 Optimized Performance:");
760 println!(
761 "- Average check time: {:?} ({:.2} ms)",
762 avg_check_duration,
763 avg_check_duration.as_secs_f64() * 1000.0
764 );
765 println!("- Found {warnings_count} warnings");
766 println!(
767 "- Lines per second: {:.0}",
768 content.lines().count() as f64 / avg_check_duration.as_secs_f64()
769 );
770 println!(
771 "- Microseconds per line: {:.2}",
772 avg_check_duration.as_micros() as f64 / content.lines().count() as f64
773 );
774
775 let max_duration_ms = if cfg!(debug_assertions) { 1000 } else { 100 };
778 assert!(
779 avg_check_duration.as_millis() < max_duration_ms,
780 "MD034 check should complete in under {}ms, took {}ms",
781 max_duration_ms,
782 avg_check_duration.as_millis()
783 );
784
785 assert_eq!(warnings_count, 350, "Should find 250 URLs + 100 emails = 350 warnings");
787 }
788}