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::range_utils::LineIndex;
7use crate::utils::range_utils::calculate_excess_range;
8use crate::utils::regex_cache::{
9    IMAGE_REF_PATTERN, INLINE_LINK_REGEX as MARKDOWN_LINK_PATTERN, LINK_REF_PATTERN, URL_IN_TEXT, URL_PATTERN,
10};
11use crate::utils::table_utils::TableUtils;
12use crate::utils::text_reflow::split_into_sentences;
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        ctx: &crate::lint_context::LintContext,
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 ctx.is_in_code_block(current_line + 1) && !trimmed.is_empty() && !line.contains(' ') && !line.contains('\t')
73        {
74            return true;
75        }
76
77        false
78    }
79}
80
81impl Rule for MD013LineLength {
82    fn name(&self) -> &'static str {
83        "MD013"
84    }
85
86    fn description(&self) -> &'static str {
87        "Line length should not be excessive"
88    }
89
90    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
91        let content = ctx.content;
92
93        // Fast early return using should_skip
94        // But don't skip if we're in reflow mode with Normalize or SentencePerLine
95        if self.should_skip(ctx)
96            && !(self.config.reflow
97                && (self.config.reflow_mode == ReflowMode::Normalize
98                    || self.config.reflow_mode == ReflowMode::SentencePerLine))
99        {
100            return Ok(Vec::new());
101        }
102
103        // Direct implementation without DocumentStructure
104        let mut warnings = Vec::new();
105
106        // Check for inline configuration overrides
107        let inline_config = crate::inline_config::InlineConfig::from_content(content);
108        let config_override = inline_config.get_rule_config("MD013");
109
110        // Apply configuration override if present
111        let effective_config = if let Some(json_config) = config_override {
112            if let Some(obj) = json_config.as_object() {
113                let mut config = self.config.clone();
114                if let Some(line_length) = obj.get("line_length").and_then(|v| v.as_u64()) {
115                    config.line_length = line_length as usize;
116                }
117                if let Some(code_blocks) = obj.get("code_blocks").and_then(|v| v.as_bool()) {
118                    config.code_blocks = code_blocks;
119                }
120                if let Some(tables) = obj.get("tables").and_then(|v| v.as_bool()) {
121                    config.tables = tables;
122                }
123                if let Some(headings) = obj.get("headings").and_then(|v| v.as_bool()) {
124                    config.headings = headings;
125                }
126                if let Some(strict) = obj.get("strict").and_then(|v| v.as_bool()) {
127                    config.strict = strict;
128                }
129                if let Some(reflow) = obj.get("reflow").and_then(|v| v.as_bool()) {
130                    config.reflow = reflow;
131                }
132                if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
133                    config.reflow_mode = match reflow_mode {
134                        "default" => ReflowMode::Default,
135                        "normalize" => ReflowMode::Normalize,
136                        "sentence-per-line" => ReflowMode::SentencePerLine,
137                        _ => ReflowMode::default(),
138                    };
139                }
140                config
141            } else {
142                self.config.clone()
143            }
144        } else {
145            self.config.clone()
146        };
147
148        // Pre-filter lines that could be problematic to avoid processing all lines
149        let mut candidate_lines = Vec::new();
150        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
151            // Quick length check first
152            if line_info.content.len() > effective_config.line_length {
153                candidate_lines.push(line_idx);
154            }
155        }
156
157        // If no candidate lines and not in normalize or sentence-per-line mode, early return
158        if candidate_lines.is_empty()
159            && !(effective_config.reflow
160                && (effective_config.reflow_mode == ReflowMode::Normalize
161                    || effective_config.reflow_mode == ReflowMode::SentencePerLine))
162        {
163            return Ok(warnings);
164        }
165
166        // Use ctx.lines if available for better performance
167        let lines: Vec<&str> = if !ctx.lines.is_empty() {
168            ctx.lines.iter().map(|l| l.content.as_str()).collect()
169        } else {
170            content.lines().collect()
171        };
172
173        // Create a quick lookup set for heading lines (only if needed)
174        let heading_lines_set: std::collections::HashSet<usize> = if !effective_config.headings {
175            ctx.lines
176                .iter()
177                .enumerate()
178                .filter(|(_, line)| line.heading.is_some())
179                .map(|(idx, _)| idx + 1)
180                .collect()
181        } else {
182            std::collections::HashSet::new()
183        };
184
185        // Use TableUtils to find all table blocks (only if needed)
186        let table_lines_set: std::collections::HashSet<usize> = if !effective_config.tables {
187            let table_blocks = TableUtils::find_table_blocks(content, ctx);
188            let mut table_lines = std::collections::HashSet::new();
189            for table in &table_blocks {
190                table_lines.insert(table.header_line + 1);
191                table_lines.insert(table.delimiter_line + 1);
192                for &line in &table.content_lines {
193                    table_lines.insert(line + 1);
194                }
195            }
196            table_lines
197        } else {
198            std::collections::HashSet::new()
199        };
200
201        // Only process candidate lines that were pre-filtered
202        // Skip line length checks entirely in sentence-per-line mode
203        if effective_config.reflow_mode != ReflowMode::SentencePerLine {
204            for &line_idx in &candidate_lines {
205                let line_number = line_idx + 1;
206                let line = lines[line_idx];
207
208                // Calculate effective length excluding unbreakable URLs
209                let effective_length = self.calculate_effective_length(line);
210
211                // Use single line length limit for all content
212                let line_limit = effective_config.line_length;
213
214                // Skip short lines immediately (double-check after effective length calculation)
215                if effective_length <= line_limit {
216                    continue;
217                }
218
219                // Skip mkdocstrings blocks (already handled by LintContext)
220                if ctx.lines[line_idx].in_mkdocstrings {
221                    continue;
222                }
223
224                // Skip various block types efficiently
225                if !effective_config.strict {
226                    // Skip setext heading underlines
227                    if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
228                        continue;
229                    }
230
231                    // Skip block elements according to config flags
232                    // The flags mean: true = check these elements, false = skip these elements
233                    // So we skip when the flag is FALSE and the line is in that element type
234                    if (!effective_config.headings && heading_lines_set.contains(&line_number))
235                        || (!effective_config.code_blocks && ctx.is_in_code_block(line_number))
236                        || (!effective_config.tables && table_lines_set.contains(&line_number))
237                        || ctx.lines[line_number - 1].blockquote.is_some()
238                        || ctx.is_in_html_block(line_number)
239                    {
240                        continue;
241                    }
242
243                    // Skip lines that are only a URL, image ref, or link ref
244                    if self.should_ignore_line(line, &lines, line_idx, ctx) {
245                        continue;
246                    }
247                }
248
249                // Don't provide fix for individual lines when reflow is enabled
250                // Paragraph-based fixes will be handled separately
251                let fix = None;
252
253                let message = format!("Line length {effective_length} exceeds {line_limit} characters");
254
255                // Calculate precise character range for the excess portion
256                let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
257
258                warnings.push(LintWarning {
259                    rule_name: Some(self.name()),
260                    message,
261                    line: start_line,
262                    column: start_col,
263                    end_line,
264                    end_column: end_col,
265                    severity: Severity::Warning,
266                    fix,
267                });
268            }
269        }
270
271        // If reflow is enabled, generate paragraph-based fixes
272        if effective_config.reflow {
273            let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, &lines);
274            // Merge paragraph warnings with line warnings, removing duplicates
275            for pw in paragraph_warnings {
276                // Remove any line warnings that overlap with this paragraph
277                warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
278                warnings.push(pw);
279            }
280        }
281
282        Ok(warnings)
283    }
284
285    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
286        // For CLI usage, apply fixes from warnings
287        // LSP will use the warning-based fixes directly
288        let warnings = self.check(ctx)?;
289
290        // If there are no fixes, return content unchanged
291        if !warnings.iter().any(|w| w.fix.is_some()) {
292            return Ok(ctx.content.to_string());
293        }
294
295        // Apply warning-based fixes
296        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
297            .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
298    }
299
300    fn as_any(&self) -> &dyn std::any::Any {
301        self
302    }
303
304    fn category(&self) -> RuleCategory {
305        RuleCategory::Whitespace
306    }
307
308    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
309        // Skip if content is empty
310        if ctx.content.is_empty() {
311            return true;
312        }
313
314        // For sentence-per-line or normalize mode, never skip based on line length
315        if self.config.reflow
316            && (self.config.reflow_mode == ReflowMode::SentencePerLine
317                || self.config.reflow_mode == ReflowMode::Normalize)
318        {
319            return false;
320        }
321
322        // Quick check: if total content is shorter than line limit, definitely skip
323        if ctx.content.len() <= self.config.line_length {
324            return true;
325        }
326
327        // Use more efficient check - any() with early termination instead of all()
328        !ctx.lines
329            .iter()
330            .any(|line| line.content.len() > self.config.line_length)
331    }
332
333    fn default_config_section(&self) -> Option<(String, toml::Value)> {
334        let default_config = MD013Config::default();
335        let json_value = serde_json::to_value(&default_config).ok()?;
336        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
337
338        if let toml::Value::Table(table) = toml_value {
339            if !table.is_empty() {
340                Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
341            } else {
342                None
343            }
344        } else {
345            None
346        }
347    }
348
349    fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
350        let mut aliases = std::collections::HashMap::new();
351        aliases.insert("enable_reflow".to_string(), "reflow".to_string());
352        Some(aliases)
353    }
354
355    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
356    where
357        Self: Sized,
358    {
359        let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
360        // Special handling for line_length from global config
361        if rule_config.line_length == 80 {
362            // default value
363            rule_config.line_length = config.global.line_length as usize;
364        }
365        Box::new(Self::from_config_struct(rule_config))
366    }
367}
368
369impl MD013LineLength {
370    /// Generate paragraph-based fixes
371    fn generate_paragraph_fixes(
372        &self,
373        ctx: &crate::lint_context::LintContext,
374        config: &MD013Config,
375        lines: &[&str],
376    ) -> Vec<LintWarning> {
377        let mut warnings = Vec::new();
378        let line_index = LineIndex::new(ctx.content.to_string());
379
380        let mut i = 0;
381        while i < lines.len() {
382            let line_num = i + 1;
383
384            // Skip special structures
385            if ctx.is_in_code_block(line_num)
386                || ctx.is_in_front_matter(line_num)
387                || ctx.is_in_html_block(line_num)
388                || (line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some())
389                || lines[i].trim().starts_with('#')
390                || TableUtils::is_potential_table_row(lines[i])
391                || lines[i].trim().is_empty()
392                || is_horizontal_rule(lines[i].trim())
393            {
394                i += 1;
395                continue;
396            }
397
398            // Helper function to detect semantic line markers
399            let is_semantic_line = |content: &str| -> bool {
400                let trimmed = content.trim_start();
401                let semantic_markers = [
402                    "NOTE:",
403                    "WARNING:",
404                    "IMPORTANT:",
405                    "CAUTION:",
406                    "TIP:",
407                    "DANGER:",
408                    "HINT:",
409                    "INFO:",
410                ];
411                semantic_markers.iter().any(|marker| trimmed.starts_with(marker))
412            };
413
414            // Helper function to detect fence markers (opening or closing)
415            let is_fence_marker = |content: &str| -> bool {
416                let trimmed = content.trim_start();
417                trimmed.starts_with("```") || trimmed.starts_with("~~~")
418            };
419
420            // Check if this is a list item - handle it specially
421            let trimmed = lines[i].trim();
422            if is_list_item(trimmed) {
423                // Collect the entire list item including continuation lines
424                let list_start = i;
425                let (marker, first_content) = extract_list_marker_and_content(lines[i]);
426                let marker_len = marker.len();
427
428                // Track lines and their types (content, code block, fence, nested list)
429                #[derive(Clone)]
430                enum LineType {
431                    Content(String),
432                    CodeBlock(String, usize),      // content and original indent
433                    NestedListItem(String, usize), // full line content and original indent
434                    SemanticLine(String),          // Lines starting with NOTE:, WARNING:, etc that should stay separate
435                    Empty,
436                }
437
438                let mut actual_indent: Option<usize> = None;
439                let mut list_item_lines: Vec<LineType> = vec![LineType::Content(first_content)];
440                i += 1;
441
442                // Collect continuation lines using ctx.lines for metadata
443                while i < lines.len() {
444                    let line_info = &ctx.lines[i];
445
446                    // Use pre-computed is_blank from ctx
447                    if line_info.is_blank {
448                        // Empty line - check if next line is indented (part of list item)
449                        if i + 1 < lines.len() {
450                            let next_info = &ctx.lines[i + 1];
451
452                            // Check if next line is indented enough to be continuation
453                            if !next_info.is_blank && next_info.indent >= marker_len {
454                                // This blank line is between paragraphs/blocks in the list item
455                                list_item_lines.push(LineType::Empty);
456                                i += 1;
457                                continue;
458                            }
459                        }
460                        // No indented line after blank, end of list item
461                        break;
462                    }
463
464                    // Use pre-computed indent from ctx
465                    let indent = line_info.indent;
466
467                    // Valid continuation must be indented at least marker_len
468                    if indent >= marker_len {
469                        let trimmed = line_info.content.trim();
470
471                        // Use pre-computed in_code_block from ctx
472                        if line_info.in_code_block {
473                            list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
474                            i += 1;
475                            continue;
476                        }
477
478                        // Check if this is a SIBLING list item (breaks parent)
479                        // Nested lists are indented >= marker_len and are PART of the parent item
480                        // Siblings are at indent < marker_len (at or before parent marker)
481                        if is_list_item(trimmed) && indent < marker_len {
482                            // This is a sibling item at same or higher level - end parent item
483                            break;
484                        }
485
486                        // Check if this is a NESTED list item marker
487                        // Nested lists should be processed separately UNLESS they're part of a
488                        // multi-paragraph list item (indicated by a blank line before them OR
489                        // it's a continuation of an already-started nested list)
490                        if is_list_item(trimmed) && indent >= marker_len {
491                            // Check if there was a blank line before this (multi-paragraph context)
492                            let has_blank_before = matches!(list_item_lines.last(), Some(LineType::Empty));
493
494                            // Check if we've already seen nested list content (another nested item)
495                            let has_nested_content = list_item_lines.iter().any(|line| {
496                                matches!(line, LineType::Content(c) if is_list_item(c.trim()))
497                                    || matches!(line, LineType::NestedListItem(_, _))
498                            });
499
500                            if !has_blank_before && !has_nested_content {
501                                // Single-paragraph context with no prior nested items: starts a new item
502                                // End parent collection; nested list will be processed next
503                                break;
504                            }
505                            // else: multi-paragraph context or continuation of nested list, keep collecting
506                            // Mark this as a nested list item to preserve its structure
507                            list_item_lines.push(LineType::NestedListItem(
508                                line_info.content[indent..].to_string(),
509                                indent,
510                            ));
511                            i += 1;
512                            continue;
513                        }
514
515                        // Normal continuation: marker_len to marker_len+3
516                        if indent <= marker_len + 3 {
517                            // Set actual_indent from first non-code continuation if not set
518                            if actual_indent.is_none() {
519                                actual_indent = Some(indent);
520                            }
521
522                            // Extract content (remove indentation and trailing whitespace)
523                            // Preserve hard breaks (2 trailing spaces) while removing excessive whitespace
524                            // See: https://github.com/rvben/rumdl/issues/76
525                            let content = trim_preserving_hard_break(&line_info.content[indent..]);
526
527                            // Check if this is a fence marker (opening or closing)
528                            // These should be treated as code block lines, not paragraph content
529                            if is_fence_marker(&content) {
530                                list_item_lines.push(LineType::CodeBlock(content, indent));
531                            }
532                            // Check if this is a semantic line (NOTE:, WARNING:, etc.)
533                            else if is_semantic_line(&content) {
534                                list_item_lines.push(LineType::SemanticLine(content));
535                            } else {
536                                list_item_lines.push(LineType::Content(content));
537                            }
538                            i += 1;
539                        } else {
540                            // indent >= marker_len + 4: indented code block
541                            list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
542                            i += 1;
543                        }
544                    } else {
545                        // Not indented enough, end of list item
546                        break;
547                    }
548                }
549
550                // Use detected indent or fallback to marker length
551                let indent_size = actual_indent.unwrap_or(marker_len);
552                let expected_indent = " ".repeat(indent_size);
553
554                // Split list_item_lines into blocks (paragraphs, code blocks, nested lists, and semantic lines)
555                #[derive(Clone)]
556                enum Block {
557                    Paragraph(Vec<String>),
558                    Code {
559                        lines: Vec<(String, usize)>, // (content, indent) pairs
560                        has_preceding_blank: bool,   // Whether there was a blank line before this block
561                    },
562                    NestedList(Vec<(String, usize)>), // (content, indent) pairs for nested list items
563                    SemanticLine(String), // Semantic markers like NOTE:, WARNING: that stay on their own line
564                }
565
566                let mut blocks: Vec<Block> = Vec::new();
567                let mut current_paragraph: Vec<String> = Vec::new();
568                let mut current_code_block: Vec<(String, usize)> = Vec::new();
569                let mut current_nested_list: Vec<(String, usize)> = Vec::new();
570                let mut in_code = false;
571                let mut in_nested_list = false;
572                let mut had_preceding_blank = false; // Track if we just saw an empty line
573                let mut code_block_has_preceding_blank = false; // Track blank before current code block
574
575                for line in &list_item_lines {
576                    match line {
577                        LineType::Empty => {
578                            if in_code {
579                                current_code_block.push((String::new(), 0));
580                            } else if in_nested_list {
581                                current_nested_list.push((String::new(), 0));
582                            } else if !current_paragraph.is_empty() {
583                                blocks.push(Block::Paragraph(current_paragraph.clone()));
584                                current_paragraph.clear();
585                            }
586                            // Mark that we saw a blank line
587                            had_preceding_blank = true;
588                        }
589                        LineType::Content(content) => {
590                            if in_code {
591                                // Switching from code to content
592                                blocks.push(Block::Code {
593                                    lines: current_code_block.clone(),
594                                    has_preceding_blank: code_block_has_preceding_blank,
595                                });
596                                current_code_block.clear();
597                                in_code = false;
598                            } else if in_nested_list {
599                                // Switching from nested list to content
600                                blocks.push(Block::NestedList(current_nested_list.clone()));
601                                current_nested_list.clear();
602                                in_nested_list = false;
603                            }
604                            current_paragraph.push(content.clone());
605                            had_preceding_blank = false; // Reset after content
606                        }
607                        LineType::CodeBlock(content, indent) => {
608                            if in_nested_list {
609                                // Switching from nested list to code
610                                blocks.push(Block::NestedList(current_nested_list.clone()));
611                                current_nested_list.clear();
612                                in_nested_list = false;
613                            }
614                            if !in_code {
615                                // Switching from content to code
616                                if !current_paragraph.is_empty() {
617                                    blocks.push(Block::Paragraph(current_paragraph.clone()));
618                                    current_paragraph.clear();
619                                }
620                                in_code = true;
621                                // Record whether there was a blank line before this code block
622                                code_block_has_preceding_blank = had_preceding_blank;
623                            }
624                            current_code_block.push((content.clone(), *indent));
625                            had_preceding_blank = false; // Reset after code
626                        }
627                        LineType::NestedListItem(content, indent) => {
628                            if in_code {
629                                // Switching from code to nested list
630                                blocks.push(Block::Code {
631                                    lines: current_code_block.clone(),
632                                    has_preceding_blank: code_block_has_preceding_blank,
633                                });
634                                current_code_block.clear();
635                                in_code = false;
636                            }
637                            if !in_nested_list {
638                                // Switching from content to nested list
639                                if !current_paragraph.is_empty() {
640                                    blocks.push(Block::Paragraph(current_paragraph.clone()));
641                                    current_paragraph.clear();
642                                }
643                                in_nested_list = true;
644                            }
645                            current_nested_list.push((content.clone(), *indent));
646                            had_preceding_blank = false; // Reset after nested list
647                        }
648                        LineType::SemanticLine(content) => {
649                            // Semantic lines are standalone - flush any current block and add as separate block
650                            if in_code {
651                                blocks.push(Block::Code {
652                                    lines: current_code_block.clone(),
653                                    has_preceding_blank: code_block_has_preceding_blank,
654                                });
655                                current_code_block.clear();
656                                in_code = false;
657                            } else if in_nested_list {
658                                blocks.push(Block::NestedList(current_nested_list.clone()));
659                                current_nested_list.clear();
660                                in_nested_list = false;
661                            } else if !current_paragraph.is_empty() {
662                                blocks.push(Block::Paragraph(current_paragraph.clone()));
663                                current_paragraph.clear();
664                            }
665                            // Add semantic line as its own block
666                            blocks.push(Block::SemanticLine(content.clone()));
667                            had_preceding_blank = false; // Reset after semantic line
668                        }
669                    }
670                }
671
672                // Push remaining block
673                if in_code && !current_code_block.is_empty() {
674                    blocks.push(Block::Code {
675                        lines: current_code_block,
676                        has_preceding_blank: code_block_has_preceding_blank,
677                    });
678                } else if in_nested_list && !current_nested_list.is_empty() {
679                    blocks.push(Block::NestedList(current_nested_list));
680                } else if !current_paragraph.is_empty() {
681                    blocks.push(Block::Paragraph(current_paragraph));
682                }
683
684                // Check if reflowing is needed (only for content paragraphs, not code blocks or nested lists)
685                let content_lines: Vec<String> = list_item_lines
686                    .iter()
687                    .filter_map(|line| {
688                        if let LineType::Content(s) = line {
689                            Some(s.clone())
690                        } else {
691                            None
692                        }
693                    })
694                    .collect();
695
696                // Check if we need to reflow this list item
697                // We check the combined content to see if it exceeds length limits
698                let combined_content = content_lines.join(" ").trim().to_string();
699                let full_line = format!("{marker}{combined_content}");
700
701                // Helper to check if we should reflow in normalize mode
702                let should_normalize = || {
703                    // Don't normalize if the list item only contains nested lists, code blocks, or semantic lines
704                    // DO normalize if it has plain text content that spans multiple lines
705                    let has_nested_lists = blocks.iter().any(|b| matches!(b, Block::NestedList(_)));
706                    let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
707                    let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine(_)));
708                    let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph(_)));
709
710                    // If we have nested lists, code blocks, or semantic lines but no paragraphs, don't normalize
711                    if (has_nested_lists || has_code_blocks || has_semantic_lines) && !has_paragraphs {
712                        return false;
713                    }
714
715                    // If we have paragraphs, check if they span multiple lines or there are multiple blocks
716                    if has_paragraphs {
717                        let paragraph_count = blocks.iter().filter(|b| matches!(b, Block::Paragraph(_))).count();
718                        if paragraph_count > 1 {
719                            // Multiple paragraph blocks should be normalized
720                            return true;
721                        }
722
723                        // Single paragraph block: normalize if it has multiple content lines
724                        if content_lines.len() > 1 {
725                            return true;
726                        }
727                    }
728
729                    false
730                };
731
732                let needs_reflow = match config.reflow_mode {
733                    ReflowMode::Normalize => {
734                        // Only reflow if:
735                        // 1. The combined line would exceed the limit, OR
736                        // 2. The list item should be normalized (has multi-line plain text)
737                        let combined_length = self.calculate_effective_length(&full_line);
738                        if combined_length > config.line_length {
739                            true
740                        } else {
741                            should_normalize()
742                        }
743                    }
744                    ReflowMode::SentencePerLine => {
745                        // Check if list item has multiple sentences
746                        let sentences = split_into_sentences(&combined_content);
747                        sentences.len() > 1
748                    }
749                    ReflowMode::Default => {
750                        // In default mode, only reflow if lines exceed limit
751                        self.calculate_effective_length(&full_line) > config.line_length
752                    }
753                };
754
755                if needs_reflow {
756                    let start_range = line_index.whole_line_range(list_start + 1);
757                    let end_line = i - 1;
758                    let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
759                        line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
760                    } else {
761                        line_index.whole_line_range(end_line + 1)
762                    };
763                    let byte_range = start_range.start..end_range.end;
764
765                    // Reflow each block (paragraphs only, preserve code blocks)
766                    let reflow_options = crate::utils::text_reflow::ReflowOptions {
767                        line_length: config.line_length - indent_size,
768                        break_on_sentences: true,
769                        preserve_breaks: false,
770                        sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
771                    };
772
773                    let mut result: Vec<String> = Vec::new();
774                    let mut is_first_block = true;
775
776                    for (block_idx, block) in blocks.iter().enumerate() {
777                        match block {
778                            Block::Paragraph(para_lines) => {
779                                // Split the paragraph into segments at hard break boundaries
780                                // Each segment can be reflowed independently
781                                let segments = split_into_segments(para_lines);
782
783                                for (segment_idx, segment) in segments.iter().enumerate() {
784                                    // Check if this segment ends with a hard break and what type
785                                    let hard_break_type = segment.last().and_then(|line| {
786                                        let line = line.strip_suffix('\r').unwrap_or(line);
787                                        if line.ends_with('\\') {
788                                            Some("\\")
789                                        } else if line.ends_with("  ") {
790                                            Some("  ")
791                                        } else {
792                                            None
793                                        }
794                                    });
795
796                                    // Join and reflow the segment (removing the hard break marker for processing)
797                                    let segment_for_reflow: Vec<String> = segment
798                                        .iter()
799                                        .map(|line| {
800                                            // Strip hard break marker (2 spaces or backslash) for reflow processing
801                                            if line.ends_with('\\') {
802                                                line[..line.len() - 1].trim_end().to_string()
803                                            } else if line.ends_with("  ") {
804                                                line[..line.len() - 2].trim_end().to_string()
805                                            } else {
806                                                line.clone()
807                                            }
808                                        })
809                                        .collect();
810
811                                    let segment_text = segment_for_reflow.join(" ").trim().to_string();
812                                    if !segment_text.is_empty() {
813                                        let reflowed =
814                                            crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
815
816                                        if is_first_block && segment_idx == 0 {
817                                            // First segment of first block starts with marker
818                                            result.push(format!("{marker}{}", reflowed[0]));
819                                            for line in reflowed.iter().skip(1) {
820                                                result.push(format!("{expected_indent}{line}"));
821                                            }
822                                            is_first_block = false;
823                                        } else {
824                                            // Subsequent segments
825                                            for line in reflowed {
826                                                result.push(format!("{expected_indent}{line}"));
827                                            }
828                                        }
829
830                                        // If this segment had a hard break, add it back to the last line
831                                        // Preserve the original hard break format (backslash or two spaces)
832                                        if let Some(break_marker) = hard_break_type
833                                            && let Some(last_line) = result.last_mut()
834                                        {
835                                            last_line.push_str(break_marker);
836                                        }
837                                    }
838                                }
839
840                                // Add blank line after paragraph block if there's a next block
841                                // BUT: check if next block is a code block that doesn't want a preceding blank
842                                if block_idx < blocks.len() - 1 {
843                                    let next_block = &blocks[block_idx + 1];
844                                    let should_add_blank = match next_block {
845                                        Block::Code {
846                                            has_preceding_blank, ..
847                                        } => *has_preceding_blank,
848                                        _ => true, // For all other blocks, add blank line
849                                    };
850                                    if should_add_blank {
851                                        result.push(String::new());
852                                    }
853                                }
854                            }
855                            Block::Code {
856                                lines: code_lines,
857                                has_preceding_blank: _,
858                            } => {
859                                // Preserve code blocks as-is with original indentation
860                                // NOTE: Blank line before code block is handled by the previous block
861                                // (see paragraph block's logic above)
862
863                                for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
864                                    if is_first_block && idx == 0 {
865                                        // First line of first block gets marker
866                                        result.push(format!(
867                                            "{marker}{}",
868                                            " ".repeat(orig_indent - marker_len) + content
869                                        ));
870                                        is_first_block = false;
871                                    } else if content.is_empty() {
872                                        result.push(String::new());
873                                    } else {
874                                        result.push(format!("{}{}", " ".repeat(*orig_indent), content));
875                                    }
876                                }
877                            }
878                            Block::NestedList(nested_items) => {
879                                // Preserve nested list items as-is with original indentation
880                                if !is_first_block {
881                                    result.push(String::new());
882                                }
883
884                                for (idx, (content, orig_indent)) in nested_items.iter().enumerate() {
885                                    if is_first_block && idx == 0 {
886                                        // First line of first block gets marker
887                                        result.push(format!(
888                                            "{marker}{}",
889                                            " ".repeat(orig_indent - marker_len) + content
890                                        ));
891                                        is_first_block = false;
892                                    } else if content.is_empty() {
893                                        result.push(String::new());
894                                    } else {
895                                        result.push(format!("{}{}", " ".repeat(*orig_indent), content));
896                                    }
897                                }
898
899                                // Add blank line after nested list if there's a next block
900                                // Check if next block is a code block that doesn't want a preceding blank
901                                if block_idx < blocks.len() - 1 {
902                                    let next_block = &blocks[block_idx + 1];
903                                    let should_add_blank = match next_block {
904                                        Block::Code {
905                                            has_preceding_blank, ..
906                                        } => *has_preceding_blank,
907                                        _ => true, // For all other blocks, add blank line
908                                    };
909                                    if should_add_blank {
910                                        result.push(String::new());
911                                    }
912                                }
913                            }
914                            Block::SemanticLine(content) => {
915                                // Preserve semantic lines (NOTE:, WARNING:, etc.) as-is on their own line
916                                // Add blank line before if not first block
917                                if !is_first_block {
918                                    result.push(String::new());
919                                }
920
921                                if is_first_block {
922                                    // First block starts with marker
923                                    result.push(format!("{marker}{content}"));
924                                    is_first_block = false;
925                                } else {
926                                    // Subsequent blocks use expected indent
927                                    result.push(format!("{expected_indent}{content}"));
928                                }
929
930                                // Add blank line after semantic line if there's a next block
931                                // Check if next block is a code block that doesn't want a preceding blank
932                                if block_idx < blocks.len() - 1 {
933                                    let next_block = &blocks[block_idx + 1];
934                                    let should_add_blank = match next_block {
935                                        Block::Code {
936                                            has_preceding_blank, ..
937                                        } => *has_preceding_blank,
938                                        _ => true, // For all other blocks, add blank line
939                                    };
940                                    if should_add_blank {
941                                        result.push(String::new());
942                                    }
943                                }
944                            }
945                        }
946                    }
947
948                    let reflowed_text = result.join("\n");
949
950                    // Preserve trailing newline
951                    let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
952                        format!("{reflowed_text}\n")
953                    } else {
954                        reflowed_text
955                    };
956
957                    // Get the original text to compare
958                    let original_text = &ctx.content[byte_range.clone()];
959
960                    // Only generate a warning if the replacement is different from the original
961                    if original_text != replacement {
962                        // Generate an appropriate message based on why reflow is needed
963                        let message = match config.reflow_mode {
964                            ReflowMode::SentencePerLine => {
965                                "Line contains multiple sentences (one sentence per line expected)".to_string()
966                            }
967                            ReflowMode::Normalize => {
968                                let combined_length = self.calculate_effective_length(&full_line);
969                                if combined_length > config.line_length {
970                                    format!(
971                                        "Line length {} exceeds {} characters",
972                                        combined_length, config.line_length
973                                    )
974                                } else {
975                                    "Multi-line content can be normalized".to_string()
976                                }
977                            }
978                            ReflowMode::Default => {
979                                let combined_length = self.calculate_effective_length(&full_line);
980                                format!(
981                                    "Line length {} exceeds {} characters",
982                                    combined_length, config.line_length
983                                )
984                            }
985                        };
986
987                        warnings.push(LintWarning {
988                            rule_name: Some(self.name()),
989                            message,
990                            line: list_start + 1,
991                            column: 1,
992                            end_line: end_line + 1,
993                            end_column: lines[end_line].len() + 1,
994                            severity: Severity::Warning,
995                            fix: Some(crate::rule::Fix {
996                                range: byte_range,
997                                replacement,
998                            }),
999                        });
1000                    }
1001                }
1002                continue;
1003            }
1004
1005            // Found start of a paragraph - collect all lines in it
1006            let paragraph_start = i;
1007            let mut paragraph_lines = vec![lines[i]];
1008            i += 1;
1009
1010            while i < lines.len() {
1011                let next_line = lines[i];
1012                let next_line_num = i + 1;
1013                let next_trimmed = next_line.trim();
1014
1015                // Stop at paragraph boundaries
1016                if next_trimmed.is_empty()
1017                    || ctx.is_in_code_block(next_line_num)
1018                    || ctx.is_in_front_matter(next_line_num)
1019                    || ctx.is_in_html_block(next_line_num)
1020                    || (next_line_num > 0
1021                        && next_line_num <= ctx.lines.len()
1022                        && ctx.lines[next_line_num - 1].blockquote.is_some())
1023                    || next_trimmed.starts_with('#')
1024                    || TableUtils::is_potential_table_row(next_line)
1025                    || is_list_item(next_trimmed)
1026                    || is_horizontal_rule(next_trimmed)
1027                    || (next_trimmed.starts_with('[') && next_line.contains("]:"))
1028                {
1029                    break;
1030                }
1031
1032                // Check if the previous line ends with a hard break (2+ spaces or backslash)
1033                if i > 0 && has_hard_break(lines[i - 1]) {
1034                    // Don't include lines after hard breaks in the same paragraph
1035                    break;
1036                }
1037
1038                paragraph_lines.push(next_line);
1039                i += 1;
1040            }
1041
1042            // Check if this paragraph needs reflowing
1043            let needs_reflow = match config.reflow_mode {
1044                ReflowMode::Normalize => {
1045                    // In normalize mode, reflow multi-line paragraphs
1046                    paragraph_lines.len() > 1
1047                }
1048                ReflowMode::SentencePerLine => {
1049                    // In sentence-per-line mode, check if any line has multiple sentences
1050                    paragraph_lines.iter().any(|line| {
1051                        // Count sentences in this line
1052                        let sentences = split_into_sentences(line);
1053                        sentences.len() > 1
1054                    })
1055                }
1056                ReflowMode::Default => {
1057                    // In default mode, only reflow if lines exceed limit
1058                    paragraph_lines
1059                        .iter()
1060                        .any(|line| self.calculate_effective_length(line) > config.line_length)
1061                }
1062            };
1063
1064            if needs_reflow {
1065                // Calculate byte range for this paragraph
1066                // Use whole_line_range for each line and combine
1067                let start_range = line_index.whole_line_range(paragraph_start + 1);
1068                let end_line = paragraph_start + paragraph_lines.len() - 1;
1069
1070                // For the last line, we want to preserve any trailing newline
1071                let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1072                    // Last line without trailing newline - use line_text_range
1073                    line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
1074                } else {
1075                    // Not the last line or has trailing newline - use whole_line_range
1076                    line_index.whole_line_range(end_line + 1)
1077                };
1078
1079                let byte_range = start_range.start..end_range.end;
1080
1081                // Combine paragraph lines into a single string for reflowing
1082                let paragraph_text = paragraph_lines.join(" ");
1083
1084                // Check if the paragraph ends with a hard break and what type
1085                let hard_break_type = paragraph_lines.last().and_then(|line| {
1086                    let line = line.strip_suffix('\r').unwrap_or(line);
1087                    if line.ends_with('\\') {
1088                        Some("\\")
1089                    } else if line.ends_with("  ") {
1090                        Some("  ")
1091                    } else {
1092                        None
1093                    }
1094                });
1095
1096                // Reflow the paragraph
1097                let reflow_options = crate::utils::text_reflow::ReflowOptions {
1098                    line_length: config.line_length,
1099                    break_on_sentences: true,
1100                    preserve_breaks: false,
1101                    sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1102                };
1103                let mut reflowed = crate::utils::text_reflow::reflow_line(&paragraph_text, &reflow_options);
1104
1105                // If the original paragraph ended with a hard break, preserve it
1106                // Preserve the original hard break format (backslash or two spaces)
1107                if let Some(break_marker) = hard_break_type
1108                    && !reflowed.is_empty()
1109                {
1110                    let last_idx = reflowed.len() - 1;
1111                    if !has_hard_break(&reflowed[last_idx]) {
1112                        reflowed[last_idx].push_str(break_marker);
1113                    }
1114                }
1115
1116                let reflowed_text = reflowed.join("\n");
1117
1118                // Preserve trailing newline if the original paragraph had one
1119                let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
1120                    format!("{reflowed_text}\n")
1121                } else {
1122                    reflowed_text
1123                };
1124
1125                // Get the original text to compare
1126                let original_text = &ctx.content[byte_range.clone()];
1127
1128                // Only generate a warning if the replacement is different from the original
1129                if original_text != replacement {
1130                    // Create warning with actual fix
1131                    // In default mode, report the specific line that violates
1132                    // In normalize mode, report the whole paragraph
1133                    // In sentence-per-line mode, report lines with multiple sentences
1134                    let (warning_line, warning_end_line) = match config.reflow_mode {
1135                        ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
1136                        ReflowMode::SentencePerLine => {
1137                            // Find the first line with multiple sentences
1138                            let mut violating_line = paragraph_start;
1139                            for (idx, line) in paragraph_lines.iter().enumerate() {
1140                                let sentences = split_into_sentences(line);
1141                                if sentences.len() > 1 {
1142                                    violating_line = paragraph_start + idx;
1143                                    break;
1144                                }
1145                            }
1146                            (violating_line + 1, violating_line + 1)
1147                        }
1148                        ReflowMode::Default => {
1149                            // Find the first line that exceeds the limit
1150                            let mut violating_line = paragraph_start;
1151                            for (idx, line) in paragraph_lines.iter().enumerate() {
1152                                if self.calculate_effective_length(line) > config.line_length {
1153                                    violating_line = paragraph_start + idx;
1154                                    break;
1155                                }
1156                            }
1157                            (violating_line + 1, violating_line + 1)
1158                        }
1159                    };
1160
1161                    warnings.push(LintWarning {
1162                        rule_name: Some(self.name()),
1163                        message: match config.reflow_mode {
1164                            ReflowMode::Normalize => format!(
1165                                "Paragraph could be normalized to use line length of {} characters",
1166                                config.line_length
1167                            ),
1168                            ReflowMode::SentencePerLine => {
1169                                "Line contains multiple sentences (one sentence per line expected)".to_string()
1170                            }
1171                            ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length),
1172                        },
1173                        line: warning_line,
1174                        column: 1,
1175                        end_line: warning_end_line,
1176                        end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
1177                        severity: Severity::Warning,
1178                        fix: Some(crate::rule::Fix {
1179                            range: byte_range,
1180                            replacement,
1181                        }),
1182                    });
1183                }
1184            }
1185        }
1186
1187        warnings
1188    }
1189
1190    /// Calculate effective line length excluding unbreakable URLs
1191    fn calculate_effective_length(&self, line: &str) -> usize {
1192        if self.config.strict {
1193            // In strict mode, count everything
1194            return line.chars().count();
1195        }
1196
1197        // Quick byte-level check: if line doesn't contain "http" or "[", it can't have URLs or markdown links
1198        let bytes = line.as_bytes();
1199        if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
1200            return line.chars().count();
1201        }
1202
1203        // More precise check for URLs and links
1204        if !line.contains("http") && !line.contains('[') {
1205            return line.chars().count();
1206        }
1207
1208        let mut effective_line = line.to_string();
1209
1210        // First handle markdown links to avoid double-counting URLs
1211        // Pattern: [text](very-long-url) -> [text](url)
1212        if line.contains('[') && line.contains("](") {
1213            for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
1214                if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
1215                    && url.as_str().len() > 15
1216                {
1217                    let replacement = format!("[{}](url)", text.as_str());
1218                    effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
1219                }
1220            }
1221        }
1222
1223        // Then replace bare URLs with a placeholder of reasonable length
1224        // This allows lines with long URLs to pass if the rest of the content is reasonable
1225        if effective_line.contains("http") {
1226            for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
1227                let url = url_match.as_str();
1228                // Skip if this URL is already part of a markdown link we handled
1229                if !effective_line.contains(&format!("({url})")) {
1230                    // Replace URL with placeholder that represents a "reasonable" URL length
1231                    // Using 15 chars as a reasonable URL placeholder (e.g., "https://ex.com")
1232                    let placeholder = "x".repeat(15.min(url.len()));
1233                    effective_line = effective_line.replacen(url, &placeholder, 1);
1234                }
1235            }
1236        }
1237
1238        effective_line.chars().count()
1239    }
1240}
1241
1242/// Check if a line ends with a hard break (either two spaces or backslash)
1243///
1244/// CommonMark supports two formats for hard line breaks:
1245/// 1. Two or more trailing spaces
1246/// 2. A backslash at the end of the line
1247fn has_hard_break(line: &str) -> bool {
1248    let line = line.strip_suffix('\r').unwrap_or(line);
1249    line.ends_with("  ") || line.ends_with('\\')
1250}
1251
1252/// Extract list marker and content from a list item
1253/// Trim trailing whitespace while preserving hard breaks (two trailing spaces or backslash)
1254///
1255/// Hard breaks in Markdown can be indicated by:
1256/// 1. Two trailing spaces before a newline (traditional)
1257/// 2. A backslash at the end of the line (mdformat style)
1258fn trim_preserving_hard_break(s: &str) -> String {
1259    // Strip trailing \r from CRLF line endings first to handle Windows files
1260    let s = s.strip_suffix('\r').unwrap_or(s);
1261
1262    // Check for backslash hard break (mdformat style)
1263    if s.ends_with('\\') {
1264        // Preserve the backslash exactly as-is
1265        return s.to_string();
1266    }
1267
1268    // Check if there are at least 2 trailing spaces (traditional hard break)
1269    if s.ends_with("  ") {
1270        // Find the position where non-space content ends
1271        let content_end = s.trim_end().len();
1272        if content_end == 0 {
1273            // String is all whitespace
1274            return String::new();
1275        }
1276        // Preserve exactly 2 trailing spaces for hard break
1277        format!("{}  ", &s[..content_end])
1278    } else {
1279        // No hard break, just trim all trailing whitespace
1280        s.trim_end().to_string()
1281    }
1282}
1283
1284/// Split paragraph lines into segments at hard break boundaries.
1285/// Each segment is a group of lines that can be reflowed together.
1286/// Lines with hard breaks (ending with 2+ spaces or backslash) form segment boundaries.
1287///
1288/// Example:
1289///   Input:  ["Line 1", "Line 2  ", "Line 3", "Line 4"]
1290///   Output: [["Line 1", "Line 2  "], ["Line 3", "Line 4"]]
1291///
1292/// The first segment includes "Line 2  " which has a hard break at the end.
1293/// The second segment starts after the hard break.
1294fn split_into_segments(para_lines: &[String]) -> Vec<Vec<String>> {
1295    let mut segments: Vec<Vec<String>> = Vec::new();
1296    let mut current_segment: Vec<String> = Vec::new();
1297
1298    for line in para_lines {
1299        current_segment.push(line.clone());
1300
1301        // If this line has a hard break, end the current segment
1302        if has_hard_break(line) {
1303            segments.push(current_segment.clone());
1304            current_segment.clear();
1305        }
1306    }
1307
1308    // Add any remaining lines as the final segment
1309    if !current_segment.is_empty() {
1310        segments.push(current_segment);
1311    }
1312
1313    segments
1314}
1315
1316fn extract_list_marker_and_content(line: &str) -> (String, String) {
1317    // First, find the leading indentation
1318    let indent_len = line.len() - line.trim_start().len();
1319    let indent = &line[..indent_len];
1320    let trimmed = &line[indent_len..];
1321
1322    // Handle bullet lists
1323    // Trim trailing whitespace while preserving hard breaks
1324    if let Some(rest) = trimmed.strip_prefix("- ") {
1325        return (format!("{indent}- "), trim_preserving_hard_break(rest));
1326    }
1327    if let Some(rest) = trimmed.strip_prefix("* ") {
1328        return (format!("{indent}* "), trim_preserving_hard_break(rest));
1329    }
1330    if let Some(rest) = trimmed.strip_prefix("+ ") {
1331        return (format!("{indent}+ "), trim_preserving_hard_break(rest));
1332    }
1333
1334    // Handle numbered lists on trimmed content
1335    let mut chars = trimmed.chars();
1336    let mut marker_content = String::new();
1337
1338    while let Some(c) = chars.next() {
1339        marker_content.push(c);
1340        if c == '.' {
1341            // Check if next char is a space
1342            if let Some(next) = chars.next()
1343                && next == ' '
1344            {
1345                marker_content.push(next);
1346                // Trim trailing whitespace while preserving hard breaks
1347                let content = trim_preserving_hard_break(chars.as_str());
1348                return (format!("{indent}{marker_content}"), content);
1349            }
1350            break;
1351        }
1352    }
1353
1354    // Fallback - shouldn't happen if is_list_item was correct
1355    (String::new(), line.to_string())
1356}
1357
1358// Helper functions
1359fn is_horizontal_rule(line: &str) -> bool {
1360    if line.len() < 3 {
1361        return false;
1362    }
1363    // Check if line consists only of -, _, or * characters (at least 3)
1364    let chars: Vec<char> = line.chars().collect();
1365    if chars.is_empty() {
1366        return false;
1367    }
1368    let first_char = chars[0];
1369    if first_char != '-' && first_char != '_' && first_char != '*' {
1370        return false;
1371    }
1372    // All characters should be the same (allowing spaces between)
1373    for c in &chars {
1374        if *c != first_char && *c != ' ' {
1375            return false;
1376        }
1377    }
1378    // Must have at least 3 of the marker character
1379    chars.iter().filter(|c| **c == first_char).count() >= 3
1380}
1381
1382fn is_numbered_list_item(line: &str) -> bool {
1383    let mut chars = line.chars();
1384    // Must start with a digit
1385    if !chars.next().is_some_and(|c| c.is_numeric()) {
1386        return false;
1387    }
1388    // Can have more digits
1389    while let Some(c) = chars.next() {
1390        if c == '.' {
1391            // After period, must have a space or be end of line
1392            return chars.next().is_none_or(|c| c == ' ');
1393        }
1394        if !c.is_numeric() {
1395            return false;
1396        }
1397    }
1398    false
1399}
1400
1401fn is_list_item(line: &str) -> bool {
1402    // Bullet lists
1403    if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
1404        && line.len() > 1
1405        && line.chars().nth(1) == Some(' ')
1406    {
1407        return true;
1408    }
1409    // Numbered lists
1410    is_numbered_list_item(line)
1411}
1412
1413#[cfg(test)]
1414mod tests {
1415    use super::*;
1416    use crate::lint_context::LintContext;
1417
1418    #[test]
1419    fn test_default_config() {
1420        let rule = MD013LineLength::default();
1421        assert_eq!(rule.config.line_length, 80);
1422        assert!(rule.config.code_blocks); // Default is true
1423        assert!(rule.config.tables); // Default is true
1424        assert!(rule.config.headings); // Default is true
1425        assert!(!rule.config.strict);
1426    }
1427
1428    #[test]
1429    fn test_custom_config() {
1430        let rule = MD013LineLength::new(100, true, true, false, true);
1431        assert_eq!(rule.config.line_length, 100);
1432        assert!(rule.config.code_blocks);
1433        assert!(rule.config.tables);
1434        assert!(!rule.config.headings);
1435        assert!(rule.config.strict);
1436    }
1437
1438    #[test]
1439    fn test_basic_line_length_violation() {
1440        let rule = MD013LineLength::new(50, false, false, false, false);
1441        let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
1442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1443        let result = rule.check(&ctx).unwrap();
1444
1445        assert_eq!(result.len(), 1);
1446        assert!(result[0].message.contains("Line length"));
1447        assert!(result[0].message.contains("exceeds 50 characters"));
1448    }
1449
1450    #[test]
1451    fn test_no_violation_under_limit() {
1452        let rule = MD013LineLength::new(100, false, false, false, false);
1453        let content = "Short line.\nAnother short line.";
1454        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1455        let result = rule.check(&ctx).unwrap();
1456
1457        assert_eq!(result.len(), 0);
1458    }
1459
1460    #[test]
1461    fn test_multiple_violations() {
1462        let rule = MD013LineLength::new(30, false, false, false, false);
1463        let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
1464        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1465        let result = rule.check(&ctx).unwrap();
1466
1467        assert_eq!(result.len(), 2);
1468        assert_eq!(result[0].line, 1);
1469        assert_eq!(result[1].line, 2);
1470    }
1471
1472    #[test]
1473    fn test_code_blocks_exemption() {
1474        // With code_blocks = false, code blocks should be skipped
1475        let rule = MD013LineLength::new(30, false, false, false, false);
1476        let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
1477        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1478        let result = rule.check(&ctx).unwrap();
1479
1480        assert_eq!(result.len(), 0);
1481    }
1482
1483    #[test]
1484    fn test_code_blocks_not_exempt_when_configured() {
1485        // With code_blocks = true, code blocks should be checked
1486        let rule = MD013LineLength::new(30, true, false, false, false);
1487        let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
1488        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1489        let result = rule.check(&ctx).unwrap();
1490
1491        assert!(!result.is_empty());
1492    }
1493
1494    #[test]
1495    fn test_heading_checked_when_enabled() {
1496        let rule = MD013LineLength::new(30, false, false, true, false);
1497        let content = "# This is a very long heading that would normally exceed the limit";
1498        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1499        let result = rule.check(&ctx).unwrap();
1500
1501        assert_eq!(result.len(), 1);
1502    }
1503
1504    #[test]
1505    fn test_heading_exempt_when_disabled() {
1506        let rule = MD013LineLength::new(30, false, false, false, false);
1507        let content = "# This is a very long heading that should trigger a warning";
1508        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1509        let result = rule.check(&ctx).unwrap();
1510
1511        assert_eq!(result.len(), 0);
1512    }
1513
1514    #[test]
1515    fn test_table_checked_when_enabled() {
1516        let rule = MD013LineLength::new(30, false, true, false, false);
1517        let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
1518        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1519        let result = rule.check(&ctx).unwrap();
1520
1521        assert_eq!(result.len(), 2); // Both table lines exceed limit
1522    }
1523
1524    #[test]
1525    fn test_issue_78_tables_after_fenced_code_blocks() {
1526        // Test for GitHub issue #78 - tables with tables=false after fenced code blocks
1527        let rule = MD013LineLength::new(20, false, false, false, false); // tables=false
1528        let content = r#"# heading
1529
1530```plain
1531some code block longer than 20 chars length
1532```
1533
1534this is a very long line
1535
1536| column A | column B |
1537| -------- | -------- |
1538| `var` | `val` |
1539| value 1 | value 2 |
1540
1541correct length line"#;
1542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1543        let result = rule.check(&ctx).unwrap();
1544
1545        // Should only flag line 7 ("this is a very long line"), not the table lines
1546        assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1547        assert_eq!(result[0].line, 7, "Should flag line 7");
1548        assert!(result[0].message.contains("24 exceeds 20"));
1549    }
1550
1551    #[test]
1552    fn test_issue_78_tables_with_inline_code() {
1553        // Test that tables with inline code (backticks) are properly detected as tables
1554        let rule = MD013LineLength::new(20, false, false, false, false); // tables=false
1555        let content = r#"| column A | column B |
1556| -------- | -------- |
1557| `var with very long name` | `val exceeding limit` |
1558| value 1 | value 2 |
1559
1560This line exceeds limit"#;
1561        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1562        let result = rule.check(&ctx).unwrap();
1563
1564        // Should only flag the last line, not the table lines
1565        assert_eq!(result.len(), 1, "Should only flag the non-table line");
1566        assert_eq!(result[0].line, 6, "Should flag line 6");
1567    }
1568
1569    #[test]
1570    fn test_issue_78_indented_code_blocks() {
1571        // Test with indented code blocks instead of fenced
1572        let rule = MD013LineLength::new(20, false, false, false, false); // tables=false
1573        let content = r#"# heading
1574
1575    some code block longer than 20 chars length
1576
1577this is a very long line
1578
1579| column A | column B |
1580| -------- | -------- |
1581| value 1 | value 2 |
1582
1583correct length line"#;
1584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1585        let result = rule.check(&ctx).unwrap();
1586
1587        // Should only flag line 5 ("this is a very long line"), not the table lines
1588        assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1589        assert_eq!(result[0].line, 5, "Should flag line 5");
1590    }
1591
1592    #[test]
1593    fn test_url_exemption() {
1594        let rule = MD013LineLength::new(30, false, false, false, false);
1595        let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1596        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1597        let result = rule.check(&ctx).unwrap();
1598
1599        assert_eq!(result.len(), 0);
1600    }
1601
1602    #[test]
1603    fn test_image_reference_exemption() {
1604        let rule = MD013LineLength::new(30, false, false, false, false);
1605        let content = "![This is a very long image alt text that exceeds limit][reference]";
1606        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1607        let result = rule.check(&ctx).unwrap();
1608
1609        assert_eq!(result.len(), 0);
1610    }
1611
1612    #[test]
1613    fn test_link_reference_exemption() {
1614        let rule = MD013LineLength::new(30, false, false, false, false);
1615        let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
1616        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1617        let result = rule.check(&ctx).unwrap();
1618
1619        assert_eq!(result.len(), 0);
1620    }
1621
1622    #[test]
1623    fn test_strict_mode() {
1624        let rule = MD013LineLength::new(30, false, false, false, true);
1625        let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1626        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1627        let result = rule.check(&ctx).unwrap();
1628
1629        // In strict mode, even URLs trigger warnings
1630        assert_eq!(result.len(), 1);
1631    }
1632
1633    #[test]
1634    fn test_blockquote_exemption() {
1635        let rule = MD013LineLength::new(30, false, false, false, false);
1636        let content = "> This is a very long line inside a blockquote that should be ignored.";
1637        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1638        let result = rule.check(&ctx).unwrap();
1639
1640        assert_eq!(result.len(), 0);
1641    }
1642
1643    #[test]
1644    fn test_setext_heading_underline_exemption() {
1645        let rule = MD013LineLength::new(30, false, false, false, false);
1646        let content = "Heading\n========================================";
1647        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1648        let result = rule.check(&ctx).unwrap();
1649
1650        // The underline should be exempt
1651        assert_eq!(result.len(), 0);
1652    }
1653
1654    #[test]
1655    fn test_no_fix_without_reflow() {
1656        let rule = MD013LineLength::new(60, false, false, false, false);
1657        let content = "This line has trailing whitespace that makes it too long      ";
1658        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1659        let result = rule.check(&ctx).unwrap();
1660
1661        assert_eq!(result.len(), 1);
1662        // Without reflow, no fix is provided
1663        assert!(result[0].fix.is_none());
1664
1665        // Fix method returns content unchanged
1666        let fixed = rule.fix(&ctx).unwrap();
1667        assert_eq!(fixed, content);
1668    }
1669
1670    #[test]
1671    fn test_character_vs_byte_counting() {
1672        let rule = MD013LineLength::new(10, false, false, false, false);
1673        // Unicode characters should count as 1 character each
1674        let content = "你好世界这是测试文字超过限制"; // 14 characters
1675        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1676        let result = rule.check(&ctx).unwrap();
1677
1678        assert_eq!(result.len(), 1);
1679        assert_eq!(result[0].line, 1);
1680    }
1681
1682    #[test]
1683    fn test_empty_content() {
1684        let rule = MD013LineLength::default();
1685        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1686        let result = rule.check(&ctx).unwrap();
1687
1688        assert_eq!(result.len(), 0);
1689    }
1690
1691    #[test]
1692    fn test_excess_range_calculation() {
1693        let rule = MD013LineLength::new(10, false, false, false, false);
1694        let content = "12345678901234567890"; // 20 chars, limit is 10
1695        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1696        let result = rule.check(&ctx).unwrap();
1697
1698        assert_eq!(result.len(), 1);
1699        // The warning should highlight from character 11 onwards
1700        assert_eq!(result[0].column, 11);
1701        assert_eq!(result[0].end_column, 21);
1702    }
1703
1704    #[test]
1705    fn test_html_block_exemption() {
1706        let rule = MD013LineLength::new(30, false, false, false, false);
1707        let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1708        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1709        let result = rule.check(&ctx).unwrap();
1710
1711        // HTML blocks should be exempt
1712        assert_eq!(result.len(), 0);
1713    }
1714
1715    #[test]
1716    fn test_mixed_content() {
1717        // code_blocks=false, tables=false, headings=false (all skipped/exempt)
1718        let rule = MD013LineLength::new(30, false, false, false, false);
1719        let content = r#"# This heading is very long but should be exempt
1720
1721This regular paragraph line is too long and should trigger.
1722
1723```
1724Code block line that is very long but exempt.
1725```
1726
1727| Table | With very long content |
1728|-------|------------------------|
1729
1730Another long line that should trigger a warning."#;
1731
1732        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1733        let result = rule.check(&ctx).unwrap();
1734
1735        // Should have warnings for the two regular paragraph lines only
1736        assert_eq!(result.len(), 2);
1737        assert_eq!(result[0].line, 3);
1738        assert_eq!(result[1].line, 12);
1739    }
1740
1741    #[test]
1742    fn test_fix_without_reflow_preserves_content() {
1743        let rule = MD013LineLength::new(50, false, false, false, false);
1744        let content = "Line 1\nThis line has trailing spaces and is too long      \nLine 3";
1745        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1746
1747        // Without reflow, content is unchanged
1748        let fixed = rule.fix(&ctx).unwrap();
1749        assert_eq!(fixed, content);
1750    }
1751
1752    #[test]
1753    fn test_content_detection() {
1754        let rule = MD013LineLength::default();
1755
1756        // Use a line longer than default line_length (80) to ensure it's not skipped
1757        let long_line = "a".repeat(100);
1758        let ctx = LintContext::new(&long_line, crate::config::MarkdownFlavor::Standard);
1759        assert!(!rule.should_skip(&ctx)); // Should not skip processing when there's long content
1760
1761        let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1762        assert!(rule.should_skip(&empty_ctx)); // Should skip processing when content is empty
1763    }
1764
1765    #[test]
1766    fn test_rule_metadata() {
1767        let rule = MD013LineLength::default();
1768        assert_eq!(rule.name(), "MD013");
1769        assert_eq!(rule.description(), "Line length should not be excessive");
1770        assert_eq!(rule.category(), RuleCategory::Whitespace);
1771    }
1772
1773    #[test]
1774    fn test_url_embedded_in_text() {
1775        let rule = MD013LineLength::new(50, false, false, false, false);
1776
1777        // This line would be 85 chars, but only ~45 without the URL
1778        let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1779        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1780        let result = rule.check(&ctx).unwrap();
1781
1782        // Should not flag because effective length (with URL placeholder) is under 50
1783        assert_eq!(result.len(), 0);
1784    }
1785
1786    #[test]
1787    fn test_multiple_urls_in_line() {
1788        let rule = MD013LineLength::new(50, false, false, false, false);
1789
1790        // Line with multiple URLs
1791        let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1792        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1793
1794        let result = rule.check(&ctx).unwrap();
1795
1796        // Should not flag because effective length is reasonable
1797        assert_eq!(result.len(), 0);
1798    }
1799
1800    #[test]
1801    fn test_markdown_link_with_long_url() {
1802        let rule = MD013LineLength::new(50, false, false, false, false);
1803
1804        // Markdown link with very long URL
1805        let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1806        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1807        let result = rule.check(&ctx).unwrap();
1808
1809        // Should not flag because effective length counts link as short
1810        assert_eq!(result.len(), 0);
1811    }
1812
1813    #[test]
1814    fn test_line_too_long_even_without_urls() {
1815        let rule = MD013LineLength::new(50, false, false, false, false);
1816
1817        // Line that's too long even after URL exclusion
1818        let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1819        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1820        let result = rule.check(&ctx).unwrap();
1821
1822        // Should flag because even with URL placeholder, line is too long
1823        assert_eq!(result.len(), 1);
1824    }
1825
1826    #[test]
1827    fn test_strict_mode_counts_urls() {
1828        let rule = MD013LineLength::new(50, false, false, false, true); // strict=true
1829
1830        // Same line that passes in non-strict mode
1831        let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1832        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1833        let result = rule.check(&ctx).unwrap();
1834
1835        // In strict mode, should flag because full URL is counted
1836        assert_eq!(result.len(), 1);
1837    }
1838
1839    #[test]
1840    fn test_documentation_example_from_md051() {
1841        let rule = MD013LineLength::new(80, false, false, false, false);
1842
1843        // This is the actual line from md051.md that was causing issues
1844        let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1845        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1846        let result = rule.check(&ctx).unwrap();
1847
1848        // Should not flag because the URL is in a markdown link
1849        assert_eq!(result.len(), 0);
1850    }
1851
1852    #[test]
1853    fn test_text_reflow_simple() {
1854        let config = MD013Config {
1855            line_length: 30,
1856            reflow: true,
1857            ..Default::default()
1858        };
1859        let rule = MD013LineLength::from_config_struct(config);
1860
1861        let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1862        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1863
1864        let fixed = rule.fix(&ctx).unwrap();
1865
1866        // Verify all lines are under 30 chars
1867        for line in fixed.lines() {
1868            assert!(
1869                line.chars().count() <= 30,
1870                "Line too long: {} (len={})",
1871                line,
1872                line.chars().count()
1873            );
1874        }
1875
1876        // Verify content is preserved
1877        let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1878        let original_words: Vec<&str> = content.split_whitespace().collect();
1879        assert_eq!(fixed_words, original_words);
1880    }
1881
1882    #[test]
1883    fn test_text_reflow_preserves_markdown_elements() {
1884        let config = MD013Config {
1885            line_length: 40,
1886            reflow: true,
1887            ..Default::default()
1888        };
1889        let rule = MD013LineLength::from_config_struct(config);
1890
1891        let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1892        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1893
1894        let fixed = rule.fix(&ctx).unwrap();
1895
1896        // Verify markdown elements are preserved
1897        assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1898        assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1899        assert!(
1900            fixed.contains("[a link](https://example.com)"),
1901            "Link not preserved in: {fixed}"
1902        );
1903
1904        // Verify all lines are under 40 chars
1905        for line in fixed.lines() {
1906            assert!(line.len() <= 40, "Line too long: {line}");
1907        }
1908    }
1909
1910    #[test]
1911    fn test_text_reflow_preserves_code_blocks() {
1912        let config = MD013Config {
1913            line_length: 30,
1914            reflow: true,
1915            ..Default::default()
1916        };
1917        let rule = MD013LineLength::from_config_struct(config);
1918
1919        let content = r#"Here is some text.
1920
1921```python
1922def very_long_function_name_that_exceeds_limit():
1923    return "This should not be wrapped"
1924```
1925
1926More text after code block."#;
1927        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1928
1929        let fixed = rule.fix(&ctx).unwrap();
1930
1931        // Verify code block is preserved
1932        assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1933        assert!(fixed.contains("```python"));
1934        assert!(fixed.contains("```"));
1935    }
1936
1937    #[test]
1938    fn test_text_reflow_preserves_lists() {
1939        let config = MD013Config {
1940            line_length: 30,
1941            reflow: true,
1942            ..Default::default()
1943        };
1944        let rule = MD013LineLength::from_config_struct(config);
1945
1946        let content = r#"Here is a list:
1947
19481. First item with a very long line that needs wrapping
19492. Second item is short
19503. Third item also has a long line that exceeds the limit
1951
1952And a bullet list:
1953
1954- Bullet item with very long content that needs wrapping
1955- Short bullet"#;
1956        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1957
1958        let fixed = rule.fix(&ctx).unwrap();
1959
1960        // Verify list structure is preserved
1961        assert!(fixed.contains("1. "));
1962        assert!(fixed.contains("2. "));
1963        assert!(fixed.contains("3. "));
1964        assert!(fixed.contains("- "));
1965
1966        // Verify proper indentation for wrapped lines
1967        let lines: Vec<&str> = fixed.lines().collect();
1968        for (i, line) in lines.iter().enumerate() {
1969            if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
1970                // Check if next line is a continuation (should be indented with 3 spaces for numbered lists)
1971                if i + 1 < lines.len()
1972                    && !lines[i + 1].trim().is_empty()
1973                    && !lines[i + 1].trim().starts_with(char::is_numeric)
1974                    && !lines[i + 1].trim().starts_with("-")
1975                {
1976                    // Numbered list continuation lines should have 3 spaces
1977                    assert!(lines[i + 1].starts_with("   ") || lines[i + 1].trim().is_empty());
1978                }
1979            } else if line.trim().starts_with("-") {
1980                // Check if next line is a continuation (should be indented with 2 spaces for dash lists)
1981                if i + 1 < lines.len()
1982                    && !lines[i + 1].trim().is_empty()
1983                    && !lines[i + 1].trim().starts_with(char::is_numeric)
1984                    && !lines[i + 1].trim().starts_with("-")
1985                {
1986                    // Dash list continuation lines should have 2 spaces
1987                    assert!(lines[i + 1].starts_with("  ") || lines[i + 1].trim().is_empty());
1988                }
1989            }
1990        }
1991    }
1992
1993    #[test]
1994    fn test_issue_83_numbered_list_with_backticks() {
1995        // Test for issue #83: enable_reflow was incorrectly handling numbered lists
1996        let config = MD013Config {
1997            line_length: 100,
1998            reflow: true,
1999            ..Default::default()
2000        };
2001        let rule = MD013LineLength::from_config_struct(config);
2002
2003        // The exact case from issue #83
2004        let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
2005        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2006
2007        let fixed = rule.fix(&ctx).unwrap();
2008
2009        // The expected output: properly wrapped at 100 chars with correct list formatting
2010        // After the fix, it correctly accounts for "1. " (3 chars) leaving 97 for content
2011        let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n   `00000000000000000002.manifest` in this example.";
2012
2013        assert_eq!(
2014            fixed, expected,
2015            "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
2016        );
2017    }
2018
2019    #[test]
2020    fn test_text_reflow_disabled_by_default() {
2021        let rule = MD013LineLength::new(30, false, false, false, false);
2022
2023        let content = "This is a very long line that definitely exceeds thirty characters.";
2024        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2025
2026        let fixed = rule.fix(&ctx).unwrap();
2027
2028        // Without reflow enabled, it should only trim whitespace (if any)
2029        // Since there's no trailing whitespace, content should be unchanged
2030        assert_eq!(fixed, content);
2031    }
2032
2033    #[test]
2034    fn test_reflow_with_hard_line_breaks() {
2035        // Test that lines with exactly 2 trailing spaces are preserved as hard breaks
2036        let config = MD013Config {
2037            line_length: 40,
2038            reflow: true,
2039            ..Default::default()
2040        };
2041        let rule = MD013LineLength::from_config_struct(config);
2042
2043        // Test with exactly 2 spaces (hard line break)
2044        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";
2045        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2046        let fixed = rule.fix(&ctx).unwrap();
2047
2048        // Should preserve the hard line break (2 spaces)
2049        assert!(
2050            fixed.contains("  \n"),
2051            "Hard line break with exactly 2 spaces should be preserved"
2052        );
2053    }
2054
2055    #[test]
2056    fn test_reflow_preserves_reference_links() {
2057        let config = MD013Config {
2058            line_length: 40,
2059            reflow: true,
2060            ..Default::default()
2061        };
2062        let rule = MD013LineLength::from_config_struct(config);
2063
2064        let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
2065
2066[ref]: https://example.com";
2067        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2068        let fixed = rule.fix(&ctx).unwrap();
2069
2070        // Reference link should remain intact
2071        assert!(fixed.contains("[reference link][ref]"));
2072        assert!(!fixed.contains("[ reference link]"));
2073        assert!(!fixed.contains("[ref ]"));
2074    }
2075
2076    #[test]
2077    fn test_reflow_with_nested_markdown_elements() {
2078        let config = MD013Config {
2079            line_length: 35,
2080            reflow: true,
2081            ..Default::default()
2082        };
2083        let rule = MD013LineLength::from_config_struct(config);
2084
2085        let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
2086        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2087        let fixed = rule.fix(&ctx).unwrap();
2088
2089        // Nested elements should be preserved
2090        assert!(fixed.contains("**bold with `code` inside**"));
2091    }
2092
2093    #[test]
2094    fn test_reflow_with_unbalanced_markdown() {
2095        // Test edge case with unbalanced markdown
2096        let config = MD013Config {
2097            line_length: 30,
2098            reflow: true,
2099            ..Default::default()
2100        };
2101        let rule = MD013LineLength::from_config_struct(config);
2102
2103        let content = "This has **unbalanced bold that goes on for a very long time without closing";
2104        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2105        let fixed = rule.fix(&ctx).unwrap();
2106
2107        // Should handle gracefully without panic
2108        // The text reflow handles unbalanced markdown by treating it as a bold element
2109        // Check that the content is properly reflowed without panic
2110        assert!(!fixed.is_empty());
2111        // Verify the content is wrapped to 30 chars
2112        for line in fixed.lines() {
2113            assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
2114        }
2115    }
2116
2117    #[test]
2118    fn test_reflow_fix_indicator() {
2119        // Test that reflow provides fix indicators
2120        let config = MD013Config {
2121            line_length: 30,
2122            reflow: true,
2123            ..Default::default()
2124        };
2125        let rule = MD013LineLength::from_config_struct(config);
2126
2127        let content = "This is a very long line that definitely exceeds the thirty character limit";
2128        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2129        let warnings = rule.check(&ctx).unwrap();
2130
2131        // Should have a fix indicator when reflow is true
2132        assert!(!warnings.is_empty());
2133        assert!(
2134            warnings[0].fix.is_some(),
2135            "Should provide fix indicator when reflow is true"
2136        );
2137    }
2138
2139    #[test]
2140    fn test_no_fix_indicator_without_reflow() {
2141        // Test that without reflow, no fix is provided
2142        let config = MD013Config {
2143            line_length: 30,
2144            reflow: false,
2145            ..Default::default()
2146        };
2147        let rule = MD013LineLength::from_config_struct(config);
2148
2149        let content = "This is a very long line that definitely exceeds the thirty character limit";
2150        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2151        let warnings = rule.check(&ctx).unwrap();
2152
2153        // Should NOT have a fix indicator when reflow is false
2154        assert!(!warnings.is_empty());
2155        assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
2156    }
2157
2158    #[test]
2159    fn test_reflow_preserves_all_reference_link_types() {
2160        let config = MD013Config {
2161            line_length: 40,
2162            reflow: true,
2163            ..Default::default()
2164        };
2165        let rule = MD013LineLength::from_config_struct(config);
2166
2167        let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
2168
2169[ref]: https://example.com
2170[collapsed]: https://example.com
2171[shortcut]: https://example.com";
2172
2173        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2174        let fixed = rule.fix(&ctx).unwrap();
2175
2176        // All reference link types should be preserved
2177        assert!(fixed.contains("[full reference][ref]"));
2178        assert!(fixed.contains("[collapsed][]"));
2179        assert!(fixed.contains("[shortcut]"));
2180    }
2181
2182    #[test]
2183    fn test_reflow_handles_images_correctly() {
2184        let config = MD013Config {
2185            line_length: 40,
2186            reflow: true,
2187            ..Default::default()
2188        };
2189        let rule = MD013LineLength::from_config_struct(config);
2190
2191        let content = "This line has an ![image alt text](https://example.com/image.png) that should not be broken when reflowing.";
2192        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2193        let fixed = rule.fix(&ctx).unwrap();
2194
2195        // Image should remain intact
2196        assert!(fixed.contains("![image alt text](https://example.com/image.png)"));
2197    }
2198
2199    #[test]
2200    fn test_normalize_mode_flags_short_lines() {
2201        let config = MD013Config {
2202            line_length: 100,
2203            reflow: true,
2204            reflow_mode: ReflowMode::Normalize,
2205            ..Default::default()
2206        };
2207        let rule = MD013LineLength::from_config_struct(config);
2208
2209        // Content with short lines that could be combined
2210        let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
2211        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2212        let warnings = rule.check(&ctx).unwrap();
2213
2214        // Should flag the paragraph as needing normalization
2215        assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2216        assert!(warnings[0].message.contains("normalized"));
2217    }
2218
2219    #[test]
2220    fn test_normalize_mode_combines_short_lines() {
2221        let config = MD013Config {
2222            line_length: 100,
2223            reflow: true,
2224            reflow_mode: ReflowMode::Normalize,
2225            ..Default::default()
2226        };
2227        let rule = MD013LineLength::from_config_struct(config);
2228
2229        // Content with short lines that should be combined
2230        let content =
2231            "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
2232        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2233        let fixed = rule.fix(&ctx).unwrap();
2234
2235        // Should combine into a single line since it's under 100 chars total
2236        let lines: Vec<&str> = fixed.lines().collect();
2237        assert_eq!(lines.len(), 1, "Should combine into single line");
2238        assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
2239    }
2240
2241    #[test]
2242    fn test_normalize_mode_preserves_paragraph_breaks() {
2243        let config = MD013Config {
2244            line_length: 100,
2245            reflow: true,
2246            reflow_mode: ReflowMode::Normalize,
2247            ..Default::default()
2248        };
2249        let rule = MD013LineLength::from_config_struct(config);
2250
2251        let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
2252        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2253        let fixed = rule.fix(&ctx).unwrap();
2254
2255        // Should preserve paragraph breaks (empty lines)
2256        assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
2257
2258        let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
2259        assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
2260    }
2261
2262    #[test]
2263    fn test_default_mode_only_fixes_violations() {
2264        let config = MD013Config {
2265            line_length: 100,
2266            reflow: true,
2267            reflow_mode: ReflowMode::Default, // Default mode
2268            ..Default::default()
2269        };
2270        let rule = MD013LineLength::from_config_struct(config);
2271
2272        // Content with short lines that are NOT violations
2273        let content = "This is a short line.\nAnother short line.\nA third short line.";
2274        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2275        let warnings = rule.check(&ctx).unwrap();
2276
2277        // Should NOT flag anything in default mode
2278        assert!(warnings.is_empty(), "Should not flag short lines in default mode");
2279
2280        // Fix should preserve the short lines
2281        let fixed = rule.fix(&ctx).unwrap();
2282        assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
2283    }
2284
2285    #[test]
2286    fn test_normalize_mode_with_lists() {
2287        let config = MD013Config {
2288            line_length: 80,
2289            reflow: true,
2290            reflow_mode: ReflowMode::Normalize,
2291            ..Default::default()
2292        };
2293        let rule = MD013LineLength::from_config_struct(config);
2294
2295        let content = r#"A paragraph with
2296short lines.
2297
22981. List item with
2299   short lines
23002. Another item"#;
2301        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2302        let fixed = rule.fix(&ctx).unwrap();
2303
2304        // Should normalize the paragraph but preserve list structure
2305        let lines: Vec<&str> = fixed.lines().collect();
2306        assert!(lines[0].len() > 20, "First paragraph should be normalized");
2307        assert!(fixed.contains("1. "), "Should preserve list markers");
2308        assert!(fixed.contains("2. "), "Should preserve list markers");
2309    }
2310
2311    #[test]
2312    fn test_normalize_mode_with_code_blocks() {
2313        let config = MD013Config {
2314            line_length: 100,
2315            reflow: true,
2316            reflow_mode: ReflowMode::Normalize,
2317            ..Default::default()
2318        };
2319        let rule = MD013LineLength::from_config_struct(config);
2320
2321        let content = r#"A paragraph with
2322short lines.
2323
2324```
2325code block should not be normalized
2326even with short lines
2327```
2328
2329Another paragraph with
2330short lines."#;
2331        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2332        let fixed = rule.fix(&ctx).unwrap();
2333
2334        // Code block should be preserved as-is
2335        assert!(fixed.contains("code block should not be normalized\neven with short lines"));
2336        // But paragraphs should be normalized
2337        let lines: Vec<&str> = fixed.lines().collect();
2338        assert!(lines[0].len() > 20, "First paragraph should be normalized");
2339    }
2340
2341    #[test]
2342    fn test_issue_76_use_case() {
2343        // This tests the exact use case from issue #76
2344        let config = MD013Config {
2345            line_length: 999999, // Set absurdly high
2346            reflow: true,
2347            reflow_mode: ReflowMode::Normalize,
2348            ..Default::default()
2349        };
2350        let rule = MD013LineLength::from_config_struct(config);
2351
2352        // Content with manual line breaks at 80 characters (typical markdown)
2353        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.";
2354
2355        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2356
2357        // Should flag for normalization even though no lines exceed limit
2358        let warnings = rule.check(&ctx).unwrap();
2359        assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2360
2361        // Should combine into a single line
2362        let fixed = rule.fix(&ctx).unwrap();
2363        let lines: Vec<&str> = fixed.lines().collect();
2364        assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
2365        assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
2366    }
2367
2368    #[test]
2369    fn test_normalize_mode_single_line_unchanged() {
2370        // Single lines should not be flagged or changed
2371        let config = MD013Config {
2372            line_length: 100,
2373            reflow: true,
2374            reflow_mode: ReflowMode::Normalize,
2375            ..Default::default()
2376        };
2377        let rule = MD013LineLength::from_config_struct(config);
2378
2379        let content = "This is a single line that should not be changed.";
2380        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2381
2382        let warnings = rule.check(&ctx).unwrap();
2383        assert!(warnings.is_empty(), "Single line should not be flagged");
2384
2385        let fixed = rule.fix(&ctx).unwrap();
2386        assert_eq!(fixed, content, "Single line should remain unchanged");
2387    }
2388
2389    #[test]
2390    fn test_normalize_mode_with_inline_code() {
2391        let config = MD013Config {
2392            line_length: 80,
2393            reflow: true,
2394            reflow_mode: ReflowMode::Normalize,
2395            ..Default::default()
2396        };
2397        let rule = MD013LineLength::from_config_struct(config);
2398
2399        let content =
2400            "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
2401        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2402
2403        let warnings = rule.check(&ctx).unwrap();
2404        assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
2405
2406        let fixed = rule.fix(&ctx).unwrap();
2407        assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
2408        assert!(fixed.lines().count() < 3, "Lines should be combined");
2409    }
2410
2411    #[test]
2412    fn test_normalize_mode_with_emphasis() {
2413        let config = MD013Config {
2414            line_length: 100,
2415            reflow: true,
2416            reflow_mode: ReflowMode::Normalize,
2417            ..Default::default()
2418        };
2419        let rule = MD013LineLength::from_config_struct(config);
2420
2421        let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
2422        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2423
2424        let fixed = rule.fix(&ctx).unwrap();
2425        assert!(fixed.contains("**bold**"), "Bold should be preserved");
2426        assert!(fixed.contains("*italic*"), "Italic should be preserved");
2427        assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2428    }
2429
2430    #[test]
2431    fn test_normalize_mode_respects_hard_breaks() {
2432        let config = MD013Config {
2433            line_length: 100,
2434            reflow: true,
2435            reflow_mode: ReflowMode::Normalize,
2436            ..Default::default()
2437        };
2438        let rule = MD013LineLength::from_config_struct(config);
2439
2440        // Two spaces at end of line = hard break
2441        let content = "First line with hard break  \nSecond line after break\nThird line";
2442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2443
2444        let fixed = rule.fix(&ctx).unwrap();
2445        // Hard break should be preserved
2446        assert!(fixed.contains("  \n"), "Hard break should be preserved");
2447        // But lines without hard break should be combined
2448        assert!(
2449            fixed.contains("Second line after break Third line"),
2450            "Lines without hard break should combine"
2451        );
2452    }
2453
2454    #[test]
2455    fn test_normalize_mode_with_links() {
2456        let config = MD013Config {
2457            line_length: 100,
2458            reflow: true,
2459            reflow_mode: ReflowMode::Normalize,
2460            ..Default::default()
2461        };
2462        let rule = MD013LineLength::from_config_struct(config);
2463
2464        let content =
2465            "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
2466        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2467
2468        let fixed = rule.fix(&ctx).unwrap();
2469        assert!(
2470            fixed.contains("[link](https://example.com)"),
2471            "Link should be preserved"
2472        );
2473        assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2474    }
2475
2476    #[test]
2477    fn test_normalize_mode_empty_lines_between_paragraphs() {
2478        let config = MD013Config {
2479            line_length: 100,
2480            reflow: true,
2481            reflow_mode: ReflowMode::Normalize,
2482            ..Default::default()
2483        };
2484        let rule = MD013LineLength::from_config_struct(config);
2485
2486        let content = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
2487        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2488
2489        let fixed = rule.fix(&ctx).unwrap();
2490        // Multiple empty lines should be preserved
2491        assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
2492        // Each paragraph should be normalized
2493        let parts: Vec<&str> = fixed.split("\n\n\n").collect();
2494        assert_eq!(parts.len(), 2, "Should have two parts");
2495        assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
2496        assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
2497    }
2498
2499    #[test]
2500    fn test_normalize_mode_mixed_list_types() {
2501        let config = MD013Config {
2502            line_length: 80,
2503            reflow: true,
2504            reflow_mode: ReflowMode::Normalize,
2505            ..Default::default()
2506        };
2507        let rule = MD013LineLength::from_config_struct(config);
2508
2509        let content = r#"Paragraph before list
2510with multiple lines.
2511
2512- Bullet item
2513* Another bullet
2514+ Plus bullet
2515
25161. Numbered item
25172. Another number
2518
2519Paragraph after list
2520with multiple lines."#;
2521
2522        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2523        let fixed = rule.fix(&ctx).unwrap();
2524
2525        // Lists should be preserved
2526        assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
2527        assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
2528        assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
2529        assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
2530
2531        // But paragraphs should be normalized
2532        assert!(
2533            fixed.starts_with("Paragraph before list with multiple lines."),
2534            "First paragraph should be normalized"
2535        );
2536        assert!(
2537            fixed.ends_with("Paragraph after list with multiple lines."),
2538            "Last paragraph should be normalized"
2539        );
2540    }
2541
2542    #[test]
2543    fn test_normalize_mode_with_horizontal_rules() {
2544        let config = MD013Config {
2545            line_length: 100,
2546            reflow: true,
2547            reflow_mode: ReflowMode::Normalize,
2548            ..Default::default()
2549        };
2550        let rule = MD013LineLength::from_config_struct(config);
2551
2552        let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
2553        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2554
2555        let fixed = rule.fix(&ctx).unwrap();
2556        assert!(fixed.contains("---"), "Horizontal rule should be preserved");
2557        assert!(
2558            fixed.contains("Paragraph before horizontal rule."),
2559            "First paragraph normalized"
2560        );
2561        assert!(
2562            fixed.contains("Paragraph after horizontal rule."),
2563            "Second paragraph normalized"
2564        );
2565    }
2566
2567    #[test]
2568    fn test_normalize_mode_with_indented_code() {
2569        let config = MD013Config {
2570            line_length: 100,
2571            reflow: true,
2572            reflow_mode: ReflowMode::Normalize,
2573            ..Default::default()
2574        };
2575        let rule = MD013LineLength::from_config_struct(config);
2576
2577        let content = "Paragraph before\nindented code.\n\n    This is indented code\n    Should not be normalized\n\nParagraph after\nindented code.";
2578        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2579
2580        let fixed = rule.fix(&ctx).unwrap();
2581        assert!(
2582            fixed.contains("    This is indented code\n    Should not be normalized"),
2583            "Indented code preserved"
2584        );
2585        assert!(
2586            fixed.contains("Paragraph before indented code."),
2587            "First paragraph normalized"
2588        );
2589        assert!(
2590            fixed.contains("Paragraph after indented code."),
2591            "Second paragraph normalized"
2592        );
2593    }
2594
2595    #[test]
2596    fn test_normalize_mode_disabled_without_reflow() {
2597        // Normalize mode should have no effect if reflow is disabled
2598        let config = MD013Config {
2599            line_length: 100,
2600            reflow: false, // Disabled
2601            reflow_mode: ReflowMode::Normalize,
2602            ..Default::default()
2603        };
2604        let rule = MD013LineLength::from_config_struct(config);
2605
2606        let content = "This is a line\nwith breaks that\nshould not be changed.";
2607        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2608
2609        let warnings = rule.check(&ctx).unwrap();
2610        assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
2611
2612        let fixed = rule.fix(&ctx).unwrap();
2613        assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
2614    }
2615
2616    #[test]
2617    fn test_default_mode_with_long_lines() {
2618        // Default mode should fix paragraphs that contain lines exceeding limit
2619        // The paragraph-based approach treats consecutive lines as a unit
2620        let config = MD013Config {
2621            line_length: 50,
2622            reflow: true,
2623            reflow_mode: ReflowMode::Default,
2624            ..Default::default()
2625        };
2626        let rule = MD013LineLength::from_config_struct(config);
2627
2628        let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2629        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2630
2631        let warnings = rule.check(&ctx).unwrap();
2632        assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2633        // The warning reports the line that violates in default mode
2634        assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2635
2636        let fixed = rule.fix(&ctx).unwrap();
2637        // The paragraph gets reflowed as a unit
2638        assert!(
2639            fixed.contains("Short line. This is"),
2640            "Should combine and reflow the paragraph"
2641        );
2642        assert!(
2643            fixed.contains("wrapping. Another short"),
2644            "Should include all paragraph content"
2645        );
2646    }
2647
2648    #[test]
2649    fn test_normalize_vs_default_mode_same_content() {
2650        let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2651        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2652
2653        // Test default mode
2654        let default_config = MD013Config {
2655            line_length: 100,
2656            reflow: true,
2657            reflow_mode: ReflowMode::Default,
2658            ..Default::default()
2659        };
2660        let default_rule = MD013LineLength::from_config_struct(default_config);
2661        let default_warnings = default_rule.check(&ctx).unwrap();
2662        let default_fixed = default_rule.fix(&ctx).unwrap();
2663
2664        // Test normalize mode
2665        let normalize_config = MD013Config {
2666            line_length: 100,
2667            reflow: true,
2668            reflow_mode: ReflowMode::Normalize,
2669            ..Default::default()
2670        };
2671        let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2672        let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2673        let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2674
2675        // Verify different behavior
2676        assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2677        assert!(
2678            !normalize_warnings.is_empty(),
2679            "Normalize mode should flag multi-line paragraphs"
2680        );
2681
2682        assert_eq!(
2683            default_fixed, content,
2684            "Default mode should not change content without violations"
2685        );
2686        assert_ne!(
2687            normalize_fixed, content,
2688            "Normalize mode should change multi-line paragraphs"
2689        );
2690        assert_eq!(
2691            normalize_fixed.lines().count(),
2692            1,
2693            "Normalize should combine into single line"
2694        );
2695    }
2696
2697    #[test]
2698    fn test_normalize_mode_with_reference_definitions() {
2699        let config = MD013Config {
2700            line_length: 100,
2701            reflow: true,
2702            reflow_mode: ReflowMode::Normalize,
2703            ..Default::default()
2704        };
2705        let rule = MD013LineLength::from_config_struct(config);
2706
2707        let content =
2708            "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2709        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2710
2711        let fixed = rule.fix(&ctx).unwrap();
2712        assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2713        assert!(
2714            fixed.contains("[ref]: https://example.com"),
2715            "Reference definition should be preserved"
2716        );
2717        assert!(
2718            fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2719            "Paragraph should be normalized"
2720        );
2721    }
2722
2723    #[test]
2724    fn test_normalize_mode_with_html_comments() {
2725        let config = MD013Config {
2726            line_length: 100,
2727            reflow: true,
2728            reflow_mode: ReflowMode::Normalize,
2729            ..Default::default()
2730        };
2731        let rule = MD013LineLength::from_config_struct(config);
2732
2733        let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2734        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2735
2736        let fixed = rule.fix(&ctx).unwrap();
2737        assert!(
2738            fixed.contains("<!-- This is a comment -->"),
2739            "HTML comment should be preserved"
2740        );
2741        assert!(
2742            fixed.contains("Paragraph before HTML comment."),
2743            "First paragraph normalized"
2744        );
2745        assert!(
2746            fixed.contains("Paragraph after HTML comment."),
2747            "Second paragraph normalized"
2748        );
2749    }
2750
2751    #[test]
2752    fn test_normalize_mode_line_starting_with_number() {
2753        // Regression test for the bug we fixed where "80 characters" was treated as a list
2754        let config = MD013Config {
2755            line_length: 100,
2756            reflow: true,
2757            reflow_mode: ReflowMode::Normalize,
2758            ..Default::default()
2759        };
2760        let rule = MD013LineLength::from_config_struct(config);
2761
2762        let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2763        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2764
2765        let fixed = rule.fix(&ctx).unwrap();
2766        assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2767        assert!(
2768            fixed.contains("80 characters"),
2769            "Number at start of line should be preserved"
2770        );
2771    }
2772
2773    #[test]
2774    fn test_default_mode_preserves_list_structure() {
2775        // In default mode, list continuation lines should be preserved
2776        let config = MD013Config {
2777            line_length: 80,
2778            reflow: true,
2779            reflow_mode: ReflowMode::Default,
2780            ..Default::default()
2781        };
2782        let rule = MD013LineLength::from_config_struct(config);
2783
2784        let content = r#"- This is a bullet point that has
2785  some text on multiple lines
2786  that should stay separate
2787
27881. Numbered list item with
2789   multiple lines that should
2790   also stay separate"#;
2791
2792        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2793        let fixed = rule.fix(&ctx).unwrap();
2794
2795        // In default mode, the structure should be preserved
2796        let lines: Vec<&str> = fixed.lines().collect();
2797        assert_eq!(
2798            lines[0], "- This is a bullet point that has",
2799            "First line should be unchanged"
2800        );
2801        assert_eq!(
2802            lines[1], "  some text on multiple lines",
2803            "Continuation should be preserved"
2804        );
2805        assert_eq!(
2806            lines[2], "  that should stay separate",
2807            "Second continuation should be preserved"
2808        );
2809    }
2810
2811    #[test]
2812    fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2813        // Test that multi-line list items don't get extra spaces when normalized
2814        let config = MD013Config {
2815            line_length: 80,
2816            reflow: true,
2817            reflow_mode: ReflowMode::Normalize,
2818            ..Default::default()
2819        };
2820        let rule = MD013LineLength::from_config_struct(config);
2821
2822        let content = r#"- This is a bullet point that has
2823  some text on multiple lines
2824  that should be combined
2825
28261. Numbered list item with
2827   multiple lines that need
2828   to be properly combined
28292. Second item"#;
2830
2831        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2832        let fixed = rule.fix(&ctx).unwrap();
2833
2834        // Check that there are no extra spaces in the combined list items
2835        assert!(
2836            !fixed.contains("lines  that"),
2837            "Should not have double spaces in bullet list"
2838        );
2839        assert!(
2840            !fixed.contains("need  to"),
2841            "Should not have double spaces in numbered list"
2842        );
2843
2844        // Check that the list items are properly combined
2845        assert!(
2846            fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2847            "Bullet list should be properly combined"
2848        );
2849        assert!(
2850            fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2851            "Numbered list should be properly combined"
2852        );
2853    }
2854
2855    #[test]
2856    fn test_normalize_mode_actual_numbered_list() {
2857        // Ensure actual numbered lists are still detected correctly
2858        let config = MD013Config {
2859            line_length: 100,
2860            reflow: true,
2861            reflow_mode: ReflowMode::Normalize,
2862            ..Default::default()
2863        };
2864        let rule = MD013LineLength::from_config_struct(config);
2865
2866        let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2867        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2868
2869        let fixed = rule.fix(&ctx).unwrap();
2870        assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2871        assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2872        assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2873        assert!(
2874            fixed.starts_with("Paragraph before list with multiple lines."),
2875            "Paragraph should be normalized"
2876        );
2877    }
2878
2879    #[test]
2880    fn test_sentence_per_line_detection() {
2881        let config = MD013Config {
2882            reflow: true,
2883            reflow_mode: ReflowMode::SentencePerLine,
2884            ..Default::default()
2885        };
2886        let rule = MD013LineLength::from_config_struct(config.clone());
2887
2888        // Test detection of multiple sentences
2889        let content = "This is sentence one. This is sentence two. And sentence three!";
2890        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2891
2892        // Debug: check if should_skip returns false
2893        assert!(!rule.should_skip(&ctx), "Should not skip for sentence-per-line mode");
2894
2895        let result = rule.check(&ctx).unwrap();
2896
2897        assert!(!result.is_empty(), "Should detect multiple sentences on one line");
2898        assert_eq!(
2899            result[0].message,
2900            "Line contains multiple sentences (one sentence per line expected)"
2901        );
2902    }
2903
2904    #[test]
2905    fn test_sentence_per_line_fix() {
2906        let config = MD013Config {
2907            reflow: true,
2908            reflow_mode: ReflowMode::SentencePerLine,
2909            ..Default::default()
2910        };
2911        let rule = MD013LineLength::from_config_struct(config);
2912
2913        let content = "First sentence. Second sentence.";
2914        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2915        let result = rule.check(&ctx).unwrap();
2916
2917        assert!(!result.is_empty(), "Should detect violation");
2918        assert!(result[0].fix.is_some(), "Should provide a fix");
2919
2920        let fix = result[0].fix.as_ref().unwrap();
2921        assert_eq!(fix.replacement.trim(), "First sentence.\nSecond sentence.");
2922    }
2923
2924    #[test]
2925    fn test_sentence_per_line_abbreviations() {
2926        let config = MD013Config {
2927            reflow: true,
2928            reflow_mode: ReflowMode::SentencePerLine,
2929            ..Default::default()
2930        };
2931        let rule = MD013LineLength::from_config_struct(config);
2932
2933        // Should NOT trigger on abbreviations
2934        let content = "Mr. Smith met Dr. Jones at 3:00 PM.";
2935        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2936        let result = rule.check(&ctx).unwrap();
2937
2938        assert!(
2939            result.is_empty(),
2940            "Should not detect abbreviations as sentence boundaries"
2941        );
2942    }
2943
2944    #[test]
2945    fn test_sentence_per_line_with_markdown() {
2946        let config = MD013Config {
2947            reflow: true,
2948            reflow_mode: ReflowMode::SentencePerLine,
2949            ..Default::default()
2950        };
2951        let rule = MD013LineLength::from_config_struct(config);
2952
2953        let content = "# Heading\n\nSentence with **bold**. Another with [link](url).";
2954        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2955        let result = rule.check(&ctx).unwrap();
2956
2957        assert!(!result.is_empty(), "Should detect multiple sentences with markdown");
2958        assert_eq!(result[0].line, 3); // Third line has the violation
2959    }
2960
2961    #[test]
2962    fn test_sentence_per_line_questions_exclamations() {
2963        let config = MD013Config {
2964            reflow: true,
2965            reflow_mode: ReflowMode::SentencePerLine,
2966            ..Default::default()
2967        };
2968        let rule = MD013LineLength::from_config_struct(config);
2969
2970        let content = "Is this a question? Yes it is! And a statement.";
2971        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2972        let result = rule.check(&ctx).unwrap();
2973
2974        assert!(!result.is_empty(), "Should detect sentences with ? and !");
2975
2976        let fix = result[0].fix.as_ref().unwrap();
2977        let lines: Vec<&str> = fix.replacement.trim().lines().collect();
2978        assert_eq!(lines.len(), 3);
2979        assert_eq!(lines[0], "Is this a question?");
2980        assert_eq!(lines[1], "Yes it is!");
2981        assert_eq!(lines[2], "And a statement.");
2982    }
2983
2984    #[test]
2985    fn test_sentence_per_line_in_lists() {
2986        let config = MD013Config {
2987            reflow: true,
2988            reflow_mode: ReflowMode::SentencePerLine,
2989            ..Default::default()
2990        };
2991        let rule = MD013LineLength::from_config_struct(config);
2992
2993        let content = "- List item one. With two sentences.\n- Another item.";
2994        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2995        let result = rule.check(&ctx).unwrap();
2996
2997        assert!(!result.is_empty(), "Should detect sentences in list items");
2998        // The fix should preserve list formatting
2999        let fix = result[0].fix.as_ref().unwrap();
3000        assert!(fix.replacement.starts_with("- "), "Should preserve list marker");
3001    }
3002
3003    #[test]
3004    fn test_multi_paragraph_list_item_with_3_space_indent() {
3005        let config = MD013Config {
3006            reflow: true,
3007            reflow_mode: ReflowMode::Normalize,
3008            line_length: 999999,
3009            ..Default::default()
3010        };
3011        let rule = MD013LineLength::from_config_struct(config);
3012
3013        let content = "1. First paragraph\n   continuation line.\n\n   Second paragraph\n   more content.";
3014        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3015        let result = rule.check(&ctx).unwrap();
3016
3017        assert!(!result.is_empty(), "Should detect multi-line paragraphs in list item");
3018        let fix = result[0].fix.as_ref().unwrap();
3019
3020        // Should preserve paragraph structure, not collapse everything
3021        assert!(
3022            fix.replacement.contains("\n\n"),
3023            "Should preserve blank line between paragraphs"
3024        );
3025        assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3026    }
3027
3028    #[test]
3029    fn test_multi_paragraph_list_item_with_4_space_indent() {
3030        let config = MD013Config {
3031            reflow: true,
3032            reflow_mode: ReflowMode::Normalize,
3033            line_length: 999999,
3034            ..Default::default()
3035        };
3036        let rule = MD013LineLength::from_config_struct(config);
3037
3038        // User's example from issue #76 - uses 4 spaces for continuation
3039        let content = "1. It **generated an application template**. There's a lot of files and\n    configurations required to build a native installer, above and\n    beyond the code of your actual application.\n\n    If you're not happy with the template provided by Briefcase, you can\n    provide your own.";
3040        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3041        let result = rule.check(&ctx).unwrap();
3042
3043        assert!(
3044            !result.is_empty(),
3045            "Should detect multi-line paragraphs in list item with 4-space indent"
3046        );
3047        let fix = result[0].fix.as_ref().unwrap();
3048
3049        // Should preserve paragraph structure
3050        assert!(
3051            fix.replacement.contains("\n\n"),
3052            "Should preserve blank line between paragraphs"
3053        );
3054        assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3055
3056        // Both paragraphs should be reflowed but kept separate
3057        let lines: Vec<&str> = fix.replacement.split('\n').collect();
3058        let blank_line_idx = lines.iter().position(|l| l.trim().is_empty());
3059        assert!(blank_line_idx.is_some(), "Should have blank line separating paragraphs");
3060    }
3061
3062    #[test]
3063    fn test_multi_paragraph_bullet_list_item() {
3064        let config = MD013Config {
3065            reflow: true,
3066            reflow_mode: ReflowMode::Normalize,
3067            line_length: 999999,
3068            ..Default::default()
3069        };
3070        let rule = MD013LineLength::from_config_struct(config);
3071
3072        let content = "- First paragraph\n  continuation.\n\n  Second paragraph\n  more text.";
3073        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3074        let result = rule.check(&ctx).unwrap();
3075
3076        assert!(!result.is_empty(), "Should detect multi-line paragraphs in bullet list");
3077        let fix = result[0].fix.as_ref().unwrap();
3078
3079        assert!(
3080            fix.replacement.contains("\n\n"),
3081            "Should preserve blank line between paragraphs"
3082        );
3083        assert!(fix.replacement.starts_with("- "), "Should preserve bullet marker");
3084    }
3085
3086    #[test]
3087    fn test_code_block_in_list_item_five_spaces() {
3088        let config = MD013Config {
3089            reflow: true,
3090            reflow_mode: ReflowMode::Normalize,
3091            line_length: 80,
3092            ..Default::default()
3093        };
3094        let rule = MD013LineLength::from_config_struct(config);
3095
3096        // 5 spaces = code block indentation (marker_len=3 + 4 = 7, but we have 5 which is marker_len+2, still valid continuation but >= marker_len+4 would be code)
3097        // For "1. " marker (3 chars), 3+4=7 spaces would be code block
3098        let content = "1. First paragraph with some text that should be reflowed.\n\n       code_block()\n       more_code()\n\n   Second paragraph.";
3099        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3100        let result = rule.check(&ctx).unwrap();
3101
3102        if !result.is_empty() {
3103            let fix = result[0].fix.as_ref().unwrap();
3104            // Code block lines should NOT be reflowed - they should be preserved with original indentation
3105            assert!(
3106                fix.replacement.contains("       code_block()"),
3107                "Code block should be preserved: {}",
3108                fix.replacement
3109            );
3110            assert!(
3111                fix.replacement.contains("       more_code()"),
3112                "Code block should be preserved: {}",
3113                fix.replacement
3114            );
3115        }
3116    }
3117
3118    #[test]
3119    fn test_fenced_code_block_in_list_item() {
3120        let config = MD013Config {
3121            reflow: true,
3122            reflow_mode: ReflowMode::Normalize,
3123            line_length: 80,
3124            ..Default::default()
3125        };
3126        let rule = MD013LineLength::from_config_struct(config);
3127
3128        let content = "1. First paragraph with some text.\n\n   ```rust\n   fn foo() {}\n   let x = 1;\n   ```\n\n   Second paragraph.";
3129        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3130        let result = rule.check(&ctx).unwrap();
3131
3132        if !result.is_empty() {
3133            let fix = result[0].fix.as_ref().unwrap();
3134            // Fenced code block should be preserved
3135            assert!(
3136                fix.replacement.contains("```rust"),
3137                "Should preserve fence: {}",
3138                fix.replacement
3139            );
3140            assert!(
3141                fix.replacement.contains("fn foo() {}"),
3142                "Should preserve code: {}",
3143                fix.replacement
3144            );
3145            assert!(
3146                fix.replacement.contains("```"),
3147                "Should preserve closing fence: {}",
3148                fix.replacement
3149            );
3150        }
3151    }
3152
3153    #[test]
3154    fn test_mixed_indentation_3_and_4_spaces() {
3155        let config = MD013Config {
3156            reflow: true,
3157            reflow_mode: ReflowMode::Normalize,
3158            line_length: 999999,
3159            ..Default::default()
3160        };
3161        let rule = MD013LineLength::from_config_struct(config);
3162
3163        // First continuation has 3 spaces, second has 4 - both should be accepted
3164        let content = "1. Text\n   3 space continuation\n    4 space continuation";
3165        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3166        let result = rule.check(&ctx).unwrap();
3167
3168        assert!(!result.is_empty(), "Should detect multi-line list item");
3169        let fix = result[0].fix.as_ref().unwrap();
3170        // Should reflow all content together
3171        assert!(
3172            fix.replacement.contains("3 space continuation"),
3173            "Should include 3-space line: {}",
3174            fix.replacement
3175        );
3176        assert!(
3177            fix.replacement.contains("4 space continuation"),
3178            "Should include 4-space line: {}",
3179            fix.replacement
3180        );
3181    }
3182
3183    #[test]
3184    fn test_nested_list_in_multi_paragraph_item() {
3185        let config = MD013Config {
3186            reflow: true,
3187            reflow_mode: ReflowMode::Normalize,
3188            line_length: 999999,
3189            ..Default::default()
3190        };
3191        let rule = MD013LineLength::from_config_struct(config);
3192
3193        let content = "1. First paragraph.\n\n   - Nested item\n     continuation\n\n   Second paragraph.";
3194        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3195        let result = rule.check(&ctx).unwrap();
3196
3197        // Nested lists at continuation indent should be INCLUDED in parent item
3198        assert!(!result.is_empty(), "Should detect and reflow parent item");
3199        if let Some(fix) = result[0].fix.as_ref() {
3200            // The nested list should be preserved in the output
3201            assert!(
3202                fix.replacement.contains("- Nested"),
3203                "Should preserve nested list: {}",
3204                fix.replacement
3205            );
3206            assert!(
3207                fix.replacement.contains("Second paragraph"),
3208                "Should include content after nested list: {}",
3209                fix.replacement
3210            );
3211        }
3212    }
3213
3214    #[test]
3215    fn test_nested_fence_markers_different_types() {
3216        let config = MD013Config {
3217            reflow: true,
3218            reflow_mode: ReflowMode::Normalize,
3219            line_length: 80,
3220            ..Default::default()
3221        };
3222        let rule = MD013LineLength::from_config_struct(config);
3223
3224        // Nested fences with different markers (backticks inside tildes)
3225        let content = "1. Example with nested fences:\n\n   ~~~markdown\n   This shows ```python\n   code = True\n   ```\n   ~~~\n\n   Text after.";
3226        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3227        let result = rule.check(&ctx).unwrap();
3228
3229        if !result.is_empty() {
3230            let fix = result[0].fix.as_ref().unwrap();
3231            // Inner fence should NOT close outer fence (different markers)
3232            assert!(
3233                fix.replacement.contains("```python"),
3234                "Should preserve inner fence: {}",
3235                fix.replacement
3236            );
3237            assert!(
3238                fix.replacement.contains("~~~"),
3239                "Should preserve outer fence: {}",
3240                fix.replacement
3241            );
3242            // All lines should remain as code
3243            assert!(
3244                fix.replacement.contains("code = True"),
3245                "Should preserve code: {}",
3246                fix.replacement
3247            );
3248        }
3249    }
3250
3251    #[test]
3252    fn test_nested_fence_markers_same_type() {
3253        let config = MD013Config {
3254            reflow: true,
3255            reflow_mode: ReflowMode::Normalize,
3256            line_length: 80,
3257            ..Default::default()
3258        };
3259        let rule = MD013LineLength::from_config_struct(config);
3260
3261        // Nested backticks - inner must have different length or won't work
3262        let content =
3263            "1. Example:\n\n   ````markdown\n   Shows ```python in code\n   ```\n   text here\n   ````\n\n   After.";
3264        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3265        let result = rule.check(&ctx).unwrap();
3266
3267        if !result.is_empty() {
3268            let fix = result[0].fix.as_ref().unwrap();
3269            // 4 backticks opened, 3 backticks shouldn't close it
3270            assert!(
3271                fix.replacement.contains("```python"),
3272                "Should preserve inner fence: {}",
3273                fix.replacement
3274            );
3275            assert!(
3276                fix.replacement.contains("````"),
3277                "Should preserve outer fence: {}",
3278                fix.replacement
3279            );
3280            assert!(
3281                fix.replacement.contains("text here"),
3282                "Should keep text as code: {}",
3283                fix.replacement
3284            );
3285        }
3286    }
3287
3288    #[test]
3289    fn test_sibling_list_item_breaks_parent() {
3290        let config = MD013Config {
3291            reflow: true,
3292            reflow_mode: ReflowMode::Normalize,
3293            line_length: 999999,
3294            ..Default::default()
3295        };
3296        let rule = MD013LineLength::from_config_struct(config);
3297
3298        // Sibling list item (at indent 0, before parent marker at 3)
3299        let content = "1. First item\n   continuation.\n2. Second item";
3300        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3301        let result = rule.check(&ctx).unwrap();
3302
3303        // Should process first item only, second item breaks it
3304        if !result.is_empty() {
3305            let fix = result[0].fix.as_ref().unwrap();
3306            // Should only include first item
3307            assert!(fix.replacement.starts_with("1. "), "Should start with first marker");
3308            assert!(fix.replacement.contains("continuation"), "Should include continuation");
3309            // Should NOT include second item (it's outside the byte range)
3310        }
3311    }
3312
3313    #[test]
3314    fn test_nested_list_at_continuation_indent_preserved() {
3315        let config = MD013Config {
3316            reflow: true,
3317            reflow_mode: ReflowMode::Normalize,
3318            line_length: 999999,
3319            ..Default::default()
3320        };
3321        let rule = MD013LineLength::from_config_struct(config);
3322
3323        // Nested list at exactly continuation indent (3 spaces for "1. ")
3324        let content = "1. Parent paragraph\n   with continuation.\n\n   - Nested at 3 spaces\n   - Another nested\n\n   After nested.";
3325        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3326        let result = rule.check(&ctx).unwrap();
3327
3328        if !result.is_empty() {
3329            let fix = result[0].fix.as_ref().unwrap();
3330            // All nested content should be preserved
3331            assert!(
3332                fix.replacement.contains("- Nested"),
3333                "Should include first nested item: {}",
3334                fix.replacement
3335            );
3336            assert!(
3337                fix.replacement.contains("- Another"),
3338                "Should include second nested item: {}",
3339                fix.replacement
3340            );
3341            assert!(
3342                fix.replacement.contains("After nested"),
3343                "Should include content after nested list: {}",
3344                fix.replacement
3345            );
3346        }
3347    }
3348}