Skip to main content

mdlint/lint/rules/
md013.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use pulldown_cmark::{Event, Tag, TagEnd};
5use serde_json::Value;
6use std::collections::HashSet;
7
8pub struct MD013;
9
10impl Rule for MD013 {
11    fn name(&self) -> &str {
12        "MD013"
13    }
14
15    fn description(&self) -> &str {
16        "Line length"
17    }
18
19    fn tags(&self) -> &[&str] {
20        &["line_length"]
21    }
22
23    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
24        let line_length = config
25            .and_then(|c| c.get("line_length"))
26            .and_then(|v| v.as_u64())
27            .unwrap_or(120) as usize;
28
29        let heading_line_length = config
30            .and_then(|c| c.get("heading_line_length"))
31            .and_then(|v| v.as_u64())
32            .map(|v| v as usize)
33            .unwrap_or(80);
34
35        let check_code_blocks = config
36            .and_then(|c| c.get("code_blocks"))
37            .and_then(|v| v.as_bool())
38            .unwrap_or(true);
39
40        let check_tables = config
41            .and_then(|c| c.get("tables"))
42            .and_then(|v| v.as_bool())
43            .unwrap_or(true);
44
45        let check_headings = config
46            .and_then(|c| c.get("headings"))
47            .and_then(|v| v.as_bool())
48            .unwrap_or(true);
49
50        let mut violations = Vec::new();
51
52        // Track special lines (headings, code blocks, tables, links/images)
53        let mut heading_lines = HashSet::new();
54        let mut code_block_lines = HashSet::new();
55        let mut table_lines = HashSet::new();
56        let mut link_only_lines = HashSet::new();
57
58        let mut in_code_block = false;
59        let mut table_start_offset = None;
60
61        for (event, range) in parser.parse_with_offsets() {
62            let line = parser.offset_to_line(range.start);
63
64            match event {
65                Event::Start(Tag::Heading { .. }) => {
66                    heading_lines.insert(line);
67                }
68                Event::Start(Tag::CodeBlock(_)) => {
69                    in_code_block = true;
70                }
71                Event::End(TagEnd::CodeBlock) => {
72                    in_code_block = false;
73                }
74                Event::Start(Tag::Table(_)) => {
75                    table_start_offset = Some(range.start);
76                }
77                Event::End(TagEnd::Table) => {
78                    if let Some(start_off) = table_start_offset {
79                        let start_line = parser.offset_to_line(start_off);
80                        let end_line = parser.offset_to_line(range.end);
81                        for l in start_line..=end_line {
82                            table_lines.insert(l);
83                        }
84                    }
85                    table_start_offset = None;
86                }
87                Event::Start(Tag::Link { .. }) | Event::Start(Tag::Image { .. }) => {
88                    // Check if this link/image is the only content on the line
89                    if let Some(line_text) = parser.lines().get(line - 1) {
90                        let trimmed = line_text.trim();
91                        // If the line starts with [ or !, it's likely a link/image only line
92                        if trimmed.starts_with('[') || trimmed.starts_with("![") {
93                            link_only_lines.insert(line);
94                        }
95                    }
96                }
97                Event::Text(_) if in_code_block => {
98                    code_block_lines.insert(line);
99                }
100                _ => {}
101            }
102        }
103
104        // Check each line
105        for (line_num, line) in parser.lines().iter().enumerate() {
106            let line_number = line_num + 1;
107            let line_len = line.chars().count();
108
109            let is_heading = heading_lines.contains(&line_number);
110            let is_code_block = code_block_lines.contains(&line_number);
111            let is_table = table_lines.contains(&line_number);
112            let is_link_only = link_only_lines.contains(&line_number);
113
114            // Skip lines that only contain links or images (can't be shortened)
115            if is_link_only {
116                continue;
117            }
118
119            // Skip if we shouldn't check this type of line
120            if is_heading && !check_headings {
121                continue;
122            }
123            if is_code_block && !check_code_blocks {
124                continue;
125            }
126            if is_table && !check_tables {
127                continue;
128            }
129
130            // Determine the limit for this line
131            let limit = if is_heading {
132                heading_line_length
133            } else {
134                line_length
135            };
136
137            if line_len > limit {
138                violations.push(Violation {
139                    line: line_number,
140                    column: Some(limit + 1),
141                    rule: self.name().to_string(),
142                    message: format!("Line exceeds maximum length ({} > {})", line_len, limit),
143                    fix: None,
144                });
145            }
146        }
147
148        violations
149    }
150
151    fn fixable(&self) -> bool {
152        false
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_short_lines() {
162        let content = "Short line\nAnother short line\nStill short";
163        let parser = MarkdownParser::new(content);
164        let rule = MD013;
165        let violations = rule.check(&parser, None);
166
167        assert_eq!(violations.len(), 0);
168    }
169
170    #[test]
171    fn test_long_line() {
172        let content = "This is a very long line that definitely exceeds the default eighty character limit and should be flagged";
173        let parser = MarkdownParser::new(content);
174        let rule = MD013;
175        let config = serde_json::json!({ "line_length": 80 });
176        let violations = rule.check(&parser, Some(&config));
177
178        assert_eq!(violations.len(), 1);
179        assert_eq!(violations[0].line, 1);
180    }
181
182    #[test]
183    fn test_custom_line_length() {
184        let content = "This line is exactly forty characters.";
185        let parser = MarkdownParser::new(content);
186        let rule = MD013;
187        let config = serde_json::json!({ "line_length": 30 });
188        let violations = rule.check(&parser, Some(&config));
189
190        assert_eq!(violations.len(), 1);
191    }
192
193    #[test]
194    fn test_heading_exception() {
195        let content =
196            "# This is a very long heading that would normally exceed the line length limit";
197        let parser = MarkdownParser::new(content);
198        let rule = MD013;
199        let config = serde_json::json!({ "headings": false });
200        let violations = rule.check(&parser, Some(&config));
201
202        assert_eq!(violations.len(), 0);
203    }
204
205    #[test]
206    fn test_code_block_check() {
207        let content = "```\nThis is a very long line in a code block that exceeds the maximum allowed character count\n```";
208        let parser = MarkdownParser::new(content);
209        let rule = MD013;
210        let config = serde_json::json!({ "line_length": 80, "code_blocks": true });
211        let violations = rule.check(&parser, Some(&config));
212
213        assert!(!violations.is_empty());
214    }
215
216    #[test]
217    fn test_code_block_ignore() {
218        let content = "```\nThis is a very long line in a code block that exceeds the maximum allowed character count\n```";
219        let parser = MarkdownParser::new(content);
220        let rule = MD013;
221        let config = serde_json::json!({ "code_blocks": false });
222        let violations = rule.check(&parser, Some(&config));
223
224        assert_eq!(violations.len(), 0);
225    }
226
227    #[test]
228    fn test_link_only_line_ignored() {
229        // A line containing only a link should not trigger line length check
230        let content = "[This is a very long link text](https://github.com/example/repository/with/a/very/long/url/path/that/exceeds/the/limit)";
231        let parser = MarkdownParser::new(content);
232        let rule = MD013;
233        let config = serde_json::json!({ "line_length": 80 });
234        let violations = rule.check(&parser, Some(&config));
235
236        assert_eq!(
237            violations.len(),
238            0,
239            "Link-only lines should not trigger MD013"
240        );
241    }
242
243    #[test]
244    fn test_image_only_line_ignored() {
245        // A line containing only an image should not trigger line length check
246        let content = "![Alt text](https://github.com/example/repository/with/a/very/long/image/url/path/that/exceeds/the/maximum/character/limit)";
247        let parser = MarkdownParser::new(content);
248        let rule = MD013;
249        let config = serde_json::json!({ "line_length": 80 });
250        let violations = rule.check(&parser, Some(&config));
251
252        assert_eq!(
253            violations.len(),
254            0,
255            "Image-only lines should not trigger MD013"
256        );
257    }
258
259    #[test]
260    fn test_badge_link_ignored() {
261        // Badge links (image inside link) should not trigger line length check
262        let content = "[![CI](https://github.com/user/repo/workflows/CI/badge.svg)](https://github.com/user/repo/actions/workflows/ci.yml?query=branch%3Amain)";
263        let parser = MarkdownParser::new(content);
264        let rule = MD013;
265        let config = serde_json::json!({ "line_length": 120 });
266        let violations = rule.check(&parser, Some(&config));
267
268        assert_eq!(violations.len(), 0, "Badge links should not trigger MD013");
269    }
270
271    #[test]
272    fn test_text_with_link_still_checked() {
273        // A line with text AND a link should still be checked
274        let content = "Check out this link: [example](https://github.com/example/repository/with/a/very/long/url/path) for more information about the thing";
275        let parser = MarkdownParser::new(content);
276        let rule = MD013;
277        let config = serde_json::json!({ "line_length": 80 });
278        let violations = rule.check(&parser, Some(&config));
279
280        assert_eq!(
281            violations.len(),
282            1,
283            "Lines with text and links should still be checked"
284        );
285    }
286
287    #[test]
288    fn test_long_table_line() {
289        let content = "| Col1 | Col2 |\n|------|------|\n| A    | B    |\n| C    | D    |";
290        let parser = MarkdownParser::new(content);
291        let rule = MD013;
292        let config = serde_json::json!({ "line_length": 10, "tables": true });
293        let violations = rule.check(&parser, Some(&config));
294
295        assert_eq!(violations.len(), 4);
296        assert!(
297            violations
298                .iter()
299                .enumerate()
300                .all(|(i, v)| v.line == (i + 1))
301        );
302    }
303
304    #[test]
305    fn test_long_table_line_ignored() {
306        let content = "| Col1 | Col2 |\n|------|------|\n| A    | B    |\n| C    | D    |";
307        let parser = MarkdownParser::new(content);
308        let rule = MD013;
309        let config = serde_json::json!({ "line_length": 10, "tables": false });
310        let violations = rule.check(&parser, Some(&config));
311
312        assert_eq!(violations.len(), 0);
313    }
314}