mdbook_lint_core/rules/standard/
md054.rs

1//! MD054 - Link and image style
2//!
3//! This rule checks for consistent link and image styles within a document.
4//! Note: This is a simplified implementation that focuses on basic consistency.
5//!
6//! ## Correct
7//!
8//! ```markdown
9//! \[Inline link\](https://example.com)
10//! \[Another inline link\](https://example.com)
11//! ```
12//!
13//! ## Incorrect
14//!
15//! ```markdown
16//! \[Inline link\](https://example.com)
17//! \[Reference link\]\[ref\]
18//!
19//! [ref]: https://example.com
20//! ```
21
22use 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
37/// MD054 - Link and image style
38pub 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    /// Create a new MD054 rule instance
53    pub fn new() -> Self {
54        Self {
55            autolink: true,
56            inline: true,
57            reference: true,
58            url_inline: true,
59        }
60    }
61
62    /// Set whether to allow autolinks
63    #[allow(dead_code)]
64    pub fn autolink(mut self, allow: bool) -> Self {
65        self.autolink = allow;
66        self
67    }
68
69    /// Allow inline links
70    #[allow(dead_code)]
71    pub fn inline(mut self, allow: bool) -> Self {
72        self.inline = allow;
73        self
74    }
75
76    /// Allow reference links
77    #[allow(dead_code)]
78    pub fn reference(mut self, allow: bool) -> Self {
79        self.reference = allow;
80        self
81    }
82
83    /// Allow URL inline links
84    #[allow(dead_code)]
85    pub fn url_inline(mut self, allow: bool) -> Self {
86        self.url_inline = allow;
87        self
88    }
89
90    /// Check for style violations using manual parsing
91    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                        // Check for autolinks: <https://...>
106                        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                            // Skip past the autolink
116                            for _ in 0..autolink_end - 1 {
117                                chars.next();
118                            }
119                        }
120                    }
121                    '[' if !in_backticks => {
122                        // Check for inline links: [text](url) or reference links: [text][ref]
123                        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                            // Skip past the link
161                            for _ in 0..link_end - 1 {
162                                chars.next();
163                            }
164                        }
165                    }
166                    _ => {}
167                }
168            }
169        }
170
171        violations
172    }
173
174    /// Find the end of an autolink starting with <
175    fn find_autolink_end(&self, text: &str) -> Option<usize> {
176        if !text.starts_with('<') {
177            return None;
178        }
179
180        // Look for https:// or http://
181        if text.len() < 8 || !text[1..].starts_with("http") {
182            return None;
183        }
184
185        // Find the closing >
186        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    /// Parse a link starting at position and return its type and end position
197    fn parse_link_at_position(&self, text: &str) -> Option<(ParsedLinkType, usize)> {
198        if !text.starts_with('[') {
199            return None;
200        }
201
202        // Find the closing ]
203        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            // Inline link: [text](url)
226            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                // Check if this is a URL inline link (URL as both text and href)
231                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            // Reference link: [text][ref]
239            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        // This rule works entirely with document content, not AST
272        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![Inline image](image.png)
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        // Reference links should still be detected when disabled, but reference definitions should not
372        let violation = assert_single_violation(MD054::new().reference(false), content);
373        assert_eq!(violation.line, 1);
374        assert!(violation.message.contains("reference"));
375    }
376}