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 and trailing whitespace)
491                            // Preserve hard breaks (2 trailing spaces) while removing excessive whitespace
492                            // See: https://github.com/rvben/rumdl/issues/76
493                            let content = trim_preserving_hard_break(&line_info.content[indent..]);
494                            list_item_lines.push(LineType::Content(content));
495                            i += 1;
496                        } else {
497                            // indent >= marker_len + 4: indented code block
498                            list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
499                            i += 1;
500                        }
501                    } else {
502                        // Not indented enough, end of list item
503                        break;
504                    }
505                }
506
507                // Use detected indent or fallback to marker length
508                let indent_size = actual_indent.unwrap_or(marker_len);
509                let expected_indent = " ".repeat(indent_size);
510
511                // Split list_item_lines into blocks (paragraphs and code blocks)
512                #[derive(Clone)]
513                enum Block {
514                    Paragraph(Vec<String>),
515                    CodeBlock(Vec<(String, usize)>), // (content, indent) pairs
516                }
517
518                let mut blocks: Vec<Block> = Vec::new();
519                let mut current_paragraph: Vec<String> = Vec::new();
520                let mut current_code_block: Vec<(String, usize)> = Vec::new();
521                let mut in_code = false;
522
523                for line in &list_item_lines {
524                    match line {
525                        LineType::Empty => {
526                            if in_code {
527                                current_code_block.push((String::new(), 0));
528                            } else if !current_paragraph.is_empty() {
529                                blocks.push(Block::Paragraph(current_paragraph.clone()));
530                                current_paragraph.clear();
531                            }
532                        }
533                        LineType::Content(content) => {
534                            if in_code {
535                                // Switching from code to content
536                                blocks.push(Block::CodeBlock(current_code_block.clone()));
537                                current_code_block.clear();
538                                in_code = false;
539                            }
540                            current_paragraph.push(content.clone());
541                        }
542                        LineType::CodeBlock(content, indent) => {
543                            if !in_code {
544                                // Switching from content to code
545                                if !current_paragraph.is_empty() {
546                                    blocks.push(Block::Paragraph(current_paragraph.clone()));
547                                    current_paragraph.clear();
548                                }
549                                in_code = true;
550                            }
551                            current_code_block.push((content.clone(), *indent));
552                        }
553                    }
554                }
555
556                // Push remaining block
557                if in_code && !current_code_block.is_empty() {
558                    blocks.push(Block::CodeBlock(current_code_block));
559                } else if !current_paragraph.is_empty() {
560                    blocks.push(Block::Paragraph(current_paragraph));
561                }
562
563                // Check if reflowing is needed (only for content paragraphs, not code blocks)
564                let content_lines: Vec<String> = list_item_lines
565                    .iter()
566                    .filter_map(|line| {
567                        if let LineType::Content(s) = line {
568                            Some(s.clone())
569                        } else {
570                            None
571                        }
572                    })
573                    .collect();
574
575                // Check if we need to reflow this list item
576                // We check the combined content to see if it exceeds length limits
577                let combined_content = content_lines.join(" ").trim().to_string();
578                let full_line = format!("{marker}{combined_content}");
579
580                let needs_reflow = self.calculate_effective_length(&full_line) > config.line_length
581                    || (config.reflow_mode == ReflowMode::Normalize && content_lines.len() > 1)
582                    || (config.reflow_mode == ReflowMode::SentencePerLine && {
583                        // Check if list item has multiple sentences
584                        let sentences = split_into_sentences(&combined_content);
585                        sentences.len() > 1
586                    });
587
588                if needs_reflow {
589                    let start_range = line_index.whole_line_range(list_start + 1);
590                    let end_line = i - 1;
591                    let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
592                        line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
593                    } else {
594                        line_index.whole_line_range(end_line + 1)
595                    };
596                    let byte_range = start_range.start..end_range.end;
597
598                    // Reflow each block (paragraphs only, preserve code blocks)
599                    let reflow_options = crate::utils::text_reflow::ReflowOptions {
600                        line_length: config.line_length - indent_size,
601                        break_on_sentences: true,
602                        preserve_breaks: false,
603                        sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
604                    };
605
606                    let mut result: Vec<String> = Vec::new();
607                    let mut is_first_block = true;
608
609                    for (block_idx, block) in blocks.iter().enumerate() {
610                        match block {
611                            Block::Paragraph(para_lines) => {
612                                // Split the paragraph into segments at hard break boundaries
613                                // Each segment can be reflowed independently
614                                let segments = split_into_segments(para_lines);
615
616                                for (segment_idx, segment) in segments.iter().enumerate() {
617                                    // Check if this segment ends with a hard break
618                                    let has_hard_break = segment.last().is_some_and(|line| line.ends_with("  "));
619
620                                    // Join and reflow the segment (removing the hard break marker for processing)
621                                    let segment_for_reflow: Vec<String> = segment
622                                        .iter()
623                                        .map(|line| {
624                                            // Strip hard break marker (2 spaces) for reflow processing
625                                            if line.ends_with("  ") {
626                                                line[..line.len() - 2].trim_end().to_string()
627                                            } else {
628                                                line.clone()
629                                            }
630                                        })
631                                        .collect();
632
633                                    let segment_text = segment_for_reflow.join(" ").trim().to_string();
634                                    if !segment_text.is_empty() {
635                                        let reflowed =
636                                            crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
637
638                                        if is_first_block && segment_idx == 0 {
639                                            // First segment of first block starts with marker
640                                            result.push(format!("{marker}{}", reflowed[0]));
641                                            for line in reflowed.iter().skip(1) {
642                                                result.push(format!("{expected_indent}{line}"));
643                                            }
644                                            is_first_block = false;
645                                        } else {
646                                            // Subsequent segments
647                                            for line in reflowed {
648                                                result.push(format!("{expected_indent}{line}"));
649                                            }
650                                        }
651
652                                        // If this segment had a hard break, add it back to the last line
653                                        if has_hard_break && let Some(last_line) = result.last_mut() {
654                                            last_line.push_str("  ");
655                                        }
656                                    }
657                                }
658
659                                // Add blank line after paragraph block if there's a next block
660                                if block_idx < blocks.len() - 1 {
661                                    result.push(String::new());
662                                }
663                            }
664                            Block::CodeBlock(code_lines) => {
665                                // Preserve code blocks as-is with original indentation
666                                if !is_first_block {
667                                    result.push(String::new());
668                                }
669
670                                for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
671                                    if is_first_block && idx == 0 {
672                                        // First line of first block gets marker
673                                        result.push(format!(
674                                            "{marker}{}",
675                                            " ".repeat(orig_indent - marker_len) + content
676                                        ));
677                                        is_first_block = false;
678                                    } else if content.is_empty() {
679                                        result.push(String::new());
680                                    } else {
681                                        result.push(format!("{}{}", " ".repeat(*orig_indent), content));
682                                    }
683                                }
684                            }
685                        }
686                    }
687
688                    let reflowed_text = result.join("\n");
689
690                    // Preserve trailing newline
691                    let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
692                        format!("{reflowed_text}\n")
693                    } else {
694                        reflowed_text
695                    };
696
697                    // Get the original text to compare
698                    let original_text = &ctx.content[byte_range.clone()];
699
700                    // Only generate a warning if the replacement is different from the original
701                    if original_text != replacement {
702                        warnings.push(LintWarning {
703                            rule_name: Some(self.name()),
704                            message: if config.reflow_mode == ReflowMode::SentencePerLine {
705                                "Line contains multiple sentences (one sentence per line expected)".to_string()
706                            } else {
707                                format!("Line length exceeds {} characters", config.line_length)
708                            },
709                            line: list_start + 1,
710                            column: 1,
711                            end_line: end_line + 1,
712                            end_column: lines[end_line].len() + 1,
713                            severity: Severity::Warning,
714                            fix: Some(crate::rule::Fix {
715                                range: byte_range,
716                                replacement,
717                            }),
718                        });
719                    }
720                }
721                continue;
722            }
723
724            // Found start of a paragraph - collect all lines in it
725            let paragraph_start = i;
726            let mut paragraph_lines = vec![lines[i]];
727            i += 1;
728
729            while i < lines.len() {
730                let next_line = lines[i];
731                let next_line_num = i + 1;
732                let next_trimmed = next_line.trim();
733
734                // Stop at paragraph boundaries
735                if next_trimmed.is_empty()
736                    || ctx.is_in_code_block(next_line_num)
737                    || ctx.is_in_front_matter(next_line_num)
738                    || ctx.is_in_html_block(next_line_num)
739                    || (next_line_num > 0
740                        && next_line_num <= ctx.lines.len()
741                        && ctx.lines[next_line_num - 1].blockquote.is_some())
742                    || next_trimmed.starts_with('#')
743                    || TableUtils::is_potential_table_row(next_line)
744                    || is_list_item(next_trimmed)
745                    || is_horizontal_rule(next_trimmed)
746                    || (next_trimmed.starts_with('[') && next_line.contains("]:"))
747                {
748                    break;
749                }
750
751                // Check if the previous line ends with a hard break (2+ spaces)
752                if i > 0 && lines[i - 1].ends_with("  ") {
753                    // Don't include lines after hard breaks in the same paragraph
754                    break;
755                }
756
757                paragraph_lines.push(next_line);
758                i += 1;
759            }
760
761            // Check if this paragraph needs reflowing
762            let needs_reflow = match config.reflow_mode {
763                ReflowMode::Normalize => {
764                    // In normalize mode, reflow multi-line paragraphs
765                    paragraph_lines.len() > 1
766                }
767                ReflowMode::SentencePerLine => {
768                    // In sentence-per-line mode, check if any line has multiple sentences
769                    paragraph_lines.iter().any(|line| {
770                        // Count sentences in this line
771                        let sentences = split_into_sentences(line);
772                        sentences.len() > 1
773                    })
774                }
775                ReflowMode::Default => {
776                    // In default mode, only reflow if lines exceed limit
777                    paragraph_lines
778                        .iter()
779                        .any(|line| self.calculate_effective_length(line) > config.line_length)
780                }
781            };
782
783            if needs_reflow {
784                // Calculate byte range for this paragraph
785                // Use whole_line_range for each line and combine
786                let start_range = line_index.whole_line_range(paragraph_start + 1);
787                let end_line = paragraph_start + paragraph_lines.len() - 1;
788
789                // For the last line, we want to preserve any trailing newline
790                let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
791                    // Last line without trailing newline - use line_text_range
792                    line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
793                } else {
794                    // Not the last line or has trailing newline - use whole_line_range
795                    line_index.whole_line_range(end_line + 1)
796                };
797
798                let byte_range = start_range.start..end_range.end;
799
800                // Combine paragraph lines into a single string for reflowing
801                let paragraph_text = paragraph_lines.join(" ");
802
803                // Check if the paragraph ends with a hard break
804                let has_hard_break = paragraph_lines.last().is_some_and(|l| l.ends_with("  "));
805
806                // Reflow the paragraph
807                let reflow_options = crate::utils::text_reflow::ReflowOptions {
808                    line_length: config.line_length,
809                    break_on_sentences: true,
810                    preserve_breaks: false,
811                    sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
812                };
813                let mut reflowed = crate::utils::text_reflow::reflow_line(&paragraph_text, &reflow_options);
814
815                // If the original paragraph ended with a hard break, preserve it
816                if has_hard_break && !reflowed.is_empty() {
817                    let last_idx = reflowed.len() - 1;
818                    if !reflowed[last_idx].ends_with("  ") {
819                        reflowed[last_idx].push_str("  ");
820                    }
821                }
822
823                let reflowed_text = reflowed.join("\n");
824
825                // Preserve trailing newline if the original paragraph had one
826                let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
827                    format!("{reflowed_text}\n")
828                } else {
829                    reflowed_text
830                };
831
832                // Get the original text to compare
833                let original_text = &ctx.content[byte_range.clone()];
834
835                // Only generate a warning if the replacement is different from the original
836                if original_text != replacement {
837                    // Create warning with actual fix
838                    // In default mode, report the specific line that violates
839                    // In normalize mode, report the whole paragraph
840                    // In sentence-per-line mode, report lines with multiple sentences
841                    let (warning_line, warning_end_line) = match config.reflow_mode {
842                        ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
843                        ReflowMode::SentencePerLine => {
844                            // Find the first line with multiple sentences
845                            let mut violating_line = paragraph_start;
846                            for (idx, line) in paragraph_lines.iter().enumerate() {
847                                let sentences = split_into_sentences(line);
848                                if sentences.len() > 1 {
849                                    violating_line = paragraph_start + idx;
850                                    break;
851                                }
852                            }
853                            (violating_line + 1, violating_line + 1)
854                        }
855                        ReflowMode::Default => {
856                            // Find the first line that exceeds the limit
857                            let mut violating_line = paragraph_start;
858                            for (idx, line) in paragraph_lines.iter().enumerate() {
859                                if self.calculate_effective_length(line) > config.line_length {
860                                    violating_line = paragraph_start + idx;
861                                    break;
862                                }
863                            }
864                            (violating_line + 1, violating_line + 1)
865                        }
866                    };
867
868                    warnings.push(LintWarning {
869                        rule_name: Some(self.name()),
870                        message: match config.reflow_mode {
871                            ReflowMode::Normalize => format!(
872                                "Paragraph could be normalized to use line length of {} characters",
873                                config.line_length
874                            ),
875                            ReflowMode::SentencePerLine => {
876                                "Line contains multiple sentences (one sentence per line expected)".to_string()
877                            }
878                            ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length),
879                        },
880                        line: warning_line,
881                        column: 1,
882                        end_line: warning_end_line,
883                        end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
884                        severity: Severity::Warning,
885                        fix: Some(crate::rule::Fix {
886                            range: byte_range,
887                            replacement,
888                        }),
889                    });
890                }
891            }
892        }
893
894        warnings
895    }
896
897    /// Calculate effective line length excluding unbreakable URLs
898    fn calculate_effective_length(&self, line: &str) -> usize {
899        if self.config.strict {
900            // In strict mode, count everything
901            return line.chars().count();
902        }
903
904        // Quick byte-level check: if line doesn't contain "http" or "[", it can't have URLs or markdown links
905        let bytes = line.as_bytes();
906        if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
907            return line.chars().count();
908        }
909
910        // More precise check for URLs and links
911        if !line.contains("http") && !line.contains('[') {
912            return line.chars().count();
913        }
914
915        let mut effective_line = line.to_string();
916
917        // First handle markdown links to avoid double-counting URLs
918        // Pattern: [text](very-long-url) -> [text](url)
919        if line.contains('[') && line.contains("](") {
920            for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
921                if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
922                    && url.as_str().len() > 15
923                {
924                    let replacement = format!("[{}](url)", text.as_str());
925                    effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
926                }
927            }
928        }
929
930        // Then replace bare URLs with a placeholder of reasonable length
931        // This allows lines with long URLs to pass if the rest of the content is reasonable
932        if effective_line.contains("http") {
933            for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
934                let url = url_match.as_str();
935                // Skip if this URL is already part of a markdown link we handled
936                if !effective_line.contains(&format!("({url})")) {
937                    // Replace URL with placeholder that represents a "reasonable" URL length
938                    // Using 15 chars as a reasonable URL placeholder (e.g., "https://ex.com")
939                    let placeholder = "x".repeat(15.min(url.len()));
940                    effective_line = effective_line.replacen(url, &placeholder, 1);
941                }
942            }
943        }
944
945        effective_line.chars().count()
946    }
947}
948
949/// Extract list marker and content from a list item
950/// Trim trailing whitespace while preserving hard breaks (exactly 2 trailing spaces)
951/// Hard breaks in Markdown are indicated by 2 trailing spaces before a newline
952fn trim_preserving_hard_break(s: &str) -> String {
953    // Strip trailing \r from CRLF line endings first to handle Windows files
954    let s = s.strip_suffix('\r').unwrap_or(s);
955
956    // Check if there are at least 2 trailing spaces (potential hard break)
957    if s.ends_with("  ") {
958        // Find the position where non-space content ends
959        let content_end = s.trim_end().len();
960        if content_end == 0 {
961            // String is all whitespace
962            return String::new();
963        }
964        // Preserve exactly 2 trailing spaces for hard break
965        format!("{}  ", &s[..content_end])
966    } else {
967        // No hard break, just trim all trailing whitespace
968        s.trim_end().to_string()
969    }
970}
971
972/// Split paragraph lines into segments at hard break boundaries.
973/// Each segment is a group of lines that can be reflowed together.
974/// Lines with hard breaks (ending with 2+ spaces) form segment boundaries.
975///
976/// Example:
977///   Input:  ["Line 1", "Line 2  ", "Line 3", "Line 4"]
978///   Output: [["Line 1", "Line 2  "], ["Line 3", "Line 4"]]
979///
980/// The first segment includes "Line 2  " which has a hard break at the end.
981/// The second segment starts after the hard break.
982fn split_into_segments(para_lines: &[String]) -> Vec<Vec<String>> {
983    let mut segments: Vec<Vec<String>> = Vec::new();
984    let mut current_segment: Vec<String> = Vec::new();
985
986    for line in para_lines {
987        current_segment.push(line.clone());
988
989        // If this line has a hard break, end the current segment
990        if line.ends_with("  ") {
991            segments.push(current_segment.clone());
992            current_segment.clear();
993        }
994    }
995
996    // Add any remaining lines as the final segment
997    if !current_segment.is_empty() {
998        segments.push(current_segment);
999    }
1000
1001    segments
1002}
1003
1004fn extract_list_marker_and_content(line: &str) -> (String, String) {
1005    // First, find the leading indentation
1006    let indent_len = line.len() - line.trim_start().len();
1007    let indent = &line[..indent_len];
1008    let trimmed = &line[indent_len..];
1009
1010    // Handle bullet lists
1011    // Trim trailing whitespace while preserving hard breaks
1012    if let Some(rest) = trimmed.strip_prefix("- ") {
1013        return (format!("{indent}- "), trim_preserving_hard_break(rest));
1014    }
1015    if let Some(rest) = trimmed.strip_prefix("* ") {
1016        return (format!("{indent}* "), trim_preserving_hard_break(rest));
1017    }
1018    if let Some(rest) = trimmed.strip_prefix("+ ") {
1019        return (format!("{indent}+ "), trim_preserving_hard_break(rest));
1020    }
1021
1022    // Handle numbered lists on trimmed content
1023    let mut chars = trimmed.chars();
1024    let mut marker_content = String::new();
1025
1026    while let Some(c) = chars.next() {
1027        marker_content.push(c);
1028        if c == '.' {
1029            // Check if next char is a space
1030            if let Some(next) = chars.next()
1031                && next == ' '
1032            {
1033                marker_content.push(next);
1034                // Trim trailing whitespace while preserving hard breaks
1035                let content = trim_preserving_hard_break(chars.as_str());
1036                return (format!("{indent}{marker_content}"), content);
1037            }
1038            break;
1039        }
1040    }
1041
1042    // Fallback - shouldn't happen if is_list_item was correct
1043    (String::new(), line.to_string())
1044}
1045
1046// Helper functions
1047fn is_horizontal_rule(line: &str) -> bool {
1048    if line.len() < 3 {
1049        return false;
1050    }
1051    // Check if line consists only of -, _, or * characters (at least 3)
1052    let chars: Vec<char> = line.chars().collect();
1053    if chars.is_empty() {
1054        return false;
1055    }
1056    let first_char = chars[0];
1057    if first_char != '-' && first_char != '_' && first_char != '*' {
1058        return false;
1059    }
1060    // All characters should be the same (allowing spaces between)
1061    for c in &chars {
1062        if *c != first_char && *c != ' ' {
1063            return false;
1064        }
1065    }
1066    // Must have at least 3 of the marker character
1067    chars.iter().filter(|c| **c == first_char).count() >= 3
1068}
1069
1070fn is_numbered_list_item(line: &str) -> bool {
1071    let mut chars = line.chars();
1072    // Must start with a digit
1073    if !chars.next().is_some_and(|c| c.is_numeric()) {
1074        return false;
1075    }
1076    // Can have more digits
1077    while let Some(c) = chars.next() {
1078        if c == '.' {
1079            // After period, must have a space or be end of line
1080            return chars.next().is_none_or(|c| c == ' ');
1081        }
1082        if !c.is_numeric() {
1083            return false;
1084        }
1085    }
1086    false
1087}
1088
1089fn is_list_item(line: &str) -> bool {
1090    // Bullet lists
1091    if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
1092        && line.len() > 1
1093        && line.chars().nth(1) == Some(' ')
1094    {
1095        return true;
1096    }
1097    // Numbered lists
1098    is_numbered_list_item(line)
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use super::*;
1104    use crate::lint_context::LintContext;
1105
1106    #[test]
1107    fn test_default_config() {
1108        let rule = MD013LineLength::default();
1109        assert_eq!(rule.config.line_length, 80);
1110        assert!(rule.config.code_blocks); // Default is true
1111        assert!(rule.config.tables); // Default is true
1112        assert!(rule.config.headings); // Default is true
1113        assert!(!rule.config.strict);
1114    }
1115
1116    #[test]
1117    fn test_custom_config() {
1118        let rule = MD013LineLength::new(100, true, true, false, true);
1119        assert_eq!(rule.config.line_length, 100);
1120        assert!(rule.config.code_blocks);
1121        assert!(rule.config.tables);
1122        assert!(!rule.config.headings);
1123        assert!(rule.config.strict);
1124    }
1125
1126    #[test]
1127    fn test_basic_line_length_violation() {
1128        let rule = MD013LineLength::new(50, false, false, false, false);
1129        let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
1130        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1131        let result = rule.check(&ctx).unwrap();
1132
1133        assert_eq!(result.len(), 1);
1134        assert!(result[0].message.contains("Line length"));
1135        assert!(result[0].message.contains("exceeds 50 characters"));
1136    }
1137
1138    #[test]
1139    fn test_no_violation_under_limit() {
1140        let rule = MD013LineLength::new(100, false, false, false, false);
1141        let content = "Short line.\nAnother short line.";
1142        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1143        let result = rule.check(&ctx).unwrap();
1144
1145        assert_eq!(result.len(), 0);
1146    }
1147
1148    #[test]
1149    fn test_multiple_violations() {
1150        let rule = MD013LineLength::new(30, false, false, false, false);
1151        let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
1152        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1153        let result = rule.check(&ctx).unwrap();
1154
1155        assert_eq!(result.len(), 2);
1156        assert_eq!(result[0].line, 1);
1157        assert_eq!(result[1].line, 2);
1158    }
1159
1160    #[test]
1161    fn test_code_blocks_exemption() {
1162        // With code_blocks = false, code blocks should be skipped
1163        let rule = MD013LineLength::new(30, false, false, false, false);
1164        let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
1165        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1166        let result = rule.check(&ctx).unwrap();
1167
1168        assert_eq!(result.len(), 0);
1169    }
1170
1171    #[test]
1172    fn test_code_blocks_not_exempt_when_configured() {
1173        // With code_blocks = true, code blocks should be checked
1174        let rule = MD013LineLength::new(30, true, false, false, false);
1175        let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
1176        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1177        let result = rule.check(&ctx).unwrap();
1178
1179        assert!(!result.is_empty());
1180    }
1181
1182    #[test]
1183    fn test_heading_checked_when_enabled() {
1184        let rule = MD013LineLength::new(30, false, false, true, false);
1185        let content = "# This is a very long heading that would normally exceed the limit";
1186        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1187        let result = rule.check(&ctx).unwrap();
1188
1189        assert_eq!(result.len(), 1);
1190    }
1191
1192    #[test]
1193    fn test_heading_exempt_when_disabled() {
1194        let rule = MD013LineLength::new(30, false, false, false, false);
1195        let content = "# This is a very long heading that should trigger a warning";
1196        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1197        let result = rule.check(&ctx).unwrap();
1198
1199        assert_eq!(result.len(), 0);
1200    }
1201
1202    #[test]
1203    fn test_table_checked_when_enabled() {
1204        let rule = MD013LineLength::new(30, false, true, false, false);
1205        let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
1206        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1207        let result = rule.check(&ctx).unwrap();
1208
1209        assert_eq!(result.len(), 2); // Both table lines exceed limit
1210    }
1211
1212    #[test]
1213    fn test_issue_78_tables_after_fenced_code_blocks() {
1214        // Test for GitHub issue #78 - tables with tables=false after fenced code blocks
1215        let rule = MD013LineLength::new(20, false, false, false, false); // tables=false
1216        let content = r#"# heading
1217
1218```plain
1219some code block longer than 20 chars length
1220```
1221
1222this is a very long line
1223
1224| column A | column B |
1225| -------- | -------- |
1226| `var` | `val` |
1227| value 1 | value 2 |
1228
1229correct length line"#;
1230        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1231        let result = rule.check(&ctx).unwrap();
1232
1233        // Should only flag line 7 ("this is a very long line"), not the table lines
1234        assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1235        assert_eq!(result[0].line, 7, "Should flag line 7");
1236        assert!(result[0].message.contains("24 exceeds 20"));
1237    }
1238
1239    #[test]
1240    fn test_issue_78_tables_with_inline_code() {
1241        // Test that tables with inline code (backticks) are properly detected as tables
1242        let rule = MD013LineLength::new(20, false, false, false, false); // tables=false
1243        let content = r#"| column A | column B |
1244| -------- | -------- |
1245| `var with very long name` | `val exceeding limit` |
1246| value 1 | value 2 |
1247
1248This line exceeds limit"#;
1249        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1250        let result = rule.check(&ctx).unwrap();
1251
1252        // Should only flag the last line, not the table lines
1253        assert_eq!(result.len(), 1, "Should only flag the non-table line");
1254        assert_eq!(result[0].line, 6, "Should flag line 6");
1255    }
1256
1257    #[test]
1258    fn test_issue_78_indented_code_blocks() {
1259        // Test with indented code blocks instead of fenced
1260        let rule = MD013LineLength::new(20, false, false, false, false); // tables=false
1261        let content = r#"# heading
1262
1263    some code block longer than 20 chars length
1264
1265this is a very long line
1266
1267| column A | column B |
1268| -------- | -------- |
1269| value 1 | value 2 |
1270
1271correct length line"#;
1272        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1273        let result = rule.check(&ctx).unwrap();
1274
1275        // Should only flag line 5 ("this is a very long line"), not the table lines
1276        assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1277        assert_eq!(result[0].line, 5, "Should flag line 5");
1278    }
1279
1280    #[test]
1281    fn test_url_exemption() {
1282        let rule = MD013LineLength::new(30, false, false, false, false);
1283        let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1284        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1285        let result = rule.check(&ctx).unwrap();
1286
1287        assert_eq!(result.len(), 0);
1288    }
1289
1290    #[test]
1291    fn test_image_reference_exemption() {
1292        let rule = MD013LineLength::new(30, false, false, false, false);
1293        let content = "![This is a very long image alt text that exceeds limit][reference]";
1294        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1295        let result = rule.check(&ctx).unwrap();
1296
1297        assert_eq!(result.len(), 0);
1298    }
1299
1300    #[test]
1301    fn test_link_reference_exemption() {
1302        let rule = MD013LineLength::new(30, false, false, false, false);
1303        let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
1304        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1305        let result = rule.check(&ctx).unwrap();
1306
1307        assert_eq!(result.len(), 0);
1308    }
1309
1310    #[test]
1311    fn test_strict_mode() {
1312        let rule = MD013LineLength::new(30, false, false, false, true);
1313        let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1314        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1315        let result = rule.check(&ctx).unwrap();
1316
1317        // In strict mode, even URLs trigger warnings
1318        assert_eq!(result.len(), 1);
1319    }
1320
1321    #[test]
1322    fn test_blockquote_exemption() {
1323        let rule = MD013LineLength::new(30, false, false, false, false);
1324        let content = "> This is a very long line inside a blockquote that should be ignored.";
1325        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1326        let result = rule.check(&ctx).unwrap();
1327
1328        assert_eq!(result.len(), 0);
1329    }
1330
1331    #[test]
1332    fn test_setext_heading_underline_exemption() {
1333        let rule = MD013LineLength::new(30, false, false, false, false);
1334        let content = "Heading\n========================================";
1335        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1336        let result = rule.check(&ctx).unwrap();
1337
1338        // The underline should be exempt
1339        assert_eq!(result.len(), 0);
1340    }
1341
1342    #[test]
1343    fn test_no_fix_without_reflow() {
1344        let rule = MD013LineLength::new(60, false, false, false, false);
1345        let content = "This line has trailing whitespace that makes it too long      ";
1346        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1347        let result = rule.check(&ctx).unwrap();
1348
1349        assert_eq!(result.len(), 1);
1350        // Without reflow, no fix is provided
1351        assert!(result[0].fix.is_none());
1352
1353        // Fix method returns content unchanged
1354        let fixed = rule.fix(&ctx).unwrap();
1355        assert_eq!(fixed, content);
1356    }
1357
1358    #[test]
1359    fn test_character_vs_byte_counting() {
1360        let rule = MD013LineLength::new(10, false, false, false, false);
1361        // Unicode characters should count as 1 character each
1362        let content = "你好世界这是测试文字超过限制"; // 14 characters
1363        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1364        let result = rule.check(&ctx).unwrap();
1365
1366        assert_eq!(result.len(), 1);
1367        assert_eq!(result[0].line, 1);
1368    }
1369
1370    #[test]
1371    fn test_empty_content() {
1372        let rule = MD013LineLength::default();
1373        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1374        let result = rule.check(&ctx).unwrap();
1375
1376        assert_eq!(result.len(), 0);
1377    }
1378
1379    #[test]
1380    fn test_excess_range_calculation() {
1381        let rule = MD013LineLength::new(10, false, false, false, false);
1382        let content = "12345678901234567890"; // 20 chars, limit is 10
1383        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1384        let result = rule.check(&ctx).unwrap();
1385
1386        assert_eq!(result.len(), 1);
1387        // The warning should highlight from character 11 onwards
1388        assert_eq!(result[0].column, 11);
1389        assert_eq!(result[0].end_column, 21);
1390    }
1391
1392    #[test]
1393    fn test_html_block_exemption() {
1394        let rule = MD013LineLength::new(30, false, false, false, false);
1395        let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1396        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1397        let result = rule.check(&ctx).unwrap();
1398
1399        // HTML blocks should be exempt
1400        assert_eq!(result.len(), 0);
1401    }
1402
1403    #[test]
1404    fn test_mixed_content() {
1405        // code_blocks=false, tables=false, headings=false (all skipped/exempt)
1406        let rule = MD013LineLength::new(30, false, false, false, false);
1407        let content = r#"# This heading is very long but should be exempt
1408
1409This regular paragraph line is too long and should trigger.
1410
1411```
1412Code block line that is very long but exempt.
1413```
1414
1415| Table | With very long content |
1416|-------|------------------------|
1417
1418Another long line that should trigger a warning."#;
1419
1420        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1421        let result = rule.check(&ctx).unwrap();
1422
1423        // Should have warnings for the two regular paragraph lines only
1424        assert_eq!(result.len(), 2);
1425        assert_eq!(result[0].line, 3);
1426        assert_eq!(result[1].line, 12);
1427    }
1428
1429    #[test]
1430    fn test_fix_without_reflow_preserves_content() {
1431        let rule = MD013LineLength::new(50, false, false, false, false);
1432        let content = "Line 1\nThis line has trailing spaces and is too long      \nLine 3";
1433        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1434
1435        // Without reflow, content is unchanged
1436        let fixed = rule.fix(&ctx).unwrap();
1437        assert_eq!(fixed, content);
1438    }
1439
1440    #[test]
1441    fn test_content_detection() {
1442        let rule = MD013LineLength::default();
1443
1444        // Use a line longer than default line_length (80) to ensure it's not skipped
1445        let long_line = "a".repeat(100);
1446        let ctx = LintContext::new(&long_line, crate::config::MarkdownFlavor::Standard);
1447        assert!(!rule.should_skip(&ctx)); // Should not skip processing when there's long content
1448
1449        let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1450        assert!(rule.should_skip(&empty_ctx)); // Should skip processing when content is empty
1451    }
1452
1453    #[test]
1454    fn test_rule_metadata() {
1455        let rule = MD013LineLength::default();
1456        assert_eq!(rule.name(), "MD013");
1457        assert_eq!(rule.description(), "Line length should not be excessive");
1458        assert_eq!(rule.category(), RuleCategory::Whitespace);
1459    }
1460
1461    #[test]
1462    fn test_url_embedded_in_text() {
1463        let rule = MD013LineLength::new(50, false, false, false, false);
1464
1465        // This line would be 85 chars, but only ~45 without the URL
1466        let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1467        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1468        let result = rule.check(&ctx).unwrap();
1469
1470        // Should not flag because effective length (with URL placeholder) is under 50
1471        assert_eq!(result.len(), 0);
1472    }
1473
1474    #[test]
1475    fn test_multiple_urls_in_line() {
1476        let rule = MD013LineLength::new(50, false, false, false, false);
1477
1478        // Line with multiple URLs
1479        let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1480        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1481
1482        let result = rule.check(&ctx).unwrap();
1483
1484        // Should not flag because effective length is reasonable
1485        assert_eq!(result.len(), 0);
1486    }
1487
1488    #[test]
1489    fn test_markdown_link_with_long_url() {
1490        let rule = MD013LineLength::new(50, false, false, false, false);
1491
1492        // Markdown link with very long URL
1493        let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1494        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1495        let result = rule.check(&ctx).unwrap();
1496
1497        // Should not flag because effective length counts link as short
1498        assert_eq!(result.len(), 0);
1499    }
1500
1501    #[test]
1502    fn test_line_too_long_even_without_urls() {
1503        let rule = MD013LineLength::new(50, false, false, false, false);
1504
1505        // Line that's too long even after URL exclusion
1506        let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1507        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1508        let result = rule.check(&ctx).unwrap();
1509
1510        // Should flag because even with URL placeholder, line is too long
1511        assert_eq!(result.len(), 1);
1512    }
1513
1514    #[test]
1515    fn test_strict_mode_counts_urls() {
1516        let rule = MD013LineLength::new(50, false, false, false, true); // strict=true
1517
1518        // Same line that passes in non-strict mode
1519        let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1520        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1521        let result = rule.check(&ctx).unwrap();
1522
1523        // In strict mode, should flag because full URL is counted
1524        assert_eq!(result.len(), 1);
1525    }
1526
1527    #[test]
1528    fn test_documentation_example_from_md051() {
1529        let rule = MD013LineLength::new(80, false, false, false, false);
1530
1531        // This is the actual line from md051.md that was causing issues
1532        let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1533        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1534        let result = rule.check(&ctx).unwrap();
1535
1536        // Should not flag because the URL is in a markdown link
1537        assert_eq!(result.len(), 0);
1538    }
1539
1540    #[test]
1541    fn test_text_reflow_simple() {
1542        let config = MD013Config {
1543            line_length: 30,
1544            reflow: true,
1545            ..Default::default()
1546        };
1547        let rule = MD013LineLength::from_config_struct(config);
1548
1549        let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1551
1552        let fixed = rule.fix(&ctx).unwrap();
1553
1554        // Verify all lines are under 30 chars
1555        for line in fixed.lines() {
1556            assert!(
1557                line.chars().count() <= 30,
1558                "Line too long: {} (len={})",
1559                line,
1560                line.chars().count()
1561            );
1562        }
1563
1564        // Verify content is preserved
1565        let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1566        let original_words: Vec<&str> = content.split_whitespace().collect();
1567        assert_eq!(fixed_words, original_words);
1568    }
1569
1570    #[test]
1571    fn test_text_reflow_preserves_markdown_elements() {
1572        let config = MD013Config {
1573            line_length: 40,
1574            reflow: true,
1575            ..Default::default()
1576        };
1577        let rule = MD013LineLength::from_config_struct(config);
1578
1579        let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1580        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1581
1582        let fixed = rule.fix(&ctx).unwrap();
1583
1584        // Verify markdown elements are preserved
1585        assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1586        assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1587        assert!(
1588            fixed.contains("[a link](https://example.com)"),
1589            "Link not preserved in: {fixed}"
1590        );
1591
1592        // Verify all lines are under 40 chars
1593        for line in fixed.lines() {
1594            assert!(line.len() <= 40, "Line too long: {line}");
1595        }
1596    }
1597
1598    #[test]
1599    fn test_text_reflow_preserves_code_blocks() {
1600        let config = MD013Config {
1601            line_length: 30,
1602            reflow: true,
1603            ..Default::default()
1604        };
1605        let rule = MD013LineLength::from_config_struct(config);
1606
1607        let content = r#"Here is some text.
1608
1609```python
1610def very_long_function_name_that_exceeds_limit():
1611    return "This should not be wrapped"
1612```
1613
1614More text after code block."#;
1615        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1616
1617        let fixed = rule.fix(&ctx).unwrap();
1618
1619        // Verify code block is preserved
1620        assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1621        assert!(fixed.contains("```python"));
1622        assert!(fixed.contains("```"));
1623    }
1624
1625    #[test]
1626    fn test_text_reflow_preserves_lists() {
1627        let config = MD013Config {
1628            line_length: 30,
1629            reflow: true,
1630            ..Default::default()
1631        };
1632        let rule = MD013LineLength::from_config_struct(config);
1633
1634        let content = r#"Here is a list:
1635
16361. First item with a very long line that needs wrapping
16372. Second item is short
16383. Third item also has a long line that exceeds the limit
1639
1640And a bullet list:
1641
1642- Bullet item with very long content that needs wrapping
1643- Short bullet"#;
1644        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1645
1646        let fixed = rule.fix(&ctx).unwrap();
1647
1648        // Verify list structure is preserved
1649        assert!(fixed.contains("1. "));
1650        assert!(fixed.contains("2. "));
1651        assert!(fixed.contains("3. "));
1652        assert!(fixed.contains("- "));
1653
1654        // Verify proper indentation for wrapped lines
1655        let lines: Vec<&str> = fixed.lines().collect();
1656        for (i, line) in lines.iter().enumerate() {
1657            if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
1658                // Check if next line is a continuation (should be indented with 3 spaces for numbered lists)
1659                if i + 1 < lines.len()
1660                    && !lines[i + 1].trim().is_empty()
1661                    && !lines[i + 1].trim().starts_with(char::is_numeric)
1662                    && !lines[i + 1].trim().starts_with("-")
1663                {
1664                    // Numbered list continuation lines should have 3 spaces
1665                    assert!(lines[i + 1].starts_with("   ") || lines[i + 1].trim().is_empty());
1666                }
1667            } else if line.trim().starts_with("-") {
1668                // Check if next line is a continuation (should be indented with 2 spaces for dash lists)
1669                if i + 1 < lines.len()
1670                    && !lines[i + 1].trim().is_empty()
1671                    && !lines[i + 1].trim().starts_with(char::is_numeric)
1672                    && !lines[i + 1].trim().starts_with("-")
1673                {
1674                    // Dash list continuation lines should have 2 spaces
1675                    assert!(lines[i + 1].starts_with("  ") || lines[i + 1].trim().is_empty());
1676                }
1677            }
1678        }
1679    }
1680
1681    #[test]
1682    fn test_issue_83_numbered_list_with_backticks() {
1683        // Test for issue #83: enable_reflow was incorrectly handling numbered lists
1684        let config = MD013Config {
1685            line_length: 100,
1686            reflow: true,
1687            ..Default::default()
1688        };
1689        let rule = MD013LineLength::from_config_struct(config);
1690
1691        // The exact case from issue #83
1692        let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
1693        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1694
1695        let fixed = rule.fix(&ctx).unwrap();
1696
1697        // The expected output: properly wrapped at 100 chars with correct list formatting
1698        // After the fix, it correctly accounts for "1. " (3 chars) leaving 97 for content
1699        let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n   `00000000000000000002.manifest` in this example.";
1700
1701        assert_eq!(
1702            fixed, expected,
1703            "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
1704        );
1705    }
1706
1707    #[test]
1708    fn test_text_reflow_disabled_by_default() {
1709        let rule = MD013LineLength::new(30, false, false, false, false);
1710
1711        let content = "This is a very long line that definitely exceeds thirty characters.";
1712        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1713
1714        let fixed = rule.fix(&ctx).unwrap();
1715
1716        // Without reflow enabled, it should only trim whitespace (if any)
1717        // Since there's no trailing whitespace, content should be unchanged
1718        assert_eq!(fixed, content);
1719    }
1720
1721    #[test]
1722    fn test_reflow_with_hard_line_breaks() {
1723        // Test that lines with exactly 2 trailing spaces are preserved as hard breaks
1724        let config = MD013Config {
1725            line_length: 40,
1726            reflow: true,
1727            ..Default::default()
1728        };
1729        let rule = MD013LineLength::from_config_struct(config);
1730
1731        // Test with exactly 2 spaces (hard line break)
1732        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";
1733        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1734        let fixed = rule.fix(&ctx).unwrap();
1735
1736        // Should preserve the hard line break (2 spaces)
1737        assert!(
1738            fixed.contains("  \n"),
1739            "Hard line break with exactly 2 spaces should be preserved"
1740        );
1741    }
1742
1743    #[test]
1744    fn test_reflow_preserves_reference_links() {
1745        let config = MD013Config {
1746            line_length: 40,
1747            reflow: true,
1748            ..Default::default()
1749        };
1750        let rule = MD013LineLength::from_config_struct(config);
1751
1752        let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
1753
1754[ref]: https://example.com";
1755        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1756        let fixed = rule.fix(&ctx).unwrap();
1757
1758        // Reference link should remain intact
1759        assert!(fixed.contains("[reference link][ref]"));
1760        assert!(!fixed.contains("[ reference link]"));
1761        assert!(!fixed.contains("[ref ]"));
1762    }
1763
1764    #[test]
1765    fn test_reflow_with_nested_markdown_elements() {
1766        let config = MD013Config {
1767            line_length: 35,
1768            reflow: true,
1769            ..Default::default()
1770        };
1771        let rule = MD013LineLength::from_config_struct(config);
1772
1773        let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
1774        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1775        let fixed = rule.fix(&ctx).unwrap();
1776
1777        // Nested elements should be preserved
1778        assert!(fixed.contains("**bold with `code` inside**"));
1779    }
1780
1781    #[test]
1782    fn test_reflow_with_unbalanced_markdown() {
1783        // Test edge case with unbalanced markdown
1784        let config = MD013Config {
1785            line_length: 30,
1786            reflow: true,
1787            ..Default::default()
1788        };
1789        let rule = MD013LineLength::from_config_struct(config);
1790
1791        let content = "This has **unbalanced bold that goes on for a very long time without closing";
1792        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1793        let fixed = rule.fix(&ctx).unwrap();
1794
1795        // Should handle gracefully without panic
1796        // The text reflow handles unbalanced markdown by treating it as a bold element
1797        // Check that the content is properly reflowed without panic
1798        assert!(!fixed.is_empty());
1799        // Verify the content is wrapped to 30 chars
1800        for line in fixed.lines() {
1801            assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
1802        }
1803    }
1804
1805    #[test]
1806    fn test_reflow_fix_indicator() {
1807        // Test that reflow provides fix indicators
1808        let config = MD013Config {
1809            line_length: 30,
1810            reflow: true,
1811            ..Default::default()
1812        };
1813        let rule = MD013LineLength::from_config_struct(config);
1814
1815        let content = "This is a very long line that definitely exceeds the thirty character limit";
1816        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1817        let warnings = rule.check(&ctx).unwrap();
1818
1819        // Should have a fix indicator when reflow is true
1820        assert!(!warnings.is_empty());
1821        assert!(
1822            warnings[0].fix.is_some(),
1823            "Should provide fix indicator when reflow is true"
1824        );
1825    }
1826
1827    #[test]
1828    fn test_no_fix_indicator_without_reflow() {
1829        // Test that without reflow, no fix is provided
1830        let config = MD013Config {
1831            line_length: 30,
1832            reflow: false,
1833            ..Default::default()
1834        };
1835        let rule = MD013LineLength::from_config_struct(config);
1836
1837        let content = "This is a very long line that definitely exceeds the thirty character limit";
1838        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1839        let warnings = rule.check(&ctx).unwrap();
1840
1841        // Should NOT have a fix indicator when reflow is false
1842        assert!(!warnings.is_empty());
1843        assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
1844    }
1845
1846    #[test]
1847    fn test_reflow_preserves_all_reference_link_types() {
1848        let config = MD013Config {
1849            line_length: 40,
1850            reflow: true,
1851            ..Default::default()
1852        };
1853        let rule = MD013LineLength::from_config_struct(config);
1854
1855        let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
1856
1857[ref]: https://example.com
1858[collapsed]: https://example.com
1859[shortcut]: https://example.com";
1860
1861        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1862        let fixed = rule.fix(&ctx).unwrap();
1863
1864        // All reference link types should be preserved
1865        assert!(fixed.contains("[full reference][ref]"));
1866        assert!(fixed.contains("[collapsed][]"));
1867        assert!(fixed.contains("[shortcut]"));
1868    }
1869
1870    #[test]
1871    fn test_reflow_handles_images_correctly() {
1872        let config = MD013Config {
1873            line_length: 40,
1874            reflow: true,
1875            ..Default::default()
1876        };
1877        let rule = MD013LineLength::from_config_struct(config);
1878
1879        let content = "This line has an ![image alt text](https://example.com/image.png) that should not be broken when reflowing.";
1880        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1881        let fixed = rule.fix(&ctx).unwrap();
1882
1883        // Image should remain intact
1884        assert!(fixed.contains("![image alt text](https://example.com/image.png)"));
1885    }
1886
1887    #[test]
1888    fn test_normalize_mode_flags_short_lines() {
1889        let config = MD013Config {
1890            line_length: 100,
1891            reflow: true,
1892            reflow_mode: ReflowMode::Normalize,
1893            ..Default::default()
1894        };
1895        let rule = MD013LineLength::from_config_struct(config);
1896
1897        // Content with short lines that could be combined
1898        let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
1899        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1900        let warnings = rule.check(&ctx).unwrap();
1901
1902        // Should flag the paragraph as needing normalization
1903        assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1904        assert!(warnings[0].message.contains("normalized"));
1905    }
1906
1907    #[test]
1908    fn test_normalize_mode_combines_short_lines() {
1909        let config = MD013Config {
1910            line_length: 100,
1911            reflow: true,
1912            reflow_mode: ReflowMode::Normalize,
1913            ..Default::default()
1914        };
1915        let rule = MD013LineLength::from_config_struct(config);
1916
1917        // Content with short lines that should be combined
1918        let content =
1919            "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
1920        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1921        let fixed = rule.fix(&ctx).unwrap();
1922
1923        // Should combine into a single line since it's under 100 chars total
1924        let lines: Vec<&str> = fixed.lines().collect();
1925        assert_eq!(lines.len(), 1, "Should combine into single line");
1926        assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
1927    }
1928
1929    #[test]
1930    fn test_normalize_mode_preserves_paragraph_breaks() {
1931        let config = MD013Config {
1932            line_length: 100,
1933            reflow: true,
1934            reflow_mode: ReflowMode::Normalize,
1935            ..Default::default()
1936        };
1937        let rule = MD013LineLength::from_config_struct(config);
1938
1939        let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
1940        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1941        let fixed = rule.fix(&ctx).unwrap();
1942
1943        // Should preserve paragraph breaks (empty lines)
1944        assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
1945
1946        let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
1947        assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
1948    }
1949
1950    #[test]
1951    fn test_default_mode_only_fixes_violations() {
1952        let config = MD013Config {
1953            line_length: 100,
1954            reflow: true,
1955            reflow_mode: ReflowMode::Default, // Default mode
1956            ..Default::default()
1957        };
1958        let rule = MD013LineLength::from_config_struct(config);
1959
1960        // Content with short lines that are NOT violations
1961        let content = "This is a short line.\nAnother short line.\nA third short line.";
1962        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1963        let warnings = rule.check(&ctx).unwrap();
1964
1965        // Should NOT flag anything in default mode
1966        assert!(warnings.is_empty(), "Should not flag short lines in default mode");
1967
1968        // Fix should preserve the short lines
1969        let fixed = rule.fix(&ctx).unwrap();
1970        assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
1971    }
1972
1973    #[test]
1974    fn test_normalize_mode_with_lists() {
1975        let config = MD013Config {
1976            line_length: 80,
1977            reflow: true,
1978            reflow_mode: ReflowMode::Normalize,
1979            ..Default::default()
1980        };
1981        let rule = MD013LineLength::from_config_struct(config);
1982
1983        let content = r#"A paragraph with
1984short lines.
1985
19861. List item with
1987   short lines
19882. Another item"#;
1989        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1990        let fixed = rule.fix(&ctx).unwrap();
1991
1992        // Should normalize the paragraph but preserve list structure
1993        let lines: Vec<&str> = fixed.lines().collect();
1994        assert!(lines[0].len() > 20, "First paragraph should be normalized");
1995        assert!(fixed.contains("1. "), "Should preserve list markers");
1996        assert!(fixed.contains("2. "), "Should preserve list markers");
1997    }
1998
1999    #[test]
2000    fn test_normalize_mode_with_code_blocks() {
2001        let config = MD013Config {
2002            line_length: 100,
2003            reflow: true,
2004            reflow_mode: ReflowMode::Normalize,
2005            ..Default::default()
2006        };
2007        let rule = MD013LineLength::from_config_struct(config);
2008
2009        let content = r#"A paragraph with
2010short lines.
2011
2012```
2013code block should not be normalized
2014even with short lines
2015```
2016
2017Another paragraph with
2018short lines."#;
2019        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2020        let fixed = rule.fix(&ctx).unwrap();
2021
2022        // Code block should be preserved as-is
2023        assert!(fixed.contains("code block should not be normalized\neven with short lines"));
2024        // But paragraphs should be normalized
2025        let lines: Vec<&str> = fixed.lines().collect();
2026        assert!(lines[0].len() > 20, "First paragraph should be normalized");
2027    }
2028
2029    #[test]
2030    fn test_issue_76_use_case() {
2031        // This tests the exact use case from issue #76
2032        let config = MD013Config {
2033            line_length: 999999, // Set absurdly high
2034            reflow: true,
2035            reflow_mode: ReflowMode::Normalize,
2036            ..Default::default()
2037        };
2038        let rule = MD013LineLength::from_config_struct(config);
2039
2040        // Content with manual line breaks at 80 characters (typical markdown)
2041        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.";
2042
2043        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2044
2045        // Should flag for normalization even though no lines exceed limit
2046        let warnings = rule.check(&ctx).unwrap();
2047        assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2048
2049        // Should combine into a single line
2050        let fixed = rule.fix(&ctx).unwrap();
2051        let lines: Vec<&str> = fixed.lines().collect();
2052        assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
2053        assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
2054    }
2055
2056    #[test]
2057    fn test_normalize_mode_single_line_unchanged() {
2058        // Single lines should not be flagged or changed
2059        let config = MD013Config {
2060            line_length: 100,
2061            reflow: true,
2062            reflow_mode: ReflowMode::Normalize,
2063            ..Default::default()
2064        };
2065        let rule = MD013LineLength::from_config_struct(config);
2066
2067        let content = "This is a single line that should not be changed.";
2068        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2069
2070        let warnings = rule.check(&ctx).unwrap();
2071        assert!(warnings.is_empty(), "Single line should not be flagged");
2072
2073        let fixed = rule.fix(&ctx).unwrap();
2074        assert_eq!(fixed, content, "Single line should remain unchanged");
2075    }
2076
2077    #[test]
2078    fn test_normalize_mode_with_inline_code() {
2079        let config = MD013Config {
2080            line_length: 80,
2081            reflow: true,
2082            reflow_mode: ReflowMode::Normalize,
2083            ..Default::default()
2084        };
2085        let rule = MD013LineLength::from_config_struct(config);
2086
2087        let content =
2088            "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
2089        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2090
2091        let warnings = rule.check(&ctx).unwrap();
2092        assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
2093
2094        let fixed = rule.fix(&ctx).unwrap();
2095        assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
2096        assert!(fixed.lines().count() < 3, "Lines should be combined");
2097    }
2098
2099    #[test]
2100    fn test_normalize_mode_with_emphasis() {
2101        let config = MD013Config {
2102            line_length: 100,
2103            reflow: true,
2104            reflow_mode: ReflowMode::Normalize,
2105            ..Default::default()
2106        };
2107        let rule = MD013LineLength::from_config_struct(config);
2108
2109        let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
2110        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2111
2112        let fixed = rule.fix(&ctx).unwrap();
2113        assert!(fixed.contains("**bold**"), "Bold should be preserved");
2114        assert!(fixed.contains("*italic*"), "Italic should be preserved");
2115        assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2116    }
2117
2118    #[test]
2119    fn test_normalize_mode_respects_hard_breaks() {
2120        let config = MD013Config {
2121            line_length: 100,
2122            reflow: true,
2123            reflow_mode: ReflowMode::Normalize,
2124            ..Default::default()
2125        };
2126        let rule = MD013LineLength::from_config_struct(config);
2127
2128        // Two spaces at end of line = hard break
2129        let content = "First line with hard break  \nSecond line after break\nThird line";
2130        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2131
2132        let fixed = rule.fix(&ctx).unwrap();
2133        // Hard break should be preserved
2134        assert!(fixed.contains("  \n"), "Hard break should be preserved");
2135        // But lines without hard break should be combined
2136        assert!(
2137            fixed.contains("Second line after break Third line"),
2138            "Lines without hard break should combine"
2139        );
2140    }
2141
2142    #[test]
2143    fn test_normalize_mode_with_links() {
2144        let config = MD013Config {
2145            line_length: 100,
2146            reflow: true,
2147            reflow_mode: ReflowMode::Normalize,
2148            ..Default::default()
2149        };
2150        let rule = MD013LineLength::from_config_struct(config);
2151
2152        let content =
2153            "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
2154        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2155
2156        let fixed = rule.fix(&ctx).unwrap();
2157        assert!(
2158            fixed.contains("[link](https://example.com)"),
2159            "Link should be preserved"
2160        );
2161        assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2162    }
2163
2164    #[test]
2165    fn test_normalize_mode_empty_lines_between_paragraphs() {
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 = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
2175        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2176
2177        let fixed = rule.fix(&ctx).unwrap();
2178        // Multiple empty lines should be preserved
2179        assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
2180        // Each paragraph should be normalized
2181        let parts: Vec<&str> = fixed.split("\n\n\n").collect();
2182        assert_eq!(parts.len(), 2, "Should have two parts");
2183        assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
2184        assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
2185    }
2186
2187    #[test]
2188    fn test_normalize_mode_mixed_list_types() {
2189        let config = MD013Config {
2190            line_length: 80,
2191            reflow: true,
2192            reflow_mode: ReflowMode::Normalize,
2193            ..Default::default()
2194        };
2195        let rule = MD013LineLength::from_config_struct(config);
2196
2197        let content = r#"Paragraph before list
2198with multiple lines.
2199
2200- Bullet item
2201* Another bullet
2202+ Plus bullet
2203
22041. Numbered item
22052. Another number
2206
2207Paragraph after list
2208with multiple lines."#;
2209
2210        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2211        let fixed = rule.fix(&ctx).unwrap();
2212
2213        // Lists should be preserved
2214        assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
2215        assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
2216        assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
2217        assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
2218
2219        // But paragraphs should be normalized
2220        assert!(
2221            fixed.starts_with("Paragraph before list with multiple lines."),
2222            "First paragraph should be normalized"
2223        );
2224        assert!(
2225            fixed.ends_with("Paragraph after list with multiple lines."),
2226            "Last paragraph should be normalized"
2227        );
2228    }
2229
2230    #[test]
2231    fn test_normalize_mode_with_horizontal_rules() {
2232        let config = MD013Config {
2233            line_length: 100,
2234            reflow: true,
2235            reflow_mode: ReflowMode::Normalize,
2236            ..Default::default()
2237        };
2238        let rule = MD013LineLength::from_config_struct(config);
2239
2240        let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
2241        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2242
2243        let fixed = rule.fix(&ctx).unwrap();
2244        assert!(fixed.contains("---"), "Horizontal rule should be preserved");
2245        assert!(
2246            fixed.contains("Paragraph before horizontal rule."),
2247            "First paragraph normalized"
2248        );
2249        assert!(
2250            fixed.contains("Paragraph after horizontal rule."),
2251            "Second paragraph normalized"
2252        );
2253    }
2254
2255    #[test]
2256    fn test_normalize_mode_with_indented_code() {
2257        let config = MD013Config {
2258            line_length: 100,
2259            reflow: true,
2260            reflow_mode: ReflowMode::Normalize,
2261            ..Default::default()
2262        };
2263        let rule = MD013LineLength::from_config_struct(config);
2264
2265        let content = "Paragraph before\nindented code.\n\n    This is indented code\n    Should not be normalized\n\nParagraph after\nindented code.";
2266        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2267
2268        let fixed = rule.fix(&ctx).unwrap();
2269        assert!(
2270            fixed.contains("    This is indented code\n    Should not be normalized"),
2271            "Indented code preserved"
2272        );
2273        assert!(
2274            fixed.contains("Paragraph before indented code."),
2275            "First paragraph normalized"
2276        );
2277        assert!(
2278            fixed.contains("Paragraph after indented code."),
2279            "Second paragraph normalized"
2280        );
2281    }
2282
2283    #[test]
2284    fn test_normalize_mode_disabled_without_reflow() {
2285        // Normalize mode should have no effect if reflow is disabled
2286        let config = MD013Config {
2287            line_length: 100,
2288            reflow: false, // Disabled
2289            reflow_mode: ReflowMode::Normalize,
2290            ..Default::default()
2291        };
2292        let rule = MD013LineLength::from_config_struct(config);
2293
2294        let content = "This is a line\nwith breaks that\nshould not be changed.";
2295        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2296
2297        let warnings = rule.check(&ctx).unwrap();
2298        assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
2299
2300        let fixed = rule.fix(&ctx).unwrap();
2301        assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
2302    }
2303
2304    #[test]
2305    fn test_default_mode_with_long_lines() {
2306        // Default mode should fix paragraphs that contain lines exceeding limit
2307        // The paragraph-based approach treats consecutive lines as a unit
2308        let config = MD013Config {
2309            line_length: 50,
2310            reflow: true,
2311            reflow_mode: ReflowMode::Default,
2312            ..Default::default()
2313        };
2314        let rule = MD013LineLength::from_config_struct(config);
2315
2316        let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2317        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2318
2319        let warnings = rule.check(&ctx).unwrap();
2320        assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2321        // The warning reports the line that violates in default mode
2322        assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2323
2324        let fixed = rule.fix(&ctx).unwrap();
2325        // The paragraph gets reflowed as a unit
2326        assert!(
2327            fixed.contains("Short line. This is"),
2328            "Should combine and reflow the paragraph"
2329        );
2330        assert!(
2331            fixed.contains("wrapping. Another short"),
2332            "Should include all paragraph content"
2333        );
2334    }
2335
2336    #[test]
2337    fn test_normalize_vs_default_mode_same_content() {
2338        let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2339        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2340
2341        // Test default mode
2342        let default_config = MD013Config {
2343            line_length: 100,
2344            reflow: true,
2345            reflow_mode: ReflowMode::Default,
2346            ..Default::default()
2347        };
2348        let default_rule = MD013LineLength::from_config_struct(default_config);
2349        let default_warnings = default_rule.check(&ctx).unwrap();
2350        let default_fixed = default_rule.fix(&ctx).unwrap();
2351
2352        // Test normalize mode
2353        let normalize_config = MD013Config {
2354            line_length: 100,
2355            reflow: true,
2356            reflow_mode: ReflowMode::Normalize,
2357            ..Default::default()
2358        };
2359        let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2360        let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2361        let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2362
2363        // Verify different behavior
2364        assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2365        assert!(
2366            !normalize_warnings.is_empty(),
2367            "Normalize mode should flag multi-line paragraphs"
2368        );
2369
2370        assert_eq!(
2371            default_fixed, content,
2372            "Default mode should not change content without violations"
2373        );
2374        assert_ne!(
2375            normalize_fixed, content,
2376            "Normalize mode should change multi-line paragraphs"
2377        );
2378        assert_eq!(
2379            normalize_fixed.lines().count(),
2380            1,
2381            "Normalize should combine into single line"
2382        );
2383    }
2384
2385    #[test]
2386    fn test_normalize_mode_with_reference_definitions() {
2387        let config = MD013Config {
2388            line_length: 100,
2389            reflow: true,
2390            reflow_mode: ReflowMode::Normalize,
2391            ..Default::default()
2392        };
2393        let rule = MD013LineLength::from_config_struct(config);
2394
2395        let content =
2396            "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2398
2399        let fixed = rule.fix(&ctx).unwrap();
2400        assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2401        assert!(
2402            fixed.contains("[ref]: https://example.com"),
2403            "Reference definition should be preserved"
2404        );
2405        assert!(
2406            fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2407            "Paragraph should be normalized"
2408        );
2409    }
2410
2411    #[test]
2412    fn test_normalize_mode_with_html_comments() {
2413        let config = MD013Config {
2414            line_length: 100,
2415            reflow: true,
2416            reflow_mode: ReflowMode::Normalize,
2417            ..Default::default()
2418        };
2419        let rule = MD013LineLength::from_config_struct(config);
2420
2421        let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2422        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2423
2424        let fixed = rule.fix(&ctx).unwrap();
2425        assert!(
2426            fixed.contains("<!-- This is a comment -->"),
2427            "HTML comment should be preserved"
2428        );
2429        assert!(
2430            fixed.contains("Paragraph before HTML comment."),
2431            "First paragraph normalized"
2432        );
2433        assert!(
2434            fixed.contains("Paragraph after HTML comment."),
2435            "Second paragraph normalized"
2436        );
2437    }
2438
2439    #[test]
2440    fn test_normalize_mode_line_starting_with_number() {
2441        // Regression test for the bug we fixed where "80 characters" was treated as a list
2442        let config = MD013Config {
2443            line_length: 100,
2444            reflow: true,
2445            reflow_mode: ReflowMode::Normalize,
2446            ..Default::default()
2447        };
2448        let rule = MD013LineLength::from_config_struct(config);
2449
2450        let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2451        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2452
2453        let fixed = rule.fix(&ctx).unwrap();
2454        assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2455        assert!(
2456            fixed.contains("80 characters"),
2457            "Number at start of line should be preserved"
2458        );
2459    }
2460
2461    #[test]
2462    fn test_default_mode_preserves_list_structure() {
2463        // In default mode, list continuation lines should be preserved
2464        let config = MD013Config {
2465            line_length: 80,
2466            reflow: true,
2467            reflow_mode: ReflowMode::Default,
2468            ..Default::default()
2469        };
2470        let rule = MD013LineLength::from_config_struct(config);
2471
2472        let content = r#"- This is a bullet point that has
2473  some text on multiple lines
2474  that should stay separate
2475
24761. Numbered list item with
2477   multiple lines that should
2478   also stay separate"#;
2479
2480        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2481        let fixed = rule.fix(&ctx).unwrap();
2482
2483        // In default mode, the structure should be preserved
2484        let lines: Vec<&str> = fixed.lines().collect();
2485        assert_eq!(
2486            lines[0], "- This is a bullet point that has",
2487            "First line should be unchanged"
2488        );
2489        assert_eq!(
2490            lines[1], "  some text on multiple lines",
2491            "Continuation should be preserved"
2492        );
2493        assert_eq!(
2494            lines[2], "  that should stay separate",
2495            "Second continuation should be preserved"
2496        );
2497    }
2498
2499    #[test]
2500    fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2501        // Test that multi-line list items don't get extra spaces when normalized
2502        let config = MD013Config {
2503            line_length: 80,
2504            reflow: true,
2505            reflow_mode: ReflowMode::Normalize,
2506            ..Default::default()
2507        };
2508        let rule = MD013LineLength::from_config_struct(config);
2509
2510        let content = r#"- This is a bullet point that has
2511  some text on multiple lines
2512  that should be combined
2513
25141. Numbered list item with
2515   multiple lines that need
2516   to be properly combined
25172. Second item"#;
2518
2519        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2520        let fixed = rule.fix(&ctx).unwrap();
2521
2522        // Check that there are no extra spaces in the combined list items
2523        assert!(
2524            !fixed.contains("lines  that"),
2525            "Should not have double spaces in bullet list"
2526        );
2527        assert!(
2528            !fixed.contains("need  to"),
2529            "Should not have double spaces in numbered list"
2530        );
2531
2532        // Check that the list items are properly combined
2533        assert!(
2534            fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2535            "Bullet list should be properly combined"
2536        );
2537        assert!(
2538            fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2539            "Numbered list should be properly combined"
2540        );
2541    }
2542
2543    #[test]
2544    fn test_normalize_mode_actual_numbered_list() {
2545        // Ensure actual numbered lists are still detected correctly
2546        let config = MD013Config {
2547            line_length: 100,
2548            reflow: true,
2549            reflow_mode: ReflowMode::Normalize,
2550            ..Default::default()
2551        };
2552        let rule = MD013LineLength::from_config_struct(config);
2553
2554        let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2555        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2556
2557        let fixed = rule.fix(&ctx).unwrap();
2558        assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2559        assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2560        assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2561        assert!(
2562            fixed.starts_with("Paragraph before list with multiple lines."),
2563            "Paragraph should be normalized"
2564        );
2565    }
2566
2567    #[test]
2568    fn test_sentence_per_line_detection() {
2569        let config = MD013Config {
2570            reflow: true,
2571            reflow_mode: ReflowMode::SentencePerLine,
2572            ..Default::default()
2573        };
2574        let rule = MD013LineLength::from_config_struct(config.clone());
2575
2576        // Test detection of multiple sentences
2577        let content = "This is sentence one. This is sentence two. And sentence three!";
2578        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2579
2580        // Debug: check if should_skip returns false
2581        assert!(!rule.should_skip(&ctx), "Should not skip for sentence-per-line mode");
2582
2583        let result = rule.check(&ctx).unwrap();
2584
2585        assert!(!result.is_empty(), "Should detect multiple sentences on one line");
2586        assert_eq!(
2587            result[0].message,
2588            "Line contains multiple sentences (one sentence per line expected)"
2589        );
2590    }
2591
2592    #[test]
2593    fn test_sentence_per_line_fix() {
2594        let config = MD013Config {
2595            reflow: true,
2596            reflow_mode: ReflowMode::SentencePerLine,
2597            ..Default::default()
2598        };
2599        let rule = MD013LineLength::from_config_struct(config);
2600
2601        let content = "First sentence. Second sentence.";
2602        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2603        let result = rule.check(&ctx).unwrap();
2604
2605        assert!(!result.is_empty(), "Should detect violation");
2606        assert!(result[0].fix.is_some(), "Should provide a fix");
2607
2608        let fix = result[0].fix.as_ref().unwrap();
2609        assert_eq!(fix.replacement.trim(), "First sentence.\nSecond sentence.");
2610    }
2611
2612    #[test]
2613    fn test_sentence_per_line_abbreviations() {
2614        let config = MD013Config {
2615            reflow: true,
2616            reflow_mode: ReflowMode::SentencePerLine,
2617            ..Default::default()
2618        };
2619        let rule = MD013LineLength::from_config_struct(config);
2620
2621        // Should NOT trigger on abbreviations
2622        let content = "Mr. Smith met Dr. Jones at 3:00 PM.";
2623        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2624        let result = rule.check(&ctx).unwrap();
2625
2626        assert!(
2627            result.is_empty(),
2628            "Should not detect abbreviations as sentence boundaries"
2629        );
2630    }
2631
2632    #[test]
2633    fn test_sentence_per_line_with_markdown() {
2634        let config = MD013Config {
2635            reflow: true,
2636            reflow_mode: ReflowMode::SentencePerLine,
2637            ..Default::default()
2638        };
2639        let rule = MD013LineLength::from_config_struct(config);
2640
2641        let content = "# Heading\n\nSentence with **bold**. Another with [link](url).";
2642        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2643        let result = rule.check(&ctx).unwrap();
2644
2645        assert!(!result.is_empty(), "Should detect multiple sentences with markdown");
2646        assert_eq!(result[0].line, 3); // Third line has the violation
2647    }
2648
2649    #[test]
2650    fn test_sentence_per_line_questions_exclamations() {
2651        let config = MD013Config {
2652            reflow: true,
2653            reflow_mode: ReflowMode::SentencePerLine,
2654            ..Default::default()
2655        };
2656        let rule = MD013LineLength::from_config_struct(config);
2657
2658        let content = "Is this a question? Yes it is! And a statement.";
2659        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2660        let result = rule.check(&ctx).unwrap();
2661
2662        assert!(!result.is_empty(), "Should detect sentences with ? and !");
2663
2664        let fix = result[0].fix.as_ref().unwrap();
2665        let lines: Vec<&str> = fix.replacement.trim().lines().collect();
2666        assert_eq!(lines.len(), 3);
2667        assert_eq!(lines[0], "Is this a question?");
2668        assert_eq!(lines[1], "Yes it is!");
2669        assert_eq!(lines[2], "And a statement.");
2670    }
2671
2672    #[test]
2673    fn test_sentence_per_line_in_lists() {
2674        let config = MD013Config {
2675            reflow: true,
2676            reflow_mode: ReflowMode::SentencePerLine,
2677            ..Default::default()
2678        };
2679        let rule = MD013LineLength::from_config_struct(config);
2680
2681        let content = "- List item one. With two sentences.\n- Another item.";
2682        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2683        let result = rule.check(&ctx).unwrap();
2684
2685        assert!(!result.is_empty(), "Should detect sentences in list items");
2686        // The fix should preserve list formatting
2687        let fix = result[0].fix.as_ref().unwrap();
2688        assert!(fix.replacement.starts_with("- "), "Should preserve list marker");
2689    }
2690
2691    #[test]
2692    fn test_multi_paragraph_list_item_with_3_space_indent() {
2693        let config = MD013Config {
2694            reflow: true,
2695            reflow_mode: ReflowMode::Normalize,
2696            line_length: 999999,
2697            ..Default::default()
2698        };
2699        let rule = MD013LineLength::from_config_struct(config);
2700
2701        let content = "1. First paragraph\n   continuation line.\n\n   Second paragraph\n   more content.";
2702        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2703        let result = rule.check(&ctx).unwrap();
2704
2705        assert!(!result.is_empty(), "Should detect multi-line paragraphs in list item");
2706        let fix = result[0].fix.as_ref().unwrap();
2707
2708        // Should preserve paragraph structure, not collapse everything
2709        assert!(
2710            fix.replacement.contains("\n\n"),
2711            "Should preserve blank line between paragraphs"
2712        );
2713        assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
2714    }
2715
2716    #[test]
2717    fn test_multi_paragraph_list_item_with_4_space_indent() {
2718        let config = MD013Config {
2719            reflow: true,
2720            reflow_mode: ReflowMode::Normalize,
2721            line_length: 999999,
2722            ..Default::default()
2723        };
2724        let rule = MD013LineLength::from_config_struct(config);
2725
2726        // User's example from issue #76 - uses 4 spaces for continuation
2727        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.";
2728        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2729        let result = rule.check(&ctx).unwrap();
2730
2731        assert!(
2732            !result.is_empty(),
2733            "Should detect multi-line paragraphs in list item with 4-space indent"
2734        );
2735        let fix = result[0].fix.as_ref().unwrap();
2736
2737        // Should preserve paragraph structure
2738        assert!(
2739            fix.replacement.contains("\n\n"),
2740            "Should preserve blank line between paragraphs"
2741        );
2742        assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
2743
2744        // Both paragraphs should be reflowed but kept separate
2745        let lines: Vec<&str> = fix.replacement.split('\n').collect();
2746        let blank_line_idx = lines.iter().position(|l| l.trim().is_empty());
2747        assert!(blank_line_idx.is_some(), "Should have blank line separating paragraphs");
2748    }
2749
2750    #[test]
2751    fn test_multi_paragraph_bullet_list_item() {
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        let content = "- First paragraph\n  continuation.\n\n  Second paragraph\n  more text.";
2761        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2762        let result = rule.check(&ctx).unwrap();
2763
2764        assert!(!result.is_empty(), "Should detect multi-line paragraphs in bullet list");
2765        let fix = result[0].fix.as_ref().unwrap();
2766
2767        assert!(
2768            fix.replacement.contains("\n\n"),
2769            "Should preserve blank line between paragraphs"
2770        );
2771        assert!(fix.replacement.starts_with("- "), "Should preserve bullet marker");
2772    }
2773
2774    #[test]
2775    fn test_code_block_in_list_item_five_spaces() {
2776        let config = MD013Config {
2777            reflow: true,
2778            reflow_mode: ReflowMode::Normalize,
2779            line_length: 80,
2780            ..Default::default()
2781        };
2782        let rule = MD013LineLength::from_config_struct(config);
2783
2784        // 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)
2785        // For "1. " marker (3 chars), 3+4=7 spaces would be code block
2786        let content = "1. First paragraph with some text that should be reflowed.\n\n       code_block()\n       more_code()\n\n   Second paragraph.";
2787        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2788        let result = rule.check(&ctx).unwrap();
2789
2790        if !result.is_empty() {
2791            let fix = result[0].fix.as_ref().unwrap();
2792            // Code block lines should NOT be reflowed - they should be preserved with original indentation
2793            assert!(
2794                fix.replacement.contains("       code_block()"),
2795                "Code block should be preserved: {}",
2796                fix.replacement
2797            );
2798            assert!(
2799                fix.replacement.contains("       more_code()"),
2800                "Code block should be preserved: {}",
2801                fix.replacement
2802            );
2803        }
2804    }
2805
2806    #[test]
2807    fn test_fenced_code_block_in_list_item() {
2808        let config = MD013Config {
2809            reflow: true,
2810            reflow_mode: ReflowMode::Normalize,
2811            line_length: 80,
2812            ..Default::default()
2813        };
2814        let rule = MD013LineLength::from_config_struct(config);
2815
2816        let content = "1. First paragraph with some text.\n\n   ```rust\n   fn foo() {}\n   let x = 1;\n   ```\n\n   Second paragraph.";
2817        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2818        let result = rule.check(&ctx).unwrap();
2819
2820        if !result.is_empty() {
2821            let fix = result[0].fix.as_ref().unwrap();
2822            // Fenced code block should be preserved
2823            assert!(
2824                fix.replacement.contains("```rust"),
2825                "Should preserve fence: {}",
2826                fix.replacement
2827            );
2828            assert!(
2829                fix.replacement.contains("fn foo() {}"),
2830                "Should preserve code: {}",
2831                fix.replacement
2832            );
2833            assert!(
2834                fix.replacement.contains("```"),
2835                "Should preserve closing fence: {}",
2836                fix.replacement
2837            );
2838        }
2839    }
2840
2841    #[test]
2842    fn test_mixed_indentation_3_and_4_spaces() {
2843        let config = MD013Config {
2844            reflow: true,
2845            reflow_mode: ReflowMode::Normalize,
2846            line_length: 999999,
2847            ..Default::default()
2848        };
2849        let rule = MD013LineLength::from_config_struct(config);
2850
2851        // First continuation has 3 spaces, second has 4 - both should be accepted
2852        let content = "1. Text\n   3 space continuation\n    4 space continuation";
2853        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2854        let result = rule.check(&ctx).unwrap();
2855
2856        assert!(!result.is_empty(), "Should detect multi-line list item");
2857        let fix = result[0].fix.as_ref().unwrap();
2858        // Should reflow all content together
2859        assert!(
2860            fix.replacement.contains("3 space continuation"),
2861            "Should include 3-space line: {}",
2862            fix.replacement
2863        );
2864        assert!(
2865            fix.replacement.contains("4 space continuation"),
2866            "Should include 4-space line: {}",
2867            fix.replacement
2868        );
2869    }
2870
2871    #[test]
2872    fn test_nested_list_in_multi_paragraph_item() {
2873        let config = MD013Config {
2874            reflow: true,
2875            reflow_mode: ReflowMode::Normalize,
2876            line_length: 999999,
2877            ..Default::default()
2878        };
2879        let rule = MD013LineLength::from_config_struct(config);
2880
2881        let content = "1. First paragraph.\n\n   - Nested item\n     continuation\n\n   Second paragraph.";
2882        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2883        let result = rule.check(&ctx).unwrap();
2884
2885        // Nested lists at continuation indent should be INCLUDED in parent item
2886        assert!(!result.is_empty(), "Should detect and reflow parent item");
2887        if let Some(fix) = result[0].fix.as_ref() {
2888            // The nested list should be preserved in the output
2889            assert!(
2890                fix.replacement.contains("- Nested"),
2891                "Should preserve nested list: {}",
2892                fix.replacement
2893            );
2894            assert!(
2895                fix.replacement.contains("Second paragraph"),
2896                "Should include content after nested list: {}",
2897                fix.replacement
2898            );
2899        }
2900    }
2901
2902    #[test]
2903    fn test_nested_fence_markers_different_types() {
2904        let config = MD013Config {
2905            reflow: true,
2906            reflow_mode: ReflowMode::Normalize,
2907            line_length: 80,
2908            ..Default::default()
2909        };
2910        let rule = MD013LineLength::from_config_struct(config);
2911
2912        // Nested fences with different markers (backticks inside tildes)
2913        let content = "1. Example with nested fences:\n\n   ~~~markdown\n   This shows ```python\n   code = True\n   ```\n   ~~~\n\n   Text after.";
2914        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2915        let result = rule.check(&ctx).unwrap();
2916
2917        if !result.is_empty() {
2918            let fix = result[0].fix.as_ref().unwrap();
2919            // Inner fence should NOT close outer fence (different markers)
2920            assert!(
2921                fix.replacement.contains("```python"),
2922                "Should preserve inner fence: {}",
2923                fix.replacement
2924            );
2925            assert!(
2926                fix.replacement.contains("~~~"),
2927                "Should preserve outer fence: {}",
2928                fix.replacement
2929            );
2930            // All lines should remain as code
2931            assert!(
2932                fix.replacement.contains("code = True"),
2933                "Should preserve code: {}",
2934                fix.replacement
2935            );
2936        }
2937    }
2938
2939    #[test]
2940    fn test_nested_fence_markers_same_type() {
2941        let config = MD013Config {
2942            reflow: true,
2943            reflow_mode: ReflowMode::Normalize,
2944            line_length: 80,
2945            ..Default::default()
2946        };
2947        let rule = MD013LineLength::from_config_struct(config);
2948
2949        // Nested backticks - inner must have different length or won't work
2950        let content =
2951            "1. Example:\n\n   ````markdown\n   Shows ```python in code\n   ```\n   text here\n   ````\n\n   After.";
2952        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2953        let result = rule.check(&ctx).unwrap();
2954
2955        if !result.is_empty() {
2956            let fix = result[0].fix.as_ref().unwrap();
2957            // 4 backticks opened, 3 backticks shouldn't close it
2958            assert!(
2959                fix.replacement.contains("```python"),
2960                "Should preserve inner fence: {}",
2961                fix.replacement
2962            );
2963            assert!(
2964                fix.replacement.contains("````"),
2965                "Should preserve outer fence: {}",
2966                fix.replacement
2967            );
2968            assert!(
2969                fix.replacement.contains("text here"),
2970                "Should keep text as code: {}",
2971                fix.replacement
2972            );
2973        }
2974    }
2975
2976    #[test]
2977    fn test_sibling_list_item_breaks_parent() {
2978        let config = MD013Config {
2979            reflow: true,
2980            reflow_mode: ReflowMode::Normalize,
2981            line_length: 999999,
2982            ..Default::default()
2983        };
2984        let rule = MD013LineLength::from_config_struct(config);
2985
2986        // Sibling list item (at indent 0, before parent marker at 3)
2987        let content = "1. First item\n   continuation.\n2. Second item";
2988        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2989        let result = rule.check(&ctx).unwrap();
2990
2991        // Should process first item only, second item breaks it
2992        if !result.is_empty() {
2993            let fix = result[0].fix.as_ref().unwrap();
2994            // Should only include first item
2995            assert!(fix.replacement.starts_with("1. "), "Should start with first marker");
2996            assert!(fix.replacement.contains("continuation"), "Should include continuation");
2997            // Should NOT include second item (it's outside the byte range)
2998        }
2999    }
3000
3001    #[test]
3002    fn test_nested_list_at_continuation_indent_preserved() {
3003        let config = MD013Config {
3004            reflow: true,
3005            reflow_mode: ReflowMode::Normalize,
3006            line_length: 999999,
3007            ..Default::default()
3008        };
3009        let rule = MD013LineLength::from_config_struct(config);
3010
3011        // Nested list at exactly continuation indent (3 spaces for "1. ")
3012        let content = "1. Parent paragraph\n   with continuation.\n\n   - Nested at 3 spaces\n   - Another nested\n\n   After nested.";
3013        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3014        let result = rule.check(&ctx).unwrap();
3015
3016        if !result.is_empty() {
3017            let fix = result[0].fix.as_ref().unwrap();
3018            // All nested content should be preserved
3019            assert!(
3020                fix.replacement.contains("- Nested"),
3021                "Should include first nested item: {}",
3022                fix.replacement
3023            );
3024            assert!(
3025                fix.replacement.contains("- Another"),
3026                "Should include second nested item: {}",
3027                fix.replacement
3028            );
3029            assert!(
3030                fix.replacement.contains("After nested"),
3031                "Should include content after nested list: {}",
3032                fix.replacement
3033            );
3034        }
3035    }
3036}