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