mdbook_lint_core/rules/standard/
md054.rs1use crate::error::Result;
23use crate::{
24 Document, Violation,
25 rule::{Rule, RuleCategory, RuleMetadata},
26 violation::Severity,
27};
28use comrak::nodes::AstNode;
29
30#[derive(Debug, Clone, PartialEq)]
31enum ParsedLinkType {
32 Inline,
33 Reference,
34 UrlInline,
35}
36
37pub struct MD054 {
39 autolink: bool,
40 inline: bool,
41 reference: bool,
42 url_inline: bool,
43}
44
45impl Default for MD054 {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51impl MD054 {
52 pub fn new() -> Self {
54 Self {
55 autolink: true,
56 inline: true,
57 reference: true,
58 url_inline: true,
59 }
60 }
61
62 #[allow(dead_code)]
64 pub fn autolink(mut self, allow: bool) -> Self {
65 self.autolink = allow;
66 self
67 }
68
69 #[allow(dead_code)]
71 pub fn inline(mut self, allow: bool) -> Self {
72 self.inline = allow;
73 self
74 }
75
76 #[allow(dead_code)]
78 pub fn reference(mut self, allow: bool) -> Self {
79 self.reference = allow;
80 self
81 }
82
83 #[allow(dead_code)]
85 pub fn url_inline(mut self, allow: bool) -> Self {
86 self.url_inline = allow;
87 self
88 }
89
90 fn check_link_styles(&self, document: &Document) -> Vec<Violation> {
92 let mut violations = Vec::new();
93
94 for (line_num, line) in document.content.lines().enumerate() {
95 let line_number = line_num + 1;
96 let mut chars = line.char_indices().peekable();
97 let mut in_backticks = false;
98
99 while let Some((i, ch)) = chars.next() {
100 match ch {
101 '`' => {
102 in_backticks = !in_backticks;
103 }
104 '<' if !in_backticks => {
105 if let Some(autolink_end) = self.find_autolink_end(&line[i..]) {
107 if !self.autolink {
108 violations.push(self.create_violation(
109 "Disallowed link style: autolink".to_string(),
110 line_number,
111 i + 1,
112 Severity::Warning,
113 ));
114 }
115 for _ in 0..autolink_end - 1 {
117 chars.next();
118 }
119 }
120 }
121 '[' if !in_backticks => {
122 if let Some((link_type, link_end)) = self.parse_link_at_position(&line[i..])
124 {
125 match link_type {
126 ParsedLinkType::Inline => {
127 if !self.inline {
128 violations.push(self.create_violation(
129 "Disallowed link style: inline".to_string(),
130 line_number,
131 i + 1,
132 Severity::Warning,
133 ));
134 }
135 }
136 ParsedLinkType::Reference => {
137 if !self.reference {
138 violations.push(self.create_violation(
139 "Disallowed link style: reference".to_string(),
140 line_number,
141 i + 1,
142 Severity::Warning,
143 ));
144 }
145 }
146 ParsedLinkType::UrlInline => {
147 if !self.url_inline {
148 violations.push(
149 self.create_violation(
150 "URL should use autolink style instead of inline"
151 .to_string(),
152 line_number,
153 i + 1,
154 Severity::Warning,
155 ),
156 );
157 }
158 }
159 }
160 for _ in 0..link_end - 1 {
162 chars.next();
163 }
164 }
165 }
166 _ => {}
167 }
168 }
169 }
170
171 violations
172 }
173
174 fn find_autolink_end(&self, text: &str) -> Option<usize> {
176 if !text.starts_with('<') {
177 return None;
178 }
179
180 if text.len() < 8 || !text[1..].starts_with("http") {
182 return None;
183 }
184
185 if let Some(end_pos) = text.find('>') {
187 let url = &text[1..end_pos];
188 if url.starts_with("http://") || url.starts_with("https://") {
189 return Some(end_pos + 1);
190 }
191 }
192
193 None
194 }
195
196 fn parse_link_at_position(&self, text: &str) -> Option<(ParsedLinkType, usize)> {
198 if !text.starts_with('[') {
199 return None;
200 }
201
202 let mut bracket_count = 0;
204 let mut closing_bracket_pos = None;
205
206 for (i, ch) in text.char_indices() {
207 match ch {
208 '[' => bracket_count += 1,
209 ']' => {
210 bracket_count -= 1;
211 if bracket_count == 0 {
212 closing_bracket_pos = Some(i);
213 break;
214 }
215 }
216 _ => {}
217 }
218 }
219
220 let closing_bracket_pos = closing_bracket_pos?;
221 let link_text = &text[1..closing_bracket_pos];
222 let remaining = &text[closing_bracket_pos + 1..];
223
224 if remaining.starts_with('(') {
225 if let Some(closing_paren) = remaining.find(')') {
227 let url = &remaining[1..closing_paren];
228 let total_length = closing_bracket_pos + 1 + closing_paren + 1;
229
230 if (url.starts_with("http://") || url.starts_with("https://")) && link_text == url {
232 return Some((ParsedLinkType::UrlInline, total_length));
233 }
234
235 return Some((ParsedLinkType::Inline, total_length));
236 }
237 } else if remaining.starts_with('[') {
238 if let Some(ref_end) = remaining.find(']') {
240 let total_length = closing_bracket_pos + 1 + ref_end + 1;
241 return Some((ParsedLinkType::Reference, total_length));
242 }
243 }
244
245 None
246 }
247}
248
249impl Rule for MD054 {
250 fn id(&self) -> &'static str {
251 "MD054"
252 }
253
254 fn name(&self) -> &'static str {
255 "link-image-style"
256 }
257
258 fn description(&self) -> &'static str {
259 "Link and image style"
260 }
261
262 fn metadata(&self) -> RuleMetadata {
263 RuleMetadata::stable(RuleCategory::Links)
264 }
265
266 fn check_with_ast<'a>(
267 &self,
268 document: &Document,
269 _ast: Option<&'a AstNode<'a>>,
270 ) -> Result<Vec<Violation>> {
271 let violations = self.check_link_styles(document);
273 Ok(violations)
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::test_helpers::{
281 assert_no_violations, assert_single_violation, assert_violation_count,
282 };
283
284 #[test]
285 fn test_all_styles_allowed_by_default() {
286 let content = r#"[Inline link](https://example.com)
287[Reference link][ref]
288<https://example.com>
289
290[ref]: https://example.com
291"#;
292
293 assert_no_violations(MD054::new(), content);
294 }
295
296 #[test]
297 fn test_disallow_autolinks() {
298 let content = r#"<https://example.com>
299[Inline link](https://example.com)
300"#;
301
302 let violation = assert_single_violation(MD054::new().autolink(false), content);
303 assert_eq!(violation.line, 1);
304 assert!(violation.message.contains("autolink"));
305 }
306
307 #[test]
308 fn test_disallow_inline_links() {
309 let content = r#"[Inline link](https://example.com)
310[Reference link][ref]
311
312[ref]: https://example.com
313"#;
314
315 let violation = assert_single_violation(MD054::new().inline(false), content);
316 assert_eq!(violation.line, 1);
317 assert!(violation.message.contains("inline"));
318 }
319
320 #[test]
321 fn test_disallow_reference_links() {
322 let content = r#"[Inline link](https://example.com)
323[Reference link][ref]
324
325[ref]: https://example.com
326"#;
327
328 let violation = assert_single_violation(MD054::new().reference(false), content);
329 assert_eq!(violation.line, 2);
330 assert!(violation.message.contains("reference"));
331 }
332
333 #[test]
334 fn test_url_inline_detection() {
335 let content = r#"[https://example.com](https://example.com)
336"#;
337
338 let violation = assert_single_violation(MD054::new().url_inline(false), content);
339 assert_eq!(violation.line, 1);
340 assert!(violation.message.contains("autolink style instead"));
341 }
342
343 #[test]
344 fn test_mixed_content_allowed() {
345 let content = r#"[Descriptive link](https://example.com)
346
347"#;
348
349 assert_no_violations(MD054::new(), content);
350 }
351
352 #[test]
353 fn test_multiple_violations() {
354 let content = r#"<https://example.com>
355[Inline link](https://different.com)
356"#;
357
358 let violations =
359 assert_violation_count(MD054::new().autolink(false).inline(false), content, 2);
360 assert!(violations[0].message.contains("autolink"));
361 assert!(violations[1].message.contains("inline"));
362 }
363
364 #[test]
365 fn test_reference_definitions_ignored() {
366 let content = r#"[Link][ref]
367
368[ref]: https://example.com
369"#;
370
371 let violation = assert_single_violation(MD054::new().reference(false), content);
373 assert_eq!(violation.line, 1);
374 assert!(violation.message.contains("reference"));
375 }
376}