mdlint/lint/rules/
md034.rs1use 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 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); }
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 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 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 assert_eq!(violations[0].column, Some(7));
201 }
202
203 #[test]
204 fn test_url_in_reference_definition() {
205 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}