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