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