rumdl_lib/rules/
md013_line_length.rs

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