rumdl_lib/rules/
md013_line_length.rs

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