Skip to main content

mdwright_lint/stdlib/
bare_url.rs

1//! Bare `http(s)://…` in prose where an autolink would be cleaner.
2//!
3//! `CommonMark` autolinks (`<https://example.com>`) render as
4//! clickable links across all renderers; bare URLs depend on
5//! renderer-specific autolinking heuristics. The rule scans prose
6//! chunks and document autolink facts. Explicit `CommonMark` autolinks
7//! (`<https://example.com>`), GFM email autolinks, and Markdown links
8//! are already portable, so their ranges are excluded from the prose scan.
9
10use 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}