mdlint/lint/rules/
md013.rs1use 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 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 if let Some(line_text) = parser.lines().get(line - 1) {
90 let trimmed = line_text.trim();
91 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 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 if is_link_only {
116 continue;
117 }
118
119 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 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 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 let content = "";
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 let content = "[](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 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}