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