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) -> &'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)] #[allow(clippy::too_many_lines)] #[allow(clippy::similar_names)] 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 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 if let Some(line_text) = parser.lines().get(line - 1) {
92 let trimmed = line_text.trim();
93 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 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 if is_link_only {
118 continue;
119 }
120
121 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 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 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 let content = "";
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 let content = "[](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 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}