Skip to main content

mdlint/lint/rules/
md034.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use pulldown_cmark::{Event, Tag, TagEnd};
5use regex::Regex;
6use serde_json::Value;
7
8pub struct MD034;
9
10impl Rule for MD034 {
11    fn name(&self) -> &'static str {
12        "MD034"
13    }
14
15    fn description(&self) -> &'static str {
16        "Bare URL used"
17    }
18
19    fn tags(&self) -> &[&str] {
20        &["links", "url"]
21    }
22
23    fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
24        let mut violations = Vec::new();
25
26        // A URL is bare only if it appears as plain text. Parsing the event
27        // stream (rather than scanning lines) makes that precise: text inside a
28        // link/image (`[text](url)`, `<url>` autolinks), inline code (`Event::Code`),
29        // raw HTML (`Event::Html`), and reference-definition URLs are never emitted
30        // as `Text` events, so they can't be mistaken for bare URLs — and a URL in
31        // link display text is no longer wrongly flagged.
32        let url_regex = Regex::new(r"(https?|ftp)://[^\s)\]>]+").expect("valid regex");
33
34        let content = parser.content();
35        let mut link_depth = 0u32;
36        let mut in_code_block = false;
37
38        for (event, range) in parser.parse_with_offsets() {
39            match event {
40                Event::Start(Tag::Link { .. } | Tag::Image { .. }) => link_depth += 1,
41                Event::End(TagEnd::Link | TagEnd::Image) => {
42                    link_depth = link_depth.saturating_sub(1);
43                }
44                Event::Start(Tag::CodeBlock(_)) => in_code_block = true,
45                Event::End(TagEnd::CodeBlock) => in_code_block = false,
46                Event::Text(_) if link_depth == 0 && !in_code_block => {
47                    let text = &content[range.clone()];
48                    for m in url_regex.find_iter(text) {
49                        let (line, column) = parser.offset_to_position(range.start + m.start());
50                        violations.push(Violation {
51                            line,
52                            column: Some(column),
53                            rule: self.name().to_owned(),
54                            message: format!("Bare URL used: {}", m.as_str()),
55                            fix: None,
56                        });
57                    }
58                }
59                _ => {}
60            }
61        }
62
63        violations
64    }
65
66    fn fixable(&self) -> bool {
67        false
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn test_no_bare_url() {
77        let content = "Check out [my site](https://example.com)";
78        let parser = MarkdownParser::new(content);
79        let rule = MD034;
80        let violations = rule.check(&parser, None);
81
82        assert_eq!(violations.len(), 0);
83    }
84
85    #[test]
86    fn test_bare_url() {
87        let content = "Check out https://example.com for more info";
88        let parser = MarkdownParser::new(content);
89        let rule = MD034;
90        let violations = rule.check(&parser, None);
91
92        assert_eq!(violations.len(), 1);
93        assert!(violations[0].message.contains("https://example.com"));
94    }
95
96    #[test]
97    fn test_angle_bracket_url() {
98        let content = "Check out <https://example.com> for info";
99        let parser = MarkdownParser::new(content);
100        let rule = MD034;
101        let violations = rule.check(&parser, None);
102
103        assert_eq!(violations.len(), 0); // Angle brackets are OK
104    }
105
106    #[test]
107    fn test_multiple_urls() {
108        let content = "Visit https://example.com and https://test.com";
109        let parser = MarkdownParser::new(content);
110        let rule = MD034;
111        let violations = rule.check(&parser, None);
112
113        assert_eq!(violations.len(), 2);
114    }
115
116    #[test]
117    fn test_url_in_code_block() {
118        let content = "```shell\ncurl -LO https://example.com/file.tar.gz\n```";
119        let parser = MarkdownParser::new(content);
120        let rule = MD034;
121        let violations = rule.check(&parser, None);
122
123        assert_eq!(violations.len(), 0, "URLs in code blocks should be ignored");
124    }
125
126    #[test]
127    fn test_url_in_inline_code() {
128        let content = "Run `curl https://example.com` to download";
129        let parser = MarkdownParser::new(content);
130        let rule = MD034;
131        let violations = rule.check(&parser, None);
132
133        assert_eq!(violations.len(), 0, "URLs in inline code should be ignored");
134    }
135
136    #[test]
137    fn test_url_alone_in_backticks() {
138        let content = "`https://example.com`";
139        let parser = MarkdownParser::new(content);
140        let rule = MD034;
141        let violations = rule.check(&parser, None);
142
143        assert_eq!(
144            violations.len(),
145            0,
146            "URL alone in a code span should not be flagged"
147        );
148    }
149
150    #[test]
151    fn test_url_alone_in_angle_brackets() {
152        let content = "<https://example.com>";
153        let parser = MarkdownParser::new(content);
154        let rule = MD034;
155        let violations = rule.check(&parser, None);
156
157        assert_eq!(
158            violations.len(),
159            0,
160            "URL alone in angle brackets (autolink) should not be flagged"
161        );
162    }
163
164    #[test]
165    fn test_url_in_link_display_text() {
166        // A URL inside link display text is part of the link, not bare.
167        let content = "See [visit https://inner.example.com here](https://dest.example.com).";
168        let parser = MarkdownParser::new(content);
169        let rule = MD034;
170        let violations = rule.check(&parser, None);
171
172        assert_eq!(
173            violations.len(),
174            0,
175            "URL in link display text should not be flagged as bare"
176        );
177    }
178
179    #[test]
180    fn test_parenthesized_bare_url() {
181        // A bare URL wrapped in prose parentheses is still bare.
182        let content = "A wrapped (https://paren.example.com) URL.";
183        let parser = MarkdownParser::new(content);
184        let rule = MD034;
185        let violations = rule.check(&parser, None);
186
187        assert_eq!(violations.len(), 1);
188        assert!(violations[0].message.contains("https://paren.example.com"));
189    }
190
191    #[test]
192    fn test_bare_url_column() {
193        let content = "Visit https://example.com now";
194        let parser = MarkdownParser::new(content);
195        let rule = MD034;
196        let violations = rule.check(&parser, None);
197
198        assert_eq!(violations.len(), 1);
199        // "Visit " is 6 chars, URL starts at column 7 (1-indexed).
200        assert_eq!(violations[0].column, Some(7));
201    }
202
203    #[test]
204    fn test_url_in_reference_definition() {
205        // Regression test for https://github.com/swanysimon/mdlint/issues/53
206        let content = "Here a [reference] is used.\n\n[reference]: https://example.com/";
207        let parser = MarkdownParser::new(content);
208        let rule = MD034;
209        let violations = rule.check(&parser, None);
210
211        assert_eq!(
212            violations.len(),
213            0,
214            "URL in a link reference definition should not be flagged as bare"
215        );
216    }
217}