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