rumdl_lib/rules/
md013_line_length.rs

1/// Rule MD013: Line length
2///
3/// See [docs/md013.md](../../docs/md013.md) for full documentation, configuration, and examples.
4use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
7use crate::utils::range_utils::calculate_excess_range;
8use crate::utils::regex_cache::{
9    IMAGE_REF_PATTERN, INLINE_LINK_REGEX as MARKDOWN_LINK_PATTERN, LINK_REF_PATTERN, URL_IN_TEXT, URL_PATTERN,
10};
11use toml;
12
13pub mod md013_config;
14use md013_config::MD013Config;
15
16#[derive(Clone, Default)]
17pub struct MD013LineLength {
18    config: MD013Config,
19}
20
21impl MD013LineLength {
22    pub fn new(line_length: usize, code_blocks: bool, tables: bool, headings: bool, strict: bool) -> Self {
23        Self {
24            config: MD013Config {
25                line_length,
26                code_blocks,
27                tables,
28                headings,
29                strict,
30                reflow: false,
31            },
32        }
33    }
34
35    pub fn from_config_struct(config: MD013Config) -> Self {
36        Self { config }
37    }
38
39    fn is_in_table(lines: &[&str], current_line: usize) -> bool {
40        // Check if current line is part of a table
41        let current = lines[current_line].trim();
42        if current.starts_with('|') || current.starts_with("|-") {
43            return true;
44        }
45
46        // Check if line is between table markers
47        if current_line > 0 && current_line + 1 < lines.len() {
48            let prev = lines[current_line - 1].trim();
49            let next = lines[current_line + 1].trim();
50            if (prev.starts_with('|') || prev.starts_with("|-")) && (next.starts_with('|') || next.starts_with("|-")) {
51                return true;
52            }
53        }
54        false
55    }
56
57    fn should_ignore_line(
58        &self,
59        line: &str,
60        _lines: &[&str],
61        current_line: usize,
62        structure: &DocumentStructure,
63    ) -> bool {
64        if self.config.strict {
65            return false;
66        }
67
68        // Quick check for common patterns before expensive regex
69        let trimmed = line.trim();
70
71        // Only skip if the entire line is a URL (quick check first)
72        if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
73            return true;
74        }
75
76        // Only skip if the entire line is an image reference (quick check first)
77        if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
78            return true;
79        }
80
81        // Only skip if the entire line is a link reference (quick check first)
82        if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
83            return true;
84        }
85
86        // Code blocks with long strings (only check if in code block)
87        if structure.is_in_code_block(current_line + 1)
88            && !trimmed.is_empty()
89            && !line.contains(' ')
90            && !line.contains('\t')
91        {
92            return true;
93        }
94
95        false
96    }
97}
98
99impl Rule for MD013LineLength {
100    fn name(&self) -> &'static str {
101        "MD013"
102    }
103
104    fn description(&self) -> &'static str {
105        "Line length should not be excessive"
106    }
107
108    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
109        let content = ctx.content;
110
111        // Early return for empty content
112        if content.is_empty() {
113            return Ok(Vec::new());
114        }
115
116        // Quick check: if total content is shorter than line limit, definitely no violations
117        if content.len() <= self.config.line_length {
118            return Ok(Vec::new());
119        }
120
121        // More aggressive early return - check if any line could possibly be long
122        let has_long_lines = if !ctx.lines.is_empty() {
123            ctx.lines
124                .iter()
125                .any(|line| line.content.len() > self.config.line_length)
126        } else {
127            // Fallback: do a quick scan for newlines to estimate max line length
128            let mut max_line_len = 0;
129            let mut current_line_len = 0;
130            for ch in content.chars() {
131                if ch == '\n' {
132                    max_line_len = max_line_len.max(current_line_len);
133                    current_line_len = 0;
134                } else {
135                    current_line_len += 1;
136                }
137            }
138            max_line_len = max_line_len.max(current_line_len);
139            max_line_len > self.config.line_length
140        };
141
142        if !has_long_lines {
143            return Ok(Vec::new());
144        }
145
146        // Create structure manually
147        let structure = DocumentStructure::new(content);
148        self.check_with_structure(ctx, &structure)
149    }
150
151    /// Optimized check using pre-computed document structure
152    fn check_with_structure(
153        &self,
154        ctx: &crate::lint_context::LintContext,
155        structure: &DocumentStructure,
156    ) -> LintResult {
157        let content = ctx.content;
158        let mut warnings = Vec::new();
159
160        // Early return was already done in check(), so we know there are long lines
161
162        // Check for inline configuration overrides
163        let inline_config = crate::inline_config::InlineConfig::from_content(content);
164        let config_override = inline_config.get_rule_config("MD013");
165
166        // Apply configuration override if present
167        let effective_config = if let Some(json_config) = config_override {
168            if let Some(obj) = json_config.as_object() {
169                let mut config = self.config.clone();
170                if let Some(line_length) = obj.get("line_length").and_then(|v| v.as_u64()) {
171                    config.line_length = line_length as usize;
172                }
173                if let Some(code_blocks) = obj.get("code_blocks").and_then(|v| v.as_bool()) {
174                    config.code_blocks = code_blocks;
175                }
176                if let Some(tables) = obj.get("tables").and_then(|v| v.as_bool()) {
177                    config.tables = tables;
178                }
179                if let Some(headings) = obj.get("headings").and_then(|v| v.as_bool()) {
180                    config.headings = headings;
181                }
182                if let Some(strict) = obj.get("strict").and_then(|v| v.as_bool()) {
183                    config.strict = strict;
184                }
185                if let Some(reflow) = obj.get("reflow").and_then(|v| v.as_bool()) {
186                    config.reflow = reflow;
187                }
188                config
189            } else {
190                self.config.clone()
191            }
192        } else {
193            self.config.clone()
194        };
195
196        // Use ctx.lines if available for better performance
197        let lines: Vec<&str> = if !ctx.lines.is_empty() {
198            ctx.lines.iter().map(|l| l.content.as_str()).collect()
199        } else {
200            content.lines().collect()
201        };
202
203        // Create a quick lookup set for heading lines
204        let heading_lines_set: std::collections::HashSet<usize> = structure.heading_lines.iter().cloned().collect();
205
206        // Pre-compute table lines using the same logic as is_in_table function
207        let table_lines_set: std::collections::HashSet<usize> = {
208            let mut table_lines = std::collections::HashSet::new();
209
210            for (i, _line) in lines.iter().enumerate() {
211                let line_number = i + 1;
212
213                // Quick check if in code block using pre-computed blocks from context or structure
214                let in_code = if !ctx.code_blocks.is_empty() {
215                    ctx.code_blocks
216                        .iter()
217                        .any(|(start, end)| *start <= line_number && line_number <= *end)
218                } else {
219                    structure.is_in_code_block(line_number)
220                };
221
222                if !in_code && Self::is_in_table(&lines, i) {
223                    table_lines.insert(line_number);
224                }
225            }
226            table_lines
227        };
228
229        for (line_num, line) in lines.iter().enumerate() {
230            let line_number = line_num + 1;
231
232            // Calculate effective length excluding unbreakable URLs
233            let effective_length = self.calculate_effective_length(line);
234
235            // Use single line length limit for all content
236            let line_limit = effective_config.line_length;
237
238            // Skip short lines immediately
239            if effective_length <= line_limit {
240                continue;
241            }
242
243            // Skip various block types efficiently
244            if !effective_config.strict {
245                // Skip setext heading underlines
246                if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
247                    continue;
248                }
249
250                // Skip block elements according to config flags
251                // The flags mean: true = check these elements, false = skip these elements
252                // So we skip when the flag is FALSE and the line is in that element type
253                if (!effective_config.headings && heading_lines_set.contains(&line_number))
254                    || (!effective_config.code_blocks && structure.is_in_code_block(line_number))
255                    || (!effective_config.tables && table_lines_set.contains(&line_number))
256                    || structure.is_in_blockquote(line_number)
257                    || structure.is_in_html_block(line_number)
258                {
259                    continue;
260                }
261
262                // Skip lines that are only a URL, image ref, or link ref
263                if self.should_ignore_line(line, &lines, line_num, structure) {
264                    continue;
265                }
266            }
267
268            // Only provide a fix if reflow is enabled
269            let fix = if self.config.reflow && !self.should_skip_line_for_fix(line, line_num, structure) {
270                // Provide a placeholder fix to indicate that reflow will happen
271                // The actual reflow is done in the fix() method
272                Some(crate::rule::Fix {
273                    range: 0..0,                // Placeholder range
274                    replacement: String::new(), // Placeholder replacement
275                })
276            } else {
277                None
278            };
279
280            let message = format!("Line length {effective_length} exceeds {line_limit} characters");
281
282            // Calculate precise character range for the excess portion
283            let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
284
285            warnings.push(LintWarning {
286                rule_name: Some(self.name()),
287                message,
288                line: start_line,
289                column: start_col,
290                end_line,
291                end_column: end_col,
292                severity: Severity::Warning,
293                fix,
294            });
295        }
296        Ok(warnings)
297    }
298
299    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
300        // Only fix if reflow is enabled
301        if self.config.reflow {
302            let reflow_options = crate::utils::text_reflow::ReflowOptions {
303                line_length: self.config.line_length,
304                break_on_sentences: true,
305                preserve_breaks: false,
306            };
307
308            return Ok(crate::utils::text_reflow::reflow_markdown(ctx.content, &reflow_options));
309        }
310
311        // Without reflow, MD013 has no fixes available
312        Ok(ctx.content.to_string())
313    }
314
315    fn as_any(&self) -> &dyn std::any::Any {
316        self
317    }
318
319    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
320        Some(self)
321    }
322
323    fn category(&self) -> RuleCategory {
324        RuleCategory::Whitespace
325    }
326
327    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
328        // Skip if content is empty
329        if ctx.content.is_empty() {
330            return true;
331        }
332
333        // Quick check: if total content is shorter than line limit, definitely skip
334        if ctx.content.len() <= self.config.line_length {
335            return true;
336        }
337
338        // Use more efficient check - any() with early termination instead of all()
339        !ctx.lines
340            .iter()
341            .any(|line| line.content.len() > self.config.line_length)
342    }
343
344    fn default_config_section(&self) -> Option<(String, toml::Value)> {
345        let default_config = MD013Config::default();
346        let json_value = serde_json::to_value(&default_config).ok()?;
347        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
348
349        if let toml::Value::Table(table) = toml_value {
350            if !table.is_empty() {
351                Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
352            } else {
353                None
354            }
355        } else {
356            None
357        }
358    }
359
360    fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
361        let mut aliases = std::collections::HashMap::new();
362        aliases.insert("enable_reflow".to_string(), "reflow".to_string());
363        Some(aliases)
364    }
365
366    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
367    where
368        Self: Sized,
369    {
370        let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
371        // Special handling for line_length from global config
372        if rule_config.line_length == 80 {
373            // default value
374            rule_config.line_length = config.global.line_length as usize;
375        }
376        Box::new(Self::from_config_struct(rule_config))
377    }
378}
379
380impl MD013LineLength {
381    /// Check if a line should be skipped for fixing
382    fn should_skip_line_for_fix(&self, line: &str, line_num: usize, structure: &DocumentStructure) -> bool {
383        let line_number = line_num + 1; // 1-based
384
385        // Skip code blocks
386        if structure.is_in_code_block(line_number) {
387            return true;
388        }
389
390        // Skip HTML blocks
391        if structure.is_in_html_block(line_number) {
392            return true;
393        }
394
395        // Skip tables (they have complex formatting)
396        if Self::is_in_table(&[line], 0) {
397            return true;
398        }
399
400        // Skip lines that are only URLs (can't be wrapped)
401        if line.trim().starts_with("http://") || line.trim().starts_with("https://") {
402            return true;
403        }
404
405        // Skip setext heading underlines
406        if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
407            return true;
408        }
409
410        false
411    }
412
413    /// Calculate effective line length excluding unbreakable URLs
414    fn calculate_effective_length(&self, line: &str) -> usize {
415        if self.config.strict {
416            // In strict mode, count everything
417            return line.chars().count();
418        }
419
420        // Quick check: if line doesn't contain "http" or "[", it can't have URLs or markdown links
421        if !line.contains("http") && !line.contains('[') {
422            return line.chars().count();
423        }
424
425        let mut effective_line = line.to_string();
426
427        // First handle markdown links to avoid double-counting URLs
428        // Pattern: [text](very-long-url) -> [text](url)
429        if line.contains('[') && line.contains("](") {
430            for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
431                if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
432                    && url.as_str().len() > 15
433                {
434                    let replacement = format!("[{}](url)", text.as_str());
435                    effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
436                }
437            }
438        }
439
440        // Then replace bare URLs with a placeholder of reasonable length
441        // This allows lines with long URLs to pass if the rest of the content is reasonable
442        if effective_line.contains("http") {
443            for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
444                let url = url_match.as_str();
445                // Skip if this URL is already part of a markdown link we handled
446                if !effective_line.contains(&format!("({url})")) {
447                    // Replace URL with placeholder that represents a "reasonable" URL length
448                    // Using 15 chars as a reasonable URL placeholder (e.g., "https://ex.com")
449                    let placeholder = "x".repeat(15.min(url.len()));
450                    effective_line = effective_line.replacen(url, &placeholder, 1);
451                }
452            }
453        }
454
455        effective_line.chars().count()
456    }
457}
458
459impl DocumentStructureExtensions for MD013LineLength {
460    fn has_relevant_elements(
461        &self,
462        ctx: &crate::lint_context::LintContext,
463        _doc_structure: &DocumentStructure,
464    ) -> bool {
465        // This rule always applies unless content is empty
466        !ctx.content.is_empty()
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::lint_context::LintContext;
474
475    #[test]
476    fn test_default_config() {
477        let rule = MD013LineLength::default();
478        assert_eq!(rule.config.line_length, 80);
479        assert!(rule.config.code_blocks); // Default is true
480        assert!(rule.config.tables); // Default is true
481        assert!(rule.config.headings); // Default is true
482        assert!(!rule.config.strict);
483    }
484
485    #[test]
486    fn test_custom_config() {
487        let rule = MD013LineLength::new(100, true, true, false, true);
488        assert_eq!(rule.config.line_length, 100);
489        assert!(rule.config.code_blocks);
490        assert!(rule.config.tables);
491        assert!(!rule.config.headings);
492        assert!(rule.config.strict);
493    }
494
495    #[test]
496    fn test_basic_line_length_violation() {
497        let rule = MD013LineLength::new(50, false, false, false, false);
498        let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
499        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
500        let result = rule.check(&ctx).unwrap();
501
502        assert_eq!(result.len(), 1);
503        assert!(result[0].message.contains("Line length"));
504        assert!(result[0].message.contains("exceeds 50 characters"));
505    }
506
507    #[test]
508    fn test_no_violation_under_limit() {
509        let rule = MD013LineLength::new(100, false, false, false, false);
510        let content = "Short line.\nAnother short line.";
511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
512        let result = rule.check(&ctx).unwrap();
513
514        assert_eq!(result.len(), 0);
515    }
516
517    #[test]
518    fn test_multiple_violations() {
519        let rule = MD013LineLength::new(30, false, false, false, false);
520        let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
521        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
522        let result = rule.check(&ctx).unwrap();
523
524        assert_eq!(result.len(), 2);
525        assert_eq!(result[0].line, 1);
526        assert_eq!(result[1].line, 2);
527    }
528
529    #[test]
530    fn test_code_blocks_exemption() {
531        // With code_blocks = false, code blocks should be skipped
532        let rule = MD013LineLength::new(30, false, false, false, false);
533        let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
534        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
535        let result = rule.check(&ctx).unwrap();
536
537        assert_eq!(result.len(), 0);
538    }
539
540    #[test]
541    fn test_code_blocks_not_exempt_when_configured() {
542        // With code_blocks = true, code blocks should be checked
543        let rule = MD013LineLength::new(30, true, false, false, false);
544        let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
545        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
546        let result = rule.check(&ctx).unwrap();
547
548        assert!(!result.is_empty());
549    }
550
551    #[test]
552    fn test_heading_checked_when_enabled() {
553        let rule = MD013LineLength::new(30, false, false, true, false);
554        let content = "# This is a very long heading that would normally exceed the limit";
555        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
556        let result = rule.check(&ctx).unwrap();
557
558        assert_eq!(result.len(), 1);
559    }
560
561    #[test]
562    fn test_heading_exempt_when_disabled() {
563        let rule = MD013LineLength::new(30, false, false, false, false);
564        let content = "# This is a very long heading that should trigger a warning";
565        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
566        let result = rule.check(&ctx).unwrap();
567
568        assert_eq!(result.len(), 0);
569    }
570
571    #[test]
572    fn test_table_detection() {
573        let lines = vec![
574            "| Column 1 | Column 2 |",
575            "|----------|----------|",
576            "| Value 1  | Value 2  |",
577        ];
578
579        assert!(MD013LineLength::is_in_table(&lines, 0));
580        assert!(MD013LineLength::is_in_table(&lines, 1));
581        assert!(MD013LineLength::is_in_table(&lines, 2));
582    }
583
584    #[test]
585    fn test_table_checked_when_enabled() {
586        let rule = MD013LineLength::new(30, false, true, false, false);
587        let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
588        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
589        let result = rule.check(&ctx).unwrap();
590
591        assert_eq!(result.len(), 2); // Both table lines exceed limit
592    }
593
594    #[test]
595    fn test_url_exemption() {
596        let rule = MD013LineLength::new(30, false, false, false, false);
597        let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
598        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
599        let result = rule.check(&ctx).unwrap();
600
601        assert_eq!(result.len(), 0);
602    }
603
604    #[test]
605    fn test_image_reference_exemption() {
606        let rule = MD013LineLength::new(30, false, false, false, false);
607        let content = "![This is a very long image alt text that exceeds limit][reference]";
608        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
609        let result = rule.check(&ctx).unwrap();
610
611        assert_eq!(result.len(), 0);
612    }
613
614    #[test]
615    fn test_link_reference_exemption() {
616        let rule = MD013LineLength::new(30, false, false, false, false);
617        let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
618        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
619        let result = rule.check(&ctx).unwrap();
620
621        assert_eq!(result.len(), 0);
622    }
623
624    #[test]
625    fn test_strict_mode() {
626        let rule = MD013LineLength::new(30, false, false, false, true);
627        let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
628        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
629        let result = rule.check(&ctx).unwrap();
630
631        // In strict mode, even URLs trigger warnings
632        assert_eq!(result.len(), 1);
633    }
634
635    #[test]
636    fn test_blockquote_exemption() {
637        let rule = MD013LineLength::new(30, false, false, false, false);
638        let content = "> This is a very long line inside a blockquote that should be ignored.";
639        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
640        let result = rule.check(&ctx).unwrap();
641
642        assert_eq!(result.len(), 0);
643    }
644
645    #[test]
646    fn test_setext_heading_underline_exemption() {
647        let rule = MD013LineLength::new(30, false, false, false, false);
648        let content = "Heading\n========================================";
649        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
650        let result = rule.check(&ctx).unwrap();
651
652        // The underline should be exempt
653        assert_eq!(result.len(), 0);
654    }
655
656    #[test]
657    fn test_no_fix_without_reflow() {
658        let rule = MD013LineLength::new(60, false, false, false, false);
659        let content = "This line has trailing whitespace that makes it too long      ";
660        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
661        let result = rule.check(&ctx).unwrap();
662
663        assert_eq!(result.len(), 1);
664        // Without reflow, no fix is provided
665        assert!(result[0].fix.is_none());
666
667        // Fix method returns content unchanged
668        let fixed = rule.fix(&ctx).unwrap();
669        assert_eq!(fixed, content);
670    }
671
672    #[test]
673    fn test_character_vs_byte_counting() {
674        let rule = MD013LineLength::new(10, false, false, false, false);
675        // Unicode characters should count as 1 character each
676        let content = "你好世界这是测试文字超过限制"; // 14 characters
677        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
678        let result = rule.check(&ctx).unwrap();
679
680        assert_eq!(result.len(), 1);
681        assert_eq!(result[0].line, 1);
682    }
683
684    #[test]
685    fn test_empty_content() {
686        let rule = MD013LineLength::default();
687        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
688        let result = rule.check(&ctx).unwrap();
689
690        assert_eq!(result.len(), 0);
691    }
692
693    #[test]
694    fn test_excess_range_calculation() {
695        let rule = MD013LineLength::new(10, false, false, false, false);
696        let content = "12345678901234567890"; // 20 chars, limit is 10
697        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
698        let result = rule.check(&ctx).unwrap();
699
700        assert_eq!(result.len(), 1);
701        // The warning should highlight from character 11 onwards
702        assert_eq!(result[0].column, 11);
703        assert_eq!(result[0].end_column, 21);
704    }
705
706    #[test]
707    fn test_html_block_exemption() {
708        let rule = MD013LineLength::new(30, false, false, false, false);
709        let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
710        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
711        let result = rule.check(&ctx).unwrap();
712
713        // HTML blocks should be exempt
714        assert_eq!(result.len(), 0);
715    }
716
717    #[test]
718    fn test_mixed_content() {
719        // code_blocks=false, tables=false, headings=false (all skipped/exempt)
720        let rule = MD013LineLength::new(30, false, false, false, false);
721        let content = r#"# This heading is very long but should be exempt
722
723This regular paragraph line is too long and should trigger.
724
725```
726Code block line that is very long but exempt.
727```
728
729| Table | With very long content |
730|-------|------------------------|
731
732Another long line that should trigger a warning."#;
733
734        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
735        let result = rule.check(&ctx).unwrap();
736
737        // Should have warnings for the two regular paragraph lines only
738        assert_eq!(result.len(), 2);
739        assert_eq!(result[0].line, 3);
740        assert_eq!(result[1].line, 12);
741    }
742
743    #[test]
744    fn test_fix_without_reflow_preserves_content() {
745        let rule = MD013LineLength::new(50, false, false, false, false);
746        let content = "Line 1\nThis line has trailing spaces and is too long      \nLine 3";
747        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
748
749        // Without reflow, content is unchanged
750        let fixed = rule.fix(&ctx).unwrap();
751        assert_eq!(fixed, content);
752    }
753
754    #[test]
755    fn test_has_relevant_elements() {
756        let rule = MD013LineLength::default();
757        let structure = DocumentStructure::new("test");
758
759        let ctx = LintContext::new("Some content", crate::config::MarkdownFlavor::Standard);
760        assert!(rule.has_relevant_elements(&ctx, &structure));
761
762        let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
763        assert!(!rule.has_relevant_elements(&empty_ctx, &structure));
764    }
765
766    #[test]
767    fn test_rule_metadata() {
768        let rule = MD013LineLength::default();
769        assert_eq!(rule.name(), "MD013");
770        assert_eq!(rule.description(), "Line length should not be excessive");
771        assert_eq!(rule.category(), RuleCategory::Whitespace);
772    }
773
774    #[test]
775    fn test_url_embedded_in_text() {
776        let rule = MD013LineLength::new(50, false, false, false, false);
777
778        // This line would be 85 chars, but only ~45 without the URL
779        let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
780        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
781        let result = rule.check(&ctx).unwrap();
782
783        // Should not flag because effective length (with URL placeholder) is under 50
784        assert_eq!(result.len(), 0);
785    }
786
787    #[test]
788    fn test_multiple_urls_in_line() {
789        let rule = MD013LineLength::new(50, false, false, false, false);
790
791        // Line with multiple URLs
792        let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
793        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
794
795        let result = rule.check(&ctx).unwrap();
796
797        // Should not flag because effective length is reasonable
798        assert_eq!(result.len(), 0);
799    }
800
801    #[test]
802    fn test_markdown_link_with_long_url() {
803        let rule = MD013LineLength::new(50, false, false, false, false);
804
805        // Markdown link with very long URL
806        let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
807        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
808        let result = rule.check(&ctx).unwrap();
809
810        // Should not flag because effective length counts link as short
811        assert_eq!(result.len(), 0);
812    }
813
814    #[test]
815    fn test_line_too_long_even_without_urls() {
816        let rule = MD013LineLength::new(50, false, false, false, false);
817
818        // Line that's too long even after URL exclusion
819        let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
820        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
821        let result = rule.check(&ctx).unwrap();
822
823        // Should flag because even with URL placeholder, line is too long
824        assert_eq!(result.len(), 1);
825    }
826
827    #[test]
828    fn test_strict_mode_counts_urls() {
829        let rule = MD013LineLength::new(50, false, false, false, true); // strict=true
830
831        // Same line that passes in non-strict mode
832        let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
833        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
834        let result = rule.check(&ctx).unwrap();
835
836        // In strict mode, should flag because full URL is counted
837        assert_eq!(result.len(), 1);
838    }
839
840    #[test]
841    fn test_documentation_example_from_md051() {
842        let rule = MD013LineLength::new(80, false, false, false, false);
843
844        // This is the actual line from md051.md that was causing issues
845        let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
846        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
847        let result = rule.check(&ctx).unwrap();
848
849        // Should not flag because the URL is in a markdown link
850        assert_eq!(result.len(), 0);
851    }
852
853    #[test]
854    fn test_text_reflow_simple() {
855        let config = MD013Config {
856            line_length: 30,
857            reflow: true,
858            ..Default::default()
859        };
860        let rule = MD013LineLength::from_config_struct(config);
861
862        let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
863        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
864
865        let fixed = rule.fix(&ctx).unwrap();
866
867        // Verify all lines are under 30 chars
868        for line in fixed.lines() {
869            assert!(
870                line.chars().count() <= 30,
871                "Line too long: {} (len={})",
872                line,
873                line.chars().count()
874            );
875        }
876
877        // Verify content is preserved
878        let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
879        let original_words: Vec<&str> = content.split_whitespace().collect();
880        assert_eq!(fixed_words, original_words);
881    }
882
883    #[test]
884    fn test_text_reflow_preserves_markdown_elements() {
885        let config = MD013Config {
886            line_length: 40,
887            reflow: true,
888            ..Default::default()
889        };
890        let rule = MD013LineLength::from_config_struct(config);
891
892        let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
893        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
894
895        let fixed = rule.fix(&ctx).unwrap();
896
897        // Verify markdown elements are preserved
898        assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
899        assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
900        assert!(
901            fixed.contains("[a link](https://example.com)"),
902            "Link not preserved in: {fixed}"
903        );
904
905        // Verify all lines are under 40 chars
906        for line in fixed.lines() {
907            assert!(line.len() <= 40, "Line too long: {line}");
908        }
909    }
910
911    #[test]
912    fn test_text_reflow_preserves_code_blocks() {
913        let config = MD013Config {
914            line_length: 30,
915            reflow: true,
916            ..Default::default()
917        };
918        let rule = MD013LineLength::from_config_struct(config);
919
920        let content = r#"Here is some text.
921
922```python
923def very_long_function_name_that_exceeds_limit():
924    return "This should not be wrapped"
925```
926
927More text after code block."#;
928        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
929
930        let fixed = rule.fix(&ctx).unwrap();
931
932        // Verify code block is preserved
933        assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
934        assert!(fixed.contains("```python"));
935        assert!(fixed.contains("```"));
936    }
937
938    #[test]
939    fn test_text_reflow_preserves_lists() {
940        let config = MD013Config {
941            line_length: 30,
942            reflow: true,
943            ..Default::default()
944        };
945        let rule = MD013LineLength::from_config_struct(config);
946
947        let content = r#"Here is a list:
948
9491. First item with a very long line that needs wrapping
9502. Second item is short
9513. Third item also has a long line that exceeds the limit
952
953And a bullet list:
954
955- Bullet item with very long content that needs wrapping
956- Short bullet"#;
957        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
958
959        let fixed = rule.fix(&ctx).unwrap();
960
961        // Verify list structure is preserved
962        assert!(fixed.contains("1. "));
963        assert!(fixed.contains("2. "));
964        assert!(fixed.contains("3. "));
965        assert!(fixed.contains("- "));
966
967        // Verify proper indentation for wrapped lines
968        let lines: Vec<&str> = fixed.lines().collect();
969        for (i, line) in lines.iter().enumerate() {
970            if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
971                // Check if next line is a continuation (should be indented with 3 spaces for numbered lists)
972                if i + 1 < lines.len()
973                    && !lines[i + 1].trim().is_empty()
974                    && !lines[i + 1].trim().starts_with(char::is_numeric)
975                    && !lines[i + 1].trim().starts_with("-")
976                {
977                    // Numbered list continuation lines should have 3 spaces
978                    assert!(lines[i + 1].starts_with("   ") || lines[i + 1].trim().is_empty());
979                }
980            } else if line.trim().starts_with("-") {
981                // Check if next line is a continuation (should be indented with 2 spaces for dash lists)
982                if i + 1 < lines.len()
983                    && !lines[i + 1].trim().is_empty()
984                    && !lines[i + 1].trim().starts_with(char::is_numeric)
985                    && !lines[i + 1].trim().starts_with("-")
986                {
987                    // Dash list continuation lines should have 2 spaces
988                    assert!(lines[i + 1].starts_with("  ") || lines[i + 1].trim().is_empty());
989                }
990            }
991        }
992    }
993
994    #[test]
995    fn test_text_reflow_disabled_by_default() {
996        let rule = MD013LineLength::new(30, false, false, false, false);
997
998        let content = "This is a very long line that definitely exceeds thirty characters.";
999        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1000
1001        let fixed = rule.fix(&ctx).unwrap();
1002
1003        // Without reflow enabled, it should only trim whitespace (if any)
1004        // Since there's no trailing whitespace, content should be unchanged
1005        assert_eq!(fixed, content);
1006    }
1007
1008    #[test]
1009    fn test_reflow_with_hard_line_breaks() {
1010        // Test that lines with exactly 2 trailing spaces are preserved as hard breaks
1011        let config = MD013Config {
1012            line_length: 40,
1013            reflow: true,
1014            ..Default::default()
1015        };
1016        let rule = MD013LineLength::from_config_struct(config);
1017
1018        // Test with exactly 2 spaces (hard line break)
1019        let content = "This line has a hard break at the end  \nAnd this continues on the next line that is also quite long and needs wrapping";
1020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1021        let fixed = rule.fix(&ctx).unwrap();
1022
1023        // Should preserve the hard line break (2 spaces)
1024        assert!(
1025            fixed.contains("  \n"),
1026            "Hard line break with exactly 2 spaces should be preserved"
1027        );
1028    }
1029
1030    #[test]
1031    fn test_reflow_preserves_reference_links() {
1032        let config = MD013Config {
1033            line_length: 40,
1034            reflow: true,
1035            ..Default::default()
1036        };
1037        let rule = MD013LineLength::from_config_struct(config);
1038
1039        let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
1040
1041[ref]: https://example.com";
1042        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1043        let fixed = rule.fix(&ctx).unwrap();
1044
1045        // Reference link should remain intact
1046        assert!(fixed.contains("[reference link][ref]"));
1047        assert!(!fixed.contains("[ reference link]"));
1048        assert!(!fixed.contains("[ref ]"));
1049    }
1050
1051    #[test]
1052    fn test_reflow_with_nested_markdown_elements() {
1053        let config = MD013Config {
1054            line_length: 35,
1055            reflow: true,
1056            ..Default::default()
1057        };
1058        let rule = MD013LineLength::from_config_struct(config);
1059
1060        let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
1061        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1062        let fixed = rule.fix(&ctx).unwrap();
1063
1064        // Nested elements should be preserved
1065        assert!(fixed.contains("**bold with `code` inside**"));
1066    }
1067
1068    #[test]
1069    fn test_reflow_with_unbalanced_markdown() {
1070        // Test edge case with unbalanced markdown
1071        let config = MD013Config {
1072            line_length: 30,
1073            reflow: true,
1074            ..Default::default()
1075        };
1076        let rule = MD013LineLength::from_config_struct(config);
1077
1078        let content = "This has **unbalanced bold that goes on for a very long time without closing";
1079        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1080        let fixed = rule.fix(&ctx).unwrap();
1081
1082        // Should handle gracefully without panic
1083        // The text reflow handles unbalanced markdown by treating it as a bold element
1084        // Check that the content is properly reflowed without panic
1085        assert!(!fixed.is_empty());
1086        // Verify the content is wrapped to 30 chars
1087        for line in fixed.lines() {
1088            assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
1089        }
1090    }
1091
1092    #[test]
1093    fn test_reflow_fix_indicator() {
1094        // Test that reflow provides fix indicators
1095        let config = MD013Config {
1096            line_length: 30,
1097            reflow: true,
1098            ..Default::default()
1099        };
1100        let rule = MD013LineLength::from_config_struct(config);
1101
1102        let content = "This is a very long line that definitely exceeds the thirty character limit";
1103        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1104        let warnings = rule.check(&ctx).unwrap();
1105
1106        // Should have a fix indicator when reflow is true
1107        assert!(!warnings.is_empty());
1108        assert!(
1109            warnings[0].fix.is_some(),
1110            "Should provide fix indicator when reflow is true"
1111        );
1112    }
1113
1114    #[test]
1115    fn test_no_fix_indicator_without_reflow() {
1116        // Test that without reflow, no fix is provided
1117        let config = MD013Config {
1118            line_length: 30,
1119            reflow: false,
1120            ..Default::default()
1121        };
1122        let rule = MD013LineLength::from_config_struct(config);
1123
1124        let content = "This is a very long line that definitely exceeds the thirty character limit";
1125        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1126        let warnings = rule.check(&ctx).unwrap();
1127
1128        // Should NOT have a fix indicator when reflow is false
1129        assert!(!warnings.is_empty());
1130        assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
1131    }
1132
1133    #[test]
1134    fn test_reflow_preserves_all_reference_link_types() {
1135        let config = MD013Config {
1136            line_length: 40,
1137            reflow: true,
1138            ..Default::default()
1139        };
1140        let rule = MD013LineLength::from_config_struct(config);
1141
1142        let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
1143
1144[ref]: https://example.com
1145[collapsed]: https://example.com
1146[shortcut]: https://example.com";
1147
1148        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1149        let fixed = rule.fix(&ctx).unwrap();
1150
1151        // All reference link types should be preserved
1152        assert!(fixed.contains("[full reference][ref]"));
1153        assert!(fixed.contains("[collapsed][]"));
1154        assert!(fixed.contains("[shortcut]"));
1155    }
1156
1157    #[test]
1158    fn test_reflow_handles_images_correctly() {
1159        let config = MD013Config {
1160            line_length: 40,
1161            reflow: true,
1162            ..Default::default()
1163        };
1164        let rule = MD013LineLength::from_config_struct(config);
1165
1166        let content = "This line has an ![image alt text](https://example.com/image.png) that should not be broken when reflowing.";
1167        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1168        let fixed = rule.fix(&ctx).unwrap();
1169
1170        // Image should remain intact
1171        assert!(fixed.contains("![image alt text](https://example.com/image.png)"));
1172    }
1173}