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