Skip to main content

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::mkdocs_admonitions;
7use crate::utils::mkdocs_attr_list::is_standalone_attr_list;
8use crate::utils::mkdocs_snippets::is_snippet_block_delimiter;
9use crate::utils::mkdocs_tabs;
10use crate::utils::range_utils::LineIndex;
11use crate::utils::range_utils::calculate_excess_range;
12use crate::utils::regex_cache::{IMAGE_REF_PATTERN, LINK_REF_PATTERN, URL_PATTERN};
13use crate::utils::table_utils::TableUtils;
14use crate::utils::text_reflow::{
15    BlockquoteLineData, ReflowLengthMode, blockquote_continuation_style, dominant_blockquote_prefix,
16    reflow_blockquote_content, split_into_sentences,
17};
18use pulldown_cmark::LinkType;
19use toml;
20
21mod block_builder;
22mod helpers;
23pub mod md013_config;
24use crate::rules::md030_list_marker_space::MD030Config;
25use crate::utils::is_template_directive_only;
26use block_builder::{Block, BlockBuilder};
27use helpers::{
28    extract_list_marker_and_content, has_hard_break, is_github_alert_marker, is_horizontal_rule, is_html_only_line,
29    is_list_item, is_standalone_link_or_image_line, is_unwrappable_line, split_into_segments,
30    trim_preserving_hard_break,
31};
32pub use md013_config::MD013Config;
33use md013_config::{LengthMode, ReflowMode};
34
35#[cfg(test)]
36mod tests;
37use unicode_width::UnicodeWidthStr;
38
39#[derive(Clone, Default)]
40pub struct MD013LineLength {
41    pub(crate) config: MD013Config,
42    /// MD030 list-marker spacing, applied when reflowing list items so the rewrite
43    /// uses the configured post-marker spacing rather than a hard-coded single
44    /// space. Defaults to MD030's defaults (a single space everywhere), which
45    /// reproduces the previous behaviour exactly. See [`MD030Config::expected_spaces`].
46    pub(crate) list_spacing: MD030Config,
47}
48
49/// Blockquote paragraph line collected for reflow, with original line index for range computation.
50struct CollectedBlockquoteLine {
51    line_idx: usize,
52    data: BlockquoteLineData,
53}
54
55impl MD013LineLength {
56    pub fn new(line_length: usize, code_blocks: bool, tables: bool, headings: bool, strict: bool) -> Self {
57        Self {
58            config: MD013Config {
59                line_length: crate::types::LineLength::new(line_length),
60                code_blocks,
61                code_spans: true,
62                tables,
63                headings,
64                paragraphs: true,  // Default to true for backwards compatibility
65                blockquotes: true, // Default to true for backwards compatibility
66                strict,
67                stern: false,
68                heading_line_length: None,
69                code_block_line_length: None,
70                reflow: false,
71                reflow_mode: ReflowMode::default(),
72                length_mode: LengthMode::default(),
73                abbreviations: Vec::new(),
74                require_sentence_capital: true,
75                ignore_link_urls: true,
76            },
77            list_spacing: MD030Config::default(),
78        }
79    }
80
81    pub fn from_config_struct(config: MD013Config) -> Self {
82        Self {
83            config,
84            list_spacing: MD030Config::default(),
85        }
86    }
87
88    /// Return a clone with code block checking disabled.
89    /// Used for doc comment linting where code blocks are Rust code managed by rustfmt.
90    pub fn with_code_blocks_disabled(&self) -> Self {
91        let mut clone = self.clone();
92        clone.config.code_blocks = false;
93        clone
94    }
95
96    /// Convert MD013 LengthMode to text_reflow ReflowLengthMode
97    fn reflow_length_mode(&self) -> ReflowLengthMode {
98        match self.config.length_mode {
99            LengthMode::Chars => ReflowLengthMode::Chars,
100            LengthMode::Visual => ReflowLengthMode::Visual,
101            LengthMode::Bytes => ReflowLengthMode::Bytes,
102        }
103    }
104
105    fn should_ignore_line(
106        &self,
107        line: &str,
108        _lines: &[&str],
109        current_line: usize,
110        ctx: &crate::lint_context::LintContext,
111    ) -> bool {
112        if self.config.strict {
113            return false;
114        }
115
116        // Quick check for common patterns before expensive regex
117        let trimmed = line.trim();
118
119        // Only skip if the entire line is a URL (quick check first)
120        if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
121            return true;
122        }
123
124        // Only skip if the entire line is an image reference (quick check first)
125        if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
126            return true;
127        }
128
129        // Note: link reference definitions are handled as always-exempt (even in strict mode)
130        // in the main check loop, so they don't need to be checked here.
131
132        // Code blocks with long strings (only check if in code block)
133        if ctx.line_info(current_line + 1).is_some_and(|info| info.in_code_block)
134            && !trimmed.is_empty()
135            && !line.contains(' ')
136            && !line.contains('\t')
137        {
138            return true;
139        }
140
141        false
142    }
143
144    /// Check if rule should skip based on provided config (used for inline config support)
145    fn should_skip_with_config(&self, ctx: &crate::lint_context::LintContext, config: &MD013Config) -> bool {
146        // Skip if content is empty
147        if ctx.content.is_empty() {
148            return true;
149        }
150
151        // For sentence-per-line, semantic-line-breaks, or normalize mode, never skip based on line length
152        if config.reflow
153            && (config.reflow_mode == ReflowMode::SentencePerLine
154                || config.reflow_mode == ReflowMode::SemanticLineBreaks
155                || config.reflow_mode == ReflowMode::Normalize)
156        {
157            return false;
158        }
159
160        // Use the smallest applicable budget across line/heading/code-block
161        // contexts so a stricter context-specific limit doesn't get masked by
162        // the document-wide budget.
163        let min_limit = config.min_effective_line_length();
164        if min_limit.is_unlimited() {
165            return true;
166        }
167        let min_limit_bytes = min_limit.get();
168
169        // Quick check: if total content is shorter than the smallest line limit,
170        // definitely skip.
171        if ctx.content.len() <= min_limit_bytes {
172            return true;
173        }
174
175        // Skip if no line exceeds the smallest applicable limit.
176        !ctx.lines.iter().any(|line| line.byte_len > min_limit_bytes)
177    }
178
179    fn normalize_mode_needs_reflow<'a, I>(&self, lines: I, config: &MD013Config) -> bool
180    where
181        I: IntoIterator<Item = &'a str>,
182    {
183        let mut line_count = 0;
184        let check_length = !config.line_length.is_unlimited();
185
186        for line in lines {
187            line_count += 1;
188            if check_length && self.calculate_effective_length(line) > config.line_length.get() {
189                return true;
190            }
191        }
192
193        line_count > 1
194    }
195}
196
197impl Rule for MD013LineLength {
198    fn name(&self) -> &'static str {
199        "MD013"
200    }
201
202    fn description(&self) -> &'static str {
203        "Line length should not be excessive"
204    }
205
206    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
207        // Use pre-parsed inline config from LintContext
208        let config_override = ctx.inline_config().get_rule_config("MD013");
209
210        // Apply configuration override if present
211        let effective_config = if let Some(json_config) = config_override {
212            if let Some(obj) = json_config.as_object() {
213                let mut config = self.config.clone();
214                if let Some(line_length) = obj.get("line_length").and_then(serde_json::Value::as_u64) {
215                    config.line_length = crate::types::LineLength::new(line_length as usize);
216                }
217                if let Some(code_blocks) = obj.get("code_blocks").and_then(serde_json::Value::as_bool) {
218                    config.code_blocks = code_blocks;
219                }
220                if let Some(code_spans) = obj.get("code_spans").and_then(serde_json::Value::as_bool) {
221                    config.code_spans = code_spans;
222                }
223                if let Some(tables) = obj.get("tables").and_then(serde_json::Value::as_bool) {
224                    config.tables = tables;
225                }
226                if let Some(headings) = obj.get("headings").and_then(serde_json::Value::as_bool) {
227                    config.headings = headings;
228                }
229                if let Some(blockquotes) = obj.get("blockquotes").and_then(serde_json::Value::as_bool) {
230                    config.blockquotes = blockquotes;
231                }
232                if let Some(strict) = obj.get("strict").and_then(serde_json::Value::as_bool) {
233                    config.strict = strict;
234                }
235                if let Some(stern) = obj.get("stern").and_then(serde_json::Value::as_bool) {
236                    config.stern = stern;
237                }
238                if let Some(v) = obj
239                    .get("ignore_link_urls")
240                    .or_else(|| obj.get("ignore-link-urls"))
241                    .or_else(|| obj.get("semantic_link_understanding"))
242                    .or_else(|| obj.get("semantic-link-understanding"))
243                    .and_then(serde_json::Value::as_bool)
244                {
245                    config.ignore_link_urls = v;
246                }
247                if let Some(reflow) = obj.get("reflow").and_then(serde_json::Value::as_bool) {
248                    config.reflow = reflow;
249                }
250                if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
251                    config.reflow_mode = match reflow_mode {
252                        "default" => ReflowMode::Default,
253                        "normalize" => ReflowMode::Normalize,
254                        "sentence-per-line" => ReflowMode::SentencePerLine,
255                        "semantic-line-breaks" => ReflowMode::SemanticLineBreaks,
256                        _ => ReflowMode::default(),
257                    };
258                }
259                config
260            } else {
261                self.config.clone()
262            }
263        } else {
264            self.config.clone()
265        };
266
267        // Fast early return using should_skip with EFFECTIVE config (after inline overrides)
268        // But don't skip if we're in reflow mode with Normalize or SentencePerLine
269        if self.should_skip_with_config(ctx, &effective_config)
270            && !(effective_config.reflow
271                && (effective_config.reflow_mode == ReflowMode::Normalize
272                    || effective_config.reflow_mode == ReflowMode::SentencePerLine
273                    || effective_config.reflow_mode == ReflowMode::SemanticLineBreaks))
274        {
275            return Ok(Vec::new());
276        }
277
278        // Direct implementation without DocumentStructure
279        let mut warnings = Vec::new();
280
281        // Special handling: line_length = 0 means "no line length limit"
282        // Skip all line length checks, but still allow reflow if enabled
283        let skip_length_checks = effective_config.line_length.is_unlimited();
284
285        // Pre-filter lines that could be problematic to avoid processing all lines.
286        // Use the smallest applicable budget across line/heading/code-block contexts
287        // so candidates aren't dropped when a stricter context-specific budget applies.
288        let prefilter_limit = effective_config.min_effective_line_length();
289        let prefilter_skip = prefilter_limit.is_unlimited();
290        let mut candidate_lines = Vec::new();
291        if !skip_length_checks && !prefilter_skip {
292            for (line_idx, line_info) in ctx.lines.iter().enumerate() {
293                // Skip front matter - it should never be linted
294                if line_info.in_front_matter {
295                    continue;
296                }
297
298                // Quick length check first
299                if line_info.byte_len > prefilter_limit.get() {
300                    candidate_lines.push(line_idx);
301                }
302            }
303        }
304
305        // If no candidate lines and not in normalize or sentence-per-line mode, early return
306        if candidate_lines.is_empty()
307            && !(effective_config.reflow
308                && (effective_config.reflow_mode == ReflowMode::Normalize
309                    || effective_config.reflow_mode == ReflowMode::SentencePerLine
310                    || effective_config.reflow_mode == ReflowMode::SemanticLineBreaks))
311        {
312            return Ok(warnings);
313        }
314
315        let lines = ctx.raw_lines();
316
317        // Create a quick lookup set for heading lines
318        // We need this for both the heading skip check AND the paragraphs check
319        let heading_lines_set: std::collections::HashSet<usize> = ctx
320            .lines
321            .iter()
322            .enumerate()
323            .filter(|(_, line)| line.heading.is_some())
324            .map(|(idx, _)| idx + 1)
325            .collect();
326
327        // Use pre-computed table blocks from context
328        // We need this for both the table skip check AND the paragraphs check
329        let table_blocks = &ctx.table_blocks;
330        let mut table_lines_set = std::collections::HashSet::new();
331        for table in table_blocks {
332            table_lines_set.insert(table.header_line + 1);
333            table_lines_set.insert(table.delimiter_line + 1);
334            for &line in &table.content_lines {
335                table_lines_set.insert(line + 1);
336            }
337        }
338
339        // Process candidate lines for line length checks
340        'line_loop: for &line_idx in &candidate_lines {
341            let line_number = line_idx + 1;
342            let line = lines[line_idx];
343
344            // Calculate actual line length (used in warning messages)
345            let effective_length = self.calculate_effective_length(line);
346
347            // Pick the context-specific limit: heading > code-block > paragraph.
348            // Headings dominate over code-block context if a setext underline ever
349            // overlaps a fenced range (defensive — these are mutually exclusive in
350            // practice, but the explicit ordering documents intent).
351            let is_heading_line = heading_lines_set.contains(&line_number);
352            let in_code_block = ctx.line_info(line_number).is_some_and(|info| info.in_code_block);
353            let line_limit = if is_heading_line {
354                effective_config.effective_heading_line_length().get()
355            } else if in_code_block {
356                effective_config.effective_code_block_line_length().get()
357            } else {
358                effective_config.line_length.get()
359            };
360
361            // A context-specific limit of 0 means "unlimited for this context".
362            if line_limit == 0 {
363                continue;
364            }
365
366            // Stern mode: like default, but the trailing-token forgiveness is
367            // disabled — a line with whitespace that exceeds the limit is a
368            // violation even if the excess is the final token. The "unwrappable"
369            // line exemption (single token, optionally prefixed by # or >) is
370            // still honored. Strict overrides stern entirely.
371            if effective_config.stern && !effective_config.strict && is_unwrappable_line(line) {
372                continue;
373            }
374
375            // Trailing-token forgiveness: only in default mode (not strict, not stern).
376            // If the line only exceeds the limit because of a long token at the end
377            // (URL, link chain, identifier), it passes. This matches markdownlint's
378            // behavior: line.replace(/\S*$/u, "#")
379            let check_length = if effective_config.strict || effective_config.stern {
380                effective_length
381            } else {
382                match line.rfind(char::is_whitespace) {
383                    Some(pos) => {
384                        let ws_char = line[pos..].chars().next().unwrap();
385                        let prefix_end = pos + ws_char.len_utf8();
386                        self.calculate_string_length(&line[..prefix_end]) + 1
387                    }
388                    None => 1, // No whitespace — entire line is a single token
389                }
390            };
391
392            // Skip lines where the check length is within the limit
393            if check_length <= line_limit {
394                continue;
395            }
396
397            // Ignore inline link/image URLs: suppress when excess comes entirely from inline URLs.
398            // Disabled by `strict` (all forgiveness off) and by `ignore_link_urls = false`
399            // (count link/image URLs toward the line length so such lines are flagged).
400            if !effective_config.strict && effective_config.ignore_link_urls {
401                let length_without_urls = self.length_without_inline_link_urls(effective_length, line_number, ctx);
402                if length_without_urls <= line_limit {
403                    continue;
404                }
405            }
406
407            // Inline code spans cannot be wrapped, so reflow cannot shorten a line
408            // whose excess length is one. When code-span checking is disabled,
409            // suppress a violation that would fit once inline code spans are excluded.
410            if !effective_config.code_spans {
411                let code_span_width: usize = ctx
412                    .code_spans()
413                    .iter()
414                    .filter(|span| span.line == line_number && span.end_line == line_number)
415                    .map(|span| self.calculate_string_length(&ctx.content[span.byte_offset..span.byte_end]))
416                    .sum();
417                if effective_length.saturating_sub(code_span_width) <= line_limit {
418                    continue;
419                }
420            }
421
422            // Skip mkdocstrings and pymdown blocks (already handled by LintContext)
423            if ctx.lines[line_idx].in_mkdocstrings || ctx.lines[line_idx].in_pymdown_block {
424                continue;
425            }
426
427            // Skip MyST comments (% comment) — structural lines, not prose
428            if ctx.lines[line_idx].is_myst_comment {
429                continue;
430            }
431
432            // Link reference definitions are always exempt, even in strict mode.
433            // There's no way to shorten them without breaking the URL.
434            // Also check after stripping list markers, since list items may
435            // contain link ref defs as their content.
436            {
437                let trimmed = line.trim();
438                if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
439                    continue;
440                }
441                if is_list_item(trimmed) {
442                    let (_, content) = extract_list_marker_and_content(trimmed);
443                    let content_trimmed = content.trim();
444                    if content_trimmed.starts_with('[')
445                        && content_trimmed.contains("]:")
446                        && LINK_REF_PATTERN.is_match(content_trimmed)
447                    {
448                        continue;
449                    }
450                }
451            }
452
453            // Skip various block types efficiently
454            if !effective_config.strict {
455                // Lines whose only content is a link/image are exempt.
456                // After stripping list markers, blockquote markers, and emphasis,
457                // if only a link or image remains, there is no way to shorten it.
458                if is_standalone_link_or_image_line(line) {
459                    continue;
460                }
461
462                // Lines consisting entirely of HTML tags are exempt.
463                // Badge lines, images with attributes, and similar inline HTML
464                // are long due to URLs in attributes and can't be meaningfully shortened.
465                if is_html_only_line(line) {
466                    continue;
467                }
468
469                // Skip setext heading underlines
470                if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
471                    continue;
472                }
473
474                // Skip block elements according to config flags
475                // The flags mean: true = check these elements, false = skip these elements
476                // So we skip when the flag is FALSE and the line is in that element type
477                if (!effective_config.headings && heading_lines_set.contains(&line_number))
478                    || (!effective_config.code_blocks
479                        && ctx.line_info(line_number).is_some_and(|info| info.in_code_block))
480                    || (!effective_config.tables && table_lines_set.contains(&line_number))
481                    || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
482                    || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
483                    || ctx.line_info(line_number).is_some_and(|info| info.in_esm_block)
484                    || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_expression)
485                    || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_block)
486                    || ctx.line_info(line_number).is_some_and(|info| info.in_mdx_comment)
487                    || ctx.line_info(line_number).is_some_and(|info| info.in_pymdown_block)
488                {
489                    continue;
490                }
491
492                // Check if this is a paragraph/regular text line
493                // If paragraphs = false, skip lines that are NOT in special blocks
494                // Blockquote content is treated as paragraph text, so it's not
495                // included in the special blocks list here.
496                if !effective_config.paragraphs {
497                    let is_special_block = heading_lines_set.contains(&line_number)
498                        || ctx.line_info(line_number).is_some_and(|info| info.in_code_block)
499                        || table_lines_set.contains(&line_number)
500                        || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
501                        || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
502                        || ctx.line_info(line_number).is_some_and(|info| info.in_esm_block)
503                        || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_expression)
504                        || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_block)
505                        || ctx.line_info(line_number).is_some_and(|info| info.in_mdx_comment)
506                        || ctx
507                            .line_info(line_number)
508                            .is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container);
509
510                    // Skip regular paragraph text when paragraphs = false
511                    if !is_special_block {
512                        continue;
513                    }
514                }
515
516                // Skip blockquote lines when blockquotes = false.
517                // Also skip lazy continuation lines that belong to a blockquote
518                // (lines without `>` prefix that follow a blockquote line).
519                if !effective_config.blockquotes {
520                    if ctx.lines[line_number - 1].blockquote.is_some() {
521                        continue;
522                    }
523                    // Check for lazy continuation: scan backwards through
524                    // non-blank lines to find if this paragraph started with
525                    // a blockquote marker
526                    if !line.trim().is_empty() {
527                        let mut scan = line_number.saturating_sub(2);
528                        loop {
529                            if ctx.lines[scan].blockquote.is_some() {
530                                // Found a blockquote ancestor — this is a lazy continuation
531                                continue 'line_loop;
532                            }
533                            if lines[scan].trim().is_empty() || scan == 0 {
534                                break;
535                            }
536                            scan -= 1;
537                        }
538                    }
539                }
540
541                // Skip lines that are only a URL, image ref, or link ref
542                if self.should_ignore_line(line, lines, line_idx, ctx) {
543                    continue;
544                }
545            }
546
547            // In sentence-per-line mode, check if this is a single long sentence
548            // If so, emit a warning without a fix (user must manually rephrase)
549            if effective_config.reflow_mode == ReflowMode::SentencePerLine {
550                let sentences = split_into_sentences(line.trim());
551                if sentences.len() == 1 {
552                    // Single sentence that's too long - warn but don't auto-fix
553                    let message = format!("Line length {effective_length} exceeds {line_limit} characters");
554
555                    let (start_line, start_col, end_line, end_col) =
556                        calculate_excess_range(line_number, line, line_limit);
557
558                    warnings.push(LintWarning {
559                        rule_name: Some(self.name().to_string()),
560                        message,
561                        line: start_line,
562                        column: start_col,
563                        end_line,
564                        end_column: end_col,
565                        severity: Severity::Warning,
566                        fix: None, // No auto-fix for long single sentences
567                    });
568                    continue;
569                }
570                // Multiple sentences will be handled by paragraph-based reflow
571                continue;
572            }
573
574            // In semantic-line-breaks mode, skip per-line checks —
575            // all reflow is handled at the paragraph level with cascading splits
576            if effective_config.reflow_mode == ReflowMode::SemanticLineBreaks {
577                continue;
578            }
579
580            // Don't provide fix for individual lines when reflow is enabled
581            // Paragraph-based fixes will be handled separately
582            let fix = None;
583
584            let message = format!("Line length {effective_length} exceeds {line_limit} characters");
585
586            // Calculate precise character range for the excess portion
587            let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
588
589            warnings.push(LintWarning {
590                rule_name: Some(self.name().to_string()),
591                message,
592                line: start_line,
593                column: start_col,
594                end_line,
595                end_column: end_col,
596                severity: Severity::Warning,
597                fix,
598            });
599        }
600
601        // If reflow is enabled, generate paragraph-based fixes
602        if effective_config.reflow {
603            let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, lines);
604            // Merge paragraph warnings with line warnings, removing duplicates
605            for pw in paragraph_warnings {
606                // Remove any line warnings that overlap with this paragraph
607                warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
608                warnings.push(pw);
609            }
610        }
611
612        Ok(warnings)
613    }
614
615    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
616        // For CLI usage, apply fixes from warnings
617        // LSP will use the warning-based fixes directly
618        let warnings = self.check(ctx)?;
619        let warnings =
620            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
621
622        // If there are no fixes, return content unchanged
623        if !warnings.iter().any(|w| w.fix.is_some()) {
624            return Ok(ctx.content.to_string());
625        }
626
627        // Apply warning-based fixes
628        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
629            .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
630    }
631
632    fn as_any(&self) -> &dyn std::any::Any {
633        self
634    }
635
636    fn category(&self) -> RuleCategory {
637        RuleCategory::Whitespace
638    }
639
640    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
641        self.should_skip_with_config(ctx, &self.config)
642    }
643
644    fn default_config_section(&self) -> Option<(String, toml::Value)> {
645        let table = crate::rule_config_serde::config_schema_table(&MD013Config::default())?;
646        if table.is_empty() {
647            None
648        } else {
649            Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
650        }
651    }
652
653    fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
654        let mut aliases = std::collections::HashMap::new();
655        aliases.insert("enable_reflow".to_string(), "reflow".to_string());
656        aliases.insert("strict_sentences".to_string(), "require-sentence-capital".to_string());
657        aliases.insert("strict-sentences".to_string(), "require-sentence-capital".to_string());
658        Some(aliases)
659    }
660
661    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
662    where
663        Self: Sized,
664    {
665        let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
666        // Use global line_length if rule-specific config still has default value
667        if rule_config.line_length.get() == 80 {
668            rule_config.line_length = config.global.line_length;
669        }
670        let mut rule = Self::from_config_struct(rule_config);
671        // Pull list-marker spacing from MD030 (via the shared serde config loader)
672        // so reflow rewrites list items with the configured spacing rather than a
673        // hard-coded single space.
674        rule.list_spacing = crate::rule_config_serde::load_rule_config::<MD030Config>(config);
675        Box::new(rule)
676    }
677}
678
679impl MD013LineLength {
680    /// True when `line_num` (1-based) sits inside a structure whose lines must be
681    /// preserved verbatim (code block, front matter, HTML/JSX/MDX block, MkDocs
682    /// container, div marker, ...). Used to keep blockquote reflow from touching
683    /// quoted-looking text embedded in such structures.
684    fn line_in_verbatim_context(&self, line_num: usize, ctx: &crate::lint_context::LintContext) -> bool {
685        ctx.line_info(line_num).is_some_and(|info| {
686            info.in_code_block
687                || info.in_front_matter
688                || info.in_html_block
689                || info.in_html_comment
690                || info.in_esm_block
691                || info.in_jsx_expression
692                || info.in_jsx_block
693                || info.in_mdx_comment
694                || info.in_mkdocstrings
695                || info.in_pymdown_block
696                || info.in_mkdocs_container()
697                || info.is_div_marker
698        })
699    }
700
701    fn is_blockquote_content_boundary(
702        &self,
703        content: &str,
704        line_num: usize,
705        ctx: &crate::lint_context::LintContext,
706        strict: bool,
707    ) -> bool {
708        let trimmed = content.trim();
709
710        trimmed.is_empty()
711            || self.line_in_verbatim_context(line_num, ctx)
712            || trimmed.starts_with('#')
713            || trimmed.starts_with("```")
714            || trimmed.starts_with("~~~")
715            || trimmed.starts_with('>')
716            || TableUtils::is_potential_table_row(content)
717            || is_list_item(trimmed)
718            || is_horizontal_rule(content)
719            || (trimmed.starts_with('[') && content.contains("]:"))
720            || is_template_directive_only(content)
721            || is_standalone_attr_list(content)
722            || is_snippet_block_delimiter(content)
723            || is_github_alert_marker(trimmed)
724            || is_html_only_line(content)
725            // A standalone link/image line is exempt from MD013 (non-strict mode),
726            // so it must end the blockquote paragraph rather than be absorbed into
727            // it, mirroring the top-level paragraph reflow boundary.
728            || (!strict && is_standalone_link_or_image_line(content))
729    }
730
731    #[allow(clippy::too_many_arguments)]
732    fn generate_blockquote_paragraph_fix(
733        &self,
734        ctx: &crate::lint_context::LintContext,
735        config: &MD013Config,
736        lines: &[&str],
737        line_index: &LineIndex,
738        start_idx: usize,
739        line_ending: &str,
740        // Extra indent (spaces) to prepend to the emitted `>` prefix so a blockquote
741        // nested in a list item moves with its parent's widened marker. Zero unless a
742        // non-default MD030 widened an ancestor list item.
743        ancestor_shift: isize,
744    ) -> (Option<LintWarning>, usize) {
745        let Some(start_bq) = ctx.lines.get(start_idx).and_then(|line| line.blockquote.as_deref()) else {
746            return (None, start_idx + 1);
747        };
748        let target_level = start_bq.nesting_level;
749
750        let mut collected: Vec<CollectedBlockquoteLine> = Vec::new();
751        let mut i = start_idx;
752
753        while i < lines.len() {
754            if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].data.content) {
755                break;
756            }
757
758            let line_num = i + 1;
759            if line_num > ctx.lines.len() {
760                break;
761            }
762
763            if lines[i].trim().is_empty() {
764                break;
765            }
766
767            let line_bq = ctx.lines[i].blockquote.as_deref();
768            if let Some(bq) = line_bq {
769                if bq.nesting_level != target_level {
770                    break;
771                }
772
773                if self.is_blockquote_content_boundary(&bq.content, line_num, ctx, config.strict) {
774                    break;
775                }
776
777                collected.push(CollectedBlockquoteLine {
778                    line_idx: i,
779                    data: BlockquoteLineData::explicit(trim_preserving_hard_break(&bq.content), bq.prefix.clone()),
780                });
781                i += 1;
782                continue;
783            }
784
785            let lazy_content = lines[i].trim_start();
786            if self.is_blockquote_content_boundary(lazy_content, line_num, ctx, config.strict) {
787                break;
788            }
789
790            collected.push(CollectedBlockquoteLine {
791                line_idx: i,
792                data: BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content)),
793            });
794            i += 1;
795        }
796
797        if collected.is_empty() {
798            return (None, start_idx + 1);
799        }
800
801        let next_idx = i;
802        let paragraph_start = collected[0].line_idx;
803        let end_line = collected[collected.len() - 1].line_idx;
804        let line_data: Vec<BlockquoteLineData> = collected.iter().map(|l| l.data.clone()).collect();
805        let paragraph_text = line_data
806            .iter()
807            .map(|d| d.content.as_str())
808            .collect::<Vec<_>>()
809            .join(" ");
810
811        let contains_definition_list = line_data
812            .iter()
813            .any(|d| crate::utils::is_definition_list_item(&d.content));
814        if contains_definition_list {
815            return (None, next_idx);
816        }
817
818        let contains_snippets = line_data.iter().any(|d| is_snippet_block_delimiter(&d.content));
819        if contains_snippets {
820            return (None, next_idx);
821        }
822
823        let needs_reflow = match config.reflow_mode {
824            ReflowMode::Normalize => {
825                self.normalize_mode_needs_reflow(line_data.iter().map(|d| d.content.as_str()), config)
826            }
827            ReflowMode::SentencePerLine => {
828                let sentences = split_into_sentences(&paragraph_text);
829                sentences.len() > 1 || line_data.len() > 1
830            }
831            ReflowMode::SemanticLineBreaks => {
832                let sentences = split_into_sentences(&paragraph_text);
833                sentences.len() > 1
834                    || line_data.len() > 1
835                    || collected
836                        .iter()
837                        .any(|l| self.calculate_effective_length(lines[l.line_idx]) > config.line_length.get())
838            }
839            ReflowMode::Default => collected
840                .iter()
841                .any(|l| self.calculate_effective_length(lines[l.line_idx]) > config.line_length.get()),
842        };
843
844        if !needs_reflow {
845            return (None, next_idx);
846        }
847
848        let fallback_prefix = start_bq.prefix.clone();
849        let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
850        // Shift the whole quote right to track a widened parent list item's content
851        // column (only widening matters for nesting; a narrowed parent leaves the quote
852        // harmlessly over-indented, which MD027/MD030 tidy).
853        let explicit_prefix = if ancestor_shift > 0 {
854            format!("{}{explicit_prefix}", " ".repeat(ancestor_shift as usize))
855        } else {
856            explicit_prefix
857        };
858        let continuation_style = blockquote_continuation_style(&line_data);
859
860        let reflow_line_length = if config.line_length.is_unlimited() {
861            usize::MAX
862        } else {
863            config
864                .line_length
865                .get()
866                .saturating_sub(self.calculate_string_length(&explicit_prefix))
867                .max(1)
868        };
869
870        let reflow_options = crate::utils::text_reflow::ReflowOptions {
871            line_length: reflow_line_length,
872            break_on_sentences: true,
873            preserve_breaks: false,
874            sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
875            semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
876            abbreviations: config.abbreviations_for_reflow(),
877            length_mode: self.reflow_length_mode(),
878            attr_lists: ctx.flavor.supports_attr_lists(),
879            myst_roles: ctx.flavor.supports_myst_roles(),
880            require_sentence_capital: config.require_sentence_capital,
881            max_list_continuation_indent: if ctx.flavor.requires_strict_list_indent() {
882                Some(4)
883            } else {
884                None
885            },
886        };
887
888        let reflowed_with_style =
889            reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &reflow_options);
890
891        if reflowed_with_style.is_empty() {
892            return (None, next_idx);
893        }
894
895        let reflowed_text = reflowed_with_style.join(line_ending);
896
897        let start_range = line_index.whole_line_range(paragraph_start + 1);
898        let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
899            line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
900        } else {
901            line_index.whole_line_range(end_line + 1)
902        };
903        let byte_range = start_range.start..end_range.end;
904
905        let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
906            format!("{reflowed_text}{line_ending}")
907        } else {
908            reflowed_text
909        };
910
911        let original_text = &ctx.content[byte_range.clone()];
912        if original_text == replacement {
913            return (None, next_idx);
914        }
915
916        let (warning_line, warning_end_line) = match config.reflow_mode {
917            ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
918            ReflowMode::SentencePerLine | ReflowMode::SemanticLineBreaks => (paragraph_start + 1, end_line + 1),
919            ReflowMode::Default => {
920                let violating_line = collected
921                    .iter()
922                    .find(|line| self.calculate_effective_length(lines[line.line_idx]) > config.line_length.get())
923                    .map_or(paragraph_start + 1, |line| line.line_idx + 1);
924                (violating_line, violating_line)
925            }
926        };
927
928        let warning = LintWarning {
929            rule_name: Some(self.name().to_string()),
930            message: match config.reflow_mode {
931                ReflowMode::Normalize => format!(
932                    "Paragraph could be normalized to use line length of {} characters",
933                    config.line_length.get()
934                ),
935                ReflowMode::SentencePerLine => {
936                    let num_sentences = split_into_sentences(&paragraph_text).len();
937                    if line_data.len() == 1 {
938                        format!("Line contains {num_sentences} sentences (one sentence per line required)")
939                    } else {
940                        let num_lines = line_data.len();
941                        format!(
942                            "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
943                        )
944                    }
945                }
946                ReflowMode::SemanticLineBreaks => {
947                    let num_sentences = split_into_sentences(&paragraph_text).len();
948                    format!("Paragraph should use semantic line breaks ({num_sentences} sentences)")
949                }
950                ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length.get()),
951            },
952            line: warning_line,
953            column: 1,
954            end_line: warning_end_line,
955            end_column: lines[warning_end_line.saturating_sub(1)].chars().count() + 1,
956            severity: Severity::Warning,
957            fix: Some(crate::rule::Fix::new(byte_range, replacement)),
958        };
959
960        (Some(warning), next_idx)
961    }
962
963    /// Reflow a single list item that lives inside a blockquote.
964    ///
965    /// The blockquote paragraph reflow treats a list marker as a content boundary,
966    /// so `> - long item ...` is never wrapped even though the identical top-level
967    /// item is. This handles that case: it collects one tight, prose-only list item
968    /// at the starting blockquote level, reflows the item body to the configured
969    /// width, and re-emits it with the blockquote prefix preserved and continuation
970    /// lines aligned under the list content.
971    ///
972    /// Sibling and nested list items end collection and are reflowed independently
973    /// by the caller's outer loop (a nested item carries its indent folded into the
974    /// blockquote prefix, so it reflows correctly on its own). Items that are not
975    /// simple tight prose - those embedding a code block, table, fence, or hard
976    /// break - are left untouched (`None`) and the cursor still advances past the
977    /// whole item so its inner lines are never reprocessed as loose prose.
978    #[allow(clippy::too_many_arguments)]
979    fn generate_blockquote_list_item_fix(
980        &self,
981        ctx: &crate::lint_context::LintContext,
982        config: &MD013Config,
983        lines: &[&str],
984        line_index: &LineIndex,
985        start_idx: usize,
986        line_ending: &str,
987        // Extra indent (spaces) to prepend to the emitted `>` prefix when this quoted
988        // list lives inside a list item whose marker widened. Zero unless a non-default
989        // MD030 widened an ancestor list item.
990        ancestor_shift: isize,
991    ) -> (Option<LintWarning>, usize) {
992        use crate::utils::blockquote::effective_indent_in_blockquote;
993
994        let Some(start_bq) = ctx.lines.get(start_idx).and_then(|line| line.blockquote.as_deref()) else {
995            return (None, start_idx + 1);
996        };
997
998        // A `>`-prefixed line can be marked as a blockquote even inside a fenced code
999        // block (or other verbatim structure); such content must never be reflowed.
1000        if self.line_in_verbatim_context(start_idx + 1, ctx) {
1001            return (None, start_idx + 1);
1002        }
1003
1004        let target_level = start_bq.nesting_level;
1005
1006        // The marker line carries the canonical blockquote prefix: its content begins
1007        // with the list marker, so no list indent has been folded into the prefix.
1008        // Track a widened parent list item's content column so the quote stays nested
1009        // (only widening can detach it; narrowing just over-indents).
1010        let bq_prefix = if ancestor_shift > 0 {
1011            format!("{}{}", " ".repeat(ancestor_shift as usize), start_bq.prefix)
1012        } else {
1013            start_bq.prefix.clone()
1014        };
1015
1016        let (marker, first_body) = extract_list_marker_and_content(&start_bq.content);
1017        if marker.is_empty() {
1018            return (None, start_idx + 1);
1019        }
1020        let marker_width = marker.chars().count();
1021
1022        // Continuation lines of a checkbox item align under the bullet+checkbox, but
1023        // are recognized from the bullet width, matching the top-level list reflow.
1024        let base_marker_width = ["[ ] ", "[x] ", "[X] "]
1025            .iter()
1026            .find_map(|cb| marker.find(*cb))
1027            .unwrap_or(marker_width);
1028
1029        // Collect the item: the marker line plus its tight prose continuation lines.
1030        // `end_idx` always tracks the last consumed line so the cursor advances past
1031        // the entire item, even when it turns out to be too complex to reflow safely.
1032        let first_piece = trim_preserving_hard_break(&first_body);
1033        let mut simple = !has_hard_break(&first_piece);
1034        let mut body_pieces: Vec<String> = vec![first_piece];
1035        let mut end_idx = start_idx;
1036        let mut i = start_idx + 1;
1037
1038        while i < lines.len() {
1039            let Some(bq) = ctx.lines[i].blockquote.as_deref() else {
1040                // A blank line ends the item.
1041                if lines[i].trim().is_empty() {
1042                    break;
1043                }
1044                // A lazy continuation (no `>` marker) is too ambiguous to reflow
1045                // safely. Consume the whole lazy run into this item's span and leave
1046                // the item untouched, so the caller does not reflow the continuation
1047                // in isolation and leave the marker line partially fixed.
1048                simple = false;
1049                while i < lines.len() && ctx.lines[i].blockquote.is_none() && !lines[i].trim().is_empty() {
1050                    end_idx = i;
1051                    i += 1;
1052                }
1053                break;
1054            };
1055            if bq.nesting_level != target_level {
1056                break;
1057            }
1058
1059            let content = bq.content.as_str();
1060            if content.trim().is_empty() {
1061                // Blank quoted line ends the tight paragraph. A following indented
1062                // paragraph (loose item) is reflowed on its own by the prose path.
1063                break;
1064            }
1065
1066            let eff_indent = effective_indent_in_blockquote(lines[i], target_level, 0);
1067            if eff_indent < base_marker_width {
1068                // Dedented: a sibling list item or text outside this item. Stop here
1069                // and let the outer loop classify it.
1070                break;
1071            }
1072            if is_list_item(content) {
1073                // A nested list item: its own item, handled independently.
1074                break;
1075            }
1076
1077            // An embedded structure (code block, table, fence, nested quote, ...)
1078            // means the item is not simple prose: keep consuming so the cursor clears
1079            // the whole structure, but do not produce a fix.
1080            if self.is_blockquote_content_boundary(content, i + 1, ctx, config.strict) {
1081                simple = false;
1082            }
1083
1084            let piece = trim_preserving_hard_break(content);
1085            if has_hard_break(&piece) {
1086                simple = false;
1087            }
1088            body_pieces.push(piece);
1089            end_idx = i;
1090            i += 1;
1091        }
1092
1093        let next_idx = end_idx + 1;
1094
1095        if !simple {
1096            return (None, next_idx);
1097        }
1098
1099        let exceeds_limit =
1100            || (start_idx..=end_idx).any(|idx| self.calculate_effective_length(lines[idx]) > config.line_length.get());
1101        let body_text = body_pieces.join(" ");
1102        let body_text = body_text.trim();
1103
1104        // Some bodies cannot be shortened and must stay verbatim, matching the
1105        // exemptions the top-level list reflow applies: link reference definitions
1106        // always, and (in non-strict mode) standalone links/images and HTML-only
1107        // lines. Reflowing a link reference definition would split it after the
1108        // colon/URL and break the definition.
1109        let is_link_ref_def =
1110            body_text.starts_with('[') && body_text.contains("]:") && LINK_REF_PATTERN.is_match(body_text);
1111        let raw_marker_line = lines[start_idx];
1112        let body_is_unwrappable = is_link_ref_def
1113            || (!config.strict && is_standalone_link_or_image_line(raw_marker_line))
1114            || (!config.strict && is_html_only_line(raw_marker_line));
1115        if body_is_unwrappable {
1116            return (None, next_idx);
1117        }
1118
1119        let needs_reflow = match config.reflow_mode {
1120            ReflowMode::Normalize => body_pieces.len() > 1 || exceeds_limit(),
1121            ReflowMode::Default => exceeds_limit(),
1122            ReflowMode::SentencePerLine => split_into_sentences(body_text).len() > 1 || body_pieces.len() > 1,
1123            ReflowMode::SemanticLineBreaks => split_into_sentences(body_text).len() > 1 || exceeds_limit(),
1124        };
1125        if !needs_reflow {
1126            return (None, next_idx);
1127        }
1128
1129        // Apply MD030 list-marker spacing in the spacing-normalizing modes, mirroring
1130        // the top-level list reflow: derive the post-marker spacing from MD030 and let
1131        // the continuation indent follow the resulting content width. Default MD030 (a
1132        // single space) leaves the marker unchanged. MkDocs keeps its rigid indent.
1133        let (marker, marker_width) = if matches!(config.reflow_mode, ReflowMode::Default | ReflowMode::Normalize)
1134            && !ctx.flavor.requires_strict_list_indent()
1135        {
1136            let is_ordered = marker.starts_with(|c: char| c.is_ascii_digit());
1137            // Bullet/number portion only (e.g. `-`, `1.`); the checkbox is content.
1138            let bullet = marker.split(' ').next().unwrap_or("");
1139            let bullet_len = bullet.chars().count();
1140            let checkbox_tail = &marker[base_marker_width..];
1141            // Decide single- vs multi-line spacing from the rewritten shape. This path
1142            // only handles a single tight prose paragraph (structural or multi-paragraph
1143            // items set `simple = false` and bail out above), so the emitted item stays
1144            // multi-line solely when the joined body wraps past one line. A multi-line
1145            // *source* that collapses onto the marker line must use MD030's single-line
1146            // spacing, matching the top-level reflow. The wrap test measures at the
1147            // single-line content column so it does not depend on the spacing chosen here.
1148            let single_col = self.calculate_string_length(&bq_prefix)
1149                + bullet_len
1150                + self.list_spacing.expected_spaces(is_ordered, false, bullet_len)
1151                + checkbox_tail.chars().count();
1152            let is_multi = !body_text.is_empty()
1153                && self.calculate_effective_length(&format!("{}{body_text}", " ".repeat(single_col)))
1154                    > config.line_length.effective_limit();
1155            let spaces = self.list_spacing.expected_spaces(is_ordered, is_multi, bullet_len);
1156            let new_marker = format!("{bullet}{}{checkbox_tail}", " ".repeat(spaces));
1157            let width = new_marker.chars().count();
1158            (new_marker, width)
1159        } else {
1160            (marker, marker_width)
1161        };
1162
1163        let prefix_width = self.calculate_string_length(&bq_prefix) + self.calculate_string_length(&marker);
1164        let reflow_line_length = if config.line_length.is_unlimited() {
1165            usize::MAX
1166        } else {
1167            config.line_length.get().saturating_sub(prefix_width).max(1)
1168        };
1169
1170        let reflow_options = crate::utils::text_reflow::ReflowOptions {
1171            line_length: reflow_line_length,
1172            break_on_sentences: true,
1173            preserve_breaks: false,
1174            sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1175            semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
1176            abbreviations: config.abbreviations_for_reflow(),
1177            length_mode: self.reflow_length_mode(),
1178            attr_lists: ctx.flavor.supports_attr_lists(),
1179            myst_roles: ctx.flavor.supports_myst_roles(),
1180            require_sentence_capital: config.require_sentence_capital,
1181            max_list_continuation_indent: if ctx.flavor.requires_strict_list_indent() {
1182                Some(4)
1183            } else {
1184                None
1185            },
1186        };
1187
1188        let reflowed = crate::utils::text_reflow::reflow_line(body_text, &reflow_options);
1189        if reflowed.is_empty() {
1190            return (None, next_idx);
1191        }
1192
1193        let continuation_indent = " ".repeat(marker_width);
1194        let reflowed_text = reflowed
1195            .iter()
1196            .enumerate()
1197            .map(|(idx, line)| {
1198                if idx == 0 {
1199                    format!("{bq_prefix}{marker}{line}")
1200                } else {
1201                    format!("{bq_prefix}{continuation_indent}{line}")
1202                }
1203            })
1204            .collect::<Vec<_>>()
1205            .join(line_ending);
1206
1207        let start_range = line_index.whole_line_range(start_idx + 1);
1208        let end_range = if end_idx == lines.len() - 1 && !ctx.content.ends_with('\n') {
1209            line_index.line_text_range(end_idx + 1, 1, lines[end_idx].len() + 1)
1210        } else {
1211            line_index.whole_line_range(end_idx + 1)
1212        };
1213        let byte_range = start_range.start..end_range.end;
1214
1215        let replacement = if end_idx < lines.len() - 1 || ctx.content.ends_with('\n') {
1216            format!("{reflowed_text}{line_ending}")
1217        } else {
1218            reflowed_text
1219        };
1220
1221        let original_text = &ctx.content[byte_range.clone()];
1222        if original_text == replacement {
1223            return (None, next_idx);
1224        }
1225
1226        let message = match config.reflow_mode {
1227            ReflowMode::Normalize => format!(
1228                "Paragraph could be normalized to use line length of {} characters",
1229                config.line_length.get()
1230            ),
1231            ReflowMode::SentencePerLine => {
1232                let num_sentences = split_into_sentences(body_text).len();
1233                format!("List item should have one sentence per line (found {num_sentences} sentences)")
1234            }
1235            ReflowMode::SemanticLineBreaks => {
1236                let num_sentences = split_into_sentences(body_text).len();
1237                format!("List item should use semantic line breaks ({num_sentences} sentences)")
1238            }
1239            ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length.get()),
1240        };
1241
1242        let warning = LintWarning {
1243            rule_name: Some(self.name().to_string()),
1244            message,
1245            line: start_idx + 1,
1246            column: 1,
1247            end_line: end_idx + 1,
1248            end_column: lines[end_idx].chars().count() + 1,
1249            severity: Severity::Warning,
1250            fix: Some(crate::rule::Fix::new(byte_range, replacement)),
1251        };
1252
1253        (Some(warning), next_idx)
1254    }
1255
1256    /// Generate paragraph-based fixes
1257    fn generate_paragraph_fixes(
1258        &self,
1259        ctx: &crate::lint_context::LintContext,
1260        config: &MD013Config,
1261        lines: &[&str],
1262    ) -> Vec<LintWarning> {
1263        let mut warnings = Vec::new();
1264        let line_index = LineIndex::new(ctx.content);
1265
1266        // Detect the content's line ending style to preserve it in replacements.
1267        // The LSP receives content from editors which may use CRLF (Windows).
1268        // Replacements must match the original line endings to avoid false positives.
1269        let line_ending = crate::utils::line_ending::detect_line_ending(ctx.content);
1270
1271        // Ancestor list-item indent shifts, innermost last. When a reflowed parent's
1272        // marker widens under a non-default MD030 (e.g. ul-multi = 3 moves the parent's
1273        // content from column 2 to 4), its nested list/blockquote children are reflowed
1274        // independently and would otherwise keep their original indent — leaving them
1275        // under the parent's new content column, where a CommonMark parser reparses them
1276        // as siblings rather than children. Each frame is (source content column,
1277        // cumulative shift applied to that item's content column); a descendant adds its
1278        // innermost open ancestor's shift to its own indent so the whole subtree moves
1279        // together. With default MD030 every shift is 0, so this is inert and the output
1280        // is byte-identical.
1281        let mut list_shift_stack: Vec<(usize, isize)> = Vec::new();
1282
1283        let mut i = 0;
1284        while i < lines.len() {
1285            let line_num = i + 1;
1286
1287            // Close ancestor frames whose list item has ended at this line: a non-blank
1288            // line indented less than the frame's source content column is no longer
1289            // inside that item. Blank lines alone don't close a (loose) list item.
1290            if !list_shift_stack.is_empty()
1291                && let Some(info) = ctx.lines.get(i)
1292                && !info.is_blank
1293            {
1294                while let Some(&(content_column, _)) = list_shift_stack.last() {
1295                    if info.indent < content_column {
1296                        list_shift_stack.pop();
1297                    } else {
1298                        break;
1299                    }
1300                }
1301            }
1302
1303            // Handle blockquote paragraphs with style-preserving reflow.
1304            // Skip blockquotes when blockquotes=false or paragraphs=false
1305            if line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some() {
1306                if !config.blockquotes || !config.paragraphs {
1307                    // Skip past all blockquote lines (explicit and lazy continuations).
1308                    // A lazy continuation is a non-blank line without `>` that follows
1309                    // a blockquote line and isn't a structural element.
1310                    let mut saw_explicit_bq = false;
1311                    while i < lines.len() && i < ctx.lines.len() {
1312                        if ctx.lines[i].blockquote.is_some() {
1313                            saw_explicit_bq = true;
1314                            i += 1;
1315                        } else if saw_explicit_bq
1316                            && !lines[i].trim().is_empty()
1317                            && !lines[i].trim_start().starts_with('#')
1318                            && !lines[i].trim_start().starts_with('>')
1319                        {
1320                            // Lazy continuation of preceding blockquote
1321                            i += 1;
1322                        } else {
1323                            break;
1324                        }
1325                    }
1326                    continue;
1327                }
1328                // A blockquote nested in a list item moves with its parent when the
1329                // parent's marker widens (see `list_shift_stack`); pass that shift so the
1330                // emitted `>` prefix lands under the parent's new content column instead
1331                // of detaching into a sibling.
1332                let ancestor_shift = list_shift_stack.last().map_or(0isize, |&(_, shift)| shift);
1333                // A list item inside the blockquote needs list-aware reflow (marker +
1334                // continuation indent); plain prose goes through the paragraph path.
1335                let is_bq_list_item = ctx.lines[i]
1336                    .blockquote
1337                    .as_deref()
1338                    .is_some_and(|bq| is_list_item(&bq.content));
1339                let (warning, next_idx) = if is_bq_list_item {
1340                    self.generate_blockquote_list_item_fix(
1341                        ctx,
1342                        config,
1343                        lines,
1344                        &line_index,
1345                        i,
1346                        line_ending,
1347                        ancestor_shift,
1348                    )
1349                } else {
1350                    self.generate_blockquote_paragraph_fix(
1351                        ctx,
1352                        config,
1353                        lines,
1354                        &line_index,
1355                        i,
1356                        line_ending,
1357                        ancestor_shift,
1358                    )
1359                };
1360                if let Some(warning) = warning {
1361                    warnings.push(warning);
1362                }
1363                i = next_idx;
1364                continue;
1365            }
1366
1367            // Skip special structures (but NOT MkDocs containers - those get special handling)
1368            let should_skip_due_to_line_info = ctx.line_info(line_num).is_some_and(|info| {
1369                info.in_code_block
1370                    || info.in_front_matter
1371                    || info.in_html_block
1372                    || info.in_html_comment
1373                    || info.in_esm_block
1374                    || info.in_jsx_expression
1375                    || info.in_jsx_block
1376                    || info.in_mdx_comment
1377                    || info.in_mkdocstrings
1378                    || info.in_pymdown_block
1379            });
1380
1381            // Skip link reference definitions but NOT footnote definitions.
1382            // Footnote definitions (`[^id]: prose`) contain reflowable text,
1383            // while link reference definitions (`[ref]: URL`) contain URLs
1384            // that cannot be shortened.
1385            let is_link_ref_def =
1386                lines[i].trim().starts_with('[') && !lines[i].trim().starts_with("[^") && lines[i].contains("]:");
1387
1388            if should_skip_due_to_line_info
1389                || lines[i].trim().starts_with('#')
1390                || TableUtils::is_potential_table_row(lines[i])
1391                || lines[i].trim().is_empty()
1392                || is_horizontal_rule(lines[i])
1393                || is_template_directive_only(lines[i])
1394                || is_link_ref_def
1395                || ctx.line_info(line_num).is_some_and(|info| info.is_div_marker)
1396                || is_html_only_line(lines[i])
1397                || (!config.strict && is_standalone_link_or_image_line(lines[i]))
1398            {
1399                i += 1;
1400                continue;
1401            }
1402
1403            // Handle footnote definitions: `[^id]: prose text that can be reflowed`
1404            // Supports multi-paragraph footnotes with code blocks, blockquotes,
1405            // tables, and lists preserved verbatim.
1406            // Validate structure: must start with `[^`, contain `]:`, and the ID
1407            // must not contain `[` or `]` (prevents false matches on nested brackets)
1408            if lines[i].trim().starts_with("[^") && lines[i].contains("]:") && {
1409                let after_caret = &lines[i].trim()[2..];
1410                after_caret
1411                    .find("]:")
1412                    .is_some_and(|pos| pos > 0 && !after_caret[..pos].contains(['[', ']']))
1413            } {
1414                let footnote_start = i;
1415                let line = lines[i];
1416
1417                // Extract the prefix `[^id]:`
1418                let Some(colon_pos) = line.find("]:") else {
1419                    i += 1;
1420                    continue;
1421                };
1422                let prefix_end = colon_pos + 2;
1423                let prefix = &line[..prefix_end];
1424
1425                // Content starts after `]: ` (with optional space)
1426                let content_start = if line[prefix_end..].starts_with(' ') {
1427                    prefix_end + 1
1428                } else {
1429                    prefix_end
1430                };
1431                let first_content = &line[content_start..];
1432
1433                // CommonMark footnotes use 4-space continuation indent
1434                const FN_INDENT: usize = 4;
1435
1436                // --- Line classification for footnote content ---
1437                #[derive(Debug, Clone)]
1438                enum FnLineType {
1439                    Content(String),
1440                    Verbatim(String, usize), // preserved text, original indent
1441                    Empty,
1442                }
1443
1444                // Helper: compute visual indent (tabs = 4 spaces)
1445                let visual_indent = |s: &str| -> usize {
1446                    s.chars()
1447                        .take_while(|c| c.is_whitespace())
1448                        .map(|c| if c == '\t' { 4 } else { 1 })
1449                        .sum::<usize>()
1450                };
1451
1452                // Helper: check if a trimmed line is a fence marker (homogeneous chars)
1453                let is_fence = |s: &str| -> bool {
1454                    let t = s.trim();
1455                    let fence_char = t.chars().next();
1456                    matches!(fence_char, Some('`') | Some('~'))
1457                        && t.chars().take_while(|&c| c == fence_char.unwrap()).count() >= 3
1458                };
1459
1460                // Helper: check if a trimmed line is a setext underline
1461                let is_setext_underline = |s: &str| -> bool {
1462                    let t = s.trim();
1463                    !t.is_empty()
1464                        && (t.chars().all(|c| c == '=' || c == ' ') || t.chars().all(|c| c == '-' || c == ' '))
1465                        && t.contains(['=', '-'])
1466                };
1467
1468                // Deferred body: `[^id]:\n    content` — first line has no content,
1469                // actual content starts on the next indented line
1470                let deferred_body = first_content.trim().is_empty();
1471
1472                // Collect all lines belonging to this footnote definition
1473                let mut fn_lines: Vec<FnLineType> = Vec::new();
1474                if !deferred_body {
1475                    fn_lines.push(FnLineType::Content(first_content.to_string()));
1476                }
1477                let mut last_consumed = i;
1478                i += 1;
1479
1480                // Strip only the footnote continuation indent, preserving
1481                // internal indentation (e.g., code block body indent)
1482                let strip_fn_indent = |s: &str| -> String {
1483                    let mut chars = s.chars();
1484                    let mut stripped = 0;
1485                    while stripped < FN_INDENT {
1486                        match chars.next() {
1487                            Some('\t') => stripped += 4,
1488                            Some(c) if c.is_whitespace() => stripped += 1,
1489                            _ => break,
1490                        }
1491                    }
1492                    chars.as_str().to_string()
1493                };
1494
1495                let mut in_fenced_code = false;
1496                let mut consecutive_blanks = 0u32;
1497
1498                while i < lines.len() {
1499                    let next = lines[i];
1500                    let next_trimmed = next.trim();
1501
1502                    // Blank line handling
1503                    if next_trimmed.is_empty() {
1504                        consecutive_blanks += 1;
1505                        // 2+ consecutive blanks terminate the footnote
1506                        if consecutive_blanks >= 2 {
1507                            break;
1508                        }
1509
1510                        // Inside a fenced code block, blank lines are part of the code
1511                        if in_fenced_code {
1512                            consecutive_blanks = 0; // Don't count blanks inside code blocks
1513                            fn_lines.push(FnLineType::Verbatim(String::new(), 0));
1514                            last_consumed = i;
1515                            i += 1;
1516                            continue;
1517                        }
1518
1519                        // Peek ahead: if next non-blank line is indented >= FN_INDENT,
1520                        // this blank is an internal paragraph separator
1521                        if i + 1 < lines.len() {
1522                            let peek = lines[i + 1];
1523                            let peek_indent = visual_indent(peek);
1524                            if !peek.trim().is_empty() && peek_indent >= FN_INDENT {
1525                                fn_lines.push(FnLineType::Empty);
1526                                last_consumed = i;
1527                                i += 1;
1528                                continue;
1529                            }
1530                        }
1531                        // No valid continuation after blank — end of footnote
1532                        break;
1533                    }
1534
1535                    consecutive_blanks = 0;
1536                    let indent = visual_indent(next);
1537
1538                    // Not indented enough — end of footnote
1539                    if indent < FN_INDENT {
1540                        break;
1541                    }
1542
1543                    // Inside a fenced code block: everything is verbatim until closing fence
1544                    if in_fenced_code {
1545                        fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
1546                        if is_fence(next_trimmed) {
1547                            in_fenced_code = false;
1548                        }
1549                        last_consumed = i;
1550                        i += 1;
1551                        continue;
1552                    }
1553
1554                    // Fence opener — start verbatim code block
1555                    if is_fence(next_trimmed) {
1556                        in_fenced_code = true;
1557                        fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
1558                        last_consumed = i;
1559                        i += 1;
1560                        continue;
1561                    }
1562
1563                    // Indented code block: indent >= FN_INDENT + 4 (= 8 spaces)
1564                    if indent >= FN_INDENT + 4 {
1565                        fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
1566                        last_consumed = i;
1567                        i += 1;
1568                        continue;
1569                    }
1570
1571                    // Structural content that must be preserved verbatim
1572                    if next_trimmed.starts_with('#')
1573                        || is_list_item(next_trimmed)
1574                        || next_trimmed.starts_with('>')
1575                        || TableUtils::is_potential_table_row(next_trimmed)
1576                        || is_setext_underline(next_trimmed)
1577                        || is_horizontal_rule(next_trimmed)
1578                        || crate::utils::mkdocs_footnotes::is_footnote_definition(next_trimmed)
1579                    {
1580                        // Preserve verbatim: blockquotes, tables, lists, setext
1581                        // underlines, and horizontal rules inside the footnote
1582                        if next_trimmed.starts_with('>')
1583                            || TableUtils::is_potential_table_row(next_trimmed)
1584                            || is_list_item(next_trimmed)
1585                            || is_setext_underline(next_trimmed)
1586                            || is_horizontal_rule(next_trimmed)
1587                        {
1588                            fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
1589                            last_consumed = i;
1590                            i += 1;
1591                            continue;
1592                        }
1593                        // Headings, new footnote defs, link refs — end the footnote
1594                        break;
1595                    }
1596
1597                    // Link reference definitions inside footnotes are not reflowable
1598                    if next_trimmed.starts_with('[')
1599                        && !next_trimmed.starts_with("[^")
1600                        && next_trimmed.contains("]:")
1601                        && LINK_REF_PATTERN.is_match(next_trimmed)
1602                    {
1603                        fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
1604                        last_consumed = i;
1605                        i += 1;
1606                        continue;
1607                    }
1608
1609                    // HTML-only lines inside footnotes are not reflowable
1610                    if is_html_only_line(next_trimmed) {
1611                        fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
1612                        last_consumed = i;
1613                        i += 1;
1614                        continue;
1615                    }
1616
1617                    // Regular prose content
1618                    fn_lines.push(FnLineType::Content(next_trimmed.to_string()));
1619                    last_consumed = i;
1620                    i += 1;
1621                }
1622
1623                // Nothing collected or only empty lines
1624                if fn_lines.iter().all(|l| matches!(l, FnLineType::Empty)) || fn_lines.is_empty() {
1625                    continue;
1626                }
1627
1628                // --- Group into blocks ---
1629                #[derive(Debug)]
1630                enum FnBlock {
1631                    Paragraph(Vec<String>),
1632                    Verbatim(Vec<(String, usize)>), // (content, indent) preserved as-is
1633                }
1634
1635                let mut blocks: Vec<FnBlock> = Vec::new();
1636                let mut current_para: Vec<String> = Vec::new();
1637                let mut current_verbatim: Vec<(String, usize)> = Vec::new();
1638
1639                for fl in &fn_lines {
1640                    match fl {
1641                        FnLineType::Content(s) => {
1642                            if !current_verbatim.is_empty() {
1643                                blocks.push(FnBlock::Verbatim(std::mem::take(&mut current_verbatim)));
1644                            }
1645                            current_para.push(s.clone());
1646                        }
1647                        FnLineType::Verbatim(s, indent) => {
1648                            if !current_para.is_empty() {
1649                                blocks.push(FnBlock::Paragraph(std::mem::take(&mut current_para)));
1650                            }
1651                            current_verbatim.push((s.clone(), *indent));
1652                        }
1653                        FnLineType::Empty => {
1654                            if !current_para.is_empty() {
1655                                blocks.push(FnBlock::Paragraph(std::mem::take(&mut current_para)));
1656                            }
1657                            if !current_verbatim.is_empty() {
1658                                blocks.push(FnBlock::Verbatim(std::mem::take(&mut current_verbatim)));
1659                            }
1660                        }
1661                    }
1662                }
1663                if !current_para.is_empty() {
1664                    blocks.push(FnBlock::Paragraph(current_para));
1665                }
1666                if !current_verbatim.is_empty() {
1667                    blocks.push(FnBlock::Verbatim(current_verbatim));
1668                }
1669
1670                // --- Reflow paragraphs and reconstruct ---
1671                let prefix_display_width = prefix.chars().count() + 1; // +1 for space
1672                let reflow_line_length = if config.line_length.is_unlimited() {
1673                    usize::MAX
1674                } else {
1675                    config
1676                        .line_length
1677                        .get()
1678                        .saturating_sub(FN_INDENT.max(prefix_display_width))
1679                        .max(20)
1680                };
1681                let reflow_options = crate::utils::text_reflow::ReflowOptions {
1682                    line_length: reflow_line_length,
1683                    break_on_sentences: true,
1684                    preserve_breaks: false,
1685                    sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1686                    semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
1687                    abbreviations: config.abbreviations_for_reflow(),
1688                    length_mode: self.reflow_length_mode(),
1689                    attr_lists: ctx.flavor.supports_attr_lists(),
1690                    myst_roles: ctx.flavor.supports_myst_roles(),
1691                    require_sentence_capital: config.require_sentence_capital,
1692                    max_list_continuation_indent: None,
1693                };
1694
1695                let indent_str = " ".repeat(FN_INDENT);
1696                let mut result_lines: Vec<String> = Vec::new();
1697                let mut is_first_block = true;
1698
1699                for block in &blocks {
1700                    match block {
1701                        FnBlock::Paragraph(para_lines) => {
1702                            let paragraph_text = para_lines.join(" ");
1703                            let paragraph_text = paragraph_text.trim();
1704                            if paragraph_text.is_empty() {
1705                                continue;
1706                            }
1707
1708                            let reflowed = crate::utils::text_reflow::reflow_line(paragraph_text, &reflow_options);
1709                            if reflowed.is_empty() {
1710                                continue;
1711                            }
1712
1713                            // Blank line separator between blocks
1714                            if !result_lines.is_empty() {
1715                                result_lines.push(String::new());
1716                            }
1717
1718                            for (idx, rline) in reflowed.iter().enumerate() {
1719                                if is_first_block && idx == 0 {
1720                                    result_lines.push(format!("{prefix} {rline}"));
1721                                } else {
1722                                    result_lines.push(format!("{indent_str}{rline}"));
1723                                }
1724                            }
1725                            is_first_block = false;
1726                        }
1727                        FnBlock::Verbatim(verb_lines) => {
1728                            // Blank line separator between blocks
1729                            if !result_lines.is_empty() {
1730                                result_lines.push(String::new());
1731                            }
1732
1733                            if is_first_block {
1734                                // Verbatim as first block in a deferred-body footnote
1735                                if deferred_body {
1736                                    result_lines.push(prefix.to_string());
1737                                }
1738                                is_first_block = false;
1739                            }
1740                            for (content, _orig_indent) in verb_lines {
1741                                result_lines.push(format!("{indent_str}{content}"));
1742                            }
1743                        }
1744                    }
1745                }
1746
1747                // If nothing was produced, skip
1748                if result_lines.is_empty() {
1749                    continue;
1750                }
1751
1752                let reflowed_text = result_lines.join(line_ending);
1753
1754                // Calculate byte range using last_consumed
1755                let start_range = line_index.whole_line_range(footnote_start + 1);
1756                let end_range = if last_consumed == lines.len() - 1 && !ctx.content.ends_with('\n') {
1757                    line_index.line_text_range(last_consumed + 1, 1, lines[last_consumed].len() + 1)
1758                } else {
1759                    line_index.whole_line_range(last_consumed + 1)
1760                };
1761                let byte_range = start_range.start..end_range.end;
1762
1763                let replacement = if last_consumed < lines.len() - 1 || ctx.content.ends_with('\n') {
1764                    format!("{reflowed_text}{line_ending}")
1765                } else {
1766                    reflowed_text
1767                };
1768
1769                let original_text = &ctx.content[byte_range.clone()];
1770                let max_length = (footnote_start..=last_consumed)
1771                    .map(|idx| self.calculate_effective_length(lines[idx]))
1772                    .max()
1773                    .unwrap_or(0);
1774                let line_limit = if config.line_length.is_unlimited() {
1775                    usize::MAX
1776                } else {
1777                    config.line_length.get()
1778                };
1779                if original_text != replacement && max_length > line_limit {
1780                    warnings.push(LintWarning {
1781                        rule_name: Some(self.name().to_string()),
1782                        message: format!(
1783                            "Line length {} exceeds {} characters",
1784                            max_length,
1785                            config.line_length.get()
1786                        ),
1787                        line: footnote_start + 1,
1788                        column: 1,
1789                        end_line: last_consumed + 1,
1790                        end_column: lines[last_consumed].chars().count() + 1,
1791                        severity: Severity::Warning,
1792                        fix: Some(crate::rule::Fix::new(byte_range, replacement)),
1793                    });
1794                }
1795                continue;
1796            }
1797
1798            // Handle MkDocs container content (admonitions and tabs) with indent-preserving reflow
1799            if ctx
1800                .line_info(line_num)
1801                .is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container)
1802            {
1803                // Skip admonition/tab marker lines — only reflow their indented content
1804                let current_line = lines[i];
1805                if mkdocs_admonitions::is_admonition_start(current_line) || mkdocs_tabs::is_tab_marker(current_line) {
1806                    i += 1;
1807                    continue;
1808                }
1809
1810                let container_start = i;
1811
1812                // Detect the actual indent level from the first content line
1813                // (supports nested admonitions with 8+ spaces)
1814                let first_line = lines[i];
1815                let base_indent_len = first_line.len() - first_line.trim_start().len();
1816                let base_indent: String = " ".repeat(base_indent_len);
1817
1818                // Collect consecutive MkDocs container paragraph lines
1819                let mut container_lines: Vec<&str> = Vec::new();
1820                while i < lines.len() {
1821                    let current_line_num = i + 1;
1822                    let line_info = ctx.line_info(current_line_num);
1823
1824                    // Stop if we leave the MkDocs container
1825                    if !line_info.is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container) {
1826                        break;
1827                    }
1828
1829                    let line = lines[i];
1830
1831                    // Stop at paragraph boundaries within the container
1832                    if line.trim().is_empty() {
1833                        break;
1834                    }
1835
1836                    // Skip list items, code blocks, headings, HTML-only lines within containers
1837                    if is_list_item(line.trim())
1838                        || line.trim().starts_with("```")
1839                        || line.trim().starts_with("~~~")
1840                        || line.trim().starts_with('#')
1841                        || is_html_only_line(line)
1842                    {
1843                        break;
1844                    }
1845
1846                    container_lines.push(line);
1847                    i += 1;
1848                }
1849
1850                if container_lines.is_empty() {
1851                    // Must advance i to avoid infinite loop when we encounter
1852                    // non-paragraph content (code block, list, heading, empty line)
1853                    // at the start of an MkDocs container
1854                    i += 1;
1855                    continue;
1856                }
1857
1858                // Strip the base indent from each line and join for reflow
1859                let stripped_lines: Vec<&str> = container_lines
1860                    .iter()
1861                    .map(|line| {
1862                        if line.starts_with(&base_indent) {
1863                            &line[base_indent_len..]
1864                        } else {
1865                            line.trim_start()
1866                        }
1867                    })
1868                    .collect();
1869                let paragraph_text = stripped_lines.join(" ");
1870
1871                // Check if reflow is needed
1872                let needs_reflow = match config.reflow_mode {
1873                    ReflowMode::Normalize => self.normalize_mode_needs_reflow(container_lines.iter().copied(), config),
1874                    ReflowMode::SentencePerLine => {
1875                        let sentences = split_into_sentences(&paragraph_text);
1876                        sentences.len() > 1 || container_lines.len() > 1
1877                    }
1878                    ReflowMode::SemanticLineBreaks => {
1879                        let sentences = split_into_sentences(&paragraph_text);
1880                        sentences.len() > 1
1881                            || container_lines.len() > 1
1882                            || container_lines
1883                                .iter()
1884                                .any(|line| self.calculate_effective_length(line) > config.line_length.get())
1885                    }
1886                    ReflowMode::Default => container_lines
1887                        .iter()
1888                        .any(|line| self.calculate_effective_length(line) > config.line_length.get()),
1889                };
1890
1891                if !needs_reflow {
1892                    continue;
1893                }
1894
1895                // Calculate byte range for this container paragraph
1896                let start_range = line_index.whole_line_range(container_start + 1);
1897                let end_line = container_start + container_lines.len() - 1;
1898                let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1899                    line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
1900                } else {
1901                    line_index.whole_line_range(end_line + 1)
1902                };
1903                let byte_range = start_range.start..end_range.end;
1904
1905                // Reflow with adjusted line length (accounting for the 4-space indent)
1906                let reflow_line_length = if config.line_length.is_unlimited() {
1907                    usize::MAX
1908                } else {
1909                    config.line_length.get().saturating_sub(base_indent_len).max(1)
1910                };
1911                let reflow_options = crate::utils::text_reflow::ReflowOptions {
1912                    line_length: reflow_line_length,
1913                    break_on_sentences: true,
1914                    preserve_breaks: false,
1915                    sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1916                    semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
1917                    abbreviations: config.abbreviations_for_reflow(),
1918                    length_mode: self.reflow_length_mode(),
1919                    attr_lists: ctx.flavor.supports_attr_lists(),
1920                    myst_roles: ctx.flavor.supports_myst_roles(),
1921                    require_sentence_capital: config.require_sentence_capital,
1922                    max_list_continuation_indent: if ctx.flavor.requires_strict_list_indent() {
1923                        Some(4)
1924                    } else {
1925                        None
1926                    },
1927                };
1928                let reflowed = crate::utils::text_reflow::reflow_line(&paragraph_text, &reflow_options);
1929
1930                // Re-add the 4-space indent to each reflowed line
1931                let reflowed_with_indent: Vec<String> =
1932                    reflowed.iter().map(|line| format!("{base_indent}{line}")).collect();
1933                let reflowed_text = reflowed_with_indent.join(line_ending);
1934
1935                // Preserve trailing newline
1936                let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
1937                    format!("{reflowed_text}{line_ending}")
1938                } else {
1939                    reflowed_text
1940                };
1941
1942                // Only generate a warning if the replacement is different
1943                let original_text = &ctx.content[byte_range.clone()];
1944                if original_text != replacement {
1945                    warnings.push(LintWarning {
1946                        rule_name: Some(self.name().to_string()),
1947                        message: format!(
1948                            "Line length {} exceeds {} characters (in MkDocs container)",
1949                            container_lines.iter().map(|l| l.len()).max().unwrap_or(0),
1950                            config.line_length.get()
1951                        ),
1952                        line: container_start + 1,
1953                        column: 1,
1954                        end_line: end_line + 1,
1955                        end_column: lines[end_line].chars().count() + 1,
1956                        severity: Severity::Warning,
1957                        fix: Some(crate::rule::Fix::new(byte_range, replacement)),
1958                    });
1959                }
1960                continue;
1961            }
1962
1963            // Helper function to detect semantic line markers
1964            let is_semantic_line = |content: &str| -> bool {
1965                let trimmed = content.trim_start();
1966                let semantic_markers = [
1967                    "NOTE:",
1968                    "WARNING:",
1969                    "IMPORTANT:",
1970                    "CAUTION:",
1971                    "TIP:",
1972                    "DANGER:",
1973                    "HINT:",
1974                    "INFO:",
1975                ];
1976                semantic_markers.iter().any(|marker| trimmed.starts_with(marker))
1977            };
1978
1979            // Helper function to detect fence markers (opening or closing)
1980            let is_fence_marker = |content: &str| -> bool {
1981                let trimmed = content.trim_start();
1982                trimmed.starts_with("```") || trimmed.starts_with("~~~")
1983            };
1984
1985            // Check if this is a list item - handle it specially
1986            let trimmed = lines[i].trim();
1987            if is_list_item(trimmed) {
1988                // Collect the entire list item including continuation lines
1989                let list_start = i;
1990                let (marker, first_content) = extract_list_marker_and_content(lines[i]);
1991                let marker_len = marker.len();
1992
1993                // Checkbox ([ ]/[x]/[X]) is inline content, not part of the list marker.
1994                // Use the base bullet/number marker width for continuation recognition
1995                // so that continuation lines at 2+ spaces are collected for "- [ ] " items.
1996                let base_marker_len = if marker.contains("[ ] ") || marker.contains("[x] ") || marker.contains("[X] ") {
1997                    marker.find('[').unwrap_or(marker_len)
1998                } else {
1999                    marker_len
2000                };
2001
2002                // MkDocs flavor requires at least 4 spaces for list continuation
2003                // after a blank line (multi-paragraph list items). For non-blank
2004                // continuation (lines directly following the marker line), use
2005                // the natural marker width so that 2-space indent is recognized.
2006                let item_indent = ctx.lines[i].indent;
2007                let min_continuation_indent = if ctx.flavor.requires_strict_list_indent() {
2008                    // Use 4-space relative indent from the list item's nesting level
2009                    item_indent + (base_marker_len - item_indent).max(4)
2010                } else {
2011                    marker_len
2012                };
2013                let content_continuation_indent = base_marker_len;
2014
2015                // Track lines and their types (content, code block, fence, nested list)
2016                #[derive(Clone)]
2017                enum LineType {
2018                    Content(String),
2019                    CodeBlock(String, usize),         // content and original indent
2020                    SemanticLine(String), // Lines starting with NOTE:, WARNING:, etc that should stay separate
2021                    SnippetLine(String),  // MkDocs Snippets delimiters (-8<-) that must stay on their own line
2022                    DivMarker(String),    // Quarto/Pandoc div markers (::: opening or closing)
2023                    AdmonitionHeader(String, usize), // header text (e.g. "!!! note") and original indent
2024                    AdmonitionContent(String, usize), // body content text and original indent
2025                    Table(String, usize), // GFM table row, preserved verbatim with original indent
2026                    Empty,
2027                }
2028
2029                let mut list_item_lines: Vec<LineType> = vec![LineType::Content(first_content)];
2030                // Set when collection stops at a nested list item or a nested
2031                // blockquote that belongs to this item. Such structure is reflowed
2032                // independently and is therefore absent from `list_item_lines`/`blocks`,
2033                // but it still keeps the emitted item spanning multiple physical lines,
2034                // which the MD030 multi-line spacing decision below must account for.
2035                let mut has_trailing_nested_structure = false;
2036                i += 1;
2037
2038                // Collect continuation lines using ctx.lines for metadata
2039                while i < lines.len() {
2040                    let line_info = &ctx.lines[i];
2041
2042                    // Use pre-computed is_blank from ctx
2043                    if line_info.is_blank {
2044                        // Empty line - check if next line is indented (part of list item)
2045                        if i + 1 < lines.len() {
2046                            let next_info = &ctx.lines[i + 1];
2047
2048                            // Check if next line is indented enough to be continuation
2049                            if !next_info.is_blank && next_info.indent >= min_continuation_indent {
2050                                // This blank line is between paragraphs/blocks in the list item
2051                                list_item_lines.push(LineType::Empty);
2052                                i += 1;
2053                                continue;
2054                            }
2055                        }
2056                        // No indented line after blank, end of list item
2057                        break;
2058                    }
2059
2060                    // Use pre-computed indent from ctx
2061                    let indent = line_info.indent;
2062
2063                    // Valid continuation must be indented at least content_continuation_indent.
2064                    // For non-blank continuation, use marker_len (e.g. 2 for "- ").
2065                    // MkDocs strict 4-space requirement applies only after blank lines.
2066                    if indent >= content_continuation_indent {
2067                        let trimmed = line_info.content(ctx.content).trim();
2068
2069                        // Check for MkDocs admonition lines inside list items BEFORE
2070                        // checking in_code_block. Lines inside code blocks within
2071                        // admonitions have both in_admonition and in_code_block set;
2072                        // admonition membership takes priority so the entire admonition
2073                        // structure (including embedded code blocks) is preserved.
2074                        if line_info.in_admonition {
2075                            let raw_content = line_info.content(ctx.content);
2076                            if mkdocs_admonitions::is_admonition_start(raw_content) {
2077                                let header_text = raw_content[indent..].trim_end().to_string();
2078                                list_item_lines.push(LineType::AdmonitionHeader(header_text, indent));
2079                            } else {
2080                                let body_text = raw_content[indent..].trim_end().to_string();
2081                                list_item_lines.push(LineType::AdmonitionContent(body_text, indent));
2082                            }
2083                            i += 1;
2084                            continue;
2085                        }
2086
2087                        // Use pre-computed in_code_block from ctx
2088                        if line_info.in_code_block {
2089                            list_item_lines.push(LineType::CodeBlock(
2090                                line_info.content(ctx.content)[indent..].to_string(),
2091                                indent,
2092                            ));
2093                            i += 1;
2094                            continue;
2095                        }
2096
2097                        // A blockquote nested inside the list item is reflowed by the
2098                        // blockquote-aware path (it preserves the `>` prefix, including the
2099                        // list indent), not as list-item prose. Collecting it as Content
2100                        // would strip the markers and reflow `>` as words, collapsing the
2101                        // blank `>` line and dropping `>` from wrapped continuations. End
2102                        // the item here so the outer loop routes the blockquote line to
2103                        // generate_blockquote_paragraph_fix. Uncollect a pending blank so
2104                        // the separator between the list prose and the blockquote survives.
2105                        if line_info.blockquote.is_some() {
2106                            has_trailing_nested_structure = true;
2107                            if matches!(list_item_lines.last(), Some(LineType::Empty)) {
2108                                list_item_lines.pop();
2109                                i -= 1;
2110                            }
2111                            break;
2112                        }
2113
2114                        // Check if this is a SIBLING list item (breaks parent)
2115                        // Nested lists are indented >= marker_len and are PART of the parent item
2116                        // Siblings are at indent < marker_len (at or before parent marker)
2117                        if is_list_item(trimmed) && indent < marker_len {
2118                            // This is a sibling item at same or higher level - end parent item
2119                            break;
2120                        }
2121
2122                        // Nested list items are always processed independently
2123                        // by the outer loop, so break when we encounter one.
2124                        // If a blank line was collected before this, uncollect it
2125                        // so the outer loop preserves the blank between parent and nested.
2126                        if is_list_item(trimmed) && indent >= marker_len {
2127                            has_trailing_nested_structure = true;
2128                            if matches!(list_item_lines.last(), Some(LineType::Empty)) {
2129                                list_item_lines.pop();
2130                                i -= 1;
2131                            }
2132                            break;
2133                        }
2134
2135                        // Normal continuation vs indented code block.
2136                        // Use min_continuation_indent for the threshold since
2137                        // code blocks start 4 spaces beyond the expected content
2138                        // level (which is min_continuation_indent for MkDocs).
2139                        if indent <= min_continuation_indent + 3 {
2140                            // Extract content (remove indentation and trailing whitespace)
2141                            // Preserve hard breaks (2 trailing spaces) while removing excessive whitespace
2142                            // See: https://github.com/rvben/rumdl/issues/76
2143                            let content = trim_preserving_hard_break(&line_info.content(ctx.content)[indent..]);
2144
2145                            // Check if this is a div marker (::: opening or closing)
2146                            // These must be preserved on their own line, not merged into paragraphs
2147                            if line_info.is_div_marker {
2148                                list_item_lines.push(LineType::DivMarker(content));
2149                            }
2150                            // Check if this is a fence marker (opening or closing)
2151                            // These should be treated as code block lines, not paragraph content
2152                            else if is_fence_marker(&content) {
2153                                list_item_lines.push(LineType::CodeBlock(content, indent));
2154                            }
2155                            // Check if this is a semantic line (NOTE:, WARNING:, etc.)
2156                            else if is_semantic_line(&content) {
2157                                list_item_lines.push(LineType::SemanticLine(content));
2158                            }
2159                            // Check if this is a snippet block delimiter (-8<- or --8<--)
2160                            // These must be preserved on their own lines for MkDocs Snippets extension
2161                            else if is_snippet_block_delimiter(&content) {
2162                                list_item_lines.push(LineType::SnippetLine(content));
2163                            }
2164                            // Check if this is a GFM table row. Tables nested inside list
2165                            // items must be preserved verbatim — joining them with prose
2166                            // breaks the column structure.
2167                            //
2168                            // `is_potential_table_row` is intentionally permissive at the
2169                            // row level: any line with `|` and 2+ cells qualifies. To avoid
2170                            // misclassifying prose continuation lines that contain a literal
2171                            // pipe (e.g. "use grep | sort to ..."), require one of:
2172                            //   - the row is pipe-bordered (`| ... |`), the canonical form
2173                            //     for tables nested in lists; or
2174                            //   - the next line is a delimiter row (this is a header); or
2175                            //   - the previous classified line was already a Table (this is
2176                            //     a continuation row).
2177                            else if TableUtils::is_potential_table_row(&content) && {
2178                                let pipe_bordered = content.trim().starts_with('|') && content.trim().ends_with('|');
2179                                let next_is_delim = ctx
2180                                    .lines
2181                                    .get(i + 1)
2182                                    .is_some_and(|next| TableUtils::is_delimiter_row(next.content(ctx.content)));
2183                                let prev_was_table = matches!(list_item_lines.last(), Some(LineType::Table(..)));
2184                                pipe_bordered || next_is_delim || prev_was_table
2185                            } {
2186                                list_item_lines.push(LineType::Table(content, indent));
2187                            } else {
2188                                list_item_lines.push(LineType::Content(content));
2189                            }
2190                            i += 1;
2191                        } else {
2192                            // indent >= min_continuation_indent + 4: indented code block
2193                            list_item_lines.push(LineType::CodeBlock(
2194                                line_info.content(ctx.content)[indent..].to_string(),
2195                                indent,
2196                            ));
2197                            i += 1;
2198                        }
2199                    } else {
2200                        // Not indented enough, end of list item
2201                        break;
2202                    }
2203                }
2204
2205                // Determine the output continuation indent.
2206                // Normalize/Default modes canonicalize to min_continuation_indent
2207                // (fixing over-indented continuation). Semantic/SentencePerLine
2208                // modes preserve the user's actual indent since they only fix
2209                // line breaking, not indentation.
2210                let indent_size = match config.reflow_mode {
2211                    ReflowMode::SemanticLineBreaks | ReflowMode::SentencePerLine => {
2212                        // Find indent of the first plain text continuation line,
2213                        // skipping the marker line (index 0), nested list items,
2214                        // code blocks, and blank lines.
2215                        list_item_lines
2216                            .iter()
2217                            .enumerate()
2218                            .skip(1)
2219                            .find_map(|(k, lt)| {
2220                                if matches!(lt, LineType::Content(_)) {
2221                                    Some(ctx.lines[list_start + k].indent)
2222                                } else {
2223                                    None
2224                                }
2225                            })
2226                            .unwrap_or(min_continuation_indent)
2227                    }
2228                    _ => min_continuation_indent,
2229                };
2230                // For checkbox items in mkdocs flavor, enforce minimum indent so
2231                // continuation lines use the structural list indent (4), not the
2232                // content-aligned indent (6) which Python-Markdown doesn't support
2233                let has_checkbox = base_marker_len < marker_len;
2234                let indent_size = if has_checkbox && ctx.flavor.requires_strict_list_indent() {
2235                    indent_size.max(min_continuation_indent)
2236                } else {
2237                    indent_size
2238                };
2239
2240                // Split list_item_lines into blocks (paragraphs, code blocks, nested lists, semantic lines, and HTML blocks)
2241                let mut builder = BlockBuilder::new();
2242                for line in &list_item_lines {
2243                    match line {
2244                        LineType::Empty => builder.feed_blank_line(),
2245                        LineType::Content(content) => builder.feed_content(content),
2246                        LineType::CodeBlock(content, indent) => builder.feed_code_line(content, *indent),
2247                        LineType::SemanticLine(content) => builder.feed_semantic_line(content),
2248                        LineType::SnippetLine(content) => builder.feed_snippet_line(content),
2249                        LineType::DivMarker(content) => builder.feed_div_marker(content),
2250                        LineType::AdmonitionHeader(header_text, indent) => {
2251                            builder.feed_admonition_header(header_text, *indent)
2252                        }
2253                        LineType::AdmonitionContent(content, indent) => {
2254                            builder.feed_admonition_content(content, *indent)
2255                        }
2256                        LineType::Table(content, indent) => builder.feed_table_line(content, *indent),
2257                    }
2258                }
2259                let blocks = builder.finalize();
2260
2261                // Helper: check if a line (raw source or stripped content) is exempt
2262                // from line-length checks. Link reference definitions are always exempt;
2263                // standalone link/image lines are exempt when strict mode is off.
2264                // Also checks content after stripping list markers, since list item
2265                // continuation lines may contain link ref defs.
2266                let is_exempt_line = |raw_line: &str| -> bool {
2267                    let trimmed = raw_line.trim();
2268                    // Link reference definitions: always exempt
2269                    if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
2270                        return true;
2271                    }
2272                    // Also check after stripping list markers (for list item content)
2273                    if is_list_item(trimmed) {
2274                        let (_, content) = extract_list_marker_and_content(trimmed);
2275                        let content_trimmed = content.trim();
2276                        if content_trimmed.starts_with('[')
2277                            && content_trimmed.contains("]:")
2278                            && LINK_REF_PATTERN.is_match(content_trimmed)
2279                        {
2280                            return true;
2281                        }
2282                    }
2283                    // Standalone link/image lines: exempt when not strict
2284                    if !config.strict && is_standalone_link_or_image_line(raw_line) {
2285                        return true;
2286                    }
2287                    // HTML-only lines: exempt when not strict
2288                    if !config.strict && is_html_only_line(raw_line) {
2289                        return true;
2290                    }
2291                    false
2292                };
2293
2294                // Check if reflowing is needed (only for content paragraphs, not code blocks or nested lists)
2295                // Exclude link reference definitions and standalone link lines from content
2296                // so they don't pollute combined_content or trigger false reflow.
2297                let content_lines: Vec<String> = list_item_lines
2298                    .iter()
2299                    .filter_map(|line| {
2300                        if let LineType::Content(s) = line {
2301                            if is_exempt_line(s) {
2302                                return None;
2303                            }
2304                            Some(s.clone())
2305                        } else {
2306                            None
2307                        }
2308                    })
2309                    .collect();
2310
2311                // Check if we need to reflow this list item
2312                // We check the combined content to see if it exceeds length limits
2313                let combined_content = content_lines.join(" ").trim().to_string();
2314
2315                // Helper to check if we should reflow in normalize mode
2316                let should_normalize = || {
2317                    // Don't normalize if the list item only contains nested lists, code blocks, or semantic lines
2318                    // DO normalize if it has plain text content that spans multiple lines
2319                    let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
2320                    let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine(_)));
2321                    let has_snippet_lines = blocks.iter().any(|b| matches!(b, Block::SnippetLine(_)));
2322                    let has_div_markers = blocks.iter().any(|b| matches!(b, Block::DivMarker(_)));
2323                    let has_admonitions = blocks.iter().any(|b| matches!(b, Block::Admonition { .. }));
2324                    let has_tables = blocks.iter().any(|b| matches!(b, Block::Table { .. }));
2325                    let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph(_)));
2326
2327                    // If we have structural blocks but no paragraphs, don't normalize
2328                    if (has_code_blocks
2329                        || has_semantic_lines
2330                        || has_snippet_lines
2331                        || has_div_markers
2332                        || has_admonitions
2333                        || has_tables)
2334                        && !has_paragraphs
2335                    {
2336                        return false;
2337                    }
2338
2339                    // If we have paragraphs, check if they span multiple lines or there are multiple blocks
2340                    if has_paragraphs {
2341                        // Count only paragraphs that contain at least one non-exempt line.
2342                        // Paragraphs consisting entirely of link ref defs or standalone links
2343                        // should not trigger normalization.
2344                        let paragraph_count = blocks
2345                            .iter()
2346                            .filter(|b| {
2347                                if let Block::Paragraph(para_lines) = b {
2348                                    !para_lines.iter().all(|line| is_exempt_line(line))
2349                                } else {
2350                                    false
2351                                }
2352                            })
2353                            .count();
2354                        if paragraph_count > 1 {
2355                            // Multiple non-exempt paragraph blocks should be normalized
2356                            return true;
2357                        }
2358
2359                        // Single paragraph block: normalize if it has multiple content lines
2360                        if content_lines.len() > 1 {
2361                            return true;
2362                        }
2363                    }
2364
2365                    false
2366                };
2367
2368                // Integrate MD030 list-marker spacing (and MD007's text-aligned
2369                // continuation). In Default/Normalize modes — the modes that already
2370                // canonicalize spacing/indent — derive the number of spaces after the
2371                // marker from the configured MD030 values instead of forcing a single
2372                // space, then align continuation lines to the resulting content column.
2373                // Sentence/Semantic modes only adjust line breaks, so they keep the
2374                // marker spacing and indentation already present in the source.
2375                //
2376                // With default MD030 (a single space everywhere) the rebuilt marker is
2377                // byte-identical to the source marker, so this is a no-op and existing
2378                // behaviour is preserved; only a non-default MD030 changes the output.
2379                // The MkDocs flavor enforces a rigid structural indent (4 spaces,
2380                // capped via max_list_continuation_indent) that Python-Markdown
2381                // requires; leave its specialized handling untouched.
2382                let (marker, indent_size, code_indent_shift) =
2383                    if matches!(config.reflow_mode, ReflowMode::Default | ReflowMode::Normalize)
2384                        && !ctx.flavor.requires_strict_list_indent()
2385                        && let Some(li) = ctx.lines[list_start].list_item.as_deref()
2386                    {
2387                        let bullet_len = li.marker.len();
2388                        // The checkbox (e.g. `[ ] `) is content, not part of the list
2389                        // marker MD030 governs; carry it over verbatim after the spacing.
2390                        let checkbox_tail = marker[base_marker_len..].to_string();
2391                        // Shift this item right by its ancestors' cumulative marker
2392                        // widening so a nested item stays under its parent's (widened)
2393                        // content column. Zero for top-level items and for the whole
2394                        // tree under default MD030, where the source indent is preserved
2395                        // verbatim (byte-identical output).
2396                        let ancestor_shift = list_shift_stack.last().map_or(0isize, |&(_, shift)| shift);
2397                        let shifted_indent = (item_indent as isize + ancestor_shift).max(0) as usize;
2398                        let indent_prefix = if ancestor_shift == 0 {
2399                            marker[..item_indent].to_string()
2400                        } else {
2401                            " ".repeat(shifted_indent)
2402                        };
2403
2404                        // Decide single- vs multi-line spacing from the *rewritten* shape,
2405                        // not the source. A multi-line source is not enough: plain prose
2406                        // continuation collapses onto the marker line during reflow, so a
2407                        // two-line bullet that fits becomes a single physical line and must
2408                        // use MD030's single-line spacing (otherwise MD013 emits a result
2409                        // that MD030 immediately rewrites). The emitted item stays
2410                        // multi-line only when reflow cannot collapse it:
2411                        //   - the prose wraps past the line length, or
2412                        //   - a structural block remains (code, table, admonition, semantic
2413                        //     line, snippet, div marker, HTML) that is not joinable prose, or
2414                        //   - more than one paragraph remains (blank-separated), or
2415                        //   - a nested list/blockquote follows (reflowed independently, so it
2416                        //     is absent from `blocks` but still keeps the item multi-line).
2417                        // The wrap test uses the single-line content column so it is
2418                        // independent of the spacing we are about to choose (avoiding a
2419                        // circular result). `ol-align-column` ignores this flag entirely in
2420                        // expected_spaces(), so ordered lists are unaffected.
2421                        //
2422                        // This is the rewritten-shape counterpart of MD030's
2423                        // `is_multi_line_list_item` (which keys off the *source*). The two
2424                        // are related but technically distinct and intentionally separate;
2425                        // if the notion of "multi-line" changes in one, revisit the other.
2426                        let single_col = shifted_indent
2427                            + bullet_len
2428                            + self.list_spacing.expected_spaces(li.is_ordered, false, bullet_len)
2429                            + checkbox_tail.len();
2430                        let prose_wraps = !combined_content.is_empty()
2431                            && self
2432                                .calculate_effective_length(&format!("{}{combined_content}", " ".repeat(single_col)))
2433                                > config.line_length.effective_limit();
2434                        let has_structural_block = blocks.iter().any(|b| !matches!(b, Block::Paragraph(_)));
2435                        let multiple_paragraphs =
2436                            blocks.iter().filter(|b| matches!(b, Block::Paragraph(_))).count() > 1;
2437                        let is_multi =
2438                            prose_wraps || has_structural_block || multiple_paragraphs || has_trailing_nested_structure;
2439
2440                        let spaces = self.list_spacing.expected_spaces(li.is_ordered, is_multi, bullet_len);
2441                        let new_marker = format!("{indent_prefix}{}{}{checkbox_tail}", li.marker, " ".repeat(spaces));
2442                        let new_col = new_marker.chars().count();
2443                        let shift = new_col as isize - marker_len as isize;
2444                        (new_marker, new_col, shift)
2445                    } else {
2446                        (marker, indent_size, 0isize)
2447                    };
2448                let expected_indent = " ".repeat(indent_size);
2449
2450                let needs_reflow = match config.reflow_mode {
2451                    ReflowMode::Normalize => {
2452                        // Only reflow if:
2453                        // 1. Any non-exempt paragraph, when joined, exceeds the limit, OR
2454                        // 2. Any admonition content line exceeds the limit, OR
2455                        // 3. The list item should be normalized (has multi-line plain text)
2456                        let any_paragraph_exceeds = blocks.iter().any(|block| match block {
2457                            Block::Paragraph(para_lines) => {
2458                                if para_lines.iter().all(|line| is_exempt_line(line)) {
2459                                    return false;
2460                                }
2461                                let joined = para_lines.join(" ");
2462                                let with_marker = format!("{}{}", " ".repeat(indent_size), joined.trim());
2463                                self.calculate_effective_length(&with_marker) > config.line_length.get()
2464                            }
2465                            Block::Admonition {
2466                                content_lines,
2467                                header_indent,
2468                                ..
2469                            } => content_lines.iter().any(|(content, indent)| {
2470                                if content.is_empty() {
2471                                    return false;
2472                                }
2473                                let with_indent = format!("{}{}", " ".repeat(*indent.max(header_indent)), content);
2474                                self.calculate_effective_length(&with_indent) > config.line_length.get()
2475                            }),
2476                            _ => false,
2477                        });
2478                        if any_paragraph_exceeds {
2479                            true
2480                        } else {
2481                            should_normalize()
2482                        }
2483                    }
2484                    ReflowMode::SentencePerLine => {
2485                        // Check if list item has multiple sentences
2486                        let sentences = split_into_sentences(&combined_content);
2487                        sentences.len() > 1
2488                    }
2489                    ReflowMode::SemanticLineBreaks => {
2490                        let sentences = split_into_sentences(&combined_content);
2491                        sentences.len() > 1
2492                            || (list_start..i).any(|line_idx| {
2493                                let line = lines[line_idx];
2494                                let trimmed = line.trim();
2495                                if trimmed.is_empty() || is_exempt_line(line) {
2496                                    return false;
2497                                }
2498                                self.calculate_effective_length(line) > config.line_length.get()
2499                            })
2500                    }
2501                    ReflowMode::Default => {
2502                        // In default mode, only reflow if any individual non-exempt line exceeds limit
2503                        (list_start..i).any(|line_idx| {
2504                            let line = lines[line_idx];
2505                            let trimmed = line.trim();
2506                            // Skip blank lines and exempt lines
2507                            if trimmed.is_empty() || is_exempt_line(line) {
2508                                return false;
2509                            }
2510                            self.calculate_effective_length(line) > config.line_length.get()
2511                        })
2512                    }
2513                };
2514
2515                // Record this item's frame so its nested children inherit the shift.
2516                // Only a reflowed item's marker actually moves; an unreflowed one keeps
2517                // its source position and so contributes no shift to its children. The
2518                // source content column (`marker_len`) is the threshold that decides
2519                // which following lines are inside this item.
2520                list_shift_stack.push((marker_len, if needs_reflow { code_indent_shift } else { 0 }));
2521
2522                if needs_reflow {
2523                    let start_range = line_index.whole_line_range(list_start + 1);
2524                    let end_line = i - 1;
2525                    let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
2526                        line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
2527                    } else {
2528                        line_index.whole_line_range(end_line + 1)
2529                    };
2530                    let byte_range = start_range.start..end_range.end;
2531
2532                    // Reflow each block (paragraphs only, preserve code blocks)
2533                    // When line_length = 0 (no limit), use a very large value for reflow
2534                    let reflow_line_length = if config.line_length.is_unlimited() {
2535                        usize::MAX
2536                    } else {
2537                        config.line_length.get().saturating_sub(indent_size).max(1)
2538                    };
2539                    let reflow_options = crate::utils::text_reflow::ReflowOptions {
2540                        line_length: reflow_line_length,
2541                        break_on_sentences: true,
2542                        preserve_breaks: false,
2543                        sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
2544                        semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
2545                        abbreviations: config.abbreviations_for_reflow(),
2546                        length_mode: self.reflow_length_mode(),
2547                        attr_lists: ctx.flavor.supports_attr_lists(),
2548                        myst_roles: ctx.flavor.supports_myst_roles(),
2549                        require_sentence_capital: config.require_sentence_capital,
2550                        max_list_continuation_indent: if ctx.flavor.requires_strict_list_indent() {
2551                            Some(4)
2552                        } else {
2553                            None
2554                        },
2555                    };
2556
2557                    let mut result: Vec<String> = Vec::new();
2558                    let mut is_first_block = true;
2559
2560                    for (block_idx, block) in blocks.iter().enumerate() {
2561                        match block {
2562                            Block::Paragraph(para_lines) => {
2563                                // If every line in this paragraph is exempt (link ref defs,
2564                                // standalone links), preserve the paragraph verbatim instead
2565                                // of reflowing it. Reflowing would corrupt link ref defs.
2566                                let all_exempt = para_lines.iter().all(|line| is_exempt_line(line));
2567
2568                                if all_exempt {
2569                                    for (idx, line) in para_lines.iter().enumerate() {
2570                                        if is_first_block && idx == 0 {
2571                                            result.push(format!("{marker}{line}"));
2572                                            is_first_block = false;
2573                                        } else {
2574                                            result.push(format!("{expected_indent}{line}"));
2575                                        }
2576                                    }
2577                                } else {
2578                                    // Split the paragraph into segments at hard break boundaries
2579                                    // Each segment can be reflowed independently
2580                                    let segments = split_into_segments(para_lines);
2581
2582                                    for (segment_idx, segment) in segments.iter().enumerate() {
2583                                        // Check if this segment ends with a hard break and what type
2584                                        let hard_break_type = segment.last().and_then(|line| {
2585                                            let line = line.strip_suffix('\r').unwrap_or(line);
2586                                            if line.ends_with('\\') {
2587                                                Some("\\")
2588                                            } else if line.ends_with("  ") {
2589                                                Some("  ")
2590                                            } else {
2591                                                None
2592                                            }
2593                                        });
2594
2595                                        // Join and reflow the segment (removing the hard break marker for processing)
2596                                        let segment_for_reflow: Vec<String> = segment
2597                                            .iter()
2598                                            .map(|line| {
2599                                                // Strip hard break marker (2 spaces or backslash) for reflow processing
2600                                                if line.ends_with('\\') {
2601                                                    line[..line.len() - 1].trim_end().to_string()
2602                                                } else if line.ends_with("  ") {
2603                                                    line[..line.len() - 2].trim_end().to_string()
2604                                                } else {
2605                                                    line.clone()
2606                                                }
2607                                            })
2608                                            .collect();
2609
2610                                        let segment_text = segment_for_reflow.join(" ").trim().to_string();
2611                                        if !segment_text.is_empty() {
2612                                            let reflowed =
2613                                                crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
2614
2615                                            if is_first_block && segment_idx == 0 {
2616                                                // First segment of first block starts with marker
2617                                                result.push(format!("{marker}{}", reflowed[0]));
2618                                                for line in reflowed.iter().skip(1) {
2619                                                    result.push(format!("{expected_indent}{line}"));
2620                                                }
2621                                                is_first_block = false;
2622                                            } else {
2623                                                // Subsequent segments
2624                                                for line in reflowed {
2625                                                    result.push(format!("{expected_indent}{line}"));
2626                                                }
2627                                            }
2628
2629                                            // If this segment had a hard break, add it back to the last line
2630                                            // Preserve the original hard break format (backslash or two spaces)
2631                                            if let Some(break_marker) = hard_break_type
2632                                                && let Some(last_line) = result.last_mut()
2633                                            {
2634                                                last_line.push_str(break_marker);
2635                                            }
2636                                        }
2637                                    }
2638                                }
2639
2640                                // Add blank line after paragraph block if there's a next block.
2641                                // Check if next block is a code block that doesn't want a preceding blank.
2642                                // Also don't add blank lines before snippet lines (they should stay tight).
2643                                // Only add if not already ending with one (avoids double blanks).
2644                                if block_idx < blocks.len() - 1 {
2645                                    let next_block = &blocks[block_idx + 1];
2646                                    let should_add_blank = match next_block {
2647                                        Block::Code {
2648                                            has_preceding_blank, ..
2649                                        } => *has_preceding_blank,
2650                                        Block::Table {
2651                                            has_preceding_blank, ..
2652                                        } => *has_preceding_blank,
2653                                        Block::SnippetLine(_) | Block::DivMarker(_) => false,
2654                                        _ => true, // For all other blocks, add blank line
2655                                    };
2656                                    if should_add_blank && result.last().is_none_or(|s: &String| !s.is_empty()) {
2657                                        result.push(String::new());
2658                                    }
2659                                }
2660                            }
2661                            Block::Code {
2662                                lines: code_lines,
2663                                has_preceding_blank: _,
2664                            } => {
2665                                // Preserve code blocks as-is with original indentation
2666                                // NOTE: Blank line before code block is handled by the previous block
2667                                // (see paragraph block's logic above)
2668
2669                                for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
2670                                    if is_first_block && idx == 0 {
2671                                        // First line of first block gets marker
2672                                        result.push(format!(
2673                                            "{marker}{}",
2674                                            " ".repeat(orig_indent - marker_len) + content
2675                                        ));
2676                                        is_first_block = false;
2677                                    } else if content.is_empty() {
2678                                        result.push(String::new());
2679                                    } else {
2680                                        // Shift nested code with the marker so it stays
2681                                        // aligned under content when MD030 widens spacing.
2682                                        result.push(format!(
2683                                            "{}{}",
2684                                            " ".repeat((*orig_indent as isize + code_indent_shift).max(0) as usize),
2685                                            content
2686                                        ));
2687                                    }
2688                                }
2689                            }
2690                            Block::SemanticLine(content) => {
2691                                // Preserve semantic lines (NOTE:, WARNING:, etc.) as-is on their own line.
2692                                // Only add blank before if not already ending with one.
2693                                if !is_first_block && result.last().is_none_or(|s: &String| !s.is_empty()) {
2694                                    result.push(String::new());
2695                                }
2696
2697                                if is_first_block {
2698                                    // First block starts with marker
2699                                    result.push(format!("{marker}{content}"));
2700                                    is_first_block = false;
2701                                } else {
2702                                    // Subsequent blocks use expected indent
2703                                    result.push(format!("{expected_indent}{content}"));
2704                                }
2705
2706                                // Add blank line after semantic line if there's a next block.
2707                                // Only add if not already ending with one.
2708                                if block_idx < blocks.len() - 1 {
2709                                    let next_block = &blocks[block_idx + 1];
2710                                    let should_add_blank = match next_block {
2711                                        Block::Code {
2712                                            has_preceding_blank, ..
2713                                        } => *has_preceding_blank,
2714                                        Block::Table {
2715                                            has_preceding_blank, ..
2716                                        } => *has_preceding_blank,
2717                                        Block::SnippetLine(_) | Block::DivMarker(_) => false,
2718                                        _ => true, // For all other blocks, add blank line
2719                                    };
2720                                    if should_add_blank && result.last().is_none_or(|s: &String| !s.is_empty()) {
2721                                        result.push(String::new());
2722                                    }
2723                                }
2724                            }
2725                            Block::SnippetLine(content) => {
2726                                // Preserve snippet delimiters (-8<-) as-is on their own line
2727                                // Unlike semantic lines, snippet lines don't add extra blank lines
2728                                if is_first_block {
2729                                    // First block starts with marker
2730                                    result.push(format!("{marker}{content}"));
2731                                    is_first_block = false;
2732                                } else {
2733                                    // Subsequent blocks use expected indent
2734                                    result.push(format!("{expected_indent}{content}"));
2735                                }
2736                                // No blank lines added before or after snippet delimiters
2737                            }
2738                            Block::DivMarker(content) => {
2739                                // Preserve div markers (::: opening or closing) as-is on their own line
2740                                if is_first_block {
2741                                    result.push(format!("{marker}{content}"));
2742                                    is_first_block = false;
2743                                } else {
2744                                    result.push(format!("{expected_indent}{content}"));
2745                                }
2746                            }
2747                            Block::Html {
2748                                lines: html_lines,
2749                                has_preceding_blank: _,
2750                            } => {
2751                                // Preserve HTML blocks exactly as-is with original indentation
2752                                // NOTE: Blank line before HTML block is handled by the previous block
2753
2754                                for (idx, line) in html_lines.iter().enumerate() {
2755                                    if is_first_block && idx == 0 {
2756                                        // First line of first block gets marker
2757                                        result.push(format!("{marker}{line}"));
2758                                        is_first_block = false;
2759                                    } else if line.is_empty() {
2760                                        // Preserve blank lines inside HTML blocks
2761                                        result.push(String::new());
2762                                    } else {
2763                                        // Preserve lines with their original content (already includes indentation)
2764                                        result.push(format!("{expected_indent}{line}"));
2765                                    }
2766                                }
2767
2768                                // Add blank line after HTML block if there's a next block.
2769                                // Only add if not already ending with one (avoids double blanks
2770                                // when the HTML block itself contained a trailing blank line).
2771                                if block_idx < blocks.len() - 1 {
2772                                    let next_block = &blocks[block_idx + 1];
2773                                    let should_add_blank = match next_block {
2774                                        Block::Code {
2775                                            has_preceding_blank, ..
2776                                        } => *has_preceding_blank,
2777                                        Block::Html {
2778                                            has_preceding_blank, ..
2779                                        } => *has_preceding_blank,
2780                                        Block::Table {
2781                                            has_preceding_blank, ..
2782                                        } => *has_preceding_blank,
2783                                        Block::SnippetLine(_) | Block::DivMarker(_) => false,
2784                                        _ => true, // For all other blocks, add blank line
2785                                    };
2786                                    if should_add_blank && result.last().is_none_or(|s: &String| !s.is_empty()) {
2787                                        result.push(String::new());
2788                                    }
2789                                }
2790                            }
2791                            Block::Table {
2792                                lines: table_lines,
2793                                has_preceding_blank: _,
2794                            } => {
2795                                // Preserve table rows verbatim with their original indentation.
2796                                // Reflowing rows would corrupt column alignment and inject `|`
2797                                // characters mid-paragraph (issue #590).
2798                                // The leading blank line is emitted by the previous block.
2799                                for (idx, (content, orig_indent)) in table_lines.iter().enumerate() {
2800                                    if is_first_block && idx == 0 {
2801                                        // First line of first block gets the list marker
2802                                        result.push(format!(
2803                                            "{marker}{}",
2804                                            " ".repeat(orig_indent.saturating_sub(marker_len)) + content
2805                                        ));
2806                                        is_first_block = false;
2807                                    } else {
2808                                        // Shift nested table rows with the marker so they
2809                                        // stay aligned when MD030 widens marker spacing.
2810                                        result.push(format!(
2811                                            "{}{}",
2812                                            " ".repeat((*orig_indent as isize + code_indent_shift).max(0) as usize),
2813                                            content
2814                                        ));
2815                                    }
2816                                }
2817
2818                                // Add blank line after table block if there's a next block.
2819                                if block_idx < blocks.len() - 1 {
2820                                    let next_block = &blocks[block_idx + 1];
2821                                    let should_add_blank = match next_block {
2822                                        Block::Code {
2823                                            has_preceding_blank, ..
2824                                        } => *has_preceding_blank,
2825                                        Block::Html {
2826                                            has_preceding_blank, ..
2827                                        } => *has_preceding_blank,
2828                                        Block::Table {
2829                                            has_preceding_blank, ..
2830                                        } => *has_preceding_blank,
2831                                        Block::SnippetLine(_) | Block::DivMarker(_) => false,
2832                                        _ => true,
2833                                    };
2834                                    if should_add_blank && result.last().is_none_or(|s: &String| !s.is_empty()) {
2835                                        result.push(String::new());
2836                                    }
2837                                }
2838                            }
2839                            Block::Admonition {
2840                                header,
2841                                header_indent,
2842                                content_lines: admon_lines,
2843                            } => {
2844                                // Reconstruct admonition block with header at original indent
2845                                // and body content reflowed to fit within the line length limit
2846
2847                                // Add blank line before admonition if not first block
2848                                if !is_first_block && result.last().is_none_or(|s: &String| !s.is_empty()) {
2849                                    result.push(String::new());
2850                                }
2851
2852                                // Output the header at its original indent
2853                                let header_indent_str = " ".repeat(*header_indent);
2854                                if is_first_block {
2855                                    result.push(format!(
2856                                        "{marker}{}",
2857                                        " ".repeat(header_indent.saturating_sub(marker_len)) + header
2858                                    ));
2859                                    is_first_block = false;
2860                                } else {
2861                                    result.push(format!("{header_indent_str}{header}"));
2862                                }
2863
2864                                // Derive body indent from the first non-empty content line's
2865                                // stored indent, falling back to header_indent + 4 for
2866                                // empty-body admonitions
2867                                let body_indent = admon_lines
2868                                    .iter()
2869                                    .find(|(content, _)| !content.is_empty())
2870                                    .map_or(header_indent + 4, |(_, indent)| *indent);
2871                                let body_indent_str = " ".repeat(body_indent);
2872
2873                                // Segment body content into code blocks (verbatim) and
2874                                // text paragraphs (reflowable), separated by blank lines.
2875                                // Code lines store (content, orig_indent) to reconstruct
2876                                // internal indentation relative to body_indent.
2877                                enum AdmonSegment {
2878                                    Text(Vec<String>),
2879                                    Code(Vec<(String, usize)>),
2880                                }
2881
2882                                let mut segments: Vec<AdmonSegment> = Vec::new();
2883                                let mut current_text: Vec<String> = Vec::new();
2884                                let mut current_code: Vec<(String, usize)> = Vec::new();
2885                                let mut in_admon_code = false;
2886                                // Track the opening fence character so closing fences
2887                                // must match (backticks close backticks, tildes close tildes)
2888                                let mut fence_char: char = '`';
2889
2890                                // Opening fences: ``` or ~~~ followed by optional info string
2891                                let get_opening_fence = |s: &str| -> Option<(char, usize)> {
2892                                    let t = s.trim_start();
2893                                    if t.starts_with("```") {
2894                                        Some(('`', t.bytes().take_while(|&b| b == b'`').count()))
2895                                    } else if t.starts_with("~~~") {
2896                                        Some(('~', t.bytes().take_while(|&b| b == b'~').count()))
2897                                    } else {
2898                                        None
2899                                    }
2900                                };
2901                                // Closing fences: ONLY fence chars + optional trailing spaces
2902                                let get_closing_fence = |s: &str| -> Option<(char, usize)> {
2903                                    let t = s.trim();
2904                                    if t.starts_with("```") && t.bytes().all(|b| b == b'`') {
2905                                        Some(('`', t.len()))
2906                                    } else if t.starts_with("~~~") && t.bytes().all(|b| b == b'~') {
2907                                        Some(('~', t.len()))
2908                                    } else {
2909                                        None
2910                                    }
2911                                };
2912                                let mut fence_len: usize = 3;
2913
2914                                for (content, orig_indent) in admon_lines {
2915                                    if in_admon_code {
2916                                        // Closing fence must use the same character, be
2917                                        // at least as long, and have no info string
2918                                        if let Some((ch, len)) = get_closing_fence(content)
2919                                            && ch == fence_char
2920                                            && len >= fence_len
2921                                        {
2922                                            current_code.push((content.clone(), *orig_indent));
2923                                            in_admon_code = false;
2924                                            segments.push(AdmonSegment::Code(std::mem::take(&mut current_code)));
2925                                            continue;
2926                                        }
2927                                        current_code.push((content.clone(), *orig_indent));
2928                                    } else if let Some((ch, len)) = get_opening_fence(content) {
2929                                        if !current_text.is_empty() {
2930                                            segments.push(AdmonSegment::Text(std::mem::take(&mut current_text)));
2931                                        }
2932                                        in_admon_code = true;
2933                                        fence_char = ch;
2934                                        fence_len = len;
2935                                        current_code.push((content.clone(), *orig_indent));
2936                                    } else if content.is_empty() {
2937                                        if !current_text.is_empty() {
2938                                            segments.push(AdmonSegment::Text(std::mem::take(&mut current_text)));
2939                                        }
2940                                    } else {
2941                                        current_text.push(content.clone());
2942                                    }
2943                                }
2944                                if in_admon_code && !current_code.is_empty() {
2945                                    segments.push(AdmonSegment::Code(std::mem::take(&mut current_code)));
2946                                }
2947                                if !current_text.is_empty() {
2948                                    segments.push(AdmonSegment::Text(std::mem::take(&mut current_text)));
2949                                }
2950
2951                                // Build reflow options once for all text segments
2952                                let admon_reflow_length = if config.line_length.is_unlimited() {
2953                                    usize::MAX
2954                                } else {
2955                                    config.line_length.get().saturating_sub(body_indent).max(1)
2956                                };
2957
2958                                let admon_reflow_options = crate::utils::text_reflow::ReflowOptions {
2959                                    line_length: admon_reflow_length,
2960                                    break_on_sentences: true,
2961                                    preserve_breaks: false,
2962                                    sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
2963                                    semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
2964                                    abbreviations: config.abbreviations_for_reflow(),
2965                                    length_mode: self.reflow_length_mode(),
2966                                    attr_lists: ctx.flavor.supports_attr_lists(),
2967                                    myst_roles: ctx.flavor.supports_myst_roles(),
2968                                    require_sentence_capital: config.require_sentence_capital,
2969                                    max_list_continuation_indent: if ctx.flavor.requires_strict_list_indent() {
2970                                        Some(4)
2971                                    } else {
2972                                        None
2973                                    },
2974                                };
2975
2976                                // Output each segment
2977                                for segment in &segments {
2978                                    // Blank line before each segment (after the header or previous segment)
2979                                    result.push(String::new());
2980
2981                                    match segment {
2982                                        AdmonSegment::Code(lines) => {
2983                                            for (line, orig_indent) in lines {
2984                                                if line.is_empty() {
2985                                                    // Preserve blank lines inside code blocks
2986                                                    result.push(String::new());
2987                                                } else {
2988                                                    // Reconstruct with body_indent + any extra
2989                                                    // indentation the line had beyond body_indent
2990                                                    let extra = orig_indent.saturating_sub(body_indent);
2991                                                    let indent_str = " ".repeat(body_indent + extra);
2992                                                    result.push(format!("{indent_str}{line}"));
2993                                                }
2994                                            }
2995                                        }
2996                                        AdmonSegment::Text(lines) => {
2997                                            let paragraph_text = lines.join(" ").trim().to_string();
2998                                            if paragraph_text.is_empty() {
2999                                                continue;
3000                                            }
3001                                            let reflowed = crate::utils::text_reflow::reflow_line(
3002                                                &paragraph_text,
3003                                                &admon_reflow_options,
3004                                            );
3005                                            for line in &reflowed {
3006                                                result.push(format!("{body_indent_str}{line}"));
3007                                            }
3008                                        }
3009                                    }
3010                                }
3011
3012                                // Add blank line after admonition if there's a next block
3013                                if block_idx < blocks.len() - 1 {
3014                                    let next_block = &blocks[block_idx + 1];
3015                                    let should_add_blank = match next_block {
3016                                        Block::Code {
3017                                            has_preceding_blank, ..
3018                                        } => *has_preceding_blank,
3019                                        Block::Table {
3020                                            has_preceding_blank, ..
3021                                        } => *has_preceding_blank,
3022                                        Block::SnippetLine(_) | Block::DivMarker(_) => false,
3023                                        _ => true,
3024                                    };
3025                                    if should_add_blank && result.last().is_none_or(|s: &String| !s.is_empty()) {
3026                                        result.push(String::new());
3027                                    }
3028                                }
3029                            }
3030                        }
3031                    }
3032
3033                    let reflowed_text = result.join(line_ending);
3034
3035                    // Preserve trailing newline
3036                    let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
3037                        format!("{reflowed_text}{line_ending}")
3038                    } else {
3039                        reflowed_text
3040                    };
3041
3042                    // Get the original text to compare
3043                    let original_text = &ctx.content[byte_range.clone()];
3044
3045                    // Physical-line-length scan, shared by the Normalize-mode gate and its
3046                    // message. The list-item reflow preserves code blocks, HTML blocks,
3047                    // admonition headers, fence markers, semantic markers, and snippet/div
3048                    // markers verbatim; only paragraph content and admonition bodies are
3049                    // restructured. Only those lines drive the length warning, so that
3050                    // preserved-but-overlong content does not keep the paragraph-level
3051                    // warning alive when the reflow would not fix that line.
3052                    let should_count_for_length = |line_idx: usize| -> bool {
3053                        let line = lines[line_idx];
3054                        let trimmed = line.trim();
3055                        if trimmed.is_empty() || is_exempt_line(line) {
3056                            return false;
3057                        }
3058                        let info = &ctx.lines[line_idx];
3059                        if info.in_code_block || info.in_html_block {
3060                            return false;
3061                        }
3062                        if info.in_admonition && mkdocs_admonitions::is_admonition_start(line) {
3063                            return false;
3064                        }
3065                        if is_fence_marker(line) || is_semantic_line(line) {
3066                            return false;
3067                        }
3068                        if is_snippet_block_delimiter(line) {
3069                            return false;
3070                        }
3071                        if line.trim_start().starts_with(":::") {
3072                            return false;
3073                        }
3074                        true
3075                    };
3076                    let max_physical_length = (list_start..i)
3077                        .filter(|&idx| should_count_for_length(idx))
3078                        .map(|idx| self.calculate_effective_length(lines[idx]))
3079                        .max()
3080                        .unwrap_or(0);
3081                    // `line-length = 0` means "no limit", so no physical line can be
3082                    // "over"; the message below then describes a structural join rather
3083                    // than a length violation.
3084                    let any_paragraph_line_over =
3085                        !config.line_length.is_unlimited() && max_physical_length > config.line_length.get();
3086
3087                    // Normalize mode reflows list-item prose just like paragraphs:
3088                    // joining continuation lines and re-wrapping to `line-length`.
3089                    // `prose_changed` is true only when the reflow alters the words or
3090                    // line breaks, not when it would merely re-indent continuation
3091                    // lines or trim trailing whitespace. Comparing the texts with each
3092                    // line's leading and trailing whitespace removed isolates "did the
3093                    // words/line breaks change" from "did the surrounding whitespace
3094                    // change". Continuation indentation is MD077's responsibility and
3095                    // trailing whitespace is MD009's; an MD013 warning for either would
3096                    // both duplicate those rules and resurface a persistent advisory on
3097                    // already-fitting items that users disable MD013 fixing to avoid.
3098                    let prose_changed = {
3099                        let stripped = |text: &str| text.lines().map(str::trim).collect::<Vec<_>>().join("\n");
3100                        stripped(original_text) != stripped(&replacement)
3101                    };
3102                    // Warn when the reflow rewraps prose (the normalize feature for
3103                    // list items), or when a physical line genuinely exceeds the limit
3104                    // and the reflow can change something (a true length violation,
3105                    // even if all that changes is the continuation indent). A line that
3106                    // is already optimal in both respects produces no warning.
3107                    let gate_ok = prose_changed || (any_paragraph_line_over && original_text != replacement);
3108                    if gate_ok {
3109                        // Generate an appropriate message based on why reflow is needed
3110                        let message = match config.reflow_mode {
3111                            ReflowMode::SentencePerLine => {
3112                                let num_sentences = split_into_sentences(&combined_content).len();
3113                                let num_lines = content_lines.len();
3114                                if num_lines == 1 {
3115                                    // Single line with multiple sentences
3116                                    format!("Line contains {num_sentences} sentences (one sentence per line required)")
3117                                } else {
3118                                    // Multiple lines - could be split sentences or mixed
3119                                    format!(
3120                                        "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
3121                                    )
3122                                }
3123                            }
3124                            ReflowMode::SemanticLineBreaks => {
3125                                let num_sentences = split_into_sentences(&combined_content).len();
3126                                format!("Paragraph should use semantic line breaks ({num_sentences} sentences)")
3127                            }
3128                            ReflowMode::Normalize => {
3129                                // When a physical line genuinely exceeds the limit, report
3130                                // it as a length violation. Otherwise the reflow is a
3131                                // structural normalization (joining/re-wrapping multi-line
3132                                // content that already fits), mirroring the paragraph path.
3133                                if any_paragraph_line_over {
3134                                    format!(
3135                                        "Line length {} exceeds {} characters",
3136                                        max_physical_length,
3137                                        config.line_length.get()
3138                                    )
3139                                } else {
3140                                    format!(
3141                                        "List item could be normalized to use line length of {} characters",
3142                                        config.line_length.get()
3143                                    )
3144                                }
3145                            }
3146                            ReflowMode::Default => {
3147                                // Report the actual longest non-exempt line, not the combined content
3148                                let max_length = (list_start..i)
3149                                    .filter(|&line_idx| {
3150                                        let line = lines[line_idx];
3151                                        let trimmed = line.trim();
3152                                        !trimmed.is_empty() && !is_exempt_line(line)
3153                                    })
3154                                    .map(|line_idx| self.calculate_effective_length(lines[line_idx]))
3155                                    .max()
3156                                    .unwrap_or(0);
3157                                format!(
3158                                    "Line length {} exceeds {} characters",
3159                                    max_length,
3160                                    config.line_length.get()
3161                                )
3162                            }
3163                        };
3164
3165                        warnings.push(LintWarning {
3166                            rule_name: Some(self.name().to_string()),
3167                            message,
3168                            line: list_start + 1,
3169                            column: 1,
3170                            end_line: end_line + 1,
3171                            end_column: lines[end_line].chars().count() + 1,
3172                            severity: Severity::Warning,
3173                            fix: Some(crate::rule::Fix::new(byte_range, replacement)),
3174                        });
3175                    }
3176                }
3177                continue;
3178            }
3179
3180            // Found start of a paragraph - collect all lines in it
3181            let paragraph_start = i;
3182            let mut paragraph_lines = vec![lines[i]];
3183            i += 1;
3184
3185            while i < lines.len() {
3186                let next_line = lines[i];
3187                let next_line_num = i + 1;
3188                let next_trimmed = next_line.trim();
3189
3190                // Stop at paragraph boundaries
3191                if next_trimmed.is_empty()
3192                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_code_block)
3193                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_front_matter)
3194                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_block)
3195                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_comment)
3196                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_esm_block)
3197                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_jsx_expression)
3198                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_jsx_block)
3199                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_mdx_comment)
3200                    || ctx
3201                        .line_info(next_line_num)
3202                        .is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container)
3203                    || (next_line_num > 0
3204                        && next_line_num <= ctx.lines.len()
3205                        && ctx.lines[next_line_num - 1].blockquote.is_some())
3206                    || next_trimmed.starts_with('#')
3207                    || TableUtils::is_potential_table_row(next_line)
3208                    || is_list_item(next_trimmed)
3209                    || is_horizontal_rule(next_line)
3210                    || (next_trimmed.starts_with('[') && next_line.contains("]:"))
3211                    || is_template_directive_only(next_line)
3212                    || is_standalone_attr_list(next_line)
3213                    || is_snippet_block_delimiter(next_line)
3214                    || ctx.line_info(next_line_num).is_some_and(|info| info.is_div_marker)
3215                    || is_html_only_line(next_line)
3216                    || (!config.strict && is_standalone_link_or_image_line(next_line))
3217                {
3218                    break;
3219                }
3220
3221                // Check if the previous line ends with a hard break (2+ spaces or backslash)
3222                if i > 0 && has_hard_break(lines[i - 1]) {
3223                    // Don't include lines after hard breaks in the same paragraph
3224                    break;
3225                }
3226
3227                paragraph_lines.push(next_line);
3228                i += 1;
3229            }
3230
3231            // Compute the common leading indent of all non-empty paragraph lines,
3232            // but only when those lines are structurally inside a list block.
3233            // Indented continuation lines that follow a nested list arrive here
3234            // with their structural indentation intact (e.g. 2 spaces for a
3235            // top-level list item). Stripping the indent before reflow and
3236            // re-applying it afterward prevents the fixer from moving those
3237            // lines to column 0.
3238            //
3239            // The list-block guard is essential: top-level paragraphs that happen
3240            // to start with spaces (insignificant in Markdown) must NOT have those
3241            // spaces preserved or injected by the fixer.
3242            let common_indent: String = if ctx.is_in_list_block(paragraph_start + 1) {
3243                let min_len = paragraph_lines
3244                    .iter()
3245                    .filter(|l| !l.trim().is_empty())
3246                    .map(|l| l.len() - l.trim_start().len())
3247                    .min()
3248                    .unwrap_or(0);
3249                paragraph_lines
3250                    .iter()
3251                    .find(|l| !l.trim().is_empty())
3252                    .map(|l| l[..min_len].to_string())
3253                    .unwrap_or_default()
3254            } else {
3255                String::new()
3256            };
3257
3258            // Combine paragraph lines into a single string for processing.
3259            // This must be done BEFORE the needs_reflow check for sentence-per-line mode.
3260            let paragraph_text = if common_indent.is_empty() {
3261                paragraph_lines.join(" ")
3262            } else {
3263                paragraph_lines
3264                    .iter()
3265                    .map(|l| {
3266                        if l.starts_with(common_indent.as_str()) {
3267                            &l[common_indent.len()..]
3268                        } else {
3269                            l.trim_start()
3270                        }
3271                    })
3272                    .collect::<Vec<_>>()
3273                    .join(" ")
3274            };
3275
3276            // Skip reflowing if this paragraph contains definition list items
3277            // Definition lists are multi-line structures that should not be joined
3278            let contains_definition_list = paragraph_lines
3279                .iter()
3280                .any(|line| crate::utils::is_definition_list_item(line));
3281
3282            if contains_definition_list {
3283                // Don't reflow definition lists - skip this paragraph
3284                i = paragraph_start + paragraph_lines.len();
3285                continue;
3286            }
3287
3288            // Skip reflowing if this paragraph contains MkDocs Snippets markers
3289            // Snippets blocks (-8<- ... -8<-) should be preserved exactly
3290            let contains_snippets = paragraph_lines.iter().any(|line| is_snippet_block_delimiter(line));
3291
3292            if contains_snippets {
3293                // Don't reflow Snippets blocks - skip this paragraph
3294                i = paragraph_start + paragraph_lines.len();
3295                continue;
3296            }
3297
3298            // Check if this paragraph needs reflowing
3299            let needs_reflow = match config.reflow_mode {
3300                ReflowMode::Normalize => self.normalize_mode_needs_reflow(paragraph_lines.iter().copied(), config),
3301                ReflowMode::SentencePerLine => {
3302                    // In sentence-per-line mode, check if the JOINED paragraph has multiple sentences
3303                    // Note: we check the joined text because sentences can span multiple lines
3304                    let sentences = split_into_sentences(&paragraph_text);
3305
3306                    // Always reflow if multiple sentences on one line
3307                    if sentences.len() > 1 {
3308                        true
3309                    } else if paragraph_lines.len() > 1 {
3310                        // For single-sentence paragraphs spanning multiple lines:
3311                        // Reflow if they COULD fit on one line (respecting line-length constraint)
3312                        if config.line_length.is_unlimited() {
3313                            // No line-length constraint - always join single sentences
3314                            true
3315                        } else {
3316                            // Only join if it fits within line-length.
3317                            // paragraph_text has the common indent stripped, so add it
3318                            // back to get the true output length before comparing.
3319                            let effective_length =
3320                                self.calculate_effective_length(&paragraph_text) + common_indent.len();
3321                            effective_length <= config.line_length.get()
3322                        }
3323                    } else {
3324                        false
3325                    }
3326                }
3327                ReflowMode::SemanticLineBreaks => {
3328                    let sentences = split_into_sentences(&paragraph_text);
3329                    // Reflow if multiple sentences, multiple lines, or any line exceeds limit
3330                    sentences.len() > 1
3331                        || paragraph_lines.len() > 1
3332                        || paragraph_lines
3333                            .iter()
3334                            .any(|line| self.calculate_effective_length(line) > config.line_length.get())
3335                }
3336                ReflowMode::Default => {
3337                    // In default mode, only reflow if lines exceed limit
3338                    paragraph_lines
3339                        .iter()
3340                        .any(|line| self.calculate_effective_length(line) > config.line_length.get())
3341                }
3342            };
3343
3344            if needs_reflow {
3345                // Calculate byte range for this paragraph
3346                // Use whole_line_range for each line and combine
3347                let start_range = line_index.whole_line_range(paragraph_start + 1);
3348                let end_line = paragraph_start + paragraph_lines.len() - 1;
3349
3350                // For the last line, we want to preserve any trailing newline
3351                let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
3352                    // Last line without trailing newline - use line_text_range
3353                    line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
3354                } else {
3355                    // Not the last line or has trailing newline - use whole_line_range
3356                    line_index.whole_line_range(end_line + 1)
3357                };
3358
3359                let byte_range = start_range.start..end_range.end;
3360
3361                // Check if the paragraph ends with a hard break and what type
3362                let hard_break_type = paragraph_lines.last().and_then(|line| {
3363                    let line = line.strip_suffix('\r').unwrap_or(line);
3364                    if line.ends_with('\\') {
3365                        Some("\\")
3366                    } else if line.ends_with("  ") {
3367                        Some("  ")
3368                    } else {
3369                        None
3370                    }
3371                });
3372
3373                // Reflow the paragraph
3374                // When line_length = 0 (no limit), use a very large value for reflow
3375                let reflow_line_length = if config.line_length.is_unlimited() {
3376                    usize::MAX
3377                } else {
3378                    config.line_length.get()
3379                };
3380                let reflow_options = crate::utils::text_reflow::ReflowOptions {
3381                    line_length: reflow_line_length,
3382                    break_on_sentences: true,
3383                    preserve_breaks: false,
3384                    sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
3385                    semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
3386                    abbreviations: config.abbreviations_for_reflow(),
3387                    length_mode: self.reflow_length_mode(),
3388                    attr_lists: ctx.flavor.supports_attr_lists(),
3389                    myst_roles: ctx.flavor.supports_myst_roles(),
3390                    require_sentence_capital: config.require_sentence_capital,
3391                    max_list_continuation_indent: if ctx.flavor.requires_strict_list_indent() {
3392                        Some(4)
3393                    } else {
3394                        None
3395                    },
3396                };
3397                let mut reflowed = crate::utils::text_reflow::reflow_line(&paragraph_text, &reflow_options);
3398
3399                // Re-apply the common indent to each non-empty reflowed line so
3400                // that the replacement preserves the original structural indentation.
3401                if !common_indent.is_empty() {
3402                    for line in &mut reflowed {
3403                        if !line.is_empty() {
3404                            *line = format!("{common_indent}{line}");
3405                        }
3406                    }
3407                }
3408
3409                // If the original paragraph ended with a hard break, preserve it
3410                // Preserve the original hard break format (backslash or two spaces)
3411                if let Some(break_marker) = hard_break_type
3412                    && !reflowed.is_empty()
3413                {
3414                    let last_idx = reflowed.len() - 1;
3415                    if !has_hard_break(&reflowed[last_idx]) {
3416                        reflowed[last_idx].push_str(break_marker);
3417                    }
3418                }
3419
3420                let reflowed_text = reflowed.join(line_ending);
3421
3422                // Preserve trailing newline if the original paragraph had one
3423                let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
3424                    format!("{reflowed_text}{line_ending}")
3425                } else {
3426                    reflowed_text
3427                };
3428
3429                // Get the original text to compare
3430                let original_text = &ctx.content[byte_range.clone()];
3431
3432                // Only generate a warning if the replacement is different from the original
3433                if original_text != replacement {
3434                    // Determine which line ranges and messages to report based on the reflow mode.
3435                    let warnings_to_report: Vec<(usize, usize, String)> = match config.reflow_mode {
3436                        ReflowMode::Default => {
3437                            // In default mode, report a warning for *every* line in the paragraph
3438                            // that exceeds the limit. Each warning will carry the same paragraph-level
3439                            // fix, making all of them auto-fixable.
3440                            paragraph_lines
3441                                .iter()
3442                                .enumerate()
3443                                .filter(|(_, line)| self.calculate_effective_length(line) > config.line_length.get())
3444                                .map(|(idx, _)| {
3445                                    let violating_line = paragraph_start + idx + 1;
3446                                    (
3447                                        violating_line,
3448                                        violating_line,
3449                                        format!("Line length exceeds {} characters", config.line_length.get()),
3450                                    )
3451                                })
3452                                .collect()
3453                        }
3454                        ReflowMode::Normalize => {
3455                            // In normalize mode, report the whole paragraph as needing normalization.
3456                            vec![(
3457                                paragraph_start + 1,
3458                                end_line + 1,
3459                                format!(
3460                                    "Paragraph could be normalized to use line length of {} characters",
3461                                    config.line_length.get()
3462                                ),
3463                            )]
3464                        }
3465                        ReflowMode::SentencePerLine => {
3466                            // In sentence-per-line mode, highlight the entire paragraph that needs reformatting.
3467                            let num_sentences = split_into_sentences(&paragraph_text).len();
3468                            let message = if paragraph_lines.len() == 1 {
3469                                // Single line with multiple sentences
3470                                format!("Line contains {num_sentences} sentences (one sentence per line required)")
3471                            } else {
3472                                // Multiple lines - could be split sentences or mixed
3473                                let num_lines = paragraph_lines.len();
3474                                format!(
3475                                    "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
3476                                )
3477                            };
3478                            vec![(paragraph_start + 1, paragraph_start + paragraph_lines.len(), message)]
3479                        }
3480                        ReflowMode::SemanticLineBreaks => {
3481                            // In semantic-line-breaks mode, highlight the entire paragraph.
3482                            let num_sentences = split_into_sentences(&paragraph_text).len();
3483                            vec![(
3484                                paragraph_start + 1,
3485                                paragraph_start + paragraph_lines.len(),
3486                                format!("Paragraph should use semantic line breaks ({num_sentences} sentences)"),
3487                            )]
3488                        }
3489                    };
3490
3491                    // Generate the actual lint warnings. All warnings for this paragraph
3492                    // share the same paragraph-level fix.
3493                    for (w_start, w_end, msg) in warnings_to_report {
3494                        warnings.push(LintWarning {
3495                            rule_name: Some(self.name().to_string()),
3496                            message: msg,
3497                            line: w_start,
3498                            column: 1,
3499                            end_line: w_end,
3500                            end_column: lines[w_end.saturating_sub(1)].chars().count() + 1,
3501                            severity: Severity::Warning,
3502                            fix: Some(crate::rule::Fix::new(byte_range.clone(), replacement.clone())),
3503                        });
3504                    }
3505                }
3506            }
3507        }
3508
3509        warnings
3510    }
3511
3512    /// Calculate string length based on the configured length mode
3513    fn calculate_string_length(&self, s: &str) -> usize {
3514        match self.config.length_mode {
3515            LengthMode::Chars => s.chars().count(),
3516            LengthMode::Visual => s.width(),
3517            LengthMode::Bytes => s.len(),
3518        }
3519    }
3520
3521    /// Calculate effective line length
3522    ///
3523    /// Returns the actual display length of the line using the configured length mode.
3524    fn calculate_effective_length(&self, line: &str) -> usize {
3525        self.calculate_string_length(line)
3526    }
3527
3528    /// Calculate line length with inline link/image URLs removed.
3529    ///
3530    /// For each inline link `[text](url)` or image `![alt](url)` on the line,
3531    /// computes the "savings" from removing the URL portion (keeping only `[text]`
3532    /// or `![alt]`). Returns `effective_length - total_savings`.
3533    ///
3534    /// Handles nested constructs (e.g., `[![img](url)](url)`) by only counting the
3535    /// outermost construct to avoid double-counting.
3536    fn length_without_inline_link_urls(
3537        &self,
3538        effective_length: usize,
3539        line_number: usize,
3540        ctx: &crate::lint_context::LintContext,
3541    ) -> usize {
3542        let line_range = ctx.line_index.line_content_range(line_number);
3543        let line_byte_end = line_range.end;
3544
3545        // Collect inline links/images on this line: (byte_offset, byte_end, text_only_display_len)
3546        let mut constructs: Vec<(usize, usize, usize)> = Vec::new();
3547
3548        // Binary search: links are sorted by byte_offset, so link.line is non-decreasing
3549        let link_start = ctx.links.partition_point(|l| l.line < line_number);
3550        for link in &ctx.links[link_start..] {
3551            if link.line != line_number {
3552                break;
3553            }
3554            if link.is_reference {
3555                continue;
3556            }
3557            if !matches!(link.link_type, LinkType::Inline) {
3558                continue;
3559            }
3560            if link.byte_end > line_byte_end {
3561                continue;
3562            }
3563            let text_only_len = 2 + self.calculate_string_length(&link.text);
3564            constructs.push((link.byte_offset, link.byte_end, text_only_len));
3565        }
3566
3567        let img_start = ctx.images.partition_point(|i| i.line < line_number);
3568        for image in &ctx.images[img_start..] {
3569            if image.line != line_number {
3570                break;
3571            }
3572            if image.is_reference {
3573                continue;
3574            }
3575            if !matches!(image.link_type, LinkType::Inline) {
3576                continue;
3577            }
3578            if image.byte_end > line_byte_end {
3579                continue;
3580            }
3581            let text_only_len = 3 + self.calculate_string_length(&image.alt_text);
3582            constructs.push((image.byte_offset, image.byte_end, text_only_len));
3583        }
3584
3585        if constructs.is_empty() {
3586            return effective_length;
3587        }
3588
3589        // Sort by byte offset to handle overlapping/nested constructs
3590        constructs.sort_by_key(|&(start, _, _)| start);
3591
3592        let mut total_savings: usize = 0;
3593        let mut last_end: usize = 0;
3594
3595        for (start, end, text_only_len) in &constructs {
3596            // Skip constructs nested inside a previously counted one
3597            if *start < last_end {
3598                continue;
3599            }
3600            // Full construct length in configured length mode
3601            let full_source = &ctx.content[*start..*end];
3602            let full_len = self.calculate_string_length(full_source);
3603            total_savings += full_len.saturating_sub(*text_only_len);
3604            last_end = *end;
3605        }
3606
3607        effective_length.saturating_sub(total_savings)
3608    }
3609}