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::LineIndex;
8use crate::utils::range_utils::calculate_excess_range;
9use crate::utils::regex_cache::{
10    IMAGE_REF_PATTERN, INLINE_LINK_REGEX as MARKDOWN_LINK_PATTERN, LINK_REF_PATTERN, URL_IN_TEXT, URL_PATTERN,
11};
12use crate::utils::table_utils::TableUtils;
13use toml;
14
15pub mod md013_config;
16use md013_config::{MD013Config, ReflowMode};
17
18#[derive(Clone, Default)]
19pub struct MD013LineLength {
20    pub(crate) config: MD013Config,
21}
22
23impl MD013LineLength {
24    pub fn new(line_length: usize, code_blocks: bool, tables: bool, headings: bool, strict: bool) -> Self {
25        Self {
26            config: MD013Config {
27                line_length,
28                code_blocks,
29                tables,
30                headings,
31                strict,
32                reflow: false,
33                reflow_mode: ReflowMode::default(),
34            },
35        }
36    }
37
38    pub fn from_config_struct(config: MD013Config) -> Self {
39        Self { config }
40    }
41
42    fn should_ignore_line(
43        &self,
44        line: &str,
45        _lines: &[&str],
46        current_line: usize,
47        structure: &DocumentStructure,
48    ) -> bool {
49        if self.config.strict {
50            return false;
51        }
52
53        // Quick check for common patterns before expensive regex
54        let trimmed = line.trim();
55
56        // Only skip if the entire line is a URL (quick check first)
57        if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
58            return true;
59        }
60
61        // Only skip if the entire line is an image reference (quick check first)
62        if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
63            return true;
64        }
65
66        // Only skip if the entire line is a link reference (quick check first)
67        if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
68            return true;
69        }
70
71        // Code blocks with long strings (only check if in code block)
72        if structure.is_in_code_block(current_line + 1)
73            && !trimmed.is_empty()
74            && !line.contains(' ')
75            && !line.contains('\t')
76        {
77            return true;
78        }
79
80        false
81    }
82}
83
84impl Rule for MD013LineLength {
85    fn name(&self) -> &'static str {
86        "MD013"
87    }
88
89    fn description(&self) -> &'static str {
90        "Line length should not be excessive"
91    }
92
93    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
94        let content = ctx.content;
95
96        // Early return for empty content
97        if content.is_empty() {
98            return Ok(Vec::new());
99        }
100
101        // Quick check: if total content is shorter than line limit, definitely no violations
102        // BUT: in normalize mode with reflow, we still want to check for multi-line paragraphs
103        if content.len() <= self.config.line_length
104            && !(self.config.reflow && self.config.reflow_mode == ReflowMode::Normalize)
105        {
106            return Ok(Vec::new());
107        }
108
109        // More aggressive early return - check if any line could possibly be long
110        let has_long_lines = if !ctx.lines.is_empty() {
111            ctx.lines
112                .iter()
113                .any(|line| line.content.len() > self.config.line_length)
114        } else {
115            // Fallback: do a quick scan for newlines to estimate max line length
116            let mut max_line_len = 0;
117            let mut current_line_len = 0;
118            for ch in content.chars() {
119                if ch == '\n' {
120                    max_line_len = max_line_len.max(current_line_len);
121                    current_line_len = 0;
122                } else {
123                    current_line_len += 1;
124                }
125            }
126            max_line_len = max_line_len.max(current_line_len);
127            max_line_len > self.config.line_length
128        };
129
130        // In normalize mode, we want to continue even if no long lines
131        if !(has_long_lines || self.config.reflow && self.config.reflow_mode == ReflowMode::Normalize) {
132            return Ok(Vec::new());
133        }
134
135        // Create structure manually
136        let structure = DocumentStructure::new(content);
137        self.check_with_structure(ctx, &structure)
138    }
139
140    /// Optimized check using pre-computed document structure
141    fn check_with_structure(
142        &self,
143        ctx: &crate::lint_context::LintContext,
144        structure: &DocumentStructure,
145    ) -> LintResult {
146        let content = ctx.content;
147        let mut warnings = Vec::new();
148
149        // Early return was already done in check(), so we know there are long lines
150
151        // Check for inline configuration overrides
152        let inline_config = crate::inline_config::InlineConfig::from_content(content);
153        let config_override = inline_config.get_rule_config("MD013");
154
155        // Apply configuration override if present
156        let effective_config = if let Some(json_config) = config_override {
157            if let Some(obj) = json_config.as_object() {
158                let mut config = self.config.clone();
159                if let Some(line_length) = obj.get("line_length").and_then(|v| v.as_u64()) {
160                    config.line_length = line_length as usize;
161                }
162                if let Some(code_blocks) = obj.get("code_blocks").and_then(|v| v.as_bool()) {
163                    config.code_blocks = code_blocks;
164                }
165                if let Some(tables) = obj.get("tables").and_then(|v| v.as_bool()) {
166                    config.tables = tables;
167                }
168                if let Some(headings) = obj.get("headings").and_then(|v| v.as_bool()) {
169                    config.headings = headings;
170                }
171                if let Some(strict) = obj.get("strict").and_then(|v| v.as_bool()) {
172                    config.strict = strict;
173                }
174                if let Some(reflow) = obj.get("reflow").and_then(|v| v.as_bool()) {
175                    config.reflow = reflow;
176                }
177                if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
178                    config.reflow_mode = match reflow_mode {
179                        "default" => ReflowMode::Default,
180                        "normalize" => ReflowMode::Normalize,
181                        _ => ReflowMode::default(),
182                    };
183                }
184                config
185            } else {
186                self.config.clone()
187            }
188        } else {
189            self.config.clone()
190        };
191
192        // Use ctx.lines if available for better performance
193        let lines: Vec<&str> = if !ctx.lines.is_empty() {
194            ctx.lines.iter().map(|l| l.content.as_str()).collect()
195        } else {
196            content.lines().collect()
197        };
198
199        // Create a quick lookup set for heading lines
200        let heading_lines_set: std::collections::HashSet<usize> = structure.heading_lines.iter().cloned().collect();
201
202        // Use TableUtils to find all table blocks in the document
203        let table_blocks = TableUtils::find_table_blocks(content, ctx);
204
205        // Pre-compute table lines from the table blocks
206        let table_lines_set: std::collections::HashSet<usize> = {
207            let mut table_lines = std::collections::HashSet::new();
208
209            for table in &table_blocks {
210                // Add header line
211                table_lines.insert(table.header_line + 1); // Convert 0-indexed to 1-indexed
212                // Add delimiter line
213                table_lines.insert(table.delimiter_line + 1);
214                // Add all content lines
215                for &line in &table.content_lines {
216                    table_lines.insert(line + 1); // Convert 0-indexed to 1-indexed
217                }
218            }
219            table_lines
220        };
221
222        for (line_num, line) in lines.iter().enumerate() {
223            let line_number = line_num + 1;
224
225            // Calculate effective length excluding unbreakable URLs
226            let effective_length = self.calculate_effective_length(line);
227
228            // Use single line length limit for all content
229            let line_limit = effective_config.line_length;
230
231            // Skip short lines immediately
232            if effective_length <= line_limit {
233                continue;
234            }
235
236            // Skip various block types efficiently
237            if !effective_config.strict {
238                // Skip setext heading underlines
239                if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
240                    continue;
241                }
242
243                // Skip block elements according to config flags
244                // The flags mean: true = check these elements, false = skip these elements
245                // So we skip when the flag is FALSE and the line is in that element type
246                if (!effective_config.headings && heading_lines_set.contains(&line_number))
247                    || (!effective_config.code_blocks && structure.is_in_code_block(line_number))
248                    || (!effective_config.tables && table_lines_set.contains(&line_number))
249                    || structure.is_in_blockquote(line_number)
250                    || structure.is_in_html_block(line_number)
251                {
252                    continue;
253                }
254
255                // Skip lines that are only a URL, image ref, or link ref
256                if self.should_ignore_line(line, &lines, line_num, structure) {
257                    continue;
258                }
259            }
260
261            // Don't provide fix for individual lines when reflow is enabled
262            // Paragraph-based fixes will be handled separately
263            let fix = None;
264
265            let message = format!("Line length {effective_length} exceeds {line_limit} characters");
266
267            // Calculate precise character range for the excess portion
268            let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
269
270            warnings.push(LintWarning {
271                rule_name: Some(self.name()),
272                message,
273                line: start_line,
274                column: start_col,
275                end_line,
276                end_column: end_col,
277                severity: Severity::Warning,
278                fix,
279            });
280        }
281
282        // If reflow is enabled, generate paragraph-based fixes
283        if effective_config.reflow {
284            let paragraph_warnings = self.generate_paragraph_fixes(ctx, structure, &effective_config, &lines);
285            // Merge paragraph warnings with line warnings, removing duplicates
286            for pw in paragraph_warnings {
287                // Remove any line warnings that overlap with this paragraph
288                warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
289                warnings.push(pw);
290            }
291        }
292
293        Ok(warnings)
294    }
295
296    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
297        // For CLI usage, apply fixes from warnings
298        // LSP will use the warning-based fixes directly
299        let warnings = self.check(ctx)?;
300
301        // If there are no fixes, return content unchanged
302        if !warnings.iter().any(|w| w.fix.is_some()) {
303            return Ok(ctx.content.to_string());
304        }
305
306        // Apply warning-based fixes
307        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
308            .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
309    }
310
311    fn as_any(&self) -> &dyn std::any::Any {
312        self
313    }
314
315    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
316        Some(self)
317    }
318
319    fn category(&self) -> RuleCategory {
320        RuleCategory::Whitespace
321    }
322
323    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
324        // Skip if content is empty
325        if ctx.content.is_empty() {
326            return true;
327        }
328
329        // Quick check: if total content is shorter than line limit, definitely skip
330        if ctx.content.len() <= self.config.line_length {
331            return true;
332        }
333
334        // Use more efficient check - any() with early termination instead of all()
335        !ctx.lines
336            .iter()
337            .any(|line| line.content.len() > self.config.line_length)
338    }
339
340    fn default_config_section(&self) -> Option<(String, toml::Value)> {
341        let default_config = MD013Config::default();
342        let json_value = serde_json::to_value(&default_config).ok()?;
343        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
344
345        if let toml::Value::Table(table) = toml_value {
346            if !table.is_empty() {
347                Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
348            } else {
349                None
350            }
351        } else {
352            None
353        }
354    }
355
356    fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
357        let mut aliases = std::collections::HashMap::new();
358        aliases.insert("enable_reflow".to_string(), "reflow".to_string());
359        Some(aliases)
360    }
361
362    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
363    where
364        Self: Sized,
365    {
366        let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
367        // Special handling for line_length from global config
368        if rule_config.line_length == 80 {
369            // default value
370            rule_config.line_length = config.global.line_length as usize;
371        }
372        Box::new(Self::from_config_struct(rule_config))
373    }
374}
375
376impl MD013LineLength {
377    /// Generate paragraph-based fixes
378    fn generate_paragraph_fixes(
379        &self,
380        ctx: &crate::lint_context::LintContext,
381        structure: &DocumentStructure,
382        config: &MD013Config,
383        lines: &[&str],
384    ) -> Vec<LintWarning> {
385        let mut warnings = Vec::new();
386        let line_index = LineIndex::new(ctx.content.to_string());
387
388        let mut i = 0;
389        while i < lines.len() {
390            let line_num = i + 1;
391
392            // Skip special structures
393            if structure.is_in_code_block(line_num)
394                || structure.is_in_html_block(line_num)
395                || structure.is_in_blockquote(line_num)
396                || lines[i].trim().starts_with('#')
397                || TableUtils::is_potential_table_row(lines[i])
398                || lines[i].trim().is_empty()
399                || is_horizontal_rule(lines[i].trim())
400            {
401                i += 1;
402                continue;
403            }
404
405            // Check if this is a list item - handle it specially
406            let trimmed = lines[i].trim();
407            if is_list_item(trimmed) {
408                // Collect the entire list item including continuation lines
409                let list_start = i;
410                let (marker, first_content) = extract_list_marker_and_content(lines[i]);
411                let indent_size = marker.len();
412                let expected_indent = " ".repeat(indent_size);
413
414                let mut list_item_lines = vec![first_content];
415                i += 1;
416
417                // Collect continuation lines (lines that are indented to match the list marker)
418                while i < lines.len() {
419                    let line = lines[i];
420                    // Check if this line is indented as a continuation
421                    if line.starts_with(&expected_indent) && !line.trim().is_empty() {
422                        // Check if this is actually a nested list item
423                        let content_after_indent = &line[indent_size..];
424                        if is_list_item(content_after_indent.trim()) {
425                            // This is a nested list item, not a continuation
426                            break;
427                        }
428                        // This is a continuation line
429                        let content = line[indent_size..].to_string();
430                        list_item_lines.push(content);
431                        i += 1;
432                    } else if line.trim().is_empty() {
433                        // Empty line might be part of the list item
434                        // Check if the next line is also indented
435                        if i + 1 < lines.len() && lines[i + 1].starts_with(&expected_indent) {
436                            list_item_lines.push(String::new());
437                            i += 1;
438                        } else {
439                            break;
440                        }
441                    } else {
442                        break;
443                    }
444                }
445
446                // Now check if this list item needs reflowing
447                let combined_content = list_item_lines.join(" ").trim().to_string();
448                let full_line = format!("{marker}{combined_content}");
449
450                if self.calculate_effective_length(&full_line) > config.line_length
451                    || (config.reflow_mode == ReflowMode::Normalize && list_item_lines.len() > 1)
452                {
453                    let start_range = line_index.whole_line_range(list_start + 1);
454                    let end_line = i - 1;
455                    let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
456                        line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
457                    } else {
458                        line_index.whole_line_range(end_line + 1)
459                    };
460                    let byte_range = start_range.start..end_range.end;
461
462                    // Reflow the content part
463                    let reflow_options = crate::utils::text_reflow::ReflowOptions {
464                        line_length: config.line_length - indent_size,
465                        break_on_sentences: true,
466                        preserve_breaks: false,
467                    };
468                    let reflowed = crate::utils::text_reflow::reflow_line(&combined_content, &reflow_options);
469
470                    // Rebuild with proper indentation
471                    let mut result = vec![format!("{marker}{}", reflowed[0])];
472                    for line in reflowed.iter().skip(1) {
473                        result.push(format!("{expected_indent}{line}"));
474                    }
475                    let reflowed_text = result.join("\n");
476
477                    // Preserve trailing newline
478                    let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
479                        format!("{reflowed_text}\n")
480                    } else {
481                        reflowed_text
482                    };
483
484                    warnings.push(LintWarning {
485                        rule_name: Some(self.name()),
486                        message: format!(
487                            "Line length exceeds {} characters and can be reflowed",
488                            config.line_length
489                        ),
490                        line: list_start + 1,
491                        column: 1,
492                        end_line: end_line + 1,
493                        end_column: lines[end_line].len() + 1,
494                        severity: Severity::Warning,
495                        fix: Some(crate::rule::Fix {
496                            range: byte_range,
497                            replacement,
498                        }),
499                    });
500                }
501                continue;
502            }
503
504            // Found start of a paragraph - collect all lines in it
505            let paragraph_start = i;
506            let mut paragraph_lines = vec![lines[i]];
507            i += 1;
508
509            while i < lines.len() {
510                let next_line = lines[i];
511                let next_line_num = i + 1;
512                let next_trimmed = next_line.trim();
513
514                // Stop at paragraph boundaries
515                if next_trimmed.is_empty()
516                    || structure.is_in_code_block(next_line_num)
517                    || structure.is_in_html_block(next_line_num)
518                    || structure.is_in_blockquote(next_line_num)
519                    || next_trimmed.starts_with('#')
520                    || TableUtils::is_potential_table_row(next_line)
521                    || is_list_item(next_trimmed)
522                    || is_horizontal_rule(next_trimmed)
523                    || (next_trimmed.starts_with('[') && next_line.contains("]:"))
524                {
525                    break;
526                }
527
528                // Check if the previous line ends with a hard break (2+ spaces)
529                if i > 0 && lines[i - 1].ends_with("  ") {
530                    // Don't include lines after hard breaks in the same paragraph
531                    break;
532                }
533
534                paragraph_lines.push(next_line);
535                i += 1;
536            }
537
538            // Check if this paragraph needs reflowing
539            let needs_reflow = if config.reflow_mode == ReflowMode::Normalize {
540                // In normalize mode, reflow multi-line paragraphs
541                paragraph_lines.len() > 1
542            } else {
543                // In default mode, only reflow if lines exceed limit
544                paragraph_lines
545                    .iter()
546                    .any(|line| self.calculate_effective_length(line) > config.line_length)
547            };
548
549            if needs_reflow {
550                // Calculate byte range for this paragraph
551                // Use whole_line_range for each line and combine
552                let start_range = line_index.whole_line_range(paragraph_start + 1);
553                let end_line = paragraph_start + paragraph_lines.len() - 1;
554
555                // For the last line, we want to preserve any trailing newline
556                let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
557                    // Last line without trailing newline - use line_text_range
558                    line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
559                } else {
560                    // Not the last line or has trailing newline - use whole_line_range
561                    line_index.whole_line_range(end_line + 1)
562                };
563
564                let byte_range = start_range.start..end_range.end;
565
566                // Combine paragraph lines into a single string for reflowing
567                let paragraph_text = paragraph_lines.join(" ");
568
569                // Check if the paragraph ends with a hard break
570                let has_hard_break = paragraph_lines.last().is_some_and(|l| l.ends_with("  "));
571
572                // Reflow the paragraph
573                let reflow_options = crate::utils::text_reflow::ReflowOptions {
574                    line_length: config.line_length,
575                    break_on_sentences: true,
576                    preserve_breaks: false,
577                };
578                let mut reflowed = crate::utils::text_reflow::reflow_line(&paragraph_text, &reflow_options);
579
580                // If the original paragraph ended with a hard break, preserve it
581                if has_hard_break && !reflowed.is_empty() {
582                    let last_idx = reflowed.len() - 1;
583                    if !reflowed[last_idx].ends_with("  ") {
584                        reflowed[last_idx].push_str("  ");
585                    }
586                }
587
588                let reflowed_text = reflowed.join("\n");
589
590                // Preserve trailing newline if the original paragraph had one
591                let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
592                    format!("{reflowed_text}\n")
593                } else {
594                    reflowed_text
595                };
596
597                // Create warning with actual fix
598                // In default mode, report the specific line that violates
599                // In normalize mode, report the whole paragraph
600                let (warning_line, warning_end_line) = if config.reflow_mode == ReflowMode::Normalize {
601                    (paragraph_start + 1, end_line + 1)
602                } else {
603                    // Find the first line that exceeds the limit
604                    let mut violating_line = paragraph_start;
605                    for (idx, line) in paragraph_lines.iter().enumerate() {
606                        if self.calculate_effective_length(line) > config.line_length {
607                            violating_line = paragraph_start + idx;
608                            break;
609                        }
610                    }
611                    (violating_line + 1, violating_line + 1)
612                };
613
614                warnings.push(LintWarning {
615                    rule_name: Some(self.name()),
616                    message: if config.reflow_mode == ReflowMode::Normalize {
617                        format!(
618                            "Paragraph could be normalized to use line length of {} characters",
619                            config.line_length
620                        )
621                    } else {
622                        format!(
623                            "Line length exceeds {} characters and can be reflowed",
624                            config.line_length
625                        )
626                    },
627                    line: warning_line,
628                    column: 1,
629                    end_line: warning_end_line,
630                    end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
631                    severity: Severity::Warning,
632                    fix: Some(crate::rule::Fix {
633                        range: byte_range,
634                        replacement,
635                    }),
636                });
637            }
638        }
639
640        warnings
641    }
642
643    /// Calculate effective line length excluding unbreakable URLs
644    fn calculate_effective_length(&self, line: &str) -> usize {
645        if self.config.strict {
646            // In strict mode, count everything
647            return line.chars().count();
648        }
649
650        // Quick check: if line doesn't contain "http" or "[", it can't have URLs or markdown links
651        if !line.contains("http") && !line.contains('[') {
652            return line.chars().count();
653        }
654
655        let mut effective_line = line.to_string();
656
657        // First handle markdown links to avoid double-counting URLs
658        // Pattern: [text](very-long-url) -> [text](url)
659        if line.contains('[') && line.contains("](") {
660            for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
661                if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
662                    && url.as_str().len() > 15
663                {
664                    let replacement = format!("[{}](url)", text.as_str());
665                    effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
666                }
667            }
668        }
669
670        // Then replace bare URLs with a placeholder of reasonable length
671        // This allows lines with long URLs to pass if the rest of the content is reasonable
672        if effective_line.contains("http") {
673            for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
674                let url = url_match.as_str();
675                // Skip if this URL is already part of a markdown link we handled
676                if !effective_line.contains(&format!("({url})")) {
677                    // Replace URL with placeholder that represents a "reasonable" URL length
678                    // Using 15 chars as a reasonable URL placeholder (e.g., "https://ex.com")
679                    let placeholder = "x".repeat(15.min(url.len()));
680                    effective_line = effective_line.replacen(url, &placeholder, 1);
681                }
682            }
683        }
684
685        effective_line.chars().count()
686    }
687}
688
689impl DocumentStructureExtensions for MD013LineLength {
690    fn has_relevant_elements(
691        &self,
692        ctx: &crate::lint_context::LintContext,
693        _doc_structure: &DocumentStructure,
694    ) -> bool {
695        // This rule always applies unless content is empty
696        !ctx.content.is_empty()
697    }
698}
699
700/// Extract list marker and content from a list item
701fn extract_list_marker_and_content(line: &str) -> (String, String) {
702    // First, find the leading indentation
703    let indent_len = line.len() - line.trim_start().len();
704    let indent = &line[..indent_len];
705    let trimmed = &line[indent_len..];
706
707    // Handle bullet lists
708    if let Some(rest) = trimmed.strip_prefix("- ") {
709        return (format!("{indent}- "), rest.to_string());
710    }
711    if let Some(rest) = trimmed.strip_prefix("* ") {
712        return (format!("{indent}* "), rest.to_string());
713    }
714    if let Some(rest) = trimmed.strip_prefix("+ ") {
715        return (format!("{indent}+ "), rest.to_string());
716    }
717
718    // Handle numbered lists on trimmed content
719    let mut chars = trimmed.chars();
720    let mut marker_content = String::new();
721
722    while let Some(c) = chars.next() {
723        marker_content.push(c);
724        if c == '.' {
725            // Check if next char is a space
726            if let Some(next) = chars.next()
727                && next == ' '
728            {
729                marker_content.push(next);
730                let content = chars.as_str().to_string();
731                return (format!("{indent}{marker_content}"), content);
732            }
733            break;
734        }
735    }
736
737    // Fallback - shouldn't happen if is_list_item was correct
738    (String::new(), line.to_string())
739}
740
741// Helper functions
742fn is_horizontal_rule(line: &str) -> bool {
743    if line.len() < 3 {
744        return false;
745    }
746    // Check if line consists only of -, _, or * characters (at least 3)
747    let chars: Vec<char> = line.chars().collect();
748    if chars.is_empty() {
749        return false;
750    }
751    let first_char = chars[0];
752    if first_char != '-' && first_char != '_' && first_char != '*' {
753        return false;
754    }
755    // All characters should be the same (allowing spaces between)
756    for c in &chars {
757        if *c != first_char && *c != ' ' {
758            return false;
759        }
760    }
761    // Must have at least 3 of the marker character
762    chars.iter().filter(|c| **c == first_char).count() >= 3
763}
764
765fn is_numbered_list_item(line: &str) -> bool {
766    let mut chars = line.chars();
767    // Must start with a digit
768    if !chars.next().is_some_and(|c| c.is_numeric()) {
769        return false;
770    }
771    // Can have more digits
772    while let Some(c) = chars.next() {
773        if c == '.' {
774            // After period, must have a space or be end of line
775            return chars.next().is_none_or(|c| c == ' ');
776        }
777        if !c.is_numeric() {
778            return false;
779        }
780    }
781    false
782}
783
784fn is_list_item(line: &str) -> bool {
785    // Bullet lists
786    if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
787        && line.len() > 1
788        && line.chars().nth(1) == Some(' ')
789    {
790        return true;
791    }
792    // Numbered lists
793    is_numbered_list_item(line)
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799    use crate::lint_context::LintContext;
800
801    #[test]
802    fn test_default_config() {
803        let rule = MD013LineLength::default();
804        assert_eq!(rule.config.line_length, 80);
805        assert!(rule.config.code_blocks); // Default is true
806        assert!(rule.config.tables); // Default is true
807        assert!(rule.config.headings); // Default is true
808        assert!(!rule.config.strict);
809    }
810
811    #[test]
812    fn test_custom_config() {
813        let rule = MD013LineLength::new(100, true, true, false, true);
814        assert_eq!(rule.config.line_length, 100);
815        assert!(rule.config.code_blocks);
816        assert!(rule.config.tables);
817        assert!(!rule.config.headings);
818        assert!(rule.config.strict);
819    }
820
821    #[test]
822    fn test_basic_line_length_violation() {
823        let rule = MD013LineLength::new(50, false, false, false, false);
824        let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
825        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
826        let result = rule.check(&ctx).unwrap();
827
828        assert_eq!(result.len(), 1);
829        assert!(result[0].message.contains("Line length"));
830        assert!(result[0].message.contains("exceeds 50 characters"));
831    }
832
833    #[test]
834    fn test_no_violation_under_limit() {
835        let rule = MD013LineLength::new(100, false, false, false, false);
836        let content = "Short line.\nAnother short line.";
837        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
838        let result = rule.check(&ctx).unwrap();
839
840        assert_eq!(result.len(), 0);
841    }
842
843    #[test]
844    fn test_multiple_violations() {
845        let rule = MD013LineLength::new(30, false, false, false, false);
846        let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
847        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
848        let result = rule.check(&ctx).unwrap();
849
850        assert_eq!(result.len(), 2);
851        assert_eq!(result[0].line, 1);
852        assert_eq!(result[1].line, 2);
853    }
854
855    #[test]
856    fn test_code_blocks_exemption() {
857        // With code_blocks = false, code blocks should be skipped
858        let rule = MD013LineLength::new(30, false, false, false, false);
859        let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
860        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
861        let result = rule.check(&ctx).unwrap();
862
863        assert_eq!(result.len(), 0);
864    }
865
866    #[test]
867    fn test_code_blocks_not_exempt_when_configured() {
868        // With code_blocks = true, code blocks should be checked
869        let rule = MD013LineLength::new(30, true, false, false, false);
870        let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
871        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
872        let result = rule.check(&ctx).unwrap();
873
874        assert!(!result.is_empty());
875    }
876
877    #[test]
878    fn test_heading_checked_when_enabled() {
879        let rule = MD013LineLength::new(30, false, false, true, false);
880        let content = "# This is a very long heading that would normally exceed the limit";
881        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
882        let result = rule.check(&ctx).unwrap();
883
884        assert_eq!(result.len(), 1);
885    }
886
887    #[test]
888    fn test_heading_exempt_when_disabled() {
889        let rule = MD013LineLength::new(30, false, false, false, false);
890        let content = "# This is a very long heading that should trigger a warning";
891        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
892        let result = rule.check(&ctx).unwrap();
893
894        assert_eq!(result.len(), 0);
895    }
896
897    #[test]
898    fn test_table_checked_when_enabled() {
899        let rule = MD013LineLength::new(30, false, true, false, false);
900        let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
901        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
902        let result = rule.check(&ctx).unwrap();
903
904        assert_eq!(result.len(), 2); // Both table lines exceed limit
905    }
906
907    #[test]
908    fn test_issue_78_tables_after_fenced_code_blocks() {
909        // Test for GitHub issue #78 - tables with tables=false after fenced code blocks
910        let rule = MD013LineLength::new(20, false, false, false, false); // tables=false
911        let content = r#"# heading
912
913```plain
914some code block longer than 20 chars length
915```
916
917this is a very long line
918
919| column A | column B |
920| -------- | -------- |
921| `var` | `val` |
922| value 1 | value 2 |
923
924correct length line"#;
925        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
926        let result = rule.check(&ctx).unwrap();
927
928        // Should only flag line 7 ("this is a very long line"), not the table lines
929        assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
930        assert_eq!(result[0].line, 7, "Should flag line 7");
931        assert!(result[0].message.contains("24 exceeds 20"));
932    }
933
934    #[test]
935    fn test_issue_78_tables_with_inline_code() {
936        // Test that tables with inline code (backticks) are properly detected as tables
937        let rule = MD013LineLength::new(20, false, false, false, false); // tables=false
938        let content = r#"| column A | column B |
939| -------- | -------- |
940| `var with very long name` | `val exceeding limit` |
941| value 1 | value 2 |
942
943This line exceeds limit"#;
944        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
945        let result = rule.check(&ctx).unwrap();
946
947        // Should only flag the last line, not the table lines
948        assert_eq!(result.len(), 1, "Should only flag the non-table line");
949        assert_eq!(result[0].line, 6, "Should flag line 6");
950    }
951
952    #[test]
953    fn test_issue_78_indented_code_blocks() {
954        // Test with indented code blocks instead of fenced
955        let rule = MD013LineLength::new(20, false, false, false, false); // tables=false
956        let content = r#"# heading
957
958    some code block longer than 20 chars length
959
960this is a very long line
961
962| column A | column B |
963| -------- | -------- |
964| value 1 | value 2 |
965
966correct length line"#;
967        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
968        let result = rule.check(&ctx).unwrap();
969
970        // Should only flag line 5 ("this is a very long line"), not the table lines
971        assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
972        assert_eq!(result[0].line, 5, "Should flag line 5");
973    }
974
975    #[test]
976    fn test_url_exemption() {
977        let rule = MD013LineLength::new(30, false, false, false, false);
978        let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
979        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
980        let result = rule.check(&ctx).unwrap();
981
982        assert_eq!(result.len(), 0);
983    }
984
985    #[test]
986    fn test_image_reference_exemption() {
987        let rule = MD013LineLength::new(30, false, false, false, false);
988        let content = "![This is a very long image alt text that exceeds limit][reference]";
989        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
990        let result = rule.check(&ctx).unwrap();
991
992        assert_eq!(result.len(), 0);
993    }
994
995    #[test]
996    fn test_link_reference_exemption() {
997        let rule = MD013LineLength::new(30, false, false, false, false);
998        let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
999        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1000        let result = rule.check(&ctx).unwrap();
1001
1002        assert_eq!(result.len(), 0);
1003    }
1004
1005    #[test]
1006    fn test_strict_mode() {
1007        let rule = MD013LineLength::new(30, false, false, false, true);
1008        let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1009        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1010        let result = rule.check(&ctx).unwrap();
1011
1012        // In strict mode, even URLs trigger warnings
1013        assert_eq!(result.len(), 1);
1014    }
1015
1016    #[test]
1017    fn test_blockquote_exemption() {
1018        let rule = MD013LineLength::new(30, false, false, false, false);
1019        let content = "> This is a very long line inside a blockquote that should be ignored.";
1020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1021        let result = rule.check(&ctx).unwrap();
1022
1023        assert_eq!(result.len(), 0);
1024    }
1025
1026    #[test]
1027    fn test_setext_heading_underline_exemption() {
1028        let rule = MD013LineLength::new(30, false, false, false, false);
1029        let content = "Heading\n========================================";
1030        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1031        let result = rule.check(&ctx).unwrap();
1032
1033        // The underline should be exempt
1034        assert_eq!(result.len(), 0);
1035    }
1036
1037    #[test]
1038    fn test_no_fix_without_reflow() {
1039        let rule = MD013LineLength::new(60, false, false, false, false);
1040        let content = "This line has trailing whitespace that makes it too long      ";
1041        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1042        let result = rule.check(&ctx).unwrap();
1043
1044        assert_eq!(result.len(), 1);
1045        // Without reflow, no fix is provided
1046        assert!(result[0].fix.is_none());
1047
1048        // Fix method returns content unchanged
1049        let fixed = rule.fix(&ctx).unwrap();
1050        assert_eq!(fixed, content);
1051    }
1052
1053    #[test]
1054    fn test_character_vs_byte_counting() {
1055        let rule = MD013LineLength::new(10, false, false, false, false);
1056        // Unicode characters should count as 1 character each
1057        let content = "你好世界这是测试文字超过限制"; // 14 characters
1058        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1059        let result = rule.check(&ctx).unwrap();
1060
1061        assert_eq!(result.len(), 1);
1062        assert_eq!(result[0].line, 1);
1063    }
1064
1065    #[test]
1066    fn test_empty_content() {
1067        let rule = MD013LineLength::default();
1068        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1069        let result = rule.check(&ctx).unwrap();
1070
1071        assert_eq!(result.len(), 0);
1072    }
1073
1074    #[test]
1075    fn test_excess_range_calculation() {
1076        let rule = MD013LineLength::new(10, false, false, false, false);
1077        let content = "12345678901234567890"; // 20 chars, limit is 10
1078        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1079        let result = rule.check(&ctx).unwrap();
1080
1081        assert_eq!(result.len(), 1);
1082        // The warning should highlight from character 11 onwards
1083        assert_eq!(result[0].column, 11);
1084        assert_eq!(result[0].end_column, 21);
1085    }
1086
1087    #[test]
1088    fn test_html_block_exemption() {
1089        let rule = MD013LineLength::new(30, false, false, false, false);
1090        let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1091        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1092        let result = rule.check(&ctx).unwrap();
1093
1094        // HTML blocks should be exempt
1095        assert_eq!(result.len(), 0);
1096    }
1097
1098    #[test]
1099    fn test_mixed_content() {
1100        // code_blocks=false, tables=false, headings=false (all skipped/exempt)
1101        let rule = MD013LineLength::new(30, false, false, false, false);
1102        let content = r#"# This heading is very long but should be exempt
1103
1104This regular paragraph line is too long and should trigger.
1105
1106```
1107Code block line that is very long but exempt.
1108```
1109
1110| Table | With very long content |
1111|-------|------------------------|
1112
1113Another long line that should trigger a warning."#;
1114
1115        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1116        let result = rule.check(&ctx).unwrap();
1117
1118        // Should have warnings for the two regular paragraph lines only
1119        assert_eq!(result.len(), 2);
1120        assert_eq!(result[0].line, 3);
1121        assert_eq!(result[1].line, 12);
1122    }
1123
1124    #[test]
1125    fn test_fix_without_reflow_preserves_content() {
1126        let rule = MD013LineLength::new(50, false, false, false, false);
1127        let content = "Line 1\nThis line has trailing spaces and is too long      \nLine 3";
1128        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1129
1130        // Without reflow, content is unchanged
1131        let fixed = rule.fix(&ctx).unwrap();
1132        assert_eq!(fixed, content);
1133    }
1134
1135    #[test]
1136    fn test_has_relevant_elements() {
1137        let rule = MD013LineLength::default();
1138        let structure = DocumentStructure::new("test");
1139
1140        let ctx = LintContext::new("Some content", crate::config::MarkdownFlavor::Standard);
1141        assert!(rule.has_relevant_elements(&ctx, &structure));
1142
1143        let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1144        assert!(!rule.has_relevant_elements(&empty_ctx, &structure));
1145    }
1146
1147    #[test]
1148    fn test_rule_metadata() {
1149        let rule = MD013LineLength::default();
1150        assert_eq!(rule.name(), "MD013");
1151        assert_eq!(rule.description(), "Line length should not be excessive");
1152        assert_eq!(rule.category(), RuleCategory::Whitespace);
1153    }
1154
1155    #[test]
1156    fn test_url_embedded_in_text() {
1157        let rule = MD013LineLength::new(50, false, false, false, false);
1158
1159        // This line would be 85 chars, but only ~45 without the URL
1160        let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1161        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1162        let result = rule.check(&ctx).unwrap();
1163
1164        // Should not flag because effective length (with URL placeholder) is under 50
1165        assert_eq!(result.len(), 0);
1166    }
1167
1168    #[test]
1169    fn test_multiple_urls_in_line() {
1170        let rule = MD013LineLength::new(50, false, false, false, false);
1171
1172        // Line with multiple URLs
1173        let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1174        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1175
1176        let result = rule.check(&ctx).unwrap();
1177
1178        // Should not flag because effective length is reasonable
1179        assert_eq!(result.len(), 0);
1180    }
1181
1182    #[test]
1183    fn test_markdown_link_with_long_url() {
1184        let rule = MD013LineLength::new(50, false, false, false, false);
1185
1186        // Markdown link with very long URL
1187        let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1188        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1189        let result = rule.check(&ctx).unwrap();
1190
1191        // Should not flag because effective length counts link as short
1192        assert_eq!(result.len(), 0);
1193    }
1194
1195    #[test]
1196    fn test_line_too_long_even_without_urls() {
1197        let rule = MD013LineLength::new(50, false, false, false, false);
1198
1199        // Line that's too long even after URL exclusion
1200        let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1201        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1202        let result = rule.check(&ctx).unwrap();
1203
1204        // Should flag because even with URL placeholder, line is too long
1205        assert_eq!(result.len(), 1);
1206    }
1207
1208    #[test]
1209    fn test_strict_mode_counts_urls() {
1210        let rule = MD013LineLength::new(50, false, false, false, true); // strict=true
1211
1212        // Same line that passes in non-strict mode
1213        let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1214        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1215        let result = rule.check(&ctx).unwrap();
1216
1217        // In strict mode, should flag because full URL is counted
1218        assert_eq!(result.len(), 1);
1219    }
1220
1221    #[test]
1222    fn test_documentation_example_from_md051() {
1223        let rule = MD013LineLength::new(80, false, false, false, false);
1224
1225        // This is the actual line from md051.md that was causing issues
1226        let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1227        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1228        let result = rule.check(&ctx).unwrap();
1229
1230        // Should not flag because the URL is in a markdown link
1231        assert_eq!(result.len(), 0);
1232    }
1233
1234    #[test]
1235    fn test_text_reflow_simple() {
1236        let config = MD013Config {
1237            line_length: 30,
1238            reflow: true,
1239            ..Default::default()
1240        };
1241        let rule = MD013LineLength::from_config_struct(config);
1242
1243        let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1244        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1245
1246        let fixed = rule.fix(&ctx).unwrap();
1247
1248        // Verify all lines are under 30 chars
1249        for line in fixed.lines() {
1250            assert!(
1251                line.chars().count() <= 30,
1252                "Line too long: {} (len={})",
1253                line,
1254                line.chars().count()
1255            );
1256        }
1257
1258        // Verify content is preserved
1259        let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1260        let original_words: Vec<&str> = content.split_whitespace().collect();
1261        assert_eq!(fixed_words, original_words);
1262    }
1263
1264    #[test]
1265    fn test_text_reflow_preserves_markdown_elements() {
1266        let config = MD013Config {
1267            line_length: 40,
1268            reflow: true,
1269            ..Default::default()
1270        };
1271        let rule = MD013LineLength::from_config_struct(config);
1272
1273        let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1274        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1275
1276        let fixed = rule.fix(&ctx).unwrap();
1277
1278        // Verify markdown elements are preserved
1279        assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1280        assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1281        assert!(
1282            fixed.contains("[a link](https://example.com)"),
1283            "Link not preserved in: {fixed}"
1284        );
1285
1286        // Verify all lines are under 40 chars
1287        for line in fixed.lines() {
1288            assert!(line.len() <= 40, "Line too long: {line}");
1289        }
1290    }
1291
1292    #[test]
1293    fn test_text_reflow_preserves_code_blocks() {
1294        let config = MD013Config {
1295            line_length: 30,
1296            reflow: true,
1297            ..Default::default()
1298        };
1299        let rule = MD013LineLength::from_config_struct(config);
1300
1301        let content = r#"Here is some text.
1302
1303```python
1304def very_long_function_name_that_exceeds_limit():
1305    return "This should not be wrapped"
1306```
1307
1308More text after code block."#;
1309        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1310
1311        let fixed = rule.fix(&ctx).unwrap();
1312
1313        // Verify code block is preserved
1314        assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1315        assert!(fixed.contains("```python"));
1316        assert!(fixed.contains("```"));
1317    }
1318
1319    #[test]
1320    fn test_text_reflow_preserves_lists() {
1321        let config = MD013Config {
1322            line_length: 30,
1323            reflow: true,
1324            ..Default::default()
1325        };
1326        let rule = MD013LineLength::from_config_struct(config);
1327
1328        let content = r#"Here is a list:
1329
13301. First item with a very long line that needs wrapping
13312. Second item is short
13323. Third item also has a long line that exceeds the limit
1333
1334And a bullet list:
1335
1336- Bullet item with very long content that needs wrapping
1337- Short bullet"#;
1338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1339
1340        let fixed = rule.fix(&ctx).unwrap();
1341
1342        // Verify list structure is preserved
1343        assert!(fixed.contains("1. "));
1344        assert!(fixed.contains("2. "));
1345        assert!(fixed.contains("3. "));
1346        assert!(fixed.contains("- "));
1347
1348        // Verify proper indentation for wrapped lines
1349        let lines: Vec<&str> = fixed.lines().collect();
1350        for (i, line) in lines.iter().enumerate() {
1351            if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
1352                // Check if next line is a continuation (should be indented with 3 spaces for numbered lists)
1353                if i + 1 < lines.len()
1354                    && !lines[i + 1].trim().is_empty()
1355                    && !lines[i + 1].trim().starts_with(char::is_numeric)
1356                    && !lines[i + 1].trim().starts_with("-")
1357                {
1358                    // Numbered list continuation lines should have 3 spaces
1359                    assert!(lines[i + 1].starts_with("   ") || lines[i + 1].trim().is_empty());
1360                }
1361            } else if line.trim().starts_with("-") {
1362                // Check if next line is a continuation (should be indented with 2 spaces for dash lists)
1363                if i + 1 < lines.len()
1364                    && !lines[i + 1].trim().is_empty()
1365                    && !lines[i + 1].trim().starts_with(char::is_numeric)
1366                    && !lines[i + 1].trim().starts_with("-")
1367                {
1368                    // Dash list continuation lines should have 2 spaces
1369                    assert!(lines[i + 1].starts_with("  ") || lines[i + 1].trim().is_empty());
1370                }
1371            }
1372        }
1373    }
1374
1375    #[test]
1376    fn test_issue_83_numbered_list_with_backticks() {
1377        // Test for issue #83: enable_reflow was incorrectly handling numbered lists
1378        let config = MD013Config {
1379            line_length: 100,
1380            reflow: true,
1381            ..Default::default()
1382        };
1383        let rule = MD013LineLength::from_config_struct(config);
1384
1385        // The exact case from issue #83
1386        let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
1387        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1388
1389        let fixed = rule.fix(&ctx).unwrap();
1390
1391        // The expected output: properly wrapped at 100 chars with correct list formatting
1392        // After the fix, it correctly accounts for "1. " (3 chars) leaving 97 for content
1393        let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n   `00000000000000000002.manifest` in this example.";
1394
1395        assert_eq!(
1396            fixed, expected,
1397            "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
1398        );
1399    }
1400
1401    #[test]
1402    fn test_text_reflow_disabled_by_default() {
1403        let rule = MD013LineLength::new(30, false, false, false, false);
1404
1405        let content = "This is a very long line that definitely exceeds thirty characters.";
1406        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1407
1408        let fixed = rule.fix(&ctx).unwrap();
1409
1410        // Without reflow enabled, it should only trim whitespace (if any)
1411        // Since there's no trailing whitespace, content should be unchanged
1412        assert_eq!(fixed, content);
1413    }
1414
1415    #[test]
1416    fn test_reflow_with_hard_line_breaks() {
1417        // Test that lines with exactly 2 trailing spaces are preserved as hard breaks
1418        let config = MD013Config {
1419            line_length: 40,
1420            reflow: true,
1421            ..Default::default()
1422        };
1423        let rule = MD013LineLength::from_config_struct(config);
1424
1425        // Test with exactly 2 spaces (hard line break)
1426        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";
1427        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1428        let fixed = rule.fix(&ctx).unwrap();
1429
1430        // Should preserve the hard line break (2 spaces)
1431        assert!(
1432            fixed.contains("  \n"),
1433            "Hard line break with exactly 2 spaces should be preserved"
1434        );
1435    }
1436
1437    #[test]
1438    fn test_reflow_preserves_reference_links() {
1439        let config = MD013Config {
1440            line_length: 40,
1441            reflow: true,
1442            ..Default::default()
1443        };
1444        let rule = MD013LineLength::from_config_struct(config);
1445
1446        let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
1447
1448[ref]: https://example.com";
1449        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1450        let fixed = rule.fix(&ctx).unwrap();
1451
1452        // Reference link should remain intact
1453        assert!(fixed.contains("[reference link][ref]"));
1454        assert!(!fixed.contains("[ reference link]"));
1455        assert!(!fixed.contains("[ref ]"));
1456    }
1457
1458    #[test]
1459    fn test_reflow_with_nested_markdown_elements() {
1460        let config = MD013Config {
1461            line_length: 35,
1462            reflow: true,
1463            ..Default::default()
1464        };
1465        let rule = MD013LineLength::from_config_struct(config);
1466
1467        let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
1468        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1469        let fixed = rule.fix(&ctx).unwrap();
1470
1471        // Nested elements should be preserved
1472        assert!(fixed.contains("**bold with `code` inside**"));
1473    }
1474
1475    #[test]
1476    fn test_reflow_with_unbalanced_markdown() {
1477        // Test edge case with unbalanced markdown
1478        let config = MD013Config {
1479            line_length: 30,
1480            reflow: true,
1481            ..Default::default()
1482        };
1483        let rule = MD013LineLength::from_config_struct(config);
1484
1485        let content = "This has **unbalanced bold that goes on for a very long time without closing";
1486        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1487        let fixed = rule.fix(&ctx).unwrap();
1488
1489        // Should handle gracefully without panic
1490        // The text reflow handles unbalanced markdown by treating it as a bold element
1491        // Check that the content is properly reflowed without panic
1492        assert!(!fixed.is_empty());
1493        // Verify the content is wrapped to 30 chars
1494        for line in fixed.lines() {
1495            assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
1496        }
1497    }
1498
1499    #[test]
1500    fn test_reflow_fix_indicator() {
1501        // Test that reflow provides fix indicators
1502        let config = MD013Config {
1503            line_length: 30,
1504            reflow: true,
1505            ..Default::default()
1506        };
1507        let rule = MD013LineLength::from_config_struct(config);
1508
1509        let content = "This is a very long line that definitely exceeds the thirty character limit";
1510        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1511        let warnings = rule.check(&ctx).unwrap();
1512
1513        // Should have a fix indicator when reflow is true
1514        assert!(!warnings.is_empty());
1515        assert!(
1516            warnings[0].fix.is_some(),
1517            "Should provide fix indicator when reflow is true"
1518        );
1519    }
1520
1521    #[test]
1522    fn test_no_fix_indicator_without_reflow() {
1523        // Test that without reflow, no fix is provided
1524        let config = MD013Config {
1525            line_length: 30,
1526            reflow: false,
1527            ..Default::default()
1528        };
1529        let rule = MD013LineLength::from_config_struct(config);
1530
1531        let content = "This is a very long line that definitely exceeds the thirty character limit";
1532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1533        let warnings = rule.check(&ctx).unwrap();
1534
1535        // Should NOT have a fix indicator when reflow is false
1536        assert!(!warnings.is_empty());
1537        assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
1538    }
1539
1540    #[test]
1541    fn test_reflow_preserves_all_reference_link_types() {
1542        let config = MD013Config {
1543            line_length: 40,
1544            reflow: true,
1545            ..Default::default()
1546        };
1547        let rule = MD013LineLength::from_config_struct(config);
1548
1549        let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
1550
1551[ref]: https://example.com
1552[collapsed]: https://example.com
1553[shortcut]: https://example.com";
1554
1555        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1556        let fixed = rule.fix(&ctx).unwrap();
1557
1558        // All reference link types should be preserved
1559        assert!(fixed.contains("[full reference][ref]"));
1560        assert!(fixed.contains("[collapsed][]"));
1561        assert!(fixed.contains("[shortcut]"));
1562    }
1563
1564    #[test]
1565    fn test_reflow_handles_images_correctly() {
1566        let config = MD013Config {
1567            line_length: 40,
1568            reflow: true,
1569            ..Default::default()
1570        };
1571        let rule = MD013LineLength::from_config_struct(config);
1572
1573        let content = "This line has an ![image alt text](https://example.com/image.png) that should not be broken when reflowing.";
1574        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1575        let fixed = rule.fix(&ctx).unwrap();
1576
1577        // Image should remain intact
1578        assert!(fixed.contains("![image alt text](https://example.com/image.png)"));
1579    }
1580
1581    #[test]
1582    fn test_normalize_mode_flags_short_lines() {
1583        let config = MD013Config {
1584            line_length: 100,
1585            reflow: true,
1586            reflow_mode: ReflowMode::Normalize,
1587            ..Default::default()
1588        };
1589        let rule = MD013LineLength::from_config_struct(config);
1590
1591        // Content with short lines that could be combined
1592        let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
1593        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1594        let warnings = rule.check(&ctx).unwrap();
1595
1596        // Should flag the paragraph as needing normalization
1597        assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1598        assert!(warnings[0].message.contains("normalized"));
1599    }
1600
1601    #[test]
1602    fn test_normalize_mode_combines_short_lines() {
1603        let config = MD013Config {
1604            line_length: 100,
1605            reflow: true,
1606            reflow_mode: ReflowMode::Normalize,
1607            ..Default::default()
1608        };
1609        let rule = MD013LineLength::from_config_struct(config);
1610
1611        // Content with short lines that should be combined
1612        let content =
1613            "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
1614        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1615        let fixed = rule.fix(&ctx).unwrap();
1616
1617        // Should combine into a single line since it's under 100 chars total
1618        let lines: Vec<&str> = fixed.lines().collect();
1619        assert_eq!(lines.len(), 1, "Should combine into single line");
1620        assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
1621    }
1622
1623    #[test]
1624    fn test_normalize_mode_preserves_paragraph_breaks() {
1625        let config = MD013Config {
1626            line_length: 100,
1627            reflow: true,
1628            reflow_mode: ReflowMode::Normalize,
1629            ..Default::default()
1630        };
1631        let rule = MD013LineLength::from_config_struct(config);
1632
1633        let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
1634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1635        let fixed = rule.fix(&ctx).unwrap();
1636
1637        // Should preserve paragraph breaks (empty lines)
1638        assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
1639
1640        let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
1641        assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
1642    }
1643
1644    #[test]
1645    fn test_default_mode_only_fixes_violations() {
1646        let config = MD013Config {
1647            line_length: 100,
1648            reflow: true,
1649            reflow_mode: ReflowMode::Default, // Default mode
1650            ..Default::default()
1651        };
1652        let rule = MD013LineLength::from_config_struct(config);
1653
1654        // Content with short lines that are NOT violations
1655        let content = "This is a short line.\nAnother short line.\nA third short line.";
1656        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1657        let warnings = rule.check(&ctx).unwrap();
1658
1659        // Should NOT flag anything in default mode
1660        assert!(warnings.is_empty(), "Should not flag short lines in default mode");
1661
1662        // Fix should preserve the short lines
1663        let fixed = rule.fix(&ctx).unwrap();
1664        assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
1665    }
1666
1667    #[test]
1668    fn test_normalize_mode_with_lists() {
1669        let config = MD013Config {
1670            line_length: 80,
1671            reflow: true,
1672            reflow_mode: ReflowMode::Normalize,
1673            ..Default::default()
1674        };
1675        let rule = MD013LineLength::from_config_struct(config);
1676
1677        let content = r#"A paragraph with
1678short lines.
1679
16801. List item with
1681   short lines
16822. Another item"#;
1683        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1684        let fixed = rule.fix(&ctx).unwrap();
1685
1686        // Should normalize the paragraph but preserve list structure
1687        let lines: Vec<&str> = fixed.lines().collect();
1688        assert!(lines[0].len() > 20, "First paragraph should be normalized");
1689        assert!(fixed.contains("1. "), "Should preserve list markers");
1690        assert!(fixed.contains("2. "), "Should preserve list markers");
1691    }
1692
1693    #[test]
1694    fn test_normalize_mode_with_code_blocks() {
1695        let config = MD013Config {
1696            line_length: 100,
1697            reflow: true,
1698            reflow_mode: ReflowMode::Normalize,
1699            ..Default::default()
1700        };
1701        let rule = MD013LineLength::from_config_struct(config);
1702
1703        let content = r#"A paragraph with
1704short lines.
1705
1706```
1707code block should not be normalized
1708even with short lines
1709```
1710
1711Another paragraph with
1712short lines."#;
1713        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1714        let fixed = rule.fix(&ctx).unwrap();
1715
1716        // Code block should be preserved as-is
1717        assert!(fixed.contains("code block should not be normalized\neven with short lines"));
1718        // But paragraphs should be normalized
1719        let lines: Vec<&str> = fixed.lines().collect();
1720        assert!(lines[0].len() > 20, "First paragraph should be normalized");
1721    }
1722
1723    #[test]
1724    fn test_issue_76_use_case() {
1725        // This tests the exact use case from issue #76
1726        let config = MD013Config {
1727            line_length: 999999, // Set absurdly high
1728            reflow: true,
1729            reflow_mode: ReflowMode::Normalize,
1730            ..Default::default()
1731        };
1732        let rule = MD013LineLength::from_config_struct(config);
1733
1734        // Content with manual line breaks at 80 characters (typical markdown)
1735        let content = "We've decided to eliminate line-breaks in paragraphs. The obvious solution is\nto disable MD013, and call it good. However, that doesn't deal with the\nexisting content's line-breaks. My initial thought was to set line_length to\n999999 and enable_reflow, but realised after doing so, that it never triggers\nthe error, so nothing happens.";
1736
1737        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1738
1739        // Should flag for normalization even though no lines exceed limit
1740        let warnings = rule.check(&ctx).unwrap();
1741        assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1742
1743        // Should combine into a single line
1744        let fixed = rule.fix(&ctx).unwrap();
1745        let lines: Vec<&str> = fixed.lines().collect();
1746        assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
1747        assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
1748    }
1749
1750    #[test]
1751    fn test_normalize_mode_single_line_unchanged() {
1752        // Single lines should not be flagged or changed
1753        let config = MD013Config {
1754            line_length: 100,
1755            reflow: true,
1756            reflow_mode: ReflowMode::Normalize,
1757            ..Default::default()
1758        };
1759        let rule = MD013LineLength::from_config_struct(config);
1760
1761        let content = "This is a single line that should not be changed.";
1762        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1763
1764        let warnings = rule.check(&ctx).unwrap();
1765        assert!(warnings.is_empty(), "Single line should not be flagged");
1766
1767        let fixed = rule.fix(&ctx).unwrap();
1768        assert_eq!(fixed, content, "Single line should remain unchanged");
1769    }
1770
1771    #[test]
1772    fn test_normalize_mode_with_inline_code() {
1773        let config = MD013Config {
1774            line_length: 80,
1775            reflow: true,
1776            reflow_mode: ReflowMode::Normalize,
1777            ..Default::default()
1778        };
1779        let rule = MD013LineLength::from_config_struct(config);
1780
1781        let content =
1782            "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
1783        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1784
1785        let warnings = rule.check(&ctx).unwrap();
1786        assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
1787
1788        let fixed = rule.fix(&ctx).unwrap();
1789        assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
1790        assert!(fixed.lines().count() < 3, "Lines should be combined");
1791    }
1792
1793    #[test]
1794    fn test_normalize_mode_with_emphasis() {
1795        let config = MD013Config {
1796            line_length: 100,
1797            reflow: true,
1798            reflow_mode: ReflowMode::Normalize,
1799            ..Default::default()
1800        };
1801        let rule = MD013LineLength::from_config_struct(config);
1802
1803        let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
1804        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1805
1806        let fixed = rule.fix(&ctx).unwrap();
1807        assert!(fixed.contains("**bold**"), "Bold should be preserved");
1808        assert!(fixed.contains("*italic*"), "Italic should be preserved");
1809        assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
1810    }
1811
1812    #[test]
1813    fn test_normalize_mode_respects_hard_breaks() {
1814        let config = MD013Config {
1815            line_length: 100,
1816            reflow: true,
1817            reflow_mode: ReflowMode::Normalize,
1818            ..Default::default()
1819        };
1820        let rule = MD013LineLength::from_config_struct(config);
1821
1822        // Two spaces at end of line = hard break
1823        let content = "First line with hard break  \nSecond line after break\nThird line";
1824        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1825
1826        let fixed = rule.fix(&ctx).unwrap();
1827        // Hard break should be preserved
1828        assert!(fixed.contains("  \n"), "Hard break should be preserved");
1829        // But lines without hard break should be combined
1830        assert!(
1831            fixed.contains("Second line after break Third line"),
1832            "Lines without hard break should combine"
1833        );
1834    }
1835
1836    #[test]
1837    fn test_normalize_mode_with_links() {
1838        let config = MD013Config {
1839            line_length: 100,
1840            reflow: true,
1841            reflow_mode: ReflowMode::Normalize,
1842            ..Default::default()
1843        };
1844        let rule = MD013LineLength::from_config_struct(config);
1845
1846        let content =
1847            "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
1848        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1849
1850        let fixed = rule.fix(&ctx).unwrap();
1851        assert!(
1852            fixed.contains("[link](https://example.com)"),
1853            "Link should be preserved"
1854        );
1855        assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
1856    }
1857
1858    #[test]
1859    fn test_normalize_mode_empty_lines_between_paragraphs() {
1860        let config = MD013Config {
1861            line_length: 100,
1862            reflow: true,
1863            reflow_mode: ReflowMode::Normalize,
1864            ..Default::default()
1865        };
1866        let rule = MD013LineLength::from_config_struct(config);
1867
1868        let content = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
1869        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1870
1871        let fixed = rule.fix(&ctx).unwrap();
1872        // Multiple empty lines should be preserved
1873        assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
1874        // Each paragraph should be normalized
1875        let parts: Vec<&str> = fixed.split("\n\n\n").collect();
1876        assert_eq!(parts.len(), 2, "Should have two parts");
1877        assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
1878        assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
1879    }
1880
1881    #[test]
1882    fn test_normalize_mode_mixed_list_types() {
1883        let config = MD013Config {
1884            line_length: 80,
1885            reflow: true,
1886            reflow_mode: ReflowMode::Normalize,
1887            ..Default::default()
1888        };
1889        let rule = MD013LineLength::from_config_struct(config);
1890
1891        let content = r#"Paragraph before list
1892with multiple lines.
1893
1894- Bullet item
1895* Another bullet
1896+ Plus bullet
1897
18981. Numbered item
18992. Another number
1900
1901Paragraph after list
1902with multiple lines."#;
1903
1904        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1905        let fixed = rule.fix(&ctx).unwrap();
1906
1907        // Lists should be preserved
1908        assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
1909        assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
1910        assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
1911        assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
1912
1913        // But paragraphs should be normalized
1914        assert!(
1915            fixed.starts_with("Paragraph before list with multiple lines."),
1916            "First paragraph should be normalized"
1917        );
1918        assert!(
1919            fixed.ends_with("Paragraph after list with multiple lines."),
1920            "Last paragraph should be normalized"
1921        );
1922    }
1923
1924    #[test]
1925    fn test_normalize_mode_with_horizontal_rules() {
1926        let config = MD013Config {
1927            line_length: 100,
1928            reflow: true,
1929            reflow_mode: ReflowMode::Normalize,
1930            ..Default::default()
1931        };
1932        let rule = MD013LineLength::from_config_struct(config);
1933
1934        let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
1935        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1936
1937        let fixed = rule.fix(&ctx).unwrap();
1938        assert!(fixed.contains("---"), "Horizontal rule should be preserved");
1939        assert!(
1940            fixed.contains("Paragraph before horizontal rule."),
1941            "First paragraph normalized"
1942        );
1943        assert!(
1944            fixed.contains("Paragraph after horizontal rule."),
1945            "Second paragraph normalized"
1946        );
1947    }
1948
1949    #[test]
1950    fn test_normalize_mode_with_indented_code() {
1951        let config = MD013Config {
1952            line_length: 100,
1953            reflow: true,
1954            reflow_mode: ReflowMode::Normalize,
1955            ..Default::default()
1956        };
1957        let rule = MD013LineLength::from_config_struct(config);
1958
1959        let content = "Paragraph before\nindented code.\n\n    This is indented code\n    Should not be normalized\n\nParagraph after\nindented code.";
1960        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1961
1962        let fixed = rule.fix(&ctx).unwrap();
1963        assert!(
1964            fixed.contains("    This is indented code\n    Should not be normalized"),
1965            "Indented code preserved"
1966        );
1967        assert!(
1968            fixed.contains("Paragraph before indented code."),
1969            "First paragraph normalized"
1970        );
1971        assert!(
1972            fixed.contains("Paragraph after indented code."),
1973            "Second paragraph normalized"
1974        );
1975    }
1976
1977    #[test]
1978    fn test_normalize_mode_disabled_without_reflow() {
1979        // Normalize mode should have no effect if reflow is disabled
1980        let config = MD013Config {
1981            line_length: 100,
1982            reflow: false, // Disabled
1983            reflow_mode: ReflowMode::Normalize,
1984            ..Default::default()
1985        };
1986        let rule = MD013LineLength::from_config_struct(config);
1987
1988        let content = "This is a line\nwith breaks that\nshould not be changed.";
1989        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1990
1991        let warnings = rule.check(&ctx).unwrap();
1992        assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
1993
1994        let fixed = rule.fix(&ctx).unwrap();
1995        assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
1996    }
1997
1998    #[test]
1999    fn test_default_mode_with_long_lines() {
2000        // Default mode should fix paragraphs that contain lines exceeding limit
2001        // The paragraph-based approach treats consecutive lines as a unit
2002        let config = MD013Config {
2003            line_length: 50,
2004            reflow: true,
2005            reflow_mode: ReflowMode::Default,
2006            ..Default::default()
2007        };
2008        let rule = MD013LineLength::from_config_struct(config);
2009
2010        let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2011        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2012
2013        let warnings = rule.check(&ctx).unwrap();
2014        assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2015        // The warning reports the line that violates in default mode
2016        assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2017
2018        let fixed = rule.fix(&ctx).unwrap();
2019        // The paragraph gets reflowed as a unit
2020        assert!(
2021            fixed.contains("Short line. This is"),
2022            "Should combine and reflow the paragraph"
2023        );
2024        assert!(
2025            fixed.contains("wrapping. Another short"),
2026            "Should include all paragraph content"
2027        );
2028    }
2029
2030    #[test]
2031    fn test_normalize_vs_default_mode_same_content() {
2032        let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2033        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2034
2035        // Test default mode
2036        let default_config = MD013Config {
2037            line_length: 100,
2038            reflow: true,
2039            reflow_mode: ReflowMode::Default,
2040            ..Default::default()
2041        };
2042        let default_rule = MD013LineLength::from_config_struct(default_config);
2043        let default_warnings = default_rule.check(&ctx).unwrap();
2044        let default_fixed = default_rule.fix(&ctx).unwrap();
2045
2046        // Test normalize mode
2047        let normalize_config = MD013Config {
2048            line_length: 100,
2049            reflow: true,
2050            reflow_mode: ReflowMode::Normalize,
2051            ..Default::default()
2052        };
2053        let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2054        let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2055        let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2056
2057        // Verify different behavior
2058        assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2059        assert!(
2060            !normalize_warnings.is_empty(),
2061            "Normalize mode should flag multi-line paragraphs"
2062        );
2063
2064        assert_eq!(
2065            default_fixed, content,
2066            "Default mode should not change content without violations"
2067        );
2068        assert_ne!(
2069            normalize_fixed, content,
2070            "Normalize mode should change multi-line paragraphs"
2071        );
2072        assert_eq!(
2073            normalize_fixed.lines().count(),
2074            1,
2075            "Normalize should combine into single line"
2076        );
2077    }
2078
2079    #[test]
2080    fn test_normalize_mode_with_reference_definitions() {
2081        let config = MD013Config {
2082            line_length: 100,
2083            reflow: true,
2084            reflow_mode: ReflowMode::Normalize,
2085            ..Default::default()
2086        };
2087        let rule = MD013LineLength::from_config_struct(config);
2088
2089        let content =
2090            "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2091        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2092
2093        let fixed = rule.fix(&ctx).unwrap();
2094        assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2095        assert!(
2096            fixed.contains("[ref]: https://example.com"),
2097            "Reference definition should be preserved"
2098        );
2099        assert!(
2100            fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2101            "Paragraph should be normalized"
2102        );
2103    }
2104
2105    #[test]
2106    fn test_normalize_mode_with_html_comments() {
2107        let config = MD013Config {
2108            line_length: 100,
2109            reflow: true,
2110            reflow_mode: ReflowMode::Normalize,
2111            ..Default::default()
2112        };
2113        let rule = MD013LineLength::from_config_struct(config);
2114
2115        let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2116        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2117
2118        let fixed = rule.fix(&ctx).unwrap();
2119        assert!(
2120            fixed.contains("<!-- This is a comment -->"),
2121            "HTML comment should be preserved"
2122        );
2123        assert!(
2124            fixed.contains("Paragraph before HTML comment."),
2125            "First paragraph normalized"
2126        );
2127        assert!(
2128            fixed.contains("Paragraph after HTML comment."),
2129            "Second paragraph normalized"
2130        );
2131    }
2132
2133    #[test]
2134    fn test_normalize_mode_line_starting_with_number() {
2135        // Regression test for the bug we fixed where "80 characters" was treated as a list
2136        let config = MD013Config {
2137            line_length: 100,
2138            reflow: true,
2139            reflow_mode: ReflowMode::Normalize,
2140            ..Default::default()
2141        };
2142        let rule = MD013LineLength::from_config_struct(config);
2143
2144        let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2145        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2146
2147        let fixed = rule.fix(&ctx).unwrap();
2148        assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2149        assert!(
2150            fixed.contains("80 characters"),
2151            "Number at start of line should be preserved"
2152        );
2153    }
2154
2155    #[test]
2156    fn test_default_mode_preserves_list_structure() {
2157        // In default mode, list continuation lines should be preserved
2158        let config = MD013Config {
2159            line_length: 80,
2160            reflow: true,
2161            reflow_mode: ReflowMode::Default,
2162            ..Default::default()
2163        };
2164        let rule = MD013LineLength::from_config_struct(config);
2165
2166        let content = r#"- This is a bullet point that has
2167  some text on multiple lines
2168  that should stay separate
2169
21701. Numbered list item with
2171   multiple lines that should
2172   also stay separate"#;
2173
2174        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2175        let fixed = rule.fix(&ctx).unwrap();
2176
2177        // In default mode, the structure should be preserved
2178        let lines: Vec<&str> = fixed.lines().collect();
2179        assert_eq!(
2180            lines[0], "- This is a bullet point that has",
2181            "First line should be unchanged"
2182        );
2183        assert_eq!(
2184            lines[1], "  some text on multiple lines",
2185            "Continuation should be preserved"
2186        );
2187        assert_eq!(
2188            lines[2], "  that should stay separate",
2189            "Second continuation should be preserved"
2190        );
2191    }
2192
2193    #[test]
2194    fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2195        // Test that multi-line list items don't get extra spaces when normalized
2196        let config = MD013Config {
2197            line_length: 80,
2198            reflow: true,
2199            reflow_mode: ReflowMode::Normalize,
2200            ..Default::default()
2201        };
2202        let rule = MD013LineLength::from_config_struct(config);
2203
2204        let content = r#"- This is a bullet point that has
2205  some text on multiple lines
2206  that should be combined
2207
22081. Numbered list item with
2209   multiple lines that need
2210   to be properly combined
22112. Second item"#;
2212
2213        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2214        let fixed = rule.fix(&ctx).unwrap();
2215
2216        // Check that there are no extra spaces in the combined list items
2217        assert!(
2218            !fixed.contains("lines  that"),
2219            "Should not have double spaces in bullet list"
2220        );
2221        assert!(
2222            !fixed.contains("need  to"),
2223            "Should not have double spaces in numbered list"
2224        );
2225
2226        // Check that the list items are properly combined
2227        assert!(
2228            fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2229            "Bullet list should be properly combined"
2230        );
2231        assert!(
2232            fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2233            "Numbered list should be properly combined"
2234        );
2235    }
2236
2237    #[test]
2238    fn test_normalize_mode_actual_numbered_list() {
2239        // Ensure actual numbered lists are still detected correctly
2240        let config = MD013Config {
2241            line_length: 100,
2242            reflow: true,
2243            reflow_mode: ReflowMode::Normalize,
2244            ..Default::default()
2245        };
2246        let rule = MD013LineLength::from_config_struct(config);
2247
2248        let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2249        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2250
2251        let fixed = rule.fix(&ctx).unwrap();
2252        assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2253        assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2254        assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2255        assert!(
2256            fixed.starts_with("Paragraph before list with multiple lines."),
2257            "Paragraph should be normalized"
2258        );
2259    }
2260}