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