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