mdwright_lint/stdlib/
bare_url.rs1use std::ops::Range;
11use std::sync::OnceLock;
12
13use regex::Regex;
14
15use crate::diagnostic::{Diagnostic, Fix};
16use crate::regex_util::compile_static;
17use crate::rule::LintRule;
18use mdwright_document::{AutolinkOrigin, Document};
19
20pub struct BareUrl;
21
22fn pattern() -> &'static Regex {
23 static RE: OnceLock<Regex> = OnceLock::new();
24 RE.get_or_init(|| compile_static(r#"https?://[^\s<>()\[\]`'"]+"#))
25}
26
27impl LintRule for BareUrl {
28 fn name(&self) -> &str {
29 "bare-url"
30 }
31
32 fn description(&self) -> &str {
33 "Bare URL in prose; wrap in `<…>` for a CommonMark autolink."
34 }
35
36 fn explain(&self) -> &str {
37 include_str!("explain/bare_url.md")
38 }
39
40 fn produces_fix(&self) -> bool {
41 true
42 }
43
44 fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
45 let excluded = link_like_ranges(doc);
46 for autolink in doc.autolinks() {
47 if autolink.origin() == AutolinkOrigin::GfmUrl && should_flag_bare_url(autolink.text()) {
48 push_diagnostic(doc, autolink.raw_range(), autolink.text(), out);
49 }
50 }
51 for chunk in doc.prose_chunks() {
52 for m in pattern().find_iter(&chunk.text) {
53 let mut end = m.end();
54 while end > m.start() {
55 let last = chunk.text.as_bytes().get(end.saturating_sub(1)).copied();
56 if matches!(last, Some(b'.' | b',' | b';' | b':' | b'!' | b'?')) {
57 end = end.saturating_sub(1);
58 } else {
59 break;
60 }
61 }
62 let url = chunk.text.get(m.start()..end).unwrap_or("");
63 if url.is_empty() {
64 continue;
65 }
66 let raw_range = chunk.byte_offset.saturating_add(m.start())..chunk.byte_offset.saturating_add(end);
67 if !ranges_overlap_any(&raw_range, &excluded) {
68 push_diagnostic(doc, raw_range, url, out);
69 }
70 }
71 }
72 }
73}
74
75fn should_flag_bare_url(text: &str) -> bool {
76 text.starts_with("http://") || text.starts_with("https://")
77}
78
79fn push_diagnostic(doc: &Document, raw_range: Range<usize>, url: &str, out: &mut Vec<Diagnostic>) {
80 let message = format!("bare URL `{url}` — wrap as `<{url}>` for a portable autolink");
81 let fix = Fix {
82 replacement: format!("<{url}>"),
83 safe: true,
84 };
85 let local = 0..raw_range.end.saturating_sub(raw_range.start);
86 if let Some(d) = Diagnostic::at(doc, raw_range.start, local, message, Some(fix)) {
87 out.push(d);
88 }
89}
90
91fn link_like_ranges(doc: &Document) -> Vec<Range<usize>> {
92 let mut ranges = doc.link_like_ranges().to_vec();
93 ranges.extend(doc.autolinks().iter().map(mdwright_document::AutolinkFact::raw_range));
94 ranges
95}
96
97fn ranges_overlap_any(range: &Range<usize>, others: &[Range<usize>]) -> bool {
98 others
99 .iter()
100 .any(|other| range.start < other.end && other.start < range.end)
101}