mdbook_lint_core/rules/standard/
md013.rs

1use crate::error::Result;
2use crate::rule::{Rule, RuleCategory, RuleMetadata};
3use crate::{
4    Document,
5    violation::{Severity, Violation},
6};
7
8/// MD013: Line length should not exceed a specified limit
9///
10/// This rule is triggered when lines exceed a specified length.
11/// The default line length is 80 characters.
12pub struct MD013 {
13    /// Maximum allowed line length
14    pub line_length: usize,
15    /// Whether to ignore code blocks
16    pub ignore_code_blocks: bool,
17    /// Whether to ignore tables
18    pub ignore_tables: bool,
19    /// Whether to ignore headings
20    pub ignore_headings: bool,
21}
22
23impl MD013 {
24    /// Create a new MD013 rule with default settings
25    pub fn new() -> Self {
26        Self {
27            line_length: 80,
28            ignore_code_blocks: true,
29            ignore_tables: true,
30            ignore_headings: true,
31        }
32    }
33
34    /// Create a new MD013 rule with custom line length
35    #[allow(dead_code)]
36    pub fn with_line_length(line_length: usize) -> Self {
37        Self {
38            line_length,
39            ignore_code_blocks: true,
40            ignore_tables: true,
41            ignore_headings: true,
42        }
43    }
44
45    /// Check if a line should be ignored based on rule settings
46    fn should_ignore_line(&self, line: &str, in_code_block: bool, in_table: bool) -> bool {
47        let trimmed = line.trim_start();
48
49        // Ignore code blocks if configured
50        if in_code_block && self.ignore_code_blocks {
51            return true;
52        }
53
54        // Ignore tables if configured
55        if in_table && self.ignore_tables {
56            return true;
57        }
58
59        // Ignore headings if configured
60        if self.ignore_headings && trimmed.starts_with('#') {
61            return true;
62        }
63
64        // Always ignore lines that are just URLs (common in markdown)
65        if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
66            return true;
67        }
68
69        false
70    }
71}
72
73impl Default for MD013 {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl Rule for MD013 {
80    fn id(&self) -> &'static str {
81        "MD013"
82    }
83
84    fn name(&self) -> &'static str {
85        "line-length"
86    }
87
88    fn description(&self) -> &'static str {
89        "Line length should not exceed a specified limit"
90    }
91
92    fn metadata(&self) -> RuleMetadata {
93        RuleMetadata::stable(RuleCategory::Formatting).introduced_in("markdownlint v0.1.0")
94    }
95
96    fn check_with_ast<'a>(
97        &self,
98        document: &Document,
99        _ast: Option<&'a comrak::nodes::AstNode<'a>>,
100    ) -> Result<Vec<Violation>> {
101        // MD013 is line-based and doesn't need AST, so we ignore the ast parameter
102        let mut violations = Vec::new();
103        let mut in_code_block = false;
104        let mut in_table = false;
105
106        for (line_number, line) in document.lines.iter().enumerate() {
107            let line_num = line_number + 1; // Convert to 1-based
108
109            // Track code block state
110            if line.trim_start().starts_with("```") {
111                in_code_block = !in_code_block;
112                continue;
113            }
114
115            // Track table state (simplified - tables have | characters)
116            let trimmed = line.trim();
117            if !in_code_block && (trimmed.starts_with('|') || trimmed.contains(" | ")) {
118                in_table = true;
119            } else if in_table && trimmed.is_empty() {
120                in_table = false;
121            }
122
123            // Check if we should ignore this line
124            if self.should_ignore_line(line, in_code_block, in_table) {
125                continue;
126            }
127
128            // Check line length
129            if line.len() > self.line_length {
130                let message = format!(
131                    "Line length is {} characters, expected no more than {}",
132                    line.len(),
133                    self.line_length
134                );
135
136                violations.push(self.create_violation(
137                    message,
138                    line_num,
139                    self.line_length + 1, // Point to the first character that exceeds limit
140                    Severity::Warning,
141                ));
142            }
143        }
144
145        Ok(violations)
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use std::path::PathBuf;
153
154    #[test]
155    fn test_md013_short_lines() {
156        let content = "# Short title\n\nThis is a short line.\nAnother short line.";
157        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
158        let rule = MD013::new();
159        let violations = rule.check(&document).unwrap();
160
161        assert_eq!(violations.len(), 0);
162    }
163
164    #[test]
165    fn test_md013_long_line() {
166        let long_line = "a".repeat(100);
167        let content = format!("# Title\n\n{long_line}");
168        let document = Document::new(content, PathBuf::from("test.md")).unwrap();
169        let rule = MD013::new();
170        let violations = rule.check(&document).unwrap();
171
172        assert_eq!(violations.len(), 1);
173        assert_eq!(violations[0].rule_id, "MD013");
174        assert_eq!(violations[0].line, 3);
175        assert_eq!(violations[0].column, 81);
176        assert_eq!(violations[0].severity, Severity::Warning);
177        assert!(violations[0].message.contains("100 characters"));
178        assert!(violations[0].message.contains("no more than 80"));
179    }
180
181    #[test]
182    fn test_md013_custom_line_length() {
183        let content = "This line is exactly fifty characters long here.";
184        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
185        let rule = MD013::with_line_length(40);
186        let violations = rule.check(&document).unwrap();
187
188        assert_eq!(violations.len(), 1);
189        assert!(violations[0].message.contains("48 characters"));
190        assert!(violations[0].message.contains("no more than 40"));
191    }
192
193    #[test]
194    fn test_md013_ignore_headings() {
195        let long_heading = format!("# {}", "a".repeat(100));
196        let document = Document::new(long_heading, PathBuf::from("test.md")).unwrap();
197        let rule = MD013::new(); // ignore_headings is true by default
198        let violations = rule.check(&document).unwrap();
199
200        assert_eq!(violations.len(), 0);
201    }
202
203    #[test]
204    fn test_md013_ignore_code_blocks() {
205        let content = r#"# Title
206
207```rust
208let very_long_line_of_code_that_exceeds_the_normal_line_length_limit_but_should_be_ignored = "value";
209```
210
211This is a normal line that should be checked."#;
212
213        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
214        let rule = MD013::new(); // ignore_code_blocks is true by default
215        let violations = rule.check(&document).unwrap();
216
217        assert_eq!(violations.len(), 0);
218    }
219
220    #[test]
221    fn test_md013_ignore_urls() {
222        let content = "https://example.com/very/long/path/that/exceeds/normal/line/length/limits/but/should/be/ignored";
223        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
224        let rule = MD013::new();
225        let violations = rule.check(&document).unwrap();
226
227        assert_eq!(violations.len(), 0);
228    }
229
230    #[test]
231    fn test_md013_ignore_tables() {
232        let content = r#"# Title
233
234| Column 1 with very long content | Column 2 with very long content | Column 3 with very long content |
235|----------------------------------|----------------------------------|----------------------------------|
236| Data 1 with very long content   | Data 2 with very long content   | Data 3 with very long content   |
237
238This is a normal line that should be checked if it's too long."#;
239
240        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
241        let rule = MD013::new(); // ignore_tables is true by default
242        let violations = rule.check(&document).unwrap();
243
244        assert_eq!(violations.len(), 0);
245    }
246
247    #[test]
248    fn test_md013_multiple_violations() {
249        let long_line = "a".repeat(100);
250        let content = format!("Normal line\n{long_line}\nAnother normal line\n{long_line}");
251        let document = Document::new(content, PathBuf::from("test.md")).unwrap();
252        let rule = MD013::new();
253        let violations = rule.check(&document).unwrap();
254
255        assert_eq!(violations.len(), 2);
256        assert_eq!(violations[0].line, 2);
257        assert_eq!(violations[1].line, 4);
258    }
259}