rumdl_lib/rules/
md013_line_length.rs

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