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